Linkage
Introduction
Have you ever started a project only to discover a few weeks later that the project is a mess? It may work and all, but the code is messy and there are some chunks not needed anymore, so you do a refactor. Code refactoring usually involves creating new functions and new files for refactored logic; the more loosely coupled the code is, the better the refactor is (trade-offing the time, ofc). So you created functions and files and then you probably did something like:
gcc main.c lib.c some_file.c
Perfectly good approach for a small number of files, but let’s imagine the project grows bigger and bigger. Now you have a thousand files to recompile every time you change something, which takes about 10 s. If there were only a way to speed things up?
As a matter of fact there is: it is called the linkage process. The idea is that each source file can be compiled separately, and only when the executable is created are they joined together (the joining is linking). Because every source file is compiled separately you can recompile only one source file; compared with compiling 1000 files it is a real time-saver.
In this article we will talk about how a C developer can influence the process of linkage, so let’s start ;)
Compilation
One picture is worth a thousand words, they say, so let’s start with a picture showing the compilation process.
Let’s walk through the compilation process:
-
Each source file is converted into a translation unit. To do so, the compiler reads the source file from top to bottom, substituting #include macros with appropriate file content.
-
Each translation unit is converted into a compilation unit. This step involves the compiler generating actual assembler based on the source code; however, besides assembler the compiler needs to generate a bunch of ELF metadata. This metadata is required to export functions and variables available in the compilation unit or to mark which ones from other compilation units are used.
-
Compilation units are joined together to form an executable or library. This new file includes assembler and metadata from all compilation units.
Linkage
We have three keywords available when it comes to linkage:
-
staticmakes the linkage internal, which means that within one translation unit each function or variable with internal linkage denotes the same function or variable. In other words, each compilation unit gets its own copy of a function or a variable. -
externalmakes the linkage external, which means each declaration of a particular function or variable with external linkage denotes the same function or variable. In other words, there is one single version of the function or variable shared among all compilation units. -
automakes the linkage absent. When linkage is not required the ELF format does not need to have any metadata about variables; they can be hard-coded in assembly.
static storage specifier
To discuss how internal linkage works we will need two files, main.c:
#include <stdio.h>
static int i;
void lib_func(void);
int main(void) {
puts("Running app...");
lib_func();
lib_func();
printf("[%s] &i=%p\n", __func__, &i);
puts("Finished app");
}
and lib.c:
#include <stdio.h>
void lib_func(void) {
static int i = 0;
printf("[%s] i=%d, &i=%p\n", __func__, i++, &i);
}
To compile the object files first do:
gcc -c *.c
Then link them together:
gcc *.o -o exec.out
Which outputs:
❯ ./exec.out
Running app...
[lib_func] i=0, &i=0x64b5150c6024
[lib_func] i=1, &i=0x64b5150c6024
[main] &i=0x64b5150c6028
Finished app
As we can see from the program’s output, we have two variables with the same name, each having a different address, which confirms that every compilation unit has its own copy of the variable.
Now let’s examine the ELF files’ symbol tables:
❯ objdump -t lib.o | grep i.1
0000000000000000 l O .bss 0000000000000004 i.1
❯ objdump -t main.o | grep i
main.o: file format elf64-x86-64
0000000000000000 l O .bss 0000000000000004 i
Variable i is marked as local which, according to the ELF docs, means:
Local symbols are not visible outside the object file containing their definition. Local
symbols of the same name may exist in multiple files without interfering with each
other.
So setting internal storage is implemented by ELF via local symbol binding. This changes also how the variable is allocated, but the internals of the memory allocation are beyond the scope of this article.
external storage specifier
To discuss how external linkage works we will need two files, main.c:
#include <stdio.h>
extern int i;
void lib_func(void);
int main(void) {
puts("Running app...");
lib_func();
lib_func();
printf("[%s] &i=%p\n", __func__, &i);
puts("Finished app");
}
and lib.c:
#include <stdio.h>
int i = 0;
void lib_func(void) {
printf("[%s] i=%d, &i=%p\n", __func__, i++, &i);
}
Which outputs:
❯ ./exec.out
Running app...
[lib_func] i=0, &i=0x604181c1a024
[lib_func] i=1, &i=0x604181c1a024
[main] &i=0x604181c1a024
Finished app
As we can see from the program’s output, all variables i point to the one shared memory address. There are no separate copies per compilation unit as with static.
Now examine the ELF files’ symbol tables:
❯ objdump -t lib.o | grep i
0000000000000000 g O .bss 0000000000000004 i
❯ objdump -t main.o | grep i
main.o: file format elf64-x86-64
0000000000000000 *UND* 0000000000000000 i
Variable i is marked as global which, according to the ELF docs, means:
Global symbols are visible to all object files being combined. One file’s definition of a
global symbol will satisfy another file’s undefined reference to the same global symbol.
Because in main.o i is marked as undefined, its linkage involves looking for a matching global address among all compilation units.
auto storage specifier
To discuss how no linkage works we need two files once again, main.c:
#include <stdio.h>
void lib_func(void);
int main(void) {
puts("Running app...");
auto int i;
lib_func();
lib_func();
printf("[%s] &i=%p\n", __func__, &i);
puts("Finished app");
}
and lib.c:
#include <stdio.h>
void lib_func(void) {
auto int i = 0;
printf("[%s] i=%d, &i=%p\n", __func__, i++, &i);
}
which outputs:
❯ ./exec.out
Running app...
[lib_func] i=0, &i=0x7fff9e4610bc
[lib_func] i=0, &i=0x7fff9e4610bc
[main] &i=0x7fff9e4610dc
Finished app
As we can see from the output, each compilation unit has its own address (similarly to internal linkage), but examining the compilation unit symbol tables shows us there is nothing there:
❯ objdump -t lib.o | grep i
❯ objdump -t main.o | grep i
Because variable i is absent from the symbol tables, no linkage is performed for it.
Conclusions
Understanding how storage-class specifiers affect linkage can make your code much cleaner. Creating variables with internal or external linkage changes their behaviour, allowing you to leverage these behaviours into tidier C code.