此节github项目地址
计算机的启动过程
当我们按下计算机的power键后,首先运行的就是BIOS,全程为Basic Input/Output System。
BIOS用于电脑开机时运行系统各部分的的自我检测(Power On Self Test),并加载引导程序或存储在主存的操作系统。
由于BIOS是计算机上第一个软件,所以它的启动依靠硬件。
由于BIOS与硬件系统集成在一起(将BIOS程序指令刻录在IC中),所以有时候也被称为固件。在大约1990年BIOS是保存在ROM(只读内存)中而无法被修改。因为BIOS的大小和复杂程度随时间不断增加,而且硬件的更新速度加快,令BIOS也必须不断更新以支持新硬件,于是BIOS就改为存储在EEPROM或者闪存中,让用户可以轻易更新BIOS。
于是BIOS被写进ROM,这块内存被映射在低端1MB内存的顶部,即0xF0000~0xFFFFF。插一句,在实模式下,有20根地址线,因此可以访问1MB的内存空间。来看看实模式下1MB内存的布局:
这个图很重要,会多次用到!
那么BIOS被启动以后,下一棒要交给谁呢?
BIOS的最后一项工作就是校验启动盘中位于0盘0道1扇区的内容。为什么是1扇区不是0扇区,这是因为CHS方法(Cylinder柱面-Header磁头-Sector扇区)中扇区的编号是从1开始编号的。如果检查到此扇区末尾的两个字节分别是0x55和0xaa,BIOS就认为此扇区中确实存在可执行程序(此程序便是我们这节讨论的MBR),便加载到物理地址0x7c00,然后跳转到此地址执行。若检查的最后两个字节不是0x55和0xaa,那么就算里面有可执行代码也不能执行了。
当MBR接受了BIOS传来的接力棒,它又做了那些事情呢?
首先了解一下MBR:主引导记录(Master Boot Record,缩写:MBR),又叫做主引导扇区,是计算机开机后访问硬盘时所必须要读取的首个扇区。但是它只有512字节大小,没办法把内核加载到内存并运行,我们要另外实现一个程序来完成初始化和加载内核的任务,这个程序叫做Loader。
所以MBR的使命,就是从硬盘把loader加载到内存,就可以把接力棒交给loader了。loader的实现我们下节再讲。
不过还得多说一句,现在我们还在实模式下晃悠。
实模式
我们已经在前面提过了实模式,那么实模式到底是什么,和保护模式又有什么区别?
实模式指的是8086CPU的工作环境,工作方式,工作状态等等这一系列内容。
在最开始的模式里,程序用的地址都是真实的物理地址,“段基址:段内偏移地址”的策略在8086CPU上首次出现,CPU运行环境为16位。
缺点也显而易见,没有对系统级别程序做任何保护,用户程序可以自由访问所以内存;20根地址线,1MB的内存大小远远不够用。
直到32位CPU出现,打破了上述囧境。我们也等以后再具体讨论保护模式。
编写MBR,初见显存
回忆一下前面讲的,BIOS要检测到MBR的最后两个字节为0x55和0xaa,然后才会开始执行MBR中的代码。
首先我们得知道MBR的具体地址,好,前面说过是0x7c00。
那么为什么是这个数字,网上有篇文章解释的很好:
为什么主引导记录的内存地址是0x7C00?
作为一只初学的萌新,我们先不谈让MBR干什么大事,先测试一下能否从BIOS跳到MBR如何?我们的初版MBR的任务就是显示彩色的“Hello MBR”。一旦BIOS能跳转过来,就在屏幕上打印这个字符串。
这就牵扯到了另一个问题?如何在屏幕上显示东西。
如果你对不学计算机的同学提这个问题,怕是要招来异样的目光O(∩_∩)O。还好,大家是同行。在屏幕上显示字符,用专业一点的话来讲就是对显存进行写入。
我们将使用2种方法,1是利用BIOS的中断调用服务,2是直接写入显存。
我们都学过计组,因此应该了解ASCII码,而显卡在任何时候都认为你发送的是ASCII码,如果你要发送数字5,应该发送数字5的ASCII码。
显卡的文本模式有多种,在此我使用默认的80*25。每个字符在屏幕上都是用连续的2个字节来表示的,低字节是字符的ASCII码,高字节的低4位是字符前景色,高4位是字符背景色。
K位是闪烁位,0不闪烁,1闪烁。I是亮度位,0正常,1高亮。
BIOS第10h号中断调用
看看维基百科:INT 10H
INT 10h INT 10H 或者 INT 16 是BIOS中断调用的第10H功能的简写, 在基于x86的计算机系统中属于第17中断向量。BIOS通常在此创建了一个中断处理程序提供了实模式下的视频服务。此类服务包括设置显示模式,字符和字符串输出,和基本图形(在图形模式下的读取和写入像素)功能。要使用这个功能的调用,在寄存器AH赋予子功能号,其它的寄存器赋予其它所需的参数,并用指令INT 10H调用。INT 10H的执行速度是相当缓慢的,所以很多程序都绕过这个BIOS例程而直接访问显示硬件。设置显示模式并不经常使用,可以通过BIOS来实现,而一个游戏在屏幕上绘制图形,需要做得很快,所以直接访问显存比用BIOS调用每个像素更适合。
代码:
;mbr.S 调用BIOS 10H号中断
;显示Hello MBR
;-------------------------------------
SECTION MBR vstart=0x7c00
;vstart作用是告诉编译器,把我的起始地址编为0x7c00
mov ax, cs ;用cs寄存器的值初始化别的寄存器
;由于mbr是由jmp 0:0x7c00跳过来的,cs=0
mov ds, ax
mov es, ax
mov ss, ax
mov fs, ax
mov sp, 0x7c00
;清屏(向上滚动窗口)
;AH=06H,AL=上滚行数(0表示全部)
;BH=上卷行属性
;(CL,CH)=窗口左上角坐标,(DL,DH)=窗口右下角
;--------------------------------------
mov ax, 0x600
mov bx, 0x700
mov cx, 0 ;左上角(0,0)
mov dx, 0x184f ;右下角(79,24),
;VGA文本模式中一行80个字符,共25行
int 0x10
;AH=13H 写字符串
;AL=写模式,BH=页码,BL=颜色,CX=字符串长度,DH=行,DL=列,ES:BP=字符串偏移量
;--------------------------------------
mov ax, message
mov bp, ax
mov cx, 9
mov ax, 0x1301
mov dx, 0x0909
mov bx, 0x00EE ;我的设置为黄色前景棕色背景+闪烁
int 0x10
;-------------------------------------
jmp $
message db "Hello MBR"
times 510-( $-$$ ) db 0
db 0x55, 0xaa
程序开头出现了vstart这个词,我来解释一下vstart的意义。
vstart=xxxx的用处就是告诉编译器:你帮我把后面的数据的地址都从xxxx开始编吧。不然的话,编译器会把数据相对文件头的偏移量作为数据的地址,那么就全都是从0开始往后加。
在程序所在目录下执行以下代码,我的程序名为mbr.S,路径为~/MoonOs/C1/boot
nasm -o mbr.bin mbr.S
这句话意思是把mbr.S汇编成纯二进制文件(默认格式)。如果要汇编成别的格式,可参考具体nasm中文手册。
然后执行(注意将每个路径换成你自己的文件路径,硬盘名称(hd.img)也要换成你自己设置的名字)
dd if=/your-path/mbr.bin of=/your-path/hd.img bs=512 count=1 conv=notrunc
dd是Linux下用于磁盘操作的命令,在Linux下man dd
即可查看。
上面的命令是:读取mbr.bin,把数据输出到我们指定的硬盘hd.img中,块大小指定为512字节,只操作1块。
然后在bochs目录下执行:
bochs -f bochsrc
因为我的配置文件为bochsrc,所以你们可以根据自己的情况修改。
最后效果如下:
直接写入显存
无论是哪种显示器,都是由显卡控制的。而无论哪种显卡,都提供了IO端口和显存。显存是位于显卡内部的一块内存。
要往显存里写东西,得先了解显存的布局。
我们使用文本模式,就要从0xB8000开始写入。我们往这块内存里输入的字符会直接落入显存,也就可以显示在屏幕上面了。
;mbr2.S 直接写入显存
;显示Hello MBR
;-------------------------------------
SECTION MBR vstart=0x7c00
;vstart作用是告诉编译器,把我的起始地址编为0x7c00
mov ax, cs ;用cs寄存器的值初始化别的寄存器
;由于mbr是由jmp 0:0x7c00跳过来的,cs=0
mov ds, ax
mov ss, ax
mov fs, ax
mov sp, 0x7c00
mov ax, 0xb800
mov es, ax
;清屏(向上滚动窗口)
;AH=06H,AL=上滚行数(0表示全部)
;BH=上卷行属性
;(CL,CH)=窗口左上角坐标,(DL,DH)=窗口右下角
;--------------------------------------
mov ax, 0x600
mov bx, 0x700
mov cx, 0 ;左上角(0,0)
mov dx, 0x184f ;右下角(79,24),
;VGA文本模式中一行80个字符,共25行
int 0x10
;直接从(0,0)开始写入
;---------------------------------------
mov byte [es: 0x00], 'H'
mov byte [es: 0x01], 0xEE
mov byte [es: 0x02], 'e'
mov byte [es: 0x03], 0xEE
mov byte [es: 0x04], 'l'
mov byte [es: 0x05], 0xEE
mov byte [es: 0x06], 'l'
mov byte [es: 0x07], 0xEE
mov byte [es: 0x08], 'o'
mov byte [es: 0x09], 0xEE
mov byte [es: 0x0A], ' '
mov byte [es: 0x0B], 0xEE
mov byte [es: 0x0C], 'M'
mov byte [es: 0x0D], 0xEE
mov byte [es: 0x0E], 'B'
mov byte [es: 0x0F], 0xEE
mov byte [es: 0x10], 'R'
mov byte [es: 0x11], 0xEE
;-------------------------------------
jmp $
times 510-( $-$$ ) db 0
db 0x55, 0xaa
效果如下:
MBR进阶,使用硬盘
我们的MBR当然不止是在屏幕上显示“hello MBR”就完事了,前面提到过MBR要从硬盘上把Loader加载到内存并且运行,并把接力棒交给它。
也许你会有如下疑问:
为什么要把loader加载入内存?
首先我们要知道MBR和操作系统都是位于硬盘上的。CPU的硬件电路被设计为只能运行处于内存中的程序,因为CPU运行内存中程序更快。所以CPU要从硬盘读取数据,决定把它加载到内存的什么位置。
怎样控制硬盘
CPU只能同IO接口进行交流,那么CPU要和硬盘交流的话,也一定要通过IO接口,硬盘的IO接口就是硬盘控制器。再具体一点,就是硬盘控制器与CPU之间通信是通过端口。所谓端口,其实就是一些位于IO接口中的寄存器。不同的端口有着不同的作用。
可以看到,端口分成了两组,我们重点看Command Block registers。
data寄存器的作用是读取或写入数据,16位(其他寄存器都是8位)。在读硬盘时,硬盘准备好数据后,硬盘控制器将其放在内部的缓冲区中,不断读此寄存器就是在读出缓冲区中的数据。写硬盘时,我们把数据送到此端口,数据就被存入缓冲区,硬盘控制器发现这个缓冲区中有数据了,就把这些数据写入相应扇区。
读硬盘时,端口0x171或0x1F1寄存器叫Error寄存器。只有在读取失败时有用,里面会记录失败信息,尚未读取的硬盘数在Sector count寄存器中。写硬盘时,这个寄存器叫Feature寄存器。用来记录一些参数。
Sector count寄存器用来指定待读取或待写入的扇区数。硬盘每完成一个扇区,就会把此寄存器值减1,如果中间失败了,那这个寄存器的值就是未完成的扇区数。如果它被指定为0,表示要操作256个扇区。
LBA寄存器涉及到LBA方法。
从硬盘读写数据,最经典的就是像硬盘控制器分别发送柱面号,磁头号,扇区号,就是我们前面说过的CHS模式。但是如果把扇区统一编址,把他们看做逻辑扇区,全都是从0开始编号,这样就能节省很多麻烦,这就是LBA方法。
最早的逻辑扇区编址方法是LBA28,用28个比特表示逻辑扇区号。则LBA28可以管理128GB的硬盘。随着硬盘技术的发展,LBA48已经出现,可管理容量达到131072TB。
但是我们为了方便,在这里使用LBA28模式。
LBA寄存器有low mid high 三个,都是8位。但是这三个也只能表示24位,剩下4位被放在device寄存器的低4位。
所以我们可以看出device寄存器是个杂项寄存器。它的第4位用来指定通道上的主盘(o)或从盘(1),第6位用来设置是LBA(1)方式还是CHS(0)方式,第5和7位固定为1。
读硬盘时,端口号为0x1F7或0x177的寄存器是Status,用来给出硬盘状态信息。第0位是ERR位,若此位为1,表情命令出错。第3位是data request位,若为1,表示硬盘已经准备好数据,主机可以把数据读出来了,第7位是BSY位,为1表示硬盘正在忙着,此寄存器中其他位都无效。写硬盘时,它是Command寄存器,把命令写进此寄存器,硬盘就可以开始工作了。
读扇区:0x20
写扇区:0x30
操作步骤如下:
- 先选择通道,往该通道的sector count寄存器写入待操作的扇区数
- 往该通道上的三个LBA寄存器写入扇区起始地址的低24位
- 往device寄存器中写入LBA地址的24~27位,并置第6位为1,使其为LBA模式,设置第4位,选择操作的硬盘(master硬盘或slave硬盘)
- 往该通道上的command寄存器写入操作命令
- 读取该通道上的status寄存器,判断硬盘工作是否完成
- 如果以上步骤是读硬盘,则进入下一个步骤,否则,结束
- 将硬盘数据读出
改造MBR
我们的MBR现在在第0扇区(LBA方式),那么不如将loader放在第2扇区,中隔一个扇区安全一些。MBR把loader读出来后,可以选择实模式下1MB的空闲内存存放。回顾前面的图,看到0x500-0x7BFF可用,0x7E00-0x9FBFF可用。因为内核地址增长是从低到高的,所以我们尽量选低地址加载loader,因此选择0x900。
;mbr_disk.S
;读取硬盘,把loader加载到0x900,并跳转过去
;-------------------------------------
LOADER_BASE_ADDR equ 0x900
LOADER_START_SECTOR equ 0x2
SECTION MBR vstart=0x7c00
;vstart作用是告诉编译器,把我的起始地址编为0x7c00
mov ax, cs ;用cs寄存器的值初始化别的寄存器
;由于mbr是由jmp 0:0x7c00跳过来的,cs=0
mov ds, ax
mov es, ax
mov ss, ax
mov fs, ax
mov sp, 0x7c00
mov ax, 0xb800
mov es, ax
;清屏(向上滚动窗口)
;AH=06H,AL=上滚行数(0表示全部)
;BH=上卷行属性
;(CL,CH)=窗口左上角坐标,(DL,DH)=窗口右下角
;--------------------------------------
mov ax, 0x600
mov bx, 0x700
mov cx, 0 ;左上角(0,0)
mov dx, 0x184f ;右下角(79,24),
;VGA文本模式中一行80个字符,共25行
int 0x10
;直接从(0,0)开始写入
;---------------------------------------
mov byte [es: 0x00], 'H'
mov byte [es: 0x01], 0xEE
mov byte [es: 0x02], 'e'
mov byte [es: 0x03], 0xEE
mov byte [es: 0x04], 'l'
mov byte [es: 0x05], 0xEE
mov byte [es: 0x06], 'l'
mov byte [es: 0x07], 0xEE
mov byte [es: 0x08], 'o'
mov byte [es: 0x09], 0xEE
mov byte [es: 0x0A], ' '
mov byte [es: 0x0B], 0xEE
mov byte [es: 0x0C], 'M'
mov byte [es: 0x0D], 0xEE
mov byte [es: 0x0E], 'B'
mov byte [es: 0x0F], 0xEE
mov byte [es: 0x10], 'R'
mov byte [es: 0x11], 0xEE
mov eax, LOADER_START_SECTOR ;起始扇区lba地址
mov bx, LOADER_BASE_ADDR ;写入的地址
mov cx, 1 ;待读入的扇区数
call rd_disk_m_16
jmp LOADER_BASE_ADDR
;读取硬盘n个扇区
rd_disk_m_16:
;-------------------------------
;eax=LBA起始扇区号
;bx=数据写入的内存地址
;cx=读入的扇区数
mov esi, eax ;备份eax,cx
mov di, cx
;step1:设置读取的扇区数
mov dx, 0x1f2
mov al, cl ;访问8位端口时,使用寄存器AL
out dx, al ;将AL中的数据写入端口号为0x1f2的寄存器中
;out目的操作数可以是8位立即数或者寄存器DX
;源操作数必须为AL或AX
mov eax, esi
;step2:将LBA地址写入0x1f3-0x1f6(在这里我们的地址就是2)
;0x1f3放0-7位
mov dx, 0x1f3
out dx, al
;0x1f4放8-15位
mov cl, 8
shr eax, cl ;右移8位
mov dx, 0x1f4
out dx, al
;0x1f5放16-23位
shr eax, cl
mov dx, 0x1f5
out dx, al
shr eax, cl
and al, 0x0f
or al, 0xe0 ;设置7-4位为1110,LBA模式,主盘
mov dx, 0x1f6
out dx, al
;step3:往Command寄存器写入读命令
mov dx, 0x1f7
mov al, 0x20
out dx, al
;step4:检查硬盘状态
.not_ready:
nop
in al, dx
and al, 0x88
cmp al, 0x08
jnz .not_ready
;step5:从0x1f0端口读出数据
mov ax, di ;DI为要读取的扇区数,data寄存器为16位,即每次读2个字节,要读DI*512/2次
mov dx, 256
mul dx ;mul指令的被乘数隐含在AX中,乘积的低16位在AX中,高16位存在DX中
mov cx, ax ;把AX的值赋给CX,用作计数器
mov dx, 0x1f0
.go_on_read:
in ax, dx ;把0x1f0端口读出的数据放在AX寄存器中
mov [bx], ax ;再把AX寄存器中的内容放在偏移地址为BX指向的内存空间
add bx, 2 ;一次读1个字
loop .go_on_read
ret ;记得调用函数后要返回
;-------------------------------------
times 510-( $-$$ ) db 0
db 0x55, 0xaa
然后在你自己的文件目录下依次执行:
nasm -o mbr_disk.bin mbr_disk.S
dd if=mbr_disk.bin of=/your-path/bochs-2.6.9/hd.img bs=512 count=1 conv=notrunc
我们现在的loader还什么都没干了,那干脆就让它显示“hello you”好了
;loader.S暂时什么也不做,只显示"hello You"
;-------------------------------------
LOADER_BASE_ADDR equ 0x900
LOADER_START_SECTOR equ 0x2
SECTION LOADER vstart=LOADER_BASE_ADDR
mov byte [es: 0x00], 'H'
mov byte [es: 0x01], 0xEE
mov byte [es: 0x02], 'e'
mov byte [es: 0x03], 0xEE
mov byte [es: 0x04], 'l'
mov byte [es: 0x05], 0xEE
mov byte [es: 0x06], 'l'
mov byte [es: 0x07], 0xEE
mov byte [es: 0x08], 'o'
mov byte [es: 0x09], 0xEE
mov byte [es: 0x0A], ' '
mov byte [es: 0x0B], 0xEE
mov byte [es: 0x0C], 'Y'
mov byte [es: 0x0D], 0xEE
mov byte [es: 0x0E], 'o'
mov byte [es: 0x0F], 0xEE
mov byte [es: 0x10], 'u'
mov byte [es: 0x11], 0xEE
jmp $
依次执行:
nasm -o loader.bin loader.S
dd if=loader.bin of=/your-path/bochs-2.6.9/hd.img bs=512 count=1 seek=2 conv=notrunc
一定要注意这里seek=2
,意思是跳过2块,因为我们的Loader在2号扇区。缺少seek的话会出错。
最后在bochs目录下执行:
bochs -f bochsrc
效果如下:
如果看到“Hello You”,说明程序的确从MBR跳转到了Loader!
下一节我们将会写一个有用的Loader,并介绍保护模式。