STM32G484에서 UART 수신 구조를 기존 슬롯 방식에서 링버퍼로 바꾸면서 예상하지 못했던 문제가 하나 생겼다. 수신 자체는 훨씬 유연해졌지만, 파서에 넘겨야 할 데이터가 항상 연속된 메모리 형태로 존재하는 것은 아니었다.
링버퍼와 연속 데이터 문제
링버퍼는 버퍼의 끝까지 데이터를 채운 뒤 다시 처음으로 돌아가면서 계속 사용할 수 있다. 문제는 하나의 메시지가 링버퍼 끝과 시작에 걸쳐 저장될 수 있다는 점이다. 논리적으로는 하나의 프레임이지만 실제 메모리에서는 두 구간으로 나뉜 상태가 된다.
Ring Buffer
[ ... Message A-1 ][ Message A-2 ... ]
↑ wrap-around
논리적으로는 하나의 메시지
물리적으로는 두 구간으로 분리
UART RX 버퍼에 들어온 데이터를 실제 프로토콜 파서에 넘기려면 결국 하나의 연속된 메모리 블록으로 다시 구성해야 한다. 그래서 링버퍼와 별개로 수신 프레임을 복사해 조립할 scratch 영역이 필요해졌다.
UART RX
↓
8KB Ring Buffer
↓
프레임 경계 확인
↓
연속된 Scratch Buffer로 재구성
↓
Parser
이번 메모리 최적화의 목적은 단순히 SRAM 몇 KB를 줄이는 것이 아니었다. 링버퍼 기반 수신 구조에서 비연속으로 저장될 수 있는 대용량 메시지를 파싱하기 위해 연속된 메모리 공간을 확보하는 것이 핵심이었다.
6KB 인증서와 8KB 버퍼
현재 처리해야 하는 인증서 데이터는 약 6KB 수준이지만, 인증서 메시지 크기 자체가 6KB로 제한되어 있는 것은 아니다. 현재 크기에 딱 맞춰 버퍼를 잡으면 조금만 커져도 다시 같은 문제를 만나게 된다. 그래서 인증서와 같은 대용량 메시지를 처리하기 위한 scratch 영역은 6KB가 아니라 8KB를 확보하는 방향으로 정리했다.
문제는 STM32G484의 별도 32KiB SRAM 영역이 이미 모두 사용 중이었다는 점이다.
기존 별도 SRAM : 32 KiB
cJSON Pool : 16 KiB
LVGL Heap : 8 KiB
Draw Buffer #1 : 4 KiB
Draw Buffer #2 : 4 KiB
합계 : 32 KiB
8KB를 새로 확보하려면 기존 메모리 배치를 다시 뜯어볼 수밖에 없었다.
LVGL 제거 검토
한 가지 의견은 LVGL 라이브러리를 내리고 기존 레거시 제품에서 사용하던 EPD 제어 방식으로 돌아가는 것이었다. 처음에는 LVGL을 제거하면 비교적 큰 메모리를 바로 확보할 수 있을 것처럼 보였다. 그래서 레거시 EPD 코드를 Codex로 다시 확인했다. 결과는 예상과 달랐다. 200×200 흑백 EPD의 framebuffer만 5,000바이트를 사용하고 있었고, 별도의 QR 데이터 버퍼 4,096바이트까지 포함하면 주요 EPD 정적 RAM 사용량은 약 9KB였다.
200 × 200 × 1bit
= 40,000 bit
= 5,000 byte
EPD Framebuffer : 5,000 B
QR Data Buffer : 4,096 B
-------------------------
합계 : 9,096 B
기존 LVGL 구성이 4KB Dual Draw Buffer를 그대로 사용하던 상태라면 차이가 컸겠지만, Draw Buffer 자체를 줄이는 방향도 함께 검토하고 있었다. 레거시 방식으로 회귀하더라도 기대했던 만큼 SRAM이 크게 확보되는 구조는 아니었다. 더구나 LVGL을 제거하면 현재 화면 구성을 레거시 EPD 렌더링 방식으로 다시 옮기고, 폰트와 레이아웃, 화면 갱신 동작까지 재검증해야 한다. 실제 메모리 이득이 크지 않은 상황에서 UI 계층 전체를 되돌리는 방향은 효율적이지 않았다.
LVGL Heap 6KB의 한계
결국 LVGL 자체를 제거하는 대신 현재 구조를 유지하면서 메모리 사용 방식을 바꾸는 쪽으로 방향을 잡았다. 먼저 기존 4KB × 2 구조였던 Draw Buffer를 2KB Single Buffer로 줄였다. EPD에 표시하는 내용 대부분이 label 중심이고 복잡한 그래픽이 많지 않아 기본 화면에서는 큰 차이가 보이지 않았다.
여기서 LVGL Heap도 기존 8KB에서 6KB로 줄일 수 있는지 확인했다. 단순한 화면과 QRCode 생성 조건만 놓고 보면 가능해 보였고, 2KB를 추가로 확보할 수 있다는 기대도 있었지만 실제 충전 시나리오에서 문제가 드러났다. 충전에 들어가면서 충전량 설정용 QRCode를 출력하는 과정에서 6KB LVGL Heap으로는 정상 동작하지 않았다. 대기 화면이나 단순 화면만 확인했을 때는 보이지 않던 peak allocation이 실제 제품 흐름에서 나타난 셈이다.
이 결과로 LVGL Heap을 6KB까지 줄이는 방향은 폐기했다. 필요한 메모리까지 억지로 줄이는 것보다, 실제 충전 동작에서 확인된 사용량을 기준으로 8KB를 유지하는 쪽이 맞았다.
Draw Buffer의 FreeRTOS Heap 이동
LVGL Heap 8KB를 유지하면 별도 32KiB SRAM 안에서 Certification Buffer 8KB를 만들 공간이 다시 부족해진다. 그래서 줄여놓은 2KB Single Draw Buffer를 별도 SRAM에 고정하지 않고 FreeRTOS Heap에서 할당하도록 옮겼다. 핵심은 LVGL이 차지하는 메모리를 무조건 최소화하는 것이 아니라, 별도 32KiB SRAM 안에서 8KB의 연속 공간을 만드는 것이었다. Draw Buffer는 2KB로 줄인 상태를 유지하되 위치를 옮기고, 실제 화면 처리에 필요한 LVGL Heap 8KB는 그대로 남겼다.
32KiB SRAM 재배치
STM32G484 별도 SRAM 영역 : 32 KiB
Before After
┌──────────────────────────┐ ┌──────────────────────────┐
│ cJSON Pool : 16 KiB │ │ cJSON Pool : 16 KiB │
│ │ │ │
│ LVGL Heap : 8 KiB │ │ LVGL Heap : 8 KiB │
│ │ │ │
│ Draw Buffer #1 : 4 KiB │ │ Certification : 8 KiB │
│ Draw Buffer #2 : 4 KiB │ │ Buffer │
├──────────────────────────┤ ├──────────────────────────┤
│ 합계 : 32 KiB │ │ 합계 : 32 KiB │
└──────────────────────────┘ └──────────────────────────┘
Draw Buffer : 2 KiB Single Buffer
→ FreeRTOS Heap에서 할당
결과적으로 별도 32KiB SRAM은 cJSON 16KB, LVGL Heap 8KB, Certification Buffer 8KB로 다시 배치할 수 있게 됐다. 기존 Draw Buffer가 차지하던 8KB를 비우고 그 자리를 Certification Buffer로 전환한 구조다. 전체 시스템 관점에서는 Draw Buffer 2KB가 FreeRTOS Heap으로 이동했기 때문에 단순히 8KB가 사라진 것은 아니다. 하지만 이번 작업의 목적이었던 별도 SRAM 안의 연속된 8KB 영역 확보는 달성했다.
SECC와 ESP32의 다른 메모리 전략
scratch buffer를 모든 통신에 같은 방식으로 적용할 수 있는 것도 아니었다. ESP32를 통해 들어오는 서버 데이터는 차량 측 통신과 비교하면 상대적으로 빈도가 낮다. 큰 메시지를 받을 때 cJSON Pool의 일부를 scratch 영역으로 임시 할당하고, 프레임을 연속된 메모리로 재구성해 파싱한 뒤 바로 해제하는 방식으로 정리할 수 있다.
ESP32 수신
↓
cJSON Pool에서 Scratch 임시 할당
↓
연속 프레임 재구성
↓
파싱
↓
즉시 해제
SECC는 상황이 다르다. 차량과의 통신은 서버 통신과 비교하기 어려울 정도로 빈번하고, 인증서 같은 큰 메시지를 처리하는 동안에도 JSON 처리가 계속 발생할 수 있다. 이 상황에서 인증서 전송용 scratch 영역까지 cJSON Pool과 공유하면 메모리 풀이 부족해질 가능성이 높다. 그래서 SECC에는 cJSON Pool과 분리된 8KB 전용 영역을 두고, ESP32 쪽에서만 cJSON Pool을 일시적인 scratch 공간으로 재사용하는 방향으로 나눴다.
대용량 메시지의 동시성 전제
이 메모리 재사용 구조에는 중요한 전제가 하나 있다. 인증서와 같은 대용량 메시지는 동시에 둘 이상 처리되지 않아야 한다. 대용량 프레임이 동시에 여러 개 들어올 수 있다면 하나의 scratch 영역을 재사용하는 현재 전략은 성립하지 않는다. 따라서 구현에서도 단순히 “보통 동시에 오지 않는다”는 기대에 의존할 수 없고, 상태 관리나 처리 순서를 통해 이 조건이 보장되는지 확인해야 한다.
이번 작업은 LVGL을 얼마나 작게 만들 수 있는지를 확인하는 과정이 아니었다. UART RX를 링버퍼로 바꾸면서 필요해진 8KB 연속 scratch 공간을 어디서 확보할 것인지가 출발점이었다. 처음에는 LVGL Heap까지 6KB로 줄이는 방향을 검토했지만 실제 충전 진입 시 QRCode 출력에서 문제가 확인됐다. 결국 LVGL Heap은 8KB를 유지하고, 2KB Single Draw Buffer를 FreeRTOS Heap으로 옮기는 방향으로 정리했다.
이제 별도 32KiB SRAM에는 cJSON 16KB, LVGL 8KB, Certification Buffer 8KB가 들어간다. 메모리를 가장 작게 만드는 것보다 실제 제품 동작을 유지하면서 필요한 연속 공간을 확보하는 쪽으로 결론이 바뀌었다.