무엇을 만들었나What I Built

산업·공사 현장에 걸 동적 전광판이다. 실시간 시계와 기상청 날씨를 띄우고, 기온에 따라 더위·동상 주의를 자동으로 바꿔 표시하며, 안전 수칙 6단계를 순서대로 스크롤한다.

This is a dynamic LED sign built for industrial and construction sites. It displays a real-time clock and Korea Meteorological Administration (KMA) weather feed, automatically switches between heatwave and frostbite warnings based on temperature, and scrolls through six workplace safety rules.

좌우로 밀어서 표 전체 내용을 확인하세요
항목내용
기간2026.07.18 ~ 07.26
컨트롤러ESP32 (NodeMCU-32S)
패널64×32 HUB75 RGB LED 매트릭스 2장 — 물리 128×32 / 논리 256×32
드라이버1/8 스캔 · ICN2038S
전원5V 20A 파워 서플라이 + 선 터미널 블록, 승압(레벨 시프터) 모듈
소프트웨어Arduino IDE · ESP32-HUB75-LED-MATRIX-PANEL-DMA-Display · Adafruit_GFX · FreeRTOS
역할1인 풀스택 — 회로/전원 설계, 펌웨어, API 연동, 픽셀 에디터 제작
Swipe horizontally to view the full table
ItemDetails
Period2026.07.18 ~ 07.26
ControllerESP32 (NodeMCU-32S)
PanelsTwo 64×32 HUB75 RGB LED matrix panels — 128×32 physical / 256×32 logical
Driver1/8 scan · ICN2038S
Power5V 20A switching power supply + crimped spade terminal block, logic level shifter module
SoftwareArduino IDE · ESP32-HUB75-LED-MATRIX-PANEL-DMA-Display · Adafruit_GFX · FreeRTOS
RoleSolo full-stack — power/circuit wiring, firmware, weather API integration, custom browser pixel editor
왜 네 편으로 나눴나이 프로젝트는 하드웨어를 붙이는 일과 화면을 만드는 일, 그리고 완성했다고 생각한 뒤에 벌어진 일이 성격이 완전히 다르다. 특히 세 번째와 네 번째가 중요하다. 1편에서 "해결했다"고 적은 문제 중 하나는 사실 원인을 못 찾은 채 증상만 덮은 것이었다.
Why This Series Is Split into Four PartsIn this project, wiring the hardware, rendering Korean text and icons, and debugging what happened after I thought I was done were completely different battles. Parts 3 and 4 matter in particular: one of the bugs I marked as "solved" early on was actually just masking a symptom without finding the real root cause.

전원부터Starting with Power

LED 매트릭스는 전기를 많이 먹는다. 64×32 패널 하나에 픽셀이 2,048개, 두 장이면 4,096개다. 전부 흰색으로 켜면 순간 전류가 상당히 올라간다. 5V 20A 파워 서플라이를 쓰고 선 터미널을 압착해 단자대에 물렸다.

LED matrices draw serious current. One 64×32 panel has 2,048 RGB pixels; two chained together have 4,096. Driving all of them white at once pulls a large current spike, so I used a 5V 20A power supply and crimped fork terminals onto the power leads to secure them to the terminal block.

여기까지는 예상한 작업이었다. 문제는 신호선 쪽에서 나왔다.

That part went as expected. The trouble started on the data lines.

문제 1 — 3.3V로는 부족했다Problem 1 — 3.3V Logic Was Not Enough

ESP32의 GPIO 신호를 HUB75 리본 케이블에 점퍼선으로 직접 물렸더니 픽셀이 계속 깜빡이고 화면이 깨졌다.

When I wired the ESP32's GPIO pins directly to the HUB75 ribbon cable with jumper wires, pixels flickered constantly and the image tore across the panel.

원인ESP32의 GPIO 출력은 3.3V 인데, HUB75 패널의 수신부는 5V TTL 기준이다. 3.3V는 5V TTL의 HIGH 문턱(대략 2V)을 넘기긴 하지만 여유가 거의 없다. 배선이 길어지거나 노이즈가 조금만 타도 문턱 아래로 내려가면서 비트가 깨진다.
Root CauseThe ESP32 outputs 3.3V GPIO signals, whereas the HUB75 panel's driver chips expect 5V TTL logic. While 3.3V technically clears a 5V TTL HIGH threshold (around 2V), the noise margin is razor-thin. Longer jumper wires or slight switching noise easily drop the signal below threshold and corrupt bits in flight.

회로 중간에 승압 칩(Step-up Converter / Level Shifter) 모듈을 넣어 로직 레벨을 5V로 올렸다. 깜빡임과 화면 깨짐이 한 번에 사라졌다.

Inserting a step-up logic level shifter module between the ESP32 and the HUB75 connector boosted the signals to 5V and eliminated the flickering immediately.

문제 2 — 픽셀이 뒤죽박죽으로 나온다Problem 2 — Pixels Rendered Out of Order

LED 매트릭스 패널에 글자가 블록 단위로 뒤섞여 출력된 상태
물리적 배선 꼬임으로 픽셀 블록의 라인 순서가 어긋난 화면. VirtualMatrix 를 만들게 된 계기다.Text sliced and scrambled across pixel blocks due to the panel's internal scan routing. This is what led me to write VirtualMatrix.

전원과 신호를 정리하고 나니 화면은 켜졌는데, 글자를 그리면 라인 순서가 엉망으로 나왔다. 코드는 멀쩡한데 화면만 조각나 있었다.

Once power and signal voltage were clean, the panel lit up stably—but any text I drew came out sliced and scrambled out of order. My drawing code was fine; the panel layout was not.

원인은 패널 내부 구조에 있었다. 1/8 스캔 방식 64×32 패널은 픽셀 블록이 2-1-4-3 순서로 배치되어 있다. 라이브러리가 가정하는 일반적인 순서와 다르기 때문에, 논리 좌표를 그대로 넘기면 화면 위에서 블록이 뒤바뀐다.

The culprit was the panel's internal multiplexing layout. These 1/8-scan 64×32 panels route pixel blocks in a 2-1-4-3 order. Because that differs from the standard 1/16-scan mapping assumed by the DMA library, passing logical (x, y) coordinates straight through swaps the left and right halves and folds rows.

1/8 스캔이 뭔가LED 매트릭스는 모든 행을 동시에 켜지 않는다. 몇 개 행씩 묶어 빠르게 번갈아 켜고 잔상으로 전체가 켜져 보이게 한다. 1/8 스캔은 32행을 8개 그룹으로 나눠 돌린다는 뜻이다. 이 그룹 나누는 방식이 패널마다 달라서, 같은 크기 패널이라도 배선 순서가 제각각이다.
What 1/8 Scan MeansAn LED matrix does not light every row at the same instant. It groups rows and multiplexes them rapidly, relying on persistence of vision. A 1/8-scan panel divides its 32 rows into 8 scan groups. Because different panel manufacturers wire those groups into shift registers differently, two 64×32 panels can have completely different physical coordinate mappings.

좌표 변환으로 덮어버리기Masking Hardware Routing with Coordinate Remapping

배선을 다시 만드는 대신 소프트웨어에서 좌표를 바꿔 넘기기로 했다. Adafruit_GFX 를 상속한 VirtualMatrix 클래스를 만들어, 논리 좌표 (x, y) 를 실제 패널의 물리 좌표 (px, py) 로 변환한 뒤 그리도록 했다.

Instead of hacking ribbon cables, I remapped the coordinates in software. I wrote a VirtualMatrix class inheriting from Adafruit_GFX that translates logical (x, y) coordinates into the panel's physical (px, py) coordinates before forwarding them to the DMA buffer.

class VirtualMatrix : public Adafruit_GFX {
  public:
    VirtualMatrix() : Adafruit_GFX(LOGICAL_WIDTH, LOGICAL_HEIGHT) {}

    void drawPixel(int16_t x, int16_t y, uint16_t color) override {
      if (x < 0 || x >= LOGICAL_WIDTH || y < 0 || y >= LOGICAL_HEIGHT) return;
      if (!dma_display) return;

      const int panel_idx = x / 64;
      const int local_x = x % 64;
      int px, py;

      // 1/8 스캔 (2-1-4-3) 픽셀 블록 및 출력 라인 순서 재배치 연산
      if (y < 16) {
        if (local_x < 32) {
          px = panel_idx * 64 + local_x + 32; py = y;
        } else {
          px = panel_idx * 64 + (local_x - 32); py = y;
        }
      } else {
        if (local_x < 32) {
          px = panel_idx * 64 + local_x + 32; py = y - 16;
        } else {
          px = panel_idx * 64 + (local_x - 32); py = y - 16;
        }
      }
      dma_display->drawPixel(px, py, color);
    }
};
class VirtualMatrix : public Adafruit_GFX {
  public:
    VirtualMatrix() : Adafruit_GFX(LOGICAL_WIDTH, LOGICAL_HEIGHT) {}

    void drawPixel(int16_t x, int16_t y, uint16_t color) override {
      if (x < 0 || x >= LOGICAL_WIDTH || y < 0 || y >= LOGICAL_HEIGHT) return;
      if (!dma_display) return;

      const int panel_idx = x / 64;
      const int local_x = x % 64;
      int px, py;

      // Remap 1/8-scan (2-1-4-3) pixel blocks and row order
      if (y < 16) {
        if (local_x < 32) {
          px = panel_idx * 64 + local_x + 32; py = y;
        } else {
          px = panel_idx * 64 + (local_x - 32); py = y;
        }
      } else {
        if (local_x < 32) {
          px = panel_idx * 64 + local_x + 32; py = y - 16;
        } else {
          px = panel_idx * 64 + (local_x - 32); py = y - 16;
        }
      }
      dma_display->drawPixel(px, py, color);
    }
};

핵심은 drawPixel 하나만 오버라이드했다는 점이다. Adafruit_GFX 의 선 그리기, 사각형, 텍스트 출력은 전부 내부적으로 drawPixel 을 부른다. 그래서 이 함수 하나만 고쳐 놓으면 그 위의 모든 그리기 함수가 자동으로 보정된다.

The key here is that I only had to override drawPixel. Every higher-level drawing routine in Adafruit_GFX—lines, rectangles, bitmaps, text—ultimately calls drawPixel. Fixing that single virtual method automatically corrected every drawing call built on top of it.

좌우로 밀어서 표 전체 내용을 확인하세요
변환하는 것이유
x / 64, x % 64몇 번째 패널인지, 그 패널 안에서 몇 번째 열인지 분리
local_x < 32 분기로 ±32좌우 절반이 서로 뒤바뀌어 있어 맞바꾼다
y >= 16 일 때 y - 16아래쪽 16행이 물리적으로는 위쪽과 같은 행에 들어간다
Swipe horizontally to view the full table
TransformationWhy It Is Needed
x / 64, x % 64Separates which physical 64-wide panel we are on from the column index inside that panel
±32 via local_x < 32Swaps the left and right 32-column halves to match the 2-1-4-3 block order
y - 16 when y >= 16Folds the bottom 16 logical rows into the physical scan row address space
여기서 얻은 것물리적인 배선 문제를 추상화 계층 하나로 덮을 수 있다는 것. 패널을 뜯어 고치거나 리본 케이블을 다시 만드는 대신, 좌표를 옮겨 적는 클래스 하나로 끝났다. 그리고 이 위에 올라가는 코드는 패널이 1/8 스캔인지 아닌지 알 필요가 없다.
TakeawayA quirky physical wiring layout can be hidden behind a single thin abstraction layer. Instead of modifying hardware, one 30-line wrapper class solved it—and none of the UI code above VirtualMatrix ever needs to know the panel is 1/8 scan.

1편 정리Part 1 Wrap-Up

여기까지가 화면에 원하는 그림을 띄울 수 있게 되기까지의 이야기다. 전원을 넉넉히 잡고, 신호 레벨을 5V로 올리고, 픽셀 좌표를 패널에 맞게 재배치했다.

That covers getting the hardware to render pixels where I actually wanted them: sizing the 5V 20A supply, shifting GPIO logic up to 5V, and remapping the 1/8-scan coordinates.

다음 편에서는 한글이 깨져서 결국 픽셀 에디터를 직접 만든 일을 쓴다. 아두이노 기본 폰트로는 한글이 성립하지 않았고, 결국 브라우저에서 도는 픽셀 에디터를 만들어 53개 도안을 직접 찍었다.

In Part 2, I will cover why Korean text turned into illegible blobs at 32 pixels tall, and how I ended up building a browser-based pixel editor to hand-craft 53 custom bitmaps.

3편은 화면이 15초씩 멈추던 문제와 배경에 남는 푸르스름한 잔상 이야기다. 그리고 4편에서 그 잔상 진단이 틀렸다는 걸 알게 된다.

Part 3 covers a 15-second UI freeze and a strange blue ghosting artifact in the background—and Part 4 is about discovering that my diagnosis of that blue ghosting was completely wrong.