1 điểm bởi GN⁺ 2024-07-16 | 1 bình luận | Chia sẻ qua WhatsApp
  • Bắt đầu từ boot sector 512 byte, bài viết xây dựng từng bước luồng bootloader tối thiểu để đưa CPU x86_64 từ real mode 16-bit lên long mode 64-bit
  • Sau khi xác nhận được phần xuất cơ bản bằng BIOS interrupt, bài viết nối nasm, ld, objcopy, QEMU để kiểm tra xem ảnh boot có thực sự chạy được hay không
  • Do giới hạn dung lượng của boot sector, mã được chia thành stage 1/stage 2; trước khi vào protected mode, BIOS int 0x13 sẽ đọc phần mã tiếp theo từ đĩa
  • Từ protected mode 32-bit trở đi không thể dùng BIOS routine nữa, nên cần khởi tạo phần cứng như GDT, flat segmentation và xuất trực tiếp ra VGA buffer
  • Để vào long mode 64-bit, cần cấu hình đầy đủ page table, PAE, EFER.LME, cr0.PG và GDT cho 64-bit; sau đó có thể gọi mã C freestanding như gọi một kernel

Môi trường 16-bit khởi đầu từ BIOS

  • CPU x86 sau khi reset sẽ ở real mode, và kích thước toán hạng mặc định là 16-bit
  • real mode dùng segmentation để tạo không gian địa chỉ 20-bit, có thể xử lý tối đa 1MB bộ nhớ
  • Mã đầu tiên BIOS chạy nằm trong boot sector của đĩa
    • BIOS tìm đĩa có sector đầu tiên kết thúc bằng magic number 0xaa55
    • BIOS nạp sector đó vào địa chỉ bộ nhớ 0x7c00
  • Không gian BIOS chuyển giao chỉ có 512 byte, nên đoạn mã này tập trung vào vai trò bootstrap để nạp phần còn lại của bootloader
  • BIOS routine chỉ dùng được khi còn ở real mode

Chuẩn bị và môi trường build

Tạo boot sector và kiểm tra chạy thực tế

  • Boot sector đầu tiên sẽ in "Hello, world!" bằng BIOS routine, rồi dừng bằng hlt và một vòng lặp
  • Để in chuỗi, dùng BIOS video services int 0x10 với ah = 0x0e
  • Makefile tạo object bằng nasm, link bằng linker script, rồi dùng objcopy -O binary để sinh ảnh boot raw
  • make boot chạy ảnh bằng QEMU
    • qemu-system-x86_64 -no-reboot -drive file=$<,format=raw,index=0,media=disk
  • Linker script được cấu hình để boot sector được đặt theo mốc 0x7c00
    • boot_sector trong MEMORY có origin là 0x7c00, length là 512
    • Section .bootsign thêm 0x55, 0xaa tại vị trí 0x7c00 + 510
  • Có thể tự xử lý offset và magic number ngay trong assembly của boot sector, nhưng ở đây linker script đảm nhiệm việc đó

stage 1 đọc stage 2 từ đĩa

  • stage 1 là mã boot sector được BIOS nạp, với mục tiêu là đưa stage 2 vào bộ nhớ
  • stage 2 chứa phần mã chuyển từ real mode 16-bit sang protected mode 32-bit
  • Sau khi đã vào protected mode thì không thể dùng BIOS routine nữa, nên việc đọc sector từ đĩa phải hoàn tất trước khi chuyển chế độ
  • Truy cập đĩa dùng BIOS disk services int 0x13
    • ah = 0x42 là chức năng BIOS extended read
    • dl = 0x80 là số hiệu ổ đĩa
    • disk address packet chứa số sector cần đọc, địa chỉ đích và sector bắt đầu
  • Ví dụ dùng READ_SECTORS_NUM equ 64 để đọc 64 sector
    • boot sector là sector 0 nên stage 2 được đọc từ sector 1
    • địa chỉ đích là BOOT_LOAD_ADDR + SECTOR_SIZE, tức 0x7c00 + 512
  • Trong mã vẫn còn xử lý tạm thời chấp nhận trường hợp số sector đọc được ít hơn yêu cầu
  • Ban đầu stage 2 sao chép print_string cho real mode để in "Hello from stage 2", nhằm xác nhận cú nhảy từ stage 1 đã nối tiếp đúng cách

Chuyển sang protected mode 32-bit

  • Để vào protected mode, trước tiên phải định nghĩa Global Descriptor Table (GDT)
  • Trong protected mode, segmentation mặc định được dùng cho bảo vệ bộ nhớ
  • Ở long mode 64-bit sẽ cần paging, nhưng để vào protected mode thì bước đầu tiên vẫn là thiết lập segmentation
  • GDT trong ví dụ tuân theo flat model trong tài liệu Intel
    • có code segment và data segment
    • cả hai segment đều ánh xạ toàn bộ linear address space
    • vì đây là bước trung gian để tiến tới long mode nên dùng mô hình đơn giản nhất
  • GDT là một cấu trúc liên tiếp nằm trong bộ nhớ
    • ở đầu có null descriptor để bắt lỗi invalid translation
    • tiếp theo là code segment descriptor và data segment descriptor
  • Quá trình chuyển diễn ra theo thứ tự sau
    • dùng cli để vô hiệu hóa interrupt
    • dùng lgdt [gdt32_pseudo_descriptor] để nạp địa chỉ và độ dài GDT vào GDTR
    • đặt cr0.PE, tức bit 0 của cr0, để bật protected mode
    • thực hiện far jump để xóa instruction pipeline và cập nhật cs sang code segment mới
  • Sau khi vào protected mode, các giá trị segment cũ không còn ý nghĩa, nên ds, ss, es, fs, gs phải được đặt thành selector của data segment mới
  • Muốn bật lại interrupt sau khi hoàn tất mọi thiết lập thì cần thêm các bước khác

Xuất ra màn hình mà không dùng BIOS

  • Trong protected mode không thể tiếp tục gọi BIOS routine
  • Việc in chuỗi chuyển sang cách ghi trực tiếp vào VGA buffer
  • print_string32 ghi byte ký tự và byte màu vào địa chỉ 0xb8000
    • giá trị màu là 0xf
    • mỗi ô ký tự dùng 2 byte
  • Hàm xuất này rất đơn giản, nên thông điệp luôn xuất hiện ở góc trên bên trái màn hình

Page table để vào long mode

  • IA-32e mode trong tài liệu Intel tương ứng với long mode trong manual của AMD64
  • Muốn chuyển sang long mode thì CPU phải đang ở protected mode và paging cũng phải được bật
  • Có thể tham khảo khái niệm paging tại Introduction to PagingOSTEP
  • Long mode với PAE bật sử dụng page table 4 cấp
  • build_page_table tạo page table 4 cấp tại địa chỉ được chỉ định
    • kích thước page là 0x1000
    • kích thước mỗi page table là 0x1000
    • số entry là 512
    • trước hết khởi tạo bốn table về 0 để mọi entry đều ở trạng thái not present
    • nối các entry đầu tiên theo chuỗi PML4 → PDP → PD → page table
    • ở lớp page table thấp nhất, thiết lập 512 entry

GDT cho 64-bit và thứ tự chuyển sang long mode

  • Dù paging đảm nhiệm không gian địa chỉ ảo và quản lý quyền truy cập, GDT vẫn cần trong long mode
  • GDT cho 64-bit cũng theo flat model và gần như giống với GDT cho protected mode
  • Khác biệt nằm ở các bit liên quan đến long mode
    • đặt cờ 64-bit code segment cho code segment
    • khi cờ này được đặt, bit kích thước thao tác mặc định phải là 0
  • Việc chuyển sang long mode đi theo luồng sau
    • tạo page table 4 cấp tại địa chỉ 0x1000
    • ghi địa chỉ PML4 table vào cr3
    • đặt bit 5 của cr4 để bật PAE
    • đọc MSR 0xc0000080 rồi đặt EFER.LME, tức bit 8
    • đặt cờ PG là bit 31 của cr0 để bật paging
    • nạp GDT cho 64-bit bằng lgdt
    • far jump sang code segment 64-bit để vào chế độ 64-bit
  • Ngay sau khi bật paging, CPU đang ở trạng thái IA-32e compatibility mode; việc nhảy sang GDT có cờ segment 64-bit sẽ hoàn tất chuyển đổi sang chế độ 64-bit
  • Thông báo xác nhận thành công được hiển thị ở góc trên bên trái màn hình thông qua VGA buffer

Gọi mã C freestanding

  • Khi đã vào long mode 64-bit, có thể gọi mã C freestanding
  • kernel.c xóa VGA buffer 0xb8000 rồi in "Hello from C"
  • Ở phía assembly, start_long_mode sau khi in chuỗi 64-bit sẽ khai báo extern _start_kernel rồi gọi _start_kernel
  • Linker script chia vùng nhớ thành ba phần
    • boot_sector: 0x7c00, length 512
    • stage2: 0x7e00, length 512
    • kernel: 0x8000, length 0x10000
  • Các section .text, .data, .rodata, .bss được đặt trong vùng kernel
  • Makefile được thay đổi để build cả assembly lẫn C
    • C compiler là gcc
    • các CFLAGS chính là -std=c99 -ffreestanding -m64 -mno-red-zone -fno-builtin -nostdinc -Wall -Wextra
  • Toàn bộ mã ví dụ được cung cấp tại liên kết tải về

1 bình luận

 
GN⁺ 2024-07-16
Ý kiến trên Hacker News
  • Có thể vào thẳng long mode với ít mã hơn nhiều mà không cần đi qua protected mode: https://wiki.osdev.org/Entering_Long_Mode_Directly
    Từng có một bootloader cho một kernel 64-bit nhỏ được làm theo cách này, và kể cả bao gồm cả mã đọc kernel từ đĩa và thiết lập chế độ VESA, nó vẫn dư chỗ trong boot sector. Cũng không cần loader giai đoạn 2

    • Tôi tò mò làm sao nhét được tất cả vào 512 byte. Có lẽ sẽ không có hệ thống tệp thực thụ nào cho phép đặt kernel ở bất cứ đâu trên đĩa như một tệp thông thường; chỉ xử lý phân mảnh tệp thôi chắc đã vượt xa 512 byte
    • Cứ dùng https://limine-bootloader.org/ thì đơn giản hơn nhiều. Không cần đụng tới real mode, với SMP cũng vậy; nó tự động tải kernel bằng ánh xạ higher-half và cũng chạy trên aarch64 và riscv64
    • Nói vậy đúng, nhưng nếu muốn nhét cả bảng phân vùng để hỗ trợ các bộ điều khiển AHCI hiện đại và SATA, không gian bootloader sẽ còn ít hơn nên cần tối ưu. Trường hợp này loader không thể dùng trọn 510 byte mà ít hơn nhiều; nếu còn phải điền các entry phân vùng hợp lệ thì cũng không dùng được các byte bên trong bảng, nên càng khó hơn
      Nếu dùng ổ cứng hiện đại thực sự thì nên xem GPT hơn là MBR. Có thể xử lý cả các đĩa lớn trên 2TB mà không bị giới hạn bởi bảng phân vùng. UEFI loại bỏ những vấn đề này và cho phép dùng một bố cục đĩa tử tế mà không quá khó khăn
      Để vào chế độ 64-bit thì không cần protected mode. Tuy nhiên tốt nhất là không dùng BIOS. Nó lộn xộn và chỉ làm mọi việc rắc rối hơn
      Dùng UEFI qua EDK2 hoặc GnuEFI thì tốt hơn; cả hai đều khá dễ và tiện để triển khai. Sẽ mất chút thời gian để quen với các khái niệm ban đầu của UEFI, nhưng nhìn các dự án ví dụ trên GitHub là có thể nắm cấu trúc khá dễ. EDK có các tệp .dec và .inf hơi khó chịu, còn GnuEFI thì phải đọc header file để tìm chức năng, nhưng vẫn tốt hơn nhiều so với giao diện BIOS có đặc tả mơ hồ. Trên phần cứng thật, thậm chí không thể giả định rằng int 0x10, int 0x15, v.v. tồn tại đúng nghĩa
      Trên hệ thống UEFI, có thể giả định một nền tảng tối thiểu ổn định, và cũng có thể liệt kê các chức năng phần cứng hoặc nền tảng một cách hợp lý. Ngoài ra UEFI đã thiết lập nền tảng khá nhiều, nên trong thành phần OS loader không cần khởi tạo quá nhiều; chỉ cần tải thẳng OS, driver và các thành phần phù hợp với thiết kế kernel. Lấy memory map, truy cập hệ thống tệp EFI và đọc những thứ cần thiết là được
    • Tôi không biết là có thể làm kiểu này. Nếu mục đích chỉ là vào long mode, ngay từ đầu tôi thắc mắc tại sao lại phải đi qua protected mode
  • 80286 có Machine Status Word (MSW), một thanh ghi 16-bit, còn 80386 mở rộng nó thành thanh ghi 32-bit CR0. Về sau, long mode 64-bit bổ sung EFER MSR và mở rộng CR0 lên 64-bit, nhưng ngay cả ngày nay CR0 chỉ dùng 11 bit, còn EFER cũng chỉ có 8 bit đang hoạt động
    Tôi thắc mắc vì sao Intel/AMD không dùng luôn các bit còn trống trong thanh ghi hiện có, mà lại hai lần chọn cách thêm mới: https://wiki.osdev.org/CPU_Registers_x86-64#CR0

    • Có lẽ khả năng cao là để làm tương thích ngược vững chắc hơn. Vì phần mềm có thể giả định giá trị của các bit dự trữ hoặc ghi giá trị vào đó. Việc phân bổ bit trong các thanh ghi phần cứng kiểu này khá tùy ý, và dùng các bit cao hơn cũng chẳng tốn kém gì mấy
    • Nếu trả lời bằng một từ thì có lẽ là quan liêu. Các tổ chức lớn nói chung đặc biệt không giỏi đưa ra quyết định tốt, và cũng sinh ra nhiều lựa chọn vô lý
      Lý do CR8 xuất hiện trong khi CR1 và CR5~CR7 vẫn ở trạng thái dự trữ có vẻ cũng tương tự
  • Thứ trông phức tạp không cần thiết nhất trong bài này là Makefile và linker script. NASM hỗ trợ xuất flat binary, có lẽ tác giả thấy dùng nó quá “hacky”

    • Cá nhân tôi thấy linker script dễ đọc và dễ suy luận hơn flat NASM nhiều. Đặc biệt là khi có nhiều tệp nguồn
    • Hoàn toàn đúng. Về sau Makefile và linker script sẽ trở thành vấn đề đau đầu quan trọng, nhưng nếu định tạo flat binary thì cứ tạo flat binary là được. Không cần phình to ra làm gì
      Trước đây OS của tôi có một tệp tên make.sh để châm chọc chuyện này. Giờ thì tôi dùng những thứ xịn như ‘định dạng tệp’, nên -fbin và --oformat=binary đã thành ký ức thoáng qua. Tôi từng cố rất lâu việc tách C file dữ liệu và C file mã, dump chúng thành binary rồi lắp ghép con quái vật từ đó, nhưng liên kết và nạp trở nên quá khó. Dùng ELF hoặc PE thì tốt hơn, và thực tế có vẻ các định dạng đó sinh ra để làm những việc như vậy
  • Trông hay và giống một bài luyện tập tốt, nhưng tôi không biết nó có hữu ích không. Tôi tò mò liệu có một UX kiểu đồ chơi Fisher-Price nào để kiểm tra hoặc thay đổi cấu hình lúc đang chạy không
    Khởi động là quá trình đi từ chế độ tối giản, single-user mode, recovery mode đến trạng thái bay lên
    Tôi đã dùng Unix cùng với các sản phẩm Microsoft từ thời Xenix/DOS, chắc khoảng 40 năm rồi. Tôi tự hỏi trong thời gian đó mọi thứ đã tiến bộ được bao nhiêu
    Tôi cũng dùng Linux từ bản Thụy Điển, tức bản phát hành đầu tiên, và cũng từng dùng GNU 0.1
    Xin lỗi vì đã gọi Xenix là Unix. Xenix là một sản phẩm me-too lộn xộn, ngay từ sau khi ra mắt cho đến khi suy tàn đã muốn trở thành thứ thuộc về quá khứ
    Microsoft giống một công ty không phát hành sản phẩm, mà đổ khay cát mèo lên đầu khách hàng. Ví dụ gần đây là Copilot và 22H2
    Nhìn cách xe F1, bút chì, máy tính bỏ túi đã phát triển, tôi tự hỏi chúng ta đang gần đến mức lý tưởng có thể sử dụng được đến đâu
    Tôi cũng thắc mắc vì sao bootloader không phải là static kernel mode. Trước đây từng như vậy, và gần đây có người đề xuất nên quay lại như thế, tôi cũng đồng ý

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

  • Ngạc nhiên vì tất cả các bước cần thiết để chuyển CPU sang đúng chế độ trông đều có vẻ không cần thiết. Phần lớn có vẻ là thủ tục cần có vì tương thích ngược
    Không biết Intel đã không thể cung cấp một cờ hoặc lệnh để khởi động ngay từ đầu ở đúng chế độ, hay không thể loại bỏ tương thích ngược
    Tôi nhớ ARM64 cũng từng có một số vấn đề tương tự. Không biết có CPU nào ngay từ đầu được thiết kế 64-bit nên không cần tương thích ngược và mặc định đi vào trạng thái mong muốn hay không. Tự hỏi liệu mục tiêu hay thiết kế của Itanium có phải như vậy không

    • X86S mà Intel đề xuất là nhằm mục đích đó

      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 [Lưu ý: PDF]

    • Intel đã thử làm điều đó với 80376 nhưng không thành công: https://en.wikipedia.org/wiki/Intel_80376
      Itanium, tức Itanic, cũng vậy
      Chính tương thích ngược mới là lý do cốt lõi để chọn x86 thay vì ARM, MIPS, RISC-V, v.v. Đáng tiếc là có vẻ một số người ở Intel và AMD không hiểu rõ điều này
    • Đã có UEFI rồi. Chỉ cần đặt một binary giống Windows vào thư mục của phân vùng thì nó sẽ chạy trong môi trường host ở chế độ 64-bit. Tất nhiên cũng có vô số bootloader xử lý thay những việc như thế
    • Không rõ arm64 có vấn đề gì
  • Đây là một dự án tuyệt vời. Nếu những người ủng hộ UEFI ở đây thắc mắc tại sao lại phải tạo ra một cách bootloader mới, có lẽ họ đang bỏ lỡ lý do vì sao người ta làm những việc như thế này
    Đúng như câu tác giả viết ở cuối, “nếu bạn đã theo đến đây thì thật tuyệt”, nó thật sự rất tuyệt

  • Không biết UEFI đã ra đời được bao lâu rồi. Giá mà họ cũng bỏ BIOS cùng với long mode thì tốt biết mấy, thật đáng tiếc

    • BIOS vốn đã được lên kế hoạch khai tử rồi. Trên bo mạch chủ mới, chức năng đó về cơ bản được mô phỏng thông qua UEFI, và cũng không được mở rộng thêm
      Nói được lên kế hoạch khai tử không có nghĩa là đã bị xóa, mà là không còn được cập nhật hay phát triển nữa với mục tiêu cuối cùng là loại bỏ
  • Không biết quy trình khởi động này có hoạt động trên EFI/UEFI không. Nếu có, cũng tò mò liệu trình giám sát UEFI có mô phỏng việc chuyển đổi giữa real mode, protected mode và long mode, hay thực hiện trên phần cứng thật

    • Không. Firmware UEFI cung cấp cho bootloader UEFI một môi trường hoàn toàn khác với môi trường BIOS legacy, tức chế độ địa chỉ thực. Firmware UEFI trên các hệ thống hiện đại đi thẳng vào 64-bit long mode, đồng thời cũng thiết lập GDT với mô hình bộ nhớ phẳng và paging ánh xạ đồng nhất
      Tôi đã viết ở đây quá trình tạo bootloader UEFI cho OS sở thích: https://0xc0ffee.netlify.app/osdev/05-bootloader-p1.html
  • Không biết việc này trên ARM có đơn giản hơn không

    • Đơn giản theo nghĩa mỗi nhà sản xuất bo mạch có phần tự làm theo ý mình. Nó đơn giản với nhà sản xuất bo mạch, nhưng trở nên kinh khủng phức tạp với tất cả những người còn lại
    • Đúng vậy. Bootloader vẫn phức tạp, nhưng cần ít thiết lập legacy hơn. Tuy nhiên nếu nhắm đến UEFI thay vì BIOS thì trên x86 cũng đơn giản hơn nhiều
    • Tôi không chắc, và cũng không kỳ vọng. Hiện tôi đang đào sâu vào RISC-V, và ở đó có vẻ có hy vọng