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

운영체제 기본/x86-64 Linux의 BIOS 부팅/GDT와 LDT 초기화

~ldmsys
관리자 (토론 | 기여)님의 2026년 10월 31일 (토) 09:00 판 (관리자님이 운영체제 기본/x86-64 Linux의 부팅/GDT와 LDT 초기화 문서를 운영체제 기본/x86-64 Linux의 BIOS 부팅/GDT와 LDT 초기화 문서로 이동했습니다: x86-64 전용 각론의 아키텍처와 장치·부팅 범위를 제목에 명시)

부팅 목차 · 이전: 진입 규약과 압축 해제 · 다음: 커널 초기화

BIOS에서 출발해 GRUB의 linux 명령으로 Linux v6.12 x86-64 bzImage를 부팅하는 경로에서는, GRUB이 32비트 보호 모드의 실행 환경을 만들어 압축 해제기의 startup_32에 제어를 넘긴다. 이 시점에 이미 GDT가 있어야 하지만, 그것이 이후 Linux가 계속 사용할 GDT라는 뜻은 아니다. Linux는 실행 모드의 전환, 압축 해제기의 이동, 커널 가상 주소로의 전환, CPU별 자료구조의 설치에 맞추어 GDTR을 여러 번 갱신한다. GRUB이 준비하는 초기 CPU 상태는 GRUB에서 커널로에서 이어진다.[1]

디스크립터와 선택자, 테이블 레지스터

디스크립터(descriptor)는 CPU가 세그먼트의 성질을 판단할 때 읽는 메모리상의 항목이다. 코드인지 데이터인지, 어느 특권 수준에서 접근할 수 있는지, 코드가 32비트인지 64비트인지 등을 담는다. GDT(Global Descriptor Table)는 그 항목들을 배열로 모은 것이다. GDT의 “global”은 LDT(Local Descriptor Table)와 대비되는 이름이지, 모든 CPU가 반드시 하나의 테이블을 공유한다는 뜻이 아니다.

선택자(selector)는 주소가 아니라 디스크립터를 선택하는 16비트 값이다. 상위 13비트는 인덱스, TI 비트는 GDT 또는 LDT의 선택, 하위 2비트는 요청 특권 수준인 RPL(Requested Privilege Level)이다. 예를 들어 x86-64 Linux의 __KERNEL_CS는 GDT 인덱스 2를 가리키는 0x10이다. 그 숫자만으로 64비트 코드가 되는 것은 아니며, 실제로 선택한 항목의 속성이 맞아야 한다.[2][3]

항목 무엇을 가리키는가 적재 명령의 의미
GDTR GDT의 선형 주소와 한계값 lgdt는 메모리의 base·limit 묶음을 읽는다. 테이블 자체를 새로 생성하지 않는다.
CS·DS 등 코드·데이터 디스크립터의 선택자와 내부에 캐시된 속성 세그먼트 레지스터를 적재하면 해당 항목의 속성을 다시 읽는다. CS를 바꾸려면 far jump/return 같은 제어 이동이 필요하다.
LDTR GDT 안의 LDT 디스크립터가 지정한 LDT lldt의 피연산자는 테이블 주소가 아니라 선택자다. null 선택자를 적재하면 사용할 LDT가 없는 상태가 된다.
TR(Task Register) GDT 안의 TSS 디스크립터가 지정한 TSS ltr도 선택자를 받는다. TSS(Task State Segment)는 코드·데이터 세그먼트의 배열이 아니라 스택 정보 등을 담는 별도 구조체다.

특히 lgdt만 실행한다고 현재 CS의 내부 속성이 새 GDT의 내용으로 즉시 바뀌지 않는다. 이 때문에 아래 코드에서는 GDT를 바꾼 뒤 데이터 세그먼트를 다시 적재하고, far return으로 CS도 다시 적재한다. “GDT를 만들었다”와 “CPU가 그 GDT의 항목을 사용한다”는 별개의 단계다.[2]

부트로더의 GDT에서 압축 해제기의 GDT로

32비트 부트 프로토콜은 진입 시 페이징이 꺼진 보호 모드, 비활성화된 인터럽트, GDT의 적절한 32비트 코드·데이터 세그먼트를 요구한다. CS는 __BOOT_CS=0x10, DS·ES·SS는 __BOOT_DS=0x18이며, 세그먼트는 base 0의 평탄한 주소 공간을 제공한다. %esi는 boot_params를 가리킨다. 이것이 GRUB에서 Linux로 넘어오는 순간의 계약이다.[1][3]

압축 해제기의 arch/x86/boot/compressed/head_64.S:startup_32는 먼저 실제 적재 주소를 계산한다. 그 주소를 이용해 자체 gdt의 base를 고친 뒤 lgdt를 실행한다. 이 GDT에는 32비트 커널 코드, 64비트 커널 코드, 데이터, 부트용 TSS 디스크립터가 들어 있다.[4]

다음은 이 교체를 보여 주는 원본의 일부다. rva()는 압축 해제기 시작점에 대한 상대 위치를 나타내며, 이 구간에서 %ebp는 실제 적재 위치를 반영한다.

/* arch/x86/boot/compressed/head_64.S:startup_32 */
leal    rva(gdt)(%ebp), %eax
movl    %eax, 2(%eax)
lgdt    (%eax)

이어서 __BOOT_DS를 DS·ES·FS·GS·SS에 넣고, 스택을 준비한다. __KERNEL32_CS와 다음 명령의 주소를 스택에 쌓은 뒤 lretl로 CS를 새 GDT의 32비트 코드 항목으로 바꾼다. 같은 숫자의 선택자라도 GDT 교체 전후에 의미하는 항목의 속성이 다를 수 있으므로, 단순히 “CS는 이미 설정되어 있다”고 넘어갈 수 없다.[4]

64비트 코드로 넘어갈 때 GDT가 하는 일

64비트 모드 진입은 GDT 하나만 바꾸는 일이 아니다. 압축 해제기는 PAE를 켜고 초기 페이지 테이블을 준비해 CR3에 넣으며, EFER의 LME 비트를 설정한다. 이어서 다음과 같이 LDTR을 비우고 부트용 TSS 선택자를 TR에 적재한다.[4]

/* arch/x86/boot/compressed/head_64.S:startup_32 */
xorl    %eax, %eax
lldt    %ax
movl    $__BOOT_TSS, %eax
ltr     %ax

여기서 lldt %ax는 빈 LDT 배열을 새로 만드는 코드가 아니다. 0인 선택자를 넣어 LDT를 사용하지 않도록 만드는 코드다. 부트용 TSS 역시 나중에 CPU별 예외 스택을 담을 정상 운영용 TSS와 구분해야 한다.

그 다음 __KERNEL_CS와 압축 해제기의 startup_64 주소를 스택에 놓고 CR0.PG를 켠다. 이때 페이징과 long mode가 활성화되어도 기존 CS의 속성은 여전히 32비트 코드이므로, 마지막 lret로 CS.L=1인 항목을 선택해야 64비트 명령 실행으로 넘어간다. 페이지 테이블은 주소 변환을 준비하고, 코드 디스크립터는 어느 실행 모드의 코드를 실행할지 결정한다는 역할 분담이다.[4][2]

GRUB handoff          startup_32                  startup_64
------------------    ------------------------    ---------------------
32-bit protected      load decompressor GDT       execute 64-bit code
paging off        --> reload DS/ES/SS and CS   --> reload GDT and CS
GRUB GDT              clear LDTR, load boot TR    relocate decompressor
                      enable PAE, LME, paging     repoint GDTR

압축 해제기의 startup_64는 gdt64를 이용해 GDTR을 다시 적재하고 lretq로 CS를 확인한다. 여기서 gdt64는 별개의 전체 테이블이 아니라, 같은 gdt를 가리킬 64비트 base를 담는 GDTR 적재용 구조다. 이후 압축 해제기를 다른 위치로 복사하면 GDTR도 복사된 GDT를 가리키도록 다시 바꾼다. 이전 위치의 GDT가 복사나 압축 해제 과정에서 덮일 수 있기 때문이다. 따라서 이 단계의 여러 lgdt는 단순한 중복이 아니라 모드와 메모리 생존 기간의 변화에 대응한다.[4]

커널 본체에서 CPU별 GDT를 설치한다

압축 해제기가 넘기는 다음 startup_64는 다른 파일의 다른 진입점이다. arch/x86/kernel/head_64.S:startup_64는 startup_64_setup_gdt_idt()를 호출한다. 이 함수는 초기 CPU용 gdt_page.gdt를 현재 접근 가능한 주소로 가리키는 GDTR 값을 만들고 native_load_gdt()로 적재한다. 이어 DS·SS·ES를 __KERNEL_DS로 다시 적재한다.[5][6]

본체의 공통 초기 경로에 있는 .Lsetup_gdt에서는 gdt_page와 CPU별 오프셋으로 GDT 주소를 구해 다시 lgdt한다. 이제 GDTR도 커널 주소 공간에서 계속 접근할 수 있는 주소를 가리켜야 한다. 초기에 접근 가능하던 낮은 주소에 GDT가 남아 있다면, 그 매핑을 제거한 뒤에는 새로운 디스크립터를 읽을 수 없기 때문이다.[5]

gdt_page는 arch/x86/kernel/cpu/common.c에서 DEFINE_PER_CPU_PAGE_ALIGNED로 선언된다. 32·64비트 커널 코드와 사용자 코드, 커널·사용자 데이터 항목 등은 정적 초기값을 가진다. “CPU별 GDT”라고 해서 처음 명령부터 동적으로 할당된 최종 CPU별 메모리를 쓰는 것은 아니다. 초기 부트 CPU용 영역에서 시작하고, start_kernel()의 setup_per_cpu_areas() 단계에서 정규 CPU별 영역을 준비한 뒤 부트 CPU도 switch_gdt_and_percpu_base()로 그 영역으로 옮겨 간다.[7][8]

이 순서 이후 trap_init()이 CPU entry area와 정상 운영용 TSS를 준비한다. 다음 도식은 부트 CPU의 관계를 요약한다. 전체 커널 초기화 순서는 커널 초기화에서 이어진다.[9][10]

Native kernel startup_64
  |
  +--> startup_64_setup_gdt_idt()   initial kernel GDT
  +--> .Lsetup_gdt                 kernel-address GDT
  |
start_kernel()
  |
  +--> setup_per_cpu_areas()       permanent per-CPU storage
  |      +--> switch_gdt_and_percpu_base()
  |
  +--> trap_init()
         +--> setup_cpu_entry_areas()
         +--> cpu_init_exception_handling(true)
         |      +--> set_tss_desc()
         |      +--> load_TR_desc()             LTR
         +--> cpu_init()
                +--> load_mm_ldt(&init_mm)      null LDTR
                +--> load_fixmap_gdt(cpu)       LGDT

정상 운영용 TSS와 LDT는 서로 다르게 초기화된다

TSS: 예외 진입에 필요한 CPU별 상태

64비트 Linux는 태스크를 바꿀 때마다 x86의 하드웨어 태스크 전환을 사용하는 방식이 아니다. 그렇다고 TSS가 없어지는 것도 아니다. IDT를 통한 처리 진입에서 TSS는 특권 전환용 스택과 IST(Interrupt Stack Table) 정보를 제공한다. 예를 들어 이중 오류나 NMI를 현재 실행 중인 코드의 불안정한 스택과 분리된 스택으로 받는 데 쓰인다. 이는 보호에서 다루는 “사용자 코드의 상태를 그대로 믿지 않고 커널로 들어오기”의 구체적인 장치다.[2][7]

v6.12에서는 trap_init()가 먼저 setup_cpu_entry_areas()를 부른다. 이어 cpu_init_exception_handling(true)가 CPU별 cpu_tss_rw의 IST 및 I/O 비트맵 상태를 준비하고, set_tss_desc()로 GDT의 TSS 디스크립터를 설정한다. 마지막으로 load_TR_desc()가 실제 ltr를 수행한다. 즉 TSS 메모리 준비 → GDT에 그 주소 기록 → TR 적재의 세 단계다.[10][7][11]

그 뒤의 cpu_init()는 load_sp0()로 진입 스택을 지정하고, 마지막에 load_fixmap_gdt(cpu)를 통해 CPU별 GDT의 읽기 전용 매핑을 GDTR에 적재한다. 쓰기용 원본과 CPU가 읽는 매핑은 서로 다른 GDT 내용을 만드는 것이 아니라 같은 테이블을 다른 주소·권한으로 접근하는 것이다.[7][11][12]

LDT: 기본 부팅에서는 만들지 않는다

LDT는 “각 프로세스마다 반드시 있어야 하는 GDT의 짝”이 아니다. 기본 부팅의 init_mm에는 사용자 LDT가 없으므로, cpu_init()의 load_mm_ldt(&init_mm)는 clear_LDT() 경로로 가고, 최종적으로 0 선택자를 lldt한다. 앞서 압축 해제기에서 LDTR을 비운 것과 일관되게, 사용하지 않는 상태를 명시적으로 만든다.[7][13][11]

이후 CONFIG_MODIFY_LDT_SYSCALL이 활성화되어 있고 사용자 프로그램이 modify_ldt()를 요청하면, 해당 mm의 mm->context.ldt에 실제 LDT가 생길 수 있다. 그때는 GDT의 LDT 디스크립터에 테이블의 주소·크기를 기록하고, 그 항목의 선택자를 LDTR에 적재한다. 이것은 호환성 등의 목적으로 사용자 실행 문맥을 설정하는 경로이며, 64비트 커널을 부팅시키기 위해 모든 프로세스의 LDT를 미리 만드는 단계가 아니다.[13][11][14]

long mode에서도 GDT가 남는 이유

64비트 코드의 일반적인 주소 계산에서는 CS·DS·ES·SS의 base가 0으로 취급되고 전통적인 세그먼트 limit 검사도 대부분 적용되지 않는다. 프로세스별 주소 공간과 페이지 단위 접근 제어의 중심은 페이지 테이블이다. 그러나 코드의 실행 모드와 특권 수준, 사용자 모드로 돌아갈 때의 선택자 검사, TSS와 LDT 같은 시스템 디스크립터까지 없어진 것은 아니다. FS·GS base도 별도 의미를 유지한다. 따라서 “64비트에서는 세그멘테이션의 역할이 축소된다”는 맞지만, “GDT도 필요 없다”는 틀리다. 주소 변환 자체는 가상 메모리, 특권 수준과 진입·복귀는 보호와 구분해 읽는다.[2][7]

참고 문헌

  1. ↑ 1.0 1.1 Linux v6.12, The Linux/x86 Boot Protocol, 32-bit Boot Protocol.
  2. ↑ 2.0 2.1 2.2 2.3 2.4 Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 3A, System Architecture Overview, Protected-Mode Memory Management, Task Management. GDTR·LDTR·TR, 세그먼트 선택자와 IA-32e 모드의 테이블을 설명한다.
  3. ↑ 3.0 3.1 Linux v6.12, arch/x86/include/asm/segment.h.
  4. ↑ 4.0 4.1 4.2 4.3 4.4 Linux v6.12, arch/x86/boot/compressed/head_64.S, startup_32, startup_64, gdt, gdt64.
  5. ↑ 5.0 5.1 Linux v6.12, arch/x86/kernel/head_64.S, startup_64와 .Lsetup_gdt.
  6. ↑ Linux v6.12, arch/x86/kernel/head64.c, startup_64_setup_gdt_idt().
  7. ↑ 7.0 7.1 7.2 7.3 7.4 7.5 Linux v6.12, arch/x86/kernel/cpu/common.c, gdt_page, cpu_init_exception_handling(), cpu_init(), load_fixmap_gdt().
  8. ↑ Linux v6.12, arch/x86/kernel/setup_percpu.c, setup_per_cpu_areas().
  9. ↑ Linux v6.12, init/main.c, start_kernel().
  10. ↑ 10.0 10.1 Linux v6.12, arch/x86/kernel/traps.c, trap_init().
  11. ↑ 11.0 11.1 11.2 11.3 Linux v6.12, arch/x86/include/asm/desc.h, native_load_gdt(), native_load_tr_desc(), native_set_ldt(), clear_LDT().
  12. ↑ Linux v6.12, arch/x86/mm/cpu_entry_area.c, setup_cpu_entry_area().
  13. ↑ 13.0 13.1 Linux v6.12, arch/x86/kernel/ldt.c, load_mm_ldt(), switch_ldt(), modify_ldt 시스템 콜.
  14. ↑ Linux v6.12, arch/x86/include/asm/mmu_context.h, CONFIG_MODIFY_LDT_SYSCALL과 load_mm_ldt().