beto-bit
Posted on August 28, 2023
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;
}
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
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");
}
}
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
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
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
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!
Posted on August 28, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.