GNU Make (C / C++)

The same integration with no helpers -- the dsdlc command line and GNU Make. It is the recipe that shows dsdlc's own decomposition: --generate-support splits runtime scaffolding from generated types, and the + sigil names the standard types compiled into the binary, so one namespace becomes three independent rules with three independent lifetimes.

Language C
Build system GNU Make
Idiom B -- your project owns the manifest
Regeneration Build-time depfile (-MD)

Notes

Needs GNU Make 4.3 for grouped targets (&:), a rule declaring that one command produces all of these files. macOS still ships 3.81, where this recipe skips.

The three tranches change at different rates: the support header and the standard types move only when the compiler does, while your definitions change all day. Each becomes its own archive, so editing a definition rebuilds one of them.

The builtin tranche's prerequisite is the dsdlc binary itself -- those types are compiled into it, so it is their source. dsdlc's -MD depfiles say the same, so a toolchain upgrade regenerates them instead of leaving a stale copy.

Prerequisites

Tool Why
make drives the build
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.

# build
make DSDLC=dsdlc
# round-trip
./roundtrip

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

Makefile

# dsdlc is expected on PATH. Override with `make DSDLC=/path/to/dsdlc`.
DSDLC ?= dsdlc

DSDL_ROOT := dsdl/lanyard
GEN       := generated
BASE      := --target-language c --outdir $(GEN)

# Grouped targets (`&:`) are how a rule says "this one command produces all of these". Without them
# the honest options are a stamp file standing in for the real outputs, or a rule that lies about
# what it makes -- and a stamp is exactly the kind of workaround that hides a missing output until
# the compiler trips over it.
ifeq ($(filter grouped-target,$(.FEATURES)),)
$(error This Makefile needs GNU Make 4.3 or newer for grouped targets; this is $(MAKE_VERSION))
endif

# --------------------------------------------------------------------------------------------------
# Three tranches, along the lines dsdlc decomposes its own output.
#
# `--generate-support` splits everything dsdlc can emit into code derived from a definition and code
# that is not -- runtime headers, package manifests, scaffolding. `+uavcan...` names the standard
# types compiled into the binary. Between them, one namespace becomes three independent rules with
# three independent lifetimes, and none of them needs a stamp or a hand-written file list.
#
# The support header and the standard types change only when the compiler does, while
# your own definitions change all day. Splitting them means editing a definition rebuilds one
# archive, not all of it -- and the standard types can be built once into a library that several
# components share, which is the usual reason to want this.

# 1. Support: everything not derived from a definition. Needs no positional target.
SUPPORT_OUT := $(subst ;, ,$(shell $(DSDLC) $(BASE) --generate-support only --list-outputs))

# 2. The standard types this namespace refers to, taken from the catalogue compiled into dsdlc and
#    named with the `+` sigil rather than through a checkout of public_regulated_data_types.
# Every standard type the namespace reaches, listed exactly. `+uavcan.si.unit` would cover nine of
# these in one line, but it generates all forty-five types in that namespace -- five times the work
# for the same result. The list is the honest trade; ask dsdlc for it rather than reading the
# definitions, by generating once and reading back the uavcan paths it produced.
BUILTIN_SEL := +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
BUILTIN_ARGS := $(BASE) --generate-support never $(BUILTIN_SEL)
BUILTIN_OUT := $(subst ;, ,$(shell $(DSDLC) $(BUILTIN_ARGS) --list-outputs))

# 3. Our own definitions. --omit-dependencies keeps the standard types out of this tranche; they are
#    tranche 2's job and generating them twice would be two rules fighting over the same files.
TYPES_ARGS := $(BASE) --generate-support never --omit-dependencies $(DSDL_ROOT)
TYPES_OUT  := $(subst ;, ,$(shell $(DSDLC) $(TYPES_ARGS) --list-outputs))
TYPES_IN   := $(subst ;, ,$(shell $(DSDLC) $(TYPES_ARGS) --list-inputs))

GEN_HDRS := $(filter %.h,$(SUPPORT_OUT) $(BUILTIN_OUT) $(TYPES_OUT))

BUILTIN_OBJS := $(patsubst %.c,%.o,$(filter %.c,$(BUILTIN_OUT)))
TYPES_OBJS   := $(patsubst %.c,%.o,$(filter %.c,$(TYPES_OUT)))

CFLAGS += -std=c11 -Wall -Wextra -I$(GEN)

all: roundtrip

# Two archives rather than one pile of objects, mirroring the tranches: the standard types are the
# library several components would share, and your own types are the one that changes.
roundtrip: src/c/roundtrip.o liblanyard.a libdsdlbuiltin.a
    $(CC) $^ -o $@

# Deleted first, then created. `ar rcs` *adds to* an archive that already exists, so without the
# rm a type you deleted keeps its object inside the library long after its source is gone. The
# signature file is a prerequisite for the same reason it is one for generation: losing a member is
# a change Make cannot see by comparing timestamps of the members that remain.
libdsdlbuiltin.a: $(BUILTIN_OBJS) $(TYPES_SIG)
    rm -f $@
    $(AR) rcs $@ $(BUILTIN_OBJS)

liblanyard.a: $(TYPES_OBJS) $(TYPES_SIG)
    rm -f $@
    $(AR) rcs $@ $(TYPES_OBJS)

# --------------------------------------------------------------------------------------------------
# Generation. One rule per tranche, each declaring every file it produces.

# --prune-manifest is what makes a deleted definition stop existing: without it the generated header
# survives in $(GEN), still on the include path, and code that includes a type nobody defines any
# more keeps compiling. One manifest per tranche, never one per directory -- a tranche owns only the
# files it emits, and one that swept $(GEN) would delete the other two tranches' work.
#
# dsdlc measures a relative manifest path from --outdir, so `--prune-manifest $(MANIFESTS)/types`
# writes $(GEN)/$(MANIFESTS)/types. The signature file below is Make's own and is named the long way
# to land in that same directory.
MANIFESTS := .dsdlc

# Pruning only happens when dsdlc runs, so deleting a definition has to make it run -- and on its
# own it does not. Make compares timestamps of the prerequisites a rule has *now*; a prerequisite
# that vanished is simply absent from the comparison, every surviving output is still newer than
# every surviving input, and the rule is considered up to date. The deleted type's header would
# survive in $(GEN) with nothing left to notice.
#
# So the input list is itself made an input. This rewrites the file only when the list changes,
# which makes adding or removing a definition a timestamp change the rule can see, while editing one
# leaves it alone (that case is already covered by $(TYPES_IN)).
TYPES_SIG := $(GEN)/$(MANIFESTS)/types.inputs
$(shell mkdir -p $(GEN)/$(MANIFESTS) && printf '%s\n' $(TYPES_IN) >$(TYPES_SIG).tmp && \
        { cmp -s $(TYPES_SIG).tmp $(TYPES_SIG) || mv $(TYPES_SIG).tmp $(TYPES_SIG); } ; \
        rm -f $(TYPES_SIG).tmp)

$(SUPPORT_OUT) &: $(DSDLC)
    $(DSDLC) $(BASE) --generate-support only -MD --prune-manifest $(MANIFESTS)/support

# The prerequisite is the compiler itself, and that is not a trick: these types are compiled into the
# binary, so the binary is their source. dsdlc's own -MD depfiles say the same thing, which is what
# makes a toolchain upgrade regenerate them instead of leaving a stale copy behind.
$(BUILTIN_OUT) &: $(DSDLC)
    $(DSDLC) $(BUILTIN_ARGS) -MD --prune-manifest $(MANIFESTS)/builtin

# $(TYPES_IN) covers editing a definition; $(TYPES_SIG) covers adding or removing one.
$(TYPES_OUT) &: $(TYPES_IN) $(TYPES_SIG) $(DSDLC)
    $(DSDLC) $(TYPES_ARGS) -MD --prune-manifest $(MANIFESTS)/types

# Nothing compiles before the headers of every tranche exist. Order-only: the -MMD depfiles below
# name the headers each object actually includes, so a schema edit propagates through those rather
# than through a blanket rebuild.
$(BUILTIN_OBJS) $(TYPES_OBJS) src/c/roundtrip.o: | $(GEN_HDRS)

%.o: %.c
    $(CC) $(CFLAGS) -MMD -MP -c $< -o $@

# Two independent sets of depfiles, answering two different questions.
#
# dsdlc's -MD files say which definitions each generated file came from, transitively: the rule for
# SensorFrame_1_0.c lists Mode.1.0.dsdl, reached through an embedded type. For the
# builtin tranche they name the dsdlc binary instead, for the reason above. No compiler could work
# either of those out.
-include $(addsuffix .d,$(SUPPORT_OUT) $(BUILTIN_OUT) $(TYPES_OUT))

# The C compiler's own -MMD files say which generated headers each object includes, which is the
# ordinary C dependency problem and nothing to do with DSDL.
-include $(patsubst %.o,%.d,$(BUILTIN_OBJS) $(TYPES_OBJS) src/c/roundtrip.o)

clean:
    rm -rf $(GEN) roundtrip liblanyard.a libdsdlbuiltin.a src/c/roundtrip.o src/c/roundtrip.d

.PHONY: all clean