인터럽트 후반부 기법을 적용하는 이유

인터러트 후반부 기법을 쓰는 이유에 대해 알아보기 전에 커널이 인터럽트를 어떤 방식으로 처리하는지 살펴볼 필요가 있습니다. 5장에서 배운 내용을 정리해보겠습니다.
   1. 인터럽트가 발생하면 커널은 실행 중인 프로세스를 멈추고 인터럽트 벡터를 실행해서 인터럽트 핸들러를 실행합니다.
   2. 인터럽트 핸들러는 짧고 빨리 실행해야 합니다.
   3. 인터럽트를 처리하는 구간이 인터럽트 컨택스트인데 이를 in_interrupt() 함수가 알려줍니다.

인터럽트 후반부 기법을 적용해야 하는 이유는 인터럽트 컨택스트에서 빨리 실행을 끝내야 하기 때문입니다. 인터럽트는 실행 중인 코드를 멈추고 인터럽트를 핸들링하기 때문입니다.

자연스럽게 임베디드 리눅스 개발자뿐만 아니라 임베디드 개발에서 다음과 같은 고민을 하게됐습니다.
인터럽트 핸들러나 인터럽트 컨택스트에서 어떻게 하면 빨리 코드를 처리해야 할까?

여러 고민 끝에 인터럽트가 발생하면 이를 처리할 코드를 2 단계로 나누게 됐습니다. 빨리 실행해야 할 코드는 인터럽트 컨택스트, 실시간으로 빨리 처리하지 않아도 되는 코드는 인터럽트를 핸들링한 다음에 실행하는 것입니다. 이 과정에서 다음과 같은 인터럽트 후반부 처리 기법을 이끌어낸 것입니다. 

이를 위해 커널은 다음과 같은 인터럽트 후반부 처리 방식을 지원합니다.
   - IRQ 스레드
   - Soft IRQ
   - 태스크릿
   - 워크큐

다음 소절에서는 인터럽트 컨택스트에서 시간이 오래 걸리는 코드를 수행하면 어떤 일이 발생하는지 살펴보겠습니다.

인터럽트 컨택스트에서 많은 일을 하면 어떻게 될까?

인터럽트 후반부 처리 기법을 왜 적용해야 하는지 설명드리기 위해 인터럽트 컨택스트에서 많은 일을 하는 코드를 적용하면 시스템이 어떻게 오작동하는지 소개합니다.

디바이스 드라이버를 개발하다 보면 인터럽트 컨택스트에서 인터럽트 핸들링을 할 때 많은 일을 하는 코드를 입력할 때가 있습니다. 대표적으로 다음과 같은 예를 들 수 있습니다.
   - I/O을 시작하는 코드
   - 과도한 while loop
   - 유저 공간으로uevent를 전달해서 인터럽트 발생을 알림
   - 스케줄링을 지원하는 커널 함수 호출

위와 같은 코드를 입력하면 시스템 반응 속도가 아주 느려지거나 평소에 볼 수 없는 오류 메시지를 볼 수 있습니다. 

인터럽트 핸들러와 서브 루틴에서 코드를 작성할 때 빨리 실행하는 코드를 입력해야 합니다. 여기서 중요한 의문점이 생깁니다.
인터럽트 컨택스트에서 인터럽트를 핸들링 하는 코드 실행 시간을 어떻게 측정할 수 있을까? 

이를 위해 ftrace 기능에서 지원하는 graph_function 트레이서 기능을 써서 인터럽트 핸들링을 할 때 얼마나 시간이 걸리는 지 측정할 필요가 있습니다. 이번 장 디버깅 장에서 이 방식에 대해 소개합니다.

심지어 인터럽트 컨택스트에서 스케줄링을 지원하는 함수를 쓸 경우 커널은 강제 커널 크래시를 유발합니다. 

여기서 인터럽트 핸들러에서 많은 일을 하다가 커널 패닉이 발생하는 예를 들어 보겠습니다.  다음 로그는 인터럽트 핸들러 실행 도중 발생한 커널 패닉 로그입니다. 함수가 일렬로 정렬해 있습니다.
01 [21.719319] [callstack mxt_interrupt,2449] task[InputReader]========= 
02 [21.719382] BUG: scheduling while atomic: InputReader/1039/0x00010001
03 [21.719417] (unwind_backtrace+0x0/0x144) from (dump_stack+0x20/0x24)
04 [21.719432] (dump_stack+0x20/0x24) from (__schedule_bug+0x50/0x5c)
05 [21.719444] (__schedule_bug+0x50/0x5c) from (__schedule+0x7c4/0x890)
06 [21.719455] (__schedule+0x7c4/0x890) from [<c0845d70>] (schedule+0x40/0x80)
07 [21.719468] (schedule+0x40/0x80) from [<c0843bc0>] (schedule_timeout+0x190/0x33c)
08 [21.719480] (schedule_timeout+0x190/0x33c) from (wait_for_common+0xb8/0x15c)
09 [21.719491] (wait_for_common+0xb8/0x15c) from (wait_for_completion_timeout+0x1c/0x20)
10 [21.719504] (wait_for_completion_timeout+0x1c/0x20) from (tegra_i2c_xfer_msg+0x380/0x958)
11 [21.719517] (tegra_i2c_xfer_msg+0x380/0x958) from (tegra_i2c_xfer+0x314/0x438)
12 [21.719531] (tegra_i2c_xfer+0x314/0x438) from (i2c_transfer+0xc4/0x128)
13 [21.719546] (i2c_transfer+0xc4/0x128) from (__mxt_read_reg+0x70/0xc8)
14 [21.719560] (__mxt_read_reg+0x70/0xc8) from (mxt_read_and_process_messages+0x58/0x1648)
15 [21.719572] (mxt_read_and_process_messages+0x58/0x1648) from (mxt_interrupt+0x78/0x144)
16 [21.719588] (mxt_interrupt+0x78/0x144) from (handle_irq_event_percpu+0x88/0x2ec)
17 [21.719601] (handle_irq_event_percpu+0x88/0x2ec) from (handle_irq_event+0x4c/0x6c)
18 [21.719614] (handle_irq_event+0x4c/0x6c) from (handle_level_irq+0xbc/0x118)
19 [21.719626] (handle_level_irq+0xbc/0x118) from (generic_handle_irq+0x3c/0x50)
20 [21.719642] (generic_handle_irq+0x3c/0x50) from (tegra_gpio_irq_handler+0xa8/0x124)
21 [21.719655] (tegra_gpio_irq_handler+0xa8/0x124) from (generic_handle_irq+0x3c/0x50)
22 [21.719669] (generic_handle_irq+0x3c/0x50) from (handle_IRQ+0x5c/0xbc)
23 [21.719682] (handle_IRQ+0x5c/0xbc) from (gic_handle_irq+0x34/0x68)
24 [21.719694] (gic_handle_irq+0x34/0x68) from (__irq_svc+0x40/0x70)

참고로 위 로그가 동작한 시스템은 엔비디아 Tegra4i SoC 디바이스입니다. 그래서 tegra가 붙은 함수들이 보입니다. 라즈베리파이 이외에 다른 리눅스 시스템에서 인터럽트를 어떻게 처리하는지 알면 좋으니 리눅스 시스템에서 발생한 문제를 소개합니다.

로그를 꼼꼼히 분석하겠습니다. 함수들이 줄 서 있는데 어느 부분 로그부터 읽어봐야 할까요? 함수들이 가장 먼저 실행된 순서로 정렬돼 있으니 가장 아랫부분 로그부터 봐야 합니다. 이제부터 5장에서 배운 내용을 떠 올리면서 로그 분석을 시작합니다.
 
가장 처음 실행된 함수 로그부터 보겠습니다. 24 번째 줄 코드를 눈으로 따라가 보면 __irq_svc 레이블이 보일 것입니다. 
21 [21.719655] (tegra_gpio_irq_handler+0xa8/0x124) from (generic_handle_irq+0x3c/0x50)
22 [21.719669] (generic_handle_irq+0x3c/0x50) from (handle_IRQ+0x5c/0xbc)
23 [21.719682] (handle_IRQ+0x5c/0xbc) from (gic_handle_irq+0x34/0x68)
24 [21.719694] (gic_handle_irq+0x34/0x68) from (__irq_svc+0x40/0x70)

인터럽트가 발생했다는 사실을 알 수 있습니다. 우리는 인터럽트가 발생하면 인터럽트 벡터인 __irq_svc가 실행한다는 것을 5장에서 배웠습니다. 또한 인터럽트 벡터인 __irq_svc 함수부터 실행된 콜스택(함수 흐름)이니 인터럽트 컨택스트입니다. 

다음 14~16 번째 로그를 보겠습니다.
14 [21.719560] (__mxt_read_reg+0x70/0xc8) from (mxt_read_and_process_messages+0x58/0x1648)
15 [21.719572] (mxt_read_and_process_messages+0x58/0x1648) from (mxt_interrupt+0x78/0x144)
16 [21.719588] (mxt_interrupt+0x78/0x144) from (handle_irq_event_percpu+0x88/0x2ec)

아래 로그로 인터럽트 핸들러로 mxt_interrupt() 함수가 호출됐다는 사실을 알 수 있습니다. 

우리는 5장에서 인터럽트 핸들러는 __handle_irq_event_percpu() 함수에서 호출한다고 배웠습니다. 그런데 위 로그에서는 handle_irq_event_percpu() 함수에서 인터럽트 핸들러를 호출합니다.

그 이유는 이 로그를 출력한 시스템의 리눅스 커널 버전이 3.10.77 버전이기 때문입니다. 다음 코드를 보면 5번째 줄 코드에서 인터럽트 핸들러를 호출합니다.
1 irqreturn_t
2 handle_irq_event_percpu(struct irq_desc *desc, struct irqaction *action)
3 {
...
4 do {
...
5 res = action->handler(irq, action->dev_id);

리눅스 커널 버전에 따라 인터럽트를 처리하는 함수가 달라질 수 있습니다.

다음 로그를 보겠습니다. 커널 패닉이 발생한 이유를 출력하고 있습니다. 
05 [21.719444] (__schedule_bug+0x50/0x5c) from (__schedule+0x7c4/0x890)
06 [21.719455] (__schedule+0x7c4/0x890) from [<c0845d70>] (schedule+0x40/0x80)
07 [21.719468] (schedule+0x40/0x80) from [<c0843bc0>] (schedule_timeout+0x190/0x33c)
08 [21.719480] (schedule_timeout+0x190/0x33c) from (wait_for_common+0xb8/0x15c)
09 [21.719491] (wait_for_common+0xb8/0x15c) from (wait_for_completion_timeout+0x1c/0x20)
10 [21.719504] (wait_for_completion_timeout+0x1c/0x20) from (tegra_i2c_xfer_msg+0x380/0x958)
11 [21.719517] (tegra_i2c_xfer_msg+0x380/0x958) from (tegra_i2c_xfer+0x314/0x438)
12 [21.719531] (tegra_i2c_xfer+0x314/0x438) from (i2c_transfer+0xc4/0x128)
13 [21.719546] (i2c_transfer+0xc4/0x128) from (__mxt_read_reg+0x70/0xc8)

함수 흐름으로 보아 wait_for_common() 함수를 호출해서 complete() 함수가 수행되기를 기다리는 상황입니다. 그런데 complete() 함수 호출을 안 하니 schedule_timeout() 함수를 호출합니다.

schedule_timeout() 함수에서 다음 순서로 함수를 호출합니다. 
__schedule() -> __schedule_bug() 

이번에 커널 패닉이 발생하는 이유를 알려주는 로그를 소개합니다. 
01 [21.719319] [callstack mxt_interrupt,2449] task[InputReader]========= 
02 [21.719382] BUG: scheduling while atomic: InputReader/1039/0x00010001

인터럽트 컨택스트에서 스케줄링을 하니 커널은 이를 감지하고 커널 패닉을 유발하는 것입니다.

02 번째 줄 로그를 보면 InputReader는 프로세스 이름, pid는 1039 그리고 0x00010001는 프로세스 struct thread_info구조체 preempt_count 필드 값입니다. 

프로세스 struct thread_info구조체 preempt_count 필드가 0x00010001 이므로 현재 실행 중인 코드를 인터럽트 컨택스트입니다. 0x00010001와 HARDIRQ_OFFSET(0x10000) 를 AND 비트 연산한 결과가 1이기 때문입니다.

위 로그에서 “scheduling while atomic”란 메시지가 보입니다. 메시지를 그대로 풀면 “아토믹 도중에 스케줄링 중이다.”라고 볼 수 있습니다.

여기서 atomic이란 무슨 의미일까요? 커널에서는 다음과 같은 상황을 atomic이라고 말합니다.
   - 선점 스케줄링이 되지 않는 실행 단위(어셈블리 명령어) 
   - 어떤 코드나 루틴이 실행 도중 스케줄링을 수행하면 안되는 컨택스트

커널에서는 인터럽트 컨택스트도 실행 도중 스케줄링하면 안되는 컨택스트로 판단합니다. 즉 인터럽트 컨택스트는 atomic operation입니다. 따라서 경고 메시지를 출력하는 겁니다. (atomic operation은 커널 동기화 장에서 자세히 다룹니다.)

정리하면 인터럽트 컨택스트에서 스케줄링을 시도하니 커널은 이를 감지하고 커널 패닉을 유발하는 겁니다. 

임베디드 개발에서 다음과 같은 고민이 생겼습니다.
인터럽트 컨택스트에선 빨리 일을 해야 하는데, 어떻게 해야 이런 문제를 피할 수 있을까? 

이 과정에서 Bottom Half와 Top Half란 개념을 정립했으며 이 기준으로 인터럽트 후반부 처리 기법을 적용하기 시작했습니다. 


Top Half/Bottom Half 란 무엇일까?

이전 소절에선 인터럽트 컨택스트에서 인터럽트 핸들링을 빠른 시간에 마무리해야 한다고 설명을 드렸습니다. 또한 시간이 오래 걸리는 함수를 호출하면 시스템은 커널 패닉과 같이 시스템이 오동작 할 수 있다고 소개했습니다.

여기서 한 가지 의문이 생깁니다.
인터럽트가 발생하면 인터럽트 핸들러에서 처리할 일이 많을 때는 어떻게 해야 할까? 

이럴 때 해야 할 일을 2가지로 나누면 됩니다. 
빨리 처리해야 하는 일과 조금 있다가 처리해도 되는 일입니다. 임베디드 용어로 인터럽트가 발생 후 빨리 처리해야 하는 일은 Top Half, 조금 있다가 처리해도 되는 일은 Bottom Half라고 말합니다. 

인터럽트 핸들러가 하는 일은 Top Half라고 할 수 있습니다. Bottom Half는 인터럽트에 대한 처리를 프로세스 레벨에서 수행하는 방식입니다. 

리눅스 커널에서 Bottom Half을 어떤 방식으로 구현할까요? 
인터럽트 핸들러는 일하고 있던 프로세스를 멈춘 시점인 인터럽트 컨택스트에서 실행합니다. 급하게 처리해야 할 일은 인터럽트 컨택스트에서 처리하고 조금 후 실행해도 되는 일은 프로세스 레벨에서 처리합니다. 이를 위해 커널에서 대표적으로 다음과 같은 기법을 인터럽트 후반부 기법으로 지원합니다.
   - IRQ 스레드
   - Soft IRQ
   - 워크큐

인터럽트 컨택스트와 커널 쓰레드 레벨에서 어떤 코드를 동작할 때 어떤 차이점이 있을까요?
우선 인터럽트 컨택스트에서는 호출할 수 있는 함수가 제한돼 있습니다. 

리눅스 커널에서는 인터럽트 컨택스트에서 많은 일을 하는 함수를 호출할 때 경고 메시지를 출력하거나 커널 패닉을 유발해서 시스템 실행을 중단시킵니다. 예를 들어 스케줄링을 지원하는 뮤텍스나 schedule() 함수를 쓰면 커널은 강제로 커널 패닉을 유발합니다.

뮤텍스 함수는 스케줄링 동작과 연관돼 있습니다. 프로세스가 뮤텍스를 획득하려고 시도하는데 만약 다른 프로세스가 이미 뮤텍스를 획득했으면 휴면에 진입합니다.

그런데 인터럽트 컨택스트에 비해 커널 쓰레드에서는 커널이 제공하는 스케쥴링을 포함한 모든 함수를 쓸 수 있습니다. 그래서 시나리오에 따라 유연하게 코드를 설계할 수 있습니다.

예를 들어 인터럽트가 발생했을 때 이를 유저 공간에 알리고 싶을 경우가 있습니다. 안드로이드 디바이스 같은 경우에 터치를 입력하면 발생하는 인터럽트를 uevent로 유저 공간에 알릴 수 있습니다. 유저 공간에 uevent를 보내는 동작은 시간이 오래 걸리는 일입니다. 따라서 시간이 오래 걸리는 코드는 인터럽트 후반부에서 처리하도록 드라이버 구조를 잡아야 합니다.

인터럽트 후반부 처리 기법 종류 및 소개

이번 장에서는 리눅스 커널이 Bottom Half을 처리하는 대표적인 기법인 IRQ 스레드와 Soft IRQ 기법을 다룹니다. 워크큐는 워크큐를 다루는 장에서 살펴볼 예정입니다. 세 가지 기법 인터럽트 후반부 처리하는 방식이 조금씩 다릅니다. 하지만 인터럽트 핸들러에서 해야 할 일을 2 단계로 나눈다는 점은 같습니다. 

이 세 가지 기법의 특징이 뭔지 알아보겠습니다.
IRQ 스레드
인터럽트를 처리하는 전용 IRQ 스레드에서 인터럽트 후속 처리를 합니다. 만약 rasp란 24번 인터럽트가 있으면 “irq/24-rasp”란 IRQ 스레드가 24번 인터럽트 후반부를 전담해서 처리합니다.

Soft IRQ
인터럽트 핸들러 실행이 끝나면 바로 일을 시작합니다. 인터럽트 핸들러 바로 처리해야 할 일을 마무리한 후 인터럽트 후반부 처리를 Soft IRQ 컨택스트에서 실행합니다. Soft IRQ 서비스 핸들러 실행 도중 시간이 오래 걸리면 ksoftirqd란 프로세스를 깨우면 Soft IRQ 서비스를 종료합니다. 
ksoftirqd란 프로세스에서 나머지 인터럽트 후반부를 처리하는 구조입니다.  

워크큐
인터럽트 핸들러가 실행될 때 워크를 워크큐에 큐잉하고 프로세스 레벨의 워커 쓰레드에서 인터럽트 후반부 처리를 하는 방식입니다.

그러면 위에서 세 가지 기법 중 어떤 방식을 인터럽트 후반부 처리로 적용해야 할까요?
사실 인터럽트를 처리하는 드라이버를 작성할 때 어떤 기법을 쓸 지는 드라이버 담당자의 몫입니다. 인터럽트 발생 빈도와 이를 처리하는 시나리오에 따라 위 세 가지 기법을 적절히 조합해서 드라이버 코드를 작성해야 합니다. 이를 위해서 인터럽트를 시스템에서 처리하는 방식과 인터럽트가 얼마나 자주 발생하는지를 알아야 합니다. 

리눅스 디바이스 드라이버를 개발할 때 다양한 하드웨어 디바이스를 인터럽트로 제어합니다. 또한 인터럽트를 관리하는 방식도 리눅스 시스템 마다 다릅니다.

이런 다양한 상황에서 어떤 인터럽트 방식을 적용해야 할지를 Q/A로 정리하면 다음과 같습니다.
Q: 인터럽트가 1초에 수 백번 발생하는 디바이스의 경우 어떤 인터럽트 후반부 기법을 적용해야 할까?

A: IRQ 스레드 방식과 워크큐 방식은 그리 적합하지 않습니다. IRQ 스레드는 RT 프로세스로 구동됩니다. 인터럽트가 많이 발생하면 IRQ 스레드를 깨워야 하고 IRQ 스레드는 RT 프로세스로 구동하므로 다른 프로세스들이 선점 스케줄링을 할 수 없습니다. IRT 스레드 핸들러 실행 시간이 조금이라도 길어지면 다른 프로세스들이 실행을 못하고 대기해야 하므로 시스템 반응 속도가 느려질 수 있습니다.

만약 IRQ 스레드 방식을 적용해야 한다면 IRQ 스레드 핸들러 함수 실행 시간이 매우 짧아야 합니다. 예를 들면, IRQ 스레드 핸들러 함수에 printk() 함수와 같이 커널 로그를 출력하는 코드도 되도록 입력하지 말아야 합니다.

또한 워크큐를 실행하는 워커 스레드는 일반 프로세스로 프로세스 우선 순위가 높지 않습니다.
인터럽트 발생 횟수만큼 워크 핸들러가 실행을 못할 수 있습니다.

따라서 인터럽트가 자주 발생하는 디바이스는 Soft IRQ나 태스크릿 방식을 적용하는 것이 바람직합니다.

Q. 현재 개발 중인 시스템은 인터럽트 개수가 200개 정도된다. 어떤 방식을 적용하면 좋을까?

1초에 인터럽트가 수 백번 발생하는 경우를 제외하곤 IRQ 스레드 방식을 적용하면 별 문제가 없습니다. 그런데 인터럽트 개수만큼 IRQ 스레드를 생성하면 기본으로 프로세스를 관리할 때 필요한 태스크 디스크립터와 같은 메모리 공간을 써야 합니다. 만약 현재 개발 중인 시스템 RAM 용량이 8G 이상이면 별 문제가 되지 않을 것입니다.
인터럽트가 발생 빈도가 낮고 빠른 시간에 인터럽트 후반부를 처리하지 않아도 될 경우 워크큐 기법을 적용하는 것도 좋습니다.

어떤 인터럽트 후반부 방식을 적용할지 결정하는 것은 어려운 일입니다. 정답이 없기 때문입니다. 이런 상황에서 필요한 것이 최적화인 것 같습니다. 인터럽트 후반부 단계에서 인터럽트 처리를 최적화하도록 설계를 잘 하려면 먼저 커널이 인터럽트를 처리하는 세부 동작과 인터럽트 후반부 기법들의 세부 구현 방식을 잘 알고 있어야 합니다.

다음 절에서 인터럽트 후반부 기법 중 IRQ 스레드 방식을 살펴보겠습니다.
 


+ Recent posts