Modular CMake - Why It's Good Practice

The importance of Modularity in Engineering

Imagine an engineering team tasked with delivering a large-scale project under a tight timeline.
To streamline the delivery, the development, testing, and integration must function in parallel as efficiently as possible.
However, dependencies across team functions often introduce bottlenecks.

For instance, the testing team must wait until the development completes to start testing.
Similarly, the integration personnel will need to wait for sanity coverage to complete before integration in order to avoid component degrade.
These blocking dependencies reduce efficiency and delay delivery.

To streamline the entire process, an emphasis on modularity must be introduced by the early stage of the process.
Modularity measures the degree to which a system is composed of independent, decoupled sub-systems.
It allows the team to divide a complex design into smaller, manageable units, each handled by a specific individual or team.

Embedding Modularity Into Build System

CMake is the most commonly used build system for C, C++ projects that supports modular project structures.
I have listed several key usage from my experience.

Source Code Isolation: Instead of compiling and linking all source files together for each target, I prefer creating static libraries that can be compiled once and reused across multiple targets.


    # Add library which includes all other bare metal code.
    add_library(LibBareMetal
      ${CMAKE_CURRENT_LIST_DIR}/startup_rv32i.s
      ${CMAKE_CURRENT_LIST_DIR}/trace.c
      ${CMAKE_CURRENT_LIST_DIR}/atomic.cpp
      ${CMAKE_CURRENT_LIST_DIR}/timeslice.cpp
    )
    target_include_directories(LibBareMetal PRIVATE
        ${CMAKE_CURRENT_LIST_DIR}
      ${CMAKE_CURRENT_LIST_DIR}/../../include
    )
  

The translation unit benefits from small object size thanks to the limited set of source files.
Often in embedded engineering it is necessary to inspect assembly instructions translated from high level language.
Instead of generating disassembly from final executable, the library itself could be disassembled.
The command riscv64-unknown-elf-objdump -d libLibYesRTOSKernel.a dumps only the disassembly for this library.

Hierarchical Project Structure: Each component—such as the application, RTOS kernel, or hardware abstraction layer—can be organized into logical layers and built independently.


      set(PROJECT_NAME multi_thread)

      add_executable(${PROJECT_NAME})
      target_sources(${PROJECT_NAME} PRIVATE
        main.cpp
      )

      ......

      # Add the kernel module as a subproject. This allows its CMakeLists.txt to define targets
      # (e.g., libraries or executables) that can be built and linked in this parent project.
      add_subdirectory(
        ${CMAKE_CURRENT_LIST_DIR}/../../kernel
        ${CMAKE_CURRENT_LIST_DIR}/../../bsp
      )
      ......

      target_link_libraries(${PROJECT_NAME} PRIVATE
        LibYesRTOSKernel
        LibBareMetal
      )
  

The add_subdirectory keyword specifies a directory in which another sub-project is defined a separate CMakeLists.txt
By design, the hierarchy of project structures must be clearly defined to avoid circular dependency between modules.

Unit Testing Enablement: Small libraries are ideal for unit testing. Individual modules could be linked to testbench executables, and to be validated in isolation before system-wide integration.


    project(UnitTest)

    ......

    # Include project source
    # message(STATUS "LHDBG CMakeList.txt in unit_tests directory CMAKE_SOURCE_DIR: ${CMAKE_SOURCE_DIR}")

    # Enable compile command dumping to json
    set(CMAKE_EXPORT_COMPILE_COMMANDS TRUE)

    # Build flags
    add_compile_definitions("-DHOST_PLATFORM")

    add_executable(mempool_unit_test
      ${CMAKE_SOURCE_DIR}/mempool_unit_test.cpp
      ${CMAKE_SOURCE_DIR}/../../kernel/src/mempool.cpp
    )

    target_link_libraries(${PROJECT_NAME} PRIVATE
      LibYesRTOSKernel
    )

    ......

    enable_testing()
    add_test(NAME mempool_unit_test0 COMMAND ./mempool_unit_test 0)
    add_test(NAME mempool_unit_test1 COMMAND ./mempool_unit_test 1)
    add_test(NAME mempool_unit_test2 COMMAND ./mempool_unit_test 2)
    add_test(NAME mempool_unit_test3 COMMAND ./mempool_unit_test 3)
    add_test(NAME mempool_unit_test4 COMMAND ./mempool_unit_test 4)
    add_test(NAME mempool_unit_test5 COMMAND ./mempool_unit_test 5)
  

In above example, enable_testing() enables ctest, allowing the user to invoke the specific set of unit tests in command line.

Fine Grain Control for Build Optimization: In embedded systems, cross-compilation for various platforms is ubiquitous.


    # Platform detection and module selection
    if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
      add_subdirectory(linux_drivers)
    elseif(CMAKE_SYSTEM_NAME STREQUAL "FreeRTOS")
      add_subdirectory(baremetal_drivers)
    endif()

    # Conditional compilation with generator expressions
    target_compile_definitions(LibBareMetal PRIVATE
      $<$:LINUX_BACKEND=1>
      $<$:EMBEDDED_BACKEND=1>
    )
  

Depending on target platform, the compilation could be switched conveniently with minimal porting effort.