효율적으로 인터럽트를 설정 및 처리하기 위해 GIC를 사용하는데, GIC의 가장 중요한 기능은 다음과 같이 요약할 수 있습니다.

 인터럽트의 우선순위를 각 I/O 장치에 적용해 설정
 입력으로 받은 인터럽트를 CPU에 전달

GIC는 인터럽트 컨트롤러이므로 GIC의 주된 기능은 다른 인터럽트 컨트롤러와 다르지 않습니다. 다른 인터럽트 컨트롤러와 다른 점은 GIC는 인터럽트를 4가지 종류로 분류해 관리한다는 점입니다. 이어서 GIC에서 정의된 4가지 인터럽트 소스를 소개하고 인터럽트 상태 머신을 설명합니다. 

GIC는 4가지 타입의 인터럽트 소스를 처리하며 다음 표에서 종류를 확인할 수 있습니다.


표 10.2 GIC를 구성하는 인터럽트 소스의 종류
 

이처럼 GIC는 페리페럴에서 전달된 4가지 인터럽트 입력을 처리하는데, 기능에 따라 다음과 같이 분류할 수 있습니다.

 PPI, SPI, LPI: 외부 I/O 디바이스에서 발생하는 하드웨어 인터럽트
 SGI: 외부 I/O 장치가 아닌 소프트웨어적으로 발생되는 인터럽트 

외부 I/O 장치에서 인터럽트가 발생하면 SPI나 PPI를 통해 CPU 코어로 전달됩니다. 먼저 SPI와 PPI에 대해 알아봅시다.

 

< '시스템 소프트웨어 개발을 위한 Arm 아키텍처의 구조와 원리' 저자>

 

<강의 영상>

 


이어서 X30 레지스터를 스펙 문서 분석을 통해 알아봅시다.

출처: ARM® Cortex®-A Series Programmer's Guide for ARMv8-A https://developer.arm.com/documentation/den0024/latest/ 
6.4 Flow control
...
Calls to subroutines, where it is necessary for the return address to be stored in the link register (X30), use the BL instruction. This does not have a conditional version. BL behaves as a B instruction with the additional effect of storing the return address, which is the address of the instruction after the BL, in register X30.

X30 레지스터를 설명한 내용인데, 요약하면 다음과 같습니다.

 X30은 BL 명령어를 실행해 주소나 함수로 분기했을 때 서브루틴에서 복귀할 주소를 저장하는 레지스터다.  

 

---

[중요] Armv7의 R14_<mode> 레지스터와 Armv8의 X30 레지스터

Armv7 아키텍처에서는 R14가 각 동작 모드별로 존재했습니다. R14는 서브루틴(함수 호출)을 호출할 때 복귀할 주소뿐만 아니라 익셉션이 유발된 후 복귀할 주소를 저장했습니다.

그런데 Armv8 아키텍처에서는 익셉션이 발생한 후 복귀할 주소를 저장하는 ELR_ELx(Exception Link Register)를 익셉션 레벨별로 정의합니다. 그래서 Armv8 아키텍처에서 X30은 익셉션 레벨별로 존재하지 않습니다.
---

이처럼 SP와 X30 레지스터는 Armv8 아키텍처에서 정의된 다른 범용 레지스터와 달리 서브루틴으로 분기하기 위해 사용됩니다.

이번 절에서 소개한 SP_ELn과 X30 레지스터를 표로 정리해 봅시다.


표 13.1 SP_ELn과 X30 레지스터의 특징
  
어셈블리 명령어를 분석할 때 SP_ELn과 X30 레지스터를 보면 그 역할과 기능이 잘 떠올랐으면 좋겠습니다.

 <강의 영상>

 

 

[Arm프로세서] Armv8 - SP_ELn과 X30 레지스터란?
 
Armv8 아키텍처에서 정의된 레지스터 중 SP_ELn과 X30 레지스터는 AAPCS와 연관된 핵심 레지스터입니다. 전체 레지스터 목록 중에서 SP_ELn과 X30 레지스터를 먼저 소개하겠습니다.

전체 레지스터 목록 중 SP_ELn과 X30 레지스터

다음 그림을 보면서 Armv8 아키텍처에서 정의된 레지스터 중 AAPCS와 연관된 레지스터 목록을 알아봅시다.


 
그림 13.1 Armv8 아키텍처의 레지스터 목록 중 AAPCS와 연관된 레지스터

위 그림은 Armv8 아키텍처에서 정의된 레지스터 목록입니다. 그림에서 빗금으로 표기된 박스를 보겠습니다.

SP_EL1은 EL1(익셉션 레벨1)에서 실행되는 SP 레지스터, SP_EL0는 EL0(익셉션 레벨0)에서 실행되는 SP 레지스터를 의미합니다. 또한 하이퍼바이저가 실행되는 EL2에서 실행되는 SP 레지스터는 SP_EL2로 표기합니다.

---
[정보] SP 레지스터와 SP_ELn 레지스터의 상관관계

Armv8 아키텍처에서는 SP 레지스터를 SP_ELn으로도 표기합니다. 그렇다면 다음과 같은 명령어는 어떻게 해석해야 할까요?

SUB SP, SP, #4

Armv8 아키텍처의 어셈블리 명령어에서 SP가 보이면 어셈블리 명령어를 실행하는 익셉션 레벨을 기준으로 해석하면 됩니다. 만약 위 명령어를 실행하는 익셉션 레벨이 EL1이면 SP_EL1, EL2이면 SP_EL2를 의미합니다.

예를 들어, 운영체제 커널은 EL1에서 실행되므로 커널 공간에 있는 프로세스의 SP 레지스터는 SP_EL1에서 확인할 수 있습니다. 하이퍼바이저는 EL2에서 실행되므로 하이퍼바이저에서 실행되는 프로세스의 SP 레지스터는 SP_EL2에서 확인할 수 있습니다. 
---

X30 레지스터는 링크 레지스터를 뜻하며, 서브루틴을 호출한 다음에 복귀할 주소를 저장합니다. Armv7 아키텍처의 링크 레지스터인 R14와 같은 기능입니다.

SP_ELn 아랫 부분에 표기된 레지스터를 보겠습니다. 프로세서의 상태를 저장하는 SPSR(Saved Program Status Register) 레지스터와 ELR(Exception Link Register) 레지스터가 있습니다. 이 레지스터도 익셉션 레벨별로 정의돼 있어 SPSR_ELx, ELR_ELx로 표기합니다. 이 레지스터는 AAPCS와는 직접적인 연관은 없습니다. 익셉션이 유발돼 익셉션을 처리한 다음에 이전 익셉션 레벨로 복귀하기 위한 용도로 쓰입니다. 

Arm 스펙 문서에서 본 SP 레지스터

이번에는 Arm 스펙 문서를 보면서 SP와 X30 레지스터에 대해 더 자세히 알아봅시다. 먼저 SP 레지스터를 살펴봅시다.  

출처: ARM® Cortex®-A Series Programmer's Guide for ARMv8-A https://developer.arm.com/documentation/den0024/latest/
4.1.2 Stack pointer

In the ARMv8 architecture, the choice of stack pointer to use is separated to some extent from the Exception level. By default, taking an exception selects the stack pointer for the target Exception level, SP_ELn. 

For example, taking an exception to EL1 selects SP_EL1. Each Exception level has its own stack pointer, SP_EL0, SP_EL1, SP_EL2, and SP_EL3.

위 스펙 내용에서 중요한 부분은 다음과 같이 정리할 수 있습니다.

 익셉션 레벨별로 스택이 존재하는데, 이 스택 주소를 저장하는 SP_ELn 레지스터가 있다.
 SP_EL0, SP_EL1, SP_EL2, SP_EL3와 같은 스택 포인터 레지스터가 존재한다.

여기서 언급된 스택은 프로세스가 사용하는 스택을 의미합니다. Armv8에서 정의된 SP_ELn 레지스터도 프로세스의 스택 주소 구간 내 특정 주소로 업데이트됩니다. 
 

<강의 영상>

 

Armv8 아키텍처의 AAPCS를 다루기에 앞서 Armv7에서 정의된 AAPCS의 주요 내용을 요약하면 다음과 같습니다.

 서브루틴을 호출하면 프로세스의 스택 공간에 레지스터를 푸시한다.
 'BL [주소]' 명령어를 실행해 서브루틴으로 분기하면 Arm 코어는 링크 레지스터인 R14에 복귀할 주소를 업데이트한다.
 서브루틴을 호출할 때 전달되는 인자는 R0 ~ R3 레지스터에 저장된다.
 함수의 리턴값은 R0 레지스터에 저장된다.

위에서 설명한 내용은 Armv8 아키텍처 관점에서 다음과 같이 바꿔서 설명할 수 있습니다.

 서브루틴을 호출하면 프로세스의 스택 공간에 레지스터를 푸시한다.
 'BL [주소]' 명령어를 실행해 서브루틴으로 분기하면 Arm 코어는 링크 레지스터인 X30에 복귀할 주소를 업데이트한다.
 서브루틴을 호출할 때 전달되는 인자는 X0 ~ X7 레지스터에 저장된다.
 함수의 리턴값은 X0 레지스터에 저장된다.

위 내용을 보면 알 수 있듯이 Armv7 아키텍처에서 다룬 AAPCS의 주요 개념은 Armv8 아키텍처에 거의 그대로 적용됩니다. 서브루틴을 호출할 때 사용되는 레지스터와 어셈블리 명령어만 다릅니다. 

이번 포스트에서는 Armv8 아키텍처에서 정의된 레지스터 목록 중에서 AAPCS와 관련된 레지스터를 소개합니다. Armv7 아키텍처에서 소개한 AAPCS와 유사한 개념이 많으니 차이점 위주로 설명하겠습니다.

[정보] AAPCS64란?
이번 장에서 소개하는 Armv8 아키텍처의 AAPCS는 64비트를 기준으로 설명하는데, 이를 AAPCS64로 표기합니다.

 

<강의 영상>

 

리눅스 커널에서의 인터럽트 처리 흐름
인터럽트가 발생했을 때 커널이 이를 처리하는 과정은 다음과 같이 3단계로 나눌 수 있습니다.



 1 단계: 인터럽트 발생
인터럽트가 발생하면 프로세스 실행을 중지하고 인터럽트 벡터로 이동합니다. 인터럽트 벡터에서 인터럽트 처리를 마무리한 후 다시 프로세스를 실행하기 위해 실행 중인 프로세스 레지스터 세트를 스택에 저장합니다. 이후 커널 내부 인터럽트 함수를 호출합니다. 


 2단계: 인터럽트 핸들러 호출
커널 내부에서는 발생한 인터럽트에 대응하는 인터럽트 디스크립터를 읽어서 인터럽트 핸들러를 호출합니다. 


 3단계: 인터럽트 핸들러 실행
인터럽트 핸들러에서 하드웨어를 직접 제어하고 유저 공간에 이 변화를 알립니다.

 


이해를 돕기 위해 한 가지 예를 들어보겠습니다. 안드로이드 휴대폰에서 화면을 손을 만지는 동작에서 여러분이 손으로 휴대폰 화면을 터치하면 내부 동작은 다음과 같은 단계로 나눌 수 있습니다.

 1단계: 터치 인터럽트 발생
하드웨어적인 터치 모듈이 변화를 감지하고 터치 모듈에 대한 인터럽트를 발생시킵니다. 이때 인터럽트 벡터가 실행됩니다.


 2단계: 터치 인터럽트 핸들러 호출
커널은 터치 인터럽트 번호로 해당 인터럽트 디스크립터를 읽습니다. 다음 인터럽트 디스크립터에 저장된 인터럽트 핸들러 주소를 찾아 인터럽트 핸들러를 호출합니다.


 3단계: 터치 인터럽트 핸들러 실행
결국 터치 인터럽트 핸들러는 해당 터치 인터럽트를 받아 정해진 처리를 합니다. 화면을 업데이트하거나 하드웨어 터치 디바이스에 인터럽트를 잘 받았다는 사실을 알립니다. 


“인터럽트 디스크립터”, “인터럽트 벡터” 같은 낯선 용어로 설명했는데, 이러한 용어의 공학적 의미는 하나하나 각 장에서 다룰 예정입니다. 


인터럽트가 발생하면 이를 커널이 처리하는 과정을 터치 드라이버를 예로 들어 살펴봤습니다. 인터럽트 발생을 처리하는 단계를 함수 흐름과 실행 주체별로 분류하면 다음 그림과 같습니다. 

 


그림 5.5 ARM 프로세서/리눅스 커널/디바이스 드라이버별 인터럽트 처리 흐름

전체 실행 흐름은 다음의 3단계로 분류할 수 있습니다.

1. ARM 프로세스
인터럽트가 발생하면 실행 중인 코드를 멈춘 후 인터럽트 벡터로 실행 흐름을 이동합니다. ARM 프로세스와 연관된 동작입니다. 

2. 리눅스 커널
인터럽트 벡터로 프로그램 카운터를 브랜치합니다. 커널 인터럽트 내부 함수에서 인터럽트를 관리하는 자료구조인 인터럽트 디스크립터를 읽습니다. 인터럽트 디스크립터에 저장된 인터럽트 핸들러를 호출합니다.

3. 디바이스 드라이버
각 디바이스 드라이버에서 등록한 인터럽트 핸들러를 실행해 인터럽트 발생에 대한 처리를 수행합니다.

정리하면 “인터럽트로 하드웨어적인 변화가 발생하면 리눅스 커널에서 어떻게 처리하는가"입니다. 이를 위해 이번 절에서는 인터럽트에 대해 소개했으니 이어지는 절에서 인터럽트 컨텍스트에 대해 살펴보겠습니다.

ARM 프로세서 관점의 시스템 콜 처리

리눅스 시스템에서 시스템 콜 관련 코드를 읽다 보면 어셈블리 코드를 만나게 됩니다. 


보통 어셈블리 코드는 ARM 프로세서 입장에서 실행하는 동작입니다. 어셈블리 코드로 구현돼 있으니 시스템 콜이 아키텍처(ARM, x86) 동작과 연관이 있다고 볼 수 있습니다. 


라즈베리 파이는 ARMv7 아키텍처에서 구동되므로 ARMv7(Aarch32, ARM 32비트) 프로세서 기준으로 시스템 콜의 세부 동작 방식을 알아보겠습니다.

ARM 프로세서 모드

 


ARM 프로세서에서 시스템 콜이 어떻게 동작하는지 알려면 ARM 프로세스 모드에 대해 알아야 합니다. ARM 프로세서는 다음과 같이 6가지 모드를 지원하며, 각 모드별 레지스터 세트를 저장합니다. 

 Supervisor
 FIQ
 IRQ
 ABORT
 UNDEF
 USER

보통 ARM 기반 리눅스 커널에서 커널 모드는 ARM의 Supervisor 모드에서 실행하고, 유저 애플리케이션은 ARM의 User 모드에서 실행됩니다. 
 ARM Supervisor 모드: 커널 공간
 ARM USER 모드: 유저 공간

여기서 다음과 같은 의문이 생깁니다. 그렇다면 ARM 프로세스 관점에서 USER 모드에서 Supervisor 모드로 스위칭하려면 어떻게 해야 할까?

ARM 프로세서에서 각 모드를 전환시키려면 익셉션을 발생시켜야 합니다. 유저 모드에서 커널 모드로 진입하려면 USER 모드에서 Supervisor 모드로 실행 모드 변환을 해야 합니다. 이를 위해 익셉션을 유발해야 하며, 이 과정에서 슈퍼바이저 콜(Supervisor Call)이라는 어셈블리 명령어를 실행해야 합니다.

ARM 프로세서 관점에서 소프트웨어 인터럽트를 발생시키는 슈퍼바이저 콜의 흐름은 다음과 같습니다.

 


그림 11.3 ARM 프로세서 관점의 시스템 콜 실행 흐름 

보다시피 유저 공간에서 'svc' 어셈블리 명령어를 실행하면 커널 공간에 있는 'vector_swi' 벡터로 실행 흐름이 바뀝니다. 그렇다면 ARM 아키텍처 관점에서 시스템 콜은 어떻게 처리할까요?  

ARM 아키텍처 입장에서 시스템 콜은 소프트웨어 인터럽트로 처리합니다. 즉, ARM 프로세서에서 소프트웨어 인터럽트는 익셉션의 한 종류로 처리합니다. ARM 프로세서에서 익셉션이 발생하면 이미 정해놓은 주소로 ARM 프로그램 카운터를 브랜치하고 정해진 동작을 수행합니다.  

그렇다면 익셉션으로 소프트웨어 인터럽트만 있을까요? 소프트웨어 인터럽트는 익셉션의 종류 중 하나입니다. 대표적인 익셉션으로 인터럽트를 예로 들 수 있습니다. 인터럽트가 발생하면 인터럽트 벡터인 irq_svc로 브랜치합니다. 마찬가지로 소프트웨어 인터럽트가 발생하면 이미 정해진 주소인 vector_swi 레이블로 브랜치합니다.

ARM 프로세서에서는 소프트웨어 인터럽트를 다음 2번째 줄과 같은 명령어로 실행합니다.

1 0x76f01170 <__libc_fork+276>    mov    r7, #120        ; 0x78                                                                                    
2 0x76f01174 <__libc_fork+280>    svc    0x00000000


각 아키텍처별로 커널 모드에서 유저 모드로 변환시키는 방식은 다릅니다.


ARM 프로세스 입장에서 슈퍼바이저 콜(Supervisor Call)을 실행하면 모드 전환을 처리합니다. 이 동작이 시스템 콜로 어떻게 동작하는지 알 수 없습니다.


수많은 운영체제들이 ARM 프로세서를 CPU로 쓰고 있습니다. 리눅스는 ARM 프로세서에서 탑재된 수많은 운영체제 중 하나일 뿐입니다.


ARM 프로세서에서 슈퍼바이저 콜로 시스템 콜 익셉션 벡터로 분기하는 과정
ARM 프로세서 기반으로 구동하는 리눅스 커널에서는 슈퍼바이저 콜로 소프트웨어 인터럽트가 발생하기 전의 시스템 콜 번호를 r7 레지스터에 저장합니다.

 

 

      
그림 11.4 ARM 프로세서 관점의 리눅스 시스템 콜 실행 흐름

USER 모드에서 ARM r7 레지스터에 POSIX 규약에서 정의한 시스템 콜 번호를 지정하고 ‘svc’ 명령어로 슈퍼바이저 콜을 실행해 Supervisor 모드로 전환합니다. Supervisor 모드는 커널 코드가 실행하는 커널 공간임을 기억합시다. Supervisor(커널) 모드에서 vector_swi 레이블을 실행할 때 USER 모드에서 저장한 r7 레지스터를 읽습니다. 

이후 시스템 콜 테이블인 sys_call_table 변수의 주소에 접근해 r7에 저장된 시스템 콜 번호에 따라 시스템 콜 핸들러로 분기합니다.

     
소프트웨어 인터럽트에 대해 조금 더 배워 봅시다. 우선 소프트웨어 인터럽트는 인터럽트가 아닙니다. 여기서 말하는 ‘인터럽트’는 하드웨어에서 올려주는 전기 신호로서 언제 발생할지 모르는 비동기적인 이벤트이나 통지입니다.

그런데 소프트웨어 인터럽트는 ARM 프로세서에서 제공하는 “svc” 어셈블리 명령어를 실행하면 동작합니다. 그럼 소프트웨어 인터럽트를 발생시키는 주인공은 누구일까요? 바로 프로세스입니다. 즉, 소프트웨어 인터럽트라는 용어의 인터럽트는 하드웨어 디바이스에서 비동기적으로 전달하는 신호는 아닙니다.

실제 인터럽트가 발생하면 ARM 프로세서는 인터럽트를 익셉션의 한 종류로 처리합니다. 처리 과정을 조금 더 세분화해서 보면 다음과 같습니다. 

1. 익셉션 발생(인터럽트는 비동기적인 신호)
2. 익셉션 벡터로 ARM 프로그램 카운터를 이동
3. 익셉션 벡터에서 기존에 실행 중인 레지스터 세트를 스택 공간에 저장
4. 익셉션 종류에 따른 서브 루틴으로 분기
5. 익셉션 처리를 마무리한 후 익셉션 서브 루틴을 실행한 주소로 복귀
6. 스택에 푸시한 레지스터를 ARM 레지스터 세트에 로딩해서 익셉션이 발생하기 전에 실행했던 주소로 이동

그런데 소프트웨어 인터럽트는 인터럽트가 아니라고 앞에서 말씀드렸습니다. 대신 소프트웨어 인터럽트는 ARM에서 지원하는 어셈블리 코드의 "svc" 명령어를 명시적으로 실행해서 익셉션을 유발합니다. 따라서 소프트웨어 인터럽트는 하드웨어 신호로 발생하는 비동기적인 이벤트는 아닙니다. 

앞에서 언급한 인터럽트 익셉션이 발생했을때 6가지 단계로 실행 흐름을 분류했습니다. 그러면 ‘익셉션’이란 단어를 ‘소프트웨어 인터럽트’로 바꿔 볼까요?

1. 유저 모드에서 svc "0x00000000" 명령어의 실행으로 커널 코드에 진입
2. 소프트웨어 인터럽트 벡터로 ARM 프로그램 카운터를 이동
3. 소프트웨어 인터럽트 벡터에서 기존에 실행 중인 레지스터 세트를 스택 공간에 저장
4. 소프트웨어 인터럽트 종류에 따른 서브 루틴으로 분기
5. 소프트웨어 인터럽트 처리를 마무리한 후 소프트웨어 인터럽트 서브 루틴을 실행한 주소로 복귀
6. 소프트웨어 인터럽트에 푸시한 레지스터를 ARM 레지스터 세트에 로딩해서 소프트웨어 인터럽트 전에 실행했던 주소로 이동(유저 모드 복귀)

소프트웨어 인터럽트를 유발하는 소스가 다른 것이지 ARM 프로세서에서 인터럽트 벡터를 실행해서 인터럽트를 처리하는 방식은 같습니다.

ARM 프로세서에서 인터럽트 벡터는 __irq_svc입니다. 마찬가지로 소프트웨어 인터럽트 벡터는 vector_swi입니다.

유저 공간에서 소프트웨어 인터럽트를 발생시키면 다음과 같은 과정으로 실행 흐름이 바뀝니다.



그림 11.5 ARM 프로세서 관점의 소프트웨어 인터럽트 발생 흐름

위 그림은 순수하게 ARM 프로세서 익셉션 관점에서 본 실행 흐름입니다. 여기서 한 가지 의문이 생깁니다. 과연 ARM 프로세서는 시스템 콜을 알고 있을까요?

ARM 프로세서 입장에서는 시스템 콜이 무엇인지 모릅니다. ARM 프로세서는 유저 공간에서 'svc 0x0' 명령어를 실행하면 해당 벡터인 vector_swi 레이블을 브랜치하기만 할 뿐입니다. 다른 관점에서 보면 리눅스 커널에서 ARM 프로세서의 '익셉션 동작' 원리를 활용해 시스템 콜을 구현한 것입니다.

그렇다면 다른 CPU 아키텍처 입장에서 생각해볼까요? x86, PowerPC, ARMv8(64비트) 프로세서는 시스템 콜을 알고 있을까요? 마찬가지로 앞에서 언급한 프로세스들도 시스템 콜이 무엇인지 모릅니다. 

앞에서 설명한 내용을 종합하면 시스템 콜의 세부 구현 방식은 다음과 같다고 결론 내릴 수 있습니다. 

각 CPU 아키텍처의 특징을 활용해 시스템 콜을 구현한다.

사실 ARM 프로세서 입장에서는 지금 실행 중인 운영체제가 리눅스인지 모릅니다. 단지 소프트웨어 인터럽트를 발생시킨 다음 vector_swi 레이블로 분기할 뿐입니다. 


다음 절에서는 시스템 콜 종류별로 시스템 콜 핸들러를 분기하는 시스템 콜 테이블을 살펴봅시다. 

시스템 콜의 특징

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

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

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

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

 


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

 

 

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

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

+ Recent posts