운영체제 기본/x86-64 Linux의 BIOS 부팅/진입 규약과 압축 해제
BIOS 환경의 GRUB가 일반 linux 명령으로 x86-64 bzImage를 부팅할 때, 커널에 넘겨주는 CPU는 아직 32비트 보호 모드다. Linux는 그 상태에서 자체 페이지 테이블을 만들고 64비트 모드로 전환한 다음, 압축된 커널 본체를 실행 가능한 이미지로 바꾼다. 이 장은 Linux v6.12의 32비트 부트 프로토콜 진입 경로를 따라간다. 이미지 적재와 인계 준비는 GRUB에서 커널로에서 다룬다.
첫 명령어에도 실행 조건이 필요하다
이 경로의 진입점은 arch/x86/boot/compressed/head_64.S:startup_32다. 파일 이름의 64는 x86-64 커널용 소스라는 뜻이지, 그 파일의 모든 명령어가 64비트라는 뜻은 아니다. 이 심볼은 .code32 구역에 있고, 부트 프로토콜이 정한 보호 모드 이미지의 오프셋 0에 놓인다. bzImage 파일의 처음은 아니며, start_kernel()의 주소도 아니다.[1]
여기에 도착했을 때의 핵심 조건은 다음과 같다.[2]
- CPU는 보호 모드이고 페이징은 꺼져 있다. A20은 활성화되어 있다.
- 세그먼트는 규약에 맞는 평탄한 32비트 주소 공간을 제공한다. 따라서 이 구간에서는 선형 주소와 물리 주소의 수치가 같다.
- 마스커블 인터럽트는 비활성화되어 있다. 아직 로더의 인터럽트 처리 환경을 커널의 환경으로 간주할 수 없다.
ESI는struct boot_params의 물리 주소다. 명령행, 메모리 지도, initrd 위치와 크기 등의 정보는 이 구조체와 그 안의 포인터로 전달된다.- 이 32비트 진입 경로에서 커널과 초기 실행 공간, initrd, 부트 정보, 명령행은 4 GiB 아래에 있어야 한다.
CPU가 실행하는 첫 코드는 이미 Linux 코드다. 그러나 아직 메모리 할당자나 일반적인 커널 스택에 기대어 C 함수를 마음대로 호출할 수는 없다. startup_32는 먼저 boot_params의 scratch 영역을 짧은 임시 스택으로 사용한다. call이 저장한 실제 실행 주소를 pop으로 얻고, 링크 시점 오프셋을 빼서 현재 이미지의 적재 기준 주소를 계산한다. 이 값이 있어야 아직 재배치되지 않은 코드가 자신의 GDT, 스택, 페이지 테이블을 찾을 수 있다.[1]
이어서 자기 GDT를 적재하고 데이터 세그먼트와 부트 스택을 설정한다. GDT를 바꾼 뒤 CS도 새 테이블에 맞게 다시 적재한다. 단순히 lgdt만 실행하면 이미 세그먼트 레지스터에 캐시된 속성까지 자동으로 바뀌지는 않기 때문이다. 구체적인 디스크립터와 LDT 처리는 GDT와 LDT 초기화에서 설명한다.
32비트 보호 모드에서 64비트 모드로
verify_cpu()로 필요한 CPU 기능을 확인한 뒤, 커널은 롱 모드에 필요한 페이징을 준비한다. 롱 모드는 명령어 하나로 32비트 레지스터를 64비트로 바꾸는 기능이 아니다. CPU 모드, 주소 변환 방식, 코드 세그먼트 속성을 서로 맞추어야 한다.[1]
startup_32 CR4.PAE = 1 build PML4 -> PDPT -> page directories CR3 = physical address of PML4 EFER.LME = 1 CR0.PG = 1 -> long mode active, compatibility mode far return to CS.L=1 -> 64-bit execution at startup_64
PAE(Physical Address Extension)는 롱 모드의 페이지 테이블을 사용하기 위해 필요한 CPU 설정이다. 먼저 CR4.PAE를 설정한다. 다음으로 초기 4단계 페이지 테이블을 만들고 그 최상위 테이블의 물리 주소를 CR3에 넣는다. 이 테이블은 2 MiB 대형 페이지 2,048개로 처음 4 GiB를 identity mapping한다. 즉, 같은 수치의 가상 주소가 같은 수치의 물리 주소를 가리킨다. 주소 변환과 페이지 테이블의 역할은 가상 메모리에서 설명한다. 페이징을 켜는 순간에도 현재 실행 중인 명령어, 부트 스택, 입력 이미지와 부트 정보를 그대로 찾게 하려는 것이다. 이것은 최종 커널 주소 공간이 아니라 모드 전환과 압축 해제를 위한 작업용 주소 공간이다.[1]
EFER.LME는 롱 모드 사용을 허용한다. 이 비트를 설정하는 것만으로 즉시 64비트 명령어 해석이 시작되지는 않는다. 필요한 페이지 테이블과 PAE 설정이 준비된 상태에서 CR0.PG를 켜면 롱 모드가 활성화되지만, 기존 CS는 여전히 32비트 코드 세그먼트이므로 우선 호환 모드에서 실행된다. 커널은 스택에 새 코드 셀렉터와 목적지 주소를 넣고 lret을 실행한다. 이 far return이 CS.L=1인 세그먼트와 startup_64의 주소를 함께 적재하여 64비트 실행으로 넘어간다.[1]
startup_64는 압축 이미지 시작점에서 0x200 떨어진 고정 위치에 있다. 이 경로에서는 GRUB가 그 위치로 직접 들어오는 것이 아니라, Linux의 startup_32가 모드 전환을 마치고 도착한다.[1]
지금 들어온 곳은 커널 본체가 아니다
소스에는 startup_64가 두 군데 있다. 지금 실행하는 것은 arch/x86/boot/compressed/head_64.S의 압축 해제기다. 압축이 끝난 뒤 실행할 것은 arch/x86/kernel/head_64.S의 커널 본체다. 둘은 따로 링크되는 이미지에 속하며, 같은 이름을 공유할 뿐 서로 다른 진입점이다.[1][3]
GRUB -> compressed/startup_32 32-bit entry -> compressed/startup_64 64-bit decompressor -> extract_kernel() -> kernel/startup_64 decompressed kernel
압축 해제기는 최종 커널의 축소판 실행 환경을 별도로 갖는다. 자체 스택과 제한된 힙, 초기 예외 처리기, 페이지 테이블을 사용한다. boot_params 주소는 C 함수 호출에도 살아남아야 하므로 R15에 보관한다. 정상 커널의 메모리 할당자나 스케줄러는 아직 존재하지 않는다.[1]
압축 해제 중 자기 자신을 덮어쓰지 않아야 한다
압축 입력보다 풀린 출력이 크다. 원래 적재 위치에서 곧바로 낮은 주소부터 출력하면 아직 읽지 않은 입력, 현재 실행 중인 해제기, 스택을 덮어쓸 수 있다. Linux는 부트 헤더의 init_size로 확보한 초기 작업 공간의 뒤쪽에 압축 이미지를 옮긴 뒤 작업한다. 옮길 위치는 출력 기준 주소에 init_size를 더하고 압축 이미지의 _end 오프셋을 빼서 구한다. 이 여유 공간의 크기는 이미지 빌드 과정에서 압축 해제와 초기 실행에 필요한 크기를 고려해 계산된다.[1][2]
low address high address
|------------------- workspace (init_size) ---------------------|
|---------------- kernel output ----------------|
|---- relocated compressed image ----|
^ ^
relocation base workspace end
출력 범위와 옮긴 압축 이미지의 전체 범위가 반드시 완전히 분리되는 것은 아니다. 입력을 소비하면서 출력이 커지는 과정까지 감안해 공간을 잡는 것이며, 핵심은 아직 필요한 입력과 실행 중인 코드를 먼저 훼손하지 않는 것이다.
복사 자체도 겹침을 고려한다. std; rep movsq; cld는 높은 주소에서 낮은 주소로 복사한다. 목적지가 더 높은 겹치는 영역일 때, 앞에서부터 복사하여 아직 읽지 않은 원본을 먼저 덮어쓰는 문제를 피하는 순서다. 복사 대상은 startup_32부터 _bss 전까지이고, BSS는 복사한 다음 새 위치에서 0으로 만든다. GDTR도 새 복사본의 GDT를 가리키도록 고친 후 .Lrelocated의 새 주소로 분기한다. 코드를 복사해 놓기만 하고 원래 위치에서 계속 실행하는 것이 아니다.[1]
새 위치에서는 압축 해제기 BSS를 비우고 다음 단계의 예외 처리와 identity mapping을 준비한다. BSS 초기화는 C의 초기값 없는 전역·정적 변수가 0이라는 조건을 실제 메모리에 구현하는 과정이다. 뒤에서 커널 본체가 비울 BSS와는 별개다.
압축을 푸는 일과 실행 이미지를 만드는 일
Linux v6.12에서 어셈블리가 호출하는 함수는 extract_kernel(boot_params, output)이다. 이 함수는 필요한 출력 공간과 배치 위치를 정하고, 정렬과 주소 범위를 검사한다. 배치 위치는 커널 구성과 부팅 옵션에 따라 달라질 수 있다. 필요한 공간은 압축 해제 결과의 크기뿐 아니라 본체의 BSS와 초기 작업 공간까지 수용해야 한다. 따라서 “압축 파일 크기의 몇 배” 같은 임의 계산으로 출력 버퍼를 정하지 않는다.[4]
extract_kernel(boot_params, output)
-> decompress_kernel(output, virt_addr, error)
-> __decompress(...)
-> parse_elf(output)
-> handle_relocations(output, output_len, virt_addr)
-> return output + entry_offset
__decompress()는 압축된 바이트 스트림을 푼다. 그 결과는 ELF 형식의 파일 이미지다. ELF는 코드와 데이터가 파일의 어느 위치에 있고, 메모리 어디에 배치되어야 하며, 어느 주소에서 실행을 시작할지를 기록하는 실행 파일 형식이다. parse_elf()는 이 정보를 읽어 메모리에 적재할 구간인 PT_LOAD들을 실행 위치에 복사하고, 실행 시작 주소인 진입점의 오프셋을 구한다. 이어 필요한 구성에서 handle_relocations()가 주소 참조를 실제 가상 배치에 맞게 보정한다. 압축 해제, ELF 구간 배치, 주소 재배치는 서로 다른 문제다. 압축된 바이트를 원래대로 되돌렸다고 해서 실행 시 참조할 주소까지 자동으로 맞는 것은 아니다.[4]
마지막 반환값은 완성된 본체의 진입 주소다. 압축 해제기는 R15에 보존했던 부트 정보 주소를 RSI로 되돌리고, 반환 주소가 들어 있는 RAX로 간접 분기한다. 이제 CPU는 이미 64비트 모드와 페이징을 사용하는 상태로 커널 본체의 startup_64에 들어간다. 다음 장에서 하는 일은 압축을 다시 푸는 것이 아니라, 이 임시 실행 환경을 커널 자신의 실행 환경으로 교체하는 것이다.[1]
이전: GRUB에서 커널로 · 다음: GDT와 LDT 초기화
참고 문헌
- ↑ 1.00 1.01 1.02 1.03 1.04 1.05 1.06 1.07 1.08 1.09 1.10 Linux v6.12, arch/x86/boot/compressed/head_64.S, startup_32, startup_64, .Lrelocated.
- ↑ 2.0 2.1 Linux v6.12, Documentation/arch/x86/boot.rst, 32-bit Boot Protocol 및 init_size.
- ↑ Linux v6.12, arch/x86/kernel/head_64.S, startup_64.
- ↑ 4.0 4.1 Linux v6.12, arch/x86/boot/compressed/misc.c, extract_kernel, decompress_kernel, parse_elf, handle_relocations.