When debugging or trying to
maximize the performance of a program, it is often
useful to look at the assembly code generated by the compiler.
There are many tutorials on how to generate assembly code with gcc
command,
but many times you have to work with a large, existing codebase that is composed
of many CMake
targets and library. In this article I will compare different methods
to read assembly code from C/C++ projects. Typical use cases are:
- When you are dealing with functions independent from the rest of the codebase, or with a few files to analize, best universal tool is “Compiler Explorer”.
- When you are working with a large project that uses
CMake
, you can use default targets to see the assembly ofcpp
files created byCMake
. You can modify the flags used to generate that assembly, details in “GCC” and “CMake” sections. - When you have a compiled object file you can use
objdump
tool to see the assembly code, see “Assembly from object file” section.
Compiler Explorer
Compiler Explorer is a web-based tool that allows you to see assembly code generated by compiler in “real time” (every change to source code recompiles the assembly). It was created by Matt Godbolt and is available at godbolt.org. It supports many compilers and you can use it to compare the assembly code generated by different compilers. Assembly code is cleaned up and colorized, so it is easier to read. You can also see which lines of the assembly code correspond to which lines of the source code.
Compiler Explorer can also be run locally. It is open source and available on GitHub. To install
it, you need to have Node.js installed. Clone the repository and run make
in the root directory. This will install all the necessary dependencies, build the project, find the compilers installed on your system and start the http server. More detailed instructions can be found here.
But most of the time you will want see the assembly of an existing project, containing multiple files and libraries. Fortunately, Compiler Explorer supports CMake
. When running locally, to load a project you have to:
- From the top menu `Add -> Tree (IDE Mode).
- From the Tree menu
Project -> Choose file
and select zipped project file. - Select
CMake
checkbox. - Choose build type for cmake, that would contain debug information (e.g.
-DCMAKE_BUILD_TYPE=Debug
). - Write name of the target you would like to compile
- Select
Add new -> Compiler
and wait for it to compile.
The C++ project I currently work with has 160 files, so Godbolt had a tough challenge,
but the website successfully handled the challenge of compiling my project.
It wasn’t the smoothiest experience, but that’s understendable. The site was very slow,
and the project took 115 seconds to recompile every change (locally it takes 10 seconds,
because I can build it with 12 cpu cores). I had to change the timout value, which was 10
seconds by default (github issue here).
To do that you have to create a file from the root directory of the project called
etc/config/compiler-explorer.local.properties
and add the following line:
compileTimeoutMs=100000
.
It was clear that the main use for the project is to work on server, not locally.
I could not see the output CMake
generated until the compilation was finished and the files I uploaded to the website were not the same as the ones I had on my computer.
It’s important to note, that there exists a script from the compiler-explorer project called asm-parser
,
that will clean the assembly code.
Compiling with GCC
Simple answer: use the -S
option, which tells the compiler to stop after the assembly phase.
The result will be assembly code with .s
extension.
|
|
Unless you can read AT&T assembly Syntax you will probably want to use the -masm=intel
option to get the Intel syntax.
There are a few options to make assembly code more readable. The -fverbose-asm
option will add commentary from the original source code. It also adds the architecture and system information at the top of the file.
You would like to also remove .cfi
directives from your assembly,
as they are used for exception handling and debugging. To disable them
use -fno-asynchronous-unwind-tables
, -fno-exceptions
options. Also -fno-rtti
and -fno-dwarf2-cfi-asm
can be useful, as explained here.
|
|
For example the following C++ code:
|
|
Will produce the following assembly code:
|
|
But assembly code won’t always be as readable as this. In most useful cases, you will compile your code with optimization enabled, which will make the assembly code harder to read, as the compiler can also change the order of instructions, remove some of them, or inline some functions.
You can also try different approach. Instead of creating assembly file with
cpp lines as commentary, you could create a source code with compiled assembly
pieces. This can be done with (-Wa
) option.
|
|
We pass to assembler options adhl
, so we add -Wa,-adhl
. The output would
not be valid assembly code, but it will be assembly list mixed with source code.
You also have to pass g
option to GCC, so the assembler can match source code
with assembly code. I also added options to remove .cfi
directives from previous
example.
|
|
|
|
However we have those .loc
directives, which are used for debugging, so
in my view the result of this method looks more messy.
Another alternative, would be to use save-temps
option, which will save all
intermediate files (.s
for assembly output, .i
for preprocessed input file).
This can be easily added to compiler options in your build tool. The results
are localted in the build directory, inside the subdirectory for the CMake target.
Assembly from object file
Sometime you may want to see the assembly code from object file. You can use
objdump
tool for that. For example:
|
|
Where -d,--disassemble
option tells objdump
to display the assembler code,
and -M
option tells objdump
to use Intel syntax.
Result:
|
|
We can see (here and in previous examples) that the names of the functions are mangled and not easily readable. However, in objdump
you can use -C
option to demangle the names.
|
|
We can also see the assembly code of only one function with -disassemble=name
option.
|
|
Note that without -C
option, the name of the function would have to be mangled.
We can also have source codes comments in our assembly with -S
option, but only when the object was compiled with -g
option. Another useful option is --no-show-raw-insn
which doesn’t show raw bytes of machine code, -r
which displays relocation entries and -w
option which disables line wrapping of long machine code lines. We can shorten -C -S -r
to -CSr
option.
|
|
|
|
When using CMake
CMake creates targets for each .cpp
file. Targets are named the same as the source file, but with different extension. There are targets for:
- assembly code before passed to assembler, with
.s
extension, - preprocessed source code, with
.i
extension.
It is important to note, that when using many CMake
subdirectories the targets are created in the binary_dir
of the target’s CMakeLists.txt
and not the base build directory.
So if you would like to generate assembly code for a target src/foo/bar/fun.cpp
you would run make src/foo/bar/fun.s
from the appropriate build directory.
We can see that the generated assembly code is not very readable, beacause we have the same problems as with the previous examples. Fortunately, we can modify flags for the target with CMAKE_CXX_CREATE_ASSEMBLY_SOURCE
variable, which is not documented but works.
|
|
The names are still mangled, but we can demangle them with some auxiliary tool.
Besides that, I have encountered strange bug, that add_compile_options
command did not add options to the assembly target (maybe because it was redefined later)
and as a consequence the assemblies targets were compiled with different flags than the release build we wanted to optimize. Also be careful to run these .s
and .i
targets
without defining CMAKE_CXX_CREATE_ASSEMBLY_SOURCE
flags explicitly, as in my case they included both the debug build flags and the release build flags.
Futher reading: