DEV Community

beto-bit
beto-bit

Posted on

How to make your own Godbolt (for C++ and Rust)

Introduction

I hope you are aware of the existence of Compiler Explorer, a.k.a. Godbolt. It is an incredible tool for inspecting how your code gets translated to assembly, just one layer above machine code.

So, for example, if you want to check if range-based loops in C++ incur some additional runtime cost, you can check the assembly output. But what if you want to run that on your machine? I mean, you can for sure use Compiler Explorer locally, but let's do it the fun way.

Generating Assembly

First, I will assume you have some GNU Make experience, if not, check this resource.
Let's start by creating a src directory and some C++ file.

Then, edit your file to something interesting!

// src/func.cpp

#include <array>

int sum_manual(const std::array<int, 10>& arr) {
    int sum = 0;
    for (std::size_t i = 0; i < arr.size(); ++i)
        sum += arr[i];

    return sum;
}

int sum_range(const std::array<int, 10>& arr) {
    int sum = 0;
    for (int num: arr)
        sum += num;

    return sum;
}
Enter fullscreen mode Exit fullscreen mode

Then we will generate the assembly output using gcc. For this purpose and because writing the whole compile command is error prone, we create a Makefile, and then edit it.

# Makefile
CC := g++
CFLAGS := -O2 -std=c++20 \
          -march=x86-64 -masm=intel -fno-stack-protector \
          -fno-dwarf2-cfi-asm -fno-asynchronous-unwind-tables 

all: out/func.asm

out/func.asm: src/func.cpp
    @ mkdir -p out              # Create directory
    ${CC} -S ${CFLAGS} $< -o $@ # Compile to assembly
    cat $@ | c++filt | tee $@ >/dev/null    # Make it pretty

.PHONY: clean
clean:
    rm -rf out
Enter fullscreen mode Exit fullscreen mode

The flags -masm=intel -fno-stack-protector -fno-dwarf2-cfi-asm -fno-asynchronous-unwind-tables are for getting a nice looking assembly. c++filt is for demangling C++ symbols.

And when running make, it generates a... 168 lines assembly, in my computer. This is because, in theory, you can run GAS (GNU Assembler) on the generated output and get a fully working program.

But we don't want a fully working program, we want a small readable assembly. So we need to filter away the things we don't care about.

Filtering

So we could use grep and inverse filtering.
But basically, we need to:

  • Include lines that start with .L{number}.
  • Include lines that start with .quad or .string or .ascii or .asciz.
  • Remove lines that start with .L{something_else}.
  • Remove all lines after :0 or .Lframe1.
  • If the line starts with .LFE change it with a newline. That's because that indicates the end of the function.

And, after trying to make it in C++ and failing to optimize it, I made it in Rust. I'm not gonna explain the code a lot, you just need to know it is quite fast. And it reads and writes to stdin.

// rspper/src/main.rs

use std::io::{self, BufWriter, Write};

type File = Box<dyn Iterator<Item = String>>;

struct AssemblyFile {
    lines: File,
}

impl AssemblyFile {
    fn new(lines: File) -> Self {
        Self { lines }
    }

    fn add_function_separator(self) -> Self {
        let lines = self.lines.map(|l| {
            if l.starts_with(".LFE") {
                String::new()
            } else {
                l
            }
        });

        Self::new(Box::new(lines))
    }

    fn remove_last_section(self) -> Self {
        let lines = self
            .lines
            .take_while(|l| !l.starts_with("0:") && !l.starts_with(".Lframe1"));

        Self::new(Box::new(lines))
    }

    fn remove_directives(self) -> Self {
        let lines = self.lines.filter(|l| {
            !l.starts_with("\t.")
                || l.starts_with("\t.quad")
                || l.starts_with("\t.string")
                || l.starts_with("\t.ascii") | l.starts_with("\t.asciz")
        });

        Self::new(Box::new(lines))
    }

    fn remove_ltags(self) -> Self {
        let lines = self.lines.filter(|l| {
            !(l.starts_with(".LF")
                || l.starts_with(".Lfunc")
                || l.starts_with(".LCFI")
                || l.starts_with(".LEH")
                || l.starts_with(".LHOT")
                || l.starts_with(".LCOLD")
                || l.starts_with(".LLSDA")
                || l.contains("endbr64"))
        });

        Self::new(Box::new(lines))
    }
}

fn main() {
    // Get an interator to stdin
    let input_lines = io::stdin().lines().map_while(Result::ok);

    let input_file = AssemblyFile::new(Box::new(input_lines))
        .add_function_separator()
        .remove_last_section()
        .remove_directives()
        .remove_ltags();

    // Hot output loop
    let stdout = io::stdout().lock();
    let mut stdout = BufWriter::new(stdout);

    for li in input_file.lines {
        stdout.write(li.as_bytes()).expect("stdin broken!");
        stdout.write("\n".as_bytes()).expect("wut");
    }
}
Enter fullscreen mode Exit fullscreen mode

And then I compile it and modify the Makefile.

# Makefile
# <...>

CHOPPER := ./rspper/target/release/rspper

# <...>

out/func.asm: src/func.cpp
    @ mkdir -p out              # Create directory
    ${CC} -S ${CFLAGS} $< -o $@ # Compile to assembly
    cat $@ | c++filt | ${CHOPPER} | tee $@ >/dev/null   # Make it pretty
Enter fullscreen mode Exit fullscreen mode

And finally, the generated good looking assembly output!

sum_manual(std::array<int, 10ul> const&):
    lea rdx, 40[rdi]
    xor eax, eax
.L2:
    add eax, DWORD PTR [rdi]
    add rdi, 4
    cmp rdi, rdx
    jne .L2
    ret

sum_range(std::array<int, 10ul> const&):
    lea rdx, 40[rdi]
    xor eax, eax
.L6:
    add eax, DWORD PTR [rdi]
    add rdi, 4
    cmp rdi, rdx
    jne .L6
    ret
Enter fullscreen mode Exit fullscreen mode

As you can see, using range-based loops generates the exact same assembly!

Getting Rusty

The process isn't that different. We just need some more compilation flags. Edit your Makefile to have this.

#<...>

all: out/func.asm out/rust.asm

out/rust.asm: src/lib.rs
    @ mkdir -p out
    rustc $< -O --crate-type=lib --emit=asm -C llvm-args=-x86-asm-syntax=intel -o $@
    cat $@ | c++filt | ${CHOPPER} | tee $@ >/dev/null

Enter fullscreen mode Exit fullscreen mode

With this, we are basically doing the same as before. It is important to mark functions as pub, because if not they're optimized away by the compiler.

Ending Notes

This has not been profoundly tested, and I may be missing to include/exclude some assembly lines. Also, this makes use of c++filt for C++ and Rust. There is this project, rustfilt, which does the same but for Rust.

I hope you found this usefull, thank you for reading!

Top comments (0)