深入理解计算机系统学习笔记
第7章 链接
链接是将代码与数据组合为单一文件的过程。在现代系统中,链接由链接器的程序自动执行。
编译可存在如下周期:
- 编译:源代码翻译为机器代码
- 加载:程序被加载器加载到内存并执行
- 运行:应用程序执行
7.1 编译器驱动程序
1 |
|
编译驱动程序包括
- 语言处理器
- 编译器
- 汇编器
- 链接器
Linux
下驱动程序的调用
1 |
|
C
预处理器(cpp
)
1 |
|
C
编译器(cc1
)
1 |
|
C
汇编器(as
)
1 |
|
链接器程序(ld
)
1 |
|
加载器[loader
]
1 |
|
7.2 静态链接
静态链接器(Linix LD
)
- 输入:可重定位目标文件与命令行参数
- 输出:完全链接、可以加载和运行的可执行目标文件
链接器的两个主要任务
- 符号解析:目标文件定义和引用符号,每个符号对应一个函数、一个全局变量或一个静态变量。符号解析的目的是将符号引用与符号定义关联起来。
- 重定位:编译器和汇编器生成从地址为0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,重定位这些节,然后修改对符号的引用,使得它们指向相应的内存位置。
7.3 目标文件
目标文件的三种形式
- 可重定位目标文件:包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
- 可执行目标文件:包含二进制代码和数据,其形式可以被直接复制到内存并指向。
- 共享目标文件:特殊的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接
目标模块是一个字节序列,目标文件则是存储在磁盘上地目标模块。目标文件在不同地操作系统上的文件格式各不相同
Unix
系统:a.out
Windows
系统:PE
MacOS-X
系统:Mach-O
Linux
系统:ELF
7.4 可重定位目标文件
ELF HEADER
- 以16字节序列开始,记载生成该文件的系统的字的大小和字节顺序。
ELF
头的大小- 目标文件的类型
- 机器类型
- 节部表的文件偏移
- 节头部表中条目的大小和数量
在ELF
文件与节头部表之间的都是节
.text
:已编译程序的机器代码.rodata
:只读数据.data
:已初始化的全局和静态C
变量.bss
:未初始化的全局和静态C
变量,以及所有初始化为0的全局或静态变量.symtab
:符号表,存放在程序中定义和引用的函数和全局变量的信息.rel.text
:.text
节中位置的列表,当链接器把该目标文件和其他文件组合时需要修改的位置。.rel.data
:被模块引用或定义的所有全局变量的重定位信息。.debug
:调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C
源文件。.line
:原始C
源程序中的行号和.text
节中机器指令之间的映射。.strtab
:字符串表,内容包括.symtab
和.debug
节中的符号表,以及节头部中的节名字。
7.5 符号和符号表
可重定位目标模块m
都有一个符号表,包含m
定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
- 由模块
m
定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C
函数和全局变量。(函数与全局变量) - 由其他模块定义并被模块
m
引用的全局符号。这些符号称为外部符号,对应于在其他模块中定义的非静态C
函数和全局变量。(其他文件定义的函数和全局变量) - 只被模块
m
定义和引用的局部符号。它们对应于带static属性的C函数和全局变量。这些符号只有在模块m中可见,其他模块不可引用。
本地程序变量指的是局部变量,存储在堆栈中而不是段中。程序只有应用时才会分配地址空间。
本地链接器符号指的是存储在段地址上的变量。程序编译链接时以及分配好地址空间。
C
语言中用static
修饰的变量类似C++
与Java
中使用private
修饰变量,是当前模块私有的。
符号表数据结构
1 |
|
符号表需要结合字符串表寻找符号信息。
三个特殊的伪节,它们在节头部表中是没有条目的
ABS
符号代表不该被重定位的符号UNDEF
符号代表未定义的符号,本目标模块引用了,但是符号是在其他模块定义的符号COMMON
符号表示还未被分配位置的未初始化的数据。value
字段给出对齐要求size
给出最小的大小
- 只有可重定位目标文件中才有这些伪节
COMMON
和.bss
的区别:COMMON
存储未初始化的全局变量,.bss
存储未初始化的静态变量,以及初始化为0的全局或静态变量
Name
:指的是符号的字符串Value
:指的是符号距离节头的偏移Size
:所占内存大小Type
:类型,指的是函数,变量还是文件等Bind
:指的是全局变量还是局部变量Ndx
:指的所在节区
练习题7.1
符号 | .symtab条目? | 符号类型 | 在哪个模块中定义 | 节 |
---|---|---|---|---|
buf | 是 | 外部 | m.o | .data |
bufp | 是 | 全局 | swap.o | .data |
bufp1 | 是 | 全局 | swap.o | COMMON |
swap | 是 | 全局 | swap.o | .text |
temp | 否 |
7.6 符号解析
链接器如何解析多重定义的全局符号
函数与已初始化的全局变量是强符号
未初始化的全局变量是弱符号
- 规则1:不允许多个同名的强符号
- 规则2:如果有一个强符号和多个弱符号同名,那么选择强符号(有强选强)
- 规则3:如果有多个弱符号同名,那么则随机从弱符号中选择一个(多弱随机)
练习题7.2
A
(a)REF(main.1) -> DEF(main.1)
(b)REF(main.2)->DEF(main.1)
B
(a)两个强符号,错误
(b)两个强符号,错误
C
(a)REF(x.1)->DEF(x.2)
(b)REF(x.2)->DEF(x.2)
与静态库链接
当链接器接收的是一组可重定位目标文件
将相关的目标模块打包成为一个单独的文件,称之为静态库作为链接器的输入。
当链接器输出可执行文件时,它仅仅复制静态库里被应用程序引用的目标模块。
当不适用静态库时,编译器开发者向用户提供库函数的几种做法:
方法一:使用编译器识别库函数,生成指定库函数代码。
- 缺点:C标准定义了大量的标准函数,给编译器增加了复杂性,并且库函数每次更新都需要更新一次编译器。
方法二:将所有标准C函数都放在单独的可重定位目标模块中
1
gcc main.c /usr/lib/libc.o
- 优点:将编译器与库函数的实现分离开
- 缺点:每个可执行文件都需要包含一份标准函数副本,以及每个运行的程序都需要将这些函数副本放置在内存中。每次库函数更新都需要重新编译重定位模块
方法三:可以将每个库函数单独编译成独立的重定位模块,但是每次连接时需要将多个重定位模块进行链接,容易出错且耗时
静态库结合了方法二与方法三,将方法相近的模块编译为独立的目标模块,然后封装为一个单独的静态库文件。
1 |
|
在链接时,链接器只复制被程序引用的目标模块。
在Linux
系统中,静态库以一种成为存档的特殊文件格式存放在磁盘中。存档文件命后缀.a
addvec.c
1 |
|
multvec.c
1 |
|
1 |
|
1 |
|
链接器解析静态库引用
链接器从左到右按照编译器驱动程序命令扫描可重定位目标文件和存档文件
链接器维护三个集合,初始时,三个集合都为空
- 可重定位目标文件的集合E
- 一个未解析的符号集合U
- 在前面输入文件中已定义的符号集合D
链接器解析引用
- 对每个输入文件
f
,链接器会判断f
是目标文件还是存档文件。如果f
是目标文件,那么链接器将f
添加到E
,U
是用来放置引用了但是没有找到定义的符号,D
则是已经在模块中定义的符号,因此若输入文件中存在集合U
中定义的符号,则将集合U
中的符号放置在集合D
中 - 如果
f
是存档文件,链接器就会将集合U
中的符号与存档文件定义的符号进行比较。若存档文件中的成员m
定义了集合U
中的符号,则将成员m
放置到集合E
中,将集合U
中的符号移动到集合D
中,对存档文件的每个成员都依次进行该过程,直到U
和D
都不发生变化。那么不包含在集合E
中的成员目标文件都会被抛弃,链接器则继续输入下一个文件。 - 若链接器完成了所有输入文件的解析,但是发现集合
U
非空,那么就会发出异常并终止。否则就合并和重定位E
中的目标文件,构建输出的可执行文件。
练习题7.3
1 |
|
7.7 重定位
重定位由两个步骤组成
- 重定位节和符号定义:链接器将所有相同类型的节合并为同一类型的新的聚合节。接着链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节以及赋给输入模块定义的每个符号。此时,程序中的每条指令和全局变量都有唯一的运行时内存地址。(往符号写入地址)
- 重定位节中的符号引用:链接器修改代码节和数据节中对符号的引用,使得它们指向正确的运行时地址。(将符号的地址重定位),重定位依赖于可重定位目标模块中称为重定位条目的数据结构
重定位条目
当编译器遇到最终未知的目标引用时,它会生成一个重定位条目,用于告知链接器在目标文件合并为可执行文件时如何修改这个引用。
代码的重定位条目放在.rel.text
中
初始化数据的重定位条目放在.rel.data
中
ELF
重定位条目的数据结构
1 |
|
两种最基本的重定位类型
R_X86_64_PC32
:重定位一个使用32位PC
相对地址的引用R_X86_64_32
:重定位一个使用32位绝对地址的引用
重定位符号引用
1 |
|
main.o
反汇编代码
main
函数中有两个重定位引用,并且汇编器为每个引用产生一个重定位条目,显示在该引用的后面。array
使用PC
相对地址进行重定位,而sum
使用绝对地址引用
重定位PC相对引用
1 |
|
e8
:是call
指令的操作码
相应的重定位条目r
由4个字段组成
r.offset
=0xf
call
指令偏移0xe
,但是e8
后一个字节码偏移为0xf
r.symbol
=sum
- 该重定位项为
sum
- 该重定位项为
r.type
=R_X86_64_PC32
- 采用PC相对引用
r.addend
=-4
- 当前需要填充字节码的地址与下一条指令的距离
重定位的流程
首先链接器确定了 节地址
ADDR(s)
=ADDR(.text)
=0x4004d0
与确定了符号地址ADDR(r.symbol)
=ADDR(sum)
=0x4004e8
接着计算引用的运行地址$refaddr = ADDR(s) + r.offset\=0x4004d0+0xf\0x4004df$
然后修改该引用,使其指向函数实际运行地址$*refptr = (unsigned)(ADDR(r.symbol)+r.addend - refaddr)\=(unsigned)(0x4004e8 + (-4) - 0x4004df)\=(unsigned)(0x5)$
最后,指令被修改为
1 |
|
- 在指令执行时,CPU执行的步骤为
- 将
PC
(下一条指令)压入栈中 - 计算地址$PC <- PC + 0x5 = 0x4004e3(下一条指令的地址) + 0x5 = 0x4004e8$
- 将
重定位绝对引用
array
的重定位条目为
r.offset
=0xa
r.symbol
=array
r.type
=R_X86_64_32
r.addend
=0
重定位流程
- 首先确定符号运行地址:
ADDR(r.symbol) = ARRD(array) = 0x601018
- 接着链接器修改引用$*refptr = (unsigned)(ADDR(r.symbol) + r.addend)\= (unsigend)(0x601018+0)\(unsigned)(0x601018)$
- 最后可执行目标文件下的指令为
1 |
|
7.8 可执行目标文件
二进制文件包含加载程序到内存并运行它所需的所有信息
ELF文件格式
ELF
头描述文件的总体格式,包含程序的入口点.init
节定义了个函数,叫做_init
,程序的初始化代码会调用它。由于可执行文件是完全链接的(已被重定位),所以它不再需要.rel
节
程序头部表描述了可执行文件到连续的内存段的映射
off
:目标文件中的偏移vaddr/paddr
:内存地址align
:对齐要求filesz
:目标文件中的段大小memsz
:内存中的段大小flags
:运行时访问权限
7.9 加载可执行目标文件
执行目标文件所执行的命令
1 |
|
可执行文件是通过加载器运行。execve
函数可以用来调用加载器,加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。将程序复制到内存并运行的过程叫做加载。
在Linux x86-64
系统中
- 代码段总是从地址
0x400000
处开始,后面紧接着是数据段 - 堆空间在数据段之后,通过
malloc
库向上增长,堆后面的区域是为共享模块保留的 - 用户栈总是从最大的合法用于地址($2^{48}-1$)开始,向较小的内存地址增长
- 从地址($2^{48}$)开始,为内核中的代码和数据保留的,内核是操作系统驻留在内存的部分
加载器加载可执行文件的流程
- 当加载器运行时,创建上图所示的内存映像
- 在程序头部表的引导下加载器将可执行文件的片复制到代码段和数据段
- 加载器跳转到程序的入口点,也就是
_start
函数的地址 _start
函数启动__libc_start_main
函数,该函数初始化执行环境,接着调用用户层的main
函数,处理main
函数的返回值,并且在需要的时候把控制返回给内核
加载器的实际工作流程
Linux
系统中的每个程序运行在一个进程上下文(抽象认为进程自己的数据记录),并且每个进程拥有自己的虚拟空间。
- 当
shell
运行一个可执行文件时,父shell
进程会生成一个子进程,它是父进程的一个复制。子进程通过execve
系统调用启动加载器。 - 加载器删除现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的堆栈空间会被初始化为零。
- 通过将虚拟内存地址空间中的页映射到可执行文件的页大小的片,新的代码和数据会被初始化为可执行文件的内容。
- 加载器跳转到
_start
函数地址,最终调用用户main
函数。
7.10 动态链接共享库
静态库的缺陷
- 需要定期维护和更新,若需要用到最新版本的库,需要显示的将程序与库重新链接
- 几乎所有的C程序都需要使用标准
I/O
函数,因此使用静态库的时候,程序运行时,就需要将库函数的副本拷贝到内存中,因此在系统上存在着成千上万的I/O
库函数的副本,浪费内存空间
共享库是一个目标模块,在可执行文件运行或加载时,可以加载到任意的内存空间,并且和内存中的程序链接起来,该过程称之为动态链接,是由一个动态链接器的程序执行的。
共享库以两种方式被可执行文件所共享
- 所有可执行的目标文件共享
.so
文件中的代码与数据 - 在内存中,一个共享库的
.text
节的一个副本可以被不同的正在运行的进程共享。
1 |
|
动态链接创建可执行文件步骤
- 首先静态执行一些链接
- 然后再程序加载时,动态完成链接
没有代码和数据复制到可执行文件中,链接器复制了重定位和符号表信息(与静态链接不同点,静态链接复制了代码和数据)
加载器加载和运行可执行文件(部分链接)
- 部分链接的可执行文件包含
.interp
节(包含动态链接的路径),加载器加载和运行动态链接器,动态链接器完成任务如下- 重定位
libc.so
的文本和数据 - 重定位
libvector.so
的文件和输 - 重定位可执行文件对由
libc.so
和libvector.so
定义的符号的引用 - 最后,动态链接器将控制传递给应用程序,此时共享库的位置就固定,在程序执行时都不会改变了
- 重定位
7.11 从应用程序中加载和链接共享库
Linux
系统为动态链接器提供了一个简单的接口,允许引用程序在运行时加载和链接共享库
1 |
|
dll.c
1 |
|
7.12 位置无关代码
多个进程是如何共享程序中的一个副本
- 方案一:为每个共享库分配一个事先预备的专用的地址空间片
- 问题一:空间利用率不高
- 即使程序不适用该库也需要预留空间
- 当共享库修改时需要判断预留空间是否足够,不足够需要找新的空间
- 当创建新的共享库,需要再次找新的内存空间
- 共享库数量多,会将内存空间分割为大小不均匀的片段
- 对于不同的操作系统,给库分配的空间大小不一
- 问题一:空间利用率不高
- 方案二:提供一种编译方式,使得共享库可以加载到内存的任何位置,并且无需链接器修改,这种编译方式被称之为位置无关代码(Position-Independent Code,PIC),使用
-fpic
参数指示编译系统生成PIC
代码
PIC数据引用
- 数据段与代码段中数据与指令的偏移是常量
- 在数据段开始的地方创建了全局偏移量表(Global Offset Table,GOT),GOT表中每个被引用的全局变量都会有一个8字节条目(GOT项地址),并且编译器为每个条目生成一个重定位记录,加载时,动态链接器会重定位GOT中的每个条目,使该条目包含正确的变量地址。每个目标模块都有属于自己的GOT
PIC函数调用
延迟绑定:将过程地址的绑定延迟到第一次调用该过程时,延迟绑定采用两个数据结构进行交互实现,GOT和过程链接表(Procedure Linkage Table, PLT)
- 过程链接表(PLT)。PLT是一个数组,每个条目为16字节代码。
- PLT[0]用于跳转到动态链接器中。
- PLT[1]调用系统启动函数(__libc_start_main),用于初始化执行环境,调用
main
函数并处理其中返回值。 - PLT[2]开始的条目调用用户代码调用的函数
- 全局偏移表(GOT)。GOT是一个数组,每个条目是8字节地址。
- GOT[0]与GOT[1]是动态链接器解析函数地址时使用的参数信息
- GOT[2]时动态链接器在
ld-linux.so
模块中的入口点。其余的每个条目对应一个被调用的函数,其地址需要在运行时被解析。
延迟解析地址步骤
- 首先跳转到
addvec
的plt
地址,即程序调用进入PLT[2]
,这是addvec
的PLT
条目 - 第一条
PLT
指令通过GOT[4]
进行间接跳转,在延迟绑定完成之前,都是跳转到PLT
条目的下调指令 - 把
addvec
的ID
压入栈中,PLT[2]
跳转到PLT[0]
,即动态链接器的函数地址 PLT[0]
将GOT[1]
项内容压入栈中,通过GOT[2]
跳转到动态链接器中。动态链接器通过压入的两个参数确定addvec
函数的实际地址,最后将实际地址重写GOT
项内容,再把控制传递给addvec
函数
7.13 库打桩机制
Linux
链接器使用库打桩技术,允许截获对共享库函数的调用,取而代之执行自己的代码(类似Windows
的Hook
技术)。
库打桩的基本思想:创建一个包装函数,它的原型与目标函数一致,使用打桩技术,使得系统调用包装函数而不是原函数,包装函数内会先执行自己的逻辑后再调用原函数并把返回值返回给调用者
打桩可以发生在编译、链接或程序加载和执行时
编译时打桩
本地定义一个malloc.h
头文件,将malloc
函数定义为自定义的mymalloc
函数,编译时使用-I
参数强制载入本地库文件,完成编译时库打桩技术
1 |
|
链接时打桩
编译时使用参数--wrap f
可以修改引用时的符号,符号f
会被修改为__wrap_f
而__real_f
会被修改为符号f
从而完成打桩技术
1 |
|
运行时打桩
自定义.so
文件,使用LD_PRELOAD
环境变量,使得系统载入我们自定义的.so
文件完成打桩
1 |
|
但是课本例子无法运行
参考网上解析CSAPP第三版运行时打桩Segmentation fault
进入gdb调试,使用(gdb) set env LD_PRELOAD=./mymalloc.so
设置环境变量,运行程序,输入bt
查看栈回溯
发现print
和malloc
函数一直互相递归调用,这是因为printf
函数里使用malloc
函数,而自定义的.so
文件又调用了printf
函数因此产生了死循环。
网上教程修改后的文件为
1 |
|
7.14 处理目标文件的工具
- AR:创建静态库,插入、删除、列出和提取成员
- STRINGS:列出一个目标文件中所有可打印的字符串
- NM:列出一个目标文件的符号表定义的符号
- SIZE:列出目标文件中节的名字和大小
- READELF:显示一个目标文件的完整结构,包括ELF头中编码的所有信息。包含SIZE和NM的功能
- OBJDUMP:显示一个目标文件中所有的信息。可以反汇编
.text
节中的二进制指令 - LDD:列出一个可执行文件再运行时所需的共享库
第8章 异常处理控制流
从给处理器上电开始,直到处理器断电,程序计算器中的序列为$a_0,a_1,…,a_{n-1}$,其中每个$a_k$是某个相应的指令$I_k$的地址。每次从$a_k$到$a_{k+1}$的过度成为控制转移。这样的控制转移序列称之为处理器控制流。
平滑序列:$I_k$和$I_{k+1}$在内存中相邻
平滑流突变:$I_k$和$I_{k+1}$不相邻,通常是由于跳转、调用和返回指令造成,这种突变是内部程序状态中的变化
系统状态变化例如:硬件定时器定期产生信号、包到达网络适配器后、程序向磁盘请求数据以及子进程终止需要通知父进程等
现代系统通过使控制流发生突变来应对这些情况,上述突变被称之为异常控制流(Exceptional Control Flow,ECF)
8.1 异常
异常就是控制流中的突变,用来相应处理器状态中的某些变化
- 状态被编码为不同的位和信号
- 状态变化被称之为事件
- 事件可能与当前指令的直接相关
- 虚拟内存缺页
- 算术溢出
- 试图除0
- 时间也可能与当前指令无关
- 系统定时器产生信号
- I/O请求完成
处理器检测到事件发生,就会去异常表的跳转表进行查询并进行间接过程调用,跳转到专门处理此类事件的异常处理程序中进行处理。处理程序完成后,根据异常事件的类型可能会发生以下三种情况之一
- 处理程序将控制返回给当前指令$I_{curr}$
- 处理程序将控制返回给$I_{next}$,如果没有异常则会执行下一条指令
- 处理程序终止被中断的程序
8.1.1 异常处理
处理器设计者分配的异常号
- 被零除
- 缺页
- 内存访问违例
- 断点
- 算术运算溢出
操作系统内核(操作系统驻留在内存的部分)
- 外部I/O设备的信号
系统启动时,操作系统分配和初始化一张称之为异常表的跳转表,表目k包含异常k的处理程序地址
异常表的起始地址放在异常表基址寄存器中
异常的调用过程与过程调用的不同之处
- 过程调用时会将返回地址压入栈中,但是根据异常的类型,返回地址可能是当前指令也可能是下一条指令
- 处理器会将额外的处理器状态压入栈中,程序返回时,需要用到这些状态。
- 若控制从用户程序转移到内核,所有这些项目都将会被压入内核栈中,而不是压入到用户栈中
- 异常处理程序运行在内核模式下,因此具有对系统资源的访问权限
8.1.2 异常的类别
异常分为四类:中断、故障、陷阱、终止。
- 中断
中断是异步发生的,来自处理器外部的I/O设备的信号的结果。例如网络适配器、磁盘控制器和定时器芯片,这些设备通过向处理器新品上的引脚发信号,并将异常号放到系统总线上,来触发中断,异常号用于标识引起中断的设备。
- 陷阱和系统调用
陷阱是有意而为的异常,陷阱最重要用途是在用户态与内核态之间提供一个接口,被称之为系统调用。
用户程序需要向内核请求服务,比如读文件(read)、创建新进程(fork)、加载新程序(execve)或终止当前进程(exit)。
- 故障
故障由错误情况引起,它可能能够被修正。当故障发生时,处理器就会将控制转移给故障处理程序。当故障能被修正(例如缺页),则将控制返回给引起故障的指令,重新执行它。否则,处理程序返回到内核中abort
例程,abort
例程会终止引起故障的应用程序。
- 终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM
或SRAM
位被损坏时发生的奇偶错误。终止处理程序不会将控制返回给应用程序,而是交给abort
例程
8.1.3 Linux/x86-64系统中的异常
x86-64
系统定义了256种不同的异常类型
- 0-31的号码由
Intel
架构师定义 - 32-255的号码由操作系统定义
1.Linux/x86-64故障和终止
- 除法错误:应用试图除以零,或者当处罚指令的结果对于目标操作数来说太大了,
Unix
不会对除法错误进行恢复处理,而是直接终止程序。Linux Shell
报告位浮点异常 - 一般保护故障:引用未定义的虚拟内存区域,写只读区域,
Linux Shell
报告为段故障 - 缺页:缺页异常时会重新执行故障的指令
- 机器检查:监测到致命的硬件错误
2.Linux/x86-64 系统调用
当应用程序想要请求内核服务时使用,例如读写文件或创建进程
c语言
1 |
|
汇编语言
1 |
|
8.2 进程
进程:正在执行的程序
系统种的每个程序都运行在某个进程的上下文种。
上下文由程序正确运行所需的状态组成的
- 程序中的代码与数据
- 栈、通用寄存器的内容
- 程序计数器(PC)
- 环境变量
- 打开文件描述符
执行目标文件的流程
- 在
shell
中输入可执行目标文件的名字 shell
创建新的进程,在该进程的上下文中运行该可执行目标文件
8.2.1 逻辑控制流
PC
值得序列称之为逻辑控制流
8.2.2 并发流
一个逻辑流的执行在时间上与另一个流重叠,称之为并发流
两个逻辑流并发的运行在不同的处理器核或者计算机上,这为并行流。
练习题8.1
考虑三个具有下述起始和结束事件的进程
1 |
|
进程对 | 并发的? |
---|---|
AB | 并发的 |
AC | 不是并发的 |
BC | 并发的 |
8.2.3 私有地址空间
进程为每个程序提供它的私有地址空间
Linux
进程的地址空间的组织结构
8.2.4 用户模式和内核模式
今存起通常使用某个控制寄存器中的一个模式位区分用户态与内核态,该寄存器描述了进程当前享有的特权。
当设置了模式位时,进程运行在内核模式中
没有设置模式位时,进程运行子用户模式中
进程通过中断、故障或者陷入系统调用这样的异常从用户态转化为内核态。
/proc
文件系统,允许用户模式进程访问内核数据结构的内容
/proc/cpuinfo
:CPU类型/proc/<process-id>/maps
:某个特殊的进程使用的内存段/sys
:输出系统总线和设备的额外的底层信息
8.25 上下文切换
操作系统内核使用上下文切换的异常控制流实现多任务
内核选择启用哪个进程的决策称之为调度,由调度器处理
内核调度过程
- 内核选择新进程抢占当前进程
- 上下文切换
- 保存当前进程的上下文
- 恢复某个先前被抢占的进程被保存的上下文
- 将控制传递给这个新恢复的进程
8.3 系统调用错误处理
1 |
|
8.4 进程控制
8.4.1 获取进程ID
1 |
|
8.4.2 创建和终止进程
进程的三种状态
- 运行:进程要么在
CPU
上运行,要么在等待被执行且最终会被内核调度 - 停止:进程的执行被挂起,且不会被调度。当收到
SIGSTOP
、SIGTSTP
、SIGTTIN
或SIGTTOU
信号时,进程就停止,知道接收到SIGCONT
信号 - 终止:进程永远地停止了,停止地原因有
- 收到信号,该信号默认行为为终止进程
- 从主程序返回
- 调用
exit
函数
exit
函数以status
退出状态来终止进程
1 |
|
父进程通过fork
函数创建新的运行的子进程
1 |
|
新创建的子进程几乎与父进程相同。子进程与父进程具有相同的用户级虚拟空间地址,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,因此子进程可以读写父进程中打开的任何文件。父进程与子进程的区别在于它们具有不同的PID
1 |
|
fork
函数特点
- 调用一次,返回两次。一次返回到父进程,一次返回到子进程。
- 并发执行。父进程与子进程是独立并发的进程并且内核能够以任意方式交替执行它们逻辑控制流中的指令。
- 两者相同但是具有独立的地址空间。
- 共享文件。父进程与子进程都将输出打印在屏幕上,原因上子进程继承了父进程所有的打开文件。当父进程调用
fork
时,stdout
文件时打开的,并指向屏幕。子进程继承了该文件,因此也会向屏幕输出。
gdb
调试fork()
函数
- 调试父进程:
set follow-fork-mode parent
- 调试子进程:
set follow-fork-mode child
练习题8.2 考虑下面的程序
1 |
|
- A.子进程的输出是
p1: x = 2
,p2: x=1
- B.父进程的输出是
p2:x=0
8.4.3 回收子进程
进程因为某种原因终止时,内核并不是立即把它从系统中清除。进程被标志为已终止状态,直到它被父进程回收。
当父进程回收已终止的子进程的时候,内核就会将子进程的退出状态传递给父进程,并且抛弃已终止进程,此时该进程就不存在系统中。
若已终止进程未被回收则称之为僵尸进程。
init
进程是所有进程的父进程,负责回收孤儿进程(即父进程终止,但其子进程还存在,存在的子进程被称之为孤儿进程)。
init
进程的PID
为1,不会终止。是在系统启动的时候由内核创建的。
僵尸进程仍然会消耗系统的内存资源。
waitpid
函数用于等到它的子进程终止或者停止
1 |
|
默认情况下(options=0
),waitpid
挂起调用进程的指向,直到它的集合中的一个子进程终止。
1.判定等待集合的成员
等待集合的成员由参数pid
确定
pid
>0,只等待进程ID
等于pid
的子进程pid
= -1,等待任何一个子进程退出pid
= 0,等待同一个进程组中的任何自己才能pid
< -1,等待一个指定进程组中的任何子进程,这个进程组的ID
等于pid
的绝对值
2.修改默认行为
options
可以设置为WNOHANG
、WUNTRACED
和WCONTINUED
或者这几种的组合
WNOHANG
:如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回值为0)。
例子(参考Linux waitpid用WNOHANG)
1 |
|
WUNTRACED
:挂起调用进程的指向,直到等待集合中的一个进程变成已终止或者被停止。返回的PID
为已终止或被停止子进程的PID
。默认的行为是只返回已终止的子进程。WCONTINUED
:挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或等待集合中一个被停止的进程搜到SIGCONT
信号重新开始执行。
3.检查已回收子进程的退出状态
若statusp
参数是飞控的,那么waitpid
就会在status
中放上关于导致返回的子进程的状态信息,status
是statusp
指向的值。wait.h
头文件定义了解释status
参数的几个宏
WIFEXITED
:如果子进程通过调用exit
或者一个返回正常终止,就返回真WEXITSTATUS
:返回一个正常终止的子进程的退出状态。只有当WIFEXITED()
返回真时,才会定义这个状态。WIFSIGNALED
:如果子进程时因为一个未捕获的信号终止的,那么就返回真WTERMSIG
:返回导致子进程终止的信号编号。只有在WIFSIGNALED()
返回为真时,才定义这个状态。WIFSTOPPED
:如果引起返回的子进程当前是停止的,那么就返回真WSTOPSIG
:返回引起子进程停止的信号和编号。只有在WIFSTOPPED()
返回为真时,才定义这个状态。WIFCONTINUED
:如果子进程收到SIGCONT
信号重新启动,则返回真。
4.错误条件
如果调用进程没有子进程,那么waitpid
返回-1
,并且设置errno
为ECHILD
如果waitpid
函数被一个信号中断,那么它返回-1
,并设置errno
为EINTR
使用man
+ 函数名可以查询需要导入的头文件
练习题8.3
列出下面程序所有可能的输出序列
1 |
|
5.wait 函数
wait
函数时waitpid
函数的简单版本
1 |
|
调用wait(&status)
等价于调用waitpid(-1,&status,0)
6. 使用waitpid的示例
waitpid1.c
1 |
|
waitpid2.c
1 |
|
练习题8.4
考虑下面的程序
1 |
|
A.这个程序会产生多少输出行
B.这些输出行的一种可能顺序是什么?
8.4.4 让进程休眠
sleep
函数将一个进程挂起一段指定的时间
1 |
|
若请求时间到了,sleep
返回0
,否则返回还剩下的要休眠的秒数。sleep
函数有可能会被信号中断过早地返回。
pause
函数让调用函数休眠,直到该进程收到一个信号
1 |
|
练习题8.5
编写一个sleep
地包装函数,叫做snooze
,带有下面地接口:
unsigned int snooze(unsigned int secs)
;
snooze
函数和sleep
函数地行为完全一样,除了它会打印出一条消息来描述进程实际休眠了多长时间:
Slept for 4 of secs.
1 |
|
8.4.5 加载并运行程序
execve
函数在当前进程地上下文中加载并运行一个新程序
1 |
|
execve
调用一次并不返回
当main
函数开始执行时,用户栈地组织结构为下图
int main(int argc, char **argv, char **envp);
int main(int argc,char *argv[], char *envp[]);
main
函数有3个参数
argc
,指的是argv[]
数组中非空指针地数量argv
,指向argv[]
数组中的第一个条目envp
,指向envp[]
数组中的第一条目
Linux
用于操作环境数组的函数
1 |
|
getenv
函数在环境数组中搜索字符串name = value
。如果找到返回指向value
的指针,否则返回NULL
1 |
|
如果环境数组包含一个形如name = oldvalue
的字符串,那么unsetenv
会删除指定键值对,而使用setenv
会使用newvalue
代替oldvalue
,但是只有在overwrite
为非零时才会覆盖。
如果name
不存在,那么setenv
酒吧name = new value
添加到数组中。
程序与进程的区别
- 程序是一堆代码和数据,作为目标文件存在于磁盘上。进程则是执行程序的实例。
- 程序是运行在进程的上下文中的
练习题8.6
编写一个叫做myecho
的程序,打印出它的命令行参数和环境变量
1 |
|
课后答案
1 |
|
8.4.6 利用fork和execve运行程序
shellex.c
1 |
|
8.5 信号
Linux
信号允许进程和内核中断其他进程。
底层硬件异常由内核异常处理程序处理,用户程进程不可见。因此信号提供一种机制,可以通知用户进程发生了异常。
按下Ctrl+C
,内核将会发送SIGINT
信号给前台进程组的每个进程。
一个进程可以通过向另一个进程发送SIGKILL
信号强制终止它。
当一个子进程终止或停止时,内核会发送一个SIGCHLD
信号给父进程
8.5.1 信号术语
传送信号到目的进程的步骤:
- 发送信号。内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程。
- 接收信号。当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序的用户层函数捕获这个信号
没有被接收的信号称之为待处理信号,一种信号至多只有一个待处理信号,若此时接收了相同类型的信号则直接抛弃,不会进行排队等号。
进程可以有选择性的阻塞接收某种信号。当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。
8.5.2 发送信号
Unix
系统提供了向进程发送信号的机制,这些机制都是基于进程组概念。
1.进程组
每个进程都只属于一个进程组,进程组由一个正整数进程组ID标识。
getpgrp
函数返回当前进程的进程组ID
:
1 |
|
默认条件下,子进程与它的父进程同属于一个进程组。
进程可以通过setpgid
函数改变自己或其他进程的进程组
1 |
|
- 若
pid
为0,则使用当前进程的PID
- 若
pgid
为0,则使用pid
指定的进程的PID
作为进程组ID
2. 用/bin/kill程序发送信号
/bin/kill
程序可以向另外的进程发送任意的信号
1 |
|
发送一个SIGKILL
信号给进程15213
负的PID
则会将信号被发送到进程组PID
中的每个进程
1 |
|
发送一个SIGKILL
信号给进程组15213中的每个进程
3.从键盘发送信号
Unix shell
使用作业表示为对一条命令行求值而创建的进程。
至多只有一个前台作业或0个或多个后台作业。
1 |
|
上述命令会创建一个由两个进程组成的前台作业,这两个进程是通过Unix
管道连接起来
shell
为每个作业创建一个独立的进程组,进程组ID
通常取父进程的PID
Ctrl + C
往前台进程组发送SIGINT
信号,终止前台作业
Ctrl + z
往前台进程发送SIGTSTP
信号,挂起前台作业
4.用kill函数发送信号
进程通过调用kill
函数发送洗脑给其他进程
1 |
|
pid>0
,这kill
函数发送信号号码sig
给进程pid
pid = 0
,则kill
函数发送信号sig
给调用进程所在进程组中的每个进程,包括调用进程自己pid < 0
,kill
函数发送信号sig
给进程组pid
中的每个进程
kill.c
1 |
|
5.用alarm函数发送信号
进程可以通过alarm
函数向它自己发送SIGALRM
信号
1 |
|
alarm
被称为闹钟函数,可以在进程中设置一个定时器,当定时器指定的时间到时,它向进程发送SIGALRM
信号,其动作是终止调用该alarm
函数的进程
8.5.3 接收信号
当进程从内核态转化为用户态时,内核会检测进程是否存在未被阻塞且待处理信号的集合。若有则内核会选择某个信号(通常为最小值的信号),并且强制进程接收信号。
进程接收信号后会采取某种行为,每个信号都有一个预定义的默认行为
- 进程终止
- 进程终止并转储内存
- 进程停止(挂起)直到被
SIGCONT
信号重启 - 进程忽略该信号
信号的默认行为可以通过signal
函数修改,除了SIGSTOP
和SIGKILL
信号不能被修改
1 |
|
signal
函数可以通过三种方法来修改和信号signum
相关联的行为
handler
是SIG_IGN
,那么忽略类型为signum
的信号handler
是SIG_DFL
,那么类型为signum
的信号行为恢复为默认行为(处理信号)- 否则,
handler
就是用户定义的函数的地址,这个函数被称为信号处理程序,只要进程接收到一个类型为signum
的信号,就会调用这个程序。通过把处理程序的地址传递给signal
函数从而改变默认行为,这叫设置信号处理程序。调用信号处理程序被称为捕获信号。执行信号处理程序被称为处理信号。
1 |
|
8.5.4 阻塞和解除阻塞信号
Linux提供阻塞信号的隐式和显式的机制:
- 隐式阻塞机制。内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。即每个信号都有对应的信号处理程序,在该信号处理程序执行时会默认阻塞该信号
- 显示阻塞机制。应用程序可以使用
sigprocmask
函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号
1 |
|
sigprocmask
函数改变当前阻塞的信号集合,具体行为依赖于how
的值
SIG_BLOCK
:把set
中的信号添加到blocked
中(blocked=blocked | set
)SIG_UNBOCK
:从blocked
中删除set
中的信号(blocked = blocked & ~set
)SIG_SETMASK
:block = set
如果oldset
非空,那么blocked
位向量之前的值保存在oldset
中
对set
信号集合进行操作的函数
sigemptyset
初始化set
为空集合sigfillset
函数把每个信号都添加到set
中sigaddset
函数把signum
添加到set
sigdelset
从set
中删除signum
sigprocmask
临时阻塞接收SIGINT
信号
1 |
|
8.5.5 编写信号处理程序
1.安全的信号处理
信号处理程序由于是与主程序以及其他信号处理程序并发地允许,因此可以并发地访问同样地全局数据结构,那么可能会造成不可预知地问题。
编写处理程序的原则
- 处理程序要尽可能简单
- 在处理程序中只调用异步信号安全的函数。
- 异步信号安全的函数是可重入的(只访问局部变量)
- 异步信号安全的函数不能被信号处理程序中断
异步信号安全的函数
信号处理程序中产生输出唯一安全的方法是使用write
函数
1 |
|
code/src/csapp.c
1 |
|
SIGINT处理程序的安全版本
1 |
|
- 保存和恢复
errno
。由于众多Linux
异步信号安全的函数都会在出错返回时设置errno
。由于处理程序可能会干扰其他主程序中其他依赖于errno
。因此解决办法为是在进入处理程序时将errno
保存在一个局部变量中,在处理程序返回前恢复 - 阻塞所有的信号,保护对共享全局数据结构的访问。
- 用
volatile
声明全局变量。若用处理程序和main
函数共享一个全局变量g
,由于main
周期性读g
。一个优化的编译器会使用缓存在寄存器中的g
的副本来满足对g
对每次引用。则导致main
无法读取g
更新过的值volatile
类型限定符来定义一个变量,告诉编译器不要缓存这个变量。强迫编译器每次在代码中引用g
时,都要从内存中读取。
- 用
sig_atomic_t
声明标志。处理程序通过写全局标志来记录收到的信号。主程序周期性地读这个标志,响应信号,再清除标志。对于通过这种方式来共享地标志,C
提供一种整型数据类型sig_atomic_t
,并且该类型的读写是保证是原子操作
1 |
|
2. 正确的信号处理
1 |
|
上述程序会导致僵尸进程。父进程接收并捕获了第一个信号,当处理程序还在处理第一个程序时,第二个信号就传送并添加到待处理信号集合里,然而,因为SIGCHLD
信号被SIGCHLD
处理程序阻塞了,所以第二个信号不会被接收,因此再第三个信号时,该信号会被抛弃,导致进程未被收回。
code/ecf/signal2.c
1 |
|
练习题8.8
下面这个程序的输出是什么?
1 |
|
输出213
3.可移植的信号处理
Unix
信号处理的另一个缺陷在于不同的系统有不同的信号处理语义
signal
函数的语义各有不同。在有些老的Unix
系统在信号k
被处理程序捕获之后就把对信号k
的反应恢复到默认值。因此在这些系统上,每次运行之后,处理程序必须调用signal
函数,显示地重新设置。- 系统调用可以被中断。像
read
、write
和accept
这样的系统调用潜在地会阻塞进程一段时间,称之为慢速系统调用。在老的Unix
系统中,当处理程序捕获到一个信号时,被中断地慢速系统调用在信号处理程序返回时不再继续,而是立即返回给用户一个错误条件,并将errno
设置为EINTR
,因此在这些系统上程序员需要手动重启被中断的系统调用的代码。
sigaction
函数允许用户在设置信号处理时,明确指定他们想要的信号处理语义。
1 |
|
定义一个包装函数,称之为Signal
调用sigaction
1 |
|
8.5.6 同步流以避免讨厌的并发错误
code/ecf/procmask1.c
1 |
|
上述代码可能会导致条件竞争
- 父进程执行
fork
函数,内核调度新创建的子进程运行 - 子进程终止,传递一个
SIGCHLD
信号给父进程 - 父进程执行前发现有未处理的信号
- 信号处理程序回收终止子进程,并且调用
deletejob
,但是父进程还没有把子进程放进作业中 - 信号处理程序完毕后,内核继续运行父进程,父进程从
fork
返回后,将回收的子进程又添加到作业中
code/efc/procmask2.c
在父进程fork
之前阻塞SIGCHLD
,在addjob
之后才解除,则避免了在addjob
之前deletejob
1 |
|
8.5.7 显示地等待信号
有时候主程序需要显示地等待某个信号处理程序运行
1 |
|
循环会浪费处理器资源,因此可以修改为其他代码使用
1 |
|
但是若信号在while
语句后,pause()
语句前到达,那么程序将永久休眠
使用sleep
替换pause
1 |
|
但是使用sleep
执行会浪费大量时间,若改为高精度的休眠函数则休眠时间太短则会造成while
语句执行次数过多,若休眠时间太长则又会导致程序运行时间太久。
使用函数sigsuspend
1 |
|
code/ecf/sigsuspend.c
1 |
|
8.6 非本地跳转
非本地跳转是通过setjmp
和longjmp
函数来提供的
1 |
|
setjmp
函数在env
缓冲区中保存当前调用环境,供后面的longjmp
使用,并返回0。
调用环境包括程序计数器、栈指针和通用目的寄存器。
1 |
|
longjmp
函数从env
缓冲区中恢复调用环境,然后触发一个从最近一次初始化env
的setjmp
调用的返回。然后setjmp
返回,并带有非零的返回值retval
code/efc/setjmp.c
1 |
|
longjmp
允许它跳过所有中间调用的特性可能会产生意外的后果,例如函数内部分配了资源,应该在函数结束时释放资源,但是由于longjmp
跳过了释放的过程导致了内存泄露。
code/ecf/restart.c
非本地跳转的另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置。
1 |
|
C++和Java中的软件异常
C++
和Java
提供的异常机制是较高层次的,是C语言的setjmp
和longjmp
函数的更加结构化的版本。
try
语句中的catch
子句类似于setjmp
函数,throw
语句类似于longjmp
函数
8.7 操作进程的工具
STRACE
:打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。PS
:列出当前系统中的进程TOP
:打印出关于当前进程资源使用的信息PMAP
:显示进程的内存映射/proc
:一个虚拟文件系统,以ASCII
文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容- 输入
cat/proc/loadavg
可以看到linux
系统上的平均负载
- 输入