3.2 Low-level exception handling in Linux
3.2: Low-level exception handling in Linux
주어진 큰 리눅스 커널 소스에서 인터럽트를 처리하는 역할을 하는 코드를 찾는 좋은 방법은 어떤 걸까요? 한 아이디어를 제안하겠습니다. 벡터 베이스 주소는 vbar_el1
레지스터에 저장되기에 만약 vbar_el1
을 찾는다면 벡터 테이블이 어디서 초기화되는 지 찾을 수 있을 것입니다. 사실 검색은 몇 가지 사용법을 제공하며 그 중 하나는 이미 우리에게 친숙한 head.S입니다. 이 코드는 __primary_switched 함수안에 있습니다. 이 함수는 MMU가 켜진 후 실행됩니다. 이 코드는 아래와 유사합니다.
adr_l x8, vectors // load VBAR_EL1 with virtual
msr vbar_el1, x8 // vector table address
이 코드에서 부터 벡터 테이블이 vectors
로 불리고 정의를 쉽게 찾을 수 있어야 한다는 것을 추측할 수 있습니다.
/*
* Exception vectors.
*/
.pushsection ".entry.text", "ax"
.align 11
ENTRY(vectors)
kernel_ventry el1_sync_invalid // Synchronous EL1t
kernel_ventry el1_irq_invalid // IRQ EL1t
kernel_ventry el1_fiq_invalid // FIQ EL1t
kernel_ventry el1_error_invalid // Error EL1t
kernel_ventry el1_sync // Synchronous EL1h
kernel_ventry el1_irq // IRQ EL1h
kernel_ventry el1_fiq_invalid // FIQ EL1h
kernel_ventry el1_error_invalid // Error EL1h
kernel_ventry el0_sync // Synchronous 64-bit EL0
kernel_ventry el0_irq // IRQ 64-bit EL0
kernel_ventry el0_fiq_invalid // FIQ 64-bit EL0
kernel_ventry el0_error_invalid // Error 64-bit EL0
#ifdef CONFIG_COMPAT
kernel_ventry el0_sync_compat // Synchronous 32-bit EL0
kernel_ventry el0_irq_compat // IRQ 32-bit EL0
kernel_ventry el0_fiq_invalid_compat // FIQ 32-bit EL0
kernel_ventry el0_error_invalid_compat // Error 32-bit EL0
#else
kernel_ventry el0_sync_invalid // Synchronous 32-bit EL0
kernel_ventry el0_irq_invalid // IRQ 32-bit EL0
kernel_ventry el0_fiq_invalid // FIQ 32-bit EL0
kernel_ventry el0_error_invalid // Error 32-bit EL0
#endif
END(vectors)
친숙해 보이지 않나요? 코드를 복사하고 조금 단순화했습니다. kernel_ventry 매크로는 RPi OS에서 정의된 ventry와 거의 유사합니다. 하지만 한 가지 차이는 kernel_ventry
는 커널 스택 오버플로우가 발생했는지 확인하는 역할 또한 수행한다는 점입니다. 이 기능은 CONFIG_VMAP_STACK
이 설정되면 활성화 되고 Virtually mapped kernel stacks
라 불리는 커널 기능의 한 부분입니다. 여기서 자세하게 다루지는 않지만 흥미가 있다면 이 기사를 읽어보기를 권장합니다.
kernel_entry
kernel_entry 매크로는 여러분에게 익숙해야 합니다. RPi OS에서 상응하는 매크로와 정확히 같은 방식으로 사용됩니다. 원래 리눅스 버전은 좀 더 복잡합니다. 코드는 아래와 같습니다.
.macro kernel_entry, el, regsize = 64
.if \regsize == 32
mov w0, w0 // zero upper 32 bits of x0
.endif
stp x0, x1, [sp, #16 * 0]
stp x2, x3, [sp, #16 * 1]
stp x4, x5, [sp, #16 * 2]
stp x6, x7, [sp, #16 * 3]
stp x8, x9, [sp, #16 * 4]
stp x10, x11, [sp, #16 * 5]
stp x12, x13, [sp, #16 * 6]
stp x14, x15, [sp, #16 * 7]
stp x16, x17, [sp, #16 * 8]
stp x18, x19, [sp, #16 * 9]
stp x20, x21, [sp, #16 * 10]
stp x22, x23, [sp, #16 * 11]
stp x24, x25, [sp, #16 * 12]
stp x26, x27, [sp, #16 * 13]
stp x28, x29, [sp, #16 * 14]
.if \el == 0
mrs x21, sp_el0
ldr_this_cpu tsk, __entry_task, x20 // Ensure MDSCR_EL1.SS is clear,
ldr x19, [tsk, #TSK_TI_FLAGS] // since we can unmask debug
disable_step_tsk x19, x20 // exceptions when scheduling.
mov x29, xzr // fp pointed to user-space
.else
add x21, sp, #S_FRAME_SIZE
get_thread_info tsk
/* Save the task's original addr_limit and set USER_DS (TASK_SIZE_64) */
ldr x20, [tsk, #TSK_TI_ADDR_LIMIT]
str x20, [sp, #S_ORIG_ADDR_LIMIT]
mov x20, #TASK_SIZE_64
str x20, [tsk, #TSK_TI_ADDR_LIMIT]
/* No need to reset PSTATE.UAO, hardware's already set it to 0 for us */
.endif /* \el == 0 */
mrs x22, elr_el1
mrs x23, spsr_el1
stp lr, x21, [sp, #S_LR]
/*
* In order to be able to dump the contents of struct pt_regs at the
* time the exception was taken (in case we attempt to walk the call
* stack later), chain it together with the stack frames.
*/
.if \el == 0
stp xzr, xzr, [sp, #S_STACKFRAME]
.else
stp x29, x22, [sp, #S_STACKFRAME]
.endif
add x29, sp, #S_STACKFRAME
#ifdef CONFIG_ARM64_SW_TTBR0_PAN
/*
* Set the TTBR0 PAN bit in SPSR. When the exception is taken from
* EL0, there is no need to check the state of TTBR0_EL1 since
* accesses are always enabled.
* Note that the meaning of this bit differs from the ARMv8.1 PAN
* feature as all TTBR0_EL1 accesses are disabled, not just those to
* user mappings.
*/
alternative_if ARM64_HAS_PAN
b 1f // skip TTBR0 PAN
alternative_else_nop_endif
.if \el != 0
mrs x21, ttbr0_el1
tst x21, #0xffff << 48 // Check for the reserved ASID
orr x23, x23, #PSR_PAN_BIT // Set the emulated PAN in the saved SPSR
b.eq 1f // TTBR0 access already disabled
and x23, x23, #~PSR_PAN_BIT // Clear the emulated PAN in the saved SPSR
.endif
__uaccess_ttbr0_disable x21
1:
#endif
stp x22, x23, [sp, #S_PC]
/* Not in a syscall by default (el0_svc overwrites for real syscall) */
.if \el == 0
mov w21, #NO_SYSCALL
str w21, [sp, #S_SYSCALLNO]
.endif
/*
* Set sp_el0 to current thread_info.
*/
.if \el == 0
msr sp_el0, tsk
.endif
/*
* Registers that may be useful after this macro is invoked:
*
* x21 - aborted SP
* x22 - aborted PC
* x23 - aborted PSTATE
*/
.endm
이제 kernel_entry
매크로를 상세하게 탐색해볼 것입니다.
.macro kernel_entry, el, regsize = 64
이 매크로는 2 매개변수: el
, regsize
를 받습니다. el
은 예외가 EL0 또는 EL1에서 생성되냐에 따라서 0
또는 1
이 될 수 잇습니다. regsize
는 32 비트 EL0이면 32이고 다른 경우에는 64입니다.
.if \regsize == 32
mov w0, w0 // zero upper 32 bits of x0
.endif
32 비트 모드에서 32 비트 범용 레지스터를 사용합니다.(x0
대신에 w0
). w0
은 x0
의 하위 부분에 구조적으로 매핑됩니다. 제공된 코드는 w0
에 쓰기를 하여 x0
레지스터의 상위 32 비트를 0으로 만듭니다.
stp x0, x1, [sp, #16 * 0]
stp x2, x3, [sp, #16 * 1]
stp x4, x5, [sp, #16 * 2]
stp x6, x7, [sp, #16 * 3]
stp x8, x9, [sp, #16 * 4]
stp x10, x11, [sp, #16 * 5]
stp x12, x13, [sp, #16 * 6]
stp x14, x15, [sp, #16 * 7]
stp x16, x17, [sp, #16 * 8]
stp x18, x19, [sp, #16 * 9]
stp x20, x21, [sp, #16 * 10]
stp x22, x23, [sp, #16 * 11]
stp x24, x25, [sp, #16 * 12]
stp x26, x27, [sp, #16 * 13]
stp x28, x29, [sp, #16 * 14]
이 부분은 모든 범용 레지스터를 스택에 저장합니다. 스택 포인터는 저장되어야 할 모든 것들을 맞추기(fit) 위해 kernel_ventry에 서이미 조정되는 것을 유의하세요. 리눅스에서 익셉션 핸들러 내부에서 나중에 저장된 레지스터를 접근하기 위해서 사용되는 pt_regs 라는 특별한 구조체가 있기 때문에 레지스터를 저장하는 순서는 문제가 됩니다. 보이듯이 이 구조체는 범용 레지스터 뿐만 아니라 kernel_entry
매크로에서 나중에 변경되는 다른 정보들도 포함합니다. 다음 몇 레슨에서 비슷한 것을 구현하고 사용할 것이기 때문에 pt_regs
구조체를 기억하기를 권장합니다.
.if \el == 0
mrs x21, sp_el0
x21
은 이제 붕괴된 스택 포인터를 가집니다. 리눅스에서 작업은 유저와 커널 모드를 위한 2개의 다른 스택을 사용하는 것을 유의하세요. 유저 모드의 경우 예외가 발생 하는 순간에 스택 포인터 값을 알아내기 위해 sp_el0
레지스터를 사용할 수 있습니다. 컨텍스트 스위치 중에 스택 포인터를 바꿔야하기 때문에 이 코드는 매우 중요합니다. 다음 레슨에서 이에 대해 자세히 다룰 것입니다.
ldr_this_cpu tsk, __entry_task, x20 // Ensure MDSCR_EL1.SS is clear,
ldr x19, [tsk, #TSK_TI_FLAGS] // since we can unmask debug
disable_step_tsk x19, x20 // exceptions when scheduling.
MDSCR_EL1.SS
비트는 “Software Step exceptions”를 활성화하는 역할을 합니다. 만약 이 비트가 설정되고 디버그 예외가 활성화 되있다면 예외는 어떤 명령어든 실행된 후 발생하게 됩니다. 이 것은 디버거에 의해서 보통 사용됩니다. 유저 모드에서 예외가 발생 할 때 현재 작업에서 TIF_SINGLESTEP 플래그가 설정되있는지 우선 확인해야 합니다. 만약 설정되 있다면 작업이 디버거 하에 동작중이라는 것을 의미하고 MDSCR_EL1.SS
비트를 설정을 풀어야(unset) 합니다. 이 코드에서 이해해야 하는 중요한 것은 현재 작업에 대한 정보가 어떻게 얻어지는 지 입니다. 리눅스에서 각 프로세스 또는 스레드(나중에 스레드를 그냥 “작업”으로 표기할 것입니다)가 연관된 task_struct를 가집니다. 이 구조체는 작업에 대한 모든 메타데이터 정보를 포함합니다. arm64
아키텍처에서 task_struct
는 thread_info라느ㄴ 다른 구조를 내포하여 task_struct
를 가르키는 포인터가 thread_info
를 가르키는 포인터로써 사용될 수 있습니다. thread_info
는 entry.S
가 직접 접근을 해야하는 하위 수준의 값과 함께 저장되는 플래그가 위치하는 곳입니다.
mov x29, xzr // fp pointed to user-space
비록 x29
는 범용 레지스터이지만 보통 특별한 의미를 갖습니다. “Frame pointer”로서 사용됩니다. 이제 이것의 목적에 대해 설명하겠습니다.
함수가 컴파일 될 때 첫 몇가지 명령어는 보통 오래된 프레임 포인터와 링크 레지스터 값을 스택에 저장하는 역할을 합니다.(빠르게 돌아보면 : x30
은 링크 레지스터로 불리고 ret
명령어에서 사용되는 “반환 주소”를 가지고 있습니다) 그 후 새로운 스택 프레임이 할당되어 함수의 모든 지역 변수를 포함할 수 있고 프레임 포인터 레지스터는 프레임의 바닥을 가르키게 설정됩니다. 함수가 어떤 지역 변수에 접근이 필요할 때마다 간단하게 프레임 포인터에 하드 코딩된 오프셋을 더합니다. 이제 오류가 발생하고 스택 트레이스를 생성해야 하는 것을 상상해 보세요. 스택에서 모든 지역 변수를 찾기 위해 현재 프레임 포인터를 사용할 수 있고 링크 레지스터는 호출하는 함수(caller)의 정확한 위치를 알아내는 것에 사용될 수 있습니다. 다음으로 오래된 프레임 포인터와 링크 레지스터 값은 항상 스택 프레임의 시작에 저장되는 사실을 이용하여 거기서 부터 그냥 읽어드립니다. 호출하는 함수의 프레임 포인터를 얻은 후 이제 모든 지역 변수 또한 접근할 수 있습니다. 이 프로세스는 스택의 최정상에 닿을 때까지 재귀적으로 반복되고 “stack unwinding”이라 불립니다. 비슷한 알고리즘이 ptrace 시스템 콜에서 사용됩니다.
이 제kernel_entry
매크로로 돌아가 EL0에서 예외가 발생한 후 x29
레지스터를 왜 클리어 할 필요가 있다는 것을 명확히 해야 합니다. 이는 리눅스에서 각 작업은 유저 모드와 커널 모드를 위한 다른 스택을 사용하기 때문이기에 일반 스택 트레이스를 갖는 것은 말이 안됩니다.
.else
add x21, sp, #S_FRAME_SIZE
이제 else 구문안에 있습니다. 이 else 구문이 오로지 EL1 에서 발생한 예외를 다룰 때만 연관있는 코드라는 것을 의미합니다. 이러한 경우 예전 스택을 재활용하고 주어진 코드는 원래 sp
값을 추후 사용을 위해 x21
레지스터에 저장합니다.
/* Save the task's original addr_limit and set USER_DS (TASK_SIZE_64) */
ldr x20, [tsk, #TSK_TI_ADDR_LIMIT]
str x20, [sp, #S_ORIG_ADDR_LIMIT]
mov x20, #TASK_SIZE_64
str x20, [tsk, #TSK_TI_ADDR_LIMIT]
Task address limit은 사용될 수 있는 가장 큰 가상 주소를 특정합니다. 유저 프로세스가 32 비트 모드에서 동작할 때 이 한계는 2^32
입니다. 64 비트 커널에서는 더 커질 수 있고 보통은 2^48
입니다. 만약 32 비트 EL1에서 예외가 발생한다면 task address limit은 TASK_SIZE_64로 수정되어야 합니다. 또한 실행이 유저 모드로 돌아가기 전에 복원되어야 하기 때문에 원래 주소 한계를 저장하는 것도 필요합니다.
mrs x22, elr_el1
mrs x23, spsr_el1
elr_el1
과 spsr_el1
은 예외를 처리하기 전에 스택에 저장되어야 합니다. 지금은 예외가 발생한 곳에서 같은 위치로 항상 반환하기 때문에 아직 RPi OS에서 수행하지는 않았습니다. 그러나 예외를 처리하는 중 컨텍스트 스위치를 해야하는 경우라면? 다음 레슨에서 자세하게 이 시나리오를 다룰 것입니다.
stp lr, x21, [sp, #S_LR]
링크 레지스터와 프레임 포인터 레지스터는 스택에 저장됩니다. 이미 예외가 EL0 또는 EL1에서 발생하냐에 따라서 프레임 포인터가 다르게 계산되는 것을 보셨습니다. 그리고 이 계산의 결과는 x21
레지스터에 이미 저장됩니다.
/*
* In order to be able to dump the contents of struct pt_regs at the
* time the exception was taken (in case we attempt to walk the call
* stack later), chain it together with the stack frames.
*/
.if \el == 0
stp xzr, xzr, [sp, #S_STACKFRAME]
.else
stp x29, x22, [sp, #S_STACKFRAME]
.endif
add x29, sp, #S_STACKFRAME
여기서 pt_regs
구조체의 stackframe 속성이 채워집니다. 이 속성은 링크 레지스터와 프레임 포인터를 포함하고 지금은 elr_el1
(지금 x22
인)이 lr
대신에 사용됩니다. stackframe
은 오로지 stack unwinding만을 위해서 사용됩니다.
#ifdef CONFIG_ARM64_SW_TTBR0_PAN
alternative_if ARM64_HAS_PAN
b 1f // skip TTBR0 PAN
alternative_else_nop_endif
.if \el != 0
mrs x21, ttbr0_el1
tst x21, #0xffff << 48 // Check for the reserved ASID
orr x23, x23, #PSR_PAN_BIT // Set the emulated PAN in the saved SPSR
b.eq 1f // TTBR0 access already disabled
and x23, x23, #~PSR_PAN_BIT // Clear the emulated PAN in the saved SPSR
.endif
__uaccess_ttbr0_disable x21
1:
#endif
CONFIG_ARM64_SW_TTBR0_PAN
매개 변수는 커널이 유저 공간 메모리를 직접적으로 접근하는 것을 방지합니다. 만약 이것이 언제 유용한지 궁금하다면 이 기사를 읽을 수 있습니다. 이제는 이러한 보안 기능은 우리의 주제에 너무 많이 벗어나기 때문에 어떻게 이것이 동작하는 지 자세한 설명은 넘어가겠습니다.
stp x22, x23, [sp, #S_PC]
여기서 elr_el1
과 spsr_el1
은 스택에 저장됩니다.
/* Not in a syscall by default (el0_svc overwrites for real syscall) */
.if \el == 0
mov w21, #NO_SYSCALL
str w21, [sp, #S_SYSCALLNO]
.endif
pt_regs
구조체는 현재 예외가 시스템 콜인지 아닌지 알려주는 한 필드를 가집니다. 기본적으로 시스템 콜이 아니라고 가정합니다. syscall이 어떻게 동작하는 지 자세한 설명을 위해 5 렉처 까지 기다려 주세요.
/*
* Set sp_el0 to current thread_info.
*/
.if \el == 0
msr sp_el0, tsk
.endif
작업이 커널 모드에서 실행될 때 sp_el0
은 필요가 없습니다. 이 값은 이전에 스택에 저장되기에 kernel_exit
매크로에서 쉽게 복원됩니다. 이 시점에서 시작해서 sp_el0
는 현재 task_struct를 빠르게 접근하기 위해서 포인터를 가지는 데 사용됩니다.
el1_irq
다음으로 살펴볼 것은 EL1에서 발생한 IRQs를 처리하는 역할을 하는 핸들러입니다. vector table으로 부터 그 핸들러가 el1_irq
라고 불리고 여기에 정의된 것을 쉽게 찾을 수 있습니다. 이제 코드를 살펴보고 줄 단위로 살펴봅시다.
el1_irq:
kernel_entry 1
enable_dbg
#ifdef CONFIG_TRACE_IRQFLAGS
bl trace_hardirqs_off
#endif
irq_handler
#ifdef CONFIG_PREEMPT
ldr w24, [tsk, #TSK_TI_PREEMPT] // get preempt count
cbnz w24, 1f // preempt count != 0
ldr x0, [tsk, #TSK_TI_FLAGS] // get flags
tbz x0, #TIF_NEED_RESCHED, 1f // needs rescheduling?
bl el1_preempt
1:
#endif
#ifdef CONFIG_TRACE_IRQFLAGS
bl trace_hardirqs_on
#endif
kernel_exit 1
ENDPROC(el1_irq)
아래는 이 함수 아래에서 수행되는 것입니다.
kernel_entry
와kernel_exit
매크로는 프로세서 상태를 저장하고 복원하는 데 불립니다. 첫 번째 매개 변수는 EL1에서 예외가 발생한 것을 나타냅니다.- 디버그 인터럽트는
enable_dbg
매크로를 호출하여 활성화 됩니다. 이 시점에서 프로세서 상태가 이미 저장되있기 때문에 활성화하기에 안전하고 인터럽트 핸들러 도중에 디버그 예외가 발생하더라도 잘 처리될 것입니다. 만약 애초에 인터럽트를 처리하는 중에 디버그 예외를 활성화 해야 할 필요가 있는지 궁금하시다면 이 커밋 메시지를 읽어주세요. #ifdef CONFIG_TRACE_IRQFLAGS
블락안에 코드는 인터럽트를 추적하는 역할을 합니다. 인터럽트 시작과 끝 2가지 이벤트를 기록합니다.#ifdef CONFIG_PREMPT
블락 안에 코드는 스케줄러를 호출할지 확인하기 위해서 현재 작업 플래그를 접근합니다. 이 코드는 다음 레슨에서 상세히 다뤄질 것입니다.ire_handler
- 실제 인터럽트 처리가 수행되는 곳입니다.
irq_handler는 매크로 이고 다음과 같이 정의되있습니다.
.macro irq_handler
ldr_l x1, handle_arch_irq
mov x0, sp
irq_stack_entry
blr x1
irq_stack_exit
.endm
코드에서 볼 수 있듯이 irq_handler
는 handle_arch_irq 함수를 실행합니다. 이 함수는 “irk stack”이라 불리는 특별한 스택으로 실행됩니다. 다른 스택으로 변경이 왜 필요할까? RPi OS에서 예를 들면 이것을 하지 않습니다. 이것이 필수는 아니라고 추측하지만 이것 없이는 한 인터럽트가 작업 스택을 사용해서 처리될 것이고 인터럽트 핸들러를 위해 얼마나 남아 잇는지 절대 확신할 수 없을 것입니다.
다음으로 handle_arch_irq를 살펴보겠습니다. 이것은 함수가 아니라 변수로 보입니다. 이 변수는 set_handle_irq 함수 안에서 설정됩니다. 하지만 누가 설정하고 이 시점에 도달한 후 인터럽트의 희미해지는 것은 무엇인가? 이 레슨에 다음 챕터에서 이것의 답을 알아볼 것입니다.
Conclusion
결론적으로 이미 낮은 수준의 인터럽트 처리 코드를 이미 살펴보앗고 handle_arch_irq
를 향한 모든 벡터 테이블로부터 인터럽트의 경로를 추적할 수 잇다고 말할 수 있습니다. 이 시점이 인터럽트가 아키텍처 특정 코드를 떠나서 드라이버 코드에 의해 처리되기 시작하는 지점입니다. 다음 챕터에서 목표는 드라이버 소스 코드에서 타이머 인터럽트의 경로를 추적하는 것입니다.
댓글남기기