Compiling C++ code means translating human-readable source code into an executable program (or a library). This process involves several steps, each with its own purpose and tools. The process can be broken down into the following stages:
The journey starts with writing C++ source files (.cpp) and their corresponding headers (.h). These files contain the core logic and structure of the program. The code you write directly influences the machine instructions generated later in the process, shaping the behavior of the final executable.
Before the actual compilation begins, the preprocessor steps in to handle directives like #include and #define. It essentially expands the source code by inserting the contents of included files directly into the .cpp file. The result of this step is a fully expanded source file, often referred to as a translation unit, typically saved with a .i or .ii extension. The output is modified source code, free of preprocessor directives.
Each translation unit is then fed into the compiler, where it is transformed into assembly code. Assembly is a low-level, human-readable representation of the program's instructions. During this stage, the compiler applies various optimizations to enhance performance and reduce resource usage. The output is an assembly file, commonly using extensions like .s or .asm.
In the assembly phase, the human-readable assembly code is converted into machine code — a series of binary instructions that the CPU can directly execute. The resulting binary data is stored in an object file, which typically has an .o or .obj extension, depending on the platform and compiler. At this point, the file is no longer readable by humans.
The final step is linking, where the various object files produced during the assembly stage are combined. The linker resolves references between different translation units, ensuring that symbols and functions used across files are correctly connected. This process produces either an executable file or a dynamic library, ready to be run or loaded by other programs.
Caution
TODO: Add image
Preprocessing is a crucial first stage in the compilation process that prepares your source code for translation into machine code. It involves executing preprocessor directives, which allow conditional compilation, macro definitions, file inclusions, and more.
File inclusion involves inserting the contents of specified header files into your source file before compilation begins. This is typically achieved using the #include directive. This is crucial for:
- Code Reusability: Sharing declarations (classes, functions, constants) across multiple source files.
- Modularity: Organizing code into logical units (headers for interfaces, source files for implementations).
- Standard Library Access: Using pre-written code from the C++ Standard Library.
#include <iostream> // Includes the iostream standard library header
#include "my_header.h" // Includes a user-defined header#include <header>: Searches for header in the system include directories. Used for standard library headers (e.g., , ).#include "header": First searches in the directory containing the current file, then in the system include directories. Used for user-defined headers.
Macro expansion replaces macro names with their corresponding defined values throughout the source code. This allows for easier code modification and improved readability.
Object-like Macros:
#define PI 3.14159
#define MAX_SIZE 1000Every occurrence of PI gets replaced with 3.14159.
Function-like Macros:
#define SQUARE(x) ((x) * (x))
#define MIN(a, b) (((a) < (b)) ? (a) : (b))This can be used like:
int area = PI * SQUARE(5); // Expands to: int area = 3.14159 * ((5) * (5));Conditional compilation directives (#ifdef, #ifndef, #if, #elif, #else, #endif) allow you to selectively include or exclude blocks of code during preprocessing. This is incredibly useful for:
- Platform-Specific Code: Adapting code for different operating systems or architectures.
- Debugging/Tracing: Enabling or disabling debug-specific code sections.
- Feature Flags: Including or excluding optional features at compile time.
- Header Guards: Protecting against multiple inclusions of the same file.
For example, the following only compiles if DEBUG is defined:
#ifdef DEBUG
std::cout << "Debug mode is ON\n";
#endifFor example, when working with platform-specific code:
#ifdef _WIN32
// Windows-specific code
#elif defined(__linux__)
// Linux-specific code
#else
// Fallback code
#endifCommon preprocessor directives:
#include: Incorporates the contents of a file.#define: Defines macros for constants or functions.#undef: Undefines a macro.#ifdef,#ifndef,#endif: Allow conditional compilation based on whether macros are defined.#pragma: Direct compiler-specific behaviors, such as warnings or optimizations.
While often overlooked, the preprocessor's removal of comments and extraneous whitespace has a subtle but important impact. This step reduces the overall size of the preprocessed file and eliminates any variability in compilation behavior that might be caused by differing commenting styles or white space in header files. This streamlined file is then passed on to the compiler, ensuring that only the essential code is processed in subsequent compilation phases.
- Excessive Macro Usage: Can lead to:
- Obscure Code: Difficult to read and debug due to unexpected text substitutions.
- Side Effects: Function-like macros might evaluate arguments multiple times, leading to unintended behavior.
- Namespace Pollution: Macros don't respect scope, potentially leading to naming conflicts.
- Header File Dependencies:
- Circular Dependencies: Header files that include each other, leading to compilation errors.
- Increased Compilation Times: Unnecessary inclusions can significantly slow down builds.
- Mismanaged Conditional Compilation:
- Inconsistent Builds: Different build configurations might produce subtly different executables.
- Dead Code: Code that is never included in any build, increasing maintenance burden.
Use header include guards or #pragma once to prevent multiple inclusions of the same header file:
#ifndef HEADER_NAME_H
#define HEADER_NAME_H
// Header contents
#endifAlternatively, use #pragma once if supported by your compiler:
#pragma once- cppcheck: Helps detect unused functions, potential macro issues, and other common C++ errors.
- include-what-you-use: Analyzes header file dependencies and suggests improvements for faster builds.
- Forward Declarations: Declare classes or structs without defining them to break dependency cycles.
- Pimpl Idiom (Pointer to Implementation): Hide implementation details in source files to reduce header file dependencies.
- Replace function-like macros with
constexpror inline functions to improve type safety and code readability:
constexpr double PI = 3.14159; // Instead of #define PI 3.14159
inline int square(int x) { return x * x; } // Instead of #define SQUARE(x) ((x) * (x))Modules replace the traditional #include model. Instead of the preprocessor copying and pasting header text thousands of times across translation units, modules are compiled once into a binary format (a Built Module Interface, or BMI). They eliminate the need for header guards entirely and prevent macro leakage — macros defined inside a module do not affect the importing translation unit.
The problem with #include:
// Including heavy headers in your header file pollutes the translation unit
// of anyone who includes your header, increasing compile times.
#pragma once
#include <vector>
#include <string>
#include <windows.h> // Disastrous: leaks thousands of macros like 'min' and 'max'
struct UserData {
std::vector<std::string> names;
};The module approach:
// math_utils.cppm (module interface)
export module math_utils;
import std; // C++23: imports the entire standard library without macro leakage
export constexpr double PI = 3.14159;
export inline int square(int x) { return x * x; }// main.cpp
import math_utils;
int main() {
auto area = PI * square(5);
}- Massive Build Speedups: The compiler parses the module interface exactly once. Including
import std;takes a fraction of a second compared to parsing<vector>,<string>, and<map>individually across fifty files. - Complete Macro Isolation: Macros defined inside a module do not leak out. If a third-party module uses a terrible macro internally, it will not break your code.
- Cleaner Architecture: You explicitly
exportwhat you want users to see. Everything else remains hidden inside the module.
- The Migration Slog: You cannot easily mix
#includeandimportfor the same types without risking One Definition Rule (ODR) violations in older toolchains. Migrating an existing, large-scale codebase requires a strict "bottom-up" approach (migrating your lowest-level dependencies first).
The compilation stage is the heart of the C++ translation process, where your preprocessed source code undergoes a metamorphosis, ultimately being transformed into assembly language—a low-level representation specific to the target architecture. The compiler parses each preprocessed translation unit independently during this stage, before they are finally linked.
The compiler's first task is lexical analysis, often referred to as tokenization. Here, the stream of characters from the preprocessed source file is dissected into a sequence of tokens. These tokens are the fundamental building blocks of the language—the smallest meaningful units that the compiler can work with.
- Keywords: Reserved words with special meanings (e.g.,
int,class,if,for,while,return). - Identifiers: Names given to variables, functions, classes, etc. (e.g.,
myVariable,calculateArea,User). - Literals: Represent fixed values (e.g.,
42,3.14f,"Hello, world!",true). - Operators: Symbols that perform operations (e.g.,
+,-,*,/,=,==,<,>). - Punctuators: Structural elements (e.g.,
;,{},(),,).
int result = calculateSum(a, 5);is broken down into the following tokens: int, result, =, calculateSum, (, a, ,, 5, ), ;
Tokenization transforms the source code into a structured stream that the parser can readily process. It's akin to breaking down a sentence into individual words before understanding its grammatical structure.
Syntax analysis, or parsing, is where the compiler verifies that the sequence of tokens conforms to the grammatical rules of the C++ language. The parser constructs an Abstract Syntax Tree (AST) — a hierarchical, tree-like representation of the code's structure.
- Grammar: A set of rules defining the valid syntax of C++. The parser uses this grammar to validate the token sequence.
- AST: The tree structure represents the code's syntactic relationships. Each node in the tree corresponds to a construct in the code (e.g., a variable declaration, an expression, a statement, a function definition).
For the code int b = a + 5;, the AST might look something like this (simplified):
= (Assignment)
/ \
b + (Addition)
/ \
a 5
- The AST captures the code's structure independently of its textual representation.
- It serves as the foundation for subsequent analysis and transformation phases.
- Syntax errors (e.g., missing semicolons, unbalanced parentheses) are detected during parsing, resulting in compiler errors.
If a piece of code is missing essential syntax elements, such as a semicolon, the compiler will raise a syntax error:
int b = a + 2
This will produce an error like: g++: expected ';' at end of declaration
Semantic analysis is where the compiler delves into the meaning of the code. It goes beyond syntax to ensure that the code is semantically valid within the context of the C++ language rules.
- Type Checking: Verifies that operations are performed on compatible types (e.g., you can't add a string to an integer).
- Scope Resolution: Determines the visibility and lifetime of variables. It ensures that variables are declared before use and are accessed within their valid scope.
- Function Call Validation: Checks that functions are called with the correct number and types of arguments.
- Overload Resolution: If multiple functions have the same name (overloading), the compiler determines the appropriate function to call based on the argument types.
std::string s = "hello";
int x = s + 5; // Error: Cannot add an integer to a stringAfter semantic analysis, the compiler creates an intermediate representation (IR) of the code. This IR is a platform-independent, lower-level representation that is closer to machine code but still abstract enough for optimization.
- Simplicity: IR is typically simpler than C++ source code, with fewer constructs and a more uniform structure.
- Abstraction: It abstracts away from specific machine instructions, making it suitable for optimization across different architectures.
The following C++ code:
class Rectangle {
public:
int getArea() { return width_ * height_; }
private:
int width_;
int height_;
}might be translated into a simplified, C-like IR (conceptual example):
struct Rectangle {
int width_;
int height_;
};
int Rectangle_getArea(Rectangle* this) {
return this->width_ * this->height_;
}The optimization phase is where the compiler applies a wide array of techniques to improve the performance and efficiency of the generated code. Optimizations are typically performed on the IR.
- Constant Folding: Evaluating constant expressions at compile time (e.g., int x = 5 * 3; becomes int x = 15;).
- Dead Code Elimination: Removing code that has no effect or is never reached.
- Inlining: Replacing function calls with the function's body (reduces call overhead but can increase code size).
- Loop Optimizations:
- Loop Unrolling: Replicating the loop body to reduce loop overhead.
- Loop Invariant Code Motion: Moving calculations that don't change within the loop outside the loop.
- Loop Fusion: Combining adjacent loops into a single loop.
- Register Allocation: Assigning frequently used variables to CPU registers for faster access.
- Instruction Scheduling: Reordering instructions to improve pipeline utilization and reduce stalls.
- Tail Call Optimization: Replacing certain recursive calls with iterative code, avoiding stack growth.
- Strength Reduction: Replacing costly operations by less expensive ones (e.g. replacing some multiplications by bit shifts).
Compilers typically offer different optimization levels:
-O0: No optimization. Fastest compilation, easiest debugging. Default if no-Oflag is specified.-O1: Basic optimizations that don't significantly increase compile time.-O2: The standard "production" optimization level. Enables most optimizations that don't involve a space-speed tradeoff.-O3: Aggressive optimizations including auto-vectorization, loop unrolling, and function inlining. Can occasionally produce slower code due to increased instruction cache pressure.-Os: Optimize for binary size. Similar to-O2but disables optimizations that increase code size.-Ofast: Enables-O3plus optimizations that violate strict standards compliance (e.g.,-ffast-math). Can change floating-point results. Use with caution in simulation or physics code.-Og(GCC/Clang): Optimizes for the debugging experience — applies optimizations that don't interfere with debuggability. A good middle ground between-O0and-O2during development.
Higher levels generally result in more aggressive optimization at the cost of longer compilation times. Note that -O3 is not always faster than -O2 at runtime — profile your actual workload before assuming higher is better.
int sum = 0;
for (int i=0; i<1000; ++i) {
sum += i;
}An optimizing compiler might transform this into (conceptually):
int sum = 499500; // Result precalculated via constant folding + loop eliminationTrade-offs: Optimization can significantly improve performance, but it can also increase compilation time and sometimes make debugging more difficult (as the optimized code might differ significantly from the source code).
Historically, compilers optimize one translation unit at a time. The compiler cannot inline a function if it is defined in a.cpp and called in b.cpp, because it only sees one file at a time.
Link-Time Optimization (LTO) breaks this boundary. When LTO is enabled (via -flto in GCC/Clang or /GL + /LTCG in MSVC), the compiler does not generate final machine code during the compilation step. Instead, it emits its Intermediate Representation (IR) into the object files.
When the linker runs, it sees IR instead of raw machine code. It merges the IR from all translation units into one program, runs the compiler's optimization passes again across the entire codebase, and then generates the final machine code.
# Building with LTO enabled (GCC/Clang)
$ clang++ -flto -O2 -c a.cpp -o a.o # Object file contains IR, not machine code
$ clang++ -flto -O2 -c b.cpp -o b.o
$ clang++ -flto -O2 a.o b.o -o my_app # Linker merges IR, optimizes across files, emits machine codeWhat LTO enables:
- Cross-file function inlining: A small function defined in
a.cppcan be inlined into call sites inb.cpp. - Cross-file devirtualization: The compiler can resolve virtual function calls when it can prove the concrete type across translation units.
- Aggressive dead code elimination: Unused functions that are technically "exported" from one translation unit but never called by any other can be stripped entirely.
Trade-offs:
- Longer link times: Linking can go from seconds to minutes on large codebases, because the linker is now doing heavy compilation work.
- Higher memory usage: Full LTO can consume large amounts of RAM during linking. Modern toolchains offer ThinLTO (
-flto=thinin Clang) which parallelizes the work and uses significantly less memory while retaining most of the performance gains. - Debugging complexity: The heavily optimized cross-file output can be harder to map back to source code in a debugger.
LTO is most impactful for projects that use many small translation units with functions called across file boundaries — which is most real-world C++ projects.
The last step in the compilation stage is the generation of assembly code. This is a low-level, human-readable representation of machine instructions specific to the target architecture (e.g., x86, ARM).
- Target-Specific: Assembly language is tied to a particular instruction set architecture (ISA).
- One-to-One (Mostly): Each assembly instruction typically corresponds to a single machine instruction.
- Assembler Input: The generated assembly code is then fed to the assembler, which translates it into machine code (object files).
A simple C++ statement like int c = a + b; might be translated into x86 assembly code like this (simplified example):
movl a, %eax ; Move the value of 'a' into register EAX
addl b, %eax ; Add the value of 'b' to register EAX
movl %eax, c ; Move the result from EAX to 'c'
The assembly stage is where the human-readable (though cryptic) assembly language, generated by the compiler, is translated into the raw binary language of the machine—machine code. This stage is handled by the assembler, a specialized tool that bridges the gap between the compiler's output and the executable instructions that the CPU can understand. Each compilation unit produces an object file containing machine code that will later be combined during linking to form the executable.
Machine Code Generation: The assembler's primary responsibility is to convert each assembly language instruction into its corresponding binary machine code representation. This involves:
- Opcode Translation: Replacing mnemonic opcodes (e.g.,
mov,add,jmp) with their numerical equivalents, which the CPU directly interprets as instructions. - Operand Encoding: Converting operands (registers, memory addresses, immediate values) into the appropriate binary format as specified by the target architecture's instruction set.
Consider a simplified x86 assembly instruction:
mov eax, 10 ; Move the value 10 into the EAX register
The assembler might translate this into a machine code sequence like:
B8 0A 00 00 00
Where:
B8is the opcode for moving an immediate value into theEAXregister.0A 00 00 00is the immediate value 10 (in little-endian byte order).
An object file typically contains the following sections:
- .text: Contains the machine code instructions.
- .data: Contains initialized global and static variables.
- .bss: Holds information about uninitialized global and static variables (space will be allocated for them during program loading).
- .rodata: Stores read-only data, such as string literals and constant values.
- Symbol Table: Lists the symbols defined and referenced in the object file.
- Relocation Table: Contains entries for addresses that need to be adjusted during linking.
- Debug Information (Optional): Provides information that can be used by debuggers to map machine code back to the original source code.
- Independence from the Compiler: The assembler is generally independent of the compiler that generated the assembly code. This allows for flexibility in the toolchain (you could potentially use different compilers and assemblers).
- Target Architecture Specificity: The assembler is inherently tied to a specific target architecture (e.g., x86, ARM, MIPS). The machine code it generates is only valid for that architecture.
- Input to the Linker: The object files produced by the assembler are the primary input for the linker, which combines them to create the final executable.
Linking is the final step of the compilation process, where the object files generated by the assembler—are combined with libraries to create a self-contained, runnable executable (or library). The linker orchestrates the resolution of symbols, the merging of code and data, and the creation of the final executable file.
This is the linker's most crucial task. It involves resolving all the external symbols that were left undefined during the assembly stage. The linker examines the symbol tables of each object file and library, matching references to external symbols with their corresponding definitions.
- Matching Symbols: For each unresolved symbol in an object file, the linker searches for a definition of that symbol in other object files or libraries.
- Multiple Definitions (ODR Violation): If the linker finds multiple definitions for the same global symbol, it typically issues an error, as this violates the One Definition Rule (ODR) in C++. There are exceptions, such as weak symbols, that allow for intentional overriding of definitions.
- Unresolved Symbols: If the linker cannot find a definition for a symbol, it generates an "unresolved external symbol" error, halting the linking process. This commonly occurs when you forget to link a necessary library or if there's a typo in a function or variable name.
Once symbols are resolved, the linker performs relocation. This involves adjusting the addresses of code and data within each object file so that they can all reside together in the executable's address space without conflicts.
- Address Adjustment: The linker assigns a final memory address to each section (
.text,.data,.rodata, etc.) from each object file. Then, it goes through the relocation tables, modifying the instructions and data that refer to addresses that have changed due to the merging of sections. - Relocation Types: The linker handles different types of relocations, such as:
- Absolute Relocations: The address is fixed at link time.
- Relative Relocations: The address is calculated relative to a base address (often used for position-independent code).
The linker incorporates code from libraries into the final executable. There are two main types of libraries:
- Static Libraries (
.aon Linux/macOS,.libon Windows): These libraries are essentially archives of object files. The linker extracts the necessary object files from the static library and includes them directly into the executable. This results in a larger executable but avoids runtime dependencies on external libraries. - Dynamic Libraries (
.soon Linux,.dylibon macOS,.dllon Windows): These libraries are not directly incorporated into the executable. Instead, the linker adds information to the executable that allows the operating system's dynamic loader to find and load the dynamic library at runtime. This results in smaller executables and allows libraries to be shared between multiple programs, but it introduces a runtime dependency.
After resolving symbols and performing relocation, the linker creates the final executable file (or dynamic library). It combines the modified sections from the object files, along with any necessary startup code, into a single file with a well-defined format (e.g., ELF, PE, Mach-O). This executable contains the machine code, data, and metadata needed for the operating system to load and run the program.
- Unresolved External Symbols: The most common linker error, indicating that the linker could not find a definition for a symbol referenced in your code. Causes include:
- Forgetting to link a necessary library.
- Typos in function or variable names.
- Incorrect header file inclusion (leading to missing declarations).
- Multiple Definitions: Occurs when the linker finds more than one definition for the same global symbol. This usually points to a violation of the ODR.
A common question when building C++ projects is whether object files and libraries produced by different compilers can be safely linked together. The answer depends on what crosses the API boundary between the components.
On Linux, GCC and Clang are generally ABI-compatible because they both implement the Itanium
C++ ABI and default to libstdc++. However, safe mixing depends on more than just the calling
convention — standard library version, exception handling, RTTI settings, and C++ standard
version all play a role.
The short version:
- Safe to mix: Libraries with C-style or POD-only APIs (PhysX, zlib, SQLite)
- Must use the same compiler: Libraries that expose STL types in their API (OpenUSD, Qt, Boost)
- Never mix:
libstdc++andlibc++in the same binary
For the full requirements checklist, practical rules, and verification commands, see the ABI guide.
When building an application that depends on libraries A, B, and C:
| Scenario | Safe? | Why |
|---|---|---|
| All libraries + app built with same compiler and flags | Yes | Identical ABI everywhere |
| Library A built with GCC, app with Clang, A has a C-style API | Yes | No STL types cross the boundary, Itanium ABI is shared |
Library A built with GCC, app with Clang, A exposes std::string in its API |
Risky | STL layout may differ; works today, breaks on compiler update |
Library A uses libstdc++, library B uses libc++, both linked into same app |
No | Two incompatible STL implementations in the same process |
Library A built with -D_GLIBCXX_USE_CXX11_ABI=0, app with =1 |
No | std::string and std::list have different memory layouts |
| Static library built with GCC, linked into Clang app, C-style API | Yes | Static linking doesn't change ABI rules — it's still safe because of the C-style API |
| Static library built with GCC, linked into Clang app, exposes STL types | Risky | Same ABI concerns as dynamic linking — static doesn't magically fix layout mismatches |
| MSVC-compiled library (.lib/.dll) linked into a Linux GCC/Clang app | No | Completely different object file formats (PE vs ELF), different ABIs (Microsoft vs Itanium), different OS. Must recompile from source. |
Libraries are collections of pre-compiled code (object files) that can be reused across multiple programs. The primary distinction lies in when and how the library's code is incorporated into your final program.
A static library is an archive of object files. During the linking phase, all the required code from the static library (.a or .lib file) is copied directly into your final executable file. This creates a larger, but completely self-contained, program. Because the code is now part of the executable itself, the original library file is no longer needed at runtime.
- Self-Contained Executable: The executable has no external dependencies on the library, making distribution and deployment simpler. Just copy the executable, and it runs.
- Potentially Faster Execution: Since the code is part of the executable, it can sometimes be loaded faster at runtime and allow for more aggressive whole-program optimizations by the linker.
- Larger Executable Size: Every program that uses the library gets its own copy of the code, leading to larger file sizes.
- Difficult to Update: If a bug is found in the library, every program that uses it must be re-linked and redistributed.
A dynamic library (or shared library) is a separate file that is not copied into the executable at link time. Instead, the linker places a reference to the library in the executable. When the program is run, the operating system's dynamic loader finds the required library on the system and loads it into memory, where it can be shared among multiple running programs.
- Smaller Executable Size: The executable is much smaller because it only contains references to the library, not the library code itself.
- Shared Memory: A single copy of the library in memory can be used by multiple programs, saving RAM.
- Easier Updates: To update the library, you can simply replace the
.so,.dylib, or.dllfile. All programs using it will benefit from the update on their next run without needing to be recompiled or re-linked (assuming the ABI remains compatible).
- External Dependency: The program requires the dynamic library file to be present on the target system in a location the OS can find. This can lead to "DLL Hell" or dependency issues.
- Slightly Slower Startup: There is a small overhead at program launch while the dynamic loader locates and loads the necessary libraries.
| Scenario | Recommended Choice | Rationale |
|---|---|---|
| Distributing a simple, standalone application | Static Library | Creates a single, easy-to-deploy executable file with no external dependencies. |
| Developing a large system with many components | Dynamic Library | Allows modules to be updated independently and reduces overall memory footprint. |
| Creating a plugin system | Dynamic Library | Plugins are a natural fit for dynamic loading at runtime. |
| Working in a resource-constrained environment (disk space) | Dynamic Library | Minimizes disk space by sharing common code. |
| Prioritizing maximum performance and link-time optimization | Static Library | Allows the linker to perform optimizations across both the application and library code. |
Static libraries freeze the library code into your executable at build time. This means the library's ABI is locked to whatever version you linked against — no surprises at runtime.
Dynamic libraries introduce ABI as a runtime concern. If you update a .so file and the new version changes the size of a struct, reorders virtual functions, or changes a function signature, existing executables will crash or misbehave. This is why major libraries (like OpenUSD, Qt, Boost) carefully manage ABI compatibility across releases, and why Linux distributions are cautious about updating shared libraries.
For your own projects: if you're distributing a plugin that loads into a host application (e.g., a Houdini plugin, a USD file format plugin), you must build your plugin as a dynamic library, and you must use the exact same compiler, standard library, and ABI settings as the host application.
When building a static library, all symbols are available to the linker by default. When building a dynamic library, you need to control which functions and classes are "visible" (exported) to consumers, and which are internal to the library.
If you don't manage visibility, your dynamic library either exports too much (causing symbol clashes, slower load times, and a fragile ABI surface) or exports nothing (causing "unresolved external symbol" errors when someone tries to use it).
On Windows, symbols are hidden by default — you must explicitly mark them for export with __declspec(dllexport). On Linux/macOS, the opposite is true: all symbols are visible by default, which is usually not what you want for a library.
Use a macro that adapts to both platforms, and compile with -fvisibility=hidden on Linux/macOS to match the Windows model:
// mylib_export.h
#if defined(_WIN32)
#ifdef BUILDING_MYLIB
#define MYLIB_API __declspec(dllexport)
#else
#define MYLIB_API __declspec(dllimport)
#endif
#else
#define MYLIB_API __attribute__((visibility("default")))
#endif// mylib.h
#include "mylib_export.h"
class MYLIB_API EngineCore { // Exported: consumers can use this
public:
void start();
void shutdown();
private:
void internal_setup(); // Not exported: internal to the .so/.dll
};
// Helper function only used inside the library — not exported
void do_internal_work();# Build the library with hidden visibility by default (Linux/macOS)
$ clang++ -shared -fvisibility=hidden -DBUILDING_MYLIB -o libmylib.so mylib.cppBest practice: Always compile dynamic libraries on Linux/macOS with -fvisibility=hidden. This forces you to explicitly mark your public API, preventing internal library symbols from clashing with symbols in the host application or other libraries. This also reduces the library's exported symbol table, which speeds up dynamic loading.
- gcc vs g++ — covered in compilers guide
- static vs dynamic libraries — covered above
- what is inside object files? (see c++ compiling book page 26) — partially covered in Assembling section
- talk about ABI — covered above and in ABI guide
- Advanced C and C++ Compiling (Paid)
- Learning how to write C/C++ code is only the first step. To be a serious programmer, you need to understand the structure and purpose of the binary files produced by the compiler: object files, static libraries, shared libraries, and, of course, executables.
- Link here
- https://www.linkedin.com/pulse/c-compilation-steps-amit-nadiger/
- https://www.toptal.com/c-plus-plus/c-plus-plus-understanding-compilation
- https://subscription.packtpub.com/book/programming/9781789801491/1/ch01lvl1sec03/the-c-compilation-model
- https://app.studyraid.com/en/read/1708/24051/compilation-process
- https://mikelis.net/code-to-binary-an-in-depth-look-at-the-c-building-process/
- https://evolved-cpp.netlify.app/e-compiling-and-linking/01-the-compilation-process/#the-c-compilation-process
- https://elhacker.info/manuales/Lenguajes%20de%20Programacion/C++/Advanced%20C%20and%20C++%20Compiling%20(Apress,%202014).pdf