CSAPP学习笔记(施工中)

CSAPP

信息的处理和表示

数字的存储

内存被划分为不同大小的字块,32位CPU->4字节,64位CPU->8字节
对字长\(w\)的机器而言,虚拟地址范围为\(0~2^w-1\),即有\(2^w\)个字节
64位架构地址空间限制为48位虚拟地址,约为\(256TB\)(\(2^{48}Bytes\)),但是仍然在64位逻辑上处理算术运算
编译器通过保持字节对齐,提高硬件效率
无论32位机器还是64位机器,\(int\_32t\)(int)和\(int\_64t\)(long long)都分别占4个和8个字节,32位机器在具体实现上,采用两个32位\(int\_32t\)模拟64位\(int\_64t\)
而浮点寄存器有自己的宽度标准,与通用寄存器宽度无关,如\(double\)始终占8个字节

小端法:最低有效位所在字节存储地址在最前面,类型转换灵活,符合数学思维
大端法:最高有效位所在字节存储地址在最前面,方便阅读识别,便于判断正负
大小端法的核心决定因素:CPU架构
x86/x86-64使用小端法
PowerPC,网络协议(TCP/IP)使用大端法
ARM, RISC-V, MIPS等同时支持两种字节序

二进制下的整数

1. 补码

对于 \(w\) 位的无符号整数\(x\)\(x = \sum\limits_{i=0}^{w-1}a_i \times2^i\)
对于\(w\)位的有符号整数\(x\) : \(x = -2^{w-1} \times a_{w - 1}+\sum\limits_{i=0}^{w-2}a_i \times2^i\)(补码表示)
在此种表示方式下有符号整数\(-x\)与无符号整数\(2^w-x\)二进制编码相同
原理:在\(mod\) \(2^w\) 意义下 \(-x\)\(2^n - x\)等价, 溢出的情况下仍满足加法与乘法结合律,交换律等定律
无符号整数与有符号整数的比较:有符号整数类型转换为无符号整数再进行比较 \(eg\): \(-1\) \(>\) \(0U\)
无符号整数与符号整数的转换:
\(-t+u=2^w (t<0)\)
\(t=u(t \geq 0)\)

2.符号扩展与数字截断

符号扩展:在不改变值的情况下提升一个\(w\)位有符号整数的位数

case1:若符号位\(a_{w-1} = 0\)

直接将\(a_w\)设为\(0\),扩展到了\(w+1\)

case2:若符号位\(a_{w-1}=1\)

将第\(a_w\)设为\(1\),第\(a_w\)\(a_{w-1}\)的贡献从\(-2^{w-1}\)\(-2^{w}+2^{w-1}\)不变,扩展到了\(w+1\)
即扩展到第\(w+1\)位时有\(a_w=a_{w-1}\)

数字截断:舍弃数字第\(k\)位及其以前的所有位
无符号数数字截断等价于对\(2^k\)取模

从较小整数类型转换到较大整数类型时,先进行符号扩展,然后再进行有无符号的类型转换
eg. 对于short x; (unsigned)x等价于(unsigned)(int)x

3.运算

对于两个\(w\)位整数的运算,加法得到的结果最多为\(w+1\)位,截断第\(w+1\)位得到\(w\)位整数,对无符号整数来说等价于对结果\(mod\) \(2^w\)
乘法得到的结果最多为\(2w\)位,截断前\(w\)位得到\(w\)位整数
\(w\)位整数与常数相乘的时候,编译器会根据上下文进行优化,优化方式取决于架构的指令集
eg. x*14=(x<<3)+(x<<2)+(x<<1)或((x<<3)-x)<<2(乘法分解为位移和加减法)
imul eax, edi, 14(编译器认为imul更快,直接使用乘法指令)

逻辑右移:右移一位后在最高位补0
算术右移:右移一位后,若符号位原本为1,则在最高位补1,否则补0
除以2的幂时:
无符号数采用逻辑右移,等价于向下取整
有符号数需要特殊处理:编译器会生成先加偏置再算术右移的代码,以保证向零取整的结果与C语言标准一致

二进制下的浮点数

1.浮点数的表示

标准化之前

对浮点数\(x\)有:\(x=\sum \limits_{i=-j}^{k}a_i \times2^i\)
左移,右移在算术上仍然等价于\(\times2\)\(\div 2\),但是误差大,受位数有限制约
标准浮点数表示法:IEEE浮点数

IEEE浮点数

符号位(s) + 阶码位(exp) + 尾数位(frac)
单精度32位 双精度64位 (和英特尔扩展精度80位)
32 = 1 + 8 + 23
64 = 1 + 11 + 52

IEEE浮点标准用\(V=(-1)^s \times 2^E * M\)表示浮点数

\(exp=\sum\limits_{i=0}^{k-1}2^ie_i\),\(bias=2^{k-1}-1\)(无符号整数)
\(frac = \sum\limits_{i=0}^{n-1}f_i\times2^{i-n}\)(分数)

1.规格化的情况(\(1 \leq exp \leq 2^k-2\))
\(E=exp-bias\)\(M=1.0 + frac\),那么有\(-2^{k-1} + 2 \leq E \leq 2^{k-1} -1\)

2.非规格化的情况(\(exp = 0\))
\(E=1-bias\)\(M=frac\)
用来表示\(0\)和极其接近\(0\)的值
注:\(+0.0\)\(-0.0\)是两个不同的数

3.特殊值(\(exp = 2^k-1\))
\(frac = 0\):\(V=INF\)
\(frac \neq 0\):\(V=NaN\)

注:
1.引入\(bias\)的意义:阶码无需引入符号位,使得正负相同的浮点数大小比较可以直接二进制按位比较
2.非规格化情况下\(E\)\(1-bias\),使得非规格化最大值为\(2^{2-{2^{k-1}}} \times (1-{2^{-n}})\),而规格化最小值为\(2^{2-{2^{k-1}}}\),二者十分接近

2.整数转浮点数

以将(int)114转换为float为例

1.转为二进制下科学计数法
\(114 = (1110010)_2 = (1.110010)_2 \times 2 ^ 6\)
2.去掉小数点前的1并在末尾添加0直到\(n\)(23)位,得到尾数部分
\(1.110010 -> 11001000000000000000000\)
3.将阶数加上偏移值\(2^{k-1}-1\)(127),转为二进制得到阶码部分
\(2^6 + 127 = 191 = (10111111)_2\)
4.加上符号位,将阶码尾数拼起来
\((01011111111001000000000000000000)_2\)

由该过程可知,整数二进制编码与其浮点数表示的尾数部分除第一个1外重合

3.思考:n位位数不能精确表示的最小正整数

假设阶码足够大,有\(n\)位尾数的浮点数不能精确表示的最小正整数为\(2^{n+1}+1\)

证明:\(n\)位的\(frac\)再加上尾数隐式的\(1.0\)一共\(n+1\)位,乘\(2^E\)可以看做左移\(E\)位,在阶码可以取到无穷大的情况下,可以取遍\(1 ~ 2^{n+1}-1\),同时在\(frac\)全为\(0\)的情况下,左移\(n+1\)位得到\(2^{n+1}\),即\(1~2^{n+1}\)都可以表出,而\(2^{n+1}+1\)无法表出
推论:\(double\)在能准确表示的最大正整数为\(2^{53}=9007199254740992\)
补充:\(n\)位尾数浮点数在\(x\)处的误差(可表示数的最小间隔) \(ULP(x)=2^{\lfloor \log_2 \lvert x \rvert \rfloor - n}\)
警钟撅烂:double r = 1e18+5; \(r\)的值仍然为\(10^{18}\)

4.浮点数的运算

舍入:找到最接近的值\(x\),使其可以按期望的浮点形式表示出来
向零舍入,向上舍入,向下舍入
向偶数舍入:优先向满足条件的最接近的数舍入,若存在两个这样的数,则向最低有效位为偶数的那个数舍入(避免了统计误差)
浮点加法,乘法运算不满足结合律,浮点乘法在加法上不存在分配律
eg.对于\(float\)\(1.14+1e18-1e18=0\)(舍入导致1.14丢失),\(1.14+(1e18-1e18)=1.14\)
无穷运算规则:+∞ + (+∞) = +∞,+∞ + (-∞) = NaN,+∞ × 0 = NaN
NaN的传播特性:任何包含NaN的运算结果都是NaN

5.C中浮点数与有符号数的转换

\(int\)\(float\)可能被舍入
\(double\)\(int,float\)可能会溢出或者被舍入
\(float,double\)\(int\)值会向零舍入,若溢出则产生整数不确定值\((100..00)_2\)\(-2^{w-1}\)

Datalab

Github
上古版本的c语法标准,需要变量声明在函数代码的顶部(
以及不能使用实现过的函数,导致每次用的时候需要手动展开((

程序的机器级表示

编译过程

以GCC编译C语言为例
linux> gcc -Og -o hello hello.c
(-Og 优化等级低,避免机器代码严重变形,便于进行调试)

  1. 预处理:将预处理指令进行替换 (头文件包含,宏展开,条件编译#if #ifdef #endif...),删除注释,得到处理后的.iC代码
  2. 编译:将.i文件进行编译得到.s汇编语言文件
  3. 汇编:将.s翻译成机器指令,得到二进制文件 (windows下.obj linux下.o)
  4. 链接:将.o文件和所需要的库文件组合在一起(头文件的函数在预处理中只声明,链接定位函数的具体实现),生成可执行文件

linux> gcc -Og -S hello.c 产生一个汇编文件 hello.s
linux> gcc -Og -c hello.c 产生二进制文件hello.o
linux> objdump -d hello.o 实现反汇编

机器级代码概述

指令集架构(ISA Instruction Set Architecture) 定义机器级代码的格式和行为,虽然指令在ISA层面被描述为顺序执行,但现代处理器采用流水线、乱序执行等技术实现并发执行,最终结果与顺序执行一致
x86系列:8086(16位架构) \(\rightarrow\) IA-32 (32位架构) \(\rightarrow\) x86-64(64位架构)
机器级程序使用多个硬件存储器组成的虚拟地址,操作系统负责将虚拟地址翻译成对应的物理地址
一条机器代码一般只执行非常基本的操作,如寄存器中两个数相加,存储器与寄存器之间传送数据,条件分支转移到新的指令地址等

汇编代码格式:AT&TIntel

  1. Intel 代码省略了指示大小的后缀:movq \(\rightarrow\) mov
  2. Intel 代码省略寄存器前面的"%":%rax \(\rightarrow\) rax
  3. Intel 代码描述内存的方式不同:(%rax) \(\rightarrow\) QWORD PTR [rax]
  4. Intel 与 AT&T列出操作数的顺序相反: movq %rax, %rbx \(\rightarrow\) mov rbx, rax

本文采用x86-64 AT&T格式

机器代码的优势:性能优化,访问硬件特性(PC,寄存器),更难逆向分析

数据的传输

数据格式

最初的架构为16位,所以Intel用 字(word) 表示16位,32位为双字(double words/long word),64位为四字(quad words)

GCC生成的部分汇编代码指令都带有一个字符后缀表示操作的大小,如movl表示传送双字

信息访问

x86-64 CPU 中含有16个通用寄存器

8086中含有%ax%sp8个通用寄存器
IA32将这8个寄存器扩展为32位 (%eax中的"e":extended)
x86-64将寄存器扩展到64位(%rax中的"r":register整个寄存器),并采用新的命名方式增加8个寄存器(%r8%r15%r8d中的"d":double words,%r8w中的"w":word)
指令通过不同的后缀大小(b,w,l,q),得以访问寄存器的不同最低字节(1,2,4,8)
小于8字节的数据传送到寄存器中时,若传送字节为1或2,则更高的字节不变;若传送字节为4,则更高的4个字节全置为0(IA32到x86-64扩展导致的)

操作数指示符

将寄存器看做数组\(R[]\),内存看做数组\(M[]\)

  1. 立即数(immediate):$后面接常数 eg. $114514
  2. 寄存器(register):以指定寄存器的低位1,2,4,8字节为操作数,返回寄存器中的值即\(R[r_a]\) eg. %eax返回第一个寄存器的低32位
  3. 内存引用:将内存看做很大的字节数组,根据计算的有效地址访问内存位置

内存引用的寻址模式:\(Imm(r_b,r_i,s)\),其中\(Imm\)为立即数偏移,\(r_b\)\(r_i\)为64位寄存器,\(s\)取1,2,4,8(默认为1)
有效地址被计算为\(addr=Imm+R[r_b]+R[r_i]*s\),返回\(M[addr]\)

数据传送指令

将数据从一个位置传送到另一个位置的指令

MOV

MOV S, D指将数据从\(S\)复制到\(D\),其中\(S\)称为源操作数,\(D\)称为目的操作数

其中源操作数可以取立即数,寄存器,内存;目的操作数可以取寄存器和内存
注意:x86架构中大部分操作(包括MOV)源操作数和目的操作数不能同时为内存,必须通过寄存器进行中转
\(movabsq\)作用:处理立即数时,\(movq\)只能传送32位立即数然后在符号扩展到64位,而\(movabsq\)能将64位立即数直接传送到寄存器,但只能移动立即数

MOVZ与MOVS

两者都是将较小源值复制到较大的目的值,后缀都有两个大小指示符,分别是源的大小和目的的大小
MOVZ 采用零扩展进行复制,无符号数扩展

注:没有movzlq的原因是使用movl传送4个字节会把前面更高的4个字节全赋为0,两者等价

MOVS采用符号扩展进行复制,有符号数扩展

压入和弹出栈数据

x86-64架构中,程序栈放在内存中某个区域,寄存器%rsp保存栈顶的地址
不同于C语言中用数组模拟实现栈时栈顶指针指向地址最高,%rsp指向的栈顶地址是栈中最小的

pushq %rbp等价于subq $8,%rsp movq %rbp,(%rsp),即将栈顶指针向前移动8个字节,再将元素入栈
pushq的优势:编码为1个字节,而等价代码编码为8个字节;同时现代x86-64处理器上pushq被高度优化,效率更高
栈内数据在内存中,同样可以用内存寻址法访问

算术和逻辑操作

四类操作:加载有效地址,一元操作,二元操作,移位
除去\(leaq\)只能用于8字节以外,其他指令都能用不同后缀(b,w,l,q)指示操作大小

加载有效地址

lea:load effective address
\(leaq Imm(r_b,r_i,s), r_d\) 表示采用内存计算的方法算出地址之后,将该有效地址作为数字直接存储到\(r_d\)
eg.%rbx的值为\(x\),则 leaq 6(%rbx,%rbx,4), %rax 表示计算\(5 \times x + 6\)并存放在\(%rax\)
作用:简洁地表示普通的算术操作

一元操作和二元操作

一元操作中的操作数既是源操作数又是目的操作数;二元操作中前者为源操作数,后者为目的操作数
eg. subq %rbx,%rax表示将\(R[rax]\)减去\(R[rbx]\)
同样,二元操作也不能源和目的都为内存,需要进行寄存器的中转

移位操作

SAR表示算术右移(Shift Arithmetic Right),SHR表示逻辑右移
为了对称,同时有SALSHL,但是两者作用完全等价,SHL更为常用
移位操作有两个操作数,前者为移位数(立即数或者%cl中的数 不能是其他寄存器或内存中的数),后者为移位的对象(内存或寄存器)
注:移位操作实际使用的移位位数为指令大小后缀对应的低位,如\(sarl\)实际移位数位给定数对32取模
eg. shlq $65 %rax 即为将%rax中的数左移1位

特殊算术操作

两个64位整数相乘时需要得到128位整数再进行截断,其中16字节的数称为八字(oct words)

imulq 的第一种形式:imulq S, DD 乘以 S,结果存储在 D
imulq的第二种形式:IMUL S,另一个参数在%rax中,乘积得到的128位结果高64位放在%rdx中,低64位放在%rax中(\(\_\_int128\))

idivq(有符号除法):将%rdx看做高64位,%rax看做低64位得到128位被除数,除数作为指令操作数给出,商存放在%rax中,余数存放在%rdx
当被除数为64位时,cqto指令通过符号扩展将被除数扩展到128位

divq(无符号除法):事先将%rdx设置为0,可以使用xorq %rdx, %rdx

控制

使得指令按一定的顺序执行(顺序结构,选择结构,循环结构)

条件码

部分指令(如计算,比较和测试,移位等)会设置条件码
注:leaq计算地址,不会设置条件码

CMPTEST进行类似于ADDAND的计算,不同之出在于不会将结果写入后一个地址,而只会设置条件码

通过CMP a, b可以得到\(a\)\(b\)的相对大小,通过AND a, a可以将\(a\)的特征存入条件码中

访问条件码

SET通过条件码的组合,将是否满足后缀条件(0/1布尔值)传送到一个低位字节中

如假设\(a\)存储在\(%rsi\)中,\(b\)存储在\(rdi\)中,执行\(cmpq %rdi, %rsi\)
setl %al表示将\(a\)寄存器的最低字节全部设置为\((bool) (a < b)\)

跳转指令

将指令执行的顺序改变,跳转到一个指定的位置

格式类似C中的goto-label

jmp被称为无条件跳转,其余被称为条件跳转
只有无条件跳转能执行间接跳转,如jmp *%rax跳转到%rax的值对应地址的指令,jmp *(%rax)跳转到%rax指向内存的值对应地址的指令

跳转指令的编码

将汇编代码编码后,跳转指令的label会被编码在该跳转指令的最后一个字节,值等于十六进制下当前指令的下一个指令地址与跳转指令地址的字节差(PC相对寻址,使得代码与位置无关,便于加载共享库)

如图所示,编码后文件每一行冒号前的数字表示该指令编码得到的第一个字节的地址
第二行中通过\(0x05 + 0x03 = 0x08\)得到这条指令会跳转到地址\(08\)即第四行执行
第五行中通过\(0x0d + 0xf8 = 0x05\)(注意是有符号运算)得到这条指令会跳转到地址\(05\)即第三行执行

条件传送与条件控制

汇编使用\(JMP\)实现\(if-else\)\(goto-lable\)进行跳转被称为条件控制

在一些特殊情况下,使用CMOV(conditional move) 的条件传送更为高效

CMOV可以自动检测操作数的类型,无需加上大小后缀,需要加上条件后缀
当条件码满足的时候,CMOV a, b 会将\(a\)的值写入\(b\)
使用条件传送实现\(if-else\)

if (condition){
	a = if_expr;
}
else {
	a = else_expr;
}

使用条件传送实现等价的代码为

int x = if_expr;
int y = else_expr;
a = condition? x:y; 

注意,以下代码不能使用条件传送

原因:不论是否为空指针,都会提前计算\(0\)\(*xp\),在指针为空的情况下会导致\(UB\)

相较于条件控制,条件传送在一些情况下更为高效
原因:现代处理器使用流水线来得到高性能,即通过预先确定要执行的指令序列来使得流水线尽量充满。在遇见指令的跳转时,处理器通过分支预测逻辑猜测是否会进行跳转,并按猜测的结果为后续指令预处理。当猜测出错时,处理器只能丢掉已经做过的处理,重新填充流水线导致效率大大降低。在是否跳转极其难以预测的情况下(如if (x & 1)),性能会受到严重影响。而条件传送没有改变程序计数器(PC),下一条指令的地址确定,保证了流水线尽量填满。

编译器会在两个分支计算简单且计算代价差异小,分支难以预测,无副作用,优化程度高(-O2 -O3)的情况下优先选择条件传送

switch 语句

在开关大跨度稀疏时,switch会被编译为条件控制
在开关小跨度密集时,switch会被编译为跳转表

eg. 左侧为C源代码,右侧为与跳转表类似的C实现 (&&表示指向代码位置的指针)

编译得到跳转表的相对地址(相邻的\(lable\)相差1个字节)

汇编代码

循环

将条件测试和跳转组合起来即可得到循环

do-while

while有两种形式

  1. jump to middle(先进入循环,在循环中途再检查条件)

2.guarded-do(先检查条件,再进入循环)

for

注意:当在\(body-statement\)中存在\(continue\)语句时,\(continue\)应当跳转到\(update-expr\)部分,否则可能导致死循环等问题
现代编译器会根据循环特征自动选择最优的实现方式,并进行循环展开等优化

过程

一种封装代码的方式,隐藏具体的实现,同时提供清晰的接口(类比C中的函数)
过程包含的机制(假设从过程\(P\)进入过程\(Q\)):

  1. 传递控制:将PC设为\(Q\)过程起始指令的地址,执行完\(Q\)过程后再将\(PC\)设置为\(P\)中调用\(Q\)下一条指令的地址
  2. 传递数据:\(P\)将特定的参数传递到过程\(Q\)中,同时\(Q\)\(P\)传递返回值
  3. 内存的分配与释放:为局部变量分配空间,并在过程结束时将这些空间释放

运行时栈

在过程\(Q\)被执行时,其调用链上的所有信息的挂起在栈上,栈中存放着传递控制,传递数据,内存分配释放的信息
栈帧:过程在寄存器中存放不下,存放在栈中的空间
局部变量都可以存储在寄存器中并且为叶子过程(未调用其他过程的过程),则不需要栈帧
注:%rsp为栈指针,%rip为PC

传递控制

从过程\(P\)到过程\(Q\)
call Q将PC跳转到\(Q\)起始指令的地址,并将call指令的下一条地址(返回地址)压入栈中
ret从栈中弹出返回地址,并将PC设置为返回地址

传递数据

\(x86-64\)架构中最多可以用6个寄存器来传送参数

当传递的参数大于6时,需要通过栈来进行传送(参数构造区)

eg.

注:通过栈传送数据时,第7个及以后的参数按从右到左的顺序压入栈中,第7个参数在栈顶(低地址),后续参数依次向高地址排列

内存的分配与释放

当寄存器不足以存放局部数据,或者局部数据被取地址(&)需要被分配地址时,会在栈上为这个数据分配空间,形成局部变量

eg.

其中:

  1. x1 x2 x3被取地址,作为局部变量在栈上分配空间,同时也属于前6个参数,传入寄存器
  2. &x1 &x2 &x3属于前6个参数,传入寄存器,又未在该过程后续被直接使用,不是局部变量
  3. x4被取地址,作为局部变量在栈上分配空间,同时也在栈上再次分配一个不同的空间,传送这个参数
  4. &x4通过在栈上分配空间作为参数传送,但不是局部变量

在执行call proc时,会在栈中压入长8个字节的返回地址,使得\(x4\)\(\&x4\)相对栈的地址偏移量变为8和16个字节,proc过程得以正确执行
注意:
栈帧内存的释放只是改变了栈指针,没有改变曾经的参数和局部变量的值,意外访问这些位置可能读到旧数据

寄存器中的局部存储空间

被调用者保存寄存器:%rbx, %rbp, %r12-%r15(调用前后值不变)
过程\(P\)调用过程\(Q\),当过程\(Q\)回到过程\(P\)时,必须保证以上寄存器的值与其调用\(Q\)前的值相等
这些寄存器可以作为临时变量,保存\(P\)中的某些信息,防止在\(Q\)中这些信息被改变(如参数,\(P\)\(Q\)参数可能不同)
实现:将过程中要使用的作为临时变量的寄存器,在过程开始时压入栈中,中间任意变换,结束过程时再从栈中弹出覆盖这些寄存器的值即可

调用者保存寄存器:%rax, %rcx, %rdx, %rsi, %rdi, %r8-%r11(调用后可能被修改)

eg.

递归过程

每个过程相关信息在栈上都有私有空间,互不干扰,使得递归能够高效而正确地实现

eg.

当递归调用是函数体中最后执行的操作时,编译器可能进行尾递归优化,将其转换为循环

数组的分配与访问

通过对数组首地址进行运算,得到需要访问的元素的地址
eg. T A[N]会在内存中以\(x_A\)开始,连续分配\(L\times N\)大小的空间,其中\(L\)为单个\(T\)的大小
A[i]的地址 \(\&A[i] = x_A + i \ times L\)
例如,假设\(i\)的值存放在%rcx中,\(int\)数组首地址\(x_A\)的在%rdx中,通过movl (%rdx, %rcx, 4), %rax即可将\(A[i]\)的值存放在\(%rax\)

对于二维数组T A[N][M]来说,有 \(\&A[i][j] = x_A + L \times i \times m + L \times j = x_A + L \times (i \times m + j)\)

以此类推,对于\(k\)维数组T A[N][][]....来说,其\(\&A[i][][]...\)等于 \(x_A\) + \(i \times L(A[][]...)\) + \(k-1\)维中访问\(A[][]...\)的地址偏移量

定长数组与变长数组

编译器对定长数组(数组大小为常量)的优化:
通过指针加减移动进行寻址,而非用乘法进行寻址,提高寻址的效率

对于变长数组(数组大小为表达式):

由于数组的大小不确定,无法使用leaq,需要使用imul,时间开销更大,但编译器仍然尽量使用指针提高效率

异质的数据结构

struct 结构

同一结构内的变量都分配在连续的内存中
eg.

struct str{
	int l, r;
	long long val;
}s;

其中\(s\)是一个指针,指向str中第一个变量\(l\)的地址
假设%rdx存放了指针\(s\),通过8(%rdx)即可以访问\(s.val\)

联合 union

eg.

struct node_t { 
	nodetype_t type; 
	union { 
		struct { 
			node_t *left; 
			node_t *right; 
		} internal; 
		double data[2]; 
	} info; 
}; 

其中,internaldata[2]互斥 (若为叶子节点则只有两个值,没有儿子指针;若不是叶子节点,则有两个儿子指针,无权值)
union的大小为其中最大变量的大小,如以上的union中所占大小为16,通过union节省了空间

同时,union也可以实现保持位模式相同的类型转换

double x = 114514.1919810
unsigned val = (unsigned)x;

只是对于值保持相同(向下取整)进行类型转换

union{
	double x;
	unsigned val;
}u;
u.x = 114514.1919810;
unsigned val = u.val;

通过union,得以保证位模式相同(从xval二进制串相同,值会发生改变)

数据对齐

对于结构体中的变量,编译器通过数据对齐保证其地址为\(K\)的倍数(\(K=1,2,4,8\)),进而提高访问的效率
例如假设处理器每次操作从内存中取8个字节,对\(double\)进行字节对齐,使其不会落在两个不同的内存块中,保证了读写时处理器只会访问一个内存块,进而保证了操作的高效

字节对齐按以下步骤进行:

  1. 对于结构内的某个变量,在其后面的地址连续填充空隙,直到下一个地址地址为下一个变量字节大小的倍数,再将下一个变量填充进这个地址中
  2. \(M\)为结构内所有变量的字节最大值(最大对齐),在最后一个变量之后连续填充空隙,直到下一个地址为\(M\)的倍数

eg.

struct str{
	int a;
	char b;
	int c;
	char d;
};

其对齐后的结果为:
a: 0~3 b:4~4 空隙:5~7 c:8~11 d:12~12 空隙:13~15

杂项

指针

指针本质是一个二进制串表示的地址,其类型决定了每次对指针运算时的大小偏移量
对于指针类型强制转换,只会改变其每次的偏移量,而不改变指向的地址
eg. int *p; p++;会让p指向的地址向后移动四个字节
指针同样能够指向函数,即指向函数首指令的地址

缓冲区溢出

以C中的\(gets\)为例(不给定目标缓冲区的大小),当读入的字符大于定义的字符数组长度时,就可能越界访问当前函数的返回地址和栈中保存的其他函数状态

缓冲区溢出攻击

通过在输入的字符串中加入可执行代码(攻击代码),同时又用指向攻击代码的指针覆盖返回地址,导致系统被攻击
对抗溢出攻击

  1. 栈随机化:通过执行代码前分配一段长度为\(0~n\)随机字节大小而不使用的空间,使得每次运行时的栈地址不固定,攻击时不一定能跳转到攻击代码
    局限:nop sled 通过在攻击代码写入大量的nop,只要能跳转到其中任意一个nop就能执行攻击代码
    eg. 32位系统随机地址范围大小大约为\(2^{23}\)字节,在攻击代码前写入\(m\)字节大小的nop,执行\(\frac{2^{23}}{m}\) 次则一定会跳转到攻击代码
    如取\(m=256\),则大约需要三万次左右次枚举起始地址,攻击就能成功
  2. 栈破坏检测:通过在栈中插入一个与运行时无关的金丝雀值(canary),恢复栈的状态时,若该值被改变,则发生了溢出攻击,程序跳转到错误处理例程
    其中canary随机产生并且只读,避免了被攻击者覆盖
    注意:现代编译器默认采用栈破坏检测,gcc中可采用-fno-stack-protector将其关闭
    3.限制可执行代码区域:将栈上的某些部分设置为可读可写但不能执行(\(NX\))

浮点代码 (AVX2)

"我们不太清楚GCC为什么会生成这样的代码,这样做既没有好处,也没有必要在XMM寄存器中把这个值复制一遍"
看破防了,感觉CSAPP也是讲得稀里糊涂的,以后再补

Bomblab

posted @ 2025-11-20 22:44  Katyusha_Lzh  阅读(20)  评论(1)    收藏  举报