本节书摘来自异步社区《操作系统真象还原》一书中的第2章,第2.3节,作者:郑钢著,更多章节内容可以访问云栖社区“异步社区”公众号查看
2.3 让MBR先飞一会儿
虽说主引导记录mbr是咱们能够掌控的第一个程序,但这并不是让我们为之激动的理由。我们平时所写的程序都要依赖于操作系统,而我们即将实现的这个程序是独立于操作系统的,能够直接在裸机上运行,这才是让我们激动的理由,对咱们来说这无疑是历史性的一刻。还记得当初我的MBR跑起来时,那可真是发自内心的高兴呀。
好了,不再抒情了,说正事要紧。MBR的大小必须是512字节,这是为了保证0x55和0xaa这两个魔数恰好出现在该扇区的最后两个字节处,即第510字节处和第511字节处,这是按起始偏移为0算起的。由于我们的bochs模拟的是x86平台,所以是小端字节序,故其最后两个字节内容是0xaa55,写到一起后似乎有点不认识了,不要怕,拆开就是0x55和0xaa。
**2.3.1 神奇好用的$和
$$ ,令人迷惑的section** $和 $$
是编译器NASM预留的关键字,用来表示当前行和本section的地址,起到了标号的作用,它是NASM提供的,并不是CPU原生支持的,相当于伪指令一样,对CPU来说是假的。
指令本来没有真伪之别,就像酒一样,因为有了假酒,所以才有了真酒之说。伪指令是相对于CPU可识别的指令来说的,它(伪指令)只是编译器定义的,CPU中并不存在这个指令,愣让CPU执行这些伪指令,CPU会抛出“UD(未定义的操作码)”异常。伪指令是编译器为了开发人员写代码方便而提供的一些符号,这些符号在编译时,会由编译器转换成CPU可识别的东西,如指令或地址等。
汇编语言中的标号是程序员“显式地”写在明处的,如:
……
code_start:
mov ax, 0
……
code_start这个标号被nasm认为是一个地址,此地址便是“mov ax,0”这条指令所在的地址,即其指令机器码存放的内存位置是code_start。code_start只是个标记,CPU并不认识,和伪指令类似,它是假的,CPU不认。所以nasm会用为其安排的地址来替换标号code_start,到了CPU手中,已经被替换为有意义的数字形式的地址了。
$属于“隐式地”藏在本行代码前的标号,也就是编译器给当前行安排的地址,看不到却又无处不在,$在每行都有。或者这种说法并不是很正确,只有“显示地”用了$的地方,nasm编译器才会将此行的地址公布出来。如果上面的例子改为:
……
code_start:
jmp $
……
这就和jmp code_start是等效的。$和code_start是同一个值。
$$ 指代本section的起始地址,此地址同样是编译器给安排的。 对于$和 $$
的意义,我强调过了,是编译器给安排的地址,默认情况下,它们的值是相对于本文件开头的偏移量。至于实际安排的是多少,还要看程序员同学是否在section中添加了vstart。这个关键字可以影响编译器安排地址的行为,如果该section用了vstart=xxxx修饰,
$$ 的值则是此section的虚拟起始地址xxxx。$的值是以xxxx为起始地址的顺延。如果用了vstart关键字,想获得本section在文件中的真实偏移量(真实地址)该怎么做?nasm编译器提供了这个方法。 section.节名.start。 如果没有定义section,nasm默认全部代码同为一个section,起始地址为0。 稍带说一下section。很多东西从名字上就能理解它的功能,毕竟名字不是乱起的。section也称为节、段,故名思义,是程序中的一小块,形象一点地说,就是用section这个关键字在程序中圈出一块地,并向编译器宣称,这块地我要做些规划,至于我用来干什么您就不用操心了,编译时请您合理安排。 为什么说合理安排呢,因为section是伪指令,是nasm提供的,具体解释权还是人家nasm说了算。比如以下代码:
section data
var dd 0
section code
jmp start
… ...
编译器一看这两个section,data中定义的是变量,code中是代码,于是把这两个section的内容分别归入最终的数据段和代码段。
有时候nasm并不会完全听您的,如改为下面的例子:
section data_a
var dd 0
section code
jmp start
section data_b
var dd 1
……
虽然人为定义了三个section,但nasm发现data_a和data_b这两个section完全能够合并到一起,于是在编译阶段会被“合理”地安排到一起。
在第0章中有说明section和segment的区别。section是伪指令,CPU运行程序是不需要这个东西的,这个只是用来给程序员规划程序用的,有了section,就可以将自己的代码分成一段一段的,当然这只是在逻辑上的段,实际上编译出来的程序还是完整的一体。逻辑上划分成段的好处是方便开发人员梳理代码,方便管理。想像一下,把一大片农田按亩来划分成一个个的小段,一眼望去,是不是显得井然有序呢?单是简短的几行汇编代码是无法体现出这一优势的,就像如果农田本来就不大,还要划分成多个段,那自然是得不偿失的。当代码量上去的时候,会发现如果不在逻辑上将其拆分成几块,对一锅粥似的代码进行维护,代价还是很大的,可能一会儿脑子也像一锅粥了呢。
划分成section后,编译器便根据您的意图,将这些section中的内容安排位置,它被安排到哪里咱们是不需要关心的,咱们也不必管,因为程序内部的关联是通过地址实现的。想想看,无非是section被安排到A位置,其他用到此section中内容的相关指令,其操作数为A地址,若section被安排到B位置,操作数便是B地址,这些都是编译器安排的,它会帮您圆上的。
关于section地址更详细的说明,大家可以参照第3章,这里只是抛砖引玉。
总之,section是给开发人员逻辑上规划代码用的,只起到思路清晰的作用,最终还是在编译阶段由nasm在物理上的规划说了算。
2.3.2 NASM简单用法
在咱们的实际工程中只用到了nasm的一些简单功能,所以不必担心连操作系统的一句代码都没写呢,却先要为学习其他的东西而付出额外的精力。
nasm -f <format><filename> [-o <output>]
以上是nasm的基本用法,对咱们来说,够用了。注意我说的是“基本”,还有好多其他参数呢,不过咱们用不着。甚至,大多数时候连-f都不用呢。
-o 就是指定输出可执行文件的名称。
查看一下nasm的帮助,ok,执行man nasm回车,输出的信息太多了,我们只看-f的说明就行了。
-f format
Specifies the output file format.
To see a list of valid output formats, use the -hf option.
瞧,人家说啦,-f是用来指定输出文件的格式。要想知道有多少种有效的输出格式,用-hf选项。那咱们还是用nasm –hf来查看一下吧,见表2-2。
一共列出了21个,不过大部分格式和咱们关系不大,咱们只关注bin和elf格式就好啦。
既然bin是默认输出格式,也就是不用-f bin来明确指定了,所以以后咱们只在输出elf格式时才用-f指定。
bin是指纯二进制。二进制就二进制吧,还有不纯的?就像前面的拿酒举例一样,本来没有真酒之说,由于有了假酒的出现,才有了真的说法。纯二进制就是不掺杂其他的东西,直接给CPU后就能用,也就是可执行文件中什么样,内存中就什么样。我们平时所说的elf或pe格式的二进制可执行文件,那里面有好多和指令无关的东西,里面掺杂了程序的内存布局、位置等信息,这是给操作系统中的程序加载器用的,是属于操作系统规划的范畴了。
2.3.3 请下一位选手MBR同学做准备
有点不好意思了,说了好久,才说到实质性的东西,好了,赶紧说正题。
代码2-1 (c2/a/boot/mbr.S)
1 ;主引导程序
2 ;------------------------------------------------------------
3 SECTION MBR vstart=0x7c00
4 mov ax,cs
5 mov ds,ax
6 mov es,ax
7 mov ss,ax
8 mov fs,ax
9 mov sp,0x7c00
10
11 ; 清屏利用0x06号功能,上卷全部行,则可清屏。
12 ; -----------------------------------------------------------
13 ;INT 0x10 功能号:0x06 功能描述:上卷窗口
14 ;------------------------------------------------------
15 ;输入:
16 ;AH 功能号= 0x06
17 ;AL = 上卷的行数(如果为0,表示全部)
18 ;BH = 上卷行属性
19 ;(CL,CH) = 窗口左上角的(X,Y)位置
20 ;(DL,DH) = 窗口右下角的(X,Y)位置
21 ;无返回值:
22 mov ax, 0x600
23 mov bx, 0x700
24 mov cx, 0 ; 左上角: (0, 0)
25 mov dx, 0x184f ; 右下角: (80,25),
26 ; VGA文本模式中,一行只能容纳80个字符,共25行。
27 ; 下标从0开始,所以0x18=24,0x4f=79
28 int 0x10 ; int 0x10
29
30 ;;;;;;;;; 下面这三行代码获取光标位置 ;;;;;;;;;
31 ;.get_cursor获取当前光标位置,在光标位置处打印字符。
32 mov ah, 3 ; 输入: 3号子功能是获取光标位置,需要存入ah寄存器
33 mov bh, 0 ; bh寄存器存储的是待获取光标的页号
34
35 int 0x10 ; 输出: ch=光标开始行,cl=光标结束行
36 ; dh=光标所在行号,dl=光标所在列号
37
38 ;;;;;;;;; 获取光标位置结束 ;;;;;;;;;;;;;;;;
39
40 ;;;;;;;;; 打印字符串 ;;;;;;;;;;;
41 ;还是用10h中断,不过这次调用13号子功能打印字符串
42 mov ax, message
43 mov bp, ax ; es:bp 为串首地址,es此时同cs一致,
44 ; 开头时已经为sreg初始化
45
46 ; 光标位置要用到dx寄存器中内容,cx中的光标位置可忽略
47 mov cx, 5 ; cx 为串长度,不包括结束符0的字符个数
48 mov ax, 0x1301 ;子功能号13显示字符及属性,要存入ah寄存器,
49 ; al设置写字符方式 ah=01: 显示字符串,光标跟随移动
50 mov bx, 0x2 ; bh存储要显示的页号,此处是第0页,
51 ; bl中是字符属性,属性黑底绿字(bl = 02h)
52 int 0x10 ; 执行BIOS 0x10 号中断
53 ;;;;;;;;; 打字字符串结束 ;;;;;;;;;;;;;;;
54
55 jmp $ ; 使程序悬停在此
56
57 message db "1 MBR"
58 times 510-($-
$$
) db 0
59 db 0x55,0xaa
简短说一下代码功能,在屏幕上打印字符串“1 MBR”,背景色为黑色,前景色为绿色。
由于还没有给大家讲解显卡的使用方法,故本段代码中关于“打印显示”的操作都利用BIOS给我们建立好的例程就好了,这里第0x10号中断便是负责有关打印的例程。
0x10中断是最为强大的BIOS中断了,调用的方法是把功能号送入ah寄存器,其他参数按照BIOS中断手册的要求放在适当的寄存器中,随后执行int 0x10即可。我们不用太细致琢磨BIOS功能调用了,大家可以参数代码中的注释了解下即可,毕竟咱们这里用BIOS中断只是临时的,以后也用不到了。
第3行的“vstart=0x7c00”表示本程序在编译时,告诉编译器,把我的起始地址编译为0x7c00。
第4~8行是用cs寄存器的值去初始化其他寄存器。由于BIOS是通过jmp 0:0x7c00跳转到MBR的,故cs此时为0。对于ds、es、fs、gs这类sreg,CPU中不能直接给它们赋值,没有从立即数到段寄存器的电路实现,只有通过其他寄存器来中转,这里我们用的是通用寄存器ax来中转。例如mov ds:0x7c00,这样就错了。
第9行是初始化栈指针,在CPU上运行的程序得遵从CPU的规则,mbr也是程序,是程序就要用到栈。目前0x7c00以下暂时是安全的区域,就把它当作栈来用。
第11~28行是清屏。因为在BIOS工作中,会有一些输出,如检测硬件的结果信息。为了让大家看清楚我们在MBR中的输出字符串,故先把BIOS的输出清掉,这里演示的是BIOS中断int 0x10的用法。
第30~35行是做打印前的工作,先获取光标位置,目的是避免打印字符混乱,覆盖别人的输出。其实这是防君子不防小人的做法,万一别人不在光标处打印,自己打印的内容同样也会被别人覆盖。不管别人了,咱们做好自己的就行,老老实实地只在光标处打印。不知道这是否能提醒大家,字符打印的位置,不一定要在光标处,字符的位置只和显存中的地址有关,和光标是没关系的,这只是人为地加个约束,毕竟光标在视觉上告诉了我们当前字符写到哪里了,完全是为了好看,不要以为光标就是新打印字符的位置。更多细节,以后讲显卡时会提到。
这里还用到了页的概念,您看第33行,往bh寄存器中写入了0,这是告诉BIOS例程,我要获取第0页当前的光标。什么是页呢?
显示器有很多种模式,如图形模式、文本模式等,在文本模式中,又可以工作于8025和4025等显示方式,默认情况下,所有个人计算机上的显卡在加电后都将自己置为8025这种显示方式。8025是指一屏可以显示25行、每行80列的字符,也就是2000个字符。但由于一个字符要用两字节来表示,低字符是字符的ASCII编码,高字节是字符属性,故显示一屏字符需要用4000字节(实际上,分配给一屏的容量是4KB),这一屏就称为一页,0页是默认页。
第38~52行是往光标处打印字符。说一下第48行的mov ax,0x1301,13对应的是ah寄存器,这是调用0x13号子功能。01对应的是al寄存器,表示的是写字符方式,其低2位才有意义,各位功能描述如下。
(1)al=0,显示字符串,并且光标返回起始位置。
(2)al=1,显示字符串,并且光标跟随到新位置。
(3)al=2,显示字符串及其属性,并且光标返回起始位置。
(4)al=3,显示字符串及其属性,光标跟随到新位置。
第55行执行了个死循环,$是本行指令的地址,这属于伪指令,是汇编器在编译期间分配的地址。在最终编译出来的程序中,$会被替换为指令实际所在行的地址。jmp是个近跳转,$是jmp自己的地址,于是跳到自己所在的地址再执行自己,又是跳到自己所在的地址再继续执行跳转,这样便实现了死循环。可见CPU可乖了,它只会埋头做事,并不会觉得有什么不妥,靠谱,值得依赖。
第57行是定义打印的字符串。
第58行的
$$ 是指本section的起始地址,上面说过了$是本行所在的地址,故$- $$
是本行到本section的偏移量。由于MBR的最后两个字节是固定的内容,分别是0x55和0xaa,要预留出这2个字节,故本扇区内前512-2=510字节要填满,那到底要用多少字节才能填满此扇区呢。用510字节减去上面通过$-
$$ 得到的偏移量,其结果便是本扇区内的剩余量,也就是要填充的字节数。由此可见第50行的“times 510-($- $$
) db 0”是在用0将本扇区剩余空间填充。
代码说完了,可还有两件大事要做,1是编译,2是如何将编译后的文件存储到0盘0道1扇区中成为MBR,以供BIOS大神加载之用。
前面介绍了nasm的用法,咱们马上来编译汇编代码。
nasm -o mbr.bin mbr.S回车,您看,这样就编译成功了,我连-f都没有指定吧。按理说此文件大小是512字节,咱们用ls命令验证一下:ls -lb mbr.bin回车,以下是ls的输出。
-rw-rw-r--. 1 work work 512 7月 26 21:10 mbr.bin
用过Linux的同学对这个输出还是很熟悉的,若头一次用Linux的同学也不要慌张,这里面好多的信息并不重要,只要看看中间部分就好了,512,果然是512字节,这下心里踏实了,下一步是考虑如何将此文件写入0盘0道1扇区。
这里再给大家介绍另一个Linux命令:dd。dd是用于磁盘操作的命令,功能太强大了,有如穿甲弹一样,可以深入磁盘的任何一个扇区,无坚不摧。所以,它也可以删除Linux操作系统自己的文件,是把双刃剑。
还是先看帮助文件,man dd回车,为了节约大家的时间,我只把咱们今后用到的几个选项摘了出来,还是那句话,够用就行了,需要时再学。
if=FILE
read from FILE instead of stdin
此项是指定要读取的文件。
of=FILE
write to FILE instead of stdout
此项是指定把数据输出到哪个文件。
bs=BYTES
read and write BYTES bytes at a time (also see ibs=,obs=)
此项指定块的大小,dd是以块为单位来进行IO操作的,得告诉人家块是多大字节。此项是统计配置了输入块大小ibs和输出块大小obs。这两个可以单独配置。
count=BLOCKS
copy only BLOCKS input blocks
此项是指定拷贝的块数。
seek=BLOCKS
skip BLOCKS obs-sized blocks at start of output
此项是指定当我们把块输出到文件时想要跳过多少个块。
conv=CONVS
convert the file as per the comma separated symbol list
此项是指定如何转换文件。
append append mode (makes sense only for output; conv=notrunc suggested)
这句话建议在追加数据时,conv最好用notrunc方式,也就是不打断文件。
齐了,dd的介绍就到这了,赶紧试验一下这个神奇的工具吧。
dd if=/your_path/mbr.bin of=/your_path/bochs/hd60M.img bs=512 count=1 conv=notrunc
各位看官,请将上面命令行中的your_path替换为您自己的实际路径。
输入文件是刚刚编译出来的mbr.bin,输出是我们虚拟出来的硬盘hd60M.img,块大小指定为512字节,只操作1块,即总共1*512=512字节。由于想写入第0块,所以没用seek指定跳过的块数。
执行上面的命令后,会有如下输出。
记录了1+0 的读入
记录了1+0 的写出
512字节(512 B)已复制,0.313312 秒,1.6 KB/秒
这就说明命令执行成功了,mbr.bin已经写进hd60M.img的第0块了。借鉴美国宇航员阿姆斯特朗的一句话:虽然这只是简单的一小步,但却是实现我们自己系统的一大步。记得当初我可是非常激动呢。
启动bochs测试一下,我习惯到bochs安装目录下启动它,bin/bochs –f bochsrc.disk回车,接着会显示如图2-4所示的界面。
默认是[6],开始模拟啦。回车。
由于咱们编译的是可调试的版本,所以会停下来,bochs等待咱们键入下一步的命令,如图2-5所示。
大家看到,这一下弹出了两个界面,前面的那个是bochs所模拟的机器,可以认为它就是台电脑了,不仅仅是电脑的显示器。后面的界面是bochs的控制台,咱们控制bochs运行就要在这里输入命令。现在激活后面的bochs控制台,输入字符c后,回车。bochs所模拟的机器就开始运行了。这里键入的c是continue,调试方法同gdb类似,详细的bochs操作方法咱们会在下一章中介绍。
MBR运行起来后,就会出现下面的效果,如图2-6所示。