🚀 A batteries-included starting point for a C23 library: build, tests, sanitizers, formatting, docs, editor, and CI already wired up.
The singly linked list it ships with is a worked example, not the point. Rip it out and put your own library in its place — the scaffolding is what you are cloning this for.
- ✨ What you get
- 🆕 Why C23
- 📋 Requirements
- ⚡ Quick start
- 🧰 The Makefile, in full
- 🌍 Cross-compiling
- 📚 Using the library
- 🗂️ Project layout
- 🧑💻 Editor setup
- 🧪 Testing, sanitizers, and leaks
- 🎨 Formatting
- 📖 API documentation
- 🤖 Continuous integration
- 🛠️ Making it your own
⚠️ Honest caveats- 🤝 Contributing
- 📄 License
| 🎯 Real C23 | -std=c23, not the -std=c2x draft spelling. nullptr, constexpr, typeof, alignas, [[attributes]], <stdckdint.h> |
| 📦 Two library flavours | Static .a and shared .dylib/.so from one set of -fPIC objects |
| 🔒 Opaque types | List and Node are incomplete outside the implementation, so callers physically cannot corrupt them |
| 🧪 Tests that can't lie | No bare assert — the harness survives -DNDEBUG |
| 🧹 Sanitizers | make sanitize for ASan + UBSan, into its own build tree |
| 💧 Leak checks | make memcheck → leaks on macOS, valgrind on Linux |
| 🧠 Smart rebuilds | -MMD -MP header dependency tracking — touch a header, rebuild exactly what used it |
| 🖊️ One formatting truth | .clang-format, enforced by CI |
| 📖 Docs | Doxygen, generated into build/docs |
| 🧑💻 Editor that understands C23 | clangd-driven IntelliSense, because cpptools' engine cannot parse C23 attributes |
| 🤖 CI across three compilers | GCC 14, Clang 19, and Apple Clang |
| 🌍 Cross-compilation | CROSS_COMPILE= prefix, target detected from the compiler, RUNNER= for qemu |
| 📥 Installable | make install honouring PREFIX and DESTDIR |
This template exists to be genuinely C23, not C17 with a newer flag. Here is what that actually means in the code you are about to read.
| Feature | What changed | Where it is used |
|---|---|---|
🕳️ nullptr |
A real typed null constant. Unlike NULL it never silently becomes an int in a variadic call |
Everywhere |
✅ bool / true / false |
Now keywords — no <stdbool.h> |
Everywhere |
📐 static_assert |
Now a keyword — no <assert.h>. The message is optional |
tests/main.c |
🧊 constexpr |
A named constant with a real type, usable in constant expressions (an enum gives you int; a const size_t is not a constant expression) |
ITEM_COUNT in tests/main.c |
🔍 typeof |
A GNU extension for 30 years, now standard — so it survives -Wpedantic |
LIST_APPEND_LITERAL |
📏 alignas |
Now a keyword — no <stdalign.h> |
struct Node's flexible array member |
➕ <stdckdint.h> |
ckd_add / ckd_sub / ckd_mul report integer overflow instead of producing it |
Guarding the allocation size |
🏷️ [[attributes]] |
The C++ bracket syntax, with seven standard attributes | See below |
| 🚫 K&R declarations | Removed. An empty () now means (void) |
All prototypes |
C23 defines seven standard attributes. Five behave the way you would expect from C++. The other two do not:
[[nodiscard]] size_t list_size(const List *list) [[reproducible]]; // ✅ correct
[[reproducible]] size_t list_size(const List *list); // ❌ wrong[[reproducible]] and [[unsequenced]] attach to the function type, not
the declaration, so they go after the parameter list. GCC rejects the
leading form outright.
They are also the two attributes Clang does not implement yet, which is why the header feature-detects them:
#if defined(__has_c_attribute)
# if __has_c_attribute(reproducible)
# define LINKEDLIST_REPRODUCIBLE [[reproducible]]
# endif
#endif
⚠️ Test for non-zero, never>= 202311L.__has_c_attributereturns the attribute's standardisation date, and Clang reports the C++ paper dates —nodiscardcomes back as202003. A>= 202311Lcheck would disable[[nodiscard]]on a compiler that supports it perfectly well.
📘 docs/C23.md has the full treatment: every attribute, the
compiler support matrix measured on real toolchains, the reproducible vs
unsequenced distinction, and the features deliberately left out.
include/linkedlist.h refuses to compile under an older
standard, rather than burying you in syntax errors on nullptr:
#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L
// C23 as published. Nothing to do.
#elif defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && \
defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202000L
// GCC 14 in -std=c23 mode: the macro says draft, the compiler says C23.
#else
#error "linkedlist.h requires C23. Compile with -std=c23 (GCC >= 14, Clang >= 19)."
#endif🪤 Version macros lie in both directions, which is why this is not a one-liner. GCC 14 implements everything here but still reports the draft
__STDC_VERSION__of202000L, because it shipped before C23 was published — GCC bumped it in 15. Meanwhile Clang 18 reports the full202311Land yet has no C23constexpr; that landed in Clang 19. CI caught both.
| Tool | Version | Needed for |
|---|---|---|
| 🔨 GCC | ≥ 14 | -std=c23 |
| 🔨 or Clang | ≥ 18 | -std=c23 |
| 🐚 GNU Make | ≥ 3.81 | the build |
| 🖊️ clang-format | any | make format |
| 📖 Doxygen | any | make docs |
| 💧 valgrind | any | make memcheck on Linux (macOS uses the built-in leaks) |
💡 Only GCC currently implements
[[reproducible]]and[[unsequenced]]. Everything still compiles on Clang — the header feature-detects them.
macOS 🍎
brew install gcc doxygen
pipx install clang-format==22.1.8Debian / Ubuntu 🐧
sudo apt-get install gcc-14 doxygen valgrind
pipx install clang-format==22.1.8git clone https://github.com/slashdevops/c-library-template.git
cd c-library-template
make # 📦 build the static + shared libraries
make test # 🧪 build and run the test suite
make help # 📋 list every targetExpected output from make test:
Running tests with gcc-16...
ok test_new_list_is_empty
ok test_append_to_empty_list
...
17 tests, 220 checks, 0 failed
Everything goes through the Makefile — the editor tasks and CI both shell out
to it, so there is exactly one place where -std=c23 is defined and nothing
can drift.
Run make help for this list at any time.
| Target | Emoji | What it does |
|---|---|---|
make / make all |
📦 | Build the static and shared libraries |
make build |
📦 | Same thing, spelled out |
make static |
🧱 | Static library only → build/lib/liblinkedlist.a |
make shared |
🔗 | Shared library only → build/lib/liblinkedlist.{dylib,so} |
make test |
🧪 | Build and run the test suite |
make sanitize |
🧹 | Run the tests under AddressSanitizer + UndefinedBehaviorSanitizer |
make memcheck |
💧 | Run the tests under leaks (macOS) or valgrind (Linux) |
make format |
🖊️ | Rewrite every source in place using .clang-format |
make format-check |
🔍 | Fail if anything is unformatted — this is what CI runs |
make docs |
📖 | Generate HTML API docs into build/docs/html |
make docs-open |
🌐 | Generate the docs and open them in a browser |
make install |
📥 | Install headers and libraries under $PREFIX |
make uninstall |
🗑️ | Undo make install |
make clean |
🧽 | Delete build/ |
make help |
📋 | Print the target list |
All of these work as make VAR=value target.
| Variable | Default | Purpose |
|---|---|---|
CC |
newest Homebrew gcc-NN, else cc (macOS) / gcc (Linux) |
The compiler |
CFLAGS |
-O2 -g |
Your flags. The project's own (-std=c23, warnings, -I) are appended with override and always survive |
LDFLAGS |
empty | Extra linker flags |
LDLIBS |
empty | Extra libraries to link |
AR |
ar |
Archiver for the static library |
SANITIZE |
unset | SANITIZE=1 enables ASan + UBSan and redirects the build to build/sanitize/ |
PREFIX |
/usr/local |
Install root |
DESTDIR |
empty | Staging root, prepended to every install path (for packagers) |
INCLUDEDIR |
$(PREFIX)/include |
Where the header goes |
LIBDIR |
$(PREFIX)/lib |
Where the libraries go |
FORMAT |
clang-format |
Formatter binary — handy for clang-format-18 on CI |
DOXYGEN |
doxygen |
Doc generator binary |
MEMCHECK |
leaks / valgrind |
Memory checker |
# 🔄 Build with a different compiler
make CC=clang test
make CC=gcc-14 test
# 🐞 Unoptimised debug build (project flags are preserved)
make CFLAGS="-O0 -g3" test
# ⚡ Parallel build
make -j$(sysctl -n hw.ncpu) # macOS
make -j$(nproc) # Linux
# 📥 Install into your home directory
make install PREFIX="$HOME/.local"
# 📦 Stage into a package root
make install DESTDIR=/tmp/stage PREFIX=/usr
# 🧹 Sanitizer run (does not disturb the normal build)
make sanitize
⚠️ make CFLAGS=…used to silently drop-std=c23and-Iinclude, because a command-line variable normally suppresses every plain assignment to it in the makefile. The project flags are appended withoverridespecifically to prevent that. It is a trap worth knowing about in any Makefile you write.
Yes — with any GCC or Clang cross toolchain. Use CROSS_COMPILE, spelled the
way the Linux kernel and buildroot spell it, trailing dash included:
# 🐧 Linux/arm64 from anywhere
make CROSS_COMPILE=aarch64-linux-gnu-
# 🔩 Bare metal — static archive only
make CROSS_COMPILE=arm-none-eabi- static
# 🎯 Prefix for the binutils, explicit CC for a version-suffixed compiler
make CROSS_COMPILE=aarch64-linux-gnu- CC=aarch64-linux-gnu-gcc-14CROSS_COMPILE only supplies defaults for CC and AR, so an explicit
CC= on the command line still wins — that last example is exactly what CI
does, because Ubuntu ships the C23-capable cross compiler as
aarch64-linux-gnu-gcc-14.
make toolchain host Darwin (darwin)
CC aarch64-linux-gnu-gcc
AR aarch64-linux-gnu-ar
target aarch64-unknown-linux-gnu
target OS linux
cross yes
runner <direct>
shared lib liblinkedlist.so
build dir build
The Makefile asks the compiler, not the kernel:
TARGET_TRIPLE := $(shell $(CC) -dumpmachine)🐛 This used to be
uname -s, which describes the machine runningmake— the wrong answer the moment you cross-compile. Building for Linux from a Mac produced a file calledliblinkedlist.dyliblinked with-install_nameinstead of a.sowith a-soname. Every GCC and Clang answers-dumpmachinewith its target triple, so that is the honest source.
| Target triple contains | Shared library | Runtime-name flag |
|---|---|---|
darwin |
liblinkedlist.dylib |
-Wl,-install_name,@rpath/… |
linux |
liblinkedlist.so |
-Wl,-soname,… |
| anything else | ❌ refused | — |
Unrecognised targets (bare metal, mingw, wasi…) still build a perfectly good
static archive, and make build quietly drops to static for them. make shared refuses with an explanation rather than guessing an extension and a
soname flag that would be confidently wrong.
A cross-built binary will not execute on your host, so make test stops with
an explanation instead of a bare "cannot execute binary file". Point RUNNER
at an emulator to actually run them:
make test CROSS_COMPILE=aarch64-linux-gnu- \
RUNNER="qemu-aarch64 -L /usr/aarch64-linux-gnu"RUNNER is just a prefix, so an ssh or docker wrapper works the same way. To
build the tests without running them at all:
make test-build CROSS_COMPILE=aarch64-linux-gnu-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.
# Debian / Ubuntu — Linux/arm64
sudo apt-get install gcc-14-aarch64-linux-gnu qemu-user-static
# macOS — bare metal targets
brew install aarch64-elf-gcc arm-none-eabi-gcc🍎 Targeting Linux from macOS needs a full sysroot with glibc, which Homebrew does not provide. Use Docker, crosstool-NG, or Zig (
zig cc -target aarch64-linux-gnu) for that combination.
build/
├── obj/
│ ├── linkedlist.o library objects (-fPIC)
│ ├── linkedlist.d generated header dependencies
│ └── tests/main.o
├── lib/
│ ├── liblinkedlist.a static
│ └── liblinkedlist.dylib shared (.so on Linux)
├── test_linkedlist the test binary
├── docs/html/ Doxygen output
└── sanitize/ a complete parallel tree for SANITIZE=1
Everything generated lives under build/, so .gitignore needs one entry and
make clean is a single rm -rf.
Sanitizer builds get their own tree because they compile with different
flags. Sharing one directory would leave make test and make sanitize
trading the same .o files back and forth, each silently reusing the other's
objects.
-MMD -MP writes a .d file next to every object listing the headers it
included, and the Makefile pulls them in with -include. Touch
include/linkedlist.h and exactly the objects that included it get rebuilt —
no make clean reflex required. -MP adds dummy rules so that deleting a
header does not break the build with "no rule to make target".
Just create them. src/*.c and tests/*.c are globbed, so a new file is
picked up with no Makefile change.
#include <linkedlist.h>
#include <stdio.h>
int main(void) {
List *list = list_new();
if (list == nullptr) {
return 1;
}
// ➕ Values are COPIED in. `value` can go out of scope immediately.
// Every fallible call is [[nodiscard]], so the compiler makes you look.
const int value = 42;
if (!list_append(list, &value, sizeof value)) {
list_destroy(list);
return 1;
}
// 🪄 Or skip the temporary entirely — typeof works the size out.
if (!LIST_APPEND_LITERAL(list, 3.5)) {
list_destroy(list);
return 1;
}
// 🔁 Walk it with a cursor.
for (const Node *n = list_first(list); n != nullptr; n = node_next(n)) {
printf("element of %zu bytes\n", node_size(n));
}
// 🎯 Or index into it.
size_t size = 0;
const int *first = list_at(list, 0, &size);
printf("first = %d (%zu bytes)\n", *first, size);
// 📤 Pop copies the element out and removes it.
int popped = 0;
if (list_pop(list, &popped, sizeof popped)) {
printf("popped %d\n", popped);
}
list_destroy(list); // 🧹 frees the list and everything still in it
}Compile against it:
# static
cc -std=c23 -Iinclude example.c build/lib/liblinkedlist.a -o example
# or after `make install PREFIX=$HOME/.local`
cc -std=c23 -I"$HOME/.local/include" example.c -L"$HOME/.local/lib" -llinkedlist -o exampleLifecycle 🔄
| Function | Description |
|---|---|
List *list_new(void) |
Allocate an empty list, or nullptr on failure |
void list_destroy(List *) |
Free the list and everything in it. nullptr is a no-op |
void list_clear(List *) |
Empty it but keep it usable |
Insertion ➕ — both copy size bytes out of value
| Function | Description |
|---|---|
bool list_append(List *, const void *value, size_t size) |
Add to the back, O(1) |
bool list_prepend(List *, const void *value, size_t size) |
Add to the front, O(1) |
LIST_APPEND_LITERAL(list, expr) |
Append an expression without naming its type |
Queries 🔍
| Function | Description |
|---|---|
size_t list_size(const List *) |
Element count |
bool list_is_empty(const List *) |
Count is zero |
const void *list_at(const List *, size_t index, size_t *size) |
Borrow element index, or nullptr |
void *list_at_mut(List *, size_t index, size_t *size) |
Same, writable |
Iteration 🔁
| Function | Description |
|---|---|
const Node *list_first(const List *) |
Cursor to the first element |
const Node *node_next(const Node *) |
Advance, or nullptr at the end |
const void *node_value(const Node *) |
The element's bytes |
size_t node_size(const Node *) |
The element's size |
Removal ➖
| Function | Description |
|---|---|
bool list_pop(List *, void *out, size_t size) |
Copy the first element out and remove it |
bool list_drop(List *) |
Remove the first element without copying |
- 📋 Everything is copied.
list_appendandlist_prependreadsizebytes out of your value. You keep the original and may free it immediately. No function in this API transfers ownership in either direction. - 🧹 One thing to remember: call
list_destroy. - ⏳ Borrowed pointers and cursors are invalidated by any insertion or
removal. Use
list_popif you need the bytes to outlive the list. - 🔬
list_popchecks the size. A mismatch means the wrong type, so the call fails and the element stays put rather than handing you a partial read. - 🚫 Zero-size elements are rejected. They would come back from
list_atas a non-null pointer to nothing, indistinguishable from a real element.
You cannot dereference a List *, take its sizeof, or allocate one yourself.
That is deliberate. An earlier version of this header defined both structs in
full, and three things followed:
| ❌ Was possible | ✅ Now |
|---|---|
| Appending the same node twice — count said 2, one node pointed at itself, teardown never terminated | Won't compile |
Handing over a hand-rolled malloc(sizeof(Node)) with an uninitialised payload pointer, which teardown passed to free |
Won't compile |
Reading via list->head->data, making the struct layout part of the ABI |
Won't compile |
Hiding the layout also paid for itself: each element's bytes now live in the
same allocation as its node (a flexible array member), halving the calls to
malloc and free and putting the payload in the same cache line as the
next pointer that led to it. Not one caller had to change.
├── 📄 include/linkedlist.h public header — the only file consumers need
├── 🔧 src/linkedlist.c implementation, incl. the hidden struct definitions
├── 🧪 tests/main.c test suite and its runner
├── 📘 docs/C23.md which C23 features are used, and why
├── 🧰 Makefile the whole build
├── 🖊️ .clang-format formatting rules, enforced by CI
├── 🧑💻 compile_flags.txt flags clangd applies to every file
├── 📖 Doxyfile API doc configuration
├── ⚙️ .editorconfig editor-agnostic whitespace baseline
├── 🆚 .vscode/ tasks, launch configs, clangd settings
├── 🤖 .github/workflows/ CI
└── 🏗️ build/ everything generated; gitignored, safe to delete
Accept the recommended extensions when VS Code offers them (.vscode/extensions.json). The important one is clangd.
🚨 IntelliSense is clangd's job here, and the cpptools engine is switched off. That is not a preference. cpptools — and especially its Tag Parser fallback — does not parse
nullptr,constexpr, or[[nodiscard]]in C, so it marks correct C23 code as broken. cpptools stays installed because its debugger is whatlaunch.jsonuses.
clangd reads compile_flags.txt, so it needs no build
integration. Keep -std=c23 in that file in sync with the Makefile. If the
project grows per-file flags, generate a real compile_commands.json with
bear -- make and delete compile_flags.txt — clangd prefers the former when
both exist.
| Shortcut | Action |
|---|---|
Cmd/Ctrl+Shift+B |
Build |
F5 |
Debug the test binary (lldb on macOS, gdb on Linux) |
Cmd/Ctrl+Shift+P → Run Task |
Any Makefile target |
There is also a Debug sanitizer build launch configuration, which runs
make sanitize first and stops on the exact line ASan or UBSan reports.
make test # 🧪 plain run
make sanitize # 🧹 ASan + UBSan
make memcheck # 💧 leaks (macOS) / valgrind (Linux)assert(list_append(list, &value, sizeof value)); // ☠️ silently vanishes under -DNDEBUGassert expands to nothing when NDEBUG is defined, taking the call itself
with it. The suite would pass while testing nothing. The CHECK macro here is a
real function call that runs in every build, counts assertions, and reports a
count you can sanity-check:
17 tests, 220 checks, 0 failed
💧 valgrind and ASan both hook the allocator and refuse to share a process, so
make memcheckdeliberately runs against a plain build, not a sanitizer one.
make format # 🖊️ rewrite everything in place
make format-check # 🔍 fail if anything is unformatted (CI runs this)Rules live in .clang-format — LLVM style, 2-space indent, 100-column limit. VS Code formats on save via clangd.
📌 The version is pinned. clang-format's output changes between major releases, so CI installs exactly
clang-format==22.1.8from PyPI. Match it locally withpipx install clang-format==22.1.8(the pip package is the same binary on every platform), or expectmake formatto fight with CI.
Every public declaration carries Doxygen comments.
brew install doxygen # once
make docs # 📖 generate build/docs/html
make docs-open # 🌐 generate and openThis README becomes the landing page. Output goes under build/, so it is
never committed, and CI uploads it as an artifact on every run.
.github/workflows/c-build.yml runs on every push and pull request:
| Job | What it proves |
|---|---|
| 🖊️ format | clang-format is happy |
| 🧪 test | Builds, tests, and sanitizes on Ubuntu (GCC 14, Clang 18) and macOS (Apple Clang), then does a staged make install / make uninstall |
| 💧 memcheck | valgrind against a non-sanitizer build |
| 📖 docs | Doxygen builds, uploaded as an artifact |
🍎 The macOS leg is not redundant. Apple Clang has C23 but not
[[reproducible]]— it is the job that proves the__has_c_attributefallback in the header actually works.
- 🏷️ Rename
LIB_NAMEin the Makefile. The archive, shared library, soname, and install paths all follow from it. - ✍️ Replace
include/linkedlist.h,src/linkedlist.c, andtests/main.c. - 📖 Update
PROJECT_NAMEandPROJECT_BRIEFin the Doxyfile. - 🐞 Point
programin .vscode/launch.json at your new test binary name. - 🔨 Adjust
compilerPathin .vscode/c_cpp_properties.json to a compiler you actually have. - 🔤 Consider prefixing your public names.
ListandNodeare fine for a template but far too generic to drop into a consumer's namespace — a real library would usell_list_t,ll_append, and so on.
➕ Adding a .c file to src/ or tests/ needs no Makefile change.
Worth saying out loud, since this is a template people will copy:
- 📉 A singly linked list is usually the wrong data structure. A dynamic array beats it on nearly every real workload — better cache behaviour, fewer allocations, cheaper iteration. It is here because it exercises the scaffolding, not because it is a recommendation.
- 🔢 The shared library is unversioned. It records a soname of
liblinkedlist.so(install name@rpath/liblinkedlist.dylib). Fine for a template and for static linking. Anything you actually distribute needs a version in the soname plus the usual symlink chain, so consumers keep working when you break ABI. - 🧵 Nothing here is thread-safe. Concurrent access needs your own locking.
- 🐢
list_atis O(n), so walking with it is O(n²). Uselist_first/node_nextfor a full traversal. - 🧬 There is no type safety.
void *plus a byte count is the C way, but nothing stops you storing anintand reading adouble.list_popchecks the size; the borrow functions leave it to you.
PRs welcome. Before you open one:
make format-check # 🖊️ formatting
make test # 🧪 tests pass
make sanitize # 🧹 clean under ASan + UBSan
make memcheck # 💧 no leaksCI runs all four across GCC 14, Clang 19, and Apple Clang, so it is worth checking at least two compilers locally:
make clean && make test
make clean && make CC=clang testApache License 2.0 — see LICENSE.