Modernize the template to C23: opaque API, cross-compilation, and three bug fixes - #5
Merged
Merged
Conversation
Move the whole template to -std=c23 (from the -std=c2x draft spelling) and rework the build, editor, and CI wiring around it. Modeled on the codecamp project's Makefile: per-object header dependency tracking, a single build/ tree, Doxygen docs, and heavily commented rules. Library correctness - list_append linked a node to itself when the list was empty, making a one-element list circular; list_destroy then looped forever. Only masked because the old suite always appended ten at a time. - list_prepend_value/list_append_value malloc'd a buffer and immediately overwrote the pointer with the caller's, leaking the buffer and aliasing caller-owned memory. They copy the bytes now, so the caller keeps ownership. - list_pop left tail dangling at the freed node after popping the last element, so the next append wrote through a stale pointer. - Added the missing malloc failure checks; list_destroy and list_node_destroy now tolerate nullptr like free does. - linkedlist.h included <stdio.h> to reach size_t; it uses <stddef.h> now. C23 - nullptr, bool/true/false as keywords, static_assert and typeof without any supporting header, constexpr, and (void) prototypes. - [[nodiscard]] on every non-void function, [[noreturn]] on the test harness bail-out, [[reproducible]] on list_size/list_is_empty. - [[reproducible]]/[[unsequenced]] are guarded behind __has_c_attribute: Clang has neither, and clangd would otherwise flag every declaration. The guard tests for non-zero rather than >= 202311L because Clang reports the C++ paper dates for the other five attributes. - Header #errors out on __STDC_VERSION__ < 202311L instead of emitting a wall of syntax errors on nullptr. Build - Static and shared libraries from one set of -fPIC objects, with a correct soname/install_name and install/uninstall honouring PREFIX and DESTDIR. - Compiler auto-detection (newest Homebrew gcc-NN on macOS, gcc on Linux), overridable with CC= because $(origin CC) is tested rather than ?=. - make sanitize builds into build/sanitize/ so ASan and plain objects never mix; make memcheck runs leaks or valgrind. - Dropped the order-only directory prerequisites: $(BUILD) expands to "build", which is also a phony target, so make merged the two and every rule ordered after | $(BUILD) silently gained "static shared" as prerequisites. - Warning set now includes -Wconversion -Wshadow -Wcast-qual -Wstrict- prototypes -Wvla; the tree is clean under both gcc-16 and clang. Tests - Replaced bare assert with a CHECK harness. assert(list_append(...)) would have compiled the call away entirely under -DNDEBUG. - Regression tests for all three bugs above, plus struct payloads, zero-size elements, null arguments, and ownership transfer. Editor and CI - clangd drives IntelliSense; the cpptools engine and its Tag Parser fallback are off because they do not parse C23 attributes in C. cpptools stays for the debugger. compile_flags.txt gives clangd the same -std=c23. - launch.json version corrected to 0.2.0, with lldb, gdb, and sanitizer configurations; tasks for every Makefile target; extensions.json added. - CI matrix over Ubuntu gcc-14 and clang-18 plus macOS Apple Clang, which is what exercises the __has_c_attribute fallback. Separate format, valgrind, and Doxygen jobs. Docs - docs/C23.md covers the attributes, the postfix placement rule that applies only to [[reproducible]]/[[unsequenced]], the support matrix, and the features deliberately left out.
Every compiler this template targets supports it, it cannot collide with another project's macro, and it drops the trailing #endif that has to stay in sync with a name 250 lines above it. Matches the sdl3/codecamp header style.
BREAKING CHANGE: every caller must be updated. The structs are no longer
defined in the header, and the insertion functions take values rather than
caller-allocated nodes.
Why
---
With both structs public, three things were expressible from outside the
library, all verified against the previous build:
- list_append(l, n) twice was accepted. The count said 2, there was one
node, and its next pointed at itself; destroying that list never
terminated. Exactly the bug that was just fixed inside list_append, still
reachable from the caller side.
- A hand-rolled malloc(sizeof(Node)) was accepted with an uninitialised
payload pointer, which teardown then handed to free.
- Reading anything meant list->head->data, so the struct layout was part of
the ABI and the read path was untestable as an API.
All three are now compile errors: "invalid use of incomplete typedef".
API
---
Insertion no longer deals in nodes, so the _value suffix is gone:
list_append(list, &value, sizeof value)
list_prepend(list, &value, sizeof value)
list_node_new and list_node_destroy are gone with it -- nothing outside the
library allocates a node any more, which is what removes the ownership
question entirely. Nothing in the API transfers ownership in either direction.
Hiding the layout forced the read API that was missing all along:
list_at / list_at_mut borrow element N, with its size
list_first / node_next cursor iteration, O(n) for a full walk
node_value / node_size read through a cursor
list_pop(list, out, size) copy the head out and remove it
list_drop / list_clear remove without copying
list_pop checks that size matches the stored element and refuses on mismatch
rather than handing back a partial read, leaving the element in place.
Zero-size elements are now rejected. They came back from list_at as a non-null
pointer to nothing, indistinguishable from a real element, which made every
borrow-returning function ambiguous.
Implementation
--------------
- Each element's bytes now live in the same allocation as its node, via a
flexible array member. Halves the malloc/free traffic and puts the payload
in the same cache line as the next pointer. No caller had to change, which
is the point of the opacity.
- alignas(max_align_t) on that member is load-bearing: a flexible array of
unsigned char is only byte-aligned, but the payload may be a double.
test_alignment_of_payload pins it down and UBSan would catch a regression.
- ckd_add from C23's <stdckdint.h> guards sizeof(Node) + size. A wrapped total
would allocate a few bytes and then memcpy gigabytes into them.
- List::size renamed to List::count. A node's size is a byte count and a
list's was an element count, one screen apart in the same header.
- unlink_head and node_at factor the bookkeeping that pop/drop and
at/at_mut respectively used to duplicate. node_at returns a mutable Node*
from a const List* with no cast: constness of a struct does not propagate
to what its members point at.
Makefile
--------
`make CFLAGS=-O0` silently dropped -std=c23, -Iinclude and every warning,
because a command-line variable suppresses plain assignments to it in the
makefile. The project's flags are appended with `override` now, so a caller's
CFLAGS tunes the build instead of replacing it.
Tests
-----
Rewritten against the public API only -- enforced, not aspirational, since
list->head no longer compiles. 17 tests, 220 checks. New coverage for payload
alignment, in-place mutation, out-of-range indices, pop with a mismatched
size, clear-then-reuse, mixed element types, the ckd_add overflow path, and
single evaluation of the LIST_APPEND_LITERAL argument.
Docs
----
README rewritten as open-source documentation: badges, a full Makefile
reference (every target, every overridable variable, worked recipes), the C23
feature table, an API reference, and an honest caveats section. The example
compiles clean under -Werror. docs/C23.md gains the alignas and ckd_add
sections and drops the claim that neither was needed.
Verified on gcc-16 and clang, plain and under ASan+UBSan, 0 leaks under leaks.
The Makefile decided the shared-library extension and the runtime-name flag
from `uname -s`, which describes the machine running make. That is the wrong
question the moment you cross-compile: building for Linux from macOS emitted
a file named liblinkedlist.dylib linked with -install_name instead of a .so
with a -soname. Everything now keys off `$(CC) -dumpmachine`, which every GCC
and Clang answers with its actual target triple.
make CROSS_COMPILE=aarch64-linux-gnu-
make CROSS_COMPILE=arm-none-eabi- static
make CROSS_COMPILE=aarch64-linux-gnu- CC=aarch64-linux-gnu-gcc-14
CROSS_COMPILE follows the kernel convention, trailing dash included, and only
supplies defaults for CC and AR -- an explicit CC= still wins, which is what
the third form relies on, since Ubuntu ships the C23-capable cross compiler
version-suffixed. AR needed the same $(origin) test as CC: make defines it by
default, so `AR ?=` never fired and a cross build would have used the host ar.
Target handling:
darwin -> .dylib with -install_name @rpath/...
linux -> .so with -soname
other -> static archive only. `make build` drops to static, and
`make shared` refuses with an explanation rather than guessing an
extension and a soname flag that would be confidently wrong for
arm-none-eabi.
Running the results:
- RUNNER prefixes the test binary, so `RUNNER="qemu-aarch64 -L /usr/aarch64-
linux-gnu"` runs a cross build. It is a plain prefix, so ssh or docker
wrappers work too.
- `make test` on a cross build with no RUNNER now explains itself instead of
failing with "cannot execute binary file".
- `make test-build` builds the tests without running them.
- `make memcheck` refuses on a cross build. leaks and valgrind inspect a live
process on this machine and have nothing to say about a foreign binary.
- New `make toolchain` prints host, CC, AR, target triple, target OS, cross
status, runner and shared-library name, so a misdetection is one command
away rather than a puzzle.
CI gains a cross-compile job: builds for aarch64-linux-gnu with gcc-14,
asserts with file(1) that the output really is aarch64 ELF, and runs the whole
suite under qemu-user.
Verified locally against stub toolchains that report aarch64-unknown-linux-gnu
and arm-none-eabi, covering the naming, the soname flag, the static fallback,
both refusal paths, the RUNNER path, and CC-over-CROSS_COMPILE precedence.
Native gcc-16 and clang builds, sanitizers, leaks and install are unchanged.
Every failure was a wrong claim in this branch, not a flaky runner. GCC 14 was rejected by its own version guard -------------------------------------------- GCC 14.2 with -std=c23 reports __STDC_VERSION__ as the draft 202000L, not 202311L -- it shipped before C23 was published and GCC only bumped the macro in 15. The guard tested `>= 202311L` and so refused the newest compiler in Ubuntu 24.04 LTS, which took out the gcc-14, valgrind and cross-compile jobs. The guard now accepts GCC >= 14 at 202000L as a second, deliberately narrow clause: 202000L is also what GCC 13 reports for -std=c2x, and GCC 13 has neither constexpr nor [[reproducible]], so widening it further would produce exactly the error cascade the guard exists to prevent. Verified against an eight-case truth table covering GCC 13/14/15/16, Clang 17/18, and a missing __STDC_VERSION__. Clang 18 has -std=c23 but not C23 constexpr ------------------------------------------- The mirror image: Clang 18 reports the full 202311L and still fails on `constexpr size_t ITEM_COUNT = 10;`. That arrived in Clang 19. The stated minimum is Clang 19 now, and CI installs it from apt.llvm.org since Ubuntu 24.04 does not carry it. A version macro is evidence, not proof. clang-format version drift -------------------------- CI ran clang-format-18 against a tree formatted with 22, and the two disagree about where to break a call. Pinned to clang-format==22.1.8 from PyPI, which is the same binary on every platform and matches what the tree already uses, so no reformatting was needed. Contributors can match it exactly with `pipx install clang-format==22.1.8`. README and docs/C23.md updated: the requirements tables, the CI description, the quoted guard, and a note on both version-macro traps.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings the template up to a real C23 baseline, fixes three genuine bugs in the
list, makes the types opaque, and adds cross-compilation.
Warning
Breaking. The public API changed shape — see Migration.
🐞 Bugs fixed
Three were live in the library, all now covered by regression tests:
list_appendon an empty list linked the node to itselflist_destroynever returned. Masked because the old suite only ever appended ten at a timelist_prepend_value/list_append_valuedidnode->data = malloc(size); node->data = value;list_poplefttailat the freed nodeTwo more in the build:
$(BUILD)expands tobuild, which was also a phony target — make mergedthem, so every rule ordered after
| $(BUILD)silently inheritedstatic shared.make testwas linking a shared library it never used.make CFLAGS=-O0dropped-std=c23,-Iincludeand every warning. Acommand-line variable suppresses plain assignments to it in the makefile; the
project's flags are appended with
overridenow.🔒 Opaque types
ListandNodeare incomplete outside the implementation. Three things thatcompiled before are now compile errors — verified, not assumed:
That is the old build. The same program now fails with
invalid use of incomplete typedef 'List'.Hiding the layout forced the read API that was missing all along — previously
the only way to read an element was
list->head->data, so the struct layoutwas part of the ABI. It also paid for itself: each element's bytes now live in
the same allocation as its node via a flexible array member, halving the
malloc/free traffic. No caller had to change, which is the point.
🆕 C23
-std=c23(not thec2xdraft spelling),nullptr,bool/static_assert/typeof/alignaswith no supporting headers,constexpr,[[nodiscard]]throughout,
[[noreturn]],[[reproducible]], andckd_addfrom<stdckdint.h>guarding the allocation size.Two things worth flagging from the attributes work:
[[reproducible]]/[[unsequenced]]are function-type attributes, so theygo after the parameter list. The leading position is a constraint violation.
them with
__has_c_attribute, testing for non-zero rather than>= 202311L— Clang reports the C++ paper dates for the other five, so adate comparison would disable
[[nodiscard]]on a compiler that has it.docs/C23.mdhas the full write-up and a support matrix measured on realtoolchains.
🌍 Cross-compilation
The Makefile picked the shared-library extension from
uname -s, i.e. the host.Building for Linux from macOS produced
liblinkedlist.dylibwith-install_name. It now asks$(CC) -dumpmachine.Unrecognised targets still get a static archive;
make sharedrefuses with anexplanation rather than guessing.
make test/memcheckexplain themselves on across build instead of failing with "cannot execute binary file".
🧪 Tests
Rewritten against the public API only — enforced, since
list->headno longercompiles. 17 tests, 220 checks.
Bare
assertis gone:assert(list_append(...))compiles the call awayentirely under
-DNDEBUG, so the suite would pass while testing nothing.🛠️ Tooling
fallback are off because they cannot parse C23 attributes in C. cpptools stays
for the debugger.
launch.jsonversion corrected to0.2.0..clang-format,compile_flags.txt,Doxyfile,.editorconfig,.vscode/extensions.json.install/uninstallwithPREFIX/DESTDIR,sanitize,memcheck,format,docs,toolchain.-Werror -Wconversion -Wshadow -Wcast-qual -Wstrict-prototypes -Wvla; clean under both compilers.format, valgrind, doxygen and cross-compile jobs. The macOS leg is what
exercises the
__has_c_attributefallback.📖 Docs
README rewritten as open-source documentation: badges, full Makefile reference
(every target, every overridable variable, worked recipes), C23 feature table,
API reference, cross-compilation guide, and an honest caveats section — starting
with the fact that a singly linked list is usually the wrong data structure.
Migration
list_append_value(l, &v, sizeof v)list_append(l, &v, sizeof v)list_node_new+list_appendlist_append— nodes are no longer a caller concernlist->head->datalist_at(l, 0, &size)orlist_first/node_valueNode *n = list_pop(l)list_pop(l, &out, sizeof out)orlist_drop(l)list_node_destroy(n)Zero-size elements are now rejected: they came back from
list_atas a non-nullpointer to nothing, indistinguishable from a real element.
✅ Verified locally
gcc-16 and clang, plain and under ASan+UBSan,
leaksreporting 0 leaks,clang-format, Doxygen, staged install/uninstall, incremental rebuild, and theREADME example compiling clean under
-Werror. Cross-compilation logic checkedagainst stub toolchains reporting
aarch64-unknown-linux-gnuandarm-none-eabi.🤖 CI — all 7 jobs green
The first run failed and was worth it — it disproved two assumptions I had
written into the docs, both since fixed:
__STDC_VERSION__.-std=c23there defines202000L, not202311L; GCC only bumped the macro in 15. My guard rejectedthe newest compiler in Ubuntu 24.04 LTS. Now allowed via a narrow
__GNUC__ >= 14clause — narrow because GCC 13 also reports202000Lfor-std=c2xand lacksconstexprentirely.-std=c23but not C23constexpr. It reports the full202311Land still fails onconstexpr size_t ITEM_COUNT = 10;. That landedin Clang 19, so 19 is the stated minimum and CI installs it from
apt.llvm.org.
Also pinned
clang-format==22.1.8from PyPI — CI had been running 18 against atree formatted with 22, and they disagree.
🤖 Generated with Claude Code