- 23 Dec, 2025
타이머 인터럽트 0.5ms 오차, 누구 탓일까
타이머 인터럽트 0.5ms 오차, 누구 탓일까 또 0.5ms 오실로스코프 파형을 본다. 타이머 인터럽트가 10ms마다 떨어져야 한다. 근데 10.5ms, 9.8ms, 10.3ms... 춤을 춘다. 클록은 정확하다. 72MHz 크리스털, 오차 ±20ppm. 이론상 0.0002% 오차다. 근데 실제로는 0.5ms씩 흔들린다. 5% 오차다. 양산팀에서 전화 왔다. "타이밍 안 맞아서 통신 끊긴대요." 그래. HW팀한테 물어봤다. "클록 확인해보셨어요?" 답: "크리스털 스펙 보내드릴게요." 본다. 문제없다. 그럼 뭐가 문제야.클록은 죄가 없다 크리스털은 정확하다. 72MHz HSE, PLL 써서 시스템 클록 만든다. 코드 확인한다. RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE; RCC_OscInitStruct.HSEState = RCC_HSE_ON; RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;문제없다. 레지스터 값도 확인한다. RCC->CR, RCC->CFGR. 전부 정상이다. 타이머 설정도 본다. TIM2->PSC = 71; // 72MHz / 72 = 1MHz TIM2->ARR = 9999; // 10ms계산한다. 1MHz 타이머 클록, 10000 카운트면 정확히 10ms다. 근데 오차가 난다. 오실로스코프에 클록 핀 붙여본다. 72MHz 맞다. 주파수 카운터로 재본다. 72.000142MHz. 오차 0.0002%. 문제없다. 그럼 뭐지. 인터럽트 지연 타이머는 정확하게 카운트한다. 근데 ISR이 늦게 실행된다. 타이머 인터럽트 발생 → NVIC 처리 → ISR 진입. 이 사이에 시간이 걸린다. 최악의 경우: 다른 인터럽트가 실행 중이면 기다린다. 우리 프로젝트는 인터럽트가 많다. UART RX, SPI, I2C, ADC, DMA... 우선순위는 대충 정했다. HAL_NVIC_SetPriority(TIM2_IRQn, 5, 0); HAL_NVIC_SetPriority(USART1_IRQn, 3, 0); HAL_NVIC_SetPriority(DMA1_Channel1_IRQn, 2, 0);UART가 우선순위 높다. DMA는 더 높다. 타이머는 5번이다. UART 통신 많으면 타이머 인터럽트가 밀린다. UART ISR이 끝날 때까지 기다린다. UART ISR 측정해본다. 길면 150us 걸린다. 데이터 파싱 때문이다. 최적화 안 돼 있다. DMA ISR은 짧다. 20us 정도. 근데 자주 발생한다. ADC 샘플링 1kHz라서. 계산해본다. DMA 1ms당 한 번, 20us씩. UART 불규칙하게, 최대 150us. 겹치면 최대 170us 지연이다. 근데 0.5ms 오차는 설명 안 된다.메모리가 느리다 Flash에서 코드 실행한다. STM32는 Flash 속도가 느리다. 72MHz에서 2 wait state 필요하다. 명령어 하나 읽는데 3 클록 걸린다. 평균적으로. ISR 코드 본다. void TIM2_IRQHandler(void) { if (TIM2->SR & TIM_SR_UIF) { TIM2->SR = ~TIM_SR_UIF; // 여기서 뭔가 한다 GPIO_TogglePin(LED_GPIO_Port, LED_Pin); counter++; if (counter >= 100) { flag = 1; counter = 0; } } }Flash에서 실행한다. 레지스터 읽기, 쓰기, GPIO 토글, 변수 증가... 전부 Flash에서 명령어 가져온다. 측정해본다. ISR 진입부터 종료까지 8us 걸린다. 명령어 약 200개 정도. 근데 Flash wait state 때문에 실제로는 더 걸린다. 캐시 미스 나면 더 길어진다. I-Cache 켜져 있다. 근데 ISR은 자주 실행 안 되면 캐시에서 빠진다. 10ms마다 실행이면 캐시 유지 안 될 수도 있다. 다른 태스크들이 Flash 접근하면 캐시 경쟁한다. FreeRTOS 태스크 5개 돌아간다. 각자 코드 실행한다. 결과: ISR 실행 시간이 일정하지 않다. 8us~25us 사이로 흔들린다. Bus contention 여기서 끝이 아니다. DMA가 메모리 접근한다. ADC 데이터를 RAM에 쓴다. SPI 수신 데이터도 DMA로 RAM에 쓴다. DMA와 CPU가 버스를 공유한다. AHB 버스다. 동시 접근하면 누군가 기다린다. STM32 Reference Manual 본다. DMA는 2 클록 사이클마다 버스 사용 권한 있다. Round-robin 방식이다. 근데 DMA 채널 3개 동시에 돌면? 버스 점유율 높아진다. CPU는 기다린다. 측정해본다. Logic analyzer로 DMA 활동 본다. ADC DMA는 1ms마다 active. SPI DMA는 통신 있을 때만. 겹치는 순간이 있다. ADC + SPI 동시 전송. 이때 CPU가 Flash 접근 못 한다. 지연 발생한다. 계산해본다. DMA 버스 점유 최대 30%. CPU는 70% 시간만 버스 쓴다. 평균적으로. ISR 실행 중에 DMA active면? ISR 실행 시간 늘어난다. 8us가 12us 된다. 불규칙하다. DMA 타이밍 예측 안 된다. 외부 통신 타이밍 때문이다. 결과: ISR 실행 시간이 8us~25us로 흔들린다.Clock jitter 크리스털은 정확하다. 근데 완벽하지 않다. 크리스털 오실레이터는 물리적 공진이다. 온도 변하면 주파수 미세하게 변한다. ±20ppm 스펙이다. 측정해본다. 오실로스코프 persistence mode로 클록 본다. 수백 사이클 겹쳐서 본다. 엣지가 완벽하게 겹치지 않는다. ±100ps 정도 흔들린다. 이게 jitter다. 72MHz 클록에서 ±100ps jitter는 ±0.0072 클록 오차다. 무시할 수준이다. 근데 PLL 통과하면? PLL은 jitter를 증폭한다. 이론적으로 10배 정도. 측정 못 한다. 장비 없다. 회사에 있는 오실로스코프는 200MHz 대역폭이다. Jitter 측정 기능 없다. 추정한다. PLL jitter 1ns 정도? 72MHz에서 0.072 클록 오차다. 10ms 타이머면 720000 클록이다. 0.072 클록 오차는 0.1ns다. 무시할 수준이다. Clock jitter는 원인 아니다. 범인은 복합적 결론: 한 가지 원인 아니다. 타이머는 정확하다. 근데 ISR 실행이 지연된다. 원인 1: 다른 인터럽트. UART ISR 150us, 우선순위 높음. 원인 2: Flash wait state. I-Cache 미스 나면 더 느림. 원인 3: DMA bus contention. CPU가 기다림. 원인 4: ISR 실행 시간 불규칙. 8us~25us 흔들림. 전부 합치면 0.5ms 오차 설명된다. 최악의 시나리오:타이머 인터럽트 발생 UART ISR 실행 중 (150us 대기) ISR 진입 Flash cache miss (15us 추가) DMA bus 점유 중 (10us 추가) ISR 실행 느림 (25us)합계: 200us 지연. 2%다. 근데 이게 누적된다. 10ms마다 최악의 경우 만나면? 확률 낮지만 발생한다. 통계적으로 평균 50us 지연이면? 0.5% 오차다. 10ms → 10.05ms. 측정 결과랑 맞다. 해결 방법 방법 1: ISR 우선순위 높인다. HAL_NVIC_SetPriority(TIM2_IRQn, 1, 0); // 최우선근데 UART 통신 놓칠 수 있다. 리스크 있다. 방법 2: UART ISR 최적화한다. 데이터 파싱을 ISR에서 하지 말고 태스크에서 한다. ISR은 버퍼에만 쓴다. void USART1_IRQHandler(void) { rx_buffer[rx_idx++] = USART1->DR; // 끝 }150us → 5us로 줄어든다. 효과 크다. 방법 3: ISR 코드를 RAM에 둔다. __attribute__((section(".RamFunc"))) void TIM2_IRQHandler(void) { // ... }Linker script 수정 필요하다. RAM 공간 확인해야 한다. STM32F103은 RAM 20KB밖에 없다. 아깝다. 근데 효과 있다. Flash wait state 없어진다. ISR 실행 시간 8us → 3us. 방법 4: DMA 우선순위 조정한다. 필요 없는 DMA는 끈다. 우선순위 낮춘다. // ADC DMA 우선순위 낮춤 DMA1_Channel1->CCR &= ~DMA_CCR_PL; DMA1_Channel1->CCR |= DMA_PRIORITY_LOW;버스 경쟁 줄어든다. 방법 5: Hardware timer output 쓴다. ISR 안 쓴다. 타이머 출력 핀을 직접 토글한다. TIM2->CCMR1 = TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_0; // Toggle mode소프트웨어 지연 없다. 하드웨어가 직접 한다. 정확도 높다. 근데 제한적이다. 핀 출력만 된다. 복잡한 로직 안 된다. 적용 결과 UART ISR 최적화했다. 5us로 줄었다. 타이머 ISR을 RAM에 뒀다. 3us로 줄었다. RAM 256바이트 썼다. DMA 우선순위 조정했다. SPI DMA만 MEDIUM, 나머지 LOW. 측정한다. 오실로스코프로 500번 확인한다. 결과: 10.00ms ± 0.05ms. 오차 0.5%. 양산팀 테스트 통과했다. 통신 안정적이다. 근데 아직 완벽하지 않다. 최악의 경우 0.1ms 오차 있다. 99.9%는 괜찮다. Trade-off다. 더 정확하려면 RTOS tick 줄이고 인터럽트 더 줄여야 한다. 근데 기능 제약 생긴다. 적당히 타협했다. 교훈 타이밍 오차는 한 가지 원인 아니다. 복합적이다. 크리스털 정확해도 소프트웨어에서 지연 생긴다. 인터럽트, 메모리, 버스... 전부 영향 준다. 측정이 중요하다. 추측하지 말고 오실로스코프로 본다. Logic analyzer 쓴다. 최적화는 단계적으로 한다. 한 번에 여러 개 바꾸면 뭐가 효과 있는지 모른다. 문서 읽는다. Reference Manual, Datasheet. Flash wait state, DMA arbitration... 몰랐으면 못 찾았다. HW팀이랑 협업한다. 크리스털 스펙, PCB 레이아웃, 전원 노이즈... 하드웨어도 영향 준다. 펌웨어는 하드웨어랑 싸운다. 정확한 타이밍 필요하면 소프트웨어만으로 안 된다. 하드웨어 기능 활용해야 한다. 그래도 0.5ms 잡았다. 뿌듯하다.타이밍 오차는 범인이 여럿이다. 하나씩 잡아야 한다.
- 22 Dec, 2025
팀원: '이건 왜 이렇게 복잡하게 했어요?' 나: '...이유가 있었어'
팀원: '이건 왜 이렇게 복잡하게 했어요?' 나: '...이유가 있었어' 코드 리뷰 시간 회의실에 모였다. 신입 팀원이 내 코드를 보고 있다. "선배님, 이 부분요." 화면을 가리킨다. 인터럽트 핸들러 안에 이상한 딜레이가 있다. void EXTI_IRQHandler(void) { // Clear interrupt flag EXTI->PR |= EXTI_PR_PR0; // WTF delay? for(volatile int i=0; i<100; i++); // Read GPIO status = GPIOA->IDR; }"이건 왜 이렇게 했어요? 인터럽트 안에서 딜레이는..." 맞는 말이다. 인터럽트는 빨리 끝내야 한다. 교과서에도 나온다. "이유가 있었어." 대답하면서 기억을 더듬는다. 뭔가... 있었는데.6개월 전 그날 2월이었다. 양산 직전. 보드에서 간헐적으로 GPIO 읽기가 실패했다. 10번에 1번꼴. "왜지?" 오실로스코프를 꺼냈다. 프로브를 GPIO 핀에 댔다. 파형을 봤다. 인터럽트 발생 → GPIO 읽기까지 시간이 너무 짧다. 약 50ns. 하드웨어 팀에 물어봤다. "GPIO 셋팅 후에 읽을 수 있을 때까지 시간이 얼마나 걸리나요?" "음... 데이터시트에는 없는데. 실측해보니까 200ns 정도?" "..." 인터럽트가 발생하는 순간, GPIO는 아직 안정화 안 됐다. 그래서 딜레이를 넣었다. 100 루프면 약 250ns. 문제가 해결됐다. 양산 나갔다. 커밋 메시지에는 이렇게 썼다. "Add delay for GPIO stabilization (HW issue workaround)"그런데 지금 "HW 이슈 워크어라운드요?" 신입이 묻는다. "Rev2 보드부터는 그 문제 없지 않나요?" 맞다. 하드웨어가 개선됐다. 풀업 저항 값을 바꿨던가. "그러면 이 딜레이는 이제 필요 없는 거 아닌가요?" 맞는 말이다. "근데 Rev1 보드도 아직 쓰는 곳 있지 않아?" "그건... 재고 소진용으로 몇 개 있긴 한데..." 애매하다. 지워도 될까? 아니면 조건부 컴파일? #ifdef HW_REV1 for(volatile int i=0; i<100; i++); #endif이렇게? 근데 누가 매번 매크로 체크할까. "일단 놔두자." "네?" "건드려서 문제 생기는 것보다는..." 변명처럼 들린다. 나도 안다.코드 고고학 6개월은 짧은 시간이다. 그런데 기억이 흐릿하다. 커밋 로그를 뒤졌다. commit a3f5c82 Author: 김펌웨어 Date: Fri Feb 16 23:47:32 2024Add delay for GPIO stabilization (HW issue workaround)- Rev1 board GPIO settling time issue - Measured 200ns required - Added 250ns delay loop - See issue #247Issue #247을 찾았다. 닫혀 있다. 관련 댓글들:HW팀 김과장: "Rev2에서 수정 완료" 나: "확인했습니다" PM: "양산은 Rev2로 진행"그럼 이제 Rev1 코드는? 재고 확인했다. Rev1 보드 37개 남음. 렌탈 제품 AS용. 언젠가 다 쓸 거다. 근데 그때까지 이 코드를 유지? 워크어라운드의 무덤 내 코드에는 이런 게 많다. // TODO: Remove after HW fix // FIXME: Temporary solution // HACK: Don't ask why // NOTE: See email from 2023.08.14다 이유가 있었다.전원 시퀀스 타이밍 I2C 버스 플로팅 ADC 노이즈 타이머 클럭 지터하드웨어 문제를 소프트웨어로 땜질. 그게 펌웨어 개발자의 숙명이다. 시간이 지나면 하드웨어는 개선된다. 근데 코드는 남는다. "혹시 몰라서" 못 지운다. 쌓인다. 스파게티가 된다. 정리할 시간은 없고 신입이 물었다. "그럼 이번에 리팩토링하면서 정리할까요?" 하고 싶다. 정말. 근데 일정표를 봤다.다음 주: 신규 센서 드라이버 추가 2주 후: OTA 업데이트 기능 3주 후: 전력 최적화 4주 후: 필드 테스트리팩토링 일정은 없다. "나중에 하자." "언제요?" "...여유 생기면." 여유는 안 생긴다. 안다. PM이 회의실에 들어왔다. "다들 있네. 좋아. 급한 거 하나 있는데." 또 시작이다. 레거시의 탄생 6개월 전의 나는 급했다.양산 일정 D-7 GPIO 문제 해결 필요 하드웨어 수정 불가 (보드 이미 제작됨) 소프트웨어로 해결해야 함딜레이 100루프를 넣었다. 테스트했다. 됐다. 커밋했다. 푸시했다. 양산 나갔다. "나중에 정리하지 뭐." 그때는 생각했다. 근데 나중은 안 왔다. 지금의 나는 6개월 전의 나를 이해한다. 그리고 6개월 후의 나도 지금의 나를 이해할 거다. "왜 이걸 안 지웠지?" "...이유가 있었어." 주석이라도 결국 타협했다. 코드는 남기되, 주석을 자세히 달았다. void EXTI_IRQHandler(void) { EXTI->PR |= EXTI_PR_PR0; /** * [WORKAROUND] GPIO stabilization delay * * Context: Rev1 board HW issue (resolved in Rev2) * - GPIO settling time: ~200ns required * - Interrupt to GPIO read: ~50ns (too fast) * - This delay: ~250ns (100 loops at 72MHz) * * Status: Required for Rev1 boards (37 units remaining) * Reference: Issue #247, Email thread 2024.02.16 * * TODO: Remove when Rev1 inventory depleted * Estimated: Q2 2025 */ for(volatile int i=0; i<100; i++); status = GPIOA->IDR; }신입이 봤다. "오, 이러면 나중에 봐도 알겠네요." "응. 그게 최선이야." 주석이 코드보다 길다. 근데 이게 맞다. 미래의 나를 위해. 6개월 뒤에 또 누가 물어볼 거다. "이건 왜 이렇게 했어요?" 그때 나는 이 주석을 보여주면 된다. "이유가 여기 있어." 펌웨어 개발자의 기억 웹 개발은 다르다. 친구가 말했다. 프론트엔드 개발자. "우리는 레거시 코드 그냥 지워. 빌드 깨지면 고치면 되지." 부럽다. "배포 잘못해도 롤백하면 되잖아." 우리는 못 한다. 펌웨어는 하드웨어에 묶여 있다.보드 리비전 양산 시기 재고 현황 AS 정책다 고려해야 한다. 코드 한 줄을 지우려면:언제 들어간 코드인가 왜 들어갔는가 어느 하드웨어 버전에 해당하는가 그 하드웨어가 아직 쓰이는가 지워도 안전한가5단계 검증. 그래서 안 지운다. "혹시 몰라서." 다음 프로젝트에서는 회의가 끝났다. 신입이 물었다. "다음 프로젝트에서는 이런 거 어떻게 관리할까요?" 생각해봤다. "처음부터 조건부 컴파일로 가야겠지." #if (HW_REVISION == 1) workaround_rev1_gpio_delay(); #endif"문서화도 확실히 하고." 워크어라운드 전용 문서. 스프레드시트로 관리.날짜 HW 리비전 문제 설명 해결 방법 제거 예정일"그리고 정기적으로 리뷰하는 시간을 잡자." "진짜요?" "...잡을 수 있으면." 현실은 안다. 또 급하게 될 거다. 근데 시도는 해야지. 그래도 퇴근길. 오늘 딜레이 코드는 안 지웠다. 주석만 달았다. 찝찝하다. 근데 어쩌겠어. 이게 펌웨어 개발이다. 하드웨어의 역사를 코드로 짊어지는 일. 6개월 전의 나, 1년 전의 나, 2년 전의 나. 다 코드 안에 있다. "이건 왜 이렇게 했어요?" "이유가 있었어." 항상 있었다. 지금도, 앞으로도.주석이 코드보다 긴 날이 온다. 그게 성장이다.
- 21 Dec, 2025
라이브 제품 버그, 하지만 재현이 안 된다
금요일 오후 4시 고객사에서 전화가 왔다. "또 멈췄어요." 이번 주에 세 번째다. 제품 돌려봤다. 정상이다. 어제도 밤새 돌렸다. 문제없었다. 로그 파일 확인했다. 깨끗하다. "몇 시에 멈췄나요?" 물었다. "오전 11시쯤이요." 대답이 왔다. 우리 랩에선 11시에도 돌았다. 24시간 내내 돌았다.팀장이 "현장 가봐야 하나" 물었다. 부산이다. KTX 타고 3시간. 가서 뭘 본다는 건지 모르겠다. 제품 들고 가서 보면 또 정상일 거다. 항상 그랬다. 재현되지 않는 버그 펌웨어 개발자의 악몽이다. 버그가 있는데 재현이 안 된다. 고쳐야 하는데 뭘 고치나. 증상을 봐야 원인을 찾지. 증상이 안 나타나면 손을 못 댄다. 웹개발자들 부럽다. 로그 서버에 다 남잖아. 스택 트레이스 보면 어디서 터졌는지 나오잖아. 우리는? UART 로그가 전부다. 그것도 고객사에 케이블 연결해달라고 부탁해야 한다. "이거 연결하면 보증 안 돼요?" 묻는다. 할 말이 없다.문제는 환경이다. 확신한다. 우리 랩이랑 현장이랑 뭔가 다른 거다. 온도? 랩은 23도로 유지된다. 현장은? 모른다. 습도? 랩은 50%. 현장은? 역시 모른다. 전원? 랩은 SMPS로 깨끗하게 들어간다. 현장은 공장 전원이다. 다른 장비들이 같이 쓴다. 노이즈 있을 거다. 전자파? 랩은 조용하다. 현장은? WiFi 공유기, 블루투스 기기, 모터, 형광등... 온갖 게 다 있다. 로그를 달라고 했다 고객사에 요청했다. "UART 로그 좀 받아주세요. 멈출 때 어떤 메시지가 나오는지 봐야 해요." "어떻게 받나요?" 답이 왔다. 설명서 보냈다. USB-UART 케이블 연결하고, 시리얼 터미널 열고, 115200 baud로 설정하고... 사흘 뒤에 연락 왔다. "케이블이 없어요." 택배로 보냈다. 일주일 뒤에 또 연락 왔다. "연결했는데 아무것도 안 나와요." 사진 보내달라고 했다. RX랑 TX를 반대로 꽂았다. 다시 설명했다. "노란 선이 RX, 하얀 선이 TX..." 이틀 뒤에 스크린샷이 왔다. 로그가 찍혀 있었다. 감동이었다.그런데 로그를 봐도 모르겠다. 마지막 줄이 "WDT Reset" 이다. Watchdog Timer가 시스템을 리셋한 거다. 뭔가 무한루프를 돌았거나, 인터럽트가 계속 들어왔거나, 태스크가 블록됐거나. 셋 중 하나다. 문제는 왜 그랬느냐다. 원인을 모른다. 코드를 다시 봤다 밤 10시. 사무실에 나 혼자 남았다. 코드를 처음부터 다시 읽었다. WiFi 연결 루틴. 문제없어 보인다. MQTT 통신 부분. 타임아웃 처리 돼 있다. 센서 읽기. 에러 체크 있다. 그런데 한 군데가 신경 쓰인다. WiFi 재연결 로직이다. while(WiFi.status() != WL_CONNECTED) { WiFi.reconnect(); vTaskDelay(100); }타임아웃이 없다. 만약 WiFi가 계속 안 잡히면? 이 루프를 못 빠져나온다. Watchdog이 걸린다. "설마 WiFi가 안 잡힐 리가." 생각했었다. 고객사는 WiFi 환경이 좋다고 했다. 공유기 바로 옆이라고. 하지만 전자레인지를 돌리면? 2.4GHz 대역에 간섭이 생긴다. WiFi가 끊길 수 있다. 재연결 시도하는데 계속 실패하면? 루프를 못 빠져나온다. "이거다." 확신은 없지만 가능성은 있다. 패치를 만들었다 타임아웃을 추가했다. 10초 동안 연결 안 되면 루프를 빠져나온다. 에러 플래그를 세운다. 다음 사이클에서 다시 시도한다. uint32_t start = xTaskGetTickCount(); while(WiFi.status() != WL_CONNECTED) { if(xTaskGetTickCount() - start > pdMS_TO_TICKS(10000)) { connection_error = true; break; } WiFi.reconnect(); vTaskDelay(100); }로그도 추가했다. "WiFi reconnection timeout" 메시지가 뜨게. 빌드했다. 테스트했다. WiFi 공유기를 껐다. 10초 후에 타임아웃 로그가 떴다. 시스템은 리셋되지 않았다. 좋다. 펌웨어를 고객사에 보냈다. "이걸로 업데이트해보세요. 혹시 전자레인지나 다른 2.4GHz 기기가 근처에 있나요?" 답장이 왔다. "옆 테이블에 전자레인지 있어요. 점심시간에 씁니다." 빙고일 수도 있다. 11시쯤 멈춘다고 했다. 점심 준비 시작하는 시간이다. 일주일을 기다렸다 업데이트 후에 문제가 안 생겼다. 월요일, 화요일, 수요일... 일주일이 지났다. "아직 안 멈췄어요." 고객사에서 연락 왔다. 목소리가 밝았다. 확신은 여전히 없다. 정말 전자레인지 때문이었는지 모른다. 다른 이유였을 수도 있다. 타이밍 이슈였거나, 메모리 문제였거나. 하지만 결과적으로 해결됐다. 그걸로 됐다. 팀장이 "잘했어" 했다. 기분은 좋지 않았다. 운이 좋았을 뿐이다. 정확한 원인을 찾은 게 아니다. 가능성 있는 부분에 방어 코드를 넣은 거다. 현장 디버깅의 어려움 펌웨어는 환경에 민감하다. 같은 코드가 랩에선 되고 현장에선 안 된다. 온도. MCU 스펙에 -40도에서 85도까지 동작한다고 써 있다. 하지만 70도 넘어가면 이상한 일이 생긴다. 클럭이 불안정해진다. EEPROM 쓰기가 실패한다. 전원. 스펙에 3.3V ±10%라고 돼 있다. 2.97V도 괜찮다는 뜻이다. 하지만 2.97V에서 WiFi 켜면 전압이 순간적으로 더 떨어진다. Brown-out이 걸린다. 리셋된다. 타이밍. 인터럽트가 100us마다 온다. 처리 시간이 95us다. 여유가 5us다. 그런데 캐시 미스가 한 번 나면? 110us 걸린다. 다음 인터럽트를 놓친다. 전자파. CE 인증 받았다. EMC 테스트 통과했다. 그런데 현장에 가면 다르다. 대형 모터가 돈다. 용접기가 작동한다. I2C 통신이 가끔 깨진다. 이런 것들은 랩에서 재현이 안 된다. 환경을 똑같이 만들 수 없다. 방어적 코딩 결국 답은 방어적 코딩이다. 모든 경우의 수를 생각한다. 타임아웃을 건다. 무한루프를 만들지 않는다. 10초 안에 응답 안 오면? 포기하고 다음으로 넘어간다. 재시도 로직을 넣는다. 한 번 실패했다고 끝이 아니다. 3번, 5번 시도한다. 그래도 안 되면 에러 플래그를 세운다. Watchdog을 건다. 시스템이 멈추면 자동으로 리셋된다. 영구적으로 죽는 것보단 낫다. 전원을 체크한다. 전압이 떨어지면 WiFi를 끈다. 센서 읽기를 줄인다. 파워를 아낀다. 로그를 남긴다. 될 수 있는 한 많이. 나중에 분석한다. 완벽한 코드는 없다. 하지만 덜 깨지는 코드는 있다. 여전히 불안하다 패치가 나간 지 한 달이 지났다. 문제 보고가 없다. 하지만 확신은 없다. 진짜 원인을 찾은 건지 모른다. 그냥 증상을 숨긴 건 아닌지. 다른 조건에서 또 터질까 봐 걱정된다. 겨울에 온도가 내려가면? 습도가 올라가면? 양산 물량이 늘어나면 더 다양한 환경에 노출된다. 지금은 100대가 돌고 있다. 다음 달에 1000대가 나간다. 통계적으로 문제가 생길 확률이 높아진다. 밤에 전화 올까 봐 무섭다. "또 멈춰요." 그 말이. 교훈이랄 것도 없다 재현 안 되는 버그는 항상 있다. 피할 수 없다. 할 수 있는 건 최선을 다하는 거다. 로그를 많이 남긴다. 타임아웃을 건다. 에러 처리를 꼼꼼히 한다. 그리고 운을 믿는다. 엔지니어가 운을 믿는다는 게 웃기지만 사실이다. 현장은 랩이 아니다. 변수가 너무 많다. 모든 걸 컨트롤할 수 없다. 다음 주에 또 전화 올까. 아닐 수도 있다. 모른다. 주말이다. 쉬어야 하는데 머릿속에서 코드가 돈다. WiFi 루프, 인터럽트 타이밍, 전원 노이즈... 그만 생각하자. 맥주나 마셔야겠다.원인을 못 찾아도 일단 돌아가면 된다. 그게 현실이다.
- 16 Dec, 2025
nRF52 BLE 연결이 끊긴다? 연결 간격을 조정해봤나
nRF52 BLE 연결이 끊긴다? 연결 간격을 조정해봤나 아침부터 시작된 악몽 출근했다. 메일함에 빨간 글씨. "양산 일정 2주 남았습니다. BLE 안정성 이슈 해결 바랍니다."시제품 20대 중 3대가 랜덤하게 연결 끊김. 재현도 안 돼. 가장 싫은 타입의 버그다. nRF52832 쓰는 IoT 센서 프로젝트. 스마트폰 앱이랑 BLE로 통신한다. 센서 데이터 1초마다 보내는 간단한 구조인데, 왜 끊기는지 모르겠다. HW팀은 "펌웨어 문제 아니냐"고 한다. 앱팀은 "안드로이드는 문제없다"고 한다. 나만 혼자 샌드백이다. 스펙 문서는 거짓말을 안 한다 일단 nRF52 SDK 문서부터 다시 읽었다. BLE Connection Parameters. 읽어도 읽어도 어렵다.Connection Interval: 7.5ms ~ 4000ms Slave Latency: 0 ~ 499 Supervision Timeout: 100ms ~ 32000ms이게 뭔 소리인지 알아? 나도 처음엔 몰랐다. Connection Interval은 통신 주기다. 짧을수록 빠르게 데이터 주고받지만 전력 소모가 크다. Slave Latency는 센서가 몇 번까지 응답 안 해도 괜찮은지. 전력 아끼려고 쓴다. Supervision Timeout은 "이 시간 동안 응답 없으면 연결 끊는다"는 뜻. 우리 코드는 이랬다. #define MIN_CONN_INTERVAL MSEC_TO_UNITS(100, UNIT_1_25_MS) #define MAX_CONN_INTERVAL MSEC_TO_UNITS(200, UNIT_1_25_MS) #define SLAVE_LATENCY 0 #define CONN_SUP_TIMEOUT MSEC_TO_UNITS(4000, UNIT_10_MS)100~200ms 간격으로 통신. Latency 0. Timeout 4초. 보기엔 문제없어 보인다. 그런데 스펙 문서 깊숙이 읽다가 발견했다. "Supervision Timeout must be larger than (1 + Slave Latency) × Connection Interval × 2" 계산해보니 우리는 딱 맞춰놨다. 여유가 없다. 200ms × 2 = 400ms < 4000ms. 수식상 문제없지만, 실전은 다르다.오실로스코프는 진실을 말한다 이론만 보면 안 된다. 실제 신호를 봐야 한다. Logic Analyzer 연결했다. nRF52 DK에 프로브 꽂고, GPIO 토글로 타이밍 찍어뒀다. 연결 이벤트마다 핀 하이/로우. 원시적이지만 확실하다. 돌렸다. 30분 관찰. 정상일 때: 200ms 간격으로 깔끔하게 이벤트 발생. 끊기기 직전: 간격이 들쑥날쑥. 150ms, 180ms, 250ms... 왜 이러지? 로그 뒤져보니 원인 발견. 센서 읽는 I2C 통신이 가끔 지연된다. 온도센서 응답 느릴 때 있다. 그 사이 BLE 이벤트 놓친다. RTOS Priority도 문제였다. BLE 스택이랑 센서 태스크 우선순위가 같았다. 센서 읽다가 BLE 이벤트 늦게 처리하면, Central(스마트폰)은 "쟤 죽은 거 아니야?" 판단한다. Supervision Timeout 4초. 여유 있어 보이지만, 실전에선 불안하다. RF 간섭 있으면? 패킷 재전송 있으면? 누적되면 4초 금방이다. 삽질의 시작 첫 번째 시도: Timeout 늘리기. #define CONN_SUP_TIMEOUT MSEC_TO_UNITS(6000, UNIT_10_MS)4초 → 6초. 결과: 여전히 끊긴다. 근본 해결 아니다. 두 번째 시도: Connection Interval 늘리기. #define MIN_CONN_INTERVAL MSEC_TO_UNITS(200, UNIT_1_25_MS) #define MAX_CONN_INTERVAL MSEC_TO_UNITS(400, UNIT_1_25_MS)생각: 주기 늘리면 센서 읽을 시간 여유 생긴다. 결과: 연결은 안정됐는데, 데이터 전송 느려져서 앱팀이 불만. "왜 이렇게 느려졌냐." 세 번째 시도: Slave Latency 추가. #define SLAVE_LATENCY 3센서가 3번까지 응답 안 해도 괜찮다는 뜻. 바쁠 때 건너뛸 수 있다. 결과: 더 불안정해졌다. 왜? 로그 보니, Latency 쓰면 Central이 "언제 응답 올지 모른다"고 타이밍 예측 못 한다. 안드로이드 BLE 스택이 짜증 낸다. 이게 스펙엔 없는 현실이다.답은 의외로 간단했다 삽질 1주일. 동기한테 하소연했다. 걔는 웨어러블 기기 펌웨어 한다. nRF52 경험 많다. "야, Connection Interval 협상 제대로 했어?" 협상? 뭔 소리야. 알고 보니 BLE는 연결 후 Parameter를 재협상한다. Central이 제안 → Peripheral(우리 센서)이 수락/거절. 우리 코드는 초기값만 설정하고, 협상 핸들링을 안 했다. Central이 "30ms 간격으로 하자" 제안하면, 그냥 수락한다. 30ms? 우리 센서는 I2C 읽는 데만 50ms 걸리는데? 코드 수정했다. void on_ble_evt(ble_evt_t const * p_ble_evt) { switch (p_ble_evt->header.evt_id) { case BLE_GAP_EVT_CONN_PARAM_UPDATE_REQUEST: { ble_gap_conn_params_t const * p_req; p_req = &p_ble_evt->evt.gap_evt.params.conn_param_update_request; // 최소 Interval 체크 if (p_req->max_conn_interval < MIN_ACCEPTABLE_INTERVAL) { // 거절하고 우리 값 제안 ble_gap_conn_params_t our_params = { .min_conn_interval = MIN_CONN_INTERVAL, .max_conn_interval = MAX_CONN_INTERVAL, .slave_latency = 0, .conn_sup_timeout = CONN_SUP_TIMEOUT }; sd_ble_gap_conn_param_update(p_ble_evt->evt.gap_evt.conn_handle, &our_params); } else { // 수락 sd_ble_gap_conn_params_reply(p_ble_evt->evt.gap_evt.conn_handle, p_req, NULL); } break; } } }Central이 너무 짧은 간격 요청하면 거절하고, 우리가 감당 가능한 값 제안한다. 추가로 BLE 이벤트 핸들러 우선순위 올렸다. // FreeRTOS Task Priority #define BLE_TASK_PRIORITY (configMAX_PRIORITIES - 1) // 최우선 #define SENSOR_TASK_PRIORITY (tskIDLE_PRIORITY + 2) // 낮춤센서 읽다가 BLE 이벤트 놓치는 일 없도록. 테스트는 잔인하다 수정 후 24시간 Stress Test. 시제품 20대 전부 켜두고, 앱 연결 → 끊기 → 재연결 반복. 사무실 한쪽에 휴대폰 20개 놓고 스크립트 돌렸다. 밤새 돌렸다. 아침에 출근해서 확인. 로그 확인: 연결 끊김 0건. 재연결 시간: 평균 1.2초. 이전엔 3~5초 걸렸다. 데이터 누락: 없음. HW팀 불러서 보여줬다. "이제 됩니다." 걔네는 그냥 "아 그래?" 하더라. 1주일 고생한 건 관심 없다. 앱팀한테도 보고. "속도는 괜찮냐?" "이전이랑 똑같네요." 그래, 그럼 됐다. 스펙엔 없는 현실 BLE 스펙 문서는 1000페이지 넘는다. 다 읽어도 실전은 다르다.Connection Interval은 협상된다. 초기값은 제안일 뿐. Central(폰)마다 행동이 다르다. 삼성은 이렇고, 애플은 저렇고. RF 환경에 따라 신뢰성이 천차만별. 사무실이랑 공장이 다르다. Supervision Timeout은 여유 있게. "이론상 충분"은 실전에서 불충분.코드로만 해결 안 된다. 파형 봐야 하고, 로그 쌓아야 하고, 24시간 돌려야 안다. 양산 일정은 2주 남았다. 아직 해야 할 테스트 많다. -30도, +85도 온도 챔버 테스트. EMI 테스트. 인증 준비. 끝나면 한잔해야겠다.오늘도 스펙 문서와 현실 사이 어딘가에서 싸운다.
- 15 Dec, 2025
펌웨어 버전 v1.2.4 릴리스, 하지만 버전 번호만 올렸다
펌웨어 버전 v1.2.4 릴리스, 하지만 버전 번호만 올렸다 오전 10시, 릴리스 노트 작성 중 모니터 앞에 앉았다. 빈 텍스트 파일이 나를 보고 있다. Release Notes - Firmware v1.2.4타이핑을 멈췄다. 뭘 쓰지. 사실 바뀐 게 없다. 코드는 v1.2.3이랑 똑같다. 단 한 줄도 안 건드렸다. 그런데 버전은 올려야 한다. 이유는 간단하다. 하드웨어가 바뀌었으니까.HW팀에서 온 메일 어제 오후 3시. HW팀 김대리가 메일을 보냈다. "펌웨어팀님, PCB 리비전 올라갑니다. Rev.B → Rev.C. 레귤레이터 교체했어요. 출력 전압은 동일하고요. 펌웨어 수정 필요 없습니다." 좋은 소식이다. 펌웨어 수정 없다니. 그런데 메일 끝에 한 줄이 더 있었다. "버전은 올려주세요. 나중에 AS 들어오면 구분해야 해서요." 아. 그렇다. 똑같은 코드인데 다른 하드웨어에서 돌아가면, 버전을 구분해야 한다. 나중에 문제 생기면 '이 제품은 Rev.B 보드였나 Rev.C 보드였나' 추적해야 하니까. 레귤레이터 교체. TPS73733에서 RT9013로. 둘 다 3.3V 출력. 성능은 비슷하다. 가격이 100원 쌌다고 들었다. 대량 양산하면 몇천만원 차이. 우리 팀은 상관없다. 전압만 안정적이면 된다. 코드 수정 없다. 그런데 버전은 올려야 한다. 시맨틱 버저닝의 딜레마 우리 팀은 시맨틱 버저닝을 쓴다. v메이저.마이너.패치 형식이다.메이저: API 바뀌거나 큰 기능 변경 마이너: 기능 추가 패치: 버그 수정그럼 이번엔 뭘 올려야 하나. 기능 안 바뀌었으니 메이저 아니다. 기능 추가 없으니 마이너도 아니다. 버그도 안 고쳤으니 패치도 아니다. 그런데 올려야 한다. 결국 패치 번호를 올렸다. v1.2.3 → v1.2.4. 제일 낮은 단위니까. 시맨틱 버저닝 원칙에는 안 맞는다. 하지만 현실에서는 맞다. 하드웨어 세계에서는.릴리스 노트에 뭘 쓸까 다시 빈 텍스트 파일이다. Release Notes - Firmware v1.2.4정직하게 쓸까. "코드 변경 없음. 하드웨어 호환성 때문에 버전만 올림." 아니다. 그러면 나중에 문서 보는 사람이 헷갈린다. "왜 버전을 올렸지? 의미 없는 거 아니야?" 좀 더 전문적으로 쓸까. Changes: - Compatible with PCB Rev.C - Hardware regulator changed (TPS73733 → RT9013) - No firmware modification required이것도 이상하다. 'No firmware modification'이라고 쓰면, '그럼 왜 버전을 올렸어?' 또 물어본다. 결국 이렇게 썼다. Release Notes - Firmware v1.2.4Changes: - Support for PCB Rev.C (hardware revision update) - Regulator component change: TPS73733 → RT9013 - Voltage output remains 3.3V, firmware logic unchanged - For traceability in production and after-serviceNote: This version is functionally identical to v1.2.3 but must be distinguished for hardware compatibility tracking.길다. 설명이 너무 많다. 하지만 나중에 내가 다시 봤을 때 이해할 수 있어야 한다. 3개월 뒤에 AS 문의 들어오면, "v1.2.4가 뭐였더라?" 찾아볼 거다. 그때 이 문서를 본다. 그때 내가 이해할 수 있으면 된다. Git 커밋 메시지 릴리스 노트 썼으니 이제 Git에 태그를 달아야 한다. git tag -a v1.2.4 -m "Release v1.2.4" git push origin v1.2.4잠깐. 커밋 메시지는 뭐라고 쓰지. 마지막 커밋은 일주일 전이다. "Fix UART timeout bug in low power mode". v1.2.3 릴리스 후 버그 하나 고쳤던 거다. 그 이후로 커밋이 없다. 그럼 v1.2.4 태그는 그 커밋에 다는 건가. 그런데 그 커밋은 v1.2.3용 버그 픽스였는데. 고민했다. 그냥 빈 커밋을 하나 만들까. git commit --allow-empty -m "Bump version to v1.2.4 for PCB Rev.C compatibility"--allow-empty. 파일 변경 없이 커밋만 만드는 옵션이다. 이런 경우에 쓴다. 커밋 메시지도 고민이다. 짧게 쓰면 정보가 부족하고, 길게 쓰면 나중에 로그 보기 불편하다. 결국 이렇게 썼다. Bump version to v1.2.4 for PCB Rev.C compatibility- Hardware: PCB Rev.B → Rev.C - Regulator: TPS73733 → RT9013 - Firmware code: no changes from v1.2.3 - Purpose: version tracking for production/AS커밋 본문에 상세하게. 첫 줄은 짧게. Push 했다. 이제 v1.2.4 태그가 원격 저장소에 올라갔다.양산팀에 전달 버전 올렸으니 양산팀한테 알려야 한다. 메일을 썼다. "양산팀님, 펌웨어 v1.2.4 릴리스되었습니다. PCB Rev.C용입니다. 바이너리 파일 첨부합니다." 바이너리 파일은 v1.2.3이랑 똑같다. SHA256 해시값도 같다. 그냥 파일 이름만 firmware_v1.2.4.bin으로 바꿨다. 양산팀에서 답장 왔다. "확인했습니다. 그런데 v1.2.3이랑 뭐가 다른가요?" 예상한 질문이다. "코드는 동일합니다. 하드웨어 버전 구분용입니다. Rev.C 보드에는 v1.2.4를 넣어주세요. 나중에 추적 필요할 수 있어서요." "알겠습니다." 간단한 대화. 하지만 이게 중요하다. 지금은 별거 아닌 것 같지만, 6개월 뒤 고객사에서 문제 리포트 들어오면 다르다. "제품 시리얼 12345에서 전원 이슈가 있습니다." 그럼 우리는 추적한다. 시리얼 번호로 생산 로그 찾는다. "아, 이거 Rev.C 보드네요." 펌웨어 버전 확인한다. "v1.2.4 맞네요." 그다음 HW팀이랑 회의한다. "Rev.C 보드 RT9013 레귤레이터 쓴 거 맞죠? 혹시 그쪽 문제 아닐까요?" 이런 식으로 범위를 좁혀간다. 만약 버전 구분 안 했으면, "이게 어떤 보드에 들어간 건지" 추적이 어렵다. 생산 날짜로 추정해야 한다. "8월 1일 이후면 아마 Rev.C일 거예요." 이렇게 '아마'로 시작하면 디버깅이 오래 걸린다. 문서 업데이트 릴리스 노트 썼고, Git 태그 달았고, 양산팀한테 전달했다. 이제 문서를 업데이트해야 한다. 우리 팀 위키에 "Firmware Version History" 페이지가 있다. 표 형식이다.Version Date PCB Rev Changesv1.2.3 2024-11-15 Rev.B UART timeout fixv1.2.4 2024-11-22 Rev.C Hardware compatibility간단하다. 나중에 누가 봐도 한눈에 들어온다. "아, v1.2.4부터 Rev.C구나." 이게 전부다. 복잡한 설명 필요 없다. 표 한 줄이면 된다. 근데 이걸 안 해두면, 나중에 누가 v1.2.4를 보고 "이게 뭐지?" 헤맨다. 코드 diff 보면 변경 없으니 더 혼란스럽다. 문서가 중요한 이유다. 사실 찜찜하다 솔직히 찜찜하다. 코드는 똑같은데 버전만 올린다. 시맨틱 버저닝 원칙에도 안 맞다. 이게 맞는 방법인가. 다른 방법도 생각해봤다.빌드 번호를 쓴다. v1.2.3+build.001, v1.2.3+build.002 이런 식으로. 하지만 우리 빌드 시스템은 빌드 번호를 지원 안 한다.메타데이터를 쓴다. v1.2.3+revC 이런 식으로. 근데 이것도 시맨틱 버저닝 표준이고, 우리 툴체인이 '+' 기호를 제대로 처리 못 한다.하드웨어 버전을 별도로 관리한다. 펌웨어 버전은 v1.2.3 고정, 하드웨어 버전 정보를 펌웨어 안에 define으로 넣는다. #define HW_REV_C. 이게 제일 깔끔한데, 이미 양산 중인 제품이라 지금 구조 바꾸기 어렵다.결국 패치 버전을 올리는 게 제일 현실적이다. 찝찝하지만, 이게 펌웨어 세계다. 웹 개발자들은 이런 거 안 겪는다. 프론트엔드 코드는 하드웨어랑 상관없다. 어떤 폰에서든 똑같은 코드가 돌아간다. 우리는 다르다. 똑같은 코드라도 보드가 다르면 추적해야 한다. 팀장님 리뷰 오후 2시. 팀장님이 내 자리로 왔다. "v1.2.4 릴리스 봤어요. 코드 변경 없는데 버전 올린 거?" "네. Rev.C 보드 대응입니다. HW팀에서 요청했어요." "흠. 릴리스 노트에 설명 잘 써놨네요. 나중에 추적 가능하겠어요." "네. 표도 업데이트했습니다." "좋아요. 근데 이런 거 너무 자주 하면 버전 번호 인플레이션 생겨요. 가능하면 묶어서 올리는 게 나을 텐데." 팀장님 말이 맞다. 하드웨어 리비전 올라갈 때마다 펌웨어 버전 올리면, 버전 번호가 너무 빨리 증가한다. v1.2.10, v1.2.15... 이렇게 되면 버전만 봐서는 큰 변화인지 작은 변화인지 구분이 안 된다. "다음부턴 HW팀이랑 미리 조율해볼게요. 리비전 올라가는 거 미리 알려달라고 하고, 펌웨어 업데이트랑 같이 묶을 수 있으면 묶고." "그래요. 그게 나을 거예요." 팀장님 돌아갔다. 사실 이번에도 묶으려고 했다. 다음 주에 BLE 통신 개선 작업이 있어서, 그거랑 같이 v1.3.0으로 올리려고 했다. 근데 HW팀이 급했다. 부품 수급 문제로 레귤레이터를 당장 교체해야 했다. 양산 라인이 멈출 뻔했다. 어쩔 수 없이 버전만 올렸다. 현실은 계획대로 안 된다. 6개월 뒤를 생각한다 지금은 귀찮다. 코드 안 바뀌었는데 버전 올리고, 문서 쓰고, 메일 보내고. 근데 6개월 뒤엔 고마울 거다. 문제 생겼을 때, 정리된 문서가 있으면 30분이면 원인 찾는다. 없으면 3일 걸린다. "이 펌웨어가 어떤 보드용이었지? 코드 diff 봐야 하나? Git 로그 뒤져야 하나? 양산팀한테 물어봐야 하나?" 이런 거 안 하려고 지금 문서 쓰는 거다. 펌웨어 개발은 코딩만 하는 게 아니다. 추적 가능성을 만드는 것도 일이다. 특히 양산 제품은 더 그렇다. 코드는 잘 돌아가는데, 3년 뒤 AS 들어왔을 때 뭐가 뭔지 모르면 소용없다. 그래서 버전 관리가 중요하다. 귀찮지만 해야 한다. 다른 팀은 어떻게 하나 점심시간. 옆 부서 이재훈이랑 밥 먹으면서 물어봤다. "너네도 이런 거 있어? 코드 안 바뀌었는데 버전 올리는 거?" "당연하지. 우리도 하드웨어 바뀔 때마다 올려. 근데 우리는 빌드 메타데이터로 관리해." "어떻게?" "버전은 그대로 두고, 빌드 ID를 다르게 줘. 펌웨어 안에 HW_VERSION define 있어서 거기다 Rev.C 같은 거 박아." "그럼 버전 번호는 안 올라가?" "응. v2.1.0에서 계속 쓰고, 내부 빌드 정보로 구분하지." 부럽다. 그게 더 깔끔하다. "너네는 왜 그렇게 했어?" "처음 설계할 때부터 그렇게 했어. 하드웨어 버전이랑 펌웨어 버전을 분리하는 게 맞다고 판단했대. 선배들이." 우리 팀은 그런 거 없이 시작했다. 처음엔 하드웨어 버전이 하나뿐이었으니까. Rev.A만 있었다. 그러다 Rev.B 나오고, Rev.C 나오고, 이제 와서 구조 바꾸기 애매하다. 기술 부채다. "다음 제품에선 그렇게 해볼까 생각 중이야. 지금 제품은 이미 늦었고." "ㅇㅇ 다음에는 처음부터 구조 잡고 시작해." 맞는 말이다. 매뉴얼도 업데이트 오후 4시. 고객사 매뉴얼도 업데이트해야 한다는 걸 깨달았다. 우리 제품은 B2B다. 고객사에서 우리 모듈을 사서 자기네 제품에 넣는다. 그래서 기술 매뉴얼이 있다. 매뉴얼 48페이지. "Firmware Version Information" 섹션이 있다. Supported Firmware Versions: - v1.2.3: Compatible with PCB Rev.B - v1.2.4: Compatible with PCB Rev.C이것만 추가하면 된다. 근데 매뉴얼은 PDF다. 워드 파일 열고, 수정하고, PDF 다시 뽑고, 서버에 업로드하고. 30분 걸렸다. 고객사한테도 메일 보냈다. "기술 매뉴얼 업데이트되었습니다. v1.2.4 정보 추가되었습니다." 답장 없다. 읽기만 했다. 그러면 된 거다. 결국 누구를 위한 버전인가 저녁 7시. 퇴근 준비하면서 생각했다. v1.2.4. 코드는 안 바뀌었지만 버전은 올라갔다. 이게 누구를 위한 건가. 개발자를 위한 거다. 내년에 나를, 3년 뒤 이 코드를 인계받을 후배를 위한 거다. 사용자는 모른다. 제품 쓸 때 펌웨어 버전 같은 거 안 본다. v1.2.3이든 v1.2.4든 똑같이 쓴다. 고객사도 별로 신경 안 쓴다. 잘 돌아가면 된다. AS팀이 본다. 문제 생기면. 그때 버전 추적한다. 결국 내부 사람들 위한 거다. 엔지니어들 위한 버전 번호다. 그래서 더 신경 써야 한다. 코드만큼 중요한 게 버전 관리다. 나중에 내가 이 코드를 다시 볼 때, "아, v1.2.4였구나" 하고 바로 이해할 수 있어야 한다. 그게 좋은 버전 관리다. 내일은 내일 출근하면 v1.2.4 바이너리를 최종 테스트한다. Rev.C 보드 10개에 올려보고, 기능 테스트 한 번 더 돌린다. 코드는 똑같지만, 하드웨어가 다르니 혹시 모른다. 레귤레이터가 달라지면서 전압 라이즈 타임이 미세하게 달라질 수 있다. 그러면 MCU 부팅 타이밍이 바뀔 수 있다. 가능성 낮지만, 확인해야 한다. 펌웨어 엔지니어는 의심쟁이여야 한다. "설마 이것도 영향 있겠어?" 하는 것들이 실제로 영향 있다. 테스트 통과하면, 양산 승인. v1.2.4 정식 릴리스. 그리고 다음 작업으로 넘어간다. BLE 통신 개선. 그건 v1.3.0이 될 거다.버전 번호는 숫자가 아니라 역사다. 코드의 타임라인이다. 귀찮아도 제대로 써야 한다.