연구 관점
start_kernel()은 커널 초기화 함수들의 단순한 나열이 아니라, subsystem 간
의존성을 선형화한 schedule입니다. 이 함수의 연구 대상은 “어떤 초기화가 어떤 전제 조건을
만들고, 그 전제 조건을 어떤 다음 subsystem이 소비하는가”입니다.
특히 중요한 경계는 setup_arch(), mm_core_init(),
sched_init(), local_irq_enable(), arch_call_rest_init()입니다.
이 경계들은 단순 milestone이 아니라, 사용할 수 있는 allocator, interrupt context,
scheduler state, RCU state, init memory lifetime이 바뀌는 지점입니다.
초기화 지도
start_kernel()을 문C식으로 읽으려면 긴 호출 목록을 먼저 외우지 말고,
각 호출이 어떤 subsystem의 사용 가능 조건을 만드는지부터 그려야 합니다.
아래 그림에서 왼쪽은 head.S가 넘긴 조건이고, 오른쪽으로 갈수록
일반 커널 런타임에 가까워집니다.
이 그림의 핵심은 “호출 순서”가 아니라 “전제 조건의 생산과 소비”입니다.
예를 들어 mm_core_init() 이후와 이전의 allocation 모델은 같은 C 함수 호출처럼 보여도 의미가 다릅니다.
| 경계 | 생기는 전제 조건 | 다음 소비자 |
|---|---|---|
| setup_arch() | architecture-specific memory map, FDT, CPU feature, paging state | setup_command_line(), mm_core_init(), driver init |
| mm_core_init() | page allocator, slab/vmalloc 초기 기반 | scheduler, VFS, driver core, initcall |
| sched_init() | runqueue, idle task, scheduler class 초기화 | kthread 생성, wakeup, rest_init |
| local_irq_enable() | timer/interrupt가 system progress에 참여 가능 | timekeeping, softirq, scheduler tick |
| arch_call_rest_init() | boot CPU가 idle로 넘어갈 준비 | pid 1 kernel_init, kthreadd |
상태 전이
start_kernel()의 위험한 부분은 “아직 안 되는 일을 되는 것처럼 부르는” 순간입니다.
그래서 아래 상태표처럼 현재 context에서 sleep, allocation, interrupt, init memory 참조가
가능한지를 같이 적어 두면 버그 위치가 훨씬 빨리 보입니다.
| 상태 | 의미 | 깨지는 지점 |
|---|---|---|
| early boot, IRQ off | boot CPU 하나가 대부분의 진행을 만들고, interrupt handler는 일반적으로 실행되지 않습니다. | timer interrupt나 softirq가 이미 돈다고 가정하는 코드 |
| memblock 중심 | physical memory는 interval set으로 예약/할당되고, page allocator는 아직 완전한 기준이 아닙니다. | 일반 GFP allocation 실패 경로처럼 읽는 해석 |
| scheduler prepared | runqueue와 idle task가 준비되지만, userspace task가 아직 system progress를 만들지는 않습니다. | kthread wakeup, completion, RCU scheduler state의 시점 착각 |
| IRQ/time enabled | clockevent, timer, interrupt가 커널 진행에 참여합니다. | IRQ enable 전후 latency를 같은 기준으로 비교하는 계측 |
| init handoff | pid 1과 kthreadd가 살아 있고 boot CPU는 idle 역할로 전환됩니다. | __init lifetime과 initcall dependency를 무시한 late reference |
경계별 실패 케이스
start_kernel()은 커널 전체가 아직 완성되지 않은 상태에서 커널 전체를 조립합니다.
그래서 여기서의 버그는 보통 한 함수 내부의 계산 오류보다, 어떤 subsystem이 준비되기 전에
다른 subsystem이 그 상태를 소비하는 ordering violation으로 나타납니다.
이 그림은 단순 순서도가 아니라 검증용 체크리스트입니다. 오른쪽 증상이 보이면 왼쪽 producer가 정말 필요한 상태를 만들었는지 역추적합니다.
| 경계 | 확인할 생산물 | 실패 증상 | 검증 방법 |
|---|---|---|---|
| setup_arch() | FDT, memblock, initrd, CPU feature, paging 전제 | early panic, 잘못된 memory reservation, DT 기반 device 미생성 | earlycon, memblock=debug, FDT dump |
| mm_core_init() | page allocator, slab, vmalloc로 넘어갈 최소 기반 | 초기 kmalloc 실패, page flag 이상, boot 중 OOM처럼 보이는 panic | page allocator trace, slub_debug, page_owner |
| sched_init() | runqueue, idle task, class chain, scheduler clock | kthread 생성 이후 hang, wakeup 손실, idle 진입 전 lockup | sched_switch, /proc/sched_debug, function graph |
| local_irq_enable() | interrupt handler가 실행될 수 있는 boundary | IRQ flood, timer interrupt 미동작, softirq backlog | /proc/interrupts, irq tracepoint, timerlat |
| do_basic_setup() | initcall graph, driver core, bus/device registration | probe 미호출, deferred probe 누적, rootfs device 없음 | initcall_debug, devices_deferred, ftrace really_probe |
start_kernel()의 줄 순서를 그대로 subsystem readiness로 읽으면 안 됩니다. 함수가 호출됐다는 사실과 그 subsystem이 외부 callback을 받아도 되는 상태는 다릅니다.
__init code/data는 “부팅 때 쓴다”가 아니라 “부팅 후 버려질 수 있다”는 lifetime annotation입니다. callback, workqueue, function pointer에 남기면 나중에 터집니다.
IRQ enable 전후 로그를 같은 시간축으로 비교하면 해석이 틀어집니다. IRQ가 켜진 뒤에는 timer, softirq, driver interrupt가 boot CPU의 선형 흐름을 끊을 수 있습니다.
불변조건
local_irq_enable()이전에는 timer interrupt와 일반 IRQ handler가 실행된다는 전제를 두면 안 됩니다.mm_core_init()이전과 이후의 allocation model은 다릅니다. early bootmem/memblock 의존 코드를 일반 allocator 코드처럼 읽으면 안 됩니다.sched_init()이전에는 scheduler 자료구조가 완전하지 않으므로 sleep, wakeup, kthread 생성의 의미가 제한됩니다.free_initmem()이후에는__inittext/data를 참조하면 lifetime violation입니다.rest_init()이후 boot CPU는 idle thread가 되고, pid 1과 kthreadd가 system progress를 이어받습니다.
계측 / 검증
부팅 순서 검증 포인트
initcall_debug
earlycon / keep_bootcon
ftrace=function_graph
trace_event=initcall:*,sched:*,irq:*
memblock=debug
rcupdate.rcu_cpu_stall_timeout=...
설명: 박사급 분석에서는 “어디까지 실행됐다”보다 “어떤 invariant가 성립하기 전에 누가 그 invariant를 사용했는가”를 확인해야 합니다. initcall latency, IRQ enable 시점, memblock reservation, scheduler activation, RCU scheduler start를 같은 timeline에 놓고 봅니다.
원본 코드
이 글은 Linux 6.18.37 LTS의 init/main.c를 기준으로 씁니다.
아래 raw 파일은 홈페이지 폴더에 같이 둔 로컬 복사본입니다.
head.S 다음에 무슨 일이 생기나
head.S의 __primary_switched는 stack, vector, BSS, FDT pointer를 정리하고
마지막에 start_kernel()을 호출합니다. 여기부터는 어셈블리 부트 코드가 아니라
C 코드의 큰 초기화 순서입니다.
이 함수는 커널 전체 목차에 가깝습니다. 한 줄씩 따라가면 memory allocator, scheduler, interrupt, timekeeping, VFS, process, security, network namespace, driver init, initcall이 모두 한 번씩 나옵니다. 그래서 이 글은 모든 세부 구현을 끝까지 파지 않고, 다음 토픽으로 갈 입구를 표시하는 데 초점을 둡니다.
870-899: start_kernel 진입부
L869-L899: boot CPU와 arch setup 전 준비
asmlinkage __visible __init __no_sanitize_address __noreturn __no_stack_protector
void start_kernel(void)
{
char *command_line;
char *after_dashes;
set_task_stack_end_magic(&init_task);
smp_setup_processor_id();
debug_objects_early_init();
init_vmlinux_build_id();
cgroup_init_early();
local_irq_disable();
early_boot_irqs_disabled = true;
/*
* Interrupts are still disabled. Do necessary setups, then
* enable them.
*/
boot_cpu_init();
page_address_init();
pr_notice("%s", linux_banner);
early_security_init();
setup_arch(&command_line);
setup_boot_config();
setup_command_line(command_line);
setup_nr_cpu_ids();
setup_per_cpu_areas();
smp_prepare_boot_cpu(); /* arch-specific boot-cpu hooks */
boot_cpu_hotplug_init();
설명: C 코드에 들어왔지만 아직 일반 커널 런타임은 아닙니다. IRQ는 꺼져 있고, allocator와 scheduler도 완전히 준비되지 않았습니다.
더 읽기: 함수 attribute가 먼저 눈에 들어옵니다. __init은 부팅 뒤 버릴 수 있는 init section에 들어간다는 뜻이고, __noreturn은 이 함수가 호출자에게 돌아가지 않는다는 뜻입니다. head.S에서 bl start_kernel 뒤에 ASM_BUG()가 있었던 이유와 맞습니다.
set_task_stack_end_magic(&init_task)는 init task stack 끝에 overflow 감지용 magic을 심습니다. 아직 process가 여러 개 있는 상태는 아니지만, 이미 init_task를 current의 기준으로 삼고 있습니다.
smp_setup_processor_id()와 boot_cpu_init()은 boot CPU identity를 커널 전역 상태에 반영합니다. 나중에 scheduler, per-cpu area, SMP bring-up에서 “현재 CPU가 누구냐”를 일관되게 봐야 하기 때문입니다.
setup_arch(&command_line)는 architecture-specific 초기화의 큰 문입니다. arm64라면 DTB 해석, memory region, memblock, paging, CPU feature, initrd, command line 포인터 같은 것들이 여기서 정리됩니다. 이 함수는 별도 글로 빼서 길게 봐야 합니다.
901-918: kernel command line 처리
L901-L918: early param과 일반 param 분리
pr_notice("Kernel command line: %s\n", saved_command_line);
/* parameters may set static keys */
jump_label_init();
parse_early_param();
after_dashes = parse_args("Booting kernel",
static_command_line, __start___param,
__stop___param - __start___param,
-1, -1, NULL, &unknown_bootoption);
print_unknown_bootoptions();
if (!IS_ERR_OR_NULL(after_dashes))
parse_args("Setting init args", after_dashes, NULL, 0, -1, -1,
NULL, set_init_arg);
if (extra_init_args)
parse_args("Setting extra init args", extra_init_args,
NULL, 0, -1, -1, NULL, set_init_arg);
/* Architectural and non-timekeeping rng init, before allocator init */
random_init_early(command_line);
설명: 커널 command line을 early param, kernel param, init process 인자로 나눠 처리합니다.
더 읽기: parse_early_param()은 early_param()으로 등록된 옵션을 먼저 처리합니다. 대표적으로 earlycon, loglevel, debug처럼 아주 이른 시점에 의미가 있는 옵션들이 이쪽입니다.
parse_args("Booting kernel", ...)는 __param section에 등록된 일반 kernel parameter를 훑습니다. 커널 코드의 여러 곳에서 module_param, core_param, __setup 계열로 등록한 값들이 여기서 걸립니다.
command line의 -- 뒤쪽은 커널이 아니라 userspace init에 넘길 인자로 취급됩니다. 그래서 after_dashes를 다시 set_init_arg로 parse합니다. bring-up 중 init argument가 이상하게 들어간다면 이 경계를 확인해야 합니다.
920-940: early memory와 scheduler
L920-L940: page allocator 전후의 큰 초기화
/*
* These use large bootmem allocations and must precede
* initalization of page allocator
*/
setup_log_buf(0);
vfs_caches_init_early();
sort_main_extable();
trap_init();
mm_core_init();
poking_init();
ftrace_init();
/* trace_printk can be enabled here */
early_trace_init();
/*
* Set up the scheduler prior starting any interrupts (such as the
* timer interrupt). Full topology setup happens at smp_init()
* time - but meanwhile we still have a functioning scheduler.
*/
sched_init();
설명: 메모리와 scheduler가 여기서 처음 큰 형태를 갖춥니다. 이 이후부터 커널은 훨씬 일반적인 실행 환경에 가까워집니다.
더 읽기: setup_log_buf와 vfs_caches_init_early는 page allocator가 완전히 서기 전에 큰 bootmem allocation을 써야 해서 앞에 옵니다. 순서가 그냥 취향이 아니라 allocator 준비 단계와 직접 연결됩니다.
mm_core_init()은 메모리 관리의 큰 전환점입니다. memblock 중심의 early allocation에서 buddy/slab/vmalloc 쪽으로 넘어가기 위한 기반이 잡힙니다. 이 함수는 memory 파트의 별도 글로 분리해야 합니다.
sched_init()은 timer interrupt를 켜기 전에 반드시 필요합니다. timer interrupt가 들어오면 scheduler tick과 time accounting이 동작할 수 있어야 하기 때문입니다. full topology setup은 나중에 smp_init()에서 하지만, 일단 단일 boot CPU 기준 scheduler는 여기서 준비됩니다.
945-980: IRQ, timer, timekeeping
L945-L980: interrupt와 time 기반 시설
radix_tree_init();
maple_tree_init();
housekeeping_init();
workqueue_init_early();
rcu_init();
trace_init();
if (initcall_debug)
initcall_debug_enable();
context_tracking_init();
/* init some links before init_ISA_irqs() */
early_irq_init();
init_IRQ();
tick_init();
rcu_init_nohz();
init_timers();
srcu_init();
hrtimers_init();
softirq_init();
timekeeping_init();
time_init();
설명: IRQ와 시간 기반 subsystem이 여기서 순서대로 올라옵니다. 이 순서가 맞아야 뒤에서 IRQ를 켤 수 있습니다.
더 읽기: rcu_init()이 비교적 이른 곳에 있습니다. 커널의 수많은 자료구조가 RCU read-side critical section을 전제로 움직이기 때문에, 나중에 driver와 network가 올라오기 전에 준비되어야 합니다.
early_irq_init()은 generic IRQ descriptor 기반을 만들고, init_IRQ()는 arch interrupt controller 초기화로 들어갑니다. arm64에서는 결국 GIC irqchip, irq domain, percpu interrupt 같은 주제로 이어집니다.
tick_init(), init_timers(), hrtimers_init(), timekeeping_init(), time_init()은 시간의 여러 층입니다. jiffies 기반 timer, high resolution timer, clocksource/clockevent, arch timer가 서로 맞물립니다.
softirq_init()은 bottom half 기반입니다. network RX/TX, timer, tasklet, RCU callback 등이 softirq를 타므로 network 파트로 가기 전에 여기서 한 번 끊어 읽는 게 좋습니다.
981-1068: IRQ enable 이후 core subsystem
L981-L1068: console, process, VFS, namespace, security, net
random_init();
kfence_init();
boot_init_stack_canary();
perf_event_init();
profile_init();
call_function_init();
WARN(!irqs_disabled(), "Interrupts were enabled early\n");
early_boot_irqs_disabled = false;
local_irq_enable();
kmem_cache_init_late();
console_init();
lockdep_init();
locking_selftest();
setup_per_cpu_pageset();
numa_policy_init();
acpi_early_init();
if (late_time_init)
late_time_init();
sched_clock_init();
calibrate_delay();
arch_cpu_finalize_init();
pid_idr_init();
anon_vma_init();
thread_stack_cache_init();
cred_init();
fork_init();
proc_caches_init();
uts_ns_init();
key_init();
security_init();
dbg_late_init();
net_ns_init();
vfs_caches_init();
pagecache_init();
signals_init();
seq_file_init();
proc_root_init();
nsfs_init();
cpuset_init();
cgroup_init();
taskstats_init_early();
delayacct_init();
acpi_subsystem_init();
arch_post_acpi_subsys_init();
kcsan_init();
/* Do the rest non-__init'ed, we're now alive */
arch_call_rest_init();
설명: IRQ를 켠 뒤부터는 subsystem 초기화가 넓게 펼쳐집니다. 여기서 커널 전체 토픽이 거의 다 갈라집니다.
더 읽기: local_irq_enable()은 큰 경계입니다. 그 전까지는 interrupt가 들어오면 안 되는 초기화가 많았고, 이 뒤부터는 timer, IRQ, lockdep selftest 같은 코드가 실제 interrupt enabled 상태를 전제로 움직일 수 있습니다.
console_init()은 일부러 이른 시점에 호출됩니다. PCI나 driver core가 모두 올라온 뒤가 아니지만, 이후 문제가 생겼을 때 로그를 뽑아야 하므로 early console에서 정식 console로 넘어가는 길을 엽니다.
fork_init(), cred_init(), signals_init()은 process 파트의 입구입니다. pid allocator, task_struct 관련 cache, credential, signal 자료구조가 준비됩니다.
vfs_caches_init(), pagecache_init(), proc_root_init()은 filesystem 파트의 입구입니다. dentry/inode/file cache, page cache, procfs root가 여기서 올라옵니다.
net_ns_init()은 network namespace의 시작점입니다. 네트워크 장치와 protocol stack은 뒤 initcall에서 더 올라오지만, namespace 기반은 여기서 먼저 잡힙니다.
680-727: rest_init(), pid 1과 kthreadd
L680-L727: init thread, kthreadd, idle thread
noinline void __ref __noreturn rest_init(void)
{
struct task_struct *tsk;
int pid;
rcu_scheduler_starting();
pid = user_mode_thread(kernel_init, NULL, CLONE_FS);
rcu_read_lock();
tsk = find_task_by_pid_ns(pid, &init_pid_ns);
tsk->flags |= PF_NO_SETAFFINITY;
set_cpus_allowed_ptr(tsk, cpumask_of(smp_processor_id()));
rcu_read_unlock();
numa_default_policy();
pid = kernel_thread(kthreadd, NULL, NULL, CLONE_FS | CLONE_FILES);
rcu_read_lock();
kthreadd_task = find_task_by_pid_ns(pid, &init_pid_ns);
rcu_read_unlock();
system_state = SYSTEM_SCHEDULING;
complete(&kthreadd_done);
schedule_preempt_disabled();
cpu_startup_entry(CPUHP_ONLINE);
}
설명: 여기서 pid 1이 될 init thread와 kernel thread를 낳는 kthreadd가 만들어집니다. boot CPU는 이후 idle loop로 들어갑니다.
더 읽기: user_mode_thread(kernel_init, ...)가 먼저 호출됩니다. init task가 pid 1을 얻어야 하기 때문입니다. 다만 이 thread가 곧바로 kthread를 만들려고 하면 kthreadd가 아직 없으므로, 뒤에서 completion으로 동기화합니다.
kernel_thread(kthreadd, ...)는 kernel thread 생성의 중심이 될 kthreadd를 만듭니다. 이후 대부분의 kthread는 직접 생성되는 것이 아니라 kthreadd를 통해 만들어집니다.
system_state = SYSTEM_SCHEDULING 이후 커널은 scheduling 가능한 상태로 넘어갑니다. boot idle thread는 schedule_preempt_disabled()를 한 번 호출하고, 마지막에는 cpu_startup_entry(CPUHP_ONLINE)로 idle loop에 들어갑니다.
1323-1330: driver_init과 initcall
L1323-L1330: do_basic_setup()
static void __init do_basic_setup(void)
{
cpuset_init_smp();
driver_init();
init_irq_proc();
do_ctors();
do_initcalls();
}
설명: driver core와 initcall이 여기서 본격적으로 올라옵니다. 대부분의 driver probe는 결국 initcall 흐름을 통해 시작됩니다.
더 읽기: driver_init()은 driver core의 큰 틀을 세웁니다. bus, class, device, kobject, sysfs 같은 토픽은 이 함수에서 갈라집니다.
do_initcalls()는 커널 전역에 흩어진 initcall section을 level 순서대로 실행합니다. subsys_initcall, device_initcall, late_initcall처럼 보던 매크로들이 실제로 실행되는 곳입니다.
embedded bring-up에서는 probe가 왜 안 도는지 추적할 때 initcall level을 먼저 봐야 합니다. device tree matching이 틀린 건지, driver가 initcall에 등록되지 않은 건지, probe defer인지가 여기서 갈립니다.
1432-1500: kernel_init과 userspace init 실행
L1432-L1500: initmem 해제 후 /init 또는 /sbin/init 실행
wait_for_completion(&kthreadd_done);
kernel_init_freeable();
async_synchronize_full();
system_state = SYSTEM_FREEING_INITMEM;
kprobe_free_init_mem();
ftrace_free_init_mem();
kgdb_free_init_mem();
exit_boot_config();
free_initmem();
mark_readonly();
pti_finalize();
system_state = SYSTEM_RUNNING;
numa_default_policy();
rcu_end_inkernel_boot();
do_sysctl_args();
if (ramdisk_execute_command) {
ret = run_init_process(ramdisk_execute_command);
if (!ret)
return 0;
pr_err("Failed to execute %s (error %d)\n",
ramdisk_execute_command, ret);
}
if (execute_command) {
ret = run_init_process(execute_command);
if (!ret)
return 0;
panic("Requested init %s failed (error %d).",
execute_command, ret);
}
if (CONFIG_DEFAULT_INIT[0] != '\0') {
ret = run_init_process(CONFIG_DEFAULT_INIT);
if (ret)
pr_err("Default init %s failed (error %d)\n",
CONFIG_DEFAULT_INIT, ret);
else
return 0;
}
if (!try_to_run_init_process("/sbin/init") ||
!try_to_run_init_process("/etc/init") ||
!try_to_run_init_process("/bin/init") ||
!try_to_run_init_process("/bin/sh"))
return 0;
panic("No working init found. Try passing init= option to kernel. "
"See Linux Documentation/admin-guide/init.rst for guidance.");
설명: 커널 초기화가 끝나면 init section을 해제하고, 마지막으로 userspace init을 실행합니다.
더 읽기: wait_for_completion(&kthreadd_done)은 rest_init에서 만든 kthreadd가 준비될 때까지 기다립니다. init thread가 kthread를 만들 수 있으려면 kthreadd가 먼저 살아 있어야 합니다.
kernel_init_freeable() 안에서 SMP, scheduler SMP, workqueue topology, late page allocator, initcall, rootfs 준비가 진행됩니다. 이 함수는 initcall과 driver probe를 따로 다루는 글에서 다시 봐야 합니다.
free_initmem() 이후에는 __init 코드와 데이터가 해제됩니다. 그래서 init 함수 포인터를 나중에 잘못 잡고 있으면 use-after-free 성격의 문제가 됩니다.
userspace init 실행 순서는 rdinit으로 잡힌 ramdisk init, init=으로 지정한 command, CONFIG_DEFAULT_INIT, 그리고 /sbin/init, /etc/init, /bin/init, /bin/sh fallback입니다. initramfs bring-up 문제는 이 순서대로 로그를 봐야 합니다.