本文通过汇编来阐述重定位的原理,不了解汇编的同学请先移步 汇编语言入门指南
本文参考李忠先生的《x86汇编语言:从实模式到保护模式》,若需了解详情,可移步本书(书上的例子较难,本文例子经过了简化)。
另外,本文仅在实模式下,通过汇编来描述重定位的基本过程,实际程序的重定位肯定更加复杂,如果想深刻了解程序的加载过程,请阅读神书《链接,装载与库》。
本文参考:《操作系统真相还原》《汇编语言第四版》《x86汇编语言:从实模式到保护模式》,程序的加载

需要注意的是,编译软件必须使用 nasm,不可使用 masm。原因是 nasm 可以生成 .bin 文件,.bin 文件是纯二进制文件,可以直接输入到 CPU 运行,不像 elf 或 pe 文件那样有许多描述信息。 可执行文件中包含描述信息和指令 ,这些描述信息就是我们重点要说的内容,而 masm 会自动生成描述信息,掩盖了这样过程,不利于我们探究重定位;相反,nasm 可以由我们自己来规划描述信息。废话不多说,让我们开始吧。

vstart 和 section.xxx.start 究竟是什么?

前置结论
很多朋友学习 nasm 时,都会对这两个关键词产生疑惑,最大的原因在于没有实际的应用场景,无法仔细体会其中的用处。后面当我们手写加载器和用户程序头部时,大家就会明白其中的奥秘。现在先让我们大概理解这两个关键词的作用。

1
2
3
4
5
6
7
#代码没有意义,仅作演示
section code1 align=16
mov ax,bx
section code2 align=16
mov bx,ax
section data align=16
db 'hello'

使用 align=16 使 section 以 16 位对齐。以上代码生成的二进制文件如下:

接着,我们交换 code1 段和 code2 段的位置:

1
2
3
4
5
6
section code2 align=16
mov bx,ax
section code1 align=16
mov ax,bx
section data align=16
db 'hello'

对应二进制代码如下:

可以发现,第一行二进制代码和第二行互换了位置。由此我们知道, .asm 汇编文件和其生成的 .bin 二进制文件是完全一一对应的关系,.bin 中的代码在内存的布局和 .asm 中的代码布局相同 。这是我们得到的第一个结论。下面继续。

1
2
3
4
section code1 align=16
mov ax,s
section data align=16
s: db 'hello'

对应代码如下:

B8 10 可知,标号 S 的地址为 0x10 ,恰好能和第二行代码的地址对应。由此我们得到第二个结论: 编译器给 .bin 程序中各符号分配的地址,就是各符号相对于 .asm 文件开头的偏移量

vstart
对代码做如下修改:

1
2
3
4
section code1 align=16
mov ax,s
section data align=16 vstart=0
s: db 'hello'

对应二进制代码如下:

B8 10 变成了 B8 00 ,可见,vstart 关键字改变了 S 标号的汇编地址,原本 S 标号的地址是此标号相对于文件开头的偏移量,而现在 S 标号的地址是以 data 段为起点的偏移量。换句话说, vstart 能够使段内所有标号的汇编地址都以此段的开头处计算,而非以整个程序的开头(即.asm文件开头)计算!

注意!听完上述 vstart 的作用后,我们很容易认为 vstart 能够告诉编译器将程序加载到某个固定的 偏移 地址,这么一看,编译器似乎具备了加载器的功能。其实不然,vstart 的作用仅仅是告诉编译器:“嘿,老兄,请你把我后面定义的 标号地址 从xxx为起点开始编址吧”,别无他用。它只负责编址,不负责加载,加载程序是加载器的事。 所以,用 vstart 的时机是:我预先知道我的程序将来会被加载到某个偏移地址处 。拿确切的例子来说,BIOS(加载器)会将 MBR 引导程序加载到 0000:7c00 处,所以 MBR 程序段必须用 vstart=7c00 修饰(不用管段地址,段地址由加载器决定,即使是加载到 1100:7c00,一样可以执行)。 一般情况下使用 vstart=0 (利于重定位),这是因为段在内存中都以 16 位对齐,所以进入段时,偏移地址总是从零开始,如果标号的汇编地址和内存中的偏移地址不一致,就会发生错误 。来看个简单的例子吧:

1
2
3
4
5
6
section code align=16 vstart=8
s:
push cs
push s
mov bp,sp
jmp dword [bp] ;ret

其二进制代码为:0E 68 08 00 89 E5 66 FF 66 00 。将这段程序加载到物理地址 10000 处,内存映像如下图:

由于 vstart=8 ,所以标号 S 代表的偏移地址也为 8,这就导致第 6 行代码 jmp 到错误位置 1000:8 处,然而实际应该 jmp 到 1000:0 处。这就是汇编地址与段内偏移地址不对应的后果。还一头雾水,不急,这个的确很绕,咋们继续,相信看完后面你就可以理解了。

另外,vstart=xxxorg xxx 功能相同。

section.xxx.start
section.xxx.start 是某段相对于程序开头的偏移量。举例如下:

1
2
3
4
section data align=16 vstart=0
msg db'hello world'
section code align=16 vstart=0
mov ax,section.code.start


可见,section.code.start=0x10 ,这就是 code 段相对于文件开头的偏移量。你一定会问,这玩意儿有啥用?唯一作用就是用来重定位。怎么个玩法?请继续阅读下文。

什么是程序加载器?

一个编译好的用户程序,放到磁盘中,是如何被加载到内存并运行的呢?大概的流程是加载器先把磁盘中的应用程序加载到内存并把执行权移交给应用程序。分为以下几个步骤:

  1. 从磁盘读取应用程序并装入内存(加载器的作用1)。
  2. 应用程序被装入内存后需要加载器对内存中的应用程序部分地址进行重定位(加载器的作用2)。
  3. 加载器将执行权移交应用程序(加载器的作用3)。

一般来说,加载器和用户程序对彼此而言都是黑盒子,它们不了解对方的功能和结构。那加载器如何启动用户程序呢?这就需要加载器和用户程序在事先协商一个接口,加载器通过接口去启动用户程序。实际的做法是,将这个接口放在每个用户程序的开头,即用户程序头部,加载器按约定从头部提取信息并完成加载。 用户程序头部在源程序中以一个段的形式出现。 用户程序头部至少要包含如下信息:

  1. 用户程序的尺寸 ,以字节为单位。加载器需要根据其尺寸来决定读取多少个逻辑扇区。
  2. 用户程序的入口 ,包括段地址和偏移地址。注意,这里的段地址并不是真正的段地址,而是 section.xxx.start ,加载器通过这个段地址来计算出内存中真正的逻辑段地址。
  3. 段重定位表及其表项个数 。用户程序中的所有段都会被重定位,并将位置记录在表中。

程序加载器的工作流程

下面我们以 MBR(加载器) 加载 OBR(用户程序)为例展开讨论。

MBR 和 OBR 和操作系统相关,概念不难,自行百度。注意,加载器和用户程序是相对概念,对于 BIOS 和 MBR,前者是加载器,后者是用户程序;对于 MBR 和 OBR,前者是加载器,后者是用户程序。可见,这是一种链式加载,各自完成指定的任务,不断交接接力棒。

1.初始化和决定加载位置
要加载一个程序,需要决定两个事情:1)从哪取:用户程序位于硬盘上的哪个逻辑扇区(START_SECTOR)。2)放在哪:内存中什么地方是空闲的(BASE_ADDR)。

将程序放在哪由操作系统决定;如何知道程序所在扇区,这个笔者暂时不清楚,暂且认为加载器能够通过某种方式获得用户程序所在扇区,我们暂不纠结这个问题,将注意力放在加载过程中。

2.将程序加载进内存
知道用户程序所在硬盘中的位置后,加载器访问硬盘,将用户程序读到内存中指定的位置。不过此时程序还无法运行,因为程序中可能有多个段(代码段或数据段),要从 code_A 段跳转到 code_B 段,或在 code_A 段访问 data_C 段的数据,就必须知道相应段的段地址,这必须经过段的重定位后才能确定。

3.重定位

重定位的操作者是加载器,提供重定位信息的是用户程序。段重定位信息由 section.xxx.startBASE_ADDR 确定。加载器利用此二者计算出各个段在内存中的逻辑段地址,并将其回填到用户程序头部。

4.将控制权移交给用户程序
用户程序取得控制权,接下来便可利用头部中的重定位表跳转于各个段之间。

以上是程序加载器的简单概述,下面我们结合代码来进行说明:

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
;===============文件说明:用户程序==================================================
;===============================================================================
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:
;===============================================================================
;code1——清屏并打印hello
SECTION code_1 align=16 vstart=0
printh: ;打印hello
push es

mov ax,0xb800 ;彩色字符模式视频缓冲区
mov es,ax
mov si,0
mov di,0
mov cx,10
cld
rep movsb

pop es

ret
start: ;程序入口
mov ax,ds
mov es,ax ;es作header基准,ds作用户数据段
mov ax,es:[stack_segment] ;设置用户的堆栈!
mov ss,ax
mov sp,stack_end

mov ax,es:[data_1_segment] ;设置用户的数据段!
mov ds,ax

;清屏
mov ax,0x600
mov bx,0x700
mov cx,0
mov dx,0x184f
int 0x10
call printh ;打印hello


push word es:[code_2_segment] ;注意,是word而非dword
push printw
retf ;转移到code_2

stop:
jmp $ ;在此处循环

;===============================================================================
;code2——打印'world'
SECTION code_2 align=16 vstart=0
printw:

mov ax,es:[data_2_segment]
mov ds,ax
mov ax,0xb800
mov es,ax
mov si,0
mov di,80;打印world
mov cx,10
cld
rep movsb

push word es:[code_1_segment]
push stop
retf ;返回code_1

;===============================================================================
SECTION data_1 align=16 vstart=0
db 'h',00000111B
db 'e',00000111B
db 'l',00000111B
db 'l',00000111B
db 'o',00000111B
;===============================================================================
SECTION data_2 align=16 vstart=0
db 'w',11000010B
db 'o',11000010B
db 'r',11000010B
db 'l',11000010B
db 'd',11000010B
;===============================================================================
SECTION stack align=16 vstart=0
times 256 db 0
stack_end:
;===============================================================================
SECTION trail align=16
program_end:
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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
         BASE_ADDR     equ 0x10000       ;将用户程序加载到物理位置BASE_ADDR处
START_SECTOR equ 100 ;BASE_ADDR的末位必须为0
;声明常数(用户程序起始逻辑扇区号)
;常数的声明不会占用汇编地址
SECTION mbr align=16 vstart=0x7c00
;设置堆栈段和栈指针
mov ax,0
mov ss,ax
mov sp,ax

mov eax,BASE_ADDR ;eax低4位一定为0
mov cl ,4 ;移动多位,必须使用cl
shr eax,cl
mov ds,ax ;令DS和ES指向该段以进行操作
mov es,ax

;以下读取程序的起始部分
xor di,di
mov si,START_SECTOR ;程序在硬盘上的起始逻辑扇区号
xor bx,bx ;加载到DS:0x0000处
call read_hard_disk_0

;以下判断整个程序有多大
mov dx,[2] ;曾经把dx写成了ds,花了二十分钟排错
mov ax,[0]
mov bx,512 ;512字节每扇区
div bx
cmp dx,0
jnz @1 ;未除尽,因此结果比实际扇区数少1
dec ax ;已经读了一个扇区,扇区总数减1
@1:
cmp ax,0 ;考虑实际长度小于等于512个字节的情况
jz direct

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

mov cx,ax ;循环次数(剩余扇区数)
@2:
mov ax,ds
add ax,0x20 ;得到下一个以512字节为边界的段地址
mov ds,ax

xor bx,bx ;每次读时,偏移地址始终为0x0000
inc si ;下一个逻辑扇区
call read_hard_disk_0
loop @2 ;循环读,直到读完整个功能程序

pop ds ;恢复数据段基址到用户程序头部段

;计算入口点代码段基址
direct:
mov eax,[0x06]
call calc_segment_base
mov [0x06],ax ;回填修正后的入口点代码段基址

;开始处理段重定位表
mov cx,[0x0a] ;需要重定位的项目数量
cmp cx,0
jz jmpToLoader ;如果为0项,直接跳转
mov bx,0x0c ;重定位表首地址

realloc:
mov eax,[bx]
call calc_segment_base
mov [bx],ax ;回填段的基址
add bx,4 ;下一个重定位项(每项占4个字节)
loop realloc

jmpToLoader:
jmp far [0x04] ;转移到用户程序

;-------------------------------------------------------------------------------
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
;-------------------------------------------------------------------------------
calc_segment_base: ;计算16位段地址
;输入:eax低20位有效,最低4位为0
;返回:AX=16位段基地址
add eax,BASE_ADDR
mov cl,4
shr eax,cl
ret
;-------------------------------------------------------------------------------
times 510-($-$$) db 0
db 0x55,0xaa

用户程序剖析

建议读者赋值粘贴代码到 notepad++,文件格式为 .asm,这样方便代码阅读和定位(双击标号即可定位)。

  • 头部是用户程序和加载器之间的接口,它们遵循事先规定好的约定。头部必须为单独一个 section。
  • 第 4 行,通过 program_end 确定了用户程序的大小。这是如何做到的呢?注意 102 行,段定义没有 vstart=0 ,所以该段内标号的汇编地址是从文件头开始算的,所以该标号就是文件尾相对文件的的偏移量,即文件的大小。
  • 第 39 行是易错的地方,务必要将 ds 备份,此时 ds 是用户程序被载入内存的位置,之后访问头部时,都必须使用此值作为段基址 。用 es 保存此值,而后 ds 用来充当 data 段的段基址(比如 45 行)。
  • 58 行,分别将 code_2 的段基址和偏移地址压栈后,使用 retf 远转移到 code_2。谁说函数调用必须用 call 或 jmp 的?这里使用 retf 的好处是跳转后不用手动清理栈。
  • 注意,除了最后一个段外,每个段都必须用 vstart=0 修饰!这样利于段在内存中的浮动装配(重定位),这点非常重要!
  • 为什么段重定位表的表项大小为 dd,即四个字节呢?段寄存器不是才两个字节大小吗?是这样的:还没重定位的时候,这里装的就是 section.xxx.start ,它是 xxx 段相对于文件开头的偏移量,这个偏移量可能大于 216=64KB2^{16}=64KB ,所以要用 4 个字节,32 位来装。需要注意的是,section.xxx.start 的最低 4 位(二进制下)一定是 0,这是因为我们的每个段都使用 align=16 对齐,所以每个段相对于文件开头的偏移量一定是 16 的整数倍,故最低四位一定是 0。

其他内容不在赘述,注释已经比较详尽了。

加载器剖析

  • 整个文件自成一段。mbr 段使用 vstart=0x7c00 修饰,原因是它知道 BIOS(MBR的加载器) 会将其加载到偏移地址为 0x7c00 的地方(0000:7c00)。
  • 第 2,3 行相当于 C 语言中的宏定义,使用 equ 来进行赋值。可以将这两句放在 boot.inc 文件中,然后在第一行引入该文件:%include 'boot.inc' 。不过引入头文件这用法似乎只有在 linux 下才行。
  • 第 24 行,简单举个例子:用户程序大小为 520 字节,除以 512,商 1 余 8,则该程序仍占用两个扇区。
  • 第 34 行,直接将段地址加上 0x20,即向后移动 512 字节。为什么不用偏移地址加上 512 字节呢?要知道,读取硬盘的数据一般是相对较大的,很多时候都超过了 64KB,一旦超过 64KB,偏移地址就会回卷,将之前的内存覆盖。
  • 第 11 行,实模式下可以使用 32 位寄存器,原因参见 保护模式概览
  • 第 12 行,左移或右移多位,必须将位数用 cl 装载,不能直接 shr eax,4

本文对硬盘的读取不展开描述,详细请参考《x86汇编:从实模式到保护模式》第137页,《操作系统真相还原》第 131 页。

运行

不想折腾的同学请使用 windows 平台完成运行。硬盘文件下载:链接,提取码:gzwb

Windows
1)将上面的两份代码分别写入 “loader.asm” 和 “app.asm” 中。
2)使用 nasm 生成 .bin 文件:

1
2
nasm -f bin app.asm -o app.bin
nasm -f bin loader.asm -o loader.bin

3)将 .bin 文件写入硬盘。通过上面的链接获取硬盘文件及其写入工具,打开 fixvhdwt ,硬盘选择 LEECHUNG.vhd ,数据文件选择 loader.bin ,然后写入逻辑第 0 扇区即可。重复以上步骤,将 app.bin 写入第 100 扇区。
4)在 bochs 安装目录下找到 bochsdbg.exe,打开后按下图顺序操作:

第 7 步点击 Boot Option 后,将 boot Drive1 改成 disk 即可。
5)运行。点击菜单界面右上方的 start,然后在命令行输入 c ,虚拟机屏幕出现 hello world 即成功。

Linux

1)先下载 bochs,参见配置过程参见 bochs使用。在 bochs 文件夹中打开终端,输入以下命令创建硬盘:

1
bximage -q -hd=16 -func=create -sectsize=512 -imgmode=flat ./build/hd.img

2)接着在 bochsrc 中修改如下代码:

1
ata0-master: type=disk, path="./build/hd.img", mode=flat

3)在 bochs-2.7/build 目录下,将之前的两份代码分别写入 loader.sapp.s ,然后使用如下命令分别生成 .bin 文件:

1
2
nasm -f bin app.s -o app.bin
nasm -f bin loader.s -o loader.bin

注意,可能会报错,提示 app.s 中有五行错误,只需将 es:[xxx_segment] 改为 [es:xxx_segment] 即可,这是 nasm 在 LInux 和 Windows 的小差别。
4)使用如下命令将.bin 文件写入硬盘:

1
2
dd if=./loader.bin of=./hd.img bs=512 count=1 conv=notrunc
dd if=./app.bin of=./hd.img bs=512 count=1 seek=100 conv=notrunc //将app写入100扇区

5)在 bochs-2.7 目录下运行:

1
bochs -f bochsrc

两次回车,出现如下界面:

点击左上方的 continue,出现以下界面即为成功:

world 后面的 F 哪来的我也很懵逼。。。