Modern C and Where C Is Used
What C23 added — nullptr, constexpr, typeof, #embed, attributes and checked arithmetic — what is coming in C2y, compiler support today, and where C is used in kernels, firmware, runtimes and libraries.
C23 at a glance
C23, published by ISO
in 2024 as ISO/IEC 9899:2024, is the biggest change to C since C99.
A compiler in C23 mode sets __STDC_VERSION__ to
202311L. The table lists the changes most programmers
notice, with the first GCC and Clang versions that support each one
according to the compilers' own status pages:
| Change | Example | GCC | Clang |
|---|---|---|---|
| null pointer constant | int *p = nullptr; |
13 | 17 |
| keywords instead of macros |
bool, true, false,
static_assert, alignas,
thread_local
|
13 | 17 |
| compile-time constants | constexpr int METERS = 400'000; |
13 | 19 |
| type of an expression | typeof(x), typeof_unqual(x) |
13 | 16 |
| type inference | auto price = 2700; |
13 | 18 |
| standard attributes |
[[nodiscard]], [[deprecated]],
[[fallthrough]]
|
10–13 | yes |
| checked integer arithmetic | ckd_mul(&r, a, b) |
14 | yes |
| embedding a file | #embed "device-ca.pem" |
15 | 19 |
| digit separators | 3'504'000'000 |
12 | 13 |
| bit-precise integers | _BitInt(12) |
14 | 15 |
| enums with a fixed type | enum status : unsigned char { … } |
13 | 20 |
C23 also removes and tightens things. According to the change list in the standard's Annex M:
- Signed integers must be two's complement; the old ones' complement and sign-magnitude representations are gone. Signed overflow is still undefined behavior (see Undefined Behavior and Safer C).
-
K&R-style function definitions with an identifier list are
removed, and an empty parameter list now means "no parameters":
int f()is the same asint f(void)(see Functions and Header Files). -
POSIX functions such as
strdup,strndup,memccpy,gmtime_randlocaltime_rjoin the standard library, along withmemset_explicit(amemsetthe compiler may not optimize away, for wiping secrets) and the new header<stdbit.h>for bit and byte utilities. -
Labels may appear before declarations, parameters may be unnamed
in a definition, and
= {}initializes any object to zero.
Compiler support today
As of October 2026 the newest releases are GCC 16.2 (August 2026)
and LLVM/Clang 23.1.3 (October 2026). What matters more in practice
is which version a server or toolchain actually has: Debian 12, for
example, ships GCC 12, which knows the option
-std=c2x but implements little of C23.
| Compiler | Default dialect | How to ask for C23 |
|---|---|---|
| GCC 15 and later | gnu23 (C23 with GNU extensions) |
-std=c23 (default already close) |
| GCC 14 | gnu17 |
-std=c23 (first version with this spelling)
|
| GCC 9 to 13 | gnu17 |
-std=c2x, partial |
| Clang 18 and later | gnu17 |
-std=c23 |
A program can check which standard it is compiled for:
#include <stdio.h>
int main(void)
{
#if __STDC_VERSION__ >= 202311L
printf("C23 or later (%ld)\n", __STDC_VERSION__);
#else
printf("before C23 (%ld)\n", __STDC_VERSION__);
#endif
return 0;
}
C23 or later (202311)
The default change in GCC 15 matters even if you never write C23.
Old code that defines its own bool,
true or false, or that calls
int f() with arguments, stops compiling when a build
machine is upgraded. And some C23 code compiles on an older compiler
with a different meaning. Before C23, auto was
an obsolete storage class and a missing type meant int,
so GCC 12 accepts this with only a warning and computes with an
integer price of 0:
$ cat price.c
#include <stdio.h>
int main(void)
{
auto price = 0.27;
printf("%.2f EUR\n", price * 350.0);
return 0;
}
$ gcc -std=c2x -o price price.c && ./price
price.c: In function ‘main’:
price.c:5:10: warning: type defaults to ‘int’ in declaration of ‘price’ [-Wimplicit-int]
5 | auto price = 0.27;
| ^~~~~
0.00 EUR
$ clang -std=c23 -o price price.c && ./price
94.50 EUR
Pin the standard explicitly in every build (-std=c17 or
-std=c23), set a minimum compiler version, and treat
warnings as errors: then a too-old compiler fails loudly instead of
quietly. How the flags work is explained in
Compiling and Running C.
nullptr and the new keywords
nullptr is the C23 keyword for the
null pointer constant. Unlike the macro NULL, which a C
library may define as 0 or as ((void *)0),
it always has its own type, nullptr_t, and converts
only to pointer types and bool. That removes the
ambiguity when a null pointer is passed where the type is not known,
such as to a variadic function or a _Generic selection.
The idea of a null pointer is explained in
Pointers.
#include <stddef.h>
#include <stdio.h>
#define TYPE_NAME(x) _Generic((x), \
nullptr_t: "nullptr_t", \
void *: "void *", \
int: "int", \
default: "something else")
int main(void)
{
int *latest = nullptr; /* no reading received yet */
bool online = false; /* bool, true and false are keywords now */
printf("NULL has type %s\n", TYPE_NAME(NULL));
printf("nullptr has type %s\n", TYPE_NAME(nullptr));
printf("latest is %s, online is %d\n",
latest == nullptr ? "empty" : "set", online);
return 0;
}
NULL has type void * nullptr has type nullptr_t latest is empty, online is 0
With glibc, NULL happens to be
((void *)0); with another C library it can be plain
0, an int. The program also uses
bool and false without including
<stdbool.h>: in C23 they are keywords, as are
static_assert, alignas,
alignof and thread_local, which used to be
spelled _Static_assert, _Alignas and so
on. static_assert may now also leave out its message.
Constants and type inference
constexpr
constexpr declares an object
whose value must be known at compile time and can be used wherever a
constant expression is required: array sizes, case labels,
static_assert. It is a typed alternative to an
object-like macro. C's version is deliberately smaller than C++'s:
it applies to objects only, not to functions.
#include <stdio.h>
constexpr int METERS = 400'000;
constexpr int HOURS_PER_YEAR = 8'760;
constexpr long long METER_HOURS = (long long)METERS * HOURS_PER_YEAR;
constexpr unsigned STATUS_MASK = 0b1010'0001;
static_assert(METER_HOURS == 3'504'000'000);
int main(void)
{
int per_district[METERS / 100'000] = {}; /* a constant size, zeroed */
printf("meter-hours per year: %lld\n", METER_HOURS);
printf("%zu districts, first total %d, mask %u\n",
sizeof per_district / sizeof per_district[0], per_district[0],
STATUS_MASK);
return 0;
}
meter-hours per year: 3504000000 4 districts, first total 0, mask 161
A constexpr value must fit its type exactly. Where a
macro or a plain const only gets a warning and goes on
with a wrong value, a constexpr is refused:
#include <stdio.h>
constexpr int METERS = 400'000;
constexpr int METER_HOURS = METERS * 8'760;
int main(void)
{
printf("%d\n", METER_HOURS);
return 0;
}
example.c:4:15: error: constexpr variable 'METER_HOURS' must be initialized by a constant expression
Clang's next line, a note, gives the reason: "value 3504000000 is outside the range of representable values of type 'int'".
Digit separators and binary literals
A digit separator is a single
quote between the digits of a numeric literal, such as
400'000 or 0b1010'0001, that makes long
numbers readable and is ignored by the compiler. C23 uses the quote
rather than the underscore of Python and Java to match C++14, where
an underscore would clash with user-defined literal suffixes. C23
also standardizes binary literals with 0b, which GCC
and Clang accepted as an extension for years; the mask above is 161.
typeof and auto
The typeof operator gives the
type of an expression (or a type name) at compile time, so a
declaration can say "a variable of the same type as this one";
typeof_unqual does the same but drops qualifiers such
as const. GCC and Clang offered typeof as
an extension long before C23, and macros in the Linux kernel use it
widely. Its classic use is a type-generic macro that evaluates each
argument once (see
The Preprocessor and Build Tools).
#include <stdio.h>
/* Swap two variables of any (same) type, evaluating each argument once. */
#define SWAP(a, b) \
do { \
typeof(a) *pa_ = &(a); \
typeof(b) *pb_ = &(b); \
typeof(a) tmp_ = *pa_; \
*pa_ = *pb_; \
*pb_ = tmp_; \
} while (0)
int main(void)
{
int low = 860, high = 95;
SWAP(low, high);
printf("%d %d\n", low, high);
double day = 0.2900, night = 0.2500;
SWAP(day, night);
printf("%.4f %.4f\n", day, night);
const int limit = 2300;
typeof_unqual(limit) counter = 0; /* int, without const */
counter += limit;
auto price = 2700; /* inferred: int */
auto kwh = 350L; /* inferred: long */
printf("%d %ld\n", counter, price * kwh);
return 0;
}
95 860 0.2500 0.2900 2300 945000
auto in C23 infers a variable's type from its
initializer, like
type inference
in other languages, but only for single objects: no
auto return types or parameters. Use it sparingly; with
C's implicit conversions an explicit type often says more.
2700 is an int and 350L a
long, and the inferred types follow the literals
exactly.
Attributes
An attribute specifier is
the C23 syntax [[name]] (or
[[name(arguments)]]) that attaches extra information to
a declaration or statement, such as "the result must be used" or
"this function is deprecated", for the compiler to check or use.
Before C23 each compiler had its own syntax (__attribute__((warn_unused_result))
in GCC and Clang); vendor attributes now have a namespaced form such
as [[gnu::packed]]. The standard ones:
| Attribute | Meaning |
|---|---|
[[nodiscard]] |
warn when the caller ignores the return value |
[[deprecated("reason")]] |
warn when the declaration is used |
[[fallthrough]] |
a switch case falls through on purpose |
[[maybe_unused]] |
no warning if this parameter or variable is unused |
[[noreturn]] |
the function never returns (like exit) |
[[unsequenced]], [[reproducible]]
|
promises about a function's side effects, for optimization; Clang 21 still ignored them |
[[nodiscard]] turns C's habit of silently ignored
return values (see
Error Handling) into a compiler error, with warnings treated as errors:
#include <stdio.h>
[[nodiscard]] static int send_reading(int watts)
{
return watts >= 0 ? 0 : -1; /* 0 = sent, -1 = rejected */
}
int main(void)
{
send_reading(1234);
return 0;
}
example.c:10:5: error: ignoring return value of function declared with 'nodiscard' attribute [-Werror,-Wunused-result]
[[deprecated]] lets a library tell its users which
function to stop using, with the reason in the message:
#include <stdio.h>
[[deprecated("use read_power_w, which returns whole watts")]]
static double read_power_kw(void)
{
return 1.234;
}
int main(void)
{
printf("%.3f kW\n", read_power_kw());
return 0;
}
example.c:11:25: error: 'read_power_kw' is deprecated: use read_power_w, which returns whole watts [-Werror,-Wdeprecated-declarations]
[[fallthrough]] and [[maybe_unused]] do
the opposite: they tell the compiler that something which looks like
a mistake is intended, so the warning
(-Wimplicit-fallthrough, part of GCC's
-Wextra, and -Wunused-parameter)
disappears without switching it off for the whole program (see
Control Flow and Loops):
#include <stdio.h>
static int read_power_w(void) { return 1234; }
[[nodiscard]] static int send_reading(int watts)
{
return watts >= 0 ? 0 : -1;
}
/* Mode 2 does everything mode 1 does, plus a check first. */
static void handle(int mode, [[maybe_unused]] int home)
{
switch (mode) {
case 2:
printf("range check\n");
[[fallthrough]]; /* intended: continue with mode 1 */
case 1:
printf("store reading\n");
break;
default:
printf("unknown mode\n");
break;
}
}
int main(void)
{
if (send_reading(read_power_w()) != 0) {
printf("reading rejected\n");
}
handle(2, 17);
return 0;
}
range check store reading
Checked integer arithmetic
Checked integer arithmetic
performs an addition, subtraction or multiplication and reports
whether the exact result fits the destination type, instead of
overflowing silently or causing undefined behavior. C23 provides it
in <stdckdint.h> as ckd_add,
ckd_sub and ckd_mul: each stores the
result through its first argument and returns true if
it did not fit. The operands may have any integer types, because the
check is done on the mathematically exact value. Here is the annual
meter-hours calculation for Kestrel's 400,000 meters, which does not
fit in an int (see
Variables, Types and Integers):
#include <stdckdint.h>
#include <stdint.h>
#include <stdio.h>
int main(void)
{
int meters = 400'000;
int hours = 8'760;
int as_int;
if (ckd_mul(&as_int, meters, hours)) {
printf("int: overflow, result unusable\n");
}
int64_t as_int64;
if (!ckd_mul(&as_int64, meters, hours)) {
printf("int64_t: %lld meter-hours\n", (long long)as_int64);
}
unsigned char level;
bool overflow = ckd_add(&level, 250, 10);
printf("250 + 10 in unsigned char: overflow=%d, wrapped value %d\n",
overflow, level);
return 0;
}
int: overflow, result unusable int64_t: 3504000000 meter-hours 250 + 10 in unsigned char: overflow=1, wrapped value 4
When the operation overflows, the stored value is the wrapped result
(260 − 256 = 4), but the return value tells you not to use it. This
is the kind of check that code reading sizes or counts from
untrusted input needs before it calls malloc. On
compilers without C23 support, GCC and Clang have offered the same
check for years as built-in functions:
__builtin_mul_overflow(a, b, &result), available in
GCC 12 as well.
Embedding files with #embed
#embed is a C23 preprocessor directive
that inserts the bytes of a file as a comma-separated list of
integers, typically to initialize an array, so that data such as a
certificate, an image or a lookup table becomes part of the program
at build time. Before C23 that took an external tool such as
xxd -i to turn the file into C source, or linker
tricks. Firmware is a typical user: a device that must trust a
certificate authority can carry the CA certificate inside its image.
The certificate file below is a placeholder for the example:
$ cat embed.c
#include <stdio.h>
static const unsigned char ca_cert[] = {
#embed "device-ca.pem"
};
int main(void)
{
printf("embedded %zu bytes, starting with: %.27s\n",
sizeof ca_cert, (const char *)ca_cert);
return 0;
}
$ wc -c device-ca.pem
72 device-ca.pem
$ clang -std=c23 -Wall -Wextra -o embed embed.c && ./embed
embedded 72 bytes, starting with: -----BEGIN CERTIFICATE-----
The array gets exactly the file's bytes, with no terminating zero
added, which is why the program prints with a precision
(%.27s) instead of treating the array as a string.
Parameters such as limit(64) take only part of a file,
and __has_embed checks whether a file can be embedded.
GCC supports #embed from version 15 and Clang from 19.
What comes next: C2y
C2y is the working name of the revision
of the C standard after C23, under development by the ISO committee
WG14; the "y" stands for a year that is not fixed yet. Compilers
accept -std=c2y for the features they already implement
(Clang 21 then reports __STDC_VERSION__ as
202400L, a placeholder). As of October 2026 the latest
working draft is N3886 (May 2026). Features that GCC and Clang
already list for C2y on their status pages include:
| Feature | Example | First support |
|---|---|---|
_Countof: number of elements of an array |
_Countof watts |
GCC 16, Clang 21 |
declarations in if conditions |
if (int n = read(); n > 0) |
GCC 15 |
| named loops | break outer; |
GCC 15, Clang 22 |
octal prefix 0o |
0o755 instead of 0755 |
GCC 15, Clang 21 |
| case ranges | case 1 ... 9: |
already a GNU extension in GCC and Clang |
_Countof finally replaces the
sizeof a / sizeof a[0] idiom, and fails to compile on a
pointer instead of giving a wrong answer (see
Arrays and Pointer Arithmetic):
$ cat count.c
#include <stdio.h>
int main(void)
{
int watts[] = {120, 95, 130, 110};
printf("%zu readings\n", _Countof watts);
return 0;
}
$ clang -std=c2y -Wall -Wextra -o count count.c && ./count
4 readings
Named loops are the C version of
labeled statements
in JavaScript and Java: break outer; leaves an
enclosing loop directly, a common reason for goto
today.
Where C is used today
What Is C? lists the places where C runs: operating system kernels, firmware, language runtimes, databases and core libraries. Three of them are worth a closer look, because they decide how C changes.
Kernels
The Linux kernel is written in C and built as GNU C11
(-std=gnu11), so it uses GNU extensions such as
typeof and statement expressions and is built
freestanding, without the C standard library (see
hosted and freestanding). The kernel can also contain code in Rust, so far mainly for
drivers; according to LWN's report on the December 2025 Maintainers
Summit, Rust support is no longer considered an experiment. The C
core is not being rewritten: new Rust code sits next to millions of
lines of C and calls it through its C interfaces.
Firmware
Microcontroller firmware is still mostly C (see
Firmware and Embedded Programming). The toolchains move fast too: ESP-IDF v6.1 (August 2026), the
framework for the ESP32 family, ships a GCC 15.2 toolchain, so
whether firmware is compiled as C17 or C23 now depends on the
-std setting in its build rather than on the compiler's
age. For the Kestrel Home pilot this is an outside manufacturer's
concern: the ESP32-C3 dongles run the manufacturer's C firmware, and
Kestrel decides when a version such as 1.4.0 rolls out (CHG-5012).
Knowing what modern C offers helps when reading the manufacturer's
release notes or asking how their parser is tested (see
fuzzing).
C as the common interface
Almost every language can call a function compiled from C, because
each platform defines a C ABI (application binary interface): how
arguments are passed, how structs are laid out, how names appear to
the linker. That is why a library written in C, C++ or Rust usually
offers a C interface, and why Python, Java, C# and Rust programs end
up calling C. Python's standard ctypes module calls the
C library directly; it only has to be told each function's
signature, because a compiled C function does not carry its types:
import ctypes
import ctypes.util
# Load the C library that this Python process already uses.
libc = ctypes.CDLL(ctypes.util.find_library("c"))
# Describe the C function's signature: size_t strlen(const char *s);
libc.strlen.argtypes = [ctypes.c_char_p]
libc.strlen.restype = ctypes.c_size_t
serial = "KHP1-000231".encode("ascii")
print("library:", ctypes.util.find_library("c"))
print("strlen from C:", libc.strlen(serial))
# int abs(int) works the same way.
libc.abs.argtypes = [ctypes.c_int]
libc.abs.restype = ctypes.c_int
print("abs(-1234) from C:", libc.abs(-1234))
library: libc.so.6 strlen from C: 11 abs(-1234) from C: 1234
The flip side: a wrong signature, or a Python string passed where C expects a buffer it may write into, crashes the Python process with the same segmentation faults as a C program (see Debugging C). Memory safety ends at the boundary with C.
C in a memory-safe direction
Government guidance from the United States and its partners since
2022 recommends memory-safe languages for new code where practical,
and roadmaps for existing C and C++ code (the details and sources
are in
Undefined Behavior and Safer C). For C that means two parallel movements. New components, such as
new drivers or parsers for untrusted input, are increasingly written
in Rust or another
memory-safe language. And the enormous amount of existing C is kept and made safer:
stricter warnings, sanitizers and fuzzing in every pipeline,
hardening flags, coding standards, and the C23 features on this page
that move checks from the programmer's memory to the compiler, such
as [[nodiscard]], constexpr range checks
and ckd_mul. Both movements will run for decades, which
is why C remains worth understanding, whether you write it, operate
it or read its advisories.