元芳你怎么看

本网站主要用于记录我个人学习的内容,希望对你有所帮助

0%

加载程序(器)的工作流程

用户程序头部

我们知道编译器编译的用户程序和加载器实际上是在不同时间、不同地方、不同的人开发的。因此,双方都不了解对象的结构和功能,此时就需要一个结构来传达必要的信息。在看下面的文章时候最好能完全掌握地址、section、vstart的内容。

加载器必须了解一些如何加载用户程序的必要信息。这通常是加载器的编写者和用户程序的编写者互相协商决定的。经验表明,把这个约定的地方放在用户程序的开头,对双方,特别是加载器来说比较方便,这就是用户程序头部。

而用户程序头部是一个段,且是第一个被定义的段,且总是位于整个源程序的开头。

用户程序头部必须包含的信息

  1. 用户程序的尺寸(单位是字节)。加载器需要根据尺寸来决定读取多少个逻辑扇区。

  2. 用户程序的入口点,包含段地址和偏移地址。加载器并不清楚用户程序的分段情况,更不知道第一条要执行的指令在用户程序中的哪个位置。因此,必须在头部中包含第一条指令的段地址和偏移地址,也就是用户程序的入口点。你可能会想,那你上一篇文章也没有这样做呀。甚至没有用户程序头部。是这样的,很多程序可能会有多个代码段,因此需要明确指出用户程序刚开始运行的地址,我的上一篇的例子还是过于理想化了。

  3. 段重定位表。还是刚刚说的,一个程序可能会有多个代码段和数据段。这些段如何用归用户程序管,但是程序加载在内存中时地址需要重新确定。

实际上的用户程序头部还包含:校验和信息、外部依赖项、内存布局、硬件要求等等。

实例

该例子是<<x86汇编 从实模式到保护模式>>中第八章的示例代码。如果需要更多代码,可以查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
SECTION header vstart=0                     ;定义用户程序头部段 
program_length dd program_end ;程序总长度[0x00]

;用户程序入口点
code_entry dw start ;偏移地址[0x04]
dd section.code_1.start ;段地址[0x06]

realloc_tbl_len dw (header_end-code_1_segment)/4
;段重定位表项个数[0x0a]

;段重定位表
code_1_segment dd section.code_1.start ;[0x0c]
code_2_segment dd section.code_2.start ;[0x10]
data_1_segment dd section.data_1.start ;[0x14]
data_2_segment dd section.data_2.start ;[0x18]
stack_segment dd section.stack.start ;[0x1c]

header_end:

加载器的工作流程

初始化和决定加载位置

加载器要加载一个用户程序,需要做两件事情:内存哪儿是空闲的(从哪个物理内存地址开始加载用户程序),用户程序在硬盘哪儿呢?起始逻辑扇区号是什么?

我们来看一看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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
    mov ax,[cs:phy_base] 
mov dx,[cs:phy_base+0x02]
mov bx,16
div bx
mov ds,ax 致高位,低地址存低位,且`phy_base`中是`0x10000`,则这两条指令过后:AX寄存器的数值是`0x00`,DX寄存器中是`0X01`。
之后将物理地址变成16位的段地址,并且传送给`DS`和`ES`寄存器。因为前面提到过的,它必须16字节对齐,因此直接将物理地址右移4位就好了。`AX`中存储的是除的商,余数为0没有意义。

#### 外围设备及其接口

接下来加载器就需要访问其他的硬件(eg:硬盘、鼠标、键盘)。因为和计算机主机连接的设备都需要和处理器打交道,就需要一种机制来统一。这里的外接设备都叫做**外围设备**。外围设备分为两种,一种是输入设备,比如键盘、鼠标;另一种是输出设备,比如显示器、打印机。

而每种设备的工作方式是不一样的。比如扬声器需要的是模拟信号;键盘传输的是**ASCII码**。不同设备传输的信号不一样,插头插孔都不一样、连线的数量也不一样。所以就有了*信号转换器*和*变速齿轮*,这就是**I/O接口**。比如:麦克风,扬声器的I/O接口叫做**声卡**;显示器的I/O接口叫做**显卡**;鼠标、键盘、U盘的I/O接口叫做**USB接口**。看的出来,不同设备的I/O接口不同。

**I/O接口**可以是电路板,可以是芯片,但是它的本质就是一个变换器。将处理器的信号转换成外围设备能看懂的信号,以及将外围设备的信号转换成处理器能理解的信号。

问题也随之而来:
- 总不能所有的I/O设备都和处理器相连,如果这样做了,那扩展性也基本上无,该如何解决?
- 每个I/O设备都抢着和处理器信息处理,没有一个合理的机制,一定会发生冲突,这个机制该如何实现?

第一个问题的解决方法:两者之间加一个中间层,也就是**总线**。比如:USB总线。总线连接所有的外围设备和处理器,且每个连接到总线上的器件**必须**有**电子开关**,这样才能随时和加入和断开。

第二个问题的解决方法:使用输入输出控制设备集中器(ICH)芯片。它的作用就是连接不同的总线,并且I/O设备对处理器访问的协调工作,也就是南桥。比如ICH连接USB总线、IDE/SATA总线、PCI/PCIE总线(扩展)。而且每个I/O接口可能连接不止一个设备,比如USB接口连接鼠标、键盘、U盘。因为同类型设备不唯一的缘故,它们内部也有线路服用和仲裁的总线体系,叫做通信总线或者设备总线。当处理器想和某个设备交流,ICH就会让其他无关设备闭嘴。

#### I/O端口和端口访问

在上面*外围设备及其接口*的基础上来具体说说外围设备和处理器交流之间的细节。

处理器是通过**端口**来和外围设备打交道的。而端口本质上就是一些处于I/O接口电路中的寄存器。每一个I/O接口都可能有好几个端口,分别用于不同的目的。比如用来连接硬盘的*PATA/SATA*接口就有好几个端口,比如命令端口、状态端口、参数端口和数据端口。由于其本质上是寄存器,因此和处理器内的寄存器类似,都有其对应的数据宽度。比如8位、16位,这是设备和I/O接口制造者之间的协议。

端口在不同的计算机系统中的实现方式是不同的,比如有些时将其端口号映射到内存地址中(0xE0001 ~ 0x FFFFF),访问这些内存实际上就是在访问对应的I/O接口;也有部分计算机系统是将端口独立编制的,和内存不发生关系。本文只有独立编制的方式。

所有的端口都是进行统一的编号的,比如:0x0001、0x0002。每个I/O接口电路都会分配多个端口。在Intel系统中,只允许65536个端口存在,端口号范围:0 ~ 65535。由于是进行独立编制的,就不能使用`mov`指令,取而代之的是`in`和`out`指令。

`in`指令是从端口进行读:
```asm
in al,dx ;访问8位端口
in ax,dx ;访问16位端口
in al,0xf0 ;访问0xf0端口
  1. 目的操作数必须是寄存器AL或者AX。原操作数应该为寄存器DX
  2. in指令不允许使用内存单元当做操作数。
  3. 虽然可以使用立即数来指定端口,但是只允许一个字节,不能大于255的端口号。
  4. 它并没有影响任何的标志位。

out指令和in功能相反,因此要求也就是:目的操作数为寄存器DX或者8位立即数。源操作数必须是寄存器AL或者AX

1
2
3
4
out 0x37,al     ;8位端口
out 0xf5,ax ;16位端口
out dx,al ;8位端口,端口号在DX中
out dx,ax ;16位端口,端口号在DX中

通过硬盘控制端口读扇区数据

我们都知道硬盘读写的基本单位是扇区。每次至少操作一个扇区,不可能仅仅读写一个扇区中的几个字节。者也就是为什么硬盘是典型的块设备,因为数据交换是块。

CHS模式

回忆一下计算机基础知识,硬盘读写数据都要什么?磁头号、柱面号、扇区号,这就是CHS模式。
比如我们可以使用bximage来查看我们创建的*.img文件的信息。其中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
➜  c07 git:(master) ✗ bximage
========================================================================
bximage
Disk Image Creation / Conversion / Resize and Commit Tool for Bochs
$Id: bximage.cc 14091 2021-01-30 17:37:42Z sshwarts $
========================================================================

1. Create new floppy or hard disk image
2. Convert hard disk image to other format (mode)
3. Resize hard disk image
4. Commit 'undoable' redolog to base image
5. Disk image info

0. Quit

Please choose one [0] 5

Disk image info

What is the name of the image?
[c.img] c07_mbr.img

disk image mode = 'flat'
hd_size: 104767488
geometry = 203/16/63 (99 MB)

只用看最后一行的geometry = 203/16/63,它的C(磁头号)就是203,H(柱面号)就是16,S(扇区号)就是63。最早的逻辑扇区编制方法是:LBA28。使用28个比特来表示逻辑扇区号。逻辑扇区一共有2^28个扇区,每个扇区有512字节,一共可管理128G。个人计算机上的主硬盘控制器被分配了8位端口,端口号从0x1f0到0x1f7。

读逻辑扇区,具体过程

假设要从硬盘上读逻辑扇区,具体过程如下:

  1. 设置需要读取的扇区数量,这个数值要写入 0x1f2 端口。这是个8位的端口,因此每次只能读写255个扇区,如果写入的值是0,表示读取 256 个扇区。
1
2
3
mov dx,0x1f2
mov al,0x01
out dx,al
  1. 设置其实LBA扇区号。因为扇区的读写是连续的,而LBA28位的编号有足足28位,8086需要将其分割成4段,从低到高分别写入端口0x1f30x1f40x1f50x1f6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
mov dx,0x1f2
mov al,1
out dx,al ;读取的扇区数

inc dx ;0x1f3
mov ax,si
out dx,al ;LBA地址7~0

inc dx ;0x1f4
mov al,ah
out dx,al ;LBA地址15~8

inc dx ;0x1f5
mov ax,di
out dx,al ;LBA地址23~16

inc dx ;0x1f6
mov al,0xe0 ;LBA28模式,主盘
or al,ah ;LBA地址27~24
out dx,al


  1. 向端口0x1f7写入0x20,请求硬盘读,也是8位端口。
1
2
3
inc dx                          ;0x1f7
mov al,0x20 ;读命令
out dx,al
  1. 等待读写操作完成。 向端口0x1f7发送读写命令后,0x1f7端口的第7个标志位为”1”表示正在工作,第3个标志位为”1”表示准备好发送或者接收数据。
1
2
3
4
5
.waits:
in al,dx
and al,0x88
cmp al,0x08
jnz .waits ;不忙,且硬盘已准备好数据传输

不是上面刚说了不要有magic number嘛?这里的0x880x08是什么鬼?先看add al,0x88因为0x88的二进制形式是1000 1000。第7位和第3位为1,有没有反应过来?又因为是and,这条指令实质上是保留寄存器AL中的第7位和第3位。如果寄存器AL中为00001000就说明是推出等待状态,可以继续往下操作了。

  1. 接下来就要连续取出数据。0x1f0是硬盘接口的数据接口,是一个16位端口。一旦硬盘控制器空闲,且准备就绪,就可以连续从该端口写入或者读取数据。下面代码举了个例子,从硬盘中读取一个扇区,将其存放到段寄存器DS指定的数据段,偏移量由寄存器BX指定:
1
2
3
4
5
6
7
8
       mov cx,256                      ;总共要读取的字节数
mov dx,0x1f0 ;数据接口
.readw:
in ax,dx
mov [bx],ax
add bx,2
loop .readw

你如果看的仔细,就会发现:那么0x1f1端口是干嘛的?其实0x1f1是错误寄存器,包含了硬盘驱动器最后一次执行命令后的状态(错误原因)。

过程调用

上面这些代码如果每次都要自己来写,那也太折磨人了,尤其是多次读写,不得疯掉。处理器支持一种叫过程调用的指令执行机制,叫做例程。它实质就是一段代码,因为每次进行的操作类似,我们像使用C语言的函数一样的思想。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
read_hard_disk_0:                        ;从硬盘读取一个逻辑扇区
;输入:DI:SI=起始逻辑扇区号
; DS:BX=目标缓冲区地址
push ax
push bx
push cx
push dx

mov dx,0x1f2
mov al,1
out dx,al ;读取的扇区数

inc dx ;0x1f3
mov ax,si
out dx,al ;LBA地址7~0

inc dx ;0x1f4
mov al,ah
out dx,al ;LBA地址15~8

inc dx ;0x1f5
mov ax,di
out dx,al ;LBA地址23~16

inc dx ;0x1f6
mov al,0xe0 ;LBA28模式,主盘
or al,ah ;LBA地址27~24
out dx,al

inc dx ;0x1f7
mov al,0x20 ;读命令
out dx,al

.waits:
in al,dx
and al,0x88
cmp al,0x08
jnz .waits ;不忙,且硬盘已准备好数据传输

mov cx,256 ;总共要读取的字数
mov dx,0x1f0
.readw:
in ax,dx
mov [bx],ax
add bx,2
loop .readw

pop dx
pop cx
pop bx
pop ax

ret

这里的ret作用也可以类似C语言的函数来理解,进入函数前需要将参数压入栈中,也就是开始的push ax将操作前的寄存器的数值压入栈中。而ret就类似于return,将压入栈中的数值弹出,恢复各个寄存器的数值。这段代码就是我们说的过程。进入该过程时候,需要将SIDI存入起始逻辑扇区号,SI存入低16位,DI存入高12位。

那么这个例程我们该如何调用呢?有以下三种调用方式:

16位相对近调用

近调用意思是说被调用的目标位于当前的代码段内,所以只需要偏移地址就可以了。

调用的地址计算:用目标过程的汇编地址减去当前call指令的汇编地址,再减去call指令的字节长度。
这里的代码和调试都是从c08_mbr.asm得到的:

1
call read_hard_disk_0

只看我们向看的:

1
2
3
<bochs:14> 
Next at t=17178875
(0) [0x000000007c20] 0000:7c20 (unk. ctxt): call .+81 (0x00007c74) ; e85100

所以我们可以看出来,这里并不是一个地址,而是一个偏移量。这里需要注意的是如果调用过程在当前指令前面,那么相对量是个正数,反之就是负数。再比如:

1
call 0x0500

这里本质上和上一个一样,绝不是将0x0500出现在机器码中,而是用这个数值减去当前指令的汇编地址来得到一个偏移量。

16位间接绝对近调用

这种也是近调用,只能调用当前代码段内的过程,指令中的操作数就不是偏移量了,而是被调用过程的真实偏移地址,故成为绝对地址。但是!这个偏移地址不是直接出现在指令中,而是由16位通用寄存器或者16位内存单元间接给出,比如:

1
2
3
4
call cx             ;被调用过程的偏移地址位于寄存器CX内,在指令执行时候有处理器从该寄存器中取得
call [0x3000] ;处理器访问数据段(使用DS),从偏移地址0x3000处取得一个字作为目标过程的真实偏移地址
call [bx] ;原理同2
call [bx+si+0x02] ;原理同2

16位直接绝对远

这种调用属于段间调用,即调用另一个代码段内的过程,所以被称为远调用。即需要被调用过程所在的段地址,也需要该过程在段内的偏移地址。比如:

1
call 0x2000:0x0030

当然你想玩个call的被调用过程处于当前代码段内,处理器也会从当前代码段转移到当前代码段。

16位间接绝对远调用

这也属于段间调用,被调用过程位于另一个代码段内,而且段地址和偏移地址都是间接给出的,比如:

1
2
3
4
call far [0x2000]
call far [proc_l]
call far [bx]
call far [bx+si]

间接远调用必须使用关键字far。指令中给出的是偏移地址,而段地址在偏移地址的后面。看下面的例子:

1
2
3
4
proc_1 dw 0x0102,0x2000
call far [proc_1]
call far [bx]
call far [bx+si]

当这条指令执行时,处理器访问由段寄存器DS指向的数据段,从指令中指定的偏移地址处取两个字(分别是段地址0x2000和偏移地址0x0102);然后将代码段寄存器CS和指令指针寄存器IP的当前内容压栈,最后用刚才取得的段地址和偏移地址代替CSIP的数值。剩下两个也是同理。

而过程调用完就需要返回,叫做过程返回

返回指令 ret

当它执行的时候,处理器只做一件事情,就是从栈中弹出来一个字到指令指针寄存器IP中。

返回指令 retf

当它执行的时候,处理器分别从栈中弹出来两个字到指令指针寄存器IP和代码段寄存器CS中。

恢复

在示例代码中,将AXBXCXDX的数值push进了栈,在过程的最后,就是恢复。反序弹出4个寄存器的数值。此时栈指针回到了进入过程内部时的位置(这很重要!)。

加载用户程序

可以看示例代码c08.asm:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
SECTION header vstart=0                     ;定义用户程序头部段 
program_length dd program_end ;程序总长度[0x00]

;用户程序入口点
code_entry dw start ;偏移地址[0x04]
dd section.code_1.start ;段地址[0x06]

realloc_tbl_len dw (header_end-code_1_segment)/4
;段重定位表项个数[0x0a]

;段重定位表
code_1_segment dd section.code_1.start ;[0x0c]
code_2_segment dd section.code_2.start ;[0x10]
data_1_segment dd section.data_1.start ;[0x14]
data_2_segment dd section.data_2.start ;[0x18]
stack_segment dd section.stack.start ;[0x1c]

header_end:

和示例代码c08_mbr.asm:

1
2
3
4
5
6
7
8
mov dx,[2]
mov ax,[0]

mov bx,512 ;512字节每扇区
div bx ;商存储在AX中,余数存储在DX
cmp dx,0
jnz @1 ;未除尽,因此结果比实际扇区数少1
dec ax ;已经读了一个扇区,扇区总数减1

前两条指令是将program_length也就是整个程序的大小的高16位置传送到寄存器DX,低16位寄存器传送到寄存器AX中。

cmp jz jnz指令理解

cmp指令中,如果两个操作数相等,则ZF被设置为1,表示条件为假或”等于零”;如果ZF为0,表示条件为真或”不等于零”。你可能觉得反直觉,cmp指令本质上是将两个数字相减,如果是 0 就将ZF设置为 1。

jzjnz 指令分别根据 ZF 的状态来决定是否跳转。具体来说:

  • jnz(Jump if Not Zero)指令会在ZF标志位不等于1时跳转,也就是在条件为真(不等于零)时跳转。
  • jz(Jump if Zero)指令会在ZF标志位等于1时跳转,也就是在条件为假(等于零)时跳转。

简单记就是:如果条件为真,也就是 1 ,也就是”if Not Zero”,也就对应了jnzjz同理。

有了上面的知识储备,让我们来看下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
 cmp dx,0
jnz @1

@1:
cmp ax,0 ;考虑实际长度小于等于512个字节的情况
jz direct

;读取剩余的扇区
push ds ;以下要用到并改变DS寄存器

mov cx,ax ;循环次数(剩余扇区数)

如果dx != 0那么就跳入@1标签内。而下面是如果ax == 0就进入标签dircrt处执行指令。