Compiler Explorer(Godbolt)是一个交互式在线汇编语言编译器,本文主要分享探索其原理的过程,并提取其开源代码实现了一个本地命令行版本。
什么是汇编
推荐阅读汇编语言入门教程 - 阮一峰的网络日志了解汇编语言的基础知识。
样例代码
1 2 3 4 5
| int main() { int a, b, c; c = a + b; return 0; }
CPP
|
Compiler Explorer 结果
首先使用 Compiler Explorer 编译上述代码,得到如下结果,确定我们的目标:
Compiler Explorer 的输出
GCC 编译到汇编
使用以下命令将 C++ 代码编译到汇编:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| .file "example.cpp" .text .def __main; .scl 2; .type 32; .endef .globl main .def main; .scl 2; .type 32; .endef .seh_proc main main: .LFB0: pushq %rbp .seh_pushreg %rbp movq %rsp, %rbp .seh_setframe %rbp, 0 subq $48, %rsp .seh_stackalloc 48 .seh_endprologue call __main movl -4(%rbp), %edx movl -8(%rbp), %eax addl %edx, %eax movl %eax, -12(%rbp) movl $0, %eax addq $48, %rsp popq %rbp ret .seh_endproc .ident "GCC: (GNU) 13.2.0"
ASSEMBLY
|
文件清理及优化参数
本文主要分享探索过程,这部分实际上并不会在最后用到,可以考虑跳过。
.seh_*
指令
首先移除 .seh_*
,这些是 MASM 的帧处理伪代码 gas
的实现。添加参数 -fno-asynchronous-unwind-tables
。
1
| g++ -S -fno-asynchronous-unwind-tables example.cpp
BASH
|
指定输出文件
使用 -o
参数指定输出文件。
1
| g++ -S -fno-asynchronous-unwind-tables -o example.asm example.cpp
BASH
|
Intel 风格
在 Compiler Explorer 上的汇编代码没有 %
,而直接编译的结果有大量的 %
,搜索发现这是 AT&T 风格和 Intel 风格的区别。
更新命令参数:
1
| g++ -S -masm=intel -fno-asynchronous-unwind-tables -o example.asm example.cpp
BASH
|
移除 .ident
以 -fno-asynchronous-unwind-tables
为线索搜索,找到了 GCC 文档,发现 -fno-ident
参数可以移除 .ident
。
1
| g++ -S -masm=intel -fno-asynchronous-unwind-tables -fno-ident -o example.asm example.cpp
BASH
|
当前的成果
此时已经和 Compiler Explorer 的输出基本一致了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| .file "example.cpp" .intel_syntax noprefix .text .def __main; .scl 2; .type 32; .endef .globl main .def main; .scl 2; .type 32; .endef main: push rbp mov rbp, rsp sub rsp, 48 call __main mov edx, DWORD PTR -4[rbp] mov eax, DWORD PTR -8[rbp] add eax, edx mov DWORD PTR -12[rbp], eax mov eax, 0 leave ret
ASSEMBLY
|
Compiler Explorer 的输出
深入 Compiler Explorer 原理
真的是 GCC 在过滤吗?
在 Compiler Explorer 上,有一个过滤器选项,不选择过滤时,结果有着巨大的差异。
不过滤未使用的标签和库函数
过滤所有
于是我怀疑是否这个过滤不是 GCC 编译器的功能,因为完整的编译流程后产生的二进制文件需要可执行,是必须包含库函数等文件的,是不是 Compiler Explorer 实际上在编译后过滤了一遍,实现的这个效果。
例如上文的简单代码,使用以下命令查看预处理后的文件是这样的:
1
| g++ -E -o example.txt example.cpp
BASH
|
1 2 3 4 5 6 7 8 9
| # 0 "example.cpp" # 0 "<built-in>" # 0 "<command-line>" # 1 "example.cpp" int main() { int a, b, c; c = a + b; return 0; }
CPP
|
而添加 #include <stdio.h>
后,预处理后的文件变为了一千多行,stdio
中的内容也被包含了进来,于是编译器会将这部分也编译为汇编代码,以便可执行文件包含所有的库。
搜索 Compiler Explorer 原理
Compiler Explorer 曾经名为 godbolt
,直接检索 godbolt 如何过滤汇编代码:
搜索结果
然后找到了 Compiler Explorer 作者 Matt Godbolt 的演讲:CppCon 2017: Matt Godbolt “What Has My Compiler Done for Me Lately? Unbolting the Compiler’s Lid”
Matt Godbolt 的演讲
这个演讲详细解释了寄存器的区别、如何保持从 8 位处理器到现在 64 位的兼容、Intel 语法和 AT&T 语法、Compiler Explorer 原理……
关于寄存器的名称和约定
从 8 位到 64 位时代的寄存器
我个人强烈推荐这个演讲给计算机本科的同学,这对于了解 C 编译器的过程、汇编语言的基本知识、寄存器、编译器的优化策略等都有很大的帮助,绝对值得花费 2-3 小时的时间学习和理解这个演讲。
其中的每一个细节的知识点都可以发散出非常多的内容,例如计算机从 8 位发展到 64 位的历史和变化、汇编语言基础、编译器的优化(他演示了几种情况,展示了编译器如何优化它们)、linux 命令、Docker、云服务器实践和虚拟机……
学习这个演讲的内容,并完全消化,无论对于目标是工业界还是学术界的学生都是有益的。
过滤以点开头的行和 C++ 符号
在演讲中,我注意到了以下命令:
最简易的过滤方式
1
| g++ /tmp/test.cc -O2 -c -S -o - -masm=intel | c++filt | grep -vE '\s+\.'
BASH
|
参数的含义如下:
-O2
优化等级
-c
仅编译和汇编,不链接,但实际上我们不需要 object 文件
-S
编译,获得汇编代码
-o -
输出到命令行
-masm=intel
使用 intel 语法
c++filt
过滤 C++ 的符号
grep -vE '\s+\.
过滤以点开头的行
c++filt
的作用是过滤 C++ 的符号,因为 C++ 有函数重载,编译器会对此处理,而我们希望获得人类可读的函数名,例如:
1 2
| $ echo _ZNSt11char_traitsIcE6lengthEPKc | c++filt std::char_traits<char>::length(char const*)
BASH
|
借鉴这个命令,优化我们的编译命令:
1
| g++ -S -masm=intel -fno-asynchronous-unwind-tables -fno-ident -o - example.cpp | c++filt | grep -vE '\s+\.' > example.asm
BASH
|
此时的编译结果如下:
1 2 3 4 5 6 7 8 9 10 11 12
| main: push rbp mov rbp, rsp sub rsp, 48 call __main mov edx, DWORD PTR -4[rbp] mov eax, DWORD PTR -8[rbp] add eax, edx mov DWORD PTR -12[rbp], eax mov eax, 0 leave ret
ASSEMBLY
|
命令参数的问题
由此我们也可以看出来 -fno-asynchronous-unwind-tables
和 -fno-ident
其实并不需要,反而可能会过滤掉我们希望保留的内容,进一步修改命令:
1
| g++ -S -masm=intel -o - example.cpp | c++filt | grep -vE '\s+\.' > example.asm
BASH
|
Matt 的简单方案
有了这样的指令,Matt 提到了他以前使用的简单方案:基于 Linux 的 watch
命令实现定时执行编译命令,然后用 tmux
一边开 vim
一边 watch
,实现了简单的 Compiler Explorer。我简单复现了他的方案:
简陋版的 Compiler Explorer
简单讨论平台差异
当前的编译结果是这样的,其中有几行依然与 Compiler Explorer 上的代码不同(绿色为我的结果,红色为 Compiler Explorer 的):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| main: + .LFB0: push rbp mov rbp, rsp + sub rsp, 48 + call __main - mov edx, DWORD PTR [rbp-4] - mov eax, DWORD PTR [rbp-8] + mov edx, DWORD PTR -4[rbp] + mov eax, DWORD PTR -8[rbp] add eax, edx - mov DWORD PTR [rbp-12], eax + mov DWORD PTR -12[rbp], eax mov eax, 0 + add rsp, 48 pop rbp ret
DIFF
|
我考虑是否有可能是有平台的区别,于是在 AMD 平台,Ubuntu 系统,使用同样的 GCC 13 编译,结果如下:
在不同平台的编译结果不同
可以看到确实对 rsp
的无用操作和 call __main
消失了,但是在使用指针变量时仍然有区别。
指针语法
Compiler Explorer 的编译结果中的引用格式是 [rbp-4]
,而我们的是 -4[rbp]
。
一般而言,-4(%rbp)
是 AT&T 语法,[rbp-4]
是 Intel 语法,但是在我们的编译结果中,Intel 语法产生了 -4[rbp]
的结果。
我们先放下这些平台差异,继续探索 Compiler Explorer 的原理。
Compiler Explorer 原理
深入源码
现在我们的代码已经和 Compiler Explorer 上的代码基本一致了,我们只需要做两件事:过滤 Library functions 和 Unused label。
Compiler Explorer 的过滤选项
演讲中并没有提到是怎么过滤的,于是我查找了 Compiler Explorer 的源码:https://github.com/compiler-explorer/compiler-explorer
首先搜索 filters
,在 API 文档中找到了所有可用的 filters:
filters 的搜索结果
继续搜索 libraryCode
,找到了这段代码,看起来是生成一个过滤用的脚本。
libraryCode 的搜索结果
objdump
,是将目标文件反汇编的工具,而 ASMPARSER
之前并没有见过,似乎是汇编语言的解析器,于是继续搜索 externalparser
,找到了这个软件名:
externalparser 的搜索结果
更新测试代码
为了测试过滤库函数的能力,我们将测试代码更新为:
1 2 3 4 5 6 7 8
| #include <vector>
int main() { std::vector<int> fq(26, 0); int a, b, c; c = a + b; return 0; }
CPP
|
asm-parser
随后找到了该作者的 asm-parser
软件,使用 C++ 编写:
https://github.com/compiler-explorer/asm-parser
这个软件只编译了 Linux 平台,我尝试在 Windows 编译没有成功,在 Linux 执行以下命令:
1 2
| g++ -g -c example.cpp objdump -d example.o -M intel -l --insn-width=16 | ./asm-parser -stdin -binary -outputtext -library_functions > example.asm
BASH
|
编译后的结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| main: ... mov DWORD PTR [rbp-0x48],0x0 lea rcx,[rbp-0x49] lea rdx,[rbp-0x48] lea rax,[rbp-0x30] mov esi,0x1a mov rdi,rax call 46 <main+0x46> lea rax,[rbp-0x49] mov rdi,rax call 52 <main+0x52> nop mov edx,DWORD PTR [rbp-0x44] mov eax,DWORD PTR [rbp-0x40] ...
ASSEMBLY
|
Compiler Explorer 上的结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| main: ... mov DWORD PTR [rbp-0x2c],0x0 lea rcx,[rbp-0x2d] lea rdx,[rbp-0x2c] lea rax,[rbp-0x50] mov esi,0x1a mov rdi,rax call 33 <main+0x33> R_X86_64_PLT32 std::vector<int, std::allocator<int> >::vector(unsigned long, int const&, std::allocator<int> const&)-0x4 lea rax,[rbp-0x2d] mov rdi,rax call 3f <main+0x3f> R_X86_64_PLT32 std::__new_allocator<int>::~__new_allocator()-0x4 nop mov edx,DWORD PTR [rbp-0x14] mov eax,DWORD PTR [rbp-0x18] add eax,edx mov DWORD PTR [rbp-0x1c],eax mov ebx,0x0 lea rax,[rbp-0x50] mov rdi,rax call 5c <main+0x5c> ...
ASSEMBLY
|
该软件的 -library_functions
参数完全删除了所有的库函数,并不是未使用的库函数,而且我发现在 Compiler Explorer 上可以选择 binary
选项,即为先编译到二进制对象文件再编译到汇编,还是直接使用源文件编译到汇编,而这个软件可能版本有些落后,且只能处理二进制文件。
Compiler Explorer 的 binary 选项
回到源码
再次回到 Compiler Explorer 的源码,找到了一个名为 asm_parser.ts
的文件,其中有大量的正则表达式,这个大概就是该平台目前实际上正在使用的过滤器。这也进一步印证了我的想法,过滤并不是 gcc 的参数,而是在编译后再过滤。
asm_parser.ts
我将源码下载下来在本地运行,尝试查看是否会在控制台输出使用的命令,但是并没有。
本地运行,但是控制台并没有日志输出
提取源码组合本地程序
提取过滤的 js 代码
于是我计划将过滤的 js 代码提取出来重新封装,这样就可以在本地运行了。
在上文提到的 asm_parser.ts
文件中,最后两个函数似乎是处理二进制和非二进制汇编的函数。
1 2 3 4 5 6 7
| processBinaryAsm(asmResult: string, filters: ParseFiltersAndOutputOptions): ParsedAsmResult { ... }
process(asm: string, filters: ParseFiltersAndOutputOptions) { return this.processAsm(asm, filters); }
TYPESCRIPT
|
接下来就很简单了,打断点调试,查看方法参数:
断点调试
asm
是汇编的原文,filter
是个集合了过滤选项的对象。
根据这里的原文,知道了实际的编译参数是这样的:
1
| gcc -g -S -masm=intel -o - example.cpp > example.asm
BASH
|
添加了 -g
参数,这是为了在汇编代码中添加调试信息,我猜测 c++filt
会在之后使用。
我将 asm_parser.ts
和需要的其他文件提取出来,并写了一个主文件。
提取的主文件
根据之前 C++ 版 asm-parser 的文档,过滤库函数是基于文件路径的,文件名必须是 example.cpp
才可以被保留,否则主文件会被当做库文件被过滤。源码中的这个正则展示了这一点:
1
| this.stdInLooking = /<stdin>|^-$|example\.[^/]+$|<source>/;
TYPESCRIPT
|
JSON 转纯文本
当前的输出还是 JSON 的格式,我希望转为纯文本,于是撰写了一个函数使其输出纯文本。
1 2 3
| function resultToText(r: ParsedAsmResult) { return r.asm.map(line => line.text).join('\n'); }
TYPESCRIPT
|
c++filt
再使用 c++filt
过滤 C++ 符号,就可以得到和 Compiler Explorer 几乎一样的结果了。
符号后的描述
对比输出结果
上图的对比可以看出,Compiler Explorer 的输出在调用函数后使用方括号补充了一段描述。再次阅读源码后,我将 CppDemangler
提取了出来。
Compiler Explorer 使用这个类标注库函数
至此,输出与 godbolt 上的输出完全一致。
Typescript 版 asm-parser
完善命令行接口
最后做一些小修改,完善命令行接口,从 stdin 读取汇编代码,输出到 stdout。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| import {AsmParser} from "./src/lib/asm-parser.js"; import {BaseCompiler} from "./src/lib/fake-base-compiler.js"; import {CppDemangler} from "./src/lib/demangler/cpp.js"; import {ParsedAsmResult} from "./src/types/asmresult/asmresult.interfaces.js"; import { ParseFiltersAndOutputOptions } from "./src/types/features/filters.interfaces.js";
const baseCompiler = new BaseCompiler(); const cppDemangler = new CppDemangler("c++filt", baseCompiler); const asmParser = new AsmParser();
const args = process.argv; const outputTextFlag = args.includes("--outputtext"); const filters: ParseFiltersAndOutputOptions = { labels: args.includes("--unused_labels"), libraryCode: args.includes("--library_code"), directives: args.includes("--directives"), commentOnly: args.includes("--comment_only"), binary: args.includes("--binary"), binaryObject: args.includes("--binary") };
function resultToText(r: ParsedAsmResult) { return r.asm.map(line => line.text).join('\n'); }
function getInput(): Promise<string> { return new Promise(function (resolve, reject) { const stdin = process.stdin; let data: string = "";
stdin.setEncoding("utf8"); stdin.on("data", function (chunk) { data += chunk; });
stdin.on("end", function () { resolve(data); });
stdin.on('error', reject); }); }
getInput().then(async (input: string) => { let result = asmParser.process(input, filters);
result = await cppDemangler.process(result);
if (outputTextFlag) { console.log(resultToText(result)); } else { console.log(JSON.stringify(result, null, 2)); } });
TYPESCRIPT
|
1
| g++ -g -S -masm=intel -o - example.cpp | ts-node-esm index.ts --unused_labels --library_code --directives --comment_only --outputtext > example.asm
BASH
|
再测试,在同一台机器上,输出结果完全一致。
然后再使用 https://github.com/vercel/pkg 将 ts 编译为二进制文件,这个工具可以同时编译到三个平台。
1 2 3
| tsc index.ts --target esnext --module nodenext --skipLibCheck --outDir dist rollup dist/index.js --file dist/bundle.js --format cjs", pkg dist/bundle.js --out-path dist
BASH
|
我将这个项目开源在了 GitHub 上,欢迎大家使用和提出建议:https://github.com/AnzhiZhang/asm-parser
至此,完整的编译命令是:
1
| g++ -g -S -masm=intel -o - example.cpp | asm-parser-win --unused_labels --library_code --directives --comment_only --outputtext > example.asm
BASH
|
源码对应关系和上色
还有最后一个小问题,Compiler Explorer 上的汇编代码是有上色的,用于标记和源码的对应关系,而我们的没有。查看接口的返回数据,可以发现前端收到的是 JSON 格式的,每一行都有对应源码的行号,这个功能是前端实现的。
Compiler Explorer 的行数对应关系
只需要在使用 asm-parser
时移除 --outputtext
参数,就可以得到带有源码行数对应关系的数据了。
1
| gcc -g -S -masm=intel -o - example.cpp | asm-parser-win --unused_labels --library_code --directives --comment_only > example.asm
BASH
|
其他平台
我们还希望在 armv8 和 riscv 平台上获得过滤的汇编代码,下文简短介绍了实现方式。
为方便演示,我仅用以下代码示例:
1 2 3 4 5
| int main() { int a, b, c; c = a + b; return 0; }
CPP
|
为了方便交叉编译,我们使用 clang
,添加 -target <triple>
即可指定目标平台:
The triple has the general format <arch><sub>-<vendor>-<sys>-<env>
, where:
arch
= x86_64
, i386
, arm
, thumb
, mips
, etc.
sub
= for ex. on ARM: v5
, v6m
, v7a
, v7m
, etc.
vendor
= pc
, apple
, nvidia
, ibm
, etc.
sys
= none
, linux
, win32
, darwin
, cuda
, etc.
env
= eabi
, gnu
, android
, macho
, elf
, etc.
armv8
1
| clang -g -S -o - -target aarch64-pc-linux-gnu example.cpp | asm-parser-win --unused_labels --library_code --directives --comment_only --outputtext
BASH
|
编译结果如下:
1 2 3 4 5 6 7 8 9 10
| main: // @main sub sp, sp, #16 mov w0, wzr str wzr, [sp, #12] ldr w8, [sp, #8] ldr w9, [sp, #4] add w8, w8, w9 str w8, [sp] add sp, sp, #16 ret
ASSEMBLY
|
Compiler Explorer 编译的 armv8 结果
riscv
1
| clang -g -S -o - -target riscv64-unknown-linux-gnu example.cpp | asm-parser-win --unused_labels --library_code --directives --comment_only --outputtext
BASH
|
编译结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| main: # @main addi sp, sp, -32 sd ra, 24(sp) # 8-byte Folded Spill sd s0, 16(sp) # 8-byte Folded Spill addi s0, sp, 32 li a0, 0 sw a0, -20(s0) lw a1, -24(s0) lw a2, -28(s0) addw a1, a1, a2 sw a1, -32(s0) ld ra, 24(sp) # 8-byte Folded Reload ld s0, 16(sp) # 8-byte Folded Reload addi sp, sp, 32 ret
ASSEMBLY
|
Compiler Explorer 编译的 riscv 结果