How Does Compiler Explorer (Godbolt) Works?

Compiler Explorer (Godbolt) is an interactive online compiler for assembly languages. This article explores its principles and implements a local command-line version.

What is Assembly Language

It is suggested to read 汇编语言入门教程 - 阮一峰的网络日志[1] to learn the basics of assembly language.

Example Code

1
2
3
4
5
int main() {
int a, b, c;
c = a + b;
return 0;
}

Result from Compiler Explorer

Frist of all, we use Compiler Explorer to compile the code above, and get the result as follows, which is our target:

Output form Compiler Explorer

Compile to Assembly by GCC

Use that command to compile C++ code to assembly[2][3]:

1
g++ -S example.cpp
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"

File Cleaning and Arguments Optimization

This article mainly shares the exploration process, this part will not actually be used in the end, you can consider skipping it.

.seh_* Commands

First we need to remove .seh_*, which are the MASM frame handling pseudo code gas implementation[4]. Add the argument -fno-asynchronous-unwind-tables.

1
g++ -S -fno-asynchronous-unwind-tables example.cpp

Specify Output File

Add the argument -o to specify the output file.

1
g++ -S -fno-asynchronous-unwind-tables -o example.asm example.cpp

Intel Style

The assembly code on Compiler Explorer does not have %, while the result of direct compilation has a lot of %. Searching, I found that this is the difference between AT&T style and Intel style.

Update the command:

1
g++ -S -masm=intel -fno-asynchronous-unwind-tables -o example.asm example.cpp

Remove .ident

Search by -fno-asynchronous-unwind-tables, found the GCC document, and found that the -fno-ident parameter can remove .ident[5].

1
g++ -S -masm=intel -fno-asynchronous-unwind-tables -fno-ident -o example.asm example.cpp

Current Result

此时已经和 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

Output from Compiler Explorer

Deep into Compiler Explorer

Is GCC Really Filtering?

I noticed that there is a filter option on Compiler Explorer, and there is a huge difference in the results when the filter is not selected.

Not Filtering

Filter All

I suspect that this filter is not a function of the GCC compiler, because the binary file generated after the complete compilation process needs to be executable, and it must contain library function files, etc., and whether the Compiler Explorer actually filters after the compilation, and implements this effect.

For example, the simple code in the previous section, use the following command to view the preprocessed file:

1
g++ -E -o example.txt example.cpp
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;
}

After adding #include <stdio.h>, the preprocessed file becomes more than a thousand lines, and the contents of stdio are also included, so the compiler will compile this part into assembly code so that the executable file contains all the libraries.

Search for Compiler Explorer Principles

Compiler Explorer was originally named godbolt, search for how does godbolt filter assembly code:

Search Result<sup id="fnref:6" class="footnote-ref"><a href="#fn:6" rel="footnote"><span class="hint--top hint--rounded" aria-label="[Step into standard library call with godbolt](https://stackoverflow.com/questions/56245402/step-into-standard-library-call-with-godbolt/56246283#56246283). Peter Cordes. 2019-05-21 [2023-12-11]. (原始内容[存档](https://web.archive.org/web/20231211162509/https://stackoverflow.com/questions/56245402/step-into-standard-library-call-with-godbolt/56246283#56246283)于2023-12-11)">[6]</span></a></sup><sup id="fnref:7" class="footnote-ref"><a href="#fn:7" rel="footnote"><span class="hint--top hint--rounded" aria-label="[How to remove "noise" from GCC/clang assembly output?](https://stackoverflow.com/questions/38552116/how-to-remove-noise-from-gcc-clang-assembly-output/38552509#38552509). Peter Cordes. 2016-01-24 [2023-12-11]. (原始内容[存档](https://web.archive.org/web/20231211162508/https://stackoverflow.com/questions/38552116/how-to-remove-noise-from-gcc-clang-assembly-output/38552509#38552509)于2023-12-11)">[7]</span></a></sup>

Then I found this talk by Matt Godbolt, the author of Compiler Explorer: CppCon 2017: Matt Godbolt “What Has My Compiler Done for Me Lately? Unbolting the Compiler’s Lid”

Matt Godbolt’s Talk

This speech explains in detail the differences between registers, how to maintain compatibility from 8-bit processors to 64-bit processors, Intel syntax and AT&T syntax, Compiler Explorer principles…

Registers Name and Convention

Registers from 8-bit to 64-bit

I personally strongly recommend this speech to undergraduate students in computer science. This is very helpful for understanding the process of the C compiler, the basic knowledge of assembly language, registers, compiler optimization strategies, etc. It is definitely worth spending 2-3 hours to learn and understand this speech.

Each of the details in it can diverge a lot of content, such as the history and changes of computers from 8-bit to 64-bit, assembly language basics, compiler optimization (he demonstrated several cases and showed how the compiler optimized them), linux commands, Docker, cloud server practice and virtual machines…

Learning the content of this talk, and fully understand it, is beneficial to students who are aiming for either industry or academia.

Filter Lines Starting with a Dot and C++ Symbols

In the speech, I noticed the following command:

The simplest way to filter

1
g++ /tmp/test.cc -O2 -c -S -o - -masm=intel | c++filt | grep -vE '\s+\.'

The parameters of this command are as follows:

  • -O2 optimization level
  • -c compile and assemble only, do not link, but we don’t need object files
  • -S compile and get assembly code
  • -o - output to command line
  • -masm=intel use intel syntax
  • c++filt filter C++ symbols
  • grep -vE '\s+\. filter lines starting with a dot

The function of c++filt is to filter C++ symbols, because C++ has function overloading, the compiler will handle this, and we want to get human-readable function names, for example:

1
2
$ echo _ZNSt11char_traitsIcE6lengthEPKc | c++filt
std::char_traits<char>::length(char const*)

From this command, we can update our compilation command as follows:

1
g++ -S -masm=intel -fno-asynchronous-unwind-tables -fno-ident -o - example.cpp | c++filt | grep -vE '\s+\.' > example.asm

The result of this compilation:

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

Problem of Command Parameters

Now we can see that -fno-asynchronous-unwind-tables and -fno-ident are actually not needed, and may filter out the content we want to keep, and further modify the command:

1
g++ -S -masm=intel -o - example.cpp | c++filt | grep -vE '\s+\.' > example.asm

Matt’s Simple Solution

In the speech, Matt mentioned his previous simple solution: use the watch command to execute the compilation command regularly, and then use tmux to open vim and watch at the same time to achieve a simple Compiler Explorer. I simply reproduced his solution:

A Simple Compiler Explorer

Discussion of Platform Differences

Here is current result, there are still some differences from the code on Compiler Explorer (green is my result, red is 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

I considered whether it was because of the difference in the platform, so I compiled with the same GCC 13 on the AMD platform, Ubuntu system, and the same GCC 13, the results are as follows:

Different Compilation Result on Different Platforms

We can see that the operations on rsp and the call __main are removed, but there are still differences when using pointer variables.

Pointer Grammar

The pointer grammar of Compiler Explorer is [rbp-4], while ours is -4[rbp].

Generally, -4(%rbp) is AT&T syntax, [rbp-4] is Intel syntax, but in our compilation result, Intel syntax produces -4[rbp].

Let’s ignore these platform differences for now and continue to explore the principles of Compiler Explorer.

Compiler Explorer Principles

Deep into Source Code

Now our code is basically the same as the code on Compiler Explorer, we only need to do two things: filter Library functions and Unused label.

Compiler Explorer's Filter Options

The talk did not mention how to filter, so I searched for the source code of Compiler Explorer: https://github.com/compiler-explorer/compiler-explorer

First, we search for filters in the source code, and find all available filters in the API documentation:

Search for filters

Then, we search for libraryCode and find the following code:

Search for libraryCode

objdump is a tool that disassembles the target file, and ASMPARSER has not been seen before, it seems to be an assembler parser, so continue to search for externalparser, and find the software name:

Search for externalparser

Update Test Code

In order to test the ability of the filter library function, we update the test code to:

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;
}

asm-parser

I found the asm-parser software of the author, which is written in C++:

https://github.com/compiler-explorer/asm-parser

This software only compiles on the Linux platform. I tried to compile it on Windows but not success. I executed the following command on 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

The result is as follows:

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]
...

The result on 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>
...

The flag -library_functions of this software completely removes all library functions, not unused library functions. I found that in Compiler Explorer, you can choose the binary option, which is to compile to binary object files and then compile to assembly, or directly use the source file to compile to assembly. This software may be a bit outdated, and can only process binary files.

binary option of Compiler Explorer

Back to Source Code

Back to the source code of Compiler Explorer, I found a file named asm-parser.ts, which contains a lot of regular expressions. This is probably the filter that the platform is currently using. This further confirms my idea that filtering is not a parameter of gcc, but filtering after compilation.

asm_parser.ts

I downloaded the source code and ran it locally to see if it would output the command used in the console, but it didn’t.

Run Locally

Extract Source Code to Local Program

Extract Filtering Code

Then I plan to extract the filtered js code and repackage it, so that it can be run locally.

In the asm-parser.ts file mentioned above, the last two functions seem to be functions that process binary and non-binary assembly.

1
2
3
4
5
6
7
processBinaryAsm(asmResult: string, filters: ParseFiltersAndOutputOptions): ParsedAsmResult {
...
}

process(asm: string, filters: ParseFiltersAndOutputOptions) {
return this.processAsm(asm, filters);
}

The next thing is simple, set a breakpoint for debugging, and view the method parameters:

Debug

asm is the text of assembly code, filter is a object contains filtering options.

By the asm text here, we know that the actual compilation parameters are as follows:

1
gcc -g -S -masm=intel -o - example.cpp > example.asm

Which is added -g parameter, this is to add debug information in the assembly code, I guess c++filt will use it later.

I extracted asm_parser.ts and other files I needed, and wrote a main file.

Main File

According to the document of asm-parser that written in C++, filter library function is based on file path, the file name must be example.cpp to be retained, otherwise the main file will be filtered as a library file. The regular expression in the source code shows this:

1
this.stdInLooking = /<stdin>|^-$|example\.[^/]+$|<source>/;

JSON to Text

The current output is still in JSON format, and I want to convert it to plain text, so I wrote a function to output plain text.

1
2
3
function resultToText(r: ParsedAsmResult) {
return r.asm.map(line => line.text).join('\n');
}

c++filt

The next step is to filter C++ symbols by c++filt, then we have the almost same result as Compiler Explorer.

Description after Symbol

Comparing Results

The comparison of the above shows that the output of Compiler Explorer adds a description in brackets after the function call. After reading the source code again, I extracted the CppDemangler class.

Compiler Explorer uses this class to mark library functions

Up to now, the output is exactly the same as the output on godbolt.

asm-parser in Typescript

Optimize Command Line Interface

Finally, I made some small modifications to the code, improved the command line interface, read the assembly code from stdin, and output to 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) => {
// parse
let result = asmParser.process(input, filters);

// demangle
result = await cppDemangler.process(result);

// print
if (outputTextFlag) {
console.log(resultToText(result));
} else {
console.log(JSON.stringify(result, null, 2));
}
});
1
g++ -g -S -masm=intel -o - example.cpp | ts-node-esm index.ts --unused_labels --library_code --directives --comment_only  --outputtext > example.asm

Test again, the output is the same as the output on Compiler Explorer, and the output is exactly the same.

Then we use https://github.com/vercel/pkg to compile ts into binary files, this tool can compile to three platforms at the same.

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

I published this project on GitHub, and welcome everyone to use and give suggestions: https://github.com/AnzhiZhang/asm-parser

Now we have the full comile command:

1
g++ -g -S -masm=intel -o - example.cpp | asm-parser-win --unused_labels --library_code --directives --comment_only --outputtext > example.asm

Source Code Correspondence and Coloring

The last small problem is that the assembly code on Compiler Explorer is colored to mark the correspondence between the assembly code and the source code, but ours is not. Looking at the return data of the interface, it can be found that the front end receives JSON format, and each line has the line number of the corresponding source code. This feature is implemented by the front end.

Correspondence on Compiler Explorer

We only need to remove the --outputtext parameter when using asm-parser, and we can get the data with the correspondence between the source code and the line numbers. The command is as follows:

1
gcc -g -S -masm=intel -o - example.cpp | asm-parser-win --unused_labels --library_code --directives --comment_only > example.asm

Other Platforms

We may also want to get filtered assembly code on the armv8 and riscv platforms. The following briefly introduces the implementation method.

To facilitate demonstration, I only use the following code:

1
2
3
4
5
int main() {
int a, b, c;
c = a + b;
return 0;
}

Also, in order to facilitate cross-compilation, we use clang and add -target <triple> to specify the target platform.[8]

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

Compile result:

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

armv8 result on Compiler Explorer

riscv

1
clang -g -S -o - -target riscv64-unknown-linux-gnu example.cpp | asm-parser-win --unused_labels --library_code --directives --comment_only --outputtext

Compile result:

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

riscv result on Compiler Explorer

参考文献

  1. 汇编语言入门教程 - 阮一峰的网络日志. 阮一峰. 2018-01-21 [2023-12-11]. (原始内容存档于2023-12-11)
  2. Does C++ compile to assembly?. Antoine Pelisse. 2011-01-24 [2023-12-11]. (原始内容存档于2023-12-11)
  3. How do you get assembler output from C/C++ source in GCC?. Andrew Edgecombe. 2008-09-26 [2023-12-11]. (原始内容存档于2023-12-11)
  4. What are .seh_* assembly commands that gcc outputs?. David Wohlferd. 2016-07-04 [2023-12-11]. (原始内容存档于2023-12-11)
  5. Code Gen Options (Using the GNU Compiler Collection (GCC)). [2023-12-11]. (原始内容存档于2023-12-11)
  6. Step into standard library call with godbolt. Peter Cordes. 2019-05-21 [2023-12-11]. (原始内容存档于2023-12-11)
  7. How to remove “noise” from GCC/clang assembly output?. Peter Cordes. 2016-01-24 [2023-12-11]. (原始内容存档于2023-12-11)
  8. Cross-compilation using Clang — Clang 18.0.0git documentation. (原始内容存档于2023-12-11)

How Does Compiler Explorer (Godbolt) Works?
https://blog.zhanganzhi.com/en/2023/12/bc4129caff08/
Author
Andy Zhang
Posted on
December 12, 2023
Licensed under