Showing Posts From

임베디드

Stack 크기를 256에서 512로 늘렸더니 해결됐다

Stack 크기를 256에서 512로 늘렸더니 해결됐다

Stack 크기를 256에서 512로 늘렸더니 해결됐다 이상한 증상 출근했다. 어제 밤 11시까지 디버깅했는데 똑같다. 보드가 이상하다. 부팅은 된다. UART 로그도 찍힌다. 근데 5분 지나면 먹통이다. 규칙이 없다. 3분에 죽을 때도 있고 10분 버틸 때도 있다. 에러 문구? 없다. HardFault? 안 뜬다. 그냥 조용히 죽는다. "하드웨어 이슈 아닐까요?" HW팀에 물었다. 전원 확인했다. 괜찮다. 클럭도 정상이다. 온도도 문제없다. 결국 내 문제다.로그를 믿지 마라 printf 디버깅부터 시작했다. printf("Task A start\n"); // ... 작업 printf("Task A end\n");Task A는 정상이다. Task B도 정상이다. Task C에서 로그가 끊긴다. "Task C 문제네." 아니었다. Task C 코드를 주석 처리했다. 똑같이 죽는다. 그럼 Task D? 주석 처리. 여전히 죽는다. 3시간 썼다. 의미 없었다. FreeRTOS 쓰고 있었다. Task가 5개다. 우선순위도 다 다르다. 누가 죽이는지 모르겠다. printf는 거짓말한다. 버퍼에 쌓여 있다가 나중에 출력된다. 죽기 직전 로그는 안 나온다. JTAG도 힌트가 없다 오실로스코ープ는 소용없다. 신호는 정상이다. JTAG 붙였다. Breakpoint 걸었다. 한 줄씩 따라갔다. 멀쩡하다. Breakpoint 없이 돌리면? 죽는다. "타이밍 이슈인가?" Interrupt 의심했다. ISR 코드 확인했다. 간단하다. Flag만 세운다. void EXTI_IRQHandler(void) { flag = 1; HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_5); }문제없어 보인다.간헐적 버그의 지옥 이틀째다. 팀장이 물었다. "진행 상황이?" "디버깅 중입니다." "언제 끝나?" 모른다. 간헐적 버그는 예측 불가다. 재현이 안 된다. 5분에 죽을 때도 있고 30분 버틸 때도 있다. 규칙을 찾을 수가 없다. 코드 리뷰했다. 동적 할당? 안 쓴다. Malloc 금지다. 전역 변수? 있다. Mutex로 보호했다. 레이스 컨디션? 가능성 있다. 근데 어디서? 밤 10시까지 봤다. 못 찾겠다. 집에 와서도 생각났다. 샤워하다가도 생각했다. 어디가 문제일까. 메모리 주소를 봐라 셋째 날 아침이다. 다른 접근을 해야 한다. 생각을 바꿨다. "증상이 없는데 어떻게 추적하지?" 메모리를 봤다. RAM 사용량을 확인했다. .data: 2048 bytes .bss: 4096 bytes heap: 8192 bytes stack: ???? bytesStack은 런타임에 바뀐다. 컴파일러가 미리 계산 못 한다. Map 파일을 열었다. 각 Task의 Stack 크기가 나온다. TaskA: 256 bytes TaskB: 256 bytes TaskC: 512 bytes TaskD: 256 bytes TaskE: 256 bytesTaskA를 집중했다. 뭘 하는 Task인가? void TaskA(void *param) { char buffer[128]; while(1) { snprintf(buffer, 128, "Sensor: %d", read_sensor()); send_uart(buffer); vTaskDelay(1000); } }Buffer 128바이트다. Stack은 256바이트다. 괜찮아 보인다. 근데 snprintf는 내부적으로 Stack을 더 쓴다. 얼마나? 모른다. libc 구현마다 다르다. 혹시?256을 512로 Stack 크기를 늘렸다. #define TASK_A_STACK_SIZE 512 // was 256컴파일했다. 보드에 올렸다. 전원 켰다. 5분 지났다. 안 죽는다. 10분 지났다. 멀쩡하다. 30분 돌렸다. 정상이다. "이게 문제였어?" 믿기지 않았다. 다시 256으로 줄였다. 3분 만에 죽는다. 512로 늘렸다. 안 죽는다. 확정이다. Stack Overflow였다. 근데 이상하다. FreeRTOS는 Stack Overflow를 감지한다. configCHECK_FOR_STACK_OVERFLOW 옵션이 있다. 켜져 있었다. 왜 안 잡혔을까? 찾아봤다. FreeRTOS는 Context Switch 시점에만 체크한다. 그 사이에 오버플로우 나면 못 잡는다. TaskA는 1초마다 실행된다. 그 1초 사이에 Stack이 터진다. 감지 못 한다. 증상이 이상한 이유 Stack Overflow는 조용하다. 메모리를 덮어쓴다. 바로 크래시 안 난다. 다른 변수를 오염시킨다. 나중에 이상한 동작을 한다. 이번 케이스는 운이 나빴다. Stack 옆에 다른 Task의 제어 구조체가 있었다. 그걸 덮어썼다. FreeRTOS는 TCB(Task Control Block)를 RAM에 둔다. Stack Overflow가 TCB를 건드렸다. Task 스케줄링이 꼬였다. 시스템이 조용히 죽었다. 에러 메시지? 없다. HardFault? 안 뜬다. 그냥 멈춘다. 간헐적인 이유는 간단하다. 실행 경로가 매번 다르다. Sensor 값에 따라 snprintf 포맷팅이 다르다. "1"이랑 "1234"는 Stack 사용량이 다르다. 큰 값이 들어오면? Stack이 깊어진다. 터진다. 작은 값이면? 괜찮다. 규칙 없이 죽는 게 정상이었다. 교훈이랄 것도 없다 Stack 크기는 넉넉하게. 256은 작다. Printf 계열 함수 쓰면 최소 512는 줘야 한다. Floating point 쓰면 1024도 모자랄 수 있다. libc는 Stack을 많이 먹는다. 특히 가변 인자 함수들. snprintf, sscanf, sprintf... 다 조심해야 한다. 디버깅 교훈도 있다. 증상이 없으면 메모리를 봐라. 로그는 거짓말한다. 메모리는 거짓말 안 한다. FreeRTOS Stack Overflow 감지는 완벽하지 않다. 믿지 마라. 직접 계산하거나 넉넉하게 할당하거나. 해결했다. 팀장한테 보고했다. "Stack 크기 문제였습니다." "그게 3일 걸려?" 뭐라 하겠나. "네." 양산 전이라 다행이다 이거 양산 나갔으면 끝이었다. 현장에서 간헐적으로 죽는다? 재현 안 된다? 펌웨어 업데이트로 해결 안 된다? (RAM 크기는 고정이니까) 리콜이다. 보드 전부 회수다. 비용? 억 단위다. 다행히 개발 단계에서 잡았다. 양산 일정은 밀렸지만. 퇴근했다. 밤 11시다. 편의점에서 맥주 샀다. 집에서 혼자 마셨다. "Stack 512면 이제 괜찮겠지?" 모른다. 다른 Task도 체크해야 한다. 내일 할 일이다. 오늘은 여기까지다.Stack 256바이트. 펌웨어 개발자의 3일. 결국 숫자 하나였다.

디버그 UART를 잘못 설정했는데 2시간이 걸렸다

디버그 UART를 잘못 설정했는데 2시간이 걸렸다

디버그 UART를 잘못 설정했는데 2시간이 걸렸다 출근 평소보다 30분 일찍 왔다. 어제 코드가 거의 완성됐거든. 오늘은 UART로 로그 찍어서 동작 확인만 하면 된다. 간단하다고 생각했다. 커피 마시고 보드 켰다. 터미널 실행했다. 아무것도 안 나온다. '응?'기본 체크 먼저 기본부터. USB-to-Serial 어댑터 인식되나? 확인. /dev/ttyUSB0 있다. 케이블 연결됐나? 확인. TX-RX 크로스 맞다. 보드 전원 들어왔나? 확인. LED 켜져 있다. 터미널 열었다. 여전히 아무것도 안 나온다. 검은 화면만. '코드 문제인가?' 코드 다시 확인했다. UART_Init(115200, UART_PARITY_NONE, UART_STOPBITS_1); printf("System Start\n");문제없어 보인다. printf도 UART로 리다이렉트 해놨다. '하드웨어 문제?' 오실로스코프 꺼냈다. TX 핀에 프로브 연결. 파형 나온다. 뭔가 보내고 있다. '그럼 왜 안 보이지?'보레이트부터 가장 기본. 보레이트 설정. 코드: 115200 터미널: 115200 맞다. 그런데 혹시? 9600으로 바꿔봤다. 안 된다. 19200으로 바꿨다. 안 된다. 230400으로 올렸다. 역시 안 된다. 다시 115200으로 돌렸다. '보레이트는 아니네.' 시계 봤다. 벌써 1시간 지났다. 데이터 비트 8비트가 표준이다. 근데 혹시나. 터미널 설정 들어갔다.Data bits: 8 Parity: None Stop bits: 1다 맞다. '뭐가 문제야.' 코드 다시 봤다. UART_Init(115200, UART_PARITY_NONE, UART_STOPBITS_1);UART_Init 함수 정의로 들어갔다. void UART_Init(uint32_t baudrate, uint8_t parity, uint8_t stopbits) { // ... USART->CR1 |= (parity << 10); USART->CR2 |= (stopbits << 12); // ... }레지스터 설정 보인다. 문제없어 보인다. '그럼 뭐지?'스펙 문서 할 수 없다. Reference Manual 펼쳤다. STM32 RM0090. 1700페이지짜리. USART 챕터 찾았다. 30장. 페이지 넘기다가 한 문장 보였다. "The USART_CR1 register must be configured before enabling the USART." '당연한 소리 아닌가?' 더 읽었다. "When M bit is set, data length is 9 bits..." 'M 비트?' 코드 다시 봤다. USART->CR1 |= (parity << 10);비트 10번이 패리티 인에이블이다. 맞다. 그런데 비트 12번. "Bit 12: M - Word length" 이게 뭐지. 스펙 읽었다.0: 8 data bits 1: 9 data bits'아.' 문제 발견 코드 다시 확인했다. #define UART_PARITY_NONE 0x00 #define UART_PARITY_EVEN 0x01 #define UART_PARITY_ODD 0x03PARITY_ODD가 0x03이다. 이걸 비트 10에 시프트하면? 0x03 << 10 = 0x0C00 비트 10번이랑 11번이 켜진다. 아니다. 비트 11번이랑 12번이다. 12번이 M 비트다. '세상에.' 패리티 설정하면서 데이터 길이를 9비트로 바꿔버린 거다. 터미널은 8비트로 받는데 보드는 9비트로 보내니까 데이터가 안 맞는다. 그래서 깨진 텍스트만 나왔던 거다. 아니다. 아예 안 보였다. 완전히 동기화가 안 됐다. 수정 코드 수정했다. #define UART_PARITY_NONE 0x00 #define UART_PARITY_EVEN 0x01 #define UART_PARITY_ODD 0x01 // PE 비트만아니다. 이것도 이상하다. 함수 자체를 다시 짰다. void UART_Init(uint32_t baudrate, uint8_t parity, uint8_t stopbits) { // M 비트 클리어 USART->CR1 &= ~USART_CR1_M; // 패리티 설정 if (parity == UART_PARITY_EVEN) { USART->CR1 |= USART_CR1_PCE; USART->CR1 &= ~USART_CR1_PS; } else if (parity == UART_PARITY_ODD) { USART->CR1 |= USART_CR1_PCE; USART->CR1 |= USART_CR1_PS; } // 나머지 설정... }컴파일. 업로드. 리셋. 터미널 봤다. System Start GPIO Init Done Timer Init Done Ready나온다. '드디어.' 시계 봤다. 2시간 10분 지났다. 교훈 UART는 간단하다고 생각했다. TX, RX 연결하고 보레이트 맞추면 된다고. 틀렸다. 보레이트, 패리티, 스톱비트, 데이터 비트. 하나만 달라도 안 된다. 더 정확히는 레지스터 하나만 잘못 건드려도 전부 꼬인다. 비트 연산 조심해야 한다. Define 쓸 때 값 확인해야 한다. 스펙 문서 읽어야 한다. 처음부터. '오늘 할 일은 뭐였지?' 로그 찍고 기능 테스트하는 거였다. 로그 찍는 데만 2시간 썼다. 기능 테스트는 내일. 점심시간 지났다. 배고프다.UART 설정 하나에 오전이 날아갔다. 내일은 진짜 기능 개발이나 해야지.

타이머 인터럽트 0.5ms 오차, 누구 탓일까

타이머 인터럽트 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 잡았다. 뿌듯하다.타이밍 오차는 범인이 여럿이다. 하나씩 잡아야 한다.

대학 동기는 지금 SI로 칼퇴, 나는 여전히 야근

대학 동기는 지금 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" "펌웨어 너도 올래?" 올 수 없다. 아직 파형 보고 있다.다음 주 월요일, 미팅 때 보고할 자료는 만들어진다. 그리고 나는 또 야근한다. 동기들은 칼퇴하겠지.

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 디버깅. 간단한 것 같지만 절대 간단하지 않다. 내일도 또 찍겠지.