STM32G4에서 6KB 인증서 체인이 무서운 이유

입사한 지 약 8개월이 지났을 무렵, 팀 전체 인원이 퇴사하면서 진행 중이던 프로젝트를 인계받아 계속 개발하게 되었다.

 

처음부터 가장 신경 쓰였던 부분은 메모리였다. 기존에 사용하던 nRF52 계열 MCU와 비교하면 새로 선정된 STM32G4는 사용할 수 있는 메모리 여유가 크게 줄어든 상태였다. 여기에 Wi-Fi 환경에서 서버와 통신하기 위해 ESP32 모듈을 사용하고, ESP32가 서버에서 전달받은 메시지를 STM32로 넘기면 STM32가 이를 기반으로 충전기 제어와 주요 로직을 수행하는 구조였다.

 

이후 SECC까지 추가되면서 STM32는 충전기 제어뿐 아니라 ESP32와 SECC 사이의 데이터 파이프라인까지 중간에서 맡게 되었다.

┌────────┐   WebSocket / JSON   ┌────────┐   UART  ┌────────────┐  UART   ┌────────┐  PLC  ┌────────┐
│  CSMS  │ <------------------> │ ESP32  │ <-----> │ STM32G484  │ <-----> │  SECC  │ <---> │  EVCC  │
└────────┘                      └────────┘         └────────────┘         └────────┘       └────────┘

ESP32는 서버와의 WebSocket 통신을 담당하고, STM32G4는 충전기 제어와 주요 상태 로직을 담당한다. 하지만 실제 시스템에서는 서버에서 ESP32로 전달된 OCPP 메시지를 UART로 넘겨받고, 필요한 처리를 거친 뒤 SECC 쪽으로 다시 전달하는 역할까지 STM32가 떠안고 있었다.

 

처음부터 제한된 메모리 환경을 전제로 개발했기 때문에 런타임에서 반복적인 malloc/free를 최대한 줄이는 방향으로 구조를 잡았다. 각 subsystem에서 필요한 RX/TX 버퍼와 상태 정보를 초기화 시점에 비교적 큰 handle로 한 번 확보하고, 이후에는 그 안의 메모리를 계속 재사용하도록 구성했다. 이 방식은 장시간 동작하는 임베디드 시스템에서 memory fragmentation을 줄이는 데 유리하지만, 반대로 subsystem이 살아 있는 동안 해당 메모리를 계속 점유한다. 기존 요구사항 안에서는 감수할 수 있는 trade-off였다.

 

문제는 PnC 관련 요구사항이 추가되면서 시작됐다. 기존에 서버에서 내려오는 OCPP 메시지는 실제 운용 과정에서 3KB를 넘는 경우가 거의 없었다. 그래서 STM32 쪽에서는 약 3KB 크기의 RX slot 세 개를 로테이션하면서 독립된 메시지를 처리하도록 설계했다. 그런데 새 요구사항에서 전달해야 하는 인증서 체인 자체가 최소 약 5.6KB 이상이었다. 여기에 서버에서 실제 DT 메시지로 구성되면 JSON 구조와 다른 필드가 붙으면서 STM32가 전달받아야 하는 메시지는 최소 6KB 이상이 된다.

 

PC나 Android, Linux 환경에서 6KB는 사실 신경 쓸 필요도 없는 크기다. 하지만 SRAM 전체가 128KB인 MCU에서는 이야기가 달라진다. 더구나 그 데이터를 STM32가 받아서 끝나는 것도 아니다. ESP32가 받은 OCPP 메시지를 STM32로 포워딩하고, STM32는 그 안의 인증서 데이터를 다시 SECC 쪽 UART로 전달해야 한다. 그래서 구현을 시작하기 전에 현재 main 기준으로 메모리가 실제로 어디에 얼마나 사용되고 있는지, 그리고 기존 구조에서 6KB 이상의 데이터를 처리할 여지가 있는지 Codex를 이용해 먼저 조사했다.

3KB RX 슬롯 구조

현재 main의 ESP32 RX 구조는 3,072B payload를 가지는 slot 세 개로 구성되어 있었다. 전체 RX slot 저장량만 보면 9KB가 넘지만, 각 slot은 서로 다른 메시지를 보관하기 위한 공간이다.

┌──────────────┐
│ RX Slot #1   │  3 KB
└──────────────┘

┌──────────────┐
│ RX Slot #2   │  3 KB
└──────────────┘

┌──────────────┐
│ RX Slot #3   │  3 KB
└──────────────┘

처리 완료된 Slot을 다시 재사용

여러 slot을 이어 하나의 큰 frame을 조립하는 구조는 아니었다. 기존 설계의 전제는 “최대 약 3KB 크기의 독립된 메시지를 여러 개 겹치지 않게 처리한다”는 쪽에 가까웠다. 따라서, 3KB × 3 = 9KB의 RX 메모리가 있다고 해서 9KB짜리 메시지를 받을 수 있는 것은 아니다. 기존 메시지가 3KB를 넘지 않을 때는 문제가 없던 가정이었지만, 6KB를 넘는 단일 메시지가 등장하면서 이 전제가 깨졌다.

128KB 메모리 예산

STM32G484의 SRAM은 사양표만 보면 128KB지만, 프로젝트에서는 이를 하나의 자유로운 덩어리처럼 사용하고 있지 않았다. 실제로는 약 96KB 영역과 별도의 32KB 영역으로 나누어 사용하고 있었고, 각 영역도 이미 역할이 정해져 있었다.

STM32G484 SRAM : Total 128 KiB

┌──────────────────────────────────────┐    ┌──────────────────────────────┐
│          SRAM 영역 : 96 KiB          │    │       별도 영역 : 32 KiB     │
│                                      │    │                              │
│  ┌────────────────────────────────┐  │    │  cJSON Pool      : 16 KiB    │
│  │ Linker RAM : 92 KiB            │  │    │                              │
│  │                                │  │    │  LVGL Heap       :  8 KiB    │
│  │ .data / .bss                   │  │    │                              │
│  │ FreeRTOS Heap : 75 KiB         │  │    │  Draw Buffer #1  :  4 KiB    │
│  │ Task / Queue / TCB / Handle    │  │    │  Draw Buffer #2  :  4 KiB    │
│  │                                │  │    │                              │
│  └────────────────────────────────┘  │    │                              │
│                                      │    │                              │
│  Linker 외 영역 : 4 KiB              │    │                              │
│                                      │    │                              │
└──────────────────────────────────────┘    └──────────────────────────────┘
               96 KiB                                  32 KiB

오른쪽 32KB 영역만 봐도 cJSON pool 16KB, LVGL heap 8KB, draw buffer 두 개가 각각 4KB씩 배치되어 있다. 숫자만 더해도 정확히 32KB다. 왼쪽 96KB 영역 역시 전부 자유롭게 쓸 수 있는 공간은 아니었다. 일반 linker RAM은 92KB이고, 그 안에서 75KB를 FreeRTOS heap으로 미리 확보해 사용하고 있었다.

FreeRTOS Heap 75KB

FreeRTOS heap은 76,800B로 설정되어 있다. 다만 이 숫자를 그대로 “75KB의 여유 메모리”로 보면 안 된다. Task stack, TCB, queue, semaphore, 각 subsystem handle 같은 동적 객체가 모두 이 공간 안에서 나뉘어 할당된다. Codex가 현재 ELF와 map, 구조체 크기를 기준으로 확인 가능한 항목만 계산했을 때 초기 동적 구성 요소는 최소 약 46,860B였다. OCPP context, task, TCB, queue까지 생성된 상태에서는 확인 가능한 항목만 약 64,092B 수준이었다.

FreeRTOS Heap : 76,800 B

초기 최소 식별량         : 약 46,860 B
OCPP 포함 최소 식별량    : 약 64,092 B

여기에는 allocator metadata와 정렬, CMSIS-RTOS wrapper 객체, semaphore, mutex, timer, 순간적인 runtime allocation 등이 포함되지 않는다. 따라서 단순히 76,800 - 64,092를 계산해 약 12KB가 남았다고 볼 수도 없다. 더구나 이번 문제처럼 수 KB 단위의 연속 버퍼가 필요하면 전체 free byte보다 실제로 확보할 수 있는 가장 큰 연속 블록이 중요하다. 현재 main 조사만으로는 실제 장치의 FreeRTOS free heap, minimum-ever-free heap, largest free block과 fragmentation 상태까지 확인할 수 없었다.

장기 생존 Handle

확인된 주요 handle도 상당한 크기를 차지하고 있었다.

ESP32 Handle : 15,584 B
GQSE Handle  :  6,608 B
OCPP Context : 13,024 B

이 숫자만 보면 왜 이렇게 크게 잡았나 싶을 수 있지만, 의도는 명확했다. 런타임에서 잦은 malloc/free가 발생해 heap이 조각나는 것을 피하기 위해, 초기화 단계에서 subsystem이 사용할 영역을 미리 확보하고 내부에서 반복해서 재사용하는 방식이었다. 기존 요구사항에서는 런타임 안정성을 높이는 쪽에 무게를 둔 설계였지만, 새로운 6KB급 요구사항이 들어오면서 이 장기 생존 메모리가 오히려 새로운 공간을 확보하는 데 제약으로 작용하기 시작했다.

Task Stack 사용량

메모리가 부족하다고 하면 가장 먼저 stack부터 줄일 수 있는지 확인하게 된다. 현재 주요 application task stack은 다음과 같았다.

Main          : 1,024 B
Relay         :   512 B
Buzzer        :   512 B
Meter / SY7M  : 1,536 B
ESP32         : 1,536 B
PN7150        : 1,024 B
EPD           :   512 B
GQSE          : 2,048 B
SECC Manager  : 1,024 B
LED           :   512 B
Charge        : 1,536 B
UI            : 2,048 B
OCPP          : 2,048 B

초기 합계      : 13,824 B
OCPP 포함      : 15,872 B

이 application task stack들 역시 FreeRTOS heap에서 동적으로 할당된다. 함수 내부 local stack 사용량도 함께 확인했지만, 큰 편에 속하는 frame이 대체로 수백 byte 수준이었다. 활성 코드에서는 수 KB 크기의 고정 local array도 확인되지 않았다.

Battery Parser     : 528 B
LVGL Canvas        : 472 ~ 488 B
CLI Flash Command  : 288 B
ESP32 FOTA         : 272 B
GQSE Update        : 264 B
OCPP Meter Values  : 232 B

실제 task별 high-water mark를 확인하면 조정할 여지는 있겠지만, 현재 baseline에서 더 큰 비중을 차지하는 것은 개별 함수 stack보다는 persistent handle과 통신 버퍼, task stack 총량, cJSON/LVGL 같은 고정 pool 쪽이었다.

SECC UART 경로

ESP32에서 STM32로 6KB 이상의 메시지를 받을 수 있게 만든다고 문제가 끝나는 것도 아니다. STM32가 전달받은 메시지 안의 인증서 데이터는 다시 SECC 쪽으로 넘겨야 한다. 그런데 현재 GQSE/SECC RX 구조 역시 512B payload slot 다섯 개를 사용하는 방식이고, 여러 slot을 하나의 큰 frame으로 조립하는 구조는 확인되지 않았다.

GQSE RX
512 B × 5 Slot

GQSE TX
1,536 B

결국 이번 요구사항은 ESP32 쪽 RX slot 하나만 크게 만드는 문제가 아니었다. ESP32 → STM32 구간에서 큰 메시지를 받아야 하고, STM32 내부에서 필요한 데이터를 처리한 뒤 STM32 → SECC 구간으로 다시 전달해야 한다. 수신과 송신 양쪽의 버퍼 구조를 함께 바꾸지 않으면 해결되지 않는 문제였다.

메모리 확보 후보

현재 상태를 확인하고 나니 처음 질문이 조금 바뀌었다. 처음에는 “6KB짜리 버퍼 하나를 추가할 수 있을까?” 정도로 생각할 수 있지만, 실제 구조를 보면 단순히 몇 KB를 새로 할당하는 것으로 끝날 상황이 아니었다.

 

128KB RAM은 이미 역할별로 나뉘어 있고, FreeRTOS heap에는 장기간 살아 있는 객체와 task stack, queue가 자리 잡고 있다. GUI 쪽에서는 LVGL heap 8KB와 4KB draw buffer 두 개를 고정으로 사용하고 있으며, ESP32와 SECC 통신 쪽에도 각각 RX/TX 버퍼가 상시 존재한다. 이 상태에서 확인해야 할 것은 명확했다.

  • Task stack에 실제로 줄일 수 있는 여유가 있는지
  • GUI의 double draw buffer가 반드시 필요한지
  • 기존 RX slot 구조를 그대로 유지하는 것이 맞는지
  • RX와 TX가 각각 큰 고정 버퍼를 계속 들고 있어야 하는지
  • 서로 동시에 사용하지 않는 메모리를 재사용할 수 있는지
  • SECC로 전달하는 과정에서 6KB 전체를 또 하나의 고정 TX 버퍼로 만들어야 하는지

특히 중요한 것은 숫자상 몇 KB를 확보하는 것보다 실제 필요한 순간에 안전하게 사용할 수 있는 연속 공간을 만드는 일이었다. 현재 main의 메모리 구조와 통신 경로는 여기까지 확인했다.

 

정말 세상 좋아졌다는 것을 다시 한번 느낀다... 내가 구현한 코드라도 모든 것을 다 기억할 수가 없는데... 이것을 일일이 찾아서 검토하려면 며칠 작업이고 누락한 부분이 꽤 나올텐데... 이것을 1-20분만에 뚝닥 조사하고 알려주니 정말 개발하기 편한 시대가 온 것 같다.

 

어디를 줄이고 어떻게 메모리를 확보할지가 관건이긴 한데, 일단 이 작업은 부족한 시간을 Codex를 최대한 활요해서 작업 시간을 단축해보려고 한다. 그리고 이번 조사에서 확실히 내가 일일이 확인하는 것보다 효율이 좋다는 것을 증명된 것 같다. 하지만 구현은 최대한 Scope를 줄여서 작업할 예정이다. Scope를 줄이지 않을 경우 임의로 수정된 부분도 있지만, 문제는 내가 한번에 변경된 모든 사항을 확인하기 어렵고 Codex가 코드 최적화를 목표로 코드를 작성하기 때문에 사람에게 가독성이 매우 떨어질 수 있어, 추후 나나 팀원이 유지보수에 문제가 생기기 때문에 이 부분은 최대한 신경써서 작업을 할 예정이다.