高级语言和机器码

掌握从高级语言到机器代码的翻译过程,也需了解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)进行区分:

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

汇编

汇编阶段负责将汇编代码(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)。可执行文件只包含对库的引用,而不包含库的代码。

汇编代码

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

选择结构语句

选择语句的基本执行思路:选择语句中的变量被保存在寄存器中,通过条件比较指令对寄存器进行比较,然后跳转到不同的分支执行不同的代码。

选择结构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:

循环结构语句

选择结构C语言

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

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

函数定义和调用

C 语言中函数对应的汇编代码从逻辑上可以分为三个部分:

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

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

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 函数对应的汇编代码中,首先需要保存 caller(下文中的 func 函数) 的栈帧并设置 callee(即 add 函数自己)的栈帧,这个步骤通过 push ebp 以及 mov ebp, esp 完成。这个例子比较简单,所以寄存器够用,caller 和 callee 的寄存器不会出现竞争的情况,所以无需在 callee 中保存 caller 的寄存器。

add 函数体的工作就是计算 x + y 的结果。

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