1P by GN⁺ | ★ favorite | 댓글 1개
  • 512바이트 부트 섹터에서 시작해 x86_64 CPU를 16비트 real mode에서 64비트 long mode로 올리는 최소 부트로더 흐름을 단계별로 구성함
  • BIOS 인터럽트로 출력부터 확인한 뒤 nasm, ld, objcopy, QEMU를 연결해 부트 이미지가 실제로 실행되는지 검증함
  • 부트 섹터의 용량 한계 때문에 stage 1/stage 2로 나누고, protected mode 진입 전에 BIOS int 0x13으로 다음 코드를 디스크에서 읽어옴
  • 32비트 protected mode부터는 BIOS 루틴을 쓸 수 없어 GDT, flat segmentation, VGA 버퍼 직접 출력 같은 하드웨어 초기화가 필요함
  • 64비트 long mode 진입은 페이지 테이블, PAE, EFER.LME, cr0.PG, 64비트용 GDT를 모두 맞춰야 하며, 이후 freestanding C 코드를 커널처럼 호출할 수 있음

BIOS에서 시작되는 16비트 환경

  • x86 CPU는 reset 이후 real mode에 있으며, 기본 피연산자 크기는 16비트임
  • real mode는 segmentation으로 20비트 주소 공간을 만들고, 최대 1MB 메모리를 다룰 수 있음
  • BIOS가 처음 실행하는 코드는 디스크의 부트 섹터에 들어 있음
    • BIOS는 첫 섹터가 0xaa55 매직 넘버로 끝나는 디스크를 찾음
    • 해당 섹터를 메모리 주소 0x7c00에 로드함
  • BIOS가 넘겨주는 공간은 512바이트뿐이어서, 이 코드는 나머지 부트로더를 불러오는 bootstrap 역할에 집중함
  • BIOS 루틴은 real mode에 머무는 동안만 사용할 수 있음

준비물과 빌드 환경

부트 섹터 만들고 실행 확인하기

  • 첫 부트 섹터는 BIOS 루틴으로 "Hello, world!"를 출력한 뒤 hlt와 루프로 멈춤
  • 문자열 출력에는 BIOS video services 호출인 int 0x10ah = 0x0e를 사용함
  • Makefile은 nasm으로 object를 만들고, linker script로 링크한 뒤 objcopy -O binary로 raw 부트 이미지를 생성함
  • make boot는 QEMU로 이미지를 실행함
    • qemu-system-x86_64 -no-reboot -drive file=$<,format=raw,index=0,media=disk
  • linker script는 부트 섹터가 0x7c00 기준으로 배치되도록 설정함
    • MEMORYboot_sector origin은 0x7c00, length는 512
    • .bootsign 섹션은 0x7c00 + 510 위치에 0x55, 0xaa를 추가함
  • 부트 섹터 assembly 안에서 offset과 magic number를 직접 처리할 수도 있지만, 여기서는 linker script가 그 역할을 맡음

stage 1이 stage 2를 디스크에서 읽어오기

  • stage 1은 BIOS가 로드하는 부트 섹터 코드이고, 목표는 stage 2를 메모리로 불러오는 것임
  • stage 2는 16비트 real mode에서 32비트 protected mode로 넘어가는 코드를 담음
  • protected mode에 들어간 뒤에는 BIOS 루틴을 사용할 수 없으므로, 디스크 섹터 읽기는 전환 전에 끝내야 함
  • 디스크 접근에는 BIOS disk services int 0x13을 사용함
    • ah = 0x42는 BIOS extended read 기능
    • dl = 0x80은 드라이브 번호
    • disk address packet에는 읽을 섹터 수, 대상 주소, 시작 섹터가 들어감
  • 예제는 READ_SECTORS_NUM equ 64로 64개 섹터를 읽음
    • 부트 섹터는 sector 0이므로 stage 2는 sector 1부터 읽음
    • 대상 주소는 BOOT_LOAD_ADDR + SECTOR_SIZE, 즉 0x7c00 + 512
  • 코드에는 요청한 것보다 적은 섹터가 읽히는 경우를 받아들이는 임시 처리가 남아 있음
  • stage 2는 처음에 real mode용 print_string을 복사해 "Hello from stage 2"를 출력하고, stage 1에서 점프가 제대로 이어졌는지 확인함

32비트 protected mode로 전환하기

  • protected mode로 들어가려면 먼저 Global Descriptor Table(GDT) 을 정의해야 함
  • protected mode에서는 기본적으로 segmentation이 메모리 보호에 쓰임
  • 64비트 long mode에서는 paging이 필요하지만, 그 전 단계인 protected mode 진입에는 segmentation 설정이 먼저 필요함
  • 예제 GDT는 Intel manual의 flat model을 따름
    • code segment와 data segment를 둠
    • 두 segment는 전체 linear address space에 매핑됨
    • long mode로 가기 위한 중간 단계라 가장 단순한 모델을 사용함
  • GDT는 메모리에 놓인 연속 구조체임
    • 시작에는 invalid translation을 잡기 위한 null descriptor가 있음
    • 그 뒤에 code segment descriptor와 data segment descriptor가 이어짐
  • 전환은 다음 순서로 진행됨
    • cli로 interrupt를 비활성화함
    • lgdt [gdt32_pseudo_descriptor]로 GDTR에 GDT 주소와 길이를 로드함
    • cr0.PE, 즉 cr0의 bit 0을 설정해 protected mode를 켬
    • far jump로 instruction pipeline을 비우고 cs를 새 code segment로 갱신함
  • protected mode 진입 후 기존 segment 값은 더 이상 의미가 없으므로 ds, ss, es, fs, gs를 새 data segment selector로 설정함
  • 모든 설정 뒤 interrupt를 다시 켜려면 추가 작업이 필요함

BIOS 없이 화면에 출력하기

  • protected mode에서는 BIOS 루틴을 더 이상 호출할 수 없음
  • 문자열 출력은 VGA buffer에 직접 쓰는 방식으로 바뀜
  • print_string320xb8000 주소에 문자와 색상 바이트를 기록함
    • 색상 값은 0xf
    • 각 문자 셀은 2바이트를 사용함
  • 이 출력 함수는 매우 단순해서 메시지는 항상 화면 왼쪽 위에 표시됨

long mode 진입을 위한 페이지 테이블

  • Intel 문서의 IA-32e mode는 AMD64 manual의 long mode에 해당함
  • long mode로 전환하려면 CPU가 protected mode에 있어야 하고, paging도 활성화되어야 함
  • paging 개념은 Introduction to PagingOSTEP를 참고함
  • PAE가 활성화된 long mode에서는 4단계 페이지 테이블을 사용함
  • build_page_table은 지정된 주소에서 4단계 페이지 테이블을 생성함
    • page 크기는 0x1000
    • 각 page table 크기는 0x1000
    • entry 수는 512
    • 먼저 네 개의 table을 0으로 초기화해 모든 entry를 not present 상태로 둠
    • PML4 → PDP → PD → page table의 첫 entry들을 연결함
    • 가장 낮은 page table layer에서 512개 entry를 설정함

64비트용 GDT와 long mode 전환 순서

  • paging이 virtual address space와 권한 관리를 맡지만, long mode에서도 GDT는 필요함
  • 64비트용 GDT도 flat model을 따르며, protected mode용 GDT와 거의 같음
  • 차이는 long mode 관련 bit 설정에 있음
    • code segment의 64-bit code segment flag를 설정함
    • 이 flag가 설정되면 default operation size bit는 0이어야 함
  • long mode 전환은 다음 흐름으로 이어짐
    • 0x1000 주소에 4단계 page table을 생성함
    • cr3에 PML4 table 주소를 넣음
    • cr4의 bit 5를 설정해 PAE를 활성화함
    • 0xc0000080 MSR을 읽고 EFER.LME, 즉 bit 8을 설정함
    • cr0의 bit 31인 PG flag를 설정해 paging을 활성화함
    • 64비트용 GDT를 lgdt로 로드함
    • 64비트 code segment로 far jump해 64비트 모드에 들어감
  • paging을 켠 직후에는 IA-32e compatibility mode 상태이며, 64비트 segment flag가 설정된 GDT로 점프하면서 64비트 모드로 전환됨
  • 성공 확인 메시지는 VGA buffer를 통해 화면 왼쪽 위에 표시됨

freestanding C 코드 호출하기

  • 64비트 long mode까지 진입하면 freestanding C 코드를 호출할 수 있음
  • kernel.c는 VGA buffer 0xb8000을 지우고 "Hello from C"를 출력함
  • assembly 쪽 start_long_mode는 64비트용 문자열 출력 뒤 extern _start_kernel을 선언하고 _start_kernel을 호출함
  • linker script는 메모리 영역을 세 부분으로 나눔
    • boot_sector: 0x7c00, length 512
    • stage2: 0x7e00, length 512
    • kernel: 0x8000, length 0x10000
  • .text, .data, .rodata, .bss 섹션은 kernel 영역에 배치됨
  • Makefile은 assembly와 C를 모두 빌드하도록 바뀜
    • C compiler는 gcc
    • 주요 CFLAGS는 -std=c99 -ffreestanding -m64 -mno-red-zone -fno-builtin -nostdinc -Wall -Wextra
  • 전체 예제 코드는 다운로드 링크로 제공됨

댓글과 토론

Hacker News 의견들
  • 보호 모드를 거치지 않고도 훨씬 적은 코드로 바로 롱 모드에 들어갈 수 있음: https://wiki.osdev.org/Entering_Long_Mode_Directly
    이 방식으로 만든 작은 64비트 커널용 부트로더가 있었고, 디스크에서 커널을 읽고 VESA 모드를 설정하는 코드까지 포함해도 부트 섹터에 넉넉히 들어갔음. 2단계 로더도 필요 없었음

    • 그걸 어떻게 512바이트 안에 다 넣는지 궁금함. 커널을 디스크 어디에나 일반 파일처럼 둘 수 있는 실제 파일 시스템은 없을 것 같은데, 파일 조각화만 처리해도 512바이트를 훌쩍 넘길 듯함
    • 그냥 https://limine-bootloader.org/를 쓰면 훨씬 단순해짐. 실 모드를 건드릴 필요가 없고, SMP에서도 마찬가지이며, 커널을 higher-half 매핑으로 자동 로드하고 aarch64와 riscv64에서도 동작함
    • 맞는 말이지만, 파티션 테이블까지 넣어 현대적인 AHCI 컨트롤러와 SATA를 지원하려면 부트로더 공간이 더 줄어서 최적화가 필요해짐. 이 경우 로더에 510바이트를 다 쓸 수 없고 훨씬 적어지며, 유효한 파티션 엔트리까지 채우려면 테이블 내부 바이트도 못 써서 더 까다로움
      실제 현대식 하드디스크를 쓰려면 MBR보다 GPT를 보는 편이 좋음. 파티션 테이블 한계를 넘지 않고 2TB 이상 큰 디스크도 다룰 수 있음. UEFI는 이런 문제를 없애고 별다른 어려움 없이 제대로 된 디스크 레이아웃을 쓰게 해줌
      64비트 모드로 들어가려면 보호 모드는 필요 없음. 다만 BIOS는 쓰지 않는 게 좋음. 지저분하고 일을 더 번거롭게 만들 뿐임
      UEFI를 EDK2나 GnuEFI로 쓰는 방식이 낫고, 둘 다 구현하기 꽤 쉽고 편함. UEFI의 초기 개념에 익숙해지는 데는 조금 걸리지만, GitHub의 예제 프로젝트를 보면 구조를 쉽게 파악할 수 있음. EDK는 .dec와 .inf 파일 등이 좀 별로이고, GnuEFI는 헤더 파일을 읽어 기능을 찾아야 하지만, 명세가 불분명한 BIOS 인터페이스보다는 훨씬 낫다. 실제 하드웨어에서는 int 0x10, int 0x15 등이 제대로 있다고 가정조차 못 함
      UEFI 시스템에서는 안정적인 최소 기반을 가정할 수 있고, 하드웨어나 플랫폼 기능도 sane하게 열거할 수 있음. 또한 UEFI가 플랫폼을 이미 꽤 설정해 두므로 OS 로더 컴포넌트에서 초기화를 많이 할 필요 없이 바로 OS, 드라이버, 커널 설계에 맞는 구성요소를 로드하면 됨. 메모리 맵을 얻고, EFI 파일 시스템에 접근해 필요한 것들을 읽어오면 됨
    • 이런 게 가능한 줄 몰랐음. 단순히 롱 모드로 가려는 목적이라면 애초에 왜 보호 모드를 거쳐야 하는지 궁금함
  • 80286에는 16비트 레지스터인 Machine Status Word(MSW)가 있고, 80386은 이를 32비트 레지스터 CR0로 확장했음. 이후 64비트 롱 모드는 EFER MSR을 추가하고 CR0를 64비트로 확장했지만, 오늘날에도 CR0에서는 11비트만 쓰이고 EFER도 활성 비트가 8개뿐임
    Intel/AMD가 기존 레지스터의 남는 비트를 그냥 쓰지 않고, 왜 두 번이나 새 선택을 했는지 궁금함: https://wiki.osdev.org/CPU_Registers_x86-64#CR0

    • 아마 하위 호환성을 더 견고하게 하려는 이유일 가능성이 큼. 소프트웨어가 예약 비트의 값을 가정하거나 거기에 값을 쓸 수 있기 때문임. 이런 하드웨어 레지스터의 비트 배정은 꽤 임의적이고, 상위 비트를 쓰는 데 별 비용도 없음
    • 한 단어로 답하면 아마 관료주의일 듯함. 큰 조직은 전반적으로 특히 좋은 결정을 잘 내리지 못하고, 말이 안 되는 선택도 많이 생김
      CR1과 CR5~CR7이 여전히 예약 상태인데 CR8이 생긴 이유도 비슷해 보임
  • 이 글에서 가장 불필요하게 복잡해 보이는 건 Makefile과 링커 스크립트임. NASM은 평면 바이너리 출력을 지원하는데, 그걸 쓰면 너무 “해키”하다고 본 모양임

    • 개인적으로는 평면 NASM보다 링커 스크립트가 훨씬 읽기 쉽고 추론하기 쉽다고 봄. 특히 소스 파일이 여러 개일 때 그렇다
    • 완전히 맞는 말임. 나중에는 Makefile과 링커 스크립트가 중요한 골칫거리가 되지만, 평면 바이너리를 만들 거면 그냥 평면 바이너리를 만들면 됨. 굳이 부풀릴 필요 없음
      예전에 내 OS에는 이걸 놀리려고 make.sh라는 파일이 있었음. 지금은 ‘파일 형식’ 같은 멋진 것들을 쓰고 있어서 -fbin과 --oformat=binary는 덧없는 추억이 됨. 데이터 C 파일과 코드 C 파일을 나눠 바이너리로 덤프한 뒤 거기서 괴물을 조립하려고 오래 시도했지만, 링크와 로딩이 너무 어려워짐. 그냥 ELF나 PE를 쓰는 편이 낫고, 실제로 그 형식들이 그런 일을 해 주는 것 같음
  • 멋지고 좋은 연습처럼 보이지만, 유용한지는 모르겠음. 실행 중 설정을 확인하거나 바꿀 수 있는 Fisher-Price 장난감 같은 UX라도 있는지 궁금함
    부팅은 미니미 모드, 단일 사용자 모드, 복구 모드에서 날아오르는 상태로 가는 과정임
    Xenix/DOS 시절부터 Microsoft 제품과 함께 Unix를 써 왔고, 대략 40년쯤 된 듯함. 그동안 얼마나 발전했는지 의문임
    Linux도 스웨덴 버전, 즉 첫 릴리스 때부터 썼고 GNU 0.1도 써 봄
    Xenix를 Unix라고 부른 건 사과함. Xenix는 출시 직후부터 쇠락할 때까지 이미 과거의 것이 되고 싶어 하던 me-too 성격의 엉망진창 제품이었음
    Microsoft는 제품을 출시하는 게 아니라 고객 위에 고양이 화장실을 비우는 회사 같음. 최근 예시는 Copilot과 22H2임
    F1 자동차, 연필, 포켓 계산기가 어떻게 발전했는지 보면, 우리는 사용 가능한 이상형에 얼마나 가까운지 궁금함
    부트로더가 정적 커널 모드가 아닌지도 의문임. 예전에는 그랬고, 최근 누군가 다시 그래야 한다고 제안했는데 나도 동의했음

  • https://wiki.osdev.org/A20_Line

  • CPU를 올바른 모드로 전환하기 위해 필요한 단계들이 전부 불필요해 보여서 놀라움. 대부분 하위 호환성 때문에 필요한 절차처럼 보임
    Intel이 처음부터 올바른 모드로 시작하는 플래그나 명령을 제공할 수는 없었는지, 아니면 하위 호환성을 제거할 수는 없었는지 궁금함
    ARM64도 비슷한 문제가 일부 있었던 것으로 기억함. 애초에 64비트로 설계되어 하위 호환성이 필요 없고 기본적으로 원하는 상태로 들어가는 CPU가 있는지 궁금함. Itanium의 목표나 설계가 그런 것이었나 싶음

    • Intel이 제안한 X86S가 그런 목적임

      X86S is a legacy-reduced-OS ISA that removes outdated execution modes and operating system ISA.
      The presence of the X86S ISA is enumerated by a single, main CPUID feature LEGACY_REDUCED_ISA in CPUID 7.1.ECX[2] which implies all the ISA removals described in this document. A new, 64-bit “start-up” interprocessor interrupt (SIPI) has a separate CPUID feature flag.
      [0] https://cdrdv2.intel.com/v1/dl/getContent/776648 [PDF 주의]

    • Intel은 80376으로 그걸 시도했지만 잘 안 됐음: https://en.wikipedia.org/wiki/Intel_80376
      Itanium, 즉 Itanic도 마찬가지였음
      하위 호환성이야말로 ARM, MIPS, RISC-V 등이 아니라 x86을 선택하는 핵심 이유임. 안타깝게도 Intel과 AMD의 일부 사람들은 이걸 잘 모르는 것 같음
    • UEFI가 이미 있음. 파티션의 폴더에 Windows 비슷한 바이너리를 넣으면 64비트 모드의 호스팅 환경에서 실행됨. 물론 이런 일을 대신 처리해 주는 부트로더도 수없이 많음
    • arm64에는 뭐가 문제인지 모르겠음
  • 훌륭한 프로젝트임. 여기서 UEFI 지지자들이 왜 굳이 새 부트로더 방식을 만들었는지 의아해한다면, 사람들이 이런 작업을 하는 이유를 놓치고 있는 것 같음
    글쓴이가 마지막에 쓴 “여기까지 따라왔다면 멋지다”는 말 그대로, 정말 멋짐

  • UEFI가 나온 지 얼마나 됐나 싶음. 롱 모드와 함께 BIOS도 폐기했으면 좋았을 텐데 아쉬움

    • BIOS는 이미 폐기 예정임. 새 메인보드에서 그 기능은 기본적으로 UEFI를 통해 에뮬레이션되고 있으며, 확장되고 있지도 않음
      폐기 예정이라는 말은 삭제됐다는 뜻이 아니라, 제거를 목표로 더 이상 갱신하거나 개발하지 않는다는 뜻임
  • 이 부팅 절차가 EFI/UEFI에서도 동작하는지 궁금함. 동작한다면 UEFI 감독자가 실 모드, 보호 모드, 롱 모드 전환을 에뮬레이션하는지, 아니면 실제 하드웨어에서 수행하는지도 궁금함

    • 아님. UEFI 펌웨어는 레거시 BIOS 환경, 즉 실주소 모드와 완전히 다른 환경을 UEFI 부트로더에 제공함. 현대 시스템의 UEFI 펌웨어는 곧바로 64비트 롱 모드로 들어가며, 평면 메모리 모델 GDT와 항등 매핑 페이징도 설정함
      취미 OS용 UEFI 부트로더를 만드는 과정을 여기 써 둠: https://0xc0ffee.netlify.app/osdev/05-bootloader-p1.html
  • 이게 ARM에서는 더 단순한지 궁금함

    • 보드 제조사마다 자기 마음대로 하는 부분이 있다는 의미에서는 단순함. 보드 제조사에게는 단순하지만, 나머지 모두에게는 끔찍하게 복잡해짐
    • 맞음. 부트로더는 여전히 복잡하지만 필요한 레거시 설정은 더 적음. 다만 BIOS 대신 UEFI를 목표로 하면 x86에서도 훨씬 단순해짐
    • 확신은 못 하겠고 기대하지는 않음. 지금 RISC-V를 깊게 파고 있는데, 거기에는 희망이 있어 보임