Bootloader x86: grafika 2D w trybie chronionym i 3D w 512 bajtach
Przetłumaczone automatycznie z angielskiego. Przeczytaj oryginał

Zastrzeżenie: opisane tu projekty są wynikiem mojej własnej, samodzielnej pracy, wykonanej bez użycia AI. Sam artykuł powstał z pomocą AI.
Katalog x86-bootloader w moim repozytorium retro-lab zawiera dwa programy, które działają bezpośrednio na procesorze po tym, jak BIOS przekaże im sterowanie. Nie ma systemu operacyjnego, libc ani sterowników: tylko asembler NASM, przerwania BIOS-u i pamięć wideo pod 0xA0000. Oba działają w emulatorze Bochs lub na prawdziwym sprzęcie uruchomionym z dyskietki.
| Część | Tryb | Rozmiar | Co robi |
|---|---|---|---|
protected-mode-2d |
32-bitowy tryb chroniony | 4555 bajtów (512 B sektora rozruchowego + 4043 B programu) | animowane kształty 2D, linie, logo, dźwięk z PC speakera, kopiowanie bufora z MMX |
real-mode-3d |
16-bitowy tryb rzeczywisty | 512 bajtów | linie Bresenhama i projekcja 3D na 2D, wszystko wewnątrz sektora rozruchowego |
protected-mode-2d: kod z 2005 roku #
Ta część jest starsza niż kod na GitHubie. Napisałem ją około 2005 roku, a jej 11. wersję zacommitowałem w 2018. Jest podzielona na małe pliki: bootsector.asm, main.asm, point.asm, line.asm, rect.asm, logo.asm i sound.asm, asemblowane osobno i łączone w jeden obraz dyskietki.

Z trybu rzeczywistego do trybu chronionego #
Sektor rozruchowy jest ładowany przez BIOS pod adres 0x7C00 i nadal działa w 16-bitowym trybie rzeczywistym. Musi zrobić wszystko, co później, bez BIOS-u, stanie się niemożliwe:
- zresetować napęd i wczytać kolejne 10 sektorów (właściwy program) pod
0000:1000za pomocąint 13h, - przełączyć się w tryb graficzny 13h (320×200, 256 kolorów) za pomocą
int 10h, - wyłączyć przerwania i załadować globalną tablicę deskryptorów (GDT) instrukcją
lgdt, - ustawić bit 0 rejestru
CR0i wykonać daleki skok, aby opróżnić kolejkę prefetch, - w kodzie 32-bitowym: ustawić segmenty danych i stosu oraz skoczyć do programu pod
08h:01000h.
cli ; Disable interrupts, we want to be alone
xor ax, ax
mov ds, ax ; Set DS-register to 0 - used by lgdt
lgdt [gdt_desc] ; Load the GDT descriptor
mov eax, cr0
or eax, 1 ; Set bit 0
mov cr0, eax
jmp 08h:clear_pipe ; Jump to code segment, offset clear_pipe
[BITS 32]
clear_pipe:
mov ax, 10h ; Data segment selector
mov ds, ax
mov ss, ax
mov esp, 090000h
jmp 08h:01000h ; Jump to the program loaded from disk
GDT to absolutne minimum, które działa: deskryptor zerowy, jeden segment kodu (odczyt/wykonanie) i jeden segment danych (odczyt/zapis), oba płaskie na całą 4-gigabajtową przestrzeń adresową.
Podwójne buforowanie z MMX #
Rysowanie bezpośrednio pod 0xA0000 powoduje migotanie. Program rysuje każdą klatkę do bufora pod 0x10000, a następnie kopiuje 64 000 bajtów do pamięci wideo. Kopiowanie wykorzystuje osiem 64-bitowych rejestrów MMX, więc jedna iteracja pętli przenosi 64 bajty, a 1000 iteracji przenosi cały ekran:
%macro draw_screen 0
mov esi, 010000h
mov edi, 0A0000h
mov cx, 1000
%%copy_loop:
movq mm0, [esi]
movq mm1, [esi+8]
; ... mm2 - mm7
movq [edi], mm0
movq [edi+8], mm1
; ... mm2 - mm7
add esi, 64
add edi, 64
loop %%copy_loop
%endmacro
MMX pojawiło się w 1996 roku, więc dla hobbystycznego projektu z 2005 roku było to najtańsze „SIMD”, jakie mogłem dostać na dowolnym PC.
real-mode-3d: wszystko w jednym sektorze #
Drugi program, napisany w 2018 roku, stawia ostrzejszy limit: całość musi zmieścić się w 512-bajtowym sektorze rozruchowym, który ładuje BIOS. Bez drugiego etapu, bez dodatkowych sektorów.

Program pozostaje w trybie rzeczywistym, wchodzi w tryb 13h przez int 10h, ustawia ES na segment 0xA000 i zapisuje piksele przez [es:di]. Rysowanie pikseli i linii to makra NASM, a nie procedury, co oszczędza narzut call/ret i pozwala asemblerowi pominąć to, co nie jest używane:
%macro PutPixelAt 3
mov bx, %2
imul bx,320
add bx, %1
mov di, bx
mov al, %3
mov [es:di], al
%endmacro
Bresenham najpierw w Rubym, potem w asemblerze #
Linie korzystają z algorytmu Bresenhama, operującego wyłącznie na liczbach całkowitych. Najpierw napisałem go w Rubym (bresenham_integer.rb), żeby mieć czytelną implementację referencyjną, a potem przetłumaczyłem linia po linii na makro LineFromTo. Komentarze w asemblerze to kod w Rubym:
mov ax, [vdy] ; d = 2*dy - dx
imul ax, 2
sub ax, [vdx]
mov [vd], ax
...
cmp word [vd], 0 ; if d > 0
jg %%.True
Część 3D to ta sama uproszczona projekcja, której użyłem na Commodore 64 (zob. 3D na Commodore 64): głębokość jest dzielona przez stałe i dodawana do x i y, co daje widok ukośny, niemal ortograficzny, przy użyciu wyłącznie całkowitoliczbowego div. Bez liczb zmiennoprzecinkowych, bez FPU, bez tablic lookup.
Budowanie i uruchamianie #
nasm main.asm -f bin -o boot.bin
dd if=/dev/zero of=disk.img bs=1024 count=1440
dd if=boot.bin of=disk.img conv=notrunc
Bochs uruchamia to z minimalnym plikiem bochsrc.bxrc:
megs: 32
floppya: 1_44=disk.img, status=inserted
boot: a
log: bochsout.txt
cpu: ips=15000000
Czego się z tego nauczyłem #
- Przejście z trybu rzeczywistego do chronionego to około dwudziestu instrukcji, ale każda z nich musi być we właściwej kolejności. Log Bochsa (
bochsout.txt) to jedyny debugger, jaki masz, i to wystarcza. - 512 bajtów to bardzo dobry nauczyciel. Makra kontra procedury, ponowne wykorzystanie rejestrów i rozmiar instrukcji nagle zaczynają mieć znaczenie.
- Napisanie algorytmu najpierw w języku wysokiego poziomu (tutaj w Rubym), a potem ręczne przetłumaczenie go, to najszybszy znany mi sposób na uzyskanie poprawnego kodu w asemblerze.
Kod źródłowy: retro-lab/x86-bootloader. Zobacz też przegląd moich praktycznych projektów.