Linux v6.6 source

Linux 6.6 · arm64 · kernel/head.S

head.S 라인 바이 라인 읽기

Linux 6.6 기준으로 arch/arm64/kernel/head.S를 직접 따라가 본 기록입니다. 커널 이미지 첫 명령에서 시작해서 start_kernel()을 부르기 직전까지 봅니다.

전체 흐름

head.S는 C 코드로 넘어가기 전에 지나가는 arm64 부트 코드입니다. 크게 보면 부트로더가 넘긴 값을 보존하고, 임시 page table을 만들고, MMU를 켠 뒤 커널이 쓸 가상 주소로 갈아탑니다.

Bootloader x0에 FDT 물리 주소를 담고 커널 이미지 첫 명령으로 점프
Image Header 부트로더가 읽는 ARM64 이미지 메타데이터
primary_entry MMU 상태 기록, boot args 보존, ID map 생성
__primary_switch MMU 활성화, KASLR, 커널 매핑 생성
__primary_switched 스택, 벡터, BSS, FDT, EL2 마무리
start_kernel C 세계로 진입

라인 번호는 Linux v6.6 기준입니다. 중간에 커널 버전이 바뀌면 몇 줄씩 밀릴 수 있으니, 아래 원본과 같이 보는 쪽이 덜 헷갈립니다.

원본 코드 전문

아래는 이 글에서 기준으로 잡은 Linux v6.6 head.S 원문입니다. 브라우저에서 외부 raw 파일을 막는 경우가 있어서, 홈페이지 안에 로컬 복사본도 같이 넣었습니다.

arch/arm64/kernel/head.S · Linux v6.6 새 탭에서 로컬 원본 열기 →

부트로더와 커널의 계약

arm64 커널로 점프할 때 부트로더가 맞춰줘야 하는 조건입니다. 이 시점에는 커널 page table을 아직 못 쓰므로 MMU와 D-cache는 꺼져 있어야 합니다. x0에는 DTB, 정확히는 FDT blob의 물리 주소가 들어옵니다.

Register x0 = FDT physical address, x1-x3 = reserved or boot args snapshot
Cache/MMU MMU off, D-cache off, I-cache on/off allowed
Memory kernel Image와 dtb가 RAM에 배치되어 있어야 함

1-88: 이미지 헤더와 early register 약속

원본 코드 L1-L88 열기 →

파일 앞부분은 코드라기보다 이미지 헤더에 가깝습니다. 부트로더가 읽을 필드가 먼저 있고, 실제 실행은 곧바로 primary_entry 쪽으로 빠집니다.

L1-L10: 파일 목적, 라이선스, 작성자

원본 코드:
/* SPDX-License-Identifier: GPL-2.0-only */
/*
 * Low-level CPU initialisation
 * Based on arch/arm/kernel/head.S
 *
 * Copyright (C) 1994-2002 Russell King
 * Copyright (C) 2003-2012 ARM Ltd.
 * Authors:	Catalin Marinas <catalin.marinas@arm.com>
 *		Will Deacon <will.deacon@arm.com>
 */

설명: 실행과는 상관없는 머리말입니다. 여기서는 low-level CPU 초기화 파일이라는 점과 GPL-2.0-only 표시만 보면 됩니다.

더 읽기: 이 파일은 arm64 커널이 C 코드로 넘어가기 전에 CPU 상태를 만드는 코드입니다. 그래서 일반적인 드라이버 코드처럼 함수 호출 흐름만 보면 안 되고, 부트로더가 넘긴 CPU 상태와 레지스터 약속을 같이 봐야 합니다.

주석에 32-bit ARM의 arch/arm/kernel/head.S를 기반으로 했다고 적혀 있습니다. arm64 코드지만, 커널 이미지 헤더와 early boot에서 스택을 거의 믿지 않고 레지스터로 상태를 끌고 가는 방식은 ARM 계열 부트 코드의 흔적이 남아 있습니다.

L12-L38: 커널/ARM64 어셈블리 의존성

원본 코드:
#include <linux/linkage.h>
#include <linux/init.h>
#include <linux/pgtable.h>

#include <asm/asm_pointer_auth.h>
#include <asm/assembler.h>
#include <asm/boot.h>
#include <asm/bug.h>
#include <asm/ptrace.h>
#include <asm/asm-offsets.h>
#include <asm/cache.h>
#include <asm/cputype.h>
#include <asm/el2_setup.h>
#include <asm/elf.h>
#include <asm/image.h>
#include <asm/kernel-pgtable.h>
#include <asm/kvm_arm.h>
#include <asm/memory.h>
#include <asm/pgtable-hwdef.h>
#include <asm/page.h>
#include <asm/scs.h>
#include <asm/smp.h>
#include <asm/sysreg.h>
#include <asm/thread_info.h>
#include <asm/virt.h>

#include "efi-header.S"

설명: 뒤에서 나오는 매크로와 상수 대부분이 여기서 들어옵니다. 특히 assembler.h, kernel-pgtable.h, efi-header.S는 계속 보게 됩니다.

더 읽기: 이 include 목록은 뒤에서 나올 코드의 성격을 미리 보여줍니다. linkage.hSYM_CODE_START, SYM_FUNC_START 같은 심볼 선언 매크로를 제공하고, asm-offsets.h는 어셈블리에서 C 구조체 offset을 쓰기 위한 값들을 제공합니다.

kernel-pgtable.h, pgtable-hwdef.h, memory.h는 page table level, block size, mapping flag 계산에 필요합니다. 즉 이 파일은 단순 entry 코드가 아니라, MMU를 켜기 위한 최소 page table builder까지 같이 들고 있습니다.

efi-header.S는 EFI boot를 위해 PE/COFF 헤더를 붙이는 부분입니다. 보드 부트로더가 Image를 직접 부르는 경우와 UEFI가 EFI stub을 거쳐 들어오는 경우를 같은 파일 앞부분에서 처리합니다.

L40-L42: PAGE_OFFSET 2MB 정렬 검사

원본 코드:
#if (PAGE_OFFSET & 0x1fffff) != 0
#error PAGE_OFFSET must be at least 2MB aligned
#endif

설명: 초기 매핑은 2MB block 단위로 잡습니다. PAGE_OFFSET이 여기서 틀어지면 런타임까지 가지 않고 빌드에서 막습니다.

더 읽기: arm64 초기 부트에서는 아직 full page allocator가 없고, page table도 손으로 만들어야 합니다. 그래서 가능한 큰 block mapping을 써서 단순하게 갑니다. 2MB 정렬이 깨지면 같은 코드를 유지한 채로 초반 linear mapping을 만들기 어려워집니다.

이 검사는 런타임 방어 코드가 아니라 빌드 타임 방어 코드입니다. 커널 설정이나 link address 조합이 잘못되면 이미 이 지점에서 멈추게 해서, 나중에 MMU enable 직후 알 수 없는 hang으로 보이는 상황을 줄입니다.

L44-L55: 커널 진입 조건 문서화

원본 코드:
/*
 * Kernel startup entry point.
 * ---------------------------
 *
 * The requirements are:
 *   MMU = off, D-cache = off, I-cache = on or off,
 *   x0 = physical address to the FDT blob.
 *
 * Note that the callee-saved registers are used for storing variables
 * that are useful before the MMU is enabled. The allocations are described
 * in the entry routines.
 */

설명: 부트로더 쪽에서 지켜야 할 최소 조건입니다. bring-up 중 여기부터 틀리면 뒤쪽 MMU 코드까지 가지도 못합니다.

더 읽기: 여기 적힌 조건은 Documentation/arch/arm64/booting.rst의 커널 진입 조건과 맞물립니다. 부트로더는 RAM과 DTB를 준비하고, 커널 Image로 점프하기 전에 MMU off, D-cache off 상태를 맞춰야 합니다.

x0는 FDT 물리 주소입니다. 이 값은 바로 뒤에서 x21로 보존되고, 나중에 early_fdt_map까지 살아남아야 합니다. early boot에서 DTB 주소가 틀리면 증상이 다양하게 보이지만, 결국 machine description이나 memory node를 못 읽는 쪽으로 터집니다.

또 하나 자주 놓치는 부분은 cache입니다. 커널 문서에서는 커널 이미지 범위가 PoC까지 clean되어 있어야 한다고 요구합니다. 부트로더가 D-cache를 끄기만 하고 clean을 빼먹으면, CPU가 보는 Image와 메모리에 실제 남은 Image가 달라지는 식의 난해한 문제가 생길 수 있습니다.

L56-L61: 이미지 시작점

원본 코드:
	__HEAD
	/*
	 * DO NOT MODIFY. Image header expected by Linux boot-loaders.
	 */
	efi_signature_nop			// special NOP to identity as PE/COFF executable
	b	primary_entry			// branch to kernel start, magic

설명: 이미지 맨 앞에서 바로 primary_entry로 branch합니다. 그 앞의 NOP는 EFI/PE 쪽 식별에 걸리는 자리입니다.

더 읽기: __HEAD는 이 코드가 커널 Image의 맨 앞쪽에 와야 한다는 의미입니다. arm64 booting 문서의 64-byte image header에서 code0, code1에 해당하는 자리라고 보면 됩니다.

efi_signature_nop는 일반 실행 흐름에서는 NOP 성격이지만, EFI/PE 관점에서는 식별에 쓰이는 특별한 자리입니다. 바로 다음 명령인 b primary_entry가 실제 커널 부트 경로의 시작점입니다.

디버깅할 때 이 branch 이전에서 멈춘다면 커널 문제가 아니라 부트로더가 Image entry를 잘못 부르거나, 압축 Image 처리, load address, exception level 같은 진입 조건을 틀렸을 가능성이 큽니다.

L62-L69: ARM64 Image header 필드

원본 코드:
	.quad	0				// Image load offset from start of RAM, little-endian
	le64sym	_kernel_size_le			// Effective size of kernel image, little-endian
	le64sym	_kernel_flags_le		// Informative flags, little-endian
	.quad	0				// reserved
	.quad	0				// reserved
	.quad	0				// reserved
	.ascii	ARM64_IMAGE_MAGIC		// Magic number
	.long	.Lpe_header_offset		// Offset to the PE header.

설명: arm64 Image header 필드입니다. 부트로더는 여기서 이미지 크기, flags, magic, PE header offset을 읽습니다.

더 읽기: 이 64-byte header는 부트로더와 커널 사이의 파일 포맷 약속입니다. _kernel_size_le는 커널 이미지가 차지하는 effective size이고, _kernel_flags_le에는 endian, page size, physical placement 같은 정보가 들어갑니다.

le64sym라는 이름 그대로 little-endian 값으로 박힙니다. arm64 booting 문서도 v3.17 이후 header 필드는 little-endian이라고 설명합니다. 부트로더가 이 값을 native endian으로 대충 읽으면 page size나 image size 해석이 틀어질 수 있습니다.

ARM64_IMAGE_MAGIC는 이 파일이 arm64 Image임을 확인하는 magic입니다. 그 뒤 .Lpe_header_offset은 EFI path에서 PE header 위치를 찾는 데 쓰입니다.

L71-L73: EFI PE header와 idmap 섹션 진입

원본 코드:
	__EFI_PE_HEADER

	.section ".idmap.text","a"

설명: EFI용 PE header를 붙인 뒤 .idmap.text로 넘어갑니다. MMU 전환 전후에도 같은 주소로 실행되어야 하는 코드가 이 섹션에 들어갑니다.

더 읽기: EFI로 부팅하면 firmware는 Linux Image를 그냥 raw binary로만 보지 않고 PE/COFF executable처럼 취급합니다. 그래서 __EFI_PE_HEADER가 필요합니다. 반대로 일반 부트로더가 직접 Image 첫 명령으로 점프하는 경우에는 위의 branch가 정상 경로입니다.

.idmap.text는 이름 그대로 identity mapping으로 실행되어야 하는 코드가 놓이는 섹션입니다. MMU를 켜는 순간에는 가상 주소 해석이 바뀌므로, 그 경계에 걸친 코드는 PA와 VA가 같은 매핑 안에서 살아 있어야 합니다.

L75-L88: early boot 레지스터 배치

원본 코드:
	/*
	 * The following callee saved general purpose registers are used on the
	 * primary lowlevel boot path:
	 *
	 *  Register   Scope                      Purpose
	 *  x19        primary_entry() .. start_kernel()        whether we entered with the MMU on
	 *  x20        primary_entry() .. __primary_switch()    CPU boot mode
	 *  x21        primary_entry() .. start_kernel()        FDT pointer passed at boot in x0
	 *  x22        create_idmap() .. start_kernel()         ID map VA of the DT blob
	 *  x23        primary_entry() .. start_kernel()        physical misalignment/KASLR offset
	 *  x24        __primary_switch()                       linear map KASLR seed
	 *  x25        primary_entry() .. start_kernel()        supported VA size
	 *  x28        create_idmap()                           callee preserved temp register
	 */

설명: 초반에는 스택을 믿기 어렵습니다. 그래서 x19부터 x28까지를 임시 변수처럼 잡아두고 계속 끌고 갑니다.

더 읽기: 이 표는 뒤 코드를 읽을 때 계속 펼쳐 놓고 봐야 합니다. x19는 진입 당시 MMU 상태, x20은 EL1/EL2 boot mode, x21은 FDT pointer입니다. 이 세 개만 놓쳐도 뒤 흐름이 갑자기 난해해집니다.

x22는 idmap 안에서 다시 잡은 FDT VA이고, x23은 physical misalignment나 KASLR offset으로 쓰입니다. x24는 linear map KASLR seed, x25는 실제 지원 가능한 VA size입니다.

callee-saved register를 고른 이유도 중요합니다. 이 파일은 중간에 bl로 여러 helper를 부르지만, 아직 C runtime이 아니기 때문에 상태를 메모리에 막 저장하기 어렵습니다. 그래서 호출을 넘어 살아남아야 하는 값은 callee-saved register에 배치합니다.

Image+0EFI signature NOP와 primary_entry branch
Headerload offset, size, flags, magic, PE offset
.idmap.textMMU on/off 경계에서 안전하게 실행될 코드
Register planx19 MMU state, x20 boot mode, x21 FDT, x23 KASLR offset

89-124: primary_entry, 커널 첫 번째 실행 루틴

원본 코드 L89-L124 열기 →

primary_entry부터 실제 부트 코드가 시작됩니다. 여기서는 x19, x20, x21이 어떻게 채워지는지만 먼저 잡고 가면 읽기 쉽습니다.

L89-L92: 첫 세 단계

원본 코드:
SYM_CODE_START(primary_entry)
	bl	record_mmu_state
	bl	preserve_boot_args
	bl	create_idmap

설명: 시작하자마자 현재 MMU 상태를 보고, 부트로더가 준 x0-x3를 저장하고, 임시 identity map을 만듭니다.

더 읽기: primary_entry에 들어온 시점의 x0는 FDT 물리 주소입니다. 하지만 바로 여러 helper를 호출하므로, 이 값을 그냥 x0에 둔 채로 믿으면 안 됩니다. 그래서 preserve_boot_args에서 x21boot_args로 빼둡니다.

record_mmu_state가 먼저 호출되는 이유는 cache maintenance 경로가 달라지기 때문입니다. 진입 당시 MMU가 꺼져 있었는지, 예외적으로 켜져 있었는지에 따라 뒤에서 idmap code를 clean할지 말지가 결정됩니다.

create_idmap은 MMU를 켜는 순간 현재 실행 중인 코드가 계속 보이도록 최소 매핑을 만듭니다. 이 단계가 틀리면 보통 __enable_mmu 이후 아무 로그 없이 멈춘 것처럼 보입니다.

L99-L106: MMU on 진입 보정과 EL 초기화

원본 코드:
	cbz	x19, 0f
	adrp	x0, __idmap_text_start
	adr_l	x1, __idmap_text_end
	adr_l	x2, dcache_clean_poc
	blr	x2
0:	mov	x0, x19
	bl	init_kernel_el			// w0=cpu_boot_mode
	mov	x20, x0

설명: x19가 0이 아니면 MMU/cache가 켜진 채 들어온 케이스입니다. 그때는 idmap 코드를 먼저 clean하고, EL 상태를 정리한 결과를 x20에 둡니다.

더 읽기: 정상적인 booting 문서 기준으로는 MMU off, D-cache off로 들어와야 합니다. 그런데 kexec나 특수한 firmware 경로에서는 이미 MMU가 켜진 상태를 고려해야 할 수 있습니다. 그래서 x19를 보고 분기합니다.

adrp x0, __idmap_text_startadr_l x1, __idmap_text_end는 clean 대상 범위를 잡습니다. dcache_clean_poc를 직접 branch-with-link-register로 호출하는 이유는 이 시점이 아직 일반 C 호출 환경이 아니기 때문입니다.

init_kernel_el의 반환값은 이후에도 계속 필요합니다. EL1에서 왔는지 EL2에서 왔는지에 따라 hyp 초기화, VHE/nVHE 처리, secondary path 처리 방식이 달라지므로 x20에 보존합니다.

L114-L123: VA 크기와 CPU setup

원본 코드:
#if VA_BITS > 48
	mrs_s	x0, SYS_ID_AA64MMFR2_EL1
	tst	x0, ID_AA64MMFR2_EL1_VARange_MASK
	mov	x0, #VA_BITS
	mov	x25, #VA_BITS_MIN
	csel	x25, x25, x0, eq
	mov	x0, x25
#endif
	bl	__cpu_setup			// initialise processor
	b	__primary_switch

설명: 52-bit VA를 쓸 수 있는 CPU인지 확인합니다. 그 다음 __cpu_setup에서 MMU enable 직전의 TCR/SCTLR 세팅을 맞추고 __primary_switch로 갑니다.

더 읽기: VA_BITS > 48 설정에서는 하드웨어가 52-bit VA를 지원하는지 확인해야 합니다. 커널을 크게 잡아 빌드했다고 해서 모든 CPU가 그 VA range를 지원하는 것은 아닙니다.

ID_AA64MMFR2_EL1의 VA range bit를 읽고, 지원이 부족하면 VA_BITS_MIN으로 내려갑니다. 이 값은 뒤에서 page table level과 TCR 설정에 영향을 줍니다.

__cpu_setup은 이 파일 밖의 CPU setup 코드로 들어가 TCR, MAIR, SCTLR 같은 MMU 관련 시스템 레지스터를 준비합니다. 여기까지 끝난 뒤에야 __primary_switch에서 실제 MMU enable을 시도합니다.

127-332: MMU 전 상태 기록, boot args, page table 헬퍼

원본 코드 L127-L332 열기 →

이 구간은 함수처럼 한 번 읽고 끝나는 코드가 아니라, 뒤쪽에서 계속 불려 나가는 작은 도구들입니다. MMU 상태 저장, boot arg 저장, page table entry 작성 매크로가 여기 모여 있습니다.

L127-L139: 현재 EL과 SCTLR 확인

원본 코드:
SYM_CODE_START_LOCAL(record_mmu_state)
	mrs	x19, CurrentEL
	cmp	x19, #CurrentEL_EL2
	mrs	x19, sctlr_el1
	b.ne	0f
	mrs	x19, sctlr_el2
0:
CPU_LE( tbnz	x19, #SCTLR_ELx_EE_SHIFT, 1f	)
CPU_BE( tbz	x19, #SCTLR_ELx_EE_SHIFT, 1f	)
	tst	x19, #SCTLR_ELx_C		// Z := (C == 0)
	and	x19, x19, #SCTLR_ELx_M		// isolate M bit
	csel	x19, xzr, x19, eq		// clear x19 if Z
	ret

설명: 현재 EL을 보고 SCTLR_EL1 또는 SCTLR_EL2를 읽습니다. 결국 x19에는 “MMU가 켜진 채 들어왔나”라는 정보만 남깁니다.

더 읽기: CurrentEL을 읽은 뒤 EL2인지 비교합니다. EL2로 들어왔다면 SCTLR_EL2, 아니면 SCTLR_EL1을 봅니다. 같은 SCTLR이라도 현재 실행 EL에 따라 레지스터가 다릅니다.

endianness 검사도 여기 들어 있습니다. CPU_LE, CPU_BE 매크로는 커널이 빌드된 endian과 CPU 현재 endian 상태가 맞는지 확인합니다. 틀리면 1f 쪽으로 빠져 boot가 깨지는 방향입니다.

마지막에 SCTLR_ELx_CSCTLR_ELx_M을 같이 봅니다. cache bit가 꺼져 있으면 x19를 0으로 만들고, cache가 켜져 있고 MMU bit도 의미가 있으면 그 정보를 남깁니다. 뒤쪽 분기에서는 x19 == 0인지가 중요합니다.

L163-L176: boot argument 저장

원본 코드:
SYM_CODE_START_LOCAL(preserve_boot_args)
	mov	x21, x0				// x21=FDT

	adr_l	x0, boot_args			// record the contents of
	stp	x21, x1, [x0]			// x0 .. x3 at kernel entry
	stp	x2, x3, [x0, #16]

	cbnz	x19, 0f				// skip cache invalidation if MMU is on
	dmb	sy				// needed before dc ivac with
						// MMU off

	add	x1, x0, #0x20			// 4 x 8 bytes
	b	dcache_inval_poc		// tail call
0:	str_l   x19, mmu_enabled_at_boot, x0

설명: x0의 FDT 주소를 x21에 따로 빼두고, 진입 당시 x0-x3 값은 boot_args에 저장합니다. 나중에 early debug할 때 이 값이 꽤 쓸모 있습니다.

더 읽기: arm64 booting 문서는 primary CPU 진입 시 x0에 DTB 물리 주소, x1-x3에는 0을 요구합니다. 커널은 일단 이 네 값을 boot_args에 저장합니다. 정상 부팅만 보면 x1-x3는 큰 의미가 없지만, firmware나 bootloader 문제를 확인할 때 흔적이 됩니다.

x21에 FDT 주소를 따로 보존하는 이유는 뒤에서 x0가 함수 인자와 scratch register로 계속 덮이기 때문입니다. 최종적으로 __primary_switched에서 다시 x21을 꺼내 __fdt_pointerearly_fdt_map에 넘깁니다.

MMU가 꺼진 정상 진입이면 저장한 boot_args 범위에 대해 cache invalidate를 합니다. 부트로더가 D-cache를 끄고 들어온 상태에서도 stale cache line 문제가 생기지 않게 하려는 처리입니다.

L207-L215: page table entry 반복 생성

원본 코드:
	.macro populate_entries, tbl, rtbl, index, eindex, flags, inc, tmp1
.Lpe\@:	phys_to_pte \tmp1, \rtbl
	orr	\tmp1, \tmp1, \flags	// tmp1 = table entry
	str	\tmp1, [\tbl, \index, lsl #3]
	add	\rtbl, \rtbl, \inc	// rtbl = pa next level
	add	\index, \index, #1
	cmp	\index, \eindex
	b.ls	.Lpe\@
	.endm

설명: 물리 주소를 PTE 포맷으로 바꾼 뒤 flag를 붙여 table에 씁니다. index를 하나씩 올리며 같은 패턴으로 entry를 채우는 루프입니다.

더 읽기: 이 매크로는 page table entry를 실제로 써 넣는 가장 안쪽 루프입니다. rtbl은 다음 level table의 물리 주소이거나 block mapping 대상 물리 주소이고, flags는 table descriptor 또는 memory attribute bit입니다.

phys_to_pte로 물리 주소를 PTE에 들어갈 형식으로 바꾼 뒤, orr로 속성 bit를 붙입니다. 그 결과를 [tbl, index, lsl #3]에 저장하는데, arm64 page table entry가 8바이트이기 때문에 index에 3을 shift합니다.

루프는 indexeindex에 도달할 때까지 돕니다. 초기 page table 작성은 동적 자료구조를 쓰는 게 아니라, 계산된 index 범위에 descriptor를 반복해서 박는 작업에 가깝습니다.

L262-L298: map_memory 큰 흐름

원본 코드:
	.macro map_memory, tbl, rtbl, vstart, vend, flags, phys, order, istart, iend, tmp, count, sv, extra_shift
	sub \vend, \vend, #1
	add \rtbl, \tbl, #PAGE_SIZE
	mov \count, #0

	.ifnb	\extra_shift
	tst	\vend, #~((1 << (\extra_shift)) - 1)
	b.eq	.L_\@
	compute_indices \vstart, \vend, #\extra_shift, #(PAGE_SHIFT - 3), \istart, \iend, \count
	mov \sv, \rtbl
	populate_entries \tbl, \rtbl, \istart, \iend, #PMD_TYPE_TABLE, #PAGE_SIZE, \tmp
	mov \tbl, \sv
	.endif
.L_\@:
	compute_indices \vstart, \vend, #PGDIR_SHIFT, #\order, \istart, \iend, \count
	mov \sv, \rtbl
	populate_entries \tbl, \rtbl, \istart, \iend, #PMD_TYPE_TABLE, #PAGE_SIZE, \tmp
	mov \tbl, \sv

#if SWAPPER_PGTABLE_LEVELS > 3
	compute_indices \vstart, \vend, #PUD_SHIFT, #(PAGE_SHIFT - 3), \istart, \iend, \count
	mov \sv, \rtbl
	populate_entries \tbl, \rtbl, \istart, \iend, #PMD_TYPE_TABLE, #PAGE_SIZE, \tmp
	mov \tbl, \sv
#endif

#if SWAPPER_PGTABLE_LEVELS > 2
	compute_indices \vstart, \vend, #SWAPPER_TABLE_SHIFT, #(PAGE_SHIFT - 3), \istart, \iend, \count
	mov \sv, \rtbl
	populate_entries \tbl, \rtbl, \istart, \iend, #PMD_TYPE_TABLE, #PAGE_SIZE, \tmp
	mov \tbl, \sv
#endif

	compute_indices \vstart, \vend, #SWAPPER_BLOCK_SHIFT, #(PAGE_SHIFT - 3), \istart, \iend, \count
	bic \rtbl, \phys, #SWAPPER_BLOCK_SIZE - 1
	populate_entries \tbl, \rtbl, \istart, \iend, \flags, #SWAPPER_BLOCK_SIZE, \tmp
	.endm

설명: VA 범위에 맞춰 필요한 table level을 하나씩 내려갑니다. 마지막 level에서는 SWAPPER_BLOCK_SIZE 단위로 실제 block mapping을 씁니다.

더 읽기: map_memory는 이 파일에서 가장 중요한 매크로 중 하나입니다. 입력으로 VA 시작/끝, 물리 시작 주소, mapping flag, page table base를 받고, 필요한 level의 table entry를 순서대로 만듭니다.

먼저 vend에서 1을 빼는 이유는 끝 주소를 inclusive range처럼 다루기 위해서입니다. 그 다음 compute_indices로 현재 level에서 시작 index와 끝 index를 계산하고, populate_entries로 table descriptor를 씁니다.

SWAPPER_PGTABLE_LEVELS 값에 따라 PUD/PMD level을 더 내려갈 수도 있고 건너뛸 수도 있습니다. 즉 같은 소스라도 page size, VA bits, config에 따라 실제 만들어지는 page table 깊이가 달라집니다.

마지막에는 SWAPPER_BLOCK_SHIFT 기준으로 block mapping을 만듭니다. early boot에서는 작은 4K page 단위보다 큰 block 단위가 훨씬 단순하고, 아직 allocator가 준비되지 않은 상태에도 맞습니다.

compute_indicesVA 범위를 table index로 변환
populate_entriesPTE/table descriptor를 반복 저장
map_memory필요한 level을 내려가며 mapping 구성
remap_region일부 구간 속성만 수정

334-439: ID map과 커널 가상 매핑

원본 코드 L334-L439 열기 →

ID map은 MMU를 켠 직후에도 지금 실행 중인 코드가 끊기지 않게 만드는 임시 매핑입니다. 반대로 커널 최종 매핑은 KIMAGE_VADDR 쪽, 즉 이후 커널이 계속 사용할 주소 공간입니다.

L382-L388: 커널 이미지 범위 ID map

원본 코드:
	adrp	x0, init_idmap_pg_dir
	adrp	x3, _text
	adrp	x6, _end + MAX_FDT_SIZE + SWAPPER_BLOCK_SIZE
	mov_q	x7, SWAPPER_RX_MMUFLAGS

	map_memory x0, x1, x3, x6, x7, x3, IDMAP_PGD_ORDER, x10, x11, x12, x13, x14, EXTRA_SHIFT

설명: 커널이 올라온 물리 주소 범위를 같은 값의 VA로 잡습니다. MMU를 켜는 바로 그 순간에 PC가 길을 잃지 않게 하는 용도입니다.

더 읽기: init_idmap_pg_dir가 idmap용 page table입니다. _text부터 _end + MAX_FDT_SIZE + SWAPPER_BLOCK_SIZE까지 잡는 이유는 커널 코드뿐 아니라, 뒤에서 FDT를 임시로 붙일 공간까지 고려하기 위해서입니다.

SWAPPER_RX_MMUFLAGS는 executable/read-only 성격의 mapping flag입니다. 커널 text를 실행해야 하므로 execute permission이 필요하고, 동시에 불필요하게 writable로 열어두지 않습니다.

이 mapping이 없으면 __enable_mmu에서 MMU를 켜는 순간 현재 PC의 주소 해석이 바뀌면서 다음 명령 fetch가 실패할 수 있습니다. early boot hang 중 가장 흔히 보는 형태가 이 경계입니다.

L389-L407: page table과 FDT 재매핑

원본 코드:
	/* Remap the kernel page tables r/w in the ID map */
	adrp	x1, _text
	adrp	x2, init_pg_dir
	adrp	x3, init_pg_end
	bic	x4, x2, #SWAPPER_BLOCK_SIZE - 1
	mov_q	x5, SWAPPER_RW_MMUFLAGS
	mov	x6, #SWAPPER_BLOCK_SHIFT
	bl	remap_region

	/* Remap the FDT after the kernel image */
	adrp	x1, _text
	adrp	x22, _end + SWAPPER_BLOCK_SIZE
	bic	x2, x22, #SWAPPER_BLOCK_SIZE - 1
	bfi	x22, x21, #0, #SWAPPER_BLOCK_SHIFT		// remapped FDT address
	add	x3, x2, #MAX_FDT_SIZE + SWAPPER_BLOCK_SIZE
	bic	x4, x21, #SWAPPER_BLOCK_SIZE - 1
	mov_q	x5, SWAPPER_RW_MMUFLAGS
	mov	x6, #SWAPPER_BLOCK_SHIFT
	bl	remap_region

설명: page table 영역은 뒤에서 계속 수정해야 하니 RW로 다시 잡습니다. FDT도 early boot에서 읽어야 하므로 커널 이미지 뒤쪽 주소로 remap합니다.

더 읽기: 앞에서 커널 이미지 범위를 RX로 잡았지만, page table 영역은 계속 써야 합니다. 그래서 init_pg_dir부터 init_pg_end 범위를 RW 속성으로 다시 remap합니다.

FDT remap 쪽은 x21의 물리 주소를 기준으로 block align을 맞춘 뒤, 커널 이미지 뒤쪽의 idmap VA에 끼워 넣습니다. 결과 VA는 x22에 남깁니다.

bfi x22, x21, #0, #SWAPPER_BLOCK_SHIFT가 눈에 띄는 부분입니다. block 안 offset은 원래 FDT 물리 주소의 low bit를 유지하고, block base만 커널 뒤쪽 remap 위치로 바꾸는 식입니다.

L423-L438: 최종 커널 매핑 생성

원본 코드:
SYM_FUNC_START_LOCAL(create_kernel_mapping)
	adrp	x0, init_pg_dir
	mov_q	x5, KIMAGE_VADDR		// compile time __va(_text)
#ifdef CONFIG_RELOCATABLE
	add	x5, x5, x23			// add KASLR displacement
#endif
	adrp	x6, _end			// runtime __pa(_end)
	adrp	x3, _text			// runtime __pa(_text)
	sub	x6, x6, x3			// _end - _text
	add	x6, x6, x5			// runtime __va(_end)
	mov_q	x7, SWAPPER_RW_MMUFLAGS

	map_memory x0, x1, x5, x6, x7, x3, (VA_BITS - PGDIR_SHIFT), x10, x11, x12, x13, x14

	dsb	ishst				// sync with page table walker
	ret

설명: 여기서부터는 임시 idmap이 아니라 커널이 계속 쓸 mapping입니다. KIMAGE_VADDR에 KASLR offset이 붙을 수도 있습니다.

더 읽기: create_kernel_mappinginit_pg_dir에 최종 커널 mapping을 만듭니다. idmap이 MMU enable 순간을 넘기기 위한 다리라면, 이 mapping은 이후 커널 text/data가 실제로 머무를 주소 공간입니다.

KIMAGE_VADDR는 compile-time 기준의 kernel virtual address입니다. CONFIG_RELOCATABLE이면 여기에 x23 KASLR displacement를 더합니다.

_text_end의 runtime physical address 차이를 구해서, virtual end 주소를 계산합니다. 마지막 dsb ishst는 page table write가 page table walker에 보이도록 보장하는 barrier입니다.

ID mapPA == VA, MMU를 켜는 순간에도 현재 코드가 계속 실행되도록 보장
FDT remap커널 이미지 뒤쪽 가상 위치로 FDT를 임시 매핑
init_pg_dir최종 커널 가상 주소 공간을 만들 초기 page table

449-525: MMU 이후, C 진입 직전 정리

원본 코드 L449-L525 열기 →

__primary_switched에 들어왔다는 건 MMU 전환은 일단 넘겼다는 뜻입니다. 이제 C 코드가 기대하는 스택, vector, BSS, FDT 포인터를 맞추는 단계로 넘어갑니다.

L449-L465: 현재 task와 stack 준비

원본 코드:
	.macro	init_cpu_task tsk, tmp1, tmp2
	msr	sp_el0, \tsk

	ldr	\tmp1, [\tsk, #TSK_STACK]
	add	sp, \tmp1, #THREAD_SIZE
	sub	sp, sp, #PT_REGS_SIZE

	stp	xzr, xzr, [sp, #S_STACKFRAME]
	add	x29, sp, #S_STACKFRAME

	scs_load_current

	adr_l	\tmp1, __per_cpu_offset
	ldr	w\tmp2, [\tsk, #TSK_TI_CPU]
	ldr	\tmp1, [\tmp1, \tmp2, lsl #3]
	set_this_cpu_offset \tmp1
	.endm

설명: init_task 기준으로 kernel stack을 잡고 per-cpu offset도 맞춥니다. 이쯤부터는 부트로더 환경이 아니라 커널 환경으로 보는 게 맞습니다.

더 읽기: sp_el0에는 현재 task pointer를 넣습니다. arm64 Linux는 커널 모드에서 sp_el0를 current task 접근에 활용합니다. 초반에는 당연히 init_task가 current입니다.

TSK_STACK에서 task의 stack base를 읽고 THREAD_SIZE를 더해 stack top을 잡은 뒤, 바로 PT_REGS_SIZE만큼 뺍니다. C 코드가 exception frame이나 stack frame을 기대하는 형태에 맞춰 공간을 비워두는 것입니다.

scs_load_current는 Shadow Call Stack이 켜진 구성에서 current task의 SCS를 준비합니다. 이후 __per_cpu_offset을 읽어 현재 CPU에 맞는 per-cpu base도 세팅합니다. 이 부분이 틀어지면 C 코드 진입 뒤 current/per-cpu 접근이 바로 망가집니다.

L472-L490: vector와 FDT/offset 저장

원본 코드:
SYM_FUNC_START_LOCAL(__primary_switched)
	adr_l	x4, init_task
	init_cpu_task x4, x5, x6

	adr_l	x8, vectors			// load VBAR_EL1 with virtual
	msr	vbar_el1, x8			// vector table address
	isb

	stp	x29, x30, [sp, #-16]!
	mov	x29, sp

	str_l	x21, __fdt_pointer, x5		// Save FDT pointer

	ldr_l	x4, kimage_vaddr		// Save the offset between
	sub	x4, x4, x0			// the kernel virtual and
	str_l	x4, kimage_voffset, x5		// physical mappings

	mov	x0, x20
	bl	set_cpu_boot_mode_flag

설명: exception vector를 VBAR_EL1에 넣고, FDT pointer와 virtual/physical offset을 전역 변수에 남깁니다.

더 읽기: __primary_switched는 MMU가 켜진 뒤의 첫 안정 구간입니다. 이제 adr_l로 얻는 주소는 커널이 사용할 virtual address라고 봐도 됩니다.

vectorsVBAR_EL1에 넣는 것은 예외 처리의 시작점입니다. 이 전까지는 exception이 발생하면 제대로 처리하기 어렵지만, 이 이후부터는 커널의 arm64 exception vector가 동작할 수 있습니다.

kimage_voffset은 kernel virtual address와 physical address 사이의 차이를 저장합니다. 나중에 __pa, __va 계열 계산에서 핵심 기준값이 됩니다. 여기서 계산이 틀리면 물리 주소 변환이 전반적으로 꼬입니다.

L492-L524: BSS, FDT, feature, start_kernel

원본 코드:
	// Clear BSS
	adr_l	x0, __bss_start
	mov	x1, xzr
	adr_l	x2, __bss_stop
	sub	x2, x2, x0
	bl	__pi_memset
	dsb	ishst				// Make zero page visible to PTW

#if VA_BITS > 48
	adr_l	x8, vabits_actual		// Set this early so KASAN early init
	str	x25, [x8]			// ... observes the correct value
	dc	civac, x8			// Make visible to booting secondaries
#endif

#ifdef CONFIG_RANDOMIZE_BASE
	adrp	x5, memstart_offset_seed	// Save KASLR linear map seed
	strh	w24, [x5, :lo12:memstart_offset_seed]
#endif
#if defined(CONFIG_KASAN_GENERIC) || defined(CONFIG_KASAN_SW_TAGS)
	bl	kasan_early_init
#endif
	mov	x0, x21				// pass FDT address in x0
	bl	early_fdt_map			// Try mapping the FDT early
	mov	x0, x20				// pass the full boot status
	bl	init_feature_override		// Parse cpu feature overrides
#ifdef CONFIG_UNWIND_PATCH_PAC_INTO_SCS
	bl	scs_patch_vmlinux
#endif
	mov	x0, x20
	bl	finalise_el2			// Prefer VHE if possible
	ldp	x29, x30, [sp], #16
	bl	start_kernel
	ASM_BUG()

설명: BSS clear, FDT early map, feature override, EL2 정리까지 끝낸 뒤 start_kernel을 부릅니다. 여기까지 오면 어셈블리 부트의 큰 고비는 넘은 셈입니다.

더 읽기: BSS를 0으로 미는 작업은 C 코드 진입 전에 반드시 끝나야 합니다. 전역 static 변수들이 0으로 초기화되어 있다는 C의 기본 가정을 여기서 만들어 줍니다.

VA_BITS > 48 구성에서는 vabits_actual을 early하게 저장하고 cache clean까지 합니다. secondary CPU들이 나중에 이 값을 볼 수 있어야 하므로 메모리에 확실히 반영합니다.

early_fdt_mapx21에 보존해 둔 FDT 주소를 C 쪽 early parser가 읽을 수 있게 매핑합니다. 이어서 feature override, SCS patch, EL2 finalise를 끝내고 start_kernel을 호출합니다.

ASM_BUG()가 마지막에 있는 이유는 start_kernel이 돌아오면 안 되기 때문입니다. 만약 돌아온다면 이미 커널 부트 흐름 자체가 깨진 상태라 바로 bug 처리하는 게 맞습니다.

547-611: EL1/EL2 초기화

원본 코드 L547-L611 열기 →

arm64 커널은 EL1에서 시작할 수도 있고 EL2에서 시작할 수도 있습니다. 이 루틴은 현재 EL을 확인하고, 커널이 쓰기 편한 초기 상태로 레지스터를 맞춘 뒤 eret으로 돌아갑니다.

L547-L561: EL1 path

원본 코드:
SYM_FUNC_START(init_kernel_el)
	mrs	x1, CurrentEL
	cmp	x1, #CurrentEL_EL2
	b.eq	init_el2

SYM_INNER_LABEL(init_el1, SYM_L_LOCAL)
	mov_q	x0, INIT_SCTLR_EL1_MMU_OFF
	pre_disable_mmu_workaround
	msr	sctlr_el1, x0
	isb
	mov_q	x0, INIT_PSTATE_EL1
	msr	spsr_el1, x0
	msr	elr_el1, lr
	mov	w0, #BOOT_CPU_MODE_EL1
	eret

설명: EL2가 아니면 EL1 path입니다. SCTLR_EL1, SPSR_EL1, ELR_EL1을 맞추고 boot mode를 EL1로 반환합니다.

더 읽기: CurrentEL이 EL2가 아니면 커널은 EL1 진입으로 판단합니다. 이 경우 hyp 초기화는 필요 없고, EL1 시스템 레지스터를 커널이 기대하는 기본값으로 맞추면 됩니다.

INIT_SCTLR_EL1_MMU_OFF는 MMU off 상태에서의 안전한 SCTLR 값입니다. pre_disable_mmu_workaround는 CPU errata나 구현별 workaround가 필요한 경우를 처리합니다.

spsr_el1elr_el1을 세팅한 뒤 eret합니다. 반환값 w0 = BOOT_CPU_MODE_EL1은 호출자 쪽에서 x20으로 보존되고, 이후 boot mode flag로 남습니다.

L563-L610: EL2 path

원본 코드:
SYM_INNER_LABEL(init_el2, SYM_L_LOCAL)
	msr	elr_el2, lr

	// clean all HYP code to the PoC if we booted at EL2 with the MMU on
	cbz	x0, 0f
	adrp	x0, __hyp_idmap_text_start
	adr_l	x1, __hyp_text_end
	adr_l	x2, dcache_clean_poc
	blr	x2
0:
	mov_q	x0, HCR_HOST_NVHE_FLAGS
	msr	hcr_el2, x0
	isb

	init_el2_state

	/* Hypervisor stub */
	adr_l	x0, __hyp_stub_vectors
	msr	vbar_el2, x0
	isb

	mov_q	x1, INIT_SCTLR_EL1_MMU_OFF

	/*
	 * Fruity CPUs seem to have HCR_EL2.E2H set to RES1,
	 * making it impossible to start in nVHE mode. Is that
	 * compliant with the architecture? Absolutely not!
	 */
	mrs	x0, hcr_el2
	and	x0, x0, #HCR_E2H
	cbz	x0, 1f

	/* Set a sane SCTLR_EL1, the VHE way */
	pre_disable_mmu_workaround
	msr_s	SYS_SCTLR_EL12, x1
	mov	x2, #BOOT_CPU_FLAG_E2H
	b	2f

1:
	pre_disable_mmu_workaround
	msr	sctlr_el1, x1
	mov	x2, xzr
2:
	__init_el2_nvhe_prepare_eret

	mov	w0, #BOOT_CPU_MODE_EL2
	orr	x0, x0, x2
	eret

설명: EL2 path에서는 HCR_EL2, hyp vector, nVHE/VHE 상태를 먼저 정리합니다. 마지막 반환값에는 EL2로 부팅했다는 정보가 남습니다.

더 읽기: EL2로 들어온 경우에는 hypervisor level을 그냥 방치할 수 없습니다. Linux가 VHE로 갈지 nVHE로 갈지, EL2 vector를 어디로 둘지, HCR_EL2를 어떤 값으로 둘지 정해야 합니다.

MMU가 켜진 채 EL2로 들어왔다면 hyp text 영역도 PoC까지 clean합니다. hyp code가 stale cache 상태로 남아 있으면 EL2 전환 이후 예측하기 어려운 문제가 생길 수 있습니다.

중간의 “Fruity CPUs” 주석은 일부 CPU에서 HCR_EL2.E2H가 RES1처럼 동작하는 케이스를 말합니다. 이 경우 nVHE로 내리기 어렵기 때문에 VHE 방식의 SCTLR 설정을 탑니다.

마지막 반환값은 BOOT_CPU_MODE_EL2에 필요하면 BOOT_CPU_FLAG_E2H를 OR한 값입니다. 뒤쪽 C 코드와 secondary path가 이 값을 보고 EL2 상태를 이어받습니다.

CurrentEL readEL1인지 EL2인지 확인
EL1 pathSCTLR_EL1, SPSR_EL1, ELR_EL1 설정
EL2 pathHCR_EL2, VBAR_EL2, nVHE/VHE 준비

617-780: secondary CPU 경로

원본 코드 L617-L780 열기 →

secondary CPU 쪽은 primary path보다 짧습니다. release 값이 자기 MPIDR과 맞을 때까지 기다렸다가, CPU setup과 MMU enable을 하고 secondary_start_kernel로 들어갑니다.

L617-L629: holding pen

원본 코드:
SYM_FUNC_START(secondary_holding_pen)
	mov	x0, xzr
	bl	init_kernel_el			// w0=cpu_boot_mode
	mrs	x2, mpidr_el1
	mov_q	x1, MPIDR_HWID_BITMASK
	and	x2, x2, x1
	adr_l	x3, secondary_holding_pen_release
pen:	ldr	x4, [x3]
	cmp	x4, x2
	b.eq	secondary_startup
	wfe
	b	pen
SYM_FUNC_END(secondary_holding_pen)

설명: 자기 MPIDR과 secondary_holding_pen_release 값이 맞을 때까지 wfe로 쉽니다. SMP bring-up에서 secondary가 안 올라오면 이 근처를 먼저 의심하게 됩니다.

더 읽기: secondary CPU는 primary처럼 kernel Image 첫 명령에서 전체 초기화를 다시 하지 않습니다. 먼저 init_kernel_el로 EL 상태를 맞추고, 자기 CPU의 MPIDR 값을 읽어 hardware id를 만듭니다.

secondary_holding_pen_release는 primary가 특정 secondary CPU를 풀어줄 때 쓰는 release 값입니다. 현재 CPU의 MPIDR과 값이 맞으면 secondary_startup으로 가고, 아니면 wfe로 대기합니다.

DT의 spin-table 방식과도 연결되는 부분입니다. booting 문서에서는 secondary CPU가 release address를 polling하다가 non-zero 값이 들어오면 그 주소로 점프한다고 설명합니다. 커널 내부 holding pen은 그 뒤에 CPU별 진입을 한 번 더 정리하는 역할을 합니다.

L641-L656: secondary_startup

원본 코드:
SYM_FUNC_START_LOCAL(secondary_startup)
	/*
	 * Common entry point for secondary CPUs.
	 */
	mov	x20, x0				// preserve boot mode
	bl	__cpu_secondary_check52bitva
#if VA_BITS > 48
	ldr_l	x0, vabits_actual
#endif
	bl	__cpu_setup			// initialise processor
	adrp	x1, swapper_pg_dir
	adrp	x2, idmap_pg_dir
	bl	__enable_mmu
	ldr	x8, =__secondary_switched
	br	x8
SYM_FUNC_END(secondary_startup)

설명: secondary도 52-bit VA 가능 여부를 확인하고 __cpu_setup을 탑니다. primary가 만들어 둔 swapper_pg_dir, idmap_pg_dir로 MMU를 켭니다.

더 읽기: secondary path는 primary가 만들어 둔 전역 상태를 재사용합니다. 그래서 create_idmap이나 create_kernel_mapping을 다시 만들지 않고, 이미 존재하는 swapper_pg_diridmap_pg_dir를 사용합니다.

그래도 CPU별 setup은 필요합니다. 각 CPU는 자기 SCTLR/TCR/MAIR 같은 시스템 레지스터를 가지고 있으므로 __cpu_setup을 다시 호출해야 합니다.

__enable_mmu가 끝난 뒤에는 직접 symbol address로 branch하지 않고 ldr x8, =__secondary_switchedbr x8를 사용합니다. MMU 전환 후 virtual address 기반 코드로 넘어가는 경계입니다.

L659-L683: secondary C entry 직전

원본 코드:
SYM_FUNC_START_LOCAL(__secondary_switched)
	mov	x0, x20
	bl	set_cpu_boot_mode_flag

	mov	x0, x20
	bl	finalise_el2

	str_l	xzr, __early_cpu_boot_status, x3
	adr_l	x5, vectors
	msr	vbar_el1, x5
	isb

	adr_l	x0, secondary_data
	ldr	x2, [x0, #CPU_BOOT_TASK]
	cbz	x2, __secondary_too_slow

	init_cpu_task x2, x1, x3

#ifdef CONFIG_ARM64_PTR_AUTH
	ptrauth_keys_init_cpu x2, x3, x4, x5
#endif

	bl	secondary_start_kernel
	ASM_BUG()
SYM_FUNC_END(__secondary_switched)

설명: boot mode flag, EL2 상태, vector, task stack을 맞춥니다. 그 다음은 secondary CPU용 C entry인 secondary_start_kernel입니다.

더 읽기: secondary도 C 코드로 들어가기 전에 primary와 비슷하게 vector와 task stack을 맞춥니다. 다만 init_task가 아니라 secondary_data에 primary가 넣어 둔 task pointer를 사용합니다.

CPU_BOOT_TASK가 0이면 __secondary_too_slow로 빠집니다. primary가 준비한 secondary_data를 제때 받지 못한 상황입니다.

pointer authentication이 켜진 구성에서는 ptrauth_keys_init_cpu도 여기서 처리합니다. 마지막 secondary_start_kernel부터는 secondary CPU의 C 부트 경로입니다.

holding penrelease address 대기
secondary_startupCPU setup과 MMU enable
secondary_switchedtask stack과 vector 준비
secondary_start_kernelC secondary path 시작

783-902: relocation, KASLR, 최종 커널 주소 공간 전환

원본 코드 L783-L902 열기 →

relocatable kernel이나 KASLR을 켜면 이 구간이 중요해집니다. idmap으로 MMU를 먼저 켜고, 실제 kernel offset을 계산한 뒤, relocation을 적용하고 __primary_switched로 branch합니다.

L783-L802: RELA relocation

원본 코드:
SYM_FUNC_START_LOCAL(__relocate_kernel)
	/*
	 * Iterate over each entry in the relocation table, and apply the
	 * relocations in place.
	 */
	adr_l	x9, __rela_start
	adr_l	x10, __rela_end
	mov_q	x11, KIMAGE_VADDR		// default virtual offset
	add	x11, x11, x23			// actual virtual offset

0:	cmp	x9, x10
	b.hs	1f
	ldp	x12, x13, [x9], #24
	ldr	x14, [x9, #-8]
	cmp	w13, #R_AARCH64_RELATIVE
	b.ne	0b
	add	x14, x14, x23			// relocate
	str	x14, [x12, x23]
	b	0b

설명: __rela_start부터 __rela_end까지 훑으면서 R_AARCH64_RELATIVE 항목만 처리합니다. 실제 더해지는 offset은 x23입니다.

더 읽기: relocatable kernel에서는 링크 때 가정한 주소와 실제 올라온 주소가 다를 수 있습니다. KASLR이 켜져 있으면 의도적으로 더 달라집니다. 그래서 relocation table을 보고 주소 상수를 고쳐야 합니다.

RELA entry는 보통 offset, type/info, addend 형태로 읽습니다. 여기서는 ldp x12, x13, [x9], #24로 앞 두 필드를 읽고, ldr x14, [x9, #-8]로 addend를 읽습니다.

R_AARCH64_RELATIVE만 처리하는 이유는 early boot에서 필요한 relocation 종류를 제한하기 위해서입니다. addend에 x23을 더하고, relocation 대상 주소도 [x12, x23]처럼 KASLR offset을 반영해 접근합니다.

L835-L864: RELR bitmap relocation

원본 코드:
	adr_l	x9, __relr_start
	adr_l	x10, __relr_end

2:	cmp	x9, x10
	b.hs	7f
	ldr	x11, [x9], #8
	tbnz	x11, #0, 3f			// branch to handle bitmaps
	add	x13, x11, x23
	ldr	x12, [x13]			// relocate address entry
	add	x12, x12, x23
	str	x12, [x13], #8			// adjust to start of bitmap
	b	2b

3:	mov	x14, x13
4:	lsr	x11, x11, #1
	cbz	x11, 6f
	tbz	x11, #0, 5f			// skip bit if not set
	ldr	x12, [x14]			// relocate bit
	add	x12, x12, x23
	str	x12, [x14]

5:	add	x14, x14, #8			// move to next bit's address
	b	4b

6:	/*
	 * Move to the next bitmap's address. 8 is the word size, and 63 is the
	 * number of significant bits in a bitmap entry.
	 */
	add	x13, x13, #(8 * 63)
	b	2b

설명: RELR은 relocation 대상 주소를 bitmap 형태로 압축합니다. 주소 entry면 바로 한 번 고치고, bitmap entry면 set bit가 있는 word마다 x23을 더합니다.

더 읽기: RELR은 RELA보다 relocation 정보를 더 압축해서 담습니다. entry의 bit0이 0이면 그 값은 주소 entry이고, bit0이 1이면 bitmap entry로 해석합니다.

주소 entry 경로에서는 x11 + x23 위치를 실제 대상 주소로 보고, 그 안의 값을 읽어 다시 x23을 더해 저장합니다. 즉 “relocation 대상 위치”와 “그 위치에 들어 있는 주소 값” 양쪽에 offset 개념이 들어갑니다.

bitmap 경로에서는 x11을 오른쪽으로 밀면서 bit가 켜진 word만 고칩니다. 한 bitmap entry가 63개의 word를 표현하므로, 끝나면 x13 += 8 * 63으로 다음 범위로 넘어갑니다.

L873-L902: __primary_switch

원본 코드:
SYM_FUNC_START_LOCAL(__primary_switch)
	adrp	x1, reserved_pg_dir
	adrp	x2, init_idmap_pg_dir
	bl	__enable_mmu
#ifdef CONFIG_RELOCATABLE
	adrp	x23, KERNEL_START
	and	x23, x23, MIN_KIMG_ALIGN - 1
#ifdef CONFIG_RANDOMIZE_BASE
	mov	x0, x22
	adrp	x1, init_pg_end
	mov	sp, x1
	mov	x29, xzr
	bl	__pi_kaslr_early_init
	and	x24, x0, #SZ_2M - 1		// capture memstart offset seed
	bic	x0, x0, #SZ_2M - 1
	orr	x23, x23, x0			// record kernel offset
#endif
#endif
	bl	clear_page_tables
	bl	create_kernel_mapping

	adrp	x1, init_pg_dir
	load_ttbr1 x1, x1, x2
#ifdef CONFIG_RELOCATABLE
	bl	__relocate_kernel
#endif
	ldr	x8, =__primary_switched
	adrp	x0, KERNEL_START		// __pa(KERNEL_START)
	br	x8
SYM_FUNC_END(__primary_switch)

설명: 먼저 idmap으로 MMU를 켭니다. 그 뒤 page table을 비우고 kernel mapping을 다시 만든 다음, TTBR1을 갱신하고 __primary_switched로 넘어갑니다.

더 읽기: __primary_switch는 primary boot path의 가장 민감한 구간입니다. 아직 최종 kernel mapping으로 완전히 넘어간 것이 아니기 때문에, idmap과 kernel mapping 양쪽을 모두 신경 써야 합니다.

처음 __enable_mmureserved_pg_dirinit_idmap_pg_dir를 넘겨 MMU를 켭니다. 이후 CONFIG_RELOCATABLE, CONFIG_RANDOMIZE_BASE에 따라 x23 KASLR offset과 x24 linear map seed를 계산합니다.

clear_page_tablescreate_kernel_mapping을 다시 호출하는 흐름이 중요합니다. 초기 mapping으로 일단 살아남은 뒤, 실제 커널이 쓸 page table을 정리해서 다시 만드는 단계입니다.

load_ttbr1로 TTBR1을 갱신하면 커널 virtual address 쪽 translation 기준이 바뀝니다. relocation까지 끝낸 뒤 br x8__primary_switched에 들어가면, 이제 C 진입 준비 단계로 넘어갑니다.

__enable_mmuID map으로 안전하게 MMU enable
KASLRx23 offset, x24 seed 계산
kernel mappinginit_pg_dir에 최종 mapping 생성
branch__primary_switched로 이동

정리

Linux 6.6 arm64 head.S는 길지만 흐름은 몇 덩어리로 나뉩니다. 이미지 헤더, boot arg 보존, idmap 생성, kernel mapping 생성, EL 정리, 그리고 start_kernel() 호출입니다.

early boot hang을 볼 때는 “어디까지 찍혔는지”부터 나누는 게 빠릅니다. 이미지 헤더에서 죽는지, FDT 전달이 틀렸는지, VA/granule 쪽인지, MMU enable 이후 mapping 문제인지가 이 파일에서 갈립니다.