深入理解计算机系统学习笔记

第7章 链接

链接是将代码与数据组合为单一文件的过程。在现代系统中,链接由链接器的程序自动执行。

编译可存在如下周期:

  • 编译:源代码翻译为机器代码
  • 加载:程序被加载器加载到内存并执行
  • 运行:应用程序执行

7.1 编译器驱动程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//main.c
int sum(int *a, int n);
int array[2] = {1,2};

int main()
{
int val = sum(array,2); //数组元素求和
return val;
}

//sum.c
int sum(int *a,int n)
{
int i, s = 0;
for(i = 0; i < n; i++){
s += a[i];
}
}

编译驱动程序包括

  • 语言处理器
  • 编译器
  • 汇编器
  • 链接器

image-20220421000959782

Linux下驱动程序的调用

1
2
3
4
gcc -Og -o prog main.c sum.c
#-O选项对程序进行优化编译、连接。
#-g选项产生符号调试工具
#-v选项可以查看编译具体的步骤

C预处理器(cpp)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#将main.c翻译成ASCII码的中间文件main.i
cpp [other arguments] man.c ./main.i

#main.i
# 1 "main.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "main.c"
int sum(int *a,int n);
int array[2] = {1,2};

int main()
{
int val = sum(array,2);
return val;
}

C编译器(cc1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#将main.i文件翻译成一个ASCII汇编语言文件main.s
#cc1的路径 /usr/lib/gcc/x86_64-linux-gnu/9/cc1
cc1 ./main.i -Og [other arguments] -o ./main.s

#汇编代码
.file "main.i"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
subq $8, %rsp
.cfi_def_cfa_offset 16
movl $2, %esi
leaq array(%rip), %rdi
call sum@PLT
addq $8, %rsp
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.globl array
.data
.align 8
.type array, @object
.size array, 8
array:
.long 1
.long 2
.ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0"
.section .note.GNU-stack,"",@progbits

C汇编器(as)

1
2
#汇编器将汇编代码文件翻译成可重定位目标文件
as [other arguments] -o ./main.o ./main.s

链接器程序(ld)

1
2
#链接器将可重定位的目标文件及系统目标文件组合起来,输出一个可执行目标文件
ld -o prog [system object files and args] ./main.o ./sum.o

加载器[loader]

1
2
#shell调用操作系统中加载器的函数,将可执行文件prog中的代码和数据复制到内存,然后将控制转移到这个程序的起始位置
./prog

7.2 静态链接

静态链接器(Linix LD

  • 输入:可重定位目标文件与命令行参数
  • 输出:完全链接、可以加载和运行的可执行目标文件

链接器的两个主要任务

  • 符号解析:目标文件定义和引用符号,每个符号对应一个函数、一个全局变量或一个静态变量。符号解析的目的是将符号引用与符号定义关联起来。
  • 重定位:编译器和汇编器生成从地址为0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,重定位这些节,然后修改对符号的引用,使得它们指向相应的内存位置。

7.3 目标文件

目标文件的三种形式

  • 可重定位目标文件:包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
  • 可执行目标文件:包含二进制代码和数据,其形式可以被直接复制到内存并指向。
  • 共享目标文件:特殊的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接

目标模块是一个字节序列,目标文件则是存储在磁盘上地目标模块。目标文件在不同地操作系统上的文件格式各不相同

  • Unix系统:a.out
  • Windows系统:PE
  • MacOS-X系统:Mach-O
  • Linux系统:ELF

7.4 可重定位目标文件

ELF HEADER

  1. 以16字节序列开始,记载生成该文件的系统的字的大小和字节顺序。
  2. ELF头的大小
  3. 目标文件的类型
  4. 机器类型
  5. 节部表的文件偏移
  6. 节头部表中条目的大小和数量

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
2
3
4
5
6
7
8
9
10
11
12
13
typedef sturct{
int name; //字符串表中的字节偏移
char type:4/*类型为数据或者是函数*/
binding:4;/*代表符号是本地的还是全局的*/
char reserved;/*未使用*/
short section;
long value; //符号的地址
/*
对于重定位文件来说value是距定义目标的节的起始位置的偏移。
对于可执行目标文件来说,该值是一个绝对运行时的地址。
*/
long size; //目标的大小
}Elf64_Symbol;

符号表需要结合字符串表寻找符号信息。

三个特殊的伪节,它们在节头部表中是没有条目的

  • ABS符号代表不该被重定位的符号
  • UNDEF符号代表未定义的符号,本目标模块引用了,但是符号是在其他模块定义的符号
  • COMMON符号表示还未被分配位置的未初始化的数据。
    • value字段给出对齐要求
    • size给出最小的大小
  • 只有可重定位目标文件中才有这些伪节

COMMON.bss的区别:COMMON存储未初始化的全局变量.bss存储未初始化的静态变量,以及初始化为0的全局静态变量

image-20220508172957864

  • 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)

与静态库链接

当链接器接收的是一组可重定位目标文件

将相关的目标模块打包成为一个单独的文件,称之为静态库作为链接器的输入。

image-20220508233013659

当链接器输出可执行文件时,它仅仅复制静态库里被应用程序引用的目标模块。

当不适用静态库时,编译器开发者向用户提供库函数的几种做法:

  • 方法一:使用编译器识别库函数,生成指定库函数代码。

    • 缺点:C标准定义了大量的标准函数,给编译器增加了复杂性,并且库函数每次更新都需要更新一次编译器。
  • 方法二:将所有标准C函数都放在单独的可重定位目标模块中

    1
    gcc main.c /usr/lib/libc.o
    • 优点:将编译器与库函数的实现分离开
    • 缺点:每个可执行文件都需要包含一份标准函数副本,以及每个运行的程序都需要将这些函数副本放置在内存中。每次库函数更新都需要重新编译重定位模块
  • 方法三:可以将每个库函数单独编译成独立的重定位模块,但是每次连接时需要将多个重定位模块进行链接,容易出错且耗时

静态库结合了方法二与方法三,将方法相近的模块编译为独立的目标模块,然后封装为一个单独的静态库文件。

1
gcc main.c /usr/lib/libm,a /usr/lib/libc.a

在链接时,链接器只复制被程序引用的目标模块。

Linux系统中,静态库以一种成为存档的特殊文件格式存放在磁盘中。存档文件命后缀.a

addvec.c

1
2
3
4
5
6
7
8
9
10
11
int addcnt = 0;

void addvec(int *x,int *y,int *z,int n)
{
int i;

addcnt++;

for(i = 0; i < n; i++)
z[i] = x[i] + y[i];
}

multvec.c

1
2
3
4
5
6
7
8
9
10
11
int multcnt = 0;

void multvec(int *x,int *y, int *z,int n)
{
int i;

multcnt++;

for(i = 0; i < n; i++)
z[i] = x[i] * y[i];
}
1
2
3
gcc -c addvec.c multvec.c
#生成静态库
ar rcs libvector.a addvec.o multvec.o
1
2
3
4
#与静态库链接
gcc -static -o prog2c main2.o ./libvector.a
#等价于
gcc -static -o prog2c main2.o -L . -lvector

链接器解析静态库引用

链接器从左到右按照编译器驱动程序命令扫描可重定位目标文件存档文件

链接器维护三个集合,初始时,三个集合都为空

  • 可重定位目标文件的集合E
  • 一个未解析的符号集合U
  • 在前面输入文件中已定义的符号集合D

链接器解析引用

  • 对每个输入文件f,链接器会判断f是目标文件还是存档文件。如果f是目标文件,那么链接器将f添加到EU是用来放置引用了但是没有找到定义的符号,D则是已经在模块中定义的符号,因此若输入文件中存在集合U中定义的符号,则将集合U中的符号放置在集合D
  • 如果f是存档文件,链接器就会将集合U中的符号与存档文件定义的符号进行比较。若存档文件中的成员m定义了集合U中的符号,则将成员m放置到集合E中,将集合U中的符号移动到集合D中,对存档文件的每个成员都依次进行该过程,直到UD都不发生变化。那么不包含在集合E中的成员目标文件都会被抛弃,链接器则继续输入下一个文件。
  • 若链接器完成了所有输入文件的解析,但是发现集合U非空,那么就会发出异常并终止。否则就合并和重定位E中的目标文件,构建输出的可执行文件。

练习题7.3

1
2
3
4
5
6
#A.p.o -> libx.a
gcc p.o libx.a
#B.p.o -> libx.a -> liby.a
gcc p.o libx.a liby.a
#C.p.o -> libx.a -> liby.a且liby.a->libx.a->p.o
gcc p.o libx.a liby.a libx.a

7.7 重定位

重定位由两个步骤组成

  • 重定位节和符号定义:链接器将所有相同类型的节合并为同一类型的新的聚合节。接着链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节以及赋给输入模块定义的每个符号。此时,程序中的每条指令和全局变量都有唯一的运行时内存地址。(往符号写入地址)
  • 重定位节中的符号引用:链接器修改代码节和数据节中对符号的引用,使得它们指向正确的运行时地址。(将符号的地址重定位),重定位依赖于可重定位目标模块中称为重定位条目的数据结构

重定位条目

当编译器遇到最终未知的目标引用时,它会生成一个重定位条目,用于告知链接器在目标文件合并为可执行文件时如何修改这个引用。

代码的重定位条目放在.rel.text

初始化数据的重定位条目放在.rel.data

ELF重定位条目的数据结构

1
2
3
4
5
6
typedef struct{
long offset; //标识需要被修改的引用的节偏移
long type:32, //用于告知链接器如何修改新的引用
symbol:32; //标识被修改引用应该指向的符号
long addend; //有些重定位类型需要它作为偏移调整
}Elf64_Rela;

两种最基本的重定位类型

  • R_X86_64_PC32:重定位一个使用32位PC相对地址的引用
  • R_X86_64_32:重定位一个使用32位绝对地址的引用

重定位符号引用

1
2
3
4
5
6
7
8
9
10
11
foreach section s{ //遍历每个节
foreach relocation entry r{ //遍历每个重定位结构条目
refptr = s + r.offset; //节地址+节偏移地址 = 重定位条目的地址
if(r.type == R_X86_64_PC32){ //使用PC相对地址引用
refaddr = ADDR(s) + r.offset;
*refptr = (unsigned)(ADDR(r.symbol) + r.addend - refaddr);
}
if(r.type == R_X86_64_32) //使用绝对地址
*refptr = (unsigned)(ADDR(r.symbol) + r.addend);
}
}

main.o反汇编代码

main函数中有两个重定位引用,并且汇编器为每个引用产生一个重定位条目,显示在该引用的后面。array使用PC相对地址进行重定位,而sum使用绝对地址引用

重定位PC相对引用

1
e: e8 00 00 00 00 callq 13 <main+0x13>
  • 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
4004e8:e8 05 00 00 00 callq 4004e8<sum>
  • 在指令执行时,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
4004d9: bf 18 10 60 00 mob $0x601018,%edi

7.8 可执行目标文件

二进制文件包含加载程序到内存并运行它所需的所有信息

image-20220510175440833

ELF文件格式

  • ELF头描述文件的总体格式,包含程序的入口点
  • .init节定义了个函数,叫做_init,程序的初始化代码会调用它。由于可执行文件是完全链接的(已被重定位),所以它不再需要.rel

程序头部表描述了可执行文件到连续的内存段的映射

image-20220510180231214

  • off:目标文件中的偏移
  • vaddr/paddr:内存地址
  • align:对齐要求
  • filesz:目标文件中的段大小
  • memsz:内存中的段大小
  • flags:运行时访问权限

7.9 加载可执行目标文件

执行目标文件所执行的命令

1
./prog

可执行文件是通过加载器运行。execve函数可以用来调用加载器,加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。将程序复制到内存并运行的过程叫做加载。

image-20220510181119921

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
2
3
4
#编译共享库
gcc -shared -fpic -o libvector.so addvec.c multvec.c
#-fpic选项指示编译器生成与位置无关的代码
#-shared选项指示链接器创建一个共享的目标文件

动态链接创建可执行文件步骤

  • 首先静态执行一些链接
  • 然后再程序加载时,动态完成链接

没有代码和数据复制到可执行文件中,链接器复制了重定位和符号表信息(与静态链接不同点,静态链接复制了代码和数据)

加载器加载和运行可执行文件(部分链接)

  • 部分链接的可执行文件包含.interp节(包含动态链接的路径),加载器加载和运行动态链接器,动态链接器完成任务如下
    • 重定位libc.so的文本和数据
    • 重定位libvector.so的文件和输
    • 重定位可执行文件对由libc.solibvector.so定义的符号的引用
    • 最后,动态链接器将控制传递给应用程序,此时共享库的位置就固定,在程序执行时都不会改变了

7.11 从应用程序中加载和链接共享库

Linux系统为动态链接器提供了一个简单的接口,允许引用程序在运行时加载和链接共享库

1
2
3
4
5
#include<dlfcn.h>
void *dlopen(const char *filename, int flag); //打开与加载共享库
void *dlsym(void *handle,char *symbol); //获取函数地址
int dlclose(void *handle); //卸载共享库
const char *dlerror(void); //描述调用dlopen、dlsym或者dlclose函数发送的最近的错误

dll.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//gcc -rdynamic -o prog2r dll.c -ldl
//-ldl参数为显示加载共享库
//-rdynamic参数为通知链接器将所有符号添加到动态符号表

#include<stdio.h>
#include<stdlib.h>
#include<dlfcn.h>

int x[2] = {1,2};
int y[2] = {3,4};
int z[2];

int main()
{
void *handle;
void (*addvec)(int *,int *, int *,int);
char *error;

handle = dlopen("./libvector.so",RTLD_LAZY); //打开共享库,延迟绑定的方式
if(!handle){
fprintf(stderr,"%s\n",dlerror());
exit(1);
}

addvec = dlsym(handle,"addvec");//获取addvec函数的地址
if((error = dlerror())!=NULL){
fprintf(stderr,"%s\n",error);
exit(1);
}

addvec(x,y,z,2);
printf("z = [%d %d]\n",z[0],z[1]);

if(dlclose(handle)<0){ //关闭共享库
fprintf(stderr,"%s\n",dlerror());
exit(1);
}
return 0;
}

7.12 位置无关代码

多个进程是如何共享程序中的一个副本

  • 方案一:为每个共享库分配一个事先预备的专用的地址空间片
    • 问题一:空间利用率不高
      • 即使程序不适用该库也需要预留空间
      • 当共享库修改时需要判断预留空间是否足够,不足够需要找新的空间
      • 当创建新的共享库,需要再次找新的内存空间
      • 共享库数量多,会将内存空间分割为大小不均匀的片段
      • 对于不同的操作系统,给库分配的空间大小不一
  • 方案二:提供一种编译方式,使得共享库可以加载到内存的任何位置,并且无需链接器修改,这种编译方式被称之为位置无关代码(Position-Independent Code,PIC),使用-fpic参数指示编译系统生成PIC代码

PIC数据引用

  • 数据段与代码段中数据与指令的偏移是常量
  • 在数据段开始的地方创建了全局偏移量表(Global Offset Table,GOT),GOT表中每个被引用的全局变量都会有一个8字节条目(GOT项地址),并且编译器为每个条目生成一个重定位记录,加载时,动态链接器会重定位GOT中的每个条目,使该条目包含正确的变量地址。每个目标模块都有属于自己的GOT

image-20220511232959402

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模块中的入口点。其余的每个条目对应一个被调用的函数,其地址需要在运行时被解析。

image-20220511235250490

延迟解析地址步骤

  • 首先跳转到addvecplt地址,即程序调用进入PLT[2],这是addvecPLT条目
  • 第一条PLT指令通过GOT[4]进行间接跳转,在延迟绑定完成之前,都是跳转到PLT条目的下调指令
  • addvecID压入栈中,PLT[2]跳转到PLT[0],即动态链接器的函数地址
  • PLT[0]GOT[1]项内容压入栈中,通过GOT[2]跳转到动态链接器中。动态链接器通过压入的两个参数确定addvec函数的实际地址,最后将实际地址重写GOT项内容,再把控制传递给addvec函数

7.13 库打桩机制

Linux链接器使用库打桩技术,允许截获对共享库函数的调用,取而代之执行自己的代码(类似WindowsHook技术)。

库打桩的基本思想:创建一个包装函数,它的原型与目标函数一致,使用打桩技术,使得系统调用包装函数而不是原函数,包装函数内会先执行自己的逻辑后再调用原函数并把返回值返回给调用者

打桩可以发生在编译、链接或程序加载和执行时

编译时打桩

本地定义一个malloc.h头文件,将malloc函数定义为自定义的mymalloc函数,编译时使用-I参数强制载入本地库文件,完成编译时库打桩技术

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/*编译*/
/*
gcc -DCOMPILETIME -c mymalloc.c
gcc -I . -o intc int.c mymalloc.o
*/
/*
编译参数解析:
1.-D为定义宏
2.-I表示搜素库的目录
*/


//int.c
#include <stdio.h>
#include <malloc.h>

int main()
{
int *p = malloc(32);
free(p);
return(0);
}

//malloc.h
#define malloc(size) mymalloc(size)
#define free(ptr) myfree(ptr)

void *mymalloc(size_t size);
void myfree(void *ptr);

//mymalloc.c
#ifdef COMPILETIME
#include <stdio.h>
#include <malloc.h>

void *mymalloc(size_t size)
{
void *ptr = malloc(size);
printf("malloc(%d) = %p\n",(int)size,ptr);
return ptr;
}

void myfree(void *ptr)
{
free(ptr);
printf("free(%p)\n",ptr);
}
#endif

/*
out:
malloc(32) = 0x558f09de52a0
free(0x558f09de52a0)
*/

链接时打桩

编译时使用参数--wrap f可以修改引用时的符号,符号f会被修改为__wrap_f__real_f会被修改为符号f从而完成打桩技术

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/*
gcc -Wl,--wrap,malloc -Wl,--wrap,free -o intl int.o mymalloc.o
*/

//mymalloc.c
#ifdef LINKTIME
#include<stdio.h>

void *__real_malloc(size_t size);
void __real_free(void *ptr);

void *__wrap_malloc(size_t size)
{
void *ptr = __real_malloc(size); //__real_malloc会被替换为malloc
printf("malloc(%d) = %p \n",(int)size,ptr);
return ptr;
}

void __wrap_free(void *ptr)
{
__real_free(ptr);//__real_free会被替换为free
printf("free(%p)\n",ptr);
}

#endif

//int.c
#include<stdio.h>
#include<malloc.h>

int main()
{
int *p = malloc(32); //malloc会被替换为__wrap_malloc
free(p); //free会被替换为__wrap_free
return(0);
}

运行时打桩

自定义.so文件,使用LD_PRELOAD环境变量,使得系统载入我们自定义的.so文件完成打桩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
//课本例子
/*
gcc -DRUMTIME -shared -fpic -o mymalloc.so mymalloc.c -ldl
*/

//mymalloc.c
#ifdef RUNTIME
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

void *malloc(size_t size)
{
void *(*mallocp)(size_t size);
char *error;

mallocp = dlsym(RTLD_NEXT, "malloc"); //RTLD_NEXT为库中第一个出现的函数
if ((error = dlerror()) != NULL)
{
fputs(error, stderr);
exit(1);
}
char *ptr = mallocp(size);
printf("malloc(%d) = %p\n", (int)size, ptr);
return ptr;
}

void free(void *ptr)
{
void (*freep)(void *) = NULL;
char *error;

if (!ptr)
return;

freep = dlsym(RTLD_NEXT, "free");
if ((error = dlerror()) != NULL)
{
fputs(error, stderr);
exit(1);
}
freep(ptr);
printf("free(%p)\n", ptr);
}
#endif

//int.c
#include<stdio.h>
#include<malloc.h>

int main()
{
int *p = malloc(32);
free(p);
return(0);
}

但是课本例子无法运行

参考网上解析CSAPP第三版运行时打桩Segmentation fault

进入gdb调试,使用(gdb) set env LD_PRELOAD=./mymalloc.so设置环境变量,运行程序,输入bt查看栈回溯

image-20220517152718348

发现printmalloc函数一直互相递归调用,这是因为printf函数里使用malloc函数,而自定义的.so文件又调用了printf函数因此产生了死循环。

网上教程修改后的文件为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#ifdef RUNTIME
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

void *malloc(size_t size)
{
void *(*mallocp)(size_t size);
char *error;
static int print_times = 0;
print_times++;
mallocp = dlsym(RTLD_NEXT, "malloc");
if ((error = dlerror()) != NULL)
{
fputs(error, stderr);
exit(1);
}
char *ptr = mallocp(size);
if(print_times == 1)//防止了递归调用
printf("malloc(%d) = %p\n", (int)size, ptr);
print_times = 0;
return ptr;
}

void free(void *ptr)
{
void (*freep)(void *) = NULL;
char *error;
static int print_times = 0;
print_times++;
if (!ptr)
return;

freep = dlsym(RTLD_NEXT, "free");
if ((error = dlerror()) != NULL)
{
fputs(error, stderr);
exit(1);
}
freep(ptr);
if(print_times == 1)
printf("free(%p)\n", ptr);
print_times = 0;
}
#endif

#include<stdio.h>
#include<malloc.h>

int main()
{
printf("Hello,World!\n");
return(0);
}

image-20220517153341247

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的处理程序地址

image-20220609234222043

异常表的起始地址放在异常表基址寄存器中

image-20220609234558373

异常的调用过程与过程调用的不同之处

  • 过程调用时会将返回地址压入栈中,但是根据异常的类型,返回地址可能是当前指令也可能是下一条指令
  • 处理器会将额外的处理器状态压入栈中,程序返回时,需要用到这些状态。
  • 若控制从用户程序转移到内核,所有这些项目都将会被压入内核栈中,而不是压入到用户栈中
  • 异常处理程序运行在内核模式下,因此具有对系统资源的访问权限

8.1.2 异常的类别

异常分为四类:中断、故障、陷阱、终止。

  • 中断

中断是异步发生的,来自处理器外部的I/O设备的信号的结果。例如网络适配器、磁盘控制器和定时器芯片,这些设备通过向处理器新品上的引脚发信号,并将异常号放到系统总线上,来触发中断,异常号用于标识引起中断的设备。

  • 陷阱和系统调用

陷阱是有意而为的异常,陷阱最重要用途是在用户态与内核态之间提供一个接口,被称之为系统调用。

用户程序需要向内核请求服务,比如读文件(read)、创建新进程(fork)、加载新程序(execve)或终止当前进程(exit)。

  • 故障

故障由错误情况引起,它可能能够被修正。当故障发生时,处理器就会将控制转移给故障处理程序。当故障能被修正(例如缺页),则将控制返回给引起故障的指令,重新执行它。否则,处理程序返回到内核中abort例程,abort例程会终止引起故障的应用程序。

  • 终止

终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAMSRAM位被损坏时发生的奇偶错误。终止处理程序不会将控制返回给应用程序,而是交给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 系统调用

当应用程序想要请求内核服务时使用,例如读写文件或创建进程

image-20220610165218222

c语言

1
2
3
4
5
int main()
{
write(1,"hello,world\n",13);
_exit(0);
}

汇编语言

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.section .data
string:
.ascii "hello,world\n"
string_end:
.equ len,string_end -string
.section .text
.global main
main:
movq $1,%rax ;系统调用号
movq $1,%rdi ;键盘输入
movq $string,%rsi ;存储地址
movq $len,%rdx ;长度
syscall ;调用write(1,"hello,world\n",13)

movq $60,%rax ;调用exit(0)
movq $0,%rdi
syscall

8.2 进程

进程:正在执行的程序

系统种的每个程序都运行在某个进程的上下文种。

上下文由程序正确运行所需的状态组成的

  • 程序中的代码与数据
  • 栈、通用寄存器的内容
  • 程序计数器(PC)
  • 环境变量
  • 打开文件描述符

执行目标文件的流程

  • shell中输入可执行目标文件的名字
  • shell创建新的进程,在该进程的上下文中运行该可执行目标文件

8.2.1 逻辑控制流

PC值得序列称之为逻辑控制流

8.2.2 并发流

一个逻辑流的执行在时间上与另一个流重叠,称之为并发流

两个逻辑流并发的运行在不同的处理器核或者计算机上,这为并行流。

练习题8.1

考虑三个具有下述起始和结束事件的进程

image-20220610171839679

1
2
3
4
5
6
7
  0     1     2   3   4    5

A -- --

B -- --

C -- --
进程对 并发的?
AB 并发的
AC 不是并发的
BC 并发的

8.2.3 私有地址空间

进程为每个程序提供它的私有地址空间

Linux进程的地址空间的组织结构

image-20220610172531499

8.2.4 用户模式和内核模式

今存起通常使用某个控制寄存器中的一个模式位区分用户态与内核态,该寄存器描述了进程当前享有的特权。

当设置了模式位时,进程运行在内核模式中

没有设置模式位时,进程运行子用户模式中

进程通过中断、故障或者陷入系统调用这样的异常从用户态转化为内核态。

/proc文件系统,允许用户模式进程访问内核数据结构的内容

  • /proc/cpuinfo:CPU类型
  • /proc/<process-id>/maps:某个特殊的进程使用的内存段
  • /sys:输出系统总线和设备的额外的底层信息

8.25 上下文切换

操作系统内核使用上下文切换的异常控制流实现多任务

内核选择启用哪个进程的决策称之为调度,由调度器处理

内核调度过程

  • 内核选择新进程抢占当前进程
  • 上下文切换
    • 保存当前进程的上下文
    • 恢复某个先前被抢占的进程被保存的上下文
    • 将控制传递给这个新恢复的进程

image-20220610174537704

8.3 系统调用错误处理

1
2
3
4
5
void unix_error(char *msg)
{
fprintf(stderr,"%s: %s\n",msg,strerror(errno));
exit(0);
}

8.4 进程控制

8.4.1 获取进程ID

1
2
3
4
5
#include<sys/types.h>
#include<unistd.h>
/* pid_t = int */
pid_t getpid(void); //返回进程的PID
pid_t getppid(void); //返回父进程的PID

8.4.2 创建和终止进程

进程的三种状态

  • 运行:进程要么在CPU上运行,要么在等待被执行且最终会被内核调度
  • 停止:进程的执行被挂起,且不会被调度。当收到SIGSTOPSIGTSTPSIGTTINSIGTTOU信号时,进程就停止,知道接收到SIGCONT信号
  • 终止:进程永远地停止了,停止地原因有
    • 收到信号,该信号默认行为为终止进程
    • 从主程序返回
    • 调用exit函数

exit函数以status退出状态来终止进程

1
2
#include<stdlib.h>
void exit(int status)

父进程通过fork函数创建新的运行的子进程

1
2
3
4
5
6
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
/*
子进程返回0,父进程返回子进程的PID,出错为-1
*/

新创建的子进程几乎与父进程相同。子进程与父进程具有相同的用户级虚拟空间地址,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,因此子进程可以读写父进程中打开的任何文件。父进程与子进程的区别在于它们具有不同的PID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include<stdlib.h>
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
#include <errno.h>

void unix_error(char *msg)
{
fprintf(stderr,"%s: %s\n",msg,strerror(errno));
exit(0);
}
pid_t Fork(void)
{
pid_t pid;
//fork函数有两个返回值
//父进程返回子进程的PID
//子进程返回0
if((pid = fork()) < 0)
unix_error("Fork error");
return pid;
}
int main()
{
pid_t pid;
int x = 1;
printf("main\n");
pid = Fork();
if(pid == 0){
printf("child : x=%d\n",++x);
printf("PID:%d\n",pid);
exit(0);
}
printf("parent: x=%d\n",--x);
printf("PID:%d\n",pid);
exit(0);
}

fork函数特点

  • 调用一次,返回两次。一次返回到父进程,一次返回到子进程。
  • 并发执行。父进程与子进程是独立并发的进程并且内核能够以任意方式交替执行它们逻辑控制流中的指令。
  • 两者相同但是具有独立的地址空间。
  • 共享文件。父进程与子进程都将输出打印在屏幕上,原因上子进程继承了父进程所有的打开文件。当父进程调用fork时,stdout文件时打开的,并指向屏幕。子进程继承了该文件,因此也会向屏幕输出。

gdb调试fork()函数

  • 调试父进程:set follow-fork-mode parent
  • 调试子进程:set follow-fork-mode child

练习题8.2 考虑下面的程序

1
2
3
4
5
6
7
8
int main()
{
int x = 1;
if(Fork() == 0)
printf("p1: x=%d\n",++x);
printf("p2: x=%d\n",--x);
exit(0);
}
  • A.子进程的输出是p1: x = 2p2: x=1
  • B.父进程的输出是p2:x=0

8.4.3 回收子进程

进程因为某种原因终止时,内核并不是立即把它从系统中清除。进程被标志为已终止状态,直到它被父进程回收。

当父进程回收已终止的子进程的时候,内核就会将子进程的退出状态传递给父进程,并且抛弃已终止进程,此时该进程就不存在系统中。

若已终止进程未被回收则称之为僵尸进程。

init进程是所有进程的父进程,负责回收孤儿进程(即父进程终止,但其子进程还存在,存在的子进程被称之为孤儿进程)。

init进程的PID为1,不会终止。是在系统启动的时候由内核创建的。

僵尸进程仍然会消耗系统的内存资源。

waitpid函数用于等到它的子进程终止或者停止

1
2
3
4
5
#include<sys/types.h>
#include<sys/wait.h>

pid_t waitpid(pid_t pid,int *statusp, int options);
//返回:成功返回子进程PID,否则返回0,其他错误返回-1

默认情况下(options=0),waitpid挂起调用进程的指向,直到它的集合中的一个子进程终止。

1.判定等待集合的成员

等待集合的成员由参数pid确定

  • pid>0,只等待进程ID等于pid的子进程
  • pid = -1,等待任何一个子进程退出
  • pid = 0,等待同一个进程组中的任何自己才能
  • pid < -1,等待一个指定进程组中的任何子进程,这个进程组的ID等于pid的绝对值

2.修改默认行为

options可以设置为WNOHANGWUNTRACEDWCONTINUED或者这几种的组合

  • WNOHANG:如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回值为0)。

例子(参考Linux waitpid用WNOHANG

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
#include<stdio.h>

int main()
{
pid_t pc,pr;

pc = fork();
if(pc < 0)
{
printf("fork error\n");
exit(1);
}
else if(pc == 0)
{
sleep(10);
exit(0);
}
else
{
do{
pr = waitpid(pc,NULL,WNOHANG);
if( pr == 0)
{
printf("No child exit\n");
sleep(1);
}
}while(pr == 0);
if ( pr == pc)
printf("successfully get child %d\n",pr);
else
printf("wait child error\n");
}
return 0;
}

image-20220611005605666

  • WUNTRACED:挂起调用进程的指向,直到等待集合中的一个进程变成已终止或者被停止。返回的PID为已终止或被停止子进程的PID。默认的行为是只返回已终止的子进程。
  • WCONTINUED:挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或等待集合中一个被停止的进程搜到SIGCONT信号重新开始执行。

3.检查已回收子进程的退出状态

statusp参数是飞控的,那么waitpid就会在status中放上关于导致返回的子进程的状态信息,statusstatusp指向的值。wait.h头文件定义了解释status参数的几个宏

  • WIFEXITED:如果子进程通过调用exit或者一个返回正常终止,就返回真
  • WEXITSTATUS:返回一个正常终止的子进程的退出状态。只有当WIFEXITED()返回真时,才会定义这个状态。
  • WIFSIGNALED:如果子进程时因为一个未捕获的信号终止的,那么就返回真
  • WTERMSIG:返回导致子进程终止的信号编号。只有在WIFSIGNALED()返回为真时,才定义这个状态。
  • WIFSTOPPED:如果引起返回的子进程当前是停止的,那么就返回真
  • WSTOPSIG:返回引起子进程停止的信号和编号。只有在WIFSTOPPED()返回为真时,才定义这个状态。
  • WIFCONTINUED:如果子进程收到SIGCONT信号重新启动,则返回真。

4.错误条件

如果调用进程没有子进程,那么waitpid返回-1,并且设置errnoECHILD

如果waitpid函数被一个信号中断,那么它返回-1,并设置errnoEINTR

使用man + 函数名可以查询需要导入的头文件

练习题8.3

列出下面程序所有可能的输出序列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main()
{
if(Fork() == 0){
printf("a");
fflush(stdout);
}
else{
printf("b");
fflush(stdout);
waitpid(-1,NULL,0);
}
printf("c");
fflush(stdout);
exit(0);
}

image-20220611011515337

5.wait 函数

wait函数时waitpid函数的简单版本

1
2
3
4
#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *statusp);

调用wait(&status)等价于调用waitpid(-1,&status,0)

6. 使用waitpid的示例

waitpid1.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <errno.h>
#define N 2

void unix_error(char *msg)
{
fprintf(stderr,"%s:%s\n",msg,strerror(errno));
exit(0);
}
pid_t Fork(void)
{
pid_t pid;
if((pid = fork()) < 0)
unix_error("Fork error");
return pid;
}

int main()
{
int status,i;
pid_t pid;

for(i = 0; i < N;i++) //fork两个子进程
if((pid = Fork()) == 0)
exit(100+i); //退出状态

while ((pid = waitpid(-1, &status,0)) > 0){//等到任意进程终止
printf("pid:%d\n",pid);
if(WIFEXITED(status)) //调用exit函数退出则执行
printf("chid %d terminated normally with exit status=%d\n",pid,WEXITSTATUS(status));//打印终止的进程ID即状态号
else
printf("child %d terminated abnormally\n",pid);
}

if (errno != ECHILD)
unix_error("waitpid error");
exit(0);
}

waitpid2.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include<stdio.h>
#include<stdlib.h>
#include<errno.h>
#include <sys/types.h>
#include <unistd.h>
#include<string.h>
#define N 2

void unix_error(char *msg)
{
fprintf(stderr,"%s:%s\n",msg,strerror(errno));
exit(0);
}

pid_t Fork(void)
{
pid_t pid;
if((pid = fork()) < 0 )
unix_error("Fork error");
return pid;
}

int main()
{
int status,i;
pid_t pid[N], retpid;

for(i = 0; i < N; i++)
if((pid[i] = Fork()) == 0)
exit(100+i);

i = 0;
//pid > 0 等待指定的子进程退出
while ((retpid = waitpid(pid[i++], &status,0)) > 0){
if (WIFEXITED(status))
printf("child %d terminated normally with exit status=%d\n",retpid,WEXITSTATUS(status));
else
printf("child %d terminated abnormally\n",retpid);
}
if(errno != ECHILD)
unix_error("waitpid error");
exit(0);
}

练习题8.4

考虑下面的程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main()
{
int status;
pid_t pid;
printf("Hello\n");
pid = Fork();
printf("%d\n",!pid);
if(pid != 0){
if (waitpid(-1,&status,0)>0){
if(WIFEXITED(status)!=0)
printf("%d\n",WEXITSTATUS(status));
}
}
printf("Bye\n");
exit(2);
}

A.这个程序会产生多少输出行

image-20220611121332443

B.这些输出行的一种可能顺序是什么?

8.4.4 让进程休眠

sleep函数将一个进程挂起一段指定的时间

1
2
#include <unistd.h>
unsigned int sleep(unsigned int secs);

若请求时间到了,sleep返回0,否则返回还剩下的要休眠的秒数。sleep函数有可能会被信号中断过早地返回。

pause函数让调用函数休眠,直到该进程收到一个信号

1
2
#include<unistd.h>
int pause(void);

练习题8.5

编写一个sleep地包装函数,叫做snooze,带有下面地接口:

unsigned int snooze(unsigned int secs);

snooze函数和sleep函数地行为完全一样,除了它会打印出一条消息来描述进程实际休眠了多长时间:

Slept for 4 of secs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include<stdio.h>
#include<unistd.h>

unsigned int snooze(unsigned int secs)
{
unsigned int rc = sleep(secs);
printf("Slept for %d of %d secs\n",secs - rc, secs);
return rc;
}

int main()
{
unsigned int rc;
rc = snooze(5);
return 0;
}

8.4.5 加载并运行程序

execve函数在当前进程地上下文中加载并运行一个新程序

1
2
3
#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]);
//成功则不返回,错误则返回-1

execve调用一次并不返回

main函数开始执行时,用户栈地组织结构为下图

int main(int argc, char **argv, char **envp);

int main(int argc,char *argv[], char *envp[]);

main函数有3个参数

  1. argc,指的是argv[]数组中非空指针地数量
  2. argv,指向argv[]数组中的第一个条目
  3. envp,指向envp[]数组中的第一条目

Linux用于操作环境数组的函数

1
2
3
#include<stdlib.h>
char *getenv(const char *name);
//返回:若存在则为指向name的指针,若无匹配的,则为NULL

getenv函数在环境数组中搜索字符串name = value。如果找到返回指向value的指针,否则返回NULL

1
2
3
4
5
6
7
#include<stdlib.h>
int setenv(const char *name,const char *newvalue, int overwrite);
//返回:若成功则为0,若错误则为-1


void unsetenv(const char *name);
//返回:无

如果环境数组包含一个形如name = oldvalue的字符串,那么unsetenv会删除指定键值对,而使用setenv会使用newvalue代替oldvalue,但是只有在overwrite为非零时才会覆盖。

如果name不存在,那么setenv酒吧name = new value添加到数组中。

程序与进程的区别

  • 程序是一堆代码和数据,作为目标文件存在于磁盘上。进程则是执行程序的实例。
  • 程序是运行在进程的上下文中的

练习题8.6

编写一个叫做myecho的程序,打印出它的命令行参数和环境变量

1
2
3
4
5
6
7
8
9
10
11
12
13
#include<stdio.h>

int main(int argc, char *argv[],char *envp[])
{
int i;
puts("Command-ine arguments:");
for (i = 0; i < argc; i++)
printf("argv[ %d]: %s\n",i,argv[i]);
puts("Enviroment variables:");
for(i = 0; i < sizeof(envp); i++)
printf("envp[ %d]: %s\n",i,envp[i]);
return 0;
}

image-20220611170151025

课后答案

1
2
3
4
5
6
7
8
9
10
11
12
13
#include<stdio.h>

int main(int argc,char *argv[],char *envp[])
{
int i;
puts("Command-ine arguments:");
for(i = 0; argv[i] != NULL; i++)
printf("argv[%2d]: %s\n",i,argv[i]);
puts("Environment variables:");
for(i = 0; envp[i] != NULL; i++)
printf("envp[%2d]: %s\n",i,envpc[i]);
return 0;
}

8.4.6 利用fork和execve运行程序

shellex.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
//gcc -o shellex shellex.c csapp.c -lpthread
#include "csapp.h"
#define MAXARGS 128

void eval(char *cmdline);
int parseline(char *buf, char **argv);
int builtin_command(char **argv);

int main()
{
char cmdline[MAXLINE];

while (1) {
printf("> ");
Fgets(cmdline,MAXLINE,stdin);//输入命令
if(feof(stdin))
exit(0);
eval(cmdline);//解析命令
}
return 0;
}

void eval(char *cmdline)
{
char *argv[MAXARGS];
char buf[MAXLINE];
int bg;
pid_t pid;

strcpy(buf,cmdline);//将命令拷贝到buf数组中
bg = parseline(buf,argv);//解析命令,将空格转换为截断符,将转换后的命令放在argv中
if( argv[0] == NULL)
return;

if(!builtin_command(argv)){
if((pid = Fork()) == 0){
if(execve(argv[0],argv,environ) < 0){
printf("%s: Command not found.\n", argv[0]);
exit(0);
}
}
}

if(!bg){
int status;
if (waitpid(pid,&status,0) < 0)
unix_error("waitfd: waitpid error");
}
else
printf("%d %s",pid,cmdline);
return;
}

int builtin_command(char **argv)
{
if(!strcmp(argv[0],"quit"))
exit(0);
if(!strcmp(argv[0],"&"))
return 1;
return 0;
}

int parseline(char *buf, char **argv)
{
char *delim;
int argc;
int bg;

buf[strlen(buf)-1] = ' '; //末尾改为空格
while(*buf && (*buf == ' ')) //跳过空格
buf++;
argc = 0;
//定位空格位置
while((delim = strchr(buf, ' '))){
argv[argc++] = buf;
*delim = '\0';
buf = delim + 1; //跳过空格
while(*buf && (*buf == ' ')) //避免参数之间含有多个空格,空格全跳过
buf++;
}
argv[argc] = NULL;
if (argc == 0)
return 1;
if((bg = (*argv[argc-1] == '&')) != 0)//判断最后一个字符是否为&,若是则后台运行进程
argv[--argc] = NULL;
return bg;
}

8.5 信号

Linux信号允许进程和内核中断其他进程。

底层硬件异常由内核异常处理程序处理,用户程进程不可见。因此信号提供一种机制,可以通知用户进程发生了异常。

image-20220611191313999

按下Ctrl+C,内核将会发送SIGINT信号给前台进程组的每个进程。

一个进程可以通过向另一个进程发送SIGKILL信号强制终止它。

当一个子进程终止或停止时,内核会发送一个SIGCHLD信号给父进程

8.5.1 信号术语

传送信号到目的进程的步骤:

  • 发送信号。内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程。
  • 接收信号。当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序的用户层函数捕获这个信号

没有被接收的信号称之为待处理信号,一种信号至多只有一个待处理信号,若此时接收了相同类型的信号则直接抛弃,不会进行排队等号。

进程可以有选择性的阻塞接收某种信号。当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。

8.5.2 发送信号

Unix系统提供了向进程发送信号的机制,这些机制都是基于进程组概念。

1.进程组

每个进程都只属于一个进程组,进程组由一个正整数进程组ID标识。

getpgrp函数返回当前进程的进程组ID

1
2
3
#include <unistd.h>
pid_t getpgrp(void);
//返回:调用进程的进程组ID

默认条件下,子进程与它的父进程同属于一个进程组。

进程可以通过setpgid函数改变自己或其他进程的进程组

1
2
3
#include<unistd.h>
int setpgid(pid_t pid, pid_t pgid);
//成功则返回0,错误则为-1
  • pid为0,则使用当前进程的PID
  • pgid为0,则使用pid指定的进程的PID作为进程组ID

2. 用/bin/kill程序发送信号

/bin/kill程序可以向另外的进程发送任意的信号

1
linux> /bin/kill -9 15213

发送一个SIGKILL信号给进程15213

负的PID则会将信号被发送到进程组PID中的每个进程

1
linux> /bin/kill -9 -15213

发送一个SIGKILL信号给进程组15213中的每个进程

3.从键盘发送信号

Unix shell使用作业表示为对一条命令行求值而创建的进程。

至多只有一个前台作业或0个或多个后台作业。

1
linux> ls | sort

上述命令会创建一个由两个进程组成的前台作业,这两个进程是通过Unix管道连接起来

shell为每个作业创建一个独立的进程组,进程组ID通常取父进程的PID

image-20220611205347003

Ctrl + C往前台进程组发送SIGINT信号,终止前台作业

Ctrl + z往前台进程发送SIGTSTP信号,挂起前台作业

4.用kill函数发送信号

进程通过调用kill函数发送洗脑给其他进程

1
2
3
4
#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);
  • pid>0,这kill函数发送信号号码sig给进程pid

  • pid = 0,则kill函数发送信号sig给调用进程所在进程组中的每个进程,包括调用进程自己

  • pid < 0kill函数发送信号sig给进程组pid中的每个进程

kill.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "csapp.h"

int main()
{
pid_t pid;
if((pid = Fork()) == 0){
Pause();//休眠进程等待信号
printf("control should never reach here!\n");
exit(0);
}

Kill(pid,SIGKILL);//给子进程发送杀死进程的信号
exit(0);
}

5.用alarm函数发送信号

进程可以通过alarm函数向它自己发送SIGALRM信号

1
2
3
#include <unistd.h>

unsigned int alarm(unsigned int secs);

alarm被称为闹钟函数,可以在进程中设置一个定时器,当定时器指定的时间到时,它向进程发送SIGALRM信号,其动作是终止调用该alarm函数的进程

8.5.3 接收信号

当进程从内核态转化为用户态时,内核会检测进程是否存在未被阻塞且待处理信号的集合。若有则内核会选择某个信号(通常为最小值的信号),并且强制进程接收信号。

进程接收信号后会采取某种行为,每个信号都有一个预定义的默认行为

  • 进程终止
  • 进程终止并转储内存
  • 进程停止(挂起)直到被SIGCONT信号重启
  • 进程忽略该信号

信号的默认行为可以通过signal函数修改,除了SIGSTOPSIGKILL信号不能被修改

1
2
3
4
5
6
#include <signal.h>
typedef void (*sighandler_t)(int);

sighandler_t signal(int signum,sighandler_t handler);

//返回:若成功则为指向前次处理程序的指针,出错则为SIG_ERR

signal函数可以通过三种方法来修改和信号signum相关联的行为

  • handlerSIG_IGN,那么忽略类型为signum的信号
  • handlerSIG_DFL,那么类型为signum的信号行为恢复为默认行为(处理信号)
  • 否则,handler就是用户定义的函数的地址,这个函数被称为信号处理程序,只要进程接收到一个类型为signum的信号,就会调用这个程序。通过把处理程序的地址传递给signal函数从而改变默认行为,这叫设置信号处理程序。调用信号处理程序被称为捕获信号。执行信号处理程序被称为处理信号。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "csapp.h"

//捕获到SIGINT信号的处理函数
void sigint_handler(int sig)
{
printf("\nCaught SIGINT!\n");
exit(0);
}

int main()
{
if(signal(SIGINT,sigint_handler) == SIG_ERR)
unix_error("signal error");
pause(); //挂起进程直到收到信号

return 0;
}

8.5.4 阻塞和解除阻塞信号

Linux提供阻塞信号的隐式和显式的机制:

  • 隐式阻塞机制。内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。即每个信号都有对应的信号处理程序,在该信号处理程序执行时会默认阻塞该信号
  • 显示阻塞机制。应用程序可以使用sigprocmask函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号
1
2
3
4
5
6
7
8
9
10
11
#include<signal.h>

int sigprocmask(int how,const sigset_t *set, sigset_t *oldset);
int sigemtyset(sigset_t *set);
int sigfillset(sigset_t *set,int signum);
int sigdelset(sigset_t *set, int signum);
//成功则返回0,出错则为-1

int sigismember(const sigset_t *set,int signum);
//若signum是set的成员则为1,不是则为0,出错则为-1

sigprocmask函数改变当前阻塞的信号集合,具体行为依赖于how的值

  • SIG_BLOCK:把set中的信号添加到blocked中(blocked=blocked | set
  • SIG_UNBOCK:从blocked中删除set中的信号(blocked = blocked & ~set)
  • SIG_SETMASKblock = set

如果oldset非空,那么blocked位向量之前的值保存在oldset

set信号集合进行操作的函数

  • sigemptyset初始化set为空集合
  • sigfillset函数把每个信号都添加到set
  • sigaddset函数把signum添加到set
  • sigdelsetset中删除signum

sigprocmask临时阻塞接收SIGINT信号

1
2
3
4
5
6
7
8
9
10
sigset_t mask,prev_mask;
Sigemptyset(&mask);
Sigaddset(&mask,SIGINT);

//将修改之前的信号存放在prev_mask中
Sigprocmask(SIG_BLOCK,&mask,&prev_mask);

//还原之前的设置
Sigprocmask(SIG_SETMASK,&prev_mask,NULL);

8.5.5 编写信号处理程序

1.安全的信号处理

信号处理程序由于是与主程序以及其他信号处理程序并发地允许,因此可以并发地访问同样地全局数据结构,那么可能会造成不可预知地问题。

编写处理程序的原则

  • 处理程序要尽可能简单
  • 在处理程序中只调用异步信号安全的函数。
    • 异步信号安全的函数是可重入的(只访问局部变量)
    • 异步信号安全的函数不能被信号处理程序中断

异步信号安全的函数

信号处理程序中产生输出唯一安全的方法是使用write函数

1
2
3
4
5
6
#include "csapp.h"

ssize_t sio_putl(long v);
ssize_t sio_puts(char s[]);

void sio_error(char s[]);

code/src/csapp.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ssize_t sio_puts(char s[])
{
//sio_strlen函数返回字符串s的长度
return write(STDOUT_FILENO,s,sio_strlen(s));
}

ssize_t sio_putl(long v)
{
char s[128];

//长整形转化为任意进制的字符串
sio_ltoa(v,s,10);
return sio_puts(s);
}

void sio_error(char s[])
{
sio_puts(s);
_exit(1);
}

SIGINT处理程序的安全版本

1
2
3
4
5
6
7
#include "csapp.h"

void sigint_handle(int sig)
{
Sio_puts("Caught SIGINT!\n");
_exit(0);
}
  • 保存和恢复errno。由于众多Linux异步信号安全的函数都会在出错返回时设置errno。由于处理程序可能会干扰其他主程序中其他依赖于errno。因此解决办法为是在进入处理程序时将errno保存在一个局部变量中,在处理程序返回前恢复
  • 阻塞所有的信号,保护对共享全局数据结构的访问。
  • volatile声明全局变量。若用处理程序和main函数共享一个全局变量g,由于main周期性读g。一个优化的编译器会使用缓存在寄存器中的g的副本来满足对g对每次引用。则导致main无法读取g更新过的值
    • volatile类型限定符来定义一个变量,告诉编译器不要缓存这个变量。强迫编译器每次在代码中引用g时,都要从内存中读取。
  • sig_atomic_t声明标志。处理程序通过写全局标志来记录收到的信号。主程序周期性地读这个标志,响应信号,再清除标志。对于通过这种方式来共享地标志,C提供一种整型数据类型sig_atomic_t,并且该类型的读写是保证是原子操作
1
volatile sig_atomic_t flag;

2. 正确的信号处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include "csapp.h"

void handler1(int sig)
{
int olderrno = errno;

if((waitpid(-1,NULL,0)) < 0)
sio_error("waitpid error");
Sio_puts("Handler reaped child\n");
Sleep(1);
errno = olderrno;
}

int main()
{
int i,n;
char buf[MAXBUF];

if(signal(SIGCHLD,handler1) == SIG_ERR)
unix_error("signal error");

for(i = 0; i < 3; i++){
if(Fork() == 0){
printf("Hello from child %d\n",(int)getpid());
exit(0);
}
}

if((n = read(STDIN_FILENO,buf,sizeof(buf))) < 0)
unix_error("read");

printf("Parent processing input\n");
while(1)
;
exit(0);
}

上述程序会导致僵尸进程。父进程接收并捕获了第一个信号,当处理程序还在处理第一个程序时,第二个信号就传送并添加到待处理信号集合里,然而,因为SIGCHLD信号被SIGCHLD处理程序阻塞了,所以第二个信号不会被接收,因此再第三个信号时,该信号会被抛弃,导致进程未被收回。

code/ecf/signal2.c

1
2
3
4
5
6
7
8
9
10
11
12
void handler2(int sig)
{
int olderrno = errno;
//将所有子进程都回收,则不会导致有僵尸进程
while(waitpid(-1,NULL,0)>0){
Sio_puts("Handler reaped child\n");
}
if(errno != ECHILD)
Sio_error("waitpid error");
Sleep(1);
errno = olderrno;
}

练习题8.8

下面这个程序的输出是什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
volatile long counter = 2;

//子进程调用信号处理函数
void handler1(int sig)
{
sigset_t mask,prev_mask;
//将所有信号放进mask里
Sigfillset(&mask);//将信号都放进mask中
//临时阻塞所有信号
Sigprocmask(SIG_BLOCK,&mask,&prev_mask);
//此时是子进程取出它自己的counter变量
Sio_putl(--counter);//打印1
//还原信号
Sigprocmask(SIG_SETMASK,&prev_mask,NULL);
_exit(0);
}

int main()
{
pid_t pid;
sigset_t mask,prev_mask;
printf("%ld",counter);//用户打印2
fflush(stdout);

signal(SIGUSR1,handler1); //用户定义的信号1
if((pid = Fork()) == 0){
while(1){}; //死循环
}
Kill(pid,SIGUSR1);//父进程给子进程发送用户定义的信号1
Waitpid(-1,NULL,0);

//将所有信号放进mask里
Sigfillset(&mask);
Sigprocmask(SIG_BLOCK,&mask,&prev_mask);
//此时是父进程取出它自己的counter变量
printf("%ld",++counter);//打印3
Sigprocmask(SIG_SETMASK,&prev_mask,NULL);
exit(0);
}

输出213

3.可移植的信号处理

Unix信号处理的另一个缺陷在于不同的系统有不同的信号处理语义

  • signal函数的语义各有不同。在有些老的Unix系统在信号k被处理程序捕获之后就把对信号k的反应恢复到默认值。因此在这些系统上,每次运行之后,处理程序必须调用signal函数,显示地重新设置。
  • 系统调用可以被中断。像readwriteaccept这样的系统调用潜在地会阻塞进程一段时间,称之为慢速系统调用。在老的Unix系统中,当处理程序捕获到一个信号时,被中断地慢速系统调用在信号处理程序返回时不再继续,而是立即返回给用户一个错误条件,并将errno设置为EINTR,因此在这些系统上程序员需要手动重启被中断的系统调用的代码。

sigaction函数允许用户在设置信号处理时,明确指定他们想要的信号处理语义。

1
2
3
4
#include<signal.h>

int sigaction(int signum,struct sigaction *atc, struct sigaction *oldact);
//成功则返回0,出错则为-1

定义一个包装函数,称之为Signal调用sigaction

1
2
3
4
5
6
7
8
9
10
11
handler_t *Signal(int signum,handler_t *handler)
{
struct sigaction action, old_action;
action.sa_handler = handler;
sigemptyset(&action.sa_mask);
action.sa_flags = SA_RESTART;

if(sigaction(signum,&action,&old_action) < 0)
unix_error("Signal error");
return(old_action.sa_handler);
}

8.5.6 同步流以避免讨厌的并发错误

code/ecf/procmask1.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void handler(int sig)
{
int olderrno = errno;
sigset_t mask_all, prev_all;
pid_t pid;

Sigfillset(&mask_all);
while((pid = waitpid(-1,NULL,0))>0){
Sigprocmask(SIG_BLOCK,&mask_all,&prev_all);
deletejob(pid);
Sigprocmask(SIG_SETMASK,&prev_all,NULL);
}
if(errno != ECHILD)
Sio_error("waitpid error");
errno = olderrno;
}

int main(int argc,char **argv)
{
int pid;
sigset_t mask_all,prev_all;

Sigfillset(&mask_all);
Signal(SIGCHLD,handler);
initjobs();

while(1){
if((pid = Fork()) == 0){
Execve("/bin/date",argv,NULL);
}
Sigprocmask(SIG_BLOCK,&mask_all,&prev_all);
addjob(pid);
Sigprocmask(SIG_SETMASK,&prev_all,NULL);
exit(0);
}

上述代码可能会导致条件竞争

  • 父进程执行fork函数,内核调度新创建的子进程运行
  • 子进程终止,传递一个SIGCHLD信号给父进程
  • 父进程执行前发现有未处理的信号
  • 信号处理程序回收终止子进程,并且调用deletejob,但是父进程还没有把子进程放进作业中
  • 信号处理程序完毕后,内核继续运行父进程,父进程从fork返回后,将回收的子进程又添加到作业中

code/efc/procmask2.c

在父进程fork之前阻塞SIGCHLD,在addjob之后才解除,则避免了在addjob之前deletejob

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void handler(int sig)
{
int olderrno = errno;
sigset_t mask_all,prev_all;
pid_t pid;

Sigfillset(&mask_all);
while((pid = waitpid(-1,NULL,0))>0)
{
Sigprocmask(SIG_BLOCK,&mask_all,&prev_all);
deletejob(pid);
Sigprocmask(SIG_SETMASK,&prev_all,NULL);
}
if(errno != ECHILD)
Sio_error("waitpid error");
errno = olderrno;
}

int main(int argc,char **argv)
{
int pid;
sigset_t mask_all,mask_one,prev_one;

Sigfillset(&mask_all);
Sigemptyset(&mask_one);
Sigaddset(&mask_one,SIGCHLD);
Signal(SIGCHLD,handler);
initjobs();
while(1){
Sigprocmask(SIG_BLOCK,&mask_one,&prev_one);
if((pid = Fork()) == 0){
Sigprocmask(SIG_SETMASK,&prev_one,NULL);
Execve("/bin/data",argv,NULL);
}
Sigprocmask(SIG_BLOCK,&mask_all,NULL);
addjob(pid);
Sigprocmask(SIG_SETMASK,&prev_one,NULL);
}
}

8.5.7 显示地等待信号

有时候主程序需要显示地等待某个信号处理程序运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include "csapp.h"

volatile sig_atomic_t pid;

void sigchld_handler(int s)
{
int olderrno = errno;
pid = waitpid(-1,NULL,0);
errno = olderrno;
}
void sigint_handler(int s)
{

}
int main(int argc,char **argv)
{
sigset_t mask,prev;

Signal(SIGCHLD,sigchld_handler);
Signal(SIGINT,sigint_handler);
Sigemptyset(&mask);
Sigaddset(&mast,SIGCHLD);

while(1){
//忽略SIGCHLD信号,防止条件竞争
Sigprocmask(SIG_BLOCK,&mask,&prev);
if(Fork() == 0)
exit(0);
pid = 0;
Sigprocmask(SIG_SETMASK,&prev,NULL);
while(!pid)//等到SIGCHLD信号处理程序执行后主程序才接着执行
;
printf(".");
}
exit(0);
}

循环会浪费处理器资源,因此可以修改为其他代码使用

1
2
while(!pid)
pause()

但是若信号在while语句后,pause()语句前到达,那么程序将永久休眠

使用sleep替换pause

1
2
while(!pid)
sleep(1);

但是使用sleep执行会浪费大量时间,若改为高精度的休眠函数则休眠时间太短则会造成while语句执行次数过多,若休眠时间太长则又会导致程序运行时间太久。

使用函数sigsuspend

1
2
3
4
5
6
7
#include <signal.h>
int sigsuspend(const sigset_t *mask);

//等价于
sigprocmask(SIG_SETMASK,&mask,&prev);
pause();
sigprocmask(SIG_SETMASK,&prev,NULL);

code/ecf/sigsuspend.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include "csapp.h"

volatile sig_atomic_t pid;

void sigchld_handler(int s)
{
int olderrno = errno;
pid = waitpid(-1,NULL,0);
errno = olderrno;
}
void sigint_handler(int s)
{

}
int main(int argc,char **argv)
{
sigset_t mask,prev;

Signal(SIGCHLD,sigchld_handler);
Signal(SIGINT,sigint_handler);
Sigemptyset(&mask);
Sigaddset(&mast,SIGCHLD);

while(1){
//忽略SIGCHLD信号,防止条件竞争
Sigprocmask(SIG_BLOCK,&mask,&prev);
if(Fork() == 0)
exit(0);
pid = 0;
Sigprocmask(SIG_SETMASK,&prev,NULL);
while(!pid)
sigsuspend(&prev);
Sigprocmast(SIG_SETMASK,&prev,NULL);
printf(".");
}
exit(0);
}

8.6 非本地跳转

非本地跳转是通过setjmplongjmp函数来提供的

1
2
3
4
5
#include <setjmp.h>
int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs);

//返回:setjmp返回0,longjmp返回非零。

setjmp函数在env缓冲区中保存当前调用环境,供后面的longjmp使用,并返回0。

调用环境包括程序计数器、栈指针和通用目的寄存器。

1
2
3
4
5
#include<setjmp.h>

void longjmp(jmp_buf env, int retval);
void siglongjmp(sigjmp_buf env,int retval);
//从不返回

longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化envsetjmp调用的返回。然后setjmp返回,并带有非零的返回值retval

code/efc/setjmp.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include "csapp.h"

jmp_buf buf; //调用环境
int error1 = 0;
int error2 = 1;

void foo(void),bar(void);
int main()
{
switch(setjmp(buf)){
case 0:
foo();
break;
case 1:
printf("Detected an error1 condition in foo\n");
break;
case 2:
printf("Detected an error2 condition in foo\n");
break;
default:
printf("Unkonw error condition in foo\n");
}
exit(0);
}

void foo(void)
{
//若遇到错误则直接从setjmp返回
if(error1)
longjmp(buf,1);
bar();
}
void bar(void)
{
//若遇到错误则直接从setjmp返回
if(error2)
longjmp(buf,2);
}

longjmp允许它跳过所有中间调用的特性可能会产生意外的后果,例如函数内部分配了资源,应该在函数结束时释放资源,但是由于longjmp跳过了释放的过程导致了内存泄露。

code/ecf/restart.c

非本地跳转的另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "csapp.h"

sigjmp_buf buf;

void handler(int sig)
{
siglongjmp(buf,1);
}

int main()
{
//设置跳转
if(!sigsetjmp(buf,1)){
//设置信号处理程序
Signal(SIGINT,handler);
}
else
Sio_puts("restarting\n");

while(1){
Sleep(1);
Sio_puts("processing...\n");
}
exit(0);
}

C++和Java中的软件异常

C++Java提供的异常机制是较高层次的,是C语言的setjmplongjmp函数的更加结构化的版本。

try语句中的catch子句类似于setjmp函数,throw语句类似于longjmp函数

8.7 操作进程的工具

  • STRACE:打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。
  • PS:列出当前系统中的进程
  • TOP:打印出关于当前进程资源使用的信息
  • PMAP:显示进程的内存映射
  • /proc:一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容
    • 输入cat/proc/loadavg可以看到linux系统上的平均负载

深入理解计算机系统学习笔记
https://h0pe-ay.github.io/深入理解计算机系统/
作者
hope
发布于
2023年6月27日
许可协议