인터럽트 우선순위 때문에 3시간을 고민했다
- 11 Dec, 2025
인터럽트 우선순위 때문에 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 인터럽트 GPIO
100ms마다 정확히 찍힌다. 오차 없다. UART 인터럽트 들어와도 상관없다. DMA가 하드웨어로 처리하니까.
로그 찍어봤다. 100.0ms, 100.0ms, 100.0ms… 완벽하다.
배터리 전압도 안정적이다. 90%면 90%, 89%면 89%. 튀지 않는다.
오후 6시였다.
팀장한테 보고했다
“됐습니다.”
“테스트는?”
“오실로스코프로 확인했습니다. 100ms 정확합니다.”
“좋아. 내일 통합 테스트 넣어.”
“네.”
커피 한 잔 더 마셨다. 네 번째였다.
배운 것
인터럽트 우선순위는 신중하게 정해야 한다.
높은 우선순위가 낮은 우선순위를 블락한다. 당연한 건데 실제로 부딪히니까 다르다.
타이밍 크리티컬한 작업은 인터럽트에 의존하면 안 된다. 특히 다른 인터럯트가 많으면.
DMA를 쓰면 CPU 부하도 줄고 타이밍도 보장된다. 하드웨어 트리거 쓰면 더 좋다.
레퍼런스 매뉴얼은 끝까지 읽어야 한다. “Note:” 부분이 중요하다. 거기에 초기화 순서 같은 게 나온다.
3시간 걸렸다. 길다. 하지만 양산 나가서 리콜되는 것보단 낫다.
퇴근
저녁 8시에 나왔다. 일찍 나온 거다.
편의점에서 맥주 샀다. 집 와서 샤워하고 마셨다.
내일은 통합 테스트다. HW팀이랑 같이 실제 배터리 연결해서 돌려본다. 또 문제 나올 수도 있다.
근데 오늘은 해결했다. 그걸로 됐다.
맥주 한 모금 더 마셨다. 시원했다.
인터럽트 우선순위, 생각보다 복잡하다.
