What Your Compiler Actually Made
Pulling apart the ELF file gcc builds from hello world: its header, sections, symbols, and a first look at main in assembly.
The smallest C program worth compiling produces a file of about 16 KB.
The source is five lines. So what’s in the rest? This part answers that
with four tools (file, readelf, nm and objdump) and no assembly
knowledge at all. By the end you’ll know where your code, your string and
your function names live inside the binary, and you’ll have seen main as
the CPU sees it.
Everything here runs inside the lab image. If you haven’t set it up yet, part 0 has the one command you need.
The program
Here’s the whole thing, in part-01/hello.c:
#include <stdio.h>
int main(void) { printf("Hello, world!\n"); return 0;}The lab builds it with these flags:
gcc -O0 -g -fno-pie -no-pie -o hello hello.c-O0turns optimization off, so the assembly follows the source line by line instead of being rearranged.-gadds debug information, which gdb will use from part 2 on.-fno-pie -no-piebuilds a position-dependent executable: it always loads at the same address, so the addresses you see in these posts are the addresses you’ll see in yours.
These flags make the first arc easier to follow. They’re not how real software ships, and part 11 drops them to see what optimized, stripped code looks like.
Four stages
“Compiling” is really four programs run back to back:
- Preprocess.
cpppastes instdio.hand expands macros. The output is still C. - Compile.
cc1turns that C into assembly, a text file of instructions. - Assemble.
asturns the assembly into machine code in an object file,hello.o. - Link.
ldglueshello.oto the C runtime’s startup code and records thathelloneeds the C library at runtime.
gcc -S stops after stage 2 and prints the assembly, which lets you see
the middle of the pipeline. Step through the frames:
Step 1 of 3: the source
$ cat hello.c#include <stdio.h>
int main(void) { printf("Hello, world!\n"); return 0;}Step 2 of 3: the compiler's assembly output
$ gcc -O0 -fno-pie -masm=intel -fno-asynchronous-unwind-tables -S -o - hello.c .file "hello.c" .intel_syntax noprefix .text .section .rodata.LC0: .string "Hello, world!" .text .globl main .type main, @functionmain: endbr64 push rbp mov rbp, rsp mov edi, OFFSET FLAT:.LC0 call puts mov eax, 0 pop rbp ret .size main, .-main .ident "GCC: (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0" .section .note.GNU-stack,"",@progbits .section .note.gnu.property,"a" .align 8 .long 1f - 0f .long 4f - 1f .long 50: .string "GNU"1: .align 8 .long 0xc0000002 .long 3f - 2f2: .long 0x33: .align 84:Step 3 of 3: the finished binary
$ file hellohello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=c2389cb890c4ae64ef9b748ff4d79b84c6798306, for GNU/Linux 3.2.0, with debug_info, not strippedDon’t try to read the assembly in frame 2 yet. Just notice that the string
"Hello, world!" and the name main are both still there as text. Frame 3
is the end of the pipeline: an actual executable.
What is this file?
file looks at the first bytes of a file and tells you what it is:
$ file hellohello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=c2389cb890c4ae64ef9b748ff4d79b84c6798306, for GNU/Linux 3.2.0, with debug_info, not strippedThat’s one long line (scroll the block sideways), so here’s what matters in it:
- ELF is the Executable and Linkable Format, the file format Linux uses for executables, object files and shared libraries.
- 64-bit and x86-64: built for 64-bit Intel and AMD processors.
- LSB stands for least significant byte first, also known as little-endian: multi-byte numbers are stored with their lowest byte first. This will matter a lot in part 3, when you read raw memory.
- executable, and not “pie executable”, because of
-no-pie. - dynamically linked:
printfisn’t inside this file. It gets loaded from the C library when the program starts. - with debug_info, not stripped: the file still carries the names of
its functions and the debug info from
-g. Binaries you download usually have both removed, and part 11 deals with that.
The ELF header
Every ELF file starts with a 64-byte header that tells the operating system
how to load it. readelf -h prints it:
$ readelf -h helloELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x401050 Start of program headers: 64 (bytes into file) Start of section headers: 14560 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 13 Size of section headers: 64 (bytes) Number of section headers: 37 Section header string table index: 36Four lines are worth reading closely:
- Magic. The first four bytes are always
7f 45 4c 46: the byte0x7ffollowed by the ASCII lettersE,L,F. This is howfileknew what it was looking at. The next two bytes are the class (02, 64-bit) and the data encoding (01, little-endian), whichreadelfspells out on the next two lines. - Type.
EXEC, a fixed-address executable. A PIE would sayDYN. - Machine. x86-64.
- Entry point address.
0x401050is where the CPU starts executing.
That entry point is not main. Keep the number in mind for the
symbols section below: it belongs to a function you never wrote, which sets
up the process and then calls main for you.
Sections
The linker arranges the file into sections, named chunks that each hold one kind of thing. There are a lot of them:
$ readelf -S helloThere are 37 section headers, starting at offset 0x38e0:
Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .interp PROGBITS 0000000000400318 00000318 000000000000001c 0000000000000000 A 0 0 1 [ 2] .note.gnu.pr[...] NOTE 0000000000400338 00000338 0000000000000030 0000000000000000 A 0 0 8 [ 3] .note.gnu.bu[...] NOTE 0000000000400368 00000368 0000000000000024 0000000000000000 A 0 0 4 [ 4] .note.ABI-tag NOTE 000000000040038c 0000038c 0000000000000020 0000000000000000 A 0 0 4 [ 5] .gnu.hash GNU_HASH 00000000004003b0 000003b0 000000000000001c 0000000000000000 A 6 0 8 [ 6] .dynsym DYNSYM 00000000004003d0 000003d0 0000000000000060 0000000000000018 A 7 1 8 [ 7] .dynstr STRTAB 0000000000400430 00000430 0000000000000048 0000000000000000 A 0 0 1 [ 8] .gnu.version VERSYM 0000000000400478 00000478 0000000000000008 0000000000000002 A 6 0 2 [ 9] .gnu.version_r VERNEED 0000000000400480 00000480 0000000000000030 0000000000000000 A 7 1 8 [10] .rela.dyn RELA 00000000004004b0 000004b0 0000000000000030 0000000000000018 A 6 0 8 [11] .rela.plt RELA 00000000004004e0 000004e0 0000000000000018 0000000000000018 AI 6 24 8 [12] .init PROGBITS 0000000000401000 00001000 000000000000001b 0000000000000000 AX 0 0 4 [13] .plt PROGBITS 0000000000401020 00001020 0000000000000020 0000000000000010 AX 0 0 16 [14] .plt.sec PROGBITS 0000000000401040 00001040 0000000000000010 0000000000000010 AX 0 0 16 [15] .text PROGBITS 0000000000401050 00001050 00000000000000ff 0000000000000000 AX 0 0 16 [16] .fini PROGBITS 0000000000401150 00001150 000000000000000d 0000000000000000 AX 0 0 4 [17] .rodata PROGBITS 0000000000402000 00002000 0000000000000012 0000000000000000 A 0 0 4 [18] .eh_frame_hdr PROGBITS 0000000000402014 00002014 0000000000000034 0000000000000000 A 0 0 4 [19] .eh_frame PROGBITS 0000000000402048 00002048 00000000000000a4 0000000000000000 A 0 0 8 [20] .init_array INIT_ARRAY 0000000000403df8 00002df8 0000000000000008 0000000000000008 WA 0 0 8 [21] .fini_array FINI_ARRAY 0000000000403e00 00002e00 0000000000000008 0000000000000008 WA 0 0 8 [22] .dynamic DYNAMIC 0000000000403e08 00002e08 00000000000001d0 0000000000000010 WA 7 0 8 [23] .got PROGBITS 0000000000403fd8 00002fd8 0000000000000010 0000000000000008 WA 0 0 8 [24] .got.plt PROGBITS 0000000000403fe8 00002fe8 0000000000000020 0000000000000008 WA 0 0 8 [25] .data PROGBITS 0000000000404008 00003008 0000000000000010 0000000000000000 WA 0 0 8 [26] .bss NOBITS 0000000000404018 00003018 0000000000000008 0000000000000000 WA 0 0 1 [27] .comment PROGBITS 0000000000000000 00003018 000000000000002d 0000000000000001 MS 0 0 1 [28] .debug_aranges PROGBITS 0000000000000000 00003045 0000000000000030 0000000000000000 0 0 1 [29] .debug_info PROGBITS 0000000000000000 00003075 000000000000008c 0000000000000000 0 0 1 [30] .debug_abbrev PROGBITS 0000000000000000 00003101 0000000000000045 0000000000000000 0 0 1 [31] .debug_line PROGBITS 0000000000000000 00003146 0000000000000056 0000000000000000 0 0 1 [32] .debug_str PROGBITS 0000000000000000 0000319c 00000000000000e6 0000000000000001 MS 0 0 1 [33] .debug_line_str PROGBITS 0000000000000000 00003282 000000000000001d 0000000000000001 MS 0 0 1 [34] .symtab SYMTAB 0000000000000000 000032a0 0000000000000330 0000000000000018 35 18 8 [35] .strtab STRTAB 0000000000000000 000035d0 00000000000001a1 0000000000000000 0 0 1 [36] .shstrtab STRTAB 0000000000000000 00003771 000000000000016f 0000000000000000 0 0 1Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings), I (info), L (link order), O (extra OS processing required), G (group), T (TLS), C (compressed), x (unknown), o (OS specific), E (exclude), D (mbind), l (large), p (processor specific)Thirty-six sections for a five-line program (entry [ 0] is an empty
placeholder that every ELF file has). Most are bookkeeping for the linker,
the loader and the debugger, and you can ignore them for now. Four are
worth knowing by name:
.textholds the machine code. Its flags areAX: allocated in memory when the program runs, and executable. It starts at0x401050, the entry point from the header..rodataholds read-only data, like string literals. It’s at0x402000and only0x12(18) bytes long: 4 bytes the C runtime puts there, followed by"Hello, world!"and its terminating zero byte. So the string lives at0x402004. Remember that address..dataholds global variables that have an initial value. FlagsWA: allocated and writable..bssholds global variables that start out as zero. Its type isNOBITS: it takes no space in the file, and the loader just hands the program zeroed memory.
hello.c has no globals, yet .data and .bss aren’t empty. That’s the C
runtime’s startup code again, which the linker added. Every
.debug_* section comes from -g.
Symbols
A symbol is a name with an address attached. nm lists them:
$ nm hello0000000000403e08 d _DYNAMIC0000000000403fe8 d _GLOBAL_OFFSET_TABLE_0000000000402000 R _IO_stdin_used00000000004020e8 r __FRAME_END__0000000000402014 r __GNU_EH_FRAME_HDR0000000000404018 D __TMC_END__000000000040038c r __abi_tag0000000000404018 B __bss_start0000000000404008 D __data_start0000000000401100 t __do_global_dtors_aux0000000000403e00 d __do_global_dtors_aux_fini_array_entry0000000000404010 D __dso_handle0000000000403df8 d __frame_dummy_init_array_entry w __gmon_start__ U __libc_start_main@GLIBC_2.340000000000401080 T _dl_relocate_static_pie0000000000404018 D _edata0000000000404020 B _end0000000000401150 T _fini0000000000401000 T _init0000000000401050 T _start0000000000404018 b completed.00000000000404008 W data_start0000000000401090 t deregister_tm_clones0000000000401130 t frame_dummy0000000000401136 T main U puts@GLIBC_2.2.500000000004010c0 t register_tm_clonesThe letter in the middle column is the symbol’s type: T is code (usually
in .text; _init and _fini live in their own small code sections),
D is initialized data, B is .bss, R is read-only data. Lowercase
means the symbol is local to this file. Three lines are the point:
0000000000401136 T main: your function, in.text, at0x401136.0000000000401050 T _start: the entry point from the ELF header._startis the startup code that eventually callsmain.U puts@GLIBC_2.2.5:Umeans undefined. The binary usesputsbut doesn’t contain it, and there’s no address because it won’t have one until the C library is loaded at runtime. Part 8 shows how that address gets filled in.
Wait: puts? The source calls printf. There’s no printf anywhere in
the list. gcc noticed that printf("Hello, world!\n") has no format
specifiers and ends in a newline, and swapped it for the cheaper
puts("Hello, world!"), which adds the newline itself. That’s even with
optimizations off. It’s your first sign that a binary isn’t a literal
translation of its source, and the compiler will take bigger liberties
than this once optimization is on.
First look at assembly
Finally, objdump -d disassembles the machine code: it turns the bytes
in .text back into readable instructions. Here’s main:
$ objdump -d -M intel --no-show-raw-insn --disassembler-color=on --disassemble=main hello
hello: file format elf64-x86-64
Disassembly of section .init:
Disassembly of section .plt:
Disassembly of section .plt.sec:
Disassembly of section .text:
0000000000401136 <main>: 401136: endbr64 40113a: push rbp 40113b: mov rbp,rsp 40113e: mov edi,0x402004 401143: call 401040 <puts@plt> 401148: mov eax,0x0 40114d: pop rbp 40114e: ret
Disassembly of section .fini:The empty “Disassembly of section” headers are objdump announcing
sections it skipped because we asked for main only. Each remaining line
is one instruction: its address, then the instruction itself.
You don’t need to know what any of these instructions do yet. That’s part 2. For now, look at the shape, and you can already match most of it to things you’ve seen in this post:
push rbp/mov rbp,rspat the top andpop rbpat the bottom are the function’s prologue and epilogue, setting up and tearing down its stack frame. Nearly every function at-O0starts and ends this way. (endbr64is a security marker for the CPU, and you can skip it.)mov edi,0x402004loads0x402004, the address of"Hello, world!"in.rodata, into a register.call 401040 <puts@plt>callsputs, which gets that address as its argument.mov eax,0x0isreturn 0: the return value goes ineax.retreturns to whoever calledmain: the C runtime code that_starthands off to.
Five lines of C, eight instructions, and you can already point at where
the string comes from and where the 0 goes.
Recap and next
- gcc runs four stages (preprocess, compile, assemble, link) and produces an ELF file, whose header tells the OS how to load it and where to start.
- Code lives in
.text, string literals in.rodata, globals in.dataand.bss, and symbols tie names likemainto addresses in them. - The binary isn’t a literal copy of your source: execution starts at
_start, notmain, andprintfquietly becameputs.
Next up, part 2 (Registers and your first instructions): we open gdb,
step through main one instruction at a time, and learn what mov, rbp
and eax actually are.