Checked Integer Arithmetic with C23 <stdckdint.h>
Thesis
Integer overflow is the quiet bug behind undersized buffers, negative lengths, and “impossible” branches that the optimizer deletes. For decades the portable C answer was hand-written pre-checks (if (a > INT_MAX - b)), which are easy to get wrong for mixed signedness and nearly impossible to get right for 64-bit multiplication. C23 (ISO/IEC 9899:2024, §7.20) standardises the fix: three type-generic macros in <stdckdint.h> —
bool ckd_add(type1 *result, type2 a, type3 b);
bool ckd_sub(type1 *result, type2 a, type3 b);
bool ckd_mul(type1 *result, type2 a, type3 b);Each computes the mathematically exact result, stores it (wrapped) in *result, and returns true if it did not fit. GCC 14 and Clang 18 ship the header and compile each call to an add/multiply plus a branch on the CPU’s overflow or carry flag. There is no longer a reason to write overflow checks by hand in new code.
The boring operator habit: every size, offset, or count that comes from outside the process passes through a ckd_* call (or an allocator that checks for you) before it reaches malloc, memcpy, or an array index.
Mental model
a (any integer type) b (any integer type)
\ /
▼ ▼
exact arithmetic in "infinite precision" — never UB
│
▼
convert to the type of *result (the ONLY type that matters)
│
┌───────────────┴───────────────┐
fits exactly does not fit
*result = exact value *result = value wrapped to width
return false return true ← treat as error
Three consequences follow:
- The result type defines “overflow”.
ckd_add(&r8, 100, 28)overflows forint8_t r8but not forint r8. To ask “does this fit in 64 bits?”, make*result64 bits wide. - Operand types and signedness do not matter.
ckd_sub(&size, 3u, 5u)correctly reports that-2does not fit insize_t;ckd_mul(&u, -1, -1)correctly reports that1fits inunsigned. No casts, no usual-arithmetic-conversion surprises. - The stored value on overflow is a wrapped leftover. It is well-defined (no UB), but it is not an answer. Return an error; do not keep computing with it.
| Approach | Signed safe? | Mixed signedness? | 64×64 multiply? | Standard? |
|---|---|---|---|---|
a + b < a after the fact |
No — UB, optimizer may delete it | No | No | — |
Pre-check a > MAX - b |
Yes, if written perfectly | Painful | Painful (division) | Yes |
Widen to int64_t, compare |
Yes | Mostly | No (no portable 128-bit) | Yes |
__builtin_*_overflow |
Yes | Yes | Yes | GCC/Clang extension |
C23 ckd_* |
Yes | Yes | Yes | ISO C23 |
Worked examples
All listings were built and run on x86_64 Linux with GCC 14.2 (Debian 14.2.0-19), Clang 19.1.7, and glibc 2.41. Each listing produced identical output under both compilers unless noted.
Case 1: the check the optimizer is allowed to delete
The classic after-the-fact test looks reasonable and is wrong for signed types: if a + 100 overflows, behaviour is undefined, so the compiler may assume it never does — and a + 100 < a is then always false.
/* naive.c — the overflow check that the optimizer is allowed to delete. */
#include <limits.h>
#include <stdio.h>
/* Intent: "would a + 100 overflow?" Signed overflow is undefined, so the
compiler may assume a + 100 never wraps and fold this to 0. */
__attribute__((noinline))
int would_overflow(int a) {
return a + 100 < a;
}
int main(void) {
printf("would_overflow(INT_MAX) = %d\n", would_overflow(INT_MAX));
return 0;
}for o in -O0 -O2; do gcc -std=c23 $o naive.c -o naive && echo "gcc $o: $(./naive)"; done
for o in -O0 -O2; do clang -std=c23 $o naive.c -o naive && echo "clang $o: $(./naive)"; donegcc -O0: would_overflow(INT_MAX) = 0
gcc -O2: would_overflow(INT_MAX) = 0
clang -O0: would_overflow(INT_MAX) = 1
clang -O2: would_overflow(INT_MAX) = 0
Same source, three different “answers” to a yes/no question. GCC folds the comparison away even at -O0; the generated function is literally “return 0”:
gcc -std=c23 -O2 -S -o - naive.c | sed -n '/would_overflow:/,/ret/p'would_overflow:
.LFB3:
.cfi_startproc
xorl %eax, %eax
ret
UBSan sees the overflow at run time, which is how you find such sites — but finding is not fixing:
gcc -std=c23 -O2 -fsanitize=undefined naive.c -o naive_ub && ./naive_ubnaive.c:9:14: runtime error: signed integer overflow: 2147483647 + 100 cannot be represented in type 'int'
would_overflow(INT_MAX) = 1
The fix is one line: int r; return ckd_add(&r, a, 100);.
Case 2: what the macros actually report
This listing pins down the semantics in the mental model: result type decides, signedness of operands is irrelevant, and the stored value on overflow is the wrapped leftover.
/* basics.c — what ckd_add / ckd_sub / ckd_mul actually report. */
#include <limits.h>
#include <stdckdint.h>
#include <stdint.h>
#include <stdio.h>
int main(void) {
int i;
bool ov = ckd_add(&i, INT_MAX, 1);
printf("INT_MAX + 1 -> int ov=%d stored=%d\n", ov, i);
/* Operands are evaluated as if in infinite precision; only the
*result* type decides whether it "fits". */
size_t n;
ov = ckd_sub(&n, (size_t)3, (size_t)5);
printf("3 - 5 -> size_t ov=%d stored=%zu\n", ov, n);
int8_t small;
ov = ckd_add(&small, 100, 27);
printf("100 + 27 -> int8_t ov=%d stored=%d\n", ov, small);
ov = ckd_add(&small, 100, 28);
printf("100 + 28 -> int8_t ov=%d stored=%d\n", ov, small);
/* Mixed signedness is fine: -1 * -1 == 1 fits in unsigned. */
unsigned u;
ov = ckd_mul(&u, -1, -1);
printf("-1 * -1 -> unsigned ov=%d stored=%u\n", ov, u);
ov = ckd_mul(&u, -1, 1);
printf("-1 * 1 -> unsigned ov=%d stored=%u\n", ov, u);
/* Widening the result type is how you ask "does it fit in 64 bits?" */
long long wide;
ov = ckd_mul(&wide, INT_MAX, INT_MAX);
printf("INT_MAX*INT_MAX -> llong ov=%d stored=%lld\n", ov, wide);
return 0;
}gcc -std=c23 -Wall -Wextra -O2 basics.c -o basics && ./basicsINT_MAX + 1 -> int ov=1 stored=-2147483648
3 - 5 -> size_t ov=1 stored=18446744073709551614
100 + 27 -> int8_t ov=0 stored=127
100 + 28 -> int8_t ov=1 stored=-128
-1 * -1 -> unsigned ov=0 stored=1
-1 * 1 -> unsigned ov=1 stored=4294967295
INT_MAX*INT_MAX -> llong ov=0 stored=4611686014132420609
bool is a keyword in C23, so no <stdbool.h> is needed. Clang 19 (clang -std=c23 …) prints byte-identical output.
Case 3: sizing an allocation from an untrusted count
This is where overflow becomes a memory-safety bug. A count read from a file header, a network packet, or argv is multiplied by an element size and added to a header size. With a large enough count the product wraps to something tiny, malloc succeeds, and the subsequent loop writes far past the end.
/* alloc.c — sizing a buffer from an untrusted element count. */
#define _DEFAULT_SOURCE /* -std=c23 is strict ISO; this exposes reallocarray() */
#include <errno.h>
#include <stdckdint.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct record { uint32_t id; uint32_t flags; }; /* 8 bytes */
struct table { size_t count; size_t cap; struct record rows[]; };
/* Publishing each pointer through a volatile stops the optimizer from
deleting an unused malloc/free pair and assuming it "succeeded". */
static void *volatile sink;
/* WRONG: the multiplication silently wraps for huge counts. */
static size_t naive_size(size_t count) {
return sizeof(struct table) + count * sizeof(struct record);
}
/* RIGHT: every step that can overflow is checked; false means "fits". */
static bool checked_size(size_t count, size_t *out) {
size_t body;
if (ckd_mul(&body, count, sizeof(struct record))) return false;
if (ckd_add(out, body, sizeof(struct table))) return false;
return true;
}
static struct table *table_new(size_t count) {
size_t bytes;
if (!checked_size(count, &bytes)) { errno = EOVERFLOW; return NULL; }
struct table *t = malloc(bytes);
if (t) { t->count = 0; t->cap = count; }
return t;
}
int main(int argc, char **argv) {
if (argc != 2) { fprintf(stderr, "usage: %s COUNT\n", argv[0]); return 2; }
size_t count = strtoull(argv[1], NULL, 0);
printf("count = %zu\n", count);
printf("naive bytes = %zu\n", naive_size(count));
size_t bytes;
if (checked_size(count, &bytes))
printf("checked = %zu\n", bytes);
else
printf("checked = overflow, refusing\n");
struct table *t = sink = table_new(count);
printf("table_new = %s (%s)\n", t ? "ok" : "NULL", t ? "-" : strerror(errno));
free(sink);
/* The library already checks n * size for you in these two calls. */
void *p = sink = calloc(count, sizeof(struct record));
printf("calloc = %s\n", p ? "ok" : "NULL");
free(sink);
p = sink = reallocarray(NULL, count, sizeof(struct record));
printf("reallocarray = %s\n", p ? "ok" : "NULL");
free(sink);
return 0;
}gcc -std=c23 -Wall -Wextra -O2 alloc.c -o alloc
./alloc 1000
./alloc 0x2000000000000001count = 1000
naive bytes = 8016
checked = 8016
table_new = ok (-)
calloc = ok
reallocarray = ok
count = 2305843009213693953
naive bytes = 24
checked = overflow, refusing
table_new = NULL (Value too large for defined data type)
calloc = NULL
reallocarray = NULL
The hostile count is 2^61 + 1. Times 8 bytes it is 2^64 + 8, which wraps to 8; plus the 16-byte header gives a 24-byte buffer for “two quintillion” records. The checked version refuses before calling malloc.
Two library calls already do the n × size check internally and are the first thing to reach for:
calloc(n, size)— ISO C; zero-fills, returnsNULLon overflow.reallocarray(ptr, n, size)— originally OpenBSD, in glibc since 2.26, standardised by POSIX.1-2024; behaves likerealloc(ptr, n * size)but fails withENOMEMwhen the multiplication overflows. Under strict-std=c23, glibc 2.41 declares it only when you request extensions with_DEFAULT_SOURCE(as the listing does);_POSIX_C_SOURCE 202405Lalone is not enough there.
Why the volatile sink? Without it, Clang 19 at -O2 deleted the unused calloc/free pair and printed calloc = ok for the hostile count — the optimizer may assume an allocation whose result is never used succeeded. A probe that is optimised away proves nothing; publish the pointer somewhere the compiler cannot see through. (The listing also avoids printing errno after calloc: ISO C does not require allocators to set it, and LLVM does not model it as changed by the call.)
Neither covers the + sizeof(struct table) header term, which is why checked_size uses ckd_mul and ckd_add.
Case 4: parsing a bounded number digit by digit
Parsers are the other hot spot. Accumulating v = v * 10 + digit into the destination type and checking at each step rejects oversized input exactly at the digit that breaks the bound — no strtoul range dance, no errno, no locale.
/* parse.c — parse a decimal port/length field without trusting its size. */
#include <stdckdint.h>
#include <stdint.h>
#include <stdio.h>
/* Returns true on success. Rejects empty input, non-digits, and any value
that does not fit in uint16_t — checked at every digit, not at the end. */
static bool parse_u16(const char *s, uint16_t *out) {
uint16_t v = 0;
if (*s == '\0') return false;
for (; *s; s++) {
if (*s < '0' || *s > '9') return false;
if (ckd_mul(&v, v, 10)) return false;
if (ckd_add(&v, v, *s - '0')) return false;
}
*out = v;
return true;
}
int main(void) {
const char *inputs[] = { "8443", "65535", "65536", "99999999999999999999", "12a", "" };
for (size_t i = 0; i < sizeof inputs / sizeof inputs[0]; i++) {
uint16_t port;
if (parse_u16(inputs[i], &port))
printf("%-22s -> %u\n", inputs[i], port);
else
printf("%-22s -> rejected\n", inputs[i]);
}
return 0;
}gcc -std=c23 -Wall -Wextra -Wconversion -O2 parse.c -o parse && ./parse8443 -> 8443
65535 -> 65535
65536 -> rejected
99999999999999999999 -> rejected
12a -> rejected
-> rejected
Note that -Wconversion is silent: ckd_add(&v, v, *s - '0') adds an int to a uint16_t and stores into uint16_t without any implicit narrowing — the narrowing is exactly what the macro checks.
Case 5: one header for C23 and older toolchains
<stdckdint.h> is a header shipped by the compiler (GCC ≥ 14, Clang ≥ 18), not by libc. Both GCC 14 and Clang 19 accept it even in -std=c17 or older modes. For older compilers, map the macros onto the GCC/Clang builtins they are defined in terms of — and mind the argument order: the builtins put the result pointer last.
/* ckd_compat.h — use C23 <stdckdint.h> when present, else the GCC/Clang
builtins it is defined in terms of. Note the argument order swap. */
#ifndef CKD_COMPAT_H
#define CKD_COMPAT_H
#if defined(__has_include) && !defined(CKD_FORCE_BUILTIN)
# if __has_include(<stdckdint.h>)
# include <stdckdint.h>
# define CKD_IMPL "stdckdint.h"
# endif
#endif
#ifndef CKD_IMPL
# if defined(__GNUC__) || defined(__clang__)
/* builtins: (a, b, result) ckd_*: (result, a, b) */
# define ckd_add(r, a, b) __builtin_add_overflow((a), (b), (r))
# define ckd_sub(r, a, b) __builtin_sub_overflow((a), (b), (r))
# define ckd_mul(r, a, b) __builtin_mul_overflow((a), (b), (r))
# define CKD_IMPL "__builtin_*_overflow"
# else
# error "no checked-arithmetic support: need C23 <stdckdint.h> or GCC/Clang builtins"
# endif
#endif
#endif /* CKD_COMPAT_H *//* compat.c — same call sites, either implementation. */
#include <limits.h>
#include <stdio.h>
#include "ckd_compat.h"
int main(void) {
long r;
int ov = ckd_mul(&r, LONG_MAX / 2, 3);
printf("impl=%s ov=%d\n", CKD_IMPL, ov);
return 0;
}gcc -std=c17 -Wall -Wextra compat.c -o compat && ./compat
gcc -std=c17 -Wall -Wextra -DCKD_FORCE_BUILTIN compat.c -o compat && ./compat
clang -std=c11 -Wall -Wextra -DCKD_FORCE_BUILTIN compat.c -o compat && ./compatimpl=stdckdint.h ov=1
impl=__builtin_*_overflow ov=1
impl=__builtin_*_overflow ov=1
Call sites use the C23 spelling everywhere; deleting the shim later is a one-line change.
The trap
Habits from the builtins, and trusting the leftover value. The compilers reject the worst mistakes, but not uniformly:
/* misuse.c — mistakes the compiler rejects. */
#include <stdckdint.h>
int main(void) {
int a = 1, b = 2, r;
bool flag;
char c;
(void)ckd_add(&flag, a, b); /* bool result: not allowed */
(void)ckd_add(&c, a, b); /* plain char result: not allowed */
(void)ckd_add(a, b, &r); /* builtin argument order by habit */
return r;
}clang -std=c23 -c misuse.c -o /dev/nullmisuse.c:8:11: error: result argument to checked integer operation must be a pointer to a non-const integer type other than plain 'char', 'bool', bit-precise, or an enumeration ('bool *' invalid)
8 | (void)ckd_add(&flag, a, b); /* bool result: not allowed */
| ^~~~~~~~~~~~~~~~~~~~
/usr/lib/llvm-19/lib/clang/19/include/stdckdint.h:37:59: note: expanded from macro 'ckd_add'
37 | #define ckd_add(R, A, B) __builtin_add_overflow((A), (B), (R))
| ^~~
misuse.c:9:11: error: result argument to checked integer operation must be a pointer to a non-const integer type other than plain 'char', 'bool', bit-precise, or an enumeration ('char *' invalid)
9 | (void)ckd_add(&c, a, b); /* plain char result: not allowed */
| ^~~~~~~~~~~~~~~~~
/usr/lib/llvm-19/lib/clang/19/include/stdckdint.h:37:59: note: expanded from macro 'ckd_add'
37 | #define ckd_add(R, A, B) __builtin_add_overflow((A), (B), (R))
| ^~~
misuse.c:10:11: error: operand argument to checked integer operation must be an integer type other than plain 'char', 'bool', bit-precise, or an enumeration ('int *' invalid)
10 | (void)ckd_add(a, b, &r); /* builtin argument order by habit */
| ^~~~~~~~~~~~~~~~~
/usr/lib/llvm-19/lib/clang/19/include/stdckdint.h:37:54: note: expanded from macro 'ckd_add'
37 | #define ckd_add(R, A, B) __builtin_add_overflow((A), (B), (R))
| ^~~
3 errors generated.
gcc -std=c23 -c misuse.c -o /dev/nullmisuse.c: In function ‘main’:
misuse.c:8:11: error: argument 3 in call to function ‘__builtin_add_overflow’ has pointer to boolean type
8 | (void)ckd_add(&flag, a, b); /* bool result: not allowed */
| ^~~~~~~
misuse.c:10:11: error: argument 2 in call to function ‘__builtin_add_overflow’ does not have integral type
10 | (void)ckd_add(a, b, &r); /* builtin argument order by habit */
| ^~~~~~~
GCC 14 silently accepts the plain-char result (the standard only recommends a diagnostic) — so code that builds with GCC can fail on Clang. Beyond what the compilers catch:
- Ignoring the return value.
ckd_add(&n, n, len);with noifis just addition with extra steps. Wrap it:if (ckd_add(&n, n, len)) return -EOVERFLOW;. - Using
*resultafter overflow. It is the truncated bit pattern (24bytes in Case 3). Never pass it on. - Checking in the wrong type.
int total; ckd_add(&total, a_size_t, b_size_t)checks fit inint, which may be what you want for a protocol field, but is wrong if the next line passestotaltomalloc. Make*resultthe type of the consumer. - Checking one step of a multi-step expression.
header + n * sizeis two operations; both need checking (Case 3). So does every iteration of an accumulator (Case 4). _BitIntand enums. C23 excludes bit-precise integers,bool, plainchar, and enumerations fromckd_*operands and results; convert to an ordinary integer type first.-fwrapvas a fix. It makes signed overflow wrap instead of being UB, so Case 1’s comparison survives — but the program still computes a wrong, wrapped size. Wrapping is not detection.
The boring rule
Any arithmetic on a size, count, offset, length, or index that is influenced by input goes through
ckd_add/ckd_sub/ckd_mul— with*resulttyped as whatever consumes the value — and an overflow return becomes an error path, never a value. Prefercalloc/reallocarraywhen the expression is exactlyn × size. Run tests under-fsanitize=undefinedto find the sites you missed.
Checklist for review:
- No after-the-fact overflow tests (
a + b < a) on signed types anywhere. - Every
malloc/reallocargument that is not a constant is either ackd_*result or replaced bycalloc/reallocarray. - Every
ckd_*call is the condition of anif(or its result is otherwise tested). - Parsers accumulate into the destination type with
ckd_mul+ckd_addper digit. - Builds that must support pre-C23 compilers use one shim header (Case 5), not ad-hoc builtins sprinkled through the code.
Try this
- Suppose
table_newin Case 3 usednaive_sizeand allocated withcalloc(1, bytes). Would the overflow be caught? Explain whycalloconly protects the multiplication it performs itself. - Extend
parse_u16from Case 4 into a genericparse_u64and test it against18446744073709551615and18446744073709551616. - Write
bool checked_area(size_t w, size_t h, size_t bpp, size_t *out)for an image decoder (w × h × bppplus a 54-byte header) and feed itw = h = 0x100000001. - Compile Case 1 with
-fwrapvunder GCC and Clang at-O2. Doeswould_overflownow return 1? Is the program correct now, or merely deterministic? - Disassemble
checked_size(gcc -O2 -S alloc.c) and find thejc/jo/seto(ormul+ flag test) that implements the check. Count the instructions it costs.
Sources
- ISO/IEC 9899:2024 (C23) §7.20 Checked integer arithmetic — summarised at cppreference:
ckd_addandckd_mul - GCC manual — Integer Overflow Builtins
- POSIX.1-2024
reallocarray()(Austin Group defect 1218); glibc 2.26 NEWS for its introduction - Clang review D157331 — Implement C23
<stdckdint.h>(Clang 18)