Multiple executables, thus multiple different builds of the same static lib?

If I have a CMakeLists.txt that encloses a larger project with multiple executables inside it, and each of those executables has different compile macros, and both have target_link_libraries to a static lib, will it automatically build the specializations or variants of that library?

For example, something like:

cmake_minimum_required(VERSION 3.25)
project(cmake_exes_test C)

set(CMAKE_C_STANDARD 11)

add_library(foo STATIC lib/foo.c)

add_executable(app1 app1/app1.c)
target_compile_definitions(app1 PUBLIC MACRO1)
target_link_libraries(app1 PRIVATE foo)

add_executable(app2 app2/app2.c)
target_compile_definitions(app2 PUBLIC MACRO2)
target_link_libraries(app2 PRIVATE foo)

ChatGPT (my only colleague :confounded:) insists that this should work cause foo to be built twice, each time with a different set of macros. But upon testing, it does not seem like that actually happens.

If that is NOT how it actually works … is the expected pattern something like this?

add_library( foo_var1 STATIC lib/foo.c)
target_compile_definitions( foo_var1 PUBLIC MACRO1 )
target_include_directories( foo_var1 PUBLIC lib )

add_library( foo_var2 STATIC lib/foo.c)
target_compile_definitions( foo_var2 PUBLIC MACRO2 )
target_include_directories( foo_var2 PUBLIC lib )

add_executable(app1 app1/app1.c)
target_link_libraries(app1 foo_var1)

add_executable(app2 app2/app2.c)
target_link_libraries(app2 foo_var2)

The obvious downside there is that I would be repeating macros that should have scope for the whole executable for every relevant library.
Feels like I am doing something wrong/missing something.

I am trying to level up with my CMake abilities, applied to embedded code projects.
This is a pattern that comes up quite a lot, where top-level macros are set for a whole executable, and a whole CMake project contains many executables, all shipping in-situ with the SDK and application source (since it produces a monolithic binary).

There is no way to propagate anything from target to it’s dependencies.
If you need something like that, you could use a CMake macro to wrap creating of specialized library builds and link them to the executable.

Other possibility (or maybe just as a helper) would be to create an INTERFACE target for each set of macros (and other common options) and link that to all relevant libraries & executables.

Okay, that is helpful to know.

After some rework, this is what I end up with:

cmake_minimum_required(VERSION 3.25)
project(cmake_exes_test C)

set(CMAKE_C_STANDARD 11)

add_library( macros1 INTERFACE )
target_compile_definitions( macros1 INTERFACE MACRO1 )

add_library( macros2 INTERFACE )
target_compile_definitions( macros2 INTERFACE MACRO2 )

add_library( foo_var1 STATIC lib/foo.c)
target_include_directories( foo_var1 PUBLIC lib )
target_link_libraries( foo_var1 macros1 )

add_library( foo_var2 STATIC lib/foo.c)
target_include_directories( foo_var2 PUBLIC lib )
target_link_libraries( foo_var2 macros2 )

add_executable(app1 app1/app1.c)
target_link_libraries(app1 PUBLIC foo_var1
                           INTERFACE macros1 )

add_executable(app2 app2/app2.c)
target_link_libraries(app2 PUBLIC foo_var2
                           INTERFACE macros2 )

I think I can see how to leverage that - build up INTERFACEs of sets of compile options, combine them with manually created super-INTERFACES, and then link them to both the libraries and executables.

Would this be considered … good and idiomatic cmake practice?

I haven’t yet managed to write a nice, clean, CMake script, so hopefully others will chime in to answer that :wink:

If you have a compiler define (or set of defines) that needs to apply to multiple targets, then using an INTERFACE library to define it and linking targets against that one is a common strategy. If only one or two targets need the compiler define, then using an INTERFACE library may be overkill.

@fenrir has already pointed out a key observation here. You can’t have one target build with different settings depending on what is consuming it (linking to it). However, it is possible to have a single target cause different effects in different consumers with some carefully crafted generator expressions and target properties. It is a pretty advanced and complex technique, and it can easily fall apart if you get things a little wrong. I would not normally recommend it unless there were no better alternatives. For reference, my Professional CMake book uses a related technique in the Link Seaming example of the Advanced Linking chapter, but I think your needs here are a little different to that.

2 Likes

Interesting.
I have recently extended this same line of thought to other compile options, to actually build totally distinct targets, e.g. with different -march=... arguments. This comes up a lot in embedded SDK’s, where one outer project will contain “executables” (statically linked ELFs) for multiple different chips.

This is the minimum working example I ended up with:

cmake_minimum_required(VERSION 3.25)

# This include will set up the toolchain for the embedded target; arm-gcc will be found from your system path
include(tools/cmake/toolchains/arm-gcc.cmake)

## Flags for ALL C/C++ compilation could are set here
# TODO: verify here that having the "-f..." args come first doesn't cause any issues
set( COMMON_FLAGS "-ffunction-sections -fdata-sections -fmessage-length=0" )
SET(CMAKE_CXX_FLAGS_INIT "${COMMON_FLAGS}")
SET(CMAKE_C_FLAGS_INIT "${COMMON_FLAGS}")
set(CMAKE_ASM_FLAGS_INIT "-mcpu=cortex-m4 ${FPU_FLAGS} -mthumb") ## TODO: unclear how to resolve this ... maybe -x assembler-as-cpp ?
SET(CMAKE_EXE_LINKER_FLAGS_INIT "-Wl,-gc-sections,--print-memory-usage")

## These will be universal across all targets
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 14)


project(cmake_multi_cpu_target_test C CXX ASM)

add_library( CMx_common INTERFACE )
set( CMx_common_opts "-mthumb;-mno-unaligned-access;-Og;-g3;-Wall" )
target_compile_options( CMx_common BEFORE INTERFACE ${CMx_common_opts} )
target_link_options( CMx_common BEFORE INTERFACE  ${CMx_common_opts} )

add_library( CM3_softfp INTERFACE )
set( CM3_opts "-mcpu=cortex-m3;-mfloat-abi=soft" )
target_compile_options( CM3_softfp BEFORE INTERFACE ${CM3_opts} )
target_compile_definitions( CM3_softfp INTERFACE -DARM_MATH_CM3 )
target_link_options( CM3_softfp BEFORE INTERFACE  ${CM3_opts} )

add_library( CM4F_hardfp INTERFACE )
set( CM4f_opts "-mcpu=cortex-m4;-mfloat-abi=hard;-mfpu=fpv4-sp-d16" )
target_compile_options( CM4F_hardfp BEFORE INTERFACE ${CM4f_opts} )
target_compile_definitions( CM4F_hardfp INTERFACE -DARM_MATH_CM4 )
target_link_options( CM4F_hardfp BEFORE INTERFACE ${CM4f_opts} )

add_library( CM7_hardfp INTERFACE )
set( CM7_opts "-mcpu=cortex-m7;-mfloat-abi=hard;-mfpu=fpv5-d16" )
target_compile_options( CM7_hardfp BEFORE INTERFACE ${CM7_opts} )
target_compile_definitions( CM7_hardfp INTERFACE -DARM_MATH_CM7 )
target_link_options( CM7_hardfp BEFORE INTERFACE  ${CM7_opts} )

add_library( nosys.specs INTERFACE )
target_compile_options( nosys.specs BEFORE INTERFACE "--specs=nosys.specs" )
target_link_options( nosys.specs BEFORE INTERFACE  "--specs=nosys.specs" )

## Exes ##
add_executable(exe_m3 main.c syscalls.c )
target_link_libraries( exe_m3 PUBLIC CMx_common CM3_softfp nosys.specs )

add_executable(exe_m4f main.c syscalls.c )
target_link_libraries( exe_m4f PUBLIC CMx_common CM4F_hardfp nosys.specs )

add_executable(exe_m7 main.c syscalls.c )
target_link_libraries( exe_m7 PUBLIC CMx_common CM7_hardfp nosys.specs )

This seems to work well and is fairly clean, although it does force me to manually create a variant and name-append and target_link_libraries every single dependency.

Maybe there is something wrt to the CMake include scoping that I am skipping over here?
Say, if target-wide options were declared in one file, then all the libraries cmake scripts included after that, etc, so the “trickle-down” or “tree”-like effect of settings options would happen.

I would guess that the generator-based method you are describing is something like that.

I have seen Makefile systems accomplish this, because they set some vars as they go down their tree of includes, then targets get defined with those, etc etc.

Given a dependency graph with the exe as the root, it should be possible to traverse the whole graph and add various options & defines, or link everything in that graph to a specific package that accomplishes that. No idea if that is possible with cmake tooling, though.
And then you’d still have name collisions unless that tree walk also auto-created the new name-appended variants and swapped the dependency link. Which is getting very complex.

A fallback approach that I am going to prototype would be to just elide all the library sub-targets,
and set up the includes & exe targets in a way that a long list of sources, include paths, and macros for each are accumulated, and then the exe itself is just one target with a giant list of sources of include paths.

Did you try a super-build instead? Instead of one target for each hardware, you have your project once with one toolchain file for each hardware.

Your scheme breaks the moment where you actually need two different compilers, e.g. not every hardware is arm based.

1 Like

I second this suggestion. Each hardware build type gets its own binary build directory. I find it easier to manage when building drivers for multiple hardware and OS platforms.

1 Like

This does sound pretty good. It actually do have it on my roadmap that I’ll need both arm-gcc and xtensa-gcc (ESP32) at some point.

I am testing out this approach … but having a few problems.

  1. CLion seems to not extend its awareness to contents ExternalProjects. So, for common source files, the include paths don’t get resolved, intellisense doesn’t work, etc.
    Since the goal of this effort is to get a wrench around a large legacy codebase of dubious quality, those features are essential.
    Appears to just be a hard limitation:
    https://youtrack.jetbrains.com/issue/CPP-252/Support-ExternalProjectAdd

  2. The subproject doesn’t seem to “clean” at all, with BUILD_ALWAYS on, or cleaning the top-level project. Not huge, but I do worry about things being out of date.

  3. I can’t seem to include(...) from the top-level path, specifically trying to include the arm-gcc.cmake toolchain file. Even though the path is right, include( ../tools/cmake/toolchains/arm-gcc.cmake) in the subproject CMakeLists.txt, it still ends up only configuring clang (system compiler).
    I can get it to work by duplicating the toolchain file into the subproejct, and including without the ../ - this makes some sense, if it is meant to be truly external.
    BUT the relative path does work for picking up common sources, so … unsure.

This is complicated enough that I actually might still use the many-separate-projects pattern, with the not-so-great compromise of using all relative paths (../../ hell) for sources, headers, includes, etc,
and then just treat them as totally independent projects, in 5+ (in my case) separate CLion windows.
CLion will complain about “sources outside tree”, but oh well.

Well, I’d use the superbuild for e.g. CI purposes.

For an IDE with CMake support, you can also use a preset file and switch between the build presets in the IDE.

Why do you need to include() the toolchain file? I think you use CMake cross-compile support wrongly. It is NOT supposed to be included from the CMakeLists.txt file but specified either via CMAKE_TOOLCHAIN_FILE variable or in a preset.

2 Likes

@hsattler I copied that pattern from an older AWS FreeRTOS repo … although it seemed to be pretty professional developed, so it seemed like a good thing to copy!

After some looking around … it is not clear if there is one definite way to use a “CMake Toolchain File” for the embedded compiler, arm-none-eabi-gcc in my case.

Well, there is this one, but fairly old:

Still, it kind-of seems like the ONLY solution here is to actually logically separate each different executable-target pair into their own, standalone CMake projects.
Tragically, due to source repo organization that I cannot change, that is going to make things pretty topsy-turvey, with a lot of parent-directory sources and intricate paths.

But … some tests seem to indicate that it will work how I want, with common library code being rebuilt each time.

So, I guess I will get used to having 11 CLion windows open at all times :weary: