本地资料高级语言和机器码
SCHEDULE LOCAL16 个小节覆盖真题 20102024
关联考点汇编代码4编译过程1做相关真题 · 5 道 →
做相关真题 · 5 道选中文字可高亮或加下划线
选中文字高亮 · 下划线

高级语言和机器码

编译过程

复习提示(低优先级):区分预处理、编译、汇编、链接的输入输出,以及静态链接与动态链接的时机。

真题练习

考查得很少,大致了解 编译的具体流程每个阶段的具体任务 就行。

C 源程序经过预处理、编译、汇编和链接生成可执行文件

一个传统的 C 程序从源代码到可执行二进制程序的过程中,需要经历 预处理(Preprocess)、 编译(Compile)、 汇编(Assemble)、 链接(Link)四个步骤,这四个步骤分别由 预处理器(Preprocessor)、 编译器(Compiler)、 汇编器(Assembler)、 链接器(Linker)完成。

预处理

预处理(Preprocess)阶段负责对 源代码(source code)进行文本的转换和处理,将其转化为 扩展代码(expanded code)。具体而言,预处理阶段包含如下工作:

  • 头文件包含:将 #include 指令包含的头文件内容插入到源文件中。
  • 宏替换:将源代码中定义的宏 #define 进行替换。
  • 删除注释:将代码中的注释删除。

编译

编译(Compile)阶段将 预处理后源代码(extended code)翻译成 汇编代码(assembly code)。编译阶段包含 词法分析、语法分析、语义分析、中间代码优化 和 汇编代码生成 等子过程。

注意:“编译”(compilation)一词在广义上可指从高级语言到可执行程序的完整过程;在编译流程的狭义阶段中,则需要与“汇编”(assemble)区分:

  • 编译是将高级语言代码转化为 汇编代码
  • 汇编是将 汇编代码转化为二进制代码

汇编

汇编(Assemble)阶段负责将 汇编代码(assembly code)转换为 目标文件(object file)。汇编器(assembler)解析 汇编指令,将其翻译为对应的 机器码

链接

链接器对多个目标文件和库执行符号解析、重定位与段合并

如上图所示,链接器(linker)的作用是将由编译器生成的一个或多个 目标代码文件(object file,通常是汇编器生成的机器代码)合并为一个单一的 可执行文件。在这个过程中,链接器主要完成如下任务:

  1. 符号解析:查找所有未定义的符号(如函数调用、全局变量)并找到对应的定义。
  2. 重定位:确定 目标文件中的符号地址,并更新相关指令或数据。
  3. 合并代码和数据段:将不同 目标文件的代码和数据合并,形成最终的 可执行文件

链接分为 静态链接动态链接 两种方式。

  • 静态链接:静态链接是在编译时将所有依赖的库代码拷贝到最终的 可执行文件 中,生成一个 完全独立的二进制文件
  • 动态链接:动态链接不会在编译时将库代码合并,而是在运行时加载外部共享库(.so / .dll)。可执行文件 只包含对库的引用,而不包含库的代码。
静态链接把库代码并入可执行文件而动态链接在装载或运行时解析共享库

汇编代码

复习提示(中优先级):应能从比较、跳转、栈和寄存器操作还原选择、循环与函数调用的控制流。

偶尔会在大题中进行综合考查,给你一段 C 语言编译成的汇编代码段,然后结合一些指令和存储系统的知识一起考查,所以还是要能理解 常见 C 语言语句和对应汇编代码的关系

高级语言程序对应的 汇编代码 常常与 存储系统 在大题中进行综合考察,这里需要重点掌握 选择循环函数调用 语句对应的汇编代码。

选择结构语句

选择结构 在汇编中通过 条件比较指令(如 cmp)设置标志位,再利用 条件跳转指令(如 jle, jne 等)决定程序流程是否进入某个分支,同时通过 无条件跳转(jmp)跳过不应执行的分支,最终形成“判断 ➝ 跳转 ➝ 执行 ➝ 合流”的控制流图结构。

选择结构 C 语言


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:

上述的汇编代码可以通过以下图示辅助理解:

if-else 选择结构对应比较、条件跳转、分支执行与合流

循环结构语句

循环结构汇编中以一个入口标签开始,通过 cmp 或类似指令判断循环条件,结合 条件跳转(如 jge, jl)决定是否继续执行循环体,然后在循环末尾使用 无条件跳转(jmp)返回判断处,形成“判断 ➝ 执行 ➝ 跳回 ➝ 再判断”的闭环结构,直到条件不满足跳出循环。

C 语言中循环语句有 whiledo-whilefor 三种,三者执行流程稍有差别,但核心都在于都使用 条件跳转 控制循环流程:

while 语句

while 循环在汇编中实现的关键是 “先判断、后执行”,即编译器会先生成一个条件判断的跳转逻辑,如果条件不满足则跳出循环,否则进入循环体执行,再跳回判断位置重复该过程。

for 语句


while (count < 10) {
    count++;
}

汇编


; 假设 count 的值存放在寄存器 ecx 中
start:
cmp ecx, 10   ; 比较 count 和 10
jge end       ; 如果 count >= 10, 跳出循环
inc ecx       ; count 增加
jmp start     ; 无条件跳回循环开始
end:

上述的汇编代码可以通过以下图示辅助理解:

while 循环先判断条件再执行循环体并跳回入口

do-while 语句

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:

上述的汇编代码可以通过以下图示辅助理解:

do-while 循环先执行循环体再判断是否跳回

for 语句

for 循环在汇编中的实现是将 初始化判断更新三个阶段明确拆分:先初始化循环变量,再判断是否进入循环体;执行完循环体后进行变量更新,并跳回判断位置。它的本质是 while 循环的结构化变体,但语义更集中,便于生成高效指令序列。

for C 语言


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:

上述的汇编代码可以通过以下图示辅助理解:

for 循环的初始化、条件判断、循环体和更新阶段

函数定义和调用

在 C 语言中,每一次函数调用,最终都会被编译器翻译成一系列汇编指令。为了完成 参数传递局部变量管理返回值传递 以及 函数返回 等工作,CPU 需要借助 栈(Stack)寄存器(Register) 以及 调用指令(call/ret) 共同完成整个调用过程。

注意:重点掌握 函数调用时栈帧(Stack Frame)的组织方式,并明确具体寄存器和参数传递规则取决于 ISA 与调用约定。

本节采用 32 位 x86(cdecl 调用约定) 作为示例进行讲解,这也是 408 中最常见的模型。

实际程序生成的汇编并没有统一标准,不同 ISA(如 x86、ARM、RISC-V)、不同编译器以及不同优化等级都会影响最终的汇编实现,因此本节示例仅用于理解函数调用的基本原理。

在理解函数对应的汇编时,首先需要区分两个概念:

  • caller(调用者):发起函数调用的函数。
  • callee(被调用者):当前正在执行的函数。

例如:

void func() {
    add(1, 2);
}

此时:funccalleraddcallee

caller 调用 callee 时参数、返回地址、旧帧指针与局部变量的职责

函数调用通常可以分为四个阶段:

  1. 入栈 callee 的参数
    • 从 caller 中调用 callee 时,需要将 callee 的参数入栈
  2. 建立 callee 栈帧(函数入口)
    • 保存调用者(caller)的返回地址和 ebp(caller 的函数栈底)。
    • 建立当前函数(callee)的栈帧。
    • 为局部变量预留栈空间。
  3. 执行 callee 函数
    • 访问函数参数。
    • 读写局部变量。
    • 执行具体业务逻辑。
  4. 销毁 callee 栈帧(函数返回)
    • 将返回值保存到规定的寄存器(32 位 x86 通常为 eax)。
    • 恢复调用者的栈帧和寄存器。
    • 返回到调用点继续执行。

这一节可以结合 函数调用时内存结构 共同理解,下面通过几个简单的例子说明下 函数定义调用 的汇编代码。

函数定义

本节以一个非常简单的加法函数 add 进行说明:

add 函数


int add(int x, int y) {
    return x + y;
}

汇编


.globl _add
_add:
    ; 保存 caller 的 ebp
    push ebp
    ; 设置 callee 的 ebp
    mov  ebp, esp

    ; x + y 的汇编表示
    mov  eax, [ebp+8]
    add  eax, [ebp+12]

    ; 恢复 caller 的 ebp, esp
    mov  esp, ebp
    pop  ebp
    ret

说明

  • add 函数的两个参数 xy 是通过栈传递的。[ebp+8][ebp+12] 分别代表第一个和第二个参数。
  • 函数返回值被保存在 eax 寄存器中。
  • push ebp / mov ebp, esp 是标准做法,用于设置新函数的栈帧,确保不同函数调用之间互不干扰。

函数调用

再看一个稍复杂的例子,func 函数调用了 add 函数。

func 函数


void func() {
    int sum = add(3, 5);
    int var = sum * 2;
}

汇编


.globl _func
_func:
    push ebp
    mov  ebp, esp
    ; 为 sum 和 var 变量分配内存空间
    sub  esp, 8          ; sum、var

    ; 调用函数 add
    push 5
    push 3
    call _add
    add  esp, 8

    ; 保存返回值
    mov  [ebp-4], eax

    ; 将eax左移1位,相当于乘以2
    mov  eax, [ebp-4]
    shl  eax, 1
    ; 将结果存储到 'var'
    mov  [ebp-8], eax

    ; 函数完成,清理堆栈,并恢复ebp
    mov  esp, ebp
    pop  ebp
    ret

函数 func 的栈结构如下图所示:

            高地址
+-----------------------------+
| caller 的参数              |
+-----------------------------+
| 参数2            [ebp+12]   |
+-----------------------------+
| 参数1            [ebp+8]    |
+-----------------------------+
| 返回地址          [ebp+4]    |
+-----------------------------+ ← EBP
| caller 的 EBP     [ebp]      |
+-----------------------------+
| 局部变量1         [ebp-4]    |
+-----------------------------+
| 局部变量2         [ebp-8]    |
+-----------------------------+
| ...                         |
               低地址

函数 func 的栈帧中,参数和返回地址位于 ebp 的高地址一侧,保存的旧 ebp 位于帧基址,局部变量位于低地址一侧。

补充

以下三个知识点可以放在一起学习:

从高级语义到机器级控制流的核对

预处理、编译、汇编、链接虽然都发生在“运行之前”,产物和职责并不相同。预处理展开宏、处理条件编译和头文件;编译器把高级语言语义转换为汇编或中间表示并进行优化;汇编器把助记符和符号地址转换为目标机器码;链接器才负责合并多个目标文件、解析外部符号并记录或完成地址重定位。把汇编文件生成出来不等于已经解决了跨文件函数调用,也不等于可执行文件已经被装入内存。

高级语言中的 if、while 和 for 最终都依赖“比较、条件转移、无条件跳转”组合。比较指令通常只更新条件码而不保存差值,紧随其后的条件跳转读取这些条件码决定是否改写 PC;循环还需要在回边处更新循环变量并测试终止条件。读汇编时先画出基本块和跳转边,再判断哪一条是条件成立路径、哪一条是退出路径,比从寄存器名字猜高层语句更可靠。

调用约定保证不同函数能协作

函数调用不是仅把 PC 跳到另一段代码。调用方必须按约定放置实参、保存自己仍需使用的易失寄存器并发出调用;调用指令保存返回地址;被调函数建立自己的栈帧、保存约定需要保护的寄存器、分配局部变量,结束时恢复状态并通过返回地址回到调用点。参数由调用方还是被调方清理、返回值放在哪个寄存器、哪些寄存器必须保存,都是调用约定的一部分。

本页以帧基址和栈指针的典型布局说明相对位置。栈一般向低地址增长,因此局部变量常通过帧基址的负偏移访问,参数和返回地址位于正偏移一侧;具体偏移仍取决于位宽、参数大小、对齐和 ABI,不能脱离题设机械背诵。递归调用时每一层都有独立的返回地址和局部变量,正是多个栈帧使同一个函数能区分“当前这一层”的参数与状态。

优化后的代码可能省略固定帧指针、把局部变量放在寄存器或重排指令,但它仍必须保留调用约定所要求的可观察效果。解答题若要求“给出栈帧访问地址”,应先写清栈增长方向、SP/FP 的语义和一个字的字节数,再代入偏移,而不是只照搬某个平台的寄存器名。