11 분 소요

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). w0x0의 하위 부분에 구조적으로 매핑됩니다. 제공된 코드는 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_structthread_info라느ㄴ 다른 구조를 내포하여 task_struct를 가르키는 포인터가 thread_info를 가르키는 포인터로써 사용될 수 있습니다. thread_infoentry.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_el1spsr_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_el1spsr_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_entrykernel_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_handlerhandle_arch_irq 함수를 실행합니다. 이 함수는 “irk stack”이라 불리는 특별한 스택으로 실행됩니다. 다른 스택으로 변경이 왜 필요할까? RPi OS에서 예를 들면 이것을 하지 않습니다. 이것이 필수는 아니라고 추측하지만 이것 없이는 한 인터럽트가 작업 스택을 사용해서 처리될 것이고 인터럽트 핸들러를 위해 얼마나 남아 잇는지 절대 확신할 수 없을 것입니다.

다음으로 handle_arch_irq를 살펴보겠습니다. 이것은 함수가 아니라 변수로 보입니다. 이 변수는 set_handle_irq 함수 안에서 설정됩니다. 하지만 누가 설정하고 이 시점에 도달한 후 인터럽트의 희미해지는 것은 무엇인가? 이 레슨에 다음 챕터에서 이것의 답을 알아볼 것입니다.

Conclusion

결론적으로 이미 낮은 수준의 인터럽트 처리 코드를 이미 살펴보앗고 handle_arch_irq를 향한 모든 벡터 테이블로부터 인터럽트의 경로를 추적할 수 잇다고 말할 수 있습니다. 이 시점이 인터럽트가 아키텍처 특정 코드를 떠나서 드라이버 코드에 의해 처리되기 시작하는 지점입니다. 다음 챕터에서 목표는 드라이버 소스 코드에서 타이머 인터럽트의 경로를 추적하는 것입니다.

태그:

카테고리:

업데이트:

댓글남기기