深入理解计算机系统
约 15998 字大约 53 分钟
2025-11-26
从一个hello.o开始

“./hello”之后,外科程序将字符逐一读入寄存器,在放到存储器里面。
当以回车结尾的时候,外壳程序知道已经已经结束了输入,通过一系列命令加载可执行的hello文件,将目标文件的代码和数据从磁盘复制到主存。利用DMA的技术,数据可以不通过CPU直接从磁盘到达主存。
目标文件hello的代码和数据被加载到主存,处理器就开始执行hello程序的Main程序中的机器语言指令。指令将字符串的字节从主存复制到寄存器文件,再从寄存器文件复制到显示设备。
L2高速缓存的时间要比访问L1缓存的时间长,L1和L2高速缓存是用一种叫做用静态随机访问存储器(SRAM)硬件技术实现。


float类型的位表示
| s | 规格化 | !=0 && !=255 | f |
|---|---|---|---|
| s | 非规格化 | 0 | f |
| s | 无穷大 | 255 | 0 |
| s | NaN | 255 | !=0 |
程序的机器级表示
Gcc 编译用汇编代码的形式输出,给出程序中的每一条指令,gcc再调用汇编器和链接器,从而依据汇编代码生成可以执行的机器代码。
对于机器级编码,有两种抽象。一种是机器级程序的格式和行为,定义为指令集体系结构(ISA),它定义了处理器状态、指令的格式以及每条指令对状态的影响。
第二种抽象是机器级程序使用的存储器地址是虚拟地址,提供的存储模型看上去是一个非常大的字节数组。
IA32中央处理单元(CPU)包含一组8个存储32位值的寄存器。

各种操作数主要分为三种:
- 立即数
在ATT格式的汇编中采用$-577可以直接表示
- 寄存器
表示某个寄存器的内容,对于双字操作就是8个32位寄存器中一个,对于单字操作就是8个16位寄存器中一个
%eax %ax
- 存储器引用
会根据计算出来的地址,访问某个存储器位置,采用符号Mb[Addr]访问某个存储器的位置

数据传送指令:
- MOV类

MOV类指令将源操作数的值复制到目的操作数中,源操作数指定的值是一个立即数,存储在寄存器中或者存储器中。
IA32加一条限制,传送指令的两个操作数不能都指向存储器位置。将一个存储器位置复制到另一个存储器位置需要两条指令。
算术和逻辑操作
加载有效地址leal
指令形式是从存储器数据到寄存器,实际上根本没有引用存储器。第一个操作数将有效地址写入到目的操作数。
leal 7(%edx, %edx,4)

特殊的算术操作

除了整数寄存器,CPU维护着一组单个位的条件码寄存器。它们描述了最近的算术或逻辑操作的属性。
CF:进位标志。最近的操作使最高位产生进位。
ZF:零标志。最近的操作得出的结果为0。
SF:符号标志。最近的操作得到的结果为负数。
OF:溢出标志。最近的操作导致一个补码溢出。

条件码有三种:1、可以根据条件码的某个组合,将一个字节设置为0或者1.
2、可以条件跳转到程序的某个其他的部分
3、可以有条件地传送数据。

跳转(jump)指令导致执行切换到程序中一个全新的位置。汇编代码中,这些跳转的目的地通常用一个标号(label)指令。

翻译条件分支
eg:

循环
利用条件测试和跳转组合实现循环。
loop:
body
t = test
if (t)
goto条件传送指令:
条件数据传送的代码比条件控制转移的代码性能好

大概是先求值,再比较一个test,但是不是所有都可以用条件传送进行编译因为有些情况会有副作用。
switch语句
根据一个整数索引值进行多重分支,处理具有多种可能结果的测试时,可以提高C代码的可读性,通过跳转表这个数据结构可以使得实现更加的高效。编译器再开关情况较多的时候就会使用跳转表。
过程
一个过程调用包括将数据和控制从代码的一部分转移到另一部分。必须再进入时为过程的局部变量分配空间,并在退出时候释放可能关键。
栈帧结构
IA32程序用程序栈来支持过程调用。机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后回复以及本地存储。为单个过程分配的那部分栈叫做栈帧。

过程P调用过程Q,Q的参数放在P的栈帧中,当P调用Q时,P的返回地址压栈,成为P栈帧的末尾。返回地址就是从Q返回时应该继续执行的地方。Q的栈帧从保存的帧指针的值开始
过程Q也用栈保存其他不能存放在寄存器中的局部变量。
- 没有足够多的寄存器存放所有的局部变量
- 有些局部变量是数组或结构,因此必须通过数组和结构引用引用访问。
- 要对一个局部变量使用地址操作符“&”,首先他必须是有一个地址。
栈向地址方向生长
转移控制

call效果将返回地址入栈,并跳转到被调用过程的起始处,返回地址程序中紧跟再call后面那条指令的地址。ret指令从栈里面弹出地址,并跳转到这个位置。
寄存器使用惯例
%eax,%edx,%ecx划分为调用者保存寄存器,当过程P调用Q的时候这几个寄存器可以被覆盖,不会对P有影响。另一方面寄存器%ebx,%esi和%edi划分为调用者保存寄存器, Q在必须覆盖这些寄存器之前必须先把内容压栈,并在返回前恢复。所以像%ebp,和%esp都是必须保持的。
数组的分配和访问
变长数组再iso99中引入,虽然声明了一个变长数组
int cal(int n, int A[n][n], int i, int j)
//像这个数组的维度必须在Aqian'mIA32惯例是保证每个栈帧的长度是16字节的整数倍。编译器可以在栈帧中以每个块的存储都是16字节对齐的方式分配存储。
指针类型代表指针访问的地址宽度
| 命令 | 效果 |
|---|---|
| quit | 退出 |
| run | 运行 |
| kill | 停止 |
| break | 设置断点 |
| delete | 删除断点 |
| step | 单步调试 |
| next | 下一个函数 |
| continue | 继续执行 |
| finish | 运行到当前函数返回 |
| disas | 反汇编当前函数 |
| print /x $eip | 16进制输出程序计数器的值 |
| print $eax | 十进制输出寄存器值 |
| x/2w addr | 检查从地址开始的双字 |
| x/20b sum | 检查函数sum前的20个字节 |
| info frame | 打印栈 |
| info registers | 打印寄存器 |
| help |
存储器的越界引用和缓冲区溢出。
栈随机化是对抗机制之一,属于地址空间布局随机化功能的一种。这种可以被“空操作雪橇”破解也就是尝试随机地址的起始不断试错。
栈破坏检测是第二道防线。GCC版本加入了栈保护者机制,用来检测缓冲区越界,想法是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀值
可以使用-fno-stack-protector阻止金丝雀值的产生。
限制可执行代码区域也是方法之一,NX位(可读可写不可执行)

int func(long int num)
{
long val = 0;
for (int i = 0; i < 8; ++i) {
val += (num & 0x0101010101010101L);
num >>= 1;
}
val += (val >> 32);
val += (val >> 16);
val += (val >> 8);
return val & 255
}
//一个巧妙的求取位为一的函数最多可以有6个整型参数通过寄存器进行传递,所以函数的参数数量最好小于6个
浮点程序的机器级表示
SSE2指令集,使用了直接基于寄存器的方法,对于优化编译器来说,是比x87更好的实现。
处理器体系结构
一个处理器支持的指令和指令的字节级编码成为它的指令集体系结构(ISA)
Y86指令集体系结构

存储器,概念上是一个字节数组,Y86程序用虚拟地址引用存储器位置。硬件和操作系统软件联合起来将虚拟地址翻译成实际或物理地址。程序状态的最后一个状态码Stat,它表明程序执行的总体状态。
HCL:硬件控制语言

将很多逻辑门组合成网,形成计算快,成为组合电路。
这网有两条限制
1、两个及以上的逻辑门的输出不能连接在一起,否则可能有信号矛盾,导致一个不合法的电压或电路故障。
2、网必须无环,也就是在网中不能有任何一天链路成环,否则导致网络的计算函数有歧义。
多路复用器:通常称为MUX,多路复用器根据输入控制信号的值,从一组不同饿数据中选出一个。简单来说就是输入两个值,有一个开关根据开关设置的值来判断使用输入的那个值。
宇级的组合电路和HCL整数表达式
将逻辑门组合成大的网,可以构造出能计算更加复杂函数的组合电路,设计对数据字进行操作的电路,执行字级计算的组合电路根据输入字的各个位。
组合电路本质来说不存储任何信息,为了产生时序电路,也就是有状态且在这个状态上进行计算的系统。
考虑两类存储器设备:时钟寄存器、随机访问寄存器
取指、译码、执行、访存、写回、更新PC

SEQ实现包括组合逻辑和两种存储设备:时钟寄存器(程序计数器、条件码寄存器),随机访问存储器(存储器文件、指令存储器和数据存储器)
流水线
流水线寄存器
Fetch:保存程序计数器的预测值
Ddecode:位于取指和译码阶段之间,保存最新去取出指令信息
Eexcute:位于译码、执行阶段,保存最新译码的指令和从寄存器文件读出的值的信息
Memory:位于执行和访存之间,保存最新执行的指令的结果,即将有访存阶段进行处理,此外它还保存关于用于处理条件转移的分支条件和分支目标的信息
Write back:位于访存和反馈路径之间,反馈路径将计算出来的值提供给寄存器文件写,当完成ret的时候,还要向PC选择逻辑提供返回地址
分支预测策略:
always taken
never taken
backward taken
forward not-taken

在valp、valc、W_valM之间进行选择预测
相关指令之间存在相关会导致问题,这种问题相关有两个形式:
1、数据相关:下一指令会用这条指令的计算结果
2、控制相关:一条指令要确定下一条指令的位置
这些相关可能产生计算错误就叫做冒险
采用Nop空指令的方式避免数据冒险
数据冒险的类型:
程序寄存器
程序计数器,更新和读取程序计数器之间的冲突导致了控制冒险,当取指阶段逻辑在取下一指令之前,预测错误的程序寄存器的值,需要进行处理
存储器
条件码寄存器:执行阶段中,整数操作会写这些寄存器,条件传送指令会在执行阶段以及条件转移会在访存阶段读这些寄存器,在条件传送或者转移到达执行阶段之前,所有整数操作已经完成,不会发生冒险。
利用暂停避免数据冒险
暂停:在执行阶段插入一个气泡,气泡类似一个自动产生的nop指令,不会改变寄存器、存储器、条件码以及程序状态。插入气泡后会重复发现数据冒险时候的阶段。
如此实现的性能并不好,耽误整体的吞吐量。
利用转发避免数据冒险
将结果值直接从一个流水线阶段传到较早阶段的技术称为数据转发,或者叫旁路。
大概是指提前将数据存入寄存器里面而不是去寄存器文件里面读取。
加载使用数据冒险不能单纯用转发解决,存储器读在流水线发生较晚的阶段。也就是加载/使用冒险。
可以使用暂停+转发处理:这种方法叫做加载互锁+转发
异常处理
指令集包括三种不同的内部异常
halt
有非法指令和功能码组合的指令
取指或数据读写试图访问一个非法地址、
当流水线有一个或多个阶段出现异常时,信息存放在流水线寄存器的状态字段中,异常时间不会对流水线中指令流有任何影响,除了会禁止流水线后面的指令更新程序员可见的状态(条件码寄存器和存储器),直到异常指令到达最后的流水阶段。因为可以保证第一条遇到异常的指令会第一个到达写回阶段,这个时候程序执行会定制,流水线寄存器W中的状态码就会被记为程序状态,如果取出了某条指令,又取消了,那么所有关于这条指令的异常状态的信息也会被取消。所有导致异常的指令后面的诶指令都不能改变程序员可见的状态。
取指阶段
这个阶段必须选择程序计数器的当前值,并且预测下一个PC值。PC选择逻辑从三个程序计数器源中。当一条预测错误的分支进入访存阶段,会从流水线存储器M中读出该指令valp的值。当ret指令进入写回阶段时,会从流水线寄存器w中读出返回地址。其他情况会使用存放在流水线寄存器F中PC的预测值。
当取出的指令为函数调用或者跳转的1时候会寻找valc,否则就访问valp。
流水线控制逻辑
处理:流水线必须暂停到ret指令达到写回阶段
加载/使用冒险:在一条从存储器中读出一个值的指令和一条使用该值的指令之间,流水线必须暂停一个周期。
预测错误的分支:在分支逻辑发现不应该选择分支之前,分支目标处的几条指令已进入流水线,必须中流水线中去掉这些指令。
异常:当一条指令导致异常,我们想要禁止后面的指令程序员可见的状态,并且在异常指令到达写回阶段停止执行。
mrmovl和popl指令
当两条指令中的任意一条处于执行阶段,且需要且需要该目的寄存器的指令正在处于译码阶段,需要在第二条指令阻塞在译码阶段,保持流水线寄存器F和D固定不变,并且在执行阶段插入气泡。
在程序执行的取指和访存阶段会发现异常、在执行、访存、写回会更新程序状态。
每个流水线寄存器中包含一个状态码stat,随着每条指令经过流水线阶段,它会记录指令的状态,异常发生时候,将信息作为指令状态的一部分记录。当异常指令达到访存阶段时,使用
禁止执行阶段中的指令设置条件码
向存储器阶段中插入气泡,以禁止向数据存储器写入
当写回阶段中有异常指令时,暂停写回,也就是暂停了整个流水线
多周期指令比如浮点加减、乘除这种一般有一个单独的硬件设备来处理这样一种逻辑,在一条指令进入译码阶段时候,可以发射到特殊单元中,这个特殊单元也是具有流水线,只需要保证这些不同单元之间的操作是同步的也就是等待调用的情况。
当访问存储数据的时候,首先会尝试访问高速缓存,并通过TLB快速得到物理地址,大多情况下能够命中高速缓存,如果不命中就需要去访问实际数据,流水线会暂停,将指令保持在取指或者访存阶段。
有时候,被引用的存储器实际上1是存储在磁盘上,硬件会发出缺页信号,会要磁盘数据传输到主存上面,完成之后操作系统就会返回导致缺页的指令处执行。
采用暂停的简单处理主要是因为,暂停的周期时间相较从磁盘中读取的时间周期来说微不足道。
优化程序性能
Amdahl定律:对计算机系统的某一部分进行加速的时候,该加速部分对系统整体的影响取决于该部分的重要性以及加速程度。
存储器别名的使用是一个影响编译器优化的重要因素:
void get_val(int *p, int *q) {
int x = 1000, y = 3000;
*p = x;
*q =y;
return *q;
}
//由于 q 和 p可能是一个地址就导致不能直接优化为return x;gcc -finline 可以使用优化等级为2或者更高的等级。
为了度量程序性能,引入标准每元素周期数(CPE),利用始终周期表示每时钟周期执行了多少条指令。

循环展开
就是减少循环次数一次加的数变多。
编译器是基于重关联变换做优化,重关联变换就是改变值合并的顺序。
gcc -funroll-loops
提高并行
void combine(int * dec, int len, int *val)
{
long int i;
long int len = len;
long int limit = len - 1;
int *ret = val;
int v1 = 0;
int v2 = 0;
for (int = 0; i < limit; i += 2) {
v1 = v1 OP dec[i];
v2 = v2 OP dev[i + 1];
}
for (; i < len; i++) {
v1 = v1 OP data[i];
}
*val = v2 OP v1;
}重写结合
void combine(int * dec, int len, int *val)
{
long int i;
long int len = len;
long int limit = len - 1;
int *ret = val;
int v1 = 0;
for (int = 0; i < limit; i += 2) {
v1 = v1 OP (dec[i] OP dev[i + 1]);
// 会明显优秀于(v1 OP dec[i]) OP dec[i]
}
for (; i < len; i++) {
v1 = v1 OP data[i];
}
*val = v1;
}利用SIMD达到更高的并行度,SSE指令是每个16字节的XMM寄存器可以存放多个值。在使用这些寄存器可以并行乘以4组或者两组。
一些限制因素:
寄存器溢出,由于并行度超过了可用的寄存器数量,编译器会报告移除并且将临时值存储到栈中,一旦出现该现象就会使得性能下降。
1、利用设计提高性能
2、消除连续函数使用
消除不必要的存储器引用
低级优化:
展开循环,降低开销,使得进一步优化
通过累计变量和重新结合的技术提高指令级并行
利用功能的风格重新
eg:
int max = a[i] > b[i] ? a[i] : b[i];
int min = a[i] > b[i] ? b[i] : a[i];
a[i] = min;
b[i] = max;
//这就是功能的风格-pg的编译选项可以开启gprof来生成每个函数调用时间。
gcc -o1 -pg pro.c -o prog
./prog file.txt
#上面这个调用产生1lgmon.out
gprof prog
#生成报告a存储器层次结构
存储器是一个具有不同容量、成本和访问时间的存储设备的层次结构。CPU寄存器保存最常用的数据,靠近CPU的小的、高速的高速缓存存储器作为一部分存储在相对慢速的主存储器中的数据和指令的缓冲区域。
存储技术
随机访问寄存器
包括动态和静态RAM,静态比动态更快也更贵,SRAM作为高速缓存存储器。DRAM作为主存以及图形系统的帧缓冲区。
SRAM将每个位存储在一个双稳态存储器单元里。每个单元三一个六晶体管电路实现,这个电路有一个属性,它可以无限期保持两个不同的电压配置或状态之一。其他任何状态都是不稳定的,从不稳定状态开始,电路会迅速转移到两个稳定状态中的一个。也可以在压稳态保持无限期的平衡,但是最细微的扰动会使得平衡被破坏。
当SRAM存储器单元的双稳态特征,只要有电,就会永远保持它的值。
DRAM
DRAM将每个位存储位对一个电容的充电,这个电容非常小,通常只有大约30毫微微法拉。DRAM对干扰非常敏感,当电容的电压被扰乱之后,他就永远不会回复了。暴露在光线下会导致电容电压改变。
体现为SRAM只要有供电就不会保持不变,DRAM需要不断刷新,晶体管密度更低,存储空间更小。
通过RAS(行访问选通)、CAS(列访问选通)请求获取DRAM芯片对应超单元的单个字节数据
存储器模块
DRAM芯片包装在存储器模块中,是抄到主板的扩展槽上的,常见包装包括168个引脚的双列直插存储器模块(DIMM)还有单列直插存储器模块(SIMM)
增强的DRAM
- 快页模式DRAM
该DRAM允许对同一行连续地访问可以直接从行缓冲区中得到服务。
- 扩展数据输出DRAM
允许单独的CAS信号在时间上靠的更加紧密一点是快页的加强。
- 同步DRAM
SDRAM用与驱动存储控制器相同的外部时钟信号的上升沿来代替许多这样的控制信号,最终效果就是SDRAM比异步的存储器更快的输出内容
- 双倍数据速率同步DARM
DDR SDRAM是对SDRAM一种增强,采用两个时钟作为控制信号。
- Rambus DRAM (RDRAM)
PROM、EPROM、EEPROM、flash memmory
访问主存
数据流通过总线的共享电子电路进行处理器和DRAM主存之间交互,这些步骤加做总线事务。读事务就是主存到CPU,写事务就是CPU到主存。
IO总线用于给外设输入输出,这个哦那个先可以容纳多种IO设备。
CPU使用一种称为存储器映射IO的技术来向IO设备发出命令,地址空间有一块地址是为了与IO设备通信保留的,这样的地址称为IO端口。磁盘控制器收到CPU读指令之后,将逻辑块好翻译成一个扇区地址,该区内容直接传送到主存,不经过CPU(DMA),之后磁盘控制器通过给CPU发出一个中断通知CPU。执行中断中一个例程,记录IO完成返回CPU中断处。
固态硬盘
固态硬盘是一种基于闪存的存储技术,SSD包接入IO总线上,一个SSD包由一个或者多个闪存芯片和内存翻译层组成
其结构决定了这个随机读写能力差,但仍然比旋转磁盘好。

随机写慢的原因:
1、擦除块需要相对较长的时间,比访问页需要时间高出一个数量级
2、如果写操作试图修改一个包含已经有数据额页,那么这个块中所有带有有用数据的页就必须拷贝到一个新的块(擦除过),才能对要修改的页进行写。
局部性
局部性优秀的程序,倾向于引用临近于其他最近引用过的数据项的数据项。
时间局部性、空间局部性。
存储器层次结构

高速缓存是一个小儿快速的存储设备,他作为存储在更大、更慢的设备中的数据对象的缓冲区域。这个过程叫做缓存cashing
片、块,一般块的大小是固定的,也是可变的,在不同层间的传输一般也是一块作为传送单元的。注意相邻块之间的大小是固定的,但是其他层之间的块大小可以不一。
缓存命中:需求数据会在更高层查找,如果找到就叫命中,如果命中就会有良好局部性
不命中:如果更高层找不到那么就是咩有命中。
覆盖一个现存块的过程叫做替换、驱逐一个块,被驱逐的块叫做牺牲块,而如何做这个选择是由于缓存的替换策略控制。
包括LRU等等页面置换算法。
缓存不命中的种类
如果k层缓存是空的,对于所有数据对象访问都不会命中,一个空的缓存叫做冷缓存,这种不命中叫做强制性不命中或者冷不命中。
发生不命中,第K层的缓存就必须执行某个放置策略。这个策略也有很多,随机放置是最灵活的,但是对于高层来说这种策略定位代价高,硬件缓存通常使用更严格的放置策略,将k+1层的某个块限制放置在第k层块的一个小子集中。而这种限制性的放置策略回导致一个冲突不命中的现象出现,这种情况下,缓存够放下数据,但是这些对象被映射到同一个缓存快之中,缓存会一直不命中。
当嵌套或者递归访问的时候反复访问同一个数组或其他什么数据的集合,这种叫做工作表,当工作表的大小超过了缓存的大小时,缓存会经历容量不明中的情况。
直接映射高速缓存
每组高速缓存数为一的即直接映射高速缓存,访问缓存的流程主要有
组选择、行匹配、字抽取
冲突不命中这种情况会导致一种抖动的情况,就是高速缓存反复加载和驱逐高速缓存的组。主要是因为不同的块被映射到了同一片空间,导致内容反复的覆盖。

高速缓存采用中间的为位作为组索引而不是高位原因是因为:一些连续存储器块回映射到相同的高速缓存快
组相联高速缓存
在这个高速缓存模式中遇到缓存不命中时候的行替换,就是利用LFU等算法进行替换。
全相联高速缓存
一个全相联高速缓存一个包含所有高速缓存行的组,也就是只有一个组。
写的问题
直写:更新高速缓存之后直接将数据写回下一层
写回:推迟存储器更新,将多次写回回禀,只有当替换算法要驱逐更新过的块的时候才写回。
总得来说:
对局部变量的反复引用是好的,因为编译器能够将它们缓存在寄存器文件中
步长为1的引用模式是好的,因为存储器层次结构中所有层次上的缓存都是将数据存储为连续的快。

系统上运行的程序
链接
gcc -02 -g -o p main.c swap.c
#驱动程序首先运行C预处理器,main.c->main.i
#驱动程序运行c编译器,main.i->main.s
#驱动程序运行汇编器. main.s->main.o
#驱动程序相同流程生成swap.o,最后运行连接器ld,组合成为可执行文件p
#为了构造可执行文件,链接器必须完成两个主要任务:
#符号解析:目标文件定义和引用符号,目的是将每个符号引用和一个符号定义联系起来
#重定位:编译器和汇编器生成从地址0开始的代码和数据节,链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储器的位置,从而实现重定位。
#链接器的目标文件纯粹是字节块的集合,这里面有程序代码、程序数据,指导链接器和加载器的数据结构。目标文件包含:
可重定位目标文件:二进制代码和数据,在编译时可以和其他可重定位目标文件合并,创建一个可执行目标文件。
可执行目标文件:二进制代码和数据,可以被直接拷贝到存储器并执行。
共享目标文件:一种特殊类型的可重定位目标文件,可以在加载或运行时被动态地加载到存储器并链接。
UNIX目前可执行文件的类型是ELF

符号以及符号表
每个可重定位的目标模块m都有一个符号表,包含m定义以及引用的符号信息
有三种不同的符号
- 由m定义并且可以被其他模块引用的全局符号,全局链接器符号对应与非静态的c函数以及被定义为不带c static 属性的全局变量
- 由其他模块定义的并被m引用的全局符号。叫做外部符号。
- 只被m模块定义和引用的本地符号,有的本地链接器符号对应于带static属性的c函数以及全局变量,目标文件中对应与模块m的节和相应的源文件名字也能获得本地符号 .symtab的符号表中不包含对应于本地非静态程序变量的任何符号,这些符号在运行时候被栈管理。
符号表是由汇编器构造的,使用汇编器输出到汇编语言.s文件的符号。.symtab节中包含ELF符号表这个符号表包含一个条目的数组。

name是字符表中的字节偏移,指向符号以null结尾的字符串的名字。value是符号的地址。对应可重定位的模块来说,value是距定义目标的节起始位置的偏移。对于可执行文件目标,该值是一个绝对运行时地址。size是目标的大小,type通常要么是数据要么是函数。符号表还可以包含各个节的条目,以及对应原始源文件的路径名的条目,所以这些目标的类型也有所不同。binding字段表示符号是本地还是全局的。
每个符号都和目标文件的某个节相关联,由section字段表示,该字段也是一个到节点头部表的索引。
有三个特殊的伪节,他们在节头部表中是没有条目的:
ABS代表不被重定位的符号
UNDEF代表未定义符号
COMMON:表示未分配位置的未初始化
符号解析
当编译器遇到一个不是在当前模块中定义的符号的时候,它会假设该符号是在其他模块中定义,生成一个连接器符号表条目,并交给连接器处理。如果链接器在它的任何输入模块中都找不到这个被引用的符号,它就会输出一条报错。
c++中链接器的毁坏,Foo::bar(int, long)经过链接器之后会被编码未bar__3Fooil
在多重定义的全局符号中,有强符号与弱符号的说法,函数和已经初始化的全局变量是强符号,未初始化的全局变量是弱符号。
不允许有多个强符号
如果一个强多个弱,选强
如果有多个弱符号那么这些弱符号中任意选择一个。

gcc -fno -common可以让编译器在遇到多重定义的全局符号,输出一条告警
重定位
完成符号解析之后就会进行重定位
重定位节和符号定义
重定位节中的符号引用
重定位条目
当汇编器生成一个目标模块的死后不知道最终存放代码以及数据的位置,也知道任何外部或者全局变量位置。所以无论何时编译器遇到一个最终位置位置的引用,就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件的时候如何修改这个引用。代码重定位条目在.rel.test中,已经初始化数据的重定位条目放在.rel.data中。

重定位符号引用
有两种类型R_386_PC32和R_386_32分别是
重定位使用一个32位PC相对地址和引用和重定位使用一个32位的绝对地址的引用
可执行目标文件
ELF头部描述文件的总体格式。包括程序的入口点,除了这些原本重定位目标文件中相似的节,还有一个.init节,这个节定义了一个函数叫做_init,程序的初始化。
段头部表描述了可执行文件连续的片被映射到连续的存储器段的方式。

加载
shell通过调用加载器来运行可执行程序。任何unix程序可以通过调用execve函数来调用加载器

共享库
gcc -shared -fPIC -o *.so *.c:生成一个与代码位置无关的共享库

利用dlopen函数加载和连接共享库
#include <dlfcn.h>
void *dlopen(const char *filename, int flag);
void *dlsym(void *handle, char *symbol);
int dlclose(void *handle);
const char *dlerror(void);用RTLD_GLOBAL选项打开的库解析filename的外部符号。如果当前可执行文件是带-rdynamic选项编译,那么对符号解析而言,全局符号也是可用的。flag选项必须要么包括RTLD_NOW,该标志告诉链接器立即解析对外部符号的引用,要么包括RTLD_LAZY标志,指示链接器推迟符号解析直到执行来自库中的代码。
dlsym函数的输入是一个指向前面已经打开共享库的句柄和一个符号名字如果符号存在,就返回符号的地址。
与位置无关的代码
编译库代码,使得不需要链接器修改库代码就可以在任何地址加载和执行这些代码。即PIC代码,利用-fPIC选项指示GNU编译系统生成PIC代码
编译器在数据段开始的地方创建了一个全局偏移量表(GOT),在这个表中,每个被这个目标模块引用的全局数据对象都有一个条目。编译器还为GOT每个条目生成一个重定位记录,在加载时候,动态链接器会重定位GOT中每个条目,使它包含正确的绝对地址。
ELF编译系统系统一种叫做延迟绑定的技术,将过程地址的绑定推迟到第一次调用的时候。
延迟班定通过GOT和过程链接表PLT之间进行交互来实现,
GOT在.data节
PLT在.text节
处理目标文件的工具
AR:创建静态库,插入、删除、列出和提取成员
STRINGS:列出一个目标文件中所有可打印的字符串
STRIP:从目标文件删除符号表信息
NM:列出一个目标文件的符号表中定义的符号
SIZE:列出目标文件中节的名字和大小
READELF:显示一个目标文件的完整结构,包含SIZE和NM功能
OBJDUMP:所有二进制工具之母,能显示一个目标文件中所有的信息,最大作用是反汇编.text节中的二进制指令
LDD:列出一个可执行文件在运行时需要的共享库
异常处理
异常
异常是控制流中的突变,用来相应处理器状态中的某些状态。比如发生虚拟存储器缺页、算术溢出,或者一条指令视图除以零,或者一个系统定时器产生信号。
在任何情况下,当处理器检测到事件发生时,他会通过一张叫做异常表的跳转表,进行一个简介过程调用。
系统会给每总类型异常分配一个唯一的非负整数的异常号,在系统启动时候分配一张异常表的跳转表。
异常表的起始地址放在一个叫做异常表基址寄存器的特殊CPU寄存器里。
异常可以分为四类:中断、陷阱、故障、终止
中断是异步的,是处理器外部的IO设备结果,硬件中断的异常处理程序通常称为中断处理程序。

陷阱是同步的,最重要作用就是用户程序和内核之间提供一个像过程的接口叫做系统调用
故障是由错误引起,他可能能被错误处理程序修正。当故障发生时,处理器将控制转移给故障处理程序,如果这个错误能修正那么就会返回继续执行,否则就会返回内核里面的abort例程。
终止是不可恢复的致命错误造成的结果

进程
逻辑控制流:程序计数器的唯一对应于包含在程序的可执行文件中的指令。
并发流:计算机系统中一个逻辑流的执行在时间上和另一个流重叠。
并行流:两个流并发执行在不同的核或者计算机上。
高速缓存污染:当切换到中断时候,高速缓存中缺少中断程序需要的数据,然后从中断切换回来的世欧由于中断程序访问了足够多的表项,高速缓存对于原程序来说缺少了一些数据。
进程控制
获取pid
每个进程有一个对应的进程ID,
getpid()就可以获取调用进程的Id,getppid获取父进程id#include <sys/types.h> #include <unistd.h> pid_t getpid(void); pid_t getppid(void);
创建以及终止进程
程序总处于:运行、停止(suspend)、终止状态三者之一
#include <stdlib.h>
#include <sys/type.h>
#include <unistd.h>
pid_t fork(void);
// fork函数创建的紫禁城不完全和父进程相同,子进程得到父进程用户级虚拟空间相同但是独立的一份拷贝,包括文本、数据和bss段、堆以及用户占,还获得所有父进程中打开的文件描述符。但是其具有不同的pid
//fork函数有两个返回值,子进程一定返回0,父进程返回pid
void exit(int status);回收子进程
进程由于某种原因终止时,内核并不是立即把他从系统中清除。进程一直保持在一种已经终止的状态直到被父进程回收。当父进程进行回收之后,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,进程不存在了。一个终止了但还未被回收的进程叫做僵尸进程。
如果父进程没有回收子进程就死了,那么这个进程就会被Init进程所回收。Init进程PID为1,并且是在系统初始化的时候在内核创建的。
pid_t waitpid(pid_t pid, int *status, int options);
// 如果成功返子进程pid,如果WNOHANG,返回0,其他错误返回-1
// pid 参数:
// 如果配置为>0值,那么指定等待对应pid进程,如果-1,那么等待当前进程所有子进程
// options参数
// WNOHANG:如果等待任何子进程都没有终止,那么立即返回0。
// WUNTRACED:挂起调用进程,直到有一个进程变为终止或者停止。
pid_t wait(int *status);
// 等于调用waitpid(-1, status, 0);进程休眠
unsigned int sleep(unsigned int secs);+加载并运行程序
int execve(const char *filename, const char *argv[], const char *envp[]);
//这个函数从不返回
// 第一个是可执行文件名
// 第二个是参数
// 第三个环境变量列表:"USER=droh"
char *getenv(const char *name);
int setenv(const char *name, const char *newvalue, int overwrite);
void unsetenv(const char *name);信号

进程组
pid_t getpgrp(void); //获取进程组ID
int setpgid(pid_t pid, pid_t pgid);
int kill(pid_t pid, int sig);
unsigned int alarm(unsigned int secs);
sighandler_t signal(int signum, sighandler_t handler);
// 可以说SIG_IGN就忽略信号
// SIG_DFL就是恢复信号行为非本地跳转
#include <setjmp.h>
int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs);
void longjmp(jmp_buf env, int retval);
void siglongjmp(sigjmp_buf env, int retval);虚拟存储器
虚拟存储器提供了三个能力:
它将主存看成是一个存储在磁盘上地址空间的高速缓存
它为了每个进程提供一致的地址空间,从而简化了存储器管理
它保护了每个机场能的地址空间不被其他进程破坏
任意时刻,虚拟页面的集合分为三个不相交的子集:
未分配的
缓存的
未缓存的
DRAM看错虚拟存储器的缓存,作为SRAM的缓存,其要是不命中需要由磁盘来处理,开销较大,所以DRAM需要精密的替换算法、需要全相连。
页表:将虚拟页映射到物理页,每次地址翻译硬件将一个虚拟地址转换为物理地址时会读取页表,操作系统会维护页表内容,以及磁盘与DRAM之间来回传送页。

getrusage:函数可以监测缺页的数量

地址翻译

地址翻译:一个N元素的虚拟地址空间到一个M元素的物理地址空间之间元素的映射

CPU里面有一个控制寄存器,页表基址寄存器PTBR,会指向当前页表。
一个n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移,和一个虚拟页号。
MMU就会利用这个虚拟页号选择合适的PTE(页表项),将其中的物理页号和对应的虚拟页便宜串联起来就是对应的物理地址。
具体命中流程:
生成虚拟地址,发送给MMU
MMU生成PTE地址,请求对应PTE
高速缓存、主存向MMU返回对应PTE
MMU构造物理地址,发给高速缓存、主存
返回请求数据
当发生不命中时,即上述第四步,PTE中有效位是0,出发MMU异常,陷入内核的缺页异常处理程序,
确定牺牲页,调入新页面,更新PTE,从缺页程序返回,再次发起这次流程


多级页表

原本无多级页表的情况下会存在 4MB的空间用来存储,现在多级的情况如果一个PTE是空的对应的二级页表就不会创建。只有一级页表总是存储在主存中,虚拟存储器可以在需要的时候创建、页面调入或调出二级页表。
虚拟存储器系统
Linux将虚拟存储器组成一片区域叫做段的集合。一个区域就是已经存在着的虚拟存储器的连续片,这些页用某种方式关联。
内核为每个进程维护一个单独的任务结构,任务结构中的元素包含或者指向内核运行该进程所需的所有信息。
task_strucrt 一个条目指向mm_struct,它描述了虚拟存储器的当前状态。
image-20260102213238540 将一个虚拟存储器区域与一个磁盘上的对象关联,以初始化这个虚拟存储器区域的内容,这个过程称为存储器映射。
虚拟存储器区域可以映射到两种
1、Unix文件系统中的普通文件:
一个区域以映射到一个普通磁盘文件的连续部分
2、匿名文件
一个区域可以映射到一个匿名文件,匿名文件是内核创建的,包含的二进制0,CPU第一次引用这样一个区域内的虚拟页面内核就在物理存储器中找到一个合适的牺牲页面,如果页面被修改过,就会将这个页面换出来,用二进制0覆盖牺牲页面并更新页表,将这个页面标记为是驻留在存储器中。磁盘和存储器之前并没有实际的数据传送,这个原因,映射到匿名文件的区域中的页面有时也叫做请求二进制0的页。
无论以上那种情况,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件。交换文件也叫做交换空间或者交换区域。
一个对象可以被映射到虚拟存储器的一个区域,要么作为共享对象要么作为私有对象。如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内。一方面对一个映射到私有对象的区域做的改变,对于其他进程不可见,并且进程对这个区域所做的任何写操作都不会反映到磁盘上的对象中。一个映射到共享对象的虚拟存储器区域叫做共享区域。
私有对象才用一种叫做写时拷贝的技巧,私有对象开始生命周期的方式基本上与共享的一样,在物理存储器中只保存有私有对象的一份拷贝。
fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给一个唯一的PID。为了给这个新进程创建虚拟存储器,它创建了当前进程的mm_struct、区域结构和页表的原样拷贝。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时拷贝。
当这两个进程任何一个后来进行写操作时,写时拷贝就会创建一个新的页面,这样就保持了私有地址空间的抽象概念。
execve
调用该函数时有如下步骤
1、删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2、映射私有区域。为新程序的文本、数据、bss和栈区域创建新的区域结构。所有这些区域都是私有的、写时拷贝。bss、栈、堆都是请求二进制0的。
3、映射共享区域,如果a.out与共享对象有链接,那么这些对象要动态链接到这个程序,然后映射到用户虚拟地址空间中的共享区域内
4、设置程序计数器
mmap函数的用户级存储器映射,这个函数要求内核创建一个新的虚拟存储器区域,最好是从地址start开始的一个区域,并将文件描述符fd指定的对象的一个连续的片映射到这个新的区域。
prot包含描述新映射的虚拟存储器区域的访问权限
- PROT_EXEC:该区域可以被CPU执行指令组成
- PROT_READ
- PROT_WRITE
- PROT_NONE
flag是描述被映射对象类型的为组成
MAP_ANON标记位:匿名对象
MAP_PRIVATE:私有的、写时拷贝对象
MAP_SHARED:共享对象
#include <unistd.h> #include <sys/mman.h> void *mmap(void *start, size_t len, int prot, int flags, int fd, off_t off); int munmap(void *start, size_t len);munmap函数删除虚拟存储器的区域
动态存储器分配,也就是堆空间分配。
分配器有两种风格一种
显式分配器:通过malloc程序分配一个块并通过调用free函数释放一个块对应c++的new和delete
隐式分配器:要求分配器检测一个已分配块合适不再被程序所使用,那么就释放这个块。隐式分配器叫做垃圾收集器。
显式分配器
#include <stdlib.h>
void *malloc(size_t size);
// calloc可以对于分配存储器进行初始化为0
void *sbrk(intptr_t incr);
/* 该函数将内核的brk指针增加incr来扩展和收缩堆。如果成功,就返回brk的旧值,否则返回-1。
并且设置errno设置为ENOMEM, 如果incr为0,那么sbrk返回当前brk的当前值。用一个负的incr也是可以的因为返回值(brk旧值)指向距新堆顶向上的abs(incr)字节处*/
void free(void *ptr);
/* ptr必须是一个已经分配过空间的起始位置,否则就出现未定义情况*/显式分配器有一些强制约束:
处理任意请求序列
立即响应请求
只使用堆
对齐块
不修改已经分配的块
碎片
分为内部碎片以及外部碎片
内部碎片是在一个分配块比有效载荷大时发生的。内部碎片已分配块大小和它们的有效载荷大小之差的和。
外部碎片是当空闲存储器合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大可以来处理这个请求是发生。
隐式空闲链表

隐式空闲链表简单但是任何操作额开销都要求空闲链表额度搜索与堆中已分配块和空闲块的总数呈线性关系。
放置已分配的块
分配执行这种搜索的方式放置策略确定,一些常见首次适配、下一次适配和最佳适配。
首次适配,从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配和首次适配很相似,只不过不是从链表的起始处开始每次搜索,而是从生一次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。
首次适配的优点是它往往将大的空闲块保留在链表的后面。缺点是它往往在靠近链表起始处留下小空闲块的碎片。
一个选择是通过合并那些在存储器中物理相邻的空闲块来创建一些更大的空闲块。
然后,如果这样还是不能生成一个大的块,或者如果空闲块已经最大程度合并,那么分配器就会通过调用sbrk函数,向内核请求额外的堆存储器。分配器将额外的堆存储器转换为一个大的空闲块,将这个块插入到空闲链表中,然后将配请求的块放置在这个新的空闲块中。

采用边界标记的方式在块结尾添加一个脚部。
垃圾收集器
该图的节点被分成一组根节点和一组堆节点。每个堆节点对应于堆中一个已分配块。

C和C++的收集器通常不能维持可达图的精确表示,这样的收集器是保守的垃圾收集器。
Mark&Sweep垃圾收集器
有标记阶段清楚阶段组成,标记阶段标记出根节点的所有可达的和已分配的后继,而后面的清楚阶段释放每个未被标记的已分配块。
程序间的交互和通信
系统级io
UNIX IO
Unix文件是一个m个字节的序列
所以所有的输入和输出都被看做对文件的IO
0:stdin STDIN_FILENO
1:stdout STDOUT_FILENO
2:stderr STDERR_FILENO
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(char *filename, int flags, mode_t mode);
--flags
/* O_CREAT\O_TRUNC\O_APPEND\O_RDONLY\O_WRONLY\O_RDWR */
-- mode
/* 带有创建文件的flags才有效,设置文件的权限 */
int close(int fd);
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t n); /* 返0表示到末尾、返-1表示出错、正常返回读取字节数 */
ssize_t write(int fd, const void *buf, size_t n);
size_t 定义为unsigned int 而ssize_t 被定义为int
使用RIO
带有缓存的输入输出函数
不带缓冲的输入输出函数




#include "csapp.h"
ssize_t rio_readn(int fd, void *usrbuf, size_t n);
ssize_t rio_writen(int fd, void *usrbuf, size_t n);
/*
rio_readn函数从描述符fd的当前文件位置最多传送n个字节到存储器位置usrbuf,
rio_readn遇到EOF只能返回一个不足值,rio_riten不会返回不足值。对于一个描述符
可以任意交错调用两个函数
*/
/*
调用一个包装函数(rio_radlineb),它从一个内部读缓冲区拷贝一个文本行,当缓冲区变空时,自动调用read重新填满缓冲区。
*/
void rio_readinitb(rio_t *rp, int fd);
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen);
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n);
/* rio_readinitb 函数从文件rp读出一个文本行(包括结尾的换行符)
rio_readlineb 函数最多读maxlen - 1个字节,余下的一个字符留给结尾的空字符
*/读取文件元数据
应用程序能够通过调用stat和fstat函数,检索到文件的信息(有时也称为文件的元数据)
#include <unistd.h>
#include <sys/stat.h>
int stat(const char *filename, struct stat *buf);
int fstat(int fd, struct stat *buf);
// Unix提供的宏指令根据st_mode成员确定文件的类型

内核文件
描述符表,每个进程都有独立的描述符表,表项由进程打开的文件描述符来索引。每个打开的描述符表项指向文件表中的一个表项。
文件表,打开文件的集合由一张文件表来表示,所有的进程共享这张表。
每个文件表的表项组成有当前文件位置、引用计数、以及一个指向V-NODE表中对应表项的指针
关闭描述符会减少相应的文件表表项中的引用计数,内核不会删除这个文件表表项,直到引用计数为0.
v-node表,同文件表一样,所有进程共享这张表,每个表项包含stat结构中的大多数信息。
IO重定向
int dup2(int oldfd, int new fd);标准IO
fopen fclose fread fwrite fgets fputs scanf printf
标准IO将一个打开的文件模型化为一个流。对于程序员而言,一个流就是一个指向FILE类型结构的指针。

尽量只使用标准IO函数而不是UNIXIO函数
对流的限制和对套接字的限制有时候会互相冲突
限制一:
跟在输出函数之后的输入函数,如果中间没有调用fflush、fseek、fsetpos或rewind调用,一个输入函数不能跟在一个输出函数后面,fflush函数清空流相关缓冲区,其余函数可以重置当前流的位置
限制二:
跟在输入函数之后的输出函数。如果中间没有使用fseek、fsetpos或者rewind,输入函数后面不能跟着输出函数除非输出函数遇到EOF
解决方案:1、使用清空或者重置之后再使用 2、打开两个流一个用于写、一个用于读(每个流都需要单独关闭),但是在线程化的函数中很有可能导致,关闭已经关闭描述符的情况。
可以使用RIO函数,利用sprintf函数在存储器中格式化一个字符串,然后利用rio_witen发送到套接口,如果需要格式化输入,使用rio_readlineb读取一个完整的文本行然后利用sscanf从文本行提取不同的字段
网络编程
客户端-服务器模型
每个网络应用都是基于客户端-服务器模型创建。这个模型一个是由一个服务器进程和一个或多个客户端进程组成。
该模型的基本操作就是事务
当一个客户端需要服务时,向服务器发送一个请求,发起一个事务
服务器接收请求,解释并利用合适方式操作资源
发送给客户端响应
客户端处理响应


网络上一个按照地理远近的层次系统,最低层次是 局域网(LAN),一个以太网段包括电缆(双绞线)和一个集线器的盒子。
使用一些电缆和一个网桥的盒子,多个以太网段可以连接成一个较大的局域网,成为桥接以太网


路由器也能连接高速点到点电话连接,叫做WAN。

主机集合被映射为一组32位的IP地址、这个IP地址被映射为一组称为因特网域名、因特网地址上的进程能够通过连接和任何其他因特网主机上的进程通信。
IP地址
一个IP地址就是一个32位无符号整数。网络程序将IP地址存放在IP结构中
struct in_addr {
unsigned int s_addr;
};因为因特网主机可以有不同的主机字节顺序,TCP/IP为任意整数数据项定义了同意的网络字节顺序(大端序)
#include <netinet/in.h>
unsigned long int htonl(unsigned long int hostlong);
unsigned short int htons(unsigned short int hostshort); // 变成网络序
unsigned long int ntohl(unsigned long int netlong);
unsigned short int ntohs(unsigned short int netshort); // 编程机器序IP一般采用一种叫做点十分的表示方法
hostname -i :查询主机点十分进程ip
#include <apra/inet.h>
int inet_aton(const cahr *cp, struct in_addr *inp);// ton检测是否是一个IP的点十分表示
char *inet_ntoa(struct in_addr in); //返回一个指向点十分进制的字符串
// inet_ntop可重入DNS数据库映射域名与IP
#include <netdb.h>
struct hostent *gethostbyname(const char *name);// 返回与域名相关的主机条目
struct hostent *gethostbyaddr(const cahr *addr ,int len, 0);// 返回ip相关的主机条目创建线程
#include <pthread.h>
typedef void *(func)(void *);
int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);
// 创建一个新的线程,并带着一个输入变量arg,在新线程的上下文中运行线程f。
// 当pthread_create返回时,参数tid包含新创建线程的ID,新线程可以使用pthread_self函数来获得它的线程ID
// 当顶层的线程返回,线程会隐式终止
// 调用pthread_exit
// exit函数,该函数终止进程以及所有与该进程有关的线程
// 对一个等待线程可以用TID作为索引停止当前线程(pthread_cancel)
void pthread_exit(void *thread_return); // 这个调用时候线程会显示终止,如果主线程调用了这个函数就会等待所有对等线程终止,燃烧终止主线程和整个进程。
int pthread_cancel(pthread_t tid);
// 回收已经终止的线程资源
int pthread_join(pthread_t tid, void **thread_return);
int pthread_detach(pthread_t tid);
// 线程从可结合的变为分离的时候,从能够被其他线程回收资源和杀死,但其占据资源要等待释放变成了线程一结束系统会自动释放资源。
// pthread_once函数允许初始化与线程相关的状态
pthread_once_t once_control = PHTREAD_ONCE_INIT;
int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));线程有其独立的部分有共享的部分需要分清。对于线程而言寄存器是不共享的,虚拟存储器可以看作是共享的。
使用信号量同步线程
共享变量在多线程编程中存在竞态问题, 使用信号量来规避这个问题
#include <semaphore.h>
int sem_init(sem_t *sem, 0, unsigned int value);
int sem_wait(sem_t *s);
int sem_post(sem_t *s);提高线程并行性
四类线程不安全函数
不保护共享变量的函数
保持跨越多个调用状态的函数
返回指向静态变量的指针的函数
调用线程不安全函数的函数
可重入性也就是具有线程安全属性的函数

常见UNIX库中线程不安全的函数

image-20260124175300965 该主线程的i产生了竞态,for循环还没有到第二个i的时候,此时进的线程获取的i是正确的但是如果此时已经进入第二个i那么这个时候就会出现问题


