Skip to content

Repository files navigation

🧩 c-library-template

C CI Standard: C23 License: Apache 2.0 Platforms

🚀 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.


📑 Table of contents


✨ What you get

🎯 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 memcheckleaks 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

🆕 Why C23

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.

🔑 The language features, and where they earn their place

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

🏷️ Attributes: the part people get wrong

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_attribute returns the attribute's standardisation date, and Clang reports the C++ paper dates — nodiscard comes back as 202003. A >= 202311L check 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.

🧯 The C23 guard

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__ of 202000L, because it shipped before C23 was published — GCC bumped it in 15. Meanwhile Clang 18 reports the full 202311L and yet has no C23 constexpr; that landed in Clang 19. CI caught both.


📋 Requirements

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.8

Debian / Ubuntu 🐧

sudo apt-get install gcc-14 doxygen valgrind
pipx install clang-format==22.1.8

⚡ Quick start

git 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 target

Expected 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

🧰 The Makefile, in full

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.

🎯 Targets

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

⚙️ Variables you can override

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

💡 Recipes

# 🔄 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=c23 and -Iinclude, because a command-line variable normally suppresses every plain assignment to it in the makefile. The project flags are appended with override specifically to prevent that. It is a trap worth knowing about in any Makefile you write.

🌍 Cross-compiling

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-14

CROSS_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.

🔎 Check what got resolved

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

🧭 How the target is detected

The Makefile asks the compiler, not the kernel:

TARGET_TRIPLE := $(shell $(CC) -dumpmachine)

🐛 This used to be uname -s, which describes the machine running make — the wrong answer the moment you cross-compile. Building for Linux from a Mac produced a file called liblinkedlist.dylib linked with -install_name instead of a .so with a -soname. Every GCC and Clang answers -dumpmachine with 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.

🏃 Running cross-built tests

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.

📦 Getting a toolchain

# 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.

🏗️ How the build tree is laid out

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.

🧠 Dependency tracking

-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".

➕ Adding source files

Just create them. src/*.c and tests/*.c are globbed, so a new file is picked up with no Makefile change.


📚 Using the library

🎬 A complete example

#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 example

📖 API at a glance

Lifecycle 🔄

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

📜 The rules

  1. 📋 Everything is copied. list_append and list_prepend read size bytes out of your value. You keep the original and may free it immediately. No function in this API transfers ownership in either direction.
  2. 🧹 One thing to remember: call list_destroy.
  3. Borrowed pointers and cursors are invalidated by any insertion or removal. Use list_pop if you need the bytes to outlive the list.
  4. 🔬 list_pop checks the size. A mismatch means the wrong type, so the call fails and the element stays put rather than handing you a partial read.
  5. 🚫 Zero-size elements are rejected. They would come back from list_at as a non-null pointer to nothing, indistinguishable from a real element.

🔒 Why List and Node are opaque

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.


🗂️ Project layout

├── 📄 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

🧑‍💻 Editor setup

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 what launch.json uses.

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+PRun 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.


🧪 Testing, sanitizers, and leaks

make test        # 🧪 plain run
make sanitize    # 🧹 ASan + UBSan
make memcheck    # 💧 leaks (macOS) / valgrind (Linux)

🚫 Why the tests avoid assert

assert(list_append(list, &value, sizeof value));   // ☠️ silently vanishes under -DNDEBUG

assert 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 memcheck deliberately runs against a plain build, not a sanitizer one.


🎨 Formatting

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.8 from PyPI. Match it locally with pipx install clang-format==22.1.8 (the pip package is the same binary on every platform), or expect make format to fight with CI.


📖 API documentation

Every public declaration carries Doxygen comments.

brew install doxygen   # once
make docs              # 📖 generate build/docs/html
make docs-open         # 🌐 generate and open

This README becomes the landing page. Output goes under build/, so it is never committed, and CI uploads it as an artifact on every run.


🤖 Continuous integration

.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_attribute fallback in the header actually works.


🛠️ Making it your own

  1. 🏷️ Rename LIB_NAME in the Makefile. The archive, shared library, soname, and install paths all follow from it.
  2. ✍️ Replace include/linkedlist.h, src/linkedlist.c, and tests/main.c.
  3. 📖 Update PROJECT_NAME and PROJECT_BRIEF in the Doxyfile.
  4. 🐞 Point program in .vscode/launch.json at your new test binary name.
  5. 🔨 Adjust compilerPath in .vscode/c_cpp_properties.json to a compiler you actually have.
  6. 🔤 Consider prefixing your public names. List and Node are fine for a template but far too generic to drop into a consumer's namespace — a real library would use ll_list_t, ll_append, and so on.

➕ Adding a .c file to src/ or tests/ needs no Makefile change.


⚠️ Honest caveats

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_at is O(n), so walking with it is O(n²). Use list_first / node_next for a full traversal.
  • 🧬 There is no type safety. void * plus a byte count is the C way, but nothing stops you storing an int and reading a double. list_pop checks the size; the borrow functions leave it to you.

🤝 Contributing

PRs welcome. Before you open one:

make format-check   # 🖊️ formatting
make test           # 🧪 tests pass
make sanitize       # 🧹 clean under ASan + UBSan
make memcheck       # 💧 no leaks

CI 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 test

📄 License

Apache License 2.0 — see LICENSE.

About

C library template

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages