AI가 짠 펌웨어: 어디서 깨지나
AI가 짠 펌웨어는 예측 가능한 nonlocal 패턴으로 실패한다. Diff에서 보이는 3가지, 49일 뒤에 터지는 3가지.
On this page
LLM이 만든 CAN 로거가 eMMC를 조용히 죽이고 있던 걸 잡았다. 그 뒤로 이런 실패를 하나씩 모으기 시작했다. Zephyr 3.6에 arm-none-eabi-gcc 12.2를 물린 환경, Sonnet 4.6과 Haiku 4.5로 220개가 넘는 테스트 케이스를 돌렸다. 모든 모델이 똑같이 틀린 체크가 있었는데, 그중 6/8이 딱 두 카테고리에 몰렸다. error handling(init 실패 시 cleanup 누락), 그리고 concurrency(ISR과 thread가 공유하는 상태).
이 글에서 다루는 6개 패턴의 실측 pass rate:
| 패턴 | Sonnet 4.6 | Haiku 4.5 | 근거 |
|---|---|---|---|
| Error path cleanup | 둘 다 실패 | 둘 다 실패 | 동일 TC linux-driver-006 (n=1) |
| volatile + ISR | 33% | 22% | isr-concurrency 카테고리 9 TC |
| DMA cache coherency | 44% | 0% | dma 카테고리 9 TC, 벤치마크 최하위 |
| Counter overflow | ~5% | ~5% | 공개 코드베이스에 과소 대표 |
| Float NaN bypass | ~5% | ~5% | 공개 코드베이스에 과소 대표 |
| Strict aliasing | ~10% | ~10% | 공개 코드에 잘못된 패턴이 다수 |
정작 흥미로운 건 숫자보다 실패의 양상이었다. 어떤 건 코드 diff에 그대로 드러난다. 뭘 찾을지만 알면 리뷰에서 잡힌다. 어떤 건 다르다. 리뷰도 통과하고 테스트도 통과하고, 몇 주에서 몇 달이 지나서야 얼굴을 내민다. 장치를 계속 켜두거나, -O0으로 짜서 -O2로 출하하는 순간에.
두 종류가 한 가지를 공유한다. nonlocal하다는 것. 버그가 그 함수 안에서는 안 보인다. Error path cleanup을 보려면 init 단계를 가로지르는 리소스 그래프를 따라가야 한다. DMA cache coherency는 하드웨어가 CPU cache를 우회한다는 사실을 알아야 보인다. 코드엔 없고 datasheet에 적혀 있는 지식이다. Counter overflow는 2^32 밀리초짜리 uptime을 머릿속에 그려야 잡힌다. 어느 것도 함수 하나의 scope 안에서는 튀어나오지 않는다.
LLM은 로컬에서 읽기 좋은 코드를 잘 뽑는다. 그런데 임베디드 버그는 대부분 nonlocal이다. 이 어긋남이 explicit prompt(답이 지시에 들어 있는 경우)와 implicit prompt(도메인 지식을 추론해야 하는 경우) 사이의 35%p gap으로 벌어진다. 모델이 올바른 코드를 못 짜서가 아니다. 올바른 코드가 함수 밖 컨텍스트에 달려 있기 때문이다.
Diff에서 잡히는 패턴
1. Error Path Cleanup
테스트한 모든 모델에서 제일 흔했던 실패다. 다단계 초기화에서 N단계가 실패하면, 1단계부터 N-1단계까지의 자원을 역순으로 되돌려야 한다. 그걸 빼먹는다.
/* Sonnet 4.6 output, prompt: "write sensor init for I2C + GPIO + DMA" */
int sensor_init(void) {
i2c_init(&config);
gpio_configure(SENSOR_PIN, GPIO_INPUT);
dma_setup(&rx_channel);
return 0; /* what if gpio_configure() failed? i2c stays acquired */
}
/* goto cleanup 추가 후 */
int sensor_init(void) {
int ret;
ret = i2c_init(&config);
if (ret < 0)
return ret;
ret = gpio_configure(SENSOR_PIN, GPIO_INPUT);
if (ret < 0)
goto err_gpio;
ret = dma_setup(&rx_channel);
if (ret < 0)
goto err_dma;
return 0;
err_dma:
gpio_release(SENSOR_PIN);
err_gpio:
i2c_deinit(&config);
return ret;
}
가독성만 놓고 보면 첫 번째가 훨씬 깔끔하다. 문제는, 모든 error path에서 I2C 버스 자원이 샌다는 점이다. 실패가 쌓이면 버스가 통째로 고갈된다.
Sonnet과 Haiku가 같은 테스트 케이스에서 나란히 이 패턴으로 넘어졌다. Zephyr 드라이버 init 시퀀스, 내부 태그로는 linux-driver-006. goto cleanup을 명시적으로 요청하면 compliance가 거의 100%까지 올라간다. 짤 줄 안다는 뜻이다. 시키지 않으면 안 짤 뿐이다.
Coverity의 resource-leak checker가 이걸 잡아준다. 그게 없어도 리뷰 규칙 하나로 커버된다. cleanup label 없는 init 함수는 일단 의심하기. cppcheck --enable=warning도 일부는 걸러내는데, path-sensitive 분석만큼 촘촘하지는 않다.
2. volatile과 ISR
ISR이 flag를 set하고 main loop이 그걸 읽는다. volatile이 빠지면 컴파일러가 -O2에서 그 읽기를 register에 캐시해버리고, 메모리를 다시 들여다보지 않는다.
int flag = 0; /* Sonnet 4.6, prompt: "ISR sets flag, main reads" -- volatile 없음 */
volatile int flag = 0; /* 키워드 하나가 전부 */
Debug 빌드에선 잘 돈다. Release에선 무한 루프. 차이는 키워드 하나다.
LLM은 ISR 안에 k_mutex_lock()이나 k_sleep()도 아무렇지 않게 집어넣는다. Zephyr에선 둘 다 deadlock 아니면 crash다. 컴파일러는 경고 한 줄 안 준다. 보드가 멈추고 나서야 알게 된다.
처음 이걸 만났을 땐 mutex deadlock인 줄 알았다. lock ordering을 한참 뒤졌다. ISR 안에서 mutex를 쥐고 있을 거라고는 상상도 못 했다. 그 뒤로 모든 locking wrapper에 k_is_in_isr() assertion을 박아뒀더니 바로 걸린다. Plain C엔 이런 런타임 체크가 없어서, 결국 코드 리뷰에 기댈 수밖에 없다.
3. DMA Cache Coherency
CPU가 버퍼에 써넣은 데이터를 DMA가 못 읽는다. CPU는 cache에 쓰고, DMA 엔진은 main memory에서 읽기 때문이다. 에러도 경고도 없다. 그냥 틀린 바이트가 나간다.
/* cache flush 없이 -- DMA는 main memory의 stale data를 읽는다 */
uint8_t tx_buf[256];
fill_buffer(tx_buf);
HAL_SPI_Transmit_DMA(&hspi1, tx_buf, 256);
/* STM32H7 (Cortex-M7, D-cache on): flush 후 DMA */
uint8_t __aligned(32) tx_buf[256];
fill_buffer(tx_buf);
SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, 256);
HAL_SPI_Transmit_DMA(&hspi1, tx_buf, 256);
STM32H7 보드에서 SPI로 센서 값을 읽는데 데이터가 간헐적으로 깨졌다. 처음엔 SPI 클럭 타이밍을 의심했다. 오실로스코프를 물려봐도 파형은 멀쩡했다. 반나절을 삽질한 끝에 범인은 Rx 버퍼의 cache invalidate 누락이었다.
Data cache가 있는 MCU라면 다 해당한다. Cortex-M7급, STM32F7/H7. 필요한 지식은 datasheet과 silicon errata에만 적혀 있다. 벤치마크에서 제일 낮은 카테고리였고, 한 모델은 cache coherency 체크에서 0점을 받았다.
여기엔 쓸만한 자동화 도구가 사실상 없다. MPU로 DMA 버퍼 영역을 non-cacheable로 잡아두거나, HAL의 DMA 래퍼가 cache 관리를 내부에서 하는지 직접 확인하는 수밖에 없다.
시간이나 환경이 드러내는 패턴
다음 세 개는 grep으로 찾을 수 있다. 그런데 코드 리뷰에서는 “정상 동작하는 코드”로 보인다. Uptime이 쌓이거나, edge case 입력이 들어오거나, toolchain이 바뀌어야 비로소 터진다.
4. 49.7일 Counter Overflow
32비트 밀리초 카운터는 2^32 ms에서 wrap된다. 날짜로 치면 49.7일이다.
if (millis() > next_event) { /* handle event */ }
49.7일째에 millis()가 0으로 되돌아간다. next_event는 여전히 큰 값이다. 조건은 영원히 false. 타이머가 다시는 안 터진다.
/* Wrap-safe: unsigned subtraction handles overflow */
if ((uint32_t)(millis() - last_event) >= interval) { ... }
실제 출하 제품에서 터진 얘기다. macOS가 32비트 카운터를 출하했다가 50일 uptime을 넘긴 기기에서 네트워킹이 죽었다. Zephyr의 OpenThread 스택도 nRF52840에서 같은 부류의 버그를 냈다. 모든 기기가 정확히 49일째에 hang, 이슈에는 “showstopper for commercial products” 태그가 붙었다.
잡는 법은 허무할 정도로 단순하다. rg 'millis\(\)\s*>' src/ 한 줄:
$ rg 'millis\(\)\s*[><=]' --glob '*.c' | head -5
drivers/heartbeat.c:47: if (millis() > next_beat) {
lib/ota/timeout.c:112: while (millis() < deadline) {
app/sensor_poll.c:83: if (millis() > sample_time) {
예시 출력이지만 패턴은 실제 프로젝트에서도 똑같다. > 비교는 전부 뺄셈으로 바꾼다. < deadline도 같은 함정이다.
5. Float NaN이 Safety Check를 우회
0으로 나누는 순간 안전 비교가 통째로 무력화된다.
float distance, elapsed_time;
/* ... */
float speed = distance / elapsed_time;
if (speed > MAX_SPEED) emergency_stop();
distance와 elapsed_time이 둘 다 0이면 0.0f / 0.0f은 NaN이다. NaN의 성질이 고약하다. 관련된 모든 비교 연산이 false를 뱉는다. NaN > MAX_SPEED? false. NaN < 0? false. NaN == NaN? 이것마저 false다. emergency_stop()은 끝내 한 번도 안 불린다.
/* NaN/Inf guard (<math.h>) */
float speed = distance / elapsed_time;
if (isnan(speed) || isinf(speed)) {
emergency_stop();
return;
}
if (speed > MAX_SPEED) emergency_stop();
isinf()도 같이 걸어둔다. distance만 0이 아니면 결과가 +-Inf로 나오는데, +Inf > MAX_SPEED는 true라 비상 정지가 걸리긴 한다(오작동이긴 해도). 문제는 -Inf > MAX_SPEED가 false라, NaN과 똑같은 우회가 생긴다는 것이다.
테스트에 0과 음수를 일부러 밀어넣으면(fault injection) 그 자리에서 드러난다. Safety-critical 경로라면 float 자체를 피하고 fixed-point로 가는 게 더 근본적인 해결이다.
6. Strict Aliasing
타입이 다른 포인터로 메모리를 읽으면 컴파일러가 그 읽기 자체를 없애버릴 수 있다.
struct sensor_reading *r = (struct sensor_reading *)buf; /* UB -- 학습 데이터에 넘친다 */
struct sensor_reading r;
memcpy(&r, buf, sizeof(r)); /* defined behavior */
-O0에선 잘 돈다. -O2에선 컴파일러가 “이 두 포인터가 같은 메모리를 가리킬 리 없다”고 가정하고(strict aliasing rule), buf를 통한 읽기를 건너뛴다. Struct엔 쓰레기 값이 남는다. buf가 struct 정렬 요구까지 못 맞추면 Cortex-M0에선 HardFault로 이어진다.
STM32F4 프로젝트에서 GCC를 10에서 12로 올렸더니 센서 드라이버가 깨졌다. 3년간 -O2로 멀쩡히 돌던 코드였다. TBAA 최적화가 더 공격적으로 바뀌면서 포인터 캐스트 경유 읽기가 통째로 사라진 것이다. memcpy로 바꾸니 풀렸고, arm-none-eabi-objdump -d로 까보면 결국 같은 register move로 컴파일된다.
방어는 CI에서 한다. -fstrict-aliasing -Wstrict-aliasing=2를 켜고, UBSan으로 런타임까지 검증한다. 작은 MCU라면 전체 펌웨어 이미지 대신 호스트 유닛 테스트에서 UBSan을 돌리는 게 현실적이다. 최소한 -O0과 -O2 빌드를 둘 다 통과시키는 것, 그게 마지노선이다.
학습이 보상하는 “깔끔함”의 함정
이 여섯 개 말고도 더 있다. eMMC wear(1편), sensor plausibility, radio stack corruption도 줄줄이 있다. 반대로 모델이 다 못하는 것도 아니다. Boilerplate, driver stub, state machine scaffolding, 단일 함수 로직은 오히려 잘 뽑는다. 실패는 도메인 지식이 implicit할 때만 튀어나온다.
왜 하필 implicit한 것만 놓칠까. LLM의 학습 데이터는 대부분 GitHub 오픈소스다. 거기서 별을 많이 받는 코드는 깔끔하고 간결한 코드다. volatile을 붙이면 “군더더기 수식어”처럼 보이고, goto를 쓰면 “나쁜 습관”으로 찍히고, error path마다 cleanup을 넣으면 “노이즈”로 읽힌다. RLHF도 같은 쪽으로 민다. 사람 평가자가 짧고 읽기 좋은 답에 높은 점수를 주기 때문이다. 그렇게 모델은 “깔끔한 코드”를 만들도록 길들여진다. 그런데 임베디드에서 안전한 코드는 별로 안 깔끔하다. 학습 인센티브와 도메인 요구가 정반대를 가리킨다.
Reviewer Agent로 잡기
그래서 코드 생성 직후에 이 패턴들을 자동으로 훑는 reviewer agent를 하나 둔다. Claude Code(또는 Cursor)의 hook이나 custom command로 firmware-safety-reviewer를 걸어두면, LLM이 코드를 뱉을 때마다 같은 질문을 자동으로 던진다. 이 글의 여섯 패턴 그대로다. error path에 reverse cleanup이 있나, ISR 공유 변수에 volatile이 붙었나, DMA 버퍼에 cache op이 있나, 32-bit counter가 wrap-safe한가, float 나눗셈 뒤에 NaN 가드가 있나, 포인터 캐스트 대신 memcpy를 썼나.
지식 자체는 LLM 안에 있다. explicit하게 시키면 ~95%를 맞힌다. 빠지는 건 적용 단계다. Reviewer가 매 생성마다 그 적용을 강제로 끌어낸다.
AI가 짠 코드를 리뷰할 때 첫 질문은 “컴파일 되나?”가 아니다. “이 코드가 제 함수 밖을 못 보면 뭘 놓치나?”다.
이 패턴들을 context engineering으로 아예 예방하는 방법은 Context Engineering 참고.
다음 편에서는 이 패턴들을 체계적으로 측정한 벤치마크를 공개한다. 233개 테스트 케이스, 5-layer 평가 파이프라인, 그리고 모델별 점수표.
댓글
댓글 불러오는 중...