Showing Posts From

임베디드

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시간. 알고리즘은 맞았는데 표준이 틀렸다. 임베디드는 이런 거에 시간 간다.

팀원이 온 지 3개월, 아직 FreeRTOS 세마포어 이해를 못 했다

팀원이 온 지 3개월, 아직 FreeRTOS 세마포어 이해를 못 했다

3개월째인데 팀에 신입이 왔다. 3개월 전이다. 대학원 출신. 임베디드 전공. 논문도 썼다. 나보다 학벌 좋다. 근데 아직도 세마포어를 못 쓴다. 정확히는, 쓰긴 쓰는데 왜 쓰는지 모른다. 그냥 선배 코드 복붙한다. 오늘 코드 리뷰했다. "여기 왜 xSemaphoreTake 썼어요?" "...경쟁 상태 막으려고요." "무슨 경쟁 상태?" "..." 침묵이 길었다.이론은 안다 신입이 멍청한 건 아니다. Context switching 개념은 안다. Race condition도 설명할 수 있다. Deadlock은 교과서적으로 외우고 있다. 문제는 실제다. "이 변수는 왜 보호 안 해요?" "어... 여기는 괜찮지 않나요?" "ISR에서 접근하는데?" "아..." 매번 이렇다. 교과서랑 다르다. 실제 펌웨어는 더럽다. ISR 있고, Task 여러 개 있고, 공유 버퍼 있고, 타이머 있고. 어디서 뭐가 먼저 실행될지 모른다. 신입은 순서도를 그린다. 깔끔하게. "Task A가 먼저 실행되고..." "누가 먼저 실행된다고 했어?" "...아 스케줄러가..." "스케줄러는 Priority 보고, 같으면 Round-robin이고, ISR 들어오면 선점되고." "..." 또 침묵. 내가 가르치면서 헷갈린다 신입 가르치다 보니 나도 헷갈린다. "Mutex랑 Binary Semaphore 차이가 뭐죠?" "음... Mutex는 소유권이 있고..." "그게 정확히 뭔데요?" 대답하려다 막혔다. Priority inheritance 얘기해야 하나. 근데 우리 프로젝트에서 그거 쓰나. 확인 안 해봤다. 집에 가서 찾아봤다. FreeRTOS 문서. "A mutex is a binary semaphore that includes a priority inheritance mechanism." 아 맞다. 이거였다. 근데 Priority inheritance가 정확히 어떻게 동작하지. High priority task가 block되면 low priority task의 priority를 올려준다. 그게 언제까지지. Mutex release할 때까지인가. 다시 문서 읽었다. 30분 걸렸다. 다음 날 신입한테 설명했다. "아~ 그래서 Mutex 써야 하는구나." 나도 이제 안다.Deadlock 재현이 안 된다 신입이 물었다. "Deadlock은 어떻게 막아요?" "Lock 순서 정하면 돼. A 먼저, B 나중에. 모든 Task가 이 순서 지키면 Deadlock 안 생겨." "근데 만약 실수하면요?" "...Deadlock 나지." "그럼 어떻게 디버깅해요?" 좋은 질문이다. 대답 못 했다. Deadlock 본 적이 거의 없다. 이론으로는 안다. Task A가 Lock1 잡고 Lock2 기다리고, Task B가 Lock2 잡고 Lock1 기다리면 둘 다 멈춘다. 간단하다. 근데 실제로는 안 본다. 우리 코드에는 Mutex가 3개밖에 없다. Lock 순서 지키기 어렵지 않다. "한번 만들어볼까요? Deadlock." 신입이 재미있다는 표정이다. 테스트 코드 짰다. void taskA(void *param) { xSemaphoreTake(mutex1, portMAX_DELAY); vTaskDelay(10); xSemaphoreTake(mutex2, portMAX_DELAY); // ... }void taskB(void *param) { xSemaphoreTake(mutex2, portMAX_DELAY); vTaskDelay(10); xSemaphoreTake(mutex1, portMAX_DELAY); // ... }돌렸다. 안 멈춘다. "왜 안 되죠?" "...타이밍 문제 아닐까." vTaskDelay를 1로 줄였다. 여전히 안 멈춘다. 30분 동안 삽질했다. 알고 보니 Priority가 같아서 Round-robin으로 돌아간다. Delay가 있으면 서로 엇갈린다. Priority 다르게 줬다. 그래도 안 멈춘다. 왜지. 결국 찾았다. Higher priority task가 먼저 두 개 다 잡는다. Lower priority는 실행도 안 된다. "아... Preemptive라서." 신입이 이해했다. Deadlock 재현하려면 두 Task가 정확히 동시에 실행돼야 한다. 불가능하다. Core가 하나라서. 억지로 만들었다. ISR에서 한 Task 깨우고, 다른 Task는 이미 실행 중이고. 복잡했다. 겨우 Deadlock 봤다. 시스템 멈췄다. "와, 진짜 멈추네요." 당연하지. 근데 이거 실제로 겪으면 어떻게 찾지. 로그도 안 찍힌다. Task 멈춰서. "configUSE_TRACE_FACILITY 켜면 Task state 볼 수 있어." "그거 뭐예요?" 나도 안 써봤다.Race condition은 재현이 더 안 된다 "Race condition은요?" 신입이 계속 묻는다. 좋은 태도다. "공유 변수 여러 Task에서 쓰면 생겨." "그러면요?" "값이 이상해져." 예제 보여줬다. uint32_t counter = 0;void taskA(void *param) { counter++; }void taskB(void *param) { counter++; }"이거 1000번 돌리면 2000 안 나올 수 있어." "정말요? 해봐도 돼요?" 해봤다. 2000 나온다. 항상. "...왜 안 깨지죠?" "음..." 생각해봤다. STM32는 32bit CPU다. uint32_t 쓰기는 atomic이다. 한 instruction이다. "아, uint32_t라서 그래. uint64_t 써봐." 써봤다. 그래도 2000 나온다. 왜지. 한참 생각했다. counter++ 는 단순해 보이지만 실제로는 세 단계다. Load, Increment, Store. 근데 컴파일러 최적화 때문에 레지스터에 올라가면 끝이다. Task 전환돼도 레지스터는 저장된다. Context switching이 그거다. "이거 실제로 깨지게 하려면..." 어떻게 하지. Volatile 없애고, 최적화 켜고, 그래도 안 깨진다. Task priority 같게 하고, 루프 1000000번 돌려도 2000 나온다. "선배, 이거 안 깨지는 거 아니에요?" "...아니야. 깨져야 하는데." 검색했다. "ARM Cortex-M에서는 단일 레지스터 쓰기 atomic" 아. 그래서 안 깨진다. 억지로 깨뜨렸다. 구조체 만들고, 여러 필드 동시에 쓰게 하고. struct { uint32_t a; uint32_t b; } data;void taskA(void *param) { data.a++; data.b++; }이것도 안 깨진다. 왜냐면 둘 다 따로 atomic이라서. 결국 포기했다. "실제로는 SPI 버퍼 같은 데서 깨져. 거기는 여러 바이트니까." "아~ 네." 신입이 이해했는지 모르겠다. 나도 헷갈린다. 문서가 애매하다 FreeRTOS 문서 읽으면서 느낀다. 애매한 부분이 많다. "fromISR 함수는 언제 써요?" "ISR에서 쓸 때." "왜 따로 있어요?" "...스케줄러 동작이 달라서?" 정확히 모른다. 찾아봤다. 일반 함수는 block될 수 있다. ISR에서 block되면 안 된다. 그래서 fromISR은 block 안 된다. 대신 결과를 리턴한다. "그럼 xQueueSendFromISR에서 큐가 꽉 차면요?" "못 넣고 리턴해." "데이터 유실되는 거네요?" "...그렇지." 생각해보니 맞다. ISR에서는 기다릴 수 없다. 큐 꽉 차면 버린다. 근데 우리 코드는 어떻게 처리했지. 확인 안 해봤다. 다음 날 코드 봤다. 리턴 값 확인 안 한다. 그냥 xQueueSendFromISR 호출하고 끝. "이거 실패하면 어떡하죠?" "...모르지." 수정했다. 실패하면 에러 카운터 올리게. if (xQueueSendFromISR(queue, &data, &xHigherPriorityTaskWoken) != pdPASS) { error_count++; }근데 이 에러 카운터를 누가 보나. 로그에도 안 찍는다. ISR에서 printf 못 쓴다. "주기적으로 체크하는 Task 만들까요?" "...나중에." 결국 안 만들었다. 양산 나가면 에러 생겨도 모른다. 뭐 어쩌겠나. 신입이 물어본 것들 "Binary semaphore랑 Counting semaphore 차이요?" "Binary는 0, 1만. Counting은 여러 개." "언제 Counting 써요?" "...리소스가 여러 개 있을 때?" 실제로 써본 적 없다. 우리 프로젝트에는 다 Binary다. "Queue랑 Semaphore 차이요?" "Queue는 데이터 전달. Semaphore는 신호만." "Queue도 신호 아니에요?" "...음, 데이터 있는 신호?" 대답이 이상하다. 근데 맞는 것 같기도 하다. "Event group은요?" "여러 이벤트 기다릴 수 있어." "예를 들면요?" "...음..." 예를 들지 못했다. 써본 적 없다. "Stream buffer는요?" "바이트 스트림 전달할 때." "Queue랑 뭐가 달라요?" "...더 빨라." 정말 빠른지 모른다. 측정 안 해봤다. 신입이 질문 많다. 좋은 건데 대답이 힘들다. 내가 모르는 걸 깨닫는다. 결국 동작하면 된다 신입한테 말했다. "완벽하게 이해 안 해도 돼. 일단 돌아가게 만들어." 냉정하게 들릴 수 있다. 근데 사실이다. 우리 프로젝트는 복잡하지 않다. Task 5개. Mutex 3개. Queue 2개. ISR 4개. 패턴은 정해져 있다. 센서 데이터 읽는 Task, Queue에 넣는다. 처리하는 Task, Queue에서 꺼낸다. 통신하는 Task, Mutex로 보호한다. 이론 몰라도 복붙하면 된다. 선배 코드 따라 하면 된다. "세마포어를 왜 쓰는지 이해 안 가도, 일단 써. 나중에 이해돼." "...네." 신입이 실망한 표정이다. 미안하다. 근데 나도 그렇게 배웠다. 5년 전 나도 몰랐다. 선배 코드 복붙했다. 돌아갔다. 이해는 나중에 됐다. 지금도 완전히 이해했는지 모르겠다. 오늘 신입 가르치면서 또 헷갈렸다. 근데 코드는 돌아간다. 5년 동안 Deadlock 없었다. Race condition도 없었다. 적어도 발견 못 했다. 그러면 된 거 아닌가.신입이 언제쯤 세마포어 이해할까. 나는 이해한 건가.

오시로스코프 프로브, 이제 내 손의 연장이다

오시로스코프 프로브, 이제 내 손의 연장이다

아침 9시, 프로브부터 꺼낸다 출근했다. 가방에서 노트북보다 먼저 꺼내는 게 프로브다. 10배 프로브 2개, 접지 클립 여분까지. 없으면 일을 못 한다. 책상 위 오실로스코프 전원 켠다. Rigol 4채널, 100MHz. 팀 막내가 쓰던 50MHz보다 낫다. 예산 싸울 때 이긴 보람이 있다. 어제 퇴근 전에 STM32 보드 올려놨다. UART 통신이 안 돼서 밤 11시까지 붙잡았다. 데이터시트는 'works normally'라는데 뭐가 정상인지 모르겠다. 프로브 끝을 TX 핀에 댄다. 익숙한 무게감. GND 클립 보드에 물린다. 파형이 뜬다. 3.3V 기준으로 신호가 왔다 갔다. 그런데 타이밍이 이상하다.파형이 답을 알려준다 UART 설정은 115200 baud. 계산하면 비트당 8.68µs여야 한다. 화면에는 10µs로 나온다. 보레이트가 안 맞는 거다. 클럭 설정 코드 다시 본다. PLL 설정, 분주비, 버스 클럭... 한참 본다. APB1이 36MHz인 줄 알았는데 실제로는 32MHz였다. 계산 실수다. 코드 고친다. 빌드, 플래싱, 리셋. 프로브는 그대로 물려둔다. 다시 파형 본다. 8.7µs. 거의 맞다. 터미널 열어본다. "Hello World" 뜬다. 됐다. printf 디버깅으로는 못 찾는다. '왜 안 나오지?'만 반복한다. 파형 보면 명확하다. 신호가 나가는지, 타이밍이 맞는지, 노이즈는 없는지. 다 보인다. HW팀 민수 선배가 이렇게 말했다. "펌웨어는 눈으로 못 보잖아. 그래서 오실로스코프가 필요한 거야." 맞다. 코드는 추상적이다. 레지스터에 값 쓰면 '잘 됐겠지' 생각한다. 실제로는 클럭이 안 돌거나, 핀 설정이 안 돼있거나, 타이밍이 엇갈린다. 파형으로 확인해야 진짜다.인터럽트 타이밍은 µs 단위 싸움이다 오후 3시. 새 프로젝트 시작이다. IoT 센서 모듈. nRF52 BLE 칩 쓴다. 센서 데이터를 I2C로 읽어서 BLE로 쏴야 한다. 센서는 100Hz 샘플링. 10ms마다 인터럽트 걸어서 읽는다. 코드 짜고 올린다. 동작한다. 그런데 가끔 데이터가 누락된다. 로그 찍어봐도 모른다. "Read OK" 다 뜬다. 그런데 BLE 패킷 보면 중간에 빈다. 10개 중 1개씩 빠진다. 프로브 꺼낸다. CH1은 센서 INT 핀, CH2는 I2C SCL, CH3는 I2C SDA. 트리거는 INT 하강 엣지. 파형 본다. INT 떨어지고 3µs 후 SCL 올라간다. 정상이다. 그런데 한참 보니까 가끔 INT와 BLE 타이머 인터럽트가 겹친다. 우선순위 문제다. BLE 스택이 내부적으로 타이머 인터럽트 쓴다. 1ms마다. 센서 인터럽트랑 동시에 걸리면 BLE가 먼저 처리된다. 그 사이 센서 FIFO 오버플로우. 데이터 날아간다. 해결은 간단하다. 센서 읽는 걸 메인 루프로 빼고 플래그로 처리. 인터럽트는 플래그만 세운다. 타이밍 겹쳐도 데이터 안 날아간다. 다시 테스트. 프로브로 확인. INT 떨어지고 → 플래그 세우고 → 메인에서 읽는다. 타이밍 다 맞다. 3시간 돌렸는데 누락 없다. 이게 Low 레벨이다. µs 단위로 뭐가 먼저 실행되는지 봐야 한다. 코드로는 모른다. 파형으로 봐야 안다.하드웨어 이슈 찾을 때도 프로브다 지난주 목요일. 양산 보드 샘플 도착했다. 프로토타입에서 잘 되던 코드 올렸다. 부팅 안 된다. LED 안 깜빡인다. UART 출력 없다. 뭔가 심각하다. 전원부터 본다. 프로브로 3.3V 핀 측정. 3.28V. 정상이다. GND도 본다. 0V. 맞다. 리셋 핀 본다. 3.3V에서 계속 있다. 리셋 안 풀린다. 회로도 본다. 리셋 핀에 풀업 저항 10kΩ, 리셋 버튼은 풀다운. 문제없어 보인다. 그런데 프로브로 리셋 버튼 핀 보니까 1.5V다. 뭔가 이상하다. HW팀 불렀다. 민수 선배가 테스터 들고 온다. 저항값 재본다. 리셋 풀업이 100Ω이다. 회로도는 10kΩ인데 실제 부품은 100Ω. 발주 실수다. 100Ω이면 전류 33mA 흐른다. MCU 리셋 핀은 최대 5mA. 내부 로직 망가진 거다. 보드 버린다. 10개 샘플 중 8개 똑같았다. 발주 다시 넣었다. 이번엔 제대로 확인했다. 1주일 걸렸다. 일정 밀렸다. PM이 화났다. 그래도 어쩔 수 없다. 프로브 안 댔으면 '펌웨어 문제' 찾느라 3일 날렸다. 파형 보고 1시간 만에 하드웨어 이슈 확인했다. 진짜 문제가 뭔지 프로브가 알려준다. 프로브 없으면 일 못 한다 웹 개발자 친구들 부럽다. 걔네는 브라우저 개발자 도구 열면 된다. 콘솔 로그 찍으면 된다. 디버거 붙이면 된다. 나는 printf도 안 될 때가 있다. UART 핀 없으면 못 쓴다. 디버거 붙이면 타이밍 바뀐다. RTOS 멀티 태스킹 환경에서 브레이크포인트 걸면 다른 태스크가 영향 받는다. 그래서 프로브다. 비침습적이다. 신호 관찰만 한다. 시스템 동작 안 바뀐다. 타이밍 그대로 본다. 진짜 상황 본다. 프로브 선택도 중요하다. 10배 프로브 써야 한다. 1배는 입력 임피던스 낮아서 신호 왜곡된다. 고속 신호는 Active 프로브 써야 하는데 비싸다. 한 개에 200만원. 접지도 중요하다. 긴 접지 클립 쓰면 노이즈 잡힌다. 짧은 스프링 쓴다. 접지 루프 최소화. 파형 깨끗해진다. 트리거 설정도 익숙해져야 한다. Edge, Pulse Width, Setup/Hold. 프로토콜 디코딩도 쓴다. I2C, SPI, UART 자동 해석. 편하다. 요즘은 Logic Analyzer도 쓴다. Saleae. 8채널, 디지털 신호 동시에 본다. 프로토콜 여러 개 섞였을 때 좋다. 근데 아날로그는 못 본다. 그래서 오실로스코프도 필요하다. 새벽 1시, 파형 보다가 발견한다 양산 일정 2주 남았다. 펌웨어 베타 테스트 중이다. 간헐적 리셋 현상 있다. 재현이 안 된다. 30분에 한 번씩 랜덤하게 리셋된다. 로그 없다. 리셋되면 다 날아간다. Watchdog은 아니다. 꺼놨다. Hard Fault도 아니다. 핸들러 안 탄다. 전원 문제 의심했다. 오실로스코프 켜고 3.3V 라인 모니터링. 트리거를 하강 엣지로 설정. 3.0V 이하 떨어지면 캡처. 보드 돌린다. 한참 기다린다. 20분 지났다. 파형 떴다. 3.3V에서 2.8V까지 순간 떨어졌다가 복구. 100µs 정도. 리셋 전압이 2.9V. 그래서 리셋된 거다. 원인 찾아야 한다. 전원 노이즈다. BLE 송신할 때 순간 전류 증가. 20mA에서 80mA로 튄다. Decoupling Cap이 부족하다. 회로도 본다. 3.3V 라인에 10µF 1개. 부족하다. 100nF 세라믹 캐패시터 추가해야 한다. MCU 가까이. 민수 선배한테 말했다. "3.3V 라인에 100nF 추가 필요해요. BLE TX 때 노이즈 있어요." "파형 봤어요?" "네. 캡처했어요. 2.8V까지 떨어져요." "알았어요. 다음 리비전에 넣을게요." 리비전 기다릴 수 없다. 납땜했다. 100nF 캐패시터 MCU 바로 옆 GND-3.3V 사이. 플럭스 닦고 테스트. 2시간 돌렸다. 리셋 없다. 파형도 안정적이다. 프로브로 문제 찾고, 프로브로 해결 확인했다. 없었으면 못 찾았다. 퇴근 못 했다. 새벽 2시. 그래도 찾아서 후련하다. 이제 손의 연장이다 5년 하다 보니 프로브가 자연스럽다. 마우스처럼. 키보드처럼. 보드 보면 어디에 프로브 댈지 안다. 클럭 신호, 데이터 라인, 인터럽트 핀. 파형 보면 뭐가 문제인지 느낌 온다. 신입 때는 몰랐다. 파형 봐도 뭔지 모르겠고, 뭘 측정해야 할지 몰랐다. 선배들이 "여기 프로브 대봐" 하면 시키는 대로 했다. 지금은 안다. 타이밍 문제면 여러 채널 동시에 본다. 전원 문제면 AC 커플링 켜고 노이즈 본다. 프로토콜 문제면 디코더 쓴다. 후배한테도 가르쳐준다. "printf만 믿지 마. 파형 봐야 돼." "네, 선배님. 근데 어디를 봐야 하나요?" "일단 의심되는 신호부터. 클럭이면 클럭, 데이터면 데이터. 동시에 여러 개 보면 상관관계 보여." 후배가 프로브 들고 있는 모습 보면 신입 때 내 모습 같다. 어색하게 들고, 접지 클립 어디 물릴지 헤맨다. 시간 지나면 익숙해진다. 프로브는 도구다. 하지만 단순한 도구 아니다. 보이지 않는 세계를 보여주는 창이다. 전자 신호의 세계. µs 단위의 세계. Low 레벨의 진실. 책상 서랍에 프로브 5개 있다. 10배 2개, 1배 2개, 고주파용 1개. 다 쓴다. 프로젝트마다 필요한 게 다르다. 회사 장비실에 예전에 쓰던 아날로그 오실로스코프 있다. Tektronix 옛날 모델. 지금은 안 쓴다. 디지털이 편하다. 캡처하고, 저장하고, USB로 빼고. 그래도 가끔 아날로그 화면 보면 감성 있다. 장비는 계속 좋아진다. 예산 싸워서 이번에 200MHz 모델 신청했다. 승인 나면 4채널, 디지털 16채널 Logic Analyzer 포함. 기대된다.프로브 쥐면 일할 준비 됐다는 느낌. 이제 내 손의 일부다.

밤 11시, 여전히 'printf' 디버깅 중이다

밤 11시, 여전히 'printf' 디버깅 중이다

밤 11시, 여전히 'printf' 디버깅 중이다 시작은 단순했다 오후 3시쯤이었다. UART 통신이 간헐적으로 끊긴다는 보고가 올라왔다. "간단한 거 아니야?" 싶었다. 보드레이트 확인하고, 패리티 비트 체크하면 될 줄 알았다. printf("UART Start\n"); 일단 찍어봤다. 잘 나온다. printf("TX: %d\n", tx_count); 이것도 나온다. "문제없는데?" 생각한 게 5시간 전이다.printf의 함정 문제는 printf를 많이 찍기 시작하면서 생겼다. 센서 데이터를 실시간으로 보고 싶어서 루프 안에 넣었다. while(1) { sensor_data = read_sensor(); printf("Data: %d\n", sensor_data); HAL_Delay(100); }그런데 이상했다. 센서 값이 튀기 시작했다. 아니, printf 넣기 전에는 정상이었는데? 로그를 더 자세히 봤다. 타이밍이 이상하다. 100ms마다 찍혀야 하는데 가끔 200ms, 300ms씩 벌어진다. "printf가 블로킹이라 그런가?" UART 전송이 끝날 때까지 CPU가 기다린다. 당연히 느리다. 그래서 인터럽트 타이밍이 밀린 거다. 밤 7시. 저녁 먹으러 가자는 팀장님 말씀에 "조금만요" 했다.로그 메시지가 만든 버그 printf를 줄여봤다. 루프에서 빼고, 중요한 곳에만 넣었다. 그랬더니 또 다른 문제가 보였다. printf("Timer Start\n"); start_timer(); printf("Timer Running\n");타이머 시작하고 바로 다음 printf 찍는데, 타이머 인터럽트가 안 들어온다. 아니, 들어오긴 하는데 타이밍이 늦다. printf 하나에 몇 ms씩 걸리니까, 타이머 정밀도가 다 깨진 거다. "이래서 printf 디버깅 하지 말라는 거구나." 알면서도 할 수밖에 없다. JTAG 디버거는 느리고, 오실로스코프는 채널이 부족하고. 결국 printf가 제일 편하다. 밤 9시. 사무실에 나랑 옆자리 김 대리만 남았다. "형, 먼저 가세요." "너도 일찍 가라. 내일 또 있어." 말은 그렇게 하고 둘 다 안 간다.DMA로 바꿔보자 printf가 블로킹이라면 DMA를 쓰면 되지 않을까? UART DMA 설정을 켰다. 코드를 수정했다. char log_buffer[256]; sprintf(log_buffer, "Data: %d\n", sensor_data); HAL_UART_Transmit_DMA(&huart2, (uint8_t*)log_buffer, strlen(log_buffer));돌려봤다. 좋아졌다. ...라고 생각한 게 10시였다. 30분 돌리니까 또 문제다. 로그가 중간에 깨진다. "DatDaData: 123" 버퍼가 덮어써진 거다. DMA 전송이 끝나기 전에 다음 sprintf가 들어간 거다. "플래그 체크를 해야 하나?" if(uart_dma_ready) { sprintf(log_buffer, ...); HAL_UART_Transmit_DMA(...); uart_dma_ready = 0; }이것도 완벽하지 않다. 로그가 누락된다. 링 버퍼를 만들어야 하나? 지금 11시인데? 또 다른 버그의 발견 DMA 설정하면서 메모리 맵을 보다가 이상한 걸 발견했다. .bss 영역이 생각보다 크다. 40KB. 전역 변수를 확인해봤다. uint8_t rx_buffer[32768];누가 이렇게 큰 버퍼를 전역으로 잡았지? 커밋 히스토리를 뒤졌다. 3개월 전, 나였다. "당시엔 급했으니까..." 근데 이게 문제다. 이 버퍼 때문에 SRAM이 부족해서 힙 할당이 실패하고 있었다. 그래서 가끔 센서 초기화가 안 됐던 거다. printf 찍다가 발견한 완전히 다른 버그. 이거 고쳐야 한다. 지금. 밤 11시 반. 커피를 내렸다. 네 번째. 스택 오버플로우 검색 "uart dma buffer overwrite" 검색했다. 스택 오버플로우에 똑같은 질문이 있다. 2016년. 답변: "Don't use sprintf with DMA. Use a queue." 큐를 만들라고? 지금? 다른 답변: "Just use ITM/SWO for debugging." SWO? 그게 뭐지? 검색했다. ST-Link로 디버그 메시지를 보내는 기능이래. printf처럼 쓸 수 있는데 UART를 안 쓴다. "진작 알았으면..." 설정 방법을 찾아봤다. CubeMX에서 SYS 설정을 바꾸고, 스크립트를 추가하고... 30분 걸렸다. 안 된다. "지금 SWO 배우고 있을 시간이 아니지." 그냥 printf로 돌아갔다. 최소화해서 쓰자. 로그 레벨 구현 정신을 차렸다. 제대로 해야 한다. 간단한 로그 시스템을 만들었다. typedef enum { LOG_ERROR, LOG_WARN, LOG_INFO, LOG_DEBUG } log_level_t;log_level_t current_log_level = LOG_WARN;void log_print(log_level_t level, const char* fmt, ...) { if(level > current_log_level) return; char buffer[128]; va_list args; va_start(args, fmt); vsnprintf(buffer, sizeof(buffer), fmt, args); va_end(args); printf("[%d] %s", HAL_GetTick(), buffer); }이제 LOG_DEBUG는 릴리스 빌드에서 안 찍힌다. 중요한 것만 LOG_ERROR로 남긴다. 30분 돌려봤다. 괜찮다. 1시간 돌렸다. 문제없다. "이제 됐나?" 새벽 1시. 긴 테스트를 돌려놓고 화장실 갔다 왔다. 로그를 봤다. [ERROR] Sensor timeout "...또야?" 타이밍 문제의 본질 로그를 자세히 봤다. 패턴이 있다. 센서 타임아웃이 발생하는 시점마다 UART 로그가 몰려 있다. 아, printf가 많이 찍힐 때 센서 폴링이 늦어진 거다. 근본적인 해결책은 하나다. RTOS 태스크 분리. 센서 읽기는 우선순위 높은 태스크. 로그는 우선순위 낮은 태스크. "지금 RTOS 도입할 거야?" 양산까지 2주 남았다. 일단은 printf를 더 줄이자. 센서 읽는 부분에선 아예 안 찍자. 에러만 간단히. if(sensor_error) { error_count++; // printf 안 찍음 }나중에 error_count 값만 주기적으로 찍는다. // 10초마다 한 번 if(HAL_GetTick() - last_log_time > 10000) { log_print(LOG_INFO, "Errors: %d\n", error_count); last_log_time = HAL_GetTick(); }돌려봤다. 타임아웃이 안 난다. 새벽 1시 반. 드디어 해결된 것 같다. 문서화 코드에 주석을 달았다. // WARNING: printf는 블로킹. 센서 루프에서 사용 금지 // 로그 필요시 log_print() 사용 // 긴급 디버깅 시에만 printf 직접 사용그리고 위키에 정리했다. 펌웨어 디버깅 가이드printf는 최소화할 것 타이밍 크리티컬한 코드에서 로그 금지 DMA 사용 시 버퍼 오버라이트 주의 긴 테스트는 로그 레벨 낮춰서내일 팀원들한테 공유해야지. 아니, 오늘이네. 벌써 새벽 2시. "집 가자." 보드 전원을 껐다. 가방을 챙겼다. 모니터를 끄려는데 슬랙 알림이 왔다. 김 대리: "형 아직 있어요?" 나: "지금 퇴근" 김 대리: "저도요 ㅋㅋ 수고하셨습니다" 계단을 내려갔다. 경비실 아저씨가 놀란 표정으로 보신다. "아직도 일해요?" "네, 마무리하고 가는 길이에요." "고생이 많네. 조심히 가요." 밖은 추웠다. 11월 새벽 공기. 원룸까지 걸어가면서 생각했다. printf 하나 제대로 못 쓰는 게 펌웨어 개발이다. 웹 개발자들은 console.log 마음껏 찍잖아. 브라우저 콘솔에 다 나오잖아. 우린 printf 하나에 타이밍이 깨지고, DMA 설정하고, 버퍼 관리하고. "그래도 재밌긴 해." 집에 도착했다. 2시 10분. 샤워하고 침대에 누웠다. 내일... 아니 오늘 출근은 10시에 해야지. 눈을 감았다. 머릿속에 printf 로그가 스쳐 지나간다. [INFO] Sleep mode enteredprintf 디버깅. 간단한 것 같지만 절대 간단하지 않다. 내일도 또 찍겠지.