How does C++ Compilation Work?
Overview
To describe how C++ compilation works, I'll use an example with these 3 files:
// helper.hpp
#define PI 3.14
double times_two(double x);
// helper.cpp
double times_two(double x) {
return x * 2;
}
// main.cpp
#include <iostream>
#include "main.hpp"
int main() {
std::cout << "tau=" << times_two(PI) << std::endl;
return 0;
}
I can compile and run the code like so:
$ clang++ main.cpp helper.cpp -o main
$ ./main
tau=6.28
Even though I just ran a single command to compile the code into an executable program, there's actually 3 steps happening behind the scenes:
- Preprocessing
- Code Generation
- Linking
The word build is often used to refer to all 3 steps. The word compile can refer to all 3 steps, or just steps 1&2. Next, I'll describe each of the steps in more detail.
I am using the Clang
C++ compiler since it's the default on my MacBook, but the information should be
applicable to GCC (the GNU Compiler Collection) as well.
Preprocessing
The preprocessor performs text-manipulation on source code. It looks at lines of code that start with
#
(the hashtag symbol). These lines of code are called preprocessor directives. There are 3 main types
of directives:
- Definitions
- Includes
- Conditionals
Definitions
In our example, #define PI 3.14
in helper.hpp
is a definition. This directive tells the
preprocessor to replace the
token "PI" with the token "3.14" in the source code. Note that this will not replace every occurrence of
the string "PI" in the source code, because sometimes the string "PI" appears in a token that has other characters.
For example, "PI" will not be replaced with "3.14" in the following lines:
int PIN = 1; // the token is PIN
std::cout << "I LIKE PI"; // the token is "I LIKE PI"
Includes
In our example, #include "helper.hpp"
in main.cpp
is an include directive. This directive
essentially tells the preprocessor to copy the contents of the file helper.hpp
into
main.cpp
. If helper.hpp
itself had include statements, those would also get copied into
main.cpp
. In the libraries section, I'll describe how the preprocessor finds
the file to include for a directive like #include <iostream>
.
Conditionals
There are no conditional directives in our example, but it would look something like:
#ifdef ENVVAR
{code block}
#endif
This tells the preprocessor to only include the code block if the environment variable is defined. Often, conditional directives are used to tweak the program for different platforms (e.g. Linux vs Windows) or different build modes (e.g. DEBUG vs RELEASE).
Running the preprocessor
We can run the preprocessor by passing the flag -E
to clang++
:
$ clang++ -E helper.cpp -o helper.i && \
clang++ -E main.cpp -o main.i
The output of the preprocessor is called a translation unit. By convention, it has a .i
file
extension. Note that the preprocessor takes a single source file as input, so it can be run in parallel on multiple
source files.
A translation unit is a text file, not a binary file, so we can read it without special tools:
$ cat main.i
...
the contents of iostream
...
# 2 "main.cpp" 2
# 1 "./helper.hpp" 1
double times_two(double x);
# 3 "main.cpp" 2
int main() {
std::cout << "tau=" << times_two(3.14) << std::endl;
return 0;
}
As we can see, the preprocessor copied the contents of the included files <iostream>
and
"helper.hpp"
, and it also replaced PI
with 3.14
.
Code Generation
The code generation step turns the translation unit into an object file. Like preprocessing, code generation is performed on each translation unit separately, so it can be parallelized.
This step translates our C++ code into machine code. However, the code generator does not know how to translate symbols (e.g. variables, classes, functions) that are defined in another translation unit, so it'll leave a placeholder. In the next section we'll talk about the linker, whose job it is to resolve these placeholders.
The object file contains the generated machine code, as well as metadata like the location of the placeholders. To
generate the object file, we provide the flag -c
to clang++
:
$ clang++ -c helper.i -o helper.o && \
clang++ -c main.i -o main.o
By convention, object files have a .o
file extension. Object files are binary files, not text files, so
we use specialized tools like objdump
to read them:
$ objdump --reloc -dC main.o
In the output, you will see the generated assembly code as well as a placeholder for the times_two
symbol which is not defined in main.o
.
Linking
The linker takes all of the object files and turns them into a single executable program. Unlike the previous steps, linking cannot be parallelized, because it does not act on each object file independently.
When combining all of the object files, the linker will also resolve the placeholders in the object files for
references to external symbols. If the linker cannot find any definition for a symbol, or if it finds multiple
definitions for a symbol (that is not marked inline
), it'll throw an error. The linker also adds code
for the C/C++ runtime (e.g. intializing global variables).
To perform the linking step, we can run clang++
without any extra flags. The compiler toolchain
infers that the inputs are object files, so it knows it doesn't need to run the preprocessor or the code generator:
$ clang++ main.o helper.o -o main
$ ./main
tau=6.28
Most compiler optimizations are performed at the code generation step, but there are some optimizations that can
only be done by the linker because it has visibility into the whole program. For example, the linker has a better
idea of how to arrange the code in memory to improve cache locality, and which functions to inline. These are known
as link-time optimizations (LTO). Even though LTO will probably make your program faster, it can greatly increase
the amount of time and memory required to build your program. To enable LTO, you have to pass the flag
-flto
to both the code generator and the linker.
Libraries
Speaking informally, a library is code written by someone else that you can use in your own program. There are two types of libraries, characterized by how you link them into your program: static libraries get linked into your program at build time, and dynamic libraries get linked into your program at run time.
Static Libraries
A static library is essentially just a collection of object files. To create a static library, you first compile the
library code to object files, and then you use the ar
command line tool to create an archive that
contains all the object files. By convention, static libraries start with lib
and have the
.a
file extension:
$ clang++ helper.cpp -o helper.o
$ ar -rs libhelper.a helper.o
Then, to use the library in your program, you just have to pass it to the linker:
$ clang++ main.o libhelper.a -o main_static
$ ./main_static
tau=6.28
Dynamic Libraries
Dynamic Libraries are also called Shared Libraries, Shared Objects, and (on Windows) Dynamic Link Libraries (DLLs).
Conceptually, a dynamic library contains the defintions of symbols. The instructions for creating a dynamic library
can differ based on your operating system and compiler. For clang++
on MacOS, you pass the flag
-dynamiclib
and a list of object files. By convention, dynamic libraries start with lib
and have the .dylib
file extension. On Linux they have the .so
file extension, and on
Windows they have the .DLL
file extension.
$ clang++ -dynamiclib helper.o -o libhelper.dylib
There are two ways to use the dynamic library in your program. The first way, called load time dynamic linking, is
to pass the flag -l{library name}
to the linker:
$ clang++ main.o -L./ -lhelper -o main_dynamic_load
$ ./main_dynamic_load
tau=6.28
Note that the linker automatically prepends lib
and appends .dylib
to the name of the
library, which is why it's important to follow the naming conventions. The flag -L./
tells the linker
to add the current directory to the search path for dynamic libraries. The executable contains metadata about the
locations of the dynamic libraries it requires, which can be viewed using otool
:
$ otool -L ./main_dynamic_load
./main_dynamic_load:
libhelper.dylib (compatibility version 0.0.0, current version 0.0.0)
/usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1300.36.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1319.0.0)
When you run the executable, the operating system will read this metadata and import the needed symbols from the
dynamic libraries, before calling the main
function. Note that the metadata can contain relative or
absolute file paths. Generally, you should not use relative file paths, because then the behavior of the executable
depends on where it is run. For example, if we run our program from a different directory, it will fail because it
can't find libhelper.dylib
in that directory.
$ mkdir dummy
$ cd dummy
$ ../main_dynamic_load
dyld[69753]: Library not loaded: libhelper.dylib
...
zsh: abort ../main_dynamic_load
The second way to use a dynamic library, called runtime dynamic linking, requires modifying the source code of the program:
// helper.cpp
// extern "C" prevents the compiler from mangling the name of this symbol
extern "C" double times_two(double x) { return x * 2; }
// main_dynamic_run.cpp
#include <iostream>
#include <dlfcn.h> // for dlopen, dlsym
// Do not include helper.hpp because the symbol times_two will be found at runtime
// #include "helper.hpp"
#define PI 3.14
int main() {
void* handle;
double (*times_two)(double);
char *error;
handle = dlopen("./libhelper.dylib", RTLD_LAZY);
if (!handle) {
std::cerr << "Cannot open library: " << dlerror() << std::endl;
return 1;
}
*(void**)(×_two) = dlsym(handle, "times_two");
if ((error = dlerror()) != nullptr) {
std::cerr << "Cannot load symbol 'times_two': " << error << std::endl;
dlclose(handle);
return 1;
}
std::cout << "tau=" << times_two(PI) << std::endl;
return 0;
}
In this approach, we use the dlopen
system call to load our dynamic library from a file path, and the
dlsym
system call to look up the address of a symbol in the dynamic library using its name. To compile
and run this program:
$ clang++ -c helper.cpp -o helper.o
$ clang++ -dynamiclib helper.o -o libhelper.dylib
$ clang++ main_dynamic_run.cpp -o main_dynamic_run
$ ./main_dynamic_run
tau=6.28
Load time dynamic linking requires that the dynamic library exists when you build your program, because the linker needs to find the symbols you are referencing in the dynamic library, while runtime dynamic linking just requires that the dynamic library exists when you run your program.
Pros and Cons
One benefit of using dynamic libraries is that the operating system only needs to keep one copy of each dynamic library in memory, which can be shared by all the running programs that depend on that library. Another benefit of using dynamic libraries is that you can upgrade the version without rebuilding your program.
On the flip side, programs that use static libraries are self-contained. Also, LTO applies to static libraries but not dynamic libraries.
A drawback of both approaches is that the code for the library comes pre-compiled, so you don't have control over the compilation options used. If you want more granular control, you have to download the source files for the library and compile them yourself.
Standard Libraries
The C++ standard library is a collection of useful classes and functions provided by the standard (such as
iostream
). The headers and library files for the standard library can be installed using the package
manager for your operating system. For example, on MacOS, the standard library is installed as part of the XCode
command line tools.
In general, header files are in directories named include
and library files are in directories named
lib
. On my MacBook, the iostream
header file is at
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/iostream
. Even though this file
does not have a .h
suffix, it is a normal header file. Clang has a list of directories that it looks in
to search for header files. You can use the flag -v
(verbose) to see that list:
$ clang++ -E main.cpp -o main.i -v
...
#include "..." search starts here:
#include <...> search starts here:
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1
/Library/Developer/CommandLineTools/usr/lib/clang/14.0.0/include
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include
/Library/Developer/CommandLineTools/usr/include
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks (framework directory)
End of search list.
The implementation of the C++ standard library is libc++
for Clang and libstdc++
for GCC.
By default, Clang will use load time dynamic linking to link the standard library into your program:
$ otool -L main
main:
/usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1300.36.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1319.0.0)
The file /usr/lib/libc++.1.dylib
does not actually exist on my system, so MacOS must be hiding the
file.
Debug Symbols
The symbol table is a data structure generated by the linker. It maps symbol names to their
corresponding
addresses in the program. The symbol table is stored in the final executable, and can be viewed using the
nm
command line tool. When you set a breakpoint on a function in a debugger like gdb
,
it
uses the symbol table to find the address of the function.
Debug symbols contain more information than the symbol table, such as the line number and source file
where
symbols are defined. They make debuggers a lot more useful, but they are not generated by default. You have to
pass
the flag -g
to the compiler. The most common data format for debug symbols is called DWARF. On
Linux,
the debug symbols are stored in the executable, but on MacOS they are stored in a separate directory with the
.dSYM
suffix. You can view the debug symbols using the dwarfdump
command line tool.
For
example:
$ clang++ main.cpp helper.cpp -o main -g
$ dwarfdump -a main.dSYM/Contents/Resources/DWARF/main
...
0x00006df5: DW_TAG_subprogram
DW_AT_low_pc (0x0000000100003cc0)
DW_AT_high_pc (0x0000000100003cdc)
DW_AT_APPLE_omit_frame_ptr (true)
DW_AT_frame_base (DW_OP_reg31 WSP)
DW_AT_linkage_name ("_Z9times_twod")
DW_AT_name ("times_two")
DW_AT_decl_file ("/Users/antoncao/Documents/code/helper.cpp")
DW_AT_decl_line (1)
DW_AT_type (0x0000000000006e22 "double")
DW_AT_external (true)
...