충전기 내부 통신 구조를 단순하게 보면 다음과 같다.
┌─────────┐ UART ┌───────────┐ UART ┌────────────┐
│ ESP32 │ <-----------> │ STM32G484 │ <-----------> │ SECC(GQSE) │
└─────────┘ └───────────┘ └────────────┘
ESP32는 서버 통신을 담당하고, SECC는 EVCC와의 ISO 15118 통신을 담당한다. 그리고 그 사이에서 STM32가 양쪽 데이터를 중계한다. 문제는 이 구조에서 가장 메모리 여유가 적은 STM32가 중계 역할을 맡고 있다는 점이었다. PnC 인증서 처리를 준비하면서 이 구조적 제약이 그대로 드러났다. EVCC와 서버 사이를 오가는 인증서 관련 메시지는 약 5.6KB까지 커질 수 있는데, 이 데이터가 결국 STM32를 반드시 통과해야 한다.
TX는 전체 메시지를 한 번에 담지 않고 512바이트 작업 버퍼로 나눠 보내는 방식으로 비교적 쉽게 정리할 수 있었다. RX는 달랐다. 기존 구조 자체가 작은 메시지를 여러 개 받아두는 슬롯 방식이었기 때문이다.
기존 RX 슬롯 구조
기존 ESP32와 GQSE의 UART RX는 여러 개의 고정 버퍼를 슬롯 형태로 사용하는 구조였다. UART 데이터는 인터럽트에서 수신하고, 하나의 메시지를 모두 받으면 해당 RX 버퍼의 정보를 task로 전달해 실제 메시지 처리를 수행한다. 문제는 task가 앞선 메시지를 처리하는 동안에도 UART에서는 다음 데이터가 계속 들어온다는 점이었다. 처음에는 이 문제를 피하기 위해 두 개의 RX 버퍼를 번갈아 사용하는 듀얼 버퍼 구조로 대응했다.
UART ISR Task
│ │
├─ RX Buffer 0 완료 ───>│ 처리
│ │
└─ RX Buffer 1 수신 │
하지만 실제 통신에서는 메시지가 일정한 간격으로 들어오지 않았다. 앞선 메시지를 task가 처리하는 동안 다음 메시지까지 빠르게 들어오면 듀얼 버퍼만으로는 부족했고, 결국 RX 슬롯 수를 늘리는 방향으로 대응했다.
ESP32는 3개의 RX 버퍼로 운영할 수 있었지만, EVCC와 직접 메시지를 주고받는 SECC는 차량마다 메시지 전송 간격이 달랐다. 짧은 시간에 여러 메시지가 연속해서 들어오는 경우까지 안정적으로 처리하기 위해 GQSE 쪽은 5개의 RX 버퍼를 사용하고 있었다.
ESP32 RX
[3KB][3KB][3KB]
GQSE RX
[512B][512B][512B][512B][512B]
기존 요구사항에서는 이 구조가 크게 문제가 되지 않았다.
512B RX 슬롯의 한계
PnC를 제외하면 SECC에서 수신하는 일반 메시지는 512바이트 안에서 처리할 수 있었다. 그래서 GQSE는 512바이트 RX 슬롯 여러 개를 준비해 두는 것으로 충분했다.
512B × 5 = 2,560B
하지만 PnC 기능을 적용하면서 조건이 완전히 달라졌다. 인증서가 포함된 메시지는 약 5.6KB까지 커질 수 있었고, 기존 512바이트 슬롯 하나에는 애초에 프레임 자체가 들어가지 않았다. 그렇다고 기존 슬롯 방식을 그대로 유지한 채 슬롯 크기만 6KB 정도로 키울 수도 없었다.
약 6KB × 5
= 약 30KB
가장 메모리가 부족한 STM32에서 RX 버퍼만 수십 KB를 사용하는 것은 현실적인 선택이 아니었다. 즉, 기존 슬롯 구조는 작은 메시지가 여러 개 연속해서 들어오는 상황에는 잘 맞았지만, 하나의 프레임 자체가 수 KB까지 커지는 PnC 요구사항에서는 더 이상 같은 방식으로 확장할 수 없는 구조가 됐다.
RX/TX 메모리 재배치
그래서 단순히 GQSE RX 버퍼만 키우는 것이 아니라 ESP32와 GQSE의 RX/TX 버퍼를 함께 다시 계산했다. 먼저 ESP32 쪽에서는 기존 3KB RX 버퍼 3개를 8KB 링버퍼 하나로 바꾸면서 약 1KB를 확보했다.
ESP32 RX
기존
3KB × 3 = 9KB
변경
8KB Ring Buffer
확보
약 1KB
앞서 TX 구조를 바꾸면서 ESP32 TX 버퍼도 5KB에서 512바이트로 줄였다.
ESP32 TX
5KB → 512B
약 4.5KB 확보
결과적으로 ESP32에서 약 5.5KB를 확보할 수 있었다. 마침 GQSE RX를 기존 512바이트 슬롯 5개에서 8KB 단일 버퍼로 바꾸는 데 필요한 추가 메모리도 약 5.5KB였다.
GQSE RX
기존
512B × 5 = 2.5KB
변경
8KB Ring Buffer
추가 필요
약 5.5KB
결국 ESP32에서 줄인 메모리를 GQSE RX 확장에 그대로 옮긴 셈이다. 여기에 GQSE TX 버퍼도 기존 1.5KB에서 512바이트로 줄이면서 약 1KB를 추가로 확보했다. PnC 때문에 RX 버퍼를 키우면서도 전체 메모리 사용량을 무작정 늘리지 않고, 기존 버퍼 구조를 다시 배치하는 방향으로 맞출 수 있었다.
8KB Ring Buffer 전환
여러 개의 RX 슬롯 대신 8KB 단일 링버퍼를 두고, read_index, write_index, frame_start_index를 기준으로 관리하도록 구조를 바꿨다.
- write_index: ISR이 다음 수신 바이트를 기록할 위치
- read_index: task가 다음으로 처리할 완료 프레임의 시작 위치
- frame_start_index: ISR이 현재 수신 중인 프레임의 시작 위치
UART 인터럽트에서는 수신된 한 바이트를 현재 write_index 위치에 저장하고 인덱스를 증가시킨다. 하나의 프레임이 완성되면 해당 프레임의 위치와 길이를 task에 전달하고, ISR은 그대로 다음 프레임을 계속 받을 수 있다. 링버퍼 안에는 여러 개의 완료 프레임과 하나의 수신 중 프레임이 함께 존재할 수 있다.
read_index write_index
↓ ↓
┌─────────┬─────────┬──────────────┬─────────────┐
│ 완료 A │ 완료 B │ 수신 중 C │ 빈 공간 │
└─────────┴─────────┴──────────────┴─────────────┘
↑
frame_start_index
task가 A를 처리하는 동안에도 ISR은 B와 C를 계속 받을 수 있다. 기존 슬롯 방식처럼 비어 있는 RX 버퍼를 찾아 다음 슬롯으로 이동할 필요가 없고, 링버퍼에 남은 공간만큼 프레임을 계속 쌓을 수 있다.
설계와 구현 역할 분리
이번 RX 버퍼 변경은 먼저 내가 동작 규칙과 데이터 소유권을 정하고, 실제 구현은 Codex에 맡기는 방식으로 진행했다. 예전 같았으면 read_index, write_index, frame_start_index를 하나씩 붙이고 UART 인터럽트와 task 처리 순서를 직접 맞춰가며 디버깅했을 것이다. 이런 종류의 작업은 내가 직접 했다면 하루 이틀 정도는 잡고 봐야 했다. 이번에는 구현 전에 먼저 동작을 다음처럼 고정했다.
ISR
→ 수신 바이트 저장
→ write_index 증가
→ 프레임 완료 시 task 통지
Task
→ read_index 기준으로 프레임 처리
→ 처리 완료 후 read_index 이동
Timeout / UART Error
→ 현재 수신 중인 frame만 rollback
그리고 Codex에는 이 동작 규칙을 벗어나지 않도록 범위를 좁혀서 구현을 맡겼다. 특히 다음 조건은 계속 명확하게 줬다.
- 지정한 파일과 함수 범위 밖은 임의로 수정하지 않을 것
- 기존 parser나 다른 모듈 구조를 멋대로 바꾸지 않을 것
- 사람이 코드를 처음 봐도 의도를 바로 이해할 수 있는 형태로 작성할 것
- 복잡한 추상화나 불필요한 helper를 늘리지 않을 것
- 내가 지정한 스코프 안에서만 단계적으로 변경할 것
Codex가 구현을 대신해 준 덕분에 반복적인 인덱스 처리와 수정 속도는 확실히 빨라졌지만, 설계와 변경 범위까지 AI에 맡긴 것은 아니었다. 내가 구조와 스코프를 먼저 정하고, Codex가 그 안에서 구현하도록 한 뒤 결과를 다시 검토하는 방식이 이번 작업에서는 꽤 잘 맞았다.
프레임 롤백
링버퍼로 바꾸면서 신경 써야 했던 부분 중 하나는 수신 중인 프레임에서 timeout이나 UART 오류가 발생했을 때였다. 기존 슬롯 방식에서는 문제가 생긴 RX 버퍼 하나를 초기화하면 됐지만, 링버퍼에서는 앞쪽에 이미 정상 수신을 완료한 프레임이 존재할 수 있다.
read_index write_index
↓ ↓
[A 정상][B 정상][C 미완성..............]
↑
frame_start_index
여기서 C를 받던 중 timeout이 발생했다고 해서 링버퍼 전체를 초기화하면 A와 B까지 사라진다. 필요한 것은 현재 수신 중인 C만 없던 것으로 만드는 것이다. 그래서 write_index를 C가 시작된 위치인 frame_start_index로 되돌리는 방식으로 정리했다.
write_index = frame_start_index;
그러면 논리적인 유효 데이터는 다음처럼 바뀐다.
read_index write_index
↓ ↓
[A 정상][B 정상][ 빈 공간 ]
C의 수신 데이터가 실제 메모리에 남아 있어도 지울 필요는 없다. write_index가 되돌아간 순간 그 뒤 데이터는 더 이상 유효하지 않고, 이후 새로 들어오는 데이터가 같은 위치부터 다시 덮어쓰기 때문이다. 이 방식이라면 task가 아직 A와 B를 처리하지 못했더라도 정상 프레임은 그대로 유지된다. ISR은 새 프레임을 계속 받고, task는 완료된 프레임을 순서대로 처리한다.
task가 A와 B 처리를 모두 끝내면 read_index가 다시 write_index를 따라잡고, 이 상태가 링버퍼가 비어 있는 상태가 된다.
read_index == write_index
Timeout과 UART 오류
프레임 timeout과 UART 오류도 링버퍼 관점에서는 같은 상황으로 정리할 수 있었다. 둘 다 현재 수신 중인 프레임의 연속성을 더 이상 신뢰할 수 없다는 의미다. 따라서 완료된 프레임은 그대로 두고 현재 수신 중인 프레임만 discard하면 된다.
다만 task에서 직접 write_index를 되돌리는 구조는 피했다. 실제 수신 상태를 갱신하는 쪽이 ISR인데 task까지 같은 상태를 직접 변경하면 경합을 고려해야 하기 때문이다. 처음에는 critical section으로 보호하는 방법도 검토했지만, 이 시스템에는 10µs 단위로 동작하는 CP ADC 샘플링 인터럽트가 있어 전역 critical section을 쉽게 넣고 싶지 않았다.
결국 task는 timeout을 판단해 frame_discard_request만 설정하고, 실제 rollback은 다음 UART ISR 진입 시 처리하도록 역할을 분리했다. UART 오류도 같은 discard 요청으로 통합했다.
Task
→ Timeout 확인
→ frame_discard_request 설정
UART ISR
→ discard 요청 확인
→ 현재 미완성 frame rollback
→ 현재 수신 바이트부터 새 frame 탐색 계속
이렇게 하면 완료된 프레임과 read_index는 건드리지 않고, 현재 수신 중인 프레임 상태만 ISR이 일관되게 관리할 수 있다.
Ring Buffer 다음 문제
여기까지는 방향이 꽤 깔끔했다. 기존 여러 개의 RX 슬롯을 8KB 링버퍼 하나로 바꾸면서 5.6KB급 메시지를 수신할 수 있는 공간을 확보했고, 작은 메시지가 연속해서 들어오는 기존 상황도 그대로 대응할 수 있었다. timeout이나 UART 오류가 발생하더라도 이미 수신을 완료한 프레임은 유지하고 현재 프레임만 되돌릴 수 있었다.
처음에는 이 정도면 RX 문제도 거의 끝났다고 생각했다. 그런데 링버퍼가 한 바퀴 돌아 write_index가 숫자상 read_index보다 앞쪽으로 오는 wrap 구간에 들어가면서 새로운 문제가 나타났다.
Ring Buffer 끝
↓
[ Frame 앞부분 ... ][8191]
[0][ ... Frame 뒷부분 ]
수신 자체에는 문제가 없었다. 문제는 기존 parser가 하나의 연속된 메모리를 전제로 만들어져 있었다는 점이었다. GQSE는 바이너리 구조체 기반이라 파서를 ring-aware하게 다시 작성하면 기술적으로는 처리할 수 있다. 하지만 offset과 비트 연산이 파서마다 퍼지기 시작하면 사람이 코드를 읽고 유지보수하기 어려운 구조가 된다.
ESP32는 더 까다로웠다. 서버에서 받은 데이터를 JSON 문자열 형태로 STM32에 바이패스하고, STM32에서는 결국 cJSON_Parse()에 연속된 JSON 문자열을 넘겨야 한다. wrap된 두 조각의 메모리를 그대로 parser에 전달할 수 없다. 결국 그동안 줄여온 메모리와 별개로, 파싱을 위해 다시 5.6KB급 연속 버퍼가 필요해지는 문제가 남았다.
8KB 링버퍼로 수신 공간 문제는 해결했지만, 다음 문제는 링버퍼에 저장된 데이터를 사람이 유지보수하기 쉬운 방식으로 어떻게 파싱할 것인가였다.
