之前的内容,操作系统是对象+api,从最小的 initramfs,构建出了足够多的对象成为一个可用的操作系统。
然后在这个可用的操作系统上,基于系统调用,可以实现 freestanding 的 shell(有功能的程序),封装 libc,然后在 libc 之上,可以实现更多的程序。
我们已经见识了一个 “层层封装” 的应用生态世界,它们的 “底座” 是操作系统提供的对象和 API,以及封装它们的 C 标准库。之前,我们 “默认” 了编译器工具链可以帮助我们实现高级语言到可执行文件的翻译。今天是时候 “打开” 这部分内容了。
我们已经见识过在系统调用 API 和操作系统系统对象上层层封装得到的世界了。是时候实现一些 “真正” 的程序了——让我们看一看到底什么是可执行文件,以及它们是如何被操作系统加载的。
理解上,可执行文件是个状态机。这部分看到底是什么。
1. 可执行文件
学习操作系统前,可执行文件是那个 “双击可以弹出窗口的东西”。
学习操作系统后,可执行文件是
回顾计算机系统基础,ABI,只规定了部分寄存器和栈,其他状态(主要是内存)由可执行文件指定。
磁盘上的一个可执行文件是操作系统的对象,everything is a file,即 文件 是对 everything 的一个抽象。计算机里 everything 的抽象是 数据。硬盘是个 file,硬盘是个字节数组,这就抽象出来了。我们可以看一个文件的数据,比如 `/bin/ls` 是个文件,我们可以运行,同时我们也可以编辑 `vim /bin/ls`。
我们可以用二进制的编辑器来编辑。`vim /bin/ls` 后 `:%!xxd` 。vim 的设计符合 unix 哲学,`:%` 代表整个当前文件,把全体内容管道给 xxd,然后得到的结果粘贴回来。
我们修改二进制内容后,可以 `:%!xxd -r` ,这就又变成了二进制文件。更多用法,查看 xxd。这个过程中,vim 什么功能也没实现,vim 甚至不知道这是个二进制文件。在 vim 里通过这种方式,集成了 unix 所有的工具,当然用插件可以做的更好。这也很有意思。
这个小例子,我们看到了可执行文件就是个字节序列,我们甚至可以修改。所以是不是 ELF 不重要,重要的是数据结构里要包含足够的信息,能够创建进程的初始状态,所以世界上的可执行文件的格式是不唯一的。
对可执行文件的初步感觉。一个 `hello world` 程序,用 `gcc` 静态链接。使用 `file a.out` 可以看到这是一个 elf 的可执行文件。
`execve()` 里调用可执行文件,这个系统调用是重置当前状态机的。重置为这个可执行文件的初始状态。因此可执行文件是状态机初始状态的描述,并且描述了状态机如何迁移。
因此可执行文件是一个描述了状态机初始状态+迁移的数据结构。
这是对可执行文件的正确理解。
可执行文件就是个数据结构,作为 “数据结构” 的可执行文件必须要描述好状态机初始状态
初始的 PC 在 ELF Header 的 entry
这个数据结构的文档在就是 ABI 的手册。
我们用 readelf
来解析二进制文件,可以看到许多状态机的初始信息。
状态机有初始状态,状态迁移就是代码,状态机一旦启动,完全只依赖于初始状态和系统调用。后面所有的状态转换都已经决定好了,即,一个程序如果没有数据输入,那么不管执行多少遍,最后的结果都是一样的。
binutils 中的工具能让我们看到状态机里的信息。一个数据结构,人眼看是不友善的,对机器可读,但是对人基本上是完全可不读的,但是信息都蕴含在里面。现在我们有了一个更有力的工具 chatgpt。
有没有一种工具,把手册调整成一种容易看的样子呢?
手册读不懂是因为有些概念不知道。
这部分的知识和计算机系统基础有重叠,关于链接一章,可执行文件的生成。
现在的 ELF,不是一个人类友好的 “状态机数据结构描述”,为了性能彻底违背了可读原则。
现在的 dore dump 是个 ELF 文件。
2. 自己设计一个可执行文件
即设计一个数据结构,对人直接可读,直接面对链接和加载中的核心概念,在 linux 上可以运行。
代码 (🔢)、符号 (📤)、重定位 (❓),让人类更好读。
一个 ELF
🔢: ff ff ff ff ff ff ff
🔢: ff ff ff ff ff ff ff
📤: _start
🔢: 48 c7 c0 3c 00 00 00
🔢: 48 c7 c7 2a 00 00 00
^
|
This byte is return code (42).
🔢: 0f 05 ff ff ff ff ff
🔢: ff ff ff ff ff ff ff
❓: i32(unresolved_symbol - 0x4 - 📍)
加载
如何实现一个加载器呢?loader,我们已经有了一段可执行文件,一段代码,我们现在想把这段可执行代码加载到内存里运行。
一个最简单的加载器
// Generated by GPT-4; unmodified
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
if (argc != 2) {
printf("Usage: %s <binary_file>\n", argv[0]);
return 1;
}
// Open the binary file
int fd = open(argv[1], O_RDONLY);
if (fd < 0) {
perror("open");
return 1;
}
// Get the file size
off_t file_size = lseek(fd, 0, SEEK_END);
lseek(fd, 0, SEEK_SET);
// Allocate memory for the binary
void *mem = mmap(NULL, file_size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE, fd, 0);
if (mem == MAP_FAILED) {
perror("mmap");
close(fd);
return 1;
}
// Close the file
close(fd);
// Cast the memory address to a function pointer and call it
void (*binary_func)() = (void (*)())mem;
binary_func();
// Clean up
munmap(mem, file_size);
return 0;
}
这是个最简单的加载器,但是操作系统里的加载都是这么个原理,把进程需要的内存搬到地址空间里,然后给正确的权限,然后配置好初始状态,就这样。
3. 加载 elf 文件
实现 ELF Loader
知道了什么是可执行文件,我们要来看看稍微复杂一点的链接和加载。
在操作系统上实现一个 ELF loader。这个会稍稍困难一点。所有我们需要参考的东西都在 System V ABI 里。
这个 loader 本身是动态链接的,它可以加载一个静态链接的可执行文件到内存里并执行。
只要操作系统可以加载一个指令序列,我们可以把 execve 放在用户层实现,即用 mmap 系统调用实现,所以严格来讲,execve 系统调用在今天的操作系统来讲,完全实现成库函数。
这个例子里我们相当于重写了 execve,即 execve_
,我们也使用了参数,但是平移了一个,因为第一个参数是加载器本身。
我们直接把静态链接的可执行文件的前 4KB 映射到内存,指针强制转换为 Elf64_Ehdr*
类型,按这个类型去解读内存里的数据,(读elf也是一个比较常用的需求。
读出了 elf 里面前面文件的信息,我们就可以做很多事了,比如程序的 entry point,思路和上面的最简单加载器差不多。
这个数据结构里还有更多的信息,这个数据结构里总有一些地方定义了把那些东西加载到那些位置,。总之,只要把 elf 里标记了 LOAD 的位置搬到内存的位置,当然也有细节上的东西,比如搬动位置对齐问题,看手册就好了。
细节不重要,当我们谈论概念的时候,不需要太多关注细节
到底重不重要取决于我们在干什么,学习原理的时候,不用太关注细节,当实际实现一个东西,写代码的时候,有需要关注手册和细节。
算好各种地址,然后 mmap 就可以了。
把所有的数据结构里的东西搬动到内存里几乎就差不多了,此外还要准备好栈的东西,这个也是看手册来解决。直接在 a.out
里定义栈也没问题。
这段代码实际上就是手册的体现。
手册难读。有大量的前置概念。初学者并不是跟着技术发展一路过来的,一个新手第一次拿到了手册,就会非常痛苦。
我们需要在一个简化的系统上,把基本概念搞清楚,然后就可以再回来看手册,就会知道手册上那些是最重要的,那些可以跳过,然后再顺着最重要的,慢慢往外看。往前后左右去看,随着积累越来越多,就可以慢慢看下来了。
Boot Block Loader,
操作系统也是个程序,如何实现加载操作系统内核的加载器呢?操作系统内核也是个 ELF 可执行文件,因此差不多,也是解析数据结构,复制到内存,然后跳转。做的事情与动态加载器完全一样,但是这时候没有 mmap 系统调用可以用,但是可以用 IO 指令直接把数据从磁盘搬动到内存,实现起来还更容易一点。
ELF 文件如何生成
现在知道什么是一个 ELF 文件了,编译器生成了 ELF 文件。事实上,这是编译器一个字节一个字节“写出来”的。
这也有很好玩的问题,最初小程序只有一个文件,指令在编译时就能确定。程序变大,会存在链接过程,因此有些指令的跳转地址是无法确定的。
call 和 jmp 后面跟个相对地址,一个问题,offset 0 跳转到哪里了。直接执行下一条指令了,顺序执行,不会卡死在这里。
ICS 课的链接和加载,
ELF 不是一个好的“描述状态机数据结构”的格式。以至于 readelf 做了一个翻译之后还是不怎么友好。基本上约等于直接去读一个内存数据结构的 core dump。
动态链接和加载
为什么需要动态链接?
拆解应用程序的需求
拆解应用程序,实现运行库和应用代码分离。
静态链接和静态链接出来的二进制文件大小是不一样的。动态链接不仅仅省磁盘上的空间,也省内存里的空间。
此外,还有重要应用,补丁和升级。运行库出现漏洞,可以方便的升级,如果都是静态链接,那升级就需要重新编译。
任何程序都不会只有一个依赖库,因为总会有些需求是别人需要过一万次的,这时候就一定有一个库来做这个事情。
linux 里有很多系统工具都是动态链接的,这样系统里可以只有一份 libc。库升级,保持接口的向后兼容,补丁发布后不再需要重编译所有依赖的应用。
所以现实的一个事情是任何的一个软件生态系统里都有依赖性。相应的社区的解决方法是 [Semantic Versioning](https://semver.org/lang/zh-CN/),所有的软件都有版本,版本号是有讲究的。
主版本号,允许删掉 API,不做 API 上的兼容。次版本号,只增不减,向后兼容来新增功能。修订号则是向下兼容的问题修正。
我们把 library 和 application 分开,这对打安全补丁是一个非常好的特性。事实上,“向前兼容”并不是一个明确定义的一个东西,软件的行为确定了,任何后一版本上软件行为的不同都叫不兼容。这是个微妙的定义。涉及到软件生态系统。
此外,大型项目的内部也可以内部分解。编译一部分,不需要重新链接。
设计一个二进制文件格式
ELF 是个数据结构,当我们对着一个数据结构的内存表示学习时,是不太容易的。我们应该从数据结构的角度出发。
同样的,对于动态链接,我们应该把这个数据结构设计成什么样,使得支持动态链接。然后再找性能缺陷去改进。
如果编译器、链接器、加载器都可以控制
假设编译器可以生成这样的二进制文件
DL_HEAD
LOAD("libc.dl") # 加载动态库
IMPORT(putchar) # 加载外部符号
EXPORT(hello) # 为动态库导出符号
DL_CODE
hello:
...
call DSYM(putchar) # 动态链接符号
...
DL_END
即,有一个 main.c
经过编译器生成了 main.S
,即这段程序
#include "dl.h"
DL_HEAD
LOAD("libc.dl")
LOAD("libhello.dl")
IMPORT(hello)
EXPORT(main)
DL_CODE
main:
call DSYM(hello)
call DSYM(hello)
call DSYM(hello)
call DSYM(hello)
movq $0, %rax
ret
DL_END
这里会调用一个外部函数,hello,但是 hello 的地址是不知道的,在链接的时候都不知道 hello 的地址,也不知道 hello 的实现。只知道在程序运行时,hello 的代码才确定,有个指针会指着 hello。
这个时候,我们需要去考虑的一个设计:在编译器把 .c 翻译成 .S 的时候,该如何去翻译呢?这个 .S 也是一个数据结构,里面应该要有一个表,现在是空的,运行的时候要填上,表里存放 hello 的入口地址。
这样编译器可以把这样一个 hello() 的调用翻译成一个间接查表的跳转即 call(hello_addr),这就初步解决了动态调用的问题。此外我们还需要加载一些动态库。
再看 libhello
#include "dl.h"
DL_HEAD
LOAD("libc.dl")
IMPORT(putchar)
EXPORT(hello)
DL_CODE
hello:
lea str(%rip), %rdi
mov count(%rip), %eax
push %rbx
mov %rdi, %rbx
inc %eax
mov %eax, count(%rip)
add $0x30, %eax
movb %al, 0x6(%rdi)
loop:
movsbl (%rbx),%edi
test %dil,%dil
je out
call DSYM(putchar)
inc %rbx
jmp loop
out:
pop %rbx
ret
str:
.asciz "Hello X\n"
count:
.int 0
DL_END
这个库需要一个 libc 的动态链接库,需要一个 putchar 函数,也没有,因此也被翻译成一个动态的 call DSYM(putchar)
以上是自己设计的一个数据结构。
如何实现 DSYM() 呢?,需要在可执行文件这个数据结构里放这么一张表。
#define REC_SZ 32
#define DL_MAGIC "\x01\x14\x05\x14"
#ifdef __ASSEMBLER__
#define DL_HEAD __hdr: \
/* magic */ .ascii DL_MAGIC; \
/* file_sz */ .4byte (__end - __hdr); \
/* code_off */ .4byte (__code - __hdr)
#define DL_CODE .fill REC_SZ - 1, 1, 0; \
.align REC_SZ, 0; \
__code:
#define DL_END __end:
#define RECORD(sym, off, name) \
.align REC_SZ, 0; \
sym .8byte (off); .ascii name
#define IMPORT(sym) RECORD(sym:, 0, "?" #sym "\0")
#define EXPORT(sym) RECORD( , sym - __hdr, "#" #sym "\0")
#define LOAD(lib) RECORD( , 0, "+" lib "\0")
#define DSYM(sym) *sym(%rip)
#else
#include <stdint.h>
struct dl_hdr {
char magic[4];
uint32_t file_sz, code_off;
};
struct symbol {
int64_t offset;
char type, name[REC_SZ - sizeof(int64_t) - 1];
};
#endif