Assembly (x86-64, AArch64, RISC-V) Module 1 – Start here: what assembly is and how to run it
From source to executable: objects and linking
Trace one program from source to a running executable: assemble, link and load. Inspect sections, symbols and relocations with otool and llvm-objdump.
What you will learn
- Trace one program through the assembler, the object file, the linker and the loader
- Inspect object sections, symbols and relocations with otool, nm and llvm-objdump
- Compare ELF, Mach-O and PE/COFF as the three containers assembly code lives in
- Explain entry points and how the kernel's loader starts a program
Before you start
What you’ll learn · Before you start
What you will learn
- Trace one program through the assembler, the object file, the linker and the loader
- Inspect object sections, symbols and relocations with otool, nm and llvm-objdump
- Compare ELF, Mach-O and PE/COFF as the three containers assembly code lives in
- Explain entry points and how the kernel's loader starts a program
Before you start
On this page
A one-instruction assembly file is not a program the kernel can run. Four stages sit between the text you type and the process that executes: the assembler turns source into a relocatable object, the linker merges objects into an executable, the kernel’s loader maps that executable’s pages into a process, and the CPU starts fetching from the entry point. Each stage writes a file or a memory image the next one reads, and each file is a container with its own shape: Mach-O on Apple platforms, ELF on Linux and most Unix systems, PE/COFF on Windows.
This lesson follows one AArch64 hello program end to end on an Apple silicon Mac, then opens the x86-64 and RISC-V objects of
the same idea with llvm-objdump and llvm-readelf. Along the way the sections, the symbol table and the
relocations stop being folklore and become bytes you can point at. Running the x86-64 and RISC-V programs needs
Linux on their own ISA or an emulator, so for those two the lesson reads the objects instead of running them.
Four stages: assemble, link, load, run
assembly language: A human-readable spelling of a processor's instructions, one line per instruction, written to the rules of a single ISA. An assembler turns the text into the matching bytes; a program for one ISA does not run on another. More in “What assembly language is and why it matters” is text. The assembler: A program that reads assembly-language text and writes the matching bytes to an object file, with any labels, constants and directives the source used. clang, nasm and GNU as are three widely used assemblers. More in “What assembly language is and why it matters” reads it and writes an object file: The file an assembler or compiler writes for one source file. It holds the machine-code bytes in named sections, a symbol table and the relocations the linker needs to patch addresses.: a container holding the machine-code bytes of your instructions, the data you declared, a symbol table: The list of labels an object file defines or references, with each label's name, the section it belongs to and the offset within that section. The linker reads it to match references between objects. that names every label, and a list of relocation: An entry beside a section's bytes that says which bytes the linker must patch, with which symbol's address, under which formula. Needed whenever the assembler cannot know a final address yet. that mark the bytes it could not fill in because they need addresses the whole program does not have yet. One source file becomes one object; nothing is runnable yet.
The linker: The tool that merges one or more object files into one executable or shared library. It concatenates sections, picks final addresses, resolves symbols and patches the bytes the assembler marked with relocations. picks up one or more objects, merges their section: A named region of an object or executable file, such as .text, .rodata, .data or .bss. The linker merges matching sections across objects and the loader groups them into segments at run time.
(all .text bytes together, all .data bytes together, and so on), decides what virtual address every section will
live at, patches each relocation with the real address and writes the result into one executable file. Where the
object had holes, the executable has committed values; where the object said “this symbol is defined somewhere else”,
the executable has resolved it or recorded that the dynamic linker will.
When you run the executable, the kernel’s loader: The kernel code that reads an executable's load commands or program headers, maps each segment into the new process with the right permissions and jumps to the entry-point address. reads its load commands or program headers, maps each loadable segment into the process’s address space with the right permissions and jumps to the entry point: The address where the kernel's loader starts a program: the value the executable's header names (e_entry in an ELF header, LC_MAIN in a Mach-O file). The Linux convention is the symbol _start; a C program's main runs later, called by the startup code. More in “First program: return an exit status” address stored in the file. From that moment it is the CPU, not any tool, that runs the instructions.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The source. hello.s is text in the assembler's syntax. It names registers, instructions and labels, and declares a string in .cstring. Nothing in it is a byte of machine code yet.
- Assembler runs. clang -c calls the integrated assembler, which turns hello.s into hello.o. The object holds the __text bytes, the __cstring data and a symbol table that names _main and msg.
- Holes the linker will fill. The object leaves zeros where a reference needs an address, with a relocation beside each. On AArch64 Mach-O the adrp and add instructions carry @PAGE and @PAGEOFF relocations for msg.
- Linker runs. clang -o calls ld-27037.1, which merges the object with the Mach-O start stub, lays sections out in __PAGEZERO and __TEXT segments, patches every hole and writes load commands.
- The executable on disk. The 16 KiB file holds the Mach-O header, the load commands, the __text bytes and the __cstring data. _main sits at virtual address 0x100000328.
- Loader runs. On exec the kernel reads the load commands, maps the __TEXT pages into the process at vmaddr 0x100000000 and jumps to _main. From here the CPU runs the instructions themselves.
The AArch64 hello below walks all four stages on Apple silicon (clang 21.0.0, ld-27037.1 on macOS 26.7.1): the assembler
turns hello.s into hello.o, the linker merges it with the Mach-O start stub into hello, and the kernel starts
_main with the shell waiting on the exit status.
// AArch64 Mach-O program: writes "hello from objects and linking\n" through _write and exits 7.
// Enters at _main because clang's start code jumps to it; w0's value on ret becomes the exit status.
// adrp/add with @PAGE/@PAGEOFF forms the address of the string without any absolute relocation,
// which Apple's linker refuses in code (ld-27037.1 says 'Found illegal text-relocations').
.globl _main
.align 2
_main:
adrp x1, msg@PAGE
add x1, x1, msg@PAGEOFF
mov x0, #1 // stdout
mov x2, #31 // bytes to write (30 chars + the \n)
mov x16, #4 // SYS_write on Darwin (unsupported interface, kept here to show the raw trap)
svc #0x80
mov w0, #7 // exit status
ret
.section __TEXT,__cstring
msg:
.asciz "hello from objects and linking\n" """Assemble, inspect and run the AArch64 Mach-O hello program.
Shows every stage of the pipeline natively on Apple silicon (clang 21.0.0, ld-27037.1): the assembler turns hello.s into
hello.o, the linker turns hello.o into the executable hello, and the kernel's loader maps its __TEXT segment and
jumps to _main. The otool, nm and size calls read the ELF-less Mach-O container: its header, section table and
symbol table.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run(
"""
# Assemble hello.s and keep the relocatable object around for inspection.
clang -c aarch64/hello.s -o aarch64/hello.o
# The Mach-O header of the object (filetype 1 = MH_OBJECT, four load commands).
otool -h aarch64/hello.o
# The symbol table of the object: _main is defined, msg is a file-local label.
nm aarch64/hello.o
# Link into an executable (clang runs ld under the hood with the Mach-O start stub).
clang -o aarch64/hello aarch64/hello.s
# The executable now has load commands the loader reads to map its __TEXT segment.
otool -l aarch64/hello | sed -n '1,30p'
# size reads the segments the loader will map; __TEXT holds code and read-only data.
size aarch64/hello
# Finally run it: it writes 31 bytes to stdout and exits with status 7.
./aarch64/hello; echo "exit=$?"
"""
) Output
# Assemble hello.s and keep the relocatable object around for inspection.
$ clang -c aarch64/hello.s -o aarch64/hello.o
# The Mach-O header of the object (filetype 1 = MH_OBJECT, four load commands).
$ otool -h aarch64/hello.o
aarch64/hello.o:
Mach header
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
0xfeedfacf 16777228 0 0x00 1 4 360 0x00000000
# The symbol table of the object: _main is defined, msg is a file-local label.
$ nm aarch64/hello.o
0000000000000000 T _main
0000000000000000 t ltmp0
0000000000000020 s ltmp1
0000000000000020 s msg
# Link into an executable (clang runs ld under the hood with the Mach-O start stub).
$ clang -o aarch64/hello aarch64/hello.s
# The executable now has load commands the loader reads to map its __TEXT segment.
$ otool -l aarch64/hello | sed -n '1,30p'
aarch64/hello:
Load command 0
cmd LC_SEGMENT_64
cmdsize 72
segname __PAGEZERO
vmaddr 0x0000000000000000
vmsize 0x0000000100000000
fileoff 0
filesize 0
maxprot 0x00000000
initprot 0x00000000
nsects 0
flags 0x0
Load command 1
cmd LC_SEGMENT_64
cmdsize 232
segname __TEXT
vmaddr 0x0000000100000000
vmsize 0x0000000000004000
fileoff 0
filesize 16384
maxprot 0x00000005
initprot 0x00000005
nsects 2
flags 0x0
Section
sectname __text
segname __TEXT
addr 0x0000000100000328
size 0x0000000000000020
# size reads the segments the loader will map; __TEXT holds code and read-only data.
$ size aarch64/hello
__TEXT __DATA __OBJC others dec hex
16384 0 0 4294983680 4295000064 100008000
# Finally run it: it writes 31 bytes to stdout and exits with status 7.
$ ./aarch64/hello; echo "exit=$?"
hello from objects and linking
exit=7
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 aarch64_pipeline_session.py
The otool -h line reports the Mach-O magic 0xfeedfacf (a 64-bit Mach-O), filetype 1 (an object file,
MH_OBJECT in Apple’s mach-o/loader.h) and four load commands. nm lists _main as a global text symbol (the
upper-case T) and msg as a file-local symbol (the lower-case s). After the link, otool -l shows two
LC_SEGMENT_64 load commands: __PAGEZERO, a zero-permission 4 GiB guard at virtual address 0, and __TEXT
starting at 0x100000000, 16 KiB wide, holding the code and the string. The size line confirms the 16,384-byte
__TEXT segment. The run then prints the message and the shell reports exit=7, exactly the value _main returned
in w0.
Checkpoint
Check: Four stages: assemble, link, load, run
Inside an object: sections, symbols, relocations
An object file carries three groups of information the linker needs. The first is the raw bytes grouped into named
sections. .text holds code the CPU will execute. .rodata (or __cstring on Mach-O) holds strings and constants
the program only reads. .data holds writable initialised data. .bss holds writable data that starts as zero and
takes no space in the file; the loader allocates it at run time. The assembler chose the section for each byte from
the directives you wrote: .text, .section __TEXT,__cstring, .data and so on.
The second group is the symbol table. Every label that the linker might need to resolve to an address goes here, with its name, the section it belongs to and the offset within that section. A global symbol can be used by other objects; a file-local symbol stays inside this file so the linker never confuses two private labels of the same name.
The third group is the list of relocations. Each one says: “in this section, at this offset, patch so many bytes with
the address of this symbol, using this formula.” The formulas differ by ISA: an x86-64 call to a nearby function
needs a 32-bit PC-relative displacement (R_X86_64_PC32); a RISC-V reference to a global uses a pair,
R_RISCV_PCREL_HI20 on an auipc and R_RISCV_PCREL_LO12_I on the following addi or load; an AArch64 Mach-O
adrp/add sequence carries page and page-offset relocations. The assembler writes zeros into the instruction bytes
and leaves the relocation entry beside them; the linker rewrites the zeros when it knows the final addresses.
The x86-64 session below shows this exactly. The source reads a 64-bit global value through a RIP-relative mov
and exits with its low byte. Apple clang’s x86-64 cross backend assembles it to an ELF relocatable object, which
llvm-objdump reads byte by byte.
.text
.intel_syntax noprefix
.globl _start
# A tiny x86-64 Linux program that reads a 64-bit value from .data, exits with its low byte.
# This file is assembled into an ELF object and inspected; running it needs x86-64 Linux,
# so the session stops at inspection.
_start:
mov rax, [rip + value]
mov rdi, rax
mov rax, 60 # SYS_exit
syscall
.section .note.GNU-stack,"",@progbits
.data
.globl value
value:
.quad 42 """Assemble an x86-64 Linux object natively and read every part the linker needs.
Apple clang targets x86_64-linux-gnu as a cross backend, so it writes an ELF relocatable object (filetype REL) on
macOS without any emulator. llvm-readelf and llvm-objdump read the bytes of the object; nothing executes foreign code.
Running the linked program needs x86-64 Linux (or qemu-user on Linux), so this session stops at the inspection stage.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run(
"""
# Assemble the x86-64 source into an ELF relocatable object.
clang -c -target x86_64-linux-gnu x86_64/exit_val.s -o x86_64/exit_val.o
# The ELF header: this is a relocatable file for x86-64, no entry point yet.
/opt/homebrew/opt/llvm/bin/llvm-readelf -h x86_64/exit_val.o | sed -n '1,12p'
# The sections of the object. .text holds code, .data holds the global, .rela.text holds relocations.
/opt/homebrew/opt/llvm/bin/llvm-objdump -h x86_64/exit_val.o
# One relocation lives in .rela.text: at offset 3 of _start, patch a 32-bit PC-relative value for `value`.
/opt/homebrew/opt/llvm/bin/llvm-objdump -r x86_64/exit_val.o
# Disassembled _start: the four bytes after `48 8b 05` are 00 00 00 00 until the linker writes the real offset.
/opt/homebrew/opt/llvm/bin/llvm-objdump -d x86_64/exit_val.o
# The symbol table: _start and value are both global, each at offset 0 of their own section.
/opt/homebrew/opt/llvm/bin/llvm-objdump -t x86_64/exit_val.o
"""
) Output
# Assemble the x86-64 source into an ELF relocatable object.
$ clang -c -target x86_64-linux-gnu x86_64/exit_val.s -o x86_64/exit_val.o
# The ELF header: this is a relocatable file for x86-64, no entry point yet.
$ /opt/homebrew/opt/llvm/bin/llvm-readelf -h x86_64/exit_val.o | sed -n '1,12p'
ELF 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: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
# The sections of the object. .text holds code, .data holds the global, .rela.text holds relocations.
$ /opt/homebrew/opt/llvm/bin/llvm-objdump -h x86_64/exit_val.o
x86_64/exit_val.o: file format elf64-x86-64
Sections:
Idx Name Size VMA Type
0 00000000 0000000000000000
1 .strtab 0000003f 0000000000000000
2 .text 00000013 0000000000000000 TEXT
3 .rela.text 00000018 0000000000000000
4 .note.GNU-stack 00000000 0000000000000000
5 .data 00000008 0000000000000000 DATA
6 .symtab 00000048 0000000000000000
# One relocation lives in .rela.text: at offset 3 of _start, patch a 32-bit PC-relative value for `value`.
$ /opt/homebrew/opt/llvm/bin/llvm-objdump -r x86_64/exit_val.o
x86_64/exit_val.o: file format elf64-x86-64
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000003 R_X86_64_PC32 value-0x4
# Disassembled _start: the four bytes after `48 8b 05` are 00 00 00 00 until the linker writes the real offset.
$ /opt/homebrew/opt/llvm/bin/llvm-objdump -d x86_64/exit_val.o
x86_64/exit_val.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <_start>:
0: 48 8b 05 00 00 00 00 movq (%rip), %rax # 0x7 <_start+0x7>
7: 48 89 c7 movq %rax, %rdi
a: 48 c7 c0 3c 00 00 00 movq $0x3c, %rax
11: 0f 05 syscall
# The symbol table: _start and value are both global, each at offset 0 of their own section.
$ /opt/homebrew/opt/llvm/bin/llvm-objdump -t x86_64/exit_val.o
x86_64/exit_val.o: file format elf64-x86-64
SYMBOL TABLE:
0000000000000000 g .text 0000000000000000 _start
0000000000000000 g .data 0000000000000000 value
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 x86_64_inspect_session.py
Two things are worth staring at. First, the single line of .rela.text: offset 3, type R_X86_64_PC32, value
value-0x4. Offset 3 is where the 4-byte displacement of the mov rax, [rip + value] instruction begins; the
-0x4 accounts for RIP being past the end of the 7-byte instruction when the CPU reads the operand. Second, the
disassembly shows 48 8b 05 00 00 00 00 for that line: three opcode bytes followed by four zeros. The linker will
overwrite those four zeros with the signed displacement from the end of the instruction to value once it has placed
the sections at their final virtual addresses.
RISC-V shows the same idea with a twist: because auipc and addi cannot each hold a full 32-bit immediate, the ISA
splits a PC-relative address across two instructions, and the object carries two relocations plus two R_RISCV_RELAX
markers that let the linker shrink the pair into one gp-relative load.
.text
.globl _start
# A tiny RISC-V Linux program that loads a 64-bit value from .data and exits with its low byte.
# Assembled into an ELF object and inspected; running it needs RISC-V Linux.
_start:
lla a0, value # local la: auipc + addi, no GOT
ld a0, 0(a0)
li a7, 93 # SYS_exit on riscv Linux
ecall
.data
.globl value
value:
.quad 42 """Assemble a RISC-V Linux object natively and read its relocations.
Apple clang has no RISC-V backend, so this uses Homebrew's clang-23 (LLVM 23.1.2) through /opt/homebrew/opt/llvm/bin.
Nothing executes RISC-V code: llvm-objdump reads the bytes of the object. Running the linked program needs RISC-V
Linux (or qemu-riscv64 on Linux), so this session stops at inspection.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run(
"""
# Assemble a RISC-V ELF relocatable object with the LLVM 23 cross backend.
/opt/homebrew/opt/llvm/bin/clang-23 -c -target riscv64-linux-gnu riscv/exit_val.s -o riscv/exit_val.o
# The ELF header: a relocatable file for the RISC-V machine type.
/opt/homebrew/opt/llvm/bin/llvm-readelf -h riscv/exit_val.o | sed -n '1,12p'
# RISC-V objects carry a .riscv.attributes section that lists the ISA extensions the file was built for.
/opt/homebrew/opt/llvm/bin/llvm-objdump -h riscv/exit_val.o
# Two PC-relative relocations form the classic auipc + addi pair that reaches `value`.
# R_RISCV_RELAX markers tell the linker it may shrink the pair into one gp-relative load.
/opt/homebrew/opt/llvm/bin/llvm-objdump -r riscv/exit_val.o
# Disassembled _start: the auipc immediate and the addi immediate are both 0 until the linker patches them.
/opt/homebrew/opt/llvm/bin/llvm-objdump -d riscv/exit_val.o
"""
) Output
# Assemble a RISC-V ELF relocatable object with the LLVM 23 cross backend.
$ /opt/homebrew/opt/llvm/bin/clang-23 -c -target riscv64-linux-gnu riscv/exit_val.s -o riscv/exit_val.o
# The ELF header: a relocatable file for the RISC-V machine type.
$ /opt/homebrew/opt/llvm/bin/llvm-readelf -h riscv/exit_val.o | sed -n '1,12p'
ELF 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: REL (Relocatable file)
Machine: RISC-V
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
# RISC-V objects carry a .riscv.attributes section that lists the ISA extensions the file was built for.
$ /opt/homebrew/opt/llvm/bin/llvm-objdump -h riscv/exit_val.o
riscv/exit_val.o: file format elf64-littleriscv
Sections:
Idx Name Size VMA Type
0 00000000 0000000000000000
1 .strtab 000000a7 0000000000000000
2 .text 00000012 0000000000000000 TEXT
3 .rela.text 00000060 0000000000000000
4 .data 00000008 0000000000000000 DATA
5 .riscv.attributes 00000066 0000000000000000
6 .symtab 000000a8 0000000000000000
# Two PC-relative relocations form the classic auipc + addi pair that reaches `value`.
# R_RISCV_RELAX markers tell the linker it may shrink the pair into one gp-relative load.
$ /opt/homebrew/opt/llvm/bin/llvm-objdump -r riscv/exit_val.o
riscv/exit_val.o: file format elf64-littleriscv
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000000 R_RISCV_PCREL_HI20 value
0000000000000000 R_RISCV_RELAX *ABS*
0000000000000004 R_RISCV_PCREL_LO12_I .Lpcrel_hi0
0000000000000004 R_RISCV_RELAX *ABS*
# Disassembled _start: the auipc immediate and the addi immediate are both 0 until the linker patches them.
$ /opt/homebrew/opt/llvm/bin/llvm-objdump -d riscv/exit_val.o
riscv/exit_val.o: file format elf64-littleriscv
Disassembly of section .text:
0000000000000000 <_start>:
0: 00000517 auipc a0, 0x0
4: 00050513 mv a0, a0
8: 6108 ld a0, 0x0(a0)
a: 05d00893 li a7, 0x5d
e: 00000073 ecall
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 riscv_inspect_session.py
The disassembled auipc has immediate 0x0 and the following mv holds a0, a0: both placeholders until the
linker patches them with the real upper 20 bits and lower 12 bits of the displacement to value. The
.riscv.attributes section is a RISC-V-specific record of the ISA extensions the file was built for, so the linker
can refuse to mix objects that need extensions a target CPU does not have.
Tip
objdump -r and llvm-objdump -r both print relocations. Add -d to see them beside the disassembly, so each
instruction shows next to the hole the linker will fill in it.
Checkpoint
Check: Inside an object: sections, symbols, relocations
Three containers: ELF, Mach-O, PE/COFF
The same instructions sit inside different containers depending on the operating system. Each container has a header that identifies it, a table of chunks and conventions the linker and the loader agree on.
ELF is specified by the generic System V ABI and used by Linux, FreeBSD, OpenBSD, Solaris, Haiku and most other
Unix systems. An ELF file starts with the four magic bytes 0x7f E L F, says whether it is 32- or 64-bit,
little- or big-endian, and names its type: REL for a relocatable object, EXEC for a non-position-independent
executable, DYN for a position-independent executable or shared library, CORE for a core dump. It has a section
header table that the linker reads and a program header table that the loader reads. The two tables describe the
same bytes at different granularities: sections for linking, segments for loading.
Mach-O is Apple’s container, documented in the headers at /usr/include/mach-o/ and in Apple’s archive
documentation. A Mach-O file begins with a magic word (0xfeedfacf for the 64-bit form seen above), identifies its
CPU type (16777228 is CPU_TYPE_ARM64) and filetype, and then lists a sequence of load commands rather than two
separate tables. Each LC_SEGMENT_64 describes a segment and its sections, so one list serves both the linker and
the loader. Mach-O does not have .rodata; its equivalent is __TEXT,__cstring for C strings and __DATA,__const
for other read-only data.
PE/COFF is the Windows container, specified by Microsoft’s PE format document. A PE file starts with the DOS
MZ stub, which contains a pointer to the PE\0\0 signature that begins the real header. After that header come a
COFF file header, an optional header (which is anything but optional for executables), a section table and the
sections themselves. Function imports and exports are handled through tables rather than through ELF-style dynamic
relocations.
One ISA, several containers
The AArch64 instructions in hello.s would assemble to the same bytes on Linux, where they would sit inside an
ELF file instead of Mach-O. The container is a wrapper; the ISA decides the bytes.
A quick table of what is called what:
| Idea | ELF | Mach-O | PE/COFF |
|---|---|---|---|
| Magic bytes | \x7fELF |
0xfeedface or 0xfeedfacf |
MZ...PE\0\0 |
| Code section | .text |
__TEXT,__text |
.text |
| Read-only data | .rodata |
__TEXT,__cstring, __DATA,__const |
.rdata |
| Writable data | .data, .bss |
__DATA,__data, __DATA,__bss |
.data, .bss |
| Linker view | section headers | load commands | section table |
| Loader view | program headers | load commands | section table |
| Linker | ld (gnu-style) via cross-link |
ld-27037.1 (Apple) |
link.exe on Windows |
Entry point and the loader
An executable has one entry-point address, written in its header, and the loader jumps there after mapping the process. The address depends on how you linked.
A program linked with the C runtime (what clang -o prog file.c and the AArch64 example above do) picks up a tiny
start stub from the libSystem or libc crt files. The stub’s address is the entry point. The stub sets up argc,
argv and envp, calls main (or _main, since Mach-O symbols carry a leading underscore), and passes the integer
main returned to _exit. That is why the AArch64 program returned 7 in w0 and the shell read exit=7.
A program linked with ld and no C runtime has _start (or whichever symbol is marked with .globl _start) as its
entry point, and must make its own exit system call because there is no runtime to return to. first-program-exit-code
of this module walks through that style for all three ISAs; exit_val.s above is the x86-64 version.
The loader itself is a kernel feature on each platform. On Linux the kernel’s execve reads the ELF header, maps the
PT_LOAD segments with the right page permissions, chooses whether to run the dynamic linker named by PT_INTERP
and finally transfers control to the entry point. On macOS execve reads the Mach-O header, follows the
LC_SEGMENT_64 and LC_LOAD_DYLINKER load commands and jumps to the entry point named by LC_MAIN (or LC_UNIXTHREAD
on older binaries). On Windows the CreateProcess API ends up in the ntdll loader, which maps the PE sections and
starts the thread at the address in the optional header’s AddressOfEntryPoint.
Two numbers from the AArch64 run anchor this: otool -l reported __PAGEZERO as a 4 GiB region at virtual address
0x0 with maxprot = 0x00000000, so any dereference of a small address traps at once, and __TEXT as a 16 KiB
region at 0x0000000100000000 with initprot = 0x00000005 (read and execute). The entry address of _main,
0x0000000100000328, is just inside __TEXT, which is why mapping that one segment was enough to let the CPU start
fetching.
Checkpoint
Check: Entry point and the loader
Practise
Be the detective: three real listings from llvm-readelf and llvm-objdump are below. For each one, decide whether
the file is a relocatable object, a non-PIE executable or a position-independent executable; give its
entry-point address, or say that it has none; and name one undefined symbol the file still needs. The
questions after the listings check each call with its reason.
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x0
SYMBOL TABLE:
0000000000000000 g .text 0000000000000000 _start
0000000000000000 *UND* 0000000000000000 printf
0000000000000000 g .data 0000000000000000 value Type: DYN (Position-Independent Executable file)
Machine: AArch64
Entry point address: 0x6a4
Dynamic section at offset 0x1dc8 contains 25 entries:
Tag Type Name/Value
0x0000... (NEEDED) Shared library: [libc.so.6] Type: EXEC (Executable file)
Machine: RISC-V
Entry point address: 0x100b0 Write your three answers down before you open the questions. The Type field decides the first part, the entry-point
address the second, and only a symbol table can answer the third, so Listing C cannot.
Practice that adapts to you
Practise this lesson
7 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is the job of the linker, in one sentence, and what does it need from each object file?
The linker merges object files into one executable or shared library: it concatenates matching sections, chooses one final virtual address for every section, resolves every symbol reference and patches the bytes the assembler left as zeros using each object’s relocation entries. From each object it needs the raw section bytes, the symbol table (so it knows which labels this object defines and which it uses) and the relocation entries (so it knows which bytes to patch and how). An object with no symbol table and no relocations is already linked; an object with unresolved symbols and no matching definition anywhere is a link error.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
You run
clang -c hello.s -o hello.oand the file is 1,544 bytes. You then runclang -o hello hello.sand the file is 33,544 bytes. Walk through what happens between the two commands and account for the extra bytes. -
Mid to senior
An AArch64 Mach-O object has an
adrpandaddpair that forms the address ofmsg, with a @PAGE relocation on theadrpand a @PAGEOFF relocation on theadd. Why does one address need two instructions and two relocations, and what would go wrong if the linker patched them independently?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What are the four stages between an assembly source file and a running process?
Assemble: the assembler writes an object file with holes in it. Link: the linker lays the sections out and fills the holes. Load: the loader maps the segments into memory and jumps to the entry point. Run: the processor executes the code.
-
What three things does an object file hand to the linker?
The raw bytes of each named section, a symbol table listing every label it defines or uses, and relocation entries naming which bytes to patch with which symbol's address.
-
Why does an assembler leave relocations instead of final addresses?
While it writes one object it cannot know where the linker will place each section, or where symbols from other objects will end up. A relocation records the bytes to patch, the symbol and the formula, and the linker applies it once the addresses are fixed.
-
Which container formats wrap the same instruction bytes on Linux, macOS and Windows, and how do they differ?
Linux and most Unix systems use ELF, with a section header table for the linker and a program header table for the loader. Apple uses Mach-O, whose single list of load commands serves both. Windows uses PE/COFF: the file opens with a small DOS stub that points to the PE signature, where the real header begins.
-
Where does a program start, and how does linking a C runtime change that?
At the entry-point address named in the executable's header. With a C runtime the entry is the runtime's start stub, which sets things up and then calls main; a bare _start program has no caller and must make the exit system call itself.
-
Why can llvm-objdump and llvm-readelf show x86-64 and RISC-V objects faithfully on an Apple silicon Mac?
They only read the bytes of the file and never execute them, so the host's own ISA does not matter. Running those programs is different: that needs a Linux system of the right ISA, or an emulator.
Key takeaways
- Four distinct stages sit between source and a running process: the assembler writes an object with holes, the linker patches the holes and lays sections out, the loader maps the segments and jumps to the entry point, and the CPU runs the code.
- An object file carries three things the linker needs: raw bytes in named sections, a symbol table of every label the linker might resolve, and relocation entries that say which bytes to patch with which symbol’s address.
- ELF, Mach-O and PE/COFF are three different containers around the same instruction bytes: Linux and most Unix use ELF with a section header table and a program header table, Apple uses Mach-O with one list of load commands, and Windows uses PE/COFF after a DOS stub.
- The entry point is a single address in the executable’s header; linking with a C runtime makes the start stub the entry point and calls
main, while a bare_startprogram must make its own exit system call. - Running inspection commands on a Mac is enough to read x86-64 and RISC-V objects faithfully, because
llvm-objdumpandllvm-readelfread bytes rather than execute foreign code; running the resulting programs needs Linux on the matching ISA, or an emulator.
Terms in this lesson
- ELF
- The Executable and Linkable Format of the generic System V ABI, used by Linux and most Unix systems. It has a section header table for the linker and a program header table for the loader.
- linker
- The tool that merges one or more object files into one executable or shared library. It concatenates sections, picks final addresses, resolves symbols and patches the bytes the assembler marked with relocations.
- loader
- The kernel code that reads an executable's load commands or program headers, maps each segment into the new process with the right permissions and jumps to the entry-point address.
- Mach-O
- Apple's container format for object files, executables and shared libraries, documented by Apple. It replaces ELF's two tables with one list of load commands that serve both the linker and the loader.
- object file
- The file an assembler or compiler writes for one source file. It holds the machine-code bytes in named sections, a symbol table and the relocations the linker needs to patch addresses.
- relocation
- An entry beside a section's bytes that says which bytes the linker must patch, with which symbol's address, under which formula. Needed whenever the assembler cannot know a final address yet.
- section
- A named region of an object or executable file, such as .text, .rodata, .data or .bss. The linker merges matching sections across objects and the loader groups them into segments at run time.
- symbol table
- The list of labels an object file defines or references, with each label's name, the section it belongs to and the offset within that section. The linker reads it to match references between objects.
References
- System V Application Binary Interface, generic ABI, latest edition (Santa Cruz Operation (editor), with the gABI maintainers)
- Mach-O programming topics (and the mach-o/loader.h headers) (Apple Inc.)
- PE Format (Microsoft)
- binutils: objdump and the GNU linker (Free Software Foundation)
- LLVM command guide: llvm-objdump (LLVM Project)
- Writing ARM64 code for Apple platforms (Apple Inc.)
- RISC-V ELF psABI: relocations and linker relaxation (RISC-V International)
Report a problem with this lesson
Kept only in this browser. Your Learn progress