实验名称

手搓最小可执行文件——ELF文件与链接

实验目的

作为 C 语言的初学者,第一个编写的 C 代码一般是如下所示的“Hello World”:

但这区区四行 C 代码,在 X86 架构下 Ubuntu 中使用 gcc 形成的可执行文件大 小可能超过 7KB !

⚫ 请尝试分析 TA 到底增加了什么内容导致可执行文件的大小产生这样的膨 胀?

⚫ 基于前述分析,如果一段极其简单的代码如下,TA 仅仅返回一个数字 XX(请 使用你学号的最后两位取代下面代码中的 “XX”):

int main ( ) { return XX; }

你可以尽你所能(后文我们会为你提供指导参考)获取功能没有任何变化的更小 (字节数)可执行文件,你能获得多小的可执行文件?

实验资源

《实验三指南》

64位Ubuntu(可以强制生成32位代码)

汇编语言编译器nasm

如图为源代码变成可执行文件的过程

要得到最小的可执行文件,可以尝试在上述各点(编译、汇编、链接)进行“尽量小”的尝试。

Step1

⚫ 前述仅仅返回你学号(最后两位)的代码,ta 的可执行文件有多大?

⚫ 如果要减小可执行代码,其 C 源码层面还能进一步简化吗?如果不能,为什么?

⚫ 求助于 gcc 编译器呢?ta 有优化选项啊?请尝试,是否缩小了可执行文件?

(1)执行仅返回学号的代码,查看可执行文件大小:

这是我们的代码部分(头文件可以删除)

我们通过-m32(gcc-multilib支持)可以强制生成32位可执行文件

这里发现我们的可执行文件有16K这么大

(2)C源码不能再简化了,main和return是c必要的语法

(3)通过gcc编译器的优化选项优化:

启用优化:-Os

剥离调试符号:-s

通过这些操作,我们将文件大小缩小到14K

可以发现gcc编译器的优化选项确实帮我们缩小了可执行文件!

Step2

如果 Step1 中的步骤都尝试过了,优化尺寸了吗?下面我们尝试一下直接开整汇编代码。 先演练一下 nasm,以下是一个手写汇编的示例及其编译过程:

;Example.asm:

BITS 32 ; 指定生成 32 位代码

GLOBAL main ; 声明 main 为全局符号

SECTION .text ; 定义代码段

main:

mov eax, 5

mov ebx, 10

add eax, ebx

ret

nasm 编译命令:

nasm -f elf32 Example.asm

gcc -m32 -Wall -s Example.o

这里我们创建Example.asm这个汇编程序

我们先分析一下这个代码:

eax赋值为5,ebx赋值为10,然后将eax和ebx相加,结果保存在eax中,那么eax执行完这个代码就是15

然后进行编译后执行

注意这里汇编代码并没有输出值,所以我们需要通过echo $?查看运行结果

得到结果是15,与我们上面的分析是吻合的。

接下来我们用汇编来实现一个return 20的代码:

接下来还是一样的进行编译:

然后执行

可以发现能得到我的学号20

接下来我们看看这个可执行文件的大小:

发现还是14K,离预期好像还是很差

Step3

⚫ 本阶段,请使用 readelf 或 objdump 随时查看可执行 ELF 文件的格式和内容。

⚫ 你是否注意到可执行 ELF 文件中包含与链接有关的部分。想一想,我们并没有明显调用 外部库函数,为什么还会出现这些链接部分以及其有什么作用?

⚫ 对于一个如此简单的程序,我们是否有必要去调用外部的库函数呢?

⚫ 尝试使用 gcc 指令 -nostdlib 来取消链接标准库和启动代码。或许你在尝试后会遇到 _start 未定义的报错(这是由于取消链接后,无法调用外部函数引起的),而这又是 程序运行的入口,所以只能由我们自己在汇编代码中进行定义,且由于此 C 程序所要 执行的功能非常简单,所以我们完全可以让任务在 _start 中完成,这样就不需要再 定义 main 函数了。

⚫ 如果你成功编译,在执行时可能会遇到段错误!? 问题在于我们将 _start 视为 C 函数,并尝试从中返回。实际上,它根本不是一个函 数。它只是目标文件中的一个符号,链接器使用它来查找程序的入口点。当我们的程 序被调用时,它是直接调用的。查看一下,我们会看到堆栈顶部的值是数字 ,这看起 来不像地址?事实上,堆栈上的是程序的 argc 值,在此之后是 argv 数组的元素,包 括终止的 NULL 元素,然后是 envp 的元素——请回忆 argc,argv,envp 是干什么用 的?所以此时堆栈上没有返回地址。

⚫ 那么, _start 如何退出呢?与大多数作系统一样, Linux 通过系统调用为其托管的 程序提供基本操作,包括打开文件、读取和写入文件句柄等,当然,还包括关闭进程。 首先我们可以通过 int 0X80 这条指令唤醒内核—— 所以 int 0X80 有什么用? 除了唤醒内核之外,还需要告诉内核我们想要结束该进程,以及该进程返回值。这需 要对 eax 赋值为 1 (告诉内核结束进程),对 ebx 赋值为返回值——为什么只能对 这两个寄存器进行赋值?

⚫ 以上操作,将在可执行文件中剔除链接的相关部分,请查看其 ELF 文件结构和内容,与 你的预期一致吗?

⚫ 你可能注意到仍然还存在链接相关的内容,因为 gcc 在链接时会增添一些额外信息, 但我们没有使用它的任何附加功能,因此我们可以自己调用链接器 ld : ld -m elf_i386 Example.o //elf_i386 是指生成 32 位 x86 架构的 ELF 格式

1.使用readelf和objdump查看ELF文件格式和内容

如图使用readelf -h查看ELF文件头信息

如图使用readelf -S查看段头部表信息

如图使用过readelf -l查看程序头

接下来使用objdump -d查看反汇编代码

终于把这串代码截图完了…

这里不免让我很好奇:我的汇编代码只有那么几行,为什么最后得到的反汇编代码这么长呢?

2.这里指南提醒我们是否存在链接部分,这里我们通过上面的输出

Section to Segment mapping(程序头)中:

Section

作用

.interp

动态链接器路径(如 /lib/ld-linux.so.2),加载时由内核读取。

.dynamic

动态链接信息表(依赖库列表、符号表地址、重定位条目等)。

.dynsym

动态符号表(记录需从外部库解析的符号,如 printf)。

.dynstr

动态符号字符串表(存储 .dynsym 中符号的名称字符串)。

.rel.dyn

动态重定位表(修正代码中对全局变量/函数的绝对地址引用)。

.rel.plt

PLT 重定位表(修正过程链接表(PLT)中的跳转地址,用于延迟绑定)。

.got

全局偏移表(GOT),存储外部变量/函数的最终地址(动态链接时填充)。

.plt

过程链接表(PLT),用于延迟绑定外部函数(如 printf 的跳转桩)。

可以找到上述这些动态链接的部分

还有辅助性链接

Section

作用

.gnu.hash

GNU 扩展的符号哈希表,加速动态符号查找。

.gnu.version

记录符号版本信息(兼容不同版本的库)。

.gnu.version_r

存储符号版本依赖关系。

.init_array

构造函数指针数组(动态链接器在加载时会执行这些函数)。

.fini_array

析构函数指针数组(程序退出时执行)。

根据上面的分析:我们发现,我们的ELF文件中确实包含与链接相关的部分,但是确实我们并没有明显调用外部库函数,这是为什么呢?

其实是由于gcc默认会链接标准库,且会保留动态链接的能力(为未来可能需要的库调用做准备)

3.我们的代码非常简单,是不需要调用外部函数库的

4.尝试使用gcc指令 -nostdlib取消链接标准库和启动代码

可以发现确实出现了_start未定义的报错

所以这里我们修改我们的汇编代码:

使用_start作为入口,而不是依赖main

5.这里我们进行编译然后运行:

可以发现编译和链接并没有报错,但是运行时提示我们段错误了!?

这里是因为我们使用ret时需要从栈顶弹出返回地址让我们跳转,但是我们的程序是独立执行的,并没有调用者,所以返回失败了。

当 Linux 执行一个程序时,内核会将以下信息压入堆栈:

argc​:命令行参数的数量(整数)。

argv​:指向参数字符串的指针数组(以 NULL 终止)。

envp​:指向环境变量的指针数组(以 NULL 终止)。

6.通过int 0x80唤醒内核,将eax赋值为1(告诉内核结束进程),对ebx赋值为返回值20:

为什么只能对这两个寄存器赋值?

调用int 0x80时要根据eax寄存器的值调用对应的内核功能,eax=1对应的是sys_exit编号;

Linux 系统调用遵循 ​特定的寄存器约定​(32位 x86 架构的 ABI):

寄存器

用途

类比函数参数

eax

系统调用号

函数名

ebx

第一个参数

arg1

ecx

第二个参数

arg2

edx

第三个参数

arg3

esi

第四个参数

arg4

edi

第五个参数

arg5

我们这里只需要返回一个参数,所以使用ebx

7.之后我们再进行剔除链接相关部分,并允许:

可以发现能得到目标值20;

使用readelf:

再用objdump看看:

可以发现,结果与我们预期的并不一致,还是有一定的差别,尽管反汇编代码已经达到了我们的预期,ELF仍然存在链接相关的内容。

8.由于gcc在链接时会增添一些额外的信息,但这些附加功能我们并没有使用,所以我们不使用gcc了,我们自己调用链接器ld

然后我们查看ELF文件结构和内容:

可以发现ELF内容精简了许多,链接部分消失了

我们使用objdump再查看一次:

与我们预期的结构一致

通过上述操作,我们将可执行文件大小缩小到了4.4K

Step 3.5

在进行 Step 4 之前,我们需要对现在版本的汇编代码进一步缩减,不然在之后的步骤可 能会遇到一点小问题(你可以尝试如果跳过这一步,观察后续会遇见什么问题,trytry)。 当然你极有可能只能瞪着你桌面上看起来已经简单到发指的汇编代码发呆。别担心,我们会直接给出其缩减版,但是你需要去自己挖掘这样修改的依据。

我们将代码修改如下:

这串代码进行了如下操作:

xor eax,eax  :清零eax

inc eax      :eax+1

mov bl, 20   :将20赋值给ebx低8位

int 0x80     :调用内核

我们可以看看代码的体积:

可以发现总字节数是7字节

而我们刚刚的代码:

总字节数是14字节。

Step 4:

现在我们获得一个相对“简单”的可执行文件,ta 有多大?如果想进一步减小 ta,就只能直接从 ELF文件下手了。

⚫ 回忆一下ELF格式,课堂讲授大致如下图所示:

你看到的 ELF 文件格式与其一致吗?

  • 每个 ELF 文件都以 ELF header 开头,其包含什么内容?
  • Program header 里面是什么?
  •  .rodata , .data , .bss 等都各自有什么内容和作用?思考一下,对于我们极其 简单的源程序来说,既没有需要调用的外部函数、也没有需要存储的变量,这些 对于我们程序的实现有确实意义吗?
  • 对于一个可执行文件来说,其必要的部分是 ELF header 、 Program header 以 及 .text section ,至于其它部分,嗯……必需吗?
  • 在锁定 ELF 文件中的必要部分后,我们可以开始尝试手搓一个更最简单的 ELF 文 件(对,就像本课程最开始那两周一样,直接写汇编代码)。当然你可能还是对 于如何写一个 ELF 文件毫无头绪,别担心,这里有一个标准的 ELF 文件,其只 包含了上述所说的三个部分( ELF header 、 Program header 、.text )

在这个模板的帮助下,我们需要做的就是将自己程序的汇编代码写在 _start 下方,是不是 so easy (✪ω✪)!

这里看见我们的可执行文件大小仍然是7K

根据我们之前通过readelf得到的结构,可以发现我的return_20的elf结构如下:
ELF Header​(readelf -h 输出)

Program Header Table​(2个条目,readelf -l 输出)

Sections​(readelf -S 输出):

.text(代码段)

.symtab(符号表)

.strtab(字符串表)

.shstrtab(段名字符串表)

对比标准elf结构图,缺少了:

.rodata、.data、.bss(程序未使用只读数据/全局变量)

.rel.txt、.rel.data(无重定位需求,因无外部依赖)

.debug(未启用调试编译)

应该也是符合elf标准的。

1.ELF头部内容如下:

根据这个我们可以得到以下信息:

  • Magic:标识这是ELF文件
  • Class:32位ELF文件
  • Little endian:小端法存储
  • Type:EXEC表示可执行文件
  • 以及还有入口地址,头长度,段头长度以及在文件中的偏移量。

2.程序表头的作用是告诉操作系统如何将可执行文件加载到内存中运行。

程序头部分:

我们的程序头有代码段和数据段两个部分;

代码段这里显示R可读,数据段显示RE可读可执行。(可以根据起始地址区分,且代码段后面跟数据段符合布局惯例)

典型的程序表头可能还有INTERP(链接路径)和DYNAMIC(包含链接符号表,重定位表),当然,我们在前面已经将链接部分删去了,所以我的程序头没有这部分内容。

3..rodata:存储程序中的只读信息(常量等),一般是只读

.data:存储已初始化的全局变量和静态变量,一般是可读可写

.bss:存储未初始化的全局变量和静态变量,一般是可读可写。(不占用文件空间,仅在运行时分配内存)

对于我们及其简单的源程序,我们并没有设置任何变量,也没有需要调用的外部函数,这些段不会被创建,无实际用处。

可以查看我们的段头部:

由于我们没有使用变量和调用函数:

确实是不存在以上三个节

4.这里我们可以发现我们存在:

.symtab(符号表:记录函数/变量名)

.strtab(字符串表:存储符号名称)

.shstrtab(节名字符串表:存储节名,如.text)

这三个节其实没有必需性

即使readelf工具需要依赖.shstrtab来获取实际的节名,但操作系统加载程序时,只需要知道段的权限和位置(依赖程序表头),并不需要知道节名。

5.这里我们按照参考的代码进行编写,然后执行:

查看是否能运行:

再看一下大小:

可以发现效果非常好,可执行文件大小“缩水”到了91字节。

Step 5: 在完成上述步骤后,我们的可执行文件只剩下了必要的干货,现在可执行文件有多大? 还能继续缩小吗?别轻易停下来,后面只剩几页了 ……

  • 你遇到过超载吗?当车内已经满员,但司机还想继续塞人时该怎么办呢?简单,后来的人坐在别人腿上就行。(这仅仅只是一个描述——请勿超载,行车不规范,亲人两行泪)——所以,当我们对于不可删减的内容还想压缩时,其中一个方法 就是“叠叠乐”或者说强行“缝合”。
  • 在 ELF 头中,有一些实际并没作用的内容,例如在标识符末尾那几个滥竽充数的 0 (数一数,共有多少个字节?)。而我们的程序,数一数需要多少个字节即可完成?所以我们是不是可以将程序实现部分放入在标识符末位的填充部分上呢?

对于我们的程序可以发现

e_ident字段末尾有times 8 db 0的填充,共8字节

而我们的程序内容是7字节(上面给出过)

于是我们可以将这部分加到填充的8个0里面

修改如下:

接下来我们执行:

再次查看大小:

相比于上面的代码,确实又减少了7字节,与预期相符

继续进行类似尝试,将 Program header 也使用相同的操作置入 ELF header。我们注意到(真是注意力惊人), ELF header 中的最后 8 个字节与 Program header 中的前 8 个字节具有某种相似之处,而这种相似,可以让我们进一步“缝合”:

这里我们分析一下

原先ELFheader最后八字节

dw 1

dw 0

dw 0

dw 0

是01 00 00 00 00 00 00 00(注意这里是小端法表示)

而Program header前8字节

dd 1

dd 0

也是01 00 00 00 00 00 00 00(注意是小端法)

可以重叠这两个部分内容

再次执行:

查看大小:

这样我们成功缩减为76字节,再次节约8字节,符合预期。

Step 6:

  • 是不是能从结构下手,更改接受的内容呢?请尝试列出所有 ELF header 的各部分 内容、作用及其大小!
  • 发现了吗?ELF header 中的大部分必要字段都在前半部分,后半部分几乎完全可以自由地进行修改。考虑到这一点,我们可以继续:

我们查看ELF header:

此时,程序头表的前 20 个字节与 ELF 头的最后 20 个字节重叠,而且还契合得很好。重叠区域内 ELF 头只有两个部分是重要的:第一个是 e_phnum 字段,它恰好与 p_paddr 字段重合,这是程序头表中为数不多的绝对被忽略的字段之一。另一个是 e_phentsize 字段,它与 p_vaddr 字段的上半部分重合。这些为我们的程序选择一个非标准加载地址来匹配,上半部分等于 0x0020。

查看是否可以执行:

查看大小:

可以发现又节省了12字节(多重叠了12字节)

Program header 中还有几个字段可以供我们可以进处理。注意到了嘛,p_memsz 表示要为内存段分配多少内存,即它至少需要与 p_filesz 一样大,但如果它更大, 也不会有任何坏处 —— 可以“尸位素餐”。同时我们可以将 p_flags 字段中的 可执行位设置为 0,因为可读位和可执行位存在着一种微妙的共生关系(任何一个都会暗示另一个)。基于前述事实,我们将可执行文件重新组织成这个小怪物:

p_flags 字段已从 5(00000101) 更改为 4(00000100),这个 4 也是 e_phoff 字 段的值,它给出了程序头表的文件偏移量,这正是我们找到它的位置。我们自己的“程 序正文”已下移至 ELF 头的下部,从 e_shoff 字段开始,到 e_flags 字段内结束。 请注意,加载地址已更改为一个低得多的数字 — 实际上,大约是尽可能低的数字。这会将 e_entry 字段中的值保持为一个相当小的数字,这很好,因为它也是 p_memsz 数字(实际上, 对于虚拟内存,这几乎无关紧要,为什么?)。

对于虚拟内存:

  • 不论p_memsz是多大,不访问就不会消耗物理内存
  • e_entry地址无关,地址不影响内存占用,只影响CPU寻址

对 p_filesz 为什么要如此更改?

因为我们没有在 p_flags 字段中设置写入位,所以 Linux 不允许定义大于 p_filesz 的 p_memsz 值,因为如果这些额外的字节不可写, 它就无法对它们进行零初始化。由于我们无法在不将程序头表移出对齐的情况下更改 p_flags 字段,因此你可能会认为唯一的解决方案是将 p_memsz 值减小大等于 p_filesz(这将使无法与 e_entry 共享它)。但是,存在另一种解决方案,即将 p_filesz 增加到等于 p_memsz。这意味着它们都比实际文件更大 ,而且要大很多 — 但它使加载程序不必写入只读内存,而这正是它所关心的。

接下来看看是否能执行:

可以正常执行

看看大小:

最后是52字节

补充:

尽管我们的可执行文件已经非常小了,但是对于我们的文件,Linux系统对于不完整部分仍然会进行补0操作,可以发现我们的文件末尾至少还存在着7个0,我们尝试着将这些0删除:

接下来看看是否能运行:

查看大小:

减少了7字节,符合我们的预期

总结

实验中出现的问题

  1. 中间对于编译指令不熟悉,花费了一点时间解决
  2. 前面部分对于指南的内容还能跟得上节奏,认真分析,后面对于汇编代码进行重合修改的时候有点力不从心了,一知半解,还改错了代码好几次。

心得体会

【真实感受】

通过这次实验,我从零开始手搓最小可执行文件,一步一步修改试错,对源文件生成可执行文件的过程又有了进一步理解。

同时通过多次使用readelf和objdump,对这两个工具有了一定的初步使用认知。

通过多次查看ELF头部、程序头以及段头表等内容,对ELF的结构和内容有了进一步认识。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐