高级语言和机器码

掌握从高级语言到机器代码的翻译过程,也需了解 C 语言中不同语句与汇编代码的对应关系,可能在大题中出现,常与 cache 和 虚拟内存 放在一起考察。

编译过程

Preprocessor
Compiler
Assembler
Linker
Source Code
Executable Code
Expanded Code
Assembly Code
Object File

一个传统的 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)解析 汇编指令,将其翻译为对应的 机器码

链接

Source
File
Source
File
Source
File
Source
File
Object
File
Object
File
Object
File
Object
File
Linker
Rumtime
Library
Executable
File

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

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

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

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

汇编代码

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

选择结构语句

选择结构 在汇编中通过 条件比较指令(如 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:

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

selection_structurestart开始comparecmp eax, ebx比较 a 和 bstart->compareconditiona > b ?compare->conditiontrue_branchmov max, eaxmax = acondition->true_branchjle不跳转(a > b)false_branchmov max, ebxmax = bcondition->false_branchjle跳转(a <= b)jump_skipjmp endif_label跳过else分支true_branch->jump_skipend_labelendif_label程序继续false_branch->end_labeljump_skip->end_label

循环结构语句

循环结构汇编中以一个入口标签开始,通过 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_loopcluster_iterations循环执行过程program_start程序开始loop_startstart:循环入口program_start->loop_startconditioncmp ecx, 10count < 10 ?loop_start->conditionloop_bodyinc ecxcount++condition->loop_bodyjge不跳转(count < 10)loop_endend:循环结束condition->loop_endjge跳转(count >= 10)jump_backjmp start跳回循环开始loop_body->jump_backjump_back->loop_start循环回跳program_continue程序继续loop_end->program_continueiter1第1次: count=0→1iter2第2次: count=1→2iter1->iter2iter3...iter2->iter3iter4第10次: count=9→10iter3->iter4iter5第11次: count=10≥10跳出循环iter4->iter5
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_loopcluster_execution执行序列 (假设初始count=8)cluster_comparison与while循环的区别cluster_assembly汇编指令执行顺序program_start程序开始loop_startstart:循环体入口program_start->loop_startloop_bodyinc ecxcount++loop_start->loop_bodyconditioncmp ecx, 10jl startcount < 10 ?loop_body->conditioncondition->loop_startjl跳转(count < 10)继续循环loop_endend:循环结束condition->loop_endjl不跳转(count >= 10)退出循环program_continue程序继续loop_end->program_continueseq1第1次: count=8→99<10, 继续seq2第2次: count=9→1010>=10, 退出seq1->seq2while_flowwhile: 判断→执行→判断...do_while_flowdo-while: 执行→判断→执行...while_flow->do_while_flowguaranteedo-while保证至少执行一次do_while_flow->guaranteeasm11. inc ecx (执行)asm22. cmp ecx, 10 (比较)asm1->asm2asm33. jl start (条件跳转)asm2->asm3asm3->asm1条件为真时回跳
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_loopcluster_for_elementsfor循环三要素cluster_execution执行过程示例program_start程序开始initializationmov ecx, 0mov eax, 0初始化: i=0, sum=0program_start->initializationloop_startloop_start:循环判断入口initialization->loop_startconditioncmp ecx, 10i < 10 ?loop_start->conditionloop_bodyadd eax, ecxsum += icondition->loop_bodyjge不跳转(i < 10)loop_endloop_end:循环结束condition->loop_endjge跳转(i >= 10)incrementinc ecxi++loop_body->incrementjump_backjmp loop_start跳回判断increment->jump_backjump_back->loop_start循环回跳program_continue程序继续loop_end->program_continueinit_box① 初始化int i = 0cond_box② 条件判断i < 10update_box③ 更新操作i++exec1i=0: sum=0+0=0exec2i=1: sum=0+1=1exec1->exec2exec3i=2: sum=1+2=3exec2->exec3exec4...exec3->exec4exec5i=9: sum=36+9=45exec4->exec5exec6i=10: 跳出循环exec5->exec6

函数定义和调用

在 C 语言中,每一个函数调用在底层都会被编译为一套具体的 汇编指令。为了实现 函数的参数传递局部变量管理返回值传递等,汇编层面必须精细地管理 栈帧(stack frame)、寄存器(register)以及 指令流程(control flow)。

函数调用在汇编中的三大阶段

  1. 函数入口
    • 保存寄存器:保存 caller 的寄存器,以确保在函数执行完后,寄存器的值不被改变。
    • 设置栈帧:保存 caller 的栈帧,设置 callee 的栈帧。
  2. 函数体
    • 这部分是函数的执行逻辑,会包含各种操作指令,此时 局部变量会被保存到栈上。
  3. 函数返回
    • 如果函数有返回值,通常会将结果保存在 eax 寄存器中。
    • 恢复栈帧:恢复栈指针,确保栈帧被正确销毁。
    • 恢复寄存器:如果函数入口时保存了寄存器,那么在返回之前,需要将它们恢复。
补充

在理解汇编时,callercallee 是两个非常关键的术语:

  • caller:调用某个函数的一方。
  • callee:被调用的函数本身。

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

函数定义

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

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

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

func 函数

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)。