ICS 与 8086 复习 16:装载运行与静态动态链接
链接已经完成,为什么启动程序时还可能提示找不到共享库?为什么一个可执行文件通常不是从 main 的第一条指令直接开始?这篇把系列末尾的几个环节接起来:链接选取哪些内容,加载器建立什么映像,启动代码怎样把控制交给程序。
本文使用 Linux ELF 语境。地址和段大小算例都是明确给定的教学条件;实际地址会受二进制选项、系统配置和地址空间随机化影响。
静态库是目标文件的集合
静态库 .a 通常是多个 .o 的归档。链接器根据当时尚未解决的引用,从库里选取需要的目标成员;通常不是把整个库无条件复制到程序中。
考虑依赖关系:main.o 调用库中的 f,定义 f 的成员又调用另一成员的 g。传统静态归档扫描中,命令行顺序会影响能否找到定义:
gcc main.o -lx -ly -o demo
若 libx.a 提供 f、f 需要的 g 由 liby.a 提供,这个次序可以让对 g 的新引用在扫描 y 时得到解决。反过来先扫描 y,扫描时尚无对 g 的需要,该成员可能没有被选入;后来扫描 x 才出现对 g 的引用,就可能留下未解析符号。
一种复习方法是在纸上维护集合 U:遇到引用,加入未解析名字;选入提供定义的成员后,从 U 删除相应名字,并加入它引出的新引用。对象文件、归档内部反复搜索和库组会影响具体过程,因此题目应给清所用链接规则。GNU ld 归档选项说明可用来核对传统扫描和库组机制。
共享库把一部分绑定推迟到运行环境
共享库 .so 可以被多个程序使用。可执行文件通常记录依赖关系,并通过动态链接器在装载时或运行过程中完成需要的查找和重定位。
| 比较项 | 静态链接库 | 动态共享库 |
|---|---|---|
| 形成程序时 | 选中的目标成员进入输出文件 | 保留共享库依赖和有关引用 |
| 部署时 | 已静态包含部分不另依赖该共享库文件 | 运行环境须提供兼容共享库 |
| 更新某库后 | 已包含旧代码的程序通常需重新链接 | 可能使用更新库,仍须接口兼容 |
| 内存共享 | 不因“静态库”名称自动共享 | 相同文件的只读代码页可被共享 |
共享不表示多个进程共用一份可写全局变量状态。正常情况下,每个进程仍具有各自的地址空间和可写数据语义。
装载时动态链接由解释器与运行时机制处理;程序主动使用 dlopen、dlsym 等接口则属于运行时动态加载。库存在于磁盘,也不保证版本、架构和搜索路径一定匹配。
PIC GOT 和 PLT 分别解决什么
共享库装到哪里,不能总在构建时写死。位置无关代码 PIC 的目的,是让代码可在不同装载位置使用,减少需要直接修改共享代码页的绝对引用。
GOT 是全局偏移表,可保存符号最终地址等信息;PLT 是过程链接表中的代码入口,常与 GOT 和动态链接器协作完成外部函数调用。在支持延迟绑定的实现里,第一次调用可能触发解析,后续调用利用已经更新的地址信息。
这些是课程需要掌握的机制关系。GOT 项数量、PLT 每项字节数、是否延迟绑定以及寄存器用法,都与架构、工具链和选项有关。不能把某张 IA32 示例表的布局当作所有 ELF 文件的固定格式。
从程序头计算装载后的范围
加载器主要依据 ELF 的程序头建立映像,而不是逐个按节名寻找 .text、.data。对一个可装入段,文件中长度为 p_filesz,内存中长度为 p_memsz。后者更大时,增加的部分需要按规定清零。
设一个教学段具有下列字段,且本例装载偏移为零。PIE 和共享目标的实际运行时地址通常还需加上相应装载偏移:
p_offset = 1000H
p_vaddr = 08049000H
p_filesz = 600H
p_memsz = A00H
第一步,文件数据来自半开区间 [1000H,1600H),共 600H=1536 字节。第二步,映射后的对应数据位于 [08049000H,08049600H)。第三步,内存总范围延伸到 08049A00H,所以 [08049600H,08049A00H) 共 400H=1024 字节为零初始化尾部。
这个算例忽略了页对齐等外围细节,专门考查“文件大小”和“内存大小”的区别。它也解释了为什么 .bss 对应的数据可以在文件中不存放一大串零,却仍能在进程内存中存在。ELF 装载规范定义了这些字段。
execve 到 main 中间还有什么
execve 成功时替换调用进程的程序映像,并不会因为名字里有 execute 就自动新建进程。Shell 启动外部命令的常见过程涉及先创建子进程,再由子进程执行 execve;这与 execve 自身的作用要区分。
对带 PT_INTERP 的动态 ELF,系统会安排程序解释器,也就是动态链接器,参与启动;完成依赖库加载和必要处理后再转向主程序入口。可执行文件入口常对应 _start 一类启动代码,运行时准备完成后才调用 main。具体入口流程受文件类型和运行时实现影响,可查 Linux execve 文档。
CPU 随后不断按控制流取指、译码和执行。程序计数器的顺序增量是指令长度;IA32 是变长指令,不能把教学模型中的 PC+1 理解为永远加一个字节。调用、分支、返回以及异常等事件会改变正常流向。
库打桩需要区分发生阶段
课件将函数替换机制分为编译时、链接时和运行时。宏替换在编译前处理源文本;链接器 --wrap 改变特定引用的解析;LD_PRELOAD 可在符合条件的动态链接场景中影响符号搜索。
它们都能用于观测或替换调用,但作用范围不同。被编译器内联的调用、某些内部绑定符号或受限制的运行环境,不一定能被某一种机制截获。复习的重点是辨认发生阶段和引用如何改向,不是记住一个环境变量就认为它能替换任何函数。
自测与简答
libx.a出现在命令行最前面,为什么仍可能无法解决后面main.o对 x 中函数的引用?p_memsz=1000H、p_filesz=700H,零初始化尾部有多少字节?execve成功后,原程序是否继续从下一条 C 语句运行?
答案:① 传统扫描到该归档时可能尚未出现待解析引用,需要成员便没有被选入;应按依赖安排次序或使用适当库组。② 900H=2304 字节。③ 不会,成功调用替换了程序映像;失败时才返回并报告错误。
资料对应
主要依据《5.程序的链接与加载执行-V1.0.pptx》第 51—59、79—97、98—113 页;系统总体运行过程可回看《1.前言&第一章计算机系统概述-V1.0.pptx》第 69—96 页。段范围计算为自编算例,未把课件历史地址图当成当前系统的固定布局。
讨论这篇文章
关于《ICS 与 8086 复习 16:装载运行与静态动态链接》的补充、不同意见或延伸话题,都可以留在这里。
滚动到这里时自动载入留言。