C Standards & Compiler Flags
C is not a single, frozen language. It has evolved through decades of standardization, and the code you write today may behave differently—or not compile at all—depending on which standard you target and which compiler flags you use. Understanding C standards and compiler flags is the difference between code that happens to work on your machine and code that is portable, correct, and maintainable across platforms, compilers, and decades.
1. Why C Standards Matter
C runs on everything from 8-bit microcontrollers to supercomputers. Unlike languages with a single dominant implementation (Go, Rust, Python), C has multiple independent compilers: GCC, Clang, MSVC, ICC, TinyCC, and dozens of embedded compilers. Each implements the C standard slightly differently. The ISO C standard is the contract: it defines exactly what every conforming compiler must support, and what behavior is left undefined.
Three reasons standards knowledge is critical:
- Portability. Code written for C99 will not compile with a C89-only embedded compiler. C11's
<threads.h>is unavailable on older systems. Knowing your target standard lets you write code that works everywhere you need it to. - Undefined behavior. The standard explicitly labels certain constructs as "undefined behavior" (UB). A program that triggers UB is not a valid C program—the compiler can do literally anything, including deleting your safety checks or formatting your hard drive. Understanding what the standard guarantees (and what it doesn't) is essential for writing correct code.
- Compiler compatibility. When GCC accepts your code but Clang rejects it (or vice versa), the standard is usually the tiebreaker. Knowing which feature belongs to which standard lets you diagnose portability problems instantly.
2. C Standards Timeline
C's evolution spans over 50 years. Here is every major standard, what it introduced, and why it matters:
K&R C (1972-1989)
The original C, described in Kernighan and Ritchie's The C Programming Language (1978). No official standard existed; each compiler vendor implemented their own dialect. Function definitions used the old-style syntax:
/* K&R function definition - still accepted by most compilers */
int add(a, b)
int a;
int b;
{
return a + b;
}
K&R C is now of historical interest only. No modern project should use it.
C89 / C90 (ANSI X3.159-1989 / ISO/IEC 9899:1990)
The first official standard. ANSI published it in 1989; ISO adopted it with minor changes in 1990. This standard gave us:
- Function prototypes (
int add(int a, int b);) voidtype andvoid *generic pointer- Standard library headers (
<stdio.h>,<stdlib.h>,<string.h>, etc.) - The preprocessor as we know it (
#define,#include,#ifdef) constandvolatiletype qualifiersenumtypes
C89/C90 is the greatest common divisor of C. If you target truly ancient embedded toolchains or legacy codebases, this is your baseline.
C99 (ISO/IEC 9899:1999)
A massive update. C99 made C feel modern for the first time. Major additions:
//single-line comments (finally!)- Variable declaration anywhere in a block (not just at the top)
inlinefunctions<stdint.h>- fixed-width integer types (int32_t,uint64_t, etc.)<stdbool.h>-bool,true,false- Variable-length arrays (VLAs)
- Designated initializers:
struct Point p = { .x = 10, .y = 20 }; long long inttypefor-loop initial declarations:for (int i = 0; i < n; i++)- Flexible array members
restrictqualifier for pointer aliasing optimization- Compound literals:
(int[]){1, 2, 3}
Most modern C codebases target C99 or later. Microsoft's MSVC, however, notoriously lagged on C99 support for decades—a key reason many Windows projects stuck with C89 well into the 2010s.
C11 (ISO/IEC 9899:2011)
C11 refined C99 and added concurrency support. Key additions:
_Generic- type-generic expressions (compile-time type dispatch)_Alignas/_Alignof- explicit alignment control_Noreturn- function attribute for functions that never return_Static_assert- compile-time assertions- Anonymous structs and unions within structs
<threads.h>- standard threading library (optional; many implementations omit it)<stdatomic.h>- atomic operations- Bounds-checking interfaces (Annex K, optional and controversial)
quick_exitandat_quick_exit- VLA support made optional (compilers can define
__STDC_NO_VLA__)
C17 / C18 (ISO/IEC 9899:2018)
This is a bug-fix release with no new features. It corrected defects in C11, clarified ambiguous wording, and incorporated technical corrigenda. If you see "C18," it's the same thing—ISO published it in 2018, but the standard calls itself C17. No code changes are needed to move from C11 to C17.
C23 (ISO/IEC 9899:2024)
The most recent standard, bringing C closer to modern systems programming. Major additions:
nullptr- a proper null pointer constant (replacingNULLmacro)typeof- standardized version of GCC'stypeofextensionautotype inference:auto x = 42;(repurposed keyword)constexpr- compile-time constant expressions#embed- binary resource embedding at compile time#elifdef/#elifndef- shorthand preprocessor conditionalstrueandfalseas built-in keywords (no longer need<stdbool.h>)- Binary integer literals:
0b10101010 - Digit separators:
1'000'000 - Empty initializer:
int a[] = {};(zero-initializes) [[attributes]]- standardized attribute syntax ([[nodiscard]],[[deprecated]],[[fallthrough]])- Removal of K&R-style function definitions
- Removal of
gets()(finally!)
C23 support is still maturing. GCC 14 and Clang 18 provide partial support; full coverage is expected over 2025-2026.
3. Key Features by Standard (Deep Dive)
C99 Features That Changed Everything
// Comments. Before C99, only /* ... */ block comments existed. Single-line comments made C far more ergonomic for quick annotations and temporary code disabling.
// This is valid C99 and later
int x = 0; /* C89 required this style everywhere */
<stdint.h> — arguably the most important header in C99. It provides integer types with guaranteed widths, ending the chaos of platform-dependent int sizes:
#include <stdint.h>
int32_t exact_32; /* Exactly 32 bits, everywhere */
uint64_t exact_64; /* Exactly 64 bits, everywhere */
int_fast16_t fast_16; /* Fastest type with at least 16 bits */
intptr_t pointer_sized; /* Integer large enough to hold a pointer */
Variable-Length Arrays (VLAs). Allowed stack-allocated arrays whose size is determined at runtime:
void process(int n) {
int data[n]; /* Allocated on the stack, size from parameter */
for (int i = 0; i < n; i++) data[i] = i * 2;
}
Warning: VLAs are controversial. They can blow up the stack, they interact poorly with sizeof, and C11 made them optional. Many style guides (Linux kernel, MISRA) ban them outright. Prefer malloc for dynamic allocation unless you have specific stack-allocation requirements.
Inline functions. Before C99, you had to use macros or separate .c files for small, performance-sensitive functions. C99 inline (and static inline) gave you type-safe, debuggable alternatives:
static inline int max(int a, int b) {
return (a > b) ? a : b;
}
for-loop declarations. A small syntax change with big readability impact:
/* C89: variable declared outside the loop */
int i;
for (i = 0; i < n; i++) { /* ... */ }
/* C99: variable scoped to the loop */
for (int i = 0; i < n; i++) { /* ... */ }
/* 'i' is no longer visible here */
C11 Features
_Generic — compile-time type dispatch. Think of it as a type-safe switch on types, invaluable for writing polymorphic macros:
#include <stdio.h>
#define print_value(x) _Generic((x), \
int: printf("int: %d\n", x), \
double: printf("double: %f\n", x), \
char*: printf("string: %s\n", x), \
default: printf("unknown type\n") \
)
int main(void) {
print_value(42); /* prints: int: 42 */
print_value(3.14); /* prints: double: 3.140000 */
print_value("hello"); /* prints: string: hello */
return 0;
}
_Alignas and _Alignof. Control memory alignment explicitly—essential for SIMD programming, cache-line optimization, and interfacing with hardware:
#include <stdalign.h> /* provides alignas, alignof macros */
#include <stdio.h>
struct alignas(64) CacheLine {
int data[16]; /* This struct is 64-byte aligned */
};
int main(void) {
printf("Alignment: %zu\n", alignof(struct CacheLine)); /* prints 64 */
return 0;
}
Anonymous structs/unions. Nest types without naming them, reducing verbosity when you just need grouping:
struct Vector3 {
union {
struct { float x, y, z; }; /* Anonymous - access as v.x, v.y, v.z */
float components[3]; /* Access as v.components[0], etc. */
};
};
struct Vector3 v;
v.x = 1.0f; /* Clean access, same memory as v.components[0] */
v.components[1] = 2.0f;
<threads.h> — standard threading, modeled on C++11's <thread>. Provides thrd_create, mtx_t, cnd_t, and tss_t. Note: this header is optional in C11; GCC and Clang on Linux provide it, but MSVC does not, and many embedded compilers omit it. Always check __STDC_NO_THREADS__ before relying on it.
C17 (C18)
No new language features. C17 is C11 with errata applied. It clarifies that memcpy with zero length is well-defined, that atexit registrations from within an atexit handler are implementation-defined, and resolves dozens of similar ambiguities. When a compiler claims C17 support, it means C11 + bug fixes.
C23 Highlights
nullptr. Finally, a type-safe null pointer constant. NULL is typically ((void*)0) or just 0, which can be silently accepted as an integer. nullptr can only be assigned to pointer types:
int *p = nullptr; /* OK */
int n = nullptr; /* Compile error: nullptr is not an integer */
typeof and auto. C23 standardizes GCC's long-standing typeof extension and repurposes auto for type inference:
auto x = 42; /* x is int */
auto y = 3.14; /* y is double */
auto ptr = &x; /* ptr is int* */
/* typeof is invaluable in macros */
#define SWAP(a, b) do { \
typeof(a) tmp = a; a = b; b = tmp; \
} while(0)
constexpr. Guarantees compile-time evaluation, enabling true constant objects:
constexpr int BUFFER_SIZE = 4096;
char buffer[BUFFER_SIZE]; /* Valid - BUFFER_SIZE is a true constant */
constexpr int factorial(int n) {
return (n <= 1) ? 1 : n * factorial(n - 1);
}
int table[factorial(5)]; /* Valid - 120 elements, computed at compile time */
#embed. Embed binary files directly into your program at compile time—no more xxd -i or custom linker scripts:
const unsigned char shader_code[] = {
#embed "shader.spv"
};
/* shader_code now contains the exact bytes of shader.spv */
4. Compiler Flags Deep Dive
Compiler flags are your primary mechanism for selecting a standard, controlling diagnostics, and tuning optimization. Every C programmer should have a core set of flags committed to muscle memory.
Selecting the Standard: -std=
This flag tells the compiler which language standard to enforce. Without it, compilers pick a default (GCC defaults to -std=gnu17; Clang defaults to -std=gnu17 on most platforms).
# Compile against a specific standard
gcc -std=c99 program.c # C99, strict (no GNU extensions)
gcc -std=c11 program.c # C11, strict
gcc -std=c17 program.c # C17, strict
gcc -std=c23 program.c # C23, strict
# GNU dialect variants include GCC extensions
gcc -std=gnu11 program.c # C11 + GNU extensions (default on many systems)
gcc -std=gnu17 program.c # C17 + GNU extensions
# Clang uses the same -std= flags
clang -std=c11 program.c
Why use strict -std=c11 instead of -std=gnu11? GNU extensions are non-portable. Code that uses typeof, statement expressions ({ ... }), or case ranges case 1 ... 10: will not compile with MSVC, ICC, or strictly conforming compilers. Use -std=c11 (without gnu) to catch accidental reliance on extensions.
Warning Flags: Your First Line of Defense
Warnings are the compiler telling you "this is legal C, but it's almost certainly a bug." Turning warnings on—and treating them as errors—catches bugs at compile time that would otherwise manifest as runtime mysteries.
# The essential warning set
gcc -Wall -Wextra -Werror -pedantic program.c
| Flag | What It Does |
|---|---|
-Wall | Enables a broad set of common warnings (unused variables, suspicious implicit conversions, missing parentheses, etc.). Despite the name, it does not enable "all" warnings; it enables all warnings the GCC authors consider broadly useful with low false-positive rates. |
-Wextra | Enables additional warnings beyond -Wall: unused parameters, comparisons that are always true/false, missing field initializers, and more. Worth enabling in every project. |
-Werror | Treats all warnings as errors. The build fails on any warning. This prevents warning creep—without it, developers learn to ignore warnings, and real bugs hide in the noise. Use it in CI; consider -Werror=implicit-function-declaration (specific errors as errors) for more flexibility during development. |
-pedantic | Rejects all programs that use forbidden extensions. Without -pedantic, GCC/Clang accept many GNU extensions even with -std=c99. For strict standards conformance, always pair -std=cXX with -pedantic. |
Additional warning flags worth enabling:
gcc -Wall -Wextra -Wpedantic \
-Wshadow \ # Warn when a local variable shadows another
-Wconversion \ # Warn on implicit conversions that may change value
-Wsign-conversion \ # Warn on implicit sign conversions
-Wcast-align \ # Warn on casts that increase alignment requirements
-Wstrict-prototypes \ # Warn on old-style function declarations
-Wmissing-prototypes \ # Warn on global functions without prototypes
-Wdouble-promotion \ # Warn when float is implicitly promoted to double
-Wundef \ # Warn on undefined identifiers in #if
-o program program.c
Optimization Flags: -O0 through -O3 and -Os
| Flag | Behavior | Use Case |
|---|---|---|
-O0 | No optimization. Compilation is fast; generated code is straightforward and easy to debug. Variables are stored to memory between statements. | Debugging (default). |
-O1 | Basic optimizations that don't significantly increase compilation time: constant folding, dead code elimination, basic register allocation. | Rarely used directly; a middle ground. |
-O2 | Nearly all optimizations that don't involve space-speed tradeoffs: instruction scheduling, loop unrolling, function inlining (limited), common subexpression elimination, alias analysis. | Production builds. The standard choice for most projects. |
-O3 | Aggressive optimizations: more aggressive inlining, loop vectorization, predictive commoning, and inter-procedural optimizations. Can increase code size and compilation time significantly. Occasionally introduces subtle bugs due to strict-aliasing assumptions. | Performance-critical code where you've profiled and -O2 isn't enough. |
-Os | Optimize for size. Enables all -O2 optimizations that don't typically increase code size, plus size-reducing transformations. | Embedded systems, bootloaders, firmware with tight flash constraints. |
-Og | Optimize for debugging. Enables optimizations that don't interfere with the debugger experience (no variable elimination, no statement reordering). | Development builds where you want reasonable performance and debuggability. |
Critical rule: Test your code at both -O0 and -O2. Optimization can expose latent bugs: uninitialized variables that happen to be zero in debug builds, strict aliasing violations, data races that are masked by slower code, and reliance on particular stack layouts. If your code works at -O0 but crashes at -O2, the bug was already there—the optimizer just made it visible.
Debugging Flags
# -g: include debug symbols (DWARF format)
gcc -g -O0 -o program program.c
# -g3: maximum debug info, including macro definitions
gcc -g3 -O0 -o program program.c
# Combine optimization with debugging symbols
gcc -g -Og -o program program.c # GCC 4.8+; debug-friendly optimization
-g embeds source-level debugging information in the binary. Without it, GDB can only show assembly and raw addresses. Always include -g in debug builds; strip it (strip program) or omit it for release.
Preprocessor Flags: -D, -U, -I
# -DNAME=VALUE: define a preprocessor macro
gcc -DDEBUG -DBUFFER_SIZE=4096 program.c
# Equivalent to having:
# #define DEBUG
# #define BUFFER_SIZE 4096
# at the top of every source file.
# -DNAME without =VALUE defines it as 1
gcc -DNDEBUG program.c # equivalent to #define NDEBUG 1
# -UNAME: undefine a macro (even if defined in source)
gcc -UDEBUG program.c # removes any #define DEBUG
# -Ipath: add an include directory to the search path
gcc -I./include -I/usr/local/include program.c
# Searches ./include and /usr/local/include before the system defaults.
These flags are essential for build-system integration. Use -D to configure feature flags, toggle debug logging, or set platform-specific constants without modifying source files. Use -I to keep your project organized with separate include/ directories.
Sanitizers: -fsanitize=
Compiler sanitizers instrument your code at compile time to detect runtime bugs that are invisible to normal debugging. They are among the most powerful tools in the C programmer's arsenal:
# Address Sanitizer (ASan): detects memory errors
# - Out-of-bounds heap/stack/global access
# - Use-after-free, double-free
# - Memory leaks (with ASAN_OPTIONS=detect_leaks=1)
gcc -fsanitize=address -g -O0 -o program program.c
./program
# Undefined Behavior Sanitizer (UBSan): detects UB
# - Signed integer overflow
# - Null pointer dereference
# - Misaligned access
# - Division by zero
# - Invalid shift operations
gcc -fsanitize=undefined -g -O0 -o program program.c
# Thread Sanitizer (TSan): detects data races
gcc -fsanitize=thread -g -O0 -o program program.c
# Memory Sanitizer (MSan): detects uninitialized reads (Clang only)
clang -fsanitize=memory -g -O0 -o program program.c
# Combine multiple sanitizers
gcc -fsanitize=address,undefined -g -O0 -o program program.c
Always run your test suite under ASan and UBSan. The overhead is moderate (ASan ~2x slowdown, 2-3x memory), but the bugs it catches—use-after-free, buffer overflows—are exactly the ones that cause the worst production failures. Sanitizers have found critical bugs in Chrome, Firefox, SQLite, the Linux kernel, and thousands of other projects.
5. GCC vs Clang: Practical Comparison
GCC and Clang are the two dominant open-source C compilers. They are largely compatible—both aim to implement the ISO C standard—but practical differences matter when you're debugging build failures or choosing a compiler for a project.
| Aspect | GCC | Clang |
|---|---|---|
| License | GPLv3 (with runtime library exception) | Apache 2.0 (with LLVM exception) |
| Default C standard | gnu17 (GCC 14), gnu23 (GCC 15+) | gnu17 on most platforms |
| Error messages | Informative but verbose; ranges shown with carets | Excellent: color-coded, shows the exact range, suggests fixes |
| Compilation speed | Faster full-LTO builds; slower non-LTO | Faster incremental builds; excellent PCH support |
| Binary speed | Historically slightly faster on x86; gap is narrowing | Comparable; sometimes better on ARM/AArch64 |
| Extensions | Many GNU extensions (__attribute__((cleanup)), nested functions) | Supports most GCC extensions for compatibility; has its own (__has_feature, __builtin_dump_struct) |
| Sanitizers | ASan, UBSan, TSan, LSan | ASan, UBSan, TSan, LSan, MSan (Memory Sanitizer is Clang-only) |
| Static analysis | -fanalyzer (GCC 10+); decent inter-procedural analysis | clang-tidy and the Clang Static Analyzer; broader checks |
| Tooling ecosystem | GDB integration is seamless | LLDB integration; clang-format, clangd (LSP server), clang-tidy |
| Platform support | Broader: many obscure embedded architectures | Strong on mainstream: x86, ARM, AArch64, RISC-V, WebAssembly |
Practical advice: Develop with Clang for the better error messages and tooling (clangd in your editor, clang-tidy for linting). Test with GCC for portability and because GCC still catches some warnings Clang misses. Use both in CI. Many projects (Linux kernel, CPython, PostgreSQL) build and test with both compilers.
Key flag differences:
# GCC-only flags
gcc -fanalyzer program.c # Static analysis
# Clang-only flags
clang -Weverything program.c # Enable literally all warnings
clang -fsanitize=memory program.c # Memory Sanitizer (no GCC equivalent)
# Both compilers use the same standard selection
gcc -std=c11 program.c
clang -std=c11 program.c
6. Undefined Behavior: The Compiler's Dark Art
Undefined behavior (UB) is the single most important concept in C that most programmers misunderstand. The C standard explicitly declares that certain constructs have "undefined behavior"—meaning the standard imposes no requirements whatsoever on what happens. The compiler is free to: produce the result you expect, produce a different result, crash, corrupt memory, delete your code, or launch a game of Tetris. All are conforming behavior.
Why Does UB Exist?
UB is deliberate, not an oversight. It gives compilers three things:
- Optimization freedom. If the standard guarantees nothing about signed integer overflow, the compiler can assume it never happens and optimize accordingly. This is how
-O2achieves its speed. - Hardware flexibility. C runs on machines with different pointer representations, different signed-integer encodings (though two's complement is now required in C23), and different trap behaviors. UB avoids mandating emulation overhead.
- Simplicity. If the standard had to define every edge case, it would be thousands of pages longer and compilers would be much slower.
Common Undefined Behaviors
| UB | Example | What Actually Happens |
|---|---|---|
| Signed integer overflow | int x = INT_MAX + 1; |
The compiler may assume x > INT_MAX is always false and delete the check. Observed in production: a bounds check was optimized away, causing a security vulnerability. |
| Null pointer dereference | int *p = NULL; *p = 42; |
Often crashes with SIGSEGV, but the compiler may optimize away the dereference entirely if it proves p == NULL. Your safety check could be deleted. |
| Out-of-bounds array access | int a[10]; a[10] = 0; |
May silently corrupt adjacent memory, crash, or appear to work. ASan catches this reliably; without ASan, it's one of the hardest bugs to find. |
| Use-after-free | free(p); *p = 5; |
The memory may still contain the old value, or it may have been reused. Exploitable as a security vulnerability (use-after-free is a leading cause of browser RCE bugs). |
| Strict aliasing violation | float f = 1.0; int *ip = (int*)&f; *ip = 0; |
The compiler may reorder or eliminate the stores because it assumes float* and int* never alias. Use memcpy or union instead. |
| Division by zero | int x = 1 / 0; |
SIGFPE on most platforms, but the compiler may constant-fold this at compile time and do anything. |
| Shift by negative or excessive amount | int x = 1 << 32; |
Undefined (for 32-bit int). The hardware may mask the shift count, but the compiler may assume it never happens and optimize accordingly. |
| Returning from a non-void function without a value | int f() { /* no return */ } |
The caller receives garbage. Clang warns; GCC requires -Wreturn-type. |
How Compilers Exploit UB for Optimization
This is not theoretical. Here's a real example of how a compiler exploits UB:
int do_something(int *p) {
int x = *p;
if (!p) return -1; /* Null check AFTER the dereference */
return x * 2;
}
Since *p is already dereferenced before the null check, the compiler reasons: if p were null, the dereference would be UB. UB never happens in a valid program. Therefore p cannot be null. Therefore the null check is dead code—the compiler deletes it. At -O2, your safety check evaporates.
The fix: check for null before dereferencing:
int do_something(int *p) {
if (!p) return -1; /* Check FIRST */
int x = *p; /* Dereference only after confirming non-null */
return x * 2;
}
This is why you must understand UB: the compiler assumes it never occurs and optimizes based on that assumption. Your code can be semantically correct but legally UB, and the optimizer will produce a binary that does not match your mental model.
7. Checking Your Standard at Compile Time
Every conforming C compiler defines the __STDC_VERSION__ macro. You can use it to conditionally compile code based on the target standard:
#include <stdio.h>
int main(void) {
#if __STDC_VERSION__ >= 202311L
printf("C23 or later\n");
#elif __STDC_VERSION__ >= 201710L
printf("C17/C18\n");
#elif __STDC_VERSION__ >= 201112L
printf("C11\n");
#elif __STDC_VERSION__ >= 199901L
printf("C99\n");
#elif defined(__STDC__)
printf("C89/C90\n");
#else
printf("Pre-ANSI (K&R) C\n");
#endif
return 0;
}
Compile and run it with different -std= flags to see the difference:
gcc -std=c99 stdcheck.c && ./a.out # prints: C99
gcc -std=c11 stdcheck.c && ./a.out # prints: C11
gcc -std=c17 stdcheck.c && ./a.out # prints: C17/C18
gcc -std=c23 stdcheck.c && ./a.out # prints: C23 or later
The version macro values:
| Standard | __STDC_VERSION__ |
|---|---|
| C89/C90 | undefined (check __STDC__ instead) |
| C94/C95 (Amendment 1) | 199409L |
| C99 | 199901L |
| C11 | 201112L |
| C17 | 201710L |
| C23 | 202311L |
Additionally, compilers provide feature-test macros for optional features:
#ifdef __STDC_NO_VLA__
#warning "VLAs are not supported by this compiler/standard selection"
#endif
#ifdef __STDC_NO_THREADS__
#warning "<threads.h> is not available"
#endif
#ifdef __STDC_NO_ATOMICS__
#warning "<stdatomic.h> is not available"
#endif
8. Writing Portable C
Portable C is C that compiles and runs correctly across different compilers, platforms, and standard versions. It requires discipline, but the payoff is code that lasts decades.
What to Avoid
- GNU extensions in portable code. Statement expressions
({ ... }),typeof(pre-C23), case ranges, and__attribute__((cleanup))are GCC/Clang-specific. Use-std=c11 -pedanticto catch them. - Implementation-defined behavior assumptions. The size of
intvaries (16, 32, or 64 bits). The signedness ofcharvaries (signed on x86, unsigned on ARM). The behavior of right-shift on signed integers varies. Use<stdint.h>types and explicitly cast. - Undefined behavior. See Section 6. Run under ASan and UBSan.
- VLAs in portable code. C11 made them optional; many compilers on embedded or safety-critical systems don't support them.
- Non-standard library functions.
strdup,asprintf,getlineare POSIX, not C standard. Usemalloc+strcpyorsnprintffor portability. - Assuming NULL is all-bits-zero. The null pointer constant is
(void*)0, but the runtime representation of a null pointer may be non-zero (rare, but the standard allows it). UseNULLornullptr(C23); nevermemseta struct and assume pointers become null.
What to Do
- Test with at least two compilers (GCC and Clang minimum; add MSVC if targeting Windows).
- Test at multiple optimization levels (
-O0,-O2,-O3). UB often manifests only under optimization. - Use CI with multiple standards: build with
-std=c99,-std=c11, and-std=c17to catch accidental reliance on newer features. - Use compiler-specific CI jobs: GCC on Linux, Clang on macOS, and cross-compile for your target platforms.
- Enable strict conformance:
-std=c11 -pedantic-errors -Wall -Wextra -Werror. - Add sanitizers to your test suite:
-fsanitize=address,undefined. Run your tests under them. - Use
<stdint.h>for integer types with guaranteed widths. - Use
static_assert(C11) or_Static_assertto enforce compile-time assumptions about sizes and alignments:
#include <assert.h>
#include <stdint.h>
static_assert(sizeof(int) >= 4, "int must be at least 32 bits");
static_assert(sizeof(void*) == sizeof(uintptr_t),
"uintptr_t must be the same size as a pointer");
9. Exercises
Exercise 1: Standard Version Detector
Write a C program called stdversion.c that detects and prints the C standard version at compile time using the __STDC_VERSION__ macro. The program should also detect and report which optional features are available (VLAs, threads, atomics) using the __STDC_NO_*__ macros. Test your program by compiling it with at least four different -std= flags: c89, c99, c11, and c17. Verify that each compilation reports the correct standard and accurately reflects feature availability.
Bonus: Add detection for compiler identity (GCC vs Clang vs MSVC) using predefined macros like __GNUC__, __clang__, and _MSC_VER.
Exercise 2: UB Hunting with Sanitizers
Create a file ub_demo.c containing at least one example of each of the following undefined behaviors:
- Signed integer overflow (
INT_MAX + 1) - Array out-of-bounds access (read and write)
- Use-after-free
- Null pointer dereference
Compile the program once with -O0 and once with -O2. Run both binaries and observe the differences in behavior. Then compile with -fsanitize=address,undefined and run again. Document what each sanitizer reports, how the output differs from the unsanitized runs, and explain why the optimizer sometimes masks or amplifies each bug.
Exercise 3: Portable Build Matrix
Take a small C program you have written previously (or write one from scratch—a linked list or a command-line calculator will do). Create a build script (shell script or Makefile) that compiles the program under all of the following configurations and reports any warnings or errors:
- GCC with
-std=c99 -pedantic -Wall -Wextra -Werror - GCC with
-std=c11 -pedantic -Wall -Wextra -Werror - GCC with
-std=c17 -pedantic -Wall -Wextra -Werror - Clang with
-std=c11 -pedantic -Wall -Wextra -Werror
If any configuration fails, fix the code until all four pass cleanly. Also run the program under -fsanitize=address,undefined with a test suite of at least five inputs. Document what changes you had to make (if any) to achieve true portability across standards and compilers. If you used GCC or Clang extensions without realizing it, identify them and replace them with standard equivalents.