전체 흐름
head.S는 C 코드로 넘어가기 전에 지나가는 arm64 부트 코드입니다.
크게 보면 부트로더가 넘긴 값을 보존하고, 임시 page table을 만들고, MMU를 켠 뒤
커널이 쓸 가상 주소로 갈아탑니다.
라인 번호는 Linux v6.6 기준입니다. 중간에 커널 버전이 바뀌면 몇 줄씩 밀릴 수 있으니, 아래 원본과 같이 보는 쪽이 덜 헷갈립니다.
원본 코드 전문
아래는 이 글에서 기준으로 잡은 Linux v6.6 head.S 원문입니다.
브라우저에서 외부 raw 파일을 막는 경우가 있어서, 홈페이지 안에 로컬 복사본도 같이 넣었습니다.
부트로더와 커널의 계약
arm64 커널로 점프할 때 부트로더가 맞춰줘야 하는 조건입니다.
이 시점에는 커널 page table을 아직 못 쓰므로 MMU와 D-cache는 꺼져 있어야 합니다.
x0에는 DTB, 정확히는 FDT blob의 물리 주소가 들어옵니다.
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 <[email protected]>
* Will Deacon <[email protected]>
*/
설명: 실행과는 상관없는 머리말입니다. 여기서는 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.h는 SYM_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에 배치합니다.
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에서 x21과 boot_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_start와 adr_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_C와 SCTLR_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_pointer와 early_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합니다.
루프는 index가 eindex에 도달할 때까지 돕니다. 초기 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가 준비되지 않은 상태에도 맞습니다.
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_mapping은 init_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입니다.
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라고 봐도 됩니다.
vectors를 VBAR_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_map은 x21에 보존해 둔 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_el1과 elr_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 상태를 이어받습니다.
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_dir와 idmap_pg_dir를 사용합니다.
그래도 CPU별 setup은 필요합니다. 각 CPU는 자기 SCTLR/TCR/MAIR 같은 시스템 레지스터를 가지고 있으므로 __cpu_setup을 다시 호출해야 합니다.
__enable_mmu가 끝난 뒤에는 직접 symbol address로 branch하지 않고 ldr x8, =__secondary_switched 후 br 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 부트 경로입니다.
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_mmu는 reserved_pg_dir와 init_idmap_pg_dir를 넘겨 MMU를 켭니다. 이후 CONFIG_RELOCATABLE, CONFIG_RANDOMIZE_BASE에 따라 x23 KASLR offset과 x24 linear map seed를 계산합니다.
clear_page_tables 후 create_kernel_mapping을 다시 호출하는 흐름이 중요합니다. 초기 mapping으로 일단 살아남은 뒤, 실제 커널이 쓸 page table을 정리해서 다시 만드는 단계입니다.
load_ttbr1로 TTBR1을 갱신하면 커널 virtual address 쪽 translation 기준이 바뀝니다. relocation까지 끝낸 뒤 br x8로 __primary_switched에 들어가면, 이제 C 진입 준비 단계로 넘어갑니다.
정리
Linux 6.6 arm64 head.S는 길지만 흐름은 몇 덩어리로 나뉩니다.
이미지 헤더, boot arg 보존, idmap 생성, kernel mapping 생성, EL 정리,
그리고 start_kernel() 호출입니다.
early boot hang을 볼 때는 “어디까지 찍혔는지”부터 나누는 게 빠릅니다. 이미지 헤더에서 죽는지, FDT 전달이 틀렸는지, VA/granule 쪽인지, MMU enable 이후 mapping 문제인지가 이 파일에서 갈립니다.