走进程序世界的田园
——引导扇区释疑
什么?又是酱鸡翅?!你眉头皱起——公司的工作餐怎么就不能出点新的花样?吹着空调还会出汗的大热的天却还要忍受这份油腻,此刻的你显然更需要西芹百合!这就如同你曾深深痴迷的这份工作,其实你是多么怀念在黑色背景上敲出一行命令之后屏幕哗哗卷动的景象,多么自豪于曾经用汇编在屏幕上绘出的彩色的图案,而如今,虚拟机,API等等新鲜玩意让你总感觉仿佛想亲吻女友的手时却亲到了手套,没有亲密感,也许,你不仅仅需要Java、.NET这样的高楼大厦,你同样需要清新的田园,比如你每天都在使用却可能一知半解的——计算机如何引导。
那么,现在就让我们一起走进这田园,看看计算机究竟是如何引导的。
计算机的启动过程
如果你买来新的计算机,硬盘上还是一片空白的时候,你按下power键,仍然可以看到许许多多字符和图案,这显然不是操作系统的一部分,而是BIOS的程序在工作。当然,最后你能看到一行字,提示你插入引导盘。是的,BIOS在寻找一个可供引导的磁盘,找到之后,便会加载盘上的引导模块,并交出控制权,将接力棒传递给操作系统。
我们考虑最简单最易学习的情况,那就是软盘。
如果你将一张非引导磁盘插入软驱的话,BIOS仍然会报错,提示你插入一张系统盘,这说明BIOS并非来者不拒全部试图加载执行的,那么它选择的标准是什么呢?实际上很简单,它会去检查软盘的0面0磁道1扇区(大小为512字节),如果发现它以55AA结束,则BIOS认为它是一个引导扇区,也就是我们说的Boot Sector。当然,一个正确的Boot Sector只有55AA这个结束标志是没有意义的,它还应该包含一段少于512字节的执行码,以便能被放在一个扇区内并正确运行。
一旦BIOS发现了Boot Sector,它就会将这512字节的整个扇区内容装载到内存的0:7c00处,然后跳转到0:7c00处将控制权彻底交给这段引导代码。到此为止,计算机不再有BIOS中固有的程序来控制,而变成由操作系统的一部分来控制。
马上实践——一个最小的引导扇区
准备工作:
硬件
一台计算机(Windows操作系统)
一张空白软盘
软件
汇编编译器NASM。最新版本可以在此链接处获得:http://sourceforge.net/projects/nasm。(此刻你可能会有疑问,为什么是NASM,而不是MASM或者TASM,对于这一点我稍候再来解释)
软盘绝对扇区读写工具。其实你完全可以自己写一个,用CreateFile和WriteFile这两个API就搞定了,非常容易。我就是这样做的,省去了在网上寻找工具的时间。
最好有一个虚拟机比如VirtualPC,可以在试验的时候不必重启自己的计算机。
代码
我们来看这一段代码:
org 07c00h ;告诉编译器程序加载到7c00处
jmp $ ;无限循环
times 510-($-$$) db 0 ;填充剩下的空间,使生成的二进制代 码恰好为512字节
dw 0aa55h ;引导扇区需要以55AA结束
在对程序进行解释之前,为了尽快看到效果有初步的感性认识,请先随我来做以下操作:
首先用NASM编译一下:
nasm boot.asm -o boot.bin
我们就得到了一个512字节大小的boot.bin,使用软盘绝对扇区读写工具将这个文件写到一张空白软盘的第一个扇区,好了,这张软盘已经是一个引导盘了。
然后把它放到你的软驱中重新启动计算机,或者使用VirtualPC模拟启动过程,从软盘引导,你看到了什么?
答案是什么令人惊喜的结果也没有出现,这倒容易理解,因为我们的程序第一个语句就是一个死循环。我们除了让程序停滞在那里,其余什么也没做。
这显然并不令人满意,我们得看到些效果才行,让我们将代码稍作修改:
org 07c00h ; 告诉编译器程序加载到7c00处
mov ax, 0b800h
mov es, ax ; 设置 es 以便直接写显存
mov byte [es:0], 'a' ; 在显存第一个字节写入字符‘a’
mov byte [es:1], 0ch ; 在显存第二个字节写入十六 进制值C,表示黑底红字
jmp $ ; 无限循环
times 510-($-$$) db 0 ; 填充剩下的空间,使生成的 二进制代码恰好为512字节
dw 0aa55h ; 引导扇区需要以55AA结束
我们在程序无限循环之前插入了四行,目的是让我们的引导程序能显示一个红色的字符‘a’。插入的这四行比较易懂,效果是在B800:0000处写入了两个字节:'a'和0Ch。我们知道,B800:0000恰好是显存的首地址。
同样的方法编译,写入磁盘并重启,你看到了什么?
你看到红色的字符a了!
多么令人激动啊,这表明我们的程序正确运行了,进一步,这说明我们自己编写的引导扇区试验成功了!
如果你用的是VirtualPC,出现的应该是这样的景象(局部):
这简直是太妙了,因为有了这样的开头,就意味着你可以在此基础上做出任意的扩展,甚至写出自己的操作系统!这一步迈出来的确并不难,但却具有历史意义!
为什么是NASM
你可能感到很奇怪,为什么居然有人用NASM这样东西,而不是你从前使用的MASM或者TASM,实际上这有点涉及到个人喜好,但是事实是,我从开始无意中接触到NASM开始,就决定从此彻底抛弃MASM了。因为它具备以下几个主要特点:
1、代码清晰,避免了MASM中容易混淆的语法。
这项特点在NASM多个细节都有体现,这里我仅举两例。
第一,在NASM中,任何不被方括号[]括起来的标签或变量名都将被认为是地址,访问标签中的内容必须使用[]。所以,mov ax, Message将会把Message对应字符串的首地址传给寄存器ax。又比如:
如果有:foo dw 1则mov ax, foo将把foo的地址传给ax,而mov bx, [foo]将把bx的值赋成为1。
实际上,在NASM中,变量和标签是一样的,也就是说,
foo dw 1 ≡ foo: dw 1
而且你会发现,offset这个东东在NASM也是不需要的。因为不加方括号时表示的就是offset。
我个人认为这是NASM的一大优点,要地址就不加方括号,也不必额外的offset,想要访问地址中的内容就必须加上方括号,代码规则非常鲜明,一目了然。
第二,既然所有标签都是地址,使得NASM具有另外一个特点,就是不记忆变量类型,所以在给变量赋值的时候,必须加上赋值的类型,比如:
mov byte [var1], 'a'
2、 可以在不同平台中使用
如果你想学习一次就可以在不同平台下使用的话,NASM几乎是唯一的选择。如果你想进行完全的代码移植,NASM是完美的工具。因为不管在Dos,Windows还是Linux,NASM都是可用的,而且用法完全相同。
3、 免费
可能这项特性已经不足以吸引你的眼球,但的确是它的一个可爱的特性。
本文不是专门的NASM介绍文章,但是我认为它的确是一个值得推荐的工具,尤其是,如果你不想仅仅了解引导扇区的写法,而是在此基础上深究下去,进行操作系统的研究,我保证你会越来越体会到NASM这一工具的优点。
关键代码解释
上面两段代码的注释已经写得比较清晰,在这里对几个问题着重强调一下。
1、org的使用
org的作用是告诉编译器,这个程序将来被加载到内存的哪个位置。我们在稍后的例子中会使用到常量,编译器就是以org指定的这个地址为基准来确定常量的地址。
2、关于$和$$
$表示当前行被汇编后的地址。这好像不太好理解,不要紧,我们把刚刚生成的二进制代码文件反汇编来看看:
ndisasmw -o 0x7c00 boot.bin >> a.asm
打开a.asm,你会发现这样一行:
00007C09 EBFE jmp short 0x7c09
$在这里的意思原来就是0x7c09(在加载到内存之后)。
那么$$表示什么?它表示一个节(section)的开始处被汇编后的地址。在这里,我们的程序只有一个节,所以$$实际上就表示程序被编译后的开始地址,也就是0x7c00。
在写程序的过程中,“$-$$”可能会被经常用到,它表示本行距离程序开始处的相对距离。现在,你应该明白510-($-$$)表示什么意思了吧?times 510-($-$$) db 0表示将0这个字节重复510-($-$$)遍,也即在剩下的空间中不停地填充0,直到程序有510字节为止,这样,加上结束标志55AA占用的两个字节,恰好是512个字节。
3、 55AA还是AA55
初学者经常被这个问题搞得非常头痛,总也搞不清楚到底谁在前谁在后,其实归根到底还是没把本质弄明白。
IBMPC的原则是“高位在高字节”。举个例子,如果有一个DWORD类型的数0x12345678放在内存中,看起来会是这样:
L ———> H
78 56 34 12
因为78处在数字的低位,于是也会被放到内存的低位。
这里有一点需要思考一下,就是计算机只知道数字,不知道类型,所以,从内存的某个地址取出一个数,必须在指明类型的情况下才是有意义的,比如已知有这样的内存映像:
L ———> H
78 56 34 12
若想取出一个BYTE,你会得到0x78;若想取出一个WORD,你会得到0x5678;若想取出一个DWORD,你会得到0x12345678。
回头看看我们的代码:
dw 0aa55h
我们指定把0aa55h这个WORD类型数字放在引导扇区最末端,aa处在数字的高位,会被放到内存的高位,于是它在内存中的映像应该是:
L———>H
55 aa
很简单,也很明了不是吗?
再作扩充——一个变一行
只显示一个字符显然是不够的,我们想要更进一步的成就感,比如显示一个字符串。可是如果显示每一个字符都要两行代码来实现的话,难免显得笨拙而低效。是的,你一定想到了,我们可以使用BIOS中断。
请看代码:
org 07c00h ; 程序会被加载到7c00处,所以需要这一句
mov ax, cs
mov ds, ax
mov es, ax
Call DispStr ; 调用显示字符串例程
jmp $ ; 无限循环
DispStr:
mov ax, BootMessage
mov bp, ax ; ES:BP = 串地址
mov cx, 16 ; CX = 串长度
mov ax, 01301h ; AH = 13, AL = 01h
mov bx, 000ch ; 页号为0(BH = 0) 黑底
红字(BL = 0Ch,高亮)
mov dl, 0
int 10h ; int 10h
ret
BootMessage: db " Hello, OS world!"
times 510-($-$$) db 0 ;填充剩下的空间,使生成的二
进制代码恰好为512字节
dw 0aa55h ; 引导扇区需要以55AA结束
这段代码看上去长了许多,但实际上主体框架只有5行(从第2行到第6行),其中调用了一个显示字符串的子程序。程序的第2、3、4行是三个mov指令,使ds和es两个段寄存器指向与cs相同的段,以便在以后进行数据操作的时候能定位到正确的位置。第5行调用子程序显示字符串,然后jmp $让程序无限循环下去。
我们来试验一下,编译,写入磁盘,启动:
成功!
来来来,下面泡一杯咖啡,然后靠在椅背上静静欣赏一下自己的成果吧,让你的屏幕暂时停在这一刻。这是一件多么有趣的作品!虽然我们的代码很短,却已经涉及到了如此多的技术细节,我们甚至使用了BIOS中断,在中断例程的帮助下,我们几乎是无所不能的,想象一下吧,最振奋人心的一点是,你可以进行磁盘操作,将更多的程序加载到内存中并且执行,这意味着你真的已经可以在这个小东西的基础上一点点扩充,甚至建造操作系统的大厦!
之所以这是田园,因为这里离泥土最近
你可能很久都没有过如此透彻地了解一件事,就好像你又看到泥土中生长出绿色的植物。这是一种回归自然的感觉,那么,就请尽情享受这一刻爽快的感受吧,忘掉Java,.NET,还有那讨厌的酱鸡翅。