STM32 SRAM을 1KB라도 더 확보하기 위한 메모리 쥐어짜기

8KB 링버퍼를 구성하려면 그만큼의 SRAM을 먼저 확보해야 했다. 문제는 이미 메모리가 넉넉한 상태가 아니었다는 점이다. 버퍼 하나를 추가한다고 끝나는 상황이 아니라, 기존에 사용하던 SRAM을 어디서 얼마나 줄일 수 있는지 다시 확인해야 했다. 결국 몇백 바이트, 1KB라도 더 확보하기 위해 map 파일까지 뒤지는 단계로 들어갔다.

SRAM 확보를 위한 map 파일 분석

처음에는 소스 코드에서 눈에 띄는 큰 버퍼나 전역 변수부터 줄이면 될 것이라고 생각하기 쉽다. 하지만 실제 메모리 사용량을 확인하려면 링크 결과를 봐야 했다.

 

Codex를 이용해 map 파일과 빌드 산출물을 분석하면서 .data, .bss, FreeRTOS Heap, 태스크 Stack에 어떤 심볼이 얼마나 들어가 있는지 확인했다. 사람이 파일과 심볼을 하나씩 추적할 수도 있지만, 이번처럼 여러 소스에 같은 패턴이 흩어져 있는 경우에는 Codex가 검색과 일괄 분석을 맡는 편이 훨씬 효율적이었다. 그 과정에서 예상보다 많은 문자열 포인터 테이블이 .data를 차지하고 있다는 사실이 눈에 들어왔다.

디버깅 문자열과 .data

이 문자열들은 불필요해서 남아 있던 것이 아니었다. 내부 상태를 숫자 코드로만 출력하면 로그를 볼 때마다 enum이나 소스 코드를 찾아야 한다. 그래서 디버깅할 때 상태를 바로 이해할 수 있도록 숫자 대신 문자열로 표현해 두었다. 메모리를 아끼겠다고 이 문자열을 모두 숫자로 바꾸는 것도 방법은 된다. 하지만 SRAM을 확보하기 위해 디버깅 편의성을 포기하고 싶지는 않았다. 필요한 것은 문자열을 없애는 것이 아니라, 문자열 표현은 유지하면서 SRAM을 사용하지 않는 방법이었다. 기존 코드에는 다음과 같은 형태의 테이블이 많았다.

static const char *table[] = {
    "STANDBY",
    "PREPARE",
    "CHARGING",
    "FINISH"
};

처음에는 나도 const char이니 당연히 Flash에서 직접 읽는 구조라고 생각했다. 하지만 여기서 const인 것은 문자열 데이터다. 배열 안의 포인터 자체는 여전히 변경할 수 있다.

table[0] = "OTHER";

즉 문자열은 읽기 전용이지만 포인터 테이블은 writable data가 될 수 있고, 실제 해당 빌드에서는 이런 테이블들이 .data를 차지하고 있었다. 이를 다음과 같이 변경했다.

static const char * const table[] = {
    "STANDBY",
    "PREPARE",
    "CHARGING",
    "FINISH"
};

두 번째 const까지 추가하면 포인터 값 자체도 변경할 수 없다. 문자열과 포인터 테이블 모두 읽기 전용으로 만들 수 있고, 해당 빌드에서는 포인터 테이블까지 Flash의 read-only 영역에 배치할 수 있었다. 이 방식으로 CLI 명령 테이블, OCPP 상태 문자열, 단위와 Phase 정보, UI 다국어 문자열 포인터, TriggerMessage 테이블처럼 같은 패턴을 사용하는 부분을 찾아 일괄 변경했다. Codex가 map 파일과 소스 코드를 함께 확인하면서 반복되는 대상을 찾고 수정 범위를 정리했다.

 

해당 시스템의 링크 대상에 포함되는 변경 심볼을 기준으로 약 1.9KB 수준의 데이터가 .data에서 읽기 전용 영역으로 이동할 수 있는 것으로 분석됐다. 정확한 값은 최종 ELF와 map 파일을 다시 기준으로 확인해야 하지만, 128KB SRAM 환경에서 1~2KB는 결코 작은 크기가 아니다.

태스크 Stack 재검토

.data를 줄이는 것만으로 끝나지는 않았다. FreeRTOS 태스크에 할당된 Stack도 다시 검토 대상이 됐다. Meter 태스크는 1,536바이트에서 1,024바이트로, GQSE 태스크는 2,048바이트에서 1,536바이트로 줄였다. FreeRTOS Timer 태스크 Stack도 1,024바이트에서 512바이트로 축소했다.

 

FreeRTOS Heap 자체를 1KB 늘린 것과 태스크 Stack 축소를 합치면 부팅 후 가용 Heap은 이론적으로 약 2.5KB 증가할 것으로 계산됐다. 다만 이것은 allocator metadata와 실제 런타임 변수를 모두 포함한 실측값이 아니라 정적 계산에 가까운 수치다. 여기서부터는 단순히 숫자를 줄이는 것으로 끝낼 수 없었다. Stack은 태스크 함수 하나의 지역 변수만 보고 판단하면 안 되기 때문이다.

함수 프레임보다 중요한 Call Stack

UI 태스크가 좋은 예였다. GCC 12.3, -Os, -fstack-usage 조건에서 ui_task() 자체의 Stack Frame은 약 48바이트였다. 이 숫자만 보면 2KB Stack은 지나치게 커 보일 수 있지만 실제 UI 태스크는 내부에서 lv_timer_handler()를 호출하고, LVGL은 화면 Refresh 과정에서 여러 단계의 Event와 Renderer를 거친다.

ui_task
 → lv_timer_handler
 → _lv_disp_refr_timer
 → refr_area_part
 → refr_obj_and_children
 → refr_obj
 → lv_obj_redraw
 → lv_event_send
 → event_send_core
 → lv_label_event
 → lv_draw_label
 → lv_draw_sw_letter

Codex가 -fstack-usage 결과와 실제 호출 경로를 결합해 누적 Call Stack을 계산한 결과, 일반적인 LVGL 화면 Refresh 경로는 약 1,264바이트까지 사용할 수 있었다. 이미지 오류 처리까지 포함한 보수적인 경로에서는 약 1,676바이트까지 증가했다. 현재 UI 태스크에 할당된 Stack은 2,048바이트이므로 정상 경로에서는 약 784바이트, 보수적인 오류 경로에서는 약 372바이트 정도가 남는 계산이다.

RTOS Context 저장 공간이나 예외 진입 프레임까지 생각하면 2KB는 과하게 잡힌 값이라기보다 추가 축소를 조심해야 하는 영역에 가깝다.

 

이 분석에서 중요한 것은 ui_task()가 48바이트를 쓴다는 사실이 아니었다. 실제 Stack 크기는 개별 함수가 아니라 전체 호출 경로를 기준으로 판단해야 한다는 점이었다.

재귀 제거와 Stack 사용량

GQSE 배터리 정보 Parser에는 재귀 호출 구조도 있었다. 큰 지역 변수를 가진 상태에서 한 프레임 안에 여러 레코드가 들어오면 레코드 수에 따라 Stack Frame이 추가될 수 있는 구조였다. 이를 반복문 기반으로 바꿔 하나의 Parser Stack Frame만 유지하도록 했다. 입력 레코드 수에 따라 Call Stack이 계속 깊어지는 구조를 제거한 것이다.

 

GQSE 태스크 Stack을 줄이는 작업은 이런 구조 변경과 함께 봐야 했다. Stack 크기를 먼저 줄이고 나중에 버티기를 기대하는 것이 아니라, Stack 사용량을 늘리는 원인을 먼저 줄인 뒤 축소 가능 범위를 판단하는 순서였다.

Task Stack 모니터링의 필요성

이번 작업의 목적은 8KB 링버퍼를 구성하기에 앞서 가능한 SRAM을 먼저 확보하는 것이었다. 그 과정에서 큰 버퍼 몇 개를 줄이는 수준을 넘어 map 파일의 .data와 .bss를 다시 보고, 디버깅용 문자열 테이블의 배치를 바꾸고, 태스크별 Call Stack까지 계산하면서 줄일 수 있는 범위를 하나씩 찾아갔다.

 

예전 같으면 이런 부분까지 파고드는 과정 자체가 개발자의 경험과 내공을 가르는 영역 중 하나였을 것이다. map 파일에서 메모리를 차지하는 심볼을 찾고, 함수 호출 경로를 따라가며 Stack 사용량을 계산하는 작업을 지금은 Codex가 상당 부분 대신 해준다. 내가 일일이 찾고 계산하지 않아도 되는 좋은 세상이라고 해야 할지, 이런 것까지 AI가 해버리면 개발자로서 내가 설 자리가 점점 줄어드는 것이라고 해야 할지는 잘 모르겠다.

 

그래도 최종적으로 어디까지 줄일지, 어떤 수치를 신뢰할지, 충전기에서 무엇을 확인해야 할지는 여전히 판단이 필요하다. 특히 몇백 바이트 단위까지 Stack을 줄이기 시작하면 이제 중요한 것은 더 줄이는 것이 아니라, 줄여놓은 상태가 실제 운용 조건에서도 안전한지를 계속 확인하는 일이다. 다음 작업은 각 태스크의 Stack High Water Mark와 FreeRTOS Heap 변화를 한눈에 확인하고, 위험 구간에 들어가는 태스크를 바로 찾을 수 있는 모니터링 도구를 만드는 것이다.

 

쥐어짰으면, 이제 실제로 터지지 않는지 계속 봐야 한다.