Linking Is Optional: Loading and Relocating an Object File at Runtime
Normally an object file is the linker's raw material, transformed into an executable before main() ever runs. But an object file can also be consumed directly from memory by an unconventional loader() function: given the name of a function inside a user-supplied module, it maps that code and executes it. Before any of that works, though, the object file's structure has to be understood well enough to patch its latent references.
ELF covers relocatable objects, executables and shared libraries; for this exercise the interesting flavor is ET_REL, which marks a plain object file. Clang and GCC emit this format, and both .cpp and .c sources work, since Linux binaries and their startup code rely more on C++ runtime support than many people assume. The experiments here stick to libc, plus /usr/bin/perl as a convenient host process for testing the loader [strace attaches to the interpreter, so the analysis below suits it well]. LLVM 9.0.0 is the toolchain under examination.
Five Regions of a Relocatable Object
The container holds sections, and headings in readelf's output describe them. Five sections carry the semantics that matter, reachable by their well-known names.
First, .text. Its contents are the compiled machine instructions — here hand-crafted bytes rather than compiler output, since a hand-written assembly fragment is as valid as anything clang emits. Second, .data, holding initialized globals with internal-linkage storage. Third, .rodata, the read-only counterpart of .data.
Two more sections run crosswise to the others. A symbol table (often .symtab) describes every symbol in the section: its name, its position within the object, sometimes its size. And each operation that needs an address resolved instead of baked in gets recorded in a relocation table.
Symbols and relocations are therefore two independent channels of information: the symbol table answers "where is this?" and the relocation table answers "what must change now, given where that is?"
Symbols Without Storage
The fourth section type is the exception to the rule that sections always hold storage. .symtab is such an exception — it carries no data. The layout works on 64-bit Linux with ELF64 as laid out in Intel and System V psABI documents [the psABI manual is also referenced by elf(5)], and mirrors GLIBC's definition.
} Elf64_Sym;
typedef struct {
};
unsigned char st_other;
unsigned short st_shndx;
Elf64_Addr st_value;
Elf64_Xword st_size;
unsigned char st_info;
unsigned int st_name;
struct {
unsigned char st_bind;
unsigned char st_type;
};
} Elf64_Sym;
Name, value, size and the index of the section it belongs to are self-descriptive. Less so is the object's ELF header, which supplies one crucial index of its own.
The scattered sections that should be treated as one unit across a module are tied together by an unnamed (SHN_UNDEF) section index of 0 in gABI.
Symbol Type and Binding: The sh_info Interpretation
The shorthand derives from a sequence of bytes. Every symbol has both a type and a binding, where type distinguishes a function from a data object or an unqualified name, and binding distinguishes local symbols — confined to the object file and usually dropped on static linking — from global ones visible across models like a dynamic symbol table. Local symbols keep their scope; globals are the ones an external linker may pick up.
A few more practical observations follow. A single symbol can occupy both worlds when it names a function whose address must also be reachable outside the module. Information about the symbol belongs to the object's symbol table. In a well-formed object file, local symbols generally precede globals, and the symbol table's sh_info field points at the first global index, letting consumers split the ranges in one shot.
The second half of the pairing, relocation, must eventually happen for anything the assembler could not resolve. Every single symbol that needs resolving points at program text, and undefined symbols (SHN_UNDEF) are precisely those a normal object file cannot access locally.
At the tool level, GNU readelf's full command suite is the natural instrument for inspecting the output we've been discussing.
Combining a marked undefined symbol (SHN_UNDEF) with relocation information forms a basis for relocations of any architecture.
ELF Section Names in Practice
To re-run the earlier example, clang relocates an archive-style object of 64-bit code on ELF64 systems, or an ARM32 object on a 64-bit Linux host, using one of the size fields already in mind.
- Extract an empty section: To build from an existing object, clang uses
-ffreestandingleaving out builtins that link against undefined symbols and have constants folded. (Constant folding also applies to simple ones.) - Top-level entry with a large body: Section names like
.Lare immediately resolvable, so.identgives a value of 0 with no relocation. - The type grouping change: Here it refers to type descriptors inside a linked list at different levels of data structure member calculations, shifted by a few bits for different relocation settings and different fields assembled in the module throughout.
We end with .LC0 repeated below in Example 2 to indicate that .LC0 exists as a symbol that was referenced but never accessed — it's a module's internal constant data.
An object file on its own is a pile of code the operating system has no plans to run. Turning it into something callable means recreating, by hand, the piece of work the linker and dynamic loader would normally do: find the machine code, move it somewhere executable, and jump to it.
What still separates source from a running process
Compilation turns source files into object files, one per translation unit. Those objects may reference each other, and a linker stitches them into a single executable, binding cross-object references along the way. That executable is often still incomplete: most Linux binaries are dynamically linked, borrowing part of their functionality at runtime from shared libraries.


The dynamic loader handles that last step — locating the needed libraries, mapping their code into the process, and resolving dependencies — when the program starts. Since machine code originates at the compiler stage, but nothing after a failed link or load step could prevent execution only by finishing those steps, the obvious question is whether an object file can be run directly, without ever linking it. Reasons to try range from validating an open-source reimplementation against a proprietary driver, to code from an obscure compiler, missing build- or runtime-time dependencies, malware analysis, or binaries from another operating system.
A minimal artifact to work with
The test case is deliberately tiny: two functions, add5 and add10, each adding a constant to its single argument.
obj.c:
int add5(int num)
{
return num + 5;
}
int add10(int num)
{
return num + 10;
}
Compiling it produces the object file the rest of the exercise consumes.
$ gcc -c obj.c
$ ls
obj.c obj.o
Because a process can only execute code that already sits in RAM, object files cannot simply be invoked. They are used the way a library is used, and loading them is our job rather than the dynamic loader's. That means writing a wrapper:
loader.c:
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
static void load_obj(void)
{
/* load obj.o into memory */
}
static void parse_obj(void)
{
/* parse an object file and find add5 and add10 functions */
}
static void execute_funcs(void)
{
/* execute add5 and add10 with some inputs */
}
int main(void)
{
load_obj();
parse_obj();
execute_funcs();
return 0;
}
The functions above are stubs; the rest of this post fills them in.
Mapping the file instead of reading it
Not all of the object file's bytes will be needed, and real objects can be large, so loading the whole thing into a buffer is wasteful. mmap is the better fit: the OS then reads only the pages actually touched, when they are touched.
loader.c:
...
/* for open(2), fstat(2) */
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
/* for close(2), fstat(2) */
#include <unistd.h>
/* for mmap(2) */
#include <sys/mman.h>
/* parsing ELF files */
#include <elf.h>
/* for errno */
#include <errno.h>
typedef union {
const Elf64_Ehdr *hdr;
const uint8_t *base;
} objhdr;
/* obj.o memory address */
static objhdr obj;
static void load_obj(void)
{
struct stat sb;
int fd = open("obj.o", O_RDONLY);
if (fd <= 0) {
perror("Cannot open obj.o");
exit(errno);
}
/* we need obj.o size for mmap(2) */
if (fstat(fd, &sb)) {
perror("Failed to get obj.o info");
exit(errno);
}
/* mmap obj.o into memory */
obj.base = mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
if (obj.base == MAP_FAILED) {
perror("Maping obj.o failed");
exit(errno);
}
close(fd);
}
...
On success, load_obj leaves the start of obj.o in the global obj. That variable has a dedicated union type because the address will be read two ways: as Elf64_Ehdr, the ELF header structure from C, and as a byte pointer, since parsing ELF data means computing byte offsets from the start of the file. Object files are, in fact, ELF files — the same container format used by Linux executables and shared libraries.
Inside the ELF container
Linux ships the elf.h header with the necessary structure definitions, so parsing is not the hard part; knowing which structures matter is. Segments and sections are the two families to keep straight.
Sections hold the payload: executable code, constant data, global variables, and so on. Segments describe nothing themselves — they tell the operating system how to get sections into RAM with the right access pattern. A segment does not contain sections; it points at where a section should be placed and whether that memory is readable, writable or executable.

Object files contain no segments at all, because they are never handed to the OS directly — segments are a linker's output, produced once the object is combined with other code. readelf makes this visible:
$ readelf --segments obj.o
There are no program headers in this file.
The sections worth knowing
The same tool enumerates the sections:
$ readelf --sections obj.o
There are 11 section headers, starting at offset 0x268:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
000000000000001e 0000000000000000 AX 0 0 1
[ 2] .data PROGBITS 0000000000000000 0000005e
0000000000000000 0000000000000000 WA 0 0 1
[ 3] .bss NOBITS 0000000000000000 0000005e
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .comment PROGBITS 0000000000000000 0000005e
000000000000001d 0000000000000001 MS 0 0 1
[ 5] .note.GNU-stack PROGBITS 0000000000000000 0000007b
0000000000000000 0000000000000000 0 0 1
[ 6] .eh_frame PROGBITS 0000000000000000 00000080
0000000000000058 0000000000000000 A 0 0 8
[ 7] .rela.eh_frame RELA 0000000000000000 000001e0
0000000000000030 0000000000000018 I 8 6 8
[ 8] .symtab SYMTAB 0000000000000000 000000d8
00000000000000f0 0000000000000018 9 8 8
[ 9] .strtab STRTAB 0000000000000000 000001c8
0000000000000012 0000000000000000 0 0 1
[10] .shstrtab STRTAB 0000000000000000 00000210
0000000000000054 0000000000000000 0 0 1
Key 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),
l (large), p (processor specific)
The Linux manpages for elf.5 document both each section's purpose and its matching structure from elf.h, which makes them a single reference for writing a parser. The most common sections:
.text— compiled machine code. For this post it is the section that matters, becauseadd5andadd10live there..dataand.bss— global and static local variables, split by whether an initializer exists:.dataholds definitions likeint foo = 5;,.bssmerely reserves space for definitions likeint bar;..rodata— constant data, mostly strings and byte arrays, such as string literals passed toprintfor used in error messages. It is absent from the output above becauseobj.ccontains neither..symtab— symbols defined in the file (functions, globals, constants) plus the external symbols it depends on..strtaband.shstrtab— packed string tables. These are not the program's own strings, which belong in.rodata; they hold names of ELF structures, such as symbol names in.symtabor the section names in the table itself. To keep structures fixed-size and compact, the names live in one place and the structures reference them by offset.
Picking functions out of .text
Knowing the code is in .text is not enough, since .text is one undifferentiated blob holding two functions. Resolving individual functions is the symbol table's job, and it has a flag of its own in readelf:
$ readelf --symbols obj.o
Symbol table '.symtab' contains 10 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS obj.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 2
4: 0000000000000000 0 SECTION LOCAL DEFAULT 3
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5
6: 0000000000000000 0 SECTION LOCAL DEFAULT 6
7: 0000000000000000 0 SECTION LOCAL DEFAULT 4
8: 0000000000000000 15 FUNC GLOBAL DEFAULT 1 add5
9: 000000000000000f 15 FUNC GLOBAL DEFAULT 1 add10
The last two entries carry the names we want. Beyond the name, each entry has useful metadata: Ndx gives the index of the section containing the symbol, and cross-checking against the section table above confirms section 1, .text. Type set to FUNC confirms these are functions. Size, Bind and Vis are of little use here. The valuable column is Value, whose name misleads: in this context it is an offset from the start of the containing section, so add5 begins at offset zero of .text and add10 at byte 15.
Executing the code
That gives a workable plan: locate the section header table and .shstrtab so sections can be looked up by name; locate .symtab and .strtab so symbols can be looked up by name; find .text and copy it into RAM with execute permission; read the offsets of add5 and add10 from .symtab; call them.
More globals, plus parse_obj, get us started:
loader.c:
...
/* sections table */
static const Elf64_Shdr *sections;
static const char *shstrtab = NULL;
/* symbols table */
static const Elf64_Sym *symbols;
/* number of entries in the symbols table */
static int num_symbols;
static const char *strtab = NULL;
...
static void parse_obj(void)
{
/* the sections table offset is encoded in the ELF header */
sections = (const Elf64_Shdr *)(obj.base + obj.hdr->e_shoff);
/* the index of `.shstrtab` in the sections table is encoded in the ELF header
* so we can find it without actually using a name lookup
*/
shstrtab = (const char *)(obj.base + sections[obj.hdr->e_shstrndx].sh_offset);
...
}
...
With the section table and .shstrtab in hand, a helper can resolve any section by name:
loader.c:
...
static const Elf64_Shdr *lookup_section(const char *name)
{
size_t name_len = strlen(name);
/* number of entries in the sections table is encoded in the ELF header */
for (Elf64_Half i = 0; i < obj.hdr->e_shnum; i++) {
/* sections table entry does not contain the string name of the section
* instead, the `sh_name` parameter is an offset in the `.shstrtab`
* section, which points to a string name
*/
const char *section_name = shstrtab + sections[i].sh_name;
size_t section_name_len = strlen(section_name);
if (name_len == section_name_len && !strcmp(name, section_name)) {
/* we ignore sections with 0 size */
if (sections[i].sh_size)
return sections + i;
}
}
return NULL;
}
...
Which immediately finds .symtab and .strtab:
loader.c:
...
static void parse_obj(void)
{
...
/* find the `.symtab` entry in the sections table */
const Elf64_Shdr *symtab_hdr = lookup_section(".symtab");
if (!symtab_hdr) {
fputs("Failed to find .symtab\n", stderr);
exit(ENOEXEC);
}
/* the symbols table */
symbols = (const Elf64_Sym *)(obj.base + symtab_hdr->sh_offset);
/* number of entries in the symbols table = table size / entry size */
num_symbols = symtab_hdr->sh_size / symtab_hdr->sh_entsize;
const Elf64_Shdr *strtab_hdr = lookup_section(".strtab");
if (!strtab_hdr) {
fputs("Failed to find .strtab\n", stderr);
exit(ENOEXEC);
}
strtab = (const char *)(obj.base + strtab_hdr->sh_offset);
...
}
...
Why .text cannot just be mapped in place
Locating .text is not sufficient. A separate copy has to be made, and three reasons explain why:
- Many CPU architectures either forbid executing code that is unaligned in memory (4 kilobytes on x86) or pay a performance penalty for it, and an ELF file on disk is packed for compactness, so its
.textmakes no promise of landing on a page boundary. - Relocations may require patching bytes inside
.text— not needed in this particular example, but unavoidable later on. Mapping the file withoutMAP_PRIVATEwould let those writes reach the file on disk and corrupt it. - Sections need different permissions at runtime:
.textreadable and executable but not writable (writable-and-executable memory is a security liability),.dataand.bssreadable and writable for globals,.rodataread-only. Permissions can only be set per page, so each section has to be copied onto a page boundary of its own.
Hardcoding 4096 would be correct on most x86 systems and wrong on others, such as arm64, so the page size is queried instead:
loader.c:
...
static uint64_t page_size;
static inline uint64_t page_align(uint64_t n)
{
return (n + (page_size - 1)) & ~(page_size - 1);
}
...
static void parse_obj(void)
{
...
/* get system page size */
page_size = sysconf(_SC_PAGESIZE);
...
}
...
page_align is a small convenience that rounds a value up to the next page boundary. The copy itself is four steps: find the .text section header, allocate memory sized for it, copy the bytes, and make the result executable.
loader.c:
...
/* runtime base address of the imported code */
static uint8_t *text_runtime_base;
...
static void parse_obj(void)
{
...
/* find the `.text` entry in the sections table */
const Elf64_Shdr *text_hdr = lookup_section(".text");
if (!text_hdr) {
fputs("Failed to find .text\n", stderr);
exit(ENOEXEC);
}
/* allocate memory for `.text` copy rounding it up to whole pages */
text_runtime_base = mmap(NULL, page_align(text_hdr->sh_size), PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (text_runtime_base == MAP_FAILED) {
perror("Failed to allocate memory for .text");
exit(errno);
}
/* copy the contents of `.text` section from the ELF file */
memcpy(text_runtime_base, obj.base + text_hdr->sh_offset, text_hdr->sh_size);
/* make the `.text` copy readonly and executable */
if (mprotect(text_runtime_base, page_align(text_hdr->sh_size), PROT_READ | PROT_EXEC)) {
perror("Failed to make .text executable");
exit(errno);
}
}
...
A helper then turns a symbol name into a callable address:
loader.c:
...
static void *lookup_function(const char *name)
{
size_t name_len = strlen(name);
/* loop through all the symbols in the symbol table */
for (int i = 0; i < num_symbols; i++) {
/* consider only function symbols */
if (ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
/* symbol table entry does not contain the string name of the symbol
* instead, the `st_name` parameter is an offset in the `.strtab`
* section, which points to a string name
*/
const char *function_name = strtab + symbols[i].st_name;
size_t function_name_len = strlen(function_name);
if (name_len == function_name_len && !strcmp(name, function_name)) {
/* st_value is an offset in bytes of the function from the
* beginning of the `.text` section
*/
return text_runtime_base + symbols[i].st_value;
}
}
}
return NULL;
}
...
And execute_funcs ties the sequence together:
loader.c:
...
static void execute_funcs(void)
{
/* pointers to imported add5 and add10 functions */
int (*add5)(int);
int (*add10)(int);
add5 = lookup_function("add5");
if (!add5) {
fputs("Failed to find add5 function\n", stderr);
exit(ENOENT);
}
puts("Executing add5...");
printf("add5(%d) = %d\n", 42, add5(42));
add10 = lookup_function("add10");
if (!add10) {
fputs("Failed to find add10 function\n", stderr);
exit(ENOENT);
}
puts("Executing add10...");
printf("add10(%d) = %d\n", 42, add10(42));
}
...
Compiling and running the loader confirms the import and execution work end to end.
$ gcc -o loader loader.c
$ ./loader
Executing add5...
add5(42) = 47
Executing add10...
add10(42) = 52
Caveats
The example is a favourable one: the object's code is self-contained, referencing no globals, no constants and no external symbols. More involved cases, including relocations, are handled later in the series.
It is also worth being explicit that parsing a file from disk is processing untrusted input. The loader.c shown here skips most bounds and integrity checks — deliberate simplification for readability, not a characteristic of production-ready parsing. Maliciously crafted input can likely exploit it, so it belongs in educational use only.
The full source for this post is available at the linked repository.



