การเขียน BIOS bootloader สำหรับโหมด 64 บิต
(thasso.xyz)- เริ่มจาก boot sector ขนาด 512 ไบต์ แล้วจัดลำดับขั้นตอนของ bootloader ขั้นต่ำที่ยก CPU x86_64 จาก real mode 16 บิตขึ้นสู่ long mode 64 บิต
- ตรวจสอบการแสดงผลด้วย BIOS interrupt ก่อน จากนั้นเชื่อม
nasm,ld,objcopyและ QEMU เพื่อตรวจว่าภาพ boot image รันได้จริงหรือไม่ - เนื่องจากข้อจำกัดด้านขนาดของ boot sector จึงแบ่งเป็น stage 1/stage 2 และก่อนเข้าสู่ protected mode จะอ่านโค้ดถัดไปจากดิสก์ด้วย BIOS
int 0x13 - ตั้งแต่ protected mode 32 บิตเป็นต้นไป จะใช้ BIOS routine ไม่ได้ จึงต้องเริ่มต้นฮาร์ดแวร์เอง เช่น GDT, flat segmentation และการเขียนออก VGA buffer โดยตรง
- การเข้าสู่ long mode 64 บิตต้องตั้งค่า page table, PAE,
EFER.LME,cr0.PGและ GDT สำหรับ 64 บิตให้ถูกต้องทั้งหมด หลังจากนั้นจึงสามารถเรียก โค้ด C แบบ freestanding ได้เหมือนเคอร์เนล
สภาพแวดล้อม 16 บิตที่เริ่มจาก BIOS
- หลัง reset แล้ว CPU x86 จะอยู่ใน real mode และขนาด operand พื้นฐานคือ 16 บิต
- real mode ใช้ segmentation เพื่อสร้างพื้นที่ที่อยู่ 20 บิต และจัดการหน่วยความจำได้สูงสุด 1MB
- โค้ดแรกที่ BIOS รันอยู่ใน boot sector ของดิสก์
- BIOS จะค้นหาดิสก์ที่ sector แรกจบด้วย magic number
0xaa55 - โหลด sector นั้นไปยังที่อยู่หน่วยความจำ
0x7c00
- BIOS จะค้นหาดิสก์ที่ sector แรกจบด้วย magic number
- พื้นที่ที่ BIOS ส่งต่อให้มีเพียง 512 ไบต์ ดังนั้นโค้ดนี้จึงมุ่งทำหน้าที่เป็น bootstrap เพื่อโหลด bootloader ส่วนที่เหลือ
- BIOS routine ใช้ได้เฉพาะขณะที่ยังอยู่ใน real mode เท่านั้น
สิ่งที่ต้องมีและสภาพแวดล้อมสำหรับ build
- หากต้องการทำตาม จำเป็นต้องมี Intel 64 and IA-32 Architectures Software Developer’s Manual, assembler และ QEMU
- assembler ในตัวอย่างคือ nasm โดยดำเนินเรื่องบนสมมติฐานว่าผู้อ่านรู้จัก x86 assembly และไวยากรณ์ของ nasm แล้ว
- แม้ไม่มี CPU x86_64 ก็สามารถรันได้โดย emulate CPU x86 ใน QEMU
- ใช้ Writing a Simple Operating System — from Scratch เป็นเอกสารพื้นฐานจนถึงโหมด 32 บิต
สร้าง boot sector และตรวจสอบการรัน
- boot sector แรกใช้ BIOS routine แสดง
"Hello, world!"แล้วหยุดด้วยhltและ loop - การแสดงสตริงใช้
int 0x10และah = 0x0eซึ่งเป็นการเรียก BIOS video services Makefileสร้าง object ด้วย nasm, link ด้วย linker script แล้วสร้าง raw boot image ด้วยobjcopy -O binarymake bootรัน image ด้วย QEMUqemu-system-x86_64 -no-reboot -drive file=$<,format=raw,index=0,media=disk
- linker script ตั้งค่าให้ boot sector ถูกวางโดยอิงจาก
0x7c00- origin ของ
boot_sectorในMEMORYคือ0x7c00และ length คือ512 - section
.bootsignเพิ่ม0x55,0xaaที่ตำแหน่ง0x7c00 + 510
- origin ของ
- แม้จะจัดการ offset และ magic number โดยตรงใน assembly ของ boot sector ได้ แต่ที่นี่ให้ linker script รับหน้าที่นั้น
ให้ stage 1 อ่าน stage 2 จากดิสก์
- stage 1 คือ โค้ด boot sector ที่ BIOS โหลด และเป้าหมายคือโหลด stage 2 เข้าสู่หน่วยความจำ
- stage 2 มีโค้ดสำหรับย้ายจาก real mode 16 บิตไปยัง protected mode 32 บิต
- หลังเข้าสู่ protected mode แล้วจะใช้ BIOS routine ไม่ได้ ดังนั้นการอ่าน sector จากดิสก์ต้องเสร็จก่อนการสลับโหมด
- การเข้าถึงดิสก์ใช้ BIOS disk services
int 0x13ah = 0x42คือฟังก์ชัน BIOS extended readdl = 0x80คือหมายเลขไดรฟ์- disk address packet มีจำนวน sector ที่จะอ่าน, ที่อยู่ปลายทาง และ sector เริ่มต้น
- ตัวอย่างอ่าน 64 sector ด้วย
READ_SECTORS_NUM equ 64- boot sector คือ sector 0 ดังนั้น stage 2 จะอ่านตั้งแต่ sector 1
- ที่อยู่ปลายทางคือ
BOOT_LOAD_ADDR + SECTOR_SIZEหรือ0x7c00 + 512
- ในโค้ดยังมีการจัดการชั่วคราวที่ยอมรับกรณีอ่าน sector ได้น้อยกว่าที่ร้องขอ
- ตอนแรก stage 2 คัดลอก
print_stringสำหรับ real mode เพื่อแสดง"Hello from stage 2"และตรวจว่ายัง jump ต่อจาก stage 1 ได้ถูกต้องหรือไม่
สลับไปยัง protected mode 32 บิต
- หากจะเข้าสู่ protected mode ต้องนิยาม Global Descriptor Table(GDT) ก่อน
- ใน protected mode โดยพื้นฐานแล้ว segmentation ใช้สำหรับการป้องกันหน่วยความจำ
- ใน long mode 64 บิตจำเป็นต้องใช้ paging แต่ก่อนถึงขั้นนั้น การเข้าสู่ protected mode ต้องตั้งค่า segmentation ก่อน
- GDT ในตัวอย่างทำตาม flat model จาก Intel manual
- มี code segment และ data segment
- ทั้งสอง segment map ไปยัง linear address space ทั้งหมด
- ใช้โมเดลที่ง่ายที่สุดเพราะเป็นขั้นกลางเพื่อไปยัง long mode
- GDT เป็นโครงสร้างต่อเนื่องที่วางอยู่ในหน่วยความจำ
- จุดเริ่มต้นมี null descriptor สำหรับดัก invalid translation
- ถัดมาคือ code segment descriptor และ data segment descriptor
- การสลับโหมดทำตามลำดับต่อไปนี้
- ปิดใช้งาน interrupt ด้วย
cli - โหลดที่อยู่และความยาวของ GDT เข้า GDTR ด้วย
lgdt [gdt32_pseudo_descriptor] - เปิด protected mode โดยตั้งค่า
cr0.PEหรือ bit 0 ของcr0 - ล้าง instruction pipeline และอัปเดต
csเป็น code segment ใหม่ด้วย far jump
- ปิดใช้งาน interrupt ด้วย
- หลังเข้าสู่ protected mode แล้ว ค่า segment เดิมไม่มีความหมายอีกต่อไป จึงตั้ง
ds,ss,es,fs,gsเป็น data segment selector ใหม่ - หากต้องการเปิด interrupt อีกครั้งหลังตั้งค่าทั้งหมดแล้ว จำเป็นต้องมีงานเพิ่มเติม
แสดงผลบนหน้าจอโดยไม่ใช้ BIOS
- ใน protected mode จะเรียก BIOS routine ไม่ได้อีกต่อไป
- การแสดงสตริงเปลี่ยนเป็นการเขียนลง VGA buffer โดยตรง
print_string32บันทึกตัวอักษรและ byte สีลงที่อยู่0xb8000- ค่าสีคือ
0xf - cell ของตัวอักษรแต่ละตัวใช้ 2 ไบต์
- ค่าสีคือ
- ฟังก์ชันแสดงผลนี้เรียบง่ายมาก ข้อความจึงแสดงที่มุมซ้ายบนของหน้าจอเสมอ
page table สำหรับเข้าสู่ long mode
- IA-32e mode ในเอกสารของ Intel สอดคล้องกับ long mode ใน AMD64 manual
- หากต้องการสลับไป long mode CPU ต้องอยู่ใน protected mode และต้องเปิดใช้ paging ด้วย
- แนวคิดเรื่อง paging อ้างอิงจาก Introduction to Paging และ OSTEP
- ใน long mode ที่เปิดใช้ PAE จะใช้ page table 4 ระดับ
build_page_tableสร้าง page table 4 ระดับที่ที่อยู่ที่กำหนด- ขนาด page คือ
0x1000 - ขนาดของ page table แต่ละตัวคือ
0x1000 - จำนวน entry คือ
512 - เริ่มจากเคลียร์ table สี่ตัวเป็น 0 เพื่อให้ entry ทั้งหมดอยู่ในสถานะ not present
- เชื่อม entry แรก ๆ ของ PML4 → PDP → PD → page table
- ตั้งค่า entry 512 รายการใน layer page table ต่ำสุด
- ขนาด page คือ
GDT สำหรับ 64 บิตและลำดับการสลับเข้า long mode
- แม้ paging จะดูแล virtual address space และการจัดการสิทธิ์ แต่ใน long mode ก็ยังต้องมี GDT
- GDT สำหรับ 64 บิตก็ทำตาม flat model และแทบเหมือนกับ GDT สำหรับ protected mode
- ความแตกต่างอยู่ที่การตั้งค่า bit ที่เกี่ยวข้องกับ long mode
- ตั้งค่า flag 64-bit code segment ของ code segment
- เมื่อ flag นี้ถูกตั้ง bit default operation size ต้องเป็น 0
- การสลับเข้า long mode ดำเนินตาม flow ต่อไปนี้
- สร้าง page table 4 ระดับที่ที่อยู่
0x1000 - ใส่ที่อยู่ PML4 table ลงใน
cr3 - เปิดใช้ PAE โดยตั้งค่า bit 5 ของ
cr4 - อ่าน MSR
0xc0000080และตั้งค่าEFER.LMEหรือ bit 8 - เปิดใช้ paging โดยตั้งค่า flag
PGซึ่งเป็น bit 31 ของcr0 - โหลด GDT สำหรับ 64 บิตด้วย
lgdt - เข้าโหมด 64 บิตโดย far jump ไปยัง code segment แบบ 64 บิต
- สร้าง page table 4 ระดับที่ที่อยู่
- ทันทีหลังเปิด paging จะอยู่ในสถานะ IA-32e compatibility mode และจะสลับเป็นโหมด 64 บิตเมื่อ jump ไปยัง GDT ที่ตั้งค่า flag segment 64 บิตไว้
- ข้อความยืนยันความสำเร็จแสดงที่มุมซ้ายบนของหน้าจอผ่าน VGA buffer
เรียกโค้ด C แบบ freestanding
- เมื่อเข้าสู่ long mode 64 บิตแล้ว จะสามารถเรียก โค้ด C แบบ freestanding ได้
kernel.cเคลียร์ VGA buffer0xb8000แล้วแสดง"Hello from C"start_long_modeฝั่ง assembly หลังจากแสดงสตริงสำหรับ 64 บิตแล้ว จะประกาศextern _start_kernelและเรียก_start_kernel- linker script แบ่งพื้นที่หน่วยความจำออกเป็นสามส่วน
boot_sector:0x7c00, length512stage2:0x7e00, length512kernel:0x8000, length0x10000
- section
.text,.data,.rodata,.bssถูกวางในพื้นที่ kernel Makefileถูกเปลี่ยนให้ build ทั้ง assembly และ C- C compiler คือ
gcc - CFLAGS หลักคือ
-std=c99 -ffreestanding -m64 -mno-red-zone -fno-builtin -nostdinc -Wall -Wextra
- C compiler คือ
- โค้ดตัวอย่างทั้งหมดมีให้ที่ ลิงก์ดาวน์โหลด
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
สามารถเข้าสู่ long mode ได้โดยตรงด้วยโค้ดที่น้อยกว่ามาก โดยไม่ต้องผ่าน protected mode: https://wiki.osdev.org/Entering_Long_Mode_Directly
เคยมี bootloader สำหรับเคอร์เนล 64 บิตขนาดเล็กที่ทำด้วยวิธีนี้ และแม้จะรวมโค้ดสำหรับอ่านเคอร์เนลจากดิสก์และตั้งค่าโหมด VESA แล้ว ก็ยังใส่ใน boot sector ได้เหลือ ๆ ไม่ต้องมี loader ขั้นที่ 2 ด้วย
ถ้าจะใช้ฮาร์ดดิสก์สมัยใหม่จริง ๆ ควรดู GPT มากกว่า MBR เพราะจัดการดิสก์ขนาดใหญ่กว่า 2TB ได้โดยไม่ติดข้อจำกัดของ partition table ส่วน UEFI ทำให้ปัญหาเหล่านี้หมดไป และช่วยให้ใช้ disk layout ที่เหมาะสมได้โดยไม่ยุ่งยากมากนัก
การเข้าสู่โหมด 64 บิตไม่จำเป็นต้องใช้ protected mode อย่างไรก็ตาม ไม่ควรใช้ BIOS มันรกและมีแต่ทำให้งานยุ่งยากขึ้น
ใช้ UEFI ผ่าน EDK2 หรือ GnuEFI จะดีกว่า ทั้งสองอย่าง implement ได้ค่อนข้างง่ายและสะดวก แม้ต้องใช้เวลาสักหน่อยกว่าจะคุ้นกับแนวคิดพื้นฐานของ UEFI แต่ดูโปรเจกต์ตัวอย่างใน GitHub ก็จะเข้าใจโครงสร้างได้ง่าย EDK มีไฟล์ .dec กับ .inf ฯลฯ ที่ไม่ค่อยดีนัก ส่วน GnuEFI ต้องอ่าน header file เพื่อหาฟังก์ชัน แต่ก็ยังดีกว่าอินเทอร์เฟซ BIOS ที่สเปกไม่ชัดเจนมาก บนฮาร์ดแวร์จริงแม้แต่ int 0x10, int 0x15 ฯลฯ ก็ยังสรุปไม่ได้ว่าจะมีอย่างถูกต้อง
บนระบบ UEFI สามารถสมมติฐานขั้นต่ำที่เสถียรได้ และ enumerate ฮาร์ดแวร์หรือฟีเจอร์ของแพลตฟอร์มได้อย่าง sane อีกทั้ง UEFI ตั้งค่าแพลตฟอร์มไว้ค่อนข้างมากแล้ว จึงไม่ต้องทำ initialization มากในคอมโพเนนต์ OS loader แค่โหลด OS, driver และคอมโพเนนต์ให้ตรงกับการออกแบบเคอร์เนลก็พอ รับ memory map แล้วเข้าถึง EFI file system เพื่ออ่านสิ่งที่ต้องใช้เข้ามา
80286 มี Machine Status Word(MSW) ซึ่งเป็นรีจิสเตอร์ 16 บิต และ 80386 ขยายมันเป็นรีจิสเตอร์ 32 บิตชื่อ CR0 ต่อมา long mode แบบ 64 บิตเพิ่ม EFER MSR และขยาย CR0 เป็น 64 บิต แต่แม้ทุกวันนี้ CR0 ก็ใช้แค่ 11 บิต และ EFER ก็มี active bit แค่ 8 บิตเท่านั้น
สงสัยว่าทำไม Intel/AMD ถึงเลือกทางใหม่ถึงสองครั้ง แทนที่จะใช้บิตที่เหลืออยู่ของรีจิสเตอร์เดิม: https://wiki.osdev.org/CPU_Registers_x86-64#CR0
เหตุผลที่ CR1 และ CR5~CR7 ยังเป็น reserved แต่กลับมี CR8 ก็น่าจะคล้ายกัน
สิ่งที่ดูซับซ้อนเกินจำเป็นที่สุดในบทความนี้คือ Makefile กับ linker script NASM รองรับการ output เป็น flat binary อยู่แล้ว แต่ดูเหมือนผู้เขียนจะมองว่าการใช้แบบนั้น “hacky” เกินไป
เมื่อก่อน OS ของผมมีไฟล์ชื่อ make.sh ไว้ล้อเรื่องนี้ ตอนนี้ผมใช้ของหรู ๆ อย่าง ‘file format’ แล้ว ดังนั้น -fbin กับ --oformat=binary จึงกลายเป็นความทรงจำอันเลือนหายไป ผมเคยพยายามอยู่นานที่จะแยกไฟล์ C สำหรับข้อมูลกับไฟล์ C สำหรับโค้ด แล้ว dump เป็น binary ก่อนจะประกอบสัตว์ประหลาดจากตรงนั้น แต่การ link และ load กลายเป็นเรื่องยากเกินไป ใช้ ELF หรือ PE ไปเลยดีกว่า และดูเหมือน format เหล่านั้นก็ทำหน้าที่แบบนั้นจริง ๆ
ดูเท่และเหมือนเป็นแบบฝึกหัดที่ดี แต่ไม่รู้ว่า มีประโยชน์ ไหม สงสัยว่ามี UX แบบของเล่น Fisher-Price ที่ใช้ตรวจสอบหรือเปลี่ยน settings ระหว่างรันได้หรือเปล่า
การ boot คือกระบวนการจากโหมดมินิมี, single-user mode, recovery mode ไปสู่สถานะพร้อมทะยานขึ้น
ผมใช้ Unix ควบคู่กับผลิตภัณฑ์ Microsoft มาตั้งแต่ยุค Xenix/DOS น่าจะราว ๆ 40 ปีแล้ว สงสัยเหมือนกันว่าช่วงนั้นมันพัฒนาขึ้นแค่ไหน
Linux ก็ใช้มาตั้งแต่เวอร์ชันสวีเดน หรือก็คือ release แรก และ GNU 0.1 ก็เคยลองใช้
ขออภัยที่เรียก Xenix ว่า Unix จริง ๆ Xenix เป็นผลิตภัณฑ์เลียนแบบเละ ๆ ที่อยากกลายเป็นของตกยุคตั้งแต่ไม่นานหลังออกวางจำหน่ายจนถึงตอนเสื่อมความนิยม
Microsoft เหมือนไม่ได้ออกผลิตภัณฑ์ แต่เป็นบริษัทที่เทกระบะทรายแมวใส่ลูกค้า ตัวอย่างล่าสุดคือ Copilot กับ 22H2
ถ้าดูว่า F1, ดินสอ, เครื่องคิดเลขพกพาพัฒนาไปอย่างไร ก็อดสงสัยไม่ได้ว่าเราเข้าใกล้อุดมคติที่ใช้งานได้จริงแค่ไหนแล้ว
ยังสงสัยด้วยว่าทำไม bootloader ถึงไม่เป็น static kernel mode เมื่อก่อนมันเคยเป็นแบบนั้น และเมื่อเร็ว ๆ นี้มีคนเสนอว่าควรกลับไปเป็นแบบนั้นอีก ผมก็เห็นด้วย
https://wiki.osdev.org/A20_Line
รู้สึกประหลาดใจที่ทุกขั้นตอนที่จำเป็นสำหรับการสลับ CPU ไปยังโหมดที่ถูกต้องดูเหมือนไม่จำเป็นทั้งหมด ส่วนใหญ่ดูเป็นขั้นตอนที่ต้องมีเพราะ ความเข้ากันได้ย้อนหลัง
สงสัยว่า Intel ไม่สามารถให้แฟล็กหรือคำสั่งสำหรับเริ่มต้นในโหมดที่ถูกต้องตั้งแต่แรกได้หรือไม่ หรือไม่สามารถตัดความเข้ากันได้ย้อนหลังออกไปได้หรือเปล่า
จำได้ว่า ARM64 ก็มีปัญหาคล้าย ๆ กันอยู่บ้างเหมือนกัน สงสัยว่ามี CPU ที่ออกแบบมาเป็น 64 บิตตั้งแต่แรก จึงไม่ต้องการความเข้ากันได้ย้อนหลัง และโดยพื้นฐานแล้วเข้าสู่สถานะที่ต้องการได้เลยหรือไม่ หรือเป้าหมาย/การออกแบบของ Itanium เป็นแบบนั้นหรือเปล่า
Itanium หรือที่เรียกว่า Itanic ก็เช่นกัน
ความเข้ากันได้ย้อนหลังนี่แหละคือเหตุผลหลักที่เลือก x86 แทน ARM, MIPS, RISC-V ฯลฯ น่าเสียดายที่บางคนใน Intel และ AMD ดูเหมือนจะไม่ค่อยเข้าใจเรื่องนี้
เป็นโปรเจกต์ที่ยอดเยี่ยม ถ้าผู้สนับสนุน UEFI ตรงนี้สงสัยว่าทำไมต้องสร้างวิธีบูตโหลดเดอร์แบบใหม่ขึ้นมา ผมคิดว่าพวกเขาพลาดเหตุผลที่ผู้คนทำงานแบบนี้ไป
อย่างที่ผู้เขียนเขียนไว้ตอนท้ายว่า “ถ้าคุณตามมาถึงตรงนี้ได้ ก็เจ๋งมาก” ซึ่งก็เจ๋งจริง ๆ
สงสัยว่า UEFI ออกมานานแค่ไหนแล้ว น่าเสียดายที่น่าจะเลิกใช้ BIOS ไปพร้อมกับ long mode เสียเลย
คำว่าเลิกใช้ไม่ได้หมายความว่าถูกลบออกแล้ว แต่หมายความว่าไม่อัปเดตหรือพัฒนาต่ออีก โดยมีเป้าหมายเพื่อเอาออก
สงสัยว่าขั้นตอนการบูตนี้ทำงานบน EFI/UEFI ด้วยหรือไม่ ถ้าทำงานได้ ก็สงสัยว่า UEFI supervisor จำลองการสลับระหว่าง real mode, protected mode, long mode หรือว่าทำบนฮาร์ดแวร์จริง
ผมเขียนขั้นตอนการทำ UEFI bootloader สำหรับ OS งานอดิเรกไว้ที่นี่: https://0xc0ffee.netlify.app/osdev/05-bootloader-p1.html
สงสัยว่าบน ARM เรื่องนี้ง่ายกว่าหรือไม่