How a Name Becomes a Distance
Every function call you write is a name where as every function call the processor executes is a number. Something in between performs the conversion, and it does so in stages, deferring each decision until the last moment at which it can still be made correctly.
This is a piece about that conversion, traced with objdump, readelf and nm on a program small enough to read in full.
I. The hole
Here is the first of two files.
/* main.c */
int add(int a, int b);
int main(void)
{
return add(2, 3);
}
add is declared and never defined. There is no body for it anywhere in this file, and the compiler does not complain. Compile it, stopping deliberately short of a program. The -c flag ends the process after assembly:
$ gcc -c main.c -o main.o
The result is an ELF relocatable object file. It is not an executable, but a component from which one may later be assembled.
$ objdump -d -M intel main.o
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push rbp
5: 48 89 e5 mov rbp,rsp
8: be 03 00 00 00 mov esi,0x3
d: bf 02 00 00 00 mov edi,0x2
12: e8 00 00 00 00 call 17 <main+0x17>
17: 5d pop rbp
18: c3 ret
The arguments are staged as expected: 3 into esi, 2 into edi, the registers reserved for the first two integer parameters under the x86-64 System V calling convention.
The call is another matter. Its destination, 0x17, is the instruction immediately beneath it.
00 00 00 00 ?
e8 is the opcode for call. So what are we to make of 00 00 00 00? is it trying to jmp to zero, No, cuz first , address zero , in normal processes has nothing mapped there at all. the bottom of the address space is left deliberately empty so that deferencing null pointer fault immediatly instead of quietly scribbling over whatever happened to be lying around. and second, Those four bytes aren't an address. For this form of call, they hold a signed offset, measured from the end of the call instruction. The four bytes that follow are not an address. For this encoding they hold a signed offset measured from the end of the instruction. So the arithmetic runs like this. The call begins at 0x12 and occupies five bytes, which puts its end at 0x17, By the time the CPU is ready to act on the instruction, it has already finished fetching it, so PC now reads 0x17. Add the offset currently zero :
0x17 + 0 = 0x17
objdump also performs the same arithmetic and reports the result. Zero denotes no displacement whatever, which lands on the following instruction incidentally. It was never the intended target.
The compiler required a call; the encoding demands four bytes of displacement; the correct displacement was unknown. So it reserved the field and left it empty. That is what i call a hole.
The addresses are not addresses
Note the first line of the dump:
0000000000000000 <main>:
main is at zero, which does not mean it will ever reside at virtual address zero. These are offsets within this object file's .text section: distances from the start of this file's own code. Nothing has been placed. The compiler is just measuring, not assigning.
The reason for the hole follows from the same isolation. The compiler was handed exactly one file. add's body resides in another object file it was never shown and cannot go looking for. It could not compute the distance because it could not see the destination.
So it reserved the space and recorded a note.
The note
The note is inside main.o, but not inside the machine code. It lives in .rela.text, a section of its own:
$ readelf -S main.o
[Nr] Name Type Address Offset
[ 1] .text PROGBITS 0000000000000000 00000040
[ 2] .rela.text RELA 0000000000000000 00000170
Nearly three hundred bytes separate the two on disk. objdump -dr interleaves them for legibility, which is convenient until it persuades you that relocations live inside the instruction stream. They do not.
$ readelf -r main.o
Relocation section '.rela.text' at offset 0x170 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000000013 000400000004 R_X86_64_PLT32 0000000000000000 add - 4
take a look at the hole ( which is R_X86_64_PLT32 ). keeping aside the Info column, which merely packs the other fields into a single number, this is the shape of every relocation entry there is.
| Field | Value | Role |
|---|---|---|
| Offset | 0x13 |
where the hole is |
| Type | R_X86_64_PLT32 |
how to compute the value |
| Symbol | add |
what it should point at |
| Addend | -4 |
a constant correction |
Two of these warrant scrutiny.
The offset is 0x13, though the instruction begins at 0x12. This is not an off-by-one. The byte at 0x12 is the e8 opcode, which means the four-byte relocation starts at 0x13. The relocation identifies the gap, not the instruction enclosing it.
The addend is -4 because two reference points are in play. The relocation marks the start of the hole at 0x13, whereas the processor measures its jump from the end of the instruction at 0x17. The two are four bytes apart, exactly the width of the field. The addend instructs the linker to shift its result from one reference point to the other, so that the number written is a displacement the processor will measure correctly.
(The PLT in R_X86_64_PLT32 pertains to machinery that matters only for calls into shared libraries. We will arrive at it in due course.)
The symbol table states the same circumstance from the opposite direction:
$ nm main.o
U add
0000000000000000 T main
main carries an offset because it is defined here. add carries none. In its place stands U, for undefined: referenced by this file, contained in it nowhere.
The division shown there is worth stating plainly. The processor will only ever execute .text. It has no notion of a relocation and will never read one. That metadata exists for the linker, and is discarded before the program runs.
Resolution
Give add a body in a file of its own, compile it separately, and present both objects at once:
/* add.c */
int add(int a, int b)
{
return a + b;
}
$ gcc -c add.c -o add.o
$ gcc -o prog main.o add.o
$ objdump -d -M intel prog
0000000000001129 <main>:
1129: f3 0f 1e fa endbr64
112d: 55 push rbp
112e: 48 89 e5 mov rbp,rsp
1131: be 03 00 00 00 mov esi,0x3
1136: bf 02 00 00 00 mov edi,0x2
113b: e8 02 00 00 00 call 1142 <add>
1140: 5d pop rbp
1141: c3 ret
0000000000001142 <add>:
1142: f3 0f 1e fa endbr64
...
e8 00 00 00 00 has become e8 02 00 00 00. The call sits at 0x113b and runs five bytes, so it ends at 0x1140, and 0x1140 + 2 = 0x1142, the first byte of add.
The 2 is not abstract. Between the call and add stand exactly two instructions, pop rbp and ret, one byte apiece. The displacement is the length of the code in the way.
Something larger has also happened. main is no longer at 0 but at 0x1129. In the object file it was only ever .text + 0; once the linker decided where .text goes, that relative offset hardened into an address. Every number in the file moved, though not a single instruction changed. They acquired a location.
The linker did not merely patch a hole. It took fragments that each measured from a private zero, gathered them into one shared address space, matched every outstanding name to a definition, and converted provisional references into concrete numbers.
That is the complete account. For add.
II. When add() is not ours
add was tractable because its body existed, on disk, in a file handed to the linker. The distance was therefore knowable at link time, and once known, permanent.
Consider a name for which none of that holds.
/* hello.c */
#include <stdio.h>
int main(void)
{
printf("answer %d\n", 42);
return 0;
}
#include <stdio.h> supplies no implementation. The preprocessor pastes the header's text into the file, and what the header contains is a declaration: one line describing printf's shape and nothing about its whereabouts. The compiler is in precisely the position it occupied with add, and behaves identically:
$ objdump -dr -M intel hello.o
1c: e8 00 00 00 00 call 21 <main+0x21>
1d: R_X86_64_PLT32 printf-0x4
21: b8 00 00 00 00 mov eax,0x0
The same empty field, the same relocation type, the same addend. Only the symbol differs. Nothing at this stage distinguishes a call into a library from a call into the file next door.
Now let's link it.
$ gcc -o hello hello.o
$ objdump -d -M intel hello
0000000000001149 <main>:
1165: e8 e6 fe ff ff call 1050 <printf@plt>
The hole is filled. e8 e6 fe ff ff is a displacement of −282, which taken from 0x116a yields 0x1050. The mechanism is unchanged. But notice it says printf@plt , not printf. which is U and another layer or deffering.
$ nm hello | grep printf
U printf@GLIBC_2.2.5
The executable is finished, the linker has exited without error, and printf remains a name this program requires and does not possess.
What sits at 0x1050
$ objdump -d -M intel hello
0000000000001050 <printf@plt>:
1050: f3 0f 1e fa endbr64
1054: ff 25 76 2f 00 00 jmp QWORD PTR [rip+0x2f76] # 3fd0
105a: 66 0f 1f 44 00 00 nop
jmp QWORD PTR [rip+0x2f76] this is a lil piece of code called PLT stub. Think of it as a forwarding booth, you program says "call printf" the linker says "i can't give you the actual printf address yet, But i can give you a fixed address for this little sub, which you can call".
and now as i said , we have another layer of indirection , what is at that jmp address.
( QWORD PTR [rip+0x2f76] means Calculate an address using RIP + 0x2f76, go to that memory location, read an 8-byte value from there, and use that value as the jump target. )
That variable lives in the global offset table:
$ readelf -S hello
[24] .got PROGBITS 0000000000003fb8 00002fb8
In English : There is a section in this executable called .got, and it begins around address 0x3fb8.
0x3fd0 falls inside .got, a data section, writable and distinct from the read-only code. And the executable ships a second relocation describing it, this one addressed not to the linker but to whatever runs next:
$ readelf -r hello
Relocation section '.rela.plt' at offset 0x610 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000003fd0 000300000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
The same four fields as before. A hole at 0x3fd0, to be filled with the address of printf. The linker did not resolve the reference. It moved the problem out of the instruction stream, where it could not be revised, and into a data slot where it can be. eg,GOT[printf] = actual_printf_address .
Why it had no choice
The reason is visible from the shell.
$ ldd hello
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f042a200000)
printf resides in a separate file, mapped into the process at run time. Ask three times:
0x00007f1429e00000
0x00007f0a5da00000
0x00007f9762600000
Three launches of the same unmodified binary, three different base addresses. Address-space layout randomisation (done for security reasons) relocates the library on every execution, and the executable itself is position-independent by default, so even main at 0x1129 is an offset from a load base chosen at run time rather than a fixed address.
No constant written into hello at link time could have been correct. The question the linker was asked (how far is printf?) has no answer that survives until the program runs.
The shape of the compromise
Note what the arrangement preserves. The call in main is an ordinary fixed displacement. The stub is an ordinary fixed jump. Both live in read-only, executable memory, and neither is modified after the linker writes it. That is what permits a single physical copy of that code to be mapped into every process on the machine simultaneously.
All the variability is confined to eight bytes of writable data. One indirection buys the entire property: instructions that never change, reaching a target that changes on every run.
Which leaves one question outstanding. Something must write an address into 0x3fd0, and it must do so after the library has been placed and before the call is executed. The ELF is a descriptor of a program image. but if it's is dynamiclly linked it will need to resolve runtime symboles by a dynamic linker.