上一篇博文我們講了如何看到實驗結果,這篇博文我們著重分析源代碼。
書中作者為了說明原理,約定了一種比較簡單地用戶程序頭部格式,示意圖如下(我參考原書圖8-15繪制的,左邊的數字表示偏移地址):

所以,如果用戶程序要利用本章的源碼c08_mbr.asm生成的加載器來加載的話,就應該遵循這種頭部格式。
下面我們講解源碼c08_mbr.asm(粘貼的源代碼不一定和配書的代碼完全一樣,因為有些地方我加了注釋)
~~~
;代碼清單8-1
;文件名:c08_mbr.asm
;文件說明:硬盤主引導扇區代碼(加載程序)
;創建日期:2011-5-5 18:17
app_lba_start equ 100 ;聲明常數(用戶程序起始邏輯扇區號)
;常數的聲明不會占用匯編地址
SECTION mbr align=16 vstart=0x7c00
;設置堆棧段和棧指針
mov ax,0
mov ss,ax
mov sp,ax
mov ax,[cs:phy_base] ;計算用于加載用戶程序的邏輯段地址
mov dx,[cs:phy_base+0x02]
mov bx,16
div bx
mov ds,ax ;令DS和ES指向該段以進行操作
mov es,ax
;以下讀取程序的起始部分
xor di,di
mov si,app_lba_start ;程序在硬盤上的起始邏輯扇區號
xor bx,bx ;加載到DS:0x0000處
call read_hard_disk_0
;以下判斷整個程序有多大
mov dx,[2] ;曾經把dx寫成了ds,花了二十分鐘排錯
mov ax,[0]
mov bx,512 ;512字節每扇區
div bx
cmp dx,0
jnz @1 ;未除盡,因此結果比實際扇區數少1
dec ax ;已經讀了一個扇區,扇區總數減1
@1:
cmp ax,0 ;考慮實際長度小于等于512個字節的情況
jz direct
;讀取剩余的扇區
push ds ;以下要用到并改變DS寄存器
mov cx,ax ;循環次數(剩余扇區數)
@2:
mov ax,ds
add ax,0x20 ;得到下一個以512字節為邊界的段地址
mov ds,ax
xor bx,bx ;每次讀時,偏移地址始終為0x0000
inc si ;下一個邏輯扇區
call read_hard_disk_0
loop @2 ;循環讀,直到讀完整個功能程序
pop ds ;恢復數據段基址到用戶程序頭部段
;計算入口點代碼段基址
direct:
mov dx,[0x08]
mov ax,[0x06]
call calc_segment_base
mov [0x06],ax ;回填修正后的入口點代碼段基址
;開始處理段重定位表
mov cx,[0x0a] ;需要重定位的項目數量
mov bx,0x0c ;重定位表首地址
realloc:
mov dx,[bx+0x02] ;32位地址的高16位
mov ax,[bx]
call calc_segment_base
mov [bx],ax ;回填段的基址
add bx,4 ;下一個重定位項(每項占4個字節)
loop realloc
jmp far [0x04] ;轉移到用戶程序
;-------------------------------------------------------------------------------
read_hard_disk_0: ;從硬盤讀取一個邏輯扇區
;輸入:DI:SI=起始邏輯扇區號
; DS:BX=目標緩沖區地址
push ax
push bx
push cx
push dx
mov dx,0x1f2
mov al,1
out dx,al ;讀取的扇區數
inc dx ;0x1f3
mov ax,si
out dx,al ;LBA地址7~0
inc dx ;0x1f4
mov al,ah
out dx,al ;LBA地址15~8
inc dx ;0x1f5
mov ax,di
out dx,al ;LBA地址23~16
inc dx ;0x1f6
mov al,0xe0 ;LBA28模式,主盤
or al,ah ;LBA地址27~24
out dx,al
inc dx ;0x1f7
mov al,0x20 ;讀命令
out dx,al
.waits:
in al,dx
and al,0x88
cmp al,0x08
jnz .waits ;不忙,且硬盤已準備好數據傳輸
mov cx,256 ;總共要讀取的字數
mov dx,0x1f0
.readw:
in ax,dx
mov [bx],ax
add bx,2
loop .readw
pop dx
pop cx
pop bx
pop ax
ret
;-------------------------------------------------------------------------------
calc_segment_base: ;計算16位段地址
;輸入:DX:AX=32位物理地址
;返回:AX=16位段基地址
push dx
add ax,[cs:phy_base]
adc dx,[cs:phy_base+0x02]
shr ax,4
ror dx,4
and dx,0xf000
or ax,dx
pop dx
ret
;-------------------------------------------------------------------------------
phy_base dd 0x10000 ;用戶程序被加載的物理起始地址
times 510-($-$$) db 0
db 0x55,0xaa
~~~
app_lba_start equ 100 ;聲明常數(用戶程序起始邏輯扇區號)
這句話作者假定用戶程序從硬盤第100扇區開始。所以在我們把這個源文件對應的.bin文件寫入虛擬硬盤的時候,要從邏輯扇區100開始寫。
equ 類似于C語言中的#define,用來定義一個常量。
一般使用格式:
符號名 EQU 表達式
作用是左邊的符號名代表右邊的表達式。
注意:不會給符號名分配存儲空間,符號名不能與其它符號同名,也不能被重新定義
SECTION mbr align=16 vstart=0x7c00
解釋:
NASM編譯器用SECTION或者SEGMENT來定義段。mbr是段名稱(可以隨便起);
注意:如果整個程序都沒有段定義語句,那么整個程序自成一個段(這點好像和MASM不同哦!);
align=16 表示16字節對齊;
vstart=0x7c00,關于這個,我們就不得不多說幾句了。
==================插敘部分================
匯編地址以及標號的本質:
1. 所謂匯編地址,就是編譯器給源程序中每條指令定義的地址,由于編譯后的程序可以在內存中浮動(即可以裝載在內存中的任意位置),因此直接用絕對地址(20位的實模式下的物理內存地址)來給源程序中的指令定位的話將不利于程序在內存中的浮動;
2. 匯編地址定位規則:
(1)一般規則:
i. 如果在沒有使用特殊指令的一般情況下(特別是vstart指令),整個源程序中第一條指令的匯編地址為0,之后所有指令的匯編地址都是相對于整個源程序第一條指令的偏移地址,即使程序中分了很多段也是如此。在這種情況下,如果將整個源程序看做一個段的話則匯編地址就是段內偏移地址;
ii. 在NASM中,所有的標號實質上就是其所在處指令的匯編地址,在編譯后會將所有標號都替換成該匯編地址值(即立即數);
(2)特殊規則:
i. 如果在定義段的時候使用了vstart偽指令,比如
“section my_segment vstart=15”,
則會提醒匯編器,該段起始指令的匯編地址是15,段內的其它指令的匯編地址都是距該段起始指令地址的偏移量加上15;因此,vstart偽指令就是指定段的起始匯編地址;如果vstart=0,則段內的匯編地址就是段內的偏移地址!(這種手法經常使用!)
ii. 使用NASM規則的標準段,是指section .data、section .text、section .bss,這三種標準段都默認包含有vstart=0,因此段內的指令以及標號的匯編地址都是段內偏移地址,并且在加載程序的時候會自動使cs指向.text,ds指向.bss,es指向.data,而無需人手工執行對段寄存器賦值的步驟,而對于i.中的定義段的方式則沒有這種自動的步驟,需要親手對段寄存器進行賦值(是這樣嗎?從網上搜來的,我不能肯定。)
(3) 引用標號:
i. 和MASM不一樣的是NASM大大簡化了對標號的引用,不需要再用seg和offset對標號取段地址和偏移地址了;
ii. 在NASM中,標號就是一個立即數,而這個立即數就是匯編地址;
iii. 在NASM中不再有MASM中數據標號的概念,也就不存在什么arr[5]之類的內存尋址形式了!
iv. 在NASM中所有出現標號的地方都會用標號的匯編地址替換,因此諸如mov ax, tag之類的指令,僅僅就是將一個立即數(tag的匯編地址)傳送至ax而已,而不是取tag地址內的數據了!如果要取標號處內存中的數據就必須使用[ ](類似C語言中的指針運算符*);
==================插敘結束================
處理器加電或者復位后,BIOS會執行硬件檢測和初始化程序,如果沒有錯誤,接下來就會進行操作系統引導。
BIOS會根據CMOS(一塊可讀寫的RAM芯片,保存系統當前的硬件配置和用戶的設定參數)里記錄的啟動順序逐個地來嘗試加載啟動代碼。
具體的過程是BIOS將磁盤的第一扇區(磁盤最開始的512字節,也就是主引導扇區)載入內存,放在0X0000:0X7C00處,然后檢查這個扇區的最后兩個字節是不是“0x55AA”,如果是則認為這是一個有效的啟動扇區,如果不是就會嘗試下一個啟動介質;
如果主引導扇區有效,則以一個段間轉移指令
jmp 0x0000:0x7c00
跳過去繼續執行;
如果所有的啟動介質都判斷過后仍然沒有找到可啟動的程序,那么BIOS會給出錯誤提示。
所以,代碼中的vstart=0x7c00不是空穴來風,而是根據代碼被加載的實際位置決定的。

當這段程序剛被加載到內存后,
CS=0x0000, IP=0x7c00
如上圖所示,假設不寫vstart=0x7c00,那么標號“number”的偏移地址就從程序頭(認為是0)開始算起,為0x012e;
但是實際上“number”的段內偏移地址是0x7d2e(0x012e+0x7c00=0x7d2e)!
為了修正這個偏移地址的差值,于是有vstart=0x7c00,也就是說段內所有指令的匯編地址都在原來的基礎上加上0x7c00.
這里還要再補充一點,如果看這個源文件對應的列表文件,是看不出來偏移地址被加了0x7c00的。
列表文件的一個截圖如下:

看到了嗎?第一條指令的匯編地址,還是從0開始的!
而且
SECTION mbr align=16 vstart=0x7c00
這句話還是出現在了列表文件里。
我的理解是,列表文件僅僅是對源碼的第一遍掃描吧。在后面的掃描中,0x7c00就起作用了。
舉個例子吧,
上圖有一行
16 00000007 2EA1[CA00] mov ax,[cs:phy_base]
列表文件的末尾有
151 000000CA 00000100 phy_base dd 0x10000
也就是說 phy_base 這個標號的匯編地址就是00CA(這時候7C00還沒有起作用)
我們再看一下編譯后的二進制文件

在偏移為0x07的地方,對應的指令碼是
2EA1CA7C
注意到其中的CA7C(低字節在前面)了嗎? 這個就是00CA+7C00=7CCA的結果啊!
我們繼續看代碼,
;設置堆棧段和棧指針
mov ax,0
mov ss,ax
mov sp,ax
定義棧需要兩個連續的步驟,即初始化SS和SP.
*——————-小貼士—————-
原書P158上方:處理器在設計的時候就規定,當遇到修改段寄存器SS的指令時,在這條指令和下一條指令執行完畢期間,禁止中斷,以此來保護棧。也就是說,我們應該在修改SS的指令之后,緊接著一條修改SP的指令。
——————————————–*
因為已經設置了SP=SS=0,所以第一次執行PUSH指令時,先把SP減2,即0x0000-0x000=0xFFFE(借位被忽略);然后把內容送入SS:SP指向的內存單元處。如下圖所示(文章中畫的只是示意圖,不是按照比例畫的,湊合看)

~~~
mov ax,[cs:phy_base] ;計算用于加載用戶程序的邏輯段地址
mov dx,[cs:phy_base+0x02]
mov bx,16
div bx
mov ds,ax ;令DS和ES指向該段以進行操作
mov es,ax
~~~
代碼的末尾部分有
phy_base dd 0x10000 ;用戶程序被加載的物理起始地址
也就是說作者安排把用戶程序加載到物理內存0x10000處,(我們完全可以修改成別的16字節對齊的地址,只要把用戶程序加載到一個空閑的地方就可以。)
上面這幾行的意思是根據物理地址計算出邏輯段地址,[DX:AX]是被除數,BX的內容是除數(16),計算結果在AX(對于本程序,結果就是0x1000)中。然后令DS和ES都指向這個段。
~~~
;以下讀取程序的起始部分
xor di,di
mov si,app_lba_start ;程序在硬盤上的起始邏輯扇區號
xor bx,bx ;加載到DS:0x0000處
call read_hard_disk_0
~~~
這段代碼的最后調用了過程 read_hard_disk_0,我們看一下過程調用的代碼,我在代碼中加了一些注釋:
~~~
read_hard_disk_0: ;從硬盤讀取一個邏輯扇區
;輸入:DI:SI=起始邏輯扇區號
; DS:BX=目標緩沖區地址
;使用LBA28尋址方式
push ax
push bx
push cx
push dx; 用到的寄存器壓棧保存
mov dx,0x1f2
mov al,1
out dx,al ;讀取的扇區數為1
inc dx ;0x1f3
mov ax,si
out dx,al ;LBA地址7~0
inc dx ;0x1f4
mov al,ah
out dx,al ;LBA地址15~8
inc dx ;0x1f5
mov ax,di
out dx,al ;LBA地址23~16
inc dx ;0x1f6
mov al,0xe0 ;LBA28模式,主盤
or al,ah ;LBA地址27~24
out dx,al
inc dx ;0x1f7
mov al,0x20 ;讀命令
out dx,al
.waits:
in al,dx
and al,0x88
cmp al,0x08
jnz .waits ;不忙,且硬盤已準備好數據傳輸
mov cx,256 ;總共要讀取的字數
mov dx,0x1f0
.readw:
in ax,dx
mov [bx],ax
add bx,2
loop .readw
pop dx
pop cx
pop bx
pop ax
ret
~~~
要理解這段,先看下面的示意圖(參照原書圖8-11畫的)
inc dx ;0x1f6
mov al,0xe0 ;LBA28模式,主盤
or al,ah ;LBA地址27~24
out dx,al

mov al,0xe0 表示選擇LBA模式,選擇主硬盤
注意,在調用這個過程的時候,DI:SI=起始邏輯扇區號,DI的低四位是有效的,高四位應該為0,其實這里我覺得應該加一句,
mov al,0xe0 這句后面加一句 and ah,0x0f
目的是把DI的高四位清零,萬一調用者忘記清零了,這樣做可以防止意外發生。
~~~
inc dx ;0x1f7
mov al,0x20 ;讀命令
out dx,al
~~~
當把起始LBA扇區號設置好后,就可以發出讀命令了。上面的代碼表示向端口0x1F7寫入0x20,請求讀硬盤。
接下來等待讀請求完成。端口0x1F7既是命令端口,也是狀態端口。部分狀態位的含義如圖:

~~~
.waits:
in al,dx ;讀端口的值
and al,0x88 ;提取出bit3和bit7
cmp al,0x08 ;bit3==1且bit7==0說明準備好了
jnz .waits ;否則繼續檢查
~~~
一旦硬盤準備好了,就可以讀取數據了。0x1F0是硬盤接口的數據端口,是16位的。可以連續從這個端口讀取數據。
mov cx,256
in ax,dx
這兩句話就表示讀取了一個字的數據(16位)到AX中
~~~
mov cx,256 ;總共要讀取的字數
mov dx,0x1f0
.readw:
in ax,dx
mov [bx],ax ;讀取的數據放在數據段,偏移地址由BX指定
add bx,2
loop .readw
~~~
現在我們再回到那部分代碼,就很容易理解了。
~~~
xor di,di ;di清零 (因為我們傳入邏輯扇區號是100,不超過16 bits)
mov si,app_lba_start ;程序在硬盤上的起始邏輯扇區號
xor bx,bx ;加載到DS:0x0000處
call read_hard_disk_0
~~~
執行到這里,內存大概如下圖所示:

~~~
;以下判斷整個程序有多大
mov dx,[2] ;曾經把dx寫成了ds,花了二十分鐘排錯
mov ax,[0]
mov bx,512 ;512字節每扇區
div bx
cmp dx,0
jnz @1 ;未除盡,因此結果比實際扇區數少1
dec ax ;已經讀了一個扇區,扇區總數減1
@1:
cmp ax,0 ;考慮實際長度小于等于512個字節的情況
jz direct
;讀取剩余的扇區
push ds ;以下要用到并改變DS寄存器
mov cx,ax ;循環次數(剩余扇區數)
@2:
mov ax,ds
add ax,0x20 ;得到下一個以512字節為邊界的段地址
mov ds,ax
xor bx,bx ;每次讀時,偏移地址始終為0x0000
inc si ;下一個邏輯扇區
call read_hard_disk_0
loop @2 ;循環讀,直到讀完整個功能程序
pop ds ;恢復數據段基址到用戶程序頭部段
~~~
上面這段代碼是為了把剩余的用戶程序讀到內存里(以扇區為單位)
我們分別講解。
~~~
;以下判斷整個程序有多大
mov dx,[2]
mov ax,[0]
mov bx,512 ;512字節每扇區
div bx
cmp dx,0
jnz @1 ;未除盡,因此結果比實際扇區數少1
dec ax ;已經讀了一個扇區,扇區總數減1
@1:
cmp ax,0 ;考慮實際長度小于等于512個字節的情況
jz direct
~~~
因為已經約定了用戶程序的頭部4個字節是用戶程序的總長度,所以這里取總長度到[dx:ax]中,把[dx:ax]除以512,就能得到有幾個扇區。dx存放余數,ax存放商。
如果dx==0,那么就把ax減一(因為前面已經讀了一個扇區),繼續執行@1;如果dx!=0,那么剩余的扇區數就是ax,然后跳到@1;
開始執行@1處的代碼時,ax已經保存了還要讀取的扇區數,但是這個值也有可能為0,如果為0,就不用再讀取了, jz direct就可以;如果不為0,就執行下面的代碼。
好了,如果你覺得上面說得不夠清楚,那么看這個簡單的流程圖吧:

~~~
;讀取剩余的扇區
push ds ;以下要用到并改變DS寄存器
mov cx,ax ;循環次數(剩余扇區數)
@2:
mov ax,ds
add ax,0x20 ;得到下一個以512字節為邊界的段地址
mov ds,ax
xor bx,bx ;每次讀時,偏移地址始終為0x0000
inc si ;下一個邏輯扇區
call read_hard_disk_0
loop @2 ;循環讀,直到讀完整個功能程序
pop ds ;恢復數據段基址到用戶程序頭部段
~~~
mov ax,ds
add ax,0x20
mov ds,ax ;這三行表示調整ds的位置,讓ds指向最后讀入的塊的末尾,也就是將要讀入的塊的開始。其他語句都好理解,這里就不解釋了。
接下來是處理段的重定位表。我們要修正每個表項的值。
為什么要修正呢?看圖就明白了。

用戶程序在編譯的時候,每個段的段地址都是相對于程序開頭(0)計算的。但是用戶程序被加載器加到到物理地址[phy_base]的時候,相當于每個段的物理地址都向后偏移了[phy_base],所以我們要修正這個差值。
我們看看代碼:
~~~
calc_segment_base: ;計算16位段地址
;輸入:DX:AX=32位物理地址
;返回:AX=16位段基地址
push dx
add ax,[cs:phy_base]
adc dx,[cs:phy_base+0x02]
shr ax,4
ror dx,4
and dx,0xf000
or ax,dx
pop dx
ret
~~~
add ax,[cs:phy_base]
adc dx,[cs:phy_base+0x02];
這兩句其實是做了一個20位數的加法,修正后的物理地址是[dx:ax];
shr ax,4
ror dx,4
and dx,0xf000
or ax,dx;
這四句是求出段基地址(16位),也就是邏輯段地址,結果在AX中。然后回填到原處(僅覆蓋低16位,高16位不用管)。
為什么要求出段基地址呢?因為在用戶程序中,對段寄存器賦值,都是從這里引用的。
~~~
;計算入口點代碼段基址
direct:
mov dx,[0x08]
mov ax,[0x06]
call calc_segment_base
mov [0x06],ax ;回填修正后的入口點代碼段基址
;開始處理段重定位表
mov cx,[0x0a] ;需要重定位的項目數量
mov bx,0x0c ;重定位表首地址
realloc:
mov dx,[bx+0x02] ;32位地址的高16位
mov ax,[bx]
call calc_segment_base
mov [bx],ax ;回填段的基址
add bx,4 ;下一個重定位項(每項占4個字節)
loop realloc
jmp far [0x04] ;轉移到用戶程序
~~~
只要參考本文開頭的用戶程序頭部示意圖,上面這段代碼不難理解。
需要說明的是 jmp far [0x04] ;這個是16位間接絕對遠轉移指令。一定要使用關鍵字far。處理器執行這條指令的時候,會訪問DS所指向的數據段,從偏移地址0x04處取出兩個字(低字是偏移地址,高字是段基址),用低字代替IP的內容,用高字代替CS的內容,于是就可以轉移了。