충전기 내부 통신 구조를 단순하게 보면 다음과 같다. ESP32는 서버 통신을 담당하고, SECC는 EVCC와의 ISO 15118 통신을 담당한다. 그리고 그 사이에서 STM32가 양쪽 데이터를 중계한다.
┌─────────┐ UART ┌───────────┐ UART ┌────────────┐
│ ESP32 │ <-----------> │ STM32G484 │ <-----------> │ SECC(GQSE) │
└─────────┘ └───────────┘ └────────────┘
문제는 이 구조에서 가장 메모리 여유가 적은 모듈이 중계 역할을 맡고 있다는 점이었다. PnC 인증서 처리를 준비하면서 이 구조적 제약이 그대로 드러났다. 인증서 관련 데이터는 6KB를 넘어갈 수 있고, EVCC에서 들어온 데이터는 SECC를 거쳐 STM32가 받아 다시 ESP32를 통해 서버로 올려야 한다. 반대 방향도 마찬가지다.
즉, 가장 큰 데이터를 통과시켜야 하는 경로 한가운데에 가장 메모리가 부족한 STM32가 놓여 있었다. UART RX/TX 버퍼를 단순히 최대 메시지 크기에 맞춰 키우는 방식부터 다시 생각해야 했다.
구조적 제약
코딩만 생각하면 TX 처리는 어렵지 않다. 메시지 전체가 들어갈 만큼 큰 버퍼를 잡고 Header와 Payload를 모두 복사한 뒤 Checksum까지 계산해서 한 번에 uart_write() 하면 된다.
큰 TX Buffer
↓
Header 구성
↓
Payload 전체 복사
↓
Checksum 계산
↓
UART Write
기존 구조도 이런 방식에 가까웠다. ESP32 쪽 TX 버퍼는 5KB, GQSE 쪽은 1.5KB를 사용하고 있었고 기존 메시지 크기에서는 큰 문제가 없었다. 하지만 PnC 인증서를 고려하면 얘기가 달라진다. 6KB가 넘는 데이터를 처리하기 위해 ESP32와 GQSE 양쪽 TX 버퍼를 모두 그 크기에 맞춰 키울 수는 없었다. 이미 다른 영역에서 SRAM을 줄이고 있는 STM32에서 다시 몇 KB씩 고정적으로 사용하게 되기 때문이다. 가장 편한 구현이 가장 부담스러운 메모리 구조가 되는 상황이었다.
512바이트 TX Streaming
TX 쪽은 비교적 답이 단순했다. UART는 결국 순서대로 전달되는 byte stream이므로, 하나의 프레임을 반드시 한 번의 uart_write()로 보내야 할 이유는 없었다.
uart_write(전체 프레임)
대신 다음처럼 보내도 수신 측에서 프레임 경계와 데이터 순서를 그대로 유지한다면 wire protocol은 달라지지 않는다.
uart_write(512)
uart_write(512)
uart_write(512)
...
그래서 TX 버퍼의 역할을 바꿨다. 완성된 메시지 전체를 저장하는 공간이 아니라 UART로 내보내기 위한 512바이트 작업 공간으로 사용한다.
Header
↓
Payload 일부를 TX Buffer에 기록
↓
512B가 차면 UART Write
↓
TX Buffer 재사용
↓
다음 Payload 계속 기록
↓
마지막 Checksum과 종료 데이터 전송
메시지가 2KB든 6KB든 TX 버퍼 크기는 512바이트로 고정된다. 전체 Payload를 한 번에 저장할 필요도 없다.
ESP32 TX 구조
ESP32 쪽은 기존 프레임 형식을 그대로 유지했다. STM32↔ESP32 통신은 JSON 문자열을 UART로 전달하는 구조였고, JSON 데이터 안에 프레임 제어문자와 충돌할 수 있는 값이 포함되는 경우를 피하기 위해 DLE escape 처리가 추가되어 있었다.
[SOH × 3]
[PROTOCOL]
[DLE 처리된 Payload]
[Checksum]
[EOT × 3]
제어문자가 포함되면 원본 1바이트를 DLE와 변환된 데이터 2바이트로 바꿔 전송한다. 이 때문에 송신부에는 별도의 escape 처리 로직이 필요하고, 실제 전송 데이터 크기도 원본 JSON보다 커질 수 있다. 개인적으로는 STM32와 ESP32 사이의 내부 통신을 JSON 문자열 기반으로 구성한 것 자체가 적절한 프로토콜인지도 의문이 남는다. 특히 메모리가 부족한 STM32에서 전체 JSON을 TX 버퍼에 담고, 다시 escape 처리까지 수행하는 구조는 앞으로 개선해야 할 부분이라고 봤다.
다만 이번 작업에서 프로토콜 자체까지 변경하면 검토 범위가 너무 커진다. 이번 단계에서 먼저 확인하고 싶었던 것은 기존 wire protocol을 그대로 유지한 상태에서도 TX 버퍼를 512바이트 작업 버퍼로 줄일 수 있는지, 그리고 그 변경으로 인해 다른 문제가 발생하지 않는지였다. 그래서 기존 DLE escape와 프레임 형식은 그대로 두고, 원본 JSON을 순서대로 처리하면서 실제 치환 결과가 512바이트 작업 버퍼를 채울 때마다 UART로 전송하도록 변경했다.
즉, 이번 변경의 목적은 ESP32 프로토콜 자체를 정리하는 것이 아니라 기존 프로토콜을 유지한 채 메모리 사용량부터 줄일 수 있는지 검증하는 것이었다.
GQSE TX 구조
STM32와 SECC 사이의 GQSE 송신도 같은 방향으로 바꿨다. 기존에는 다음 형태의 프레임 전체를 TX 버퍼에 구성한 뒤 한 번에 전송했다.
[Header 8B]
[Payload 전체]
[Checksum 4B]
현재는 Header를 먼저 넣고 Payload를 순서대로 512바이트 작업 버퍼에 채운다. 버퍼가 차면 전송하고 다시 채운다. 마지막에는 남은 Payload와 Checksum을 붙여 전송한다.
Header
↓
Payload
↓
512B마다 UART Write
↓
Checksum
↓
마지막 UART Write
General, IEC 61851, ISO 15118 송신도 같은 방향으로 정리했다. 큰 Payload를 TX 버퍼로 다시 복사하는 대신 이미 만들어진 Payload 구조체를 직접 읽어가며 전송하도록 바꿨다.
단순 Checksum의 역설
여기서 예상하지 못한 행운이 하나 있었다. 기존 STM32↔ESP32와 STM32↔GQSE 메시지에서 CRC라고 부르던 무결성 값은 일반적인 CRC32 table 연산이 아니었다. 실제 구현은 Payload 바이트를 순서대로 누적하는 매우 단순한 Checksum에 가까웠다.
checksum += data;
예전에 이 코드를 봤다면 왜 최소한 CRC32 정도를 사용하지 않았을까 싶었을 부분이다. 내가 처음 설계했다면 아마 CRC32부터 떠올렸을 것 같다. 그런데 TX를 512바이트 단위로 나눠 보내는 지금 상황에서는 이 단순한 방식이 오히려 유리했다. 전체 Payload가 연속된 메모리에 존재할 필요가 없기 때문이다.
Chunk 1 처리 → Checksum 누적
Chunk 2 처리 → 계속 누적
Chunk 3 처리 → 계속 누적
...
마지막 → 누적된 Checksum 전송
각 청크를 처리하면서 값을 계속 더하기만 하면 기존과 같은 결과를 얻을 수 있다. 전체 Payload를 다시 모으거나 전송이 끝난 뒤 처음부터 다시 순회할 필요도 없다. 당시에는 아쉬워 보였던 설계가 이번 메모리 구조 변경에서는 스트리밍 전송을 단순하게 만들어줬다. 조금 웃픈 상황이었다.
5.5KB 메모리 확보
TX 버퍼 크기는 다음과 같이 줄었다.
ESP32
5,120B → 512B
절감 : 4,608B
GQSE
1,536B → 512B
절감 : 1,024B
-------------------------
총 절감 : 5,632B
두 버퍼 모두 handle 내부에서 동적으로 할당되므로 이 변화는 정적 .bss 감소보다는 FreeRTOS heap 사용량 감소로 나타난다. STM32 전체 메모리 규모를 생각하면 약 5.5KB는 작지 않다. 특히 이번 작업의 목적 자체가 PnC 인증서 처리를 위해 SRAM을 더 확보하는 것이었기 때문에, 최대 메시지 크기에 맞춰 TX 버퍼를 키우는 대신 기존 TX 메모리까지 줄일 수 있다는 점이 중요했다.
재전송 제외
대신 하나는 포기했다. 전체 프레임을 하나의 TX 버퍼에 유지하지 않기 때문에 여러 번의 UART Write 중간에 오류가 발생하면 이미 전송한 앞부분을 되돌릴 수 없다.
512B 전송 성공
512B 전송 성공
512B 전송 실패
이 상황에서 프레임 전체를 다시 전송하려면 별도의 상태 관리와 재전송 정책이 필요하다. 하지만 이번 변경의 목적은 UART 프로토콜 자체를 다시 설계하는 것이 아니라 제한된 STM32 메모리 안에서 PnC 대용량 메시지를 통과시키는 것이었다. 그래서 일부 청크의 UART 전송 오류에 대한 프레임 단위 재전송은 이번 범위에서 제외했다. 중간 전송이 실패하면 현재 송신은 실패로 처리하고, 복잡한 재전송 상태까지 TX 구조에 추가하지 않는 방향을 선택했다.
다음 문제, RX
6KB가 넘는 메시지를 보내기 위해 6KB가 넘는 TX 버퍼가 반드시 필요한 것은 아니었다. 512바이트 작업 공간만 있어도 순서대로 데이터를 만들어 UART로 내보낼 수 있었고, 기존의 단순 Checksum 방식 덕분에 구조 변경도 비교적 빠르게 정리할 수 있었다.
문제는 UART RX 버퍼인데, 일단 8KB 링버퍼로 인증서를 처리하는 방식으로 생각하고 있는데 이 부분은 좀 고려해야 될 사항이 많아 쉽게 검토 대상이 될란지 모르겠다...
