剖析重定位——程序加载器/vstart解析
本文通过汇编来阐述重定位的原理,不了解汇编的同学请先移步 汇编语言入门指南 。
本文参考李忠先生的《x86汇编语言:从实模式到保护模式》,若需了解详情,可移步本书(书上的例子较难,本文例子经过了简化)。
另外,本文仅在实模式下,通过汇编来描述重定位的基本过程,实际程序的重定位肯定更加复杂,如果想深刻了解程序的加载过程,请阅读神书《链接,装载与库》。
本文参考:《操作系统真相还原》《汇编语言第四版》《x86汇编语言:从实模式到保护模式》,程序的加载
需要注意的是,编译软件必须使用 nasm,不可使用 masm。原因是 nasm 可以生成 .bin 文件,.bin 文件是纯二进制文件,可以直接输入到 CPU 运行,不像 elf 或 pe 文件那样有许多描述信息。 可执行文件中包含描述信息和指令 ,这些描述信息就是我们重点要说的内容,而 masm 会自动生成描述信息,掩盖了这样过程,不利于我们探究重定位;相反,nasm 可以由我们自己来规划描述信息。废话不多说,让我们开始吧。
vstart 和 section.xxx.start 究竟是什么?
前置结论
很多朋友学习 nasm 时,都会对这两个关键词产生疑惑,最大的原因在于没有实际的应用场景,无法仔细体会其中的用处。后面当我们手写加载器和用户程序头部时,大家就会明白其中的奥秘。现在先让我们大概理解这两个关键词的作用。
1 | #代码没有意义,仅作演示 |
使用 align=16
使 section 以 16 位对齐。以上代码生成的二进制文件如下:
接着,我们交换 code1
段和 code2
段的位置:
1 | section code2 align=16 |
对应二进制代码如下:
可以发现,第一行二进制代码和第二行互换了位置。由此我们知道, .asm 汇编文件和其生成的 .bin 二进制文件是完全一一对应的关系,.bin 中的代码在内存的布局和 .asm 中的代码布局相同 。这是我们得到的第一个结论。下面继续。
1 | section code1 align=16 |
对应代码如下:
由 B8 10
可知,标号 S 的地址为 0x10
,恰好能和第二行代码的地址对应。由此我们得到第二个结论: 编译器给 .bin 程序中各符号分配的地址,就是各符号相对于 .asm 文件开头的偏移量 。
vstart
对代码做如下修改:
1 | section code1 align=16 |
对应二进制代码如下:
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 | section code align=16 vstart=8 |
其二进制代码为:0E 68 08 00 89 E5 66 FF 66 00
。将这段程序加载到物理地址 10000
处,内存映像如下图:
由于 vstart=8
,所以标号 S 代表的偏移地址也为 8,这就导致第 6 行代码 jmp 到错误位置 1000:8
处,然而实际应该 jmp 到 1000:0
处。这就是汇编地址与段内偏移地址不对应的后果。还一头雾水,不急,这个的确很绕,咋们继续,相信看完后面你就可以理解了。
另外,
vstart=xxx
与org xxx
功能相同。
section.xxx.start
section.xxx.start
是某段相对于程序开头的偏移量。举例如下:
1 | section data align=16 vstart=0 |
可见,section.code.start=0x10
,这就是 code 段相对于文件开头的偏移量。你一定会问,这玩意儿有啥用?唯一作用就是用来重定位。怎么个玩法?请继续阅读下文。
什么是程序加载器?
一个编译好的用户程序,放到磁盘中,是如何被加载到内存并运行的呢?大概的流程是加载器先把磁盘中的应用程序加载到内存并把执行权移交给应用程序。分为以下几个步骤:
- 从磁盘读取应用程序并装入内存(加载器的作用1)。
- 应用程序被装入内存后需要加载器对内存中的应用程序部分地址进行重定位(加载器的作用2)。
- 加载器将执行权移交应用程序(加载器的作用3)。
一般来说,加载器和用户程序对彼此而言都是黑盒子,它们不了解对方的功能和结构。那加载器如何启动用户程序呢?这就需要加载器和用户程序在事先协商一个接口,加载器通过接口去启动用户程序。实际的做法是,将这个接口放在每个用户程序的开头,即用户程序头部,加载器按约定从头部提取信息并完成加载。 用户程序头部在源程序中以一个段的形式出现。 用户程序头部至少要包含如下信息:
- 用户程序的尺寸 ,以字节为单位。加载器需要根据其尺寸来决定读取多少个逻辑扇区。
- 用户程序的入口 ,包括段地址和偏移地址。注意,这里的段地址并不是真正的段地址,而是
section.xxx.start
,加载器通过这个段地址来计算出内存中真正的逻辑段地址。 - 段重定位表及其表项个数 。用户程序中的所有段都会被重定位,并将位置记录在表中。
程序加载器的工作流程
下面我们以 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.start
和 BASE_ADDR
确定。加载器利用此二者计算出各个段在内存中的逻辑段地址,并将其回填到用户程序头部。
4.将控制权移交给用户程序
用户程序取得控制权,接下来便可利用头部中的重定位表跳转于各个段之间。
以上是程序加载器的简单概述,下面我们结合代码来进行说明:
1 | ;===============文件说明:用户程序================================================== |
1 | BASE_ADDR equ 0x10000 ;将用户程序加载到物理位置BASE_ADDR处 |
用户程序剖析
建议读者赋值粘贴代码到 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 段相对于文件开头的偏移量,这个偏移量可能大于 ,所以要用 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 | nasm -f bin app.asm -o app.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.s
和 app.s
,然后使用如下命令分别生成 .bin 文件:
1 | nasm -f bin app.s -o app.bin |
注意,可能会报错,提示 app.s
中有五行错误,只需将 es:[xxx_segment]
改为 [es:xxx_segment]
即可,这是 nasm 在 LInux 和 Windows 的小差别。
4)使用如下命令将.bin 文件写入硬盘:
1 | dd if=./loader.bin of=./hd.img bs=512 count=1 conv=notrunc |
5)在 bochs-2.7 目录下运行:
1 | bochs -f bochsrc |
两次回车,出现如下界面:
点击左上方的 continue,出现以下界面即为成功:
world 后面的 F 哪来的我也很懵逼。。。