[链接器的世界-原理篇06] 从入口到 main:程序启动之前发生了什么
下文命令中的文件名只表示本次观察产生的输入和输出;目录位置可以由读者自行选择。
可执行文件里的 0x401067 只是一个数字。要让它成为下一条指令的地址,操作系统必须先建立对应的内存映射、准备栈和寄存器,再把控制权交过去。链接器安排了文件中的字节和地址;加载器让这些安排在一个进程里生效。
观察这个交接过程,用一个静态链接的 C 程序就够了。示例包含一个初始化过的全局变量、一个 4 KiB 的未初始化数组和一次 printf,在 x86-64 Linux 上用本机 musl-gcc 静态链接:
#include <stdio.h>int counter = 1;int buffer[1024];int main(int argc, char **argv) { buffer[0] = counter + argc; printf("hello %s %d\n", argv[0], buffer[0]); return 0;}$ musl-gcc -static -no-pie -O1 hello.c -o hello$ readelf -lW helloElf file type is EXEC (Executable file)Entry point 0x401067There are 6 program headers, starting at offset 64
Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x000190 0x000190 R 0x1000 LOAD 0x001000 0x0000000000401000 0x0000000000401000 0x004907 0x004907 R E 0x1000 LOAD 0x006000 0x0000000000406000 0x0000000000406000 0x000cb4 0x000cb4 R 0x1000 LOAD 0x006fc0 0x0000000000407fc0 0x0000000000407fc0 0x000150 0x0017f8 RW 0x1000 GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10 GNU_RELRO 0x006fc0 0x0000000000407fc0 0x0000000000407fc0 0x000040 0x000040 R 0x1文件里还有符号表、字符串表和节头表,可加载段只占其中一部分。每一行 LOAD 就像装箱单上的一项:从文件偏移 Offset 起取 FileSiz 个字节,放到虚拟地址 VirtAddr,在内存里占 MemSiz 个字节,权限是 Flg。GNU_RELRO 圈出重定位完成后应改成只读的区域,GNU_STACK 只声明栈权限,它们不另建一份文件映射。再加上 ELF1 头里的入口地址 0x401067,链接器要交代给加载器的话基本就说完了。
本章的编译、链接、文件检查和运行全部在同一台 x86-64 Linux 上完成:Ubuntu 26.04,Linux 7.0.0-28,GCC2 15.2、GNU3 binutils4 2.46、Clang/LLD 21.1.8、musl5 1.2.5。musl-gcc6 是本机 GCC 的 musl 包装器,gcc 默认使用 glibc7。直接运行 ./程序,不经过跨架构容器或用户态模拟器。每个观察都应在独立临时目录中执行。
源码阅读固定采用 Linux v6.8 和 musl v1.2.5,便于对应行号;运行记录来自上述 Linux 7.0。源码版本与实验内核版本不同,具体错误码以实际运行记录为准。
先明确加载器要兑现什么
对一个有效的 PT_LOAD,ELF 要求加载器建立以下关系。设本次加载相对于链接地址的偏移为 B;固定地址程序通常取 B=0,位置无关程序的 B 由本次运行选择。
| 范围 | 应有的内容 |
|---|---|
文件 [p_offset, p_offset + p_filesz) | 段的初始化字节 |
内存 [B + p_vaddr, B + p_vaddr + p_filesz) | 与上面的文件字节一一对应 |
内存 [B + p_vaddr + p_filesz, B + p_vaddr + p_memsz) | 零填充,不需要文件存储 |
这要求 p_filesz ≤ p_memsz,区间计算不能溢出,初始化字节必须可从文件取得。p_flags 指定映射需要的读、写、执行权限。以上是文件交给加载器的约定;把文件复制到已清零的内存,或按页映射文件后补齐零,属于不同的兑现方式。加载器无需知道哪个符号叫 buffer,也不必查找名为 .bss 的节:段的范围已经表达了需求。
最小的加载器:xv6 的 kexec
Linux 的 fs/binfmt_elf.c 有 2115 行(v6.8),其中 load_elf_binary 一个函数就将近 500 行,先看一个小得多的版本。xv68 是 MIT 操作系统课 6.1810 用的教学操作系统,几千行 C,跑在 RISC-V9 上。它的 exec 系统调用在内核里的实现叫 kexec,在 kernel/exec.c。下面引用的是 mit-pdos/xv6-riscv 的 riscv 分支,commit 06aad25,第 48 到 78 行:
// Read the ELF header. if (readi(ip, 0, (uint64)&elf, 0, sizeof(elf)) != sizeof(elf)) goto bad;
// Is this really an ELF file? if (elf.magic != ELF_MAGIC) goto bad;
if ((pagetable = proc_pagetable(p)) == 0) goto bad;
// Load program into memory. for (i = 0, off = elf.phoff; i < elf.phnum; i++, off += sizeof(ph)) { if (readi(ip, 0, (uint64)&ph, off, sizeof(ph)) != sizeof(ph)) goto bad; if (ph.type != ELF_PROG_LOAD) continue; if (ph.memsz < ph.filesz) goto bad; if (ph.vaddr + ph.memsz < ph.vaddr) goto bad; if (ph.vaddr % PGSIZE != 0) goto bad; uint64 sz1; if ((sz1 = uvmalloc(pagetable, sz, ph.vaddr + ph.memsz, flags2perm(ph.flags))) == 0) goto bad; sz = sz1; if (loadseg(pagetable, ph.vaddr, ip, ph.off, ph.filesz) < 0) goto bad; }readi 从文件的指定偏移读若干字节,相当于内核里的 pread。整个流程是:读 ELF 头,核对开头 4 字节的魔数 \x7fELF;新建一张空的用户页表(页表是 CPU 用来把虚拟地址翻译成物理地址的表,每个进程一张);然后按 phoff 和 phnum 逐个读程序头,不是 PT_LOAD 的一律跳过。对每个 PT_LOAD,uvmalloc 把进程的大小从当前的 sz 扩到 vaddr + memsz,中间每一页都分配物理页,按 flags2perm 把 ELF 的 X、W 位翻成页表的执行、可写权限位;loadseg 再把文件里 off 起的 filesz 个字节读进这些页。
kernel/elf.h 里只定义了 ELF 头和程序头两个结构,没有节头,也没有符号和重定位。kexec 用到的字段,ELF 头里是 magic、phoff、phnum、entry,程序头里是 type、flags、off、vaddr、filesz、memsz。第 2 章讲的节、符号表、重定位记录,kexec 一个都不读。
.bss 在这里没有任何专门的代码。uvmalloc 按 vaddr + memsz 分配页,loadseg 只读 filesz 个字节,中间那段自然就是零,因为 uvmalloc 每拿到一页都先清零(kernel/vm.c 第 233 行 memset(mem, 0, PGSIZE);)。xv6 book 第 3.7 节用 /init 举例:数据段 filesz 是 0x10,memsz 是 0x30,"uvmalloc allocates enough physical memory to hold 0x30 bytes, but reads only 0x10 bytes from the file"。
ph.vaddr % PGSIZE != 0 这一条就是第 5 章讲 xv6 user.ld 时说过的要求:xv6 不用 mmap,loadseg 一页一页地读,所以段必须从页边界开始,user/user.ld 的 . = ALIGN(0x1000); 就是为它写的。
段装完,kexec 在程序映像之上再分配栈:先空出一页并清掉用户可访问位,当作保护页(guard page,访问它就触发异常,用来抓栈溢出),上面才是栈页。参数字符串逐个拷到栈顶,每次按 16 字节对齐,再压入指向它们的指针数组。最后几行提交新映像(第 132 到 140 行):
// Commit to the user image. oldpagetable = p->pagetable; p->pagetable = pagetable; p->sz = sz; p->trapframe->epc = elf.entry; // initial program counter = ulib.c:start() p->trapframe->sp = sp; // initial stack pointer proc_freepagetable(oldpagetable, oldsz);
return argc; // this ends up in a0, the first argument to main(argc, argv)trapframe 保存着进程陷入内核(因系统调用、异常或中断从用户态切换到内核态)时的用户寄存器,系统调用返回时从这里恢复。把 epc(返回后执行的地址)改成 elf.entry,把 sp 改成新栈顶,系统调用一返回,CPU 就从新程序的入口开始执行。argc 作为系统调用的返回值放进 a0 寄存器,argv 的地址在前面已经写进了 a1,按 RISC-V 的调用约定(规定参数和返回值放在哪些寄存器里的约定),这正好是 main 的头两个参数。顺序也有讲究:前面任何一步失败都跳到 bad,释放新页表并返回 −1,旧程序还在,可以收到这个错误;只有全部成功,才换上新页表、释放旧的。
再看循环里那三道检查。memsz < filesz 拒绝"内存里比文件里还小"的段;vaddr % PGSIZE 是上面说的对齐;中间那条 vaddr + memsz < vaddr 检查的是加法溢出。xv6 book 专门解释了它(第 3.7 节):
The exec system call loads bytes from the ELF file into memory at addresses specified by the ELF file. Users or processes can place whatever addresses they want into an ELF file. Thus exec is risky, because the addresses in the ELF file may refer to the kernel, accidentally or on purpose.
如果 vaddr 指向任意地址,memsz 又大到让两者之和绕回 0x1000 附近,后面按 vaddr + memsz 算出来的大小就像一个正常值。书里说,在早期把内核也放进用户地址空间的 xv6 版本里,这能让用户把 ELF 里的数据拷进内核内存;RISC-V 版的内核有独立的页表,loadseg 只往进程的页表里写,这条路已经走不通,检查仍然保留。接着是这样一段话:
It is easy for a kernel developer to omit a crucial check, and real-world kernels have a long history of missing checks whose absence can be exploited by user programs to obtain kernel privileges. It is likely that xv6 doesn't do a complete job of validating user-level data supplied to the kernel, which a malicious user program might be able to exploit to circumvent xv6's isolation.
可执行文件是用户提供的数据,加载器读它,就和系统调用读用户传来的参数一样,要当作不可信输入。
Linux 多做了什么
Linux 处理 ELF 的代码在 fs/binfmt_elf.c,入口是 load_elf_binary。execve 打开文件、读出开头一段字节以后,依次问内核里注册的每一种可执行格式"这是不是你的",ELF 格式的处理函数就是它。本节以 Linux v6.8 的 fs/binfmt_elf.c 为准,行号都出自这个版本,与本次实验内核版本分开记录。这些检查和调用顺序按 v6.8 解读,不把实现细节当作所有内核版本的 ABI10 承诺。
先把程序头都读一遍
函数一开始的检查和 xv6 差不多,只是更全(第 843 到 848 行):魔数要对,e_type 只能是 ET_EXEC 或 ET_DYN,elf_check_arch 核对 e_machine 等字段是不是本机架构。读程序头表的 load_elf_phdrs(第 523 行)还要求 e_phentsize 等于结构体大小,整张表的大小满足 size == 0 || size > 65536 || size > ELF_MIN_ALIGN 时一律拒绝。ELF_MIN_ALIGN 是一页,所以在 4 KiB 页的机器上,程序头最多 73 个。任何一条不满足,函数返回 -ENOEXEC,意思是"这不是我认识的格式",execve 把这个错误交还给调用者,旧程序照常运行。
然后它把程序头表扫两遍。第一遍找 PT_INTERP(第 868 到 893 行,节选):
if (elf_ppnt->p_type != PT_INTERP) continue; ... retval = elf_read(bprm->file, elf_interpreter, elf_ppnt->p_filesz, elf_ppnt->p_offset); ... /* make sure path is NULL terminated */ retval = -ENOEXEC; if (elf_interpreter[elf_ppnt->p_filesz - 1] != '\0') goto out_free_interp;
interpreter = open_exec(elf_interpreter);PT_INTERP 段的内容是一个以零结尾的路径字符串,比如 /lib/ld-musl-x86_64.so.1,叫程序解释器。通常由内核直接启动的动态链接可执行文件带有这个段,内核会顺带把这个文件也加载进来,最后跳到它的入口而不是程序自己的入口。解释器就是第 7 章的主角 ld.so。本章的 hello 是静态链接的,没有这个段,interpreter 保持为空。
第二遍看 PT_GNU_STACK(第 927 到 931 行):
case PT_GNU_STACK: if (elf_ppnt->p_flags & PF_X) executable_stack = EXSTACK_ENABLE_X; else executable_stack = EXSTACK_DISABLE_X; break;这个段不对应文件里的任何内容,只用 p_flags 告诉内核栈要不要执行权限(第 1 章讲过它的来历,第 5 章讲过它在 readelf11 里的样子)。结果存在 executable_stack 里,稍后传给建立栈的 setup_arg_pages,没有这个段时按架构的默认值处理。练习三会把它的 p_flags 改成 RWX,在 maps 里看栈的权限怎么变。
到这里为止的所有错误都还能正常返回给调用者。接下来第 996 行调用 fs/exec.c 里的 begin_new_exec。它先在第 1273 行设下 bprm->point_of_no_return = true,注释是 "Ensure all future errors are fatal";然后用 de_thread 杀掉进程里的其他线程,再用 exec_mmap 把进程的内存描述符 current->mm 换成新的,释放旧的地址空间。新的 mm 并不是这时才建:execve 一开始就为新程序准备了一个只含栈的 mm,参数和环境变量字符串也早已拷进那块栈里。必须先换上它再映射段,是因为后面要用的 vm_mmap、vm_brk_flags 都只作用于当前进程的 current->mm。换上之后再出错,旧程序已经不存在,execve 没有可以返回的地方,内核只能用 SIGSEGV 结束进程。xv6 的页表可以脱离进程单独建好,所以它能先建新的、成功了再释放旧的。
一个段一次 mmap
换上新的地址空间、建好栈之后,主循环逐个处理 PT_LOAD。每个段交给 elf_load,它再调用 elf_map,核心是一次 vm_mmap(内核内部的 mmap):
unsigned long size = eppnt->p_filesz + ELF_PAGEOFFSET(eppnt->p_vaddr); unsigned long off = eppnt->p_offset - ELF_PAGEOFFSET(eppnt->p_vaddr); addr = ELF_PAGESTART(addr); size = ELF_PAGEALIGN(size); ... map_addr = vm_mmap(filep, addr, size, prot, type, off);ELF_PAGEOFFSET 取地址的页内偏移,ELF_PAGESTART 向下取整到页边界,ELF_PAGEALIGN 向上取整。段的起点往回退到页边界,文件偏移跟着退同样的字节数,所以第 5 章说的同余是必需的:只有 p_vaddr 和 p_offset 的页内偏移相同,往回退之后两者才同时落在页边界上,mmap 才接受。
映射用的标志是 MAP_PRIVATE。这样映射的页是写时复制(copy-on-write)的:没写之前,进程看到的就是内核页缓存(page cache,内核为文件内容在内存里保留的缓存页)里那一页,多个进程运行同一个程序时共享同一份物理内存;进程第一次写某一页时,内核才给它复制一份私有副本,改动不会写回文件。地址上的要求另有两个标志表达。MAP_FIXED 表示"必须放在这个地址",那里原有的映射会被直接替换掉;MAP_FIXED_NOREPLACE(Linux 4.17 起)同样要求这个地址,但那里已有映射时返回 EEXIST,不做替换。对 ET_EXEC 类型的程序,第一个段用 MAP_FIXED_NOREPLACE 放到 p_vaddr,后面的段直接用 MAP_FIXED。段的权限由 make_prot 从 p_flags 翻译成 PROT_READ、PROT_WRITE、PROT_EXEC。
这个程序和前例一样有 counter 和 buffer[1024],运行时打印自己的 /proc/self/maps,这个文件按地址列出进程当前的每一段映射。在 x86-64 Linux 上原生编译运行(省略了与本节无关的几行):
$ musl-gcc -static -no-pie -O1 bsstail.c -o bsstail$ readelf -lW bsstail | grep LOAD LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x000190 0x000190 R 0x1000 LOAD 0x001000 0x0000000000401000 0x0000000000401000 0x007d5d 0x007d5d R E 0x1000 LOAD 0x009000 0x0000000000409000 0x0000000000409000 0x000e2c 0x000e2c R 0x1000 LOAD 0x009fb0 0x000000000040afb0 0x000000000040afb0 0x000160 0x001be0 RW 0x1000$ ./bsstail00400000-00401000 r--p 00000000 00:23 734688 <work>/bsstail00401000-00409000 r-xp 00001000 00:23 734688 <work>/bsstail00409000-0040a000 r--p 00009000 00:23 734688 <work>/bsstail0040a000-0040c000 rw-p 00009000 00:23 734688 <work>/bsstail0040c000-0040d000 rw-p 00000000 00:00 03a584000-3a585000 ---p 00000000 00:00 0 [heap]3a585000-3a586000 rw-p 00000000 00:00 0 [heap]本机 GNU ld 将只读头部、代码、只读数据和可写数据分成四个 LOAD 段。Align 是 0x1000,和实验机 4 KiB 页一致。第四个 LOAD 从 0x40afb0 开始,向下取整到 0x40a000;文件偏移 0x9fb0 也向下取整到 0x9000。文件映射长度是 0xfb0 + 0x160 = 0x1110,向上取整为两页,因此 maps 显示 0040a000-0040c000 rw-p 00009000。第四列是文件偏移,p 表示私有映射。
0040c000-0040d000 没有文件名,是剩余 .bss 对应的匿名映射。[heap] 是堆,[vvar]、[vvar_vclock] 和 [vdso] 是内核提供的数据或代码,[stack] 是栈。它们不能混同为可执行文件的 LOAD 段。
.bss 是怎么来的
第 5 章说过,p_memsz 大于 p_filesz 时,多出来的部分由加载器清零。在 Linux 里这件事分两步,都在 elf_load 里(第 400 到 446 行,节选):
if (eppnt->p_filesz) { map_addr = elf_map(filep, addr, eppnt, prot, type, total_size); ... if (eppnt->p_memsz > eppnt->p_filesz) { zero_start = map_addr + ELF_PAGEOFFSET(eppnt->p_vaddr) + eppnt->p_filesz; ... if (padzero(zero_start) && (prot & PROT_WRITE)) return -EFAULT; } } ... if (eppnt->p_memsz > eppnt->p_filesz) { ... zero_start = ELF_PAGEALIGN(zero_start); zero_end = ELF_PAGEALIGN(zero_end);
error = vm_brk_flags(zero_start, zero_end - zero_start, prot & PROT_EXEC ? VM_EXEC : 0);第一步处理文件映射的最后一页。mmap 只能整页映射,文件内容在页中间结束时,这一页的后半截映射进来的是文件里紧跟在后面的字节,它们可能属于其他节,不能假定为零。padzero(第 118 行)把从 p_filesz 结束处到页尾的这一截清零,源码注释说得很直接:"otherwise this memory will contain the junk from the file that should not be present"。清零是一次写操作,写时复制让这一页变成进程私有的副本,文件本身不受影响。
第二步处理剩下的整页。vm_brk_flags 在 zero_start 和 zero_end 之间建立一段匿名映射,匿名区域首次读取时可能映射共享零页,首次写入时才需要私有的清零物理页。上面 maps 里的第五行就是它:段的终点是 0x40afb0 + 0x1be0 = 0x40cb90,文件映射到 0x40c000 为止,剩下的 0x40c000 到 0x40d000 由匿名映射补齐。
下图把同一个例子的段边界和页边界区分开。程序头要求的零区间从 0x40b110 开始,其中 [0x40b110, 0x40c000) 属于最后一张文件映射页,要覆盖为零;后面的 [0x40c000, 0x40cb90) 由匿名页提供。段声明到 0x40cb90 为止,整页映射却延伸到 0x40d000。不能把这部分页内余量算进 p_memsz,也不能把整个匿名映射长度当成 BSS 的大小。
第一步可以直接验证。bsstail.c 从辅助向量的 AT_PHDR 项(内核放在栈上的一个值,告诉程序"你的程序头表在内存哪里")找到自己的 RW 段,分别打印文件里和内存里 p_filesz 结束处的 16 个字节:
RW: vaddr 0x40afb0 filesz 0x160 memsz 0x1be0 -> file part ends at 0x40b110file @0xa110: 47 43 43 3a 20 28 55 62 75 6e 74 75 20 31 35 2ememory@0x40b110: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00文件里那 16 个字节是 ASCII 的 GCC: (Ubuntu 15.,也就是紧跟在 .data 后面的 .comment 节(readelf -S 显示它的文件偏移正是 0xa110)。同一个位置在内存里已经是全零,.bss 从这里开始。
映射了,但还没读
xv6 的 kexec 在返回之前就把段的内容全部读进了内存。Linux 的 vm_mmap 只是在进程的地址空间里登记一段区域(内核叫它 VMA,virtual memory area,和第 5、10 章里 GNU ld 所说的 VMA 同名不同义),并不要求此时把区域内所有文件页都装入页表。程序第一次访问某一页时,CPU 找不到页表项,触发缺页异常(page fault),内核查到这一页属于哪个 VMA、对应文件的哪个位置,才去页缓存里找这一页(必要时从磁盘读入),填进页表,再让程序继续执行。这叫按需调页(demand paging)。
示例有一个 32 MiB 的只读表格和一个 32 MiB 的 .bss 数组,它在启动时、读完表格后、写完数组后各读一次 /proc/self/smaps(maps 的详细版,每段映射附带大小、驻留量等统计),打印两个大映射的 Size 和 Rss(实际驻留在物理内存里的大小):
#define N (32u << 20) /* 32 MiB */const unsigned char table[N] = { 1 }; /* 进 .rodata,占文件空间 */unsigned char zeros[N]; /* 进 .bss,不占文件空间 */$ musl-gcc -static -no-pie -O1 paging.c -o paging$ ./pagingtable=0x40d0e0 zeros=0x2410140start 0040d000 r--p Size 32776 kB Rss 124 kBstart 02411000 rw-p Size 32768 kB Rss 4 kBafter-table 0040d000 r--p Size 32776 kB Rss 32776 kBafter-table 02411000 rw-p Size 32768 kB Rss 4 kBafter-zeros 0040d000 r--p Size 32776 kB Rss 32776 kBafter-zeros 02411000 rw-p Size 32768 kB Rss 32768 kB第一次打印时,包含只读表格的映射只有 124 kB 在内存里,是从 _start 到这一刻实际执行过的代码和读过的数据所在的页,外加内核预读的一些相邻页。每隔 4096 字节读一次表格,32 MiB 才全部调入。.bss 的匿名映射也一样,写过每一页以后才占满 32768 kB。可执行文件的全部字节不必在 execve 完成前读入;启动开销仍受映射数量、实际触及的页和缓存状态等因素影响,后续缺页把一部分读盘工作推迟到了运行过程中。
基址:ET_EXEC 与 ET_DYN
到现在为止的例子都是 ET_EXEC,段放在 p_vaddr 指定的地址,每次运行都一样。这里的 ET_DYN(PIE12 和共享库)按接近 0 的链接地址排列;格式不强制第一段必须从 0 开始。运行地址要在链接地址上加装载偏移,内核要先选一个基址,代码里叫 load_bias,之后每个段的地址都是 load_bias + p_vaddr。第一个 LOAD 段到来时,load_elf_binary 这样选(第 1086 到 1095 行,以及第 1104 行):
if (interpreter) { load_bias = ELF_ET_DYN_BASE; if (current->flags & PF_RANDOMIZE) load_bias += arch_mmap_rnd(); alignment = maximum_alignment(elf_phdata, elf_ex->e_phnum); if (alignment) load_bias &= ~(alignment - 1); elf_flags |= MAP_FIXED_NOREPLACE; } else load_bias = 0; ... load_bias = ELF_PAGESTART(load_bias - vaddr);有解释器的 ET_DYN 就是普通的动态链接 PIE,基址从 ELF_ET_DYN_BASE 起,再加上一个随机量。x86-64 上 ELF_ET_DYN_BASE 定义为 DEFAULT_MAP_WINDOW / 3 * 2(arch/x86/include/asm/elf.h)。DEFAULT_MAP_WINDOW 是用户态 mmap 默认可用的地址上限,四级页表下为 (1UL << 47) - PAGE_SIZE,也就是 128 TiB 减一页。算出来 ELF_ET_DYN_BASE 是 0x555555554aaa,按页取整后是 0x555555554000。关掉随机化(gdb 默认就会关掉)调试一个 PIE 时,程序总在 0x555555554000 附近,就是这么来的。
没有解释器的 ET_DYN 走 else 分支,load_bias 为 0,又没有 MAP_FIXED 一类的标志,于是 mmap 自己在 mmap 区域(共享库、匿名大块内存通常所在的高地址区域)里挑一个随机位置。源码上方的注释解释了为什么要分开:这类文件通常是 ld.so 本身,人们会用 ./ld.so someprog 的方式直接运行它,它再去加载 someprog,两者不能抢同一片地址。静态 PIE 也属于这一类。在本机原生运行同一个静态 PIE(用 -static-pie 编译)两次,再运行用默认选项编译的动态链接 PIE 两次:
$ musl-gcc -static-pie -O1 spie_maps.c -o spie_maps$ ./spie_maps; ./spie_mapsops[0]=0x76b9cb2d74c9 main=0x76b9cb2d74d1555590c0d000-555590c0e000 rw-p 00000000 00:00 0 [heap]76b9cb2d6000-76b9cb2d7000 r--p 00000000 00:23 734730 <work>/spie_maps76b9cb2d7000-76b9cb2de000 r-xp 00001000 00:23 734730 <work>/spie_maps76b9cb2de000-76b9cb2df000 r--p 00008000 00:23 734730 <work>/spie_maps76b9cb2df000-76b9cb2e1000 rw-p 00008000 00:23 734730 <work>/spie_mapsops[0]=0x7b4be7b124c9 main=0x7b4be7b124d1555568701000-555568702000 rw-p 00000000 00:00 0 [heap]7b4be7b11000-7b4be7b12000 r--p 00000000 00:23 734730 <work>/spie_maps7b4be7b12000-7b4be7b19000 r-xp 00001000 00:23 734730 <work>/spie_maps7b4be7b19000-7b4be7b1a000 r--p 00008000 00:23 734730 <work>/spie_maps7b4be7b1a000-7b4be7b1c000 rw-p 00008000 00:23 734730 <work>/spie_maps$ musl-gcc -pie -fPIE -O1 spie_maps.c -o pie_dyn$ ./pie_dyn; ./pie_dynops[0]=0x573bac1231b9 main=0x573bac1231c1573bac122000-573bac123000 r--p 00000000 00:23 734736 <work>/pie_dyn573bac123000-573bac124000 r-xp 00001000 00:23 734736 <work>/pie_dyn573bac124000-573bac125000 r--p 00002000 00:23 734736 <work>/pie_dyn573bac125000-573bac126000 r--p 00002000 00:23 734736 <work>/pie_dyn573bac126000-573bac127000 rw-p 00003000 00:23 734736 <work>/pie_dyn573be6870000-573be6871000 ---p 00000000 00:00 0 [heap]573be6871000-573be6872000 rw-p 00000000 00:00 0 [heap]ops[0]=0x5580b34291b9 main=0x5580b34291c15580b3428000-5580b3429000 r--p 00000000 00:23 734736 <work>/pie_dyn5580b3429000-5580b342a000 r-xp 00001000 00:23 734736 <work>/pie_dyn5580b342a000-5580b342b000 r--p 00002000 00:23 734736 <work>/pie_dyn5580b342b000-5580b342c000 r--p 00002000 00:23 734736 <work>/pie_dyn5580b342c000-5580b342d000 rw-p 00003000 00:23 734736 <work>/pie_dyn5580d9900000-5580d9901000 ---p 00000000 00:00 0 [heap]5580d9901000-5580d9902000 rw-p 00000000 00:00 0 [heap]静态 PIE 两次基址分别是 0x76b9cb2d6000 和 0x7b4be7b11000,处在高地址 mmap 区域;动态 PIE 两次基址是 0x573bac122000 和 0x5580b3428000。x86-64 的 ELF_ET_DYN_BASE 约在 0x555555554000,动态主程序在这个区域加随机偏移。静态 PIE 的堆也出现在 0x5555 开头,这是下面 brk 处理的特例。随机地址每次都会改变,不能当成固定答案。
brk
maps 里的 [heap] 也是 load_elf_binary 安排的。循环里记下所有段的最高地址 elf_brk,即 p_vaddr + p_memsz 的最大值。循环结束后,进程的 brk(堆顶指针,brk/sbrk 系统调用通过移动它来扩大或缩小堆)就从这里开始,随后可能被挪动(第 1254 到 1267 行):
if ((current->flags & PF_RANDOMIZE) && (randomize_va_space > 1)) { /* * For architectures with ELF randomization, when executing * a loader directly (i.e. no interpreter listed in ELF * headers), move the brk area out of the mmap region * (since it grows up, and may collide early with the stack * growing down), and into the unused ELF_ET_DYN_BASE region. */ if (IS_ENABLED(CONFIG_ARCH_HAS_ELF_RANDOMIZE) && elf_ex->e_type == ET_DYN && !interpreter) { mm->brk = mm->start_brk = ELF_ET_DYN_BASE; }
mm->brk = mm->start_brk = arch_randomize_brk(mm);randomize_va_space 控制地址随机化策略。上面的条件要求进程启用 PF_RANDOMIZE,且该设置大于 1,才进一步随机化 brk 的起点。另一个条件处理没有解释器的 ET_DYN:先将 brk 移到 ELF_ET_DYN_BASE 区域,再施加随机偏移。可执行映像位于高地址 mmap 区域时,这样可以为向上增长的堆保留空间。
前面的两次静态 PIE 映射正好说明了这种分离:映像基址分别为 0x76b9cb2d6000、0x7b4be7b11000,而堆分别从 0x555590c0d000、0x555568701000 开始。理解这一布局的关键是堆与映像不必相邻,而不是记住这些随机地址。
这里的放置规则和条件来自所引用的 Linux v6.8 实现;ELF 格式本身不规定 brk 的位置。分配器还可能建立自己的保护页:musl 的 mallocng 在使用 brk 扩展堆时,会建立不可访问的页。因此,不能仅凭 maps 中的 ---p 就认定它由 ELF 加载器插入。
最后,START_THREAD(第 1298 行)把用户态的指令指针设成入口(有解释器时是解释器的入口),栈指针设成新栈顶,execve 返回用户态时,CPU 就从那里开始执行。和 xv6 相比,Linux 多认了 PT_INTERP、PT_GNU_STACK 等几种程序头,用 mmap 加按需调页代替拷贝,按 ET_EXEC 和 ET_DYN 分别选基址,并给堆和栈都加上随机偏移。
内核留在栈上的东西
xv6 把 argc 和 argv 放进寄存器,直接交给 main。Linux 不通过寄存器传参数,它把所有信息按约定的格式写在新栈上,_start 拿到的只有一个栈指针。这个格式由各架构的 psABI13 规定,x86-64 写在 x86-64 psABI 第 3.4 节 Process Initialization 里。
写栈的是 create_elf_tables。execve 早先已经把参数字符串和环境变量字符串从旧进程拷到了新栈的最顶端(argv 和 envp 指向旧地址空间,旧地址空间马上要释放,所以必须先拷走)。create_elf_tables 在这些字符串下面依次放上:随机下移的一段空隙(arch_align_stack,x86-64 上最多 8 KiB,arm64 上最多一页,同样是为了让栈地址难以预测)、平台名字符串、16 个随机字节,然后是辅助向量、envp 指针数组、argv 指针数组,最低处是 argc。
辅助向量(auxiliary vector,简称 auxv)是一组"类型, 值"二元组,以 AT_NULL 结尾,是内核递给用户态的一张附加信息表。它的内容在第 241 到 264 行(节选):
NEW_AUX_ENT(AT_HWCAP, ELF_HWCAP); NEW_AUX_ENT(AT_PAGESZ, ELF_EXEC_PAGESIZE); NEW_AUX_ENT(AT_CLKTCK, CLOCKS_PER_SEC); NEW_AUX_ENT(AT_PHDR, phdr_addr); NEW_AUX_ENT(AT_PHENT, sizeof(struct elf_phdr)); NEW_AUX_ENT(AT_PHNUM, exec->e_phnum); NEW_AUX_ENT(AT_BASE, interp_load_addr); ... NEW_AUX_ENT(AT_ENTRY, e_entry); ... NEW_AUX_ENT(AT_RANDOM, (elf_addr_t)(unsigned long)u_rand_bytes);验证这张图,最直接的办法是让程序自己把栈打印出来。利用一个事实:musl 的 _start 把内核给的栈指针原样传下去,main 收到的 argv 就指向栈上的指针数组,argv - 1 就是 argc 所在的位置。从那里往高地址走,越过 argv 和 envp 的两个 NULL,就是辅助向量。下面是在 x86-64 Linux 上原生运行的结果,env -i 清空环境变量,只留两个,好让输出短一些:
$ musl-gcc -static -no-pie -O1 stack.c -o stack$ env -i HOME=/root PATH=/bin ./stack hi0x7ffff5c240d0 argc = 20x7ffff5c240d8 argv[0] = 0x7ffff5c25fd0 ./stack0x7ffff5c240e0 argv[1] = 0x7ffff5c25fd8 hi0x7ffff5c240e8 argv[2] = 00x7ffff5c240f0 envp = 0x7ffff5c25fdb HOME=/root0x7ffff5c240f8 envp = 0x7ffff5c25fe6 PATH=/bin0x7ffff5c24100 envp = NULL0x7ffff5c24108 AT_SYSINFO_EHDR 0x70097ab3e0000x7ffff5c24118 AT_MINSIGSTKSZ 0x6f00x7ffff5c24128 AT_HWCAP 0x178bfbff0x7ffff5c24138 AT_PAGESZ 0x10000x7ffff5c24148 AT_CLKTCK 0x640x7ffff5c24158 AT_PHDR 0x4000400x7ffff5c24168 AT_PHENT 0x380x7ffff5c24178 AT_PHNUM 0x60x7ffff5c24188 AT_BASE 00x7ffff5c24198 AT_FLAGS 00x7ffff5c241a8 AT_ENTRY 0x4010670x7ffff5c241b8 AT_UID 00x7ffff5c241c8 AT_EUID 00x7ffff5c241d8 AT_GID 00x7ffff5c241e8 AT_EGID 00x7ffff5c241f8 AT_SECURE 00x7ffff5c24208 AT_RANDOM 0x7ffff5c242790x7ffff5c24218 AT_HWCAP2 0x20x7ffff5c24228 AT_EXECFN 0x7ffff5c25ff00x7ffff5c24238 AT_PLATFORM 0x7ffff5c242890x7ffff5c24248 AT_RSEQ_FEATURE_SIZE 0x210x7ffff5c24258 AT_RSEQ_ALIGN 0x400x7ffff5c24268 AT_NULL 0AT_RANDOM bytes: b8 06 49 bb f2 09 15 12 b9 e7 fb 0e 7a 65 b2 ee&_start = 0x401067, phdr in image = 0x400040最前面的 AT_SYSINFO_EHDR 和 AT_MINSIGSTKSZ 来自 ARCH_DLINFO,是架构自己加的项,排在通用项之前;AT_MINSIGSTKSZ 是内核建议的信号处理栈最小尺寸。其余几项里,AT_CLKTCK 是 times() 一类接口使用的时钟频率(0x64 即每秒 100 次),AT_FLAGS 是一组标志位,这里为 0;AT_UID、AT_EUID、AT_GID、AT_EGID 是进程的真实和有效用户 ID、组 ID;AT_RSEQ_FEATURE_SIZE 和 AT_RSEQ_ALIGN 告诉 C 库内核支持的 rseq(restartable sequences,一种让用户态代码在被抢占时能安全重来的机制)结构有多大、要怎样对齐。把地址画成图(高地址在上,指针 8 字节,辅助向量每项 16 字节):
高地址 参数字符串、环境变量字符串、AT_EXECFN 字符串 对齐空隙、平台名 "x86_64"、16 个随机字节0x7ffff5c24268 AT_NULL, 0 ... 其余辅助向量 ...0x7ffff5c24108 AT_SYSINFO_EHDR, 0x70097ab3e0000x7ffff5c24100 NULL(环境变量结束)0x7ffff5c240f8 envp[1] → PATH=/bin0x7ffff5c240f0 envp[0] → HOME=/root0x7ffff5c240e8 NULL(参数结束)0x7ffff5c240e0 argv[1] → hi0x7ffff5c240d8 argv[0] → ./stack0x7ffff5c240d0 argc = 2 ← _start 收到的栈指针地址可以逐项核对:argv 从 argc 后 8 字节开始,两个参数后是 NULL,两个环境变量后又是 NULL,再往上才是成对的辅助向量。AT_RANDOM 指向 0x7ffff5c24279 的 16 字节,AT_PLATFORM 紧跟在 0x7ffff5c24289。地址和随机字节只是这次执行的样本,布局规则才是启动契约。
几个最常用的字段:
AT_PHDR、AT_PHENT、AT_PHNUM:程序头表在内存中的地址、每项字节数、项数。它们让启动代码无需重新打开可执行文件,就能遍历已经映射的程序头表。AT_ENTRY:程序自己的入口,这里等于&_start。对静态程序它和内核实际跳转的地址相同,看起来多余;动态链接时内核先跳到ld.so,ld.so干完活要靠它找到程序的入口。AT_BASE是解释器的基址,静态程序没有解释器,所以是 0。AT_PAGESZ:页大小。C 库不必写死 4096,musl 把它存进libc.page_size(__libc_start_main.c第 32 行),malloc等代码按它来对齐。AT_RANDOM:16 个由内核生成的随机字节的地址。musl 的__init_libc第 40 行调用__init_ssp((void *)aux[AT_RANDOM]),但__init_ssp默认是__libc_start_main.c第 15 到 16 行定义的弱别名,指向一个什么都不做的函数,没有引用栈保护失败处理函数的输入可能仍显示W __init_ssp;本机 GCC 默认启用栈保护,本章hello实测已是T __init_ssp。只有程序里有函数启用了栈保护(stack protector,编译器在函数栈帧里放一个随机的金丝雀值 canary,返回前检查它有没有被缓冲区溢出改掉),引用了__stack_chk_fail,链接器才会从libc.a拉进__stack_chk_fail.o,用里面真正的__init_ssp取代弱定义。本机 GCC 默认开启-fstack-protector-strong,一个带char buf[64]的函数就足以让nm14 显示T __init_ssp和B __stack_chk_guard。真正的__init_ssp从AT_RANDOM拷出 8 个字节作为金丝雀值,再把其中第 2 个字节清零(src/env/__stack_chk_fail.c),这样经由字符串函数的溢出很难把金丝雀值读出来或原样写回。内核不给这几个字节时,它的退路是拿__stack_chk_guard自己的地址乘一个常数,这个值远不如内核给的随机。AT_SECURE:非零表示这次执行提升了权限,比如 setuid 程序(以文件所有者而非运行者的身份执行)。musl 在AT_SECURE非零、或者真实与有效的用户 ID 或组 ID 不同时,检查 0、1、2 号文件描述符是否打开,没打开就用/dev/null补上(第 42 到 56 行),防止程序后来打开的文件恰好拿到 1 号描述符,把输出写进不该写的文件。AT_EXECFN:传给execve的文件名。AT_HWCAP、AT_HWCAP2:CPU 支持的指令集特性位。AT_SYSINFO_EHDR:vDSO 的 ELF 头地址。
从文件偏移换算到内存地址
e_phoff 是文件坐标,AT_PHDR 是运行时坐标。连接二者的是覆盖程序头表的 PT_LOAD 段:文件中的 p_offset 对应运行时地址 load_bias + p_vaddr。设表的起点为 F,则它离段起点的距离为 F - p_offset,因此:
AT_PHDR = load_bias + p_vaddr + (e_phoff - p_offset)这里的 load_bias 是加载时对链接虚拟地址统一加上的平移量;固定地址的示例中为 0。公式要求表的相关字节已经由该映射覆盖,不能拿任意一个 LOAD 的地址来代入。示例的第一个 LOAD 从文件偏移 0 映射到 0x400000,e_phoff=64,所以表地址是 0 + 0x400000 + (64 - 0) = 0x400040。输出中的 __ehdr_start + e_phoff 得到同一地址,是因为这个映射恰好也包含位于文件开头的 ELF 头;不能据此把所有 ELF 的表地址都写成“某个基址加 64”。
Linux v6.8 的加载代码从覆盖 e_phoff 的 LOAD 计算 phdr_addr,再写入辅助向量。这个计算不依赖 PT_PHDR。PT_PHDR 则是描述程序头表自身的程序头:它的 p_vaddr 给出表的链接虚拟地址。存在有效的该项时,用户态可以用 AT_PHDR - PT_PHDR.p_vaddr 反推 load_bias。两种方法分别回答“表加载到哪里”和“这个映像平移了多少”。
程序头表的作用也不止于加载各段。启动代码可以从中找到 PT_TLS,建立每个线程各有一份的 TLS 数据;动态加载器可以找到 PT_DYNAMIC,读取动态链接信息。后文分别展开这些机制。此处需要掌握的是:辅助向量提供运行时入口,程序头表再描述映像中的结构。
用户态加载器若要启动这样的程序,也必须遵循同一份 x86-64 栈与辅助向量契约;直接执行 Linux 程序时,这些工作由内核与启动代码承担。
_start 之后
这个没有解释器的静态程序加载成功后,内核恢复到用户态,CPU 从 ELF 入口开始执行;成功的 execve 不会返回旧程序的调用点。hello 的入口 0x401067 是 _start,离 main还有好几步。
从 _start 到 main
musl 的 _start 是用内联汇编写的,在 arch/x86_64/crt_arch.h(musl 1.2.5,各架构一份,放在 arch/ 而不是 crt/ 下):
__asm__(".text \n"".global " START " \n"START ": \n"" xor %rbp,%rbp \n"" mov %rsp,%rdi \n"".weak _DYNAMIC \n"".hidden _DYNAMIC \n"" lea _DYNAMIC(%rip),%rsi \n"" andq $-16,%rsp \n"" call " START "_c \n");START 在 crt1.c 里定义为 "_start"。五条指令:把帧指针 %rbp 清零,标记这是调用链的最外层,从帧指针往回追溯调用栈的工具走到这里就停下;把内核给的栈指针(指向 argc)作为第一个参数放进 %rdi;把 _DYNAMIC 的地址作为第二个参数放进 %rsi;把栈指针向下对齐到 16 字节,psABI 要求 call 之前栈按 16 字节对齐;调用 _start_c。.dynamic 节是一个由"标签, 值"对组成的数组,记录动态链接要用到的各张表在哪里、有多大,PT_DYNAMIC 程序头指向它,第 7 章细讲。_DYNAMIC 是链接器为这个节定义的符号,这里用弱引用,静态的 hello 里没有 .dynamic,它就是 0。链接后的反汇编能看到这一点,lea 算出来的值正好是 0:
0000000000401067 <_start>: 401067: xor %rbp,%rbp 40106a: mov %rsp,%rdi 40106d: lea -0x401074(%rip),%rsi # 0 <_init-0x401000> 401074: and $0xfffffffffffffff0,%rsp 401078: call 401080 <_start_c>内核交给 _start 的栈指针本来就按 16 字节对齐,andq 只是保险。编译器认定函数入口处 %rsp + 8 是 16 的倍数,会用 movaps 存取栈上的 16 字节数据;地址不对齐时 CPU 触发异常,进程收到 SIGSEGV。下面的测试自己写 _start,对齐之后故意多减 8,再调用一个在栈上放 16 字节向量的函数(clang 为它生成了 movaps %xmm0, -0x18(%rsp)),用 ld.lld 静态链接:
$ cat start.s .globl _start_start: xor %ebp, %ebp and $-16, %rsp#ifdef MISALIGN sub $8, %rsp#endif call start_c$ cat sse.ctypedef int v4 __attribute__((vector_size(16)));v4 g = { 41, 1, 0, 0 };__attribute__((noinline)) int f(void) { volatile v4 x = g; return x[0] + x[1]; }__attribute__((noreturn)) void start_c(void) { __asm__ volatile("syscall" :: "a"(60), "D"(f())); __builtin_unreachable();}$ clang -O1 -c sse.c$ clang -x assembler-with-cpp -c start.s -o start_good.o$ ld.lld -static start_good.o sse.o -o sse_good$ clang -DMISALIGN -x assembler-with-cpp -c start.s -o start_bad.o$ ld.lld -static start_bad.o sse.o -o sse_bad$ ./sse_good; echo $?42$ ./sse_bad; echo $?Segmentation fault139sse_good 正常返回 42,sse_bad 在相同本机环境收到 SIGSEGV,退出状态 139;唯一改变是进入被调函数时的栈对齐。
_start_c 在 crt/crt1.c 第 14 到 19 行,它不需要第二个参数:
void _start_c(long *p){ int argc = p[0]; char **argv = (void *)(p+1); __libc_start_main(main, argc, argv, _init, _fini, 0);}从栈上取出 argc,argv 紧跟其后,再把 main、_init、_fini 的地址一起交给 __libc_start_main。反汇编里这一步是几条 mov 立即数加一个 jmp:0x401169 是 main,0x401000 是 _init,0x405904 是 _fini。
src/env/__libc_start_main.c 分两个阶段。第 72 到 87 行的第一阶段调用 __init_libc:
int __libc_start_main(int (*main)(int,char **,char **), int argc, char **argv, void (*init_dummy)(), void(*fini_dummy)(), void(*ldso_dummy)()){ char **envp = argv+argc+1;
/* External linkage, and explicit noinline attribute if available, * are used to prevent the stack frame used during init from * persisting for the entire process lifetime. */ __init_libc(envp, argv[0]); ... return stage2(main, argc, argv);}envp 就是 argv + argc + 1,跳过 argv 末尾的 NULL,和上一节的栈图一致。__init_libc(第 23 到 57 行)沿着 envp 找到辅助向量,把它展开成按类型下标的数组 aux[],然后依次设置 __environ、页大小、程序名,调用 __init_tls(aux) 建立主线程的线程局部存储和线程指针,调用 __init_ssp 设好栈保护值,最后做上一节说的 AT_SECURE 检查。参数名里的 init_dummy、fini_dummy 说明 musl 并不使用传进来的 _init 和 _fini,它直接引用这两个符号。第二阶段在第 89 到 97 行:
static int libc_start_main_stage2(int (*main)(int,char **,char **), int argc, char **argv){ char **envp = argv+argc+1; __libc_start_init();
/* Pass control to the application */ exit(main(argc, argv, envp)); return 0;}__libc_start_init(第 59 到 65 行)先调用 _init(),再从 __init_array_start 到 __init_array_end 逐个调用 .init_array 里的函数指针,这两个边界符号由链接器定义,和第 5 章的 __start_/__stop_ 是同一类东西。然后才调用 main,并把返回值直接交给 exit。所以 main 返回和调用 exit 效果一样。
exit 在 src/exit/exit.c 第 27 到 33 行,按顺序执行 atexit 注册的函数、.fini_array(从后往前,第 19 到 21 行)和 _fini、刷新 stdio 缓冲区,最后用 _Exit 发出真正的退出系统调用。
init_array 什么时候执行
GCC 的 __attribute__((constructor)) 会把函数指针放进 .init_array,destructor 放进 .fini_array。用这个程序验证上面的顺序:
#include <stdio.h>#include <stdlib.h>__attribute__((constructor)) static void before(void) { puts("constructor: before main"); }__attribute__((destructor)) static void after(void) { puts("destructor: after exit"); }static void bye(void) { puts("atexit handler"); }int main(void) { atexit(bye); puts("main"); return 0; }$ musl-gcc -static -no-pie -O1 ctor.c -o ctor$ objdump -s -j .init_array -j .fini_array ctor$ nm -n ctor | grep -E " (before|after|frame_dummy|__do_global_dtors_aux)$"$ ./ctorctor: file format elf64-x86-64
Contents of section .init_array: 404fa0 60114000 00000000 82114000 00000000 `.@.......@.....Contents of section .fini_array: 404fb0 20114000 00000000 9b114000 00000000 .@.......@.....0000000000401120 t __do_global_dtors_aux0000000000401160 t frame_dummy0000000000401182 t before000000000040119b t afterconstructor: before mainmainatexit handlerdestructor: after exit.init_array 里有两个 8 字节的小端指针,0x401160 是 crtbeginS.o 的 frame_dummy,0x401182 是 before;.fini_array 里是 __do_global_dtors_aux 和 after。crtbegin.o 排在 ctor.o 前面,它的项也就排在前面。.init_array 正着执行,.fini_array 倒着执行,after 先于 __do_global_dtors_aux,构造和析构的顺序正好相反。atexit 的函数在 .fini_array 之前执行。
C++ 的全局对象构造也走这条路:编译器为每个翻译单元生成一个初始化函数,把指针放进 .init_array。在静态链接的程序里,这一切都在 __libc_start_main 里、main 之前发生;动态链接时,共享库的 .init_array 改由 ld.so 在跳到 _start 之前执行,那是第 7 章的内容。
执行路径说明了 _start 如何把内核提供的状态变成 C 运行环境。这些启动代码并不是从 hello.c 编译出来的:它们由编译器驱动程序作为额外输入交给链接器。
驱动程序加了哪些文件
第 5 章用 gcc -### 看过链接命令里那串启动文件。这里使用本机 musl 包装器,-v 实际执行并打印命令,输入顺序为(省略长路径和插件选项):
$ musl-gcc -v -static -no-pie -O1 hello.c -o hello.../collect2 ... -nostdlib -static -z relro -o hello /usr/lib/x86_64-linux-musl/Scrt1.o /usr/lib/x86_64-linux-musl/crti.o .../crtbeginS.o hello.o --start-group .../libgcc.a .../libgcc_eh.a -lc --end-group .../crtendS.o /usr/lib/x86_64-linux-musl/crtn.oUbuntu 的 musl-gcc.specs 这里选择 Scrt1.o、crtbeginS.o、crtendS.o,即使输出是静态 ET_EXEC 也如此;不要把别的发行版驱动器选择 crt1.o、crtbeginT.o 的惯例硬套过来。启动文件的职责不变,具体变体要读驱动命令。
驱动程序做的事可以手工复现。本例没有用到 libgcc 辅助运算,C 库只需一个 -lc:
S=/usr/lib/x86_64-linux-muslG=$(dirname "$(gcc -print-libgcc-file-name)")musl-gcc -O1 -c hello.c -o hello.old -static -o hello_manual \ "$S/Scrt1.o" "$S/crti.o" "$G/crtbeginS.o" hello.o \ -L"$S" -lc "$G/crtendS.o" "$S/crtn.o"./hello./hello_manualcmp hello hello_manual本次两份文件逐字节相同,cmp 没有输出;分别打印 hello ./hello 2 和 hello ./hello_manual 2。输入文件排列、链接器默认值或驱动选项改变时,不应预设这种相同仍然成立。
省掉提供 _start 的文件,链接器就只能报告缺少入口或选择默认入口;这不是一套完整的 C 运行时启动路径。入口必须先完成初始化再调用 main,不能把“链接成功”当成“启动正确”。
五个启动文件
nm 加上 readelf -S 能看清每个文件的贡献:
crt1.o,来自 C 库。定义_start和_start_c,引用main、__libc_start_main,弱引用_init、_fini、_DYNAMIC。在这组使用标准 musl 启动路径的输入中,它负责提供必要的入口;自定义启动代码可以承担同样的责任。同目录下还有两个变体:Scrt1.o用于动态链接的 PIE,rcrt1.o用于静态 PIE。crti.o和crtn.o,也来自 C 库。各自只有.init和.fini两个节,crti.o里各 1 字节,crtn.o里各 2 字节。musl 的源码是crt/x86_64/crti.s:_init:后面一条push %rax;crtn.s是pop %rax和ret。第 5 章说过同名节按输入顺序拼接,其他目标文件放进.init的代码会夹在中间,拼起来是一个完整的函数_init。push %rax只是为了让中间的代码执行时栈按 16 字节对齐。hello_manual里没有别人往.init放东西,反汇编出来就是三条指令:
0000000000401000 <_init>: 401000: 50 push %rax 401001: 58 pop %rax 401002: c3 retcrtbegin.o和crtend.o,来自 GCC。crtbegin.o有frame_dummy、__do_global_dtors_aux两个函数,并且分别往.init_array和.fini_array里放了一个指向它们的指针(符号__frame_dummy_init_array_entry和__do_global_dtors_aux_fini_array_entry)。frame_dummy检查弱引用的__register_frame_info是否存在,存在就把本程序的.eh_frame注册给 GCC 的栈展开库,这是没有.eh_frame_hdr时的老办法(现在的做法是.eh_frame_hdr,第 8 章讲);register_tm_clones、deregister_tm_clones服务于 GCC 的事务内存扩展(transactional memory,-fgnu-tm,让一段代码像数据库事务那样整体生效或整体撤销),程序里没用到时它们什么也不做。crtend.o里有内容的只是一个 4 字节的.eh_frame,全零,符号叫__FRAME_END__:长度为 0 的项是.eh_frame的结束标记。它必须排在最后,才能给所有输入文件的.eh_frame收尾,这就是crtend.o要放在hello.o和 C 库之后的原因。
C 程序不用 C++ 异常也不写构造函数时,这几项都可有可无,本例只留 crt1.o 也能运行,不能据此推断所有 C 程序都可以省略其余启动文件。它们的顺序却不能乱:crti.o 必须是第一个贡献 .init 的,crtn.o 必须是最后一个,crtend.o 的结束标记必须在所有 .eh_frame 之后。
static-pie:没有 ld.so,谁来修地址
第 5 章讲 PIE 时留了一个尾巴:PC 相对寻址不受基址影响,可数据里存的绝对地址,链接器只能按基址 0 填一个值,再留一条 R_X86_64_RELATIVE 重定位,等运行时加上真实基址。普通 PIE 由 ld.so 处理这些重定位。静态 PIE(static-pie)是静态链接、但仍然是 ET_DYN、可以放在任意基址的程序,前面"基址"一小节看到内核把它放在随机的位置,却不加载任何解释器。这些绝对地址只能由程序自己修。
同一个加载偏移,为什么有些值不用改
设链接时地址为 v,加载偏移为 B,那么该位置的运行时地址是 B+v。B 不是文件偏移;只有当映像的最低加载地址从零开始时,它才恰好也是映像的运行时起点。需要判断的是某个值如何随 B 变化,而不只是这个数有多大。
| 引用 | 链接时已知的量 | 加载后的计算 | 是否仍依赖 B |
|---|---|---|---|
| 同一映像内的 PC-relative 引用 | 目标 S、字段 P、加数 A | (B+S)+A−(B+P) = S+A−P | 否 |
| 数据中指向本映像的指针 | 目标 S、加数 A | B+(S+A) | 是 |
| 地址无关的 ABS 常量 | 常量 C、加数 A | C+A | 否 |
| PC-relative 引用一个 ABS 常量 | 常量 C、字段 P、加数 A | C+A−(B+P) | 是 |
第一行的抵消只适用于两端随同一 B 移动的情况。ABS 符号不随映像移动,不能因为它的数值看起来像一个映像地址,就给它加上加载偏移。反过来,一个映像符号即使链接时地址为零,仍然属于随 B 移动的类别。
例如 S=0x1100、P=0x1000、A=−4 时,位移为 0xfc。映像从 B=0x70000000 装入,目标和字段都加上这个偏移,位移仍是 0xfc。但指向 S=0x3000 前八字节的指针必须成为 0x70002ff8;链接时的 0x2ff8 还不是可直接解引用的运行时地址。
自重定位代码首先要获得 B。一个通用办法是找到某个锚点的实际地址,再减去其链接时地址:若锚点为 X,位置相对指令取得 B+X,则 (B+X)−X=B。修正指针后才能读取它们。下面的 musl 实现用 .dynamic 作为锚点,其链接时地址来自 PT_DYNAMIC;这体现的是同一个地址关系,而不是文件位置相减。
下面的程序故意在数据里放了几个绝对地址:一个函数指针数组,一个指向字符串常量的指针。
#include <stdio.h>static int add(int a, int b) { return a + b; }static int sub(int a, int b) { return a - b; }int (*const ops[])(int, int) = { add, sub }; /* 数据里存的绝对地址 */const char *greeting = "hi";int main(void) { printf("ops[0]=%p ops[1]=%p greeting=%p main=%p -> %d %s\n", (void *)ops[0], (void *)ops[1], (void *)greeting, (void *)main, ops[0](40, 2), greeting); return 0;}本机 musl 的静态库与 rcrt1.o 已支持静态 PIE,直接使用驱动程序即可,不需要从另一个系统拷启动文件:
$ musl-gcc -static-pie -fPIE -O1 spie.c -o spie$ readelf -hlW spie Type: DYN (Position-Independent Executable file) Entry point address: 0x1067 LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x000358 0x000358 R 0x1000 LOAD 0x001000 0x0000000000001000 0x0000000000001000 0x004c87 0x004c87 R E 0x1000 LOAD 0x006000 0x0000000000006000 0x0000000000006000 0x000d40 0x000d40 R 0x1000 LOAD 0x006e20 0x0000000000007e20 0x0000000000007e20 0x0002f0 0x000998 RW 0x1000 DYNAMIC 0x006e50 0x0000000000007e50 0x0000000000007e50 0x000180 0x000180 RW 0x8 GNU_RELRO 0x006e20 0x0000000000007e20 0x0000000000007e20 0x0001e0 0x0001e0 R 0x1如需手工复现,应从本机 /usr/lib/x86_64-linux-musl 选择 rcrt1.o、crti.o、libc.a、crtn.o,GCC 目录选择 crtbeginS.o、crtendS.o;顺序与上节相同,只将入口文件换成 rcrt1.o,链接参数使用 -static -pie --no-dynamic-linker -z text。静态 PIE 要求参与链接的库也采用位置无关代码。
-z text 要求代码段里不能有动态重定位,--no-dynamic-linker 让链接器不生成 PT_INTERP。类型是 DYN,地址从 0 开始,有 DYNAMIC 段却没有 INTERP 段。动态重定位表里全是 R_X86_64_RELATIVE:
$ readelf -rW spieRelocation section '.rela.dyn' at offset 0x208 contains 14 entries: Offset Info Type Symbol's Value Symbol's Name + Addend0000000000007e20 0000000000000008 R_X86_64_RELATIVE 14c00000000000007e28 0000000000000008 R_X86_64_RELATIVE 14800000000000007e30 0000000000000008 R_X86_64_RELATIVE 14c90000000000007e38 0000000000000008 R_X86_64_RELATIVE 14d10000000000007e40 0000000000000008 R_X86_64_RELATIVE 80200000000000007e48 0000000000000008 R_X86_64_RELATIVE 87a80000000000007fe8 0000000000000008 R_X86_64_RELATIVE 7e500000000000008000 0000000000000008 R_X86_64_RELATIVE 80000000000000008008 0000000000000008 R_X86_64_RELATIVE 60320000000000008010 0000000000000008 R_X86_64_RELATIVE 80200000000000008038 0000000000000008 R_X86_64_RELATIVE 52f00000000000008068 0000000000000008 R_X86_64_RELATIVE 53300000000000008070 0000000000000008 R_X86_64_RELATIVE 53200000000000008078 0000000000000008 R_X86_64_RELATIVE 81e800000000000014c9 t add0000000000008008 D greeting0000000000007e30 D ops00000000000014d1 t subops 在 0x7e30,它的两项各有一条重定位,加数分别是 add 的 0x14c9 和 sub 的 0x14d1;greeting 在 0x8008,加数 0x6032 是字符串 "hi" 在 .rodata 里的位置。R_X86_64_RELATIVE 的计算是 B + A,B 是运行时的基址,A 是加数,不需要查任何符号。其余几条分别在 .init_array(0x7e20)、.fini_array(0x7e28)、.got(0x7fe8,第 1 章提过的 GOT15,即全局偏移表,集中存放运行时才能确定的地址)和数据段里,比如 0x8000 处的 __dso_handle 存的就是它自己的地址,这个符号由 crtbeginS.o 定义。
rcrt1.o:把 ld.so 的开头搬进启动文件
修这些地址的代码在 rcrt1.o 里。musl 的 crt/rcrt1.c 一共 14 行:
#define START "_start"#define _dlstart_c _start_c#include "../ldso/dlstart.c"
int main();weak void _init();weak void _fini();int __libc_start_main(int (*)(), int, char **, void (*)(), void(*)(), void(*)());
hidden void __dls2(unsigned char *base, size_t *sp){ __libc_start_main(main, *sp, (void *)(sp+1), _init, _fini, 0);}它直接包含了 musl 动态链接器的第一段代码 ldso/dlstart.c,并把其中的 _dlstart_c 改名为 _start_c。_start 还是同一段汇编,所以 _start_c 的第二个参数 _DYNAMIC 这次派上了用场:它是用 lea _DYNAMIC(%rip) 按 PC 相对方式算出来的,不依赖任何重定位,拿到的就是 .dynamic 在内存里的真实地址。
ldso/dlstart.c 的 _dlstart_c 先像 __init_libc 一样从栈上找到辅助向量,再把 .dynamic 里的"标签, 值"对按标签展开到数组 dyn[] 里,然后计算基址(第 104 到 115 行):
base = aux[AT_BASE]; if (!base) { size_t phnum = aux[AT_PHNUM]; size_t phentsize = aux[AT_PHENT]; Phdr *ph = (void *)aux[AT_PHDR]; for (i=phnum; i--; ph = (void *)((char *)ph + phentsize)) { if (ph->p_type == PT_DYNAMIC) { base = (size_t)dynv - ph->p_vaddr; break; } } }作为 ld.so 运行时,AT_BASE 是内核给的解释器基址;作为静态 PIE 运行时没有解释器,AT_BASE 是 0,就用 .dynamic 的真实地址减去 PT_DYNAMIC 程序头里记的 p_vaddr(这里是 0x7e50),差就是基址。这又是一处靠 AT_PHDR 找程序头的地方。接着处理重定位表。.dynamic 里标签为 DT_RELA 的项给出 RELA 重定位表的地址(相对基址),DT_RELASZ 给出它的总字节数(第 136 到 142 行):
rel = (void *)(base+dyn[DT_RELA]); rel_size = dyn[DT_RELASZ]; for (; rel_size; rel+=3, rel_size-=3*sizeof(size_t)) { if (!IS_RELATIVE(rel[1], 0)) continue; size_t *rel_addr = (void *)(base + rel[0]); *rel_addr = base + rel[2]; }每条 RELA 记录是三个 8 字节字段:偏移、类型信息、加数。IS_RELATIVE 挑出相对重定位,x86-64 上就是 R_X86_64_RELATIVE(arch/x86_64/reloc.h 里 #define REL_RELATIVE R_X86_64_RELATIVE),对每一条执行 *(base + offset) = base + addend,就是 B + A。紧接着的第 144 到 157 行处理 DT_RELR,这是一种更紧凑的相对重定位编码,用位图一次表示一串相邻的地址,第 7 章讲动态重定位时会再提到它。全部修完,第 160 到 162 行用 GETFUNCSYM 取得 __dls2 的地址并调用它(宏的第三个参数 base+dyn[DT_PLTGOT] 是 GOT 的地址,DT_PLTGOT 标签记录 GOT 的位置,供某些架构使用,x86-64 版本用不到),__dls2 再调用 __libc_start_main,之后的路线和非 PIE 完全一样。
这段代码有一个严格的限制:修完之前,它不能用任何需要重定位的东西。全局的函数指针、字符串指针在那之前都还是按基址 0 算的值,调用 memset 之类的库函数也不行。所以 _dlstart_c 里所有循环都是手写的,aux 和 dyn 是栈上的局部数组。取 __dls2 的地址也要小心,x86-64 的 reloc.h 把 GETFUNCSYM 定义成一条 lea __dls2(%rip),按 PC 相对方式现算,不读任何存在数据里的指针。
在本机 Linux 上运行两次:
$ ./spie; ./spieops[0]=0x7d0001edb4c9 ops[1]=0x7d0001edb4d1 greeting=0x7d0001ee0032 main=0x7d0001edb4da -> 42 hiops[0]=0x7b4bd59314c9 ops[1]=0x7b4bd59314d1 greeting=0x7b4bd5936032 main=0x7b4bd59314da -> 42 hi第一次 ops[0] 是 0x7d0001edb4c9,减去 add 的相对地址 0x14c9,得到基址 0x7d0001eda000;第二次得到另一基址 0x7b4bd5930000。两次都输出 42 hi,说明 rcrt1.o 按各次实际基址修复了数据中的绝对地址。这里观察到的随机化来自本机 Linux 内核。
glibc 走的是同一个思路,具体实现不同。以 glibc 2.42 为例,静态链接时的 __libc_start_main 在 csu/libc-start.c 里,它先设置 __environ(第 246 行),调用 _dl_aux_init 解析辅助向量(第 264 行)、__tunables_init 读取 GLIBC_TUNABLES(第 267 行)、ARCH_INIT_CPU_FEATURES 探测 CPU 特性(第 269 行),然后才在第 274 行调用 _dl_relocate_static_pie,前面的注释写着 "Before this point relocations must be avoided"。之所以排在这几步之后,是因为 IRELATIVE 重定位要调用按 CPU 特性挑选实现的解析函数,CPU 特性得先探测好。_dl_relocate_static_pie 定义在 elf/dl-reloc-static-pie.c,第 80 行用 ld.so 同一套 ELF_DYNAMIC_RELOCATE 处理主程序自己的动态重定位。这里的调用次序以 glibc 2.42 为准。
加载器是安全边界
前面喂给加载器的都是链接器写出的合法文件。回到 xv6 book 那段话:程序头里的地址和长度由写文件的人决定,用十六进制编辑器改几个字节就能编造出任何值,而加载器在内核里,以最高权限按这些值去分配、映射、清零。真实的内核在这方面"有很长的漏检历史",说的就是这类输入。
Linux 对 PT_LOAD 的检查在主循环里(第 1165 到 1176 行):
/* * Check to see if the section's size will overflow the * allowed task size. Note that p_filesz must always be * <= p_memsz so it is only necessary to check p_memsz. */ if (BAD_ADDR(k) || elf_ppnt->p_filesz > elf_ppnt->p_memsz || elf_ppnt->p_memsz > TASK_SIZE || TASK_SIZE - elf_ppnt->p_memsz < k) { /* set_brk can never work. Avoid overflows. */ retval = -EINVAL; goto out_free_dentry; }k 是 p_vaddr,TASK_SIZE 是用户地址空间的上限,BAD_ADDR(k) 判断 k >= TASK_SIZE。和 xv6 先相加再比较不同,这里写成 TASK_SIZE - p_memsz < k:先确认 p_memsz 不超过 TASK_SIZE,减法就不会下溢,整个判断里没有加法,也就不存在溢出的可能。入口地址另有一道检查,静态程序走的是第 1226 行的 if (BAD_ADDR(elf_entry)),同样只要求它小于 TASK_SIZE。
下面修改本机 tiny 的 ELF 头或可写 LOAD 段。tiny 由 musl-gcc -static -no-pie -O1 tiny.c -o tiny 生成;RW 是零起始下标 3 的程序头,Offset 0x3fc0、VirtAddr 0x404fc0、FileSiz 0x150、MemSiz 0x17f8。patch.py 按字段位置写入,不依赖 ARM 布局。以下为 strace -e trace=execve 实测摘要:
| 修改 | execve 结果 | 后续结果 |
|---|---|---|
| e_machine = 0x1234 | ENOEXEC | strace 退出 1,旧映像未被替换 |
| e_entry = 0xdead0000 | 0 | SIGSEGV,SEGV_MAPERR,地址 0xdead0000 |
| RW p_filesz = 0x17f9 | EINVAL | SIGSEGV |
| RW p_memsz = 0xfffffffffff00000 | ENOMEM | SIGSEGV |
| RW p_memsz = 0xffffffffffbfc040,使末端绕回 0x1000 | ENOMEM | SIGSEGV |
| RW p_offset = 0x103fc0 | EFAULT | SIGSEGV |
| RX p_offset = 0x101000 | 0 | SIGBUS,BUS_ADRERR,地址 0x401047 |
修改点与运行命令已在本节列出。每次只修改一处;ulimit -c 0 避免生成大 core 文件。
几个记号先说明。ENOEXEC、EINVAL(参数不合法)、ENOMEM(内存不足或地址范围不可用)、EFAULT(访问了无效的地址)是系统调用返回的错误码。信号后面的 si_code 说明原因:SEGV_MAPERR 是访问的地址上没有映射,BUS_ADRERR 是地址有映射、但没有可以对应的后备内容。core dumped 表示按产生 core dump 的信号终止;是否留下可取得的 core 文件,还取决于资源限制和系统的 core 收集配置。
除第一种外,shell 看到的退出状态依次是 139、139、139、139、139、135:139 是 128 加 SIGSEGV 的编号 11,135 是 128 加 SIGBUS 的编号 7。这七种结局可以分成四类。
第一类在分界线之前被拒绝。e_machine 不对,elf_check_arch 失败,execve 返回 ENOEXEC,调用者还活着。上面的 exited with 1 其实是 strace 自己的退出码:它 execve 失败后打印 strace: exec: Exec format error,然后以 1 退出。不同 shell 对 ENOEXEC 的二次处理可能不同,不能拿 shell 的报错代替内核错误码;这里用 strace 直接观察系统调用。
第二类越过了分界线。p_filesz > p_memsz 被上面那条检查拦下,execve 也确实返回了 EINVAL,但 begin_new_exec 已经换掉了旧的地址空间,没有人能接住这个返回值,于是 strace 看到的是"返回 −1,然后被 SIGSEGV 杀死"。p_memsz 过大和 p_vaddr + p_memsz 绕回这两种,在走到这条检查之前就失败了:主循环先调用 elf_load 映射段,再做检查。从错误码看,是 elf_load 里为 .bss 建匿名映射的 vm_brk_flags 拿到一个不合理的长度,返回了 ENOMEM。段越过文件末尾时,mmap 本身并不检查文件有多长,映射能建立;可这个段有 .bss,padzero 要往文件映射的最后一页写零,那一页在文件末尾之外,写入失败,于是是 EFAULT。这一类里内核都发现了问题,只是发现时已经过了分界线,结局一律是 SIGSEGV。
第三类是加载器没有发现问题。代码段越过文件末尾,又没有 .bss 要清零,映射顺利建立,execve 返回 0。CPU 跳到入口 0x401047 取第一条指令时触发缺页,内核发现这一页对应的文件位置在文件末尾之外,发出 SIGBUS。这个错误要到真正访问那一页时才暴露出来。
第四类不在加载器的检查范围内。e_entry 是 0xdead0000,它小于 TASK_SIZE,BAD_ADDR 检查通过,内核照常跳过去,那里没有映射,进程收到 SIGSEGV,si_addr 正好是 0xdead0000。
上面每次只改坏一个字段。还有一种文件,每个段单独看都合法,问题出在段与段之间。下面两个文件用 ld.lld 加链接器脚本生成(--no-check-sections 关掉 lld 自己的重叠检查),入口在代码段开头,执行 exit(value),value 是数据段里的 42。shared 的两个段落在同一页里,overlap 的代码段加长到 0x100d 字节,数据段落进了它的地址范围:
$ readelf -lW shared | grep LOAD LOAD 0x001000 0x0000000000201000 0x0000000000201000 0x00000d 0x00000d R E 0x1000 LOAD 0x001800 0x0000000000201800 0x0000000000201800 0x000004 0x000004 RW 0x1000$ readelf -lW overlap | grep LOAD LOAD 0x001000 0x0000000000201000 0x0000000000201000 0x00100d 0x00100d R E 0x1000 LOAD 0x002800 0x0000000000201800 0x0000000000201800 0x000004 0x000004 RW 0x1000$ strace -e trace=execve ./sharedexecve("./shared", ...) = 0--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_ACCERR, si_addr=0x201000} ---$ strace -e trace=execve ./overlapexecve("./overlap", ...) = 0--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_ACCERR, si_addr=0x201000} ---si_code 的 SEGV_ACCERR 表示地址上有映射,但权限不允许这次访问。两个文件都完成了 exec。后面的数据段用 MAP_FIXED 替换代码段所在的整页,入口 0x201000 变成可读写、不可执行,CPU 取第一条指令就失败。即使段本身不重叠,只要取整到页以后相互覆盖,就可能出现这种结果。
这些运行里,最坏的结果是被测进程自己被杀死,内核没有一次按编造的地址和长度去读写自己的内存。若另写用户态加载器,它与被加载程序共享地址空间,也继承当前进程的文件描述符和权限;它不是隔离不可信程序的沙箱。即便只加载受信任的程序,也要检查这些条件:p_filesz <= p_memsz,p_offset + p_filesz 不超过文件长度,p_vaddr + p_memsz 不溢出,p_vaddr 和 p_offset 模页大小同余,程序头表本身也在文件范围之内。
当入口交给另一个模块
静态链接的程序从磁盘到 main 的路径到这里就走完了:内核按程序头表映射各个 PT_LOAD 段、补齐 .bss、选定基址、在栈上放好参数和辅助向量;C 库从 _start 接手,初始化自己,执行 .init_array,调用 main,最后由 exit 收尾。静态 PIE 只多一步自重定位。
把 hello.c 去掉 -static 再链接一次,程序头表里多出 PHDR、INTERP 和 DYNAMIC,其中 INTERP 和 DYNAMIC 和本章直接相关:
$ musl-gcc -O1 hello.c -o hello_dyn$ readelf -lW hello_dyn | grep -E "INTERP|interpreter|DYNAMIC" INTERP 0x000238 0x0000000000000238 0x0000000000000238 0x000019 0x000019 R 0x1 [Requesting program interpreter: /lib/ld-musl-x86_64.so.1] DYNAMIC 0x002e00 0x0000000000003e00 0x0000000000003e00 0x0001c0 0x0001c0 RW 0x8.dynamic 里还有一项 NEEDED: libc.so。这一次 load_elf_binary 第一遍扫描就会找到 PT_INTERP,把 /lib/ld-musl-x86_64.so.1 也映射进来,AT_BASE 填上它的基址,START_THREAD 跳去的是它的入口,程序自己的 _start 要等动态链接器完成准备后才会执行。此时仍须建立外部符号的运行时绑定。musl 把 libc 和动态链接器合在同一个文件里,printf 的代码随解释器一同映射;glibc 的 libc 则是另一个待加载的共享库。两种实现都不能仅凭主程序的链接地址确定这次运行应使用的函数地址。rcrt1.o 只用修一个模块、只用处理"基址加加数"这一种重定位;ld.so 要找到并加载每一个依赖库,按名字在它们之间查找符号,还要处理好几种重定位。链接器为了让这件事可行,事先在文件里准备了哪些东西,是第 7 章的内容。
练习
练习一,观察。在本机编译 maps.c,从以下程序头推导各段映射;页大小为 4 KiB。列出起止地址、权限、文件偏移,并指出哪些部分需要匿名页:
$ musl-gcc -static -no-pie -O1 maps.c -o maps$ readelf -lW maps LOAD 0x000000 0x400000 0x400000 0x000190 0x000190 R 0x1000 LOAD 0x001000 0x401000 0x401000 0x005767 0x005767 R E 0x1000 LOAD 0x007000 0x407000 0x407000 0x000ce8 0x000ce8 R 0x1000 LOAD 0x007fb0 0x408fb0 0x408fb0 0x000160 0x001840 RW 0x1000练习二,手算。ex.c 的 char small[1000] 位于可写段里。原生编译后,RW 的 p_vaddr=0x405fb0、p_offset=0x4fb0、p_filesz=0x160、p_memsz=0xc20。分别计算文件映射、padzero 和剩余匿名页的范围。不要把 .bss 一律视为新建的匿名页。
练习三,改坏。先预测,再执行下面命令。下标来自本次 readelf -lW:tiny 的可写 LOAD 下标是 3,maps 的 GNU_STACK 下标是 4。其他版本应先读取程序头,不应写死旧文件的字节偏移。
python3 patch.py tiny e1 e_entry 0x400000python3 patch.py maps e2 p_flags 7 4python3 patch.py tiny e3 p_filesz 0x28 3strace -e trace=execve ./e1./e2 | grep stackstrace -e trace=execve ./e3e1 把入口指向 ELF 头;e2 开启栈执行权限;e3 缩短文件提供的 RW 数据而保留原有内存长度。记录 execve 的结果、信号、退出状态,并解释原因。
答案
练习一
四个 LOAD 的文件映射分别是:
00400000-00401000 r--p 0000000000401000-00407000 r-xp 0000100000407000-00408000 r--p 0000700000408000-0040a000 rw-p 000070000040a000-0040b000 rw-p 00000000 (匿名)最后一个 LOAD 从 0x408fb0 向下取整到 0x408000,文件偏移退到 0x7000,文件字节结束于 0x409110,文件映射向上取整到 0x40a000。内存末端是 0x408fb0 + 0x1840 = 0x40a7f0,所以再用匿名页补到 0x40b000。以上五行已用 ./maps 的 /proc/self/maps 输出核对。GNU_RELRO 描述 LOAD 已覆盖的区域,GNU_STACK 声明栈权限,均不新增文件映射;堆、vDSO、栈也不来自上述文件区间。
练习二
- 页内偏移:
0x405fb0 & 0xfff = 0xfb0。 - 文件映射起点 0x405000,文件偏移 0x4000,长度
align_up(0xfb0 + 0x160) = 0x2000,终点 0x407000。 - 文件字节结束于
0x405fb0 + 0x160 = 0x406110,padzero清零到 0x407000。 - 内存字节结束于
0x405fb0 + 0xc20 = 0x406bd0,向上取整仍为 0x407000,因此无需额外匿名页。
small 位于文件映射最后一页被清零的尾部。.bss 是文件里不存初值的数据,不等于每次都要另外映射一页。
练习三
本机 x86-64 Linux 的结果:
| 输入 | execve | 结果 |
|---|---|---|
| e1 | 0 | SIGSEGV / SEGV_ACCERR,si_addr=0x400000,状态 139 |
| e2 | 0 | 正常结束,栈由 rw-p 变为 rwxp |
| e3 | 0 | SIGSEGV / SEGV_MAPERR,si_addr=NULL,状态 139 |
e1 指向只读、不可执行的第一个 LOAD。内核只检查入口地址是否超出用户空间,不检查该地址的映射权限;CPU 真正取指时才因 NX 权限失败。不能套用 AArch64 旧例中“执行 ELF 头后得到非法指令”的结论。
e2 将 PF_X 加入 PT_GNU_STACK,内核据此给初始栈加上执行权限;本次 maps 确认是 rwxp。输入缺少 .note.GNU-stack 时链接器默认如何决定栈权限,是另一个受版本和发行版配置影响的问题,不影响此处显式改写 PT_GNU_STACK 的实验。
e3 满足文件长度不大于内存长度,但缩短后的文件数据使 GOT 和 .data 的大部分被当成零填充区。程序完成 exec,却在 C 启动过程中通过被清零的指针访问地址 0。加载器能检查结构边界,不能证明程序内部数据仍有正确语义。
参考
- Linux v6.8 ELF 加载器与 exec 地址空间交接。
- xv6 的 exec 实现与 x86-64 进程初始化 ABI。
- musl v1.2.5:入口汇编、C 运行库初始化、早期重定位。
附录:术语与工具
-
ELF — ELF(Executable and Linkable Format)规定目标文件、可执行文件与共享对象的结构。通用规则见 gABI,架构相关的调用约定和重定位规则见对应 psABI。 官方文档。 ↩
-
GCC — GCC(GNU Compiler Collection)是一组语言编译器。命令
gcc是驱动入口,会组织编译、汇编和链接;在终端调用它,并不意味着后续工作都在同一个进程里完成。 官方文档。 ↩ -
GNU — GNU 是 “GNU’s Not Unix” 的递归缩写,指自由软件操作系统项目。GCC、binutils 和 glibc 都属于 GNU 项目,但分别承担编译、二进制处理和 C 运行库职责。 官方文档。 ↩
-
binutils — GNU binutils 是一组处理目标文件的工具,包含汇编器
as、链接器ld,以及readelf、nm、objdump、ar等检查与归档工具。 官方文档。 ↩ -
musl — musl 是 Linux 的一种 C 标准库实现,提供
printf等库函数及运行时支持。本系列在需要分析或链接较小的静态运行库时使用它;普通 Linux 服务器不一定预装 musl。 官方文档。 ↩ -
musl-gcc —
musl-gcc是 GCC 的包装器,为编译和链接选择 musl 头文件、启动文件及库。它本身不意味着交叉编译;是否静态链接由-static等选项决定。 官方文档。 ↩ -
glibc — glibc(GNU C Library)是许多 Linux 发行版默认使用的 C 库。库的启动文件、共享库和动态链接器共同参与程序构建与运行。 官方文档。 ↩
-
xv6 — xv6 是 MIT 用于操作系统教学的小型 Unix 风格内核。本系列采用 RISC-V 版本,借助较小的加载器与链接脚本观察 ELF 到进程的交接。 官方文档。 ↩
-
RISC-V — RISC-V 是开放的指令集架构。本系列主线是在原生 x86-64 Linux 上构建链接器;RV64 用于架构对照与内核案例。相关例子的编码、寄存器约定和重定位规则见 RISC-V psABI,不能直接套用 x86-64 的规则。 ↩
-
ABI — ABI(Application Binary Interface)规定二进制组件如何协作,包括调用约定、数据布局和文件格式等。它约束编译结果之间的交接,比源码层面的 API 更靠近机器。 官方文档。 ↩
-
readelf —
readelf检查 ELF 头、节、段、符号及重定位等结构;它读取文件而不执行其中的程序。GNUreadelf与 LLVMllvm-readelf的显示格式可能不同。 官方文档。 ↩ -
PIE — PIE(position-independent executable)是可以在不同加载基址运行的可执行文件。生成 PIE 需要编译与链接选项配合;static-PIE 还需要自身的启动路径完成必要重定位。 官方文档。 ↩
-
psABI — psABI(processor-specific ABI)是特定处理器架构的二进制接口约定。不同架构可以共用 ELF 文件结构,同时拥有不同的指令、调用约定和重定位公式。 官方文档。 ↩
-
nm —
nm列出目标文件的符号。字母标记概括符号所在节或绑定等属性;需要判断准确语义时,应继续对照 ELF 符号表字段。 官方文档。 ↩ -
GOT — GOT(Global Offset Table)保存供代码间接访问的地址或相关偏移。它让部分地址修补集中到数据表中;表项的具体用途由重定位类型与 ABI 决定。 官方文档。 ↩