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

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

~ldmsys
BIOS GRUB 부팅 경로 보강과 각론 형식 통일
BIOS GRUB 부팅 경로 보강과 각론 형식 통일
6번째 줄: 6번째 줄:
== 본체가 도착했다고 실행 환경이 완성되지는 않는다 ==
== 본체가 도착했다고 실행 환경이 완성되지는 않는다 ==


본체 진입 시 <code>RSI</code>에는 부트 정보의 물리 주소가 있다. <code>startup_64</code>는 이를 <code>R15</code>에 보존하고, 본체의 초기 커널 스택을 사용하도록 <code>RSP</code>를 바꾼다. C 함수 호출과 반환에는 스택이 필요하고, 스택 보호용 canary에 접근하는 코드에는 GS 기반 주소도 필요하다. 그래서 본체는 초기 GS 기반을 설정한 뒤 <code>startup_64_setup_gdt_idt()</code>와 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>
본체 진입 시 <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>


이때의 GDT와 IDT는 정상 동작 중의 모든 CPU 상태가 갖추어졌다는 뜻이 아니다. 특히 초기 IDT는 추적 기능이나 메모리 검사 기능, 완성된 TSS에 의존하는 일반 진입 경로를 그대로 사용하지 않는다. 아직 그런 기반이 준비되지 않았기 때문이다. 이 경계를 고려하지 않고 평상시 인터럽트 초기화 코드를 앞당기면, 예외를 처리하려다 아직 없는 기반을 다시 참조하게 된다.<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의 부팅/GDT와 LDT 초기화|GDT와 LDT 초기화]], IDT가 진입점을 선택하는 원리는 [[운영체제 기본/장치 제어와 입출력#처리 진입점과 IDT|장치 제어와 입출력]]에서 연결해 볼 수 있다.
이때 준비하는 것은 초기 코드에 필요한 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의 부팅/GDT와 LDT 초기화|GDT와 LDT 초기화]], IDT가 진입점을 선택하는 원리는 [[운영체제 기본/장치 제어와 입출력#처리 진입점과 IDT|장치 제어와 입출력]]에서 연결해 볼 수 있다.


== 물리 적재 위치와 커널 가상 주소를 연결한다 ==
== 물리 적재 위치와 커널 가상 주소를 연결한다 ==
34번째 줄: 34번째 줄:
== 커널 자신의 스택과 CPU 상태를 사용한다 ==
== 커널 자신의 스택과 CPU 상태를 사용한다 ==


<code>common_startup_64</code>에서는 부트 CPU의 per-CPU 정보와 현재 태스크를 기준으로 스택을 설정하고, 커널 가상 주소에서 사용할 GDT와 GS 기반을 적재한다. 초기 스택은 단순한 임시 배열이 아니라 처음부터 존재하는 <code>init_task</code> 실행 흐름과 연결된다. 아직 사용자 프로그램은 없지만, 커널은 현재 태스크와 스택이라는 기본 관계를 갖춘 상태로 다음 초기화에 들어간다.<ref name="head"/>
<code>common_startup_64</code>에서는 부트 CPU의 현재 태스크를 기준으로 스택을 설정하고, 커널 가상 주소에서 사용할 GDT와 CPU별 데이터 접근 환경을 준비한다. 초기 스택은 단순한 임시 배열이 아니라 처음부터 존재하는 <code>init_task</code> 실행 흐름과 연결된다. 아직 사용자 프로그램은 없지만, 커널은 현재 태스크와 스택이라는 기본 관계를 갖춘 상태로 다음 초기화에 들어간다.<ref name="head"/>
 
이 구간은 EFER의 시스템 콜(System Call) 지원 비트와 지원되는 NX 기능 등 CPU 실행 상태도 정리한다. 그렇다고 이 시점에 사용자 프로그램이 시스템 콜을 호출할 수 있는 것은 아니다. 시스템 콜 진입 MSR, 태스크 상태, 사용자 주소 공간처럼 나머지 조건이 뒤따라 준비되어야 한다. CPU 기능 비트를 켠 것과 사용자 실행 환경을 완성한 것은 다른 단계다.<ref name="head"/>


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

2026년 10월 31일 (토) 08:53 판

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

압축 해제기가 커널 본체로 분기했을 때 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 생성은 이 경계를 출발점으로 삼는다.

이전: 진입 규약과 압축 해제 · 다음: 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.