Back to all articles
Mastering Complexity: Libraries, Dependencies and Cross-Compilation with `cmake`

Mastering Complexity: Libraries, Dependencies and Cross-Compilation with `cmake`

A practical guide on how to use Modern CMake to manage projects with multiple libraries, find external dependencies and configure cross-compilation in a...

Human-architected research synthesized with the assistance of AI personas.
7 min read

✨TL;DR / Executive Summary

A practical guide on how to use Modern CMake to manage projects with multiple libraries, find external dependencies and configure cross-compilation in a...

By Athena, Senior Software Engineer

πŸ’‘ TL;DR (Too Long; Didn't Read)

This article advances from "Hello, World" to a realistic C project with cmake, demonstrating how to manage multiple directories, create and link libraries, and find external dependencies (like zlib). The key is the "Modern CMake" philosophy focused on targets. Instead of manually manipulating compiler flags, you define targets (executables or libraries) and attach properties to them (e.g., include directories) with target_* commands. find_package locates external dependencies portably. Finally, we show how cmake elegantly solves cross-compilation through a toolchain file, allowing you to compile for different architectures (like ARM) without changing a single line of your main CMakeLists.txt.


In our last conversation, we established a fundamental truth: cmake invites us to describe what our project is, instead of obsessively dictating how it should be built. We left behind make's imperative philosophy to embrace the clarity of a declarative system.

Now, let's move beyond "Hello, World" and enter the real world. The real world is made of components, of interacting libraries, of external dependencies and, for many of us in the embedded and systems universe, of multiple hardware architectures. It's here, in managing this complexity, that cmake doesn't just shineβ€”it becomes indispensable.

Let's model a simple yet common project: a small energy monitoring system. It will have a main executable (app) that uses a library (lib) to read data from a sensor.

Our directory structure will be:

power_monitor/
β”œβ”€β”€ CMakeLists.txt         # The main file
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ CMakeLists.txt     # For the executable
β”‚   └── monitor.c
└── lib/
    β”œβ”€β”€ CMakeLists.txt     # For the library
    β”œβ”€β”€ sensor.c
    └── sensor.h

In the make world, this usually means a complex Makefile at the root or multiple interconnected Makefiles with confusing rules and variables. With cmake, the logic is cleaner. The root CMakeLists.txt orchestrates the project, and each subdirectory describes what it contains.

The root CMakeLists.txt is simple:

cmake
# power_monitor/CMakeLists.txt cmake_minimum_required(VERSION 3.10) project(PowerMonitor C) # Adds subdirectories to the build. # CMake will look for and process the CMakeLists.txt inside them. add_subdirectory(lib) add_subdirectory(app)

Targets: The Currency of "Modern CMake"

This is the most important concept you need to internalize: in "Modern CMake," everything revolves around targets. A target is a logical entityβ€”either an executable or a library. You create a target and then attach properties to it.

Let's define our sensor library.

cmake
# lib/CMakeLists.txt # Creates a library target called "sensor" from its sources. # Can be STATIC (default) or SHARED. add_library(sensor sensor.c)

With a single line, we created a target called sensor. cmake will handle all the rules to compile sensor.c and create the library file (libsensor.a or sensor.so).

Now, let's define our executable.

cmake
# app/CMakeLists.txt # Creates an executable target called "monitor" from its source. add_executable(monitor monitor.c) # Here's the magic: we link the executable to the library target. target_link_libraries(monitor PRIVATE sensor)

The target_link_libraries line is the heart of the matter. See what it does, which in a Makefile would require multiple lines and manual rules:

  1. Ensures Build Order: Knows that the sensor target must be built before the monitor target.
  2. Automates Linking: Automatically adds the correct linker flags, like -L/path/to/lib -lsensor. You never need to write this again.
  3. Passes Properties (We'll See Next): Transfers information, like include directories, from the library to the executable.

Managing Properties: The target_* Family

Our sensor library has a public header, sensor.h. The monitor executable needs to include it. How do we do this without fragile relative paths like -I../lib? By assigning the include directory property to the library target.

cmake
# lib/CMakeLists.txt add_library(sensor sensor.c) # Attaches the include directory to the "sensor" target. target_include_directories(sensor PUBLIC # Variable that points to the current CMakeLists.txt directory ${CMAKE_CURRENT_SOURCE_DIR} )

The PUBLIC keyword is crucial. It means:

  • The include directory is needed to compile the sensor library itself (PRIVATE).
  • The include directory is also needed for anything that links to sensor (INTERFACE).

Since monitor links to sensor via target_link_libraries, cmake automatically propagates this property. monitor now knows where to find sensor.h without its CMakeLists.txt needing any knowledge of the library's directory structure. This is build encapsulation. It's powerful and drastically reduces complexity in large projects.

The End of Treasure Hunting: find_package()

Our projects rarely live on an island. Let's say our monitor needs to use a popular external library, like zlib for data compression.

With make, you'd be searching for where zlib is installed, adding -I/usr/include and -lz to your Makefile, hoping it works on all systems.

With cmake, we ask:

cmake
# app/CMakeLists.txt add_executable(monitor monitor.c) # Find the ZLIB package. If not found, CMake will fail with an error. find_package(ZLIB REQUIRED) # We link to both our internal 'sensor' library and the external 'ZLIB'. target_link_libraries(monitor PRIVATE sensor ZLIB::ZLIB)

The find_package(ZLIB REQUIRED) command searches for configuration files that zlib (and most well-behaved libraries) installs on the system. These files tell cmake where to find headers and libraries. The result is an "imported target" called ZLIB::ZLIB, which we can use to link cleanly and portably. The treasure hunt is over.

The Definitive Solution for Cross-Compilation

Now, the crown jewel for us systems engineers. We need to compile our power_monitor for a Raspberry Pi (arm-linux-gnueabihf architecture).

In the make world, this would be a nightmare of variables and conditionals. With cmake, we describe our toolchain (the cross-compiler toolset) in a separate file.

Create a file called raspberrypi.cmake:

cmake
# raspberrypi.cmake - Toolchain File # The target system set(CMAKE_SYSTEM_NAME Linux) set(CMAKE_SYSTEM_PROCESSOR arm) # The path to your cross-compiler set(TOOLCHAIN_PREFIX /path/to/your/toolchain/bin/arm-linux-gnueabihf) # The compilers set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}-gcc) set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}-g++) # Where to look for libraries and headers of the target system set(CMAKE_FIND_ROOT_PATH /path/to/your/target/sysroot) set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)

Now, to build for Raspberry Pi, the process is as follows. Note that we don't change a single line in our CMakeLists.txt files:

bash
# Create a separate build directory for the ARM target mkdir build-rpi && cd build-rpi # Invoke cmake, telling it to use our toolchain file cmake .. -DCMAKE_TOOLCHAIN_FILE=../raspberrypi.cmake # Build the project cmake --build .

Inside the build-rpi directory, you'll find a monitor executable compiled for ARM. To compile for your x86 host again, just go to the original build directory and run the build. Your project definition remains pure and architecture-agnostic. cmake completely abstracts the toolchain complexity.

Conclusion of Part Two

Today, we moved from theory to solving real-world problems. We built a multi-directory project, created and linked an internal library, managed its properties in an encapsulated way, found and linked an external dependency portably, and finally, compiled the same code for a completely different architecture without changing our build logic.

Each of these tasks represents a fragility and complexity point in a Makefile. With cmake, they become part of a declarative and robust workflow, focused on targets and their relationships.

In the final part of our series, we'll go further. We'll explore the ecosystem that cmake offers to automate the entire software lifecycle: testing with CTest, packaging with CPack, and integration of custom tools, solidifying its role as the backbone of any modern systems engineering project.


Receive new articles

Subscribe to receive notifications about new articles directly to your email

We won't send spam. You can unsubscribe at any time.