C Compilation and Linking: From Source Code to a Running Program
Understand the distinct jobs of the compiler and the linker, how object files, static and dynamic libraries, and symbols fit together to produce an executable, and how to tell apart a compile-time syntax error from a link-time undefined-symbol error.
Turning C source code into a program you can actually run is a two-stage process, and beginners frequently blur the two stages together — which makes their error messages confusing. The compiler's job is to turn each source file into machine code, one file at a time, with no knowledge of any other file. The linker's job is to take all those separately compiled pieces and stitch them into one final program, resolving every reference to a function or variable defined somewhere else. This split is exactly why C (and Objective-C) programs can be built from many files, reuse precompiled libraries, and why 'undefined symbol' errors look and feel completely different from ordinary syntax errors — a distinction that will save you real debugging time once Objective-C, frameworks, and Xcode's build system enter the picture.
The Compiler's Job: Source to Object Code
The compiler (after the preprocessor has already expanded #include and #define, from earlier lessons) takes one translation unit — one fully preprocessed .c file — and translates it into machine code, checking C's syntax and (mostly) its type rules along the way. It works on a single file at a time and has no visibility into other .c files in the project; if that file calls a function it has only seen a declaration for (via a header), the compiler trusts the declaration's shape, generates a call instruction referencing that function by name, and moves on — it does not need to know where that function is actually defined, only what its signature looks like.
The compiler's output is an object file, typically named with a .o extension (file.c compiles to file.o). An object file contains machine code for everything that file defined, plus a table of symbols: names it defines (that other files might reference) and names it needs but doesn't define itself (that it expects to find elsewhere). An object file on its own is not runnable — it's an incomplete puzzle piece, machine code with holes where calls to externally-defined functions will eventually need real addresses filled in.
• Compiler input: one preprocessed .c/.m translation unit at a time. • Compiler checks syntax and types, and translates that file's code into machine instructions. • Compiler output: an object file (.o) — machine code plus a symbol table, not yet runnable. • The compiler trusts a function's declared signature; it does not need to see (or care) where it's actually implemented.
The Linker's Job: Resolving Symbols Into a Program
Once every .c file in a project has been compiled into its own .o object file, the linker takes over. Its job is to combine all those object files (plus any libraries) into a single executable, and — critically — to resolve every symbol reference: for each name one object file needs but doesn't define (like main.o needing add, which it only declared via a header), the linker searches every other object file and library it's been given until it finds exactly one file that actually defines that name, and patches the machine code's placeholder address to point at the real one. This is called symbol resolution, and it's a completely separate, later step from anything the compiler did.
A symbol is exported (visible to the linker, so other files can reference it) unless it's explicitly hidden — in C, marking a function or file-scope variable static makes it internal to that translation unit only, invisible to the linker entirely, which is how you keep implementation helpers truly private (recall the public/private header discussion from the previous lesson). Everything not marked static is, by default, an externally visible symbol the linker can match up across files.
If the linker cannot find a definition anywhere for a symbol some object file needs, it fails with an error like 'undefined symbol: _add' or 'Undefined symbols for architecture x86_64'. If it finds the same symbol defined in more than one place (two files both defining a non-static function with the same name), it fails with a 'duplicate symbol' error instead. Both are link-time errors — they happen after every individual file has already compiled successfully.
• Linker input: multiple object files (.o) plus any libraries, from an entire project's compiled pieces. • Linker job: symbol resolution — match every needed symbol to exactly one real definition, and patch addresses. • static hides a function/variable from the linker entirely (internal to its own file). • 'Undefined symbol' = no definition found anywhere; 'duplicate symbol' = defined more than once.
Static Libraries: Bundled Object Files
Rather than distributing (or recompiling) dozens of loose .o files, related object files are commonly bundled into a static library — on Linux/macOS these use the .a extension (an 'archive'). A static library is essentially a container of object files plus an index. When the linker builds an executable and needs a symbol, it can pull the one specific object file containing that symbol out of the .a archive and link it in, the same as if you had compiled and linked that .o file directly. Because the needed code is copied into the final executable at link time, a program linked against a static library has no runtime dependency on that library file being present later — but it does make the executable larger, and if the library is updated, every program using it must be relinked to pick up the change.
• Static library (.a on Linux/macOS): an archive bundling many object files. • Code from a static library is copied into the final executable at link time. • No runtime dependency on the library afterward, but larger binaries and no automatic updates.
Dynamic Libraries: Shared at Runtime
A dynamic (or shared) library — .so on Linux, .dylib on macOS — works differently: instead of copying the library's code into your executable at link time, the linker just records that your program depends on that library and which symbols it needs from it. The actual code is loaded into memory and linked in at runtime, when the program starts (or sometimes later, if loaded on demand), by a component called the dynamic linker/loader. Multiple running programs on the same machine can share a single copy of a dynamic library's code in memory, which saves disk space and RAM compared to every program carrying its own static copy, and a dynamic library can be updated (e.g. a security patch) without recompiling every program that depends on it — as long as its public interface doesn't change.
The tradeoff is a runtime dependency: if a dynamic library your program needs is missing, moved, or an incompatible version at launch time, the program fails to start with a runtime linking error, even though it compiled and statically linked perfectly fine earlier. This is a different failure mode from a link-time 'undefined symbol' error — the symbol was found and resolved successfully when you built the program; the problem only surfaces later, when the dynamic loader can't locate the library file itself at launch.
• Dynamic/shared library (.so on Linux, .dylib on macOS): code loaded and linked at program startup, not copied in. • Multiple programs can share one in-memory copy; the library can be updated independently of programs using it. • Risk: a missing/incompatible dynamic library at launch time causes a runtime error, distinct from a build-time linker error.
Frameworks: A Step Beyond a Plain Dynamic Library
Apple's platforms package much of their functionality as frameworks rather than bare dynamic libraries. A framework is a bundle (a structured directory that looks like a single file in Finder) that packages a dynamic library together with its public headers, resources (images, localized strings, nib/storyboard files), and versioning metadata, all in one unit. Where a raw .dylib is just compiled code, a framework is a self-contained, distributable package: #import <Foundation/Foundation.h> in Objective-C reaches into exactly this kind of bundle — the Foundation framework's headers describe its public interface (recall the header/source split from the previous lesson, at the scale of an entire framework), and the framework's dynamic library provides the compiled implementation, loaded at runtime the same way any other dynamic library is. This lesson only introduces frameworks as the natural extension of what a dynamic library is; a dedicated later lesson on Modules and Frameworks covers how Objective-C code actually consumes them in depth.
• A framework bundles a dynamic library with its headers, resources, and metadata into one distributable unit. • Apple frameworks (like Foundation) are consumed via #import of their public headers, same preprocessor mechanism as plain C. • Frameworks build directly on the compiler/linker/dynamic-library concepts from this lesson — covered fully in the later Modules and Frameworks lesson.
Reading the Error: Syntax Error vs. Undefined Symbol
Beginners very commonly conflate two error categories that come from completely different stages of the build and mean completely different things. A compile-time (syntax) error happens while a single file is being compiled — before the linker ever runs — and means the compiler could not even understand what you wrote: a missing semicolon, an unmatched brace, calling a function with the wrong number of arguments, using an undeclared identifier the compiler has never seen a prototype for. These errors reference a specific file and line number, and nothing gets compiled into an object file at all until they're fixed.
A link-time (undefined symbol) error happens after every file involved has already compiled successfully into object code — it means the compiler was fully satisfied (it saw a declaration and trusted it), but when the linker later went looking for where that function or variable is actually defined, across every object file and library it was given, it found nothing. The telltale sign is the error's wording: 'undefined symbol', 'undefined reference to', or 'Undefined symbols for architecture...', usually naming a mangled or underscore-prefixed symbol name rather than a source line — because the linker doesn't work with lines of source code at all, only with compiled object files and symbol tables. A syntax error means 'what you wrote doesn't parse as valid C'; an undefined-symbol error means 'what you wrote parses fine and compiles fine, but nobody in the whole build actually provides the thing you're calling' — commonly caused by forgetting to add a new .c file to the build, forgetting to link a needed library, or a typo in a function name that only the header's declaration (not a definition) exists for.
• Syntax/compile-time error: happens per-file, before linking; the compiler couldn't parse or type-check your code. • Undefined-symbol/link-time error: happens after all files compiled fine; the linker found no definition for a needed name. • Compile errors cite a source file and line; link errors cite a missing symbol name, not a source line. • A typo'd function name with only a header declaration (no matching definition anywhere) is a classic cause of undefined-symbol errors.
Code Examples
The -c flag tells clang to compile only, stopping before linking, producing an object file for each source file independently. The third command is a link-only step: it takes both object files and resolves main.o's reference to add() against the definition it finds in math_utils.o, producing the runnable app.
clang main.o math_utils.o -o app The first error is a compile-time syntax/semantic error: it names main.c and a specific line, and occurs while main.c alone is being compiled, before any linking happens. The second error occurs only at the link step, after both files compiled successfully — the linker searched every object file it was given and found no definition anywhere for a function named subtract, likely because it was declared in a header but never actually implemented in any.c file, or the.o containing it was left out of the link command. Notice the second error names a symbol (_subtract) and not a source line — the linker has no concept of source lines, only compiled object files and their symbol tables.
ar bundles one or more object files into a static archive (.a). Linking main.c against libmathutils.a lets the linker pull the needed object code for add()/multiply() straight out of the archive, copying it into the final app executable — app has no runtime dependency on libmathutils.a afterward.
ar rcs libmathutils.a math_utils.o
$ clang -c main.c -o main.o
$ clang -c math_utils.c -o math_utils.o
$ clang main.o math_utils.o -o app
$ ./app
5$ clang -c main.c -o main.o
main.c:5:12: error: use of undeclared identifier 'total'
printf("%d", total)
^
1 error generated.
$ # -- after fixing the syntax error and recompiling --
$ clang main.o math_utils.o -o app
Undefined symbols for architecture x86_64:
"_subtract", referenced from:
_main in main.o
ld: symbol(s) not found for architecture x86_64$ clang -c math_utils.c -o math_utils.o
$ ar rcs libmathutils.a math_utils.o
$ clang main.c libmathutils.a -o app
$ ./appFrom Source Files to a Running Program
The compiler turns each source file into an object file independently; the linker combines object files and libraries, resolving every symbol reference into one runnable program.
main.c ──compile──▶ main.o ─┐
math_utils.c ─compile▶ math_utils.o ─┤── LINK ──▶ executable ──▶ run
│ (resolves symbols,
libSomeLib.a (static) ───────────┤ may attach .dylib/.so
libSomeLib.dylib (dynamic, linked at runtime) ─┘ at runtime)Important Rules
The compiler works one file at a time and only needs declarations to compile a call; the linker works across all files and needs exactly one definition per symbol.
A syntax error happens per-file at compile time; an undefined-symbol error happens across the whole build at link time, after everything compiled.
mark file-local helper functions and variables static to hide them from the linker and avoid symbol clashes.
Static libraries (.a) copy their code into your executable at link time; dynamic libraries (.so/.dylib) are loaded and linked at runtime.
A framework bundles a dynamic library with its headers and resources into one distributable unit — the model Objective-C frameworks like Foundation use.
Build Systems Hide, But Don't Remove, This Split
When you build an Xcode project, the same compile-then-link pipeline runs under the hood for every Objective-C file: each .m file is compiled to an object file, and the linker combines all of them together with any static or dynamic libraries and frameworks (like Foundation.framework or UIKit.framework) the project depends on. A red 'Undefined symbols for architecture...' error in Xcode's build log is exactly the link-time error covered in this lesson — most often caused by a source file not being added to the target's 'Compile Sources' build phase, or a framework not being linked, even though every individual file's Objective-C syntax is perfectly correct.
$ clang main.c -o app // forgot to compile/link math_utils.c at all Undefined symbols for architecture x86_64: "_add", referenced from: _main in main.o
$ clang main.c math_utils.c -o app $./app main.c only had a declaration of add() (via the header), not its definition — the definition lives in math_utils.c. Forgetting to compile and link that file in leaves the linker unable to find add() anywhere, producing an undefined-symbol error even though main.c itself compiled without complaint.
// Treating an undefined-symbol error like a typo in the calling code // and re-reading main.c's syntax over and over looking for a mistake
// Recognize it as a link-time error: check that (1) the function is actually // defined somewhere, (2) that .c file was compiled, and (3) its .o was // included in the final link command
Because an undefined-symbol error only appears after every file compiled successfully, the bug is virtually never a syntax mistake in the calling file — it's almost always a missing definition, a missing file in the build/link command, or a name mismatch between the declaration and the actual definition.
What does the compiler need in order to successfully compile a call to a function defined in a different .c file? A. The full definition (body) of that function, in the same file B. Only a declaration (prototype) of that function, typically seen via a #included header C. Nothing — the compiler automatically finds function bodies anywhere in the project D. The compiled .o file of the function must already exist first
B. Only a declaration (prototype) of that function, typically seen via a #included header — The compiler only needs to know a function's shape (name, parameter types, return type) to check and compile a call to it — that's exactly what a declaration provides. The actual definition only needs to be found later, by the linker.
What is the key difference between a static library (.a) and a dynamic library (.so/.dylib)? A. Static libraries are always slower; dynamic libraries are always faster B. A static library's code is copied into the executable at link time; a dynamic library's code is loaded and linked at runtime C. Dynamic libraries can only be used with Objective-C, static libraries only with C D. There is no functional difference, only naming
B. A static library's code is copied into the executable at link time; a dynamic library's code is loaded and linked at runtime — Static libraries are archives of object code copied directly into the final executable during linking; dynamic libraries are separate files whose code is loaded and linked into the program's address space when it launches (or later), and can be shared across multiple running programs.
A program fails to build with an error reading 'Undefined symbols for architecture x86_64: "_calculateTotal"'. What does this tell you? A. There is a syntax error on the line where calculateTotal is called B. Every file compiled successfully, but the linker could not find a definition for calculateTotal anywhere it searched C. The program compiled and linked fine, but crashed at runtime D. calculateTotal was spelled correctly but is missing a return type
B. Every file compiled successfully, but the linker could not find a definition for calculateTotal anywhere it searched — This exact error format is a link-time, not compile-time, failure: it names a missing symbol rather than a source file and line, meaning every individual file compiled without error but the linker never found a matching definition for that symbol across all the object files and libraries it was given.
What does an Apple framework (e.g. Foundation.framework) bundle together, beyond what a plain dynamic library provides? A. Nothing extra — a framework is just a renamed .dylib file B. A dynamic library plus its public headers, resources, and versioning metadata, all in one distributable bundle C. The framework contains only compiled machine code with no headers at all D. A framework always statically links its code into every program that uses it
B. A dynamic library plus its public headers, resources, and versioning metadata, all in one distributable bundle — A framework packages a dynamic library together with its public headers (the declarations, same idea as the C header/source split), resources such as images and localized strings, and metadata, all as one structured bundle — which a dedicated later lesson covers in depth.
Given a project with main.c (which calls a function greet(const char *name) declared in greet.h) and greet.c (which is supposed to define it), write the exact sequence of clang commands to compile each file separately into object files and then link them into an executable named greeter. Then describe what error you would see, and at which stage, if greet.c were accidentally left out of the final link command.
$ clang -c main.c -o main.o $ clang -c greet.c -o greet.o $ clang main.o greet.o -o greeter $ ./greeter # If greet.o were left out of the link command: $ clang main.o -o greeter Undefined symbols for architecture x86_64: "_greet", referenced from: _main in main.o ld: symbol(s) not found for architecture x86_64 # This is a link-time undefined-symbol error, not a compile error -- # main.o compiled fine because it only needed greet's declaration from greet.h.
Compile each .c file with -c first, then link the resulting .o files together in a separate command.