Showing Posts From
펌웨어
- 28 Dec, 2025
토요일에도 회사, 양산 준비 열기 1주일 남음
토요일에도 회사, 양산 준비 열기 1주일 남음 오전 10시, 토요일 출근 알람 없이 일어났다. 7시 반. 토요일인데 몸이 알아서 깬다. 평소 같으면 "토요일은 무슨"이라고 했을 거다. 이번은 다르다. 양산 D-7. 팀장이 어제 말했다. "내일 나올 수 있는 사람?" 아무도 대답 안 했다. 5초쯤 지나서. "전 나오겠습니다." 내가 먼저 말했다. 그러니까 다들 나온다고 했다. 5명 전원. 씻고 나왔다. 회사 도착 9시 50분. 주차장이 텅 비었다. 우리 팀 차만 5대.주차장에서 코드 리뷰 사무실 들어가기 전에 차에 앉았다. 노트북 켰다. 어젯밤 커밋한 코드. DMA 버퍼 오버플로우 체크 로직. 주차장에서 다시 봤다. if(dma_buffer_idx >= DMA_BUFFER_SIZE) { // 여기서 뭘 해야 하지 }주석이 이렇게 되어 있다. 어젯밤 새벽 2시에 쓴 거다. 뭘 생각했는지 기억이 안 난다. 10분 동안 봤다. 아, 인터럽트 끄고 리셋해야지. 노트북에 메모했다. 사무실 가서 고치자. 차에서 내렸다. 팀장 차도 와 있다. 회의실에 모인 5명 다들 와 있었다. 회의실에 노트북 펼쳐놓고. "커피 타올게요." 막내가 말했다. "나도." "저도요." 결국 5잔. 팀장이 화이트보드에 썼다.UART 타임아웃 이슈 DMA 버퍼 관리 저전력 모드 전류 초과 OTA 검증 미완4개. 1주일에 4개. "하나씩 가자. UART부터." 시작했다.UART 타임아웃, 그놈의 9600bps UART 통신이 가끔 멈춘다. 재현이 안 된다. 100번 중 1번꼴. "타임아웃 값이 문제 아닐까요?" "이미 늘렸어요. 1초로." "그럼 보레이트?" "9600으로 고정이에요. 센서 스펙." 센서 스펙. 저가 센서라 9600bps 고정이다. 바꿀 수가 없다. 오실로스코ープ 가져왔다. 실제 파형 봤다. Start 비트는 정상. Data 비트도 정상. Stop 비트에서 가끔 글리치. "하드웨어 문제 아닐까요?" 내가 물었다. "PCB는 검증 끝났어요." HW 팀장 답변. 그럼 뭐지. 점심때까지 봤다. 결론: 일단 리트라이 로직 강화. 근본 원인은 모른다. 치킨과 디버깅 점심은 치킨. 토요일이니까 치킨. 회사 앞 치킨집. "양산 나가면 괜찮을까요?" 막내가 물었다. "괜찮아야지." 팀장이 웃었다. 웃긴데 안 웃겼다. 치킨 먹으면서도 코드 봤다. 노트북 옆에 치킨. 키보드에 기름 묻었다. 휴지로 닦았다. "DMA 버퍼는 제가 오늘 고칠게요." 내가 말했다. "저는 저전력 모드 볼게요." 선배가 말했다. 역할 분담 끝. 치킨 다 먹었다. 오후 2시.DMA 버퍼 수정, 인터럽트 지옥 사무실로 돌아왔다. 에어컨 빵빵하다. 토요일이라 전기 아깝지 않은가 봐. DMA 버퍼 코드 열었다. 아침에 본 그거. void DMA_IRQHandler(void) { if(dma_buffer_idx >= DMA_BUFFER_SIZE) { __disable_irq(); dma_buffer_idx = 0; memset(dma_buffer, 0, DMA_BUFFER_SIZE); __enable_irq(); error_count++; } }이렇게 고쳤다. 빌드. 보드에 올렸다. 테스트 시작. 1분. 정상. 5분. 정상. 10분. 정상. 좋아. 30분째. 에러 로그 떴다. error_count: 1 뭐지. 다시 코드 봤다. 인터럽트 안에서 memset? 이게 문제인가? 수정. volatile uint8_t buffer_reset_flag = 0;void DMA_IRQHandler(void) { if(dma_buffer_idx >= DMA_BUFFER_SIZE) { buffer_reset_flag = 1; error_count++; } }// 메인 루프에서 if(buffer_reset_flag) { __disable_irq(); dma_buffer_idx = 0; memset(dma_buffer, 0, DMA_BUFFER_SIZE); buffer_reset_flag = 0; __enable_irq(); }다시 빌드. 다시 올렸다. 오후 4시. 저전력 모드, 18mA의 비극 선배가 소리쳤다. "이거 봐요!" 멀티미터 들고 있다. "18mA요." 스펙은 10mA 이하. 거의 두 배. "어디서 새는 거예요?" "모르겠어요. LED 다 껐는데." 회로도 펼쳤다. A4 용지 10장. 하나씩 봤다.MCU 슬립 모드: OK 센서 파워다운: OK 통신 모듈 오프: OK그럼? "풀업 저항 아닐까요?" 내가 말했다. "다 확인했어요." 30분 더 봤다. 발견했다. ADC 클럭. 슬립 모드에서도 안 꺼져 있다. 레지스터 하나 놓쳤다. RCC->APB2ENR &= ~RCC_APB2ENR_ADC1EN;이 한 줄. 안 들어가 있었다. 추가하고 측정. 9.2mA. 성공. 오후 6시. 저녁 7시, 아직 2개 남음 저녁은 편의점. 컵라면이랑 삼각김밥. 회의실에서 먹었다. 화이트보드 봤다.UART 타임아웃 이슈 (임시 해결) DMA 버퍼 관리 (완료) 저전력 모드 전류 초과 (완료) OTA 검증 미완하나 남았다. 제일 큰 거. OTA. Over The Air 펌웨어 업데이트. 양산 나가면 필드 업데이트 필요하다. 근데 검증이 덜 됐다. "부트로더 점프 부분 확인해 봤어요?" 팀장이 물었다. "네, 그건 되는데요." 막내가 답했다. "뭐가 문제?" "CRC 체크가 가끔 실패해요." CRC. 펌웨어 무결성 검사. 이게 실패하면 벽돌. "가끔?" "네. 100번 중 3번 정도." 100번 중 3번. 3%면 적은 건가? 아니다. 양산 1만 개면 300개 벽돌. "오늘 못 고치면?" "월요일에 해야죠." 팀장 얼굴이 어둡다. 다들 어둡다. OTA, 끝나지 않는 밤 CRC 실패 원인 찾기. 로그 뽑았다. Flash Write: OK Flash Read: OK CRC Calculate: 0xA3B5C2D1 CRC Expected: 0xA3B5C2D1 Result: PASS이렇게 나올 때도 있고. Flash Write: OK Flash Read: OK CRC Calculate: 0xA3B5C2D1 CRC Expected: 0xA3B5C2D8 Result: FAIL이렇게 나올 때도 있다. Expected 값이 달라진다. "플래시에 쓸 때 문제인가?" "근데 Flash Read는 OK잖아요." 코드 다시 봤다. 플래시 쓰기 부분. for(int i = 0; i < fw_size; i += 4) { HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, FLASH_APP_ADDR + i, *(uint32_t*)(fw_buffer + i)); }뭔가 이상하다. 플래시 쓰기 후 검증이 없다. 추가했다. uint32_t written_data = *(uint32_t*)(FLASH_APP_ADDR + i); uint32_t expected_data = *(uint32_t*)(fw_buffer + i); if(written_data != expected_data) { // 에러 처리 }다시 돌렸다. 100번. 실패 0번. 됐다. 오후 9시 반. 퇴근, 토요일 밤 "오늘 고생했습니다." 팀장이 말했다. "수고하셨어요." 다들 인사. 짐 챙겼다. 노트북, 충전기, 텀블러. 주차장 나왔다. 여전히 우리 차만 5대. 시동 걸었다. 라디오 켰다. 토요일 밤 음악 프로그램. 집 가는 길. 10분. 편하다. 양산까지 7일. 월요일부터 또 전쟁. 근데 오늘은 괜찮았다. 문제 3개 해결했다. 팀원들이랑 같이. 토요일에 회사 나온 게 아깝지 않다. 이상하게. 집 도착. 현관문 열고. 침대에 누웠다. 내일은 쉰다. 진짜 쉰다.월요일이 두렵지만, 오늘은 잘했다.
- 12 Dec, 2025
펌웨어 업데이트 중 전원이 나간다면?
펌웨어 업데이트 중 전원이 나간다면? 벽돌 한 번 만들어봤다 지난달이다. 제품 50개가 벽돌이 됐다. OTA 업데이트 중에 전원이 나갔다. 정확히는 사용자가 플러그를 뽑았다. 부트로더가 깨졌다. 복구 불가능. A/S 센터로 회수. 손실 금액 계산했더니 2300만원. 내 연봉의 절반이다. 회의실에서 질책받았다. "왜 대비 안 했어요?" 대비했다고 생각했는데 아니었다. CRC 체크만으로는 부족했다. 그날부터 OTA 재설계 시작했다. 3개월 걸렸다. 이제는 좀 안다. 전원이 언제 나가도 된다.부트로더가 죽으면 끝이다 부트로더는 MCU가 켜지면 제일 먼저 실행되는 코드다. 여기가 망가지면 아무것도 못 한다. USB도 안 잡힌다. UART도 응답 없다. 우리 제품은 nRF52840 쓴다. Flash 1MB. 부트로더는 앞쪽 16KB에 박혀있다. 여기를 건드리면 안 된다. 처음에는 단순했다. 펌웨어를 통째로 교체하는 방식. Flash 지우고 → 새 펌웨어 쓰고 → 리부트. 간단하다. 문제는 쓰는 도중 전원이 나가면? Flash 절반만 써진 상태. 부트로더는 이걸 읽으려고 시도한다. CRC 안 맞는다. 뭘 해야 할지 모른다. 멈춘다. 복구 방법이 없다. JTAG 연결해서 다시 써야 하는데, 제품이 이미 고객한테 갔다. 택배로 회수해야 한다. 그게 50개였다. 다신 안 그런다고 다짐했다. Bank A/B로 나눈다 해결책은 Flash를 둘로 나누는 거다. Bank A와 Bank B. [Bootloader 16KB] [Bank A 480KB] ← 현재 실행 중인 펌웨어 [Bank B 480KB] ← 새 펌웨어 다운로드 [User Data 48KB]동작 방식은 이렇다.현재는 Bank A에서 실행 중 OTA 시작하면 Bank B에 새 펌웨어 다운로드 다운로드 완료되면 CRC 체크 맞으면 Bank B로 전환, 리부트 부트로더가 Bank B 실행여기서 핵심은 Bank A는 절대 안 지운다는 거다. 다운로드 중에 전원 나가도 Bank A는 멀쩡하다. 다시 켜면 Bank A로 부팅된다. 안전하다. 하지만 문제가 있다.새 펌웨어가 버그면? Bank B로 전환했다. 부팅됐다. 그런데 새 펌웨어에 치명적 버그가 있다. 3초 후 크래시. 리부트. 다시 크래시. 무한 루프. 사용자는 제품을 못 쓴다. 벽돌은 아니지만 벽돌이나 마찬가지다. 롤백이 필요하다. 자동으로. 우리는 이렇게 구현했다. 부트로더가 Boot Count를 센다. Flash의 특정 영역에 카운터를 둔다. 부팅할 때마다 +1. 펌웨어가 정상 동작하면 카운터를 0으로 리셋. 만약 카운터가 3 이상이면? 부트로더가 판단한다. "이 펌웨어는 망했다." Bank A로 롤백한다. 코드는 대충 이렇다. uint8_t boot_count = read_boot_count(); boot_count++; write_boot_count(boot_count);if (boot_count >= 3) { // 롤백 switch_to_bank_a(); reset_boot_count(); system_reset(); }// 정상 부팅 시도 start_firmware();// 여기까지 오면 펌웨어가 정상 reset_boot_count();펌웨어는 부팅 후 5초 이내에 reset_boot_count()를 호출해야 한다. 부트로더한테 "나 정상이야" 신호 보내는 거다. 만약 크래시 나면? reset_boot_count() 못 부른다. 리부트. 카운터 증가. 3번 반복. 롤백. 이제 좀 안전하다. 전원이 쓰는 도중 나간다면 그래도 문제가 남아있다. Flash 쓰는 도중 전원 나가면? Flash는 페이지 단위로 쓴다. nRF52는 4KB 페이지. 페이지 지우고 → 데이터 쓰고 → 다음 페이지. 만약 3번째 페이지 쓰는 중에 전원 나가면? 3번째 페이지는 절반만 써진다. CRC 체크하면 안 맞는다. 다운로드 실패. 다시 시도해야 한다. 이건 괜찮다. Bank A는 멀쩡하니까. 하지만 효율이 나쁘다. 480KB 펌웨어면 120개 페이지다. 119개 다 쓰고 마지막에 전원 나가면? 처음부터 다시. 해결책은 Chunked Write다. 펌웨어를 4KB Chunk로 나눈다. 각 Chunk마다 CRC를 붙인다. 서버에서 Chunk 단위로 보낸다. 디바이스는 Chunk를 받을 때마다:CRC 체크 Flash에 쓰기 Flash에서 읽어서 다시 CRC 체크 맞으면 서버한테 ACK 다음 Chunk 요청전원이 나가면? 마지막 ACK 보낸 Chunk까지만 써진 거다. 다시 켜면 거기서부터 이어 받는다. 코드는 이렇다. typedef struct { uint32_t chunk_id; uint32_t offset; uint16_t size; uint16_t crc; uint8_t data[4096]; } firmware_chunk_t;bool write_chunk(firmware_chunk_t *chunk) { // CRC 체크 uint16_t calc_crc = calculate_crc(chunk->data, chunk->size); if (calc_crc != chunk->crc) { return false; } // Flash 쓰기 flash_write(BANK_B_BASE + chunk->offset, chunk->data, chunk->size); // Verify uint8_t verify_buf[4096]; flash_read(BANK_B_BASE + chunk->offset, verify_buf, chunk->size); if (memcmp(chunk->data, verify_buf, chunk->size) != 0) { return false; } // 진행 상황 저장 save_ota_progress(chunk->chunk_id); return true; }save_ota_progress()가 중요하다. 이게 있어야 재시작 시 이어받기가 된다.부트로더도 업데이트해야 한다면 제일 무서운 케이스다. 부트로더 자체를 업데이트해야 할 때. 부트로더에 버그가 있거나, 새 기능이 필요하거나. 어쩔 수 없이 해야 한다. 이건 진짜 조심해야 한다. 부트로더 업데이트 중에 전원 나가면? 복구 불가능. 무조건 벽돌. 우리는 Two-Stage Bootloader를 쓴다. [Stage 1 Bootloader 8KB] ← 절대 안 건드림 [Stage 2 Bootloader 8KB] ← 업데이트 가능 [Bank A 480KB] [Bank B 480KB] [User Data 48KB]Stage 1은 최소한의 기능만. Flash에서 Stage 2 읽어서 실행. 그게 다다. Stage 1은 한 번 박으면 끝. 절대 업데이트 안 한다. 버그 있으면 안 된다. 그래서 코드가 100줄도 안 된다. Stage 2가 실제 부트로더. 여기에 모든 OTA 로직이 있다. 업데이트 필요하면 Stage 2만 교체. Stage 2 업데이트 과정:새 Stage 2를 임시 영역에 다운로드 CRC 체크 Stage 1한테 "업데이트 플래그" 설정 리부트 Stage 1이 플래그 확인 Stage 1이 임시 영역에서 Stage 2 교체 CRC 다시 체크 맞으면 새 Stage 2 실행 안 맞으면 이전 Stage 2로 복구Stage 1이 하는 거라 안전하다. Stage 1은 간단해서 버그가 없다. 서버 쪽도 중요하다 디바이스만 잘 만들어도 안 된다. 서버가 개판이면 소용없다. 우리는 AWS S3에 펌웨어 올린다. CloudFront로 배포. 전 세계 어디서든 빠르다. 펌웨어 버전 관리는 이렇게 한다. { "version": "2.3.1", "build": 1234, "min_bootloader": "1.2.0", "file_size": 491520, "sha256": "a3b5c...", "chunks": 120, "url": "https://cdn.example.com/fw/2.3.1.bin", "release_notes": "Bug fixes", "mandatory": false, "rollout_percentage": 10 }rollout_percentage가 핵심이다. 처음엔 10%만 배포. 문제 없으면 50%, 그 다음 100%. 만약 문제 생기면? 서버에서 즉시 중단. 이미 다운받은 디바이스는? 롤백된다. Boot Count 메커니즘 덕분에. 디바이스는 1시간마다 서버에 버전 체크한다. 새 버전 있으면 다운로드. 없으면 다음에. 배터리 10% 이하면 다운로드 안 한다. 충전 중일 때만. 전원 나갈 확률 줄이는 거다. 테스트는 어떻게 하나 실험실에서는 다 된다. 문제는 현장이다. 우리는 파워 서플라이에 릴레이 달았다. 랜덤 타이밍에 전원 끊는다. 하루 종일 OTA 돌리면서 전원 100번 끊는다. 한 번도 벽돌 안 되면 통과. 한 번이라도 되면 코드 수정. 이거 하느라 2주 걸렸다. 처음엔 10번 중 1번 벽돌됐다. CRC 체크 타이밍 문제였다. Flash 쓰고 바로 읽으면 안 됐다. 10ms 딜레이 넣으니까 해결됐다. 그 다음은 필드 테스트. 베타 사용자 50명한테 배포. 이번엔 롤백 테스트. 일부러 버그 있는 펌웨어 보냈다. 크래시 나게. 50명 전부 롤백됐다. 한 명도 벽돌 안 됐다. 성공. 실제로 써보니 양산 나간 지 6개월. 디바이스 5000개. OTA 업데이트 8번 했다. 전원 나간 케이스 237건. 전부 복구됐다. 벽돌 0건. 롤백된 케이스 3건. 펌웨어 버그 때문. 자동으로 롤백됐다. 사용자는 몰랐다. A/S 비용 95% 감소. 예전엔 한 달에 200만원 나갔다. 지금은 10만원. 상사가 물었다. "이거 다른 제품에도 적용 가능해?" 가능하다. 근데 시간 걸린다. 제품마다 MCU가 다르다. 부트로더 다시 짜야 한다. 지금 다른 팀 도와주는 중이다. STM32 제품에 적용하고 있다. Flash 레이아웃이 달라서 코드 수정 많이 했다. 배운 것들 OTA는 쉬운 게 아니다. 겉으로 보면 "펌웨어 다운받아서 업데이트"인데, 속은 복잡하다. 핵심은 "언제 전원 나가도 복구 가능"이다. 이게 안 되면 OTA 하면 안 된다. Bank A/B는 필수다. 플래시 용량이 부족해도 어떻게든 만들어야 한다. 벽돌 만드는 것보다 낫다. 롤백 메커니즘도 필수. 버그 있는 펌웨어 배포하면 끝이다. 자동 롤백 없으면 A/S 지옥. 서버 쪽 rollout percentage 꼭 쓴다. 처음부터 100% 배포하면 위험하다. 테스트는 파워 서플라이 끊으면서 한다. 이거 안 하면 필드에서 터진다. 시간 많이 걸린다. 처음 만들 때 3개월 걸렸다. 다른 제품 포팅하는 데도 한 달씩 걸린다. 하지만 안 하면 나중에 더 고생한다.OTA는 한 번 제대로 만들면 평생 쓴다. 지금은 안심하고 업데이트 누른다. 전원 나가도 된다.
- 09 Dec, 2025
메모리 누수 추적 3주간의 기록
메모리 누수 추적 3주간의 기록 1주차: 뭔가 이상하다 제품이 12시간 돌면 죽는다. 정확히 말하면 11시간 43분에서 12시간 18분 사이. 이게 재현이 안 된다는 게 문제다. 월요일 아침. QA팀에서 메일이 왔다. "장시간 구동 시 통신 끊김 현상" 첨부된 로그를 봤다. Heap 할당 실패. 우리 제품은 STM32F4. RAM 192KB. malloc 안 쓴다. 정책이다. 동적 할당은 fragmentation 때문에 금지. 그럼 뭐가 문제지?화요일. Memory map을 뜯어봤다. .bss, .data, .heap, .stack 영역 확인. 링커 스크립트 다시 읽었다. Stack은 0x2000_0000에서 시작. 32KB 할당. Heap은 쓰지 않으니 패스. RTOS는 FreeRTOS. Task별 Stack 사이즈 체크. 총 5개 Task.Main: 2KB Network: 4KB Sensor: 1KB Display: 2KB Logger: 3KB합치면 12KB. 여유 있다. 수요일. uxTaskGetStackHighWaterMark() 함수 추가. 각 Task의 최소 남은 Stack 확인. 12시간 돌렸다. 출력값:Main: 856 bytes Network: 124 bytes ← 이거다 Sensor: 512 bytes Display: 1024 bytes Logger: 648 bytesNetwork Task가 거의 다 썼다. 2주차: Stack을 늘려도 죽는다 목요일. Network Task Stack을 8KB로 증가. 다시 돌렸다. 금요일 아침. 또 죽었다. 14시간 만에. 로그 확인. 이번엔 Network가 아니다. Main Task에서 Hard Fault. 뭐지?팀장님한테 보고. "Stack 늘려도 안 됩니다" "malloc 안 쓰는 거 확실해?" "네, 전체 검색 돌렸습니다" 그 자리에서 같이 코드 리뷰. 30분 보다가 팀장님이 물었다. "printf 쓰니?" 쓴다. 디버그 메시지용. UART로 출력. "버퍼 크기는?" "256 bytes요" "메시지 길이는?" "...확인 안 했습니다" 오후 내내 printf 사용처 체크. 총 87군데. 대부분은 짧다. 그런데 하나 발견. char timestamp[32]; char sensor_data[64]; sprintf(buffer, "Time:%s Data:%s Value:%d Status:%d", timestamp, sensor_data, value, status);buffer는 256인데. 만약 timestamp와 sensor_data가 full이면? 32 + 64 + α = 100자 넘는다. 여전히 256 안에 든다. 근데 뭔가 찝찝하다. 월요일. 모든 sprintf를 snprintf로 교체. 길이 체크 추가. 3일 돌렸다. 또 죽었다. 3주차: 진짜 범인 수요일 밤 10시. 팀원들 다 퇴근했다. 나 혼자 남았다. GDB 연결. Breakpoint 100개 찍었다. 메모리 영역별로. 새벽 2시. 뭔가 보였다. Network Task에서 recv() 호출할 때. Buffer 주소가 이상하다. 0x2001_F000대. 우리 RAM 끝이 0x2003_0000. 0x2001_F000이면 괜찮은 주소 아닌가? 아니다. 링커 스크립트 다시 확인. Stack top: 0x2002_0000 Heap start: 0x2002_0000 겹친다. 누가 설정을 잘못했다. 아니, 초기 설정은 맞았다. 3개월 전 MCU를 F4에서 F7로 바꾸면서. RAM이 192KB에서 320KB로 늘었다. 근데 링커 스크립트는 안 고쳤다.목요일. 링커 스크립트 수정. Stack: 0x2000_0000 ~ 0x2001_0000 (64KB) Heap: 0x2001_0000 ~ 0x2004_0000 (192KB) 빌드. 플래싱. 동작 확인. 72시간 돌렸다. 안 죽는다. 금요일 오후. 팀장님한테 보고. "메모리 레이아웃 문제였습니다" "3주 걸렸네" "...죄송합니다" "괜찮아. 찾았으면 됐지" QA한테 전달. 168시간 테스트 시작. 배운 것들 malloc 안 써도 메모리 문제는 생긴다. 특히 Embedded에서는. Stack과 Heap이 충돌하면? 증상이 random하다. 재현도 어렵다. 디버깅은 지옥이다. Memory map은 항상 의심해야 한다. MCU 바뀔 때마다 체크. RAM 크기 변하면 링커 스크립트도 같이. uxTaskGetStackHighWaterMark()는 신이다. FreeRTOS 쓰면 꼭 넣어라. 각 Task 시작할 때 한 번. 종료할 때 한 번. printf는 위험하다. Buffer overflow 체크 안 하면 망한다. snprintf 쓰자. 길이 제한 걸자. GDB는 만능이 아니다. Embedded 디버깅은 다르다. Hardware 봐야 하고. Timing 봐야 하고. 때로는 오실로스코프가 답이다. 3주가 길다고? 어떤 버그는 3개월 간다. 어떤 건 양산 나가서 발견된다. 그것보단 낫다. 다음 주 월요일. 새 프로젝트 시작. 이번엔 nRF52. RAM이 256KB. 링커 스크립트부터 체크했다. 괜찮다. 이제 다른 버그를 찾으러 간다.메모리 누수가 아니라 메모리 충돌이었다. 더 최악이지 뭐.
- 09 Dec, 2025
버전 관리, 펌웨어는 다르다
버전 관리, 펌웨어는 다르다 웹은 v1.2.3으로 끝난다 웹 개발자 친구가 물었다. "너네도 Git 쓰지?" 쓴다. 당연히 쓴다. "그럼 버전 관리 똑같지 않아?" 아니다. 완전히 다르다. 웹은 간단하다. v1.2.3 찍고 배포. 안 되면 롤백. 5분이면 끝. 펌웨어는? 펌웨어 버전 v1.2.3 하드웨어 리비전 Rev2.1 메인 칩 STM32F4 Rev Y 센서 칩 BME280 Rev 1.8 이게 하나의 '버전'이다. 문서에는 이렇게 적는다. "FW v1.2.3 / HW Rev2.1 / MCU RevY / Sensor Rev1.8" 이 조합이 양산에 나갔다. 필드에서 문제 생겼다. 어느 버전이 나갔더라? 추적이 시작된다.하드웨어 리비전부터가 문제다 회로가 바뀌면 리비전이 올라간다. Rev1.0 → Rev1.1 → Rev2.0 문제는 이거다. Rev1.1에서 돌던 펌웨어가 Rev2.0에서 안 돌 수 있다. 저항 값 하나 바뀌어도 타이밍이 달라진다. 커패시터 용량 달라지면 부팅 시퀀스가 바뀐다. "하드웨어 약간만 수정했어요" 약간이 없다. 전부 다시 테스트다. 그래서 펌웨어에 하드웨어 리비전을 박는다. #define HW_REVISION_MIN 0x0200 // Rev2.0 #define HW_REVISION_MAX 0x0210 // Rev2.1if (hw_rev < HW_REVISION_MIN) { error_incompatible_hardware(); }이거 안 넣으면? 고객이 구형 보드에 신형 펌웨어 올린다. 보드 망가진다. 반품 들어온다. HW팀이 "Rev 올렸어요" 말 안 하고 넘어간 적 있다. 양산 나가고 일주일 뒤에 알았다. 밤새서 추적했다. 어느 시리얼 넘버부터 Rev2.1인지. 엑셀 파일이 세 개 버전으로 나뉘어 있었다. 최종본이 뭔지 몰랐다. 칩 리비전은 더 골치다 같은 STM32F407이어도 내부 리비전이 있다. Rev A, Rev Y, Rev Z 실리콘 버그 수정한 거다. Rev A에는 I2C 버그가 있다. 특정 조건에서 ACK를 못 받는다. 우리 코드에서 그 조건이 걸렸다. 해결책? Rev A는 워크어라운드 코드 추가. Rev Y부터는 정상 코드. uint32_t mcu_rev = read_mcu_revision(); if (mcu_rev == MCU_REV_A) { i2c_init_with_workaround(); } else { i2c_init_normal(); }문제는 양산 중에 칩이 바뀐다는 거다. 반도체 회사가 예고 없이 Rev 올린다. 우리한테 통보 안 한다. 어느 날 갑자기 보드가 안 돌았다. 칩 확인했다. Rev Z로 바뀌어 있었다. 데이터시트에 없는 리비전이었다. ST 게시판 뒤져서 찾았다. "Rev Z has modified flash timing" 플래시 타이밍이 바뀌었다. 우리 부트로더가 타이밍 하드코딩이었다. 코드 수정. 테스트. 3일 걸렸다. 그동안 생산 라인 멈췄다.센서 칩도 버전이 있다 BME280 온습도 센서 쓴다. 이것도 리비전이 있다. 칩 ID로 구분한다. 초기 버전은 습도 센서에 오프셋이 있다. +3% 정도 높게 나온다. 데이터시트 읽다가 발견했다. "Known issue in early samples" 그래서 펌웨어에서 보정한다. if (bme280_chip_id == 0x58) { // Early revision humidity -= 3.0; }문제는 부품사가 바뀔 때다. 중국 부품사로 변경했다. 같은 BME280인데 칩 ID가 다르다. 0x60이다. 보정 안 들어간다. 값이 이상하다. 필드 테스트에서 걸렸다. 다시 코드 추가. if (bme280_chip_id == 0x58) { humidity -= 3.0; } else if (bme280_chip_id == 0x60) { humidity -= 1.5; // 중국 버전은 1.5% }이제 우리 펌웨어는 센서 칩 버전도 체크한다. 처음부터 알았으면 설계를 다르게 했을 텐데. 양산 추적이 악몽이다 고객사에서 전화 왔다. "제품이 계속 재부팅돼요." 시리얼 넘버 받았다. S/N: 2024010500123 이 제품이 어떤 조합인지 찾아야 한다. 생산 기록 엑셀 열었다. 2024년 1월 5일 생산분. 500번대 시리얼. 펌웨어 버전: v2.3.1 하드웨어: Rev2.0 MCU: STM32F407 Rev Y 센서: BME280 ID 0x60 이더넷 칩: W5500 Rev 1.1 근데 또 문제다. 이날 생산분 중에 일부는 Rev1.9 보드를 썼다. 보드 재고가 섞여 있었다. 시리얼 500번대가 Rev1.9인지 Rev2.0인지 모른다. 생산 팀에 전화했다. "기록 남겼죠?" "네... 어디 적었는지 찾아볼게요." 한 시간 뒤에 연락 왔다. "500~600번은 Rev1.9 보드예요." 아, 그럼 Rev1.9 + v2.3.1 조합이네. 우리 테스트 매트릭스에 없는 조합이다. 이 조합으로 테스트 안 했다. v2.3.1은 Rev2.0 기준으로 개발했다. Rev1.9는 GPIO 핀 배치가 약간 다르다. 재부팅 원인 찾았다. 패치 펌웨어 만들었다. v2.3.1a Rev1.9 전용. 고객에게 보냈다. "이 펌웨어로 업데이트하세요." "어떻게 업데이트하나요?" USB 케이블 연결하고, 프로그램 실행하고... 전화로 20분 설명했다.매트릭스가 폭발한다 테스트 매트릭스가 기하급수다. 펌웨어 버전: 5개 (메이저 버전만) 하드웨어 리비전: 3개 MCU 리비전: 3개 센서 칩: 2개 5 × 3 × 3 × 2 = 90가지 90가지를 다 테스트할 수 없다. 보드도 없고 시간도 없다. 그래서 "지원 조합"을 정한다. 공식 문서에 적는다.FW Ver HW Rev MCU Rev Sensorv2.3.x Rev2.0 Y, Z 0x60v2.2.x Rev1.9 Y 0x58이 조합만 보증한다. 다른 조합은 "미검증"이라고 적는다. 그래도 필드에서는 온갖 조합이 나온다. 부품 수급 때문에 칩이 바뀐다. 보드 재고 때문에 리비전이 섞인다. 어쩔 수 없다. 문제 생기면 그때 대응한다. 추적 시스템을 만들었다 더 이상 못 참겠다. 엑셀로는 한계다. 사내 DB 만들었다. 생산 시 자동으로 기록되게. 시리얼 넘버 입력하면펌웨어 버전 하드웨어 리비전 사용된 모든 칩 리비전 생산 일시 작업자다 나온다. 만드는 데 2주 걸렸다. 생산 팀 설득하는 데 1주 걸렸다. "왜 이렇게 복잡하게 해요?" "나중에 추적 못 하면 더 복잡합니다." 지금은 잘 쓴다. 고객 문의 오면 10분이면 찾는다. 문제는 과거 제품이다. DB 만들기 전 제품은 여전히 엑셀이다. 2년 치 생산 기록. 정리 안 돼 있다. 언젠가 정리해야 하는데. 할 시간이 없다. 웹은 부럽다 웹 개발자는 이런 거 신경 안 쓴다. 서버 버전 하나. 클라이언트 버전 하나. 끝. 브라우저 버전? 그건 유저 문제다. "최신 브라우저로 업데이트하세요." 우리는? "최신 하드웨어로 교체하세요?" 불가능하다. 필드에 나간 제품은 10년 간다. 그 10년 동안 펌웨어 업데이트가 나간다. 하드웨어는 그대로다. Rev1.0 보드가 2015년에 나갔다. 2025년 지금도 돌고 있다. 펌웨어는 v8.2.1까지 올라갔다. v8.2.1이 Rev1.0을 지원해야 한다. 10년 전 하드웨어를. 레거시 코드가 산더미다. // Rev1.0 workaround (DO NOT REMOVE!) #ifdef HW_REV_1_0 delay_ms(100); // Wait for old capacitor #endif주석에 "DO NOT REMOVE" 적어놨다. 누가 지우면 Rev1.0 보드가 죽는다. 근데 신입이 "왜 이런 코드가?" 하면서 지웠다. 다행히 리뷰에서 걸렸다. "이거 못 지운다. Rev1.0 때문이다." "Rev1.0이 뭔데요?" "10년 전 보드다." "아직도 써요?" "쓴다." 버전 넘버링 규칙도 복잡하다 웹은 시맨틱 버저닝이다. Major.Minor.Patch 명확하다. 우리도 그렇게 하려 했다. 안 됐다. v2.3.1인데 어느 하드웨어용인지 알 수가 없다. 그래서 규칙을 바꿨다. v{HW}{Major}{Minor}_{Patch} 예: v2_03_01_05HW Rev2.x 전용 메이저 3 마이너 1 패치 5근데 이러니까 숫자가 길다. 고객이 헷갈려한다. "버전이 v2_03_01_05인가요? v20_30_10_5인가요?" 다시 규칙 바꿨다. FW-R2-v3.1.5FW: 펌웨어 R2: Rev2.x용 v3.1.5: 버전좀 나아졌다. 근데 또 문제다. Rev2.0과 Rev2.1이 호환 안 될 때가 있다. FW-R2.0-v3.1.5 FW-R2.1-v3.1.6 이렇게 해야 한다. 버전 스트링이 점점 길어진다. 펌웨어에 박힌 문자열도 길어진다. 플래시 용량 아깝다. 256KB 플래시인데 버전 정보만 1KB 먹는다. 효율적으로 바꿨다. 바이너리 인코딩. typedef struct { uint8_t hw_major; uint8_t hw_minor; uint8_t fw_major; uint8_t fw_minor; uint16_t fw_patch; uint32_t build_date; // Unix timestamp } version_info_t;24바이트로 줄었다. 근데 이러니까 사람이 못 읽는다. 디버깅할 때 메모리 덤프 보면 "02 00 03 01 00 05..." 뭔지 모른다. 문자열도 같이 넣었다. 24 + 64 = 88바이트. 플래시 용량 항상 부족하다. 빌드 정보도 넣어야 한다 버전만으로 부족하다. 같은 v3.1.5인데 빌드가 다를 수 있다. Git 커밋 해시 넣었다. #define FW_VERSION "v3.1.5" #define GIT_HASH "a3f2c91" #define BUILD_DATE "2025-01-15" #define BUILD_TIME "14:23:01"이것도 자동으로 생성되게 했다. 빌드 스크립트에서 넣는다. #!/bin/bash GIT_HASH=$(git rev-parse --short HEAD) BUILD_DATE=$(date +%Y-%m-%d) BUILD_TIME=$(date +%H:%M:%S)echo "#define GIT_HASH \"$GIT_HASH\"" > version.h echo "#define BUILD_DATE \"$BUILD_DATE\"" >> version.h echo "#define BUILD_TIME \"$BUILD_TIME\"" >> version.h이제 펌웨어 버전이 이렇게 나온다. "FW-R2.0-v3.1.5-a3f2c91 (2025-01-15 14:23:01)" 길다. 근데 필요하다. 고객이 문제 보고하면 정확히 어느 빌드인지 알아야 한다. "v3.1.5 쓰는데 문제 있어요." "커밋 해시가 뭐죠?" "어떻게 알아요?" "시리얼 터미널 열고 'version' 입력하세요." 고객 대부분이 못 한다. 원격 지원으로 알려줘야 한다. 양산 브랜치 관리 개발은 develop 브랜치에서 한다. 양산은 release 브랜치. 문제는 여러 양산 버전이 동시에 유지된다는 거다.release/v2.3 (Rev1.9용, 구형 제품) release/v3.1 (Rev2.0용, 현재 제품) release/v4.0 (Rev2.1용, 신제품)세 브랜치 다 유지보수한다. 버그 픽스가 나오면? develop에서 고친다. 세 브랜치에 다 체리픽한다. git checkout release/v2.3 git cherry-pick abc123git checkout release/v3.1 git cherry-pick abc123git checkout release/v4.0 git cherry-pick abc123충돌 난다. 하드웨어 차이 때문에 코드가 다르다. 수동으로 머지한다. 하루 걸린다. 웹 개발자는 "master 브랜치 하나면 되잖아?" 한다. 안 된다. 양산 나간 버전은 못 바꾼다. OTA 업데이트는 더 복잡하다 OTA (Over-The-Air) 업데이트 지원한다. 인터넷으로 펌웨어 업데이트. 편하긴 한데 복잡하다. 서버에 펌웨어 파일이 여러 개다.FW-R1.9-v2.3.5.bin FW-R2.0-v3.1.8.bin FW-R2.1-v4.0.2.bin제품이 서버에 접속한다. "나 Rev2.0이고 v3.1.5 쓰는데 업데이트 있어?" 서버가 확인한다. "Rev2.0용 최신은 v3.1.8이야. 다운받아." 다운로드 시작. 256KB 파일. 2G 모뎀으로 받는다. 5분 걸린다. 중간에 끊길 수 있다. 재개 기능 필요하다. 청크로 나눠서 받는다. 다 받았다. 체크섬 확인. OK. 플래시에 쓴다. 리부팅. 새 펌웨어 실행. 자기 검증. "나 v3.1.8 맞아?" 맞다. 서버에 보고. "업데이트 성공." 여기까지 10분. 문제는 실패 케이스다. 다운로드 중 끊겼다? 다시 받아야 한다. 근데 몇 번까지 재시도? 3번? 5번? 체크섬 안 맞는다? 파일 깨졌다. 다시 받는다. 플래시 쓰기 실패? 하드웨어 문제다. 롤백해야 한다. 새 펌웨어 부팅 안 된다? 백업 펌웨어로 되돌린다. 백업 펌웨어도 안 된다? 제품 벽돌됐다. 서비스센터 가야 한다. 이런 케이스 다 처리해야 한다. typedef enum { OTA_IDLE, OTA_CHECKING, OTA_DOWNLOADING, OTA_VERIFYING, OTA_FLASHING, OTA_REBOOTING, OTA_SUCCESS, OTA_FAILED, OTA_ROLLBACK } ota_state_t;상태 머신 복잡하다. 웹은? 새 버전 배포하면 바로 적용된다. 유저는 새로고침만 하면 된다. 실패하면? 서버가 알아서 롤백한다. 우리는 제품이 필드에 있다. 롤백이 자동으로 안 된다. 제품 안에 코드로 다 처리해야 한다. 호환성 지옥 OTA로 v2.3.5에서 v3.1.8로 업데이트한다. 메이저 버전이 올라갔다. 데이터 포맷이 바뀌었다. 구버전 설정 파일을 신버전이 읽을 수 있나? 마이그레이션 코드 필요하다. if (config_version == 0x0203) { // v2.3 // Migrate to v3.1 format new_config.mode = old_config.legacy_mode; new_config.timeout = old_config.legacy_timeout * 1000; // ms로 변환 }문제는 버전이 여러 개 건너뛸 수 있다는 거다. v2.1 → v3.1 업데이트? v2.2, v2.3을 건너뛴다. 마이그레이션을 순차로 해야 한다. if (config_version == 0x0201) { migrate_v21_to_v22(); migrate_v22_to_v23(); migrate_v23_to_v31(); }마이그레이션 코드가 쌓인다. 10년 치 마이그레이션. 코드가 비대해진다. "오래된 버전 지원 중단하면 안 돼요?" 안 된다. 필드에 v1.0이 아직 있다. v1.0에서 v4.0으로 업데이트 가능해야 한다. 마이그레이션 경로를 유지해야 한다. 웹은? DB 마이그레이션 한 번 돌리면 끝. 유저는 신경 안 쓴다. 우리는 제품마다 마이그레이션이 일어난다. 100만 대 출하했으면 100만 번 마이그레이션이 필드에서 실행된다. 한 번이라도 실패하면? 고객 불만. 제품 반품. 테스트를 엄청 많이 한다. 필드 테스트의 고통 실험실에서는 다 된다. 필드에 나가면 문제가 생긴다. v3.1.5로 업데이트했다. 고객 보고: "가끔 멈춰요." 재현이 안 된다. 우리 보드로는 정상이다. 고객 보드를 받았다. 시리얼 넘버 확인. 2023년 생산품. Rev1.9 + 초기 센서 칩 조합. 우리 테스트는 Rev2.0 + 최신 칩으로 했다. 고객 조합으로 테스트. 재현됐다. 센서 폴링 타이밍 문제였다. v3.1.5a 패치. 고객에게 보냈다. 일주일 뒤 또 연락. "다른 제품도 같은 문제 있어요." 시리얼 넘버 받았다. 2024년 생산품. Rev2.0 + 중국 칩. 또 다른 조합이다. 패치가 안 먹혔다. 중국 칩 타이밍이 달랐다. v3.1.5b 패치. 또 보냈다. 이제 세 버전이 있다.v3.1.5 (오리지널) v3.1.5a (Rev1.9용 패치) v3.1.5b (중국 칩용 패치)관리가 안 된다. v3.1.6 릴리스하면서 다 합쳤다. 모든 조합 지원하게. 테스트 기간이 두 배 늘었다. 문서화는 필수다 이 모든 걸 문서로 남긴다. 버전별 릴리스 노트. 지원 하드웨어 조합. 알려진 이슈. 마이그레이션 가이드. 문서 쓰는 데 개발 시간만큼 걸린다. # FW v3.1.5 Release Note## Supported Hardware - Rev2.0 + STM32F407 RevY/Z + BME280 ID 0x60## Changes - Fixed sensor polling timing issue (#234) - Added OTA retry mechanism (#245) - Improved error reporting (#251)## Known Issues - Incompatible with Rev1.9 + early sensor chip (Use v3.1.5a instead)## Migration - v2.x to v3.1: Auto migration supported - v1.x to v3.1: Manual config reset required이렇게 적는다. 고객은 안 읽는다. "문서 어디 있어요?" 링크 보내준다. "너무 길어요. 요약해주세요." 전화로 설명한다. 기술 지원팀이 따로 있으면 좋으련만. 우리가 다 한다. 개발하고, 테스트하고, 문서 쓰고, 고객 지원하고. 웹은 기술 지원이 FAQ면 된다. 우리는 하드웨어 조합별로 답이 다르다. FAQ가 100페이지다. 내부 버전과 외부 버전 마케팅팀이 버전을 바꾸고 싶어 한다. "v3.1.5는 너무 복잡해요. v3으로 하면 안
- 03 Dec, 2025
전원을 껐다 켜봤나요?
"전원을 껐다 켜봤나요?" 오늘도 전원부터 출근했다. 어제 돌려놓은 테스트. 실패했다. 메일을 확인한다. HW팀 김과장님. "펌웨어 쪽 확인 부탁드립니다." 또다. 보드를 본다. LED가 안 켜진다. 전원을 뺐다 꽂는다. 켜진다. "해결됐습니다." 메일을 보낸다. 5분 걸렸다.이게 우리 일이다. 전원을 끄고 켠다. 하루에 50번. 99%의 좌절 오후 2시. 회의실. "통신이 안 됩니다." PM이 말한다. "리셋 해보셨어요?" 내가 묻는다. "해봤는데 안 돼요." 알았다. 직접 가봐야 한다. 현장에 간다. UART 로그를 본다. 통신은 된다. 그런데 데이터가 이상하다. "전원 껐다 켜보겠습니다." 모두가 본다. 나를. 전원을 끈다. 5초 기다린다. 켠다. 똑같다. 안 된다. "역시 펌웨어 문제네요." 누군가 말한다.99%는 이렇다. 리셋으로 해결 안 된다. 그런데도 우리는 계속한다. 왜냐면 1%가 있으니까. 1%의 기적 작년 겨울이었다. 양산 한 달 전. 필드 테스트 중. 기기가 멈췄다. "재현이 안 됩니다." 테스트팀이 말한다. 현장에 갔다. 한 시간 기다렸다. 멈췄다. JTAG를 연결한다. 스택을 본다. watchdog에 걸렸다. 그런데 원인을 모른다. 코드를 본다. 로그를 본다. 파형을 본다. 3일을 본다. "일단 리셋 루틴 추가하겠습니다." 보고했다. PM이 물었다. "근본 원인은요?" "모릅니다. 그런데 워치독으로 리셋되면 복구는 됩니다." 침묵. "그렇게 하세요." 그게 정답이었다. 양산 후 6개월. 문제없다.리셋이 해결책은 아니다. 그런데 때로는 유일한 대안이다. 리셋의 철학 임베디드 개발자들은 안다. 전원 껐다 켜는 게 얼마나 중요한지. 웹 개발자들은 모른다. 걔네는 서버 재시작하면 된다. 1초면 된다. 우리는 다르다. 리셋에도 종류가 있다. 소프트웨어 리셋. NVIC_SystemReset(). 코드로 리셋한다. 빠르다. 하지만 일부 레지스터는 안 지워진다. 하드웨어 리셋. NRST 핀. 완전히 리셋된다. 그런데 보드를 만져야 한다. 전원 리셋. 플러그를 뺀다. 가장 확실하다. 모든 걸 초기화한다. 프로젝트마다 다르다. 어떤 리셋이 필요한지. 작년 프로젝트. BLE 스택이 꼬였다. 소프트웨어 리셋으론 안 풀렸다. 전원을 완전히 끊어야 했다. 코드에 추가했다. "배터리 분리 후 3초 대기하세요." 사용자 매뉴얼에 들어갔다. 엔지니어가 아닌 사람들은 이해 못 한다. "왜 그냥 리셋 버튼 안 만들어요?" 만들었다. 버튼으로는 안 된다. 전원 분리가 필요하다. "왜요?" 설명했다. 10분 동안. 이해 못 한다. 그냥 했다. 디버깅의 늪 오늘 오전. 새 프로젝트. ESP32로 WiFi 연결. 안 된다. 로그를 본다. "Failed to connect". 당연하다. 그걸 보려고 로그를 보는 게 아닌데. 스펙 문서를 연다. 732페이지. WiFi 챕터. 읽는다. 한 시간. 뭔 소린지 모른다. "일단 리셋해보자." esp_restart(). 코드에 추가한다. 테스트한다. 안 된다. 다시 리셋. 또 안 된다. 10번 반복. 안 된다. 점심시간이다. 점심 먹고 온다. 다시 본다. 로그를 자세히 본다. "SSID not found". 아. WiFi 이름을 잘못 입력했다. 오타. 고친다. 된다. 리셋은 필요 없었다. 그런데 어쨌든 했다. 양산의 공포 양산이 제일 무섭다. 개발할 땐 문제 생기면 고친다. 펌웨어 업데이트하면 된다. 양산 후엔 다르다. 제품이 나간다. 만 대. 십만 대. 문제 생기면? 리콜. 비용이 몇억. 그래서 리셋 루틴을 넣는다. 보험처럼. Watchdog. 무조건 넣는다. 코드가 멈추면 리셋. Brown-out detection. 전압 떨어지면 리셋. Stack overflow check. 스택 터지면 리셋. 예외 처리. Hardfault 나면? 로그 남기고 리셋. 동료가 물었다. "너무 많이 리셋하는 거 아냐?" 아니다. 적당하다. "근본 원인 찾아야 하는 거 아냐?" 찾는다. 당연히. 그런데 못 찾을 수도 있다. 양산 전에 모든 버그를 찾는 건 불가능하다. 그래서 리셋이 필요하다. 사용자는 모른다. 기기가 잠깐 껐다 켜진 걸. 3초면 부팅된다. 우리만 안다. 그게 watchdog 리셋이란 걸. 로그에 남는다. 필드 테스트 데이터를 본다. 한 달에 watchdog 리셋 5번. 괜찮다. 10번 넘어가면 문제다. 코드를 다시 봐야 한다. 중국 칩의 비밀 중국 칩을 쓸 때가 있다. 싸니까. 그런데 스펙이 불친절하다. 영어 번역이 이상하다. "Reset function is to be enabled when power." 무슨 뜻인지 모른다. 구글 번역에 중국어를 넣는다. 더 모른다. 포럼을 뒤진다. 중국 포럼. 번역기 돌린다. 누군가 말한다. "그냥 껐다 켜세요." 중국어로. 해본다. 된다. 왜 되는지 모른다. 그냥 된다. 이게 중국 칩이다. 스펙보다 경험이 중요하다. 작년에 쓴 칩. 레지스터 하나가 리셋 안 됐다. 스펙엔 리셋된다고 나온다. 버그 아니냐고 물었다. 팹리스에. 답장 왔다. "이건 Feature입니다. 의도된 겁니다." 아니다. 버그다. 그냥 인정 안 하는 거다. 코드로 해결했다. 리셋 후 수동으로 레지스터 클리어. 이게 우리 일이다. HW팀과의 전쟁 "펌웨어 문제 아니에요?" HW팀 이과장님. "회로 확인해보셨어요?" 나. "회로는 문제없어요. 시뮬레이션 돌렸어요." "실제 파형 보셨어요?" "그건... 펌웨어가 잘못 설정한 거 아닐까요?" 한숨 나온다. 보드를 가져온다. 오실로스코프 연결한다. 프로브를 댄다. SCL, SDA 라인. I2C 통신. 파형을 본다. 이상하다. 클럭이 흔들린다. "이거 풀업 저항 얼마예요?" "4.7k요." "속도는요?" "400kHz요." 계산한다. 머릿속으로. 안 맞는다. "속도 낮춰보겠습니다." 코드를 고친다. 100kHz로. 테스트한다. 된다. "펌웨어 문제 맞네요." 이과장님이 말한다. 아니다. 하드웨어 문제다. 풀업 저항이 약하다. 그런데 말 안 한다. 이미 PCB 제작했다. 고치려면 재작업이다. 펌웨어로 우회한다. 속도 낮추면 된다. 성능은 좀 떨어진다. 괜찮다. 이게 임베디드다. 완벽한 하드웨어는 없다. 펌웨어로 커버한다. 리셋할 수 없는 것들 가끔 생각한다. 인생도 리셋할 수 있으면. 5년 전으로. 대학 졸업할 때로. 웹 개발 갈까, 임베디드 갈까 고민하던 때. 임베디드를 선택했다. 하드웨어가 재밌어 보였다. 지금은? 후회는 안 한다. 그런데 가끔 힘들다. 야근이 많다. 장비가 회사에 있어서 재택이 안 된다. 연봉은 나쁘지 않다. 5800만원. 야근 수당 더하면 6천 넘는다. 그런데 시급으로 계산하면? 별로다. 웹 개발자 친구. 연봉 7천. 재택 주 3일. 칼퇴. 부럽다. 솔직히. 그런데 걔는 모른다. 내가 아는 걸. 하드웨어가 돌아갈 때의 쾌감. LED가 처음 켜질 때. 센서 데이터가 들어올 때. 그건 웹에서 못 느낀다. 리셋 버튼을 누른다. 기기가 꺼진다. 다시 켜진다. 부팅 로그가 올라온다. UART로. "System initialized." 이 문장을 볼 때. 3일 밤샌 끝에. 그때는 행복하다. 내일도 리셋 퇴근 전이다. 9시. 오늘 작업한 코드. 커밋한다. 메시지를 쓴다. "Add watchdog reset handling." 푸시한다. 내일 출근하면 볼 것들. 테스트 결과. 실패했을 것이다. 또. 메일. "펌웨어 확인 부탁드립니다." 또. 회의. 결론 없을 것이다. 또. 그리고 나는 물을 것이다. "전원 껐다 켜보셨나요?" 이게 우리 일이다.내일도 전원을 끌 것이다. 그리고 켤 것이다. 50번쯤.
- 02 Dec, 2025
하드웨어 문제? 소프트웨어 문제? 중간에 몇 달이 간다
하드웨어 문제? 소프트웨어 문제? 중간에 몇 달이 간다 누가 진범인가 월요일 아침. 회의실 문이 닫힌다. "펌웨어팀, 이 제품 버그 좀 봐주시겠어요?" 하드웨어팀 팀장이 말한다. 목소리는 차분하지만 눈빛은 이미 비난하고 있다. 나는 버그 리포트를 받아든다. A4 한 장. 한글과 한문 섞인 번역체로 쓰여 있다. "디바이스 켜졌다가 5분 뒤 꺼져요." 이게 전부다. 내 머리가 이미 회전하기 시작한다. 소프트웨어 워치독이 있나? 배터리 문제는? 전원 관리 로직은? 아니면 실제로 하드웨어가 재부팅되는 건가? 펌웨어 크래시로 보이지만 사실 회로에서 전류가 튀는 건 아닌가? "일단 확인해보겠습니다." 내 대답. 항상 이 말부터다.오실로스코프 앞에서의 3시간 보드를 받아온다. 오실로스코프 프로브를 잡는다. 손이 익숙하다. 너무 익숙해서 가끔 전봇대 사진 봐도 신경이 쓰인다. VCC를 먼저 본다. 깨끗하다. 정말 깨끗하다. 5V가 흔들리지 않는다. 아, 잠깐. 순간 0.2V 드롭이 보인다. 아니다. 프로브 접지가 문제인가? 한 시간이 지난다. 파형을 다시 본다. 신호는 여전히 깨끗하다. 그럼 왜 꺼져? 혼잣말이 나온다. "클럭은? 클럭이 멈춰?" 오실로스코프 채널을 바꾼다. 48MHz 클럭이 보인다. 주기는 20.8ns. 정확하다. 그럼 뭐가 문제지? 한 시간 더. 커피를 마신다. 찬 커피다. 아침에 받은 거라서. "혹시 리셋 신호?" 리셋 핀을 본다. 3.3V에 안정적이다. 눌리지 않는다. 결론: 파형상으로는 아무 문제 없다. 그럼 소프트웨어다. 아니다. 기다려. 정말 그럴까?그 사이에서 뭔가가 발생한다 코드를 본다. 초기화 루틴부터. 1000줄이 있다. PLL 설정. 타이머 설정. UART 설정. I2C 설정. SPI 설정. GPIO 설정. "이게 순서가 맞나?" 레퍼런스 매뉴얼을 찾는다. STM32H745. 영어 1500페이지. 목차를 본다. 시스템 아키텍처. 전원 관리. 클럭 트리. RCC 레지스터. 2시간이 지난다. 혹시 스택 오버플로우? 혹시 메모리 부족? 혹시 인터럽트 우선순위 충돌? 전부 아니다. 슬랙으로 하드웨어팀에 메시지를 보낸다. "혹시 회로 다시 한 번 확인 가능할까요? 디커플링 캡이나..." 5분이 지난다. 하드웨어팀 팀장이 회의실 문을 열고 들어온다. "펌웨어 문제 아니면요? 우리 회로는 검증 다 했거든요." "저도 파형상으로는 문제가 없는데요. 혹시..." "펌웨어팀에서 먼저 확인해줄 수 있을까요?" 문이 닫힌다. 또 1시간. 또 다른 1시간. 또 다른 1시간. 며칠이 지난다. 상태 리포트를 쓴다. "원인 파악 중. 하드웨어와 소프트웨어의 경계 영역 검토 필요." 이 문장은 내가 아무것도 모른다는 뜻이다. 혹은 그 반대 "좌표를 확인해봤나요?" 하드웨어팀 신입 사원이 묻는다. 좌표? "이 IC, 데이터시트 봤어요? 핀 배치가..." 데이터시트를 본다. 맞다. 맞는데. 잠깐. 핀 번호가. "어? 43번이 GND인데 여기는 NC로 되어 있네요." "아 그건 한국 대리점에서 수정한 버전 문서예요." "...뭐?" 그 IC에서 나가는 신호가 5번인 줄 알았는데 사실은 그게 아니었다. 다른 핀이었다. 그 핀은 시뮬래이션상으로는 출력이 없는 핀이었다. 그래서 소프트웨어에서는 그 신호를 버려버렸다. 마이크로초 단위의 타이밍 오류가 발생했다. 그게 쌓여서 5분에 버퍼 오버플로우가 된 거다. 그런데 버퍼 오버플로우가 스택을 침범했다. 스택에는 뭐가 있었나. 핸들러 포인터가 있었다. 그게 깨졌다. 다음 인터럽트가 들어왔을 때 CPU가 엉뚱한 주소로 점프했다. 워치독이 감지했다. 리셋됐다. 결론: 하드웨어다. 아니다. 결론: 펌웨어 버퍼 사이즈다. 아니다. 결론: 스택 배치다. 아니다. 결론: 데이터시트가 잘못됐다.진짜 보상은 결론이 나왔다. 메일을 쓴다. "원인: 데이터시트 버전 불일치로 인한 IC 핀맵 오류. 영향: 신호 누락으로 인한 버퍼 오버플로우. 해결책: 데이터시트 최신 버전 확인 (영문) 펌웨어 버퍼 사이즈 1.5배 증량 스택 가드 추가"보낸다. 팀장이 본다. "좋아. 수정하지." 며칠이 지난다. 새 펌웨어가 나온다. 테스트한다. 24시간. 문제 없다. 48시간. 문제 없다. 72시간. 문제 없다. 처음으로 웃음이 나온다. 이게 펌웨어 개발의 쾌감이다. 파형을 봤을 때는 답이 없었다. 코드를 봤을 때도 답이 없었다. 그런데 세상에는 많은 문제가 경계에 있다. 하드웨어와 소프트웨어의 경계. 설계와 구현의 경계. 명세와 현실의 경계. 그 경계를 헤매다가 찾는 답. 그 답은 오실로스코프로도 보이지 않고 디버거로도 잘 안 보이고 머리로만 떠올라야 한다. 웹 개발자들은 이런 게 없겠지. 배포 잘못했으면 롤백하면 되니까. 우리는 한 달에 50만 개를 양산한다. 한 개가 재발할 확률이 0.0001%라도 50개가 나간다. 그 50개를 회수하려면 기차 2대가 필요하다. 그래서 우리는 며칠이고 기다린다. 오실로스코프 앞에서. 데이터시트 앞에서. 그리고 마침내 찾았을 때. 아, 그 느낌. 그게 이 일을 계속하는 이유다. 돈이 아니라. 밤새는 게 가치 있다고 느끼는 그 순간.내일 또 다른 버그 리포트가 올 거다. "어떤 제품 꺼져요." 그럼 또 시작한다.
- 02 Dec, 2025
양산 D-7, 잠을 잘 수 없다
양산 D-7, 잠을 잘 수 없다 알람이 울린다. 5시 30분. 어제는 3시간 잤다. 그 전날은 2시간. 통패턴이다. 일주일 수면 시간을 계산하지 않는 게 정신 건강에 좋다. 침대에 누웠는데 눈이 감기지 않는다. 천장을 본다. 까만 천장. 거기에 코드가 보인다. if (sensor_value > THRESHOLD) { // 여기 로직이 맞나? }맞다. 틀렸다. 아니, 모르겠다. 일단 일어난다.D-7이라는 숫자 양산까지 일주일. 정확히는 7일. 168시간. 10,080분. 더는 세지 않는다. 세면 더 짧아 보인다. 오늘 아침 회의. HW팀 리더가 물었다. "펌웨어 테스트 상태는?" 나: "90% 정도요." HW팀 리더: "90%면 앞으로 7일 동안 마무리 되겠네요?" 아. 이미 계산이 되어 있었구나. 남은 10%는 뭘까. 아무도 모른다. 나도 모른다. 그래서 무섭다. 엣지 케이스의 악몽 어제 테스트 케이스 리스트를 다시 봤다. 문서는 총 47페이지. 펌웨어팀이 만든 게 아니라 QA팀이 만들었다. 아, QA팀은 테스트만 한다. 우리는 이 테스트를 통과하려고 코드를 짠다. 47페이지를 다 읽으면서 생각했다. '혹시 우리가 놓친 조건이 있나?' 예를 들면.온도 0도에서 켜졌을 때는 어떻게 되나? 전원을 뺐다가 5초 안에 다시 꽂으면? 시리얼 통신하는 도중에 전원 끊으면? 펌웨어 업데이트 중에 전원 끊으면?이런 것들. 생각하면 끝이 없다. 한 가지 놓친 게 있으면 리콜이다. 리콜 비용. CEO한테서 들은 소문은 '최소 3억'. 최악은 '10억 이상'. 우리 팀 연봉 다 더해도 그 정도다. 나 혼자면 20년을 일해도 못 버는 돈이다. 그럼 그만큼의 책임이 내 어깨에 있다는 뜻이다. 어제 저녁 10시. 새로운 엣지 케이스를 발견했다. 극저온에서 배터리가 들어갔을 때, 부트 시퀀스에서 전압 체크하는 타이밍이 밀릴 수 있다는 걸 알았다. 딱 1ms 차이. 1ms가 뭐 하는 건가 싶겠지만, 그 1ms 때문에 다른 인터럽트가 선점되고, 그럼 UART 버퍼가 터질 수 있다. 터지면 모니터링 명령이 날아가고, 장비가 시작되지 않는다. 회로도를 다시 봤다. 콘덴서 용량이... 아니다. 그건 HW팀 문제다. 그럼 우리가 할 수 있는 건? 타이머 할당을 바꾸거나, 인터럽트 우선순위를 조정하거나, 또는... 밤 12시. 코드를 고쳤다. 테스트를 돌렸다. 통과. 다시 돌렸다. 통과. 5번 더. 다 통과. 그런데 혹시 다른 케이스에서 망가진 건 아닐까? 회귀 테스트. 모든 테스트를 다시 돌린다. 3시간 30분. 다 통과. 아침 3시 30분. 잠에 든다.양산 후 되돌릴 수 없다 웹개발자 친구 있다. SI 회사 다닌다. "버그 있으면 그냥 배포 다시 하면 돼. 새 버전 올리고. 끝." 나: "..." "너는 왜 그렇게 신경 써?" 펌웨어는 롤백이 없다. 물론 기술적으로 불가능한 건 아니다. 펌웨어 업데이트 메커니즘이 있고, 장비를 수거해서 새 버전을 올리고 반송할 수 있다. 비용만 우리 회사에서 감당하면. 하지만 고객입장에서는? "이 제품 버그 있어서 교환해야 해요." 신뢰도가 떨어진다. 두 번 이상 리콜되면 우리 제품은 끝이다. 시장에서. 그래서 처음부터 완벽해야 한다. 완벽이라는 건 뭘까. 혼자서 생각하면 미친다. 여자친구가 있을 때. 그때도 이랬다. 야근 때문에 헤어졌다는 게 공식 이유지만, 사실은 이것 때문이었다. 내 정신이 여기 없었다. 펌웨어에. 양산에. 리콜의 악몽에. "넌 왜 항상 일에만 생각해?" "양산까지만. 양산 나가면..." 양산이 나갔다. 그 다음 날부터 다음 프로젝트의 양산이 시작됐다. 지금 여기 회사 카페. 아침 8시. 검은색 커피. 세 잔째. 옆 테이블 신입. 인턴. 밝다. 웃는다. 최고 7시간을 자는 거 같다. 나를 봤을 때 이런 생각이 있겠지. '저 사람 왜 저렇게 피곤해 보이지?' 답을 줄 수 없다. 말로는 불가능하다. 침대에 누워도 잠이 오지 않는다. 눈을 감고 기다린다. 5분. 10분. 20분. 그럼 차라리 일한다. 집에 가는 길. 9시 40분. 퇴근이 이른 날이다. 내일 테스트 결과를 생각한다. 실패하는 건 아닐까. 지금까지 다 했는데 갑자기? 그럼 HW팀 보고를 어떻게 하지. 우리가 못 찾은 버그가 있었다고? 아, 그리고 스펙 문서. 다시 읽어야 한다. 혹시 우리가 놓친 요구사항이... 이미 3번 읽었는데. 4번째 읽을까.D-6 회사 도착. 9시 15분. 테스트 결과 확인. 모두 통과. 다시 확인. 모두 통과. 한 번 더. 모두 통과. 그런데 이게 이상하다. 이렇게 잘 될 리가 없다. "혹시 테스트 자체에 문제는 없나?" 팀원이랑 테스트 코드를 다시 본다. 1시간. 이상 없다. 그럼 정말 다 잘 된 건가. 아니다. 뭔가 놓친 게 있다. 분명히. 야근할까. 아니다. 일단 오늘은 자자. 한 번 자본다. 제대로. 침대에 든다. 8시간을 자자고 다짐한다. 5시간을 잤다. 깼다. 또 코드가 생각난다. 포기한다. 회사로 간다. 그냥 이런 거다 이게 펌웨어 개발자의 삶이다. 양산까지 이런 거다. 웹개발자처럼 칼퇴할 수 없다. 배포 버튼 누르고 집 가면 끝. 문제 생기면 롤백. 대면 해결. 우리는 다르다. 보드에 코드가 올라가면 끝. 그 다음부턴 고객 손에. 고객이 전원을 켜면 우리 코드가 돈다. 온도 영하 20도인 한겨울에도. 습도 95%인 욕실에서도. 시골 할머니 손에서도. 모든 상황을 예상할 수 없다. 그래서 잠을 못 자는 거다. D-7이라는 숫자는 사실 숫자가 아니라 카운트다운이다. 남은 시간. 소진되어 가는 기회. 돌이킬 수 없는 경계선. 그 선을 넘는 순간, 우리 펌웨어는 세상으로 나간다. 그리고 나는 계속 그걸 생각한다. 밤마다. 천장을 보면서.내일도 버틴다. 그 다음날도. D-6일 때도 D-1일 때도. 그리고 양산 나간 후에도.