Harnessing the Power of CMake for Modern Software Development
Preface
It is needless to say good software handles change smoothly and makes change easy. Speaking of change, there are various reasons you may need to update your codebase: a customer requests an unexpected feature, an outdated dependency requires replacement, or you want to support new platforms like macOS or the increasingly popular Windows on ARM.
Keeping your code usable on different platforms can also create new business opportunities. Customers choose operating systems based on their needs, so if you’re offering them a new tool, it’s best to ensure it works on the platforms they already use.
As your project evolves, it grows larger and more complex. You can manage this complexity by breaking the project into smaller, manageable parts. At the core of this process is your build system. A good build system not only supports growth but also encourages best software development practices.
Build infrastructures are usually set up once at the beginning of a project. Changing build parameters requires careful consideration, and not all team members may be familiar with the details of the design.
The situation becomes even more challenging if the build system was created by someone who has since left the company. They may have selected compiler options that later prove incompatible with your changes, but you won’t know how adjusting those options might impact different parts of the code. To make matters worse, those options might be hidden deep within the build system’s user interface, forgotten by everyone.
In this article, I’ll discuss how CMake helps address these challenges and why you should consider it for your next C/C++ project. I’ll also share some best practices for simplifying your workflow and creating an effective build system.
Benefiting from CMake
The first benefit of using CMake is that, unlike XML-based build systems, which are not designed for human readability or editing, CMake is entirely text-based. Nothing is concealed—every aspect of the build system is visible and clear. This transparency allows the build system to be treated like source code, enabling team members to review its history and easily understand each change.
Another benefit of using CMake is its broad platform support. Whether you’re working on an embedded system with just Vim, or developing on Windows with tools like Visual Studio or QT Creator, all of these environments support CMake. All you need to get started is CMake and a text editor installed.
Modern CMake is designed around targets and their transient property system. This allows you to define dependencies, build them in the correct order, and even install them or create an installer—all automatically. By leveraging this system, you can manage complex builds with ease.
Let’s explore how we can benefit from CMake in various aspects of development:
-
1. Targets and Their Dependencies
CMake’s target-based approach allows you to define logical units of your build, such as executables, libraries, or tests. Each target can specify its dependencies, and CMake ensures they are built in the correct order.
CMake can generate dependency graphs using Graphviz. Below is an example of a dependency graph for a CMake template project. This template includes one executable linked to a static library, as well as Google Test and benchmarking support.
git checkout main mkdir tmp cd tmp/ cmake -S .. --preset linux-default-debug --graphviz=dependencies.dot dot -Tpng -o dependencies.png dependencies.dotThe following is a sample dependency graph, colored for better readability:
Targets in CMake can have their own properties, such as include directories. When you link one target to another, all of those properties are automatically passed to the dependent target. This automatic handling of dependencies simplifies the process of installation and packaging.
-
2. Automated Testing
CMake includes a built-in testing tool called CTest, which offers excellent support for testing frameworks like Google Test and Boost.Test. By integrating tests directly into your CMake scripts, you can automate test execution after each build. Adding automated testing support to your CMake project requires only a few lines of code, as demonstrated in the Google Test documentation.
-
3. Simplified Installation
With CMake, you can define installation rules for your targets and their dependencies. This makes it easy to create an installation package that installs your binaries, libraries, and headers to the correct locations across different platforms, whether it's
/usr/localon Linux orC:\Program Fileson Windows.With well-defined targets, installation can be accomplished with just a few lines of code. You simply specify your targets in the
installexpression:install( TARGETS precompiled libsee_obj libsee_static libsee_shared PUBLIC_HEADER DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/libsee ) if(UNIX) install(CODE "execute_process(COMMAND ldconfig)") endif()In this example, the specified targets are installed, and public headers are placed in the designated include directory. If the system is Unix-based, the
ldconfigcommand is executed to update the shared library cache. -
4. Automated Packaging
CMake supports creating platform-specific packages with tools like
CPack. You can automate the creation of installers for your project, be it DEB or RPM packages for Linux, MSI for Windows, or DMG for macOS.For example, on Windows, you can define the following in your CMake presets, and CPack will automatically create an installer for your project:
"packagePresets": [ { "name": "windows-nsis", "hidden": true, "generators": ["NSIS"], "configurations": ["Release"], "vendorName": "Mohammad Rahimi", "packageDirectory": "package-windows-nsis", "environment": { "CPACK_NSIS_DISPLAY_NAME": "SeeMake", "CPACK_NSIS_PACKAGE_NAME": "SeeMake", "CPACK_NSIS_URL_INFO_ABOUT": "https://github.com/MhmRhm" } } ]In this configuration, CPack will use the NSIS generator to create an installer for the project in the specified package directory.
-
5. Static and Dynamic Checks
CMake helps integrate static analysis tools like
clang-tidyorcppcheckwith just one cmake expression:set_target_properties(target-name PROPERTIES CXX_CPPCHECK "${CPPCHECK_PATH};--enable=warning;--error-exitcode=10" )Here, the
cppcheckcommand is added as a property to a target and will run alongside the compiler.Dynamic checks can be more complex and depend on the platform you’re using. For example, on Linux, you can use Valgrind, while on Windows, you have the
/fsanitizeoption. Keeping your code cross-platform allows you to utilize different tools available on each system, enabling you to identify and fix weaknesses in your code.On Windows, you can enable dynamic checks for targets by adding specific compile-time definitions and compiler flags. It's a good idea to put this logic in a CMake function, which you can call for each target that requires dynamic checks. However, it’s generally not advisable to enable dynamic checks for your main application due to potential performance drops; instead, you can add them to your tests.
function(AddDynamicCheck target) target_compile_definitions(${target} PRIVATE _DISABLE_VECTOR_ANNOTATION PRIVATE _DISABLE_STRING_ANNOTATION ) target_compile_options(${target} PRIVATE /fsanitize=address /Zi ) endfunction() -
6. Coverage Reports
You can generate code coverage reports easily with CMake by integrating tools like
gcovandlcov. By doing this, you ensure that your test suite properly covers your code, providing insights into untested areas.You can add targets in CMake to run code coverage tools, so whenever you build these targets, CMake will automatically generate coverage reports for you. For example, on Windows using LLVM tools, you can create a CMake function like this to generate coverage reports:
function(AddCoverage target) find_program(LLVM_COV_PATH llvm-cov REQUIRED) find_program(LLVM_PROFDATA_PATH llvm-profdata REQUIRED) add_custom_target(coverage-${target} COMMAND $<TARGET_FILE:${target}> COMMAND del coverage /S /Q COMMAND ${LLVM_PROFDATA_PATH} merge -sparse default.profraw -o default.profdata COMMAND ${LLVM_COV_PATH} show $<TARGET_FILE:${target}> -instr-profile=default.profdata -show-line-counts-or-regions -use-color -show-instantiation-summary -show-branches=count -format=html -output-dir=coverage-${target} COMMAND ${LLVM_COV_PATH} report $<TARGET_FILE:${target}> -instr-profile=default.profdata -show-region-summary=false -show-branch-summary=false WORKING_DIRECTORY ${CMAKE_BINARY_DIR} ) endfunction()In this function,
AddCoverage, we usefind_programto locatellvm-covandllvm-profdata. Then, we create a custom target that runs the coverage commands. It will remove any existing coverage reports, merge profile data, and generate both HTML reports and text summaries.This setup ensures that whenever you build the
coverage-${target}, you’ll receive updated coverage reports, helping you analyze how well your tests cover your code.cmake --build --preset windows-clang-debug --target coverage-google_test_libsee cd ../SeeMake-build-windows-clang-debug/coverage-google_test_libsee/ python3 -m http.server 8172 # Go to localhost:8172 to view the test coverage report
-
7. Documentation
One way to automatically generate documentation is by using Doxygen. You can invoke the Doxygen executable on your source files through CMake’s convenience function
doxygen_add_docs:set(DOXYGEN_GENERATE_HTML NO) set(DOXYGEN_GENERATE_MAN YES) doxygen_add_docs( doxygen ${PROJECT_SOURCE_DIR} COMMENT "Generate man pages" )This setup adds a new target, and when you build this target, it will generate the documentation.
-
8. Presets and Workflows
CMake supports presets that allow you to define common configurations and workflows. This is particularly useful for setting up different build types (Debug, Release), platform-specific configurations, or integrating with continuous integration (CI) systems. These presets help standardize the build process across teams, reducing setup complexity and increasing productivity.
You can define different presets for configuration, building, testing, and packaging in your workflow. By combining these presets, you can create workflows that execute all these steps with a single command.
Here's an example of such a workflow:
"workflowPresets": [ { "name": "windows-default-release", "displayName": "Windows Release", "steps": [ { "type": "configure", "name": "windows-default-release" }, { "type": "build", "name": "windows-default-release" }, { "type": "test", "name": "windows-test-release" }, { "type": "package", "name": "windows-default-nsis" } ] } ]In this example, the
windows-default-releaseworkflow includes steps for configuration, building, testing, and packaging. Each step references a preset that defines how it should be executed. With this setup, you can run the entire workflow with a single command, streamlining your development process.cmake --workflow --preset linux-default-release
Conclusion
CMake is a cross-platform tool, and by using it in your projects, you reduce your dependency on other tools and platforms. This allows you to test your code in different environments and stay vigilant to changes, ensuring better code portability and adaptability across various systems. Using tools like dynamic checks, code coverage, and automatic documentation in CMake improves code quality, testing, and maintainability. CMake’s flexibility allows you to manage these tasks easily with custom functions and targets. With presets and workflows, you can streamline builds and testing, making your development faster and more reliable.