用户程序头部
我们知道编译器编译的用户程序和加载器实际上是在不同时间、不同地方、不同的人开发的。因此,双方都不了解对象的结构和功能,此时就需要一个结构来传达必要的信息。在看下面的文章时候最好能完全掌握地址、section、vstart的内容。
加载器必须了解一些如何加载用户程序的必要信息。这通常是加载器的编写者和用户程序的编写者互相协商决定的。经验表明,把这个约定的地方放在用户程序的开头,对双方,特别是加载器来说比较方便,这就是用户程序头部。
而用户程序头部是一个段,且是第一个被定义的段,且总是位于整个源程序的开头。
用户程序头部必须包含的信息
用户程序的尺寸(单位是字节)。加载器需要根据尺寸来决定读取多少个逻辑扇区。
用户程序的入口点,包含段地址和偏移地址。加载器并不清楚用户程序的分段情况,更不知道第一条要执行的指令在用户程序中的哪个位置。因此,必须在头部中包含第一条指令的段地址和偏移地址,也就是用户程序的入口点。你可能会想,那你上一篇文章也没有这样做呀。甚至没有用户程序头部。是这样的,很多程序可能会有多个代码段,因此需要明确指出用户程序刚开始运行的地址,我的上一篇的例子还是过于理想化了。
段重定位表。还是刚刚说的,一个程序可能会有多个代码段和数据段。这些段如何用归用户程序管,但是程序加载在内存中时地址需要重新确定。
实际上的用户程序头部还包含:校验和信息、外部依赖项、内存布局、硬件要求等等。
实例
该例子是<<x86汇编 从实模式到保护模式>>中第八章的示例代码。如果需要更多代码,可以查看。
1 | SECTION header vstart=0 ;定义用户程序头部段 |
加载器的工作流程
初始化和决定加载位置
加载器要加载一个用户程序,需要做两件事情:内存哪儿是空闲的(从哪个物理内存地址开始加载用户程序),用户程序在硬盘哪儿呢?起始逻辑扇区号是什么?
我们来看一看mbr的具体细节。来看第六行:app_lba_start equ 100
。这里的app_lba_start
就是指的就是用户程序起始逻辑扇区号号。你可能会想,我为啥要多此一举,直接传立即数不好吗?有编程语言素养的人一定会理解我说的:程序中尽可能避免 magic number
,它的不好之处我也就不赘述。使用equal
也不会占用任何汇编地址,也不会再运行的时候占用任何内存位置。
加载用户程序需要一个确定的内存物理地址,phy_base dd 0x10000
示例代码将其初始化为0x10000,你当然可以改成其他地址,只要它是空闲的,且必须16字节对齐。一般情况下,加载器及其栈的地址范围在0xA0000以上,在BIOS和外围设备的范围。
准备加载用户程序
我们将主引导扇区程序定义为一个段。比如示例代码中:SECTION mbr align=16 vstart=0x7c00
,这句话是必要的,因为需要设置vstart
。
由于phy_base
是一个32位的数,且是低端序列存放的,要先通过物理起始地址计算用于加载用户程序的逻辑段地址,所以是:
1 | mov ax,[cs:phy_base] |
- 目的操作数必须是寄存器
AL
或者AX
。原操作数应该为寄存器DX
。 in
指令不允许使用内存单元当做操作数。- 虽然可以使用立即数来指定端口,但是只允许一个字节,不能大于255的端口号。
- 它并没有影响任何的标志位。
out
指令和in
功能相反,因此要求也就是:目的操作数为寄存器DX
或者8位立即数。源操作数必须是寄存器AL
或者AX
。
1 | out 0x37,al ;8位端口 |
通过硬盘控制端口读扇区数据
我们都知道硬盘读写的基本单位是扇区。每次至少操作一个扇区,不可能仅仅读写一个扇区中的几个字节。者也就是为什么硬盘是典型的块设备,因为数据交换是块。
CHS模式
回忆一下计算机基础知识,硬盘读写数据都要什么?磁头号、柱面号、扇区号,这就是CHS
模式。
比如我们可以使用bximage
来查看我们创建的*.img
文件的信息。其中
1 | ➜ c07 git:(master) ✗ bximage |
只用看最后一行的geometry = 203/16/63
,它的C(磁头号)就是203
,H(柱面号)就是16
,S(扇区号)就是63
。最早的逻辑扇区编制方法是:LBA28
。使用28个比特来表示逻辑扇区号。逻辑扇区一共有2^28个扇区,每个扇区有512
字节,一共可管理128G。个人计算机上的主硬盘控制器被分配了8位端口,端口号从0x1f0到0x1f7。
读逻辑扇区,具体过程
假设要从硬盘上读逻辑扇区,具体过程如下:
- 设置需要读取的扇区数量,这个数值要写入
0x1f2
端口。这是个8位的端口,因此每次只能读写255个扇区,如果写入的值是0,表示读取 256 个扇区。
1 | mov dx,0x1f2 |
- 设置其实LBA扇区号。因为扇区的读写是连续的,而
LBA28
位的编号有足足28位,8086需要将其分割成4段,从低到高分别写入端口0x1f3
、0x1f4
、0x1f5
和0x1f6
。
1 | mov dx,0x1f2 |
- 向端口
0x1f7
写入0x20
,请求硬盘读,也是8位端口。
1 | inc dx ;0x1f7 |
- 等待读写操作完成。 向端口
0x1f7
发送读写命令后,0x1f7
端口的第7个标志位为”1”表示正在工作,第3个标志位为”1”表示准备好发送或者接收数据。
1 | .waits: |
不是上面刚说了不要有magic number
嘛?这里的0x88
和0x08
是什么鬼?先看add al,0x88
因为0x88
的二进制形式是1000 1000
。第7位和第3位为1,有没有反应过来?又因为是and
,这条指令实质上是保留寄存器AL
中的第7位和第3位。如果寄存器AL
中为00001000
就说明是推出等待状态,可以继续往下操作了。
- 接下来就要连续取出数据。
0x1f0
是硬盘接口的数据接口,是一个16位端口。一旦硬盘控制器空闲,且准备就绪,就可以连续从该端口写入或者读取数据。下面代码举了个例子,从硬盘中读取一个扇区,将其存放到段寄存器DS指定的数据段,偏移量由寄存器BX
指定:
1 | mov cx,256 ;总共要读取的字节数 |
你如果看的仔细,就会发现:那么0x1f1
端口是干嘛的?其实0x1f1
是错误寄存器,包含了硬盘驱动器最后一次执行命令后的状态(错误原因)。
过程调用
上面这些代码如果每次都要自己来写,那也太折磨人了,尤其是多次读写,不得疯掉。处理器支持一种叫过程调用的指令执行机制,叫做例程。它实质就是一段代码,因为每次进行的操作类似,我们像使用C语言的函数一样的思想。
1 | read_hard_disk_0: ;从硬盘读取一个逻辑扇区 |
这里的ret
作用也可以类似C语言的函数来理解,进入函数前需要将参数压入栈中,也就是开始的push ax
将操作前的寄存器的数值压入栈中。而ret
就类似于return
,将压入栈中的数值弹出,恢复各个寄存器的数值。这段代码就是我们说的过程。进入该过程时候,需要将SI
和DI
存入起始逻辑扇区号,SI
存入低16位,DI
存入高12位。
那么这个例程我们该如何调用呢?有以下三种调用方式:
16位相对近调用
近调用意思是说被调用的目标位于当前的代码段内,所以只需要偏移地址就可以了。
调用的地址计算:用目标过程的汇编地址减去当前call指令的汇编地址,再减去call
指令的字节长度。
这里的代码和调试都是从c08_mbr.asm得到的:
1 | call read_hard_disk_0 |
只看我们向看的:
1 | <bochs:14> |
所以我们可以看出来,这里并不是一个地址,而是一个偏移量。这里需要注意的是如果调用过程在当前指令前面,那么相对量是个正数,反之就是负数。再比如:
1 | call 0x0500 |
这里本质上和上一个一样,绝不是将0x0500
出现在机器码中,而是用这个数值减去当前指令的汇编地址来得到一个偏移量。
16位间接绝对近调用
这种也是近调用,只能调用当前代码段内的过程,指令中的操作数就不是偏移量了,而是被调用过程的真实偏移地址,故成为绝对地址。但是!这个偏移地址不是直接出现在指令中,而是由16位通用寄存器或者16位内存单元间接给出,比如:
1 | call cx ;被调用过程的偏移地址位于寄存器CX内,在指令执行时候有处理器从该寄存器中取得 |
16位直接绝对远
这种调用属于段间调用,即调用另一个代码段内的过程,所以被称为远调用。即需要被调用过程所在的段地址,也需要该过程在段内的偏移地址。比如:
1 | call 0x2000:0x0030 |
当然你想玩个call
的被调用过程处于当前代码段内,处理器也会从当前代码段转移到当前代码段。
16位间接绝对远调用
这也属于段间调用,被调用过程位于另一个代码段内,而且段地址和偏移地址都是间接给出的,比如:
1 | call far [0x2000] |
间接远调用必须使用关键字far
。指令中给出的是偏移地址,而段地址在偏移地址的后面。看下面的例子:
1 | proc_1 dw 0x0102,0x2000 |
当这条指令执行时,处理器访问由段寄存器DS指向的数据段,从指令中指定的偏移地址处取两个字(分别是段地址0x2000
和偏移地址0x0102
);然后将代码段寄存器CS
和指令指针寄存器IP
的当前内容压栈,最后用刚才取得的段地址和偏移地址代替CS
和IP
的数值。剩下两个也是同理。
而过程调用完就需要返回,叫做过程返回。
返回指令 ret
当它执行的时候,处理器只做一件事情,就是从栈中弹出来一个字到指令指针寄存器IP中。
返回指令 retf
当它执行的时候,处理器分别从栈中弹出来两个字到指令指针寄存器IP
和代码段寄存器CS
中。
恢复
在示例代码中,将AX
,BX
,CX
,DX
的数值push
进了栈,在过程的最后,就是恢复。反序弹出4个寄存器的数值。此时栈指针回到了进入过程内部时的位置(这很重要!)。
加载用户程序
可以看示例代码c08.asm:
1 | SECTION header vstart=0 ;定义用户程序头部段 |
和示例代码c08_mbr.asm:
1 | mov dx,[2] |
前两条指令是将program_length
也就是整个程序的大小的高16位置传送到寄存器DX
,低16位寄存器传送到寄存器AX
中。
cmp jz jnz指令理解
cmp
指令中,如果两个操作数相等,则ZF被设置为1,表示条件为假或”等于零”;如果ZF为0,表示条件为真或”不等于零”。你可能觉得反直觉,cmp
指令本质上是将两个数字相减,如果是 0 就将ZF设置为 1。
jz
和 jnz
指令分别根据 ZF 的状态来决定是否跳转。具体来说:
jnz
(Jump if Not Zero)指令会在ZF标志位不等于1时跳转,也就是在条件为真(不等于零)时跳转。jz
(Jump if Zero)指令会在ZF标志位等于1时跳转,也就是在条件为假(等于零)时跳转。
简单记就是:如果条件为真,也就是 1 ,也就是”if Not Zero”,也就对应了jnz
,jz
同理。
有了上面的知识储备,让我们来看下面代码:
1 | cmp dx,0 |
如果dx != 0
那么就跳入@1
标签内。而下面是如果ax == 0
就进入标签dircrt
处执行指令。