리눅스 커널 타이머 인터럽트, top half부터 bottom half 세 가지 기법까지
hrtimer 콜백에서 시작해서 tasklet, workqueue, threaded IRQ 세 가지 bottom half 기법의 속도 차이를 직접 측정해본 기록이다. 원리를 이해하는 것과 그 원리가 실측치로 얼마나 정직하게 드러나는지 확인하는 것은 완전히 다른 문제였다.
학습 주제
리눅스 커널의 인터럽트 처리 구조(top half / bottom half)와, bottom half를 구현하는 세 가지 방식(tasklet, workqueue, threaded IRQ)의 성능 차이를 hrtimer 기반 커스텀 모듈로 직접 측정해봤다. 학습 날짜는 2026년 8월 12일.
탐구 과정
처음 막힌 지점은 사소했다. "탑하프에서 프린트를 찍어도 되나, 이게 오버헤드로 측정치를 왜곡하지 않나" 하는 의심이었다. 알고 보니 printk와 trace_printk는 완전히 다른 무게의 함수였다. 이 부분을 짚고 나서야 측정 자체를 신뢰할 수 있었다.
그다음은 top half/bottom half의 시간 순서를 내 나름의 직관("CPU는 예약을 걸고 나중에 처리한다")으로 먼저 세워보고, 그게 정확히 어디서 어긋나는지 짚어가는 방식으로 이해를 다졌다. 그리고 나서 "hrtimer 콜백도 진짜 인터럽트처럼 방해받지 않는 자격이 있는 건가, 아니면 스케줄러가 적당히 끼워 넣어주는 건가"라는 질문으로 넘어갔는데, 이건 3주차에 배웠던 GPIO 인터럽트 구조를 다시 꺼내서 대조해보니 풀렸다.
디바이스 트리 쪽 개념(노드, platform, compatible)도 사실 1주차 내용인데 가물가물해서 다시 정리했고, 마지막으로는 insmod부터 첫 콜백 호출까지 전체 흐름을 순서대로 나열해보는 걸로 이해를 확인했다. 그리고 실제로 세 가지 기법을 반복 측정했는데, 처음 20사이클 결과와 나중에 10초씩 3회 반복한 결과가 순서가 뒤바뀌어서 당황했다. 이 부분이 이번 학습에서 가장 오래 붙잡고 있었던 지점이다.
핵심 학습 내용
top half와 bottom half
CPU가 다른 작업을 하다가 인터럽트가 들어오면, 그 순간 하던 일을 강제로 멈추고 즉시 반응한다. 이게 top half다. 방해받지 않고, 아주 짧게 끝나야 한다. top half의 마지막 임무는 "무거운 작업은 나중에 처리해줘"라고 예약을 거는 것이고, 이 예약된 작업이 bottom half다.
시간순으로 정리하면:
키보드 눌림
→ [즉시 실행: top half, 매우 짧음, 방해 불가]
→ (bottom half 예약)
→ CPU는 원래 하던 일로 복귀
→ ... 한참 뒤 ...
→ [예약된 bottom half 실행, 다른 일들처럼 스케줄링됨]
내가 처음 세웠던 "예약을 걸어놓고 CPU는 계속 오간다"는 직관이 절반만 맞았다. top half 자체가 예약이 아니라, top half가 마지막에 bottom half라는 예약을 거는 것이었다.
hrtimer 콜백도 진짜 인터럽트다
GPIO 버튼 예시에서는 "GPIO controller가 감지 → GIC가 CPU를 강제로 끌어당김 → 벡터 테이블 진입" 구조였다. 타이머도 똑같은데, GPIO controller 자리에 CPU 내장 카운터(ARM Generic Timer)가 들어간다는 점만 다르다.
주의할 점은 이 카운터가 알아서 무한 반복하는 게 아니라는 것이다. holy_cow_th_handler 마지막에서 hrtimer_forward_now()를 호출해 매번 다음 만료 시각을 재계산해서 다시 세팅하는 구조다. 정리하면:
CPU 내장 카운터(하드웨어)
→ 만료 시 GIC로 신호
→ GIC가 CPU를 강제로 끌어당김
→ 콜백 실행
→ 콜백 끝나기 전에 다음 만료 시각 재예약
노드 / platform / compatible
디바이스 트리에서 박스 하나하나가 노드다. holy_cow_timer도 루트 밑에 박스가 하나 더 추가되는 것뿐이다. platform은 전용 통신 프로토콜(I2C의 SDA/SCL, SPI의 MOSI/MISO/SCLK/CS) 없이 디바이스 트리 설명만 믿고 등록되는 장치들의 버스다. compatible은 그냥 문자열 비교다. 커널은 의미를 이해하지 못하고 글자가 똑같은지만 확인한다.
I2C는 2가닥 선을 여러 장치가 공유하되 장치마다 주소로 구분하는 방식(전화 교환대), SPI는 클럭/데이터선은 공유하되 장치마다 전용 Chip-Select 선이 따로 있는 방식이다. holy_cow_timer가 platform으로 등록된 이유는 실제 하드웨어가 없어서 애초에 전용 전선을 쓸 필요가 없기 때문이다.
insmod부터 첫 콜백까지 전체 흐름
.dts 작성
→ dtc로 .dtbo 컴파일
→ dtoverlay 명령으로 살아있는 커널의 메모리 속 디바이스 트리에 새 노드 접붙임
→ 이 순간 커널이 struct platform_device를 만들어 장치 명단에 등록 (아직 드라이버 없이 대기)
→ insmod
→ module_platform_driver()가 드라이버 명단에 등록
→ 매칭 로직이 즉시 compatible 문자열 비교
→ 일치하면 probe() 호출
→ probe 안에서 메모리 할당, 후반부 준비(kthread/work/tasklet), period-ms 읽기
→ 마지막으로 hrtimer_setup() + hrtimer_start()
→ 이 줄이 실행되기 전까진 카운터가 세팅조차 안 된 상태
→ 타이머가 실제로 돌기 시작, 설정된 주기 뒤 첫 콜백 호출
이 순서를 직접 나열해보고 나서야 "probe가 언제 불리고, 타이머가 언제부터 진짜로 도는지"가 명확해졌다.
세 가지 기법의 속도 차이가 나는 원리
- Tasklet: 하드웨어 인터럽트 처리가 끝나고 원래 하던 일로 복귀하기 직전, 커널이 항상 "대기 중인 SoftIRQ 있나?"를 확인하는 절차(
irq_exit())에 박혀 있다. 스케줄링 결정 자체가 없다. - Workqueue: 진짜 독립된 커널 스레드(kworker)를 깨워야 해서 컨텍스트 스위치가 필요하고, 그 워커가 다른 작업 중이면 줄을 서야 한다.
- Threaded IRQ: 컨텍스트 스위치는 필요하지만 전용 스레드라 다른 작업과 경쟁하지 않는다.
속도 차이의 축은 두 가지로 요약된다. "컨텍스트 스위치가 필요한가"와 "필요하다면 전용인가 공유인가".
이해한 내용
가장 크게 얻은 건 표준편차를 실제 데이터에 적용해서 읽는 감각이다. 처음 20사이클만 돌렸을 때는 세 방식이 깔끔한 순서로 나왔는데, 10초씩 3회 반복(더 큰 표본)으로 다시 측정하니 Threaded IRQ와 Workqueue 순서가 뒤집혔다.
Threaded IRQ total이 43.95±20.75, Workqueue total이 41.72±19.86으로 나왔을 때, 평균 차이(2.23us)가 각 그룹의 흔들림(±20us)보다 훨씬 작다는 게 핵심이었다. 반 A 평균 키가 170cm, 반 B가 172cm인데 각 반 안에 150~190cm가 다 섞여 있으면 그 2cm 차이로 "어느 반이 크다"고 말할 근거가 없는 것과 같은 상황이다. 반면 Tasklet의 gap(3.45±1.96)은 평균도 낮고 흔들리는 폭도 확실히 좁았다. 이건 우연이 아니라 메커니즘 자체가 다르기 때문이다 — 스케줄러 개입이 거의 없어서 매번 거의 똑같이 실행된다.
그리고 이 결과가 "정상"이라는 걸 확인한 것도 중요했다. Tasklet의 압도적 우위는 구현 방식과 무관하게 항상 나온다. 인터럽트 처리 절차 자체에 필수 단계로 박혀 있기 때문이다. Threaded IRQ와 Workqueue가 구분되지 않는 것도 사실 정상인데, 이 구현이 kthread_run()으로 만든 평범한 우선순위 스레드라서 kworker와 스케줄러 입장에서 딱히 구분될 이유가 없고, 인위적 부하 없는 idle 시스템이라 workqueue의 "공유 풀 경합" 단점도 잘 안 드러난다. 반대로 진짜 버그를 의심해야 할 신호는 Tasklet이 제일 느리게 나오거나, 표준편차가 음수거나, top half 시간이 bottom half 시간보다 긴 경우였다.
실전 적용
측정 스크립트(ftrace_measure.sh, parse_trace.py)를 만들어놨으니, 나중에 실제 부하가 걸린 시스템(다른 프로세스가 CPU를 많이 쓰는 상황)에서 다시 측정해서 workqueue의 경합이 실제로 드러나는지 비교해보고 싶다. 또 threaded IRQ 스레드에 우선순위를 높여서(sched_setscheduler 등) workqueue와 실제로 격차가 벌어지는지 재현해보는 것도 다음 실습으로 좋을 것 같다.
추가 학습 계획
- SoftIRQ 자체의 우선순위 구조와
ksoftirqd가 언제 개입하는지 더 깊이 보고 싶다. - 실제 프로덕션 드라이버들이 threaded IRQ에 우선순위를 어떻게 조정하는지(
IRQF_ONESHOT,RT스케줄링 관련) 찾아볼 계획. - workqueue의 공유 풀 경합이 실제로 드러나는 부하 시나리오를 만들어서 재측정.