Compilers · Toolchain Engineering

How a Compiler Driver Dispatches Work to Preprocessor, Compiler, Assembler, and Linker

The program named cc compiles nothing itself. It parses one command line, routes each option to the stage that understands it, and chains four subprocesses through temporary files.

cc -c hello.c leaves hello.o on disk, and it is tempting to believe that cc is the compiler. It is not. On a typical GNU/Linux installation cc is a small driver program whose entire job is to read a command line, decide which parts of it belong to which of four other programs, and run those programs in sequence. The compiler proper is a separate executable with a name like cc1; the assembler is as; the linker is reached through a wrapper called collect2 and then ld. Nothing in hello.c is parsed by cc.

That division explains a class of behaviour that otherwise looks arbitrary: why -c succeeds while the eventual link fails, why some flags appear to do nothing, why an assembler error cites a line in a .s file the programmer never wrote. The driver is a dispatch table with a knowledge of installation paths, and almost every confusing diagnostic at a toolchain boundary comes from an option landing in the wrong column of that table.

Four programs and the boundaries between them

The pipeline has well-defined cut points, and the driver’s stop options select one of them. Preprocessing expands #include and #define and emits a single token stream. Compilation proper parses that stream and emits assembly text. Assembly turns assembly text into an object file with relocations and a symbol table. Linking combines object files, resolves relocations against libraries, and produces an executable or shared object.

Each cut point has a flag: stop after preprocessing, stop after compilation, stop after assembly, or run the whole chain. The default is the whole chain, ending in an executable named a.out unless an output name is given. These are not conveniences for debugging alone. A build system that wants to compile and link in separate steps uses the assembly cut point because it needs the object file as a cacheable artifact; a build system that wants to inspect macro expansion uses the preprocessing cut point.

Between the stages the driver creates temporary files. Assembly text goes to a temporary path, the object file goes to another, and both are unlinked on exit unless asked to keep them. The flag that keeps them is worth knowing because it converts an opaque pipeline into four inspectable artifacts; so is the flag that replaces the temporary files with pipes, trading disk traffic for memory and making the intermediate stages unavailable for inspection.

Language selection is also the driver’s decision, made from the filename suffix in the common case. A .c file goes to the C front end, .cc or .cpp to the C++ front end, .s to the assembler alone with no compilation, .S to the preprocessor and then the assembler. An explicit language flag overrides the suffix, which is how a file with an unusual extension, or standard input, gets routed correctly.

Why one option has to reach two stages

Most options belong to exactly one program. Optimization level, warning selection, and code generation flags belong to the compiler. Include paths belong to the preprocessor. Relocation model flags belong to the assembler. Library search paths belong to the linker.

A significant minority belongs to more than one, and this is where drivers earn their complexity. Position-independent executables are the clearest case: the compiler must generate position-independent code, and the linker must be told to produce a position-independent executable rather than a shared object or a fixed-address binary. A single user-facing flag therefore expands into one flag for the compiler and a different flag for the linker. Debug information behaves the same way: the compiler emits the sections, and the link step must be told how to combine them. A sysroot, which relocates the entire view of headers and libraries, has to be forwarded to every stage that searches a directory.

The driver resolves this with an explicit mapping from each option to the set of stages that consume it. Options that do not fit the mapping have escape hatches. A prefix form passes a comma-separated list straight to the linker, another passes a list to the assembler, and a third passes a list to the preprocessor. Single arguments can be handed over one at a time when the value itself contains a comma. These escape hatches exist precisely because the driver cannot anticipate every option of every stage, and they are also the source of a common bug: a linker option written without its prefix is interpreted by the compiler, which either ignores it or rejects it.

Ordering matters at the link boundary and nowhere else. Libraries are scanned left to right, and an archive contributes only the members needed to satisfy symbols that are still undefined at the point the archive is reached. A library placed before the object file that uses it contributes nothing, and the resulting undefined-symbol error looks like a missing library rather than a misplaced one. Cyclic dependencies between archives require an explicit grouping flag that makes the linker rescan a set of archives until no new symbols are resolved.

Reading the driver instead of guessing

Two diagnostic flags expose the dispatch. One prints the commands the driver would run and exits without running them; the other prints them and then runs them. The difference matters when a build fails in a way that suggests the driver made a decision the programmer did not intend: the dry-run form is safe to use on a failing command because it cannot produce a partial output.

The printed commands have a recognisable shape. The compiler proper is invoked with the preprocessed input, an output path for assembly text, and every option the driver classified as belonging to compilation. The assembler is invoked with the assembly file and the object output. The link is invoked with the object files, the library search paths, the libraries, and the linker options that survived classification. Seeing the four commands side by side is usually enough to answer the question of which stage received a flag.

Some drivers go further and describe their behaviour in a specification file, a small language of pattern-matched rules that says, for a given input, which programs to run with which arguments. Distribution vendors patch these specifications to change defaults: enabling position-independent executables everywhere, adding hardening flags, inserting distribution-specific include directories. That is why the same command line can produce different artifacts on two systems with the same upstream compiler version, and why the printed command list is more trustworthy than the manual when the two disagree.

# The shape of a dry run, with paths elided:
#   cc1 hello.c -o /tmp/ccXXXX.s <compiler options>
#   as -o /tmp/ccYYYY.o /tmp/ccXXXX.s
#   collect2 -o a.out /tmp/ccYYYY.o <linker options> -lc
cc -### -c hello.c

Failures that surface one stage late

The most instructive failures are the ones where a stage succeeds and a later stage rejects the result, because the error message points at the wrong part of the command line.

A missing 32-bit runtime is the standard example. Asking a 64-bit compiler to target a 32-bit ABI succeeds at compilation and assembly: the front end knows how to lay out 32-bit types, and the assembler knows how to encode the instructions. The failure arrives at the link, where the startup objects and the C library for that ABI are absent. A build that compiles and links in separate invocations therefore reports success for every compilation unit and fails only at the end, which reads as a linker problem rather than an installation problem.

Assembler diagnostics are a second case. The assembler sees generated assembly, so its line numbers refer to that file, not to the source. Without debug line information the mapping is unavailable and the error is unactionable; with it, the toolchain can report the original source line. That mapping is the same mechanism that makes source-level debugging work, and it is worth understanding separately from the driver.

Dependency generation is a third case, and a benign one. When asked, the compiler front end writes a side file listing every header that contributed to a translation unit. The driver passes the request through to the front end and the file appears next to the object file. The link stage never sees it. This is the hook that incremental build systems use, and its correctness depends on the front end’s own view of which files it opened, not on a separate scan of the source text. Header Dependency Discovery covers what those side files contain and where they break.

What the driver is actually for

The alternative to a driver is a build system invoking each stage directly. That is what happens inside large build systems anyway: they construct command lines for the compiler proper and the assembler themselves, because they need precise control and because the driver’s path knowledge is redundant once the paths are known.

The driver survives because it encapsulates three things that are tedious to get right per invocation. It knows where the front end, assembler, linker, startup objects, and default libraries live for each supported target. It knows the default flag set that makes those pieces agree with one another. And it presents one stable interface across targets, so that a command line written for one architecture can usually be retargeted by changing a prefix rather than by rewriting the pipeline. What that prefix implies for headers and libraries is covered in Target Triples, Sysroots, and What Actually Changes When Cross-Compiling.

The cost is opacity. A driver that decides everything is a program whose decisions are invisible unless explicitly printed, and every option that must reach two stages is an opportunity for a mismatch that no single stage can detect. The practical discipline is to treat the driver as a router: when behaviour is surprising, print the routes before theorising about the compiler. The next boundary in the chain, where the front end records which headers it read, is where incremental correctness begins.

References

  1. GCC Online Documentation — Overall Options — GNU Project
  2. Clang Documentation — Driver Internals — LLVM Project
  3. GNU Binutils Documentation — as, the GNU Assembler — GNU Binutils