Modern C in 2026 (C23)
C is the language everything compiles down to — or compiles through. The Linux kernel, every major operating system core, virtually every cryptographic library, every language runtime that matters, and the firmware running inside the device you are reading this on is C. It is also a language where a single typo can corrupt memory silently, hand an attacker shell, and leave no trace in the process. The C23 standard — officially ISO/IEC 9899:2024, published October 2024 — is the most significant revision since C11, addressing a surprising number of these footguns while adding genuinely useful primitives. But the standard by itself does not make C safe. The modern answer is layered: a better language standard, a disciplined compiler flag set, dynamic instrumentation through sanitizers, and coverage-guided fuzzing that lets machines find the bugs your eyes miss.
The thesis here is not that C23 closes the gap with Rust on memory safety — it does not, and it cannot without abandoning C’s core identity. The thesis is that C written with the full 2026 toolchain — C23 features, -fsanitize=address,undefined, AFL++ or libFuzzer in CI, -D_FORTIFY_SOURCE=3, and static analysis in the pipeline — is meaningfully safer than C written five years ago, and that the gap between “C project” and “exploitable C project” is almost entirely explained by teams who skip this toolchain.
What C23 Actually Adds
C23 was ratified after years of grinding through the ISO WG14 committee process, with the final text published in late 2024. GCC 15 made -std=gnu23 the default C dialect — if you compile with GCC 15 and no -std= flag, you are already in C23 mode. Clang supports -std=c23 from Clang 18 onward; Clang 19 added #embed. Neither compiler implements 100% of the standard, but the most practically useful features are available now.
nullptr and nullptr_t
C inherited null pointer handling from C89: NULL is a macro that expands to 0 or (void*)0 depending on context, creating silent implicit conversions between integers and pointers. C23 adds nullptr, a keyword constant of the new type nullptr_t (defined in <stddef.h>). nullptr is implicitly convertible to any pointer type and to bool, but not to integer types — the class of bugs where NULL silently compared equal to 0 as an integer no longer compiles cleanly.
|
|
constexpr Objects
C++ has had constexpr since C++11. C23 adds it for objects (not functions — that is still C++-only). A constexpr object must be initialized with a constant expression and is implicitly static and const. This replaces the #define pattern for typed compile-time constants, giving you type checking, scoping, and the ability to use the constant in _Static_assert.
|
|
typeof and typeof_unqual
typeof(expr) returns the type of an expression without evaluating it, enabling truly type-safe macros. typeof_unqual strips top-level qualifiers (const, volatile, restrict). These were GCC extensions for decades; C23 standardizes them.
|
|
Previously this macro required __typeof__ for portability or fell back to the dangerous void-pointer swap that silently accepted mismatched types.
Checked Integer Arithmetic: <stdckdint.h>
Integer overflow is one of the most common sources of security vulnerabilities in C. Signed overflow is undefined behavior; unsigned overflow wraps silently; both create exploitable conditions in allocation size calculations, array index arithmetic, and protocol parsing. C23 adds <stdckdint.h> with three type-generic macros that perform the operation at infinite mathematical precision and report whether the result overflowed:
|
|
ckd_add, ckd_sub, and ckd_mul each write the wrapped result to the output pointer and return true if overflow occurred. The macros work across signed and unsigned types of differing widths, which was previously impossible without a library or carefully audited inline code.
#embed for Binary Resource Inclusion
Before C23, embedding a binary blob (a font, a certificate, firmware, an icon) meant either a build-step code generator, a base64-encoded string with runtime decode, or a linker-section trick. C23 adds #embed:
|
|
The preprocessor reads the file at compile time and expands it as a comma-separated integer constant list. __has_embed("path") lets you check availability before using it. GCC 15 and Clang 19 both support #embed; GCC additionally offers gnu::base64 and gnu::offset parameters for vendor-specific use. The performance improvement over the old code-generator approach is significant — GCC’s implementation avoids the exponential string-concatenation behavior that older versions choked on for large blobs.
_BitInt(N)
Fixed-width bit-precise integer types. _BitInt(128) gives you a 128-bit signed integer; unsigned _BitInt(7) gives you a 7-bit unsigned integer. This is not a bignum library — arithmetic wraps or overflows as you would expect from a fixed-width type. The use cases are: cryptographic primitives that operate on specific widths, hardware register modeling in embedded code, and protocol field manipulation where you care about exact bit counts. GCC 14+ and Clang 18+ support _BitInt on x86-64 and AArch64; support on other architectures varies.
Keywords Promoted from Macros, Attributes, and Smaller Additions
Several things that were <stdbool.h> macros are now keywords: bool, true, false. static_assert (previously _Static_assert with a macro alias) is now a direct keyword. These changes matter primarily because code no longer needs to include headers to use them, and because you cannot #define over keywords.
The [[attributes]] syntax is adopted from C++, standardizing what were previously compiler-specific extensions:
[[nodiscard]]— warn if the return value is discarded (critical for functions that return error codes)[[maybe_unused]]— suppress unused-variable warnings for parameters present for API compatibility[[deprecated("message")]]— emit diagnostic when the annotated entity is used[[fallthrough]]— mark intentional switch fallthrough, suppressing the warning
The auto keyword gains type inference in C23, modeled on GCC’s existing __auto_type. auto x = expr; infers the type of x from expr. Unlike C++’s auto, it works only for object definitions — no return type inference, no function parameters.
Binary integer literals and %b format specifier: 0b10110011 is now valid syntax, and printf("%b\n", val) prints the binary representation.
u8 string literals now have type char8_t[] instead of char[], aligning the C type system with C++ and making UTF-8-specific APIs express their intent at the type level.
K&R function definitions (the old-style int f(a, b) int a; int b; {} syntax) are removed. They were already undefined behavior bait and have been deprecated since C99.
Compiler Support Matrix (2026)
| Feature | GCC 14 | GCC 15 | Clang 18 | Clang 19 |
|---|---|---|---|---|
-std=c23 flag |
Yes | Default | Yes | Yes |
nullptr / nullptr_t |
Yes | Yes | Yes | Yes |
constexpr objects |
Yes | Yes | Yes | Yes |
typeof / typeof_unqual |
Yes | Yes | Yes | Yes |
<stdckdint.h> |
Yes | Yes | Yes | Yes |
#embed |
Partial | Yes | No | Yes |
_BitInt(N) |
x86/ARM | x86/ARM | Yes | Yes |
[[nodiscard]] etc. |
Yes | Yes | Yes | Yes |
bool/true/false keywords |
Yes | Yes | Yes | Yes |
auto type inference |
Yes | Yes | Yes | Yes |
Binary literals 0b... |
Yes | Yes | Yes | Yes |
u8 / char8_t |
Yes | Yes | Yes | Yes |
GCC 15 is considered essentially feature-complete for C23. Clang 19 catches up on #embed. Clang has begun landing C2y (post-C23) features behind -std=c2y in Clang 19.
The Undefined Behavior Minefield
A C program that contains undefined behavior (UB) is not a program that “might crash” — it is a program where the compiler is legally permitted to assume the UB never occurs, and may optimize accordingly. This has concrete, non-hypothetical consequences.
The Six That Matter Most
Signed integer overflow. INT_MAX + 1 is UB. Compilers use this to eliminate overflow checks. The canonical example: GCC optimizes away the check if (x + 1 < x) because the optimizer assumes signed overflow cannot happen, making the guard permanently false. This has been the root cause of multiple Linux kernel CVEs.
Strict aliasing violations. The C standard says that objects of different types cannot alias (with specific exceptions). Accessing memory through a pointer of the wrong type is UB. Network parsing code that casts a uint8_t* to uint32_t* to extract a field is undefined behavior even if the alignment is correct, and optimizers have been observed to generate incorrect code for exactly this pattern.
Out-of-bounds array access. Writing past the end of an array is UB. The compiler does not insert bounds checks; the hardware may or may not fault at the specific address; but even if neither fault occurs, the optimizer may have used the “no out-of-bounds” assumption to eliminate surrounding code. The result is exploitable with no crash.
Use-after-free. Accessing freed memory is UB. The allocator may have handed the memory to another allocation by the time the stale pointer is dereferenced, producing type confusion vulnerabilities. Or the optimizer may have assumed liveness and done something unexpected with the access.
Reading uninitialized memory. An uninitialized local variable has an indeterminate value. Reading it is UB. The compiler is allowed to give it different values at different reads. MemorySanitizer catches this; hardware typically does not.
Data races. Concurrent unsynchronized access where at least one is a write is UB. Unlike Java, where data races produce an arbitrary valid value for the type, C and C++ data races can produce values that are not possible by any sequential execution, and compilers have produced torn reads and speculative stores that create security problems.
Why UB Is a Security Problem, Not a Theory Problem
The chain from undefined behavior to exploitable vulnerability is concrete:
Programmer writes code with UB
|
v
Compiler assumes UB cannot occur
|
v
Optimizer removes "dead" bounds check or null check
|
v
Attack input triggers the "impossible" case
|
v
Heap overflow / type confusion / use-after-free
|
v
Attacker controls $rip / gets arbitrary write primitive
The CVE record for Linux, Chrome, Firefox, and OpenSSL is substantially a record of UB that slipped through review. Most of these did not crash during testing because UB is often benign with the specific compiler version and flags used during development, then becomes exploitable when optimization is increased, the compiler version changes, or the platform differs.
The Modern Toolchain: Sanitizers
Sanitizers are compiler instrumentations that insert runtime checks for specific categories of UB and memory bugs. They are not production hardening — the overhead is too high — but they are the primary mechanism for finding these bugs during development, testing, and fuzzing.
The Four Sanitizers
| Sanitizer | Flag | Catches | Overhead | Can Combine With |
|---|---|---|---|---|
| AddressSanitizer (ASan) | -fsanitize=address |
Heap/stack/global overflows, use-after-free, use-after-return, use-after-scope | ~2x CPU, ~3x RAM | UBSan |
| UndefinedBehaviorSanitizer (UBSan) | -fsanitize=undefined |
Signed overflow, OOB, misaligned access, null deref, invalid enum | ~1.2x CPU | ASan |
| MemorySanitizer (MSan) | -fsanitize=memory |
Uninitialized memory reads | ~3x CPU | UBSan (not ASan) |
| ThreadSanitizer (TSan) | -fsanitize=thread |
Data races, lock-order inversions | 5–15x CPU, 5–10x RAM | UBSan (not ASan/MSan) |
ASan + UBSan is the standard development build configuration. Enable them on your unit tests, integration tests, and fuzz builds:
|
|
-O1 is important — at -O0 the sanitizer sometimes misses bugs that optimization would expose, and at -O2/-O3 the sanitizer itself has higher overhead. -fno-omit-frame-pointer produces readable stack traces in reports.
MSan requires that your entire dependency tree be compiled with MSan instrumentation or it generates false positives — you cannot instrument only your code and link against an uninstrumented glibc. This makes MSan harder to deploy but worth the effort for security-critical parsers and decoders.
TSan is incompatible with ASan and MSan. Run TSan separately on multithreaded code; it is the only reliable tool for detecting races that do not consistently manifest as crashes.
A note on UBSan specificity: -fsanitize=undefined is an alias for a collection of individual checks. For the most thorough coverage, add -fsanitize=signed-integer-overflow,integer-divide-by-zero,vla-bound,null,pointer-overflow. The full -fsanitize=undefined already includes these, but naming them explicitly helps when you need to enable them without the full suite.
Coverage-Guided Fuzzing with libFuzzer and AFL++
Sanitizers find bugs when code executes. Fuzzing provides the inputs. The combination — fuzzing with ASan enabled — is the single highest-leverage practice in C security work. See fuzzing for developers for a full treatment of the mechanics; here is what is specific to C.
A libFuzzer harness is a single function:
|
|
Compile and run:
|
|
AFL++ provides an alternative engine with better mutation heuristics and LLVM LTO instrumentation mode for tighter coverage:
|
|
AFL++ and libFuzzer harnesses are compatible — a harness written for libFuzzer works with afl-clang-fast via the libFuzzer compatibility shim. This lets you run both engines and cross-pollinate corpora.
The ASan-plus-fuzzer pipeline looks like this:
Source code
|
v
afl-clang-lto / clang -fsanitize=address,fuzzer
|
v
Instrumented binary
|
v
Fuzzer engine (AFL++ or libFuzzer)
|
|---> Generate/mutate input
| |
| v
| Run instrumented binary
| |
| |-- New coverage? --> Add to corpus --> mutate more
| |
| |-- ASan/UBSan trip? --> Crash report + stack trace
| |
| `-- No crash, no new coverage --> Discard
|
v
Crash corpus --> Deduplicate --> Minimize --> Report
Run only 1–2 fuzzer instances with sanitizers enabled (overhead is 50–100x relative to a clean build). Use additional CPU cores for sanitizer-free fast fuzzing and periodically re-run interesting corpus entries through the sanitizer build.
Defensive Coding Patterns
Tooling catches bugs; defensive coding patterns prevent them. The two are complements, not alternatives.
Compiler Warnings as Errors
The absolute minimum flag set for any new C project in 2026:
|
|
-D_FORTIFY_SOURCE=3 (glibc 2.35+) enables compile-time and runtime buffer overflow checks on standard library calls. Level 3 adds checks that level 2 missed, including some object-size tracking through pointer casts. Most major Linux distributions now ship packages compiled with at least level 2; level 3 is available in recent Fedora and Ubuntu releases.
-fstack-protector-strong inserts stack canaries on functions that have local arrays, take the address of locals, or use alloca. It is not a complete defense but catches a meaningful class of stack overflows before they corrupt the return address.
Integer Arithmetic with stdckdint.h
Every allocation size calculation, every array index derived from external input, every length field from a network packet: use ckd_mul and ckd_add. The cost is negligible on modern hardware; the exploit prevention is real.
|
|
Bounds and Length Discipline
strncpy does not null-terminate when the source is longer than the limit — use strnlen + explicit termination, or the C23-standardized strndup. Prefer snprintf over sprintf; prefer memcpy over strcpy when operating on binary data; use strlcpy where available (OpenBSD libc, musl, glibc 2.38+). Pass lengths explicitly as size_t, not int.
[[nodiscard]] for Error Returns
One of the most common bugs in C is ignoring return values from functions that can fail:
|
|
With [[nodiscard]], the compiler emits a warning (which is an error under -Werror) if the caller discards the return value. Applied consistently to error-returning functions, this eliminates whole categories of ignored-error bugs.
Static Analysis
clang-tidy with the clang-analyzer-security.* checks and cert-* checkers covers a large fraction of known C vulnerability patterns: format string issues, taint tracking from external inputs, unsafe arithmetic, and API misuse. Integrate it as a pre-commit hook or CI step:
|
|
cppcheck provides complementary analysis — it finds different bugs than clang-tidy, particularly around uninitialized variables and resource leaks. Run both.
Modern Build Tooling
CMake and Meson
CMake 3.21+ understands C23 via set(CMAKE_C_STANDARD 23). A minimal security-conscious CMakeLists.txt:
|
|
CMAKE_EXPORT_COMPILE_COMMANDS=ON generates compile_commands.json in the build directory, which clang-tidy, clangd (the LSP server), and other tools consume to know how each file was compiled. This is the glue that makes modern C tooling work coherently.
Meson is a compelling alternative with less syntax complexity:
|
|
Package Management Reality
This is where C’s honesty check comes in. C has no package manager that works like Cargo or Go modules. vcpkg (Microsoft) and Conan are the two serious options, and both require meaningful configuration overhead. vcpkg has better integration with CMake via toolchain files; Conan has a more flexible recipe system and better support for cross-compilation scenarios. Neither handles transitive dependency version resolution as gracefully as Cargo.
For projects that need a handful of well-known libraries, the practical approach in 2026 is still: system packages for mature libraries (libssl, libz, libsqlite3), git submodules or FetchContent for smaller dependencies, and vcpkg or Conan for complex dependency graphs. There is no elegant solution here. This is a genuine gap.
C in a Rust World
The honest case for C has three parts: the kernel, the embedded target space, and the ABI.
The kernel and system layer. The Linux kernel is C, and will remain substantially C for the foreseeable future. Rust is now a permanent second language in the kernel (the Linux Kernel Maintainers Summit in Tokyo in December 2025 confirmed Rust as a core language, no longer experimental), but new kernel subsystems are C, device driver bindings go through C, and the architecture-specific assembly code has a C interface. You cannot work on kernel internals without C fluency.
Embedded and MCU targets. Bare-metal microcontroller development — STM32, nRF52840, RISC-V MCUs — has toolchain support for C that is vastly broader than Rust. arm-none-eabi-gcc targets everything; Rust’s embedded ecosystem is maturing but has gaps in toolchain availability for obscure architectures, HAL completeness, and RTOS integration. A 2026 study comparing Rust and C for embedded firmware found Rust competitive on ARM Cortex-M targets but still trailing on vendor SDK integration and real-time guarantees documentation. C is still the default for production firmware targeting constrained silicon.
The C ABI as the FFI lingua franca. Every language that exposes a native extension interface uses the C ABI. Python’s C extension API is C. Ruby, Lua, R, Julia, Node.js N-API — all C. When a Go binary calls a Rust library via CGo, the boundary is C-typed structs and extern "C" functions. WebAssembly component model tools that generate bindings between languages produce C-typed interfaces at the boundary (see webassembly beyond the browser for how this plays out in practice). C is not just a language; it is the protocol that languages use to talk to each other. You need to be able to write and read it to work at system boundaries.
Where you should reach for Rust instead. New network services, parsers for untrusted data, any project where a security audit will be required, anything you would write in C++ by default. The ownership model eliminates use-after-free and data races at compile time, which is a structural advantage that no amount of sanitizer investment in C can fully replicate. The Rust for systems programming post and the practical Rust onramp cover this in depth. The honest advice is: if you are starting a new user-space systems project today, default to Rust unless you have a specific reason for C. The reasons are: performance-critical kernel interfaces, MCU targets with incomplete Rust support, FFI boundary code that must be callable from many languages, or an existing C codebase where a rewrite is not economical.
C is not dying. It is becoming more specialized. That specialization is precisely where it remains irreplaceable.
Verdict
C23 is a meaningful improvement over C17. nullptr fixes a real class of null pointer type confusion bugs. <stdckdint.h> gives you checked arithmetic with no external dependency. #embed eliminates a class of ugly build-step workarounds. [[nodiscard]] and [[fallthrough]] standardize what were compiler-specific annotations. typeof and constexpr objects make type-safe macros and named constants cleaner.
What C23 does not do: it does not give you memory safety by default, it does not eliminate undefined behavior from the language, and it does not add a module system or package manager. The language is better, but the discipline gap between safe C and exploitable C is still wide, and it is closed by tooling and practice, not by the standard alone.
The 2026 minimum viable C project looks like this: -std=c23, -Wall -Wextra -Werror, -fsanitize=address,undefined in debug and CI builds, _FORTIFY_SOURCE=3, compile_commands.json for clang-tidy and clangd, and a fuzzing harness for any code that processes external input. That stack will not catch everything — MSan requires full-tree instrumentation, TSan requires separate runs, and no static tool catches all runtime bugs — but it catches a large fraction of the bugs that become CVEs, with acceptable overhead in CI.
C is harder to write safely than Rust. That is true and important. It is also the language the kernel, the firmware, and the FFI boundary are written in, and that is not changing in the next decade. Write it defensively, instrument it aggressively, and fuzz anything that touches the network.
Sources
- C23 (C standard revision) — Wikipedia
- C Standards Support in GCC — GNU Project
- Clang — C Programming Language Status
- Compiler support for C23 — cppreference.com
- How to implement C23 #embed in GCC 15 — Red Hat Developer
- LLVM Clang 19 Lands Support For C23’s #embed — Phoronix
- GCC Preparing To Set C23 “GNU23” As Default C Language Version — Phoronix
- Checked integer arithmetic in the prospect of C23 — Jens Gustedt’s Blog
- Standard library header
<stdckdint.h>(C23) — cppreference.com - Catch-23: The New C Standard Sets the World on Fire — ACM Queue
- AddressSanitizer (ASan): A Practical Guide for Safer C/C++ — swenotes.com
- C++ Sanitizers: ASan, TSan, UBSan, and MSan Explained — pkglog.com
- Compiler Options Hardening Guide for C and C++ — OpenSSF
- libFuzzer — Testing Handbook
- AFL++ — Testing Handbook
- Rust vs C in the Kernel — Medium
- Study compares Rust and C languages for embedded firmware — CNX Software
- C23 is Finished: Here is What is on the Menu — The Pasture (thephd.dev)
- auto in C23 — DEV Community
- nullptr in C23 — DEV Community
Comments