Skip to content

From Source Code to a Running Program

Suppose main.c calls a function implemented in total.c. An IDE may build the program with one gesture, but the durable model is a pipeline:

text
source + headers → preprocessed translation unit → object file
object files + libraries → executable → loaded process

Different tools may fuse stages for speed, and compiler drivers usually coordinate them, but each boundary produces its own class of failures.

The compiler driver coordinates several tools

Create a tiny source file:

c
// hello.c
#include <stdio.h>

int main(void) {
    puts("hello");
    return 0;
}

This familiar command compiles and links:

sh
cc -std=c17 -Wall -Wextra -pedantic hello.c -o hello

cc is a driver. It chooses language tools, passes options, invokes an assembler where appropriate, supplies startup objects and default libraries, and invokes the linker. Calling the linker executable directly is occasionally useful but normally requires platform details the driver already knows.

Run ./hello on a Unix-like shell or hello.exe on Windows. The shell starts the executable; the operating-system loader maps it and its required shared libraries into a new process, then runtime startup code eventually calls main.

Preprocessing forms a translation unit

Before ordinary language translation, directives beginning with # are processed. #include textually includes another file; macros replace preprocessing tokens; conditional directives select source.

c
#include "total.h"

#if defined(ENABLE_TRACE)
#define TRACE(message) log_trace(message)
#else
#define TRACE(message) ((void)0)
#endif

The resulting source is a translation unit. Each .c or .cpp source file normally becomes one translation unit after its includes are expanded. Headers are not compiled once and imported as independent runtime modules; their relevant text participates in every translation unit that includes them.

You can inspect preprocessed output:

sh
cc -E hello.c

It is often enormous because standard headers include other definitions. Inspection is useful for macro and conditional-compilation problems, not as an everyday reading format.

Modern C++ modules offer a different mechanism, but header-based translation remains dominant and must be understood first.

Compilation produces assembly-level intent

The language front end parses and type-checks a translation unit, applies permitted transformations, and lowers it toward machine instructions. To stop at assembly text:

sh
cc -std=c17 -S hello.c -o hello.s

Assembly output is target-specific. Optimization level changes it substantially:

sh
cc -std=c17 -O2 -S hello.c -o hello-optimized.s

An optimizer preserves the behavior required by the abstract-machine rules. If a program has undefined behavior, the implementation has no obligation to preserve the result observed in an unoptimized build. Optimization often reveals a preexisting bug rather than creating one.

Assembly produces an object file

Stopping before linking creates a relocatable object:

sh
cc -std=c17 -Wall -Wextra -c hello.c -o hello.o

On Windows with MSVC, the analogous artifact normally uses .obj. An object contains machine code and data plus metadata: defined symbols, unresolved symbol references, relocation records, sections, and debugging information when requested.

It is not normally runnable. Addresses and external references are not yet fully resolved.

Separate compilation creates explicit interfaces

Consider three files:

c
// total.h
#ifndef TOTAL_H
#define TOTAL_H

#include <stddef.h>

long total_values(const int values[], size_t count);

#endif
c
// total.c
#include "total.h"

long total_values(const int values[], size_t count) {
    long total = 0;
    for (size_t i = 0; i < count; ++i) {
        total += values[i];
    }
    return total;
}
c
// main.c
#include "total.h"
#include <stdio.h>

int main(void) {
    const int values[] = {3, 5, 8};
    printf("%ld\n", total_values(values, 3));
    return 0;
}

Compile each translation unit, then link:

sh
cc -std=c17 -Wall -Wextra -c total.c -o total.o
cc -std=c17 -Wall -Wextra -c main.c -o main.o
cc main.o total.o -o totals

Both sources include the declaration, so their compiler invocations can check calls and definitions against it. The linker later matches the unresolved reference in main.o to the definition in total.o.

Headers are contracts, but ordinary C compilation does not automatically prove that every translation unit saw the same declaration. Including a component's own header first in its implementation is a useful consistency check.

Linking resolves symbols and lays out a binary

A linker combines object files and needed library members, assigns addresses, applies relocations, and writes an executable or library.

If main.c calls total_values without any visible declaration, a modern compiler should diagnose the source. If it sees a declaration but total.o is absent from the link, compilation can succeed and linking fails with an “undefined reference” or “unresolved external symbol.” If two objects provide externally visible definitions of the same C function, linking normally reports a duplicate symbol.

That distinction guides the fix:

  • Parse/type diagnostic: fix source, declarations, includes, or language mode.
  • Undefined symbol: supply the defining object/library and verify the symbol/ABI.
  • Duplicate symbol: fix definitions or linkage.
  • Missing library at program startup: fix deployment or loader search policy.

Adding random include paths cannot resolve an already compiled undefined symbol; adding random libraries cannot make invalid syntax compile.

Static and shared libraries package code differently

A static library is usually an archive of object files. On Unix-like systems:

sh
ar rcs libtotal.a total.o
cc main.o -L. -ltotal -o totals

The linker copies needed archive members into the final link. The executable does not open libtotal.a at startup.

A shared library remains a runtime dependency. Its creation and naming are platform-specific: .so on many Unix-like systems, .dylib on macOS, and a .dll plus an import library in common Windows workflows. Position-independent-code flags, exported symbols, install names, sonames, and loader paths enter the design.

“Static versus dynamic” is therefore not merely file extension preference. It changes deployment, updates, process sharing, ABI constraints, licensing considerations, and diagnostic paths.

The loader finishes the job

When a dynamic executable starts, the loader locates required shared libraries, maps segments, performs remaining relocations, and establishes runtime state. Search rules differ across operating systems and should not be replaced with a global environment-variable hack in production.

A library can be present on disk yet undiscoverable, discoverable but built for the wrong architecture, or loadable but missing the versioned symbol a consumer needs. These are load-time compatibility failures, not source compilation failures.

Useful inspection tools include nm, objdump, readelf, and ldd on relevant Unix-like platforms; otool and nm on macOS; and dumpbin on Windows. Each exposes some combination of symbols, formats, architectures, and dynamic dependencies.

Debug and release are collections of choices

There is no language-defined “Debug mode.” Build configurations collect flags and policies. A learning-friendly debug command might use:

sh
cc -std=c17 -Wall -Wextra -Wpedantic -g -O0 hello.c -o hello

A release configuration might use optimization and still retain some debug information:

sh
cc -std=c17 -Wall -Wextra -Wpedantic -O2 -g hello.c -o hello

Exact flags differ in MSVC. Debug information and optimization are independent axes, although optimization makes source-level stepping less intuitive. Assertions, runtime-library selection, sanitizers, link-time optimization, and symbol stripping are additional axes. A build system names configurations; the underlying behavior comes from their settings.

Optional prompts

Debug: main.c compiles, but the final command reports an undefined reference to parse_file. Should you first edit the header search path or inspect linked objects and libraries?

Answer: Inspect the link inputs. Compilation saw enough of a declaration to emit a call. The linker cannot find a matching definition, so header discovery is no longer the primary failure.

Explain: Why does editing a header usually require recompiling every source file that includes it?

Answer: Inclusion contributes text to each source file's translation unit. Each resulting object was compiled against that header content, so potentially affected translation units must be rebuilt.

Predict: Does an executable linked against a static archive need that .a file at startup?

Answer: Normally no. Needed members were copied into the linked output. Shared libraries remain load-time dependencies; static archives are link-time inputs.

Further reference