시스템 콜의 특징

이번 절에서는 시스템 콜의 특징을 알아보겠습니다.

앞서 알아봤듯이 시스템 콜은 유저 모드에서 커널 모드로 진입하는 관문입니다. 소프트웨어 구조 관점에서 보면 시스템 콜은 유저 공간과 커널 공간 사이의 가상 계층으로 볼 수도 있습니다. 이 계층은 다음과 같은 특징이 있습니다.

1. 시스템 콜 계층으로 시스템 안정성과 보안을 지킬 수 있습니다. 유저 모드에서 애플리케이션이 커널 공간에 아무런 제약 없이 접근한다고 가정해 봅시다. 실수로 애플리케이션이 커널 코드 영역의 메모리를 오염시키면 시스템이 오동작할 가능성이 높습니다. 그래서 유저 모드에서 시스템 콜로만 커널 모드에 진입해서 제한된 메모리 공간에 접근하는 것입니다.

2. 유저 애플리케이션에서 추상화된 하드웨어 인터페이스를 제공합니다. 유저 모드에서 구동 중인 애플리케이션 입장에서 하나의 파일 시스템 위에서 구동 중인 것으로 착각하게 합니다.

3. 시스템 콜 구현으로 유저 애플리케이션의 호환성과 이식성을 보장할 수 있습니다. 대부분의 리눅스 배포판은 시스템 콜 인터페이스를 POSIX(Portable Operating System Interface)라는 유닉스 표준 규약에 맞게 구현합니다. 이를 통해 유저 애플리케이션 코드를 라즈베리 파이, 안드로이드 등 리눅스 계열의 시스템과 유닉스 운영체제에서도 구동할 수 있습니다.

4. 유저 공간에서 실행되는 애플리케이션은 커널과 독립적으로 구동됩니다. 유저 애플리케이션 입장에서는 파일 시스템과 프로세스 생성과 같은 내부 동작에 신경 쓸 필요가 없습니다. 

또한 시스템 콜은 ARM 아키텍처와 연관이 깊은 동작입니다. ARM 프로세서는 시스템 콜을 익셉션의 한 종류인 소프트웨어 인터럽트로 실행하기 때문입니다. ARM 프로세스 관점에서 시스템 콜을 어떻게 구현했는지 함께 살펴봅시다.

이전 절에서 시스템 콜을 구성하는 주요 개념을 알아봤습니다. 이번에는 시야를 넓혀 전체 리눅스 시스템에서의 시스템 콜 실행 흐름을 살펴보겠습니다.  


시스템 콜의 전체 흐름 파악하기

 


다음 그림은 이번 장에서 다룰 시스템 콜의 전체 흐름입니다.

 

 
그림 11.2 시스템 콜의 전체 흐름

먼저 위 그림에서 유저 공간이라고 표시된 부분을 눈으로 따라가 봅시다.

open(), write(),read() 함수는 파일을 열거나 읽고 쓰는 파일 입출력 동작이고, fork()와 exit() 함수는 프로세스 생성 및 종료와 연관된 동작을 실행합니다. 이를 리눅스 저수준 함수라고 부릅니다. 다른 관점에서 GNU C 라이브러리로 진입하는 함수이며, API(Application Programming Interface)라고도 합니다.


리눅스 시스템에는 390여 개의 표준 함수가 있는데 위 그림에서 대표적인 함수 5개를 표현한 것입니다.

라즈베리 파이에서 다음 파일을 열어보면 시스템 콜 번호를 확인할 수 있습니다.

/usr/include/arm-linux-gnueabihf/asm/ unistd-common.h
#define __NR_restart_syscall  (__NR_SYSCALL_BASE+  0)
#define __NR_exit (__NR_SYSCALL_BASE+  1)
#define __NR_fork (__NR_SYSCALL_BASE+  2)
...
#define __NR_pkey_mprotect (__NR_SYSCALL_BASE+394)
#define __NR_pkey_alloc (__NR_SYSCALL_BASE+395)
#define __NR_pkey_free (__NR_SYSCALL_BASE+396)

 


시스템 콜의 세부 실행 단계

 

 


시스템 콜을 제대로 이해하려면 시스템 콜을 발생시키는 유저 공간부터 시스템 콜 핸들러를 실행하는 커널 공간 계층까지 전체 흐름을 살펴볼 필요가 있습니다. 시스템 콜의 동작 흐름은 크게 4단계로 나눌 수 있습니다.

1단계: 리눅스 저수준 표준 함수 호출


유저 애플리케이션에서 파일 시스템에 접근해서 파일을 열고 읽고 쓰려면 open(), write(), read() 함수를 각각 호출해야 합니다. 혹은 프로세스를 생성하거나 종료할 때 fork()나 exit() 함수를 호출합니다. 이 함수들을 API라고 하며, 유저 애플리케이션에서 리눅스 커널에서 제공하는 기능을 사용하기 위해 만든 인터페이스를 의미합니다. 이 인터페이스는 모두 리눅스 시스템에서 제공하는 GNU C 라이브러리 내부에 구현돼 있습니다.

2단계: 유저 공간에서 시스템 콜 실행


리눅스 시스템의 저수준 함수를 호출하면 GNU C 라이브러리 내부에 있는 어셈블리 코드가 실행됩니다. 이때 시스템 콜이 발생합니다. 이 과정을 제대로 이해하려면 어떤 ARM 어셈블리 명령어로 시스템 콜을 발생시키는지 살펴볼 필요가 있습니다.

3단계: 커널 공간에서 시스템 콜 실행

 

시스템 콜이 실행되면 커널 공간으로 이동해 시스템 콜 테이블에 접근합니다. 이 시스템 콜 테이블로 시스템 콜 번호에 해당하는 시스템 콜 핸들러 함수로 분기됩니다. 시스템 콜 동작에 따라 호출되는 시스템 콜 핸들러 함수는 다음과 같습니다.

 가상 파일 시스템: sys_open()/sys_write()/sys_read() 함수
 프로세스 생성 및 종료: sys_clone()/sys_exit_group() 함수


시스템 콜 핸들러 함수는 리눅스 저수준 함수 앞에 sys_ 접두사가 붙는 경우가 대부분입니다. write() 함수의 시스템 콜 핸들러는 sys_write() 함수이고, read() 함수의 시스템 콜 핸들러는 sys_read() 함수입니다. 하지만 모든 시스템 콜 핸들러 함수가 이 규칙을 따르지는 않습니다. 리눅스 저수준 함수인 fork()의 경우 sys_clone() 시스템 콜 핸들러가 실행됩니다.

4단계: 시스템 콜 핸들러 실행

 

시스템 콜 핸들러에서는 유저 공간에서 전달한 인자에 오류가 있는지 체크합니다. 이후 시스템 콜의 종류에 따라 가상 파일 시스템 계층이나 프로세스 관리 함수에 접근합니다.

여기까지 유저 공간에서 커널 공간까지 시스템 콜의 처리 과정을 알아봤습니다.

ftrace와 커널 로그로 인터럽트 컨텍스트 확인해보기

이번 절에서는 ftrace 로그를 분석하면서 커널이 인터럽트를 어떻게 처리하는지 알아봅시다. 


리눅스 커널에서 커널 동작을 가장 정밀하게 담고 있는 로그는 뭘까요? 아마 많은 리눅스 전문가들은 ftrace라고 대답할 겁니다. ftrace는 리눅스 커널에서 제공하는 가장 강력한 디버그 로그입니다. 리눅스 커널의 공식 트레이서이기도 합니다. 여러분도 ftrace 로그를 자주 활용해서 리눅스 커널을 익히기를 바랍니다.


ftrace로 인터럽트를 처리하는 인터럽트 핸들러 함수에 필터를 걸고 콜 스택 로그를 받아 보겠습니다. 

인터럽트 동작을 확인하기 위한 ftrace 설정
ftrace로 인터럽트의 동작 방식을 분석하기 전에 ftrace를 설정하는 방법을 소개합니다. 먼저 다음 명령어를 입력해 봅시다.

#!/bin/bash

echo 0 > /sys/kernel/debug/tracing/tracing_on
sleep 1
echo "tracing_off"

echo 0 > /sys/kernel/debug/tracing/events/enable
sleep 1
echo "events disabled"

echo  secondary_start_kernel  > /sys/kernel/debug/tracing/set_ftrace_filter
sleep 1
echo "set_ftrace_filter init"

echo function > /sys/kernel/debug/tracing/current_tracer
sleep 1
echo "function tracer enabled"

echo dwc_otg_common_irq > /sys/kernel/debug/tracing/set_ftrace_filter
sleep 1
echo "set_ftrace_filter enabled"

echo 1 > /sys/kernel/debug/tracing/events/irq/irq_handler_entry/enable
echo 1 > /sys/kernel/debug/tracing/events/irq/irq_handler_exit/enable
echo "event enabled"

echo 1 > /sys/kernel/debug/tracing/options/func_stack_trace
echo "function stack trace enabled"

echo 1 > /sys/kernel/debug/tracing/tracing_on
echo "tracing_on"

 

 



이 같은 명령어를 입력한 후 irq_stack_trace.sh라는 이름으로 저장합니다. 그러고 나서 다음과 같은 명령어를 입력해 irq_stack_trace.sh 셸스크립트를 실행하면 ftrace를 빨리 설정할 수 있습니다.

root@raspberrypi:/home/pi # ./irq_stack_trace.sh

ftrace 설정 명령어 중 다음 코드를 함께 봅시다.

echo dwc_otg_common_irq > /sys/kernel/debug/tracing/set_ftrace_filter

이 명령어는 set_ftrace_filter에 다음 함수를 설정합니다.

dwc_otg_common_irq()

dwc_otg_common_irq() 함수가 호출될 때 함수 콜스택을 ftrace로 보기 위해 set_ftrace_filter 파일에 함수를 지정하는 것입니다.

이어서 ftrace를 받는 방법을 소개합니다.

#!/bin/bash

echo 0 > /sys/kernel/debug/tracing/tracing_on
echo "ftrace off"

sleep 3

cp /sys/kernel/debug/tracing/trace . 
mv trace ftrace_log.c

위 명령어를 입력해 get_ftrace.sh 셸 스크립트로 저장합니다. 그러고 나서 다음 명령어로 이 셸 스크립트를 실행하면 같은 폴더에 ftrace 로그를 저장한 ftrace_log.c 파일이 만들어집니다.

root@raspberrypi:/home/pi # ./get_ftrace.sh 

지금까지 설명한 실습 과정을 정리해 봅시다.

1. irq_stack_trace.sh 셸 스크립트를 실행해 ftrace를 설정한다.
2. get_ftrace.sh 셸 스크립트를 실행해 ftrace 로그를 받는다.

라즈베리 파이에서 받은 ftrace로 인터럽트 컨텍스트 확인
이제 ftrace 로그 분석을 시작하겠습니다. 먼저 ftrace 로그를 소개합니다.

1 kworker/0:0-27338 [000] d.h.  6028.897808: irq_handler_entry: irq=56 name=dwc_otg
2 kworker/0:0-27338 [000] 6028.897809: dwc_otg_common_irq <-__handle_irq_event_percpu
3 kworker/0:0-27338 [000] 6028.897847: <stack trace>
4  => handle_irq_event
5  => handle_level_irq
6  => generic_handle_irq
7  => bcm2836_chained_handle_irq
8  => generic_handle_irq
9  => __handle_domain_irq
10 => bcm2836_arm_irqchip_handle_irq
11 => __irq_svc
12 => _raw_spin_unlock_irqrestore
13 => _raw_spin_unlock_irqrestore
14 => schedule_timeout
15 => wait_for_common
16 => wait_for_completion_timeout
17 => usb_start_wait_urb
18 => usb_control_msg
19 => __usbnet_read_cmd
20 => usbnet_read_cmd
21 => __smsc95xx_read_reg
22 => __smsc95xx_phy_wait_not_busy
23 => __smsc95xx_mdio_read
24 => check_carrier
25 => process_one_work
26 => worker_thread
27 => kthread
28 => ret_from_fork


ftrace 로그를 보면 어느 로그부터 분석해야 할지 의문이 앞섭니다. 이때 염두에 둘 점은 아래에 있는 함수에서 위에 있는 함수 쪽으로 함수가 호출된다는 것입니다. 즉, ret_from_fork() 함수가 맨 먼저 실행된 후 다음과 같은 순서로 함수가 호출된 것입니다.

 kthread → worker_thread → process_one_work

이후 handle_level_irq() → handle_irq_event()→ __handle_irq_event_percpu()→ dwc_otg_common_irq() 순서로 함수가 호출됐습니다.  

다음 ftrace 로그는 조금 헷갈릴 수 있어 상세히 볼 필요가 있습니다.

2 kworker/0:0-27338 [000] 6028.897809: dwc_otg_common_irq <-__handle_irq_event_percpu
3 kworker/0:0-27338 [000] 6028.897847: <stack trace>
4  => handle_irq_event
5  => handle_level_irq

handle_irq_event() 함수까지 함수 호출이 수행된 듯합니다. 실제로는 다음 흐름으로 맨 마지막에 실행된 함수는 dwc_otg_common_irq()입니다. 함수 흐름은 다음과 같습니다.
handle_irq_event → __handle_irq_event_percpu → dwc_otg_common_irq


먼저 1번째 줄을 보겠습니다.

1 kworker/0:0-27338 [000] d.h.  6028.897808: irq_handler_entry: irq=56 name=dwc_otg

위 ftrace 메시지는 다음과 같은 사실을 말해줍니다.
 pid가 27338인 kworker/0:0 프로세스 실행 중 인터럽트가 발생
 인터럽트 번호는 56번이고 이름은 dwc_otg
 인터럽트 핸들러가 실행을 시작한 시간은 6028.897808임

이번에는 콜 스택을 볼 차례입니다. 콜 스택에서는 맨 먼저 호출된 함수부터 봐야 하니 로그의 가장 아랫부분부터 봐야 합니다. 

12 => _raw_spin_unlock_irqrestore
13 => _raw_spin_unlock_irqrestore
14 => schedule_timeout
15 => wait_for_common
16 => wait_for_completion_timeout
17 => usb_start_wait_urb
18 => usb_control_msg
19 => __usbnet_read_cmd
20 => usbnet_read_cmd
21 => __smsc95xx_read_reg
22 => __smsc95xx_phy_wait_not_busy
23 => __smsc95xx_mdio_read
24 => check_carrier
25 => process_one_work
26 => worker_thread
27 => kthread
28 => ret_from_fork

위 ftrace 로그는 인터럽트가 발생하기 전의 함수 호출 흐름입니다. 콜스택을 보니 kworker/0:0 프로세스가 실행 중입니다. check_carrier() 워크 핸들러 함수가 호출된 후 USB 드라이버가 동작 중입니다. 

이어서 인터럽트가 발생하고 난 후의 로그를 보겠습니다. 

1 kworker/0:0-27338 [000] d.h.  6028.897808: irq_handler_entry: irq=56 name=dwc_otg
2 kworker/0:0-27338 [000] 6028.897809: dwc_otg_common_irq <-__handle_irq_event_percpu
3 kworker/0:0-27338 [000] 6028.897847: <stack trace>
4  => handle_irq_event
5  => handle_level_irq
6  => generic_handle_irq
7  => bcm2836_chained_handle_irq
8  => generic_handle_irq
9  => __handle_domain_irq
10 => bcm2836_arm_irqchip_handle_irq
11 => __irq_svc
12 => _raw_spin_unlock_irqrestore

여기서 어떤 함수가 실행되던 도중에 인터럽트가 발생한 것일까요? 이 질문을 받으면 다음과 같이 대답할 수 있습니다. 

_raw_spin_unlock_irqrestore() 함수 실행 중 "irq=56 name=dwc_otg" 인터럽트가 발생했다.
 
ARM 프로세스는 인터럽트가 발생하면 익셉션을 유발해 __irq_svc 인터럽트 벡터를 실행합니다. 이후 리눅스 커널 내부의 인터럽트를 처리하는 커널 내부의 함수가 다음 순서로 호출되는 것입니다.

 handle_level_irq()
 handle_irq_event()
 __handle_irq_event_percpu()
 dwc_otg_common_irq()

이후 “irq=56 name=dwc_otg” 인터럽트를 처리하는 인터럽트 핸들러인 dwc_otg_common_irq() 함수를 호출합니다.

다소 복잡해 보이는 ftrace 로그를 그림으로 정리하면 다음과 같습니다. 

 

 


그림 5.6 인터럽트 발생 시 프로세스 스택 공간에서의 함수 호출 흐름  

그림 5.6은 56 번 인터럽트가 발생한 후의 함수 실행 흐름입니다. 오른쪽 상단에 인터럽트 컨텍스트라고 표시된 함수 구간이 있습니다. 이 구간에서 어떤 함수가 실행 중이면 '현재 인터럽트 컨텍스트다'라고 말할 수 있습니다.

ftrace 로그와 위 그림을 토대로 전체 실행 흐름은 다음과 같이 정리할 수 있습니다. 

 pid가 27338인 kworker/0:0 프로세스가 _raw_spin_unlock_irqrestore() 함수를 실행
 "irq=56 name=dwc_otg" 인터럽트가 발생해 인터럽트 벡터인 __irq_svc() 함수로 브랜치
 인터럽트 핸들러인 dwc_otg_common_irq() 함수가 실행됨

커널 로그를 이용한 인터럽트 컨텍스트 확인
이번에는 다른 리눅스 시스템에서 추출한 커널 로그를 보면서 인터럽트 컨텍스트를 배워봅시다. 먼저 커널 로그를 소개합니다.

https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux-stable.git/commit/?id=bbe097f092b0d13e9736bd2794d0ab24547d0e5d

WARNING: CPU: 0 PID: 0 at include/linux/usb/gadget.h:405
 ecm_do_notify+0x188/0x1a0
 Modules linked in:
 CPU: 0 PID: 0 Comm: swapper Not tainted 4.7.0+ #15
 Hardware name: Atmel SAMA5
 [<c010ccfc>] (unwind_backtrace) from [<c010a7ec>] (show_stack+0x10/0x14)
 [<c010a7ec>] (show_stack) from [<c0115c10>] (__warn+0xe4/0xfc)
 [<c0115c10>] (__warn) from [<c0115cd8>] (warn_slowpath_null+0x20/0x28)
 [<c0115cd8>] (warn_slowpath_null) from [<c04377ac>] (ecm_do_notify+0x188/0x1a0)
 [<c04377ac>] (ecm_do_notify) from [<c04379a4>] (ecm_set_alt+0x74/0x1ac)
 [<c04379a4>] (ecm_set_alt) from [<c042f74c>] (composite_setup+0xfc0/0x19f8)
 [<c042f74c>] (composite_setup) from [<c04356e8>] (usba_udc_irq+0x8f4/0xd9c)
 [<c04356e8>] (usba_udc_irq) from [<c013ec9c>] (handle_irq_event_percpu+0x9c/0x158)
 [<c013ec9c>] (handle_irq_event_percpu) from [<c013ed80>] (handle_irq_event+0x28/0x3c)
 [<c013ed80>] (handle_irq_event) from [<c01416d4>] (handle_fasteoi_irq+0xa0/0x168)
 [<c01416d4>] (handle_fasteoi_irq) from [<c013e3f8>] (generic_handle_irq+0x24/0x34)
 [<c013e3f8>] (generic_handle_irq) from [<c013e640>] (__handle_domain_irq+0x54/0xa8)
 [<c013e640>] (__handle_domain_irq) from [<c010b214>] (__irq_svc+0x54/0x70)
 [<c010b214>] (__irq_svc) from [<c0107eb0>] (arch_cpu_idle+0x38/0x3c)
 [<c0107eb0>] (arch_cpu_idle) from [<c0137300>] (cpu_startup_entry+0x9c/0xdc)
 [<c0137300>] (cpu_startup_entry) from [<c0900c40>] (start_kernel+0x354/0x360)
 [<c0900c40>] (start_kernel) from [<20008078>] (0x20008078)
 ---[ end trace e7cf9dcebf4815a6 ]---J6

커널 로그에서 __irq_svc(asm) ~ unwind_backtrace() 함수들은 인터텁트 컨텍스트에서 수행되며, start_kernel() ~ arch_cpu_idle() 함수 구간은 프로세스 컨텍스트라고 볼 수 있습니다. 

커널 로그에서 __irq_svc 레이블은 개발 도중 자주 보게 됩니다. 위와 같이 콜스택에서 인터럽트 벡터인 __irq_svc 레이블을 보면 “아, 인터럽트가 발생해서 인터럽트를 처리 중이구나”라고 해석하면 됩니다. 임베디드 개발 중에는 이 같은 패턴의 커널 로그를 자주 만나니 잘 기억해둡시다.

이번 절에서는 인터럽트 컨텍스트에 대해 알아봤습니다. 지금까지 배운 내용을 정리해 봅시다.

 인터럽트 컨텍스트란 무엇인가?
    인터럽트가 발생해 인터럽트를 핸들링하는 동작입니다.

 인터럽트 컨텍스트를 왜 정의할까?
     인터럽트를 핸들링하는 시점에서 더 빠르고 간결하게 코드를 실행하기 위해서입니다.

다음 절에서는 인터럽트 컨텍스트를 알려주는 in_interrupt() 함수를 살펴보겠습니다.


프로세스는 높은 주소에서 낮은 주소 방향으로 스택을 씁니다. 사실 꼭 높은 주소에서 낮은 주소 방향으로 스택을 사용하도록 설정할 필요는 없습니다. 반대로 낮은 주소에서 높은 주소 방향으로 스택을 쓰도록 설정할 수도 있습니다. 그런데 대부분의 리눅스 벤더나 SoC에서 프로세스를 높은 주소에서 낮은 주소 방향으로 설정해 업계의 사실상 표준이 된 것입니다.

이번에는 프로세스가 실행 중인 프로세스 스택 공간에 대해 알아봅시다. 프로세스가 생성될 때 커널은 프로세스에게 0x2000 크기만큼 스택 공간을 할당합니다. 프로세스는 자신의 스택 공간에서 실행되므로 프로세스 입장에서 스택 공간은 운동장으로 볼 수 있습니다. 프로세스는 스택 메모리 공간 내에서만 실행되면서 다음과 같은 상황에서 스택 공간을 사용합니다.

 함수를 호출할 때 돌아올 주소를 스택에 저장
 지역변수를 스택을 써서 저장 

프로세스별로 할당된 스택 주소는 어떻게 확인할 수 있나요? 이 질문에 답하려면 태스크 디스크립터를 알아야 합니다. 프로세스별로 할당된 스택 주소는 태스크 디스크립터를 나타내는 task_struct 구조체의 stack 필드에서 확인할 수 있습니다.



커널이 인터럽트를 처리하는 과정과 자료구조를 왜 잘 알아야 할까요? 인터럽트를 처리하는 방식이 시스템 전반에 큰 영향을 끼치기 때문입니다. 또한 리눅스 커널 시스템 전반을 잘 이해하기 위해서도 커널이 인터럽트를 어떻게 처리하는지 잘 알고 있어야 합니다. 또 다른 이유는 다음과 같습니다. 

 대부분의 리눅스 드라이버는 인터럽트를 통해 하드웨어 디바이스와 통신합니다. 그래서 디바이스 드라이버 코드를 처음 분석할 때 인터럽트를 처리하는 함수나 코드를 먼저 확인합니다. 인터럽트의 동작 방식을 잘 알고 있으면 디바이스 드라이버 코드를 빨리 이해할 수 있습니다.


 인터럽트가 발생하면 프로세스는 이미 정해진 동작을 수행합니다. 인터럽트 처리 과정을 숙지하면 프로세스가 스택 메모리 공간에서 어떻게 실행되는지 알게 됩니다. 


 CPU 아키텍처(x86, ARM)에 따라 인터럽트 벡터는 달리 동작합니다. 인터럽트 벡터가 어떻게 동작하는지 잘 알면 자연히 ARM 아키텍처에 대해 더 많이 알게 됩니다.

또한 리눅스 커널의 핵심 동작을 이해하기 위해서도 인터럽트의 세부 동작 방식을 알 필요가 있습니다. 그 이유는 다음과 같습니다.

 스케줄링에서 선점(Preemptive) 스케줄링 진입 경로 중 하나가 인터럽트 처리를 끝낸 시점입니다.
 유저 공간에서 등록한 시그널 핸들러는 인터럽트 핸들링을 한 다음 처리를 시작합니다.
 레이스 컨디션이 발생하는 가장 큰 이유 중 하나가 비동기적으로 인터럽트가 발생해서 코드 실행을 멈추기 때문입니다.

무엇보다 리눅스 커널을 새로운 보드에 포팅하거나 시스템 전반을 설계하는 개발자는 커널이 인터럽트를 어떻게 처리하는지 잘 알아야 합니다. 커널 패닉이나 시스템이 느려지는 성능 문제가 인터럽트 동작과 연관된 경우가 많기 때문입니다.

 


여기서 “비동기적”이라는 용어는 언제 발생할지 모른다는 의미입니다. 

 

 

우리가 어떤 과목을 공부하기 전에 그 과목 공부의 필요성을 알면 조금 더 집중할 수 있습니다. 인터럽트도 마찬가지입니다. 커널에서 인터럽트를 처리하는 방식을 왜 배워야 하는지 알면 덜 지루할 것입니다.

다음 절에서는 리눅스 커널에서 인터럽트를 처리하는 흐름을 살펴보겠습니다.
 

앞에서 인터럽트에 대해 소개했으니 리눅스 커널에서 인터럽트를 처리하는 방식을 이해하기 위해 알아야 할 주요 개념을 소개합니다.

 인터럽트 핸들러
 인터럽트 벡터 
 인터럽트 디스크립터 
 인터럽트 컨텍스트

인터럽트 핸들러란?

 

인터럽트가 발생하면 이를 핸들링하기 위한 함수가 호출되는데 이를 인터럽트 핸들러라고 합니다. 예를 들어, 키보드를 타이핑해서 인터럽트가 발생하면 키보드 인터럽트를 처리하는 키보드 인터럽트 핸들러가 호출됩니다. 마찬가지로 휴대폰에서 화면을 손으로 만지면 터치 인터럽트가 발생하고 터치 인터럽트를 처리하는 터치 인터럽트 핸들러가 호출됩니다.

다음 그림을 보면서 각 디바이스별로 인터럽트 핸들러가 처리되는 과정을 알아보겠습니다.
 


그림 5.2 디바이스별로 실행되는 인터럽트 핸들러

그림 5.2에서 볼 수 있듯이 인터럽트 종류별로 인터럽트 핸들러가 있습니다. 인터럽트 핸들러는 함수 형태로 존재하며, 커널 내 인터럽트 함수에서 호출합니다. 이처럼 인터럽트가 발생해 지정한 인터럽트 핸들러가 동작하려면 어떻게 해야 할까요? request_irq() 함수를 적절한 인자와 함께 호출해서 미리 인터럽트 핸들러를 등록해야 합니다.

이해를 돕기 위해 컴퓨터에서 마우스를 움직였을 때 인터럽트를 처리하는 코드를 예로 들겠습니다.

https://github.com/raspberrypi/linux/blob/rpi-4.19.y/drivers/input/mouse/amimouse.c
01 static int amimouse_open(struct input_dev *dev)
02 {
03 unsigned short joy0dat;
...
04 error = request_irq(IRQ_AMIGA_VERTB, amimouse_interrupt, 0, "amimouse",
05     dev);

04 번째 줄을 보면 request_irq() 함수의 두 번째 인자로 인터럽트 핸들러 함수인 amimouse_interrupt()를 등록합니다.

이후 마우스 인터럽트가 발생하면 request_irq() 함수에서 지정한 amimouse_interrupt() 함수가 호출됩니다.

https://github.com/raspberrypi/linux/blob/rpi-4.19.y/drivers/input/mouse/amimouse.c
01 static irqreturn_t amimouse_interrupt(int irq, void *data)
02 {
03 struct input_dev *dev = data;
04 unsigned short joy0dat, potgor;
05 int nx, ny, dx, dy;
...
06 input_report_key(dev, BTN_LEFT,   ciaa.pra & 0x40);
07 input_report_key(dev, BTN_MIDDLE, potgor & 0x0100);
08 input_report_key(dev, BTN_RIGHT,  potgor & 0x0400);

인터럽트 핸들러에서는 마우스에서 입력한 데이터 정보를 참고해 유저 공간에 알리는 동작을 수행합니다.

코드는 복잡해 보이지만 다음 그림을 보면 인터럽트의 처리 과정을 쉽게 이해할 수 있습니다.
 


그림 5.3 마우스를 움직였을 때 마우스 인터럽트 핸들러를 호출하는 과정

마우스를 움직이면 마우스가 움직였다는 인터럽트가 발생해 인터럽트 벡터가 실행됩니다. 이후 커널 인터럽트 내부 함수에서 해당 인터럽트에 맞는 인터럽트 핸들러를 찾아 호출합니다. 많은 하드웨어 디바이스가 이 같은 방식으로 인터럽트를 통해 하드웨어의 변화를 알립니다.

인터럽트 컨텍스트는 언제 활성화될까?

 

인터럽트 컨텍스트는 현재 코드가 인터럽트를 처리 중이라는 뜻입니다. 인터럽트 컨텍스트에 대한 이해를 돕기 위해 먼저 소프트웨어 관점에서 인터럽트의 실행 흐름을 단계별로 보겠습니다.

1. 프로세스 실행 중
2. 인터럽트 벡터 실행
3. 커널 인터럽트 내부 함수 호출
4. 인터럽트 종류별로 인터럽트 핸들러 호출
  4.1 인터럽트 컨텍스트 시작
5. 인터럽트 핸들러의 서브루틴 실행 시작
6. 인터럽트 핸들러의 서브루틴 실행 마무리
  6.1 인터럽트 컨텍스트 마무리

복잡한 단계로 인터럽트가 처리되는 것 같아도 처리 과정을 요약하면 다음과 같습니다.

 인터럽트가 발생하면 실행 중인 코드를 멈추고 인터럽트 벡터로 이동해 인터럽트에 대한 처리를 수행합니다.
 인터럽트 종류별로 지정한 인터럽트 핸들러가 실행됩니다.

앞의 목록에서 4.1~6.1 사이에 호출된 함수는 인터럽트 컨텍스트에서 실행됐다고 할 수 있습니다. 여기서 한 가지 의문이 생깁니다. 현재 실행 중인 코드가 인터럽트 컨텍스트인지 어떻게 알 수 있을까요?

in_interrupt() 함수를 호출하면 현재 인터럽트 컨텍스트인지 알려줍니다. 이 함수가 true를 반환하면 현재 실행 중인 코드가 4.1~6.1 구간에 있다는 뜻입니다. 

인터럽트 디스크립터란?


인터럽트 종류별로 다음과 같은 인터럽트의 세부 속성을 관리하는 자료구조를 인터럽트 디스크립터라고 합니다.

 인터럽트 핸들러
 인터럽트 핸들러 매개변수 
 논리적인 인터럽트 번호 
 인터럽트 실행 횟수 

프로세스의 세부 속성을 표현하는 자료구조가 태스크 디스크립터이듯이 인터럽트에 대한 속성 정보를 저장하는 자료구조가 인터럽트 디스크립터인 것입니다. 커널 인터럽트의 세부 함수에서는 인터럽트 디스크립터에 접근해 인터럽트 종류별로 세부적인 처리를 수행합니다. 그림 5.4는 인터럽트가 발생했을 때 인터럽트 핸들러를 호출하는 흐름입니다.
 


그림 5.4 인터럽트 디스크립터로 인터럽트 핸들러를 호출하는 과정

커널 내부의 인터럽트 함수에서 인터럽트 종류별로 지정된 인터럽트 핸들러를 호출하려면 먼저 인터럽트 디스크립터에 접근해야 합니다. 인터럽트 디스크립터는 인터럽트 핸들러의 주소 정보를 갖고 있는데, 커널에서는 이를 읽어서 인터럽트 핸들러를 호출합니다.

인터럽트 디스크립터는 irq_desc 구조체이며 선언부는 다음과 같습니다.

https://github.com/raspberrypi/linux/blob/rpi-4.19.y/include/linux/irqdesc.h
struct irq_desc {
struct irq_common_data irq_common_data;
struct irq_data irq_data;
unsigned int __percpu *kstat_irqs;
irq_flow_handler_t handle_irq;

참고로 이번 절에서 소개한 인터럽트의 주요 개념은 5.2절부터 상세히 살펴볼 예정입니다.

인터럽트란 무엇일까요? 인터럽트란 일반적인 상황에서 갑자기 발생하는 비동기적인 통지나 이벤트라고 볼 수 있습니다. 이번 절에서는 인터럽트라는 용어와 그것의 의미를 알아보겠습니다.

 

일상 생활에서의 인터럽트
인터럽트란 단어가 생소하신가요? 낯설게 들리는 분도 있고 귀에 익은 분도 있을 것입니다. 일상생활에서 인터럽트란 갑자기 생긴 일이나 하던 일을 멈춘다는 의미입니다. 일상적으로 하던 일을 멈추게 하는 무엇인가가 갑자기 발생한 상황을 뜻합니다. 예를 들면, 책을 읽다가 갑자기 전화가 와서 읽던 책을 덮어 놓고 전화를 받는 상황이 그러합니다.

 

하드웨어 관점에서 인터럽트란?
임베디드 시스템 관점에서 인터럽트는 무엇일까요? 먼저 하드웨어 관점에서 생각해 봅시다. 하드웨어 관점에서 인터럽트란 하드웨어의 변화를 감지해서 외부 입력으로 전달되는 전기 신호입니다.

 

한 가지 예를 들어보겠습니다. 손으로 키보드를 치면 하드웨어적으로 키보드 하드웨어의 변화를 감지하고 신호가 발생합니다. 그래서 보통 하드웨어 개발자들은 오실로스코프란 장비로 인터럽트 신호가 제대로 올라오는지 측정합니다.

오실로스코프로 인터럽트 신호를 측정하면 다음과 같은 파형을 볼 수 있습니다. 
 


그림 5.1 인터럽트 파형의 예

참고로 인터럽트 신호는 그림 5.1과 같이 인터럽트를 식별하는 구간에 일정하게 5V(Voltage)를 유지하거나 0V에서 5V로 바뀌는 두 가지 종류로 분류합니다.

CPU 입장에서 인터럽트란?

 


이번에는 소프트웨어 관점에서 인터럽트가 무엇인지 알아봅시다. 인터럽트가 발생하면 프로세스는 하던 일을 멈추고 '이미 정해진 코드'를 실행해서 하드웨어의 변화를 처리합니다. 여기서 '이미 정해진 코드'란 어떤 의미일까요? 인터럽트 벡터와 인터럽트 핸들러를 말합니다.  이처럼 인터럽트가 발생하면 소프트웨어적으로 처리하는 과정을 인터럽트 서비스 루틴(Interrupt Service Routine)이라고 합니다.

이번에는 CPU(ARM) 관점에서 인터럽트를 어떻게 처리하는지 알아봅시다. 인터럽트는 CPU 아키텍처별로 다르게 처리합니다. x86, ARMv7, ARMv8 아키텍처별로 인터럽트를 처리하는 방식이 다른 것입니다. 라즈베리 파이는 ARMv7 기반 아키텍처이므로 ARMv7 CPU에서 인터럽트를 처리하는 과정을 알면 됩니다. 그럼 ARMv7 아키텍처에서는 인터럽트를 어떻게 처리할까요? ARMv7 프로세서에서 인터럽트는 익셉션(Exception)의 한 종류로 처리하므로 익셉션 처리 방식에 대해 알 필요가 있습니다. 

ARMv7 아키텍처에서 익셉션의 동작 원리는 무엇일까요? ARMv7 프로세서는 외부 하드웨어 입력이나 오류 이벤트가 발생하면 익셉션 모드로 진입합니다. ARMv7 프로세스는 익셉션이 발생했다고 감지하면 익셉션 종류별로 이미 정해 놓은 주소로 브랜치합니다. 조금 어려운 개념인데 순간 이동과 비슷한 개념으로 생각해도 좋습니다. 이미 정해진 주소로 브랜치하는 동작은 조금만 생각해보면 그리 낯설지는 않습니다. 어떤 코드에서 함수를 호출할 때 어셈블리 코드로 분석하면 이와 유사한 동작을 합니다.

한 가지 예를 들겠습니다.

https://github.com/raspberrypi/linux/blob/rpi-4.19.y/kernel/sched/core.c
01 asmlinkage __visible void __sched schedule(void)
02 {
...
03 do {
04 preempt_disable();
05 __schedule(false);

05 번째 줄과 같이 __schedule(false) 함수를 호출할 때 어셈블리 코드 관점에서는 어떻게 동작할까요? ARM 코어 프로그램 카운터를 __schedule() 주소로 바꿉니다. 즉, 현재 실행 중인 레지스터 세트를 스택에 푸시합니다.

마찬가지로 ARM 이 익셉션 모드를 감지하면 익셉션 모드별로 정해진 주소로 ARM 코어 프로그램 카운터를 바꿉니다. 이후 실행 중인 코드의 레지스터 세트를 스택에 푸시합니다.

인터럽트나 소프트웨어적인 심각한 오류가 발생하면 ARMv7 프로세스는 ‘이미 정해진 주소’에 있는 코드를 실행합니다. 이미 정해진 주소 코드를 익셉션 벡터(Exception Vector)라 하며, 각 익셉션의 종류에 따라 주소의 위치가 다릅니다. 그런데 ARMv7 프로세서는 인터럽트를 익셉션 벡터 중 하나의 모드로 처리합니다(이 동작은 5.3절에서 상세히 다룹니다).

이제 인터럽트 소개를 마쳤으니 이번에는 인터럽트에 대해 조금 더 자세히 살펴보겠습니다. 임베디드 시스템이나 운영체제에서 '인터럽트를 처리하는 방식'을 논할 때 흔히 “인터럽트 핸들러는 빨리 실행해야 한다.”라는 이야기를 많이 듣습니다. 이는 리눅스 디바이스 드라이버에서도 마찬가지입니다. 그러면 리눅스 커널에서도 인터럽트 핸들러를 빨리 실행해야 하는 이유는 무엇일까요? 가장 큰 이유는 인터럽트가 발생하면 실행되는 코드가 멈추기 때문입니다.

앞으로 여러분이 리눅스 디바이스 드라이버나 커널 코드를 볼 때는 우리가 보고 있고 있거나 실행하는 어떤 커널 코드도 인터럽트가 발생하면 실행이 멈춰서 인터럽트 벡터로 실행 흐름을 이동할 수 있다는 사실을 머릿속으로 그리면서 분석하면 좋겠습니다.

그런데 인터럽트가 발생하면 실행 중인 코드를 멈추고 익셉션 벡터로 이동한다는 사실은 코드만 봐서 이해하기는 어렵습니다. 이를 위해 실습이 필요합니다. 라즈베리 파이 같은 리눅스 시스템에서는 ftrace로 인터럽트의 동작 방식(인터럽트 종류와 인터럽트 발생 빈도)을 확인할 필요가 있습니다. 

...
MP:FFFFFFFF80002FEA|                    csrrc      x9,sstatus,x5  // atomic read and clear bits in CSR.
MP:FFFFFFFF80002FEE|                    csrr       x18,sepc
MP:FFFFFFFF80002FF2|                    csrr       x19,stval
MP:FFFFFFFF80002FF6|                    csrr       x20,scause
MP:FFFFFFFF80002FFA|                    csrr       x21,sscratch

void handle_exception() 
{
   word scause;
   word offset_vect;
   void *exception_func;
   bool interrupt_status;

   scause = sys_csr_reg_read(scause); // csrr       x20,scause

   interrupt_status = scause >> (SXLEN -1); // 

   if ( interrupt_status == true) {
       la_reg = ret_from_exception;  // la ra, ret_from_exception
       handle_arch_irq(); 
   }
   else {
      la_reg = ret_from_exception;  // la ra, ret_from_exception
      if (scause & 0b1000) {
         handle_syscall();
      }
      else {
         offset_vect = get_exception_reason(scause);
         exception_func = excp_vect_table + offset_vect;
         (void*)exception_func; // c.jr x5
      }
   }
}

새로운 '리눅스 커널' 책인 《디버깅을 통해 배우는 리눅스 커널의 구조와 원리 1, 2》 이 출간돼 소개합니다.

 

링크

http://www.yes24.com/Product/Goods/90085976

http://www.yes24.com/Product/Goods/90087307

 

 

이 책은 방대한 리눅스 커널을 처음 접하는 분들이 리눅스 커널을 학습하는 데 실패하지 않고, 끝까지 따라갈 수 있도록 구성했습니다. 두 권에 걸쳐 리눅스 커널의 주요 구성요소를 빠짐없이 소개하고 있어 리눅스 커널을 한층 더 깊게 체감하실 수 있습니다.

 

이 책을 다음과 같은 분들께 추천합니다.

 

- 리눅스 보드로 졸업 과제를 준비 중인 졸업반 학생

- 연구실의 프로젝트를 리눅스 환경(리눅스 보드, 리눅스 배포판)에서 진행 중인 대학원생

- 리눅스 커널을 실무에서 어떻게 디버깅하는지 알고 싶은 리눅스 시스템 개발자

- 리눅스 드라이버와 운영체제의 동작 원리를 더 깊게 알고 싶은 분

 

리눅스 개발에 도움이 되는 유용한 내용을 담고 있으니, 많은 리눅스 시스템 개발자분들이 읽어 주셨으면 좋겠습니다.

 

참고로 이 책은 제가 집필했습니다. 감사합니다.

 

"이 포스팅이 유익하다고 생각되시면 공감 혹은 댓글로 응원해주시면 감사하겠습니다.

"혹시 궁금한 점이 있으면 댓글로 질문 남겨주세요. 아는 한 성실히 답변 올려드리겠습니다!"

 

Thanks,

Austin Kim(austindh.kim@gmail.com)

 

여러분들은 리눅스 시스템이 탑재된 휴대폰이나 라즈베리파이를 쓰면 동시에 여러 프로그램을 실행할 수 있습니다. 휴대폰을 보면 다양한 프로그램이 동시에 실행하는 것을 확인할 수 있습니다. 예를 들면 브라우저를 실행하면서 음악을 듣거나 메신저를 하면서 어플리케이션을 다운로드 할 수 있습니다.

그래서 사람들은 여러 프로세스들이 동시에 CPU에서 실행한다고 느낄 수 있습니다. 하지만 CPU는 여러 개의 프로세스를 절대로 동시에 실행할 수는 없습니다. 리눅스 커널을 포함한 다양한 운영체제에서 스케줄링과 멀티 태스킹 기법이 생겨난 이유는 다음과 같습니다.
CPU는 한 순간에 한 개의 프로세스의 코드만을 실행할 수 있습니다.
 
여러 개의 프로세스들이 효율적으로 번갈아 CPU에서 실행할 수 있게 규칙을 부여하고 프로세스들을 관리하는 소프트웨어 모듈을 스케줄러라고 말합니다.

하나의 프로세스는 CPU에서 실행을 시작하면 계속 CPU에서 실행하는 것이 아니라 실행을 하다가 잠깐 멈추고 다시 실행하는 방식으로 동작합니다. 즉, 프로세스는 CPU를 점유하면서 실행 중인 상태와 실행 대기하는 상태로 계속 변경하는 것입니다.

메모리에 존재하는 여러 프로세스 중에서 실제 CPU에서 실행될 프로세스를 선택하는 일을 스케줄링이라고 말합니다. 이 때 어떤 프로세스를 어떤 방식으로 선택할지를 결정해야 합니다. 

스케줄링 동작은 다음 그림으로 표현할 수 있습니다.
 

CPU에서 실행하려고 대기 중인 Process A ~ Process D 프로세스 중 하나를 선택해서 CPU에서 실행시키는 동작입니다.

스케줄링 동작을 다른 각도에서 살펴보겠습니다. 다음 그림은 프로세스 상태 변화 다이어그램입니다.
 

커널은 프로세스에게 프로세스 상태를 부여합니다. 프로세스가 생성 및 실행된 후 종료할 때까지 위와 같은 상태 변화로 동작합니다.

프로세스가 CPU에서 실행하기 위해서는 실행 대기(TASK_RUNNING) 상태로 변경한 다음 커널 스케줄링에 의해 CPU 실행(TASK_RUNNING) 상태로 변경되어야 합니다.

대부분 보통 프로세스 실행 상태 변화 흐름을 프로세스 1인칭으로 바라볼 때가 많습니다.
이번에는 시스템 전체 관점으로 프로세스 상태 변화 다이어그램을 살펴봅시다.

 

커널에서 실행 중인 전체 프로세스가 각각 어떤 상태로 실행 중인지를 보여주는 그림입니다. 원형으로 표시된 A~N는 각각 프로세스를 의미합니다.

A~D 프로세스들은 실행 대기(TASK_RUNNING) 상태에 있습니다. CPU에서 실행하기 위해 대기 중인 프로세스입니다.

CPU 실행(TASK_RUNNING) 상태를 보면 E 프로세스가 CPU에서 실행 중입니다.

다른 관점으로 스케줄링 동작을 다음과 같이 설명할 수 있습니다.
실행 대기(TASK_RUNNING) 상태에 있는 프로세스 중 하나를 선택해서 CPU 실행(TASK_RUNNING) 상태로 바꿔주는 동작

커널 스케줄링은 프로세스 상태 기준으로 실행 대기 중에 있는 프로세스를 어떤 방식으로 실행할지를 결정합니다. 따라서 프로세스 상태 정보는 매우 중요합니다.

대부분 드라이버 코드를 작성할 때 프로세스 상태를 변경하는 코드를 작성할 필요는 없습니다. 하지만, 커널 스레드 핸들 함수를 구현할 때 프로세스 상태를 변경하는 코드를 입력할 때가 있습니다. 이 때 반드시 set_current_state() 함수를 써서 프로세스 상태를 변경해야 합니다.

그 이유는 다음과 같이 set_current_state() 함수 코드를 보면 알 수 있습니다.
[https://elixir.bootlin.com/linux/v4.14.70/source/include/linux/sched.h]
1 #define set_current_state(state_value)
2 do {
3 WARN_ON_ONCE(is_special_task_state(state_value));
4 current->task_state_change = _THIS_IP_;
5 smp_store_mb(current->state, (state_value));
6 } while (0)

5 번째 줄을 보면 smp_store_mb() 함수를 호출해서 메모리 배리어를 실행합니다.
메모리 배리어 코드를 추가하면 GCC 컴파일러가 코드 최적화를 위해 코드 위치를 변경하는 것을 방지합니다.


리눅스 커널 코드를 읽으면 다음 매크로를 자주 볼 수 있습니다.
+ current_thread_info()
+ current

위 매크로를 활용해서 프로세스 태스크 디스크립터와 struct thread_info 구조체 필드 정보를 읽어 프로세스를 제어합니다. 

4.10.1 current_thread_info()
이전 절에서 프로세스 스택 최상단 주소에 struct thread_info 구조체에 프로세스 실행을 저장한다고 분석했습니다.

current_thread_info() 매크로 함수는 프로세스가 어떤 함수를 실행하더라도 struct thread_info 구조체가 있는 스택 최상단 주소를 전달합니다.

current_thread_info() 매크로 함수 구현부를 분석하기 전에 매크로 함수가 어느 코드에서 쓰는 지 알아봅시다.
1  static __always_inline int preempt_count(void)
2  {
return READ_ONCE(current_thread_info()->preempt_count);
4  }
5
6  static __always_inline volatile int *preempt_count_ptr(void)
7  {
8 return &current_thread_info()->preempt_count;
9  }
10
11 static __always_inline void preempt_count_set(int pc)
12 {
13 *preempt_count_ptr() = pc;
14 }

1번째 줄 코드에서 preempt_count() 함수는 current_thread_info() 함수를 써서 struct thread_info 구조체 멤버인 preempt_count 에 접근합니다.

6번째 줄 코드에 있는 preempt_count_ptr() 함수는 struct thread_info 구조체 멤버 preempt_count 주소를 반환합니다.

11~13번째 줄 코드는 preempt_count_ptr() 함수를 써서 struct thread_info 구조체 멤버 preempt_count에 pc란 인자를 저장합니다.

이번에는 다른 코드를 봅시다.
#define raw_smp_processor_id() (current_thread_info()->cpu)

struct thread_info 구조체 멤버인 cpu에 접근하는 매크로 함수입니다.
프로세스가 현재 실행 중이거나 이전에 실행했던 CPU 번호를 저장합니다.

위에서 다룬 리눅스 커널 전반에 아주 많이 쓰는 함수니 눈에 잘 익히도록 합시다.

이제 current_thread_info() 매크로 함수 코드를 분석하겠습니다.

1 register unsigned long current_stack_pointer asm ("sp");
2
3 static inline struct thread_info *current_thread_info(void)
4 {
5 return (struct thread_info *)
6 (current_stack_pointer & ~(THREAD_SIZE - 1));
7 }

참고로, THREAD_SIZE 이란 0x2000 값으로 ARM 아키텍처에서 실행하는 프로세스 스택 사이즈를 의미합니다.
#define THREAD_SIZE 0x2000

ARM64 비트 아키텍처에서는 커널 프로세스 스택 사이즈가 0x4000 바이트입니다.
이 책에서는 라즈베리파이가 탑재한 ARM32 아키텍처를 기준으로 코드를 분석합니다.

current_thread_info() 매크로 함수에서 current_stack_pointer 코드의 의미를 먼저 확인합시다.

이 코드는 현재 구동 중인 프로세스의 스택 주소를 current_stack_pointer 변수로 가져오는 명령어입니다.
1 register unsigned long current_stack_pointer asm ("sp");

asm ("sp")  어셈블리 명령어는 현재 실행 중인 프로세스 스택 주소를 알려줍니다. 

우리가 보는 모든 함수는 프로세스 스택 공간에서 실행합니다. 위 명령어와 조합해서 1번 코드와 같이 선언하면 current_stack_pointer 전역 변수에 스택 주소를 저장합니다.

분석한 내용을 적용해서 6~7번째 줄 코드를 다음과 같이 변경해봅시다.
(current_stack_pointer & ~((0x2000) - 1));
(current_stack_pointer & ~(0x1fff));

THREAD_SIZE가 0x2000이고 0x2000 에서 1을 빼면 0x1fff입니다.

위 코드에서 ~란 비트 연산자는 비트를 모두 역전시킵니다. 
0x1fff를 이진수로 바꿔서 ~연산을 수행하면 다음과 같은 결괏값을 얻을 수 있습니다.
0001|1111|1111|1111
~연산
-------------------
1110|0000|0000|0000

1110|0000|0000|0000 이진수를 16진수로 바꾸면 0xE000이 되는 것입니다.

현재 스택 주소에서 0xE000란 값을 AND 연산하는 코드입니다. 
(current_stack_pointer & ~((0x2000) - 1));
(current_stack_pointer & ~(0x1fff));
(current_stack_pointer & (0xe000));

그런데 현재 스택 주소에서 0xE000을 AND 연산하는 게 어떤 의미가 있을까요?
아래 볼드체로된 0--12비트는 모두 0이니 다른 주소와 AND 연산을 하면 0이 됩니다.
1110|0000|0000|0000(0xE000)

0xD000C248 & (0000|0000|0000) 연산을 하면 0xD000C000이 됩니다. 16진수 3자리 값을 모두 0으로 변환시킵니다. 
1100|0000|0000|0000|1011|0010|0100|1000 (0xD000C248)
                              0000|0000|0000
AND ------------------------------------------
1100|0000|0000|0000|1011|0000|0000|0000 (0xD000C000)


이번에 0xE000 값에서 0xE만 빼서 이진수로 어떤 동작을 하는지 확인할 차례입니다.
이제 0xE와 AND 비트 연산을 하는 의미를 알아보려는 것입니다.
1110|0000|0000|0000

1110비트 연산을 수행하면 현재 값이 짝수면 그 값을 그대로 유지하고 홀수면 -1만큼 뺍니다.
아래 연산 결과를 참고하시면 이해가 빠를 겁니다.
 
이제 정리를 합시다. 각각 스택 주소를 다음과 같다고 했을 때 0xE000와 AND 연산을 하면 결과값은 다음과 같습니다.
스택주소 
0xD0002248 & 0xE000 = 0xD0002000
0xD0003248 & 0xE000 = 0xD0002000
0xD0004248 & 0xE000 = 0xD0004000
0xD0005248 & 0xE000 = 0xD0004000

현재 스택 주소에서 위 규칙으로 비트를 연산하는 이유는 뭘까요? ARM32 비트 아키텍처에서 0x2000바이트 크기만큼 스택을 지정합니다. 커널이 프로세스 스택 주소를 할당할 때 0x2000바이트 기준 정렬을 맞춰서 할당하기 때문입니다. 프로세스 최상단 주소는 짝수 바이트입니다. 그래서 위와 같은 비트 연산으로 스택 최상단 주소를 계산한 것입니다.

4.10.2 current 매크로란
current란 매크로는 현재 구동 중인 프로세스의 태스크 디스크립터 주소에 접근합니다.

먼저 current 매크로를 쓰는 코드를 보겠습니다. 파일 디스크립터를 할당하는 get_unused_fd_flags() 함수입니다.
1 int get_unused_fd_flags(unsigned flags)
2 {
3 return __alloc_fd(current->files, 0, rlimit(RLIMIT_NOFILE), flags);
4 }
5 EXPORT_SYMBOL(get_unused_fd_flags);

3번째 줄 코드를 보면 current 변수로 current->files 멤버에 접근해서 __alloc_fd 함수 첫 번째 파라미터로 전달합니다. 코드를 조금 눈여겨보면 뭔가 이상합니다. 어느 코드에도 current를 지역변수나 전역 변수로 선언한 흔적이 없습니다.

하지만 current 매크로는 struct task_struct 구조체 주소를 포인터 형태로 반환합니다.
그래서 current->files 코드로 struct task_struct 구조체 멤버 files(파일 디스크립터)에 접근하는 것입니다.

이번에는 __put_task_struct() 함수 코드를 봅시다.
1 void __put_task_struct(struct task_struct *tsk)
2 {
3 WARN_ON(!tsk->exit_state);
4 WARN_ON(atomic_read(&tsk->usage));
5 WARN_ON(tsk == current);

5번째 줄 코드를 보면 __put_task_struct() 함수로 전달된 struct task_struct 타입 tsk 파라미터와 current가 같은지 체크를 합니다. 이번도 마찬가지입니다. 함수 내 current란 변수를 선언한 흔적이 없습니다. 그런데 갑자기 current란 변수가 나타났습니다.

__put_task_struct() 함수 인자인 tsk는 struct task_struct 구조체 포인터 타입인데 current와 같은지 비교하는 코드입니다.

current 매크로는 리눅스 커널 코드에서 굉장히 많이 볼 수 있습니다. 이 코드의 의미를 정확히 이해해야 커널 코드를 읽는 데 문제가 없을 것입니다.

current 매크로에 대해서 알아봅시다. 
current는 현재 구동 중인 프로세스의 태스크 디스크립터의 주소를 알려주는 역할을 수행합니다. 구조체는 struct task_struct이고 포인터형 변수입니다. 그래서 current->comm, current->files 형태로 각 멤버에 접근합니다. 

해당 코드는 다음과 같습니다.
1 #define get_current() (current_thread_info()->task)
2 #define current get_current()

current 매크로는 get_current() 매크로 함수로 치환되는데 이전 소절에 다뤘던 current_thread_info() 매크로 함수를 써서 task 멤버 주소를 반환합니다.

실행 중인 프로세스 스택 주소를 이용해 최상단 주소에 접근해서 struct thread_info 구조체 task 멤버 주소를 반환하는 코드입니다.



+ Recent posts