C — the Language ISO/IEC 9899:2024 (C23) · no compiler flags · 380 entries

The language on its own — what the standard says, what it leaves to the implementation, and what it leaves undefined. Nothing here is a compiler switch; those live on the two toolchain sheets. The index comes first — 14 cards, 380 entries, statements and keywords at the head of it — because that is what you reach for with the editor still open, and because the whole grammar of C is six kinds of statement and the rest is expressions. Roll over any keyword for a minimal code example, typeset rather than squeezed into a tooltip. Below the index are the 21 guide cards: precedence and the four rules that actually cause bugs, what the type system guarantees and what it does not, integer promotion and the unsigned trap, how to read a declaration, pointers against arrays, struct layout, the four storage durations, linkage and the inline rule, aliasing and alignment, sequencing, the preprocessor, _Generic, the library functions worth refusing, threads and the memory model, undefined behaviour and why the optimiser deleted your check, what C23 changed, and where C++ parts company. Of the 380 entries, 61 are new in C23 and 56 describe behaviour the standard leaves undefined, unspecified or implementation-defined — the two things most worth knowing about, and both called out by the dot on every row.

Dots: ISO C, C99 or earlier new in C23 undefined / unspecified / implementation-defined a common extension, not ISO
Sources: ISO/IEC 9899:2024 (C23) and the public draft N3220, with N1570 for C11 and N1256 for C99; the rationale documents for the parts that only make sense historically. Behaviour claims were checked against GCC 15 and Clang 21 rather than remembered. Hover a clipped row for the whole entry, or a keyword for an example. Companion sheets: C — the GCC Toolchain and C — the Clang Toolchain.

Statement, Keyword & Library Index

Statements and keywords first, then operators, types, declarations and the library. Roll over any keyword for a code example — type in the filter box, or press /DotsISO C, C99 or earliernew in C23undefined / implementation-definedextension, not ISO

Statements

21

Choosing

if / elseif (n > 0) puts("yes"); else puts("no");The controlling expression is compared against 0; any scalar type works, pointers included.branch on a scalar condition
switchswitch (c) { case 'a': f(); break; default: g(); }The controlling expression is integer-promoted. Only integer constant expressions may be case labels.branch on an integer expression
case / defaultcase 1: case 2: f(); break; default: g();Duplicate case values are a constraint violation. A case label outside any switch is an error; one nested inside an inner block still belongs to the switch. [undefined, unspecified or implementation-defined]a label inside a switch
case A ... Bcase 'a' ... 'z': lower++; break;A GNU extension, not ISO C. Note the spaces around the dots.a range of case values

Looping

whilewhile (*p) p++;The test happens before the first iteration, so the body may run zero times.test, then body
do ... whiledo { n /= 10; } while (n);Runs at least once. The trailing semicolon is required.body, then test
forfor (int i = 0; i < n; i++) sum += a[i];Since C99 the first clause may be a declaration, and that object is scoped to the loop.init, test, advance
breakfor (;;) { if (done) break; }It leaves ONE construct. To leave two nested loops you need a goto or a flag.leave the innermost loop or switch
continuefor (i = 0; i < n; i++) { if (!a[i]) continue; use(a[i]); }In a for loop the advance clause still runs; in a while loop it does not, which is how infinite loops get written.skip to the next iteration

Jumping

gotoif (err) goto cleanup; ... cleanup: free(p);Labels are function-scoped. Jumping INTO the scope of a variable-length array is undefined; jumping forward past an initialisation leaves the object indeterminate.jump to a label in this function
returnreturn n < 0 ? -n : n;Falling off the end of a non-void function and then using the value is undefined behaviour. main is the one exception: it returns 0.leave the function, with a value
label:again: if (retry()) goto again;C23 allows a label at the end of a compound statement; before C23 it had to be followed by a statement.a named point in the function
goto *ptrvoid *t[] = {&&add, &&sub}; goto *t[op];A GNU extension: &&label yields a void*. How fast interpreter dispatch loops are written. [a common extension, not ISO C]computed goto

Blocks and expressions

{ ... }{ int t = a; a = b; b = t; }Declarations inside it are visible only within it, and automatic objects in it are destroyed at the closing brace.a compound statement, and a scope
expr ;x = f(y); puts("hi");The value is discarded. Discarding the result of a function marked warn_unused_result is what -Wunused-result catches.an expression statement
;while (*d++ = *s++) ;A whole loop body, when the work is in the header. Write it on its own line or it reads as a bug.the null statement
({ ... })#define MAX(a,b) ({ \ typeof(a) _a=(a), _b=(b); \ _a > _b ? _a : _b; })A GNU extension: the value of the last expression is the value of the block. The safe way to write a MAX macro. [a common extension, not ISO C]a statement expression

Declarations, which are statements too

int x = 1;for (int i = 0; i < n; i++) { }Legal anywhere a statement is since C99. Before that, only at the top of a block.a declaration with an initialiser
static_assertstatic_assert(sizeof(int) == 4, "need 32-bit int");A keyword in C23; _Static_assert with before it. The message is optional since C23. [new in C23]a compile-time check
typeof / typeof_unqualtypeof(*p) tmp = *p;Standard in C23; __typeof__ is the GNU spelling that works everywhere. The operand is not evaluated. [new in C23]the type of an expression
constexprconstexpr int N = 64; int buf[N];C23. Unlike a const object, it may be used where a constant expression is required. [new in C23]a true compile-time constant object

Keywords

25

Types

voidvoid *p = malloc(n); void f(void);void* converts to and from any object pointer without a cast. f(void) means no parameters — and since C23, f() means the same.no value; or an incomplete type
charchar c = getchar(); unsigned char b = 0xFF;Exactly 1 byte by definition, but plain char is signed on x86 and unsigned on Arm. Say signed char or unsigned char when it matters.the smallest addressable unit
short / int / longlong n = 1L; long long big = 1LL;Only the minimum widths are fixed: short and int at least 16 bits, long at least 32, long long at least 64.the signed integer types
signed / unsignedunsigned u = 0u - 1u; /* UINT_MAX */Unsigned arithmetic wraps and is defined; signed overflow is undefined behaviour.the signedness qualifiers
float / doubledouble x = 0.1 + 0.2; /* != 0.3 */long double is 80-bit on x86 Linux and plain double on AArch64 macOS — never assume.the floating types
bool / true / falsebool ok = (p != NULL);Real keywords in C23; before that _Bool plus . Any nonzero scalar converts to true. [new in C23]the boolean type
_BitInt(N)_BitInt(24) sample = 0;C23. Useful for hardware registers and bit-exact arithmetic; not subject to integer promotion in the usual way. [new in C23]an integer of exactly N bits
_Complex / _Imaginary#include <complex.h> double complex z = 1 + 2*I;With , spelled complex and I. _Imaginary is optional and rarely implemented.complex arithmetic
struct / union / enumstruct P { int x, y; }; enum C { RED, GREEN };The tag lives in its own namespace, which is why struct foo and a variable foo can coexist.the aggregate and enumerated types
nullptr / nullptr_tchar *p = nullptr;C23. Unlike NULL it has a type, so it cannot be passed as an int through varargs by mistake. [new in C23]the null pointer constant

Storage and linkage

staticstatic int counter; /* file-local */ void f(void) { static int n; n++; }Two unrelated meanings: at file scope it hides the name from other translation units; inside a function it makes the object persist across calls. [undefined, unspecified or implementation-defined]internal linkage, or static storage
externextern int errno; /* header */ int errno; /* one .c */The declaration in a header, the definition in exactly one .c file. Without extern in the header you get multiple-definition errors.declare without defining
autoauto n = 1 + 2; /* C23: int */The default, so it was noise for fifty years. In C23 it also means type inference, like C++.automatic storage
registerregister int i; /* &i is an error */The hint is ignored by every modern compiler. The prohibition is real: you may not take the address of a register object.a hint, and a prohibition
thread_localthread_local int depth = 0;C23 spelling; _Thread_local in C11, __thread as the GNU extension before that. [new in C23]one instance per thread
typedeftypedef struct node Node; typedef int (*cmp)(const void*, const void*);Not a macro: the name obeys scope, and typedef int a[10]; then const a x makes ten const ints, not a const pointer. [new in C23]a name for a type
inlineinline int sq(int x) { return x*x; } extern inline int sq(int); /* one .c */The linkage rule is the hard part: an inline function needs exactly one external definition somewhere, or the link fails at -O0. [new in C23]a suggestion, plus a linkage rule

Qualifiers

constconst char *p; /* p may move */ char *const q; /* *q may change */A promise about the access path, not the object. Casting it away and then writing to a genuinely const object is undefined behaviour. [undefined, unspecified or implementation-defined]this lvalue may not be modified through
volatilevolatile uint32_t *reg = (void*)0x4000; *reg = 1;It stops the compiler eliding or reordering accesses. It says NOTHING about atomicity or about other threads — use _Atomic for those. [undefined, unspecified or implementation-defined]every access is an observable side effect
restrictvoid copy(int *restrict d, const int *restrict s, int n);A promise you make to the optimiser. Break it — pass overlapping buffers to memcpy — and the behaviour is undefined, silently. [undefined, unspecified or implementation-defined]this pointer is the only route to the object
alignas / alignofalignas(64) char line[64]; size_t a = alignof(double);C23 keywords; _Alignas/_Alignof plus in C11. [new in C23]alignment control and query
_Atomic_Atomic int n = 0; n++; /* atomic RMW */With . Plain reads and writes of an _Atomic object are sequentially consistent.atomic access, with a memory order

Operators that are keywords

sizeofsize_t n = sizeof arr / sizeof arr[0];Its operand is not evaluated — sizeof(a[i++]) does not increment i. The result is size_t, which is unsigned. [undefined, unspecified or implementation-defined]the size in bytes, at compile time
_Generic#define abs(x) _Generic((x), \ int: abs, double: fabs)(x)C11. Type-based dispatch at compile time; the unselected branches are not even type-checked in C23. [new in C23]select an expression by type
true / falsebool done = false;C23 keywords of type bool; macros expanding to 1 and 0 before that. [new in C23]the boolean constants

Operators & Precedence

27

Highest — postfix

a[i] f() . ->p->x == (*p).xa[i] is defined as *(a+i), which is why 3["abc"] compiles.subscript, call, member, member through pointer
i++ i--x = i++; /* x = old i */The value is the OLD one.postfix increment: use, then change
(T){...}f(&(struct P){1, 2});C99. It is an lvalue with automatic storage in a function, static at file scope.compound literal

Unary

++i --ix = ++i; /* x = new i */The value is the NEW one.prefix: change, then use
+  -  ~  !~0u == UINT_MAX !x == (x == 0)Unary + is not a no-op: it applies the integer promotions.plus, negate, bitwise not, logical not
*  &int *p = &x; *p = 5;Dereferencing a null, dangling or misaligned pointer is undefined behaviour, not a crash you can rely on. [undefined, unspecified or implementation-defined]dereference, address-of
(T)expr(double)n / dCasting a pointer to a wider-aligned type and dereferencing it is undefined even if the address happens to be aligned.cast
sizeof alignofsizeof(int) alignof(max_align_t)Both are compile-time except sizeof of a VLA.size, alignment

Binary, in precedence order

* / %7 / 2 == 3 -7 % 2 == -1Division or remainder by zero is undefined. INT_MIN / -1 overflows and is also undefined. [undefined, unspecified or implementation-defined]multiply, divide, remainder
+ -p + 1 /* next element */Pointer arithmetic is in units of the pointed-to type, and only within one object plus one past its end.add, subtract
<<  >>1u << 31 n >> 3Shifting by the width or more, or by a negative amount, is undefined. Right-shifting a negative value is implementation-defined. [undefined, unspecified or implementation-defined]shift left, shift right
<  >  <=  >=if (a <= b) ...Comparing pointers into different objects is undefined; only equality is defined there.relational
== !=if (p != NULL) ...Comparing a float with == is almost always a bug; compare against a tolerance.equality
&if ((flags & MASK) != 0)Lower precedence than ==, which is the classic bug: (x & 1 == 0) parses as x & (1 == 0).bitwise and
^a ^= b; b ^= a; a ^= b;bitwise exclusive or
|flags |= O_APPEND;Also lower precedence than the comparisons. Parenthesise.bitwise or
&&if (p && p->next)The right side is not evaluated if the left is 0, and there is a sequence point between them.logical and, short-circuiting
||if (!p || p->n == 0)Same, when the left is nonzero.logical or, short-circuiting
c ? a : bmax = a > b ? a : b;Exactly one arm is evaluated. The common type of the arms is worked out by the usual conversions.conditional

Assignment and comma

= += -= *= /= %=x += 1;The left operand is evaluated only once in a compound assignment — the reason a[f()] += 1 is safe and a[f()] = a[f()] + 1 is not.assign and compound-assign
&=  |=  ^=  <<=  >>=flags &= ~MASK;bitwise compound assignment
,for (i = 0, j = n; i < j; i++, j--)Evaluates left, discards it, yields right. There IS a sequence point. Not the comma in an argument list.the comma operator

The precedence traps

*p++*p++ == *(p++) (*p)++ /* different */Postfix binds tighter than unary *. (*p)++ increments what it points at. [undefined, unspecified or implementation-defined]increments p, not *p
&x[i]&a[3] == a + 3Subscript binds tighter than &.the address of the element
x & 1 == 0(x & 1) == 0 /* what you meant */Equality binds tighter than the bitwise operators. This is the single most common C precedence bug.parses as x & (1 == 0)
-1 >> 1(unsigned)-1 >> 1 /* defined */Arithmetic shift on every real compiler, but the standard does not require it.implementation-defined
*p.f(*p).f == p->fThe member operators bind tighter than the dereference. Use p->f.parses as *(p.f)

Types & Limits

31

What is actually guaranteed

CHAR_BITbits in a byte — at least 8
sizeof(char) == 1by definition, always
short >= 16 bitsand int >= 16 bits
long >= 32 bitsand long long >= 64
plain char signednessimplementation-defined
sizeof(void*) vs sizeof(int)unrelated
sizeof(struct) >= sum of memberspadding is permitted

The fixed-width types — <stdint.h>

int8_t … int64_texactly that many bits, two’s complement
uint8_t … uint64_tthe unsigned forms
int_least8_t / int_fast8_tat least / fastest at least
intptr_t / uintptr_tan integer that can hold a pointer
intmax_t / uintmax_tthe widest integer type
size_tthe type of sizeof — unsigned
ptrdiff_tthe type of a pointer difference — signed
SIZE_MAX / SIZE_C(x)the limit, and a constant of that type
PRId64 / PRIu32 / SCNx16the printf and scanf macros

<limits.h> and <float.h>

CHAR_MIN / CHAR_MAXthe range of plain char
SCHAR_MIN / UCHAR_MAXthe explicit forms
INT_MIN / INT_MAX / UINT_MAXint and unsigned int
LONG_MAX / LLONG_MAXthe wider signed types
DBL_EPSILONthe gap between 1.0 and the next double
DBL_MIN / DBL_MAXthe smallest normal and the largest finite
DBL_DIG / DBL_DECIMAL_DIGdigits you can trust: 15 / 17
FLT_EVAL_METHODwhat precision intermediates use

Conversion rules, in order

integer promotionanything narrower than int becomes int
usual arithmetic conversionsthe narrower operand converts to the wider
signed → unsignedwell defined: modulo 2^N
unsigned → signedimplementation-defined if it does not fit
float → integertruncates toward zero; UB if out of range
array → pointerdecays to a pointer to its first element
function → pointerdecays, everywhere but sizeof and &

Declarations

23

Reading one

the spiral ruleint *a[10]; /* array of pointers */ int (*b)[10]; /* pointer to array */Right binds tighter: [] and () beat *. When in doubt, parenthesise, or run cdecl.start at the name, go right, then left
int (*f)(void)int (*fp)(int, int) = add; int r = fp(1, 2);Without the parentheses it is a function returning int*.pointer to function returning int
int *f(void)int *make(void);function returning pointer to int
char *argv[]int main(int argc, char *argv[])As a parameter it is really char**; the array bound is discarded.array of pointers to char
int (*a[5])(void)int (*ops[5])(void) = { f, g };The shape a dispatch table takes.array of 5 pointers to function
typedef, to stop the paintypedef int (*op)(int, int); op table[4];One typedef turns every declaration above into something readable.name the intermediate type

Where const goes

const char *pconst char *p = "hi"; p = "bye"; /* ok */ p[0] = 'H'; /* err */p may be reassigned; *p may not be written. Same as char const *p.pointer to const char
char *const pchar *const p = buf; p[0] = 'H'; /* ok */ p = other; /* err */p may not be reassigned; *p may be written.const pointer to char
const char *const pconst char *const msg = "hi";Read it right to left and it says exactly that.both
casting const away*(char *)cp = 'x'; /* UB if const */Only if the underlying object was not itself defined const. [undefined, unspecified or implementation-defined]legal to do, undefined to then write

Initialisation

= {0}struct P p = {0}; int a[100] = {0};Any omitted member is zero-initialised, so {0} zeroes everything.zero-initialise the whole object
= {}struct P p = {};Same effect, and it works for a struct whose first member is itself a struct. [new in C23]the C23 empty initialiser
= { .x = 1, .y = 2 }struct P p = { .y = 2 }; /* x = 0 */C99. Order-independent, and everything unnamed is zeroed.designated initialisers
= { [3] = 7 }int a[] = { [9] = 1 }; /* 10 ints */The array is sized by the highest index used.designated array initialisers
no initialiser, automatic storageint x; /* garbage */ static int y; /* 0 */Reading it is undefined behaviour. Static and thread storage are zeroed for you. [undefined, unspecified or implementation-defined]the value is indeterminate
char s[] = "hi"char a[] = "hi"; /* writable */ char *b = "hi"; /* not */Whereas char *s = "hi" points at a string literal you may not write to.a modifiable copy, 3 bytes

Structs, unions, enums

struct tag { ... };the tag namespace is separate
struct { int n; int a[]; }a flexible array member
unionone object, several readings
bitfieldsint x : 3;
anonymous struct / unionmembers promoted to the enclosing scope
enum with a fixed typeenum E : unsigned char { ... }
offsetof(T, m)the byte offset of a member

The Preprocessor

23

Directives

#include "f.h" / <f.h>#include <stdio.h> #include "config.h"The angle form searches only the system list, which is why a header of your own found with <> is a build-system bug.quoted searches locally first
#define / #undef#define MAX(a,b) ((a) > (b) ? (a) : (b))A function-like macro is only that if the ( follows the name with no space.define and remove a macro
#if / #elif / #else / #endif#if defined(__linux__) && !defined(NDEBUG)The expression is integer only: no sizeof, no floats, no enums. Undefined identifiers become 0.conditional compilation
#ifdef / #ifndef#ifndef HDR_H #define HDR_H ... #endifThe include-guard idiom, and the wrong tool for anything with two conditions.shorthand for defined()
#elifdef / #elifndef#ifdef A #elifdef B #endifNew in C23; nothing else changed about conditionals. [new in C23]the C23 shorthands
#error / #warning#if CHAR_BIT != 8 #error "need 8-bit bytes" #endif#warning was a universal extension long before C23 standardised it.stop, or complain
#pragma#pragma once #pragma pack(push, 1)An unrecognised pragma is ignored, which is why -Wunknown-pragmas exists.implementation-defined instruction
#line#line 42 "input.y"What generated code emits so errors point at the real source.reset the line number and file name
#embedconst unsigned char logo[] = { #embed "logo.png" };C23. Replaces the xxd-and-generate-a-header dance entirely. [new in C23]include a binary file as bytes

Operators inside a macro

#x#define STR_(x) #x #define STR(x) STR_(x) STR(__LINE__) /* "42" */It stringifies the argument BEFORE expansion, so you need a second level of macro to expand it first.stringify the argument
a##b#define CAT_(a,b) a##b #define CAT(a,b) CAT_(a,b)Same two-level rule. The result must be a single valid token.paste two tokens together
__VA_ARGS__#define LOG(f, ...) \ printf(f, __VA_ARGS__)C99. At least one argument was required until C23.the variadic arguments
__VA_OPT__(x)#define LOG(f, ...) \ printf(f __VA_OPT__(,) __VA_ARGS__)C23 (a GNU extension long before). Solves the trailing-comma problem properly. [new in C23]x only if there were variadic args
do { ... } while (0)#define SWAP(a,b) do { \ int t=(a); (a)=(b); (b)=t; \ } while (0)Makes the macro safe as the body of an if without braces, and forces the trailing semicolon.the multi-statement macro wrapper

Predefined, and required

__FILE__ / __LINE__printf("%s:%d\n", __FILE__, __LINE__);assert is built out of these two.the current file and line
__func__printf("in %s\n", __func__);C99. Not a macro — it is a static const char array the compiler declares.the enclosing function’s name
__DATE__ / __TIME__when it was compiled
__STDC__1 in a conforming implementation
__STDC_VERSION__#if __STDC_VERSION__ >= 202311L199901L, 201112L, 201710L, 202311L for C23.the standard, as a number
__STDC_HOSTED__1 hosted, 0 freestanding
NDEBUGturns assert into nothing
__has_include(<x.h>)#if __has_include(<threads.h>)C23, and a Clang extension for years before.is this header available?
__has_c_attribute(x)is this [[attribute]] available?

Standard Attributes

13

C23 [[...]] attributes

[[deprecated]][[deprecated("use g")]] void f(void);Optional message: [[deprecated("use g()")]]. [new in C23]warn when this is used
[[nodiscard]][[nodiscard]] int open_it(void);The standard spelling of GNU warn_unused_result. Optional message. [new in C23]warn when the result is thrown away
[[maybe_unused]]void cb([[maybe_unused]] void *ctx);For a parameter kept to match a callback signature. [new in C23]do not warn if this is unused
[[fallthrough]]case 1: f(); [[fallthrough]]; case 2:A comment no longer counts under -Wimplicit-fallthrough=5. [new in C23]this switch case falls through deliberately
[[noreturn]][[noreturn]] void die(const char *m);Replaces C11’s _Noreturn, which is deprecated. [new in C23]this function does not return
[[unsequenced]][[unsequenced]] int sq(int x);Lets the compiler cache the result across calls. [new in C23]pure: no state, same answer every time
[[reproducible]]effectively pure, but may read state

The GNU forms they replaced

__attribute__((deprecated))the pre-C23 spelling
__attribute__((warn_unused_result))became [[nodiscard]]
__attribute__((unused))became [[maybe_unused]]
__attribute__((noreturn))became [[noreturn]]
__attribute__((cleanup(f)))void closep(FILE **f){ if(*f) fclose(*f); } __attribute__((cleanup(closep))) FILE *f = fopen(p, "r");Runs f when the variable leaves scope — the closest C gets to RAII, and still an extension. [a common extension, not ISO C]no standard equivalent
__attribute__((format(printf,1,2)))no standard equivalent

Undefined Behaviour

28

Arithmetic

signed integer overflowif (ckd_add(&r, a, b)) overflow();Which is why `if (x + 1 < x)` is deleted. Use __builtin_add_overflow or the C23 ckd_* functions. [undefined, unspecified or implementation-defined]the optimiser assumes it cannot happen
division or remainder by zeroundefined, not a signal you may catch
INT_MIN / -1overflows, so undefined
shift by >= the widthundefined
shift of a negative valueleft shift undefined; right implementation-defined
conversion of an out-of-range floatundefined

Memory and pointers

dereferencing NULLundefined
reading uninitialised memoryundefined, and unstable
use after free, double freeundefined
out-of-bounds accessundefined
pointer arithmetic outside an objectundefined even without dereferencing
comparing unrelated pointers with < >undefined
misaligned access through a pointerundefined
strict aliasing violationfloat f; uint32_t u; memcpy(&u, &f, sizeof u);Accessing an object through a pointer to an unrelated type. memcpy is the portable fix; a union works in C. [undefined, unspecified or implementation-defined]undefined
modifying a string literalundefined
overlapping memcpyundefined — restrict was the promise

Sequencing and objects

i = i++two unsequenced modifications
f(i++, i++)argument evaluation is unsequenced
returning a pointer to a localthe object is gone at the return
falling off a non-void functionundefined if the caller uses the value
a data raceundefined
infinite loop with no side effectsundefined

What to do about it

-fsanitize=undefinedcatches most of the arithmetic ones at run time
-fsanitize=addresscatches the memory ones
-fwrapvmakes signed overflow wrap, defined
-fno-strict-aliasingstops the compiler using type-based aliasing
memcpy for type punningalways correct, always optimised away
unsigned for anything that may wrapwrapping is defined for unsigned

Strings & Memory

32

<string.h> — the memory half

memcpy(d, s, n)copy n bytes; must not overlap
memmove(d, s, n)copy n bytes; overlap is fine
memset(p, c, n)fill with a byte value
memcmp(a, b, n)compare bytes; returns sign
memchr(p, c, n)find a byte
memset_explicit(p, c, n)a memset the optimiser may not remove
memccpy(d, s, c, n)copy up to and including c

<string.h> — the string half

strlen(s)length, not counting the NUL
strcpy(d, s)copy, including the NUL
strncpy(d, s, n)NOT a safe strcpy
strlcpy(d, s, n)truncating copy that always terminates
snprintf(d, n, "%s", s)the portable safe copy
strcmp / strncmpcompare; returns sign
strchr / strrchrfind a character, first / last
strstr(h, n)find a substring
strspn / strcspnlength of the prefix in / not in a set
strtok(s, d)destructive, and holds static state
strdup / strndupallocate a copy
strerror(errno)the message for an errno value

<stdlib.h> — memory and conversion

malloc(n)n bytes, uninitialised
calloc(n, sz)n*sz bytes, zeroed
realloc(p, n)resize; may move the block
free(p)release it; free(NULL) is fine
aligned_alloc(a, n)aligned allocation
free_sized(p, n)free, telling it the size
atoi(s)no error reporting at all
strtol(s, &end, base)the one that reports errors
strtod / strtofthe same, for floating point
qsort(base, n, sz, cmp)sort, unstable
bsearch(k, base, n, sz, cmp)binary search a sorted array
abort / exit / atexitend the program
getenv(name)read the environment

Input & Output

40

<stdio.h> — streams

fopen(path, "rb")open a stream
fclose(f)close, flushing first
fread(p, sz, n, f)read n items of sz bytes
fwrite(p, sz, n, f)the mirror image
fgets(buf, n, f)read a line, safely
gets(s)removed from the language
getline(&p, &n, f)read a line, allocating as needed
fseek / ftell / rewindposition a stream
fflush(f)push buffered output out
setvbuf(f, NULL, _IONBF, 0)change the buffering
feof / ferror / clearerrwhy the last call stopped
remove / rename / tmpfilefilesystem operations

printf conversions

%d %isigned int
%u %o %x %Xunsigned: decimal, octal, hex
%f %Ffixed point, 6 decimals by default
%e %Escientific
%g %Gthe shorter of %e and %f
%a %Ahexadecimal float
%sa NUL-terminated string
%cone character
%pa pointer
%%a literal percent sign
%nwrite the count so far through a pointer

Length modifiers and flags

%hhd %hdsigned char, short
%ld %lldlong, long long
%zusize_t
%tdptrdiff_t
%jdintmax_t
%Lflong double
%" PRId64 "a fixed-width type
%-10s %10sleft / right justify in a field
%08.3fzero-pad to 8, 3 decimals
%+d % dalways sign; space for positive
%#x %#oalternate form: 0x, 0 prefix
%*d %.*swidth or precision from an argument

scanf, and why not to

scanf("%d", &n)leaves the bad input in the buffer
scanf("%s", buf)unbounded, like gets
fgets + strtolthe replacement
sscanf(line, "%d %d", &a, &b)parse from a string
%[^\n]a scanset: everything but newline

Maths & Time

24

<math.h> — the ones with sharp edges

fabs / fmin / fmaxabsolute value, minimum, maximum
sqrt / cbrt / hypotroots; hypot avoids overflow
pow(x, y)x to the y
exp / log / log2 / log10exponential and logarithms
sin / cos / tan / atan2trigonometry, in radians
floor / ceil / round / truncthe four roundings
fmod / remainderfloating remainder, two definitions
isnan / isinf / isfiniteclassify a value
NaN comparisonsevery comparison with NaN is false
nextafter(x, y)the next representable value
fma(x, y, z)x*y+z with one rounding
ckd_add / ckd_sub / ckd_mulchecked integer arithmetic

<time.h>

time(NULL)seconds since the epoch
clock_gettime(CLOCK_MONOTONIC, &ts)the one to measure elapsed time with
timespec_get(&ts, TIME_UTC)the ISO C spelling
clock()processor time, in CLOCKS_PER_SEC
localtime_r / gmtime_rbroken-down time, reentrantly
strftime(buf, n, "%F %T", tm)format a time
mktime(&tm)broken-down time back to time_t
difftime(a, b)the difference, as a double

<ctype.h>, and the trap in it

isalpha / isdigit / isspace …isspace((unsigned char)*p)The argument must be representable as unsigned char or be EOF. Passing a plain char that is negative is undefined behaviour. [undefined, unspecified or implementation-defined]classify a character
tolower / toupperconvert case
isprint / isgraph / ispunctprintable, printable-not-space, punctuation
isxdigita hexadecimal digit

Threads & Atomics

24

<threads.h> — C11, and often absent

thrd_create(&t, fn, arg)start a thread
thrd_join(t, &res)wait for it, collect the result
thrd_detach(t)never join it; it cleans itself up
mtx_init / mtx_lock / mtx_unlocka mutex
cnd_wait / cnd_signal / cnd_broadcasta condition variable
call_once(&flag, fn)run exactly once, ever
tss_create / tss_get / tss_setthread-specific storage
pthread_* insteadwhat everything actually uses

<stdatomic.h>

_Atomic int n;an atomic object
atomic_load / atomic_storeexplicit access
atomic_load_explicit(&n, memory_order_relaxed)access with a chosen ordering
atomic_fetch_add / _sub / _or / _andread-modify-write
atomic_compare_exchange_weak(&p, &e, d)CAS, in a loop
atomic_flag_test_and_setthe one lock-free type guaranteed
atomic_is_lock_free(&x)ask whether it really is
atomic_thread_fence(order)a standalone barrier

The memory orders, from weakest

memory_order_relaxedatomic, no ordering at all
memory_order_consumeordering through data dependency
memory_order_acquirelater reads may not move before this load
memory_order_releaseearlier writes may not move after this store
memory_order_acq_relboth, for a read-modify-write
memory_order_seq_cstone total order across all threads
a data raceundefined behaviour, not a wrong answer
volatile is not atomicit orders nothing between threads

Standard Headers

36

Freestanding (available everywhere)

<stddef.h>size_t ptrdiff_t NULL offsetof
<stdint.h>int32_t uintptr_t INT64_MAX
<limits.h>INT_MAX CHAR_BIT
<stdbool.h>bool true false
<stdalign.h>alignas alignof
<stdarg.h>va_list va_start va_arg
<float.h>DBL_EPSILON FLT_EVAL_METHOD
<stdatomic.h>_Atomic and the memory orders
<stdnoreturn.h>noreturn

Hosted

<stdio.h>FILE printf fopen
<stdlib.h>malloc strtol qsort exit
<string.h>memcpy strlen strcmp
<math.h>sqrt fma isnan
<tgmath.h>type-generic maths
<errno.h>errno and the E constants
<assert.h>assert static_assert
<time.h>clock_gettime strftime
<ctype.h>isalpha tolower
<inttypes.h>PRId64 SCNu32 strtoimax
<signal.h>signal raise sig_atomic_t
<setjmp.h>setjmp longjmp
<locale.h>setlocale
<threads.h>thrd_create mtx_t
<complex.h>complex I cabs
<fenv.h>fesetround feclearexcept
<uchar.h>char16_t char32_t
<wchar.h> <wctype.h>wide characters

POSIX, in constant use

<unistd.h>read write close fork getopt
<fcntl.h>open O_RDONLY
<sys/stat.h>stat mkdir chmod
<sys/mman.h>mmap munmap
<pthread.h>the actual threading API
<dlfcn.h>dlopen dlsym dlerror
<sys/socket.h> <netdb.h>sockets and getaddrinfo
<poll.h>poll
<endian.h>htobe64 and friends

C23 at a Glance

33

New keywords and spellings

bool true falsenow keywords
nullptr / nullptr_ta typed null pointer constant
constexpra true compile-time constant object
typeof(x) / typeof_unqual(x)the GNU extension, standardised
autotype inference from the initialiser
static_assert(c, "msg")a keyword now
alignas / alignofwithout the underscores
thread_localwithout the underscore
_BitInt(N)exact-width integer of any width

New syntax

[[nodiscard]] [[maybe_unused]]standard attribute syntax
[[gnu::always_inline]]vendor-namespaced attributes
0b1010 and 1'000'000binary literals and digit separators
#embed "file"embed a binary file
__VA_OPT__(,)conditional variadic expansion
struct s v = {};the empty initialiser
enum e : unsigned char { ... }an enum with a fixed underlying type
f()now means f(void)

New library

<stdbit.h>portable bit operations
<stdckdint.h>checked arithmetic
unreachable()in <stddef.h>
memset_explicit()a memset the optimiser may not remove
strdup / strndupfinally standard
memccpy / strdupimported from POSIX
timespec_getres()clock resolution
printf %bbinary conversion

Removed or deprecated

K&R function definitionsremoved
Trigraphsremoved
Implicit intlong gone, now firmly an error
Implicit function declarationsnow an error
_Noreturn / <stdnoreturn.h>deprecated
<iso646.h>deprecated
ATOMIC_VAR_INITremoved
Non-two's-complement signed integersremoved from the standard

The Working Guide

C as the standard defines it — the statements first, then the type system, the object model, and the corners that cost an evening

The Shape of a Program

translation units and phases

C has no modules and no build system. The unit of compilation is one .c file after the preprocessor has finished with it — a translation unit — and the linker glues the results together by name. Everything awkward about headers, static and extern follows from that one fact.

a.c + every header it includesone translation unit → one object filethe linkermatches names across objects. It has no idea what a type isa headeris not a module; it is text, pasted inthe ODR, informallyone definition per program, any number of declarations

The eight translation phases, which explain the odd corners

PhaseDoesExplains
1–2map the character set; splice lines ending in \why a trailing space after \ in a macro breaks it
3split into preprocessing tokens; comments become one spacewhy a/**/b is two tokens, not ab
4execute directives; expand macros; recurse into #includewhy the preprocessor cannot see types
5–6convert escapes; concatenate adjacent string literals"a" "b" is "ab" — how long strings are wrapped
7compilethe first phase that knows what an int is
8linkwhere undefined reference comes from

main, exactly

int main(void)one of the two forms the standard guaranteesint main(int argc, char *argv[])the other. argv[argc] is a null pointervoid main()not C. It has never been C, whatever your textbook saidfalling off the end of mainreturns 0 — main is the ONE function where that is definedreturn 0 / EXIT_SUCCESSEXIT_FAILURE is the only other portable value

Hosted and freestanding

A hosted implementation gives you the whole library and starts at main. A freestanding one — a kernel, firmware, a bootloader — is only required to provide <float.h>, <limits.h>, <stdarg.h>, <stdbool.h>, <stddef.h>, <stdint.h> and a handful more, and the entry point is whatever the implementation says. __STDC_HOSTED__ tells you which you are in.

Statements

the whole grammar, on one card

C has six kinds of statement and no more. Everything else is an expression with a semicolon after it.

KindForms
labelledlabel:   case c:   default:
compound{ declarations and statements } — and a scope
expressionexpr ;   and the null statement ;
selectionif   if/else   switch
iterationwhile   do/while   for
jumpgoto   continue   break   return

The forms, written out

if (e) s1 else s2e is any scalar, compared against 0. Pointers workswitch (e) { case k: … default: … }e is integer-promoted; case labels must be integer CONSTANT expressionswhile (e) stest first — the body may run zero timesdo s while (e);test last — runs at least once. Semicolon requiredfor (init; test; step) sinit may be a declaration since C99, scoped to the loopfor (;;)the idiomatic infinite loop; while(1) trips -Wconstant-condition on some settingsgoto label;function-scoped. The one legitimate use is error cleanupreturn e;or bare `return;` in a void function

The traps in the grammar itself

Four bugs that are grammar, not logic. if (x = 1) assigns and is always true — write if ((x = 1)) if you meant it, so -Wparentheses stops complaining. if (x) ; { … } — the stray semicolon is the whole body. A dangling else always binds to the nearest unmatched if, whatever the indentation says. And a switch case without break falls through silently: since C23, say [[fallthrough]]; when you mean it, because a comment no longer satisfies -Wimplicit-fallthrough=5.

goto, used properly

int f(void) {  FILE *a = NULL, *b = NULL; int rc = -1;declare and zero everything first  if (!(a = fopen(p, "r"))) goto out;  if (!(b = fopen(q, "w"))) goto out;  rc = 0;out:one exit, one cleanup path  if (b) fclose(b); if (a) fclose(a);reverse order of acquisition  return rc; }

This is the shape the Linux kernel uses everywhere, and it is not a stylistic relic: C has no destructors, so a single labelled cleanup block is the only way to release N resources on M error paths without writing N×M lines. The rule that makes it safe is initialising every resource to a sentinel before the first goto can jump past it.

Two sharp edges around jumps

breakleaves ONE loop or switch. Two nested loops need a goto or a flagcontinue in a forthe step clause STILL runscontinue in a whilethe increment you wrote in the body does NOT — the classic infinite loopgoto into a VLA's scopeundefined behaviourgoto forward past an initialiserthe object exists but holds an indeterminate valuea label at the end of a blocklegal in C23; before it, a label had to be followed by a statement (`;` sufficed)

Expressions

precedence, and where it bites

The precedence table has fifteen levels and nobody remembers it. Four facts about it cause almost every real bug.

The four to actually memorise.  1. The bitwise operators bind looser than the comparisons, so x & 1 == 0 means x & (1 == 0).  2. Postfix binds tighter than unary, so *p++ increments p, not *p.  3. The member operators bind tighter than *, so *p.f is *(p.f).  4. Assignment is right-associative and yields a value, which is what makes if (x = 1) compile.

The table, compressed

Tightest firstAssociativity
() [] . -> i++ i-- (T){}left
++i --i + - ! ~ * & (T) sizeof alignofright
* / %left
+ -left
<< >>left
< <= > >=left
== !=left
& then ^ then |left — all three looser than ==
&& then ||left; both short-circuit
?:right
= += -= …right
,left

Short-circuiting is a guarantee, not an optimisation

if (p && p->n)p->n is NOT evaluated when p is null. Guaranteedif (i < n && a[i])the bounds check that actually worksx = f() || g();g() runs only if f() returned 0there is a sequence point at && and ||everything left of it is complete before anything right of it starts& and | do NOT short-circuitboth sides always evaluate

The conditional operator, and what type it has

c ? a : bexactly one arm is evaluated1 ? 1 : 2.0has type double — the arms are converted to a common type first1 ? "a" : NULLchar*; a null pointer constant converts to the other arm's pointer typex ? f() : (void)0the idiom for "call it or do nothing" in a macro

Compound literals, the underused feature

f(&(struct point){ .x = 1, .y = 2 });an anonymous object, passed by addressp = (int[]){ 1, 2, 3 };an anonymous array; lifetime is the enclosing block(struct opt){ .verbose = 1 }named arguments, effectively — everything else is zeroed

C99, and available everywhere. The lifetime rule is the catch: at block scope the object dies at the closing brace, so returning a pointer to one is a dangling pointer. At file scope it has static storage and lives forever.

Types

what is guaranteed and what is not

Almost nothing about size is fixed. What is fixed is the ordering and the minimum widths, and that turns out to be enough to write portable code — provided you never assume the rest.

GuaranteedNot guaranteed
sizeof(char) == 1, by definitionthat a char is 8 bits (CHAR_BIT says)
char ≤ short ≤ int ≤ long ≤ long longthat any two of them differ
short ≥ 16, long ≥ 32, long long ≥ 64 bitsthat int is 32 (it is 16 on AVR)
two's complement, since C23the signedness of plain char
sizeof yields size_t, unsignedthat a pointer fits in a long
padding may exist between memberswhere, or how much
The one that costs money: long. It is 64 bits on Linux and macOS and 32 bits on Windows. Code that stores a pointer or a file offset in a long is portable between two of the three. Use int64_t, size_t, ptrdiff_t or intptr_t and the question does not arise.

Which type to reach for

inta small number, a loop counter, a flag. Still the right defaultsize_ta size, a count, an index into an array. Anything sizeof or strlen returnedptrdiff_tthe difference between two pointers, and any index that may go negativeint32_t / uint32_ta wire format, a hardware register, a file formatuint8_ta byte of data. NOT char, which may be signedunsignedanything that is allowed to wrap: a hash, a checksum, a ring indexboola truth value, since C23 without an includedoublefloating point. float only when you have a reason — memory, or SIMD width

Floating point, briefly

0.1 + 0.2 != 0.3true, and not a compiler bug. Binary cannot represent 0.1NaN == NaNfalse. Every comparison with NaN is false, including !=isnan(x)the correct test; x != x breaks under -ffast-mathDBL_EPSILONthe gap after 1.0 — NOT a general-purpose tolerancefabs(a-b) <= tol * fmax(fabs(a), fabs(b))a relative comparison that works away from zero%.17gprints a double so it reads back exactlylong double80-bit on x86 Linux, 64-bit on AArch64 macOS, 128-bit on some. Avoid

_BitInt and the C23 additions

_BitInt(24) x;exactly 24 bits, signed. For hardware and bit-exact arithmeticunsigned _BitInt(3) f;a 3-bit field that is a real type, not a bitfieldBITINT_MAXWIDTHhow wide this implementation will gonullptr_tthe type of nullptr — a null pointer constant with a type at last

Conversions

promotion, and the unsigned trap

Every arithmetic operator converts its operands before it does anything. Two rules, applied in order, explain a whole class of bug that only appears at the boundaries.

Rule 1: integer promotion

Anything narrower than intchar, short, a bitfield, bool — becomes int before it is used, if int can hold every value; otherwise unsigned int. This happens even to two unsigned operands.

unsigned char a = 200, b = 200;a * bboth promote to int; 40000 fits, so the result is int 40000uint16_t x = 65535; x * xpromotes to int; 4294836225 OVERFLOWS int — undefined behaviour(unsigned)x * xthe fix: force one operand wide and unsigned first

Rule 2: the usual arithmetic conversions

After promotion, the two operands are brought to a common type. Same rank but different signedness, and the signed one converts to unsigned — which is where it goes wrong.

-1 < 1u is false. The -1 converts to UINT_MAX. The same rule makes if (i < strlen(s) - 1) loop forever on an empty string, because strlen returns size_t and 0 - 1 is SIZE_MAX. Turn on -Wsign-compare (it is in -Wextra) and fix every hit; there is no idiom that makes mixed-sign comparison safe.

The conversions with named behaviour

ConversionBehaviour
signed → unsigneddefined: reduced modulo 2N
unsigned → signed, value fitsdefined: the value
unsigned → signed, does not fitimplementation-defined; two's complement wrap since C23
float → integertruncates toward zero; undefined if out of range
integer → floatrounds; defined, may lose precision
pointer → integerimplementation-defined; use uintptr_t
anything → booldefined: zero becomes false, everything else true

Array and function decay

int a[10]; f(a);a decays to int* — the array's size is gone at the callsizeof a40. Inside f, sizeof(param) is 8: the size of a pointervoid f(int a[10])the 10 is documentation. It is really int*void f(int a[static 10])C99: promises at least 10 elements. The compiler may warn, and may optimise on it&atype int(*)[10] — the ONE place the array does not decayf, &f, *fall the same function pointer. Function designators decay too

Losing the size at a function boundary is why every C API that takes a buffer also takes a length, and why getting the two out of step is the most productive bug family in the language.

Declarations

reading them, and the const rule

A C declaration says: this expression, written with the name in it, has this type. Read it as an expression and the syntax stops being arbitrary.

int *a[10];*a[i] is an int → a is an array of 10 pointers to intint (*b)[10];(*b)[i] is an int → b is a pointer to an array of 10 intsint (*f)(void);(*f)() is an int → f is a pointer to a function returning intint *g(void);*g() is an int → g is a function returning a pointer to intint (*h[5])(void);an array of 5 pointers to functions — a dispatch tablechar *const *p;pointer to a const pointer to char

The rule underneath: [] and () bind tighter than *, so parentheses are what put the * first. cdecl will read any of them aloud, and one typedef makes all of them readable.

const reads right to left

const char *pp is a pointer to a const char. p may move; *p may not be writtenchar const *pidentical to the above. The two spellings mean the same thingchar *const pp is a const pointer to char. *p may be written; p may not moveconst char *const pneitherchar **q; const char **r; q = r;an error, and correctly so — const is not covariant through a pointer

Initialisation, and what is zeroed

int x;at block scope: INDETERMINATE. Reading it is undefined behaviourstatic int y;zero. Static and thread storage are always zero-initialisedstruct P p = {0};every member zeroed, however many there arestruct P p = {};C23: the same, and it works when the first member is itself a structstruct P p = { .y = 2 };designated: y is 2, everything else is 0int a[] = { [9] = 1 };ten ints; the array is sized by the highest indexchar s[] = "hi";a writable array of 3 charschar *s = "hi";a pointer to a string literal. Writing through it is undefined
= {0} is the most useful three characters in C. It zeroes an entire aggregate, however deeply nested, and it keeps working when somebody adds a member. Combined with designated initialisers it gives you named optional arguments: connect(&(struct opts){ .timeout = 5 }) — every other field defaulted, and the call site says which one you set.

Where the type qualifiers actually go

volatile uint32_t *rega pointer to a volatile register — what memory-mapped I/O needsuint32_t *volatile pa volatile pointer to ordinary memory. Almost never what you meantvoid f(int *restrict a, int *restrict b)a promise that a and b do not overlap_Atomic int nvs int _Atomic n — both legal, both the samealignas(64) char line[64]C23 spelling; _Alignas before it

Pointers & Arrays

the same thing, and not

An array is not a pointer. It decays to one in almost every expression, which is close enough to fool everyone until the one place it does not.

int a[10]int *p
sizeof408
assignablenoyes
&xint (*)[10]int **
as a parameterbecomes int *int *
in a structthe storage is in the struct8 bytes pointing elsewhere
a[i] is *(a + i)which is why 3["abc"] compiles, and why it is symmetricp - qptrdiff_t, in elements — defined only within one arraya + 10legal: one past the end may be FORMED, never dereferenceda - 1undefined, even without dereferencing itvoid *converts to and from any object pointer with no cast. Not to a function pointersizeof(void)a constraint violation in ISO C; GCC and Clang say 1 as an extension

Multidimensional arrays are arrays of arrays

int m[3][4];3 arrays of 4 ints, contiguous. Row-majorm[i][j] is *(*(m+i)+j)two dereferences, one contiguous blockvoid f(int m[3][4])really int (*)[4] — the SECOND dimension is what mattersvoid f(int n, int m[n][4])a VLA parameter; the first dimension may be dynamicvoid f(int r, int c, int m[r][c])C99: both dimensions dynamic. The parameters must come firstint **ppNOT compatible with int[3][4]. A different memory layout entirely
int ** is not a 2-D array. It is an array of pointers, each pointing somewhere else — two allocations, two indirections, no contiguity. A real 2-D array is one block. Passing one where the other is expected compiles only with a cast, and the cast makes it undefined behaviour rather than fixing anything.

Function pointers

int (*cmp)(const void *, const void *) = my_cmp;the qsort shapecmp(a, b)identical to (*cmp)(a, b) — both spellings worktypedef int (*op)(int, int);name it once and never write it againop table[] = { add, sub, mul };a dispatch tablevoid *dlsym(…)POSIX requires a cast to a function pointer that ISO C does not define. Use a union or memcpy if it mattersa function pointer is not void*the conversion is not defined by ISO C, though every real platform allows it

The null pointer

NULLa macro: 0, 0L, or ((void*)0). The variation is the problemnullptrC23. Has a type, so it cannot be passed as an int through varargs by accidentif (p)the idiomatic null test; identical to p != NULLexecl(path, arg, (char *)NULL)varargs need the CAST — NULL may be a bare 0 and the wrong widtha null pointer is not necessarily all-zero bitstrue in the standard; false on everything you own. memset to zero anyway

Structs, Unions & Enums

layout, and what you may assume

A struct is its members in declaration order, with implementation-defined padding wherever alignment demands it and possibly at the end. Order is guaranteed; everything else is not.

struct { char a; int b; char c; }12 bytes on x86-64: 1 + 3 pad + 4 + 1 + 3 padstruct { int b; char a, c; }8 bytes. Same members, sorted widest-firstoffsetof(struct s, b)the only portable way to ask where a member isalignof(struct s)the alignment of its most-aligned membersizeof is always a multiple of alignofwhich is where the trailing padding comes from
Never memcmp two structs. The padding bytes are indeterminate — they may differ between two structs holding identical values, so the comparison returns non-zero for equal objects. Compare member by member, or hash the members. The same reason means you cannot memcpy a struct into a file and expect another machine to read it.

Flexible array members

struct buf { size_t n; char data[]; };C99. Must be last, and sizeof does not count itmalloc(sizeof(struct buf) + n)one allocation, header and payload contiguouschar data[0]the pre-C99 GNU spelling. Still seen; not ISOchar data[1]the pre-C99 portable hack, and an off-by-one waiting to happen__attribute__((counted_by(n)))GCC 14 / Clang 18: tells the bounds sanitizer where the length lives

Unions, and type punning

union { float f; uint32_t u; } x;one object, two readingsx.f = 1.0f; printf("%08x", x.u);DEFINED in C — reading a member you did not write is type punning, and C allows itthe same code in C++undefined. This is a real difference between the languagesmemcpy(&u, &f, sizeof u)defined in both, and compiles to the same instruction. Prefer ita trap representationthe one case where reading the other member is still undefined

Bitfields, and why they are not for hardware

struct { unsigned a : 3, b : 5; };eight bits of intent, and no portable layoutallocation orderimplementation-defined: high bits first or low bits firstplain `int x : 1`signed or unsigned is implementation-defined. Say `signed` or `unsigned`straddling a storage unitimplementation-defined whether it is allowed&fieldnot permitted at all — a bitfield has no address

They are fine for saving space inside one program compiled by one compiler. For a hardware register or a wire format, use an explicit uint32_t with shifts and masks — it is the same amount of code and it means the same thing everywhere.

Enums

enum colour { RED, GREEN, BLUE };0, 1, 2. The enumerators are ints, in the ordinary namespaceenum { A = 1, B = 4, C };C is 5. Explicit values are allowed anywhereenum { N = 100 };the pre-C23 way to get a named integer constant that works in an array boundenum e : unsigned char { … };C23: a fixed underlying type at last-Wswitch-enumevery enumerator must be handled, even with a default. Worth turning onan enum variable may hold any value of its typea switch on one still needs a default

Objects & Lifetime

four storage durations

Every object has a storage duration, and it decides when the memory exists and when reading it is undefined.

DurationLivesInitialised to
automaticfrom entering the block to leaving itnothing — indeterminate
staticthe whole programzero, or the initialiser
threadthe whole threadzero, or the initialiser
allocatedfrom malloc to freenothing (calloc: zero)
int x;inside a function: indeterminate. Reading it is undefined behaviourstatic int y;zero, guaranteed, before main runsthread_local int z;zero, per threadchar *p = malloc(n);indeterminate bytes. calloc(n,1) for zeroed-ftrivial-auto-var-init=zeroremoves the whole class, at a cost near zero

The dangling-pointer families

return &local;the object is gone at the return. -Wreturn-local-addr catches the direct case onlychar *f(void){ char b[64]; …; return b; }the same bug, wearing an arrayfree(p); use(p);use after free. ASan's specialityp = realloc(p, n);leaks the original if realloc returns NULL. Use a temporarya pointer into a struct you realloc'devery interior pointer is invalid after a realloc that moveda compound literal at block scopedies at the closing brace, like any local
The lifetime rule that catches people out: a returned pointer must outlive the caller's use of it. There are exactly four ways to satisfy it — return a pointer to static storage (not reentrant), to allocated storage (the caller must free it), into an object the caller passed in (the usual answer), or return the value itself rather than a pointer. Any C API you design is choosing one of those four, and saying which in the documentation is most of the contract.

Variable-length arrays

int a[n];C99, made optional in C11. sizeof a is evaluated at run time__STDC_NO_VLA__defined if the implementation does not have them-Wvlathe flag most projects turn on to ban themthe stack limita large n is an unbounded stack allocation with no way to detect the failureVLA parametersvoid f(int r, int c, int m[r][c]) — genuinely useful, and widely supported

The parameter form is worth keeping; the local-variable form is not. It gives you an allocation that can fail silently with no error path, which is why the Linux kernel removed every one of them.

Linkage & Scope

and the inline rule

Scope decides where a name is visible; linkage decides whether two declarations in different translation units refer to the same object. They are independent, and confusing them is what makes header files mysterious.

At file scopeLinkageMeans
int x;externalone object, shared across the program
static int x;internalprivate to this translation unit
extern int x;external, no definitiona declaration — the definition is elsewhere
const int x = 1;external (unlike C++)put it in a header and you get duplicate definitions
The header rule, once and for all. A header contains declarations: extern int x;, function prototypes, types, macros, and static inline functions. It contains no definitions of objects. The definition — int x; — goes in exactly one .c file. Break that and the symptom is multiple definition of 'x' at link time, or, worse, a linker that quietly merges them under -fcommon.

The inline rule, which is genuinely strange

static inline int sq(int x){return x*x;}put this in a header. It always works. Use itinline int sq(int x){…}an inline DEFINITION. It does NOT emit a symbolextern inline int sq(int);in exactly one .c file: emits the external definitionthe failureundefined reference to `sq' at -O0, where nothing was inlined and no symbol existsC++ is differentthere, inline means "one definition, merge them". C's rule is not that

If you take one thing from this card: write static inline in headers and never think about it again. The bare inline form exists so a library can provide both an inlinable definition and a real symbol, and outside that case it is a trap that only fires in unoptimised builds.

Tentative definitions, and -fno-common

int x;at file scope with no initialiser: a TENTATIVE definitionint x; int x;legal in one translation unit — they are the same objectint x; in two .c filespre-2020: silently merged. Since GCC 10 / Clang 11: a link error-fcommonthe old behaviour. Its absence is why old code suddenly stopped linkingint x = 0;an actual definition. Initialise it and the ambiguity is gone

The namespaces C actually has

ordinary identifiersvariables, functions, typedefs, enum constantstagsstruct, union and enum names — a separate namespacestruct membersone namespace per structlabelsone per functionstruct stat; int stat();both legal at once, which is why struct tags are usually typedef'd

Aliasing & Alignment

the rules the optimiser relies on

Strict aliasing is the assumption that two pointers to different types do not point at the same object. It lets the compiler keep a value in a register across a store through an unrelated pointer — and it is what breaks code that reinterprets memory by casting.

float f = 1.0f;uint32_t u = *(uint32_t *)&f;UNDEFINED. Works at -O0, may not at -O2memcpy(&u, &f, sizeof u);defined, portable, and compiles to the same single instructionunion { float f; uint32_t u; } x;defined in C (not in C++). Also fine-fno-strict-aliasingturns the assumption off. What the Linux kernel builds with-Wstrict-aliasing=2catches some of it. Not all — it cannot

What you may alias, whatever the types

char *, signed char *, unsigned char *a character type may alias ANYTHING. This is the exemption memcpy relies onuint8_t *only because it is a typedef for unsigned char in practice — not by the standarda struct and its first memberdefined: a pointer to a struct may be converted to a pointer to its first membercompatible typessigned and unsigned versions of the same type may alias__attribute__((may_alias))the GNU escape hatch, for one type
memcpy is the whole answer. Every modern compiler recognises a fixed-size memcpy between two locals and emits the single load or store you wanted — no function call, no copy. It is defined behaviour, it is portable to C++, and it is not slower. There is no remaining reason to write the pointer cast.

Alignment

alignof(double)8 on nearly everything; 4 on 32-bit x86 Linuxalignas(64) char line[64];cache-line alignment, C23 spellingmax_align_tthe strictest fundamental alignment. What malloc guaranteesaligned_alloc(64, n)C11. n must be a multiple of the alignment*(uint32_t*)(buf + 1)misaligned. Fine on x86, a fault on some Arm configurations, and undefined everywhere-fsanitize=alignmentcatches it at run time, which is the only way you will find it

restrict, the promise you make

void copy(int *restrict d, const int *restrict s, size_t n)d and s do not overlap. Now the loop vectorisesmemcpydeclared with restrict — which is exactly why overlapping memcpy is undefinedmemmovedeclared without it, and therefore safe for overlapbreaking the promiseundefined behaviour, with no diagnostic and no sanitizer that finds it

restrict is the one qualifier that is purely a promise to the optimiser with nothing checking it. It is worth adding to a hot numerical kernel where you control every caller, and worth leaving off a public API where you do not.

Sequencing

what happens before what

C does not evaluate expressions left to right. It evaluates them in any order it likes, subject to a small set of sequencing rules — and two modifications of the same object with no rule between them are undefined behaviour, not merely unpredictable.

Unspecified is not undefined. The order of function arguments is unspecified: f(g(), h()) may call either first, but the program is valid and one of the two orders happens. i = i++ is undefined: two unsequenced modifications of i, and the compiler may do anything at all, including deleting surrounding code.

Where the sequence points are

;at the end of a full expression&& and ||everything on the left completes before the right starts?:the condition completes before either armthe comma OPERATORleft completes before right. NOT the comma between argumentsa function callall arguments are evaluated (in some order) before the callC11 changed the vocabulary"sequenced before" replaced "sequence point"; the rules are the same

The classic undefined expressions

i = i++;undefineda[i] = i++;undefinedf(i++, i++);undefined — unsequenced modifications, not merely unspecified orderi = ++i + 1;undefinedprintf("%d %d", i++, i++);undefineda[i++] = b[i++];undefined

And the ones that are fine

i++; j = i;a semicolon between themx = i++ + 1;one modification of i, one use. Fineif (p && p->n++)&& sequences ita[f()] += 1;the left operand of a compound assignment is evaluated ONCEx = y = z = 0;right-associative; each assignment is sequenced by the next

volatile, and what it does not do

volatileeach access is an observable side effect: the compiler may not elide, cache or reorder ITS accessesit does notmake anything atomicit does notstop the CPU reordering, or emit a barrierit does notsynchronise anything between threadsuse it formemory-mapped registers, and variables touched by a signal handler (sig_atomic_t)use _Atomic foreverything to do with threads

"I made it volatile and the race went away" means the timing changed, not the bug. A data race is undefined behaviour regardless of volatile; the fix is _Atomic, a mutex, or not sharing the object.

The Preprocessor

a text substituter with no idea what C is

The preprocessor runs before the compiler and knows nothing about types, scope or expressions. Every surprising thing it does follows from that.

#define N 10an object-like macro. No type, no scope, no respect for shadowing#define SQ(x) ((x)*(x))function-like ONLY if the ( touches the name#define SQ (x) …with a space: an object-like macro whose body starts "(x)"#undef Nfrom here to the end of the file#if / #elif / #else / #endifinteger arithmetic only. No sizeof, no floats, no enumsundefined identifiers in #ifevaluate to 0. A typo'd macro name is silently false

Writing a macro that does not bite

#define SQ(x) ((x)*(x))parenthesise every parameter AND the whole bodySQ(a+b)without the inner parens: a+b*a+bSQ(i++)evaluates i++ twice. No amount of parenthesising fixes this#define SWAP(a,b) do{…}while(0)the wrapper that makes a multi-statement macro safe after `if` and forces the semicolonstatic inlinethe real answer. A function evaluates its arguments once and has a type

Stringify and paste, and the two-level rule

#define STR_(x) #xthe inner one stringifies#define STR(x) STR_(x)the outer one expands x FIRSTSTR(__LINE__)"42". With only one level you get "__LINE__"#define CAT_(a,b) a##bsame shape for token pasting#define CAT(a,b) CAT_(a,b)and the result must be one valid token

Variadic macros

#define LOG(fmt, ...) printf(fmt, __VA_ARGS__)C99. Breaks on LOG("hi") — a trailing comma with nothing after it#define LOG(fmt, ...) printf(fmt __VA_OPT__(,) __VA_ARGS__)C23: the comma appears only if there are arguments. The proper fix##__VA_ARGS__the GNU extension everyone used before C23. Still works

The directives worth knowing about

#pragma oncenot ISO, universally supported, and it cannot get the guard name wrong#ifndef HDR_H …the portable guard. The name must be unique across the entire program#error "message"fail the compile with a message. Use it to assert about the platform#warning "message"standard at last in C23; an extension everywhere for decades#embed "f.bin"C23: a binary file as a comma-separated byte list. Replaces xxd -i#elifdef / #elifndefC23 shorthands#line 42 "x.y"what a code generator emits so errors point at the real source

_Generic

type dispatch, at compile time

C11's one genuinely new expression. It picks a branch by the type of a controlling expression, at compile time, with no run-time cost — and it is what makes a type-generic macro possible without the preprocessor guessing.

_Generic(x, int: a, double: b, default: c)the whole syntax. Yields a, b or c by the type of xx is NOT evaluatedlike sizeof. Side effects in it do not happenthe type is after promotion?NO — lvalue conversion applies, so arrays decay and const is strippedduplicate typesa constraint violation, caught at compile timeno match and no defaultan error, which is often what you want

The idiom it exists for

#define abs(x) _Generic((x), \  int: abs, long: labs, \  float: fabsf, double: fabs, \  default: fabs)(x)tgmath.h is built exactly like this
#define TYPENAME(x) _Generic((x), \  int: "int", double: "double", \  char*: "char*", default: "?")a printf-style debug helper in four lines

What it cannot do

match a struct by shapeonly by exact type namematch "any pointer"each pointer type is separate. void* does not catch thembe used in a #ifit is an expression, not a preprocessor constructsee through a typedefa typedef is not a distinct type, so `typedef int myint` matches `int:`distinguish int from a plain enumbefore C23 an enum's compatible type is implementation-defined
C23 improved it in one important way: the unselected branches are no longer required to be type-correct. Under C11, every arm had to compile even though only one was chosen, which meant a generic macro over pointers and integers usually needed casts in the arms that would never be taken. That restriction is gone.

Where you have already met it

<tgmath.h>sqrt, sin, pow dispatching over float / double / long double / complex<stdatomic.h>atomic_load and friends are generic over the atomic typesckd_add / ckd_sub / ckd_mulC23's checked arithmetic, generic over the integer types_Generic + __auto_typehow a small container library gets written in C without void*

The Standard Library

and what to refuse

The library is small, old, and uneven. Several functions cannot be used safely at all, and knowing which is worth more than knowing the rest of it.

RefuseBecauseUse
getsno bound. Removed in C11fgets, or POSIX getline
strcpy, strcat, sprintfno boundsnprintf, which always terminates
strncpydoes not terminate on truncation, and padssnprintf or strlcpy
atoi, atol, atofundefined on overflow; 0 means both "0" and "error"strtol, strtod
strtokmodifies the input, keeps static statestrspn/strcspn, or strtok_r
randquality unspecified; often 15 bitsa real PRNG; arc4random or getrandom for security
scanf("%s")unbounded, and leaves bad input in the bufferfgets then parse

strtol, done properly

errno = 0;it is not cleared for youchar *end;long v = strtol(s, &end, 10);base 0 accepts 0x and leading-0 octalif (end == s) …nothing was converted at allif (*end != '\0') …trailing junkif (errno == ERANGE) …overflow or underflowif (v < INT_MIN || v > INT_MAX) …and it still may not fit an int

Six checks. That is why people reach for atoi, and why programs that use atoi accept "12abc" as 12 and "99999999999999" as something undefined.

The parts that are genuinely good

snprintfalways terminates, and returns the length it WANTED — check it to detect truncationqsort / bsearchfine. Do not write `return *a - *b` in the comparator: it overflowsmemcpy / memmove / memsetrecognised and inlined by every compilerstrtodcorrectly rounded, unlike most hand-written parsers<stdint.h> and <inttypes.h>fixed-width types and their printf macros<stdckdint.h>C23 checked arithmetic. Long overdue

errno, and what it actually promises

errnothread-local, and it is a macro, not a variablea successful call may still SET itso check the return value first, errno only after a failureset errno = 0 beforethe functions that report failure only through it: strtol, strtodstrerror(errno)the message. Not reentrant; strerror_r isperror("open")prints "open: No such file or directory" to stderr

Strings & Memory

the shape of the bugs

C has no string type. It has a convention: a pointer to bytes ending in a zero. Every string bug is one of four things — no terminator, not enough room, the wrong length, or a pointer that outlived its buffer.

char s[] = "hi";3 bytes on the stack, writablechar *s = "hi";a pointer to a string LITERAL. Writing through it is undefinedsizeof s vs strlen(s)3 vs 2. One counts the NUL, the other does notstrlen is O(n)calling it in a loop condition is the commonest accidental quadratic in C"a" "b"concatenated at translation phase 6 — how a long string is wrapped in source

Copying without a buffer overflow

snprintf(d, sizeof d, "%s", s);always terminates. The portable answerif (snprintf(d, sizeof d, "%s", s) >= (int)sizeof d) …and it TELLS you it truncatedstrlcpy(d, s, sizeof d)BSD, glibc 2.38+. What people think strncpy doesmemcpy(d, s, len); d[len] = 0;when you already know the lengthstrncpy(d, s, n)does not terminate if strlen(s) >= n. It was built for fixed-width recordssizeof dcorrect only while d is an array. Inside a function it is a pointer, and sizeof is 8
sizeof on a parameter is the classic silent overflow. void f(char d[64]) { strcpy(d, s); } — the 64 is discarded, sizeof d is the size of a pointer, and any bound you compute from it is wrong. Pass the length explicitly. Every C API that takes a buffer takes a length for exactly this reason.

malloc, and the three ways it goes wrong

p = malloc(n * sizeof *p);`sizeof *p` survives a change of type; sizeof(struct foo) does notcalloc(n, sizeof *p)checks n * sizeof for overflow. malloc(n * sz) does notq = realloc(p, n); if (!q) …; p = q;never `p = realloc(p, …)` — that leaks on failurefree(p); p = NULL;turns a use-after-free into a null dereference, which is at least deterministicmalloc(0)may return NULL or a unique pointer. Both conformevery interior pointeris invalid after a realloc that moved the block

The idioms worth keeping

while ((c = fgetc(f)) != EOF)c must be an int — EOF does not fit in a charwhile (fgets(buf, sizeof buf, f))reads a line; keeps the newline if it fittedbuf[strcspn(buf, "\n")] = 0;strip the trailing newline, safely, in one lineisspace((unsigned char)*p)the ctype cast. A negative plain char is undefined behaviour herefor (char *p = s; *p; p++)iterate a string without calling strlenmemcmp is not constant-timeuse a real constant-time compare for secrets

Threads & the Memory Model

what C11 actually promises

Before C11 the language said nothing about threads, and every guarantee came from the platform. C11 added a memory model: a definition of what a data race is, and a promise that a program without one behaves as if the threads interleaved.

A data race is undefined behaviour, not a wrong answer. Two threads, one memory location, at least one of them writing, and no synchronisation between them. The compiler is entitled to assume it does not happen, so the consequences are not "you read a stale value" — they are arbitrary. -fsanitize=thread finds real ones with almost no false positives.

The three ways to not have a race

a mutexmtx_lock / mtx_unlock, or pthread_mutex_lock. The default answer_Atomicfor a single scalar. Plain ++ on one is an atomic read-modify-writedo not sharethread_local, or pass a copy. The cheapest and most reliable

Atomics, and the orderings

_Atomic int n = 0; n++;atomic, sequentially consistent, and correctatomic_fetch_add_explicit(&n, 1, memory_order_relaxed)atomic, no ordering. Right for a counter nobody reads until the endmemory_order_acquireon a load: nothing after it may move before itmemory_order_releaseon a store: nothing before it may move after it. Pairs with acquirememory_order_seq_cstone total order over all threads. The default, and the expensive oneatomic_compare_exchange_weakCAS. The weak form may fail spuriously, and you were looping anyway
Use sequential consistency until a profile says otherwise. It is the only ordering that behaves the way people reason, and on x86 it costs almost nothing for loads. Relaxed and acquire/release are worth reaching for in a hot lock-free structure, and nowhere else — the class of bug they introduce does not reproduce and does not show up in testing.

<threads.h>, and why nobody uses it

thrd_create / thrd_joinC11's own threadsmtx_t / cnd_t / tss_tmutex, condition variable, thread-specific storagecall_oncerun exactly once, correctly. Genuinely useful__STDC_NO_THREADS__defined when the implementation has none — including macOS, stillpthreadswhat everything actually uses, because it is everywhere C11 threads are not

Signals, which are not threads

volatile sig_atomic_t flag;the ONLY type a handler may safely touch in ISO Casync-signal-safe functionsa short POSIX list. printf and malloc are not on itthe correct handlerset a flag; do the work in the main loopsigaction, not signalsignal()'s behaviour on re-entry is implementation-defined

Undefined Behaviour

why the optimiser deleted your check

Undefined behaviour is not "unpredictable output". It is a promise you made to the compiler that a situation cannot arise — and the optimiser is entitled to build on it, including by deleting the code that would have handled it.

if (x + 1 < x) overflow();deleted. Signed overflow is undefined, so the condition is assumed falseint *p = ...; *p = 1; if (p) …the null check is deleted: the dereference already promised p is not nullchar buf[8]; if (i < 8) use(buf[i]); use(buf[i]);the second access licenses removal of the first checkwhile (1) { }with no side effects, may be assumed to terminate and removedx << 32 on a 32-bit intx86 shifts modulo 32, so it does something, and something surprising
The rule that explains all of them: undefined behaviour propagates backwards. If a path contains UB, the compiler may assume the path is never taken, and may therefore delete a test that would have led to it — a test you wrote several lines earlier. This is why the bug appears at -O2 and vanishes at -O0, and why "it works with optimisation off" is not evidence of a compiler bug.

The families, in order of how often they bite

FamilyExampleFound by
signed overflowINT_MAX + 1, INT_MIN / -1-fsanitize=signed-integer-overflow
out of boundsa[n] on int a[n]-fsanitize=address
use after freefree(p); *p;-fsanitize=address
uninitialised readint x; if (x)MSan; -Wmaybe-uninitialized
null dereference*(int*)0-fsanitize=null
bad shift1 << 32-fsanitize=shift
strict aliasing*(uint32_t*)&afloatnothing reliable. Use memcpy
data racetwo threads, one variable-fsanitize=thread

The defence, in four lines

-Wall -Wextra -Wshadow -Wconversioncatches it before it runs-fsanitize=address,undefined -fno-sanitize-recover=allcatches it while it runs, in the test suite-fwrapvremoves the signed-overflow family entirely, at a small cost-ftrivial-auto-var-init=zeroremoves the uninitialised-read family, at almost none

The first two are free and belong in every project. The second two are trades: you give up a little optimisation and get a whole bug class off the table. For anything that parses input from outside, that is a good trade.

C23

what actually changed

ISO/IEC 9899:2024. The largest revision since C99, and unusually, most of it is things that were already extensions everywhere — which is why adoption has been quick.

The keywords that stopped being macros

bool, true, falsereal keywords. <stdbool.h> is now empty and deprecatedstatic_asserta keyword; the message is optionalthread_localwas _Thread_localalignas, alignofwere _Alignas, _Alignoftypeof, typeof_unqualthe GNU extension, standardisednullptr, nullptr_ta typed null pointer constant at lastconstexpra true compile-time constant object, usable in an array boundautonow means type inference, as in C++

The changes that break old code

Four silent breakages. foo() in a declaration now means foo(void), so a call with the wrong argument count is an error rather than undefined behaviour. K&R function definitions are gone. typedef enum { false, true } bool; is now a syntax error. And an enum may have an underlying type wider than int. GCC 15 made gnu23 its default, so this arrives whether you asked for it or not — state -std= explicitly.

The genuinely new things

#embed "logo.png"a binary file as initialiser bytes. Replaces the xxd-and-generate dance_BitInt(N)an integer of exactly N bits[[nodiscard]] [[deprecated]] [[maybe_unused]] [[fallthrough]] [[noreturn]]standard attributes, in the C++ spelling0b1010binary literals, standardised1'000'000digit separators — the apostrophe, as in C++ckd_add / ckd_sub / ckd_mulchecked arithmetic in <stdckdint.h>memset_explicita memset the optimiser may not delete. For wiping secrets%b in printfbinary output, at last__VA_OPT__the trailing-comma fix for variadic macros#elifdef / #elifndef / #warningsmall preprocessor additions

And the guarantees that changed

two's complement is mandatoryso INT_MIN is exactly -INT_MAX-1, everywheresigned overflow is STILL undefinedthis is the part people misread. The representation is fixed; the overflow is not defined= {}the empty initialiser, for any objecta label may end a compound statementno more `default: ;`unreachable()<stddef.h>: the standard __builtin_unreachable<stdbit.h>popcount, leading zeros, bit width — as portable functions

Where C++ Differs

for code that must compile as both

C is not a subset of C++, and the places they disagree are exactly the places a shared header breaks.

CC++
void * converts implicitlyit does not — malloc needs a cast
type punning through a union is definedundefined; only memcpy or bit_cast
const int x = 1; has external linkageinternal — so it may live in a header
a struct tag is in its own namespaceit is an ordinary type name
char c = 'a';'a' is an int'a' is a char; sizeof('a') differs
inline needs one extern definitioninline means "merge the definitions"
designated initialisers, any orderC++20, declaration order only, no array designators
VLAs (optional since C11)never standard
compound literalsnot standard; a common extension
flexible array membersnot standard; a common extension
no name manglingmangled — hence extern "C"

The header that works from both

#ifdef __cplusplusextern "C" {stop C++ mangling these names#endif… declarations …#ifdef __cplusplus}#endifthe whole idiom, and it has not changed in thirty years
extern "C" affects linkage, not the language. Inside the braces the code is still compiled as C++ — so a designated initialiser or a compound literal in that header will still be rejected by a C++ compiler. It only stops the names being mangled.

Writing C that a C++ compiler accepts

cast malloc's result(struct foo *)malloc(…). Ugly in C, required in C++do not use `class`, `new`, `template`, `this` as identifiersthey are keywords thereavoid designated initialisers in shared headersC++20 accepts a restricted form; earlier ones accept noneavoid VLAs and compound literalsneither is standard C++use memcpy, not a union, to type-pundefined in bothenum values are ints in Can implicit conversion C++ will not do the other way

Traps & Idioms

the ones that survive experience

Six bugs that look correct

if (x = 1)assignment, always true. -Wparentheses; double the parens if you meant itif (x & 1 == 0)parses as x & (1 == 0). == binds tighter than &while (!feof(f))feof is only true AFTER a failed read — the loop runs one time too manychar c = getchar();EOF does not fit in a char. It must be an intfor (i = 0; i < strlen(s); i++)O(n²), and strlen is called every iterationif (i < strlen(s) - 1)size_t: on an empty string, 0-1 is SIZE_MAX and the test is always true

Six idioms worth adopting

p = malloc(n * sizeof *p);`sizeof *p` cannot go stale when the type changesstruct opt o = { .timeout = 5 };named arguments, with everything else zeroedbuf[strcspn(buf, "\n")] = 0;strip a trailing newline in one safe lineif (ckd_add(&r, a, b)) …C23 checked arithmetic; __builtin_add_overflow before itgoto out; … out: cleanup;one exit path for N resourcesstatic_assert(sizeof(struct hdr) == 16, "layout");catch a layout change at compile time, not in the field

Four things people believe that are not true

"NULL is all-zero bits"not guaranteed by the standard. True on everything you own; do not write code that needs it"char is signed"signed on x86, unsigned on Arm. This is why ctype.h needs the cast"volatile makes it thread-safe"it makes nothing atomic and emits no barrier"unsigned overflow is undefined"it is DEFINED — it wraps. Only signed overflow is undefined

The flags to start every project with

-std=c17 (or c23)never rely on the default; the two compilers disagree-Wall -Wextra -Wshadow -Wconversionthe real baseline-Og -gwhile developing-fsanitize=address,undefined -fno-sanitize-recover=allin the test suite-O2 -g -D_FORTIFY_SOURCE=3for release — and keep the unstripped binary-MMD -MPso the build rebuilds correctly after a header edit

The two toolchain sheets — GCC and Clang — take each of these apart properly, including what every one of those flags actually turns on.