Migration Strategy
Porting a project to CMake is a non-trivial endeavor that requires understanding both the source build system and CMake's modern idioms. The CMake Tutorial covers building from scratch, but migration demands a different approach — preserving existing behavior while modernizing the build infrastructure.
Incremental Approach
For large projects, migrate one component at a time while keeping the old build system operational:
flowchart TD
A[Existing Build System] --> B[Identify leaf libraries]
B --> C[Create CMakeLists.txt for leaf]
C --> D[Verify: same object files produced]
D --> E{All leaves migrated?}
E -->|No| B
E -->|Yes| F[Migrate intermediate libraries]
F --> G[Migrate executables]
G --> H[Migrate install/package rules]
H --> I[Remove old build system]
I --> J[Modern CMake project]
# Phase 1: Parallel builds — verify CMake produces identical artifacts
# Build with old system
make clean && make -j8
find . -name "*.o" | sort > old-objects.txt
nm libfoo.a | sort > old-symbols.txt
# Build with CMake
cmake -S . -B build-cmake -DCMAKE_BUILD_TYPE=Release
cmake --build build-cmake -j8
find build-cmake -name "*.o" | sort > cmake-objects.txt
nm build-cmake/libfoo.a | sort > cmake-symbols.txt
# Compare symbols (order-independent)
diff old-symbols.txt cmake-symbols.txt
Big-Bang Approach
For smaller projects or when the old build system is too broken to maintain, convert everything at once:
# Step 1: Document what the old build system does
make -n 2>&1 | tee old-build-commands.txt
# This prints commands without executing — shows all flags, paths, link lines
# Step 2: Extract compiler flags per target
grep -E "^(gcc|g\+\+|clang)" old-build-commands.txt | \
awk '{print $NF, $0}' | sort > flags-by-target.txt
# Step 3: Identify link dependencies
grep -E "\-l[a-z]" old-build-commands.txt | \
grep -oE "\-l[a-zA-Z0-9_]+" | sort -u > link-deps.txt
From Makefiles
Mapping Targets
Every Makefile target maps to a CMake target. The key difference: Makefiles express recipes (how to build), while CMake expresses requirements (what to build).
# Original Makefile
CC = gcc
CFLAGS = -Wall -Wextra -O2 -I./include
LDFLAGS = -lm -lpthread
SRCS = src/main.c src/utils.c src/parser.c
OBJS = $(SRCS:.c=.o)
myapp: $(OBJS)
$(CC) $(OBJS) $(LDFLAGS) -o $@
libutil.a: src/utils.o src/parser.o
ar rcs $@ $^
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) myapp libutil.a
# Equivalent modern CMakeLists.txt
cmake_minimum_required(VERSION 3.21)
project(MyApp LANGUAGES C)
# Library target (replaces libutil.a rule)
add_library(util STATIC
src/utils.c
src/parser.c
)
target_include_directories(util PUBLIC include)
# Executable target (replaces myapp rule)
add_executable(myapp src/main.c)
target_link_libraries(myapp PRIVATE
util
m # -lm
Threads::Threads # -lpthread (portable)
)
# Compiler warnings (replaces CFLAGS)
target_compile_options(myapp PRIVATE -Wall -Wextra)
target_compile_options(util PRIVATE -Wall -Wextra)
# Find pthreads portably
find_package(Threads REQUIRED)
Converting Compiler Flags
| Makefile Pattern | CMake Equivalent | Notes |
|---|---|---|
CFLAGS += -I./include | target_include_directories(t PUBLIC include) | Use PUBLIC for headers others need |
CFLAGS += -DVERSION=2 | target_compile_definitions(t PRIVATE VERSION=2) | Scoped to target |
LDFLAGS += -L/opt/lib | target_link_directories(t PRIVATE /opt/lib) | Prefer find_package instead |
LDFLAGS += -lz | find_package(ZLIB REQUIRED) + target_link_libraries(t PRIVATE ZLIB::ZLIB) | Imported targets preferred |
CFLAGS += -std=c11 | target_compile_features(t PUBLIC c_std_11) | Portable standard selection |
CFLAGS += -O2 | CMAKE_BUILD_TYPE=Release | Don't hardcode optimization |
Objective: Convert a real open-source project's Makefile to CMake.
Clone a small C project that uses plain Makefiles (e.g., jq, suckless tools, or htop). Run make -n to capture all build commands, then create a CMakeLists.txt that produces the same binary. Verify with diff on the stripped executables and run the project's test suite with both builds.
From Autotools
configure.ac Mapping
Autotools uses configure.ac (M4 macros) and Makefile.am (automake templates). The mapping to CMake is systematic but requires understanding both systems. Reference: cmake-modules(7) for all check modules.
# Autotools configure.ac (typical)
AC_PREREQ([2.69])
AC_INIT([myproject], [1.0.0])
AM_INIT_AUTOMAKE([foreign subdir-objects])
AC_PROG_CC
AC_PROG_CXX
# Feature checks
AC_CHECK_HEADERS([sys/epoll.h sys/event.h])
AC_CHECK_FUNCS([mmap posix_fadvise])
AC_CHECK_LIB([pthread], [pthread_create])
AC_CHECK_LIB([z], [deflate])
# Conditional compilation
AC_ARG_ENABLE([ssl],
[AS_HELP_STRING([--enable-ssl], [Enable SSL support])],
[enable_ssl=$enableval], [enable_ssl=no])
if test "x$enable_ssl" = xyes; then
PKG_CHECK_MODULES([OPENSSL], [openssl >= 1.1])
fi
AC_CONFIG_FILES([Makefile src/Makefile])
AC_OUTPUT
# Equivalent CMakeLists.txt
cmake_minimum_required(VERSION 3.21)
project(myproject VERSION 1.0.0 LANGUAGES C CXX)
# Feature checks (replace AC_CHECK_HEADERS)
include(CheckIncludeFile)
check_include_file(sys/epoll.h HAVE_SYS_EPOLL_H)
check_include_file(sys/event.h HAVE_SYS_EVENT_H)
# Function checks (replace AC_CHECK_FUNCS)
include(CheckFunctionExists)
check_function_exists(mmap HAVE_MMAP)
check_function_exists(posix_fadvise HAVE_POSIX_FADVISE)
# Library checks (replace AC_CHECK_LIB)
find_package(Threads REQUIRED)
find_package(ZLIB REQUIRED)
# Optional feature (replace AC_ARG_ENABLE)
option(ENABLE_SSL "Enable SSL support" OFF)
if(ENABLE_SSL)
find_package(OpenSSL 1.1 REQUIRED)
endif()
# Generate config header (replace AC_CONFIG_HEADERS)
configure_file(config.h.in config.h @ONLY)
add_executable(myproject src/main.c src/network.c)
target_link_libraries(myproject PRIVATE
Threads::Threads
ZLIB::ZLIB
$<$:OpenSSL::SSL>
)
target_include_directories(myproject PRIVATE ${CMAKE_BINARY_DIR})
Platform Checks
| Autotools Macro | CMake Module | CMake Command |
|---|---|---|
AC_CHECK_HEADERS | CheckIncludeFile | check_include_file(header.h VAR) |
AC_CHECK_FUNCS | CheckFunctionExists | check_function_exists(func VAR) |
AC_CHECK_LIB | find_package / find_library | find_package(Pkg REQUIRED) |
AC_CHECK_SIZEOF | CheckTypeSize | check_type_size("int" SIZEOF_INT) |
AC_CHECK_MEMBERS | CheckStructHasMember | check_struct_has_member(...) |
PKG_CHECK_MODULES | PkgConfig | pkg_check_modules(... IMPORTED_TARGET) |
From qmake
Qt's qmake uses .pro files. Since Qt 6, CMake is the officially supported build system. The mapping is straightforward:
# Original qmake .pro file
QT += core gui widgets network
TARGET = myqtapp
TEMPLATE = app
SOURCES += main.cpp mainwindow.cpp network.cpp
HEADERS += mainwindow.h network.h
FORMS += mainwindow.ui
RESOURCES += resources.qrc
CONFIG += c++17
LIBS += -lcurl
# Equivalent CMakeLists.txt for Qt 6
cmake_minimum_required(VERSION 3.21)
project(myqtapp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Network)
find_package(CURL REQUIRED)
add_executable(myqtapp
main.cpp
mainwindow.cpp
mainwindow.h
network.cpp
network.h
mainwindow.ui
resources.qrc
)
target_link_libraries(myqtapp PRIVATE
Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Network
CURL::libcurl
)
qmake2cmake (part of qt-tools). It generates a starting CMakeLists.txt from your .pro file. The output often needs manual refinement but saves significant time on large projects.
Migrating Install Rules
# Modern CMake install rules (replace make install targets)
include(GNUInstallDirs)
install(TARGETS myapp mylib
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
)
install(DIRECTORY include/
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/myproject
FILES_MATCHING PATTERN "*.h"
)
install(FILES LICENSE README.md
DESTINATION ${CMAKE_INSTALL_DOCDIR}
)
# Generate and install CMake config files for downstream consumers
install(TARGETS mylib EXPORT MyProjectTargets)
install(EXPORT MyProjectTargets
FILE MyProjectTargets.cmake
NAMESPACE MyProject::
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MyProject
)
Test Migration
# Convert test targets to CTest
enable_testing()
# Simple test (replaces: make test or make check)
add_executable(test_parser tests/test_parser.cpp)
target_link_libraries(test_parser PRIVATE util GTest::gtest_main)
add_test(NAME parser_tests COMMAND test_parser)
# Test with environment variables (common in Autotools)
add_test(NAME integration_test COMMAND ${CMAKE_SOURCE_DIR}/tests/run_integration.sh)
set_tests_properties(integration_test PROPERTIES
ENVIRONMENT "TEST_DATA_DIR=${CMAKE_SOURCE_DIR}/tests/data"
TIMEOUT 120
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
)
Objective: Port an Autotools project to CMake while maintaining test compatibility.
Choose a small Autotools project (e.g., libconfig or jansson). Run ./configure && make check to establish a baseline. Create CMakeLists.txt with equivalent checks, builds, and tests. Verify all tests pass identically. Compare config.h output from both systems.
Common Pitfalls
- Variable pollution: Using
CMAKE_CXX_FLAGSglobally instead of per-targettarget_compile_options() - GLOB abuse: Using
file(GLOB ...)for source files — CMake can't detect new files without reconfigure - Missing dependencies: Not declaring link dependencies that "worked" due to link order luck
- Hardcoded paths: Embedding
/usr/local/libinstead of usingfind_package() - Ignoring visibility: Making everything PUBLIC when PRIVATE would be correct
# BAD: Variable pollution (legacy style)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra")
include_directories(${PROJECT_SOURCE_DIR}/include)
link_directories(/opt/custom/lib)
# GOOD: Target-scoped properties (modern style)
target_compile_options(mylib PRIVATE -Wall -Wextra)
target_include_directories(mylib PUBLIC
$
$
)
find_library(CUSTOM_LIB custom PATHS /opt/custom/lib)
target_link_libraries(mylib PRIVATE ${CUSTOM_LIB})
flowchart LR
A[Old Build] --> B[Capture Artifacts]
C[CMake Build] --> D[Capture Artifacts]
B --> E{Compare}
D --> E
E -->|Symbols match| F[Run Tests]
E -->|Mismatch| G[Debug Differences]
F -->|All pass| H[Migration Complete]
F -->|Failures| G
G --> I[Fix CMakeLists.txt]
I --> C
Validation Techniques
# Compare compile commands between old and new build systems
# CMake generates compile_commands.json with:
cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -S . -B build
# Extract flags per file and compare
jq -r '.[] | "\(.file): \(.command)"' build/compile_commands.json | sort > cmake-flags.txt
# From old Makefile (using make -n):
make -n 2>&1 | grep -E "^(gcc|g\+\+)" | sort > make-flags.txt
# Diff to find missing/extra flags
diff make-flags.txt cmake-flags.txt
# Verify link lines match
# CMake: check link.txt files in build tree
find build -name "link.txt" -exec cat {} \;
# Compare shared library ABI
nm -D libfoo.so | awk '{print $3}' | sort > cmake-exports.txt
nm -D old-build/libfoo.so | awk '{print $3}' | sort > old-exports.txt
diff old-exports.txt cmake-exports.txt
Objective: Create an automated validation script for your migration.
Write a shell script that builds with both systems, compares binary sizes (within 5% tolerance), verifies symbol tables match, runs all tests with both builds, and checks that installed files are in the same locations. This becomes your regression test during the migration period.
Conclusion & Next Steps
Porting to CMake is an investment that pays dividends in cross-platform support, IDE integration, and modern dependency management. Whether migrating incrementally from a working Makefile or converting a complex Autotools project, the key is validation — compare artifacts, run tests, and verify install layouts at every step.
Next in the Series
In Part 29: Creating Reproducible Build Environments, we'll ensure that the same source always produces the same binary by pinning toolchains, dependencies, and configurations with CMake Presets, Docker, and deterministic compiler flags.