[笔记]CSAPP第三章 程序的机器级表示
- 阅读和理解汇编代码有助于最大程度优化代码性能。
生成汇编代码文件
-
使用
gcc
命令:#gcc -Og -S prog.c
-Og
指的是编译器的优化选项。-S
将使得编译结果为.s
的汇编语言文件。 -
或者可以使用反汇编器,先通过
#gcc -Og -c prog.c
得到prog.o
的机器代码文件(或者是可执行文件),然后通过objdump
程序来反汇编,#objdump -d prog.o
得到汇编代码。 -
注意
Linux
下gcc
得到的汇编代码是ATT
格式,和Intel
风格不同,ATT
风格:
movq %rbx, %rax
Intel
风格:
mov rax, rbx
上面两条语句的功能完全一样,但是
Intel
代码省略了mov
后面表示大小的后缀q
,省略了寄存器rax
等前面的%
,同时列出操作数的顺序相反,源操作数在后而目标操作数在前,这与ATT
风格的源操作数->目标操作数的顺序相反。此外还有一些其他不同,不再一一列出。
数据格式
Intel用"word"表示16位数据类型,称32位数为"双字(double words)",称64位数为"四字(quad words)",标准int
值存储为双字(4字节,32位),指针(用char*
表示)存储为8字节的四字。浮点数的代码后缀和整型不同,主要有两种形式:单精度(4字节)和双精度(8字节),分别对应C语言的float
和double
。
C声明 | Intel数据类型 | 汇编代码后缀 | 大小(字节) |
---|---|---|---|
char |
字节 | 1 | 1 |
short |
字 | 1 | 2 |
int |
双字 | 1 | 4 |
long |
四字 | 1 | 8 |
char* |
四字 | 1 | 8 |
float |
单精度 | 1 | 4 |
double |
双精度 | 1 | 8 |
访问信息
- x86-64的CPU包含16个存储64位值的通用目的寄存器,用来存储整数数据和指针。如下图所示。
这些寄存器的名称与它们最开始的用途相关,然而大多数已经过时,不再具备特殊含义。现在最特殊的寄存器是栈指针%rsp,用来指明运行时栈的结束位置。
操作数指示符
-
指令的操作数指示执行操作时要使用的源数据值和放置结果的目的位置。
-
操作数有三种类型:
- 立即数:表示常数值。
- 寄存器:表示寄存器内容。
- 内存引用:根据计算出来的地址(通常称为有效地址)访问某个内存位置。
-
寻址模式
在下表中,用\(r_a\)表示任意寄存器a
,用引用\(R[r_a]\)表示它的值,这是将寄存器集合看成一个数组R
,用寄存器标识符作为索引;用符号\(M_b[Addr]\)表示对存储在内存中从地址Addr
开始的b
个字节值的引用,为了简便省略下标b
。
类型 | 格式 | 操作数值 | 名称 |
---|---|---|---|
立即数 | \(\$Imm\) | \(Imm\) | 立即数寻址 |
寄存器 | \(r_a\) | \(R[r_a]\) | 寄存器寻址 |
存储器 | \(Imm\) | \(M[Imm]\) | 绝对寻址 |
存储器 | (\(r_a\)) | \(M[R[r_a]]\) | 间接寻址 |
存储器 | \(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]\) | 比例变址寻址 |
数据传送指令
MOV
类指令movb
、movw
、movl
、movq
和movabsq
将数据从源位置复制到目的位置,不做任何变化。除了movl
指令以寄存器为目的时,会把该寄存器的高位4字节设置为0。- x86-64规定传送指令的两个操作数不能都指向内存位置。
MOVZ
和MOVS
类指令movzbw
、movzbl
、movzwl
、movzbq
和movzwq
等以零扩展进行传送,把目的中剩余的字节填充为0。movsbw
、movsbl
、movswl
、movsbq
、movswq
、movslq
和cltq
等以符号扩展进行传送,复制源操作数的最高位。注意cltq
指令只用于将寄存器%eax
符号扩展到%rax
,这是一条没有操作数的指令,等效于movslq %eax, %rax
。- 每条指令的最后两个字符即为大小指示符,第一个字符指定源的大小,第二个指定目的大小。只考虑目的大于源的情况。
- 由于
movl
指令以寄存器为目的时,会把该寄存器的高位4字节设置为0,所以没有movzlq
指令。
压入和弹出栈数据
-
x86-64中,程序栈存放在内存中某个区域,且栈从高地址向低地址增长,即栈顶地址最低,栈底地址最高。栈指针
%rsp
存放栈顶元素的地址。 -
pushq
指令和popq
指令
指令 | 效果 | 描述 |
---|---|---|
pushq S |
\(R[\%rsp]\leftarrow R[\%rsp]-8\); \(M[R[\%rsp]]\leftarrow S\) |
将四字压入栈 |
popq S |
\(D\leftarrow M[R[\%rsp]]\); \(R[\%rsp]\leftarrow R[\%rsp]+8\) |
将四字弹出栈 |
pushq
指令的功能是把数据压入到栈上,首先将栈指针减8移动栈顶,然后将值写入到新的栈顶地址,pushq %rbp
的行为相当于下面两条指令:
subq $8, %rsp
movq %rbp, (%rsp)
popq
指令的功能是从栈顶弹出栈,首先从栈顶位置读出数据,然后将栈指针加8,popq %rax
的行为相当于下面两条指令:
movq (%rsp), %rax
addq $8, %rsp
算术和逻辑操作
整数算术操作:
指令 | 效果 | 描述 |
---|---|---|
leaq S, D |
\(D \leftarrow \&S\) | 加载有效地址 |
inc D dec D neg D not D |
\(D \leftarrow D+1\) \(D \leftarrow D-1\) \(D \leftarrow -D\) \(D \leftarrow ~D\) |
加1 减1 取负 取补 |
add S, D sub S, D imul S, D xor S, D or S, D and S, D |
\(D \leftarrow D+S\) \(D \leftarrow D-S\) \(D \leftarrow D*S\) \(D \leftarrow D\)^\(S\) \(D \leftarrow D | S\) \(D \leftarrow D\& S\) |
加 减 乘 异或 或 与 |
sal k, D shl k, D sar k, D shr k, D |
\(D \leftarrow D<<S\) \(D \leftarrow D<<S\) \(D \leftarrow D>>_AS\) \(D \leftarrow D>>_LS\) |
左移 左移(等同于 sal ) 算术右移 逻辑右移 |
- 对于移位操作的最高位,算术移位
sar
填上符号位,逻辑移位slr
填上0。
控制
条件码
- 常用的条件码
CF:进位标志
ZF:零标志
SF:符号标志
OF:溢出标志 CMP
和TEST
- 这两类指令只设置条件码而不改变任何其他寄存器。
CMP
指令与SUB
指令的行为一致,cmpq S1,S2
基于S2 - S1
的结果进行比较。两数相等时该指令将ZF置零,其他标志可以用来确定两个操作数的大小关系。TEST
指令与AND
指令的行为一致。
访问条件码
SET
指令可以根据条件码的某种组合将一个字节设置为0或1。SET
指令后缀表示不同的条件而不是操作数的字节大小,setl
和setb
表示“小于时设置(set less)”和“低于时设置(set below)”,setg
和seta
表示“大于时设置(set greater)”和“高于时设置(set above)”,sete
表示“等于时设置(set equal)”,e
可以和l
、g
等后缀组合表示小于等于或大于等于时设置。
跳转指令
jmp
指令是无条件跳转。可以是直接跳转;也可以是间接跳转。je
、jl
、jg
、jb
、ja
等等是有条件跳转。
过程
- 考虑这样一套动作:过程P调用过程Q,Q执行后返回到P。这些动作必须包括以下机制:
- 传递控制。在进入过程Q时,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
- 传递数据。P必须能向Q提供一个或多个参数,Q必须能向P返回一个值。
- 分配和释放内存。在开始时,Q可能要为局部变量分配空间,而在返回前又必须释放这些存储空间。
运行时栈
- 程序可以用栈来管理其过程所需要的存储空间,栈和程序寄存器存放着传递控制和数据、分配内存所需要的信息。
- 当x86-64过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间。这个部分称为过程的栈帧。
- 通过寄存器,过程P可以传递最多6个整数值(即指针和整数),多出的参数值需要存储到栈帧里。(寄存器优化内容:尽可能避免函数参数超过6个)
转移控制
指令 | 描述 |
---|---|
call Label |
过程调用(直接调用) |
call *Operand |
过程调用(间接调用) |
ret |
从过程调用中返回 |
call
的效果是将返回地址(返回P后执行指令的地址)压入栈,并跳到Q的第一条指令,Q执行直到遇到ret
指令。ret
指令从栈中弹出存储的返回地址,之后跳转到这个地址,就在call
指令之后,继续执行P。
数据传送
- x86-64中,可以通过寄存器最多传递6个整型(即指针和整数)参数。
- 函数中超过6个整型参数的部分参数要通过栈来传递。
栈上的局部存储
- 局部数据有时候必须存放在内存中,常见情况包括:
- 寄存器不足以存放所有本地数据;
- 对一个局部变量使用地址运算符
&
,因此必须能够为它产生一个地址; - 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。
寄存器中的局部存储空间
- 寄存器组是唯一被所有过程共享的资源。虽然在给定时刻只有一个过程是活动的,但仍然需要保证当P调用Q时,Q不会覆盖P之后会使用的寄存器值。
数组分配和访问
- 数组被存储到内存中一块连续区域。访问数组元素常用到比例变址寻址。
结构的内存对齐
-
原则:
- 任何K字节的基本对象的地址都必须是K的倍数。
- 考虑到结构数组,整个结构的内存必须是其内部最大的基本对象占用内存的整数倍,否则无法保证下一个结构元素内基本对象的地址是K的倍数。
-
有时可以通过调整结构体内对象顺序可以改善分配的内存,例如:
struct S1{
char c1;
int i;
char c2;
};
struct S2{
char c1;
char c2;
int i;
};
编译器会为S1分配12个字节,而对于S2只需要8个字节。
浮点代码
- 这部分暂时没有细看,以后再补上。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 如何使用 Uni-app 实现视频聊天(源码,支持安卓、iOS)
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)