CMake + Ninja Multi-Config (C / C++)

The recipe for a project that has outgrown one namespace. Two root namespaces share one output tree, one runtime header, and one standard type, built in Debug and Release from a single Ninja Multi-Config tree -- which is exactly the shape that does not work unless generation is divided up first.

Language C
Build system CMake + Ninja Multi-Config
Idiom B -- your project owns the manifest
Regeneration Configure-time query (--list-outputs)

Notes

Ninja refuses to build a graph in which two rules produce the same file. Two whole-namespace calls into one output directory do precisely that, twice over: both emit the runtime header, and both emit the uavcan.time type they share. It is not a warning and not last-writer-wins -- the build does not start. dsdlc_generate() detects the overlap at configure time and names both claimants rather than letting Ninja report it.

The fix is to give each shared thing exactly one owner, using the seams dsdlc already has: SUPPORT only for the runtime header, a BUILTIN call for the shared standard type, and OMIT_DEPENDENCIES on the namespace calls so they emit their own definitions and nothing else.

Generated code is not configuration-dependent. The output directory is deliberately not under $<CONFIG>: codegen is a function of the schema and the backend flags, so a per-config tree would run identical work per configuration to produce byte-identical output. Generate once, compile per configuration -- the last step below asserts that the tree really is config-agnostic, since getting this wrong is invisible until someone wonders why a two-config build generates everything twice.

Prerequisites

Tool Why
cmake configures and drives the build
ninja the Multi-Config generator this recipe is about
cc compiles the generated C and the round-trip program

Commands

Run these from the recipe directory, with dsdl/ and src/ copied alongside it. This is the exact sequence CI runs.

# configure
cmake -G "Ninja Multi-Config" -S . -B build "-DCMAKE_PREFIX_PATH=$LLVM_DSDL_PREFIX"
# build Debug
cmake --build build --config Debug
# build Release
cmake --build build --config Release
# round-trip (Debug)
./build/Debug/roundtrip
# round-trip (Release)
./build/Release/roundtrip
# assert one config-agnostic generated tree
python3 -c "import pathlib,sys; g=pathlib.Path('build/generated'); missing=[p for p in ('lanyard/health/SystemHealth_1_0.h','lanyard/flight/VehicleState_1_0.h','dsdl_runtime.h','uavcan/time/SynchronizedTimestamp_1_0.h') if not (g/p).is_file()]; percfg=[p.name for p in g.iterdir() if p.name in ('Debug','Release')]; sys.exit('missing from the shared tree: %s' % missing if missing else ('per-config codegen found: %s' % percfg if percfg else 0))"

The types this builds

The whole lanyard namespace -- twenty-four definitions, browsable from the showroom overview, where each one is paired with its wire-layout facts and a declaration excerpt in every language.

The build files

CMakeLists.txt

cmake_minimum_required(VERSION 3.20)
project(lanyard_ninja_multi C)

find_package(llvm-dsdl REQUIRED)

# One output tree shared by every call below, and deliberately NOT under a per-configuration
# directory. Generated code is a function of the schema and the backend flags and of nothing else --
# no optimisation level, no NDEBUG. Putting it under $<CONFIG> would run identical codegen once per
# configuration to produce byte-identical trees, and would make the Debug and Release halves of this
# build disagree about where the headers are for no reason. Generate once; compile it as many times
# as the generator wants to.
set(GEN "${CMAKE_BINARY_DIR}/generated")

# Three calls into that one tree, because several calls sharing an output directory is exactly the
# situation Ninja refuses when two of them claim the same file. Left alone, every call would emit the
# runtime header and the namespace call would pull in the standard types it reaches, so the work is
# divided up front along the seams dsdlc already has. dsdlc_generate() checks for overlap itself and
# fails at configure time naming both claimants.

# The runtime header has exactly one owner.
dsdlc_generate(dsdl_support
  LANGUAGE c
  SUPPORT  only
  OUTDIR   "${GEN}")

# So do the standard types this namespace reaches. `+` names the catalog compiled into dsdlc, so
# none of this needs a checkout of public_regulated_data_types.
#
# They are listed exactly rather than by namespace selector: `+uavcan.si.unit` would cover nine of
# them in one line and generate all forty-five types in that namespace, which is five times the work
# for the same result. Ask dsdlc for the list rather than reading the definitions -- generate once
# and read back the `uavcan/` paths it produced.
dsdlc_generate(dsdl_builtin
  LANGUAGE c
  SUPPORT  never
  OUTDIR   "${GEN}"
  BUILTIN
    +uavcan.diagnostic.Severity.1.0
    +uavcan.node.Health.1.0
    +uavcan.si.unit.angle.Quaternion.1.0
    +uavcan.si.unit.angle.Vector3.1.0
    +uavcan.si.unit.angular_velocity.Vector3.1.0
    +uavcan.si.unit.electric_current.Scalar.1.0
    +uavcan.si.unit.length.Scalar.1.0
    +uavcan.si.unit.length.WideScalar.1.0
    +uavcan.si.unit.temperature.Scalar.1.0
    +uavcan.si.unit.velocity.Vector3.1.0
    +uavcan.si.unit.voltage.Scalar.1.0
    +uavcan.time.SynchronizedTimestamp.1.0)

# The namespace then generates its own definitions and nothing else. Without OMIT_DEPENDENCIES it
# would pull the standard types into its own output and collide with dsdl_builtin.
dsdlc_generate(lanyard
  LANGUAGE          c
  SUPPORT           never
  OMIT_DEPENDENCIES
  NAMESPACE         "${CMAKE_CURRENT_SOURCE_DIR}/dsdl/lanyard"
  OUTDIR            "${GEN}")

target_link_libraries(lanyard PUBLIC dsdl_support dsdl_builtin)

add_executable(roundtrip src/c/roundtrip.c)
target_link_libraries(roundtrip PRIVATE lanyard)