$ gcc -S -o hello.s hello.i
这个命令告诉GCC编译器将预处理后的文件(hello.i)编译成汇编代码(hello.s)。-S
选项指示编译器停止在汇编阶段,不要进行汇编和链接。-o
选项后面跟着的是输出文件的名称。
$ gcc -c -o hello.o hello.s
这个命令用于将汇编代码文件(hello.s)编译成目标代码文件(hello.o)。-c
选项告诉GCC只进行编译和汇编,不进行链接。输出文件名由-o
选项指定。hello.o是无法直接查看的,这里要借助反汇编工具objdump -d hello.o
gcc -o hello hello.o
最后这个命令将目标代码文件(hello.o)链接成最终的可执行文件(hello)。没有选项指示编译器停止,所以GCC会完成链接步骤。-o
选项后面指定的是最终生成的可执行文件的名称。
-O参数 | 含义 |
---|---|
-O0 | 关闭所有优化选项 |
-O1 | 基本优化,编译器会生成更快的代码 |
-O2 | O1的升级版 |
-O3 | 目前最高的优化级别,更多的编译时间,更大的二进制文件,占用更大的内存,稍快一点 |
-Os | 优化代码大小,当CPU缓存或磁盘空间较小时 |
采用高级别的优化会使得代码难以理解。
在Intel x86-64的处理器中包含了16个通用目的寄存器(%rax,%rbx,%rcx,%rdi,%r11…),这些寄存器用来存放整数数据和指针,这16个寄存器都是以%r开头的。在了解他们功能之前,先来讲清楚调用者保存寄存器和被调用这保存寄存器,如下由于A调用了B所以A叫做调用者B为被调用者:
由B中addq可以知道寄存器%rbx被修改了,但是在逻辑上函数A在调用B前后寄存器%rbx应该保持一致,解决这个问题由两个策略,一个是调用者保存,另一个是被调用者保存。
对于具体使用哪一种策略,不同寄存器被定义成不同的策略:
这段C语言代码定义了一个名为 mulstore
的函数,该函数调用另一个名为 mult2
的函数,mult2
函数接收两个 long
类型的参数并返回一个 long
类型的结果。对应的汇编代码段说明了 mulstore
函数的操作。汇编代码的每一步作用如下:
pushq %rbx
这条指令将 %rbx
寄存器的值压栈。这是因为 %rbx
是一个被调用者保存寄存器(callee-saved register),函数在使用这个寄存器前需要保存它的原始值,以便在函数返回前能恢复它,保证调用函数的上下文不被更改。
movq %rdx, %rbx
将 %rdx
寄存器的值(即 dest
参数,指向要存储结果的地址)移动到 %rbx
寄存器。这样做可能是为了在调用 mult2
之后,能够使用 %rbx
来间接寻址和存储 mult2
的结果。
call mult2
调用 mult2
函数。在 x86-64 的调用约定中,调用函数前,第一个和第二个参数分别通过 %rdi
和 %rsi
寄存器传递。在这里,mult2
函数的返回值将会存放在 %rax
寄存器中。
movq %rax, (%rbx)
将 %rax
寄存器的值(mult2
函数的返回值)存储到 %rbx
寄存器指向的内存位置(即 *dest
的原始位置)。
popq %rbx
将之前压栈的 %rbx
寄存器的值出栈,恢复 %rbx
的原始值。这是函数结束前的清理步骤,保证了 mulstore
函数不会影响 %rbx
寄存器的值。
ret
返回指令,用于从函数调用返回。它将从堆栈中弹出返回地址并跳转回调用者的代码。
关于第一次看到这个会有的疑问:
%rdi
、%rsi
、%rdx
、%rcx
、%r8
和 %r9
寄存器传递。如果有更多的参数,它们将通过堆栈传递。%rax
寄存器通常用来存储函数的返回值。如果函数返回的是整数或指针类型的值,那么这个值会被放置在 %rax
寄存器中以便于调用者可以从这里取得它。目标代码文件xxx.o是一个二进制文件是无法直接查看的,我们就需要借助反汇编工具objdump,将目标代码文件xxx.o反汇编成xxx.s,得到的结果大致为如下所示,通过对比反汇编得到的汇编代码与汇编器直接产生的汇编代码,我们可以发现一些细小的差异,反汇编代码省略了很多的指令的后缀,但在ret、call指令添加后缀,由于后缀是大小指示符,通常情况下是可以省略的。
后缀b、w、l、q。
单位:字节/byte/8bit,字/word/16bit,双字/long word/32bit,四字/quad words/64bits
注意:这里的“字”不是32位和64位计算机中的“字”,32位计算机:1字=32位=4字节,64位计算机:1字=64位=8字节
C声明 | Inter数据类型 | 汇编代码后缀 | 大小(字节) |
---|---|---|---|
char | 字节 | b | 1 |
short | 字 | w | 2 |
int | 双字 | l | 4 |
long | 四字 | q | 8 |
char* | 四字 | q | 8 |
float | 单精度 | s | 4 |
double | 双精度 | l | 8 |
有这一些后缀,mov指令可能为movb、mobw、movl、movq
从左到右分别为64、32、16、8位,每一部分都有不同的功能,如右边一列所示。
寄存器中,1、2字节赋值,剩余字节不变。4字节赋值,高位4字节清零。后面半段意思就是,寄存器8个字节嘛,然后你要将四个赋值给寄存器假如原本值为0x98765432,我想赋值0x6666,那么最终寄存器值为0x00006666.
Tips:1、2字节赋值为低字节赋值,r8~r15为64位系统新增。
大多数指令包含两部分——操作码和操作数
在汇编语言中,大多数指令都会涉及一或多个操作数,但是像ret指令是没有操作数的。操作数可以是具体的数据值(如数字、字符等),也可以是数据存储的位置(如寄存器或内存地址)。
立即数寻址: 立即数寻址方式指的是操作数被直接编码在指令中。这个数是一个常数,它在指令执行时不会改变。例如,在指令 mov $0x1, %eax
中,$0x1
就是一个立即数,它将被直接移动到 %eax
寄存器中。
寄存器寻址: 寄存器寻址是最快的寻址方式,因为它不涉及内存访问。操作数是寄存器中的值。例如,指令 add %ebx, %eax
将 %ebx
寄存器的值加到 %eax
寄存器的值上。在这里,%ebx
和 %eax
都是使用寄存器寻址方式的操作数。
内存引用寻址(Memory Addressing): 内存引用寻址方式涉及从内存中读取数据或向内存中写入数据。这种寻址方式可以通过多种形式表达:
mov 0x8049a04, %eax
将地址 0x8049a04
处的值加载到 %eax
中。mov (%ebx), %eax
会将 %ebx
寄存器中的地址对应的内存内容加载到 %eax
寄存器中。mov 4(%ebx), %eax
将地址 %ebx+4
处的内存内容加载到 %eax
中。mov 4(%ebx, %ecx, 2), %eax
表示将地址 %ebx + %ecx*2 + 4
处的内存内容加载到 %eax
中。注意点就是,4(%rax)是0x100+0x4,9(%rax,%rdx)为%rax和%rdx的值相加,并加上一个偏移量来计算得出的,0xFC(,%rcx,4)取 %rcx
寄存器的值,将 %rcx的值乘以缩放因子4偏移量 0xFC。最后一个就是%rax+4*%rdx。
上面那些就是C/C++里面数组中地址的加减问题嘛。
数据传输的意思就是字面上的,不要纠结。下面来看汇编的数据传输的指令:
(注释:大小不等)
(等大小传输)指令 | 关键后缀与大小(bit) | 注释 |
---|---|---|
movb | b(8bit) | 传输一个“半个字”大小的数据 |
movw | w(16bit) | 传输一个字大小的数据 |
movl | l(32bit) | 传输一个长字大小的数据 |
movq | q(64bit) | 传输一个四字大小的数据 |
(大小不等且符号位扩展)指令 | 注释 |
---|---|
movsbw | 注意点就是mov后面是s,代表的就是符号位扩展,bw就是两个的后缀代表的就是“半个字”到一个字 |
movsbl | 略 |
movswl | 略 |
(大小不等且用零号扩展)指令 | 注释 |
---|---|
movzbw | 为了好看加了这一列 |
movzbl | 无 |
movabsq传送绝对的4字:立即数按64位;
以上指令不止这些,理解就行了。
注意点,mov类型由寄存器大小决定,不可以单独用mov进行不同大小寄存器互传(内存可以);内存之间不可以对拷,即不能movq (%rdi) (%rsi),应该使用中介寄存器:
movq (%rdi), %rax
movq %rax, (%rsi)
// 假设rdi和rsi是指向64位整型(long long int)的指针
long long int *src = (long long int *)rdi; // 假设rdi是源指针的地址
long long int *dst = (long long int *)rsi; // 假设rsi是目标指针的地址
// 进行解引用拷贝
*dst = *src;
在 x86-64 架构的汇编语言中,movq
和 movabsq
是用来移动数据的指令,它们的功能是将数据从一个地方传送到另一个地方,但它们在使用上有细微的差别。
movq: 这条指令用于将64位的值移动到寄存器或内存中。movq
可以用于各种寻址模式,包括直接寻址、寄存器寻址、基址变址寻址等。它通常用于操作较小的立即数值或当源和目标都是寄存器时。当movq
用于立即数时,它将32位的立即数值符号扩展到64位(算术扩展即看符号位),然后存入目标寄存器。例如,movq $0x12345678, %rax
会将32位数 0x12345678
扩展为64位,然后存入 %rax
。
movabsq: 这条指令是 movq
的一个特殊变种,它允许直接操作64位的立即数或绝对地址。这是当你需要将一个完整的64位立即数值加载到寄存器,或者你需要引用一个具体的64位内存地址时使用的。movabsq
可以确保你能够将任意的64位值直接移动到寄存器中,而不进行符号扩展。例如,movabsq $0x123456789abcdef0, %rax
会将64位的立即数 0x123456789abcdef0
直接加载到 %rax
寄存器中。
mov
指令的后缀通常指示操作数的大小,也就是要传输的数据的大小。这个后缀与第二个操作数(目标操作数)的大小一致,因为mov
指令的作用是将数据从源操作数移动到目标操作数。
使用movl时候,即4字节赋值,则高位32位清零,如上面那一张图片所示,其中eax被包含于rax。
long exchange(long* xp, long y)
{
long x = *xp;
*xp = y;
return x;
}
exchange:
movq (%rdi), %rax # 将第一个参数xp指向的值加载到寄存器rax中,即long x = *xp;
movq %rsi, (%rdi) # 将第二个参数y的值存储到xp指向的内存位置中,即*xp = y;
ret # 返回rax寄存器中的值,即return x;
Tips:一般默认函数的第一参数存入rdi寄存器(与位有关 edi、di、dil),第二个参数存入rsi寄存器(esi、si、sil) ,第三个放入rdx寄存器(edx、dx、dl)
压入栈图示:
压栈(push
指令)
pushq %rax
:这条指令将 %rax
寄存器中的64位值压入栈中。push
指令时,栈指针 %rsp
首先减去操作数大小(在64位系统中通常是8字节)。%rax
寄存器中的值被存储在新的 %rsp
指向的地址处。出栈(pop
指令)
popq %rdx
:这条指令将栈顶的64位值弹出,并存入 %rdx
寄存器中。pop
指令时,首先从 %rsp
指向的地址读取数据到目标寄存器 %rdx
。%rsp
增加操作数大小,即向上移动8字节。栈的增长方向
push
操作会减少 %rsp
的值,而 pop
操作会增加 %rsp
的值。栈操作等效汇编指令
push
和 pop
指令的操作可以用更基本的汇编指令序列来模拟。例如,pushq %rax
可以分解为两个步骤:
subq $8, %rsp
:将栈指针减去8字节(64位)。movq %rax, (%rsp)
:将 %rax
寄存器的值移动到 %rsp
指向的新栈顶地址。popq %rdx
也可以分解为:
movq (%rsp), %rdx
:将 %rsp
指向的栈顶地址的值移动到 %rdx
寄存器。addq $8, %rsp
:将栈指针增加8字节。栈顶和栈底
top of the stack
)是栈中最后压入的元素,是下一个将要被弹出的元素。在 x86-64 架构中,栈顶由 %rsp
寄存器指向。bottom of the stack
)是栈中第一个元素的位置,通常是程序开始运行时栈的初始位置。加载有效地址的操作码:leal/leaq,作用:将内存地址加载到一个寄存器中。
leal disp(%base,%index,scale),%dest
leaq disp(%base,%index,scale),%dest
注意,对于leaq指令所执行的操作并不是去内存地址(5x+7)处读取数据,而是将有效值(5x+7)这个值直接写入到目的寄存器rax
leal/leaq还可以进行算术操作:
一开始的数据:
移位k可以是一个立即数也可以是一个放在%rcx的%cl中的数,其他的寄存器不行。移位规则:
long shift_left4_rightn(long x,long n)
x in %rdi,n in %rsi
shift_left4_rightn:
movq %rdi,%rax ---Get x
salq $4,%rax ---x<<=4
movl %esi,%ecx ---Get n (4 bytes)
sarq %cl,%rax ---x>>=n
在我们执行运算的时候,ALU除了执行算术运算和逻辑运算之外,还会根据运算的结果去设置条件码寄存器:
下面是条件寄存器的相关知识,条件码寄存器是由CPU来维护的,长度是单个比特位,它描述了最近执行操作的属性
影响标志位的指令总结:
对于以下指令只设置条件码,不改变其他寄存器
条件码一般不会直接读取,会利用条件码状态设置某个字节(低位单字节存储器、一个字节的内存地址),指令的不同后缀指明了相关条件码的组合
jmp + 标号直接跳转或jmp + 寄存器或内存目标中读出的的跳转目标
虽然我们不关心机器代码格式,但是理解跳转指令的目标如何编码,这对第七章研究链接非常重要。此外他也能帮助理解反汇编的输出。在汇编代码中,跳转目标用符号来书写。汇编器、链接器会产生跳转目标的适当编码。跳转指令有几种不同的编码,但是最常用的就是它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码,这些地址偏移量可以编码为1,2或4字节。其次的编码方式就是给出绝对地址,用4个字节直接指定目标。
jmp跳转过程为,3(当前地址)+ 2(指令长度)+ 3(偏移量)= 8(目标地址);jg跳转过程为:b(当前地址)+ 2(指令长度)- 8(偏移量)= b - 6 = 5(十六进制的目标地址,十进制是11 - 6 = 5)
条件控制来实现条件分支简单但也低效,数据的条件转移在一些受限的情况下可行,更符合现代处理器的特点
在针对一个测试有多种可能的结果时,switch语句很有用,它通过跳转表这种数据结构,使得实现更加高效
void switch_eg(long x, long n, long *dest)
{
long val = x;
switch (n) {
case 100:
val *= 13;
break;
case 102:
val += 10;
/* Fall through */
case 103:
val += 11;
break;
case 104:
case 106:
val *= val;
break;
default:
val = 0;
}
*dest = val;
}
subq $100, %rsi
:从n
中减去100,计算出偏移量(n-100
),结果存回%rsi
。
cmpq $6, %rsi
:将计算后的偏移量与6比较。
ja .L8
:如果偏移量大于6(即n > 106
),跳转到.L8
标签(default
情况)。
jmp *.L4(,%rsi,8)
:基于偏移量进行间接跳转,%rsi
乘以8(因为是64位系统,地址是8字节对齐的),加上.L4
的地址,跳转到对应的case
标签。
.L3
标签下的两行指令:
leaq (%rdi,%rdi,2), %rax
:计算3 * x
,等价于x + x*2
,结果存入%rax
。leaq (%rdi,%rax,4), %rdi
:计算x + 4*(3*x)
,结果是13 * x
,存回%rdi
。.L5
标签:addq $10, %rdi
将x
加上10。
.L6
标签:addq $11, %rdi
将x
加上11。
.L7
标签:imulq %rdi, %rdi
将x
自乘。
.L8
标签:movl $0, %edi
将0
移动到%edi
,即设置val
为0。
.L2
标签:最终的结果从%rdi
移动到通过%rdx
指定的内存地址(即*dest = val
)。
ret
指令将控制权返回给函数的调用者。
前六个参数是通过相应的寄存器来传递的,超过6个参数的部分就是通过栈来传递的,如下图所示:
这里有两点要注意的是,第一通过栈来传输数据时,所以数据的大小都是向8的倍数对齐(如上图中的a4),而局部变量是不需要对齐的,虽然变量a4只占一个字节,但是仍然为其分配了8个字节的存储空间。另外一点需要注意,就是使用寄存器进行参数传递时,寄存器的使用是有特殊顺序规定的,此外寄存器的名字使用取决于传递参数的大小,例如如果第一个参数的大小是4字节,那么需要用寄存器%edi来保存,总结如下:
下面来解释一下栈的过程:
subq $16, %rsp
:这条指令从栈指针寄存器%rsp
中减去16。这实际上在栈上分配了16字节的空间,通常用于局部变量或维持对齐。movq $534, (%rsp)
:这条指令将数值534移动(存储)到栈指针%rsp
所指向的地址上。因为刚才已经通过subq
指令在栈上预留了空间,这个值就被存储在栈的最顶端。movq $1057, 8(%rsp)
:这条指令将数值1057移动到栈指针%rsp
当前指向地址的上面8个字节的位置。在64位系统中,movq
指令操作的是8字节,所以这条指令实际上是在栈上第一个8字节存储的值(534)之上再存储一个8字节的值(1057)。经过这三条指令后,栈的情况如下:
%rsp
): 534存储的位置。%rsp+8
: 1057存储的位置。%rsp+16
: 栈指令执行前的栈顶位置。在上面图片中,前六个参数是通过寄存器传递的(栈上出现它们身影是因为这是调用前的状态,保证程序的正确性防止值的覆盖,对于某些值需要在过程中开空间保存,也不需要8倍数对齐),后两个参数是通过栈来传递的,由于&x4是一个地址,所以刚好是8字节。
在程序执行的过程中,寄存器是被所有函数共享的一种资源,为了避免寄存器的使用过程中出现数据覆盖的问题,处理器规定了寄存器的使用惯例,所有的函数调用都必须遵守这个惯例,即调用者保存和被调用者保存,在1.3中已经简单讲过这个知识点了。
下面是一个递归调用的例子,递归调用和调用其他函数是一样的,每次函数调用都有他自己私有的状态信息:
汇编代码如下:
salq $6, %rdx
:这是一个左移指令,将 %rdx
(存储变量 i
)左移6位,相当于乘以64。在这里,因为矩阵是16x16的,所以每行有16个整数,每个整数4字节,16x4等于64。这条指令用于计算行 i
在矩阵A中的起始位置。
addq %rdx, %rdi
:将 %rdx
加到 %rdi
,其中 %rdi
存储了矩阵A的起始地址,这样 %rdi
就指向了A[i][0]。
leaq (%rsi, %rcx, 4), %rcx
:这条指令加载B矩阵中第k列的起始地址到 %rcx
。%rsi
存储了矩阵B的起始地址,%rcx
存储了变量 k
。因为矩阵以行为单位存储,所以每一列的元素间隔是矩阵宽度(16个整数)乘以整数大小(4字节)。
leaq 1024(%rcx), %rsi
:这条指令设置一个界限,它计算了B矩阵中第k列的结束地址。因为一行有64字节(16个整数 x 4字节/整数),所以整个矩阵有16行,即1024字节。
movl $0, %eax
:这条指令把0移动到 %eax
,初始化累加器 result
。
接下来是循环部分,对应C代码中的 for
循环:
movl (%rdi), %edx
:从内存地址 %rdi
(当前A[i][j])读取值到 %edx
。
imull (%rcx), %edx
:将 %edx
(A[i][j]的值)与内存地址 %rcx
(当前B[j][k])的值相乘,并将结果存储回 %edx
。
addl %edx, %eax
:将 %edx
(乘积结果)加到 %eax
(累加器 result
)。
addq $4, %rdi
:将 %rdi
加4,移动到下一个A矩阵元素。
addq $64, %rcx
:将 %rcx
加64,移动到下一行的B矩阵元素。
cmpq %rsi, %rcx
:比较 %rcx
和 %rsi
(B矩阵k列的结束地址)。
jne .L7
:如果 %rcx
不等于 %rsi
,则跳转回循环开始(标签 .L7
)继续执行。
最后的指令 rep; ret
是一个返回序列。rep
前缀通常用于字符串操作指令,用于重复操作直到某个条件不满足。但在这里它似乎是多余的,因为它没有与之配合的字符串操作指令。ret
是函数返回指令,它将控制权交还给函数的调用者,并将累加结果(在 %eax
中)作为返回值。
在C代码中:
int A[expr1][expr2];
定义了一个二维变长数组,其中 expr1
和 expr2
是在运行时确定的表达式。long var_ele(long n, int A[n][n], long i, long k)
函数接受四个参数:一个长整型的 n
,它表示数组的大小,一个类型为 int
、大小为 n
xn
的二维数组 A
,以及两个长整型的索引 i
和 k
,函数返回数组在 i
行 k
列的元素。在汇编代码中:
%rdi
寄存器存储 n
的值。%rsi
寄存器存储数组 A
的地址。%rdx
寄存器存储 i
的值。%rcx
寄存器存储 j
的值。汇编指令做了以下操作:
imulq %rdx, %rdi
:将 i
和 n
相乘,结果存回 %rdi
。这计算了二维数组中第 i
行的起始位置的偏移量(因为每行有 n
个元素,每个元素占4字节)。leaq (%rsi, %rdi, 4), %rax
:基于 %rsi
(数组 A
的地址)和 %rdi
(行偏移量)计算元素 A[i][0]
的地址,并将这个地址存入 %rax
。movl (%rax, %rcx, 4), %eax
:读取位于 %rax
+ 4*k
的内存内容,即元素 A[i][k]
的值,并将其存入 %eax
。这里 %rcx
存储 k
,所以 %rcx * 4
给出了列 k
的偏移量。ret
指令结束函数,返回时 %eax
寄存器中的值就是函数的返回值,即 A[i][k]
。这就是如何在汇编层面处理C语言中的变长数组访问。
下面是简单的结构体示例:
C语言对于数组引用不进行任何边界检查,而且局部变量信息和状态信息(例如保存的寄存器的值和返回地址)都存放在栈中。下面来看一个代码例子:
在上面的C代码例子以及它的汇编代码例子中,展示了一个很好的缓冲区溢出的例子,由于gets函数没有办法确定是否分配了足够的空间,所以任何超过7个字符的字符串都会导致越界。随着字符串变长,0-7字符不会造成破坏,9-23会导致未使用的栈空间遭到破坏,24-31会导致返回地址遭到破坏,32往后会导致caller中保存的状态遭到破坏。
这种技术叫做地址空间布局随机化ASLR,每次运行时程序的不同部分,包括程序代码、库代码、栈、全局变量和堆数据都会被加载到内存的不同区域。
栈破坏检测:最近的GCC版本在产生代码中加入了一种栈保护者机制来检测缓冲区越界,其思想是在栈帧中任意布局缓冲区与栈状态之间存储一个特殊的值(金丝雀值canary),这个值是随机产生的,程序会检测金丝雀前后变化来判定。
限制可执行代码区域:消除攻击者向系统中插入可执行代码的能力。