- 14 Dec, 2025
센서 캘리브레이션, 왜 이렇게 시간이 걸리나
센서 캘리브레이션, 왜 이렇게 시간이 걸리나 시작은 단순했다 프로젝트 킥오프 때 PM이 말했다. "온도 센서 하나 달고, 가속도 센서 하나 달면 되죠?" 그때 난 고개를 끄덕였다. 센서 데이터시트 보고 I2C 연결하면 끝이라고 생각했다. 2주면 충분하다고 보고했다. 지금 6주째다. 회의실에서 시연할 때였다. 온도를 보여주는데 25.3도가 나왔다. 실제 온도계는 23.1도. "2도 차이는 오차 범위 아닌가요?" PM이 물었다. 나는 대답했다. "데이터시트상 오차는 ±0.5도입니다." 그날부터 캘리브레이션 지옥이 시작됐다.데이터시트는 거짓말을 하지 않는다 데이터시트를 다시 읽었다. "Typical accuracy ±0.5°C"라고 적혀 있다. Typical이란 단어가 눈에 들어왔다. 각주를 봤다. "at 25°C, in ideal conditions". 이상적 조건. 우리 제품은 영하 10도에서 영상 50도까지 써야 한다. 보드 위에는 MCU가 있고, 전원 IC가 있고, 다 발열한다. 센서 옆에 파워 LED가 있다. 켜지면 0.3도가 올라간다. 이상적이지 않다. HW팀에 물었다. "센서 위치 옮길 수 있나요?" 답은 "레이아웃 다시 그려야 하는데요"였다. 양산 2달 남았다. 레이아웃 변경은 불가능하다. 결국 소프트웨어로 해결해야 한다. 캘리브레이션이다. 첫 번째 시도: 오프셋 보정 간단하게 생각했다. 실제값 - 측정값 = 오프셋. 이걸 빼주면 되지 않나. 항온 챔버를 빌렸다. 25도로 맞췄다. 센서는 27.2도를 가리킨다. 오프셋 -2.2도. 코드에 넣었다. float get_calibrated_temp(void) { float raw = read_temp_sensor(); return raw - 2.2; }다시 측정했다. 25.0도. 완벽하다. PM에게 보여줬다. "이제 됩니다." 다음 날 HW팀이 왔다. "0도에서 측정해봤는데 1.5도 차이 납니다." 챔버를 0도로 맞췄다. 센서는 1.2도. 보정하면 -1.0도. 실제는 0도. 1도 차이다. 오프셋이 온도마다 다르다.두 번째 시도: 선형 보정 고등학교 수학이 떠올랐다. 일차 함수. y = ax + b. 두 점을 측정했다. (0도, 1.2도), (25도, 27.2도). 기울기를 구했다. a = (27.2 - 1.2) / (25 - 0) = 1.04. 센서가 1.04배씩 틀린다. float get_calibrated_temp(void) { float raw = read_temp_sensor(); return (raw - 1.2) / 1.04; }0도에서 테스트. 0.0도. 25도에서 테스트. 25.1도. 완벽하다. "이제 정말 됩니다." PM에게 보고했다. 다음 주 HW팀이 또 왔다. "50도에서 측정해봤는데 2도 차이 납니다." 챔버를 50도로 맞췄다. 센서는 52.8도. 보정하면 49.6도. 실제는 50도. 0.4도 차이. 선형이 아니다. 세 번째 시도: 다항식 보정 이차 함수를 써야 한다. y = ax² + bx + c. 세 점이 필요하다. 0도, 25도, 50도. 각각 측정했다. 연립방정식을 풀었다. 대학교 선형대수 교재를 꺼냈다. 행렬 계산기를 돌렸다. 계수가 나왔다. a = 0.00012, b = 1.038, c = -1.15. float get_calibrated_temp(void) { float raw = read_temp_sensor(); float corrected = 0.00012 * raw * raw + 1.038 * raw - 1.15; return corrected; }전 구간을 측정했다. 오차가 ±0.3도 이내다. 데이터시트보다 좋다. 성공했다고 생각했다.문제는 개체차 양산 시제품이 왔다. 10개. 모두 측정했다. 1번: 0.2도 오차 2번: 0.8도 오차 3번: 1.3도 오차 ... 10번: 0.5도 오차 센서마다 특성이 다르다. 당연하다. 반도체 공정은 완벽하지 않다. 같은 웨이퍼에서 나와도 다르다. PM이 물었다. "양산할 때마다 캘리브레이션 하면 되지 않나요?" 양산 계획은 월 1만 개다. 하나당 3개 온도에서 10분씩 측정해야 한다. 30분 × 10,000개 = 5,000시간. 불가능하다. "자동화하면요?" PM이 또 물었다. 챔버가 100만원이다. 10대 사면 1억. 공간도 필요하다. 인건비도 필요하다. 제품 원가가 3만원인데. 불가능하다. 네 번째 시도: 원포인트 캘리브레이션 다른 방법을 생각했다. 한 점만 측정하면 어떨까. 25도에서만 측정한다. 그 오차를 저장한다. 부팅할 때 불러온다. 전체 구간에 적용한다. "25도는 어떻게 만드나요?" HW팀이 물었다. 에어컨 튼 사무실. 온도계 옆에 보드를 놓는다. 30분 기다린다. 25도 ±1도 정도는 된다. 정밀하진 않지만 대량 생산에선 현실적이다. void factory_calibration(void) { float raw = read_temp_sensor(); float offset = 25.0 - raw; // 25도 기준 write_flash(OFFSET_ADDR, &offset, sizeof(float)); }float get_calibrated_temp(void) { float offset; read_flash(OFFSET_ADDR, &offset, sizeof(float)); float raw = read_temp_sensor(); return raw + offset; }10개 시제품에 적용했다. 전 구간 오차가 ±1도 이내다. 데이터시트보다 나쁘지만 스펙은 만족한다. PM이 승인했다. 생산팀에 전달했다. 2주 후 생산팀에서 연락 왔다. "25도를 어떻게 확인하나요?" 가속도계는 더 복잡하다 온도 센서는 그나마 나았다. 가속도계는 차원이 다르다. 3축이다. X, Y, Z. 각각 오프셋이 있다. 각각 감도가 다르다. 축 간 크로스토크도 있다. 데이터시트를 봤다. "Factory calibrated". 공장에서 보정했다고 한다. 믿었다. 보드를 평평하게 놓았다. Z축은 1g를 가리켜야 한다. 중력이니까. 측정값: 0.97g. 보드를 세웠다. X축이 1g를 가리켜야 한다. 측정값: 1.03g. Factory calibrated라더니. 6면 캘리브레이션 방법을 찾았다. 보드를 6방향으로 놓는다. +X, -X, +Y, -Y, +Z, -Z. 각각 ±1g가 나와야 한다. 실제로 측정했다.+X: 1.03g -X: -0.98g +Y: 1.01g -Y: -0.99g +Z: 0.97g -Z: -1.02g평균을 구했다. 오프셋을 구했다. 스케일을 구했다. typedef struct { float offset_x, offset_y, offset_z; float scale_x, scale_y, scale_z; } accel_calib_t;void six_point_calibration(accel_calib_t *calib) { // 각 면에서 측정 // 오프셋 = (max + min) / 2 // 스케일 = 2.0 / (max - min) // 복잡한 계산... }한 개 보정하는데 10분 걸렸다. 각 면에서 안정화를 기다려야 한다. 정확히 수평을 맞춰야 한다. 양산은 불가능하다. 결국 타협 PM과 회의했다. "가속도계 정확도를 낮추면 안 될까요?" 스펙 문서를 다시 봤다. "가속도 정확도 ±5%". 현재 오차는 ±3%. 스펙은 만족한다. "그럼 캘리브레이션 안 해도 되는 거 아닌가요?" PM이 물었다. "지금은 그렇습니다. 근데 센서 로트 바뀌면 또 모릅니다." "그럼요?" "그때 다시 보겠습니다." 엔지니어링의 현실이다. 완벽한 해답은 없다. 시간과 비용과 정확도의 균형이다. 배운 것들 6주간 배운 걸 정리했다.데이터시트의 'Typical'은 '최선의 경우'다 센서마다 특성이 다르다 온도는 비선형이다 개체차는 피할 수 없다 양산성을 고려해야 한다 완벽한 캘리브레이션은 불가능하다 스펙을 만족하면 충분하다책상 서랍에 노트를 넣었다. 온도 보정 공식, 측정 데이터, 시행착오가 빼곡하다. 다음 프로젝트에서 또 쓸 것이다. 센서는 항상 틀린다. 그걸 보정하는 게 내 일이다. 완벽할 순 없다. 하지만 쓸 만하게는 만들 수 있다. 그게 펌웨어 엔지니어가 하는 일이다. 다음 프로젝트 오늘 새 프로젝트 킥오프가 있었다. PM이 말했다. "이번엔 압력 센서도 들어갑니다." 나는 데이터시트를 펼쳤다. "Typical accuracy ±2%"라고 적혀 있다. 각주를 봤다. "at 25°C, 1atm, in ideal conditions". 한숨이 나왔다. "캘리브레이션 일정 좀 넉넉히 잡아주세요." 내가 말했다. "얼마나요?" PM이 물었다. "4주요." "2주면 안 될까요?" 또 시작이다.센서는 거짓말쟁이다. 근데 우린 그걸 믿어야 한다. 보정하고, 측정하고, 또 보정한다. 결국 경험만 쌓인다.
- 13 Dec, 2025
목요일 밤 11시, 명일 릴리스를 위해 코드 리뷰 중
목요일 밤 11시, 명일 릴리스를 위해 코드 리뷰 중 11시 3분. 팀원 코드 열었다. 내일 오전 10시 릴리스. 지금 발견하면 안 되는데.처음엔 괜찮아 보였다 커밋 메시지: "타이머 인터럽트 우선순위 조정 완료" 좋아. 이 부분 문제였는데. 코드 열어봤다. 한 200줄 정도 수정됐다. 처음 50줄은 깔끔했다. 우선순위 설정 부분, 문제없다. 다음 100줄도 괜찮았다. 그런데. void TIMER_IRQHandler(void) { GPIO_ToggleBit(LED_PORT, LED_PIN); // 다른 처리들... delay_us(100); // <- 이거 }인터럽트 핸들러 안에서 delay를 쓴다. 11시 15분. 슬랙 열었다. "민수씨, 혹시 아직 안 주무셨나요?" 읽음 표시 떴다. 3초 후 답장. "네 형 아직이요" 다행이다. 전화했다.Low 레벨에서 보면 "민수씨, 67번 줄 delay_us 있는데요" "아 그거요? LED 깜빡임 보려고 넣었어요" "네... 근데 여기 인터럽트 핸들러잖아요" 침묵. "인터럽트 타이밍이 문제가 될 수 있을 것 같은데요" "아..." 설명했다. 100us 딜레이 중에 다른 인터럽트 못 받는다. UART 인터럽트 우선순위가 같으면 데이터 놓친다. 시리얼 통신 깨진다. "죄송합니다 형. 제가 생각을 못 했네요" "괜찮아요. 지금 발견해서 다행이에요" 다행이긴 뭐가 다행이야. 내일 아침인데. 11시 28분. 수정 방법 논의했다.딜레이 빼기 → 가장 간단 플래그로 메인에서 처리 → 더 안전 타이머 우선순위 더 낮추기 → 근본 해결 아님2번으로 결론. 민수가 지금 수정한대. "30분이면 될 것 같아요" 좋아. 12시까지 커밋해달라고 했다.테스트는 내가 해야 한다 민수 코드 수정하는 동안. 나는 테스트 시나리오 짰다. 확인해야 할 것:LED 정상 동작 UART 통신 안정성 인터럽트 중첩 상황 엣지 케이스보드 3개 꺼냈다. 오실로스코프 채널 4개 다 쓸 거다. 11시 50분. 커밋 알림 왔다. "수정 완료했습니다" 코드 확인했다. 깔끔하다. volatile uint8_t led_toggle_flag = 0;void TIMER_IRQHandler(void) { led_toggle_flag = 1; // 다른 처리들... }// 메인 루프에서 if (led_toggle_flag) { GPIO_ToggleBit(LED_PORT, LED_PIN); led_toggle_flag = 0; }좋아. 이제 올려보자. 12시 5분. 빌드 시작. 경고 2개. 무시 가능한 거다. 플래시 굽기 시작. "Programming... OK" 전원 켰다. LED 깜빡인다. 정상. 시리얼 터미널 열었다. 데이터 들어온다. 정상. 이제 스트레스 테스트. 새벽까지 테스트 12시 30분. 본격 시작. 테스트 1: UART 연속 수신 → 10만 바이트 전송했다. → 에러 0개. 통과. 테스트 2: 타이머 + UART 동시 → 양쪽 다 인터럽트 발생시켰다. → 파형 깨끗하다. 통과. 테스트 3: 최악의 상황 → 모든 인터럽트 동시 발생. → SPI, I2C, UART, 타이머 전부. → 오실로스코프가 크리스마스트리 같다. → 근데 데이터는 정상. 통과. 1시 15분. 엣지 케이스 몇 개 더 돌렸다. 전부 통과. 슬랙에 썼다. "테스트 완료했습니다. 문제없어요" 민수: "감사합니다 형ㅠㅠ" "고생하셨어요. 내일 봐요" 1시 30분. 문서 작업 시작했다. 수정 사항 정리.변경된 파일 목록 테스트 결과 알려진 이슈 (없음)릴리스 노트 업데이트. 버전 넘버 확인. v2.4.3 2시. 팀장님한테 보고 메일 보냈다. "내일 오전 릴리스 준비 완료" 답장 바로 왔다. "고생 많으셨습니다" 팀장님도 안 주무신 거다. 다들 긴장한다. 양산 첫 펌웨어라. 릴리스 전날은 다 이렇다 2시 20분. 정리했다. 보드 3개 전원 끄고. 프로브 정리하고. 테스트 로그 폴더에 저장하고. 가방 쌌다. 내일은... 아니 오늘은 9시 출근. 7시간 후다. 불 끄기 전에. 코드 한 번 더 봤다. 괜찮다. 이번엔 문제없을 거다. 사무실 나왔다. 새벽 공기가 차갑다. 걸어가면서 생각했다. 만약 내일... 아니 오늘 아침에 발견했으면? 릴리스 못 했다. 일정 미뤄졌다. 민수 혼났을 거다. 코드 리뷰를 밤에 한 게. 귀찮았지만. 잘한 거다. 잠들기 전 2시 40분. 집 도착. 씻지도 않고 침대에 누웠다. 내일... 오늘 할 일.9시 출근 10시 릴리스 오후 모니터링 이슈 없으면 퇴근이슈 없기를. 눈 감았다. 근데 잠이 안 온다. 혹시 놓친 게 있나? 타이머 설정 다시 확인했나? 인터럽트 우선순위 표 맞나? 일어나서 노트북 켰다. 코드 다시 봤다. 괜찮다. 진짜 괜찮다. 3시 10분. 다시 누웠다.내일 릴리스 잘 되면, 민수한테 점심 사줘야지.
- 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는 한 번 제대로 만들면 평생 쓴다. 지금은 안심하고 업데이트 누른다. 전원 나가도 된다.
- 11 Dec, 2025
인터럽트 우선순위 때문에 3시간을 고민했다
인터럽트 우선순위 때문에 3시간을 고민했다 아침엔 몰랐다 출근했다. 어제 올린 코드 테스트 결과 확인했다. 타이머 값이 이상했다. 100ms마다 찍히는 로그가 98ms, 102ms, 97ms... 들쭉날쭉이었다. '뭐지?' 처음엔 타이머 설정 문제인 줄 알았다. 클럭 소스 확인했다. LSE 32.768kHz. 프리스케일러 확인했다. 맞다. ARR 값도 맞다. 그럼 왜? 커피 마시고 다시 봤다. 여전히 이상했다.인터럽트가 두 개였다 우리 제품은 인터럽트가 두 개 돌아간다.UART 인터럽트 - 센서 데이터 받기 (1ms마다) 타이머 인터럽트 - 메인 로직 (100ms마다)UART는 우선순위 1로 설정했다. 타이머는 2. 숫자 낮을수록 높은 우선순위다. 센서 데이터 놓치면 안 되니까 UART를 높게 뒀다. 문제는 여기서 시작이었다. UART 인터럽트가 타이머 인터럽트를 블락한다. 우선순위가 높으니까. 타이머 인터럽트가 발생해도 UART 처리 끝날 때까지 기다린다. 그래서 타이밍이 밀린 거였다. 오실로스코프 꺼냈다 확인이 필요했다. 프로브 두 개 연결했다.CH1: UART RX 핀 CH2: 디버그용 GPIO (타이머 인터럽트 들어갈 때 HIGH)트리거 걸고 돌렸다. 화면에 보였다. UART 신호 들어올 때 GPIO가 안 올라간다. UART 끝나고 나서야 올라간다. 지연이 0.5ms에서 2ms까지 다양했다. '이거구나.' 센서에서 1ms마다 데이터가 온다. UART 인터럽트 처리에 평균 0.3ms 걸린다. 그런데 타이머 인터럽트 시점이랑 겹치면? 타이머는 기다린다. UART가 끝날 때까지. 100ms 주기인데 매번 랜덤하게 밀리니까 로그가 들쭉날쭉한 거였다.문제는 ADC였다 우리 메인 로직은 타이머 인터럽트에서 ADC 읽는다. 배터리 전압 체크한다. 100ms마다 읽어서 평균내서 상태 판단한다. 그런데 ADC 읽는 타이밍이 밀리면? ADC는 타이밍 크리티컬하다. 샘플링 시점이 중요하다. 특히 우리 회로는 RC 필터 달려있어서 settling time이 필요하다. 정확히 100ms 간격으로 읽어야 한다. 근데 98ms, 102ms 이러니까 값이 튄다. 배터리 90%인데 85%로 보였다가 92%로 보였다가. 양산 들어가면 고객 불만 들어온다. '배터리 표시가 왔다갔다해요.' 리콜이다. 3시간 동안 고민했다 점심 먹고 돌아와서 계속 생각했다. 해결 방법은 세 가지였다. 1. 우선순위를 바꾼다 타이머를 1로, UART를 2로. 그러면 타이머가 먼저 처리된다. ADC 타이밍 문제 해결. 근데 UART 데이터 놓친다. 센서 통신 프로토콜이 타임아웃 100ms다. 데이터 놓치면 센서가 에러 뱉는다. 이것도 문제. 2. 타이머 인터럽트 처리 시간을 줄인다 ADC 읽는 걸 인터럽트에서 빼고 메인 루프로. 인터럽트에선 플래그만 세운다. 근데 메인 루프는 다른 작업도 많다. RTOS 안 쓰는 베어메탈이라 루프 한 바퀴 도는 시간이 일정하지 않다. 이것도 타이밍 보장 안 된다. 3. 설계를 다시 한다 ADC를 DMA로 읽는다. 인터럽트 안 쓰고. 하드웨어 타이머로 트리거 걸어서 자동으로 100ms마다 읽히게. 이게 답이었다. 근데 DMA 설정 다시 해야 한다. 타이머 트리거 설정 해야 한다. 버퍼 관리 다시 짜야 한다. 오후 3시였다. 팀장한테 보고했다. "설계 수정 필요합니다." "얼마나 걸려?" "하루면 됩니다." "내일까지."DMA 설정했다 레퍼런스 매뉴얼 펼쳤다. STM32 RM0090. 1700페이지 중에 DMA 챕터는 10장. 80페이지. 읽었다. 다 읽었다. // ADC를 타이머 트리거로 DMA 읽기 void adc_dma_init(void) { // DMA 클럭 활성화 RCC->AHB1ENR |= RCC_AHB1ENR_DMA2EN; // ADC 클럭 활성화 RCC->APB2ENR |= RCC_APB2ENR_ADC1EN; // 타이머 클럭 활성화 RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; // DMA 스트림 설정 DMA2_Stream0->CR = 0; while(DMA2_Stream0->CR & DMA_SxCR_EN); // 스트림 비활성화 대기 DMA2_Stream0->PAR = (uint32_t)&ADC1->DR; // 소스: ADC 데이터 레지스터 DMA2_Stream0->M0AR = (uint32_t)adc_buffer; // 목적지: 버퍼 DMA2_Stream0->NDTR = ADC_BUFFER_SIZE; // 전송 개수 // 설정: peripheral to memory, circular mode, 16bit DMA2_Stream0->CR = DMA_SxCR_CHSEL_0 | // 채널 0 DMA_SxCR_MSIZE_0 | // 메모리 16bit DMA_SxCR_PSIZE_0 | // peripheral 16bit DMA_SxCR_MINC | // 메모리 주소 증가 DMA_SxCR_CIRC | // circular mode DMA_SxCR_EN; // 활성화 // ADC 설정 ADC1->CR1 = 0; ADC1->CR2 = ADC_CR2_ADON | // ADC 켜기 ADC_CR2_DMA | // DMA 활성화 ADC_CR2_DDS | // DMA 연속 요청 ADC_CR2_EXTEN_0 | // 외부 트리거 rising edge ADC_CR2_EXTSEL; // TIM2 TRGO // 타이머 설정 (100ms 주기) TIM2->PSC = 8399; // 84MHz / 8400 = 10kHz TIM2->ARR = 999; // 10kHz / 1000 = 10Hz = 100ms TIM2->CR2 = TIM_CR2_MMS_1; // TRGO on update TIM2->CR1 = TIM_CR1_CEN; // 타이머 시작 }컴파일했다. 에러 없다. 올렸다. 안 됐다. 레지스터 순서가 중요했다 문제는 초기화 순서였다. ADC_CR2에 ADON 비트 쓰고 나서 설정을 더 써야 한다. 근데 나는 한 번에 다 썼다. 데이터시트 읽어보니 ADON을 1로 만들고 나서 다른 설정 해야 한다고 나와 있었다. 수정했다. // 순서 중요 ADC1->CR2 = ADC_CR2_ADON; // 먼저 켜고 for(volatile int i=0; i<100; i++); // 안정화 대기 ADC1->CR2 |= ADC_CR2_DMA | // 그 다음 설정 ADC_CR2_DDS | ADC_CR2_EXTEN_0 | ADC_CR2_EXTSEL;다시 올렸다. 됐다. 테스트했다 오실로스코프로 확인했다.CH1: ADC 입력 (배터리 전압) CH2: DMA Transfer Complete 인터럽트 GPIO100ms마다 정확히 찍힌다. 오차 없다. UART 인터럽트 들어와도 상관없다. DMA가 하드웨어로 처리하니까. 로그 찍어봤다. 100.0ms, 100.0ms, 100.0ms... 완벽하다. 배터리 전압도 안정적이다. 90%면 90%, 89%면 89%. 튀지 않는다. 오후 6시였다. 팀장한테 보고했다 "됐습니다." "테스트는?" "오실로스코프로 확인했습니다. 100ms 정확합니다." "좋아. 내일 통합 테스트 넣어." "네." 커피 한 잔 더 마셨다. 네 번째였다. 배운 것 인터럽트 우선순위는 신중하게 정해야 한다. 높은 우선순위가 낮은 우선순위를 블락한다. 당연한 건데 실제로 부딪히니까 다르다. 타이밍 크리티컬한 작업은 인터럽트에 의존하면 안 된다. 특히 다른 인터럯트가 많으면. DMA를 쓰면 CPU 부하도 줄고 타이밍도 보장된다. 하드웨어 트리거 쓰면 더 좋다. 레퍼런스 매뉴얼은 끝까지 읽어야 한다. "Note:" 부분이 중요하다. 거기에 초기화 순서 같은 게 나온다. 3시간 걸렸다. 길다. 하지만 양산 나가서 리콜되는 것보단 낫다. 퇴근 저녁 8시에 나왔다. 일찍 나온 거다. 편의점에서 맥주 샀다. 집 와서 샤워하고 마셨다. 내일은 통합 테스트다. HW팀이랑 같이 실제 배터리 연결해서 돌려본다. 또 문제 나올 수도 있다. 근데 오늘은 해결했다. 그걸로 됐다. 맥주 한 모금 더 마셨다. 시원했다.인터럽트 우선순위, 생각보다 복잡하다.
- 10 Dec, 2025
대학 동기는 지금 SI로 칼퇴, 나는 여전히 야근
대학 동기는 지금 SI로 칼퇴, 나는 여전히 야근 토요일 오후 2시, 단톡방 토요일 오후 2시. 회사 책상에 앉아 있다. 보드에 프로브 연결하고 있는데 핸드폰이 울렸다. 대학 동기 단톡방이다. "오늘 홍대 ㄱ?" "ㅇㅇ 6시" "나도 ㄱㄱ" 30초 만에 약속이 잡혔다. 나는 답장 안 했다. 못 한다. 오늘 양산 샘플 테스트해야 한다. 월요일 미팅 전까지.6년 전, 우리는 같은 전자과였다 2019년 졸업할 때만 해도. 우리 5명은 다 비슷한 회사 지원했다. 전자과 나왔으니까. 펌웨어든 하드웨어든 그쪽으로. 민수는 대기업 펌웨어 갔다. 나도 중견 전자회사 펌웨어로. 재훈이는 SI 회사 임베디드팀. 승호는 하드웨어 설계. 지환이만 웹으로 전향했다. 코딩 부트캠프 6개월. 그때는 지환이가 제일 불안해 보였다. "전공도 아닌데 괜찮을까?" 우리가 오히려 걱정했다. 6년 지났다. 지환이 연봉이 제일 높다. 8천 중반. 작년 송년회에서 들은 이야기 작년 12월. 오랜만에 5명 다 모였다. 재훈이가 말했다. "나 이직했어. SI 웹 마이그레이션 팀으로." 임베디드에서 웹으로 넘어간 거다. C 던지고 JavaScript 잡았다. "왜?" "칼퇴하고 싶어서." 이유가 명확했다. SI 임베디드는 납품 전에 죽어난다. 현장 투입되면 한 달 숙식. 그런데 웹 쪽은 다르다고 했다. "프로젝트 일정은 빡빡한데, 그래도 6시 반이면 퇴근해." "야근? 배포 전에 일주일 정도?" "주말? 거의 안 나가." 나는 술 마시면서 계산했다. 나는 작년에 주말 출근 23일. 평일 야근은 세지도 않았다."넌 아직 펌웨어?" 만날 때마다 듣는다. 이 질문. "너 아직 펌웨어 해?" 악의는 없다. 그냥 궁금한 거다. 근데 자꾸 들으면 기분이 묘하다. '아직'이라는 단어가 걸린다. 마치 내가 뭔가 선택을 잘못한 것처럼. 아직도 거기 있냐는 뉘앙스. 민수는 작년에 웹으로 옮겼다. 대기업 펌웨어 5년 하다가. 같은 회사 웹 서비스팀으로. "연봉은 비슷한데, 삶의 질이 달라." 민수가 말했다. "펌웨어 할 때는 양산 때마다 속 터졌잖아." "지금은 배포 잘못해도 롤백하면 돼." "핫픽스 올리면 끝이야." 나는 고개를 끄덕였다. 양산 나가면 끝이다. 펌웨어는. 리콜 한 번 나면 억 단위다. 그 부담감을 민수도 알았다. 승호는 여전히 하드웨어 승호만 여전하다. 하드웨어 설계. 얘는 정말 좋아한다. 회로 그리는 걸. "나는 코딩은 못 하겠어." 승호 말이다. "PCB 뽑혀서 오는 거 보면 좋거든." "내가 그린 회로가 실물로." 이해한다. 나도 펌웨어 처음 시작할 때 그랬다. 내가 짠 코드로 LED 켜지는 거 보면. 신기했다. 근데 5년 지나니까. 그 신기함이 좀 희석됐다. LED 켜지는 거야 이제 당연하고. 문제는 양산성, 안정성, 전력 소모. 하드웨어 이슈 만나면. 팀 싸움 난다. "이거 펌웨어에서 처리 못 해요?" "회로 수정하면 단가 올라가는데." 양산 일정은 코앞이고. 승호는 그래도 행복해 보인다. 연봉은 우리랑 비슷한데. 야근은 나보다 덜 한다. PCB 수정은 외주 맡기면 끝이니까. 펌웨어처럼 밤새서 코드 뜯을 일은 없다.지환이는 이제 다른 세계 지환이 만나면 대화가 안 통한다. 쓰는 언어가 다르다. "요즘 TypeScript 마이그레이션 중이야." "React 18 올리면서 Suspense 적용하고." "CI/CD 파이프라인 Jenkins에서 GitHub Actions로." 나는 끄덕이면서 듣는다. 근데 실제로는 반도 모른다. 지환이도 내 이야기 들으면 그럴 것이다. "RTOS 태스크 우선순위 꼬여서." "SPI 통신 타이밍 이슈로 파형 봤는데." "DMA 버퍼 오버플로우 나더라." 6년 전에는 같은 전자회로 배웠는데. 이제는 완전히 다른 분야다. 지환이 연봉은 8500이다. 나는 5800. 야근 수당 포함하면 6500 정도. 시급으로 계산하면. 나는 더 낮을 수도 있다. 작년에 야근 시간 계산해봤다. 한 달 평균 80시간. 80시간이면 일주일치다. 한 달에 5주 일하는 셈이다. 재훈이의 SI 웹팀 이야기 재훈이 만났을 때 물어봤다. "SI 웹으로 가니까 어때?" "일은 빡세." 재훈이가 말했다. "프로젝트 데드라인은 촉박하고." "클라이언트 요구사항은 매일 바뀌고." "그래도 야근은 덜 해." 이유를 들어보니 이랬다. 웹은 개발 환경이 좋다. 로컬에서 바로 테스트 가능. 배포도 버튼 하나. 문제 생기면 로그 보면 된다. 콘솔 찍으면 바로 보인다. 펌웨어는 다르다. 보드에 올려야 한다. UART 연결하고 시리얼 모니터 켜고. printf 찍어도 타이밍 놓치면 안 보인다. 하드웨어 문제면. 오실로스코프 들고 파형 봐야 한다. 재훈이 말이다. "펌웨어 할 때는 하루 종일 보드 한 개만 봤잖아." "지금은 하루에 기능 3개는 만들어." 생산성이 다르다는 얘기다. 그래서 같은 SI인데. 웹 쪽이 야근이 덜하다. 물론 배포 전에는 죽어난다. 근데 펌웨어는 양산 전에 죽어나고. 양산 후에도 불안하다. 필드에서 문제 터지면. 그게 진짜 지옥이다. 내가 펌웨어를 계속하는 이유 솔직히 나도 생각해봤다. 웹으로 갈까. 민수처럼 사내 이동. 재훈이처럼 SI 웹팀. 아니면 아예 스타트업 백엔드. C 할 줄 알면 다른 언어는 쉽다고 한다. JavaScript, Python 같은 거. 근데 나는 안 옮겼다. 이유가 있다. 첫째, 나는 하드웨어가 좋다. 코드가 물리적으로 뭔가 움직이는 게 좋다. 웹은 화면에서만 움직인다. 데이터가 오고 가고. 물론 그것도 중요하다. 근데 나는. LED 깜빡이고. 모터 돌아가고. 센서 값 읽히는 게. 그게 더 와닿는다. 둘째, 희소성. 펌웨어 할 줄 아는 사람이 적다. 웹 개발자는 많다. 부트캠프 6개월이면 취업한다. 물론 실력은 천차만별이지만. 펌웨어는 그게 안 된다. 전공 지식 필요하다. 전자회로, 디지털 논리, 아날로그 신호. MCU 구조, 메모리 맵, 레지스터. 진입장벽이 높다. 그래서 경쟁자가 적다. 10년 후에도. 펌웨어 개발자는 필요할 것이다. IoT, 자율주행, 로봇. 다 펌웨어 필요하다. 셋째, 나는 아직 배울 게 많다. 5년 했는데도. MCU마다 특성이 다르다. STM32 하다가 ESP32 하면 새롭다. nRF52 블루투스는 또 다르다. RTOS도 FreeRTOS, Zephyr, 다 다르다. 최적화는 끝이 없다. 전력 1mA 줄이는 게 양산에서는 큰 차이다. 이런 거 파고들면 재미있다. 웹에서는 못 느끼는 재미. 그래도 가끔은 그래도 가끔은 부럽다. 지환이 보면. "오늘 6시에 퇴근했어." "헬스장 갔다 왔어." "주말에 제주도 다녀왔어." 나는. "오늘 10시에 퇴근했어." "집 가서 씻고 잤어." "주말에 회사 나왔어." 라이프스타일이 다르다. 민수는 웹으로 옮기고 여자친구 사귀었다. 시간이 생기니까. 나는 작년에 헤어졌다. 야근 때문에. "너 맨날 일하잖아." 그 말이 마지막이었다. 틀린 말은 아니었다. 연봉도 차이 난다. 지환이는 8500. 나는 6500. 2000 차이. 한 달에 160만원. 야근 시간 생각하면. 시급은 더 차이 난다. 그래도 나는 여기 있다. 펌웨어 팀에. 선택의 문제 결국 선택이다. 뭐가 맞다 틀리다가 아니라. 지환이는 웹이 맞았다. 돈도 벌고 시간도 생기고. 민수는 웹으로 가서 만족한다. 삶의 질이 올라갔다고. 재훈이도 SI 웹으로 옮겨서 행복하다. 칼퇴할 수 있어서. 승호는 하드웨어가 천직이다. 회로 그리는 게 좋아서. 나는 펌웨어가 맞는다. 지금은. 야근 많고. 연봉 낮고. 주말 출근하고. 그래도. 어제 밤 2시까지 디버깅하다가. 문제 원인 찾았다. SPI 클럭 타이밍이 3ns 어긋났던 거였다. 오실로스코프로 파형 보고 찾았다. 그 순간. '이거다' 싶었다. 이런 쾌감은. 웹에서는 못 느낀다. 물론 웹도 디버깅 쾌감 있을 것이다. 근데 나는 이게 더 좋다. 하드웨어랑 씨름하는 것. 물리적 신호 보는 것. 3.3V 로직 레벨에서 싸우는 것. 10년 후의 나 10년 후에도 펌웨어 할까. 모르겠다. 어쩌면 웹으로 갈 수도 있다. 백엔드나 DevOps 쪽으로. C 오래 하면 시스템 프로그래밍도 할 수 있다. 리눅스 커널이나 드라이버 쪽. 아니면 계속 펌웨어 하면서. 시니어 되고 아키텍트 되고. 그것도 괜찮다. 지금 팀장님은 15년차다. 연봉 1억 넘는다. 야근은 우리보다 적게 한다. 의사결정만 하고. 실제 코딩은 우리가 한다. 그 자리까지 10년 걸린다. 나는 5년 했으니 반. 5년 더 하면 거기 갈 수 있을까. 그때도 펌웨어 하고 있을까. 지금은 지금은 토요일 오후 5시. 여전히 회사다. 동기들 단톡방 봤다. 사진 올라왔다. 홍대 어느 카페. "여기 케이크 JMT" "펌웨어 너도 올래?" 올 수 없다. 아직 파형 보고 있다.다음 주 월요일, 미팅 때 보고할 자료는 만들어진다. 그리고 나는 또 야근한다. 동기들은 칼퇴하겠지.