《操作系统真象还原》——2.3 让MBR先飞一会儿-阿里云开发者社区

开发者社区> 开发与运维> 正文
登录阅读全文

《操作系统真象还原》——2.3 让MBR先飞一会儿

简介: 虽说主引导记录mbr是咱们能够掌控的第一个程序,但这并不是让我们为之激动的理由。我们平时所写的程序都要依赖于操作系统,而我们即将实现的这个程序是独立于操作系统的,能够直接在裸机上运行,这才是让我们激动的理由,对咱们来说这无疑是历史性的一刻。

本节书摘来自异步社区《操作系统真象还原》一书中的第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。
screenshot
screenshot

一共列出了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所示的界面。

screenshot

默认是[6],开始模拟啦。回车。

由于咱们编译的是可调试的版本,所以会停下来,bochs等待咱们键入下一步的命令,如图2-5所示。

大家看到,这一下弹出了两个界面,前面的那个是bochs所模拟的机器,可以认为它就是台电脑了,不仅仅是电脑的显示器。后面的界面是bochs的控制台,咱们控制bochs运行就要在这里输入命令。现在激活后面的bochs控制台,输入字符c后,回车。bochs所模拟的机器就开始运行了。这里键入的c是continue,调试方法同gdb类似,详细的bochs操作方法咱们会在下一章中介绍。

screenshot

MBR运行起来后,就会出现下面的效果,如图2-6所示。

screenshot

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:
开发与运维
使用钉钉扫一扫加入圈子
+ 订阅

集结各类场景实战经验,助你开发运维畅行无忧

其他文章
最新文章
相关文章