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

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

~ldmsys
용어 통일: 시스템 콜, 컨텍스트 스위치와 첫 등장 영문 병기
목차 순서에 맞춰 이전 장을 GDT 초기화로 연결
 
(같은 사용자의 중간 판 4개는 보이지 않습니다)
1번째 줄: 1번째 줄:
[[운영체제 기본|운영체제 기본 목차]] · [[운영체제 기본/x86-64 Linux의 부팅|부팅 목차]]
[[운영체제 기본|운영체제 기본 목차]] · [[운영체제 기본/x86-64 Linux의 BIOS 부팅|부팅 목차]]


'''시작 상태:''' 압축 해제된 본체의 <code>arch/x86/kernel/head_64.S:startup_64</code>에 도착했다. CPU는 64비트 모드이며 초기 매핑과 부트 정보가 있다.
압축 해제기가 커널 본체로 분기했을 때 CPU는 이미 64비트 모드에서 페이징을 사용한다. 그러나 그때의 스택, 주소 공간, 예외 처리기는 압축 해제를 위한 임시 환경이다. 본체는 자신의 실행 기반을 세운 뒤, 메모리 관리·스케줄러·인터럽트·VFS처럼 다른 기능의 전제가 되는 하위 시스템을 순서대로 준비한다. 이 장은 '''Linux v6.12, BIOS로 부팅한 x86-64 부트 CPU'''가 <code>arch/x86/kernel/head_64.S:startup_64</code>에서 <code>rest_init()</code>까지 진행하는 경로를 다룬다.


'''끝 상태:''' <code>start_kernel()</code>이 핵심 기반을 초기화하고, 별도 태스크를 만드는 <code>rest_init()</code>으로 넘어간다.
<span id="1._본체가_도착했다고_실행_환경이_완성되지는_않는다"></span>
== 본체가 도착했다고 실행 환경이 완성되지는 않는다 ==


기준은 '''Linux v6.12의 x86-64 부트 CPU 경로'''다. “이미 커널 명령어를 실행한다”와 “프로세스를 스케줄링할 준비가 끝났다”는 서로 다른 상태다.
본체 진입 시 <code>RSI</code>에는 부트 정보의 물리 주소가 있다. <code>startup_64</code>는 이를 <code>R15</code>에 보존하고, 본체의 초기 커널 스택을 사용하도록 <code>RSP</code>를 바꾼다. C 함수 호출과 반환에는 스택이 필요하므로, 압축 해제기가 사용하던 스택을 그대로 계속 쓰지 않고 본체의 스택으로 옮기는 것이다. 이어 <code>startup_64_setup_gdt_idt()</code>로 초기 GDT와 IDT를 준비하고 CPU 상태를 확인한다. '''정상 커널에서 C 함수를 호출하는 조건도 초기 코드가 먼저 만들어야 한다.'''<ref name="head">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kernel/head_64.S?h=v6.12 Linux v6.12, arch/x86/kernel/head_64.S], startup_64, common_startup_64, initial_code.</ref>


== 1. 본체가 도착했다고 실행 환경이 완성되지는 않는다 ==
이때 준비하는 것은 초기 코드에 필요한 GDT와 IDT다. IDT는 CPU 예외나 인터럽트가 발생했을 때 어느 처리 코드로 들어갈지를 지정한다. 아직 일반적인 인터럽트 처리 환경이 완성되지 않았으므로, 본체는 이 단계에서 사용할 수 있는 초기 예외 처리 경로부터 마련한다.<ref name="headc">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kernel/head64.c?h=v6.12 Linux v6.12, arch/x86/kernel/head64.c], __startup_64, x86_64_start_kernel, x86_64_start_reservations 및 bringup IDT 주석.</ref> 디스크립터의 교체 과정은 [[운영체제 기본/x86-64 Linux의 BIOS 부팅/GDT와 LDT 초기화|GDT와 LDT 초기화]], IDT가 진입점을 선택하는 원리는 [[운영체제 기본/장치 제어와 입출력#처리 진입점과 IDT|장치 제어와 입출력]]에서 연결해 볼 수 있다.


앞 장의 해제기는 본체를 배치해 주었다. 그러나 그 해제기의 스택과 임시 매핑을 그대로 영구적인 커널 환경으로 사용할 수는 없다. 본체는 자신의 초기 스택을 잡고 GDT/IDT를 준비하며 CPU 상태를 확인한다. GDT는 세그먼트 정보를, IDT는 예외·인터럽트 진입 정보를 제공한다. 이 시기의 준비는 이후 모든 기능이 갖춰진 상태와 같지 않다. 예외 처리 자체도 사용할 수 있는 초기화 수준에 맞아야 하기 때문이다.<ref name="head">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kernel/head_64.S?h=v6.12 v6.12 arch/x86/kernel/head_64.S], startup_64, common_startup_64.</ref><ref name="headc">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kernel/head64.c?h=v6.12 v6.12 arch/x86/kernel/head64.c], __startup_64, startup_64_setup_gdt_idt 및 초기 IDT에 관한 주석.</ref>
== 물리 적재 위치와 커널 가상 주소를 연결한다 ==


또한 본체가 실제로 적재된 물리 위치가 빌드 시 예상한 위치와 다를 수 있다. <code>__startup_64()</code>는 그 차이를 반영해 초기 페이지 테이블을 보정한다. 어셈블리 코드는 CR3에 사용할 페이지 테이블 정보를 넣고 커널 가상 주소의 공통 시작 코드로 분기한다. '''파일을 올바르게 풀어 놓는 작업과 CPU가 올바른 주소 변환으로 그것을 실행하는 작업을 구별해야 한다.'''<ref name="head"/><ref name="headc"/>
압축 해제기는 커널을 사용할 수 있는 물리 메모리에 배치했다. 반면 커널 심볼은 링크 시점의 가상 주소를 갖는다. 본체의 초기 페이지 테이블도 빌드 시점의 배치를 전제로 만들어져 있으므로, 실제 적재 위치가 달라졌다면 그대로 사용할 수 없다. <code>__startup_64(physaddr, boot_params)</code>는 실제 물리 위치와 링크 시점 기준의 차이인 <code>load_delta</code>를 구하고, 초기 페이지 테이블에 들어 있는 물리 주소들을 보정한다.<ref name="headc"/>


여기서 새 사용자 프로세스가 만들어지는 것은 아니다. 여전히 부트 CPU에서 같은 초기 실행 흐름이 계속된다. 페이지 테이블이 바뀌었다는 사실만으로 사용자 모드 전환이나 컨텍스트 스위치(Context Switch)가 일어났다고 해석하지 않는다.
여기에는 두 종류의 매핑이 함께 필요하다. 하나는 앞으로 사용할 높은 커널 가상 주소를 실제 본체의 물리 메모리에 연결하는 매핑이고, 다른 하나는 페이지 테이블을 교체하는 순간에도 현재 명령어를 계속 실행하기 위한 identity mapping이다. <code>CR3</code>를 바꾸었다고 현재 RIP가 자동으로 높은 커널 주소로 이동하지 않기 때문이다.


== 2. x86 전용 준비를 마치고 공통 코드로 간다 ==
<pre>
before CR3 switch:
  current RIP (identity address) -> kernel physical memory
 
after CR3 switch:
  current RIP (identity address) -> same physical memory
  high kernel virtual address  -> same physical memory
 
jump to common_startup_64:
  RIP now uses the high kernel virtual address
</pre>


어셈블리 공통 경로는 <code>initial_code</code>를 간접 호출한다. 부트 CPU에 대한 초기값은 <code>x86_64_start_kernel</code>이다. 다만 이것을 “최초의 C 함수”라고 부르면 앞에서 실행한 <code>__startup_64()</code> 같은 C 코드를 놓치게 된다. 이 함수의 의미는 C 언어의 시작점이라기보다 '''본체의 다음 초기화 단계로 넘어가는 경계'''에 가깝다.<ref name="head"/><ref name="headc"/>
<code>__startup_64()</code>가 반환하면 어셈블리는 본체의 <code>early_top_pgt</code>를 가리키는 값을 <code>CR3</code>에 적재하고, 높은 커널 가상 주소의 <code>common_startup_64</code>로 분기한다. 보정 대상과 전환용 매핑을 한꺼번에 준비한 뒤 주소 공간을 바꾸는 순서다. 이후에는 전환에만 필요했던 저위 identity mapping을 제거할 수 있다.<ref name="head"/><ref name="headc"/>


<code>x86_64_start_kernel()</code>은 초기 페이지 테이블을 정리하고 본체의 BSS를 비우며, 초기 예외 처리와 부트 정보 복사 등을 수행한다. BSS는 0으로 초기화되어야 하는 전역·정적 데이터 영역이다. 압축 해제기도 자신의 BSS를 비웠지만, 이제 다루는 것은 본체의 영역이다. 같은 이름의 준비가 반복된다고 해서 같은 메모리를 같은 이유로 두 번 처리하는 것은 아니다.<ref name="headc"/>
이것은 '''같은 실행 흐름의 주소 표현을 바꾸는 작업'''이다. 새 프로세스를 만들거나 컨텍스트 스위치(Context Switch)를 한 것이 아니다. 또한 사용자 공간으로 넘어가는 것도 아니다. 페이지 테이블, 물리 주소, 가상 주소의 관계는 [[운영체제 기본/가상 메모리|가상 메모리]]에서 설명하는 원리와 같다. 다만 여기서는 일반 프로세스용 주소 공간을 만드는 것이 아니라, 메모리 관리 코드 자체가 실행될 수 있는 첫 주소 공간을 만들고 있다.


이어 <code>x86_64_start_reservations()</code>가 플랫폼별 초기 처리를 거쳐 <code>start_kernel()</code>을 호출한다. 인자 이름 <code>real_mode_data</code>는 역사적 이름이지 현재 CPU의 실행 모드를 설명하는 문장이 아니다.<ref name="headc"/>
== 커널 자신의 스택과 CPU 상태를 사용한다 ==
 
<code>common_startup_64</code>에서는 부트 CPU의 현재 태스크를 기준으로 스택을 설정하고, 커널 가상 주소에서 사용할 GDT와 CPU별 데이터 접근 환경을 준비한다. 초기 스택은 단순한 임시 배열이 아니라 처음부터 존재하는 <code>init_task</code> 실행 흐름과 연결된다. 아직 사용자 프로그램은 없지만, 커널은 현재 태스크와 스택이라는 기본 관계를 갖춘 상태로 다음 초기화에 들어간다.<ref name="head"/>
 
끝에서는 <code>initial_code</code>가 가리키는 함수를 호출한다. 부트 CPU에서 그 초기값은 <code>x86_64_start_kernel</code>이다. 이를 “최초의 C 함수”라고 부르는 것은 정확하지 않다. 이미 앞에서 <code>__startup_64()</code> 같은 C 함수가 실행되었다. 이 지점의 의미는 '''커널 가상 주소와 본체 스택에 정착한 뒤 다음 초기화 단계로 넘어간다'''는 데 있다.
 
<span id="2._x86_전용_준비를_마치고_공통_코드로_간다"></span>
== x86 전용 준비를 마치고 공통 코드로 간다 ==
 
<code>x86_64_start_kernel()</code>은 <code>reset_early_page_tables()</code>로 전환용 identity mapping을 제거하고, <code>clear_bss()</code>로 본체 BSS와 초기 brk 영역을 0으로 만든다. 압축 해제기가 비운 BSS는 압축 해제기의 전역변수 영역이었고, 지금 비우는 것은 본체의 영역이다. 이름이 같다고 같은 메모리를 중복 처리하는 것이 아니다.<ref name="headc"/>
 
그 뒤 초기 예외 처리기를 준비하고 <code>copy_bootdata()</code>로 로더가 남긴 부트 정보와 명령행을 본체의 저장 공간으로 복사한다. 커널이 정보를 받아들인 뒤에는 로더의 원래 저장 영역을 영구적인 소유물처럼 붙잡고 있을 필요가 없다. 물리 주소로 받은 정보에 접근할 때는 커널의 주소 변환 규칙에 맞게 주소를 바꾼다. 초기 페이지 폴트 처리에서는 필요한 매핑을 만들어 접근을 이어갈 수 있도록 준비되어 있다.<ref name="headc"/>


'''중간 처리와 조건부 기능을 생략한 호출·분기 관계:'''
<pre>
<pre>
kernel/head_64.S:startup_64
kernel/head_64.S:startup_64
   → __startup_64()                 초기 페이지 테이블 보정
   -> __startup_64()
   → common_startup_64             커널 가상 주소에서 진행
   -> load CR3, jump to common_startup_64
   → initial_code가 가리키는 함수
   -> call initial_code
      x86_64_start_kernel()
      x86_64_start_kernel()
        → x86_64_start_reservations()
        -> reset_early_page_tables()
          → start_kernel()
        -> clear_bss()
        -> copy_bootdata()
        -> x86_64_start_reservations()
              -> start_kernel()
</pre>
 
<code>x86_64_start_reservations()</code>는 필요한 부트 정보가 확보되었는지 확인하고 플랫폼별 초기 처리를 거쳐 공통 코드인 <code>start_kernel()</code>을 호출한다. 인자 이름 <code>real_mode_data</code>는 부트 정보의 역사적 이름이지, 현재 CPU가 리얼 모드라는 뜻은 아니다.
 
<span id="3._start_kernel은_무엇을_가능하게_만드는가"></span>
== 메모리 관리부터 준비하는 이유 ==
 
<code>start_kernel()</code>의 초기화 순서는 임의의 함수 목록이 아니다. 뒤에서 실행할 기능이 앞 단계의 결과를 사용한다. 먼저 <code>setup_arch()</code>로 x86의 부트 정보와 메모리 배치 등을 처리하고, per-CPU 영역과 명령행 처리를 진행한다. 이때 마스커블 인터럽트는 여전히 꺼져 있다.<ref name="main">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/init/main.c?h=v6.12 Linux v6.12, init/main.c], start_kernel, rest_init, kernel_init_freeable.</ref>
 
초기 부팅에서는 아직 일반 페이지 할당자와 slab 할당자가 모두 준비되지 않았다. 그렇다고 그 할당자들을 준비할 자료구조까지 메모리 없이 만들 수는 없다. 이 때문에 부트 단계의 메모리 확보와 정상 동작 단계의 메모리 할당을 구분한다. <code>start_kernel()</code>은 큰 초기 할당을 사용하는 로그 버퍼와 <code>vfs_caches_init_early()</code>를 먼저 처리한 뒤 <code>mm_core_init()</code>으로 핵심 메모리 관리 초기화를 진행한다. 소스의 주석도 이 큰 초기 할당들이 페이지 할당자 초기화보다 앞서야 함을 명시한다.<ref name="main"/>
 
여기서 “초기 페이지 테이블을 만들었다”와 “메모리 관리가 준비되었다”는 다르다. 앞의 페이지 테이블은 CPU가 코드와 데이터에 접근하게 만든다. 메모리 관리 초기화는 어떤 물리 페이지를 사용할 수 있는지, 누가 어떤 메모리를 소유하는지, 커널 자료구조를 어떤 할당 경로로 제공할지를 갖추는 일이다. 주소 변환이 가능하다는 사실만으로 임의의 <code>kmalloc()</code> 사용까지 안전해지지는 않는다.
 
== 스케줄러가 인터럽트보다 먼저 준비된다 ==
 
메모리 관리 기반 다음에는 <code>sched_init()</code>으로 스케줄러를 준비한다. 타이머 인터럽트가 실행 시간을 계산하거나 재스케줄링을 요구하기 전에, 현재 태스크와 실행 큐를 관리할 수 있어야 하기 때문이다. 이 시점에는 모든 CPU의 최종 스케줄링 토폴로지까지 완성된 것은 아니다. 추가 CPU 준비와 전체 토폴로지 구성은 뒤의 SMP 초기화에서 이어진다.<ref name="main"/>
 
<pre>
setup_arch()
  -> early allocations
  -> mm_core_init()
  -> sched_init()
  -> early_irq_init(), init_IRQ()
  -> tick / timers / softirq / timekeeping
  -> local_irq_enable()
  -> task and VFS infrastructure
  -> rest_init()
</pre>
</pre>


== 3. start_kernel은 무엇을 가능하게 만드는가 ==
이어서 IRQ 관리 자료구조와 인터럽트 진입 경로, tick, 타이머, softirq, 시간 계측 기반을 준비한다. 그런 다음에야 <code>local_irq_enable()</code>로 부트 CPU의 마스커블 인터럽트를 허용한다. 초기 예외를 받을 수 있는 상태와 외부 장치의 인터럽트를 정상 처리할 수 있는 상태를 구별해야 한다. <code>cli</code> 상태에서도 CPU 예외까지 모두 사라지는 것은 아니다. 그래서 앞 단계에도 초기 IDT와 예외 처리가 필요했다.<ref name="main"/><ref name="headc"/>
 
스케줄러 초기화 역시 곧바로 사용자 태스크 실행을 뜻하지 않는다. 실행 큐와 스케줄링 기반을 만들었더라도 아직 PID 1을 만들고 실행시키는 단계가 남아 있다. 실행 주체와 컨텍스트 스위치의 의미는 [[운영체제 기본/프로세스와 스레드|프로세스와 스레드]]와 연결된다.
 
== VFS 준비는 루트 파일 시스템 마운트와 다르다 ==
 
인터럽트를 허용한 뒤에도 태스크와 파일 시스템에 필요한 기반을 계속 초기화한다. <code>fork_init()</code>과 <code>proc_caches_init()</code> 등으로 태스크 생성에 필요한 기반을 갖추고, <code>vfs_caches_init()</code>, <code>pagecache_init()</code> 등으로 경로 탐색과 파일 객체를 다루는 공통 계층을 준비한다. 앞의 <code>vfs_caches_init_early()</code>와 이 단계는 동일한 일을 두 번 하는 호출이 아니라, 사용할 수 있는 메모리 관리 수준에 따라 나뉜 초기화다.<ref name="main"/>


<code>start_kernel()</code>에는 아키텍처 준비, 메모리 관리, 스케줄러, 인터럽트·시간 관리, VFS 등 다양한 초기화가 모여 있다. 이름 목록보다 중요한 것은 '''다음 기능을 쓰기 전에 그 기능의 기반을 먼저 만든다'''는 의존 관계다.<ref name="main">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/init/main.c?h=v6.12 v6.12 init/main.c], start_kernel.</ref>
VFS를 준비했다고 디스크의 루트 파일 시스템까지 이미 마운트된 것은 아니다. VFS는 파일시스템 구현들을 다루는 공통 구조와 인터페이스다. 경로·inode·열린 파일 객체가 연결되는 방식은 [[운영체제 기본/Linux의 파일시스템/VFS와 파일 객체|VFS와 파일 객체]]에서 설명한다. 어떤 파일 시스템을 루트로 사용할지, initramfs 내용을 어떻게 사용할지, 어느 사용자 프로그램을 처음 실행할지는 그 다음 단계에서 결정되고 실행된다. 이 구별이 있어야 <code>start_kernel()</code>을 “모든 장치와 파일 시스템이 준비되는 함수”로 오해하지 않는다.


예를 들어 스케줄러는 타이머 인터럽트 같은 사건이 실행 흐름을 건드리기 전에 준비된다. 소스도 <code>sched_init()</code>을 인터럽트 시작보다 앞에 두는 이유를 명시한다. 초기 인터럽트 비활성 상태를 유지하며 필요한 준비를 진행한 뒤 <code>local_irq_enable()</code>에 도달한다. 이 순서는 단순한 함수 정렬이 아니라 아직 준비되지 않은 기능이 먼저 사용되는 일을 막는 순서다.<ref name="main"/>
== 초기 실행 흐름에서 여러 태스크로 넘어간다 ==


그러나 <code>start_kernel()</code>이 끝났다고 모든 장치와 모든 CPU의 초기화가 끝나는 것은 아니다. 이 함수가 <code>rest_init()</code>으로 넘어가면 별도 태스크가 만들어지고, 이후 PID 1의 커널 실행 문맥에서 나머지 초기화가 이어진다. 따라서 부팅을 “전부 초기화한 다음 처음 프로세스를 만든다”로 요약하면 실제 경계를 놓친다.<ref name="main"/>
마지막에 <code>start_kernel()</code>은 <code>rest_init()</code>으로 넘어간다. 여기서 PID 1이 될 실행 흐름과 <code>kthreadd</code>를 만들고, 원래의 부트 실행 흐름은 부트 CPU의 idle 태스크로 정착한다. PID 1의 커널 실행 문맥에서 추가 CPU, initcall, 장치와 파일 시스템 관련 나머지 초기화가 이어진다.<ref name="main"/>


'''소스 탐색:''' [https://elixir.bootlin.com/linux/v6.12/ident/__startup_64 __startup_64] · [https://elixir.bootlin.com/linux/v6.12/ident/x86_64_start_kernel x86_64_start_kernel] · [https://elixir.bootlin.com/linux/v6.12/ident/start_kernel start_kernel]. 각주는 동일 태그의 커널 저장소 원문이다.
따라서 부팅은 “초기화를 전부 마친 뒤 프로세스를 만든다”가 아니다. 먼저 태스크를 만들고 스케줄링할 최소 기반을 마련한 뒤, 그 태스크가 나머지 부팅 작업을 수행한다. 다음 장의 [[운영체제 기본/x86-64 Linux의 BIOS 부팅/PID 1 생성|PID 1 생성]]은 이 경계를 출발점으로 삼는다.


'''이전:''' [[운영체제 기본/x86-64 Linux의 부팅/진입 규약과 압축 해제|진입 규약과 압축 해제]] · '''다음:''' [[운영체제 기본/x86-64 Linux의 부팅/PID 1 생성|PID 1 생성]]
'''이전:''' [[운영체제 기본/x86-64 Linux의 BIOS 부팅/GDT와 LDT 초기화|GDT와 LDT 초기화]] · '''다음:''' [[운영체제 기본/x86-64 Linux의 BIOS 부팅/PID 1 생성|PID 1 생성]]


== 참고 문헌 ==
<references />
<references />

2026년 10월 31일 (토) 09:27 기준 최신판

운영체제 기본 목차 · 부팅 목차

압축 해제기가 커널 본체로 분기했을 때 CPU는 이미 64비트 모드에서 페이징을 사용한다. 그러나 그때의 스택, 주소 공간, 예외 처리기는 압축 해제를 위한 임시 환경이다. 본체는 자신의 실행 기반을 세운 뒤, 메모리 관리·스케줄러·인터럽트·VFS처럼 다른 기능의 전제가 되는 하위 시스템을 순서대로 준비한다. 이 장은 Linux v6.12, BIOS로 부팅한 x86-64 부트 CPU가 arch/x86/kernel/head_64.S:startup_64에서 rest_init()까지 진행하는 경로를 다룬다.

본체가 도착했다고 실행 환경이 완성되지는 않는다

본체 진입 시 RSI에는 부트 정보의 물리 주소가 있다. startup_64는 이를 R15에 보존하고, 본체의 초기 커널 스택을 사용하도록 RSP를 바꾼다. C 함수 호출과 반환에는 스택이 필요하므로, 압축 해제기가 사용하던 스택을 그대로 계속 쓰지 않고 본체의 스택으로 옮기는 것이다. 이어 startup_64_setup_gdt_idt()로 초기 GDT와 IDT를 준비하고 CPU 상태를 확인한다. 정상 커널에서 C 함수를 호출하는 조건도 초기 코드가 먼저 만들어야 한다.[1]

이때 준비하는 것은 초기 코드에 필요한 GDT와 IDT다. IDT는 CPU 예외나 인터럽트가 발생했을 때 어느 처리 코드로 들어갈지를 지정한다. 아직 일반적인 인터럽트 처리 환경이 완성되지 않았으므로, 본체는 이 단계에서 사용할 수 있는 초기 예외 처리 경로부터 마련한다.[2] 디스크립터의 교체 과정은 GDT와 LDT 초기화, IDT가 진입점을 선택하는 원리는 장치 제어와 입출력에서 연결해 볼 수 있다.

물리 적재 위치와 커널 가상 주소를 연결한다

압축 해제기는 커널을 사용할 수 있는 물리 메모리에 배치했다. 반면 커널 심볼은 링크 시점의 가상 주소를 갖는다. 본체의 초기 페이지 테이블도 빌드 시점의 배치를 전제로 만들어져 있으므로, 실제 적재 위치가 달라졌다면 그대로 사용할 수 없다. __startup_64(physaddr, boot_params)는 실제 물리 위치와 링크 시점 기준의 차이인 load_delta를 구하고, 초기 페이지 테이블에 들어 있는 물리 주소들을 보정한다.[2]

여기에는 두 종류의 매핑이 함께 필요하다. 하나는 앞으로 사용할 높은 커널 가상 주소를 실제 본체의 물리 메모리에 연결하는 매핑이고, 다른 하나는 페이지 테이블을 교체하는 순간에도 현재 명령어를 계속 실행하기 위한 identity mapping이다. CR3를 바꾸었다고 현재 RIP가 자동으로 높은 커널 주소로 이동하지 않기 때문이다.

before CR3 switch:
  current RIP (identity address) -> kernel physical memory

after CR3 switch:
  current RIP (identity address) -> same physical memory
  high kernel virtual address   -> same physical memory

jump to common_startup_64:
  RIP now uses the high kernel virtual address

__startup_64()가 반환하면 어셈블리는 본체의 early_top_pgt를 가리키는 값을 CR3에 적재하고, 높은 커널 가상 주소의 common_startup_64로 분기한다. 보정 대상과 전환용 매핑을 한꺼번에 준비한 뒤 주소 공간을 바꾸는 순서다. 이후에는 전환에만 필요했던 저위 identity mapping을 제거할 수 있다.[1][2]

이것은 같은 실행 흐름의 주소 표현을 바꾸는 작업이다. 새 프로세스를 만들거나 컨텍스트 스위치(Context Switch)를 한 것이 아니다. 또한 사용자 공간으로 넘어가는 것도 아니다. 페이지 테이블, 물리 주소, 가상 주소의 관계는 가상 메모리에서 설명하는 원리와 같다. 다만 여기서는 일반 프로세스용 주소 공간을 만드는 것이 아니라, 메모리 관리 코드 자체가 실행될 수 있는 첫 주소 공간을 만들고 있다.

커널 자신의 스택과 CPU 상태를 사용한다

common_startup_64에서는 부트 CPU의 현재 태스크를 기준으로 스택을 설정하고, 커널 가상 주소에서 사용할 GDT와 CPU별 데이터 접근 환경을 준비한다. 초기 스택은 단순한 임시 배열이 아니라 처음부터 존재하는 init_task 실행 흐름과 연결된다. 아직 사용자 프로그램은 없지만, 커널은 현재 태스크와 스택이라는 기본 관계를 갖춘 상태로 다음 초기화에 들어간다.[1]

끝에서는 initial_code가 가리키는 함수를 호출한다. 부트 CPU에서 그 초기값은 x86_64_start_kernel이다. 이를 “최초의 C 함수”라고 부르는 것은 정확하지 않다. 이미 앞에서 __startup_64() 같은 C 함수가 실행되었다. 이 지점의 의미는 커널 가상 주소와 본체 스택에 정착한 뒤 다음 초기화 단계로 넘어간다는 데 있다.

x86 전용 준비를 마치고 공통 코드로 간다

x86_64_start_kernel()은 reset_early_page_tables()로 전환용 identity mapping을 제거하고, clear_bss()로 본체 BSS와 초기 brk 영역을 0으로 만든다. 압축 해제기가 비운 BSS는 압축 해제기의 전역변수 영역이었고, 지금 비우는 것은 본체의 영역이다. 이름이 같다고 같은 메모리를 중복 처리하는 것이 아니다.[2]

그 뒤 초기 예외 처리기를 준비하고 copy_bootdata()로 로더가 남긴 부트 정보와 명령행을 본체의 저장 공간으로 복사한다. 커널이 정보를 받아들인 뒤에는 로더의 원래 저장 영역을 영구적인 소유물처럼 붙잡고 있을 필요가 없다. 물리 주소로 받은 정보에 접근할 때는 커널의 주소 변환 규칙에 맞게 주소를 바꾼다. 초기 페이지 폴트 처리에서는 필요한 매핑을 만들어 접근을 이어갈 수 있도록 준비되어 있다.[2]

kernel/head_64.S:startup_64
  -> __startup_64()
  -> load CR3, jump to common_startup_64
  -> call initial_code
       x86_64_start_kernel()
         -> reset_early_page_tables()
         -> clear_bss()
         -> copy_bootdata()
         -> x86_64_start_reservations()
              -> start_kernel()

x86_64_start_reservations()는 필요한 부트 정보가 확보되었는지 확인하고 플랫폼별 초기 처리를 거쳐 공통 코드인 start_kernel()을 호출한다. 인자 이름 real_mode_data는 부트 정보의 역사적 이름이지, 현재 CPU가 리얼 모드라는 뜻은 아니다.

메모리 관리부터 준비하는 이유

start_kernel()의 초기화 순서는 임의의 함수 목록이 아니다. 뒤에서 실행할 기능이 앞 단계의 결과를 사용한다. 먼저 setup_arch()로 x86의 부트 정보와 메모리 배치 등을 처리하고, per-CPU 영역과 명령행 처리를 진행한다. 이때 마스커블 인터럽트는 여전히 꺼져 있다.[3]

초기 부팅에서는 아직 일반 페이지 할당자와 slab 할당자가 모두 준비되지 않았다. 그렇다고 그 할당자들을 준비할 자료구조까지 메모리 없이 만들 수는 없다. 이 때문에 부트 단계의 메모리 확보와 정상 동작 단계의 메모리 할당을 구분한다. start_kernel()은 큰 초기 할당을 사용하는 로그 버퍼와 vfs_caches_init_early()를 먼저 처리한 뒤 mm_core_init()으로 핵심 메모리 관리 초기화를 진행한다. 소스의 주석도 이 큰 초기 할당들이 페이지 할당자 초기화보다 앞서야 함을 명시한다.[3]

여기서 “초기 페이지 테이블을 만들었다”와 “메모리 관리가 준비되었다”는 다르다. 앞의 페이지 테이블은 CPU가 코드와 데이터에 접근하게 만든다. 메모리 관리 초기화는 어떤 물리 페이지를 사용할 수 있는지, 누가 어떤 메모리를 소유하는지, 커널 자료구조를 어떤 할당 경로로 제공할지를 갖추는 일이다. 주소 변환이 가능하다는 사실만으로 임의의 kmalloc() 사용까지 안전해지지는 않는다.

스케줄러가 인터럽트보다 먼저 준비된다

메모리 관리 기반 다음에는 sched_init()으로 스케줄러를 준비한다. 타이머 인터럽트가 실행 시간을 계산하거나 재스케줄링을 요구하기 전에, 현재 태스크와 실행 큐를 관리할 수 있어야 하기 때문이다. 이 시점에는 모든 CPU의 최종 스케줄링 토폴로지까지 완성된 것은 아니다. 추가 CPU 준비와 전체 토폴로지 구성은 뒤의 SMP 초기화에서 이어진다.[3]

setup_arch()
  -> early allocations
  -> mm_core_init()
  -> sched_init()
  -> early_irq_init(), init_IRQ()
  -> tick / timers / softirq / timekeeping
  -> local_irq_enable()
  -> task and VFS infrastructure
  -> rest_init()

이어서 IRQ 관리 자료구조와 인터럽트 진입 경로, tick, 타이머, softirq, 시간 계측 기반을 준비한다. 그런 다음에야 local_irq_enable()로 부트 CPU의 마스커블 인터럽트를 허용한다. 초기 예외를 받을 수 있는 상태와 외부 장치의 인터럽트를 정상 처리할 수 있는 상태를 구별해야 한다. cli 상태에서도 CPU 예외까지 모두 사라지는 것은 아니다. 그래서 앞 단계에도 초기 IDT와 예외 처리가 필요했다.[3][2]

스케줄러 초기화 역시 곧바로 사용자 태스크 실행을 뜻하지 않는다. 실행 큐와 스케줄링 기반을 만들었더라도 아직 PID 1을 만들고 실행시키는 단계가 남아 있다. 실행 주체와 컨텍스트 스위치의 의미는 프로세스와 스레드와 연결된다.

VFS 준비는 루트 파일 시스템 마운트와 다르다

인터럽트를 허용한 뒤에도 태스크와 파일 시스템에 필요한 기반을 계속 초기화한다. fork_init()과 proc_caches_init() 등으로 태스크 생성에 필요한 기반을 갖추고, vfs_caches_init(), pagecache_init() 등으로 경로 탐색과 파일 객체를 다루는 공통 계층을 준비한다. 앞의 vfs_caches_init_early()와 이 단계는 동일한 일을 두 번 하는 호출이 아니라, 사용할 수 있는 메모리 관리 수준에 따라 나뉜 초기화다.[3]

VFS를 준비했다고 디스크의 루트 파일 시스템까지 이미 마운트된 것은 아니다. VFS는 파일시스템 구현들을 다루는 공통 구조와 인터페이스다. 경로·inode·열린 파일 객체가 연결되는 방식은 VFS와 파일 객체에서 설명한다. 어떤 파일 시스템을 루트로 사용할지, initramfs 내용을 어떻게 사용할지, 어느 사용자 프로그램을 처음 실행할지는 그 다음 단계에서 결정되고 실행된다. 이 구별이 있어야 start_kernel()을 “모든 장치와 파일 시스템이 준비되는 함수”로 오해하지 않는다.

초기 실행 흐름에서 여러 태스크로 넘어간다

마지막에 start_kernel()은 rest_init()으로 넘어간다. 여기서 PID 1이 될 실행 흐름과 kthreadd를 만들고, 원래의 부트 실행 흐름은 부트 CPU의 idle 태스크로 정착한다. PID 1의 커널 실행 문맥에서 추가 CPU, initcall, 장치와 파일 시스템 관련 나머지 초기화가 이어진다.[3]

따라서 부팅은 “초기화를 전부 마친 뒤 프로세스를 만든다”가 아니다. 먼저 태스크를 만들고 스케줄링할 최소 기반을 마련한 뒤, 그 태스크가 나머지 부팅 작업을 수행한다. 다음 장의 PID 1 생성은 이 경계를 출발점으로 삼는다.

이전: GDT와 LDT 초기화 · 다음: PID 1 생성

참고 문헌

  1. ↑ 1.0 1.1 1.2 Linux v6.12, arch/x86/kernel/head_64.S, startup_64, common_startup_64, initial_code.
  2. ↑ 2.0 2.1 2.2 2.3 2.4 2.5 Linux v6.12, arch/x86/kernel/head64.c, __startup_64, x86_64_start_kernel, x86_64_start_reservations 및 bringup IDT 주석.
  3. ↑ 3.0 3.1 3.2 3.3 3.4 3.5 Linux v6.12, init/main.c, start_kernel, rest_init, kernel_init_freeable.