文章目录
- 传送门
- 链接
- 基础
- 链接器的意义
- 编译器驱动程序
- 静态链接
- ELF目标文件格式
- 可重定位目标文件
- 符号和符号表
- 链接过程
- 符号解析
- 解析规则
- 静态链接库
- 带有静态链接库的解析过程
- 重定位
- 重定位条目
- 重定位节
- 重定位符号引用
- 重定位相对引用
- 重定位绝对引用
- 加载可执行目标文件
- 动态链接共享库
- 库打桩技术
- 概述
- 打桩举例
- 编译时打桩
- 链接时打桩
- 加载/运行时打桩
- 位置无关代码(PIC,Position-Independent Code)
- 异常控制流
- 概述
- 异常
- 概述
- 异步异常
- 同步异常
- 进程
- 概述
- 并发与上下文切换
- 进程控制
- 系统调用的错误处理
- 进程操作函数
- 进程图
- 进程监管
- 进程同步
- 孤儿子进程捕获
- shell
- 信号
- 挂起与阻塞信号
- 发送信号
- 通过/bin/kill发送信号
- 通过键盘发送信号
- 通过kill函数发信号
- 接受信号
- 总结
- 非局部跳转
- 虚拟内存
- 概念
- 地址空间
- 虚拟内存的缓存机制
- 虚拟内存管理机制
- 虚拟内存的保护机制
- 地址翻译
- 体系
- 分配
- 高级
传送门
此系列文章分为三篇,本文对应CSAPP的第二卷:在系统上运行程序,目标是让读者理解程序与OS的交互关系。
第一卷:程序结构与执行——信息表示、指令、处理器、性能优化、储存层次
第二卷:在系统上运行程序——链接、异常控制流、虚拟内存
第三卷:程序间的交流与通信
链接
基础
链接器的意义
多个文件分别编译,形成若干.o文件,最后链接,形成一个可执行文件。链接的功能就是将多个部分合并为一个可执行程序。
为什么要用链接器?说白了就是把程序拆了。
- 模块化:不用就没办法把程序拆开,就要写到一个文件里。
- 编译效率:只需要修改一部分程序,只需要将该部分程序重新编译即可,不需要编译所有文件。
- 开发效率:有利于代码复用
链接分为两种:
- 静态链接
- 编译时链接。最基本的链接。
- 动态链接
- 运行时链接。对储存最友好,但最复杂。
- 加载时链接。将静态链接部分延迟
本章基于·Linux x86-64系统,使用标准的ELF-64文件格式(简称ELF)。无论是什么系统,什么格式,基本的链接概念和方法,文件结构都是共通的。
编译器驱动程序
首先记住这两个程序,以后要一直用:
- 对main来说,sum函数是外部引用。对sum.c来说,sum函数是定义
- 对main来说,array是全局变量。
当我们写了这两个程序,在电脑里直接按个F5或者F11就可以编译运行,实际上其中过程很复杂,我们只是简单的叫他编译器,其实上却是一套流程,总称为编译器驱动程序。
分为4个流程:
- cpp。预处理器:宏替换
- ccl。编译器:将C文件变成汇编语言asm文件
- as。汇编器:将ASCII格式的asm文件汇编成二进制的.o文件。
- ld。链接器:将若干.o文件链接成一个可执行目标文件。
静态链接
静态链接,指的是在链接过程把第三方库的.o文件也一起连到可执行目标文件中。动态链接则是在运行的时候才链接。为了辅助链接工作,.o文件是被分成一节一节的,有的放数据,有的放代码,节有很多,携带了各种辅助信息。
具体来说,链接器的作用与工作流程:
- 符号解析。
- 符号就是变量名或者函数名,即函数,public变量,static变量,extern变量。
- 符号定义就是将符号引用和符号定义联系起来,比如这个文件里的一个extern引用了另一个文件的public变量,链接器建立他们之间的联系。
- 符号解析要用到符号表,在.o文件的一节里。
- 重定位。这一步真正进行合并。
- 在此之前,每个.o文件中的符号,都是一个文件内的相对位置,每一个文件内部都是从0地址开始的。现在要合并成一个,变成可执行文件在内存中的绝对位置。
- 这一步的关键在于要重新修改引用的地址。需要修改的地址都被记录在了重定位条目中。
ELF目标文件格式
有三种目标文件,这三种格式归属于可执行和可连接格式(ELF),统称为ELF二进制。结构类似,略有不同。
- 可重定位目标文件。汇编器汇编出来的.o文件,每个.o对应一个.c。其可以用于生成可执行文件,但是本身不可执行。
- 可执行目标文件。若干个.o文件经过链接后生成的文件,可以直接加载到内存中执行,也就是说已经完成了重定位。Unix为为a.out文件,现在的Linux中也有这种传统。
- 共享目标文件。用于动态链接,能在加载时或者运行时装入内存。在Windows上通常是.dll文件,Linux为.so文件
ELF文件由一节一节组成,这种分段的结构比较清晰。三类文件都是ELF文件格式,只不过进行了内容的调整,以及去掉一些特殊的节。下面通过可重定位目标文件展示一个总体结构:
可重定位目标文件
前面的与链接没有太大关系,是程序本身的信息:
- ELF头。储存硬件的配置信息
- 段头表。与虚拟地址有关
- .text节。代码段
- .rodata节。只读数据,比如跳转表
- .data节。全局数据(初始化过)
- .bss节。全局数据(未初始化),不占用磁盘空间,运行的时候再分配。
后面的几节提供了辅助链接的信息:
- .symtab节。符号表,存放全局符号信息,不存放局部变量符号。
- .rel.text。一个列表,每一条都指向.text节里一条指令,每一个调用外部函数或者引用外部全局变量的指令都需要修改目标地址。链接器链接的时候,会修改.rel.text指向的位置。
- .rel.data。一个列表,每一条指向一个全局变量的定义或者引用。同样是链接的时候修改。
最后是一些其他节:
- debug节等特殊节。略过
- 节头表。储存了每个节的起始偏移和大小
符号和符号表
符号表里有当前可重定位目标模块m所定义或者引用的符号,有三种:
- m定义的全局符号。包括全局变量和非静态函数
- m引用的外部符号。这是其他文件中的全局符号。
- m内部的静态全局变量。
- 函数里定义的静态局部变量。如果有两个函数中定义了同名的static局部变量,符号表里会稍作变化以作区分,比如x.1,x.2
注意,符号表里没有非静态局部变量:
- 局部变量,局部变量运行的时候在栈上开。
符号表是汇编器构造的,链接器只是用这个现成的表罢了。符号表是一个符号数组,每个符号都是一个结构体,描述了一个对象的信息,但是注意,symtab中只储存对象的元数据,而对象本身,甚至是对象的名字都存在其他的节中,比如静态局部变量在data和bss中,而不是在symtab中:
- name:实际上是一个32位的char*指针,指向符号名。符号名存在字符串表中。
- type:函数还是数据
- binding:全局还是本地
- reserved:不用。
- section:在哪个节中,是数字索引,比如1,2,3。还有一些特殊的标志
- ABS:绝对符号,进制重定位
- UNDEF:未定义,是对符号的引用,要重定位
- COMMON:未分配,与.bss的不同在于,COMMON只针对全局变量,而.bss是未初始化的静态变量和初始化为0的特殊变量。
- value:指针,指向符号的值
- size:目标的大小
查看main.o的符号表,最下面的三个符号是我们要看的。可以看到,array和main都有节,但是sum函数在外部,所以用UNDEF标识。
链接过程
符号解析
解析规则
链接器的输入是一组可重定位目标模块,每个模块的符号表里都定义了一组符号,分为不同的类型。如果不重名也就罢了,关键是,重名了以后怎么办?
Linux中将符号分为强弱类型:
- 强:函数和已初始化的全局变量。
- 弱:未初始化的全局变量,或者是带了extern的。
根据强弱,有三种规则:
- 两个同名强符号,报错
- 一强多弱,选强的
- 多个弱的,随便选一个
这种规则是很合理的,但是当检测到同名变量的时候,只要不是两强就不会报错,甚至不会提醒,所以用的时候会给新手带来困惑,下图中,foo3中是强符号,bar3中是弱符号,所以bar3.c引用了foo3.c的x。关键在于,bar3把这个符号的值修改了,但是用户不知道。所以在main里,把x初始化为15213,结果f函数把x篡改成15212了,而这一切不会提示。
更狗的是,如果有多个弱符号,你完全不知道会选择哪个,这就会造成无法把控的问题。有经验的程序员会保证自己掌控一个强符号。
强弱符号也可以解释符号结构中section字段中,COMMON和.bss有所不同的原因。如果全局变量没初始化,就不能确定是强符号,就有可能是引用,所以编译器把决定权交给链接器。而初始化为0后,就确定是强符号了,编译器直接把变量放到.bss中,符号表的section字段对应.bss节。
所以,程序员使用全局变量的时候非常小心,甚至干脆就不用全局变量:
- 尽可能用static
- 初始化使得全局符号变成强符号。
- 使用extern显式声明外部引用
静态链接库
#include<stdio.h>
,我们经常这么干,但是却不知道底层是怎么运行的,这里就解释一下。
一个头文件对应一个静态库,之所以要有库,就是为了代码复用,我只管用,不需要去实现细节。问题来了,.a静态库和.o文件有什么不同,.a静态库又和.h头文件有什么关系?为了说明这个问题,我们要从最久远的时代说起。
最早的时候啥也没,只有.o文件。程序员把所有标准库函数都放到一个.o文件中,比如libc.o。然后直接链接到我自己编写的代码中就可以。但是这样有个缺点,不管我用没用到某个函数,我都会把一整个.o文件链接进去,很不划算。而且,一旦修改一个库函数,整个库文件就都得重新编译。
既然不想把所有的函数都连接进去,我可以把每个函数都编译成一个.o文件,用的时候,用多少就链接几个函数:linux> gee main. c /usr /li b/printf. o /usr /lib/ scanf. o … .。但是这样虽然体积小,也便于修改,但是用起来更麻烦了,写程序用的函数多了去了,哪能一个又一个地去写命令行呢?
所以就产生了一种折中的方法,把若干个相关的函数.o文件,封装到一个.a文件里,成为一个模块。这个模块和.o文件很不一样,虽然两者都会把若干函数封装到一起,但是在链接的时候,如果指定.o文件,会把.o整个链接进去,而指定.a文件后,只会把用到的函数的.o文件链接进去。
可以说.a就是用于抽取部分函数的.o文件的。下图中,可以看到若干.o文件被从.a里抽取出来,进行链接。这样完美兼顾了库开发的效率与使用效率。
带有静态链接库的解析过程
下面这两种静态链接写法是等价的,静态链接要输入若干目标文件,链接器会从左到右扫描命令行参数。注意,从左到右就涉及到顺序,这可能会引发错误,后面会说。
linux> gcc -static -o prog2c main2.o ./libveetor.a
linux> gcc -static -o prog2c main2.o -L. -lveetor
来看一下链接器是如何扫描并解析引用的:
- 链接器维护三个集合,一个文件集合,两个符号集合,初始都是空的。
- E:.o目标文件集合。
- U:未解析的符号,即引用了但还没有找到定义的符号。
- D:已解析的符号,即在前面的扫描中已经找到定义的符号。
- 从左到右扫描,要判断参数的文件f是什么类型
- f是.o文件。放到E中,同时把E符号表中的符号根据类型分别放到U和D中
- f是存档文件。遍历存档,如果存档中有一个.o定义了被引用的符号,则把这个.o丢到E中,同时把U中的对应符号移到D中。之后继续扫描,直到把存档都遍历完毕。
- 当扫描完毕后,如果U中还有符号,说明存在未解析的引用(这是个很常见的报错),否则就是可以链接了。
可以看到,我们是顺序扫描的,且一个存档只会扫描一次。假如main里引用了.a文件的一个函数,但是.a文件先于main被扫描,则a不会消除main里的U引用,最后就会在U里剩下一个未解析的引用。反之,如果把.a文件放到main后面就没问题了。总之,引用者一定要排在被引用者之前,如果存在互相引用的情况,可以重复写,比如libx.a liby.a libx.a,或者干脆弄一个.a文件里也ok。
重定位
重定位是真正的合并,当链接器完成符号解析,就证明所有符号的引用都有其对应的定义,即重定位是可以进行完毕不会出错的。重定位只管无脑去做就行了:
- 重定位节和符号定义。链接器将所有同类节都合到一起
- 重定位节里的符号引用。在步骤1中,符号原有的地址会发生改变,此时就要修改对那些地址的引用。哪些地址呢?有专门的重定位条目去描述这些需要修改的部分。
重定位条目
编译过程生成.o文件的时候,因为后面会发生合并,地址会变,所以他是不知道代码和数据最后要被放在哪的,但凡是编译过程中不确定引用目标的时候,就会生成一个重定位条目,告诉链接器在链接的时候去修改条目对应的位置。text节的重定位条目是.rel.text,data节是.rel.data,其他部分不需要进行重定位。
下图为一个重定位条目的格式,一个条目类似于符号,描述了需要重定位的目标,记录了其元数据,并不是目标本身:
- offset:要修改的部分的首地址(比如有一条指令调用sum函数,指令的节内地址就是offset)
- type:如何重定位,类型很多,主要两种
- R_X86_64_PC32:PC相对寻址
- R_X86_64_32:PC绝对寻址
- symbol:标识了被修改引用应该指向的符号(比如一条指令调用sum函数,symbol就对应sum符号条目)
- addend:与一些特殊的偏移调整有关
重定位节
这一部分比较直观,就是单纯的拼起来了,重点在后面,略过。
重定位符号引用
- 外循环是对每个节,每个条目遍历
- 内循环
- 首先是用refptr储存要修改的目标的地址
- 判断重定位类型,根据类型去修改refptr对应的目标,比如把指令call的地址修改了。
来举个例子:
可以看到,main有两个重定位条目,全局的array需要重定位,是绝对值。call sum需要重定位,是PC相对寻址。
重定位相对引用
给出call sum的重定位条目信息。
给定节位置和要引用符号的位置:
计算出要修改目标的新值后修改目标:
这里注意PC相对寻址:
- PC指针指向下一条指令的位置,即4004de+5(指令长度)=0x4004e3
- 将PC压入栈中
- PC+=0x5(call的相对目标),此时PC=0x4004e8
重定位绝对引用
绝对引用就很简单了,计算量很小,流程一样。比如前面那个汇编代码中,有一条mov指令将array地址放到寄存器%edi中。我们就是要修改mov指令后的值。先给出重定位条目:
可以看到是绝对引用:
赋值后,指令要mov的立即数被我们修改成0x601018,注意小端法表示,以字节为单位反序存放。
加载可执行目标文件
可执行目标文件的结构也是ELF结构,只是略有不同,需要注意程序头部表(program header table),这个表记录了可执行文件的连续的片如何被映射到内存段中,可以看到,左图中同一颜色的连续区域与虚拟内存同一颜色段是一一对应的,顺序看起来是反的,其实是一致的。
动态链接共享库
前面介绍了静态链接。在实际使用中,还有一种动态链接方式,程序在链接的时候,声明动态链接,则只进行部分链接。通过加载时或运行时链接库文件来进一步减少程序体积,这样的话,内存中就可以放更多的程序。
动态链接:
- 加载时动态链接。
- so要在刚加载到内存时进行完全的链接。
- 这种方式只是延迟了链接的时间。
- 运行时动态链接。
- 动态链接和静态链接最大的不同在于,动态链接并没有真正的吧.so代码链接到程序中,而是让程序直接调用外部的接口。相当于直接消灭了一部分的链接。
- 如果外部接口不在内存中,就把动态库加载进去,如果在内存中,就直接调用。
- 平时更常用,可以在运行时打桩。
加载时动态链接只是让磁盘文件减少,内存没有减少,该链接的还是链接进去了。但是运行时动态链接可以让不同程序共享一段共享链接库,真正减少内存占用。
库打桩技术
概述
库打桩是链接技术的应用,让程序员截获任何的函数调用过程。比如main代码调用了sum,库打桩就可以截获出来这个调用行为。
库打桩可以发生在:
- 编译时。编译时插入宏替换代码替换套壳调用。
- 静态链接。更改符号消解方式,链接时将引用符号解析成套壳调用符号。
- 加载/运行时。在动态库文件中插入打桩代码,则程序在动态链接时会额外执行一些代码。
库打桩的应用:
- 安全
- 沙箱。可以做成陷阱去捕获病毒
- 幕后加密
- 调试
- 运行时调试。有的bug只会在运行时体现,很难发现。
- 监控程序状态
- 了解函数调用过程
- 跟踪内存分配
打桩举例
这是基准程序。
编译时打桩
- 套壳写自己的函数
- 编译的时候插入宏替换代码,实现函数调用套壳。
接下来具体解释
第一步:先把函数调用套壳,写一个自己的调用函数。函数会打印出运行状态。
第二步:编译的时候将源文件修改,进行宏替换。
链接时打桩
链接时打桩不会修改编译代码,但是会在链接阶段符号消解的时候,把真正的malloc调用替换成我们自己写的函数符号。
加载/运行时打桩
技术比较复杂。首先要修改动态库代码源文件,插入库打桩调用的函数。则程序在动态链接加载/调用动态库的时候,会自动执行打桩插入的代码。
位置无关代码(PIC,Position-Independent Code)
可以把共享模块的代码段加载到内存的任何位置,而无需链接器做任何修改
略。
异常控制流
异常控制流是程序加载以后,运行的时候,操作系统对程序的状态控制。
这一部分我讲的比较简略,宏观,因为我学过了操作系统。
概述
CPU内部很复杂,但是对外的表现上很简单。从通电开始,不断读入,执行指令,就像一条河流一样,不停运行。CPU本质上是串行的,顺序执行的,这就是控制流。
为了实现复杂功能,就要对CPU执行防线进行控制,这就是改变控制流。比如跳转,分支,调用,这一部分主要由OS实现,即使是硬件中断,也需要操作系统配合。
在运行的过程中,会有各种信息触发控制流的改变,比如一件事情干完了,发出一个结束的信息,又比如出现了某些错误,这些都会改变CPU的执行,处理这种异常信息的机制就叫异常控制流。这个机制存在于系统的各个层次:
- 底层次机制
- 异常。包括中断,陷阱等等,操作系统中涉及较多。
- 高层机制
- 进程切换。软硬件配合,是OS的主场
- 信号机制。有OS实现,比如信号量
- 非局部跳转。C语言库实现
异常
概述
- 用户态发生异常。
- 切核心态处理异常
- 处理完毕后继续执行用户态代码。
为了实现异常处理,需要一张异常表格。异常表里记录了对应处理程序的地址,本质上就是个跳转表。发生了异常后,会在异常表中进行匹配,然后跳到对应的程序去处理。
异步异常
异步异常是CPU外部的异常,此时CPU正在做事件A,结果CPU外面来了一个中断,将你的执行打断,处理完中断后你再继续处理事件A。
这是很常见的,因为中断本身就是一种信号机制,并不是真的异常。
同步异常
同步异常来源于CPU内部。此时CPU正在执行任务A,结果A的指令有问题(代码有bug),于是CPU就从内部产生一个异常。同步与异步的区别就在于,异常是来源于CPU内部的指令,还是CPU外部的中断。
CPU内部指令执行有三种异常:
- 陷阱Trap:这个也不算是真正的异常,是软中断机制。
- 比如汇编使用INT n;指令,又或者进行系统调用
- 在切换到核心态执行完处理后,自行恢复用户态,CPU继续执行原来的控制流。
- 故障Faults:意料之外的异常,但是还可以恢复
- 比如缺页中断,保护异常
- 不太严重,很多可以重新执行,也有的直接终止。比如碰到了非法内存,已经跑步下去了,就kill调进程。
- 终止Aborts:严重错误,直接终止
- 比如非法指令,校验错误
进程
概述
为了更好的控制程序,OS将程序以及一些其他的相关信息封装成了一个进程。进程是CPU调度的单位(现在有线程,更加精细)
进程的特点在于:
- 让每个程序看起来独占CPU,但是实际上是切来切去的。CPU和OS提供了进程切换的上下文切换机制,保存一些临时状态,比如寄存器组的值。
- 每个程序看起来独占主存空间,实际上是虚拟内存。
并发与上下文切换
对于一个CPU,多个进程宏观上是并发执行的,实际上是切来切去的。其中有上下文切换机制,关键在于保护现场。上下文切换是由操作系统管理的,操作系统本身也是一个进程,在Linux中,有0号内核线程和1号线程,负责系统的管理。
进程控制
系统调用的错误处理
系统调用本身是函数,比如fork函数,或者malloc函数。函数一般是有返回值的,所以系统调用最常用的错误处理机制是利用返回值。
- 如果返回值是一个正常的,就继续
- 否则,就退出或者执行处理。比如malloc返回一个-1。
系统不会自动处理错误,错了不处理就退出,要想人为处理,就在外面套if条件,更高级的做法是吧这个套了if的系统调用变成一个包装函数:
进程操作函数
进程操作本身是系统调用,有丰富的库函数。
- 获得进程。getpid,getppid
- 进程终止。
- 收到终止信号,比如命令行按下ctrl C
- return。终止只是return的副作用
- exit函数族。单纯终止,没有返回值
- 创建进程。
- fork。父进程返回子进程pid,子进程返回0。两个进程并发,复制一份独立的资源,复制fork后的所有代码,需要加exit阻断。
- vfork。父子进程资源共享,父进程阻塞等待子进程结束。
- 加载程序。exce函数族,有一大堆函数,用法不同,但是都是去执行一个可执行文件
- 和fork不同,是直接把当前程序替换为了目标的可执行程序,是完全的替换,包括代码段,数据段,堆栈,全换了。
- pid不变
- 没有返回值,仅仅是用于调用程序。
进程图
进程图用于描述并发程序语句中的偏序关系。有点拗口,实际上就是描述一大堆并发程序之间是先后,还是并列(就是离散数学中的偏序关系):
- 节点代表语句(函数调用)
- 带箭头的线代表程序执行方向,不可反向执行
- 边或者顶点上都可以加标注
一个进程图本身是偏序图,偏序图就可以从入口开始进行拓扑排序。通过进程图,可以判断一个执行流是合法的还是非法的:
例子非常简单,你就顺着执行控制流,如果发现其执行顺序是反着箭头来的,那就是非法的。
进程监管
进程同步
两个进程之间,经常是并发的,但是很多时候我们又需要他们有先后关系,此时就需要同步机制:
- wait。父进程等待子进程。
- waitpid。等待特定进程,选项很多。
孤儿子进程捕获
父进程创建子进程后,要管理子进程,否则会出现很大的问题。但是总是会有一些特殊情况,父进程无法管理子进程,比如直接把父进程kill掉了,子进程还在,此时就变成了孤儿/僵尸进程。这个时候,OS会有子进程捕获机制,防止各种进程造成的内存泄漏,资源泄露。
捕获孤儿进程的是内核线程1,又叫1号进程(init),1号进程可以捕获到长时间不动僵死的进程,成为其父进程后kill掉。
shell
首先明白,Linux的进程之间是树的关系,是上下级管理的关系。
shell本身是一种程序,不断读取命令行输入,解析,去进行对应的操作。
shell程序很多,什么bach,zsh之类的,具有不同的特点。
shell有一个问题,就是shell只负责接收前台信息并进行对应操作的调用,而不会跟踪这个操作执行的情况。为了让shell明白操作执行完了,需要在操作执行完后给shell发一个信息,这个信息就是信号机制。
信号
信号类似于中断,通过数字区分类型,但是是软件层面上的,由内核进行宏观管理的,是内核发送给进程的。
挂起与阻塞信号
内核为每个进程维护一个挂起向量和阻塞向量。这两个向量控制着信号的挂起和阻塞。
- 挂起,就是信号发送了但没被接受
- 挂起向量是对挂起信号的one-hot编码,所以同一时间只能有一个信号挂起
- 挂起的信号被接收后,one-hot编码清零
- 挂起信号不排队。挂起一个信号以后,再来新的信号会直接被抛弃
- 阻塞,就是进程屏蔽发过来的信号
- 阻塞向量是对信号的掩码,1就是屏蔽,0就是接收
- 可以用sigprocmask函数操作。函数名的mask也揭示了其掩码的本质。
发送信号
发送信号的原因:
- 发生了系统事件,比如内核检测到执行错误或者子进程执行完毕
- 另一个进程调用了kill信号
但是无论如何,一定是经手内核,由内核控制的。这也很好理解,不能随便来个进程就能把另一个kill掉吧,得服从OS管理。
还有就是,可以对一个进程发信号,也可以对一组进程发信号。需要注意的是,前台进程归属于前台进程组。
通过/bin/kill发送信号
在shell里敲命令就可以发送信号。我们平时常用的kill命令就是给进程发信号,目标可以是前台进程和后台进程。
通过键盘发送信号
键盘发送的信号仅限于当前shell前台进程组,不会影响后台程序。
- Ctrl C:SIGINT信号,睡眠
- Ctrl Z:SIGSTP信号,终止进程。
平时shell卡住了,就可以ctrl Z终止掉。
通过kill函数发信号
写代码,调用kill函数。至于目标进程是否接受,接受的策略,由OS以及目标代码决定。
接受信号
其实并不存在“接收”。OS是高于其他进程的最高级进程,所以其他进程没有拒绝的权利。进一步说,OS是直接修改进程的上下文的,进程会根据上下文执行操作,修改了上下文后控制流自然就变了。看起来好像是接受了信息。
信号接收前要进行判断,要找到挂起且非屏蔽的信号,去响应。否则就忽略。信号“响应”的方式有三种:
- 忽略
- 终止进程
- 调用自定义的信号处理函数
可以看到,很像是中断,但是是软件层次的,而且有一个共通的管理者:OS
如果要修改默认响应行为,需要写一个handler。当然,handler也可以忽略或者选择默认行为,但是我们一般是去指定自己写的处理函数了。
这个handler比较特别,他在逻辑上和进程是并行的,但是handler本身不是进程,只是依附于进程的信号监控者,如果信号来了,他就会处理。
总结
- 信号可以从程序中产生
- 信号由OS管理
- 可以自定义信号处理程序
- 编写信号的处理函数要非常小心,可以作为攻击系统的漏洞。
非局部跳转
将控制转移到任意位置的强大(但比较危险)用户级机制。略。
虚拟内存
因为我学过OS,所以对虚拟内存比较熟悉,假定你已经学过OS,如果没有,可以看我之前的OS文章:
内存管理基本模型
Linux实例分析——内存管理
概念
地址空间
注意区分几个名词:
- 线性地址空间。值地址值是线性增长的,从0开始
- 虚拟地址空间/逻辑地址空间。从0开始,有尽头的线性地址,对应逻辑上的地址
- 物理地址空间。从0开始,有尽头的线性地址,实际内存中的地址
以前,CPU使用物理地址,但是这样比较危险。比如我把0地址的内容改了,计算机就直接崩了。
使用虚拟地址后,需要将逻辑地址翻译成物理地址,有很多好处:
- 翻译过程中,可以加入各种保护,验证机制,做到安全,高效管理。
- 给程序一个隔离的地址空间,便于设计与调度
- 虚拟内存通过交换技术在逻辑上扩大内存空间