CMake Best Practices

KeepTruckin’s Embedded Software Implementation of CMake

Derek Palmerton
motive-eng
6 min readApr 8, 2020

--

Often in C development, the build system is an afterthought, and the result is a messy, incoherent system that has been patched together as needed. These systems are typically difficult to change or append, due to the scale of the problem. Once a project gets off the ground and starts adding code to a repository, it can be extremely time-consuming and cost-prohibitive to change the infrastructure, because every developer now relies on it to do their daily tasks.

Enter the notion that when we create these projects, we should create them with the build system not only in mind, but as a central factor in how we design our repository infrastructure. When implemented correctly, CMake can be easy to use, promote better practices in system design, and make new infrastructure easy to bolt on.

The idea behind KeepTruckin’s embedded software implementation of CMake is to keep modules as small and independent as possible. To this end, we place modules into their own folders and only link other modules and libraries that are absolutely necessary. This promotes development in a way that is both platform-independent (portable) and easily extensible.

Creating a New Module

When creating a new module, the developer should first create the file structure. The following is our preferred file structure:

In the root directory of the module, we create a file called CMakeLists.txt. This file defines the build, link, and test procedures for this module.

There are three types of modules: STATIC, SHARED, and INTERFACE. STATIC and SHARED libraries are traditional in that they contain both source files that get compiled and header files that are either private or public; but INTERFACE libraries simply provide a number of header files that define an interface.

The INTERFACE type can be very useful when defining a generic class of modules in order to provide a layer of abstraction from the minutiae of a sensor interface. From experience, we prefer using statically compiled libraries, especially when building a system with circular dependencies, because that is the only way a project with this characteristic will compile.

Code Block 1: INTERFACE Library

Code Block 2: SHARED library

Including Directories

When developing a library or module, you may find it advantageous to expose some header files as an interface, while keeping some private for organization sake. Typically, putting public interface files in the inc folder and private headers in src is a good guideline.

There are other ways to do this, such as having multiple subdirectories in the inc folder and defining include directories as PUBLIC or PRIVATE, but I find that this quickly gets messy.

To set up include directories for the module, use target_include_directories, as opposed to any other target-generic version of this function. Explicitly adding include directories for a target reduces confusion and allows other modules that link the library to automatically have access to the PUBLIC include directories given to this function. As an aside, interface modules can only include directories as INTERFACE permissions level.

Code Block 3: INTERFACE Library Include Directories

Code Block 4: SHARED Library Include Directories

Linking Libraries

Linking libraries is much like including directories in CMake, and carries many of the same concepts. Using target_link_libraries, a module can include any library’s public interface that is included in the same top level target (usually an executable).

PRIVATE or PUBLIC?

As a rule, if a library uses another module in its public interface, it should link that module as PUBLIC; otherwise, it should link it as PRIVATE. This ensures that all compilation dependencies are met, while providing interfaces only on a “need-to-know” basis.

Code Block 3: Linking libraries

Installing Targets

Sometimes a developer wants to create a library and use it elsewhere, outside of its project structure. If this is the case, it is best to “install” the library to a specified deployment location. For each CMake build, an environment variable is defined to tell the scripts where this deployment directory is (currently this variable is called DESTDIR; this may change in the future). This can be hard-coded in some systems, but I’ve found it useful to pass in the destination from an environment variable that can be used elsewhere in the build system.

Code Block 4: Installing Targets

Often when developing a module or library, the developer wants to link to it from outside the repository. The requirements to do this include the above installation, as well as an “installation” of the public header files. The following is an example of how to do so using LBB_COMMON_INCLUDES_PATH, which is defined as an environment variable.

Code Block 5: Installing Targets for External Use

Creating Tests

Most of the time, when creating a new module, a developer wants or is required to add unit tests around their module. At KeepTruckin, we define an environment variable called TEST and set it to ‘1’ when a test is being executed in order to conditionally compile these test binaries. The developer must create an executable using the add_executable macro with a new target name (usually the module name with ‘_test’ appended for clarity). In order to compile some tests, libraries must be linked, much like for the module itself, and then add_test is used to notify CMake that this binary is for unit testing.

Code Block 6: Test Example

Complete Example

Code Block 7: Full SHARED Library Example

Code Block 8: Full INTERFACE Library Example

Additional Considerations

Linking to External Libraries

Assuming an external library has followed the steps for installing its artifacts, the consumer of the library knows where the artifacts exist. When linking from an external library, the developer must tell CMake where the artifacts exist, using find_library for the library files (*.a, *.so) and include_directories for the header files.

Code Block 9: Retrieving External Library References

This code stores the location of the libraries in LbbJournal and UbloxGpsIntf, which are cached variables that can be used by CMake during the build process, and provide CMake with the include directories for these libraries’ header files. The following is an example of how to use these cached variables to link the external libraries into a module.

Code Block 10: Linking External Library

Creating an Executable

The add_executable function is used to define an executable target. It works much like add_library.

Code Block 11: Creating an Executable

Intermediate CMakeLists.txt Files

To define the structure of the build repository intermediate files, use add_subdirectory to point to more CMakeLists.txt files.

Code Block 12: Intermediate Subdirectory Inclusion

ToolChain Files

To provide cross-compilation support, CMake supports using toolchain files that are passed in on the command line at configuration time. My preference is to define the compiler, global compile options (used for compile, link, and assembler), extra compiler flags, extra linker flags (such as using a linker file), and extra assembler flags in this file.

Code Block 13: Toolchain File Example

References

--

--

Derek Palmerton
motive-eng

Working towards building better systems in embedded software.