高级语言和机器码
编译过程
一个传统的 C 程序从源代码到可执行二进制程序的过程中,需要经历预处理(Preprocess)、编译(Compile)、汇编(Assemble)、链接(Link)四个步骤,这四个步骤分别由预处理器(Preprocessor)、编译器(Compiler)、汇编器(Assembler)、链接器(Linker)完成。
预处理
预处理(Preprocess)阶段负责对源代码(source code)进行文本的转换和处理,将其转化为扩展代码(expanded code)。具体而言,预处理阶段包含如下工作:
- 头文件包含:将
#include
指令包含的头文件内容插入到源文件中。 - 宏替换:将源代码中定义的宏
#define
进行替换。 - 删除注释:将代码中的注释删除。
编译
编译(Compile)阶段将预处理后的源代码(extended code)翻译成汇编代码(assembly code)。编译阶段包含词法分析、语法分析、语义分析、中间代码优化和汇编代码生成等子过程。
注意编译(compilation)这个词一般的含义是将高级语言代码转化为二进制程序,但是如果在整个编译流程中谈到这个词,则需要将其与汇编(assemble)进行区分:
- 编译是将高级语言代码转化为汇编代码
- 汇编是将汇编代码转化为二进制代码
汇编
汇编阶段负责将汇编代码(assembly code)转换为目标文件(object file)。汇编器(assembler)解析汇编指令,将其翻译为对应的机器码。
链接
如上图所示,链接器(linker)的作用是将由编译器生成的一个或多个目标代码文件(object file,通常是汇编器生成的机器代码)合并为一个单一的可执行文件。在这个过程中,链接器主要完成如下任务:
- 符号解析:查找所有未定义的符号(如函数调用、全局变量)并找到对应的定义。
- 重定位:确定目标文件中的符号地址,并更新相关指令或数据。
- 合并代码和数据段:将不同目标文件的代码和数据合并,形成最终的可执行文件。
链接分为静态链接和动态链接两种方式。
- 静态链接:静态链接是在编译时将所有依赖的库代码拷贝到最终的可执行文件中,生成一个完全独立的二进制文件。
- 动态链接:动态链接不会在编译时将库代码合并,而是在运行时加载外部共享库(.so / .dll)。可执行文件只包含对库的引用,而不包含库的代码。
汇编代码
高级语言程序对应的汇编代码 尝尝与存储系统 在大题中进行综合考察,这里需要重点掌握选择、循环、函数调用语句对应的汇编代码。
选择结构语句
选择结构在汇编中通过条件比较指令(如 cmp)设置标志位,再利用条件跳转指令(如 jle, jne 等)决定程序流程是否进入某个分支,同时通过无条件跳转(jmp)跳过不应执行的分支,最终形成“判断 ➝ 跳转 ➝ 执行 ➝ 合流”的控制流图结构。
if (a > b) {
max = a;
} else {
max = b;
}
; 假设 a, b 的值分别存放在寄存器 eax 和 ebx 中
; 比较 a 和 b
cmp eax, ebx
; 如果 a <= b, 跳转到 else_label
jle else_label
; a > b 的分支,无需跳转
mov max, eax ; max = a
jmp endif_label ; 跳转到 endif_label
; a <= b 的分支
else_label:
mov max, ebx ; max = b
; 执行结束
endif_label:
循环结构语句
循环结构在汇编中以一个入口标签开始,通过 cmp 或类似指令判断循环条件,结合条件跳转(如 jge, jl)决定是否继续执行循环体,然后在循环末尾使用无条件跳转(jmp)返回判断处,形成“判断 ➝ 执行 ➝ 跳回 ➝ 再判断”的闭环结构,直到条件不满足跳出循环。
C 语言中循环语句有 while
、do-while
和 for
三种,三者执行流程稍有差别,但核心都在于都使用条件跳转控制循环流程:
while 语句
while
循环在汇编中实现的关键是“先判断、后执行”,即编译器会先生成一个条件判断的跳转逻辑,如果条件不满足则跳出循环,否则进入循环体执行,再跳回判断位置重复该过程。
while (count < 10) {
count++;
}
; 假设 count 的值存放在寄存器 ecx 中
start:
cmp ecx, 10 ; 比较 count 和 10
jge end ; 如果 count >= 10, 跳出循环
inc ecx ; count 增加
jmp start ; 无条件跳回循环开始
end:
do-while 语句
do-while
循环的核心在于“先执行一次,再判断”,因此汇编中先直接执行循环体,然后再进行条件判断,根据比较结果决定是否跳回继续执行。这种结构通过将判断逻辑放在循环体之后,确保循环体至少执行一次。
do {
count++;
} while (count < 10);
; 假设 count 存放在 ecx 中
start:
inc ecx ; count++
cmp ecx, 10 ; 比较 count 和 10
jl start ; 若 count < 10,继续循环
end:
for 语句
for
循环在汇编中的实现是将初始化、判断、更新三个阶段明确拆分:先初始化循环变量,再判断是否进入循环体;执行完循环体后进行变量更新,并跳回判断位置。它的本质是 while
循环的结构化变体,但语义更集中,便于生成高效指令序列。
for (int i = 0; i < 10; i++) {
sum += i;
}
; 假设 i 存在 ecx 中,sum 存在 eax 中
mov ecx, 0 ; 初始化 i = 0
mov eax, 0 ; 初始化 sum = 0
loop_start:
cmp ecx, 10 ; 判断 i < 10
jge loop_end ; 如果 i >= 10,跳出循环
add eax, ecx ; sum += i
inc ecx ; i++
jmp loop_start ; 回到判断
loop_end:
函数定义和调用
在 C 语言中,每一个函数调用在底层都会被编译为一套具体的汇编指令。为了实现函数的参数传递、局部变量管理、返回值传递等,汇编层面必须精细地管理栈帧(stack frame)、寄存器(register)以及指令流程(control flow)。
函数调用在汇编中的三大阶段:
- 函数入口
- 保存寄存器:保存调用者(caller)的寄存器,以确保在函数执行完后,寄存器的值不被改变。
- 设置栈帧:保存 caller 的栈帧,设置被调用者(callee)的栈帧。
- 函数体
- 这部分是函数的执行逻辑,会包含各种操作指令,此时局部变量会被保存到栈上。
- 函数返回
- 如果函数有返回值,通常会将结果保存在 eax 寄存器中。
- 恢复栈帧:恢复栈指针,确保栈帧被正确销毁。
- 恢复寄存器:如果函数入口时保存了寄存器,那么在返回之前,需要将它们恢复。
在理解汇编时,caller 和 callee 是两个非常关键的术语:
caller
:调用某个函数的一方。callee
:被调用的函数本身。
这一节可以结合 函数调用时内存结构 共同理解,下面通过几个简单的例子说明以下函数定义和调用。
函数定义
本节以一个非常简单的加法函数 add
进行说明:
int add(int x, int y) {
return x + y;
}
; 假定 'a' 和 'b' 作为参数通过堆栈传递
.globl _add
_add:
; 保存 caller 的 ebp
push ebp
; 设置 callee 的 ebp
mov ebp, esp
; x + y 的汇编表示
mov eax, [ebp+8]
add eax, [ebp+12]
; 恢复 caller 的 ebp
pop ebp
; 函数返回
ret
说明
add
函数的两个参数x
和y
是通过栈传递的。[ebp+8]
和[ebp+12]
分别代表第一个和第二个参数。- 函数返回值被保存在
eax
寄存器中。 push ebp / mov ebp, esp
是标准做法,用于设置新函数的栈帧,确保不同函数调用之间互不干扰。
函数调用
再看一个稍复杂的例子,func
函数调用了 add
函数。
void func(int a, int b) {
int sum = add(a, b);
int var = sum * 2;
// ... 一些使用 var 的代码 ...
}
.globl _func
_func:
push ebp
mov ebp, esp
; 调用函数 add
sub esp, 8
push dword [ebp+12]
push dword [ebp+8]
call _add
add esp, 8
; 保存返回值
mov [ebp-4], eax
mov eax, [ebp-4]
; 将eax左移1位,相当于乘以2
shl eax, 1
; 将结果存储到 'var'
mov [ebp-8], eax
; ... 更多使用 'var' 的代码 ...
; 函数完成,清理堆栈,并恢复ebp
mov esp, ebp
pop ebp
ret
说明:
- 调用
add(a, b)
之前,参数是从右往左压栈的,这是C
语言默认的调用约定(cdecl
)。 call _add
会把当前指令地址压入栈中(以便ret
时跳回来)。eax
保存了add
的返回值,存入局部变量sum
。- 使用
shl eax, 1
是将sum
乘以2
(左移一位即乘2
)。