라즈베리파이 인터럽트 드라이버 후반부(Bottom-Half) 처리 원리 파헤치기
리눅스 커널 모듈 과제를 하다가 하드웨어 버튼 없이 인터럽트를 테스트해야 하는 상황이 생겨서, 커널 타이머로 인터럽트를 흉내 내는 방법을 공부하다가 결국 Tasklet, Work Queue, Threaded IRQ까지 후반부 처리 기법 전체를 파고들게 된 하루였습니다.
학습 주제
- 주제: 리눅스 커널 인터럽트 후반부(Bottom-Half) 처리 - Tasklet, Work Queue, Threaded IRQ
- 날짜: 2026년 8월 5일
- 배경: 4주차 과제가 GPIO 버튼 인터럽트 기반인데, 실습 환경에 버튼이 없어서 커널 타이머로 인터럽트 발생을 에뮬레이션하며 개념을 파고들었습니다.
탐구 과정
시작은 단순했습니다. 버튼이 없으니 request_irq() 대신 timer_setup()과 mod_timer()로 일정 주기마다 인터럽트가 발생한 것처럼 만들고, 그 안에서 Tasklet / Work Queue / Threaded 스레드 세 가지 방식으로 후반부를 처리하는 코드를 받았습니다.
코드 자체는 어렵지 않았는데, 코드에 등장하는 함수들을 하나씩 뜯어보다가 계속 막히는 지점들이 생겼습니다.
mod_timer에서mod는 대체 뭘 줄인 말인가jiffies가 인터럽트 발생 횟수라고 들었는데 왜 시간 단위처럼 쓰이는가del_timer_sync는 왜 굳이sync가 붙어있는가- Tasklet, Work Queue, Threaded 방식이 각자 자기 구조체(
tasklet_struct,work_struct,timer_list)를 갖는 이유는 뭔가 - 그 구조체 안에서 모든 일이 일어나는 건가
여기까지 정리하고 나니 더 헷갈리는 부분이 나왔습니다. 코드를 보면
tasklet_schedule(&my_tasklet); // 먼저 등장
mod_timer(&my_timer, jiffies + msecs_to_jiffies(2000)); // 나중에 등장
이렇게 스케줄링을 먼저 걸고 타이머를 연장하는데, 저는 "접수증(작업)을 넣었으면 그게 처리될 때까지 기다렸다가 다음 알람을 세팅해야 하는 거 아닌가?" 하는 생각이 들었습니다. 그리고 더 나아가서 "스케줄이라는 말 자체가 나중에 처리한다는 뜻 아닌가? 근데 실제로는 거의 즉시 실행되던데?"라는 의문도 생겼습니다.
마지막으로는 "그럼 Tasklet 안에서 무거운 작업을 하면 안 된다면서, 애초에 왜 후반부를 나눠서 쓰는 거지?"라는, 처음 질문으로 다시 돌아가는 듯한 근본적인 의문에 부딪혔습니다.
핵심 학습 내용
1. 용어부터 정리
mod_timer의mod: Modify(수정하다)의 줄임말입니다. 이미 등록된 타이머의 만료 시각을 새로 고쳐 쓰는 함수라서, 타이머 콜백 안에서 반복 호출하면 "주기적 타이머"처럼 동작하게 됩니다.jiffies가 시간 단위인 이유: 시스템 하드웨어 타이머는 초당 정해진 횟수(HZ)만큼 인터럽트를 발생시키고, 커널은 이 인터럽트가 한 번 터질 때마다jiffies를 1 증가시킵니다.HZ = 1000이면 1000번의 인터럽트가 곧 1초이므로, "2초 뒤"는 결국jiffies + 2*HZ시점을 의미합니다.del_timer_sync의sync: 다른 CPU 코어에서 해당 타이머 콜백이 이미 실행 중일 수 있는데, 그 실행이 끝날 때까지 기다렸다가 안전하게 제거한다는 뜻입니다. 이걸 안 쓰고 모듈을 내리면 실행 중인 콜백이 사라진 메모리를 참조하다가 커널 패닉이 날 수 있습니다.
2. 구조체 = 접수증, 커널 = 처리반
세 가지 후반부 기법(Tasklet, Work Queue, Threaded IRQ)이 각자 구조체를 갖는 이유는, 커널이 "무슨 함수를 실행할지 / 어떤 데이터를 넘길지 / 대기열 상 어디에 연결되어 있는지 / 지금 상태가 뭔지"를 기록해 둘 장부가 필요하기 때문입니다.
정리하면서 만든 비유가 도움이 많이 됐습니다.
구조체 = 병원 접수증, 커널/CPU/kworker = 실제 진료하는 의사
INIT_WORK()로 접수증을 작성하고, schedule_work()로 대기열 상자에 넣으면, 실제로는 백그라운드에서 도는 kworker 스레드가 그 접수증을 꺼내서 적힌 함수를 실행해줍니다. 구조체 자체는 아무 일도 하지 않고, 진짜 일은 커널의 스케줄러와 CPU가 합니다.
3. 코드 순서와 실행 순서가 다르다
가장 헷갈렸던 부분이 여기였습니다. 코드는
tasklet_schedule(&my_tasklet); // ① 먼저 작성됨
mod_timer(&my_timer, ...); // ② 나중에 작성됨
이렇게 되어 있는데, 실제 동작은 정반대로 느껴집니다.
mod_timer는 실행되자마자 "2초 뒤에 깨워줘"라고 하드웨어 타이머에 예약만 걸고 즉시 함수를 빠져나옵니다. 코드로는 뒤에 있지만 시간축으로 보면 "2초 대기"의 시작점입니다.tasklet_schedule은 대기열에 접수증을 넣는 순간이고, Tasklet은 수 마이크로초 내로 거의 즉시 실행되기 때문에 카운트 증가와 로그 출력이 눈 깜짝할 사이에 먼저 일어나 버립니다.
만약 접수증 처리가 끝나기를 기다렸다가 타이머를 연장한다면, 처리 시간만큼 다음 알람이 계속 밀리게 됩니다. 그래서 알람 담당(Top-Half)은 "접수증 던지기"와 "다음 알람 맞추기"만 순식간에 끝내고 바로 빠져나오고, 실제 처리는 완전히 별개의 흐름으로 진행됩니다.
또 하나 헷갈렸던 건 "2초 뒤"가 슬립(msleep) 상태인가 하는 부분이었는데, 아닙니다. mod_timer는 스레드를 재우는 게 아니라 하드웨어 타이머 칩에 예약만 걸어두고 즉시 리턴하는 비동기 방식입니다. CPU는 그 2초 동안 다른 작업을 처리하다가, 정확히 2초가 지나면 하드웨어 인터럽트로 다시 깨어나 timer_callback이 호출됩니다.
4. "스케줄"이라는 단어의 함정
tasklet_schedule()이라는 이름 때문에 "나중에 처리하겠다"는 느낌을 받기 쉬운데, 실제로는 "우선순위 대기열에 등록해서 지금 당장 가능한 가장 빠른 시점에 처리하라"는 뜻에 가깝습니다. 세 가지 방식의 체감 속도를 비교하면:
| 방식 | 실행 시점 |
|---|---|
| Tasklet | 사실상 즉시 (수 마이크로초 내) |
| Work Queue | 거의 즉시, CPU 상황에 따라 수 밀리초 지연 가능 |
| Threaded IRQ | 거의 즉시 (전용 스레드) |
5. 왜 Tasklet에서는 무거운 작업을 하면 안 되는데, 애초에 후반부를 나누는가
이 질문이 이번 학습에서 가장 중요한 지점이었습니다. "무겁다"는 말이 사실 두 가지 의미로 쓰인다는 걸 이해하고 나서야 풀렸습니다.
- 자야 하거나(sleep) 블로킹되는 작업 — 파일 I/O, 네트워크,
msleep(), 락 대기 등 - CPU를 오래 붙잡는 순수 연산 — 복잡한 계산, 대용량 루프 등
Top-Half(진짜 인터럽트 핸들러)는 실행되는 동안 다른 모든 하드웨어 인터럽트를 막아버립니다. 이 구간이 1ms만 길어져도 마우스가 씹히고 오디오가 끊기는 등 시스템 전체가 버벅입니다. Tasklet은 이 "다른 인터럽트를 막는 시간"을 최소화하기 위한 도구입니다 — 자면 안 되지만, 실행되는 동안 다른 인터럽트는 다시 허용됩니다.
반면 Work Queue와 Threaded IRQ는 독립된 커널 스레드/프로세스 문맥에서 돌기 때문에, 커널 스케줄러가 이 스레드를 재웠다가 다른 작업으로 전환할 수 있어서 msleep()이나 파일 I/O 같은 진짜 무거운 작업이 안전합니다.
정리하면 세 단계로 나뉩니다.
절대 끼어들 수 없어도 용납 가능한 짧은 일 → Top-Half
끼어들어도 되는 중간 일 → Tasklet
무거워서 아예 따로 프로세스/스레드로 돌리는 일 → Work Queue / Threaded IRQ
6. 예제 코드 - 세 가지 방식을 한 모듈에서 동시에 실행
static void timer_callback(struct timer_list *t)
{
pr_info("2초 타이머 알람 발생!\n");
tasklet_schedule(&my_tasklet); // ① 인터럽트 문맥, 즉시 실행
schedule_work(&my_work); // ② kworker 스레드가 처리
atomic_inc(&kthread_trigger);
wake_up_interruptible(&kthread_wq); // ③ 전용 스레드 깨움
mod_timer(&my_timer, jiffies + msecs_to_jiffies(2000));
}
세 방식 모두 timer_callback 안에서 등록되지만, 실행되는 문맥(인터럽트 문맥 vs 프로세스 문맥)이 다르기 때문에 각각 다른 제약을 받는다는 걸 로그로 직접 확인할 수 있었습니다.
이해한 내용
- 코드에 적힌 순서와 실제 실행되는 시간적 순서는 별개라는 것 — 특히 비동기 이벤트 기반 코드를 읽을 때는 "언제 실제로 실행되는가"를 따로 추적해야 합니다.
mod_timer,tasklet_schedule같은 함수들은 실제 작업을 수행하는 게 아니라 "예약/등록"만 하고 즉시 리턴하는 논블로킹 함수라는 것.- Tasklet과 Work Queue/Threaded IRQ의 차이는 단순히 "빠르다/느리다"가 아니라, 실행되는 문맥(인터럽트 문맥 vs 프로세스 문맥)에 따른 "할 수 있는 일의 범위" 차이라는 것.
- 원래 GPIO 기반 과제 코드와 타이머 에뮬레이션 코드는 100% 동일하지 않지만, 핵심인 후반부 스케줄링/처리 로직은 완전히 동일하다는 것 —
request_irq()자리만timer_setup()으로 바뀐 구조입니다.
실전 적용
- 과제 제출 시 보고서에 "하드웨어 버튼 부재로 커널 타이머를 이용해 Top-Half 이벤트를 에뮬레이션했다"는 점을 명시하면 될 것 같습니다.
- 나중에 실제 GPIO 버튼이 생기면,
timer_setup()/mod_timer()부분만request_irq()또는request_threaded_irq()로 바꿔서 동일한 후반부 로직을 그대로 재사용할 수 있을 것 같습니다. - 앞으로 인터럽트 관련 드라이버 코드를 볼 때 "이 작업이 sleep이 필요한가?"를 먼저 판단해서 Tasklet과 Work Queue/Threaded IRQ 중 뭘 써야 할지 고르는 기준으로 삼으려 합니다.
추가 학습 계획
- 실제
request_threaded_irq()를 GPIO 인터럽트에 적용해서, 오늘 kthread로 흉내 낸 부분과 실제 API가 내부적으로 어떻게 다른지 비교해보고 싶습니다. - SoftIRQ와 Tasklet의 관계를 좀 더 깊이 파보고 싶습니다 (Tasklet이 SoftIRQ 위에서 어떻게 구현되어 있는지).
- Work Queue의
system_wq와 커스텀 workqueue 생성(create_workqueue)의 차이도 다음에 다뤄보려 합니다.