아두이노에 0.96인치 OLED 붙이기 - 컴파일 리포트가 숨긴 1KB
이 글에는 알리익스프레스 제휴 링크가 포함되어 있습니다. 링크를 통해 구매가 이루어지면 작성자가 일정액의 수수료를 받습니다.
알리에서 파는 0.96인치 OLED 모듈을 아두이노 UNO에 붙여봤습니다. 1~2천원짜리 흔한 부품이고 예제도 널려 있어서 금방 끝났는데, 컴파일 리포트를 믿었다가 하마터면 놓칠 뻔한 것이 하나 있었습니다. 화면 버퍼 1KB가 리포트에 잡히지 않는다는 점입니다.
연결 과정과 함께 그 이야기를 정리했습니다.
4핀이면 대개 I2C입니다
먼저 내 모듈이 뭔지 확인해야 합니다. 0.96인치 OLED는 인터페이스가 두 종류로 나옵니다.
- 핀이 4개 → I2C
- 핀이 7개 이상 → SPI
핀 개수만 세면 인터페이스는 바로 갈립니다. 다만 컨트롤러까지 확정되는 건 아닙니다. 대부분 SSD1306이지만 SH1106 같은 다른 칩이 들어간 물건도 섞여 나오고, 이 경우 화면이 몇 픽셀 밀려 보입니다. 이 글에서 쓴 모듈은 SSD1306 / 128x64 / 단색입니다.
노랑/파랑 2색으로 파는 것도 있는데, 컨트롤러가 다른 게 아니라 패널 상단 영역의 발광색이 다르게 제작된 것입니다. 코드로 색을 고를 수 없고, 위쪽(대개 16픽셀 행)에 그리면 노랗게 나올 뿐입니다.
첫 번째 함정 - 핀 이름이 제조사마다 다릅니다
제 모듈에는 이렇게 인쇄되어 있습니다.
1
GND VDD SCK SDA
SCK에서 한 번 멈칫하게 됩니다. 원래 SPI에서 쓰는 이름인데 I2C 모듈에 태연히 찍혀 있거든요. 결론부터 말하면 SCL과 같은 핀입니다. GND/VDD/SCK/SDA 구성의 4핀 모듈이라면 SCK라고 적혀 있어도 A5에 꽂으면 됩니다.
VDD 역시 VCC와 같은 로직 전원입니다. (SSD1306 데이터시트상으로는 VDD가 로직, VCC가 패널 구동 고전압으로 구분되지만, 4핀 모듈은 로직 전원 하나만 밖으로 빼놓습니다.)
정리하면 이렇습니다.
| 실크에 찍힌 것 | 실제 | UNO |
|---|---|---|
VCC / VDD | 로직 전원 | 5V |
GND | 접지 | GND |
SCL / SCK | 클럭 | A5 |
SDA | 데이터 | A4 |
UNO와 클래식 Nano에서 Wire 라이브러리가 쓰는 하드웨어 I2C 핀은 A4/A5로 고정입니다. (소프트웨어 I2C를 쓰면 다른 핀도 가능하지만, 굳이 그럴 이유는 거의 없습니다.)
전원은 모듈 사양을 확인하고 꽂으세요. 대부분은 온보드 레귤레이터가 있어 5V를 받지만, 3.3V 전용 물건도 있습니다. 뒷면에 SOT-23 부품(662K 등)이 보이면 5V로 써도 됩니다.
그리고 핀 순서도 제조사마다 다릅니다. GND-VDD-SCK-SDA와 VCC-GND-SCL-SDA가 모두 유통됩니다. GND가 첫 핀인 물건도 있고 VCC가 첫 핀인 물건도 있으니, 위치로 외우지 말고 이름을 읽고 꽂아야 합니다.
I2C 스캐너부터 돌립니다
화면이 안 켜질 때 “배선 문제인가 코드 문제인가”를 가르는 게 먼저입니다. 그래서 첫 스케치는 I2C 스캐너로 시작했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <Wire.h>
void setup() {
Wire.begin();
Serial.begin(9600);
}
void loop() {
byte count = 0;
Serial.println(F("Scanning..."));
for (byte address = 1; address < 127; address++) {
Wire.beginTransmission(address);
if (Wire.endTransmission() == 0) {
Serial.print(F("I2C device found at address 0x"));
Serial.println(address, HEX);
count++;
}
}
Serial.print(count);
Serial.println(F(" device(s) found"));
delay(3000);
}
1
2
3
Scanning...
I2C device found at address 0x3C
1 device(s) found
0x3C가 잡혔습니다. 전원과 I2C 배선은 일단 맞았다는 뜻입니다. 다만 주소에서 응답이 왔다는 것뿐이라, 컨트롤러 종류나 초기화 설정까지 검증된 건 아닙니다. 그건 다음 단계에서 확인합니다.
참고로 같은 모듈이어도 기판 뒷면 저항 위치에 따라 0x3D인 물건이 섞여 나오니, 스케치의 주소 상수를 스캔 결과에 맞춰야 합니다.
두 번째 함정 - 컴파일 리포트가 알려주지 않는 1KB
널리 쓰이는 라이브러리 중 하나가 Adafruit_SSD1306입니다. 예제도 많고 API도 직관적입니다. 그런데 UNO에서 쓸 때 조심할 게 있습니다.
UNO(arduino:avr:uno)에서 텍스트만 띄우는 최소 예제를 빌드하면 이렇게 나옵니다. (Adafruit_SSD1306 2.5.16 기준)
1
2
스케치는 프로그램 저장 공간 14758 바이트(45%)를 사용.
전역 변수는 동적 메모리 527바이트(25%)를 사용, 1521바이트의 지역변수가 남음.
여유가 1521바이트니 넉넉해 보입니다. 그런데 실제로는 그렇지 않습니다.
라이브러리 소스를 열어보면 이런 줄이 있습니다.
1
2
// Adafruit_SSD1306.cpp:499
if ((!buffer) && !(buffer = (uint8_t *)malloc(WIDTH * ((HEIGHT + 7) / 8))))
128 x 64 / 8 = 1024바이트 프레임버퍼를 begin() 안에서 malloc으로 잡습니다. (엄밀히는 1KiB지만, 이 글에서는 관행대로 1KB라고 부르겠습니다.) 동적 할당이라 링커가 알 수 없고, 컴파일 리포트의 “전역 변수” 수치에 나타나지 않습니다.
계산하면 1521 - 1024 = 497바이트가 남습니다. 그런데 이 497바이트는 여유분이 아니라 앞으로 스택과 추가 동적 할당이 함께 써야 할 전부입니다.
UNO의 SRAM이 2KB인데 그중 절반을 화면 버퍼가 먹는 셈입니다. 여기에 큰 전역 버퍼를 쓰는 센서 라이브러리나 문자열 배열을 더 얹으면 컴파일은 멀쩡히 되는데 실행에서 탈이 납니다. 증상은 두 갈래로 갈립니다.
malloc자체가 실패 →begin()이false를 반환하고 화면이 안 켜집니다. 그래서 반환값을 꼭 검사해야 합니다- 할당은 됐지만 스택이 힙을 침범 → 실행 중 랜덤 리셋이나 이상 동작
1
2
3
4
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println(F("SSD1306 init failed"));
for (;;); // 여기서 멈춰야 원인을 알 수 있습니다
}
그래서 저는 이런 습관이 생겼습니다. AVR처럼 RAM이 빠듯한 타깃에서는 컴파일 리포트의 여유 수치를 실사용량으로 믿지 않습니다. 디스플레이·네트워크·오디오처럼 큰 버퍼를 쓰는 라이브러리는 이렇게 먼저 확인합니다.
1
grep -n "malloc" ~/Documents/Arduino/libraries/<라이브러리>/*.cpp
U8g2 페이지 버퍼 모드
RAM이 부족하면 U8g2로 옮기면 됩니다.
U8g2는 임베디드용 단색 디스플레이 그래픽 라이브러리입니다. 이름은 Universal 8bit Graphics Library version 2에서 왔고, SSD1306뿐 아니라 SH1106·ST7920 등 단색 컨트롤러 수십 종을 같은 API로 다룹니다. 패널을 바꿔도 생성자 한 줄만 교체하면 나머지 코드가 그대로 돕니다. 내장 폰트가 수백 종이라 큰 숫자나 픽셀 폰트를 골라 쓰기도 편합니다.
다만 여기서 U8g2를 꺼낸 이유는 하나입니다. 화면 버퍼를 통째로 잡을지 나눠 잡을지 고를 수 있다는 점입니다. Adafruit_SSD1306에는 그 선택지가 없어 무조건 1KB를 씁니다.
1. 설치
1
arduino-cli lib install "U8g2"
2. 생성자 고르기
생성자 이름에 버퍼 방식이 들어 있습니다. 이름만 바꾸면 메모리 사용량이 달라집니다.
1
2
3
4
5
U8G2_SSD1306_128X64_NONAME_1_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);
// ^
// 1 = 1페이지 (약 128B, 가장 절약)
// 2 = 2페이지 (약 256B)
// F = Full (1024B, Adafruit와 동일)
_1_은 화면을 8줄씩 나눠 8번에 걸쳐 전송합니다. 그래서 버퍼가 128바이트면 충분합니다.
3. 최소 실행 예제
여기까지 합치면 이렇게 됩니다. 그대로 올리면 동작합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <Arduino.h>
#include <U8g2lib.h>
#include <Wire.h>
// 마지막 인자는 RESET 핀. I2C 모듈에는 없으므로 U8X8_PIN_NONE
U8G2_SSD1306_128X64_NONAME_1_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);
void setup() {
// 주소가 0x3D인 모듈이라면 begin() **앞에서** 지정합니다
// u8g2.setI2CAddress(0x3D * 2);
u8g2.begin();
}
void loop() {
// 그릴 값은 페이지 루프에 들어가기 전에 확정합니다
unsigned long sec = millis() / 1000;
char buf[16];
snprintf(buf, sizeof(buf), "%lu s", sec);
u8g2.firstPage();
do {
u8g2.setFont(u8g2_font_6x10_tf);
u8g2.drawStr(0, 10, "U8g2 page mode");
u8g2.drawStr(0, 30, buf);
} while (u8g2.nextPage());
delay(200);
}
4. RAM 비교
같은 내용을 띄우는 예제를 두 라이브러리로 각각 빌드해 비교하면 이렇습니다.
| 리포트의 전역 RAM | 화면 버퍼(힙) | 합계 | |
|---|---|---|---|
| Adafruit_SSD1306 | 527B | 1024B | 약 1551B |
U8g2 _1_ | 704B | 0B | 704B |
(합계는 정적 할당 + 화면 버퍼만 더한 값입니다. 스택과 그 외 동적 할당은 별도입니다.)
컴파일 리포트의 전역 수치만 보면 U8g2가 더 커 보이는데, Adafruit 쪽 1024B는 리포트에 안 잡히는 힙이라 실제로는 반대입니다.
5. 페이지 모드의 제약
대신 지켜야 할 규칙이 하나 있습니다.
1
2
3
4
5
6
// 발췌 — 잘못된 예. counter가 프레임당 8씩 증가합니다
u8g2.firstPage();
do {
counter++; // ← 페이지마다 실행됩니다
u8g2.drawStr(0, 20, buf);
} while (u8g2.nextPage());
firstPage()/nextPage() 블록은 페이지 수만큼 반복 실행됩니다. 그 안에 카운터 증가나 센서 읽기 같은 부작용을 넣으면 안 됩니다. 위 최소 예제처럼 그릴 값은 루프 진입 전에 확정하고, 블록 안에서는 그리기만 해야 합니다.
그 외 걸리기 쉬운 것들
SSD1306_EXTERNALVCC를 잘못 지정하면 화면만 까맣습니다. 이 모듈들은 외부 고전압 전원이 없고 SSD1306 내부 charge pump로 패널을 구동합니다. begin() 첫 인자에 따라 charge pump 명령(0x8D)의 인자가 갈리는데, EXTERNALVCC면 펌프를 끄기 때문에 I2C 통신은 멀쩡한데 화면만 안 켜집니다.
다행히 인자를 아예 생략하면 안전합니다. begin()의 첫 인자 기본값이 SSD1306_SWITCHCAPVCC거든요.
1
bool begin(uint8_t switchvcc = SSD1306_SWITCHCAPVCC, uint8_t i2caddr = 0, ...);
즉 “빼먹으면 안 켜진다”가 아니라 “굳이 EXTERNALVCC로 바꾸면 안 켜진다”가 맞습니다. 예제를 베끼다 이 인자를 건드리게 되면 조심하세요.
U8g2는 8비트 주소를 씁니다. 스캐너가 알려주는 건 7비트 주소인데, U8g2는 8비트로 저장했다가 전송할 때 >>1 합니다(U8x8lib.cpp:1369). 그래서 2를 곱해야 합니다.
1
2
3
// 발췌 — 두 라이브러리의 표기 차이만 나란히 둔 것
display.begin(SSD1306_SWITCHCAPVCC, 0x3C); // Adafruit: 7비트 그대로
u8g2.setI2CAddress(0x3C * 2); // U8g2: 8비트라 2배
참고로 0x3C는 U8g2 기본값이라 이 줄 자체를 생략해도 됩니다. 스캐너가 0x3D를 알려준 모듈에서만 0x3D * 2로 바꿔주면 됩니다. 그리고 이 호출은 begin() 앞에 와야 합니다.
부록 - 스크립트로 시리얼 출력 받기
OLED 연결과는 별개지만, 예제를 자동으로 검증하려다 걸린 것이라 같이 적어둡니다.
arduino-cli monitor를 리다이렉트해서 백그라운드로 돌리면 바로 종료됩니다. 대화형 도구라 stdin이 닫히면 끝나버립니다. 에러도 안 남기고 로그가 0바이트로 끝나서 보드가 죽은 줄 알았는데, 업로드는 멀쩡했습니다.
1
2
# 이렇게 하면 로그가 0바이트로 끝납니다
arduino-cli monitor -p /dev/cu.usbmodem1301 -c baudrate=9600 > out.log &
자동화할 때는 시리얼 장치를 직접 읽는 편이 확실합니다.
1
2
stty -f /dev/cu.usbmodem1301 9600 raw -echo
head -c 220 /dev/cu.usbmodem1301 # 220바이트를 받으면 종료
단 head는 지정한 바이트가 다 들어와야 끝납니다. 보드가 그만큼 출력하지 않으면 계속 기다리므로, 스크립트에서는 별도 타임아웃을 걸어두는 편이 안전합니다. 사람이 직접 볼 때는 그냥 arduino-cli monitor를 쓰면 됩니다.
정리
예제 4개를 만들어 GitHub에 올려뒀습니다. I2C 스캐너, 기본 텍스트 출력, 도형·애니메이션·하드웨어 스크롤, U8g2 페이지 버퍼 모드까지 전부 UNO에서 실기 검증했습니다.
가장 기억에 남는 건 컴파일 리포트가 알고 있는 것을 다 말해주지 않는다는 점이었습니다. “여유 1521바이트”라는 숫자를 그대로 믿었다면, 나중에 센서를 얹고 나서야 원인 모를 리셋을 만났을 겁니다.
도구가 보여주는 수치에는 늘 집계 범위가 있습니다. 이번엔 그게 “정적 할당만”이었고, 그 밖은 라이브러리 소스를 열어야 보였습니다. RAM이 빠듯한 보드를 다룰 때는 이 한 번의 확인이 결국 시간을 아껴줍니다.
