본문으로 이동
  • 현재 모든 문서는 작성 중입니다. 보시는 데 불편함이 있더라도 양해 부탁드립니다.

운영체제 기본/x86-64 Linux의 BIOS 부팅: 두 판 사이의 차이

~ldmsys
Linux v6.12 x86-64 부트 진입, 압축 해제, PID 1 생성과 사용자 init 실행 경로 설명
 
부팅 각론을 짧은 안내와 네 하위 장의 읽기 순서로 분리
태그: 대체됨
1번째 줄: 1번째 줄:
[[운영체제 기본|목차]] · 관련 총론: [[운영체제 기본/보호|보호와 시스템 호출]], [[운영체제 기본/프로세스와 스레드|프로세스와 스레드]]
[[운영체제 기본|운영체제 기본 목차]]


이 장은 '''실행 문맥, 프로세스 생성, 프로그램 교체'''를 Linux의 실제 소스에 연결하는 각론이다. 기준은 '''Linux v6.12'''이며, x86-64의 부트 CPU가 '''bzImage의 64비트 부트 프로토콜 진입점'''으로 제어를 넘겨받는 경로를 추적한다. 펌웨어의 리셋 벡터부터 모든 플랫폼의 부팅을 하나로 설명하는 장은 아니다.
Linux 커널이 제어를 넘겨받은 뒤, 사용자 공간의 첫 프로그램을 실행하기까지의 과정을 따라간다. 기준은 '''Linux v6.12, x86-64, bzImage의 64비트 부트 프로토콜'''이다. 다른 부팅 방식과 갈라지는 지점은 해당 장에서 구분한다.


핵심 질문은 “커널의 첫 명령어가 어떻게 init을 호출하는가?”보다 “'''어떤 실행 환경을 먼저 만들고, 어느 시점에 별도 태스크를 생성하며, 그 태스크가 언제 사용자 프로그램으로 바뀌는가?'''”이다. 함수 호출, 분기, 태스크 생성, exec는 서로 다른 사건이다. 아래 화살표도 이 차이를 구별해서 읽어야 한다.
전체 흐름은 '''진입 환경 준비 → 압축 해제된 커널의 초기화 → PID 1 생성 → init 실행'''이다. 아래 장을 순서대로 읽으며 각 단계가 무엇을 넘겨받고 무엇을 준비해 다음 단계로 넘기는지 살펴본다.


== 1. 진입점은 하나가 아니다 ==
== 읽는 순서 ==
# [[운영체제 기본/x86-64 Linux의 부팅/진입 규약과 압축 해제|진입 규약과 압축 해제]] — 로더가 준비할 CPU 상태와 부트 정보, 두 startup_64의 차이.
# [[운영체제 기본/x86-64 Linux의 부팅/커널 초기화|커널 초기화]] — 초기 주소 공간과 C 실행 환경에서 start_kernel까지.
# [[운영체제 기본/x86-64 Linux의 부팅/PID 1 생성|PID 1 생성]] — 부트 태스크에서 별도 태스크를 만들고 나머지 초기화를 이어가는 과정.
# [[운영체제 기본/x86-64 Linux의 부팅/init 실행과 사용자 모드 진입|init 실행과 사용자 모드 진입]] — /init과 init=의 선택, exec, 실제 사용자 모드 전환.


64비트 부트 프로토콜에서 로더는 <code>struct boot_params</code>를 준비하고, CPU를 페이징이 활성화된 64비트 모드에 둔다. 필요한 영역의 identity mapping, 세그먼트 상태, 비활성화된 인터럽트 등도 진입 계약에 포함된다. <code>%rsi</code>에는 부트 매개변수 구조체의 주소가 들어간다. 따라서 이 경로의 커널 첫 명령어가 “리얼 모드에서 시작해 모든 CPU 상태를 처음부터 만든다”고 설명하면 틀린다.<ref name="boot">[https://github.com/torvalds/linux/blob/v6.12/Documentation/arch/x86/boot.rst Linux v6.12: The Linux/x86 Boot Protocol], 특히 64-bit Boot Protocol 절.</ref>
함수 호출, 주소로의 분기, 태스크 생성, 프로그램 교체는 서로 다른 사건이다. 흐름도의 화살표를 읽을 때 이 차이를 구별하면 부팅 경로를 이해하기 쉽다.
 
문서가 말하는 64비트 진입점은 적재된 커널의 시작 주소에 <code>0x200</code>을 더한 위치다. bzImage에서 이는 '''압축 해제기 쪽''' <code>startup_64</code>에 해당한다. 여기서 “시작 주소”를 디스크상의 bzImage 파일 첫 바이트나 최종 커널의 가상 주소와 혼동하면 안 된다. 부트 프로토콜에 맞춰 적재한 이미지 부분을 기준으로 해석해야 한다.<ref name="boot"/><ref name="compressed">[https://github.com/torvalds/linux/blob/v6.12/arch/x86/boot/compressed/head_64.S Linux v6.12: arch/x86/boot/compressed/head_64.S].</ref>
 
이름이 같은 심볼을 구별하는 가장 안전한 방법은 '''파일 경로까지 함께 적는 것'''이다.
 
{| class="wikitable"
! 위치 !! 역할
|-
| <code>arch/x86/boot/compressed/head_64.S:startup_64</code> || 압축된 커널을 풀어서 본체로 넘어가기 위한 진입점
|-
| <code>arch/x86/kernel/head_64.S:startup_64</code> || 압축 해제된 커널 본체의 초기 진입점
|}
 
32비트 프로토콜로 진입하면 앞에 <code>startup_32</code>와 모드 전환 과정이 붙는다. EFI stub은 펌웨어가 PE/COFF 이미지로 실행하는 별도 진입 경로이며, 전통적인 별도 로더의 역할 일부를 수행할 수 있다. '''BIOS, UEFI, 32비트 진입, 64비트 진입을 모두 반드시 통과하는 일렬의 단계로 그리지 않는다.''' 본체 소스는 이미 준비된 비압축 커널에 64비트 로더가 직접 들어오는 경우도 명시한다.<ref name="compressed"/><ref name="head">[https://github.com/torvalds/linux/blob/v6.12/arch/x86/kernel/head_64.S Linux v6.12: arch/x86/kernel/head_64.S].</ref><ref>[https://github.com/torvalds/linux/blob/v6.12/Documentation/admin-guide/efi-stub.rst Linux v6.12: The EFI Boot Stub].</ref>
 
== 2. 압축 해제기: 실행 중인 코드를 덮어쓰지 않고 본체를 만든다 ==
 
압축 해제기에도 스택, 예외 처리, 주소 매핑 등 최소한의 실행 환경이 필요하다. 여기서의 준비를 이후 커널 전체의 메모리 관리 초기화와 동일시해서는 안 된다. 또한 압축 입력과 출력이 같은 메모리 공간을 사용하므로, 풀어 쓴 데이터가 아직 실행 중인 해제기나 읽지 않은 입력을 덮지 않도록 배치해야 한다.
 
<code>compressed/head_64.S</code>는 압축 이미지를 안전한 위치로 복사한 뒤 옮겨진 코드로 분기한다. 그 경로에서 BSS를 비우고 필요한 매핑을 마련한 다음 <code>extract_kernel()</code>을 호출한다. 반환값은 “압축 해제 성공 여부”가 아니라 '''다음에 실행할 커널 진입 주소'''다.<ref name="compressed"/>
 
<code>arch/x86/boot/compressed/misc.c</code>를 보면 이 과정이 단순한 압축 해제 라이브러리 호출보다 넓다는 것을 알 수 있다. <code>extract_kernel()</code>은 배치를 선택·검사하고, <code>decompress_kernel()</code>은 압축을 풀고 ELF를 해석하며 relocation을 처리한다. 최종적으로 출력 주소와 진입 오프셋으로 다음 진입 주소를 만든다. KASLR 같은 설정 때문에 “항상 특정 물리 주소에 본체를 놓는다”는 식의 상수 중심 설명은 피하는 편이 정확하다.<ref name="misc">[https://github.com/torvalds/linux/blob/v6.12/arch/x86/boot/compressed/misc.c Linux v6.12: arch/x86/boot/compressed/misc.c], extract_kernel 및 decompress_kernel.</ref>
 
다음은 '''호출·분기 관계만 남긴 의사 코드'''다. 레지스터 보존, 조건부 기능, 오류 처리 등은 생략했다.
 
<pre>
압축 해제기 startup_64
  → 안전한 위치로 이동한 코드
  → entry = extract_kernel(boot_params, output)
  → boot_params를 %rsi에 두고 entry로 jump
</pre>
 
== 3. 커널 본체: 아키텍처 초기화에서 공통 초기화로 ==
 
본체의 <code>startup_64</code>도 처음부터 C 함수 하나를 평범하게 호출할 수 있는 환경을 전제하지는 않는다. 초기 스택과 GDT/IDT를 준비하고 CPU를 검사하며, <code>__startup_64()</code>를 통해 실제 적재 위치에 맞게 초기 페이지 테이블을 보정한다. 이어 CR3를 설정하고 커널 가상 주소의 공통 시작 코드로 분기한다. 즉, '''압축을 푼다는 문제와 본체가 자신의 주소 공간에서 실행된다는 문제는 별개'''다.<ref name="head"/><ref name="headc">[https://github.com/torvalds/linux/blob/v6.12/arch/x86/kernel/head64.c Linux v6.12: arch/x86/kernel/head64.c].</ref>
 
부트 CPU 경로에서 <code>initial_code</code>의 초기값은 <code>x86_64_start_kernel</code>이다. 공통 어셈블리 경로는 이를 간접 호출한다. 이 함수를 “처음 실행되는 모든 C 코드”라고 부르면 부정확하다. 앞에서도 페이지 테이블 보정 같은 C 코드가 실행되기 때문이다.<ref name="head"/><ref name="headc"/>
 
<code>x86_64_start_kernel()</code>은 초기 페이지 테이블 정리, BSS 초기화, 초기 예외 처리, 부트 정보 복사 등을 수행하고 <code>x86_64_start_reservations()</code>를 거쳐 <code>start_kernel()</code>에 도달한다. <code>real_mode_data</code>라는 인자 이름이 남아 있어도 현재 CPU가 리얼 모드라는 뜻은 아니다. 전달되는 것은 앞에서부터 이어져 온 부트 정보다.<ref name="headc"/>
 
<code>start_kernel()</code>은 <code>setup_arch()</code>, 메모리·스케줄러·인터럽트·타이머·VFS 등의 초기화를 진행한다. 아직 일반 사용자 프로세스가 실행되는 단계는 아니다. 마지막에는 <code>rest_init()</code>으로 넘어간다.<ref name="main">[https://github.com/torvalds/linux/blob/v6.12/init/main.c Linux v6.12: init/main.c], start_kernel, rest_init, kernel_init_freeable, kernel_init, run_init_process.</ref>
 
== 4. rest_init: PID 0에서 PID 1을 만든다 ==
 
<code>rest_init()</code>은 <code>user_mode_thread(kernel_init, NULL, CLONE_FS)</code>로 PID 1을 먼저 만들고, <code>kernel_thread()</code>로 <code>kthreadd</code>(PID 2)를 만든다. PID 1은 <code>kthreadd_done</code>을 기다린다. 기존 부트 태스크(PID 0)는 idle 경로로 들어간다. '''PID 0 자체가 init으로 바뀌는 것이 아니다.'''<ref name="main"/>
 
<code>user_mode_thread</code>라는 이름도 즉각 사용자 모드에서 실행한다는 뜻은 아니다. <code>kernel/fork.c</code>에서 이 함수는 시작 함수와 인자를 담아 <code>kernel_clone()</code>을 호출한다. <code>kernel_thread()</code>와 달리 이 생성 경로는 <code>kthread</code> 속성을 설정하지 않는다. 이는 나중에 exec할 태스크를 만드는 것과 계속 커널 스레드로 남을 태스크를 만드는 것의 차이다. <code>fs/exec.c</code>의 <code>kernel_execve()</code>는 실제로 <code>PF_KTHREAD</code> 태스크의 호출을 거부한다.<ref>[https://github.com/torvalds/linux/blob/v6.12/kernel/fork.c Linux v6.12: kernel/fork.c], kernel_thread 및 user_mode_thread.</ref><ref name="exec">[https://github.com/torvalds/linux/blob/v6.12/fs/exec.c Linux v6.12: fs/exec.c], kernel_execve.</ref>
 
아래는 '''동기화와 중간 호출을 생략한 실행 흐름도'''다. 세로선은 함수 호출만을 뜻하지 않으며, 분기점에서 태스크가 나뉜다.
 
<pre>
부트 로더 → compressed/startup_64 → extract_kernel
                                      |
                                      v
kernel/startup_64 → x86_64_start_kernel
                → x86_64_start_reservations → start_kernel → rest_init
                                                              |
              +------------------------------------------------+------+
              |                        |                            |
      기존 PID 0                  새 PID 1                    kthreadd
      idle 경로                  kernel_init                  커널 스레드
                                      |
                              kernel_init_freeable
                                      |
                              run_init_process
                                      |
                              kernel_execve
                                      |
                          사용자 모드의 init 프로그램
</pre>
 
<code>kernel_init_freeable()</code>은 SMP·initcall 등의 나머지 초기화와 initramfs 준비를 진행한다. 돌아온 <code>kernel_init()</code>은 비동기 초기화 완료를 기다리고 초기화용 메모리를 해제한 뒤 실행할 init을 고른다.<ref name="main"/>
 
== 5. /init, rdinit=, init=는 같은 선택지가 아니다 ==
 
initramfs는 초기 사용자 공간에 필요한 파일들을 rootfs에 푸는 방식이다. <code>init/initramfs.c</code>의 <code>populate_rootfs()</code>는 <code>rootfs_initcall</code>로 등록되며, 비동기 작업에서 내장 아카이브와 조건에 따라 외부 이미지를 푼다. <code>wait_for_initramfs()</code>가 이 작업과 파일 접근 사이를 동기화한다. 따라서 “init이라는 디스크 파일을 열기 전에 파일 시스템을 어떻게 얻는가?”에 대한 첫 답은 '''이미 커널이 만든 초기 rootfs와 거기에 풀린 파일'''이다.<ref>[https://github.com/torvalds/linux/blob/v6.12/init/initramfs.c Linux v6.12: init/initramfs.c], populate_rootfs, do_populate_rootfs, wait_for_initramfs.</ref>
 
아래는 '''선택 규칙의 요약'''이며 실제 코드 전체가 아니다.<ref name="main"/>
 
<pre>
초기 후보 = rdinit= 지정 경로, 기본 /init
사전 실행 접근 검사 실패 → 초기 후보 해제; prepare_namespace()
 
초기 후보가 남아 있으면 실행 시도 → 성공하면 끝
init= 지정값 실행 시도            → 실패하면 panic
CONFIG_DEFAULT_INIT가 비어 있지 않으면 시도
/sbin/init → /etc/init → /bin/init → /bin/sh
모두 실패하면 panic
</pre>
 
초기 후보의 사전 검사를 통과한 뒤 exec만 실패한 경우, 그 실패가 <code>prepare_namespace()</code>를 다시 호출하지는 않는다. '''후보 이름의 순서뿐 아니라 현재 루트가 무엇인지도 중요하다.'''<ref name="main"/>
 
<code>prepare_namespace()</code>는 장치 탐색을 기다리고 루트 장치를 해석하여 루트를 마운트하는 커널 측 경로다. 반면 initramfs의 <code>/init</code>이 실행되면 실제 루트를 찾고 전환하는 작업을 초기 사용자 공간이 맡을 수 있다. 루트 전환 후 다른 init으로 exec하는 것도 사용자 공간의 책임이다. “커널이 /init을 실행한 다음 무조건 /sbin/init도 실행한다”는 순서는 없다.<ref>[https://github.com/torvalds/linux/blob/v6.12/init/do_mounts.c Linux v6.12: init/do_mounts.c], prepare_namespace.</ref><ref>[https://github.com/torvalds/linux/blob/v6.12/Documentation/filesystems/ramfs-rootfs-initramfs.rst Linux v6.12: ramfs, rootfs and initramfs], What is initramfs? 절. 이 문서의 역사적 설명과 현행 세부 선택 규칙은 구별하며, 후자는 위 v6.12 코드를 기준으로 한다.</ref>
 
== 6. exec와 사용자 모드 진입은 구분한다 ==
 
<code>run_init_process()</code>는 인자·환경을 넘겨 <code>kernel_execve()</code>를 호출한다. '''새 PID를 하나 더 만드는 것이 아니라 PID 1의 실행 프로그램을 교체'''하는 단계다.<ref name="main"/><ref name="exec"/>
 
여기에도 흔한 함정이 있다. 사용자 프로그램에서 성공한 <code>execve()</code>는 원래 프로그램으로 돌아오지 않지만, 커널 내부의 <code>kernel_execve()</code>는 성공 상태를 반환할 수 있다. 이 반환을 “init 실행에 실패했거나 init 프로그램이 종료되었다”는 뜻으로 읽으면 안 된다. 실행 이미지와 사용자 레지스터 상태를 준비한 다음, 커널의 복귀 경로가 실제 권한 수준 전환을 마무리한다.<ref name="exec"/>
 
x86에서는 <code>ret_from_fork_asm</code>이 <code>ret_from_fork()</code>를 호출한다. 이 C 함수가 시작 함수인 <code>kernel_init</code>을 실행하고, exec 성공 뒤 돌아오면 <code>syscall_exit_to_user_mode()</code>로 이어진다. 어셈블리 경로는 이어서 사용자 모드 복귀 코드로 분기한다. v6.12에는 FRED 사용 여부에 따른 대체 경로도 있으므로 마지막 명령을 모든 구성에서 한 가지로 고정하지 않는다.<ref>[https://github.com/torvalds/linux/blob/v6.12/arch/x86/kernel/process.c Linux v6.12: arch/x86/kernel/process.c], ret_from_fork.</ref><ref>[https://github.com/torvalds/linux/blob/v6.12/arch/x86/entry/entry_64.S Linux v6.12: arch/x86/entry/entry_64.S], ret_from_fork_asm.</ref>
 
이제 PID 1은 사용자 공간의 프로그램을 실행한다. 그것이 초기 rootfs의 <code>/init</code>인지, 최종 루트의 init인지, 어떤 서비스 관리자인지는 구성과 선택 결과에 달려 있다. 여기까지가 커널 진입점에서 사용자 공간으로 책임을 넘기는 경계다. 이후 서비스 시작 순서는 커널 부팅 경로와 분리해서 다룬다.
 
== 소스 읽기 안내 ==
 
심볼 검색은 반드시 '''v6.12로 고정'''하고 같은 이름의 파일을 구별한다. 다음 Elixir 링크는 탐색용이며, 본문의 검증 근거는 각주에 연결한 Linus Torvalds 저장소의 동일 태그 원문이다. 행 번호는 표시 방식에 따라 달라질 수 있어 고정하지 않았다.
 
* [https://elixir.bootlin.com/linux/v6.12/source/arch/x86/boot/compressed/head_64.S 압축 해제기 head_64.S]
* [https://elixir.bootlin.com/linux/v6.12/source/arch/x86/boot/compressed/misc.c 압축 해제·ELF·relocation]
* [https://elixir.bootlin.com/linux/v6.12/source/arch/x86/kernel/head_64.S 커널 본체 head_64.S]
* [https://elixir.bootlin.com/linux/v6.12/source/arch/x86/kernel/head64.c 본체 초기 C 코드]
* [https://elixir.bootlin.com/linux/v6.12/source/init/main.c start_kernel부터 init 선택까지]
* [https://elixir.bootlin.com/linux/v6.12/source/fs/exec.c kernel_execve]
 
== 각주 ==
<references />

2026년 10월 31일 (토) 06:42 판

운영체제 기본 목차

Linux 커널이 제어를 넘겨받은 뒤, 사용자 공간의 첫 프로그램을 실행하기까지의 과정을 따라간다. 기준은 Linux v6.12, x86-64, bzImage의 64비트 부트 프로토콜이다. 다른 부팅 방식과 갈라지는 지점은 해당 장에서 구분한다.

전체 흐름은 진입 환경 준비 → 압축 해제된 커널의 초기화 → PID 1 생성 → init 실행이다. 아래 장을 순서대로 읽으며 각 단계가 무엇을 넘겨받고 무엇을 준비해 다음 단계로 넘기는지 살펴본다.

읽는 순서

  1. 진입 규약과 압축 해제 — 로더가 준비할 CPU 상태와 부트 정보, 두 startup_64의 차이.
  2. 커널 초기화 — 초기 주소 공간과 C 실행 환경에서 start_kernel까지.
  3. PID 1 생성 — 부트 태스크에서 별도 태스크를 만들고 나머지 초기화를 이어가는 과정.
  4. init 실행과 사용자 모드 진입 — /init과 init=의 선택, exec, 실제 사용자 모드 전환.

함수 호출, 주소로의 분기, 태스크 생성, 프로그램 교체는 서로 다른 사건이다. 흐름도의 화살표를 읽을 때 이 차이를 구별하면 부팅 경로를 이해하기 쉽다.