写在前面的话:此系列文章为笔者学习CSAPP时的个人笔记,分享出来与大家学习交流,目录大体与《深入理解计算机系统》书本一致。因是初次预习时写的笔记,在复习回看时发现部分内容存在一些小问题,因时间紧张来不及再次整理总结,希望读者理解。
《深入理解计算机系统(CSAPP)》第3章 程序的机器级表示 - 学习笔记_友人帐_的博客-CSDN博客
《深入理解计算机系统(CSAPP)》第5章 优化程序性能 - 学习笔记_友人帐_的博客-CSDN博客
《深入理解计算机系统(CSAPP)》第6章 存储器层次结构 - 学习笔记_友人帐_的博客-CSDN博客
《深入理解计算机系统(CSAPP)》第7章 链接- 学习笔记_友人帐_的博客-CSDN博客
《深入理解计算机系统(CSAPP)》第8章 异常控制流 - 学习笔记_友人帐_的博客-CSDN博客
《深入理解计算机系统(CSAPP)》第9章虚拟内存 - 学习笔记_友人帐_的博客-CSDN博客
第三章 程序的机器级表示
1. 代码编译过程
gcc -Og -o hello source1.c source2.c ...
gcc -Og -S -fno-if-conversion source.c // 优化等级低,便于理解
注:①-Og
为代码优化等级;②-o
表示生成可执行文件,-s
可以生成汇编代码;③生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个main函数;④有时会在末尾(return后)有90 nop
指令,目的是为了使函数代码变为16字节,使得存储器能更好地放置下一个代码块,提高性能。
源代码转化为可执行代码:
①C预处理器:扩展源代码,插入所有用#include命令指定的文件,并扩展所有#define声明指定的宏;
②编译器:生成源文件的汇编代码,后缀.s;
③汇编器:将汇编代码转化为二进制目标代码文件,后缀.o;
④链接器:将目标代码文件与实现库函数的代码合并,填入全局值的地址,生成可执行代码文件。
GDB调试指令
2. 反汇编
反汇编:将可执行二进制文件翻译为汇编语言
objdump -d target.o
objdump -S target.o > target.txt (在反汇编中加入C代码,且重定向输出到target.txt)
cat target.txt 查看
3. 汇编相关(AT&T格式)
3.1 伪指令
所有以.
开头的指令都是指导汇编器和链接器工作的伪指令;
3.2 汇编代码后缀
3.3 寄存器
(1)通用寄存器:
规则:生成1字节和2字节数字的指令会保持剩下的字节不变;生成4字节数字的指令会把高位4个字节置为0。
(2)段寄存器:
(3)指令指针寄存器EIP/PC
(4)EFLAGS寄存器
(5)系统寄存器
(6)浮点单元FPU
3.4 操作数指示符
操作数类型:
①立即数:用$
符号表示,代表常数值;
②寄存器:直接访问寄存器的值;
③内存寻址: I m m ( R b , R i , S ) = I m m + R b + R i ∗ S Imm(R_b,R_i,S)=Imm+R_b+R_i*S Imm(Rb,Ri,S)=Imm+Rb+Ri∗S,其中, I m m Imm Imm表示立即数偏移, R b R_b Rb表示基址寄存器, R i R_i Ri表示变址寄存器, S S S为比例因子,仅能取值1,2,4,8。计算得到的为一个地址,相当于解引用操作。
3.5 mov指令
注意:
①两个操作数不能同时指向内存位置。
②寄存器部分的大小必须与指令最后一个字符(‘b’,‘w’,‘l’,‘q’)指定的大小匹配。大多数情况,MOV指令只会更新目的操作数指定的寄存器字节或内存位置,但当movl指令以寄存器作为目的时,会把该寄存器的高4位设置为0(任何为寄存器生成32位值的指令都会把该寄存器的高位部分置为0)。
③不要给%rsp赋值(系统保留)。
扩展 - 零扩展(movz)、符号扩展(movs)+大小指示符(源,目的)
条件传送
cmov
cc
src, dst
cc
表示条件,使用EFLAGS中的条件码实现条件判断对于无符号数,使用a,b,e,b,c分别表示大于、小于、等于、否定、进位
对于有符号数,使用g,l,e,n,o分别表示大于、小于、等于、否定、溢出
3.6 栈操作
栈向下增长,栈顶为低地址。
指令:
示意:
3.7 算术和逻辑操作
注意:
leaq并不是取源S的地址放入寄存器D,而是将有效地址放入,例如若%rdx值为x,则指令leap 7(%rdx, %rdx, x) %rax
将寄存器%rax的值设置为5x+7
特殊的算数操作:八字
3.8 调试常用指令
- 编译:./mak64 try64
- 调试:gdb ./try64
- 设置断点:b _start
- 打开源代码窗口:layout src
layout split - 上方源代码,下方汇编
打开寄存器观察窗口:tui r g r表示register,g表示general,通用寄存器
运行:r
在断点处停止后,单步运行:n
4. IA32的内存管理
4.1 实地址模式
实地址模式:存储内容为真实物理地址。
内存分段:16位地址线不能直接表示20位地址,采用内存分段方式,使用两个16位地址来表示。表示为段地址:段内偏移量
;约定段地址低4为为0,便可以用16位地址表示段地址,如此将内存空间划分为64KB的段。而实际地址可以采用实际地址 = 段地址0 + 段内偏移量
计算得到(段地址后补
0
16
0_{16}
016)。
4.2 保护模式
将段描述符(8字节,段的相关信息)在段描述符表中的索引值存放在段寄存器(2字节)中。而在段描述符表中的每一行(每一个段描述符),都保存了段的相关参数、访问权限等信息。
GDT(全局描述符表)和LDT(局部描述符表)都用来存放各种描述符,例如段描述符,但这掩盖不了它们也是内存段的事实。简单地讲,他们也是段。但是,因为它们用于系统管理,故称为系统的段或者系统段。GDT是唯一的,整个系统中只有一个,所以只需要用GDTR寄存器(CPU中)存放其线性基地址和段界限即可。其中存储了操作系统使用的代码段、数据段、堆栈段的描述符以及各个程序的LDT段;但LDT不同,每个程序有一个独立的LDT,存放了对应程序的代码段、数据段、堆栈段的描述符等信息。当要使用这些LDT时,可以用它们的选择子来访问GDT,将LDT描述符加载到LDTR寄存器。
全局描述符表寄存器GDTR(48位)指向GDT在内存中的地址。局部描述符表寄存器LDTR(16位)指向LDT段在GDT中的索引。
在段选择器(保护模式下的段寄存器)中:
(1)平坦分段模式
而在段描述符表中:(16进制)所有段被映射到32位物理地址空间,程序至少分为代码段和数据段两个段。
全局描述符表GDT:
基址(32位)指向段的起始地址(图示中指向0000 0000,很低的地址空间为操作系统所用);
界限指该段的长度,其中,0040是相对值,要在后方加上000(乘以4k),即0040 000
,也说明了段的大小都是4k的倍数。
(2)多段模式
局部描述符表LDT:
保护模式下的段寻址总结:
(3)内存分页
5. 控制(条件、循环、分支)
5.1 条件码
CF
:进位标志。最高有效位有进位(无符号溢出)置为1,否则置0。
ZF
:零标志。最近的操作得出的结果为0。
SF
:符号标志。最近的操作得到的结果为负数。
OF
:溢出标志。最近的操作导致一个补码溢出(正溢出或负溢出)
除了leaq指令,其余算术和逻辑操作都伴随着进位标志的设置。
还有一些指令只用于设置条件吗而不改变任何其他寄存器。
SET指令:可以通过条件码的组合来进行各种条件操作(指令的后缀表示不同的条件而不是操作数大小)。目的操作数是低位单字节寄存器元素之一,或是一个字节的内存位置,指令会将这个字节设置为0或1。
cmp指令
cmpq src1, src2
计算src2-src1的值和0比较,仅用结果设置条件码,而不改变目的操作数。
test指令
testq src2, src1
根据src2 & src1 的数值来设置条件码,结果并不保存,通常将其中一个操作数看作是一个掩码。
5.2 跳转指令jump
jump指令会导致执行切换到程序中一个全新的位置。在汇编中,这些跳转的目的地通常用一个标号(label)指明。
直接跳转:jmp .L1 直接跳转到L1标号位置
间接跳转:jmp *%rax 以%rax中的值作为读地址,从内存中读出跳转目标
注:跳转条件为1表示无条件跳转。
跳转指令的编码
PC相对跳转
绝对地址跳转
5.3 用条件控制来实现条件分支
将if转化为goto类型,直接对应于汇编。
if-else的通用转换模板
// if-else版本
if (test-expr)
then-statement
else
else-statement
// goto版本
t = test-expr;
if (!t)
goto false;
then-statement
goto done;
false:
else-statement
done:
使用数据的条件转移来实现条件分支的好处:
(将未知的跳转转换为已知的计算,消除不确定性)
可以使得CPU的流水线中充满了待执行的指令,避免条件预测逻辑(猜测每条跳转指令是否会执行)预测错误而导致浪费CPU的时钟周期,以提高流水线性能。
条件传送指令:
5.4 循环
用条件测试和跳转组合起来实现循环(do-while、while、for)
// do-while通用形式
do
body-statement
while (test-expr);
// goto形式
loop:
body-statement
t = test-expr;
if (t)
goto loop;
/* ----------------- */
// while 通用形式
while (test-expr)
body-statement
// while goto形式1 - 跳转到中间
goto test;
loop:
body-statement
test:
t = test-expr;
if (t)
goto loop;
// while goto形式2 - guarded-do
// 先翻译为do-while,再翻译为goto
t = test-expr;
if (!t)
goto done;
loop:
body-statement
t = test-expr;
if (t)
goto loop;
done:
/* ----------------- */
// for 通用形式
for (init-expr; test-expr; update-expr)
body-statement
// for转化为while
init-expr;
while (test-expr) {
body-statement
update-expr;
}
5.5 switch语句
连续性较好的switch语句用跳转表,不好的用决策树(一堆if else)
switch使用跳转表(一个数组),表项 i i i是一个代码段的地址,这个代码段实现当switch索引值等于 i i i时程序所执行的指令。当switch情况比较多时(>4),并且值的范围跨度比较小时,就会使用跳转表。跳转表的优点是执行语句的时间与情况数量无关。
跳转表对重复情况的处理就是简单地使用相同的代码标号,而对于缺失的情况的处理就是使用默认情况的标号(loc_def)
6. 函数(过程)
6.1 过程机制
**传递控制:**调用时转到调用过程代码的起始位置,结束时回到返回点。
**传递数据:**过程参数与返回值的传递。
**内存管理:**过程运行期间申请内存,返回时解除分配。
该机制全部由机器指令实现。
6.2 栈
栈的结构:
注意:①%rsp始终指向栈顶元素的位置。②向低地址生长。
栈的操作
入栈指令
pushq src
从src取操作数 → 将%rsp减8 → 将操作数写到%rsp指向的位置
出栈指令
popq dst
从%rsp中保存的地址处读取数值 → 将%rsp加8 → 将数值保存到dst(dst必须为寄存器或内存操作数)
6.3 过程控制流
过程调用
call func_label
执行操作:
①将返回地址入栈(返回地址即为紧随call指令的下一条指令的地址)
②跳转到func_label(函数名字就是函数代码段的起始地址)
过程返回
ret
执行操作:
从栈中弹出返回地址,放入%rip里(pc)
参数传递
返回值:%rax
局部变量:仅在需要时申请栈空间
6.4 栈帧
栈的分配单位为帧,保存单个过程实例的状态数据(参数、局部变量、返回地址等)
管理:进入过程时申请空间(生成代码,构建栈帧,包括call指令产生的push操作),返回时解除申请(结束代码,清理栈帧,包括ret指令产生的pop操作)
寄存器保存约定
寄存器组是唯一被所有过程共享的资源,必须确保当一个过程调用另一个过程时,被调用者不会覆盖调用者稍后会使用的寄存器值。
①调用者保存Caller Saved
调用者在调用前,在它的栈帧中保存临时值(寄存器)。
②被调用者保存Callee Saved
被调用者要先在自己的栈帧中保存,然后再使用寄存器。返回到调用者之前,恢复这些保存的值。
一个过程运行的示例
long caller()
{
long arg1 = 534;
long arg2 = 1057;
long sum = swap_add(&arg1, &arg2);
long diff = arg1 - arg2;
return sum * diff;
}
对应汇编代码:
7. 数组
C语言可以产生指向数组中元素的指针,并对这些指针进行运算。在机器代码中,这些指针会被翻译成地址计算。
7.1 数组规则
T A[N];
来声明数组(数据类型T,整型常数N)。会在内存中分配一个L·N字节的连续空间(L为数据类型T的字节大小),用A来作为指向数组开头的指针的标识符。数组元素i会被存放在地址
x
A
+
L
⋅
i
x_A+L·i
xA+L⋅i的地方。
7.2 指针运算
7.3 二维数组
8. 结构体和联合
8.1 结构体
结构体的所有字段都存放在内存中一段连续的区域内,而指向结构体的指针就是结构体第一个字节的地址。使用字段相对于起始地址的偏移量来维护各个字段的信息。
字段的顺序必须与声明一致,即便其他顺序能使得内存更加紧凑也不行。
由编译器来决定总的尺寸和和字段的位置。
对齐
对齐后的数据:基本数据类型需要K字节,地址必须是K的倍数。
对其数据的动机:内存按4字节或8字节(对齐的)块来访问(4\8取决于系统),当一个数据跨越2个页面时,虚拟内存比较棘手,不能高效地装载或存储跨越四字边界的数据。
编译器在结构体中插入空白,以确保字段的正确对齐。
结构体内部:满足每个元素的对齐要求。
结构体整体的对齐存放:
K = 所有元素的最大对齐要求值
则起始地址和结构体长度必须是K的倍数。
空间的节省:大尺寸数据类型在前。
8.2 联合
允许以多种类型来引用一个对象,用不同的字段来引用相同的内存块。一个联合的总的大小等于它最大字段的大小。若一个数据结构中的两个不同字段的使用是互斥的,那么将这两个字段声明为联合的一部分会减小分配空间的总量。
访问位模式
联合可以用来访问不同数据类型的位模式。在联合中,以一种数据类型来存储联合中的参数,又以另一种数据类型来访问,结果是他们会具有一样的位表示,包括符号位字段、指数和尾数。
此时字节顺序问题变得很重要,在小端与大端机器中对相同的位模式解读不同。
9. 浮点数
寄存器
YMM是256位,32字节
XMM是128位,16字节
支持操作
①标量操作:单精度/双精度
②SIMD操作:单指令多数据操作,允许多个操作以并行模式执行,对多个不同的数据并行执行同一个操作。
内存引用
参数传递:浮点型参数用YMM\XMM寄存器
使用不同的mov指令在YMM\XMM寄存器之间、或内存和YMM\XMM寄存器之间传送数值。
10.高级主题
10.1 理解指针
-
每个指针都对应一个类型
通常如果对象类型位T,则指针的类型为T*
特殊的void *类型代表通用指针,可以通过显式强转或者赋值等隐式强转来将其转换成一个有类型的指针。
-
每个指针都有一个值
这个值是某个指定类型的对象的地址,特殊的NULL(0)值表示该指针没有指向任何地方。
-
指针用’&'运算符创建
对应于机器代码的leaq指令
-
数组与指针紧密联系
一个数组的名字可以像一个指针变量一样引用,但是不能修改。数组引用(a[3])与指针运算和间接引用(*(a+3))有一样的效果。
-
指针强转
将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。效果是改变指针运算的伸缩。
- 指针可以指向函数
函数名为指针。可以将指针赋值为函数,并且用指针来调用函数。
函数指针的值是该函数机器代码表示中第一条指令的地址。
10.1 内存布局
堆中:大变量在高地址,小变量在低地址,便于内存的回收与释放,减少内存碎片
10.2 缓冲区溢出 buffer overflow
C对于数组引用不进行任何边界检查,而且局部变量和状态信息(保存的寄存器值和返回地址等)都存放在栈中。对越界的数组元素的写操作会破坏存储在栈中的状态信息,当程序使用被破坏的状态,试图重新加载寄存器或执行ret指令时就会出现很严重的错误。
安全隐患
一种常见的状态破坏称为缓冲区溢出,通常在栈中分配某个字符数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。
缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码(exploit code),另外,还有一些字节会用一个指向攻击代码的指针覆盖返回地址。那么,执行ret指令的效果就是跳转到攻击代码。
在一种攻击形式中,攻击代码会使用系统调用启动一个shell程序,给攻击者提供一组操作系统函数。在另一种攻击形式中,攻击代码会执行一些未授权的任务,修复对栈的破坏,然后第二次执行ret指令,(表面上)正常返回到调用者。
防护
- 避免溢出漏洞
使用fgets代替gets: char *fgets(char *str, int n, FILE *stream)
一般用fgets(buf, sizeof(buf), stdin)
使用strncpy代替strcpy
在scanf函数中别用%s:使用fgets读入字符串,或用%ns代替%s,其中n是一个合适的整数
- 使用系统级的防护
①随机栈偏移(地址空间布局随机化技术的一部分):程序启动后,在栈中分配随机数量的空间,将移动整个程序使用的栈空间地址,每次程序执行,栈都重新定位,很难预测插入代码的起始地址。
②非可执行段:标记存储区为“只读”或“可写”,但不给予“执行”权限,将stack标记为不可执行。
- 编译器使用“栈金丝雀”(stack canaries) / 哨兵值(guard value)
在栈中buffer之后的位置放置特殊的值——金丝雀(canary),退出函数之前,检查是否被破坏。编译器默认开启。