• 忘掉天地
  • 仿佛也想不起自己
bingliaolongBingliaolong  2026-04-06 22:44 Aet 隐藏边栏 |   抢沙发  1 
文章评分 1 次,平均分 5.0

概述

编译阶段

  1. 编译阶段是整个编译流水线中最复杂、最核心的阶段——它是编译器的"大脑"

  1. 定义
    1. 编译阶段把预处理后的 C/C++ 源码(纯文本)翻译成汇编语言(或 LLVM IR
    2. 这个阶段要完成"理解高级语言的含义,并用低级语言表达出来"这个核心任务
  2. 总结
    1. 预处理阶段是纯文本替换,不理解 C++ 语法
    2. 后面的汇编阶段是机械翻译,一条汇编指令对应一串固定的字节
    3. 而编译阶段需要真正理解代码的含义——它要理解 int x = a + b 表示"把两个整数相加并存储",然后决定用哪些寄存器、用哪条机器指令来实现

编译阶段的内部流程

  1. 编译阶段本身又分为多个子阶段
  2. 具体见编译阶段详解

生成ATT汇编

生成Intel汇编

编译阶段详解

code

词法分析

  1. 实际查看 Clang 的词法分析结果
    1. 每个 Token 带着精确的源码位置
    2. 当后面的阶段检测到错误时(比如语法错误、类型错误),错误信息中的文件名和行号就来自这里

  1. 词法分析还做了一件事
    1. 过滤掉所有空白和注释
    2. 预处理阶段已经去掉了注释,但空格、换行、缩进在预处理输出中还在
    3. 词法分析器跳过这些空白,只输出有意义的 Token
  2. Clang 源码中的位置

语法分析

  1. 注意 AST 中出现了很多 ImplicitCastExpr(隐式类型转换)
    1. 这些在源码中看不到,但 C++ 语言规则要求它们存在
    2. 比如 DeclRefExpr 'a' 是一个左值(lvalue),加法运算需要右值(rvalue),所以编译器插入了一个 LValueToRValue 隐式转换
    3. 这就是下一个阶段(语义分析)的工作
  2. Clang 源码中的位置

语义分析

  1. 快速检查

  1. Clang 源码中的位置

IR 生成

  1. IR结果-未优化

  1. 注意 IR 的几个关键特征:

  1. Clang 源码中的位置

优化

  1. 对比 -O0-O2IR

  1. 查看 -O2 执行了哪些 Pass

  1. Clang 源码中的位置

后端代码生成

  1. 查看后端的中间过程

  1. LLVM 源码中的位置

编译阶段不做的事情

示例

code

intel汇编

intel汇编解析

构成

  1. 汇编器指令(以 . 开头的,不是 CPU 指令,是给汇编器看的元数据)
  2. add 函数的汇编代码
  3. main 函数的汇编代码
  4. 字符串常量数据
  5. 文件尾部元信息

.text

  1. 声明接下来的内容属于 .text 节(代码段)
  2. 对应 ELF 文件中的 .text section

.intel_syntax noprefix

  1. 告诉汇编器使用 Intel 语法,并且寄存器名不需要 % 前缀
  2. 这是用 -masm=intel 参数的结果
  3. 如果不加这个参数,生成的是 AT&T 语法(movl %edi, -4(%rbp) 那种风格)

.file "test.cpp"

  1. 记录源文件名。这个信息会写入 ELF 的调试信息和符号表中

.globl _Z3addii

  1. 声明 _Z3addii 是一个全局符号(外部可见)
  2. .globl 对应 nm 输出中的大写 T
  3. 如果没有 .globl,这个符号就是局部的(小写 t),其他 .o 文件看不到它

_Z3addii 是什么?

  1. C++ 的名称修饰(Name Mangling
  2. 因为 C++ 有函数重载,add(int, int)add(double, double) 必须有不同的符号名
  3. 规则是:

  1. 区别于msvc的名称修饰

.p2align 4, 0x90

  1. 把下一条指令的地址对齐到 2⁴ = 16 字节边界
  2. 0x90(NOP 指令)填充空隙
  3. 函数入口对齐到 16 字节是为了 CPU 指令缓存性能——缓存行通常是 64 字节,对齐的代码缓存命中率更高

.type _Z3addii,@function

  1. 告诉汇编器这个符号是一个函数(不是变量)
  2. 这个信息写入 ELF 符号表的 st_info 字段
    1. readelf -s 输出中的 FUNC 类型就来自这里

_Z3addii: # @_Z3addii

  1. 函数标签(Label),标记函数的入口地址
  2. @_Z3addiiLLVM 内部 IR 中的符号名注释

.cfi_startproc

  1. CFI = Call Frame Information
  2. 这是 DWARF 调试信息的一部分,告诉调试器和异常处理机制"这里开始了一个新函数的栈帧"
    1. GDBbt 命令和 C++ 异常的栈展开(stack unwinding)都依赖 CFI 信息
  3. 对应 Windows.pdata.xdata 节中的 unwind 信息

# %bb.0:

  1. 注释,表示这是第 0 个基本块(Basic Block
  2. %bb.0LLVM IR 中基本块的编号
  3. 一个基本块是一段顺序执行的指令,没有中间的跳转——只在开头进入,末尾离开

push rbp

  1. add 函数的指令
  2. 把调用者的帧指针压入栈
    1. rsp 自动减 864 位系统指针是 8 字节)

.cfi_def_cfa_offset 16

  1. 告诉调试器:现在 CFACanonical Frame Address,规范帧地址)在 rsp + 16
    1. 因为刚压入了 8 字节的 rbp,加上之前 call 指令压入的 8 字节返回地址,所以 CFA 距离当前 rsp16

.cfi_offset rbp, -16

  1. 告诉调试器:旧的 rbp 值保存在 CFA-16 的位置
  2. 调试器做栈展开时需要恢复每一帧的 rbp

mov rbp, rsp

  1. 建立当前函数的帧指针
  2. 从此刻起,rbp 指向当前栈帧的底部,局部变量和参数都通过 rbp 偏移访问

.cfi_def_cfa_register rbp

  1. 告诉调试器:现在 CFA 改用 rbp + 16 来计算了
    1. 因为 rbp 已经固定,后续 rsp 可能变化,但 rbp 不变

mov dword ptr [rbp - 4], edi

  1. 把第一个参数 a 从寄存器 edi 保存到栈上 [rbp-4] 的位置
  2. dword ptr 表示操作的是 4 字节(32 位)数据,因为 int4 字节
  3. ediSystem V 调用约定中第一个整数参数的寄存器

mov dword ptr [rbp - 8], esi

  1. 把第二个参数 besi 保存到栈上 [rbp-8]
  2. esi 是第二个参数的寄存器
  3. 为什么要把寄存器里的参数保存到栈上?
    1. 因为这是 -O0(未优化)编译
    2. 编译器在 -O0 模式下会把所有变量都放到栈上,方便调试
    3. GDBprint a 时,GDB 就是从 [rbp-4] 读取 a 的值
    4. 开了优化后,这两行会被消除,参数直接留在寄存器中

mov eax, dword ptr [rbp - 4]

  1. 从栈上读回 aeax

add eax, dword ptr [rbp - 8]

  1. eax = eax + [rbp-8]
    1. 也就是 eax = a + b
    2. 计算结果在 eax
  2. System V 调用约定规定整数返回值放在 eax/rax 中,所以这条指令之后返回值已经准备好了

pop rbp

  1. 恢复调用者的帧指针
  2. rsp 自动加 8

.cfi_def_cfa rsp, 8

  1. 告诉调试器:栈帧已经被销毁了,CFA 现在在 rsp + 8(因为栈上还剩返回地址)

ret

  1. 从栈中弹出返回地址到 rip,跳转回调用者
    1. 等价于 pop rip
  2. 执行完后控制权回到 maincall _Z3addii 的下一条指令

.Lfunc_end0

  1. .Lfunc_end0 标记函数末尾的地址
  2. .size 指令计算函数的大小:末尾地址 - 起始地址
    1. 这个信息写入 ELF 符号表的 st_size 字段
  3. .L 前缀表示这是一个局部标签,不会出现在符号表中

.cfi_endproc

  1. CFI 信息结束。和 .cfi_startproc 配对

main 函数

  1. add 函数一样的序言声明
  2. 注意 main 没有名称修饰——因为 main 是程序入口,链接器和 C 运行时需要按字面名 main 找到它
    1. C++ 标准规定 main 不做 mangling

  1. add 函数一样的栈帧建立

  1. add 函数一样的栈帧建立

  1. 在栈上分配 16 字节的局部变量空间
    1. 为什么是 16 而不是 8(只需要两个 int = 8 字节)?
    2. 因为 x86-64 ABI 要求 call 指令执行时 rsp 必须 16 字节对齐
    3. 编译器分配的栈空间总是 16 的倍数

  1. mov dword ptr [rbp - 4], 0
    1. 这行源码里没有对应的语句
    2. 它是编译器为 main 函数隐含生成的——main 的返回值初始化为 0
    3. C++ 标准规定如果 main 没有显式 return,默认返回 0
    4. 编译器在 -O0 下会在栈上预留一个位置存这个返回值
  2. 准备调用 add(3, 4)
    1. System V 调用约定,第一个参数放 edi(3),第二个放 esi(4)
    2. 对比 Windows x64 调用约定会是 ecxedx

  1. 调用 add 函数
    1. call 做两件事:把下一条指令的地址(返回地址)压入栈,然后跳转到 _Z3addii

  1. add 返回后,返回值在 eax
    1. 这行把返回值(7)保存到局部变量 x 的栈位置 [rbp-8]

  1. 准备 printf 的第二个参数
    1. 从栈上读回 x 的值到 esi</li> <li>printf("res is %d\n", x)x 是第二个参数,所以放 esi

  1. 准备 printf 的第一个参数
    1. 格式字符串 "res is %d\n" 的地址
    2. leaLoad Effective Address,只计算地址不访问内存

  1. mov al, 0
    1. 这行是 System V 调用约定对可变参数函数(variadic function)的特殊要求
    2. printf 是可变参数函数(int printf(const char*, ...)),调用约定规定 al 中要存放使用的浮点寄存器数量
    3. 这里没有浮点参数,所以 al = 0
    4. Windows x64 没有这个要求
  2. call printf@PLT
    1. 通过 PLTProcedure Linkage Table)调用 printf
    2. printflibc.so 中,是动态链接的外部函数,所以需要通过 PLT 间接调用
  3. xor eax, eax
    1. eax = 0
    2. 这是 return 0 的实现
    3. xor eax, eaxmov eax, 0 更好——指令更短(2 字节 vs 5 字节),而且现代 CPUxor reg, reg 有特殊优化(识别为"清零惯用语",不需要等待 eax 的旧值)
    4. 这是编译器最经典的小优化之一,即使在 -O0 下也会做
  4. add rsp, 16
    1. 释放之前 sub rsp, 16 分配的栈空间
  5. 恢复帧指针,返回
    1. add 函数的结尾一样

[rip + .L.str]

  1. 是 RIP 相对寻址——用当前指令地址(rip)加上到 .L.str 的偏移来计算字符串的地址
  2. 这是位置无关代码(PIC)的关键技术
  3. 无论这段代码被加载到内存的哪个位置,rip.L.str 的偏移是固定的,所以总能找到正确的字符串地址
  4. 对应 WindowsMSVCx64 下也使用 RIP 相对寻址,原理一样

对比 call _Z3addii(直接调用)和 call printf@PLTPLT 间接调用)

  1. add 是你自己定义的函数,在同一个编译单元中,链接器可以直接填入地址
  2. printf 在共享库中,需要运行时动态解析

.type .L.str,@object

  1. 声明 .L.str 是一个数据对象(不是函数)

.section .rodata.str1.1,"aMS",@progbits,1

  1. 切换到 .rodata.str1.1
    1. 这是只读数据段中专门存放字符串的子节
  2. 参数含义:
    1. "aMS"a = allocatable(加载到内存),M = mergeable(相同字符串可以合并),S = strings(包含以零结尾的字符串)
    2. @progbits:节包含程序数据(不是 BSS 那样的空间占位)
    3. 1:对齐到 1 字节
  3. MS 标志让链接器可以做字符串合并优化:
    1. 如果多个 .o 文件都有 "res is %d\n" 这个字符串,链接器只保留一份

.L.str:

  1. 字符串标签和内容
    1. .asciz 表示以零字节(\0)结尾的 ASCII 字符串
    2. .L 前缀表示局部标签

.size .L.str, 11

  1. 字符串大小是 11 字节:res is %d\n10 个字符加上末尾的 \0

.ident "Debian clang version 14.0.6"

  1. ELF.comment 节中记录编译器版本
  2. readelf -p .comment program 可以看到

.section ".note.GNU-stack","",@progbits

  1. 声明这个目标文件的栈不需要可执行权限
  2. 这是一个安全特性——如果所有 .o 文件都有这个标记,链接器就会生成不可执行栈的可执行文件(NX 位保护,防止栈溢出攻击执行 shellcode
  3. 如果任何一个 .o 缺少这个标记,链接器会保守地让栈可执行

.addrsig

  1. 地址重要性标记(Address Significance Table
  2. 这是 LLVMLLD 链接器用的优化信息——告诉链接器哪些符号的地址被"取过"(即代码中有 &func 这样的操作)
  3. 如果一个函数的地址没有被取过,链接器在做 ICFIdentical Code Folding,合并相同函数体)时可以更激进地优化
  4. GNU ld 会忽略这些标记

完整的执行流程图

其他

CPU 指令缓存机制

CPU 指令缓存性能

关于缓存命中

声明:本文为原创文章,版权归所有,欢迎分享本文,转载请保留出处!

bingliaolong
Bingliaolong 关注:0    粉丝:0
Everything will be better.

发表评论

表情 格式 链接 私密 签到
扫一扫二维码分享