메모리 누수 추적 3주간의 기록

메모리 누수 추적 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. 링커 스크립트부터 체크했다. 괜찮다. 이제 다른 버그를 찾으러 간다.메모리 누수가 아니라 메모리 충돌이었다. 더 최악이지 뭐.

버전 관리, 펌웨어는 다르다

버전 관리, 펌웨어는 다르다

버전 관리, 펌웨어는 다르다 웹은 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으로 하면 안

CRC 검사로 4시간을 날렸다

CRC 검사로 4시간을 날렸다

CRC 하나로 4시간 출근했다. 오늘은 통신 프로토콜 검증. UART로 센서 데이터 받는 건데, 가끔 값이 이상하다. 데이터 오류인지 파싱 문제인지 모르겠어서 CRC 붙이기로 했다. CRC 자체는 어렵지 않다. 체크섬 계산해서 끝에 붙이고, 수신측에서 다시 계산해서 맞는지 확인. 이론은 간단하다. 문제는 구현이다.레퍼런스 코드를 찾았다 구글에 'CRC-16 CCITT C code' 검색. 스택오버플로우에 코드 있다. 복사 붙여넣기. 컴파일. 보드에 올렸다. uint16_t crc16_ccitt(uint8_t *data, uint16_t length) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < length; i++) { crc ^= data[i] << 8; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x8000) crc = (crc << 1) ^ 0x1021; else crc = crc << 1; } } return crc; }송신측에서 CRC 계산해서 붙였다. 수신측에서 받아서 다시 계산. 비교했다. 안 맞는다. 처음엔 내가 잘못 썼나 싶었다. 데이터 길이 확인. 맞다. 바이트 순서 확인. 빅엔디안 리틀엔디안 바꿔봤다. 안 맞는다. 알고리즘 문제일까 CRC-16에도 종류가 많다. CCITT, MODBUS, USB, XMODEM... 초기값이 다르고, 다항식이 다르고, 최종 XOR 값이 다르다. 스펙 문서 찾았다. 센서 제조사 문서에 'CRC-16 CCITT' 쓴다고 나와 있다. 그럼 내 코드가 맞는 거 아닌가. 혹시 몰라서 온라인 CRC 계산기 찾았다. 테스트 데이터 넣어봤다. "01 02 03 04 05" 이런 거. 계산기 결과랑 내 코드 결과 비교. 다르다. 뭐가 문제지.다항식부터 다시 확인 CRC-16 CCITT 다항식은 0x1021이다. 내 코드에 있다. 맞다. 초기값은 0xFFFF. 코드에 있다. 맞다. 최종 XOR? 어... 코드에 없다. 그냥 crc 리턴한다. 혹시 이게 문제인가 싶어서 return crc ^ 0xFFFF; 해봤다. 여전히 안 맞는다. 시간은 오후 3시. 벌써 2시간 지났다. 팀장한테 물어볼까 했는데, 이런 거 물어보면 '그것도 모르나' 소리 들을 것 같아서 참았다. HW팀한테 물어봤자 '펌웨어 문제 아니에요?' 할 거고. 코드 한 줄씩 뜯어봤다 디버거 붙여서 스텝 실행. CRC 계산 과정을 한 바이트씩 확인했다. data[0] = 0x01 crc ^= 0x01 << 8 → crc = 0xFFFF ^ 0x0100 = 0xFEFF 비트 시프트 8번 돌면서 다항식 XOR...손으로 계산해봤다. 종이에 이진수 써가면서. 내 코드 결과랑 같다. 그럼 코드는 맞는 거다. 온라인 계산기가 틀렸나? 여러 개 찾아봤다. 다 같은 값 나온다. 내 값이랑 다르다. 뭐지. 커뮤니티 검색 포기하고 커뮤니티 뒤졌다. 'CRC-16 CCITT wrong result' 검색. 임베디드 포럼에 똑같은 질문 있다. 2015년 게시글. 답변 보니까 누가 이렇게 썼다. "CRC-16 CCITT는 초기값이 두 가지예요. 0xFFFF 쓰는 버전이랑 0x0000 쓰는 버전. 그리고 입력 데이터 반사 여부도 다릅니다. 정확히는 CRC-16/CCITT-FALSE랑 CRC-16/XMODEM이 다른 겁니다." 뭐라고?표준이 여러 개 찾아보니까 CRC-16 'CCITT'라고 부르는 게 실제로는 여러 변종이 있다.CRC-16/CCITT-FALSE: 초기값 0xFFFF, 입력/출력 반사 없음 CRC-16/XMODEM: 초기값 0x0000, 입력/출력 반사 없음 CRC-16/KERMIT: 초기값 0x0000, 입력/출력 반사 있음내가 쓴 코드는 CCITT-FALSE였다. 온라인 계산기는 XMODEM 기준이었다. 센서 스펙 문서는 그냥 'CCITT'라고만 써놨다. 초기값 0x0000으로 바꿨다. 테스트했다. 맞는다. 시간은 저녁 7시. 4시간 날렸다. 왜 표준이 이렇게 많나 CRC 알고리즘 자체는 1961년에 나왔다. 그 이후로 여러 회사가 각자 입맛대로 변형해서 썼다. 초기값 다르게, 다항식 다르게, 반사 여부 다르게. 나중에 표준화하려고 했는데 이미 쓰는 곳이 많아서 그냥 다 표준으로 인정했다. 그래서 CRC-16만 해도 변종이 수십 개다. 문제는 다들 이름을 똑같이 'CRC-16 CCITT'라고 부른다는 거다. 정확한 변종 이름을 안 쓴다. 스펙 문서에 초기값, 다항식, 반사 여부 명시 안 하면 알 수가 없다. 결국 테스트 데이터로 센서 제조사 문서에 샘플 데이터 있었다. "이 데이터의 CRC는 0x1234입니다" 이런 거. 그걸로 역산했다. 초기값 0x0000 쓰면 맞고, 0xFFFF 쓰면 안 맞는다. XMODEM 방식이다. 코드 수정했다. 테스트 통과. 양산 코드에 들어갔다. 4시간의 교훈 CRC 구현할 때는 다음을 확인해야 한다.다항식 (polynomial) 초기값 (initial value) 입력 반사 (reflect input) 출력 반사 (reflect output) 최종 XOR (final XOR)이 5개가 하나라도 다르면 결과가 다르다. 스펙 문서에 'CRC-16'이라고만 쓰여 있으면 제조사한테 물어봐야 한다. 아니면 샘플 데이터로 역산. 스택오버플로우 코드 복붙은 위험하다. 질문자가 어떤 변종 쓰는지 모른다. 답변자도 그냥 일반적인 거 올린다. 온라인 계산기도 조심해야 한다. 어떤 변종 쓰는지 확인. CRC-16 선택지가 10개 넘게 나오는 계산기가 정확하다.CRC 하나에 4시간. 알고리즘은 맞았는데 표준이 틀렸다. 임베디드는 이런 거에 시간 간다.

회사 '낡은' 컨벤션 vs 인터넷의 '신규' 트렌드

회사 '낡은' 컨벤션 vs 인터넷의 '신규' 트렌드

출근길에 본 글 지하철에서 Reddit 봤다. "Modern Embedded C Programming in 2024" 같은 제목. 클릭했다. 댓글에 누가 적어놨더라. "왜 아직도 헝가리안 표기법 쓰냐", "typedef 남발은 90년대나 하던 거". 웃겼다. 우리 회사 코드가 정확히 그거다. 회사 도착. 컴퓨터 켰다. 어제 푸시한 코드 리뷰가 떠 있다. "변수명 규칙 지켜주세요. g_u8DataBuffer 이런 식으로요." 헝가리안 표기법이다. 타입까지 변수명에 적는 거. C++이나 모던 C에서는 이미 안 쓴다는 게 정설인데.10년 전 코드베이스 우리 회사 메인 프로젝트는 2014년에 시작됐다. 벌써 11년째다. 그때 정한 코딩 컨벤션을 아직도 쓴다. 파일 하나 열면 이런 식이다. /****************************************************************************** * File Name : drv_uart.c * Description : UART Driver Module * Author : Kim XX * Date : 2014.03.15 * Version : v1.0 ******************************************************************************/typedef unsigned char UINT8; typedef unsigned short UINT16; typedef signed char INT8;static UINT8 g_u8RxBuffer[256]; static UINT16 g_u16RxIndex = 0;주석 박스. typedef로 타입 재정의. 전역변수에 g_ 접두사. 타입까지 변수명에. 2014년이면 이게 맞았다. 당시 임베디드 책들이 다 이렇게 가르쳤다. 문제는 2025년에도 이걸 쓴다는 거다. 신입이 작년에 들어왔다. 코드 보더니 물었다. "왜 stdint.h 안 쓰고 typedef로 다시 정의하나요?" 답은 간단했다. "원래 그렇게 해왔어요." 인터넷에서 본 트렌드 점심시간. Reddit r/embedded 들어갔다. 누가 올린 글. "Stop using global variables in embedded C". 내용 읽어봤다. 전역변수 대신 구조체로 컨텍스트 만들고, 함수에 포인터로 넘기라는 거다. 테스트하기도 쉽고 재사용성도 좋다고. 댓글도 다들 동의한다. "2024년에 전역변수는 레거시", "static 남발은 유지보수 지옥". 맞는 말이다. 우리 코드 전역변수 천지다. static uint8_t g_u8SensorData[10]; static bool g_bIsConnected = false; static uint32_t g_u32Timestamp = 0;파일 하나에 전역변수 30개 넘는 것도 있다. 어디서 바뀌는지 추적하려면 전체 검색 돌려야 한다. GitHub에서 최신 프로젝트들 봤다. Zephyr, FreeRTOS 예제 코드들. struct sensor_context { uint8_t data[10]; bool is_connected; uint32_t timestamp; };void sensor_read(struct sensor_context *ctx) { // ... }깔끔하다. 컨텍스트 구조체 하나로 상태 관리. 함수는 순수하게 로직만.개선 제안서 작성 저녁 먹고 돌아왔다. 생각했다. 제안서 써볼까. PPT 켰다. 제목 적었다. "코드 컨벤션 개선 제안". 슬라이드 3장 만들었다.현재 문제점: typedef 중복 정의, 전역변수 과다, 헝가리안 표기법 개선안: stdint.h 사용, 구조체 기반 컨텍스트, 모던 C 네이밍 기대효과: 가독성 향상, 유지보수성 개선, 신규 인력 적응 빠름레퍼런스도 달았다. NASA C Style Guide, Google C++ Style Guide, Linux Kernel Coding Style. 다음 날 팀장한테 보여줬다. 5분 봤다. 말했다. "취지는 좋은데요." 여기서 끝나면 좋겠는데. "지금은 여유가 없어요." 지금은 여유가 없다 팀장 말을 정리하면 이렇다. "지금 프로젝트 일정 빡빡합니다. 양산 2달 남았어요. 코드 스타일 바꾸다가 버그 생기면 어떻게 할 건데요. 제품 나간 다음에 생각해봅시다." 맞는 말이다. 양산 앞둔 시점에 대규모 리팩토링은 위험하다. 그런데. 2년 전에도 들었다. 그때도 "양산 끝나고". 1년 전에도 들었다. 그때는 "다음 프로젝트 시작할 때". 지금 또 들었다. "제품 나간 다음에". 결국 안 바뀐다. 영원히. 이유는 간단하다. 임베디드는 항상 바쁘다. 양산 끝나면 다음 제품. 다음 제품 끝나면 또 다음. 여유라는 게 없다. 선배한테 물었다. 7년차. "형, 우리 코드 스타일 언제부터 이랬어요?" "내가 입사했을 때도 이랬어. 그때도 바꾸자는 얘기 나왔는데." "안 바뀐 거네요." "응. 계속 바쁘다고."실무와 이상의 간극 퇴근길. 생각했다. 인터넷에서 보는 건 이상적이다. 깨끗한 코드, 모던한 기법, 최신 트렌드. 실제 회사는 다르다. 레거시 코드, 빡빡한 일정, 리스크 회피. 누구 잘못도 아니다. 구조적인 문제다. 웹 개발자들 부럽다. 걔네는 배포하고 A/B 테스트하고 롤백한다. 실험할 수 있다. 우리는 못한다. 한 번 양산 나가면 끝이다. 펌웨어 업데이트 구조 없으면 그냥 그대로 간다. 리스크가 크니까 보수적이 된다. 보수적이니까 안 바뀐다. 악순환이다. 신입이 물어봤다. 어제. "선배님, 학교에서 배운 거랑 너무 달라요. 전역변수 쓰지 말라고 배웠는데 여기는..." 할 말이 없었다. "그냥... 익숙해져." 작은 개선이라도 포기는 안 한다. 큰 개선은 안 돼도 작은 건 된다. 새로 작성하는 파일에는 stdint.h 썼다. uint8_t, uint32_t 이런 식으로. 리뷰에서 지적 안 들어왔다. 팀장도 그냥 넘어갔다. "기존 코드랑 다른데요." "새 모듈이라 따로 뺐습니다." "음... 뭐 돌아가면 되지." 승인 떨어졌다. 다음 파일도 그렇게 했다. 조금씩. 전역변수도 줄였다. 새 기능은 구조체로 만들었다. struct ble_context { uint8_t rx_buffer[256]; uint16_t rx_index; bool is_connected; };함수도 컨텍스트 받게 수정했다. 기존 코드는 못 고친다. 건드리면 테스트 다시 해야 한다. 시간 없다. 새로 짜는 것만이라도 깨끗하게. 이게 현실적이다. 10년 후엔 생각해봤다. 10년 후. 2035년. 나도 11년차가 돼 있다. 신입이 들어온다. 코드 보고 물어본다. "왜 이렇게 작성했어요?" 나는 뭐라고 답할까. "원래 그렇게 해왔어." 이러고 싶지는 않다. 그렇다고 지금 당장 전부 바꿀 수는 없다. 현실이 그렇다. 타협점을 찾아야 한다. 레거시는 유지. 신규는 개선. 천천히. 10년 걸려서 바뀔 수도 있다. 어쩌면 안 바뀔 수도 있다. 그래도 시도는 한다. 포기하면 영원히 안 바뀐다.회사 코드는 박물관이다. 신입은 적응한다. 나도 그랬다.

ESP32의 WiFi 연결이 불안정하다고? 지연시간이 길었나?

ESP32의 WiFi 연결이 불안정하다고? 지연시간이 길었나?

ESP32 WiFi 연결이 불안정하다고? 지연시간이 길었나? 오늘도 WiFi가 안 된다 출근했다. 어제 밤 11시까지 ESP32 WiFi 코드 손봤는데 오늘 아침에 테스트하니까 또 안 된다. 연결은 되는데 5분 지나면 끊긴다. 로그 보니까 WIFI_EVENT_STA_DISCONNECTED. 이유 코드는 WIFI_REASON_BEACON_TIMEOUT. 비콘 타임아웃. 즉 공유기한테 신호를 못 받았다는 거다. "코드 문제 아닌가요?" 팀장이 물었다. 코드는 어제도 돌아갔다. 장소만 바뀌었다. 개발실에서는 되는데 회의실에서는 안 된다.무선 통신은 이렇다. 같은 코드가 어디서는 완벽하고 어디서는 쓰레기가 된다. 하드웨어 환경에 따라 달라진다. 개발자가 제어할 수 없는 영역이다. 커피 마시고 오실로스코프 켰다. 안테나가 문제일까 ESP32 모듈 두 종류 있다. 하나는 PCB 안테나, 하나는 외부 안테나 커넥터. 우리 제품은 PCB 안테나다. 싸니까. PCB 안테나는 보드 배치가 중요하다. 안테나 밑에 GND 플레인 있으면 신호 약해진다. 주변에 금속 케이스 있어도 문제다. 우리 케이스는 플라스틱이지만 내부에 EMI 차폐용 금속 테이프 붙어 있다. HW팀한테 물었다. "안테나 주변 keep-out 영역 지켰어요?" "네, 5mm 확보했습니다." 데이터시트에는 10mm 권장이다. 5mm면 부족하다. 하지만 PCB 크기 제약이 있었다. 제품이 작아야 한다고 했다. 그래서 안테나 영역을 줄였다."그럼 외부 안테나 버전으로 테스트해보죠." 외부 안테나 달았다. 연결 안정적이다. 10분 지나도 안 끊긴다. 1시간 돌렸다. 문제없다. 문제는 양산이다. 외부 안테나는 부품 비용이 오르고 조립 공정이 추가된다. 원가 담당자가 싫어한다. "PCB 안테나로 안 되나요?" 된다. 조건만 맞으면. 전파 간섭이라는 늪 회의실에서 안 되는 이유를 찾아야 한다. WiFi 스캔 돌렸다. 주변 AP가 12개다. 다들 2.4GHz 채널 1, 6, 11 쓴다. 우리 공유기는 채널 6. 옆 회사 공유기도 채널 6. 채널 겹치면 간섭 생긴다. 특히 2.4GHz는 채널 폭이 넓어서 1, 6, 11만 겹치지 않는다. 나머지는 다 겹친다. 공유기 설정 들어가서 채널 11로 바꿨다. 다시 테스트. 조금 나아졌다. 그래도 10분 지나면 끊긴다. RSSI 로그 찍어봤다. 신호 강도다. -70dBm 정도 나온다. 약하다. -50dBm 이상은 되어야 안정적이다. -70dBm이면 패킷 로스 생긴다.공유기를 가까이 가져왔다. 1m 거리. RSSI -40dBm. 연결 안정적이다. 30분 테스트, 문제없다. 거리가 문제다. 그리고 벽이 문제다. 회의실과 개발실 사이에 콘크리트 벽 하나 있다. 2.4GHz는 벽 투과 잘 되지만 신호는 약해진다. 팀장한테 보고했다. "안테나 성능 부족입니다. 거리 3m 이내에서는 안정적인데 벽 넘어가면 불안정합니다." "고객 환경은 더 안 좋을 텐데요." 맞다. 고객은 공유기를 어디 둘지 모른다. 10m 떨어질 수도 있다. 벽 두 개 넘어갈 수도 있다. 코드로 버틸 수 있는 것들 하드웨어 못 바꾸면 펌웨어로 버텨야 한다. 할 수 있는 게 몇 가지 있다. 첫째, WiFi 파워 세이빙 끄기. ESP32는 기본적으로 DTIM 주기마다 슬립 들어간다. 전력 아끼려고. 하지만 슬립 들어가면 비콘 놓칠 수 있다. 신호 약할 때는 치명적이다. esp_wifi_set_ps(WIFI_PS_NONE);이거 하나로 연결 안정성 올라간다. 대신 전류 소모 20mA 증가. 배터리 제품이면 고민해야 한다. 우리는 DC 전원이라 상관없다. 둘째, 재연결 로직 강화. 연결 끊기면 자동으로 재연결 시도한다. ESP-IDF 기본 동작이다. 하지만 재연결 시도 간격이 짧으면 공유기가 부담스러워한다. 너무 길면 서비스 중단 시간이 길어진다. 나는 5초 간격으로 설정했다. 3번 실패하면 10초로 늘린다. 10번 실패하면 리부팅. 완벽하지 않지만 최선이다. 셋째, TCP Keepalive. TCP 연결 유지용 패킷이다. 일정 시간마다 상대한테 '살아있냐' 물어본다. 대답 없으면 연결 끊는다. int keepalive = 1; setsockopt(sock, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(int));int keepidle = 30; // 30초 idle 후 시작 int keepintvl = 5; // 5초 간격 int keepcnt = 3; // 3번 실패하면 끊기 setsockopt(sock, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(int)); setsockopt(sock, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(int)); setsockopt(sock, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(int));이것도 도움 된다. 연결 끊겼는지 빨리 알 수 있다. 지연시간은 또 다른 문제 연결 안정성 해결했다고 끝이 아니다. 지연시간 문제가 남았다. ping 찍어봤다. 평균 50ms. 나쁘지 않다. 그런데 가끔 500ms 튄다. 1초 넘을 때도 있다. 이게 문제다. 우리 제품은 실시간 제어 장치다. 명령 내리면 100ms 안에 반응해야 한다. 지연시간 500ms면 사용자는 '고장 났나' 생각한다. 원인을 찾아야 한다. Wireshark 켰다. 패킷 캡처 시작. 보니까 재전송이 많다. TCP 패킷이 안 가고 다시 보낸다. WiFi 환경이 불안정하니까 패킷 로스 생긴다. 로스 생기면 재전송하고, 재전송하면 지연 증가한다. 해결 방법? 근본적으로는 WiFi 신호 강도 올려야 한다. 안테나 개선이 답이다. 펌웨어로 할 수 있는 건 UDP 쓰기. TCP는 신뢰성 보장하려고 재전송한다. UDP는 안 한다. 대신 데이터 유실 가능하다. 실시간성이 중요하면 UDP가 낫다. 제어 명령은 최신 상태가 중요하지 과거 명령은 의미 없다. 100ms 전 명령어 1초 후에 도착하면 쓸모없다. "UDP로 바꿔볼까요?" 제안했다. "데이터 유실되면요?" 팀장이 물었다. "중요한 건 ACK 받고, 나머지는 재전송 안 하는 걸로요." Application 레벨에서 ACK 구현하기로 했다. 중요한 명령만 확인 응답 받는다. 나머지는 그냥 보낸다. 유실되면 다음 거 보낸다. 코드 수정 일주일 걸렸다. 테스트해보니 지연시간 평균 30ms. 튀어도 150ms. 개선됐다. 양산 전 마지막 테스트 양산 전에 환경 테스트 한다. 다양한 조건에서 돌려본다.거리 테스트: 1m, 3m, 5m, 10m 장애물 테스트: 벽 없음, 벽 1개, 벽 2개 간섭 테스트: AP 1개, AP 5개, AP 10개 장시간 테스트: 24시간, 72시간거리 테스트. 10m에서 RSSI -75dBm. 연결 유지되지만 패킷 로스 5%. 사용 가능하지만 권장하지 않는다. 매뉴얼에 '공유기와 5m 이내 설치 권장' 쓰기로 했다. 장애물 테스트. 벽 2개 넘어가면 연결 불안정. 이건 어쩔 수 없다. 안테나 성능 한계다. 매뉴얼에 '장애물 최소화' 추가. 간섭 테스트. AP 10개 환경에서 연결 유지된다. 하지만 지연시간 증가. 평균 80ms. 허용 범위다. 장시간 테스트. 72시간 돌렸다. 중간에 두 번 끊겼다. 자동 재연결 됐다. 괜찮다. 테스트 보고서 작성했다. 팀장한테 올렸다. "양산 가능합니다. 단, 안테나 개선 버전 2차 개발 건의합니다." "1차는 이대로 가고 2차에서 개선하죠." 그렇게 결정됐다. 결국 하드웨어다 펌웨어 개발자지만 인정한다. WiFi 안정성은 결국 하드웨어다. 안테나 배치, 케이스 설계, PCB 레이아웃. 이게 90%다. 펌웨어는 나머지 10% 보완하는 거다. 개발실에서 완벽해도 현장에서 안 되면 소용없다. 고객 환경은 통제 불가능하다. 공유기 위치, 주변 간섭, 벽 재질, 전부 다르다. 그래서 마진을 남겨야 한다. 신호 강도 -50dBm 이상 목표로 설계해야 현장에서 -70dBm 나와도 버틴다. 우리는 -70dBm 목표로 설계했다. 그래서 현장에서 불안정하다. 다음 버전에서는 외부 안테나 쓰기로 했다. 원가 오르지만 안정성이 중요하다. 고객 클레임 처리 비용이 안테나 비용보다 비싸다. 한 번 출장 가면 20만원이다. 클레임 10건이면 200만원. 안테나는 개당 500원 차이다. 1000개 팔아도 50만원 차이. 계산하면 답 나온다. 퇴근하면서 생각했다. 무선 통신 제품은 어렵다. 유선은 꽂으면 된다. 무선은 환경 따라 달라진다. 다음 프로젝트는 유선으로 하고 싶다. 하지만 IoT 시대에 유선 제품은 없다. 다들 무선 원한다. 편하니까. 편한 건 사용자고 고생은 개발자다. 뭐 어쩌겠나. 월급 받는 일이다.내일은 BLE 디버깅이다. WiFi보다 더 까다롭다고 들었다. 미리 두통약 챙겨야겠다.