JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Bigger Builds: CMake and Package Managers

Your Makefile knows where things live on your machine — but not on anyone else's, and not where to find code you didn't write. This last guide meets CMake, the tool that generates build files for any system, and the package managers that fetch and version the libraries your project depends on.

Where the Makefile runs out of road

By now you can write a real Makefile, hand it the dependency graph, and watch it rebuild only what changed. You learned in this rung to link against a static library or a dynamic library, and to point the compiler at the right include and library paths with `-I` and `-L`. That is a genuinely capable setup — for one machine. The trouble starts the moment your project has to build somewhere else.

Think about what your Makefile hard-codes. It says `gcc`, but a colleague on macOS uses `clang`, and a Windows toolchain calls its compiler something else entirely. It says `-I/usr/local/include`, but on another machine the library was installed under `/opt`, or `/usr/include`, or a folder only that person knows. Every one of those paths is a fact about your computer that a Makefile cannot discover on its own. Hand your Makefile to a friend and it breaks not because your logic is wrong, but because their machine is laid out differently. This is the portability problem, and plain Make has no answer to it.

You could try to patch this by hand — write `if` branches in the Makefile for each operating system, probe for where libraries might be installed — but that path leads to thousand-line Makefiles that nobody can read, re-invented separately by every project. The honest fix is to stop writing the Makefile yourself and instead describe your build at a higher level, letting a tool generate the right Makefile (or Visual Studio project, or Ninja file) for whatever machine it runs on. That tool is the subject of the rest of this guide.

CMake: the tool that writes your build files

CMake is a meta-build system: a build system that builds build systems. You do not write a Makefile anymore; you write a CMakeLists.txt that says, abstractly, "there is a program called app, made from these source files, and it needs this library." CMake reads that, inspects the actual machine it is running on — which compiler exists, where the libraries really live — and emits a concrete Makefile (or a Ninja build, or an Xcode/Visual Studio project) tailored to that machine. One description, many possible build files.

Here is the same little two-file program from earlier in this rung, described to CMake. Notice how much shorter and more declarative it is than a Makefile: there are no compile commands, no `-c`, no link line, no object files mentioned at all. You state intent — an executable from these sources — and CMake figures out the compile-and-link steps, including the incremental-rebuild graph, for the platform underneath.

cmake_minimum_required(VERSION 3.16)
project(app C)

add_executable(app main.c util.c)

# find a library named foo and link it; CMake locates its
# include path and library path for us, on any platform
find_package(Foo REQUIRED)
target_link_libraries(app PRIVATE Foo::Foo)
A minimal CMakeLists.txt: declare the program and the library, not the commands.

Building with CMake is two steps, and it is worth seeing them clearly. First the configure step, `cmake -B build`, runs CMake itself: it probes the machine and generates the real build files into a `build` folder, kept separate from your source (an out-of-source build, so generated files never pollute your code). Then the build step, `cmake --build build`, runs whatever underlying tool was generated — often Make — to actually compile and link. The split matters: configure is the expensive platform-detective work that you do once, and build is the fast, repeatable compile you do over and over.

Finding libraries: the part CMake really earns its keep

Recall the manual chore from guide 4: to use a library you had to pass `-I` so the compiler finds its header and `-L -lfoo` so the linker finds its code, with the exact paths typed in by you. The `find_package(Foo REQUIRED)` line above is CMake automating exactly that search. It hunts through the standard locations on this machine, discovers the header's include path and the matching library path, and bundles them into a target you link with one line. You stop hard-coding `/usr/local/include` and let the tool locate it per machine.

Because the library story from earlier in this rung is baked into CMake's model, a single keyword decides static versus dynamic. Asking for a static library copies the library's code straight into your executable at link time — bigger file, but self-contained. Asking for a dynamic library leaves a reference that the loader resolves at run time from a separate .so or .dll, which is why two programs can then share one copy in memory. CMake expresses this as a target property rather than a pile of flags, so switching between them is a one-word change instead of a Makefile rewrite.

But be honest about where the magic stops. `find_package(Foo REQUIRED)` only succeeds if library Foo is already installed on this machine in a place CMake knows how to look, or ships a CMake helper file describing itself. If Foo isn't there at all, CMake stops with a clear error — `REQUIRED` means "fail loudly now" rather than break mysteriously at link time. CMake locates dependencies; it does not, by itself, fetch them. That missing piece — getting the library onto the machine in the first place — is the job of a different tool.

Package managers: fetching the code you didn't write

Almost no real program is written entirely from scratch; it leans on libraries other people wrote — a JSON parser, a compression routine, an HTTP client. Each of those is an external dependency, code your project needs but does not contain. A package manager is the tool that fetches those dependencies, installs them where the compiler and CMake can find them, and — crucially — records exactly which version you used. On Linux you have seen system package managers like `apt` or `dnf`; for C and C++ projects, tools such as vcpkg and Conan do the same job scoped to your project.

The reason recording the version matters is the same reason this whole rung exists: builds must be reproducible. If your project says "use compression library version 1.3.1" and a teammate's machine quietly has 1.2.0, you can get different behavior, or a build that links but then misbehaves at run time. This is where library versioning and ABI compatibility becomes concrete. A library's ABI — the exact binary layout of its functions and structs — can change between versions, and if your program was built against one layout but loads a .so with a different one at run time, you get crashes that no recompile of your code can explain. Pinning versions in the package manager is how you keep that from happening.

Put together, the modern flow looks like this: a package manager fetches and pins your dependencies, CMake locates them and describes your build abstractly, and CMake then generates the platform-specific Makefile or Ninja file that the compiler and linker actually run. Each tool owns one honest job, and they hand off cleanly. None of this is magic, and none of it removes your responsibility to understand the layers below — but it is the difference between a build that works only on your laptop today and one a stranger can clone and build, correctly, on a different machine next year.

Stepping back: what this rung gave you

Look at the arc of these five guides. You started by feeling the pain of typing compile commands by hand and seeing why you need a build system at all. You met Make and wrote a Makefile that derives build order from a dependency graph and rebuilds only what changed. You learned that libraries come in two flavors, static and dynamic, with real tradeoffs in file size, memory sharing, and when the code is bound in. You practiced pointing the compiler and linker at the right include and library paths. And now you have climbed one level higher, to the tools that make all of this portable and reproducible across machines.

The honest takeaway is that none of these tools is the point — the point is the dependency graph and the discipline of reproducible builds. Make, CMake, and a package manager are three layers of automation over one stubborn truth: a real program is many pieces of code, written by many people, that must be compiled and linked together in the right order, with the right versions, on a machine you may never see. Understanding that truth is what makes you able to pick up any of these tools — or the next one that replaces them — without being lost.