408答疑
三、程序的机器级代码表示
本节是 2022 年新增的考点,历年统考真题曾多次以综合题的形式考查过,难度较大。统考大纲没有指定具体指令集,但历年统考真题主要考查的是 x86 汇编指令,因此本节主要介绍 x86 汇编指令。
常用汇编指令介绍
相关寄存器
x86 处理器中有 8 个 32 位的通用寄存器,主要寄存器及说明如下图所示。
- 为了向后兼容,EAX、EBX、ECX 和 EDX 的高两位字节和低两位字节可以独立使用,E 表示 Extended,表示 32 位的寄存器。
- 例如,EAX 的低两位字节称为 AX,而 AX 的高低字节又可分别作为两个 8 位寄存器,分别称为 AH 和 AL。
- 除 EBP 和 ESP 外,其他几个寄存器的用法是比较灵活的。
汇编指令格式
使用不同的编程工具开发程序时,用到的汇编程序也不同,一般有两种不同的汇编格式:AT&T 格式和 Intel 格式(统考要求掌握的是 Intel 格式)。它们的区别主要体现如下:
-
指令字母大小写:AT&T 格式的指令只能用小写字母,而 Intel 格式的指令对大小写不敏感。
-
操作数顺序:
- 在 AT&T 格式中,第一个为源操作数,第二个为目的操作数,方向从左到右,合乎自然;
- 在 Intel 格式中,第一个为目的操作数,第二个为源操作数,方向从右向左。
-
寄存器和立即数前缀:
- 在 AT&T 格式中,寄存器需要加前缀“%”,立即数需要加前缀“$”;
- 在 Intel 格式中,寄存器和立即数都不需要加前缀。
-
内存寻址:在内存寻址方面,AT&T 格式使用“(”和“)”,而 Intel 格式使用“[”和“]”。
-
复杂寻址方式:在处理复杂寻址方式时,例如 AT&T 格式的内存操作数“disp(base, index, scale)”分别表示偏移量、基址寄存器、变址寄存器和比例因子,如“8(%edx, %eax, 2)”表示操作数为 M [ R [ e d x ] + R [ e a x ] ∗ 2 + 8 ] M[R[edx] + R[eax]*2 + 8] M[R[edx]+R[eax]∗2+8],其对应的 Intel 格式的操作数为 [ e d x + e a x ∗ 2 + 8 ] [edx + eax*2 + 8] [edx+eax∗2+8]。
-
数据长度:在指定数据长度方面,AT&T 格式指令操作码的后面紧跟一个字符,表明操作数大小,“b”表示 byte(字节)、“w”表示 word(字)或“l”表示 long(双字)。Intel 格式也有类似的语法,它在操作码后面显式地注明 byte ptr、word ptr 或 dword ptr。
32 或 64 位体系结构都是由 16 位扩展而来的,因此用 word(字)表示 16 位。
下表所示为 AT&T 格式指令和 Intel 格式指令的对比:
AT&T 格式指令 | Intel 格式指令 | 含义 |
---|---|---|
mov $100, %eax | mov eax, 100 | 100→R[eax] |
mov %eax, %ebx | mov ebx, eax | R[eax]→R[ebx] |
mov %eax, (%ebx) | mov [ebx], eax | R[eax]→M[R[ebx]] |
mov %eax, -8(%ebp) | mov [ebp-8], eax | R[eax]→M[R[ebp]-8] |
lea 8(%edx, %eax, 2), %eax | lea eax, [edx+eax*2+8] | R[edx]R[eax]*2+8→R[eax] |
movl %eax, %ebx | mov dword ptr ebx, eax | 长度为 4 字节的 R[eax]→R[ebx] |
- mov 指令用于在内存和寄存器之间或者寄存器之间移动数据;
- lea 指令用于将一个内存地址(而不是其所指的内容)加载到目的寄存器;
- R[r] 表示寄存器 r 的内容,M[addr] 表示主存单元 addr 的内容,→ 或 ← 表示信息传送方向。
两种汇编格式的相互转换并不复杂,但历年统考真题采用的均是 Intel 格式。
常用指令
汇编指令通常可分为数据传送指令、算术和逻辑运算指令和控制流指令,下面以 Intel 格式为例,介绍一些常用的指令。以下用于操作数的标记分别表示寄存器、内存和常数。
寄存器标记:
<reg>
:表示任意寄存器,若其后带有数字,则指定其位数。<reg32>
:表示 32 位寄存器(eax, ebx, ecx, edx, esi, edi, esp 或 ebp);<reg16>
:表示 16 位寄存器(ax, bx, cx 或 dx);<reg8>
:表示 8 位寄存器(ah, al, bh, bl, ch, cl, dh, dl)。
内存地址标记:
<mem>
:表示内存地址( [如eax]、[var + 4] 或 dword ptr [eax + ebx])。
常数标记:
<con>
:表示 8 位、16 位或 32 位常数。<con8>
:表示 8 位常数;<con16>
:表示 16 位常数;<con32>
:表示 32 位常数。
分析汇编指令对应的二进制代码:
x86 中的指令机器码长度为 1 字节,对同一指令的不同用途有多种编码方式,比如 mov
指令就有 28 种机内编码,用于不同操作数类型或用于特定寄存器。例如,
mov ax, <con16> #机器码为 B8H
mov al, <con8> #机器码为 BOH
mov <reg16>, <regl6>/<mem16> #机器码为 89H
mov <reg8>/<mem8>, <reg8> #机器码为 8AH
moy <req16>/<mem16>, <req16> #矶器码为 8BH
数据传送指令
mov 指令
将第二个操作数(寄存器的内容、内存中的内容或常数值)复制到第一个操作数(寄存器或内存)。
- 语法:
mov <reg>, <reg>
mov <reg>, <mem>
mov <mem>, <reg>
mov <reg>, <con>
mov <mem>, <con>
- 举例:
mov eax, ebx # 将 ebx 值复制到 eax
mov byte ptr [var], 5 # 将 5 保存到 var 值指示的内存地址的一字节中
- 双操作数指令的两个操作数不能都是内存,即
mov
指令不能用于直接从内存复制到内存,若需在内存之间复制,可先从内存复制到一个寄存器,再从这个寄存器复制到内存。
push 指令
将操作数压入内存的栈,常用于函数调用。ESP 是栈顶,入栈前先将 ESP 值减 4(栈增长方向与内存地址增长方向相反),然后将操作数压入 ESP 指示的地址。
- 语法:
push <reg32>
push <mem>
push <con32>
- 举例:
push eax #将 eax 值入栈
push [var] #将 var 值指示的内存地址的 4 字节值入栈
栈中元素固定为 32 位
pop 指令
与 push
指令相反,pop
指令执行的是出栈工作,出栈前先将 ESP 指示的地址中的内容出栈,然后将 ESP 值加 4。
- 语法:
pop eax # 弹出栈顶元素送到 eax
pop [ebx] # 弹出栈顶元素送到 ebx 值指示的内存地址的 4 字节中
算术和逻辑运算指令
add/sub 指令
add
指令将两个操作数相加,相加的结果保存到第一个操作数中。sub
指令用于两个操作数相减,相减的结果保存到第一个操作数中。
- 语法:
add <reg>, <reg>
add <reg>, <mem>
add <mem>, <reg>
add <reg>, <con>
add <mem>, <con>
sub <reg>, <reg>
sub <reg>, <mem>
sub <mem>, <reg>
sub <reg>, <con>
sub <mem>, <con>
- 举例:
sub eax, 10 # eax ← eax - 10
add byte ptr [var], 10 # 10 与 var 值指示的内存地址的一字节值相加,并将结果保存在 var 值指示的内存地址的字节中
inc/dec 指令
inc
、dec
指令分别表示将操作数自加 1、自减 1。
- 语法:
inc <reg>
inc <mem>
dec <reg>
dec <mem>
- 举例:
dec eax # eax 值自减 1
inc dword ptr [var] # var 值指示的内存地址的 4 字节值自加 1
imul 指令
有符号整数乘法指令,有两种格式:
- 两个操作数,将两个操作数相乘,将结果保存在第一个操作数中,第一个操作数必须为寄存器;
- 三个操作数,将第二个和第三个操作数相乘,将结果保存在第一个操作数中,第一个操作数必须为寄存器。
- 语法:
imul <reg32>, <reg32>
imul <reg32>, <mem>
imul <reg32>, <reg32>, <con>
imul <reg32>, <mem>, <con>
- 举例:
imul eax, [var] # eax ← eax * [var]
imul esi, edi, 25 # esi ← edi * 25
乘法操作结果可能溢出,则编译器置溢出标志 OF=1,以使 CPU 调出溢出异常处理程序。
idiv 指令
有符号整数除法指令,它只有一个操作数,即除数,而被除数则为 edx:eax 中的内容(共 64 位),操作结果有两部分:商和余数,商送到 eax,余数则送到 edx。
- 语法:
idiv <reg32>
idiv <mem>
- 举例:
idiv ebx # 进行除法操作
idiv dword ptr [var] # 进行内存地址的除法操作
and/or/xor 指令
and
、or
、xor
指令分别是逻辑与、逻辑或、逻辑异或操作指令,用于操作数的位操作,操作结果放在第一个操作数中。
- 语法:
and <reg>, <reg>
and <reg>, <mem>
and <mem>, <reg>
and <reg>, <con>
and <mem>, <con>
or <reg>, <reg>
or <reg>, <mem>
or <mem>, <reg>
or <reg>, <con>
or <mem>, <con>
xor <reg>, <reg>
xor <reg>, <mem>
xor <mem>, <reg>
xor <reg>, <con>
xor <mem>, <con>
- 举例:
and eax, 0fH # 将 eax 中的前 28 位全部置为 0,最后 4 位保持不变
xor edx, edx # 置 edx 中的内容为 0
not 指令
位翻转指令,将操作数中的每一位翻转,即 0→1、1→0。
- 语法:
not <reg>
not <mem>
- 举例:
not byte ptr [var] # 将 var 值指示的内存地址的一字节的所有位翻转
neg 指令
取负指令。
- 语法:
neg <reg>
neg <mem>
- 举例:
neg eax # eax ← -eax
shl/shr 指令
逻辑移位指令,shl 为逻辑左移,shr 为逻辑右移,第一个操作数表示被操作数,第二个操作数指示移位的位数。
- 语法:
shl <reg>, <con8>
shl <mem>, <con8>
shl <reg>, <cl>
shl <mem>, <cl>
shr <reg>, <con8>
shr <mem>, <con8>
shr <reg>, <cl>
shr <mem>, <cl>
- 举例:
shl eax, 1 # 将 eax 值左移 1 位
shr ebx, cl # 将 ebx 值右移 n 位(n 为 cl 中的值)
控制流指令
x86 处理器维持着一个指示当前执行指令的指令指针(IP),当一条指令执行后,此指针自动指向下一条指令。IP 寄存器不能直接操作,但可以用控制流指令更新。通常用标签(label)指示程序中的指令地址,在 x86 汇编代码中,可在任何指令前加入标签。例如,
mov esi,[ebp+8]
begin: xor ecx, ecx
mov eax,[esi]
这样就用 begin 指示了第二条指令,控制流指令通过标签就可以实现程序指令的跳转。
jmp 指令
jmp
指令控制 IP 转移到 label 所指示的地址(从 label 中取出指令执行)。
- 语法:
jmp <label>
- 举例:
jmp begin # 跳转到 begin 标记的指令执行
jcondition 指令
条件转移指令,依据 CPU 状态字中的一系列条件状态转移。CPU 状态字中包括指示最后一个算术运算结果是否为 0,运算结果是否为负数等。
- 语法:
je <label> (jump when equal)
jz <label> (jump when last result was zero)
jne <label> (jump when not equal)
jg <label> (jump when greater than)
jge <label> (jump when greater than or equal to)
jl <label> (jump when less than)
jle <label> (jump when less than or equal to)
- 举例:
cmp eax, ebx # 比较 eax 和 ebx
jle done # 若 eax 值 <= ebx 值,则跳转到 done 执行;否则执行下一条指令
cmp/test 指令
cmp
指令的功能相当于 sub
指令,用于比较两个操作数的值。test
指令的功能相当于 and
指令,对两个操作数进行逐位与运算。与 sub
和 and
指令不同的是,这两类指令都不保存操作结果,仅根据运算结果设置 CPU 状态字中的条件码。
- 语法:
cmp <reg>, <reg>
cmp <reg>, <mem>
cmp <mem>, <reg>
cmp <reg>, <con>
test <reg>, <reg>
test <reg>, <mem>
test <mem>, <reg>
test <reg>, <con>
- 举例:
cmp dword ptr [var], 10 # 将 var 指示的主存地址的 4 字节内容,与 10 比较
jne loop # 若相等则继续顺序执行;否则跳转到 loop 处执行
test eax, eax # 测试 eax 是否为零
jz xxxx # 为零则置标志 ZF 为 1,跳转到 xxxx 处执行
call/ret 指令
分别用于实现子程序(过程、函数等)的调用及返回。
- 语法:
call <label>
ret
- 举例:
call label # 调用 label 标记的子程序
call
指令将下一条指令的地址(返回地址)入栈,然后无条件转移到由标签指示的指令。与其他简单的跳转指令不同,call
指令保存该指令的下一条指令的地址(当call
指令结束后,返回保存的地址)。ret
指令实现子程序的返回机制,ret
指令弹出栈中保存的指令地址,然后无条件转移到保存的指令地址执行。call
和ret
是程序(函数)调用中最关键的两条指令。
理解上述指令的语法和用途,可以更好地帮助读者解答相关题型。读者在上机调试 C 程序代码时,也可以尝试用编译器调试,以便更好地帮助理解机器指令的执行。
选择语句的机器级表示
- 常见的选择结构语句有
if-then
、if-then-else
等。编译器通过条件码(标志位)设置指令和各类转移指令来实现程序中的选择结构语句。 - 条件码描述了最近的算术或逻辑运算操作的属性,可以检测这些寄存器来执行条件分支指令,最常用的条件码有 CF、ZF、SF 和 OF。
- 常见的算术逻辑运算指令(add, sub, imul, or, and, shl, inc, dec, not, sal 等)会设置条件码,还有 cmp 和 test 指令只设置条件码而不改变任何其他寄存器。
- 之前介绍的 jcondition 条件转移指令,就是根据条件码 ZF 和 SF 来实现跳转的。
if-else 语句
if-else 语句的通用形式如下:
if (test_expr)
then_statement
else
else_statement
这里的 test_expr 是一个整数表达式,它的取值为 0(假),或为非 0(真)。两个分支语句(then_statement 或 else_statement)中只会执行一个。
这种通用形式可以被翻译成如下所示的 goto 语句形式:
t = test_expr;
if (!t)
goto false;
then_statement
goto done;
false:
else_statement
done:
C 语言函数
对于下面的 C 语言函数:
int get_cont(int *p1, int *p2) {
if (p1 > p2)
return *p2;
else
return *p1;
}
已知 p1 和 p2 对应的实参已被压入调用函数的栈帧,它们对应的存储地址分别为 R[ebp] + 8、R[ebp] + 12(EBP 指向当前栈帧底部),返回结果存放在 EAX 中。对应的汇编代码为:
mov eax, dword ptr [ebp+8] # R[eax] ← M[R[ebp]+8](将基址为 EBP 的偏移地址 8 处的值(即 p1)加载到 EAX 寄存器中),即 R[eax] = p1
mov edx, dword ptr [ebp+12] # R[edx] ← M[R[ebp]+12](将基址为 EBP 的偏移地址 12 处的值(即 p2)加载到 EDX 寄存器中),即 R[edx] = p2
cmp eax, edx # 比较 p1 和 p2(比较 EAX 和 EDX 寄存器中的值),即根据 p1-p2 的结果置标志
jbe .L1 # 若 p1 <= p2,则转标记 L1 处执行
mov eax, dword ptr [edx] # R[eax] ← M[R[edx]](将 EDX 寄存器指向的地址(即 p2 指向的值)加载到 EAX 寄存器中),即 R[eax] = M[p2]
jmp .L2 # 无条件跳转到标记 L2 执行
.L1:
mov eax, dword ptr [eax] # R[eax] ← M[R[eax]](将 EAX 寄存器指向的地址(即 p1 指向的值)加载到 EAX 寄存器中),即 R[eax] = M[p1]
.L2:
p1 和 p2 是指针型参数,所以在 32 位机中的长度是 dword ptr,比较指令 cmp 的两个操作数都应来自寄存器,因此应先将 p1 和 p2 对应的实参从栈中取到通用寄存器,比较指令执行后得到各个条件码,然后根据各条件码值的组合选择执行不同的指令,因此需要用到条件转移指令。
循环语句的机器级表示
循环语句在编程中非常常见,主要有 while
、for
和 do-while
三种形式。汇编中没有直接对应的指令,但可以通过条件测试和跳转指令组合实现循环效果。大多数编译器将这三种循环结构转换为 do-while
形式来产生机器代码。
do-while 循环
do-while 循环的通用形式如下:
do {
body_statement
} while (test_expr);
这种通用形式可以被翻译成条件和 goto 语句:
loop:
body_statement
t = test_expr;
if (t)
goto loop;
- 每次循环,程序会执行循环体内的语句
body_statement
至少执行一次,然后执行测试表达式。若测试为真,则继续执行循环。
while 循环
while 循环的通用形式如下:
while (test_expr) {
body_statement
}
与 do-while 的不同之处在于,第一次执行 body_statement
之前,就会测试 test_expr
的值,循环有可能中止。GCC 通常会将其翻译成条件分支加 do-while 循环的方式。
t = test_expr;
if (!t)
goto done;
do {
body_statement
t = test_expr;
} while (t);
done:
相应地,进一步将它翻译成 goto 语句:
t = test_expr;
if (!t)
goto done;
loop:
body_statement
t = test_expr;
if (t)
goto loop;
done:
for 循环
for 循环的通用形式如下:
for (init_expr; test_expr; update_expr) {
body_statement
}
这个 for 循环的行为与下面的 while 循环代码的行为一样:
init_expr;
while (test_expr) {
body_statement
update_expr;
}
进一步将它翻译成 goto 语句:
init_expr;
t = test_expr;
if (!t)
goto done;
loop:
body_statement
update_expr;
t = test_expr;
if (t)
goto loop;
done:
C 语言函数
下面是一个用 for 循环写的自然数求和的函数:
int nsum_for(int n) {
int i;
int result = 0;
for (i = 1; i <= n; i++)
result += i;
return result;
}
这段代码中的 for 循环的不同组成部分如下:
init_expr i = 1
test_expr i <= n
update_expr i++
body_statement result += i
通过替换前面给出的模板中的相应位置,很容易将 for
循环转换为 while
或 do-while
循环。将这个函数翻译为 goto 语句代码后,不难得出其过程体的汇编代码:
mov ecx, dword ptr [ebp+8] # R[ecx] ← M[R[ebp]+8](将基址为 EBP 的偏移地址 8 处的值(即 n)加载到 ECX 寄存器中),即 R[ecx] = n
mov eax, 0 # R[eax] ← 0(R[eax] = 0), 即 result = 0
mov edx, 1 # R[edx] ← 1(R[edx] = 1), 即 i = 1
cmp edx, ecx # Compare R[edx] : R[ecx], 即比较 i : n
jg .L2 # If greater, 转跳到 L2 执行
.L1:
add eax, edx # R[eax] ← R[eax]+R[edx](R[eax] = R[eax] + R[edx]), 即 result += i
add edx, 1 # R[edx] ← R[edx]+1(R[edx] = R[edx] + 1), 即 i++
cmp edx, ecx # Compare R[edx] 和 R[ecx], 即比较 i : n
jle .L1 # If less or equal, 转跳到 L1 执行
.L2:
已知 n 对应的实参已被压入调用函数的栈帧,其对应的存储地址为 R[ebp]+8,过程 nsum_for 中的局部变量 i 和 result 被分别分配到寄存器 EDX 和 EAX 中,返回参数在 EAX 中。
过程调用的机器级表示
- call/ret 指令主要用于过程调用,属于无条件转移指令。
- 假定过程 P(调用者)调用过程 Q(被调用者),过程调用的执行步骤如下:
- 参数传递:调用者 P 将入口参数(实参)放到被调用者 Q 能访问到的地方。
- 返回地址保存:调用者 P 将返回地址存到特定的地方,然后将控制转移到被调用者 Q。
- 现场保存:被调用者 Q 保存调用者 P 的现场(通用寄存器的内容),并为自己的非静态局部变量分配空间。
- 执行过程:被调用者 Q 执行过程。
- 现场恢复:被调用者 Q 恢复调用者 P 的现场,将返回结果放到 P 能访问到的地方,并释放局部变量所占空间。
- 返回:被调用者 Q 取出返回地址,将控制转移到调用者 P。
步骤 2 由 call 指令实现的,步骤 6 通过 ret 指令返回到过程 P。
返回地址与现场的保存与恢复
- 在上述步骤中,需要为入口参数、返回地址、过程 P 的现场、过程 Q 的局部变量、返回结果找到存放空间。
- 用户可见寄存器数量有限,调用者和被调用者需共享寄存器,若直接覆盖对方的寄存器,则会导致程序出错。
- 因此有如下规范:寄存器 EAX、ECX 和 EDX 是调用者保存寄存器,当 P 调用 Q 时,若 Q 需用到这些寄存器,则由 P 将这些寄存器的内容保存到栈中,并在返回后由 P 恢复它们的值。
- 寄存器 EBX、ESI、EDI 是被调用者保存寄存器,当 P 调用 Q 时,Q 必须先将这些寄存器的内容保存在栈中才能使用它们,并在返回 P 之前先恢复它们的值。
栈帧结构
- 每个过程都有自己的栈区,称为栈帧,因此,一个栈由若干栈帧组成,寄存器 EBP 指示栈帧的起始位置,寄存器 ESP 指示栈顶,栈从高地址向低地址增长。
- 过程执行时,ESP 会随着数据的入栈而动态变化,而 EBP固定不变。
- 当前栈帧的范围在 EBP 和 ESP 指向的区域之间。
C 语言函数
下面用一个简单的 C 语言程序来说明过程调用的机器级实现。
int add(int x, int y) {
return x + y;
}
int caller() {
int temp1 = 125;
int temp2 = 80;
int sum = add(temp1, temp2);
return sum;
}
经 GCC 编译后,caller 和 add 过程对应的代码如下:
add:
push ebp
mov ebp, esp
mov eax, dword ptr [ebp+12] # R[eax] = x
mov edx, dword ptr [ebp+8] # R[edx] = y
lea eax, [edx+eax] # R[eax] = x + y
pop ebp
ret
caller:
push ebp
mov ebp, esp
sub esp, 24
mov dword ptr [ebp-12], 125 # M[R[ebp]-12] ← 125,即 temp1 = 125
mov dword ptr [ebp-8], 80 # M[R[ebp]-8] ← 80,即 temp2 = 80
mov eax, dword ptr [ebp-8] # R[eax] ← M[R[ebp]-8],即 R[eax] = temp2
mov dword ptr [esp+4], eax # M[R[esp]+4] ← R[eax],即 temp2 入栈
mov eax, dword ptr [ebp-12] # R[eax] ← M[R[ebp]-12],即 R[eax] = temp1
mov dword ptr [esp], eax # M[R[esp]] ← R[eax],即 temp1 入栈
call add # 调用 add 函数,将返回值保存在 eax 中
mov dword ptr [ebp-4], eax # M[R[ebp]-4] ← R[eax],即 add 返回值送 sum
mov eax, dword ptr [ebp-4] # R[eax] ← M[R[ebp]-4],即 sum 作为返回值
leave
ret
- 下图给出了 caller 和 add 的栈帧,假定 caller 被过程 P 调用。
- 执行 caller 函数部分第 4 行的指令后,ESP 所指的位置如图所示,可以看出 GCC 为 caller 的参数分配了 24 字节的空间。
- 从汇编代码中可以看出,caller 中只使用了调用者保存寄存器 EAX,没有使用任何被调用者保存寄存器,因此在 caller 栈帧中无须保存除 EBP 外的任何寄存器的值;
- caller 有三个局部变量 temp1、temp2 和 sum,皆被分配在栈帧中;
- 在用 call 指令调用 add 函数之前,caller 先将入口参数从右向左依次将 temp2 和 temp1 的值(即 80 和 125)保存到栈中。
- 在执行 call 指令时再把返回地址压入栈中,此外,最初进入 caller 时,还将 EBP 的值压入了栈,因此 caller 的栈帧中用到的空间占 4 + 12 + 8 + 4 = 28 4+12+8+4=28 4+12+8+4=28 字节。但是,caller 的栈帧共有 4 + 24 + 4 = 32 4+24+4=32 4+24+4=32 字节,其中浪费了 4 字节的空间(未使用)。这是因为 GCC 为保证数据的严格对齐而规定每个函数的栈帧大小必须是 16 字节的倍数。
- call 指令执行后,add 函数的返回参数存放在 EAX 中,因此 call 指令后面的两条指令中,指令“mov [ebp-4], eax”将 add 的结果存入 sum 变量的存储空间,该变量的地址为 R[ebp]-4;指令“mov eax, dword ptr [ebp-4]”将 sum 变量的值作为返回值送到寄存器 EAX 中。
leave 指令功能
在执行 ret 指令之前,应将当前栈帧释放,并恢复旧 EBP 的值。上述 caller 函数部分第 14 行由 leave 指令实现了这个功能,leave 指令功能相当于以下两条指令的功能:
mov esp, ebp
pop ebp
- 第一条指令使 ESP 指向当前 EBP 的位置。
- 第二条指令执行后,EBP 恢复为 P 中的旧值,并使 ESP 指向返回地址。
ret 指令功能
- 执行完 leave 指令后,ret 指令就可从 ESP 所指处取返回地址,以返回 P 执行。
- 当然,编译器也可通过 pop 指令和对 ESP 的内容做加法来进行退栈操作,而不一定要使用 leave 指令。
过程调用的三个阶段(以 add 函数为例)
通常,一个过程调用对应的机器级代码都有三个部分:准备阶段、过程体和结束阶段。
准备阶段
- 上述第 1、2 行的指令构成准备阶段的代码段,这是最简单的准备阶段代码段,它通过将当前栈指针 ESP 传送到 EBP 来完成将 EBP 指向当前栈帧底部的任务。
- 如上图所示,EBP 指向 add 栈帧底部,从而可以通过 EBP 获取入口参数。这里 add 的入口参数 x 和 y 对应的值(125 和 80)分别在地址为 R [ e b p ] + 8 R[ebp]+8 R[ebp]+8、 R [ e b p ] + 12 R[ebp]+12 R[ebp]+12 的存储单元中。
过程体
- 上述第 3、4、5 行的指令序列是过程体的代码段,过程体的代码段将返回值放在 EAX 中。
- 这里好像没有加法指令,实际上第 5 行 lea 指令执行的是加法运算 R [ e d x ] + R [ e a x ] = x + y R[edx] + R[eax] = x + y R[edx]+R[eax]=x+y。
结束阶段
- 上述第 6、7 行的指令序列是结束阶段的代码段,通过将 EBP 弹出栈帧来恢复 EBP 在 caller 过程中的值,并在栈中退出 add 过程的栈帧,使得执行到 ret 指令时栈顶中已经是返回地址。
- 这里的返回地址应该是 caller 代码中第 12 行的指令 “mov [ebp-4], eax” 的地址。
add 函数的特点
- add 过程中没有用到任何被调用者保存寄存器,没有局部变量。
- 此外,add 是一个被调用过程,并且不再调用其他过程,因此也没有入口参数和返回地址要保存。
- 因此,在 add 的栈帧中除了需要保存 EBP,无须保留其他任何信息。
五、参考资料
鲍鱼科技课件
b站免费王道课后题讲解:
网课全程班: