CSAPP 第三章:程序的机器级表示
3.1 历史观点
摩尔定律
摩尔定律(Moore's Law:是由英特尔公司的联合创始人之一戈登·摩尔(Gordon Moore)在1965年提出的经验观察和预测。摩尔定律指出,集成电路上可容纳的晶体管数量每隔约18个月翻倍,同时造价减半。换句话说,摩尔定律认为集成电路的性能将以指数级增长,而成本则将相对下降。
摩尔定律的观察基础是在集成电路中使用的晶体管数量和密度不断增加,这使得处理器的速度和性能也在不断提高。这一观察发展为一个准则,成为计算机硬件行业的重要指导原则。
摩尔定律的实际效应是,在相同的芯片面积上,可以集成更多的晶体管,从而提高处理器的性能。这使得计算机和其他电子设备变得更快、更小、更强大,并带来了信息技术的快速发展。
X86和X86-64
x86是一种32位的处理器架构,最初由英特尔(Intel)开发并推广。它是基于英特尔的8086和8088处理器设计的,并成为个人电脑(PC)中最为广泛使用的架构之一。x86架构支持32位寻址和32位数据处理,其指令集和寄存器设计是基于32位的。
x86-64(也称为AMD64或x64)是x86架构的64位扩展。它由AMD首先引入,并得到了其他处理器制造商的采纳,包括英特尔。x86-64架构扩展了x86的寻址空间和寄存器大小,支持64位寻址和64位数据处理。这使得x86-64处理器能够处理更大的内存地址空间和执行更复杂的计算任务。与传统的32位x86架构相比,x86-64架构在处理器性能和内存管理方面有显著的优势。
x86-64架构是目前个人电脑和服务器领域最为广泛使用的处理器架构之一。它支持运行32位和64位的操作系统和应用程序,提供了更高的性能和更大的灵活性。许多现代操作系统和软件都提供了针对x86-64架构的优化版本,以充分利用其性能和功能。
3.2 程序编码
汇编过程
编译C语言程序的过程:
预处理(Preprocessing):编译器对源代码进行预处理,包括宏展开、头文件包含等操作。预处理器可以根据#include指令将其他文件的内容插入源文件中,并对宏进行替换。
编译(Compilation):编译器将预处理后的源代码转换为汇编代码(Assembly Code),即将C语言的代码转换为机器可以理解的低级指令。
汇编(Assembly):汇编器将汇编代码转换为机器码,即可执行的二进制指令。汇编器将每条汇编指令翻译成对应的机器指令,并生成目标文件(Object File)。
链接(Linking):链接器将目标文件与所需的库文件进行链接,生成最终的可执行文件(Executable File)。链接器将目标文件中的函数和变量引用与其定义进行匹配,解析符号引用,并将它们关联起来。
ATT(AT&T)和Intel的语法区别
ATT(AT&T)和Intel是两种不同的汇编语法格式,它们主要在语法结构和指令书写上存在一些区别。
- 语法结构:
- ATT语法:源操作数在前,目的操作数在后,使用逗号分隔。例如:
movl %eax, %ebx
。 - Intel语法:目的操作数在前,源操作数在后,使用逗号分隔。例如:
mov ebx, eax
。
- ATT语法:源操作数在前,目的操作数在后,使用逗号分隔。例如:
- 寄存器命名:
- ATT语法:寄存器名称以
%
开头。例如:%eax
。 - Intel语法:寄存器名称没有特殊的符号前缀。例如:
eax
。
- ATT语法:寄存器名称以
- 立即数和内存引用:
- ATT语法:立即数使用
$
符号表示,内存引用使用括号表示。例如:movl $10, %eax
(将立即数10移动到eax寄存器);movl (%ebx), %eax
(将ebx寄存器指向的内存地址的值移动到eax寄存器)。 - Intel语法:立即数没有特殊符号表示,内存引用使用方括号表示。例如:
mov eax, 10
(将立即数10移动到eax寄存器);mov eax, [ebx]
(将ebx寄存器指向的内存地址的值移动到eax寄存器)。
- ATT语法:立即数使用
- 操作数大小:
- ATT语法:根据操作数的大小,使用不同的后缀来表示,如
b
表示字节(byte),w
表示字(word),l
表示双字(double word)。例如:movb $1, %al
(将立即数1移动到al寄存器,al为8位寄存器)。 - Intel语法:操作数的大小根据指令而定,不需要显式指定后缀。例如:
mov al, 1
(将立即数1移动到al寄存器,al为8位寄存器)。
- ATT语法:根据操作数的大小,使用不同的后缀来表示,如
需要注意的是,不同的汇编器可能支持不同的语法格式,默认使用的是特定的汇编语法。在使用汇编语言时,应根据所选的语法格式编写相应的汇编代码。
3.3 数据格式
下面是常见的C语言数据类型在x86-64架构中的Intel数据类型、汇编代码后缀和大小(以字节为单位)的表格:
C数据类型 | Intel数据类型 | 汇编代码后缀 | 大小(字节) |
---|---|---|---|
char | BYTE | b | 1 |
short | WORD | w | 2 |
int | DWORD | l | 4 |
long | QWORD | q | 8 |
long long | QWORD | q | 8 |
float | DWORD | s | 4 |
double | QWORD | l | 8 |
3.4 访问信息
X86-64中的整数寄存器
64位 | 32位 | 16位 | 8位 | 名称 | 作用 |
---|---|---|---|---|---|
%rax | %eax | %ax | %al | 返回值寄存器 | 存放函数返回值 |
%rbx | %ebx | %bx | %bl | 数据寄存器 | 被调用者保存 |
%rcx | %ecx | %cx | %cl | 第4个参数寄存器 | 用于存放第4个函数参数 |
%rdx | %edx | %dx | %dl | 第3个参数寄存器 | 用于存放第3个函数参数 |
%rsi | %esi | %si | %sil | 第2个参数寄存器 | 用于存放第2个函数参数 |
%rdi | %edi | %di | %dil | 第1个参数寄存器 | 用于存放第1个函数参数 |
%rbp | %ebp | %bp | %bpl | 基址指针寄存器 | 被调用者保存 |
%rsp | %esp | %sp | %spl | 栈指针寄存器 | 用于指向栈顶位置 |
%r8 | %r8d | %r8w | %r8b | 第5个参数寄存器 | 用于存放第5个函数参数 |
%r9 | %r9d | %r9w | %r9b | 第6个参数寄存器 | 用于存放第6个函数参数 |
%r10 | %r10d | %r10w | %r10b | 附加寄存器 | 调用者保存 |
%r11 | %r11d | %r11w | %r11b | 附加寄存器 | 调用者保存 |
%r12 | %r12d | %r12w | %r12b | 数据寄存器 | 被调用者保存 |
%r13 | %r13d | %r13w | %r13b | 数据寄存器 | 被调用者保存 |
%r14 | %r14d | %r14w | %r14b | 数据寄存器 | 被调用者保存 |
%r15 | %r15d | %r15w | %r15b | 数据寄存器 | 被调用者保存 |
操作数指示符
下面是一个关于操作数格式的表格示例,使用ATT格式的汇编代码:
类型 | 格式 | 操作数值 | 名称 |
---|---|---|---|
立即数 | 立即值 | $42 | 立即数42 |
寄存器 | 寄存器名 | %eax | 通用寄存器 |
寄存器 | 内存引用 | (%ebx) | 寄存器间接寻址 |
寄存器 | 寄存器+偏移 | 12(%ecx) | 寄存器偏移寻址 |
寄存器 | 寄存器+立即值 | 0x1234(%edx) | 立即值偏移寻址 |
存储器 | 基址(, 索引) | (%ebx, %esi) | 基址索引寻址 |
存储器 | 基址(, , 缩放因子) | (%ecx,,8) | 变址寻址 |
数据传输指令
指令 | 描述 | 数据大小 |
---|---|---|
movb | 传输一个字节大小的数据 | 8位 |
movw | 传输一个字(16位)大小的数据 | 16位 |
movl | 传输一个双字(32位)大小的数据 | 32位 |
movq | 传输一个四字(64位)大小的数据 | 64位 |
movzbw | 将无符号字节数据拓展为字大小 | 8位拓展为16位 |
movsbw | 将有符号字节数据拓展为字大小 | 8位拓展为16位 |
在x86-64架构的汇编语言中,movl指令会将32位数据的低32位复制到目的操作数的低32位,并将目的操作数的高32位清零。
movz指令将无符号字节数据拓展为字大小,填充零位;movs指令将有符号字节数据拓展为字大小,保持符号位不变。
压入和弹出栈数据
指令 | 效果 | 描述 |
---|---|---|
pushq | 将值压入栈顶 | 该指令将给定的源操作数的值压入栈顶,并将栈指针减小8个字节(根据操作数的大小) |
popq | 将值从栈顶弹出 | 该指令将栈顶的值弹出,并将栈指针增加8个字节(根据操作数的大小),并将值存储到目的操作数中 |
这些指令用于栈的操作,栈是一个后进先出(LIFO)的数据结构。在x86-64汇编语言中,栈被广泛用于函数调用、局部变量存储和寄存器保存等任务。
使用pushq指令时,要指定要推入栈顶的操作数,可以是寄存器、内存位置或立即数。例如,pushq %rax
将将寄存器 %rax 的值压入栈顶。
使用popq指令时,要指定从栈顶弹出的目的操作数,可以是寄存器或内存位置。例如,popq %rbx
将从栈顶弹出一个值,并将其存储到寄存器 %rbx。
这些指令对栈指针进行相应的调整,以确保栈的正确管理(可以发现,栈是倒过来的)。在使用这些指令时,需要注意栈的正确使用,以避免栈溢出或栈指针错误的情况。
3.5 算术逻辑操作
指令 | 效果 | 描述 |
---|---|---|
leaq | 计算有效地址 | 将源操作数的内存地址计算并存储到目的操作数中 |
inc | 增加操作数的值 | 将源操作数的值增加1,结果存储到目的操作数中 |
dec | 减少操作数的值 | 将源操作数的值减少1,结果存储到目的操作数中 |
neg | 取反操作数的值 | 将源操作数的值取反(取负),结果存储到目的操作数中 |
not | 取反操作数的位 | 将源操作数的位取反,结果存储到目的操作数中 |
add | 加法操作 | 将源操作数的值与目的操作数相加,结果存储到目的操作数中 |
sub | 减法操作 | 将源操作数的值与目的操作数相减,结果存储到目的操作数中 |
imul | 有符号乘法操作 | 将源操作数与目的操作数进行有符号乘法运算,结果存储到目的操作数中 |
xor | 异或操作 | 将源操作数与目的操作数进行按位异或运算,结果存储到目的操作数中 |
or | 或操作 | 将源操作数与目的操作数进行按位或运算,结果存储到目的操作数中 |
and | 与操作 | 将源操作数与目的操作数进行按位与运算,结果存储到目的操作数中 |
sal | 左移算术(带符号) | 将源操作数的位向左移动指定的位数,空位用0填充,结果存储到目的操作数中(带符号左移) |
shl | 左移逻辑(无符号) | 将源操作数的位向左移动指定的位数,空位用0填充,结果存储到目的操作数中(无符号左移) |
sar | 右移算术(带符号) | 将源操作数的位向右移动指定的位数,空位用符号位填充,结果存储到目的操作数中(带符号右移) |
shr | 右移逻辑(无符号) | 将源操作数的位向右移动指定的位数,空位用0填充,结果存储到目的操作数中(无符号右移) |
加载有效地址
leaq和movq基本等同,但是目的数必须是寄存器。
leaq可以用来做一些巧妙的操作,但是往往这和设计用途无关。
一元和二元操作
一元操作的源和目的是同一个数。
二元操作的源操作数是第一个,目的操作数是第二个。
例如 subq %rax,%rdx是使%rdx的值变为%rdx减去%rax的值。
移位操作
左移指令有两个名字:SAl和SHL。两者的效果是一样的。,都是在右面填上0。
右移指令在x86汇编语言中,SAR和SHR指令的不同字母来自以下单词:
SAR:Shift Arithmetic Right(算术右移) SHR:Shift Logical Right(逻辑右移)
移位量可以是一个立即数,也可以放在单字节寄存器%cl中。
特殊的算术操作
指令 | 效果 | 描述 |
---|---|---|
imulq | 有符号乘法操作(四字节) | 将源操作数与目的操作数进行有符号乘法运算,结果存储到目的操作数中 |
mulq | 无符号乘法操作(四字节) | 将源操作数与目的操作数进行无符号乘法运算,结果存储到目的操作数中 |
cqto | 扩展符号位(四字节到八字节) | 将四字节的有符号值扩展到八字节,并存储到目的操作数中(对于imulq和divq指令的操作准备) |
idivq | 有符号除法操作(四字节) | 将目的操作数除以源操作数,商存储到目的操作数中,余数存储到%rdx寄存器中 |
divq | 无符号除法操作(四字节) | 将目的操作数除以源操作数,商存储到目的操作数中,余数存储到%rdx寄存器中 |
对于imulq指令,当只使用一个操作数时,它会将源操作数与目的操作数(即%rax寄存器)进行有符号乘法运算,并将结果存储在%rdx:%rax寄存器对中。这个寄存器对存储了64位结果,其中高32位存储在%rdx寄存器中,低32位存储在%rax寄存器中。
3.6 控制
有时需要有条件的执行,数据决定指令执行的顺序,这时就要使用jump指令改变执行顺序。
条件码
条件码是指在计算机体系结构中用于记录运算结果状态的一组标志位。常见的条件码包括进位标志(Carry Flag,CF)、零标志(Zero Flag,ZF)、符号标志(Sign Flag,SF)和溢出标志(Overflow Flag,OF)。
- 进位标志(CF):
- 用途:记录算术和逻辑运算中的进位或借位情况。
- 设置条件:当无符号数相加产生进位,或者无符号数相减产生借位时,CF被设置为1;否则,CF被设置为0。
- 零标志(ZF):
- 用途:记录运算结果是否为零。
- 设置条件:当运算结果为零时,ZF被设置为1;否则,ZF被设置为0。
- 符号标志(SF):
- 用途:记录运算结果的符号。
- 设置条件:当运算结果为负数时,SF被设置为1;当运算结果为非负数时,SF被设置为0。
- 溢出标志(OF):
- 用途:记录有符号数运算中是否发生溢出。
- 设置条件:当有符号数相加或相减产生溢出时,OF被设置为1;否则,OF被设置为0。
这些条件码在程序执行过程中,可以被用于控制程序的流程,比如进行条件判断、循环等。根据条件码的状态,程序可以根据不同的条件选择不同的执行路径。例如,使用条件跳转指令根据CF、ZF、SF和OF的状态进行分支判断,从而实现程序的控制流程。
好的,下面是对x86-64架构下的cmp
和test
指令进行说明的表格:
指令 | 效果 | 描述 |
---|---|---|
cmp | 比较操作数 | 比较源操作数和目的操作数的值,根据比较结果设置标志位 |
test | 位测试操作 | 对源操作数和目的操作数进行按位与运算,并根据结果设置标志位 |
这两个指令,cmp等价于sub,test等价于and,唯一的区别在于,这个操作只改变条件码而不改变操作数。
访问条件码
在x86-64架构下,set
指令是一个条件设置指令,用于根据某个条件的结果设置一个目标操作数的值。它根据条件的真假结果设置目标操作数的值为1或0。set
指令通常与条件跳转指令(如jmp
、jz
等)一起使用,用于在条件满足时设置一个寄存器或内存位置的值。
set
指令有多个变种,每个变种都对应不同的条件码(flags)和条件判断。这些变种包括:
seta
/setnbe
:如果无符号数大于(Above)指定条件,则将目标操作数设置为1,否则设置为0。setae
/setnb
/setnc
:如果无符号数大于等于(Above or Equal)指定条件,则将目标操作数设置为1,否则设置为0。setb
/setnae
/setc
:如果无符号数小于(Below)指定条件,则将目标操作数设置为1,否则设置为0。setbe
/setna
:如果无符号数小于等于(Below or Equal)指定条件,则将目标操作数设置为1,否则设置为0。sete
/setz
:如果等于(Equal)指定条件,则将目标操作数设置为1,否则设置为0。setg
/setnle
:如果有符号数大于(Greater)指定条件,则将目标操作数设置为1,否则设置为0。setge
/setnl
:如果有符号数大于等于(Greater or Equal)指定条件,则将目标操作数设置为1,否则设置为0。setl
/setnge
:如果有符号数小于(Less)指定条件,则将目标操作数设置为1,否则设置为0。setle
/setng
:如果有符号数小于等于(Less or Equal)指定条件,则将目标操作数设置为1,否则设置为0。setne
/setnz
:如果不等于(Not Equal)指定条件,则将目标操作数设置为1,否则设置为0。
这些变种可以根据特定的条件码和条件判断来设置目标操作数的值,目标操作数可以是一个寄存器或内存位置。例如,如果指令的结果满足"等于"条件,sete %al
会将AL寄存器的值设置为1;否则,它将设置AL寄存器的值为0。
跳转
- 无条件跳转: 无条件跳转指令用于无条件地改变程序的执行流程,跳转到指定的代码位置。其中,常见的无条件跳转指令有:
jmp
:无条件跳转到目标地址,目标地址可以是一个绝对地址或相对地址。 例如:jmp label
将会无条件跳转到标签为label
的位置。
- 直接/间接跳转: 直接跳转指令通过指定目标地址来改变程序的执行流程。目标地址可以通过直接给出或者存储在寄存器或内存中。常见的直接跳转指令有:
jmp reg
:跳转到寄存器中存储的目标地址。 例如:jmp eax
将会跳转到eax
寄存器中存储的地址。jmp [mem]
:跳转到内存中存储的目标地址。 例如:jmp [0x1000]
将会跳转到地址为0x1000
处存储的目标地址。- 跳转目标是从寄存器或者内存位置读出的,成为间接跳转,写法是加一个操作符
*
,例如jmp *%rax
。
- 有条件跳转: 有条件跳转指令根据特定的条件码(flags)和条件判断来确定是否进行跳转。根据条件的真假结果,可以有符号数和无符号数的情况。常见的有条件跳转指令有:
- 有符号数条件跳转:
jg
/jnle
:如果有符号数大于(Greater)指定条件,则跳转。jge
/jnl
:如果有符号数大于等于(Greater or Equal)指定条件,则跳转。jl
/jnge
:如果有符号数小于(Less)指定条件,则跳转。jle
/jng
:如果有符号数小于等于(Less or Equal)指定条件,则跳转。je
/jz
:如果等于(Equal)指定条件,则跳转。jne
/jnz
:如果不等于(Not Equal)指定条件,则跳转。 例如:jg label
将会在有符号数大于指定条件时跳转到标签为label
的位置。
- 无符号数条件跳转:
ja
/jnbe
:如果无符号数大于(Above)指定条件,则跳转。jae
/jnb
/jnc
:如果无符号数大于等于(Above or Equal)指定条件,则跳转。
jb
/jnae
/jc
:如果无符号数小于(Below)指定条件,则跳转。jbe
/jna
:如果无符号数小于等于(Below or Equal)指定条件,则跳转。je
/jz
:如果等于(Equal)指定条件,则跳转。jne
/jnz
:如果不等于(Not Equal)指定条件,则跳转。 例如:ja label
将会在无符号数大于指定条件时跳转到标签为label
的位置。
这些跳转指令可以根据特定条件的结果来决定是否跳转到目标位置,从而实现程序执行流程的控制。
在计算机中,PC(程序计数器)用于存储下一条要执行的指令的地址。在跳转指令的执行过程中,PC会被修改为跳转目标的地址,以实现代码的跳转和程序流程的改变。
相对跳转(PC相对寻址)是一种常见的跳转方式,其中跳转目标相对于当前指令的地址进行偏移。PC相对寻址使用一个相对于当前指令的偏移量,该偏移量可以是正数或负数,用于计算跳转目标的地址。
在x86-64架构中,相对跳转通常使用有符号的偏移量。例如,jmp rel32
指令使用一个32位的有符号相对偏移量,该偏移量会与当前指令的地址相加,从而计算出跳转目标的地址。相对跳转指令会将这个计算出的跳转目标地址加载到PC中,使得程序执行流程转移到该地址处。
例如,假设当前指令的地址为0x1000(jmp的下一条指令),执行了一条相对跳转指令
jmp rel32
,偏移量为-10。那么,PC会被修改为 0x1000 + (-10) =
0xFF6 的值,即跳转到地址 0xFF6 执行下一条指令。
PC相对寻址允许程序在不同的代码位置之间进行跳转,提供了灵活性和动态性,可以根据程序的需求在不同的条件下选择不同的执行路径。
用条件控制
在x86-64架构下,条件控制和条件传送是两种不同的方式来实现条件分支:
条件控制是通过条件跳转指令(如
jmp
、jz
、jnz
等)来实现条件分支。程序在执行到条件跳转指令时,会根据指定的条件判断是否满足跳转条件,如果满足,则会跳转到指定的目标位置执行相应的代码;如果不满足,则会继续顺序执行下一条指令。这种方式需要进行跳转操作,可能会导致指令预测失败,从而降低性能。条件传送是通过条件传送指令(如
cmov
系列指令)来实现条件分支。这些指令会根据条件判断来选择是否将数据从源操作数传送到目标操作数。如果条件满足,则将源操作数的值传送到目标操作数;如果条件不满足,则不进行传送操作。这种方式不需要跳转操作,因此可以避免指令预测失败的开销,提高性能。
条件传送方式在指令流水线中更高效,主要是因为它避免了分支预测失败的开销,减少了流水线的清空操作,并且提高了指令级并行性,允许更多的指令同时执行。这使得条件传送在一些需要频繁进行条件分支的代码段中能够更好地发挥性能优势。然而,需要根据具体的代码特点和性能需求进行权衡选择,有时条件控制方式可能更适合。
cmov
指令在条件传送中起到了关键的作用。它是条件传送指令中的一种,用于执行条件传送操作。cmov
指令的基本语法如下:
1 | cmovXX dest, src |
其中,XX
是条件码(如e
、ne
、g
、ge
、l
、le
等),根据不同的条件码可以执行不同的条件判断。dest
表示目标操作数,src
表示源操作数。
cmov
指令的工作原理是先进行条件判断,如果条件满足,则将源操作数的值传送到目标操作数;如果条件不满足,则不进行传送操作。这样可以根据条件的真假来灵活选择数据传送的方式,而不需要进行跳转操作。
通过使用cmov
指令进行条件传送,可以提高程序的执行效率,尤其在一些需要频繁进行条件分支的代码段中。然而,需要注意的是,cmov
指令的执行时间可能会比条件跳转指令长,因此在一些执行时间敏感的代码段中,需要权衡选择合适的方式来实现条件分支。
只有在每个分支都容易计算的时候,才会使用条件传送,这样能避免条件控制带来的预测错误的开销。当然也会有一些特殊的情况,比如条件不成立,则分支中有空指针,显然不能进行条件传送。所以尽管条件传送效率更高,但是大部分情况下仍然选择条件控制。
3.7 过程
运行时栈
x86-64架构中的运行时栈(Runtime Stack)是一种用于支持函数调用和局部变量存储的数据结构。它是计算机内存中的一块特殊区域,由处理器和操作系统共同管理。
运行时栈通常以“栈”的形式组织数据,采用后进先出(LIFO)的方式进行操作。它在函数调用和返回过程中提供了一种有效的方式来保存和恢复现场,包括函数参数、返回地址和局部变量等。
以下是一些关于x86-64运行时栈的重要概念和特点:
栈指针(Stack Pointer):在x86-64中,栈指针是一个特殊的寄存器,通常称为
RSP
(64位模式下)或者ESP
(32位模式下)。栈指针指向栈顶的位置,即最后一个入栈的元素。栈帧(Stack Frame):栈帧是函数在运行时栈中的一块区域,用于存储函数的局部变量、函数参数和其他与函数执行相关的数据。每次函数调用时,都会创建一个新的栈帧,用于保存当前函数的上下文信息。
函数调用过程:当一个函数被调用时,以下步骤通常发生在运行时栈中:
- 将函数参数压入栈中。
- 保存当前函数的返回地址。
- 分配栈空间用于存储局部变量。
- 将控制转移到被调用函数的入口点。
- 在被调用函数中,可以访问参数和局部变量。
- 当函数执行完毕时,恢复返回地址,并释放栈空间。
- 将控制返回给调用函数。
栈溢出(Stack Overflow):由于运行时栈的大小是有限的,当递归调用层级过深或者在函数中分配过多的局部变量时,可能会导致栈空间不足,从而发生栈溢出错误。
运行时栈在x86-64架构中是一个重要的组成部分,它提供了函数调用和局部变量存储的机制。了解运行时栈的工作原理和特点有助于理解函数调用、栈帧布局和内存管理等底层的运行时机制。
转移控制
在x86-64架构中,call
(调用)和ret
(返回)是用于函数调用和返回的指令。
call
指令:call
指令用于调用一个函数或跳转到指定的代码段。- 在执行
call
指令时,会将当前指令的下一条指令的地址(即返回地址)压入栈中,保存现场。 - 然后,
call
指令会将控制转移到目标函数或代码段的入口点,开始执行目标代码。 - 当目标函数或代码段执行完毕后,通过
ret
指令返回调用者。
ret
指令:ret
指令用于函数返回,将控制权从被调用函数返回到调用者。- 在执行
ret
指令时,它会从栈中弹出保存的返回地址,并将控制转移到该地址所指向的指令。 - 这样,程序的执行流程回到了调用者处,继续执行后续的代码。
在函数调用过程中,call
和 ret
指令的配对使用,确保了函数的正确调用和返回:
- 调用者:
- 调用者在准备调用函数之前,将函数参数压入栈中,按照函数调用约定传递参数。
- 调用者使用
call
指令将控制转移给被调用函数,并将返回地址压入栈中。
- 被调用函数:
- 被调用函数接收参数,并在栈上分配空间用于保存局部变量。
- 被调用函数执行完毕后,使用
ret
指令将控制权返回给调用者。
- 调用者继续执行:
- 当被调用函数执行完毕,控制权返回到调用者处。
- 调用者从栈中弹出返回地址,并继续执行后续的代码。
这样,通过 call
和 ret
指令的组合,实现了函数之间的调用和返回,确保了程序的正确执行流程和返回路径。
数据传送
x86-64中,通过寄存器传递参数,但是有上限(详情见86-64中的整数寄存器),多于的参数只能通过栈传递。
栈上的局部储存
大部分情况下,不需要超出寄存器大小的本地存储趋于,但是有时候局部数据必须存放在内存中:
- 寄存器不足够存放所有的本地数据
- 对一个局部变量使用地址运算符'&',因此必须产生一个地址
- 局部变量是数组或者结构。
寄存器中的局部储存空间
寄存器组是唯一被所有过程共享的资源,虽然给定时刻只有一个过程是活动的,但是我们仍然必须确保,被调用者不会覆盖调用者稍后会使用的寄存器。
因此,x86-64采用了统一的寄存器使用管理,所有的过程都必须遵循。
按照惯例,寄存器%rbx、%rbp和%r12-%r15被划分为被调用者保存寄存器。被调用的过程要么保证这个值不变,要么压入栈中,在返回时复原这个值。
其它寄存器,除了栈指针%rsp,所有函数都可以任意修改这个寄存器。
3.8 数组的分配和访问
基本原则
在计算数组元素的地址时,可以使用以下公式:
1 | address = base_address + element_size * index |
其中: - address
是数组元素的地址。 -
base_address
是数组的起始地址或首元素的地址。 -
element_size
是每个数组元素的大小(以字节为单位)。 -
index
是要访问的元素的索引(从0开始)。
通过将索引乘以每个元素的大小,然后加上起始地址,可以计算出特定元素的地址。
例如,假设有一个数组 arr
,起始地址为
0x1000
,每个元素的大小为 4
字节(32位整数),要计算第三个元素(索引为
2)的地址,可以使用以下公式:
1 | address = 0x1000 + 4 * 2 |
计算结果为 0x1008
,即第三个元素的地址为
0x1008
。
嵌套的数组
如果数组是嵌套的,也就是多维数组,计算数组元素的地址就会稍微复杂一些。在多维数组中,每个维度都有一个对应的索引,因此需要使用多个索引来定位数组中的元素。
对于一个二维数组,可以使用以下公式来计算元素的地址:
1 | address = base_address + (element_size * (row_index * columns + column_index)) |
其中: - address
是数组元素的地址。 -
base_address
是数组的起始地址或首元素的地址。 -
element_size
是每个数组元素的大小(以字节为单位)。 -
row_index
是行索引,指定要访问的行。 -
column_index
是列索引,指定要访问的列。 -
columns
是数组的列数,表示每行有多少个元素。
在一个三维数组中,需要使用类似的公式,但是会涉及更多的索引和维度。
需要注意的是,对于多维数组,计算地址的公式可能会因不同的数组排列方式(行主序、列主序等)而有所不同。在一些情况下,还可能需要进行乘法优化或使用偏移量来处理特殊的嵌套数组布局。
定长数组和变长数组
对于定长数组,每个元素的大小已知且相同,可以使用之前提到的公式来计算元素的地址。
例如,对于一个定长数组 arr
,起始地址为
base_address
,每个元素的大小为
element_size
,要计算第 index
个元素的地址,可以使用以下公式:
1 | address = base_address + (element_size * index) |
其中 address
是数组元素的地址。
对于变长数组(例如动态分配的数组),情况略有不同。由于数组的大小是在运行时确定的,每个元素的地址无法使用简单的公式进行计算。通常,变长数组的元素是通过指针或引用进行访问,而不是直接计算地址。
在使用变长数组时,通常需要使用动态内存分配函数(例如
malloc()
)在堆上分配内存,并使用指针来访问数组元素。此时,可以使用指针运算来访问数组元素,而不需要直接计算地址。
例如,假设有一个变长数组 arr
,通过动态内存分配函数
malloc()
在堆上分配了一块内存,并返回了指向数组首元素的指针
arr_ptr
。要访问第 index
个元素,可以使用以下指针运算:
1 | element_ptr = arr_ptr + index |
其中 element_ptr
是指向第 index
个元素的指针。
需要注意的是,对于变长数组,必须在分配内存后才能进行访问,而且还需要确保在不再使用数组时释放相关的内存,以避免内存泄漏。
总结起来,对于定长数组,可以使用公式计算元素的地址。对于变长数组,通常需要使用指针来访问数组元素。
3.9 异质的数据结构
结构体
在x86-64架构中,结构体的实现与其他架构类似,使用一系列的字节来存储结构体的成员。下面是一种常见的实现方式:
内存对齐:结构体在内存中存储时,通常要进行内存对齐,以保证结构体成员在内存中的地址是对齐的。这有助于提高内存访问的效率。可以使用编译器提供的特定指令或指令集来实现内存对齐。
成员偏移:每个结构体成员在结构体中的偏移量决定了其在内存中的位置。编译器会根据结构体成员的类型和对齐要求计算出偏移量,并将成员放置在适当的位置。
结构体大小:结构体的大小取决于其成员的类型和排列方式。成员之间可能存在填充字节,以确保内存对齐。
以下是一个示例结构体的定义和实现:
1 |
|
在上述示例中,我们定义了一个名为Person
的结构体,它包含了一个字符数组name
、一个整数age
和一个浮点数height
作为成员。在main()
函数中,我们创建了一个person
结构体变量,并通过点运算符.
来访问和赋值结构体的成员。最后,我们使用printf()
函数输出结构体成员的值。
需要注意的是,结构体的具体实现可能因编译器、编译选项和对齐要求等因素而有所差异。因此,在实际编程中,最好遵循编译器的规范,并确保对结构体的访问和操作是正确和可移植的。
联合
联合(Union)是一种特殊的数据类型,允许在相同的内存位置存储不同类型的数据。与结构体不同,联合只能同时存储其中一个成员的值。以下是联合的实现方式:
内存共享:联合的所有成员共享相同的内存空间,其大小取决于最大成员的大小。这意味着联合的不同成员会占用相同的内存位置。
成员访问:通过使用联合变量的名称和成员名称,可以访问和操作联合的成员。
下面是一个简单的联合示例:
1 |
|
在上述示例中,我们定义了一个名为Data
的联合,它包含了一个整数intValue
、一个浮点数floatValue
和一个字符数组stringValue
作为成员。在main()
函数中,我们创建了一个data
联合变量,并通过点运算符.
来访问和赋值联合的成员。需要注意的是,对于联合来说,我们只能同时访问其中一个成员,对一个成员的赋值会影响其他成员。
请注意,联合的使用需要谨慎,特别是在涉及类型转换和内存访问方面。由于联合的成员共享相同的内存空间,对一个成员的修改可能会影响其他成员的值。因此,在使用联合时,请确保正确地访问和操作联合的成员,以避免潜在的错误和问题。
数据对齐
数据对齐是为了提高内存访问效率和处理器的性能而进行的一种优化技术。数据对齐确保数据在内存中按照特定的规则进行存储,使得访问对齐的数据可以更快速地进行,减少内存访问的开销。
以下是一些原因说明为什么要进行数据对齐:
提高内存访问效率:处理器通常以特定的块大小(例如字节、字、双字等)从内存中读取数据。如果数据按照处理器的访问要求进行对齐,处理器可以直接从内存中读取整块数据,而不需要进行额外的操作。这样可以提高内存读取的效率。
减少内存访问次数:当结构体或对象中的成员按照对齐要求进行排列时,可以避免在访问不对齐的数据时进行多次内存读取。相反,可以通过一次对齐的读取来获取多个成员的值,从而减少内存访问的次数,提高效率。
需要注意的是,数据对齐可能会导致一些额外的内存空间被填充,以确保成员之间的对齐。这些额外的填充字节可能会增加结构体或对象的大小。因此,在设计数据结构时,需要权衡内存使用和访问效率之间的平衡。
数据对齐是由编译器根据平台的要求自动完成的。大多数编程语言和编译器提供了一些机制,如编译器指令、编译选项或属性,以控制数据对齐的方式和行为。
3.10 在机器级程序中将控制欲数据结合起来
指针
C语言的指针是其核心特性之一,它允许直接访问内存地址,进而实现高效的内存操作和数据结构表示。指针在C语言中扮演着重要的角色,并且对于理解机器代码和底层内存操作至关重要。
首先,让我们讨论指针的概念。指针是一个变量,它存储了内存地址的值。可以通过使用取地址运算符&
将变量的地址赋给指针变量。例如,int *ptr;
声明了一个指向整数的指针。然后,通过将某个整数变量的地址赋给ptr
,可以使ptr
指向该变量。
指针的重要性在于它提供了直接访问内存的能力。通过解引用操作符*
,可以访问指针指向的内存位置上存储的值。例如,*ptr
将返回指针ptr
指向的整数值。
指针的使用要正确且谨慎。以下是一些指针相关的关键原则:
- 每个指针都有值
- 指针用'&'运算符创建
- 数组与指针紧密联系
- *操作符用来间接引用指针
- 将指针强制转化类型,只改变类型不改变值
- 指针也可以指向函数,指向的是第一条指令的地址
内存越界引用和缓存区溢出
内存越界引用(Memory Out-of-Bounds Reference): 内存越界引用指的是程序试图访问超出其分配内存范围的位置的行为。当程序访问超过其分配内存边界的位置时,可能会导致未定义的行为和安全漏洞。
例如,在C语言中,如果你声明了一个数组
int arr[5];
,它有五个元素,索引从0到4。如果你试图访问arr[5]
或者arr[100]
,这就是内存越界引用,因为你超出了数组的有效索引范围。这样的行为可能会导致程序崩溃、数据损坏或者安全漏洞,因为你可能会误用或覆盖其他内存区域的数据。内存越界引用可能导致的问题包括程序崩溃、内存泄漏、数据损坏、安全漏洞(如利用越界引用进行代码执行)等。因此,在编写程序时,务必要注意数组和指针的边界,并确保不发生内存越界引用。
缓冲区溢出(Buffer Overflow): 缓冲区溢出是指向一个缓冲区写入超过其容量的数据量的行为。当程序向一个固定大小的缓冲区写入超过其可容纳的数据时,多余的数据会覆盖到相邻的内存区域,可能导致程序崩溃或者被攻击者利用。
缓冲区溢出通常发生在使用不安全的字符串处理函数(如C语言中的
strcpy
、sprintf
等)时,因为这些函数不会检查目标缓冲区的容量。攻击者可以利用缓冲区溢出漏洞,覆盖关键数据、修改程序行为、执行恶意代码等。为了避免缓冲区溢出漏洞,可以使用安全的字符串处理函数(如C语言中的
strncpy
、snprintf
等),并且要仔细检查输入数据的大小,确保不会写入超过缓冲区容量的数据。
对抗缓存区溢出攻击
栈随机化(Stack Randomization): 栈随机化是一种安全增强技术,它通过在每次程序运行时随机选择栈的起始地址来改变栈的内存布局。在 Linux 系统中,栈随机化是 ASLR(Address Space Layout Randomization)的一部分。
ASLR 通过随机化程序的内存布局,包括栈、堆、共享库、代码段等,来增加攻击者对系统的预测难度。栈随机化是 ASLR 的一项措施,它使得栈的位置在每次程序运行时都会发生随机变化,从而减少了栈溢出攻击的成功概率。
栈随机化通过使攻击者无法准确预测栈的位置来防止栈溢出攻击。即使攻击者成功地溢出了栈缓冲区,他们也无法确定准确的返回地址或其他敏感数据的位置,从而阻止了攻击的成功执行。
栈破坏检测(Stack Protection): 栈破坏检测是一种用于检测栈溢出攻击的技术。其中一种常见的栈破坏检测技术是使用金丝雀值(Canary Value),它也是 ASLR 的一部分。
在 Linux 系统中,当启用栈保护机制时,编译器会自动将一个特殊的随机值(金丝雀值)插入到栈帧中函数返回地址之前。在函数返回时,系统会检查金丝雀值是否保持不变。如果金丝雀值被篡改,系统会判定栈溢出攻击已发生,并采取相应的安全措施,如终止程序运行或触发警报。
栈破坏检测技术通过使用金丝雀值可以检测栈溢出攻击。攻击者在进行溢出时,必须同时修改栈缓冲区和相应的金丝雀值。如果金丝雀值被修改,检测机制就会触发并采取相应的措施。
ASLR(Address Space Layout Randomization): ASLR 是一种操作系统级的安全技术,旨在增加攻击者对系统内存布局的预测难度。除了栈随机化之外,ASLR 还包括对代码段、堆、共享库等内存区域的随机化。
在 Linux 中,启用 ASLR 后,操作系统会在每次程序加载时,随机地将程序的内存布局进行重排。这意味着相同的程序在不同的运行实例中,其内存布局会发生随机变化。
ASLR 防止了许多常见的攻击技术,如栈溢出、代码注入等。通过随机化内存布局,攻击者无法准确预测关键数据和代码的位置,从而增加了攻击的难度。
支持变长栈帧
支持变长栈帧(Variable Length Stack Frames)是一种在函数调用过程中动态调整栈帧大小的技术。这通常用于支持具有可变数量参数或动态分配局部变量的函数。下面是几种常见的支持变长栈帧的方法:
栈指针调整: 变长栈帧的一种常见方法是通过在函数开始时动态调整栈指针来为变量分配空间。这通常涉及使用额外的指令来增加或减少栈指针的偏移量,以适应变量的大小。
例如,对于具有可变数量参数的函数,可以使用类似于C语言中的
va_list
和相关的宏(如va_start
和va_end
)来获取参数的地址,并使用栈指针调整来分配和访问参数。动态分配空间: 另一种支持变长栈帧的方法是通过动态分配空间来容纳变量。这可以通过在堆上分配内存并将指针存储在栈帧中来实现。
例如,如果函数需要动态分配一个可变大小的数组,可以使用堆分配函数(如
malloc
)来分配内存,并将返回的指针存储在栈帧中的变量中。在函数结束时,需要确保释放已分配的内存,以避免内存泄漏。额外的栈帧元数据: 对于支持变长栈帧的函数,通常需要存储关于栈帧结构的额外元数据信息。这些信息可以包括参数的数量、大小以及局部变量的数量和偏移量等。
这些额外的栈帧元数据通常存储在特定的位置,如栈帧的开头或结尾。通过解析和使用这些元数据,函数可以动态地访问和操作变长栈帧中的变量。