这一篇在干嘛?
前面各章把”进程”当作已经存在的东西来调度、来分配内存,本章回答一个更根本的问题:**进程是从哪来的?**我们将看到内核如何根据磁盘上的可执行文件,为一个进程搭建全新的执行环境——包括用户凭证、命令行参数、内存布局、动态链接库等方方面面。理解这一章,你才能真正看懂双击一个程序(或在 shell 里敲一个命令)背后发生了什么。
图 0:本章主题——程序执行:从可执行文件到运行中的进程
可执行文件是什么 | 进程凭证与能力 | 命令行参数与 shell 环境 | 库:静态链接与共享链接 | 程序段与进程内存区域 | 执行跟踪:ptrace | 可执行格式与执行域 | exec 函数族与 execve 全流程
可执行文件是什么
第 3 章把进程定义为”执行上下文”(execution context)——也就是完成一次特定计算所需的全部信息:访问过的页面、打开的文件、硬件寄存器的内容等等。顺着这个定义,可执行文件就是一个普通文件,它描述了”如何初始化一个新的执行上下文”,即如何开始一次新的计算。
来看一个最日常的例子:用户想列出当前目录下的文件,于是他在 shell 提示符后输入 /bin/ls。shell 会 fork 出一个新进程,这个新进程再调用 execve() 系统调用,其中一个参数就是包含 ls 可执行文件完整路径名的字符串 /bin/ls。内核的服务例程 sys_execve() 找到对应文件、检查它的可执行格式,然后按照文件里存储的信息修改当前进程的执行上下文。系统调用返回时,这个进程执行的已经是 ls 的代码了,目录列表随之输出。
这里有个值得注意的细节:当进程开始运行新程序时,它的执行上下文会发生天翻地覆的变化,因为此前计算中获得的大多数资源都被丢弃了:
- 旧的命令行参数被
execve()传入的新参数替换,环境也换成了新的; - 从父进程继承的所有页面(原本通过写时复制机制共享)全部释放,新计算从一个全新的用户态地址空间开始;
- 甚至进程的特权级都可能改变(比如执行了 setuid 程序,见下一节)。
但也有两样东西不会变:进程的 PID 不变;此外,新计算会继承上一次计算中所有没在 execve() 时被自动关闭的已打开文件描述符。这就是 shell 里”重定向”能跨命令生效的底层原因之一。
进程凭证与能力
传统 Unix 系统给每个进程都挂上一组凭证(credentials),把进程绑定到特定的用户和用户组。凭证在多用户系统上至关重要:它决定了每个进程能做什么、不能做什么,既保护了每个用户私人数据的完整性,也保护了系统整体的稳定性。
凭证的使用需要两头配合:进程数据结构里要存凭证,被保护的资源(最典型的就是文件)上也要记录归属。在 Ext2 文件系统里,每个文件都属于特定用户并绑定一个用户组,文件主人可以分别为”自己 / 同组用户 / 其他用户”设定允许的操作。当进程试图访问文件时,VFS 总会依据文件上设定的权限和进程的凭证来检查这次访问是否合法。
传统凭证字段
进程描述符里存凭证的字段如下表所示,这些字段存放用户和用户组的标识符,通常会与被访问文件 inode 里对应的标识符做比较:
| 字段 | 含义 |
|---|---|
| uid, gid | 真实用户/组标识符 |
| euid, egid | 有效用户/组标识符 |
| fsuid, fsgid | 用于文件访问的有效用户/组标识符 |
| groups | 补充组标识符 |
| suid, sgid | 保存的用户/组标识符 |
UID 为 0 表示超级用户(root),GID 为 0 表示 root 组。只要凭证字段里存着 0,内核就会跳过权限检查,允许进程执行普通用户做不了的操作,比如系统管理、硬件操纵等。
进程创建时总是继承父进程的凭证,但凭证之后可以修改—— either 在执行新程序时,或通过适当的系统调用。通常 uid、euid、fsuid、suid 四个字段值相同。例外发生在执行 setuid 程序时:所谓 setuid 程序,就是可执行文件的 setuid 标志被打开的程序,执行它时 euid 和 fsuid 被设为文件属主的标识符。几乎所有权限检查都落在 fsuid(文件相关操作)和 euid(其余所有操作)这两个字段上。
一个经典例子:passwd
为什么需要 fsuid 和 setuid?想想普通用户改密码的场景。所有密码都存在一个公共文件里,但用户当然不能直接编辑这个受保护的文件。于是用户调用系统程序 /usr/bin/passwd——它设置了 setuid 标志,属主是超级用户。shell fork 出的进程执行这个程序时,euid 和 fsuid 被设为 0(超级用户)。之后内核做访问控制检查时在 fsuid 里看到 0,进程就能访问密码文件了。当然,passwd 程序本身只允许你改自己的密码,不会让你为所欲为。
suid 字段与特权升降
setuid 程序在 Unix 漫长历史中留下的教训是:这类程序相当危险。恶意用户可能触发代码中的编程错误(bug),迫使 setuid 程序执行其设计者从未打算过的操作,最坏情况下整个系统安全都会被攻破。为了把风险降到最低,Linux 允许进程只在需要时获得 setuid 特权、不需要时丢弃。进程描述符里的 suid 字段保存着 setuid 程序启动时的有效标识符(euid 和 fsuid 的值),进程可以通过 setuid()、setresuid()、setfsuid()、setreuid() 这几个系统调用来改变有效标识符。
这四个系统调用对凭证字段的影响如下表(调用进程若不是超级用户,即 euid 不为 0,那么只能把字段设为凭证里已有的值——比如普通用户进程可以用 setfsuid() 把 fsuid 设为 500,但前提是其他某个凭证字段已经是 500):
| 字段 | setuid(e) 当 euid=0 | setuid(e) 当 euid≠0 | setresuid(u,e,s) | setreuid(u,e) | setfsuid(f) |
|---|---|---|---|---|---|
| uid | 设为 e | 不变 | 设为 u | 设为 u | 不变 |
| euid | 设为 e | 设为 e | 设为 e | 设为 e | 不变 |
| fsuid | 设为 e | 设为 e | 设为 e | 设为 e | 设为 f |
| suid | 设为 e | 不变 | 设为 s | 设为 e | 不变 |
重点看 setuid() 的两种行为差异:
- euid 为 0(超级用户):系统调用把调用进程的所有凭证字段(uid、euid、fsuid、suid)都设为参数 e。也就是说,超级用户进程可以借它彻底丢弃特权、变成普通用户的进程。典型场景是用户登录:系统 fork 出一个带超级用户特权的进程,该进程调用
setuid()丢掉特权,然后才去执行用户的登录 shell。 - euid 非 0(普通用户):系统调用只改 euid 和 fsuid,另外两个字段不动。这正好适合 setuid 程序”在需要时提升有效特权、用完再降回去”的用法——suid 字段里始终保存着原始的有效标识符,随时可以恢复。
进程能力(capabilities)
“超级用户 vs 普通用户”的二分模型太粗了:一个进程要么什么都能干,要么几乎什么都干不了。POSIX.1e 草案(现已撤回)引入了基于”能力”的另一种凭证模型,Linux 内核支持它(尽管大多数发行版并未启用)。
一个能力就是一个标志,断言进程是否被允许执行某个特定操作或某类操作。内核定义了许多能力,比如:
| 名称 | 说明 |
|---|---|
| CAP_CHOWN | 忽略文件用户/组归属变更的限制 |
| CAP_DAC_OVERRIDE | 忽略文件访问权限 |
| CAP_KILL | 发送信号时绕过权限检查 |
| CAP_NET_BIND_SERVICE | 允许绑定 1024 以下的 TCP/UDP 端口 |
| CAP_NET_RAW | 允许使用 RAW 和 PACKET 套接字 |
| CAP_SETUID | 忽略用户进程凭证操作的限制 |
| CAP_SYS_ADMIN | 允许一般性系统管理 |
| CAP_SYS_BOOT | 允许使用 reboot() |
| CAP_SYS_MODULE | 允许加载/卸载模块 |
| CAP_SYS_NICE | 允许提升进程优先级 |
| CAP_SYS_PTRACE | 允许跟踪任意进程 |
| CAP_SYS_TIME | 允许修改系统时间 |
| CAP_SYS_TTY_CONFIG | 允许配置 tty 设备 |
能力模型的核心优势在于:任何程序在任意时刻只需要有限的几个能力。即使恶意用户发现了利用有缺陷程序的途径,她能非法执行的也只有一小撮操作。举例来说,假设一个有 bug 的程序只持有 CAP_SYS_TIME 能力,那么利用这个 bug 的攻击者顶多能非法改动实时时钟和系统时钟,别的特权操作一概做不了。
不过有个现实限制:VFS 和 Ext2 文件系统目前都不支持能力模型,没办法把”执行这个文件时应启用哪组能力”记录在文件上。进程可以用 capget() 和 capset() 系统调用显式地获取和削减自己的能力——比如可以改造登录程序,只保留一部分能力、丢掉其余的。
内核内部其实已经在按能力做检查了。比如 nice() 系统调用允许用户改变进程静态优先级,传统模型下只有超级用户能提升优先级,内核本应检查 euid 是否为 0;但 Linux 内核定义了恰好对应这类操作的能力 CAP_SYS_NICE,检查时调用 capable() 函数并传入 CAP_SYS_NICE 即可。这套机制能工作,靠的是内核里的一些”兼容性补丁”:每当进程把 euid 和 fsuid 设为 0(无论通过上表的系统调用还是执行 root 的 setuid 程序),内核就把该进程的所有能力全部置上,让所有检查都通过;而当进程把 euid 和 fsuid 恢复为属主的真实 UID 时,内核检查进程描述符里的 keep_capabilities 标志,如果该标志被置位就不丢弃能力。进程可以通过 Linux 特有的 prctl() 系统调用设置和清除 keep_capabilities 标志。
LSM 框架
Linux 2.6 中,能力与 **Linux 安全模块框架(LSM)紧密集成。LSM 允许开发者定义多种可替换的内核安全模型。每个安全模型由一组安全钩子(security hook)**实现——钩子是内核在即将执行某个重要安全相关操作时调用的函数,由它决定操作是继续进行还是被拒绝。
钩子存放在 security_operations 类型的表里,当前使用的安全模型的钩子表地址存在 security_ops 变量中。默认情况下内核使用 dummy_security_ops 表实现的最小安全模型:其中每个钩子基本只检查对应的能力(如果有),或者无条件返回 0(允许操作)。比如 stime() 和 settimeofday() 的服务例程在修改系统日期时间前会调用 settime 安全钩子,dummy 表对应的函数只检查当前进程是否置有 CAP_SYS_TIME 能力,据此返回 0 或 -EPERM。更复杂的安全模型也是存在的,最著名的例子是美国国家安全局开发的 Security-Enhanced Linux(SELinux)。
常见坑:把 setuid 当成"万能提权"
setuid 位只是让进程的有效身份临时变成文件属主(通常是 root),并不改变真实身份 uid。而像
setuid()这类系统调用对普通用户有严格限制:只能把凭证字段设为凭证里已有的值。很多初学者写的提权脚本失效,正是因为误以为 euid 一改、uid 等字段会跟着变。
命令行参数与 shell 环境
用户敲命令时,为响应请求而载入的程序可以从 shell 接收命令行参数。比如输入:
$ ls -l /usr/bin
shell 进程创建一个新进程去执行这条命令,新进程载入 /bin/ls 可执行文件。此时从 shell 继承来的大部分执行上下文都丢了,但 ls、-l、/usr/bin 这三个独立的参数被保留下来。一般地,新程序可以接收任意数量的参数。
参数传递的约定取决于所用的编程语言。C 语言中,main() 函数可以接收一个整数(说明传入了多少个参数)和一个指向字符串的指针数组地址,标准原型是:
int main(int argc, char *argv[])回到上面的例子:/bin/ls 被调用时 argc 为 3,argv[0] 指向 “ls”,argv[1] 指向 “-l”,argv[2] 指向 “/usr/bin”。argv 数组的结尾总是一个空指针,所以 argv[3] 是 NULL。
C 语言里还可以给 main() 传第三个可选参数——环境变量。环境变量用于定制进程的执行上下文、向用户或其他进程提供通用信息,或者让进程在跨越一次 execve() 时保住某些信息。此时 main 可以声明为:
int main(int argc, char *argv[], char *envp[])envp 参数指向一个指针数组,数组元素指向形如 VAR_NAME=something 的环境字符串,等号前面是变量名,后面是实际值。envp 数组同样以空指针结尾,其地址也保存在 C 库的 environ 全局变量里。
这些命令行参数和环境字符串被放置在用户态栈上,紧挨着返回地址之前(参见第 10 章”参数传递”一节)。用户态栈底部的布局如下图所示——注意环境变量位于栈底附近、紧跟着一个 0 长整数:
图 1:用户态栈的底部布局:自上而下依次是 argc/argv 指针数组、envp 指针数组、环境字符串、命令行参数字符串,最底端是一个 0 长整数
库:静态链接与共享链接
每个高级语言源码文件要经过若干步骤才能变成目标文件(object file),其中包含与高级指令对应的汇编指令机器码。但目标文件不能直接执行:对于源码文件外部的全局符号引用(比如库函数、同程序其他源文件里的函数),目标文件里并没有对应的线性地址。分配(解析)这些地址的工作由链接器完成——它收集程序的所有目标文件,构造出可执行文件,同时分析程序用到了哪些库函数,并以下面描述的方式把它们”粘”进可执行文件。
几乎所有程序都要用库。看看这个一行 C 程序:
void main(void) { }它什么也不算,但要让它跑起来仍需要大量工作:建立执行环境(本章后面会讲),以及程序终止时杀死进程(参见第 3 章)。特别地,main 函数结束时,C 编译器会在目标代码里插入一个 exit_group() 函数调用。我们从第 10 章知道,程序通常通过 C 库的包装例程来发起系统调用——C 编译器也一样。所以每个可执行文件里除了编译程序语句直接生成的代码,还包含一些处理用户态进程与内核交互的”胶水”代码,其中一部分就存在 C 库里。除了 C 库,Unix 系统里还有大量其他函数库——一个通用 Linux 系统通常使用数百个库,比如数学库 libm 提供高级浮点运算函数,X11 库 libX11 汇集了 X11 窗口系统图形界面的基础底层函数。
静态库与共享库
传统 Unix 系统的可执行文件全部基于静态库:链接器产出的可执行文件不仅包含原程序代码,还把程序引用到的库函数代码也复制进来。静态链接的大缺点是浪费磁盘空间——每个静态链接的可执行文件都重复携带一份库代码。
现代 Unix 系统使用共享库:可执行文件里不包含库的目标代码,只有对库名的引用。程序被载入内存执行时,一个叫动态链接器(也叫 ld.so)的程序负责分析可执行文件里的库名、在系统目录树里定位库文件、把所需的代码提供给正在运行的进程。进程还可以在运行时用 dlopen() 库函数加载额外的共享库。
在支持文件内存映射的系统上,共享库尤其划算,因为它能减少执行程序所需的主内存:动态链接器把库链接进进程时并不复制目标代码,只把库文件的相关部分内存映射进进程地址空间。这样,包含库机器码的页框就能被所有使用同一代码的进程共享——静态链接显然做不到这一点。
共享库也有代价:动态链接程序的启动时间通常比静态链接的长;而且动态链接程序的可移植性不如静态链接的,因为如果系统上装的是同一个库的不同版本,程序可能无法正常运行。用户随时可以要求静态链接,比如 GCC 提供 -static 选项,让链接器用静态库代替共享库。
程序段与进程内存区域
从逻辑上讲,Unix 程序的线性地址空间传统上被划分成若干线性地址区间,称为段(segment):
- 代码段(text segment):包含程序的可执行代码。
- 已初始化数据段:包含已初始化的数据,即静态变量和全局变量中那些初值存在可执行文件里的(因为程序启动时就得知道它们的值)。
- 未初始化数据段(bss):包含未初始化的数据,即初值不存于可执行文件的全局变量(程序会在引用前自己赋值);历史上称为 bss 段。
- 栈段:包含程序栈,其中有正在执行的函数的返回地址、参数和局部变量。
每个 mm_struct 内存描述符(见第 9 章)都有一些字段标识进程几个关键内存区域的角色:
| 字段 | 说明 |
|---|---|
| start_code, end_code | 程序本地代码(可执行文件里的代码)所在内存区域的起止线性地址 |
| start_data, end_data | 程序本地已初始化数据所在内存区域的起止线性地址,大致对应数据段 |
| start_brk, brk | 进程动态分配内存区域(堆)的起止线性地址(见第 9 章”堆管理”) |
| start_stack | main() 返回地址正上方的地址;更高地址是保留区(栈向低地址生长) |
| arg_start, arg_end | 栈中存放命令行参数部分的起止地址 |
| env_start, env_end | 栈中存放环境字符串部分的起止地址 |
要注意的是,共享库和文件内存映射已经让”按程序段划分进程地址空间”这套分类过时了——每个共享库都会映射到上述列表之外的独立内存区域。
弹性内存区域布局
内核 2.6.9 引入了弹性内存区域布局(flexible memory region layout):每个进程的内存布局取决于用户态栈预期会增长多少。老的经典布局仍然可用(主要在内核无法限制进程用户态栈大小时)。以 80x86 架构、用户态地址空间默认到 3 GB 为例,两种布局如下:
| 内存区域类型 | 经典布局 | 弹性布局 |
|---|---|---|
| 代码段(ELF) | 从 0x08048000 开始(两种布局相同) | 同左 |
| 数据段与 bss 段 | 紧跟代码段之后(两种布局相同) | 同左 |
| 堆 | 紧跟数据段与 bss 段之后(两种布局相同) | 同左 |
| 文件内存映射与匿名内存区域 | 从 0x40000000(用户态地址空间的 1/3)开始,库放在依次更高的地址 | 从用户态栈末端(最低地址)附近开始,库放在依次更低的地址 |
| 用户态栈 | 从 0xc0000000 开始,向低地址生长(两种布局相同) | 同左 |
两种布局的差异只在文件内存映射和匿名映射区域的位置。经典布局中,这些区域从整个用户态地址空间的三分之一处(通常 0x40000000)开始,新区域放在更高线性地址上,即向着用户态栈的方向扩张。弹性布局正相反:映射区域放在用户态栈末端附近,新区域放在更低地址上,向堆的方向扩张(记住栈本身也向低地址生长)。
内核通常在能通过 RLIMIT_STACK 资源限制(见第 3 章”进程资源限制”)拿到用户态栈大小上限时使用弹性布局;这个限制决定了为栈保留的线性地址空间大小,但不得小于 128 MB、不得大于 2.5 GB。反之,如果 RLIMIT_STACK 被设为”无限”,或系统管理员把 sysctl 变量 legacy_va_layout 设为 1(写 /proc/sys/vm/legacy_va_layout 文件,或用相应的 sysctl() 系统调用),内核无法确定用户态栈大小的上限,就沿用经典布局。
为什么引入弹性布局?主要好处是让进程更好地利用用户态线性地址空间。经典布局下堆被限制在不到 1 GB,其他内存区域最多约 2 GB(减去栈大小);弹性布局下这些限制都没有了——堆和其他内存区域可以自由扩张,直到占满用户态栈和程序固定大小段之外的所有线性地址。
动手实验:观察内存布局
书上给了一个很有启发性的小实验。编写并编译下面的 C 程序:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
char cmd[32];
brk((void *)0x8051000);
sprintf(cmd, "cat /proc/self/maps");
system(cmd);
return 0;
}这个程序先扩大进程的堆(通过 brk() 系统调用,见第 9 章”堆管理”),然后读取 /proc 特殊文件系统里的 maps 文件——它列出了进程自身的全部内存区域。
不加栈大小限制时运行:
# ulimit -s unlimited; /tmp/memorylayout
08048000-08049000 r-xp 00000000 03:03 5042408 /tmp/memorylayout
08049000-0804a000 rwxp 00000000 03:03 5042408 /tmp/memorylayout
0804a000-08051000 rwxp 0804a000 00:00 0
40000000-40014000 r-xp 00000000 03:03 620801 /lib/ld-2.3.2.so
40014000-40015000 rwxp 00013000 03:03 620801 /lib/ld-2.3.2.so
40015000-40016000 rwxp 40015000 00:00 0
4002f000-40157000 r-xp 00000000 03:03 620804 /lib/libc-2.3.2.so
40157000-4015b000 rwxp 00128000 03:03 620804 /lib/libc-2.3.2.so
4015b000-4015e000 rwxp 4015b000 00:00 0
bffeb000-c0000000 rwxp bffeb000 00:00 0
ffffe000-fffff000 ---p 0000000 00:00 0
(依据编译器套件版本和链接方式不同,你看到的表可能略有差异。)每行开头的两个十六进制数是内存区域的范围,随后是权限标志,最后是有关该区域映射文件的信息(若有):文件内起始偏移、块设备号与 inode 号、文件名。
逐行解读这份”进程地址空间地图”:
- 所有列出的区域都是私有内存映射(权限列里的字母 p)——这不奇怪,因为这些区域的存在目的只是向进程提供数据,进程执行指令时可以修改它们的内容,而磁盘上关联的文件保持不变,这正是私有映射的行为方式。
- 从 0x8048000 开始的区域映射了 /tmp/memorylayout 文件的第 0 到 4095 字节,权限是可执行、只读、私有——这就是程序的代码段。
- 从 0x8049000 开始的区域映射同一文件的同一部分。这个程序太小了,代码、数据、bss 三个段挤在同一个文件页里,所以数据+bss 区域在线性地址空间上与前一个区域重叠。
- 第三个区域是进程的堆,注意它终止于传给
brk()的线性地址 0x8051000。 - 接下来从 0x40000000 和 0x40014000 开始的两个区域分别是动态链接器 /lib/ld-2.3.2.so 的代码段和数据+bss 段。动态链接器从不单独执行,它总是被映射进执行其他程序的进程地址空间。从 0x40015000 开始的匿名区域是动态链接器自己分配的。
- C 库(此系统上在 /lib/libc-2.3.2.so)的代码段和数据+bss 段映射在从 0x4002f000 开始的两个区域。记住私有区域的页框只要未被修改,就可以通过写时复制机制在多个进程间共享——所以 C 库只读的代码页实际上被几乎所有正在执行的进程共享(静态链接的除外)。从 0x4015b000 开始的匿名区域是 C 库分配的。
- 从 0xbffeb000 到 0xc0000000 的匿名区域是用户态栈——第 9 章讲过,栈会在必要时自动向低地址扩张。
- 最后从 0xffffe000 开始的单页匿名区域是进程的 vsyscall 页,发起系统调用和从信号处理函数返回时会访问它(见第 10、11 章)。
再限制栈大小运行同一个程序:
# ulimit -s 100; /tmp/memorylayout
08048000-08049000 r-xp 00000000 03:03 5042408 /tmp/memorylayout
08049000-0804a000 rwxp 00000000 03:03 5042408 /tmp/memorylayout
0804a000-08051000 rwxp 0804a000 00:00 0
b7ea3000-b7fcb000 r-xp 00000000 03:03 620804 /lib/libc-2.3.2.so
b7fcb000-b7fcf000 rwxp 00128000 03:03 620804 /lib/libc-2.3.2.so
b7fcf000-b7fd2000 rwxp b7fcf000 00:00 0
b7feb000-b7fec000 rwxp b7feb000 00:00 0
b7fec000-b8000000 r-xp 00000000 03:03 620801 /lib/ld-2.3.2.so
b8000000-b8001000 rwxp 00013000 03:03 620801 /lib/ld-2.3.2.so
bffeb000-c0000000 rwxp bffeb000 00:00 0
ffffe000-fffff000 ---p 00000000 00:00 0
注意布局的变化:动态链接器被映射到最高栈地址之上约 128 MB 处,而且由于 C 库的内存区域创建得更晚,它们拿到了更低的线性地址——这正是弹性布局”新区域向低地址扩张”的直观体现。
执行跟踪:ptrace
执行跟踪是这样一种技术:让一个程序监视另一个程序的执行。被跟踪的程序可以一条条指令地执行,直到收到某个信号或发起某个系统调用为止。调试器广泛使用执行跟踪,并辅以在被调试程序中插入断点、运行期访问其变量等技术。这里关注的是内核如何支持执行跟踪,而不是调试器本身怎么工作。
Linux 通过 ptrace() 系统调用实现执行跟踪,它在 80x86 架构上支持的命令包括:
| 命令 | 说明 |
|---|---|
| PTRACE_ATTACH | 开始对另一进程执行跟踪 |
| PTRACE_CONT | 恢复执行 |
| PTRACE_DETACH | 终止执行跟踪 |
| PTRACE_GETREGS / PTRACE_SETREGS | 读/写 CPU 特权寄存器 |
| PTRACE_GETFPREGS / PTRACE_SETFPREGS | 读/写浮点寄存器 |
| PTRACE_GETFPXREGS / PTRACE_SETFPXREGS | 读/写 MMX 和 XMM 寄存器 |
| PTRACE_PEEKTEXT / PTRACE_POKETEXT | 从代码段读/写一个 32 位值 |
| PTRACE_PEEKDATA / PTRACE_POKEDATA | 从数据段读/写一个 32 位值 |
| PTRACE_PEEKUSR / PTRACE_POKEUSR | 读/写 CPU 的普通寄存器和调试寄存器 |
| PTRACE_GETSIGINFO / PTRACE_SETSIGINFO | 获取/伪造最后一次递送给被跟踪进程的信号的信息 |
| PTRACE_SETOPTIONS | 修改 ptrace() 的行为 |
| PTRACE_GET_THREAD_AREA / PTRACE_SET_THREAD_AREA | 代表被跟踪进程读/写线程本地存储(TLS)区 |
| PTRACE_GETEVENTMSG | 从被跟踪进程获取附加数据(如新 fork 进程的 PID) |
| PTRACE_KILL | 杀死被跟踪进程 |
| PTRACE_SINGLESTEP | 单步执行一条汇编指令 |
| PTRACE_SYSCALL | 恢复执行直到发起/结束一次系统调用 |
权限规则是这样的:持有 CAP_SYS_PTRACE 能力标志的进程可以跟踪系统里除 init 之外的所有进程;没有该能力的进程 P 只能跟踪与 P 属主相同的进程。此外,一个进程同一时刻不能被两个进程跟踪。
ptrace() 会修改被跟踪进程描述符里的 parent 字段,让它指向跟踪进程——于是跟踪进程成了被跟踪进程的有效父进程。跟踪结束时(以 PTRACE_DETACH 调用 ptrace),系统调用把 p_pptr 恢复为 real_parent 的值,还原被跟踪进程原来的父进程(见第 3 章”进程间的关系”)。
被跟踪程序可以关联几种被监视的事件:
- 一条汇编指令执行完毕;
- 进入一次系统调用;
- 退出一次系统调用;
- 收到一个信号。
被监视事件发生时,被跟踪程序被停止,并向其父进程发送 SIGCHLD 信号。父进程想恢复子进程执行时,根据想监视的事件类型选用 PTRACE_CONT、PTRACE_SINGLESTEP 或 PTRACE_SYSCALL 命令:
- PTRACE_CONT 只是恢复执行,子进程一直运行到再次收到信号。这种跟踪靠进程描述符 ptrace 字段里的 PT_PTRACED 标志实现,
do_signal()函数会检查它(见第 11 章”递送信号”)。 - PTRACE_SINGLESTEP 强制子进程执行下一条汇编指令然后再次停止。80x86 上靠 eflags 寄存器里的 TF 陷阱标志实现:该标志置位时,每条汇编指令执行后都会引发”Debug”异常,相应的异常处理程序清除该标志、停止当前进程、向父进程发 SIGCHLD。注意设置 TF 标志不是特权操作,所以用户态进程不用 ptrace() 也能强制单步执行;内核用描述符里的 PT_DTRACE 标志记录子进程是否正被 ptrace() 单步跟踪。
- PTRACE_SYSCALL 让被跟踪进程恢复执行,直到发起一次系统调用。进程会被停止两次:第一次在系统调用开始时,第二次在系统调用结束时。它靠进程 thread_info 结构 flags 字段里的 TIF_SYSCALL_TRACE 标志实现,
system_call()汇编函数会检查该标志(见第 10 章)。
进程还可以利用 Intel Pentium 处理器的一些调试特性来被跟踪:父进程可以用 PTRACE_POKEUSR 命令为子进程设置 dr0~dr7 调试寄存器的值。当调试寄存器监视的事件发生时,CPU 引发”Debug”异常,异常处理程序便可暂停被跟踪进程并向父进程发 SIGCHLD。
可执行格式与执行域
可执行格式
Linux 的标准可执行格式是 ELF(Executable and Linking Format),由 Unix 系统实验室开发,如今是 Unix 世界使用最广的格式,System V Release 4、Sun 的 Solaris 2 等知名 Unix 都以 ELF 为主要可执行格式。老版本 Linux 支持另一种 a.out(Assembler OUTput)格式,变体还不少,如今已很少使用——ELF 实用得多。
Linux 还支持许多其他格式,因此能运行为其他操作系统编译的程序,比如 MS-DOS 的 EXE、BSD Unix 的 COFF;还有些格式与平台无关,如 Java 程序和 bash 脚本。
每种可执行格式由一个 linux_binfmt 类型的对象描述,它主要提供三个方法:
- load_binary:读取可执行文件中存储的信息,为当前进程建立新的执行环境;
- load_shlib:把共享库动态绑定到已运行的进程上,由
uselib()系统调用激活; - core_dump:把当前进程的执行上下文存进名为 core 的文件;文件格式取决于所执行程序的类型,通常在进程收到默认动作为”转储”的信号时创建(见第 11 章)。
所有 linux_binfmt 对象挂在一条单链表上,首元素地址存于 formats 变量;通过 register_binfmt() 和 unregister_binfmt() 函数可以在链表中插入、删除元素。系统启动时,对每个编译进内核的可执行格式都会执行 register_binfmt();加载实现新格式的模块时也会执行它,模块卸载时则调用 unregister_binfmt()。
formats 链表的最后一个元素永远是描述”解释型脚本”格式的对象。它只定义 load_binary 方法,对应的 load_script() 函数检查可执行文件是否以 #! 两个字符开头;如果是,就把第一行剩余部分解释为另一个可执行文件的路径名,并尝试执行它——以脚本文件名作为参数。这就是所有 shell 脚本得以运行的秘密。
Linux 还允许用户注册自定义可执行格式。格式可以用文件头 128 字节里的魔数(magic number)识别,也可以用文件扩展名识别(比如 MS-DOS 的 .exe 表示可执行程序、.bat 表示批处理脚本)。内核发现文件是自定义格式时,会启动相应的解释器程序:解释器在用户态运行,接收可执行文件路径名作为参数,然后继续计算。例如包含 Java 程序的可执行文件就交给 /usr/lib/java/bin/java 这样的 Java 虚拟机处理。这套机制与脚本格式类似,但更强大——对自定义格式没有任何限制。注册新格式的方法是往 binfmt_misc 特殊文件系统(通常挂载在 /proc/sys/fs/binfmt_misc)的 register 文件里写入一个字符串:
:name:type:offset:string:mask:interpreter:flags
各字段含义:name 是新格式的标识符;type 是识别方式(M 表示魔数、E 表示扩展名);offset 是魔数在文件内的起始偏移;string 是要匹配的字节序列;mask 是用来屏蔽 string 中某些位的掩码;interpreter 是解释器程序完整路径名;flags 控制解释器的调用方式。比如超级用户执行下面这条命令就能让内核识别 Microsoft Windows 可执行格式:
$ echo ':DOSWin:M:0:MZ:0xff:/usr/bin/wine:' > /proc/sys/fs/binfmt_misc/register
Windows 可执行文件头两个字节是 MZ 魔数,由 /usr/bin/wine 解释器程序执行。
执行域
Linux 的一个标志性特性是能执行为其他操作系统编译的文件(当然前提是文件里是本机同架构的机器码)。对这类”外来”程序提供两种支持:
- 模拟执行:用于包含不兼容 POSIX 的系统调用的程序;
- 本机执行:适用于系统调用完全兼容 POSIX 的程序。
MS-DOS 和 Windows 程序属于模拟类:它们包含 Linux 不认识的 API,必须由 DOSemu 或 Wine 这类模拟器把每个 API 调用翻译成模拟包装函数的调用,后者再使用现有的 Linux 系统调用。因为模拟器基本是用户态应用程序,本书不深入讨论。
而 POSIX 兼容的程序即使是在其他操作系统上编译的,也能比较顺利地直接执行,因为 POSIX 操作系统的 API 大体相似(理应完全一致,实际上不总是)。内核需要抹平的小差异通常在于系统调用的发起方式或各种信号的编号,这些信息存放在 exec_domain 类型的执行域描述符中。
进程通过设置描述符里的 personality 字段,并把相应 exec_domain 数据结构的地址存入 thread_info 结构的 exec_domain 字段来指定自己的执行域。进程可以发出 personality() 系统调用来改变自己的”个性”;该系统调用参数的典型取值包括 PER_LINUX(标准执行域)、PER_SVR4(System V Release 4)、PER_BSD(BSD Unix)、PER_SUNOS(SunOS)、PER_SCOSVR3(SCO Unix 3.2)、PER_SOLARIS(Sun 的 Solaris)、PER_HPUX(HP 的 HP-UX)、PER_IRIX32/PER_IRIX64(SGI IRIX)、PER_UW7(SCO UnixWare 7)、PER_OSF4(Digital UNIX)、PER_LINUX32_3GB(64 位架构上模拟 32 位 Linux 程序、使用 3 GB 用户态地址空间)等二十余种。程序员不该直接改程序的 personality——personality() 系统调用应当由搭建进程执行上下文的胶水代码发出(见下一节)。
exec 函数族与 execve 全流程
Unix 提供一族函数,用一个由可执行文件描述的新上下文替换进程当前的执行上下文。这些函数名字以 exec 开头,后面跟一两个字母,统称 exec 函数。它们因参数解释方式不同而各异:
| 函数名 | PATH 搜索 | 命令行参数 | 环境变量数组 |
|---|---|---|---|
| execl() | 否 | 列表 | 无 |
| execlp() | 是 | 列表 | 无 |
| execle() | 否 | 列表 | 有 |
| execv() | 否 | 数组 | 无 |
| execvp() | 是 | 数组 | 无 |
| execve() | 否 | 数组 | 有 |
第一个参数都是待执行文件的路径名,可以是绝对路径,也可以相对进程当前目录;若名字里不含 /,execlp() 和 execvp() 会在 PATH 环境变量指定的所有目录中搜索。带 l 的函数(execl、execlp、execle)用可变数量的参数逐个给出命令行参数,参数表以 NULL 结尾(习惯上第一个参数重复可执行文件名);带 v 的函数(execv、execvp、execve)用单个参数给出指针数组的地址,数组最后一个元素必须是 NULL。execle() 和 execve() 的最后一个参数是环境字符串指针数组的地址;其他函数则通过 C 库的 environ 全局变量获取新程序的环境。
除 execve() 之外的所有 exec 函数都只是 C 库里定义的包装例程,最终都用 execve()——这是 Linux 为程序执行提供的唯一系统调用。
sys_execve() 与 do_execve()
sys_execve() 服务例程接收三个参数(都在用户态地址空间里):可执行文件路径名的地址、指向”NULL 结尾的命令行参数字符串指针数组”的地址、指向”NULL 结尾的环境变量(NAME=value 格式)字符串指针数组”的地址。函数先把路径名复制进一个新分配的页框,然后调用 do_execve(),传入页框指针、两个指针数组和内核态栈上保存用户态寄存器内容的位置。do_execve() 依次做这些事:
- 动态分配一个
linux_binprm数据结构,用它填充与新可执行文件有关的数据; - 调用 path_lookup()、dentry_open() 和 path_release() 获得与可执行文件关联的 dentry 对象、file 对象和 inode 对象;失败则返回相应错误码;
- 验证当前进程有权执行该文件;并检查文件没在被写(看 inode 的 i_writecount 字段),然后往该字段存入 -1 以禁止后续写访问;
- 多处理器系统上调用 sched_exec(),找出负载最轻、能执行新程序的 CPU,并把当前进程迁移过去(见第 7 章);
- 调用 init_new_context() 检查当前进程是否在用自定义的局部描述符表(LDT,见第 2 章);是的话就为新程序分配并填充一张新 LDT;
- 调用 prepare_binprm() 填充 linux_binprm 结构,它又做三件事:再次检查文件可执行(第 3 步的检查不够,因为带 CAP_DAC_OVERRIDE 能力的进程总能通过检查);考虑可执行文件的 setuid/setgid 标志初始化 e_uid、e_gid 字段(并做前面说过的能力兼容性检查);把文件头 128 字节读进 buf 字段——这 128 字节里有可执行格式的魔数和其他识别信息;
- 把路径名、命令行参数和环境字符串复制进一个或多个新分配的页框(它们最终会归入用户态地址空间);
- 调用 search_binary_handler() 扫描 formats 链表,逐个尝试各元素的 load_binary 方法(传入 linux_binprm);一旦某个 load_binary 成功认出文件的格式,扫描立即停止;
- 若 formats 链表里没有这种格式,释放所有已分配页框并返回 -ENOEXEC——内核认不出这个可执行文件;
- 否则释放 linux_binprm 结构,返回与文件格式对应的 load_binary 方法的返回值。
load_binary 方法:搭建新世界
load_binary 方法(这里假设文件存放在支持文件内存映射的文件系统上,且程序需要若干共享库)要做的事:
- 检查文件头 128 字节里的魔数以确认格式;不匹配就返回 -ENOEXEC;
- 读取可执行文件头,其中描述了程序的各个段和所需的共享库;
- 从可执行文件里取得动态链接器的路径名——动态链接器负责定位共享库并把它们映射进内存;
- 拿到动态链接器的 dentry 对象(以及 inode 对象和 file 对象);
- 检查动态链接器的执行权限;
- 把动态链接器头 128 字节复制进缓冲区;
- 对动态链接器类型做一致性检查;
- 调用 flush_old_exec() 释放上一次计算用到的几乎所有资源。这一步内部又包括:若信号处理函数表与其他进程共享,就分配新表并递减旧表的使用计数,同时通过 de_thread() 函数让进程脱离旧的线程组(见第 3 章);调用 unshare_files() 在 files_struct 与其他进程共享时复制一份(见第 12 章);调用 exec_mmap() 释放内存描述符、所有内存区域和分配给进程的所有页框,并清空进程的页表;把描述符的 comm 字段设为可执行文件路径名;调用 flush_thread() 清除 TSS 段里保存的浮点寄存器和调试寄存器值;调用 flush_signal_handlers() 把每个信号的处理重置为默认动作;调用 flush_old_files() 关闭所有 files->close_on_exec 标志置位的已打开文件(见第 12 章)。
从此刻起就是”不归点”(point of no return)了:再出任何差错,函数都无法恢复之前的计算。
- 清除进程描述符里的 PF_FORKNOEXEC 标志——fork 时置位、执行新程序时清除,进程记账需要它;
- 设置进程的新 personality;
- 调用 arch_pick_mmap_layout() 选择进程内存区域的布局(即上一节的经典/弹性布局);
- 调用 setup_arg_pages() 为用户态栈分配新的内存区域描述符并插入进程地址空间,同时把包含命令行参数和环境变量字符串的页框划给这个新区域;
- 调用 do_mmap() 创建映射可执行文件代码段的新内存区域。区域的起始线性地址取决于格式,因为程序的可执行代码通常不可重定位——函数假定代码段从特定逻辑地址偏移(从而特定线性地址)开始加载,ELF 程序从 0x08048000 开始;
- 调用 do_mmap() 创建映射数据段的新内存区域。同理,起始地址取决于格式(代码期望在指定偏移处找到自己的变量),ELF 程序的数据段紧跟代码段之后;
- 为可执行文件其他特殊段分配额外的内存区域——通常没有;
- 调用加载动态链接器的函数(ELF 格式下叫 load_elf_interp())。它执行与第 12~14 步类似的操作,只是对象换成动态链接器;动态链接器代码和数据区域的起始地址由链接器自己指定,但都很高(通常在 0x40000000 以上),以免与待执行文件的区域冲突;
- 把该可执行格式的 linux_binfmt 对象地址存入进程描述符的 binfmt 字段;
- 确定进程的新能力;
- 创建动态链接器专用的表,存放在用户态栈上命令行参数与环境变量指针数组之间的位置(见图 1);
- 设置内存描述符的 start_code、end_code、start_data、end_data、start_brk、brk、start_stack 等字段;
- 调用 do_brk() 创建映射程序 bss 段的新匿名内存区域(进程往变量写入时会触发缺页,从而分配页框)。区域大小在链接时算好;起始地址必须指定,因为代码不可重定位,ELF 程序的 bss 段紧跟数据段之后;
- 调用 start_thread() 宏修改保存在内核态栈上的用户态 eip 和 esp 寄存器值,让它们分别指向动态链接器的入口点和新用户态栈顶;
- 若进程正被跟踪,通知调试器 execve() 已完成;
- 返回 0(成功)。
execve() 系统调用结束、进程回到用户态恢复执行时,执行上下文已面目全非:调用系统调用的那段代码已经不存在了。从这个意义上说,execve() 成功时从不”返回”——取而代之的是,一个新程序被映射进了进程的地址空间。
不过新程序还不能马上执行,因为动态链接器还得先加载共享库。虽然动态链接器运行在用户态,这里简单勾勒一下它的工作方式:它先利用内核存放在用户态栈上的信息(环境变量指针数组与 arg_start 之间的部分)为自己建立基本的执行上下文;然后检查待执行程序,确定必须加载哪些共享库、每个库里真正用到哪些函数;接着发起多个 mmap() 系统调用,创建映射库函数页(代码和数据)的内存区域;再根据库内存区域的线性地址,更新所有对共享库符号的引用;最后跳到待执行程序的 main 入口点,结束自己的使命。从此,进程开始执行可执行文件和共享库里的代码。
可以看到,执行一个程序牵涉内核设计的许多方面:进程抽象、内存管理、系统调用、文件系统……正是这类主题让人由衷感叹 Linux 是一件多么精妙的作品!
常见坑:以为 execve 之后"旧的还在"
execve 成功意味着旧的地址空间、未处理的信号处理器、close_on_exec 标记的文件全部被清掉,而且进入”不归点”后失败也无法回滚——返回错误的 execve 其实已经回不去了。另外别忘了:PID 不变、未标记 close_on_exec 的文件描述符会被继承,很多服务程序正是靠这一点在 exec 前后传递监听套接字的。
通关标准
不看原文,能独立画出”shell 输入 /bin/ls 后”的完整链路:fork → execve → sys_execve → do_execve(linux_binprm、128 字节魔数、search_binary_handler)→ load_binary(flush_old_exec 清旧资源 → do_mmap 代码段/数据段 → do_brk bss → start_thread 改 eip/esp)→ 动态链接器补共享库 → 跳到 main;并能说清 exec 前后”什么变了、什么没变”。
uid、euid、fsuid、suid 四个字段各有什么作用?执行 setuid 程序时哪个字段会变?
uid 是真实用户标识符(进程属主),euid 是有效用户标识符(绝大多数权限检查看它),fsuid 专用于文件访问的权限检查,suid 保存 setuid 程序启动时的有效标识符以便随时恢复。执行 setuid 程序时,euid 和 fsuid 被设为可执行文件属主的标识符(如 root 时为 0),uid 和 suid 保持原值——passwd 命令正是靠 fsuid=0 才能写受保护的密码文件。
经典内存布局与弹性内存布局的区别是什么?内核依据什么选择?
两者只在”文件内存映射与匿名区域”的位置不同:经典布局从用户态地址空间 1/3 处(0x40000000)开始向高地址(栈方向)扩张,堆因此被限制在 1 GB 以内;弹性布局从用户态栈末端附近开始向低地址(堆方向)扩张,堆与映射区可自由占满剩余空间。内核能在 RLIMIT_STACK 里取到用户态栈上限(128 MB ~ 2.5 GB)时用弹性布局;RLIMIT_STACK 为无限、或 legacy_va_layout 设为 1 时退回经典布局。
PTRACE_SINGLESTEP 和 PTRACE_SYSCALL 分别靠什么机制实现?
SINGLESTEP 靠 eflags 寄存器的 TF 陷阱标志:置位后每条汇编指令执行完都触发 Debug 异常,处理程序清标志、停住进程、给父进程发 SIGCHLD;TF 不是特权操作,不用 ptrace 也能单步。SYSCALL 靠 thread_info 的 flags 里的 TIF_SYSCALL_TRACE 标志:system_call() 汇编函数检查它,进程在系统调用开始和结束各停一次。
Linux 如何执行一个它"不认识"的脚本或二进制?formats 链表最后的兜底元素是什么?
可执行格式由 linux_binfmt 对象描述,全部挂在 formats 链表上,用 register_binfmt()/unregister_binfmt() 增删。链表最后一个元素永远是解释型脚本格式:load_script() 检查文件是否以 #! 开头,是则把第一行剩余部分当作解释器路径执行脚本。其他自定义格式可通过写 /proc/sys/fs/binfmt_misc/register 注册(:名称:类型:偏移:魔数:掩码:解释器:标志),内核据此启动解释器(如用 wine 跑 Windows 的 MZ 文件)。
为什么说"execve() 成功时从不返回"?它返回错误码时又意味着什么?
成功时,flush_old_exec 已释放旧地址空间、重置信号处理器并关闭 close_on_exec 文件,用户态返回地址指向的旧代码已不存在,进程接下来执行的是动态链接器而非调用者——所以说”从不返回”。返回错误码(如 -ENOEXEC)则发生在”不归点”(flush_old_exec)之前,进程的旧执行上下文完好无损,可以继续按原样运行,这也是 shell 能依次尝试多种格式的原因。