CMake is an open-source, cross-platform build system that helps developers to manage their projects and build them on different platforms. It is widely used in the software development community, especially for C and C++ projects. In this blog post, we will explore how to use CMake effectively to manage your projects and improve your workflow as a software developer.

An Example CMakeLists.txt

First, let’s start with the basics of CMake. CMake uses a simple, human-readable language called CMakeLists.txt to describe the build process of a project. This file contains instructions on how to find and configure dependencies, set compiler flags, and create the final executable or library. Here is an example of how I typically define my CMake from my open-source ZeroMQ-based RPC library.

###############################################################################
# CMakeLists.txt for zRPC library
#  - Creates a CMake target library named 'zRPC'
###############################################################################
cmake_minimum_required(VERSION 3.14 FATAL_ERROR)

# Define the project, including its name, version, and a brief description
project(zRPC
        VERSION "0.0.1"
        DESCRIPTION "0MQ-based RPC client/server library with MessagePack support"
       )

# Define CMake options to control what targets are generated and made available to build
option(ZRPC_BUILD_TESTS "Enable build of unit test applications" ON)

# Setup default compiler flags
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(compile_options -pedantic-errors
                    -pedantic
                    -Wall
                    -Wextra
                    -Wconversion
                    -Wsign-conversion
                    -Wno-psabi
                    -Werror
    CACHE INTERNAL "Compiler Options"
   )

###############################################################################
# Bring in CPM
###############################################################################
include(cmake/CPM.cmake)

###############################################################################
# Bring in CPPZMQ header-only API
###############################################################################
CPMAddPackage(
  NAME cppzmq
  VERSION 4.8.1
  GITHUB_REPOSITORY "zeromq/cppzmq"
  OPTIONS "CPPZMQ_BUILD_TESTS OFF"
)

###############################################################################
# Bring in MSGPACK-C header-only API
###############################################################################
CPMAddPackage(
  NAME msgpack
  GIT_TAG cpp-4.1.1
  GITHUB_REPOSITORY "msgpack/msgpack-c"
  OPTIONS "MSGPACK_BUILD_DOCS OFF" "MSGPACK_CXX20 ON" "MSGPACK_USE_BOOST OFF"
)

###############################################################################
# Bring in C++ CRC header-only API
###############################################################################
CPMAddPackage(
  NAME CRCpp
  GIT_TAG release-1.1.0.0
  GITHUB_REPOSITORY "d-bahr/CRCpp"
)
if(CRCpp_ADDED)
  add_library(CRCpp INTERFACE)
  target_include_directories(CRCpp SYSTEM INTERFACE ${CRCpp_SOURCE_DIR}/inc)
endif(CRCpp_ADDED)

###############################################################################
# zRPC library
###############################################################################
add_library(${PROJECT_NAME} SHARED)
target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(${PROJECT_NAME} PUBLIC cppzmq msgpackc-cxx CRCpp pthread)
target_sources(${PROJECT_NAME} PRIVATE  src/zRPCClient.cpp
                                        src/zRPCServer.cpp
                                        src/zRPCPublisher.cpp
                                        src/zRPCSubscriber.cpp
                               PUBLIC   include/zRPC.hpp
              )
target_compile_options(${PROJECT_NAME} PUBLIC ${compile_options})


###############################################################################
# Test applications
###############################################################################
if (ZRPC_BUILD_TESTS)
  add_executable(client tests/client.cpp)
  target_link_libraries(client zRPC)
  target_compile_options(client PUBLIC ${compile_options})

  add_executable(server tests/server.cpp)
  target_link_libraries(server zRPC)
  target_compile_options(server PUBLIC ${compile_options})

  add_executable(publisher tests/publisher.cpp)
  target_link_libraries(publisher zRPC)
  target_compile_options(publisher PUBLIC ${compile_options})

  add_executable(subscriber tests/subscriber.cpp)
  target_link_libraries(subscriber zRPC)
  target_compile_options(subscriber PUBLIC ${compile_options})

  include(cmake/CodeCoverage.cmake)
  append_coverage_compiler_flags()
  add_executable(unittest tests/unit.cpp)
  target_link_libraries(unittest zRPC)
  target_compile_options(unittest PUBLIC ${compile_options})

  setup_target_for_coverage_gcovr_xml(NAME ${PROJECT_NAME}_coverage
                                      EXECUTABLE unittest
                                      DEPENDENCIES unittest
                                      BASE_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
                                      EXCLUDE "tests"
				                             )
endif(ZRPC_BUILD_TESTS)
Code language: CMake (cmake)

Once you have your CMakeLists.txt file created, you can use the CMake command-line tool to generate the build files for a specific platform, such as Makefiles or Visual Studio project files. It is considered best practice to keep your build files separated from your source files, so I am in the habit of creating a “_bld” directory for that purpose.

mkdir _bld; cd _bld
cmake ..

CMake Targets

Targets are the basic building blocks of a CMake project. They represent the executable or library that is built as part of the project. Each target has a unique name and is associated with a set of source files, include directories, and libraries that are used to build it.

CMake also supports creating custom targets, which can be used to run arbitrary commands as part of the build process, such as running tests or generating documentation. You can specify properties for the target, like include directories, libraries, or compile options. You can also specify dependencies between the targets, so that when one target is built, it will automatically build any targets it depends on.

This is a really powerful feature that CMake provides because when I define my library target, I define what it needs to build such as the source files, includes, and external libraries. Then, when I define my executable, I only need to specify the library that it depends on — the requisite includes and other libraries that need to be linked in come automatically!

add_library(${PROJECT_NAME} SHARED)
target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(${PROJECT_NAME} PUBLIC cppzmq msgpackc-cxx CRCpp pthread)
target_sources(${PROJECT_NAME} PRIVATE  src/zRPCClient.cpp
                                        src/zRPCServer.cpp
                                        src/zRPCPublisher.cpp
                                        src/zRPCSubscriber.cpp
                               PUBLIC   include/zRPC.hpp
              )
target_compile_options(${PROJECT_NAME} PUBLIC ${compile_options})

# The executable only needs to depend on zRPC now,
# not all the other dependencies and include directories
add_executable(client tests/client.cpp)
target_link_libraries(client zRPC)Code language: PHP (php)

Dependency Management

One of the most important aspects of CMake is its ability to help you find and use dependencies. CMake provides a number of built-in commands that can be used to find and configure dependencies, such as find_package and find_library. These commands can be used to locate and configure external libraries, such as Boost or OpenCV, and make them available to your project. This can save you a lot of time and effort compared to manually configuring dependencies for each platform, which is how it was done with plain Makefiles in the past.

In my example above, I use a tool called CPM, or the CMake Package Manager. This is an abstraction of the find_package and find_library methods available in the CMake language. One huge advantage of this tool is that it can not only be used to find and use local packages, but it can be used to pull packages at a specific version or tag from remote git repositories. You can see how I used this to pull in the cppzmq, msgpack, and CRCpp packages that my library depends on.

###############################################################################
# Bring in CPM
###############################################################################
include(cmake/CPM.cmake)

###############################################################################
# Bring in CPPZMQ header-only API
###############################################################################
CPMAddPackage(
  NAME cppzmq
  VERSION 4.8.1
  GITHUB_REPOSITORY "zeromq/cppzmq"
  OPTIONS "CPPZMQ_BUILD_TESTS OFF"
)

###############################################################################
# Bring in MSGPACK-C header-only API
###############################################################################
CPMAddPackage(
  NAME msgpack
  GIT_TAG cpp-4.1.1
  GITHUB_REPOSITORY "msgpack/msgpack-c"
  OPTIONS "MSGPACK_BUILD_DOCS OFF" "MSGPACK_CXX20 ON" "MSGPACK_USE_BOOST OFF"
)

###############################################################################
# Bring in C++ CRC header-only API
###############################################################################
CPMAddPackage(
  NAME CRCpp
  GIT_TAG release-1.1.0.0
  GITHUB_REPOSITORY "d-bahr/CRCpp"
)
if(CRCpp_ADDED)
  add_library(CRCpp INTERFACE)
  target_include_directories(CRCpp SYSTEM INTERFACE ${CRCpp_SOURCE_DIR}/inc)
endif(CRCpp_ADDED)
Code language: CMake (cmake)

Cross-Platform Build Support

Another powerful feature of CMake is its ability to generate build files for multiple platforms. For example, you can use CMake to generate Makefiles for Linux, Visual Studio project files for Windows, or Xcode project files for macOS. This allows you to easily build and test your project on different platforms, without having to manually configure the build process for each one.

# Basic command to generate Makefiles (Linux/MacOS)
cmake -G "Unix Makefiles" ..

# Basic command to generate Visual Studio build files
cmake -G "Visual Studio 16" -A x64 ..

# More complex command from the VS Code CMake extension performing cross-compilation for ARM
/usr/bin/cmake --no-warn-unused-cli -DCMAKE_EXPORT_COMPILE_COMMANDS:BOOL=TRUE \
    -DCMAKE_BUILD_TYPE:STRING=Release 
    -DCMAKE_C_COMPILER:FILEPATH=/usr/bin/arm-linux-gnueabihf-gcc-10 
    -DCMAKE_CXX_COMPILER:FILEPATH=/usr/bin/arm-linux-gnueabihf-g++-10 
    -DARCH:STRING=armv7 -DENABLE_TESTS:STRING=ON 
    -S/workspaces/zRPC -B/workspaces/zRPC/_bld/ARM/Release 
    -G "Unix Makefiles"Code language: PHP (php)

Best Practices

To improve your workflow with CMake, there are a few best practices that you should follow:

  • Keep your CMakeLists.txt files small and organized. The build process of a project can become complex, so it’s important to keep your CMakeLists.txt files well-organized and easy to understand.
  • Use variables to define common build options, such as compiler flags or library paths. This makes it easy to change these options globally, without having to modify multiple parts of your CMakeLists.txt files.
  • Use include() and add_subdirectory() commands to split your project into smaller, more manageable parts. This makes it easier to understand the build process, and also makes it easy to reuse parts of your project in other projects. I have found that many, small CMake files are easier to manage and maintain than fewer, large CMake files.
  • Use the install() command to specify where the final executable or library should be installed. This makes it easy to distribute your project to other users.
  • Use the add_custom_command() and add_custom_target() commands to add custom build steps to your project. For example, you can use these commands to run a script that generates source code files or to run a test suite after building.

By following these best practices, you can effectively use CMake to manage your projects and improve your workflow as a software developer. CMake is a powerful tool that can save you a lot of time and effort, and by mastering its features, you can build and distribute your projects with ease.

Last modified: January 30, 2023

Author

Comments

Write a Reply or Comment

Your email address will not be published.