Skip to content

Modernize the template to C23: opaque API, cross-compilation, and three bug fixes - #5

Merged
christiangda merged 5 commits into
mainfrom
feat/c23-modernization
Aug 9, 2026
Merged

Modernize the template to C23: opaque API, cross-compilation, and three bug fixes#5
christiangda merged 5 commits into
mainfrom
feat/c23-modernization

Conversation

@christiangda

@christiangda christiangda commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

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:

Bug Effect
list_append on an empty list linked the node to itself One-element lists were circular; list_destroy never returned. Masked because the old suite only ever appended ten at a time
list_prepend_value/list_append_value did node->data = malloc(size); node->data = value; Leaked the buffer and aliased caller-owned memory instead of copying
list_pop left tail at the freed node The next append wrote through a stale pointer

Two more in the build:

  • $(BUILD) expands to build, which was also a phony target — make merged
    them, so every rule ordered after | $(BUILD) silently inherited
    static shared. make test was linking a shared library it never used.
  • make CFLAGS=-O0 dropped -std=c23, -Iinclude and every warning. A
    command-line variable suppresses plain assignments to it in the makefile; the
    project's flags are appended with override now.

🔒 Opaque types

List and Node are incomplete outside the implementation. Three things that
compiled before are now compile errors — verified, not assumed:

same node appended twice -> list_size = 2
  head == tail ? yes   head->next == head ? yes (self-cycle)
raw malloc'd node accepted by list_prepend? yes

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 layout
was 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 the c2x draft spelling), nullptr, bool/static_assert/
typeof/alignas with no supporting headers, constexpr, [[nodiscard]]
throughout, [[noreturn]], [[reproducible]], and ckd_add from
<stdckdint.h> guarding the allocation size.

Two things worth flagging from the attributes work:

  • [[reproducible]]/[[unsequenced]] are function-type attributes, so they
    go after the parameter list. The leading position is a constraint violation.
  • They are also the two Clang does not implement. The header feature-detects
    them with __has_c_attribute, testing for non-zero rather than
    >= 202311L — Clang reports the C++ paper dates for the other five, so a
    date comparison would disable [[nodiscard]] on a compiler that has it.

docs/C23.md has the full write-up and a support matrix measured on real
toolchains.

🌍 Cross-compilation

The Makefile picked the shared-library extension from uname -s, i.e. the host.
Building for Linux from macOS produced liblinkedlist.dylib with
-install_name. It now asks $(CC) -dumpmachine.

make CROSS_COMPILE=aarch64-linux-gnu-
make CROSS_COMPILE=arm-none-eabi- static
make toolchain            # show what got resolved
make test CROSS_COMPILE=aarch64-linux-gnu- RUNNER="qemu-aarch64 -L /usr/aarch64-linux-gnu"

Unrecognised targets still get a static archive; make shared refuses with an
explanation rather than guessing. make test/memcheck explain themselves on a
cross build instead of failing with "cannot execute binary file".

🧪 Tests

Rewritten against the public API only — enforced, since list->head no longer
compiles. 17 tests, 220 checks.

Bare assert is gone: assert(list_append(...)) compiles the call away
entirely under -DNDEBUG, so the suite would pass while testing nothing.

🛠️ Tooling

  • clangd drives IntelliSense; the cpptools engine and its Tag Parser
    fallback are off because they cannot parse C23 attributes in C. cpptools stays
    for the debugger. launch.json version corrected to 0.2.0.
  • New: .clang-format, compile_flags.txt, Doxyfile, .editorconfig,
    .vscode/extensions.json.
  • Static and shared libraries, install/uninstall with PREFIX/DESTDIR,
    sanitize, memcheck, format, docs, toolchain.
  • Warning set now -Werror -Wconversion -Wshadow -Wcast-qual -Wstrict-prototypes -Wvla; clean under both compilers.
  • CI matrix over Ubuntu GCC 14 / Clang 18 / macOS Apple Clang, plus separate
    format, valgrind, doxygen and cross-compile jobs. The macOS leg is what
    exercises the __has_c_attribute fallback.

📖 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

Before After
list_append_value(l, &v, sizeof v) list_append(l, &v, sizeof v)
list_node_new + list_append list_append — nodes are no longer a caller concern
list->head->data list_at(l, 0, &size) or list_first/node_value
Node *n = list_pop(l) list_pop(l, &out, sizeof out) or list_drop(l)
list_node_destroy(n) gone — nothing to own

Zero-size elements are now rejected: they came back from list_at as a non-null
pointer to nothing, indistinguishable from a real element.

✅ Verified locally

gcc-16 and clang, plain and under ASan+UBSan, leaks reporting 0 leaks,
clang-format, Doxygen, staged install/uninstall, incremental rebuild, and the
README example compiling clean under -Werror. Cross-compilation logic checked
against stub toolchains reporting aarch64-unknown-linux-gnu and
arm-none-eabi.

🤖 CI — all 7 jobs green

Job Result
clang-format
test (ubuntu, gcc-14)
test (ubuntu, clang-19)
test (macos, Apple Clang)
cross-compile (aarch64-linux-gnu) ✅ built aarch64 ELF, 220 checks under qemu
valgrind
doxygen

The first run failed and was worth it — it disproved two assumptions I had
written into the docs, both since fixed:

  • GCC 14 reports the draft __STDC_VERSION__. -std=c23 there defines
    202000L, not 202311L; GCC only bumped the macro in 15. My guard rejected
    the newest compiler in Ubuntu 24.04 LTS. Now allowed via a narrow
    __GNUC__ >= 14 clause — narrow because GCC 13 also reports 202000L for
    -std=c2x and lacks constexpr entirely.
  • Clang 18 has -std=c23 but not C23 constexpr. It reports the full
    202311L and still fails on constexpr size_t ITEM_COUNT = 10;. That landed
    in Clang 19, so 19 is the stated minimum and CI installs it from
    apt.llvm.org.

Also pinned clang-format==22.1.8 from PyPI — CI had been running 18 against a
tree formatted with 22, and they disagree.

🤖 Generated with Claude Code

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.
@christiangda christiangda self-assigned this Aug 9, 2026
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.
@christiangda
christiangda merged commit 5f13b75 into main Aug 9, 2026
7 checks passed
@christiangda
christiangda deleted the feat/c23-modernization branch August 9, 2026 13:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant