Two separate questions, easy to confuse
By now you can build a program from your own files and you know, from guide 3, the difference between a static library you fold into your executable and a dynamic library you borrow at run time. What we have not done yet is the plumbing: actually telling the build use this outside library. That sounds like one task, but it is really two — and keeping them apart is the single biggest thing that makes this topic click instead of frustrate. The two questions are: where does the compiler find the library's header, and where does the linker find the library's compiled code?
These two live in two different phases of the build, which is exactly why they confuse people. Recall the separate compilation picture: first the compiler turns each `.c` into an object file, then the linker joins the object files together. The header matters in the first phase, the library file in the second. A header is a promise; the library is the code that keeps it. Get one right and the other wrong, and you get a build that fails at a surprising place — so let us take them one at a time.
The header: a promise the compiler reads
When you write `#include <zlib.h>` at the top of a file, you are not pasting in the library's actual machine code — you are pasting in its header, which is mostly a list of function prototypes: declarations like `int compress(unsigned char *dest, unsigned long *destLen, const unsigned char *source, unsigned long sourceLen);`. Each prototype tells the compiler the name of a function, what arguments it takes, and what it returns — but contains no body, no implementation. That is the interface without the implementation: enough for the compiler to check that your call is well-typed, and nothing more.
So the header's whole job at compile time is to let the compiler type-check your calls. With the prototype in hand it can confirm you passed the right number and kind of arguments, and it knows what the result type is. Crucially, it does not need the function's body to do this — the preprocessor literally pastes the header text in, the compiler reads the declarations, and the actual code stays elsewhere until link time. This is why a missing header gives a compile-phase error like `implicit declaration of function 'compress'` or `unknown type name`: the compiler simply never met the promise, so it cannot tell whether your call makes sense.
But the compiler can only paste a header it can find. Headers live in files on disk, and the compiler searches a fixed list of directories — its include path — for the name inside the angle brackets. The system directory `/usr/include` is on that list by default, which is why `#include <stdio.h>` just works. A library you installed somewhere unusual is not, and that is where the next idea comes in.
Include paths and library paths: the two -I and -L flags
The compiler's search list for headers and the linker's search list for library files are the two halves of the include path and library path, and each has its own flag. To add a directory to the header search, you pass `-I` followed by the directory: `gcc -I/opt/zlib/include -c main.c`. That tells the compiler "also look in /opt/zlib/include when you resolve an `#include`." To add a directory to the library search for the linker, you pass `-L`: `gcc main.o -L/opt/zlib/lib -lz -o app`. Same idea, different phase — `-I` for the compiler, `-L` for the linker.
Notice the third flag in that link command: `-lz`. This is how you name which library to link, and it has a small naming convention worth learning once. `-lz` means "link the library named z," and the toolchain expands that to a filename: it looks for `libz.a` (a static library) or `libz.so` (a shared library) in the library search path. So `-lz` finds `libz.so`, `-lm` finds the math library `libm.so`, `-lpthread` finds `libpthread.so`. The `lib` prefix and the `.a`/`.so` suffix are added for you; you supply only the middle. Miss the `-l` entirely and the linker never even looks for the library, no matter how many `-L` paths you gave it.
# compile phase: -I tells the COMPILER where headers live gcc -I/opt/zlib/include -c main.c -> main.o # link phase: -L tells the LINKER where library files live, # -lz picks the library libz.so (or libz.a) gcc main.o -L/opt/zlib/lib -lz -o app # -I<dir> add <dir> to the header search (compiler) # -L<dir> add <dir> to the library search (linker) # -lNAME link libNAME.so / libNAME.a (linker)
The linker: undefined references and the order trap
Now the second phase. After the compiler accepted your call on the strength of the header's promise, it left behind, in the object file, a note that says "I call a function named compress, but I do not have its code — someone please supply it." That unfilled note is an undefined symbol. The linker's job is to match every such note against the actual code that defines it, pulling that code from your other object files and from the libraries you named with `-l`. When a symbol stays unmatched, the link fails with the most famous message in this whole rung: `undefined reference to 'compress'`.
This is why a linker error feels so different from a compile error, and why beginners are baffled the first time one strikes a program that compiled cleanly. The compile step passed because the header satisfied it — the prototype was present, the call was well-typed. The link step failed because the code was missing — you forgot `-lz`, or `-L` pointed at the wrong directory, or you spelled the library name wrong. The same root cause produces both halves of the mantra: a missing header breaks the compile; a missing library breaks the link.
A third path you only meet at run time
There is a twist that catches people who think they are finished once the program links. If you linked against a dynamic library, the linker did not copy `libz.so` into your executable — it only recorded a note saying "at run time, go find libz.so." So the `-L` path you used at link time is not enough; when you actually run the program, a different searcher — the dynamic loader — has to find the library all over again, and it uses its own list of directories, the shared library search path. That path is a third thing, separate from both `-I` and `-L`.
This is the source of the classic mystery: the program builds perfectly, then refuses to start with `error while loading shared libraries: libz.so.1: cannot open shared object file`. Nothing is wrong with your code — the loader simply cannot find the `.so` at startup because it was installed somewhere the system loader does not search. On Linux the loader checks standard directories like `/usr/lib`, plus the colon-separated list in the `LD_LIBRARY_PATH` environment variable, plus the cache built by `ldconfig`. So `-L` at build time and the run-time search path are answering two different questions at two different moments: where to find the library to record the dependency versus where to find the library to load it.
A static library sidesteps this entire third path, and that is its quiet appeal: because its code was copied into your executable at link time, there is no `.so` to hunt for at startup, so the program runs anywhere with no library-path worries. The price, as guide 3 weighed it, is a bigger binary and no shared updates. Whichever you choose, the mental model is the same three-step chain: the compiler finds the header, the linker finds the library file, and — only for dynamic libraries — the loader finds the library file again when you run.
Doing it by hand once, then letting tools do it
Spelling out `-I`, `-L`, and `-l` by hand for one library is instructive, but real libraries often need several flags each, and those flags differ from machine to machine. The standard cure is a helper called pkg-config: a library installs a small metadata file, and `pkg-config --cflags zlib` prints exactly the `-I` flags the compiler needs while `pkg-config --libs zlib` prints exactly the `-L` and `-l` flags the linker needs. You embed those in your build so the right paths are discovered rather than hard-coded — the same instinct that, scaled up, drives the package managers and CMake in the final guide.
When you do hit one of these errors, work the chain in order rather than guessing. Each step maps to one of the three searches, so the message tells you which knob to turn — and turning the right one is far faster than scattering flags and hoping.
- Compiler can't find the header (a 'no such file' on the #include, or 'implicit declaration'): the header's directory is missing from the include path — add it with -I<dir>, or install the library's development package.
- Compiles but won't link ('undefined reference to ...'): you named no library, named the wrong one, or the -L directory is wrong — add -lNAME and the correct -L<dir>, and put the -l after your object files.
- Links but won't start ('error while loading shared libraries'): the dynamic loader can't find the .so at run time — install it in a standard directory, run ldconfig, or set LD_LIBRARY_PATH to the directory holding it.
- Prefer not to guess these flags by hand: let pkg-config emit them — `gcc (pkg-config --cflags zlib) -c main.c` then `gcc main.o (pkg-config --libs zlib) -o app` — and let CMake do the same at project scale.
That is the whole shape of using an outside library. A header is an interface the compiler reads to type-check your calls, found along the include path; the library file is the implementation the linker splices in, found along the library path; and a dynamic library adds one more search, by the loader, when the program runs. Keep the three searches and the three error messages mapped to each other, and a class of bug that feels like dark magic becomes a short, fixable checklist — which is exactly the leverage the last guide's package managers and CMake hand you at full scale.