Pisanie boot-sektorów

Gdy już choć średnio znacie asemblera, to po pewnym czasie pojawiają się pytania (mogą one być spowodowane tym, co usłyszeliście lub Waszą własną ciekawością):

  1. Co się dzieje, gdy ma zostać uruchomiony jest system operacyjny?
  2. Skąd BIOS ma wiedzieć, którą część systemu uruchomić?
  3. Jak BIOS odróżnia systemy operacyjne, aby móc je uruchomić?

Odpowiedź na pytanie 2 brzmi: nie wie. Odpowiedź na pytanie 3 brzmi: wcale. Wszystkie Wasze wątpliwości rozwieje odpowiedź na pytanie 1.

Gdy zakończył się POST (Power-On Self Test), wykrywanie dysków i innych urządzeń, BIOS przystępuje do czytania pierwszych sektorów tych urządzeń, na których ma być szukany system operacyjny (u mnie jest ustawiona kolejność: CD-ROM, stacja dyskietek, dysk twardy).
Gdy znajdzie sektor odpowiednio zaznaczony: bajt nr 510 = 55h i bajt 511 = AAh (pamiętajmy, że 1 sektor ma 512 bajtów, a liczymy od zera), to wczytuje go pod adres bezwzględny 07C00h i uruchamia kod w nim zawarty (po prostu wykonuje skok pod ten adres). Nie należy jednak polegać na tym, że segment kodu CS = 0, a adres instrukcji IP=7C00h (choć najczęściej tak jest).

To właśnie boot-sektor jest odpowiedzialny za ładowanie odpowiednich części właściwego systemu operacyjnego. Na komputerach z wieloma systemami operacyjnymi sprawa też nie jest tak bardzo skomplikowana. Pierwszy sektor dysku twardego, zwany Master Boot Record (MBR), zawiera program ładujący (Boot Manager, jak LILO czy GRUB), który z kolei uruchamia boot-sektor wybranego systemu operacyjnego.

My oczywiście nie będziemy operować na dyskach twardych, gdyż byłoby to niebezpieczne. Z dyskietkami zaś można eksperymentować do woli...
A instrukcja jest prosta: umieszczamy nasz programik w pierwszym sektorze dyskietki, zaznaczamy go odpowiednimi ostatnimi bajtami i tyle. No właśnie... niby proste, ale jak o tym pomyśleć to ani to pierwsze, ani to drugie nie jest sprawą banalną.

Do zapisania naszego bootsektorka na dyskietkę możemy oczywiście użyć gotowców - programów typu rawwrite itp. Ma to pewne zalety - program był już używany przez dużą liczbę osób, jest sprawdzony i działa.
Ale coś by było nie tak, gdybym w kursie programowania w asemblerze kazał Wam używać cudzych programów. Do napisania swojego własnego programu zapisującego dany plik w pierwszym sektorze dyskietki w zupełności wystarczy Wam wiedza uzyskana po przeczytaniu części mojego kursu poświęconej operacjom na plikach wraz z tą krótką informacją ze Spisu Przerwań Ralfa Brown'a:


(przeskocz opis int 13h, ah=3)
	INT 13 - DISK - WRITE DISK SECTOR(S)
	        AH = 03h
	        AL = number of sectors to write (must be nonzero)
	        CH = low eight bits of cylinder number
	        CL = sector number 1-63 (bits 0-5)
             		high two bits of cylinder (bits 6-7, hard disk only)
        	DH = head number
        	DL = drive number (bit 7 set for hard disk)
        	ES:BX -> data buffer
	Return: CF set on error
	        CF clear if successful

Jak widać, sprawa już staje się prosta. Oczywiście, AL=1 (bo zapisujemy 1 sektor), DX=0 (bo stacja ma 2 głowice, a pierwsza ma numer 0, zaś numer dysku 0 wskazuje stację A:), CX=1 (bo numery sektorów zaczynają się od 1, a zapisujemy w pierwszym cylindrze, który ma numer 0).
Schemat działania jest taki:

Sprawa jest tak prosta, że tym razem nie podam gotowca.

Gdy już mamy program zapisujący bootsektor na dyskietkę, trzeba się postarać o to, aby nasz programik (który ma stać się tym bootsektorem) miał dokładnie 512 bajtów i aby 2 ostatnie jego bajty to 55h, AAh.
Oczywiście, nie będziemy ręcznie dokładać tylu bajtów, ile trzeba, aby dopełnić nasz program do tych 512. Zrobi to za nas kompilator. Wystarczy po całym kodzie i wszystkich danych, na szarym końcu, umieścić takie coś (NASM/FASM):


(przeskocz tworzenie sygnatury)
		times 510 - ($ - start) db 0
		dw 0aa55h

Dla TASMa powinno to wyglądać mniej-więcej tak:

		db 510 - ($ - offset start) dup (0)
		dw 0aa55h
	end start

To wyrażenie mówi tyle: od bieżącej pozycji w kodzie odejmij pozycję początku kodu (tym samym obliczając długość całego kodu), otrzymaną liczbę odejmij od 510 - i dołóż tyle właśnie bajtów zerowych. Gdy już mamy program długości 510 bajtów, to dokładamy jeszcze znacznik i wszystko jest dobrze.

Jest jednak jeszcze jedna sprawa, o której nie wspomniałem - ustawienie DS i wartości org dla naszego kodu. Otóż, jeśli stwierdzimy, że nasz kod powinien zaczynać się od offsetu 0 w naszym segmencie, to ustawmy sobie org 0 i DS=07C0h (tak, liczba zer się zgadza), ale możemy też mieć org 7C00h i DS=0. Żadne z tych nie wpływa w żaden sposób na długość otrzymanego programu, a należy o to zadbać, gdyż nie mamy gwarancji, że DS będzie pokazywał na nasze dane po uruchomieniu bootsektora.

Teraz, uzbrojeni w niezbędną wiedzę, zasiadamy do pisania kodu naszego bootsektora. Nie musi to być coś wielkiego - tutaj pokażę coś, co w lewym górnym rogu ekranu pokaże cyfrę jeden (o bezpośredniej manipulacji ekranem możecie przeczytać w moim artykule o bezpośrednim dostępie do ekranu) i po naciśnięciu dowolnego klawisza zresetuje komputer (na jeden ze sposobów podanych w artykule o zarządzaniu zasilaniem).

Oto nasz kod (NASM):


(przeskocz przykładowy bootsektor)
	; nasm -o boot.bin -f bin boot.asm

	org 7c00h			; lub     "org 0"

	start:
		mov	ax, 0b800h
		mov	es, ax		; ES = segment pamięci ekranu

		mov	byte [es:0], "1" ; piszemy "1"

		xor	ah, ah
		int	16h		; czekamy na klawisz

		mov	bx, 40h
		mov	ds, bx
		mov	word [ds:72h], 1234h	; 40h:72h = 1234h -
						; wybieramy gorący reset

		jmp	0ffffh:0000h		; reset

	times 510 - ($ - start) db 0		; dopełnienie do 510 bajtów
	dw 0aa55h				; znacznik

Nie było to długie ani trudne, prawda? Rzecz jasna, nie można w bootsektorach używać żadnych przerwań systemowych, na przykład DOS-owego int 21h, bo żaden system po prostu nie jest uruchomiony i załadowany. Tak napisany programik kompilujemy do formatu binarnego. W TASM-ie kompilacja wyglądałaby jakoś tak (po dodaniu w programie dyrektyw .model tiny, .code, .8086 i end start):

	tasm bootsec1.asm
	tlink bootsec1.obj,bootsec1.bin /t

Po kompilacji umieszczamy go na dyskietce przy użyciu programu napisanego już przez nas wcześniej. Resetujemy komputer (i upewniamy się, że BIOS spróbuje uruchomić system z dyskietki), wkładamy dyskietkę i.... cieszymy się swoim dziełem (co prawda ta jedynka będzie mało widoczna, ale rzeczywiście znajduje się na ekranie).

Zauważcie też, że ani DOS ani Windows nie rozpoznaje już naszej dyskietki, mimo iż przedtem była sformatowana. Dzieje się tak dlatego, że w bootsektorze umieszczane są informacje o dysku.
Bootsektor typu FAT12 (DOSowy/Windowsowy) powinien się zaczynać mniej-więcej tak:


(przeskocz systemowy obszar bootsektora)
	org 7c00h			; lub org 0, oczywiście

	start:
		jmp short kod
		nop

		db "        "	; nazwa OS i wersja OEM (8B)
		dw 512		; bajtów/sektor (2B)
		db 1		; sektory/jednostkę alokacji (1B)
		dw 1		; zarezerwowane sektory (2B)
		db 2		; liczba tablic alokacji (1B)
		dw 224		; liczba pozycji w katalogu głównym (2B)
				; 224 to typowa wartość
		dw 2880		; liczba sektorów (2B)
		db 0f0h		; Media Descriptor Byte (1B)
		dw 9		; sektory/FAT (2B)
		dw 18		; sektory/ścieżkę (2B)
		dw 2		; liczba głowic (2B)
		dd 0		; liczba ukrytych sektorów (4B)
		dd 0		; liczba sektorów (część 2),
				; jeśli wcześniej było 0 (4B)
		db 0		; numer dysku (1B)
		db 0		; zarezerwowane (1B)
		db 0		; rozszerzona sygnatura bloku ładującego
		dd 0bbbbddddh	; numer seryjny dysku (4B)
		db "           "; etykieta (11B)
		db "FAT 12  "	; typ FAT (8B), zwykle  "FAT 12  "

	kod:
		; tutaj dopiero kod bootsektora

Ta porcja danych oczywiście uszczupla ilość kodu, którą można umieścić w bootsektorze. Nie jest to jednak duży problem, gdyż i tak jedyną rolą większości bootsektorów jest uruchomienie innych programów (second stage bootloaders), które dopiero zajmują się ładowaniem właściwego systemu.

Jeszcze ciekawostka: co wypisuje BIOS, gdy dysk jest niewłaściwy (bez systemu)?
Otóż - nic! BIOS bardzo chętnie przeszedłby do kolejnego urządzenia.
Dlaczego więc tego nie robi i skąd ten napis o niewłaściwym dysku systemowym??
Odpowiedź jest prosta - sformatowana dyskietka posiada bootsektor!
Dla BIOSu jest wszystko OK, uruchamia więc ten bootsektor. Dopiero ten wypisuje informację o niewłaściwym dysku, czeka na naciśnięcie klawisza, po czym uruchamia int 19h. O tym, co robi przerwanie 19h możecie przeczytać w artykule o resetowaniu.

Miłego bootowania systemu!

P.S. Jeśli nie chcecie przy najdrobniejszej zmianie kodu resetować komputera, możecie poszukać w Internecie programów, które symulują procesor (w tym fazę ładowania systemu). Jednym z takich programów jest Bochs.



Co dalej?

Mimo iż bootsektor jest ograniczony do 512 bajtów, to może w dość łatwy sposób posłużyć do wczytania do pamięci o wiele większych programów. Wystarczy użyć funkcji czytania sektorów:


(przeskocz opis int 13h, ah=2)
	INT 13 - DISK - READ SECTOR(S) INTO MEMORY
		AH = 02h
		AL = number of sectors to read (must be nonzero)
		CH = low eight bits of cylinder number
		CL = sector number 1-63 (bits 0-5)
		     high two bits of cylinder (bits 6-7, hard disk only)
		DH = head number
		DL = drive number (bit 7 set for hard disk)
		ES:BX -> data buffer
	Return: CF set on error
		CF clear if successful

Jak widać, poza wartością rejestru AH, jej parametry nie różnią się od parametrów funkcji zapisu sektorów.
Wystarczy więc wybrać nieużywany segment pamięci, na przykład ES=8000h i począwszy od offsetu BX=0, czytać sektory zawierające nasz kod, zwiększając BX o 512 za każdym razem. Kod do załadowania nie musi być oczywiście w postaci pliku na dyskietce, to by tylko utrudniło pracę (gdyż trzeba wtedy czytać tablicę plików FAT). Najłatwiej załadować kod tym samym sposobem, co bootsektor, ale oczywiście do innych sektorów. Polecam zacząć od sektora dziesiątego lub wyżej, gdyż zapisanie tam danych nie zamaże tablicy FAT i przy próbie odczytu zawartości dyskietki przez system nie pojawią się żadne dziwne obiekty.

Po załadowaniu całego potrzebnego kodu do pamięci przez bootsektor, wystarczy wykonać skok:

	jmp	8000h:0000h

Wtedy kontrolę przejmuje kod wczytany z dyskietki.

Ale jest jeden kruczek - trzeba wiedzieć, jakie numery cylindra, głowicy i sektora podać do funkcji czytające sektory, żeby rzeczywiście odczytała te właściwe.
Struktura standardowej dyskietki jest następująca: 512 bajtów na sektor, 18 sektorów na ścieżkę, 2 ścieżki na cylinder (bo są dwie strony dyskietki, co daje 36 sektorów na cylinder), 80 cylindrów na głowicę. Razem 2880 sektorów po 512 bajtów, czyli 1.474.560 bajtów.

Mając numer sektora (bo wiemy, pod jakimi sektorami zapisaliśmy swój kod na dyskietce), odejmujemy od niego 1 (tak by zawsze wszystkie numery sektorów zaczynały się od zera), po czym dzielimy go przez 36. Uzyskany iloraz to numer cylindra (rejestr CH), reszta zaś oznacza numer sektora w tymże cylindrze (rejestr CL). Jeśli ta reszta jest większa bądź równa 18, należy wybrać głowicę numer 1 (rejestr DH), zaś od numeru sektora (rejestr CL) odjąć 18. W przeciwnym przypadku należy wybrać głowicę numer 0 i nie robić nic z numerem sektora.

W ten sposób otrzymujemy wszystkie niezbędne dane i możemy bez przeszkód w pętli czytać kolejne sektory zawierające nasz kod.

Całą tę procedurę ilustruje ten przykładowy kod:


(przeskocz procedurę czytania sektorów)
secrd:
;wejście: ax=sektor, es:bx wskazuje na dane

	dec ax		; z numerów 1-36 na 0-35
	mov cl,36	; liczba sektorów na cylinder = 36
	xor dx,dx	; zakładamy na początek: głowica 0, dysk 0 (a:)
	div cl		; AX (numer sektora) dzielimy przez 36
	mov ch,al	; AL=cylinder, AH=przesunięcie względem
			;	początku cylindra, czyli sektor
	cmp ah,18	; czy numer sektora mniejszy od 18?
	jb .sec_ok	; jeśli tak, to nie robimy nic
	sub ah,18	; jeśli nie, to odejmujemy 18
	inc dh		; i zmieniamy głowicę
.sec_ok:
	mov cl, ah	; CL = numer sektora
	mov ax,0201h	; odczytaj 1 sektor
	inc cl		; zwiększ z powrotem z zakresu 0-17 do 1-18

	push dx		; niektóre biosy niszczą DX, nie ustawiają
			;	flagi CF, lub zerują flagę IF
	stc
	int 13h		; wykonaj czytanie
	sti
	pop dx


Spis treści off-line (Alt+1)
Spis treści on-line (Alt+2)
Ułatwienia dla niepełnosprawnych (Alt+0)