What was supposed to be one blog post about memory segmentation turned into what will be a series of posts. As the first in the series, we cover the extreme basics of object files and symbols. In follow up posts, I plan to talk about static libraries, dynamic libraries, dynamic linkage, memory segments, and finally memory usage accounting. I also cover command line tools for working with these notions, both in Linux and OSX.
A quick review of the compilation+execution pipeline (for terminology):
- Lexing produces tokens
- Parsing produces an abstract syntax tree
- Analysis produces a code flow graph
- Optimization produces a reduced code flow graph
- Code gen produces object code
- Linkage produces a complete executable
- Loader instructs the OS how to start running the executable
This series will focus on part #6.
Let’s say you have some amazing C/C++ code, but for separations of concerns, you want to start moving it out into separate source files. Whereas previously in one file you had:
1 2 3 4 5 6 7 8 |
|
You now have two source files and maybe a header:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
In the single source version, we would have compiled and linked that with
clang main.c
and had an executable file. In the multiple source version, we
first compile our source files to object files, then link them altogether.
That can be done separately:
1 2 3 |
|
We can also do the compilation and linkage in one step:
1
|
|
Nothing special thus far; C/C++ 101. In the first case of separate compilation and linkage steps, we were left with intermediate object files (.o). What exactly are these?
Object files are almost full executables. They contain machine code, but that code still requires a relocation step. It also contains metadata about the addresses of its variables and functions (called symbols) in an associative data structure called a symbol table. The addresses may not be the final address of the symbol in the final executable. They also contain some information for the loader and probably some other stuff.
Remember that if we fail to specify the helper object file, we’ll get an undefined symbol error.
1 2 3 4 5 6 |
|
The problem is main.o refers to some symbol called helper
, but on it’s own
doesn’t contain any more information about it. Let’s say we want to know what
symbols an object file contains, or expects to find elsewhere. Let’s introduce
our first tool, nm
. nm
will print the name list or symbol table for a
given object or executable file. On OSX, these are prefixed with an
underscore.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Let’s dissect what’s going on here. The output (as understood by man 1 nm
)
is a space separated list of address, type, and symbol name. We can see that
the addresses are placeholders in object files, and final in executables. The
name should make sense; it’s the name of the function or variable. While I’d
love to get in depth on the various symbol types and talk about sections, I
don’t think I could do as great a job as Peter Van Der Linden in his book
“Expert C Programming: Deep C Secrets.”
For our case, we just care about whether the symbol in a given object file is defined or not. The type U (undefined) means that this symbol is referenced or used in this object code/executable, but it’s value wasn’t defined here. When we compiled main.c alone and got the undefined symbol error, it should now make sense why we got the undefined symbol error for helper. main.o contains a symbol for main, and references helper. helper.o contains a symbol for helper, and references to puts. The final executable contains symbols for main and helper and references to puts.
You might be wondering where puts comes from then, and why didn’t we get an undefined symbol error for puts like we did earlier for helper. The answer is the C runtime. libc is implicitly dynamically linked to all executables created by the C compiler. We’ll cover dynamic linkage in a later post in this series.
When the linker performs relocation on the object files, combining them into a final executable, it goes through placeholders of addresses and fills them in. We did this manually in our post on JIT compilers.
While nm
gave us a look into our symbol table, two other tools I use
frequently are objdump
on Linux and otool
on OSX. Both of these provide
disassembled assembly instructions and their addresses. Note how the symbols
for functions get translated into labels of the disassembled functions, and
that their address points to the first instruction in that label. Since I’ve
shown objdump
numerous times
in
previous posts,
here’s otool
.
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 |
|
readelf -s <object file>
will give us a list of symbols on Linux.
ELF
is the file format used by the loader on Linux, while OSX uses
Mach-O.
Thus readelf
and otool
, respectively.
Also note that for static linkage, symbols need to be unique*, as they refer to memory locations to either read/write to in the case of variables or locations to jump to in the case of functions.
1 2 3 4 5 6 7 8 9 10 11 12 |
|
*: there’s a notion of weak symbols, and some special things for dynamic libraries we’ll see in a follow up post.
Languages like C++ that support function overloading (functions with the same name but different arguments, return types, namespaces, or class) must mangle their function names to make them unique.
Code like:
1 2 3 4 5 6 7 |
|
Will produce symbols like:
1 2 3 4 5 |
|
Note: GNU nm
on Linux distros will have a --demangle
option:
1 2 3 4 5 |
|
On OSX, we can pipe nm
into c++filt
:
1 2 3 4 |
|
Finally, if you don’t have an object file, but instead a backtrace that needs
demangling, you can either invoke c++filt
manually or use
demangler.com.
Rust also mangles its function names. For FFI or interface with C functions,
other languages usually have to look for or expose symbols in a manner suited
to C, the lowest common denominator.
C++
has extern "C"
blocks and
Rust
has extern
blocks.
We can use strip
to remove symbols from a binary. This can slim down a
binary at the cost of making stack traces unreadable. If you’re following
along at home, try comparing the output from your disassembler and nm
before
and after running strip
on the executable. Luckily, you can’t strip the
symbols out of object files, otherwise they’d be useless as you’d no longer be
able to link them.
If we compile with the -g
flag, we can create a different kind of symbol;
debug symbols.
Depending on your compiler+host OS, you’ll get another file you can run through
nm
to see an entry per symbol. You’ll get more info by using dwarfdump
on
this file. Debug symbols will retain source information such as filename and
line number for all symbols.
This post should have been a simple refresher of some of the basics of working with C code. Finding symbols to be placed into a final executable and relocating addresses are the main job of the linker, and will be the main theme of the posts in this series. Keep your eyes out for more in this series on memory segmentation.