This article's aim is to explain how a modern operating system makes it possible to use shared libraries with load-time relocation. It focuses on the Linux OS running on 32-bit x86, but the general principles apply to other OSes and CPUs as well.
Note that shared libraries have many names - shared libraries, shared objects, dynamic shared objects (DSOs), dynamically linked libraries (DLLs - if you're coming from a Windows background). For the sake of consistency, I will try to just use the name "shared library" throughout this article.
Loading executables
Linux, similarly to other OSes with virtual memory support, loads executables to a fixed memory address. If we examine the ELF header of some random executable, we'll see an Entry point address:
This is placed by the linker to tell the OS where to start executing the executable's code [1]. And indeed if we then load the executable with GDB and examine the address 0x8048470, we'll see the first instructions of the executable's .text segment there.
What this means is that the linker, when linking the executable, can fully resolve all internal symbol references (to functions and data) to fixed and final locations. The linker does some relocations of its own [2], but eventually the output it produces contains no additional relocations.
Or does it? Note that I emphasized the word internal in the previous paragraph. As long as the executable needs no shared libraries [3], it needs no relocations. But if it does use shared libraries (as do the vast majority of Linux applications), symbols taken from these shared libraries need to be relocated, because of how shared libraries are loaded.
Load-time relocation in action
To see the load-time relocation in action, I will use our shared library from a simple driver executable. When running this executable, the OS will load the shared library and relocate it appropriately.
Curiously, due to the address space layout randomization feature which is enabled in Linux, relocation is relatively difficult to follow, because every time I run the executable, the libmlreloc.so shared library gets placed in a different virtual memory address [9].
This is a rather weak deterrent, however. There is a way to make sense in it all. But first, let's talk about the segments our shared library consists of:
To follow the myglob symbol, we're interested in the second segment listed here. Note a couple of things:
- In the section to segment mapping in the bottom, segment 01 is said to contain the .data section, which is the home of myglob
- The VirtAddr column specifies that the second segment starts at 0x1f04 and has size 0x10c, meaning that it extends until 0x2010 and thus contains myglob which is at 0x200C.
Now let's use a nice tool Linux gives us to examine the load-time linking process - the dl_iterate_phdr function, which allows an application to inquire at runtime which shared libraries it has loaded, and more importantly - take a peek at their program headers.
So I'm going to write the following code into driver.c:
header_handler implements the callback for dl_iterate_phdr. It will get called for all libraries and report their names and load addresses, along with all their segments. It also invokes ml_func, which is taken from the libmlreloc.so shared library.
To compile and link this driver with our shared library, run:
Running the driver stand-alone we get the information, but for each run the addresses are different. So what I'm going to do is run it under gdb [10], see what it says, and then use gdb to further query the process's memory space:
Since driver reports all the libraries it loads (even implicitly, like libc or the dynamic loader itself), the output is lengthy and I will just focus on the report about libmlreloc.so. Note that the 6 segments are the same segments reported by readelf, but this time relocated into their final memory locations.
Let's do some math. The output says libmlreloc.so was placed in virtual address 0x12e000. We're interested in the second segment, which as we've seen in readelf is at ofset 0x1f04. Indeed, we see in the output it was loaded to address 0x12ff04. And since myglob is at offset 0x200c in the file, we'd expect it to now be at address 0x13000c.
So, let's ask GDB:
Excellent! But what about the code of ml_func which refers to myglob? Let's ask GDB again:
As expected, the real address of myglob was placed in all the mov instructions referring to it, just as the relocation entries specified.
Relocating function calls
So far this article demonstrated relocation of data references - using the global variable myglob as an example. Another thing that needs to be relocated is code references - in other words, function calls. This section is a brief guide on how this gets done. The pace is much faster than in the rest of this article, since I can now assume the reader understands what relocation is all about.
Without further ado, let's get to it. I've modified the code of the shared library to be the following:
ml_util_func was added and it's being used by ml_func. Here's the disassembly of ml_func in the linked shared library:
What's interesting here is the instruction at address 0x4b3 - it's the call to ml_util_func. Let's dissect it:
e8 is the opcode for call. The argument of this call is the offset relative to the next instruction. In the disassembly above, this argument is 0xfffffffc, or simply -4. So the call currently points to itself. This clearly isn't right - but let's not forget about relocation. Here's what the relocation section of the shared library looks like now:
If we compare it to the previous invocation of readelf -r, we'll notice a new entry added for ml_util_func. This entry points at address 0x4b4 which is the argument of the call instruction, and its type is R_386_PC32. This relocation type is more complicated than R_386_32, but not by much.
It means the following: take the value at the offset specified in the entry, add the address of the symbol to it, subtract the address of the offset itself, and place it back into the word at the offset. Recall that this relocation is done at load-time, when the final load addresses of the symbol and the relocated offset itself are already known. These final addresses participate in the computation.
What does this do? Basically, it's a relative relocation, taking its location into account and thus suitable for arguments of instructions with relative addressing (which the e8 call is). I promise it will become clearer once we get to the real numbers.
I'm now going to build the driver code and run it under GDB again, to see this relocation in action. Here's the GDB session, followed by explanations:
The important parts here are:
- In the printout from driver we see that the first segment (the code segment) of libmlreloc.so has been mapped to 0x12e000 [11]
- ml_util_func was loaded to address 0x0012e49c
- The address of the relocated offset is 0x0012e4b4
- The call in ml_func to ml_util_func was patched to place 0xffffffe4 in the argument (I disassembled ml_func with the /r flag to show raw hex in addition to disassembly), which is interpreted as the correct offset to ml_util_func.
Obviously we're most interested in how (4) was done. Again, it's time for some math. Interpreting the R_386_PC32 relocation entry mentioned above, we have:
Take the value at the offset specified in the entry (0xfffffffc), add the address of the symbol to it (0x0012e49c), subtract the address of the offset itself (0x0012e4b4), and place it back into the word at the offset. Everything is done assuming 32-bit 2-s complement, of course. The result is 0xffffffe4, as expected.
Conclusion
Load-time relocation is one of the methods used in Linux (and other OSes) to resolve internal data and code references in shared libraries when loading them into memory. These days, position independent code (PIC) is a more popular approach, and some modern systems (such as x86-64) no longer support load-time relocation.
Still, I decided to write an article on load-time relocation for two reasons. First, load-time relocation has a couple of advantages over PIC on some systems, especially in terms of performance. Second, load-time relocation is IMHO simpler to understand without prior knowledge, which will make PIC easier to explain in the future. (Update 03.11.2011: the article about PIC was published)
Regardless of the motivation, I hope this article has helped to shed some light on the magic going behind the scenes of linking and loading shared libraries in a modern OS.