有点意思,没点意思(一)

第一章 数

程序员和编译器不能用表达式(x-y<0)代替(x<y),因为它可能会产生溢出,也不能用表达式(-x>-y)来替代,因为在补码表示中负数和正数的范围是不对称的。“

没意思(一) 逻辑运算符与位运算符

区别一:运算对象与结果类型

  • 逻辑运算符 &&||
    操作对象是布尔值(真 / 假,对应代码里非 0/0 等逻辑判定),结果也只有布尔值(真或假,即代码里的非 0 或 0 )。比如 (3 > 2) && (5 < 6) ,先判定两边条件真假,再做逻辑运算。
  • 位级运算符 &|
    操作对象是二进制位(整数的二进制形式),按位逐位运算,结果是整数(对应二进制位运算后的数值) 。比如 5 & 3(5 是 101,3 是 011 ),按位与结果是 001(即十进制 1 )。

区别二:短路求值(你提供内容里的核心区别 )

  • 逻辑运算符 &&||
    存在 “短路” 特性 —— 若通过第一个操作数就能确定整个表达式结果,就不会执行第二个操作数
    像 a && 5/a ,若 a 为 0(逻辑假 ),&& 左边已确定结果为假,直接跳过 5/a ,避免除零错误;p && *p++ 同理,p 为空指针时,左边为假,不执行 *p++ ,避免空指针解引用崩溃。
  • 位级运算符 &|
    不存在短路,会严格计算两个操作数,再逐位做位运算。比如 a & b ,不管 a 是什么,都会计算 b ,再对二进制位逐位与运算 。

没意思(二)浮点数

1. 规格化数(Normal Numbers

由来
计算机要表示“很大”或“很小”的数(比如 10000000.000001),直接存整数会浪费位数。
规格化数采用科学计数法的二进制版:±1.xxxx... × 2^E1.xxx 是尾数,E 是指数)。
- 隐藏最高位的 1:因为二进制科学计数法的整数部分一定是 1(如 10.0=1.0×2^10.1=1.0×2^-1),所以省略这个 1,只存后面的小数部分,节省 1 位空间,提高精度。

结构
- 符号位(1 位):0 正,1 负
- 指数(8 位):实际指数 E = 指数值 - 127(偏移量 127,方便表示正负指数)
- 尾数(23 位):存储 1.xxxx... 中的 xxxx...(隐含前面的 1

例子
- 2.0
二进制是 10.0 = 1.0 × 2^1
指数 E=1,指数值 =1+127=128(二进制 10000000),
尾数是 0(因为 1.0 省略了前面的 1,只存小数部分 0),
符号位 0
最终二进制:0 10000000 00000000000000000000000,即十六进制 0x40000000

2. 非规格化数(Denormal Numbers

由来
当数“太小”,规格化数的指数 E 最小是 -126(指数值 0,因为 0-127=-127 但实际最小是 -126,这里可能记错,正确是指数值 0 对应 E=-126),但比 -126 更小的数怎么办?
非规格化数允许指数全为 0,此时 不隐藏最高位的 1,而是用 0.xxxx... × 2^-126 表示,尾数可以是 0 到全 1,填补规格化数和 0 之间的“空隙”,避免“下溢”时直接归零,提高精度连续性。

结构
- 符号位(1 位):0 正,1 负
- 指数(8 位):全 0(指数值 0,对应实际指数 E = -126
- 尾数(23 位):直接存 0.xxxx...(不隐含 1,可以是全 0,此时就是 0

例子
- 最小的正非规格化数
尾数最低位是 1,其余 0,即 0.000...0001(23 位),
实际值 = 0.000...0001 × 2^-126 = 2^-149(因为 23位尾数对应小数部分,加上指数 -126 ,总偏移 -126-23=-149 ), 二进制:0 00000000 00000000000000000000001,即十六进制0x00000001。 - **+0 和 -0**:尾数全 0,指数全 0,符号位不同,二进制分别是0x000000000x80000000`。

3. 无穷大(Infinity)

由来
当计算结果“太大”(如 1.0/0.0),无法用规格化数表示,就用 无穷大。分为正无穷和负无穷。

结构
- 符号位(1 位):0 正,1 负
- 指数(8 位):全 1(指数值 255)
- 尾数(23 位):全 0(表示无穷大是一个确定的“边界值”)

例子
- 正无穷1.0/0.0 的结果,
二进制:0 11111111 00000000000000000000000,即十六进制 0x7F800000
- 负无穷-1.0/0.0,符号位 1,其余同上,十六进制 0xFF800000

4. NaN(非数,Not a Number)

由来
当计算结果“无效”(如 0.0/0.0√-1),无法用任何数表示,就用 NaN(“不是一个数”)。
NaN 不是一个确定的值,而是表示“这是一个错误的结果”。

结构
- 符号位(1 位):通常忽略(有些系统用 0,有些用 1,但无意义)
- 指数(8 位):全 1(和无穷大一样)
- 尾数(23 位)非 0(只要不全 0,就是 NaN,不同尾数表示不同类型的 NaN,比如“信号 NaN”和“静默 NaN”)

例子
- 最简单的 NaN:尾数最高位是 1,其余 0,
二进制:0 11111111 10000000000000000000000,即十六进制 0x7FC00000
- 0/0 的结果:通常是一个 NaN,具体尾数由编译器决定,但指数一定全 1,尾数非 0。

总结: 1. 规格化数:用科学计数法高效表示大多数普通数,节省位数,提高精度。
2. 非规格化数:填补规格化数和 0 之间的“空隙”,避免极小的数直接归零,提高数值连续性。
3. 无穷大:表示计算溢出(如除以 0)的合理结果,避免程序崩溃。
4. NaN:表示无效的计算(如 0/0),让程序知道“这是一个错误,无法继续计算”。

第二章 汇编

没意思(三)寻址

类型 格式 操作数值 名称
立即数 $Imm Imm 立即数寻址
寄存器 rₐ R[rₐ] 寄存器寻址
存储器 Imm M[Imm] 绝对寻址
存储器 (rₐ) M[R[rₐ]] 间接寻址
存储器 Imm(r_b) M[Imm + R[r_b]] (基址 + 偏移量)寻址
存储器 (r_b, r_i) M[R[r_b] + R[r_i]] 变址寻址
存储器 Imm(r_b, r_i) M[Imm + R[r_b] + R[r_i]] 变址寻址
存储器 (r_i, s) M[R[r_i]·s] 比例变址寻址
存储器 Imm(r_i,s) M[Imm + R[r_i]·s] 比例变址寻址
存储器 (r_b, r_i, s) M[R[r_b] + R[r_i]·s] 比例变址寻址
存储器 Imm(r_b, r_i, s) M[Imm + R[r_b] + R[r_i]·s] 比例变址寻址

操作数可以表示立即数(常数)值、寄存器值或是来自内存的值。比例因子 s 必须是 1、2、4 或者 8

示例

地址
0x100 0xFF
0x104 0xAB
0x108 0x13
0x10C 0x11
寄存器
%rax 0x100
%rcx 0x1
%rdx 0x3

写下表,给出所示操作数的值

操作数
%rax 0 x 100
0 x 104 0 xAB
$0 x 108 0 x 108
(%rax) 0 xFF
4 (%rax) 0 xAB
9 (%rax,%rdx) 0 x 11
260 (%rcx,%rdx) 0 x 13
0 xFC (,%rcx, 4) 0 xFF
(%rax,%rdx, 4) 0 x 11

没意思(四)栈帧

  • SP 始终指向栈顶,用于栈操作(push/pop)。
  • BP 用于访问栈帧内的局部变量或参数(通过偏移量)
栈.png

压栈过程(函数调用时)

  1. 保存返回地址:当一个函数被调用时,程序首先会将当前指令的下一条指令的地址(即函数调用结束后要返回继续执行的地址)压入栈中 。这是为了确保函数执行完毕后,程序能回到正确的位置继续执行。
  2. 传递参数:按照调用约定(如 C 语言中的 cdecl 、stdcall 等),将调用函数的参数从右向左(以cdecl为例)依次压入栈中。这样被调用函数可以按照顺序正确获取参数。
  3. 保存寄存器值:为了防止被调用函数修改调用函数中正在使用的寄存器值,通常会将调用函数中需要保护的寄存器(如通用寄存器 eaxebx 等)的值压入栈中 。在函数返回前,再将这些值恢复,保证调用函数的执行不受影响。
  4. 分配局部变量空间:在栈上为被调用函数的局部变量分配内存空间。比如函数中定义的普通变量、数组等,它们的内存都在栈帧中分配。
  5. 执行函数体:完成上述操作后,程序跳转到被调用函数的入口地址,开始执行函数体中的代码。

出栈过程(函数返回时)

  1. 清理局部变量:函数执行完毕后,首先释放为局部变量分配的栈空间。局部变量的生命周期随着函数执行结束而结束,它们所占用的栈内存会被释放。
  2. 恢复寄存器值:将之前压入栈中的寄存器值按照压入的相反顺序弹出,恢复到原来的寄存器中。这样,调用函数中的寄存器状态就恢复到了函数调用前的样子。
  3. 清理参数:根据调用约定,将栈上的函数参数弹出。在某些调用约定(如 cdecl)中,由调用函数负责清理参数;而在另一些约定(如 stdcall)中,由被调用函数清理参数。
  4. 获取返回地址:将栈顶保存的返回地址弹出,程序跳转到该地址,继续执行调用函数中函数调用之后的代码。

第三章 处理器

没意思(五) 流水线

流程

  1. 取指:依程序计数器(PC)从内存读指令,拆分出指令代码、功能等信息,还确定下条指令地址。
  2. 译码:从寄存器文件读操作数,为执行做准备。
  3. 执行:算术/逻辑单元(ALU)按指令操作,算地址、改栈指针等,还可能处理条件、跳转判断。
  4. 访存:数据写入内存,或从内存读出数据。
  5. 写回:把执行结果写回寄存器文件。
  6. 更新 PC:把下条指令地址设为 PC,让处理器继续执行。

流水线冒险

流水线冒险是处理器执行指令流水线中,下条指令无法按预期在时钟周期执行的情况。

  • 结构冒险(Structural Hazard)
  • 因缺乏硬件支持而导致指令不能在预定的时钟周期内执行的情况。
    • 本质:硬件资源不够用,多条指令“抢同一套硬件”,导致流水线卡壳。
    • 举例
      比如处理器只有一个存储器,既得给取指阶段“供应指令”(从内存读指令),又得给访存阶段“读写数据”(比如 lw / sw 指令访问内存)。要是取指的指令和访存的指令“撞车”(同一时钟周期都要访问内存),硬件没法同时满足,后面指令就得等,流水线就“断流”——这就是结构冒险。
  • 数据冒险(Data Hazard)
  • 因无法提供指令执行所需数据而导致指令不能在预定的时钟周期内执行的情况。
    • 本质:指令之间“数据依赖”没处理好,后面指令要用的数据,前面指令还没算完/没写回。
    • 举例
      指令 1:add $t0, $t1, $t2(把 $t1$t2 相加,结果存在 $t0
      指令 2:sub $t3, $t0, $t4(用 $t0 的值减 $t4,存在 $t3
    • 流水线里,指令 2 可能在“译码/执行阶段”就需要 $t0 的值,但指令 1 还没到“写回阶段”(没把结果真正存进 $t0 )。这时候指令 2 拿到的 $t0 是旧数据,结果就会错——这就是数据冒险。

很容易想到的解决办法就是"轮空", 但这样会浪费大量时钟周期。

[!note] 前推(旁路)相关概念 一种最基本的解决方法是基于以下实现:在解决数据冒险问题之前不需要等待指令的执行结束。对于上述的代码序列,一旦 ALU 生成了加法运算的结果,就可以将它用作减法运算的一个输入项。从内部资源中直接提前得到缺少的运算项的过程称为前推(forwarding) 或者旁路(bypassing)

  • 前推:也称为旁路。一种解决数据冒险的方法,具体做法是从内部寄存器而非程序员可见的寄存器或存储器中提前取出数据。
  • “前推” 这个名称来源于将结果从前面的指令直接发送到后面的指令的思想。“旁路” 这个名称来源于把寄存器堆中的结果直接传递到需要的单元中。
  • 控制冒险(Control Hazard)
  • 因为取到的指令并不是所需要的(或者说指令地址的变化并不是流水线所预期的)而导致指令不能在预定的时钟周期内执行。
    • 本质:指令执行顺序“被跳转/分支指令打乱”,流水线不知道该“取哪条指令”,导致断流。
    • 举例
      指令:beq $t0, $t1, LABEL(如果 $t0 等于 $t1,跳转到 LABEL 处执行;否则继续执行下条指令)
    • 流水线在“执行阶段”才知道要不要跳转,但“取指阶段”已经把下条指令读进来了。如果真的跳转,已经读的指令就没用了,得重新取 LABEL 处的指令,这期间流水线就会“空转”——这就是控制冒险。

[!note] 阻塞和预测 - 阻塞(stall):一种可能的解决方法是取分支指令后立即阻塞流水线,直到流水线确定分支指令的结果并知道下一条真正要执行的指令在哪为止。 - 预测(predict): 计算机的确是采用预测的方法来处理分支的。一种简单的预测方法就是总预测分支未发生。当预测正确(分支未发生)的时候,流水线会全速地执行。只有当分支发生时流水线才会阻塞。 - 分支预测:一种解决分支冒险的方法。它预测分支结果并立即沿预测方向执行,而不是等真正的分支结果确定后才开始执行。

第四章 存储器处理结构

没意思(六)Cache

前置

  • 块或行:可存在于或不存在于 cache 中的信息的最小单元。
  • 命中率:在高层存储器中找到目标数据的存储访问比例。
  • 缺失率:在高层存储器中没有找到目标数据的存储访问比例。
  • 命中时间:访问某存储器层次结构所需要的时间,包括了判断当前访问是命中还是缺失所需的时间。
  • 缺失代价:将相应的块从低层存储器替换到高层存储器所需的时间,包括访问块、将数据逐层传输、将数据插入发生缺失的层和将信息块传送给请求者的时间。
  • 有效位:表中的一个字段,用来标识一个块是否含有一个有效数据。
  • 标记:表中的一个字段,包含了地址信息,这些地址信息可以用来判断 cache 中的字是否就是所请求的字。
高速缓存存储器.png

[!note] 补充 - 块是 “数据搬运的最小套餐” 。 - 主存和 Cache 不是 “单个字节” 来回传数据,而是一次性传一个 “块”。 - 比如块大小是 4 字节(1 字),主存给 Cache 传数据时,不是只传 1 个字节,而是一次性传 4 个字节的 “块”。 - 这么做是为了利用程序的 “局部性原理”:程序访问内存时,往往会 “连续访问相邻数据”(比如数组遍历、指令顺序执行)。提前把 “一块数据” 搬进 Cache,下次访问附近数据时,直接从 Cache 拿,不用再跑主存,效率更高。 - 块的大小决定 “偏移位数” 。 - 块越大,包含的字节越多,需要的 “偏移(Offset)” 位数也越多(因为要定位块内具体字节)。 - 比如: - 块大小 = 1 字节 → 偏移 0 位(只有 1 个字节,不用定位)。 - 块大小 = 4 字节 → 偏移 2 位(2²=4,对应块内 4 个字节位置:00、01、10、11 )。 - 块大小 = 8 字节 → 偏移 3 位(2³=8,对应 8 个位置)。 - 块在 Cache 里的 “占位” 逻辑 - Cache 里的每个 “槽位”(可以理解为 Cache 的一个存储位置),刚好存一个块的数据,再加上 “标记(Tag)” 和 “有效位(Valid Bit)”: - 标记(Tag):存主存地址的 “高位特征”,用来判断这个块是不是 CPU 要访问的主存数据。 - 有效位:标记这个块里的数据是否 “有效”(比如刚加载的新数据有效,替换出去的旧数据可能无效)。 - 块数据:从主存搬过来的 “一块数据”(比如 4 字节)。

Cache.png

Interesting Problem

为什么用中间位.png

Cache映射 直接、组相联与全相联

  • 直接相联映射(Direct Mapped Cache): 固定的映射关系;
  • 全相联映射(Fully Associative Cache): 灵活的映射关系;
  • 组相联映射(N-way Set Associative Cache): 前两种方案的折中方法。、

(1) 直接相联映射 - 直接相联映射的策略:在内存块和缓存块之间建立起固定的映射关系,一个内存块总是映射到同一个缓存块上。如前置所展示的。

(2) 全相联映射 - 对于直接映射存在2个问题: - 问题1,缓存利用不充分:每个内存块只能映射到固定的位置上,即使Cache上有空闲位置也不会使用。 - 问题2,块冲突率高:直接映射会频繁出现块冲突,影响缓存命中率。

  • 为了改进直接相联映射的缺点,全相联映射的策略是:允许内存块映射到任何一个Cache块上。

[!note] 组相联 组相联高速缓存中的行匹配比直接映射高速缓存中的更复杂,因为它必须检查多个行的标记位和有效位,以确定所请求的字是否在集合中。传统的内存是一个值的数组,以地址作为输入,并返回存储在那个地址的值。另一方面,相联存储器是一个(key,value)对的数组,以key为输入,返回与输入的key相匹配的(key,value)对中的value值。因此,我们可以把组相联高速缓存中的每个组都看成一个小的相联存储器,key是标记和有效位,而value就是块的内容。

(3) 组相联映射 - 组相联映射结合了直接相联映射和全相联映射的优点,组相联映射的策略是:将Cache分为多组,每个内存块固定映射到一个分组中,又允许映射到组内的任意Cache块。显然,组相联的分组为1时,就等于全相联映射,而分组等于Cache块个数时,就等于直接映射。 - 兼顾速度与灵活性:查找范围限于组内(快于全相联),组内多块可选(冲突失效少于直接映射),硬件成本适中; 缺点是仍可能发生组内冲突,且组大小(路数)需权衡(路数越多成本越高)。

Cache 读写

  • 写直达:也译为写通过或写穿。写操作总是同时更新cache和下一存储器层次,以保持二者一致性。
  • 当 CPU 执行写操作时,同时更新缓存和主存,确保缓存与主存中的数据始终保持一致。
    • 优点:
      • 数据·一致性最强:缓存和主存实时同步,无需担心缓存块替换时的数据丢失。
      • 实现简单:无需标记缓存块是否被修改(无需“脏位”)。
    • 问题:
      • 写操作延迟高:每次写操作都要等待主存更新完成,而主存速度远慢于缓存,会拖累 CPU 效率。
  • 写缓冲:一个保存等待写入主存数据的缓冲队列。
  • 作为写直达策略的优化手段,在缓存与主存之间增加一个临时缓冲队列。当 CPU 执行写操作时,先将数据写入缓存和写缓冲,随后由硬件异步将缓冲中的数据批量写入主存,CPU 无需等待主存完成写入即可继续执行。
    • 优点:
      • 减少 CPU 等待时间:通过缓冲掩盖主存写入的延迟,提升写操作效率。
      • 兼容写直达的一致性:最终仍会将数据同步到主存,保持一致性。
    • 问题:
      • 缓冲容量有限:若连续写操作过快,可能导致缓冲溢出,仍需 CPU 等待。
      • 增加硬件复杂度:需要管理缓冲队列的读写顺序和冲突(如读操作需先检查缓冲)。
  • 写回:当发生写操作时,新值仅仅被写入cache块中,只有当修改过的块被替换时才写到较低层存储结构中。
  • CPU 执行写操作时,只更新缓存中的数据,不立即写入主存。仅当该缓存块被替换出缓存时,才将修改过的数据写回主存(通过“脏位”标记哪些块被修改过)
    • 优点
      • 写操作性能极高:避免频繁写入主存,尤其适合对同一缓存块的多次连续写操作(只需一次最终写回)。
    • 问题:
      • 数据一致性较弱:缓存与主存可能暂时不一致,若发生断电或缓存故障,可能丢失未写回的数据。
      • 实现复杂:需要维护“脏位”标记,并在替换时判断是否需要写回主存。

[!note] 写直达 写直达(Write - Through)是 “写操作同时更新 Cache + 主存” 的策略,当发生写缺失(要写的数据不在 Cache 里)时,有两种处理方式: 1. 写分配(Write Allocate): - 流程:从主存取回对应数据块,放入 Cache,再在 Cache 里改写数据(相当于 “先读入,再修改” )。

  1. 写不分配(No Write Allocate)
    • 流程:不把主存数据读入 Cache,直接写主存(仅修改主存,跳过 Cache )。
    • 比如:操作系统填零一页,没有必要读入数据再修改

[!note] 写回 写回(Write - Back)是 “只改 Cache,替换时再回写主存” 的策略,实现比写直达复杂,核心差异在 缺失处理” 和 执行周期: 1. 缺失处理的风险: - 写直达:Cache 里数据和主存一致,就算写缺失、标记不匹配,直接写主存即可,无数据冲突。 - 写回:Cache 里可能存 “改过但没回写主存” 的数据(脏数据)。如果写操作触发缺失,必须先把脏数据块回写主存,否则会丢失修改。 2. 执行周期的差异: - 写直达:写操作可 “一个周期” 完成(读标记→匹配则改 Cache + 主存;不匹配则触发缺失、直接写主存 )。 - 写回:写操作需 “两个周期”(第一周期查是否命中;命中则第二周期改 Cache ),或依赖 写缓冲 优化(把数据先放缓冲,流水线并行处理,让写操作看似 “一个周期” 完成 )。

没意思(七)虚拟内存

前置

  • 计算机系统的主存被组织成一个由 M 个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址(Physical Address,PA)。
物理寻址.png
  • 虚拟存储器中,块被称为页(page),访问缺失则被称为缺页(page fault)。
  • 在虚拟存储器中,处理器产生一个虚拟地址(virtual address),再结合软硬件转换成一个物理地址(physical address),然后就可以被用来访问主存了。
  • 如果还拿图书馆作类比,我们可以认为一本书的书名就是虚拟地址,物理地址则是这本书在图书馆中的位置,它可能是图书馆的索书号。
虚拟内存与物理映射.png

[!note] 补充一 页表、程序计数器以及寄存器,确定了一个虚拟机的状态(state)。如果我们想让另一个虚拟机使用处理器,我们必须保存该状态。随后,在恢复了该状态之后,虚拟机就可以继续执行。我们通常称该状态为一个进程(process)。如果一个进程占据了处理器,那么这个进程就是活跃的(active),否则就认为它是非活跃的(in - active)。操作系统可以通过加载进程的状态令一个进程活跃起来,同时激活程序计数器,进程将会在程序计数器中保存的值处开始执行。

页表.png > [!note] 补充二 > - Dirty bit > - 为了追踪读入主存中的页是否被写过,可以在页表中增加一个脏位(dirty bit)。当页中任何字被写时就将这一位置位。如果操作系统选择替换某一页,脏位指明了在把该页所占用的主存让给另一页之前,是否需要将该页写回磁盘。因此,一个修改过的页也通常被称为脏页(dirty page)。 > - Use bit > - 为了帮助操作系统估算最近最少使用的页,一些计算机提供了一个引用位(reference bit)或者称为使用位(use bit),当一页被访问时该位被置位。操作系统定期将引用位清零,然后再重新记录,这样就可以判定在这段特定时间内哪些页被访问过。有了这些使用信息,操作系统就可以从那些最近最少访问的页中选择一页(通过检查其引用位是否关闭)。

TLB

由来

  • 由于页表存放在主存中,因此程序每次访存至少需要两次:第一次访存先获得物理地址,第二次访存才获得数据。提高访问性能的关键在于依靠页表的访问局部性。
  • 当一个转换的虚页号被使用时,它可能在不久的将来再次被用到,因为对该页中字的引用同时具有时间局部性和空间局部性。
  • 因此,现代处理器都包含一个特殊的 cache 以跟踪最近使用过的地址变换。这个特殊的地址转换 cache 通常称为快表(Translation-Lookaside Buffer,TLB)(将其称为地址变换高速缓存更精确)。
  • TLB 就相当于记录目录中的一些书的位置的小纸片;我们在纸片上记录一些书的位置,并且将小纸片当成图书馆索书号的 cache,这样就不用一直在整个目录中搜索了。
  • 快表:用于记录最近使用地址的映射信息的高速缓存,从而可以避免每次都要访问页表。

过程处理

  • TLB的每个标记项存放虚页号的一部分,每个数据项中存放了物理页号。由于我们每次访问的是TLB而不是页表,TLB需要包括其他状态位,如脏位和引用位。每次访问,我们都要在TLB中查找虚页号。
  • 如果命中,物理页号就用来形成地址,相应的引用位被置位。
  • 如果处理器执行的是写操作,脏位同样要被置位。
  • 如果TLB发生缺失,我们必须判断是发生缺页还是仅仅是一次TLB缺失。
  • 如果该页在主存中,那么TLB缺失只是一次转换缺失。在这种情况下,处理器可以通过将页表中的变换装载到TLB中并且重新访问来进行缺失处理。
  • 如果该页不在主存中,TLB缺失就是一次真的缺页。在这种情况下,处理器调用操作系统的异常处理。由于TLB中的项比主存中的页数少得多,发生TLB缺失会比缺页频繁得多。 TLB缺失既可以通过硬件处理,也可以通过软件处理。
  • 实际上,两种方法的性能差别很小,这是因为无论哪种方法,需要执行的基本操作都是一样的。在发生了TLB缺失,并且已经在页表中找到了缺失的变化时,我们就需要从TLB中选择一项进行替换。由于TLB表项中包含了引用位和脏位,当替换某一项时,需要把这些位复制回页表项中。这些位是TLB表项中唯一可以修改的部分。利用写回策略——只是在缺失的时候将这些表项写回而不是任何写操作都写回——是非常有效的,因为我们期望TLB 缺失率更低。一些系统使用其他技术来近似引用位和脏位,以消除除了缺失后装入新表项之外写TLB的必要。 TLB.png
Cache总流程.png

缺失与缺页

  • 页在主存中,只需要创建缺失的 TLB 表项。
  • 页不在主存中,需要将控制权交给操作系统来解决缺页。
    • 操作系统使用虚拟地址查找页表项,并在磁盘上找到被访问的页的位置。
    • 选择替换一个物理页;如果被选中的页被修改过,需要在把新的虚拟页装入之前将这个物理页写回到磁盘上。
    • 启动读操作,将被访问的页从磁盘上取回到所选择的物理页的位置上。 ### 其他
  • 虚拟寻址 cache:一种使用虚拟地址而不是物理地址访问的 cache。
  • 别名:使用两个地址访问同一个目标的情形,一般发生在虚拟存储器中两个虚拟地址对应到同一个物理页时。
  • 物理寻址 cache:使用物理地址寻址的 cache。

第五章 链接

没意思(八)链接

处理流程

  • 预处理器:将 C 语言代码 (da. C) 转化成 da.i 文件 (gcc –E),对应于预处理命令 cpp
  • 编译器:C 语言代码 (da. C, wang. C) 经过编译器的处理 (gcc -0g -S) 成为汇编代码 (da. S, wang. S)
  • 汇编器:汇编代码 (da. S, wang. S) 经过汇编器的处理 (gcc 或 as) 成为对象程序 (da. O, wang. O)
  • 链接器:对象程序 (da. O, wang. O) 以及所需静态库 (lib. A) 经过链接器的处理 (gcc 或 ld) 最终成为计算机可执行的程序
  • 加载器:将可执行程序加载到内存并进行执行,loader 和 ld-linux.so

三种对象文件

所谓的对象文件 (Object File) 实际上是一个统称,具体来说有以下三种形式: - 可重定位目标文件 Relocatable object file (.o file) - 每个 .o 文件都是由对应的 .c 文件通过编译器和汇编器生成,包含代码和数据,可以与其他可重定位目标文件合并创建一个可执行或共享的目标文件 - 可执行目标文件 Executable object file (a.out file) - 由链接器生成,可以直接通过加载器加载到内存中充当进程执行的文件,包含代码和数据 - 共享目标文件 Shared object file (.so file) - 在 windows 中被称为 Dynamic Link Libraries (DLLs),是类特殊的可重定位目标文件,可以在链接 (静态共享库) 时加入目标文件或加载时或运行时 (动态共享库) 被动态的加载到内存并执行

静态链接

静态链接.png

可重定位目标文件

  •  Executable and Linkable Format (ELF)   可重定位目标文件.png

    ELF解析1.png
    ELF解析2.png

符号表与符号

每个可重定位目标模块 m 都有一个符号表,它包含 m 定义和引用的符号的信息。

  • 全局符号:由模块 m 定义且能被其他模块引用,对应非静态 C 函数和全局变量。

  • 外部符号:由其他模块定义,被模块 m 引用,对应其他模块中定义的非静态 C 函数和全局变量。

  • 局部符号:仅在模块 m 内定义和引用,对应带 static 属性的 C 函数和全局变量,只能在模块 m 内可见,不能被其他模块引用。

  • 补充说明:. Symtab 中的符号表不包含对应本地非静态程序变量的符号,因其在运行时栈中管理,链接器不关心。带 C static 属性的本地过程变量不在栈中管理,编译器会在. Data 或. Bss 为其分配空间,并在符号表创建本地链接器符号。==函数中的 static 变量不在栈中==。

链接过程

链接器主要是将有关的目标文件彼此相连接生成可加载、可执行的目标文件。链接器的核心工作就是符号表解析和重定位。

符号解析

局部符号引用解析
  • 对于在相同模块中定义和引用的局部符号,符号解析很简单。因为编译器规定每个模块中每个局部符号只能有一个定义,静态局部变量也会有本地链接器符号且名字唯一。

补充一:

1
2
3
4
5
6
7
8
9
// 函数定义,属于在当前模块(example.c)中定义  
void printMessage() {
printf("Hello, World!\n");
}

int main() {
printMessage(); // 对 printMessage 函数的引用,与定义在相同模块(example.c)中
return 0;
}

这里的 printMessage 函数的定义和 main 函数对它的引用,都在 example.c 这一模块内,就属于 “在相同模块中定义”。这种情况下,符号的作用域局限于该模块,编译器处理其引用和解析时,范围明确,规则也相对简单(如局部符号每个模块只允许一个定义)。

补充二:

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

// 模块内函数
void func() {
// 静态局部变量,编译器会为其创建唯一本地链接器符号
static int localVar = 0;
localVar++;
printf("Local static variable: %d\n", localVar);
}

int main() {
func();
func();
return 0;
}
在上述代码中,func 函数里的 localVar 是静态局部变量。编译器在处理时,会为它生成独有的本地链接器符号。当在 func 函数内对 localVar 进行引用操作(如 localVar++ )时,链接器能根据编译器设定的规则,迅速将引用和该变量的定义关联起来,完成符号解析。

全局符号引用解析
  • 当编译器遇到非当前模块定义的符号(变量或函数名 )时,会假定该符号在其他模块定义,生成链接器符号表条目并交给链接器处理。
  • 若链接器在输入模块中找不到被引用符号的定义,就会输出错误信息并终止链接过程。以在 Linux 机器上编译包含未定义函数 foo 调用的 linkerror.c 文件为例,编译器可正常运行,但链接器因无法解析 foo 的引用而终止,并给出错误提示。

链接器只知道非静态的全局变量/函数,而对于局部变量一无所知。局部非静态变量和局部静态变量的区别: - 局部非静态变量会保存在栈中 - 局部静态变量会保存在 .bss 或 .data 中

如果两个函数中定义了同名的静态变量会怎么样呢? 那也就是重整 符号重整.png

规则: 符号解析规则.png

重定位

一旦链接器完成了符号解析这一步,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。重定位由两步组成:

  • 重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输入模块的. Data 节被全部合并成一个节,这个节成为输出的可执行目标文件的. Data 节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
  • 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目(relocation entry)的数据结构。

静态库与共享库

静态库

  • 静态库是一个外部函数与变量的集合体。静态库的文件内容,通常包含一堆程序员自定的变量与函数,其内容不像动态链接库那么复杂,在编译期间由编译器与连接器将它集成至应用程序内,并制作成目标文件以及可以独立运作的可执行文件。而这个可执行文件与编译可执行文件的程序,都是一种程序的静态创建(static build)
  • 具体过程就是把不同文件的 .o 文件通过 Archiver 打包成为一个 .a 文件。Archiver 支持增量更新,如果有函数变动,只需要重新编译改动的部分。
静态库.png

链接器是如何解析外部引用的呢?详细的步骤为:

  • 扫描当前命令中的 .o 和 .a 文件
  • 扫描过程中,维护一个当前未解析引用的列表
  • 扫描到新的 .o 或 .a 文件时,试图去寻找未解析引用
  • 如果扫描结束时仍旧有为解析的引用,则报错 因为是按顺序查找,所以实际上是有引用依赖问题的,也就是说写编译命令的时候,顺序是很重要的。

共享库

共享库.png
  • 重定位动态库
    • 将 libc.so(标准 C 库动态链接库)的文本(代码)和数据重定位到某个内存段。
    • 将 libvector.so 的文本和数据重定位到另一个内存段。
  • 重定位符号引用:对 prog21 程序中所有由 libc.so 和 libvector.so 定义的符号的引用进行重定位,确保这些引用指向正确的运行时地址。
  • 控制权转移与共享库位置固定:动态链接器完成上述操作后,将控制权传递给应用程序。从此刻起,共享库(如 libc.solibvector.so)在内存中的位置固定,程序执行过程中不再改变。这保证了程序运行时对共享库符号引用的稳定性和一致性。