External symbols break the simple relocation model
In the previous installment we handled relocations for internal dependencies within an object file. Now consider what happens when object code references a function that lives outside the file — for example, the C standard library's puts. Our toy object file gains a dependency:
#include <stdio.h>
...
void say_hello(void)
{
puts("Hello, world!");
}
The relocation table tells the story. The compiler emits a R_X86_64_PLT32 relocation for the puts call, and our loader already understands that relocation type. The problem appears in the symbol table: the target symbol's section index is UND (0), meaning it has no section within obj.o. External symbols simply do not belong to any section in the object file, so the loader's existing logic — which computes a runtime address by adding a symbol's offset to its section's base — fails.
The fix requires a mechanism to route the call to an actual implementation of puts. The loader itself is linked against the C library, but for illustration let's provide a custom implementation that wraps the library function:
...
/* external dependencies for obj.o */
static int my_puts(const char *s)
{
puts("my_puts executed");
return puts(s);
}
...
We now have a runtime address for puts, but there is a catch. R_X86_64_PLT32 is a 32-bit relative relocation, so the target must be within 2 GB of the call site. Unlike the data sections we mapped ourselves in part 2, the loader's own code is placed by the operating system at an arbitrary address — we cannot guarantee proximity. The solution used by real ELF executables and shared libraries is the Procedure Linkage Table (PLT) and Global Offset Table (GOT).
A simplified jump table
Rather than a full PLT/GOT with lazy binding, we'll implement a straightforward jump table that resolves external references at load time. The table is simply a collection of unconditional jump instructions, one per external symbol. Because x86-64 has no jump instruction with a direct 64-bit address operand, each entry must store both the target address and a near jump whose 32-bit displacement points at that stored address.
Each entry looks like this:
...
struct ext_jump {
/* address to jump to */
uint8_t *addr;
/* unconditional x64 JMP instruction */
/* should always be {0xff, 0x25, 0xf2, 0xff, 0xff, 0xff} */
/* so it would jump to an address stored at addr above */
uint8_t instr[6];
};
struct ext_jump *jumptable;
...
The jump instruction itself is constant across all entries: {0xff, 0x25, 0xf2, 0xff, 0xff, 0xff}. Here 0xff 0x25 encodes an indirect jump with a 32-bit displacement, and 0xfffffff2 (-14) is the offset from the end of the instruction back to the addr field, which immediately precedes instr in the structure.
Before allocating the table, the loader must count how many external references exist. We add a pass over the .rela.text section that counts relocations whose target symbol has an undefined section index:
...
/* number of external symbols in the symbol table */
static int num_ext_symbols = 0;
...
static void count_external_symbols(void)
{
const Elf64_Shdr *rela_text_hdr = lookup_section(".rela.text");
if (!rela_text_hdr) {
fputs("Failed to find .rela.text\n", stderr);
exit(ENOEXEC);
}
int num_relocations = rela_text_hdr->sh_size / rela_text_hdr->sh_entsize;
const Elf64_Rela *relocations = (Elf64_Rela *)(obj.base + rela_text_hdr->sh_offset);
for (int i = 0; i < num_relocations; i++) {
int symbol_idx = ELF64_R_SYM(relocations[i].r_info);
/* if there is no section associated with a symbol, it is probably
* an external reference */
if (symbols[symbol_idx].st_shndx == SHN_UNDEF)
num_ext_symbols++;
}
}
...
With the count in hand, the table is allocated as part of the same mmap call that maps the object file's sections, ensuring it sits close enough for 32-bit relocations to reach it. The size is simply sizeof(struct ext_jump) * num_ext_symbols. After relocations are applied, the table's memory must be marked read-only and executable, since the CPU will execute the jump instructions stored there:
...
static void parse_obj(void)
{
...
do_text_relocations();
...
/* make the jumptable readonly and executable */
if (mprotect(jumptable, page_align(sizeof(struct ext_jump) * num_ext_symbols), PROT_READ | PROT_EXEC)) {
perror("Failed to make the jumptable executable");
exit(errno);
}
}
...
Resolving relocation with an external target
The relocation logic in do_text_relocations currently fails for external symbols because it assumes every symbol has a section base. The updated logic treats symbols with no section as external, looks up the function's runtime address, stores it in the next available jump table entry, and uses the address of that entry's instr field as the relocation target — the same way it would use a local symbol's address:
...
static void do_text_relocations(void)
{
...
for (int i = 0; i < num_relocations; i++) {
...
/* symbol, with respect to which the relocation is performed */
uint8_t *symbol_address;
/* if this is an external symbol */
if (symbols[symbol_idx].st_shndx == SHN_UNDEF) {
static int curr_jmp_idx = 0;
/* get external symbol/function address by name */
jumptable[curr_jmp_idx].addr = lookup_ext_function(strtab + symbols[symbol_idx].st_name);
/* x64 unconditional JMP with address stored at -14 bytes offset */
/* will use the address stored in addr above */
jumptable[curr_jmp_idx].instr[0] = 0xff;
jumptable[curr_jmp_idx].instr[1] = 0x25;
jumptable[curr_jmp_idx].instr[2] = 0xf2;
jumptable[curr_jmp_idx].instr[3] = 0xff;
jumptable[curr_jmp_idx].instr[4] = 0xff;
jumptable[curr_jmp_idx].instr[5] = 0xff;
/* resolve the relocation with respect to this unconditional JMP */
symbol_address = (uint8_t *)(&jumptable[curr_jmp_idx].instr);
curr_jmp_idx++;
} else {
symbol_address = section_runtime_base(§ions[symbols[symbol_idx].st_shndx]) + symbols[symbol_idx].st_value;
}
...
}
...
The external function lookup is intentionally naive for this demonstration, resolving only puts:
...
static void *lookup_ext_function(const char *name)
{
size_t name_len = strlen(name);
if (name_len == strlen("puts") && !strcmp(name, "puts"))
return my_puts;
fprintf(stderr, "No address for function %s\n", name);
exit(ENOENT);
}
...
Note that this delegation gives us a natural hooking point: the object file's call to puts is diverted to my_puts rather than the C library function directly. With the table populated and relocations resolved, the loader now handles external dependencies:
$ gcc -o loader loader.c
$ ./loader
Executing add5...
add5(42) = 47
Executing add10...
add10(42) = 52
Executing get_hello...
get_hello() = Hello, world!
Executing get_var...
get_var() = 5
Executing set_var(42)...
Executing get_var again...
get_var() = 42
Executing say_hello...
my_puts executed
Hello, world!
This simple jump table omits the lazy binding that real PLT/GOT implementations use to defer symbol resolution until first call, but it solves the core problem: external 32-bit relative relocations now have a local, fixed target that forwards execution to an arbitrary runtime address. The complete source is available in the same repository as the earlier parts.



