Linking

· gcc, ld, objdump, nm, readelf

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.

c
/* 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:

bash
$ 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.

bash
$ 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:

text
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:

bash
$ 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.

bash
$ 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:

bash
$ 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.

257d5d91-856d-4299-b751-50c04efd3c5a-01-hole
257d5d91-856d-4299-b751-50c04efd3c5a-01-hole

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:

c
/* add.c */
int add(int a, int b)
{
    return a + b;
}
bash
$ gcc -c add.c -o add.o
$ gcc -o prog main.o add.o
bash
$ 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.

02-fill
02-fill

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.

c
/* 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:

bash
$ 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.

bash
$ 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.

bash
$ 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.

03-two-fates
03-two-fates

What sits at 0x1050

bash
$ 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:

bash
$ 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:

bash
$ 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.

bash
$ 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:

text
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.

04-indirection
04-indirection

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.