Skip to content

Part 1 · The C Language24 min read

C Standards, Compiler Extensions, and Portable Code

“C” is not one language. Code written for C89 looks different from C99, C11 added features firmware relies on daily, and C23 changes the meaning of some old constructs. On top of the standard, every embedded compiler adds extensions, and vendor code uses them freely. This lesson maps that territory: what each revision of the standard added that matters on a microcontroller, which -std to choose today, the GNU extensions you will meet in real code, how to hide extensions behind macros, which implementation-defined behaviors to pin down, what -ffreestanding actually guarantees, and a balanced look at C versus C++ for firmware.

StandardYear__STDC_VERSION__Headline additions for firmware
C89/C901989/1990(not defined) / 199409L for the 1995 amendmentthe first ISO C: prototypes, const, volatile, void *
C991999199901Lstdint.h, stdbool.h, designated initializers, compound literals, inline, // comments, declarations anywhere, for (int i...), VLAs, restrict, flexible array members, snprintf, long long, __func__, variadic macros
C112011201112L_Static_assert, _Atomic and stdatomic.h, _Generic, _Alignas/_Alignof, anonymous structs and unions, _Noreturn, optional threads; VLAs made optional; gets removed
C172018201710Ldefect fixes only, no new features
C232024202311Lnullptr, constexpr, typeof, bool/true/false as keywords, static_assert as a keyword, binary literals, digit separators, #embed, [[attributes]], _BitInt, = {} initialization; void f() now means “no parameters”; two’s complement required

You can test the standard in effect at compile time:

#if !defined(__STDC_VERSION__) || __STDC_VERSION__ < 201112L
#error "This code requires C11 or later"
#endif

C99 is where C became pleasant to write, and almost every feature this course uses comes from it. Most of them were covered in earlier lessons; here they are in one example:

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
typedef struct {
uint32_t baud;
uint8_t data_bits;
bool parity;
} uart_cfg_t;
typedef struct {
uint16_t len;
uint8_t data[]; /* flexible array member */
} packet_t;
static inline uint32_t sum_bytes(const uint8_t *restrict p, size_t n)
{
uint32_t s = 0u;
for (size_t i = 0u; i < n; i++) { /* declaration in the for */
s += p[i];
}
return s;
}
uint32_t demo(void)
{
uart_cfg_t cfg = { .baud = 115200u, .data_bits = 8u }; /* designated */
const uint8_t *bytes = (const uint8_t[]){ 1u, 2u, 3u }; /* compound literal */
return cfg.baud + sum_bytes(bytes, 3u);
}

The one C99 feature firmware rejects is the variable-length array (uint8_t buf[n]; with runtime n): it puts an unbounded allocation on the stack and defeats stack analysis, as discussed in Arrays, strings, and tables. C11 made VLAs optional (a compiler that lacks them defines __STDC_NO_VLA__), and -Wvla turns any use into a warning.

C11 is the minimum this course targets, because of four features:

  • _Static_assert for compile-time checks of struct layouts, register offsets and configuration values. Including assert.h gives the friendlier spelling static_assert.
  • _Alignas and _Alignof for DMA buffers and allocators, from Memory: where everything lives.
  • _Atomic and stdatomic.h for data shared with interrupts, covered properly in Concurrency, atomics, and memory ordering. A compiler without them defines __STDC_NO_ATOMICS__. On Cortex-M3/M4 the atomic operations compile to ldrex/strex loops; on Cortex-M0, which lacks those instructions, many of them become library calls.
  • _Noreturn for functions such as a panic handler or the reset path, so the compiler can warn if they could return and skip saving registers for the caller.

Two smaller additions also show up in firmware. _Generic selects an expression by type, covered in The preprocessor, used well. Anonymous structs and unions let members of a nested union be accessed directly:

#include <stdint.h>
typedef struct {
uint8_t type;
union { /* anonymous: no member name */
int16_t temp_c10; /* access as msg.temp_c10 */
uint16_t raw; /* access as msg.raw */
};
} sensor_msg_t;
int16_t read_temp(const sensor_msg_t *msg)
{
return msg->temp_c10; /* no msg->u.temp_c10 */
}

C23 is the largest revision since C99. Support arrived in GCC 13 and Clang 17 for most features (#embed needs GCC 15 or Clang 19), and commercial embedded compilers are catching up more slowly. A taste:

c23_demo.c
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
constexpr uint32_t BAUD = 115'200; /* constexpr object, digit separator */
[[nodiscard]] static uint32_t brr_value(uint32_t pclk_hz)
{
return pclk_hz / BAUD;
}
int main(void)
{
static_assert(BAUD % 100 == 0); /* keyword; message optional */
uint8_t mask = 0b1010'0101; /* binary literal */
typeof(mask) copy = mask; /* typeof is standard now */
int *p = nullptr; /* nullptr has its own type */
bool ok = (p == nullptr); /* bool is a keyword: no header */
printf("%" PRIu32 " %u %d\n", brr_value(42'000'000u), (unsigned)copy, (int)ok);
return 0;
}
Terminal window
gcc -std=c23 -Wall -Wextra -Wpedantic c23_demo.c -o c23_demo && ./c23_demo

GCC 14 and later accept -std=c23; GCC 13 spells the same mode -std=c2x.

364 165 1

The features most useful to firmware:

C23 featureReplacesWhy it matters
constexpr objects#define constants, enum hackstyped, scoped compile-time constants that work in array sizes and static_assert
nullptrNULL (which may be 0 or (void *)0)has its own type, so it cannot be confused with an integer in variadic calls or _Generic
typeof, typeof_unqualGNU __typeof__standard way to write type-generic macros
[[nodiscard]], [[maybe_unused]], [[fallthrough]], [[deprecated]], [[noreturn]]__attribute__((...))portable attribute syntax
0b literals, ' separatorshex and commentsreadable register masks: 0b0000'0011'0000'0000
#embed "file.bin"xxd -i generated arraysinclude binary data (fonts, certificates, FPGA images) directly
stdckdint.h: ckd_add, ckd_mulhand-written overflow checks, __builtin_add_overflowstandard checked arithmetic
= {}= {0}zero-initialize anything, including structs whose first member is a struct

Two C23 changes can break old code, and you will meet them when upgrading toolchains. A declaration void f(); used to mean “parameters unspecified” and now means “no parameters”, so old code that called f(1, 2) through such a declaration stops compiling. And bool, true, false, static_assert and alignas are now keywords, so legacy headers that typedef unsigned char bool; produce errors. GCC 15 made -std=gnu23 its default, which is exactly how many projects discover this: the toolchain upgrade breaks the vendor SDK. Specify -std explicitly in every build, and the default never surprises you.

For new firmware today, the practical choice is -std=c11 (or c17, identical in features), with these considerations:

  • Vendor code. CMSIS headers and vendor HALs often use GNU extensions such as inline assembly with __asm and statement expressions. Under strict -std=c11 -Wpedantic some of them produce warnings or errors. The usual fix is -std=gnu11 for the whole build, or strict C11 for your own code and gnu11 for vendor directories.
  • Team toolchains. If any part of the product must build with IAR or an older Keil compiler, use only what all of them support. All current versions support C11; C23 support varies.
  • C23 is a good choice for a new project whose toolchains you control and which are recent (GCC 13+, Clang 17+). The features are genuinely useful, but do not adopt it if half your vendor code then fails to compile.

The difference between c11 and gnu11: gnu11 enables GNU extensions and removes the strictly conforming behavior in a few places, such as defining the non-underscore keyword asm and the macro linux on Linux hosts. With c11 you can still use extensions spelled with double underscores (__attribute__, __asm__, __typeof__), which is why strict mode is workable for firmware.

GCC’s extensions are supported by Clang and, largely, by Arm’s own compiler (armclang), so in practice they are the lingua franca of embedded C. Recognise them when you read vendor or kernel code:

ExtensionExampleStandard alternative
__attribute__((...))__attribute__((packed, aligned(4))), weak, section(".ramfunc"), used, noinlineC23 [[...]] for some; most have none
statement expressions({ int t = f(); t * t; })an inline function
typeof / __typeof____typeof__(x) tmp = x;C23 typeof
zero-length arraysuint8_t data[0]; at the end of a structC99 flexible array member uint8_t data[];
case rangescase '0' ... '9':list the cases, or an if
__builtin_* functions__builtin_clz, __builtin_ctz, __builtin_popcount, __builtin_bswap32, __builtin_expect, __builtin_unreachable, __builtin_add_overflowportable C (often slower) or C23 stdbit.h/stdckdint.h
inline assembly__asm volatile ("cpsid i" ::: "memory");none
conditional with omitted middlex ?: y (means x ? x : y, evaluating x once)spell it out
labels as valuesgoto *table[op];switch

Statement expressions are what make the classic “safe MAX” macro possible, evaluating each argument exactly once:

#define MAX(a, b) ({ \
__typeof__(a) a_ = (a); \
__typeof__(b) b_ = (b); \
a_ > b_ ? a_ : b_; \
})

MAX(i++, j) increments i once, unlike the naive ((a) > (b) ? (a) : (b)). Compile it with -std=c11 -Wpedantic and GCC warns ISO C forbids braced-groups within expressions, which is your signal that the code is no longer standard C. In your own code, a static inline function for each type you need is usually the better choice. Two GNU features to avoid entirely: nested functions (GCC only, not Clang, and they require an executable stack for trampolines) and anything relying on -std=gnu without a reason you can name.

When code must build on more than one compiler, every extension goes through a macro in one header, and the rest of the code base uses only the macros. This is exactly what CMSIS does in cmsis_compiler.h. A minimal version:

compiler.h
#ifndef COMPILER_H
#define COMPILER_H
#include <stdint.h>
#if defined(__ICCARM__) /* IAR Embedded Workbench for Arm */
#define FW_WEAK __weak
#define FW_USED __root
#define FW_PACKED_STRUCT __packed struct
#define FW_LIKELY(x) (x)
#define FW_UNLIKELY(x) (x)
#define FW_HAVE_BUILTIN_CLZ 0
#elif defined(__GNUC__) || defined(__clang__) /* GCC, Clang, armclang */
#define FW_WEAK __attribute__((weak))
#define FW_USED __attribute__((used))
#define FW_PACKED_STRUCT struct __attribute__((packed))
#define FW_LIKELY(x) __builtin_expect(!!(x), 1)
#define FW_UNLIKELY(x) __builtin_expect(!!(x), 0)
#define FW_HAVE_BUILTIN_CLZ 1
#else
#error "Unsupported compiler: add its definitions to compiler.h"
#endif
/* Count leading zeros; returns 32 for x == 0 on every compiler. */
static inline uint32_t fw_clz32(uint32_t x)
{
#if FW_HAVE_BUILTIN_CLZ
return (x == 0u) ? 32u : (uint32_t)__builtin_clz(x);
#else
uint32_t n = 0u;
if (x == 0u) {
return 32u;
}
while ((x & 0x80000000u) == 0u) {
x <<= 1;
n++;
}
return n;
#endif
}
#endif

Things to notice:

  • The order of the checks matters. Several compilers define __GNUC__ to claim GCC compatibility, including Clang and armclang, so test for the specific compilers first and GCC-compatible ones last. The #error makes an unknown compiler fail loudly instead of silently building with missing attributes.
  • FW_PACKED_STRUCT wraps the struct keyword, because compilers disagree on where the packing keyword goes: GCC puts the attribute after struct, IAR puts __packed before it. Usage is typedef FW_PACKED_STRUCT { uint8_t a; uint32_t b; } wire_t;.
  • fw_clz32 hides a builtin behind a function. On GCC and Clang it compiles to the single clz instruction. The fallback is slower but correct, and it also defines the x == 0 case, which __builtin_clz leaves undefined. On a real IAR project you would map it to IAR’s own intrinsic instead of the loop; the point is that callers never know.
  • Prefer standard features where they exist. Alignment uses C11 _Alignas, not __attribute__((aligned)); “does not return” uses _Noreturn. Only genuinely non-standard things belong in compiler.h.

The IAR spellings above are the ones CMSIS uses; check your compiler manual before relying on any mapping in this table, and when in doubt, read how cmsis_compiler.h and its per-compiler headers do it.

Implementation-defined behavior to pin down

Section titled “Implementation-defined behavior to pin down”

Portable code either avoids implementation-defined behavior or states its assumptions so they are checked. These are the ones that bite firmware:

BehaviorTypical valuesWhat to do
signedness of plain charunsigned on Arm (AAPCS), signed on x86use uint8_t/int8_t for data, char only for text; GCC’s -fsigned-char/-funsigned-char force it
size of int32 bits on Arm, 16 bits on AVR and MSP430use stdint.h types; remember integer promotion follows int’s size
size of long32 bits on Arm and 64-bit Windows, 64 bits on 64-bit Linux/macOSnever use long for a fixed width
right shift of a negative valuearithmetic on every mainstream compilershift unsigned values, or document and assert
bitfield layout, order and paddingcompiler and ABI specificmasks and shifts for anything that crosses a wire or maps a register (Bit manipulation)
signedness of plain int bitfieldseitheralways write unsigned or signed explicitly
size of an enumABI-dependent; changed by -fshort-enumsdo not put enum types in wire formats; mixing objects built with different settings gives a linker warning
alignment of uint64_t and double8 on Arm, 4 on 32-bit x86 Linuxaffects struct layout: use offsetof checks
converting an out-of-range value to a signed typewraps on GCC/Clangavoid, or check the range first
byte orderlittle-endian on Cortex-M as configured by vendorsserialize with shifts, never by casting

Assumptions you cannot avoid should become compile-time checks in one file that every build compiles:

platform_check.c
#include <limits.h>
#include <stdint.h>
_Static_assert(CHAR_BIT == 8, "8-bit bytes required");
_Static_assert(sizeof(int) == 4, "code assumes 32-bit int");
_Static_assert(sizeof(void *) == 4, "code assumes 32-bit pointers");
_Static_assert((-1 >> 1) == -1, "code assumes arithmetic right shift");
_Static_assert(UINTPTR_MAX == UINT32_MAX, "uintptr_t must be 32 bits");
#if defined(__BYTE_ORDER__) && (__BYTE_ORDER__ != __ORDER_LITTLE_ENDIAN__)
#error "code assumes a little-endian target"
#endif

This file compiles to nothing. Its value is that porting to a target where an assumption is false produces an error naming the assumption, instead of a subtle runtime bug months later. __BYTE_ORDER__ is a GCC and Clang predefined macro, hence the defined guard. The pointer-size check deliberately fails on a 64-bit host, so host test builds leave this file out (or check the host’s values in a separate file).

Writing code that builds on GCC, Clang, IAR and Keil

Section titled “Writing code that builds on GCC, Clang, IAR and Keil”

A short list of rules covers almost all of it:

  1. Write standard C11 and compile your own code with -Wpedantic (or the equivalent strict mode on other compilers) so accidental extensions are reported.
  2. Use stdint.h and stdbool.h types for everything with a size.
  3. Put every extension in compiler.h, and use CMSIS intrinsics (__disable_irq(), __DSB(), __CLZ()) for core instructions; CMSIS already implements them for each Arm compiler.
  4. Keep inline assembly in the port layer, a few files under platform/. Inline assembly syntax differs between compilers far more than C does.
  5. Accept that startup and linking are compiler-specific. GCC uses a GNU linker script (.ld), IAR an .icf file, Keil a scatter file (.sct); startup files differ too. They live in platform/ per toolchain, behind the same vector table names.
  6. Pin implementation-defined assumptions with _Static_assert as above.
  7. Build with two compilers in CI. GCC and Clang are both free and disagree usefully: each catches warnings the other misses. If the product ships with IAR or Keil, build with it nightly too.
  8. Avoid #pragma outside compiler.h. Pragmas are entirely implementation-defined; C99’s _Pragma("...") lets you put one inside a macro.

The C standard defines two kinds of implementation. A hosted implementation runs on an operating system and provides the whole standard library, with program startup at main. A freestanding implementation is for code that runs with no OS. It need only provide the headers that contain no functions: float.h, iso646.h, limits.h, stdalign.h, stdarg.h, stdbool.h, stddef.h, stdint.h and stdnoreturn.h (C23 adds a few more, including some string.h functions). The name and type of the startup function are implementation-defined. __STDC_HOSTED__ is 1 or 0 to tell you which you have.

GCC’s -ffreestanding flag tells the compiler it is building for a freestanding environment:

  • It implies -fno-builtin: GCC no longer assumes that a function called printf or strlen is the standard one, so it will not replace printf("x\n") with puts, or strlen("abc") with 3. You can still request the optimized version explicitly as __builtin_strlen.
  • main is no longer special, so its signature is not checked.
  • __STDC_HOSTED__ becomes 0.

What it does not do: it does not stop GCC from emitting calls to memcpy, memmove, memset and memcmp for struct copies and loops, as Memory: where everything lives warned. GCC documents that even freestanding code must provide those four.

Who uses it: kernels, bootloaders and projects with no C library at all (built with -nostdlib). Typical application firmware linked against newlib or picolibc does not need it, and enabling it costs the builtin optimizations of common string functions. Use it when you are deliberately providing your own versions of standard functions, so that the compiler does not make assumptions about them.

This is a course about C, but you should be able to argue the question either way, because many teams now write firmware in C++.

What C++ offers firmware:

  • Stronger typing: enum class, references, no implicit void * conversions, and constexpr functions evaluated at compile time.
  • RAII: a lock or critical-section guard object that releases automatically at the end of a scope, on every return path. It removes a whole class of “forgot to re-enable interrupts” bugs.
  • Templates: type-safe containers (a ring buffer of any element type with a compile-time capacity) and register-access libraries that compile to the same single str instruction as hand-written C, with errors caught at compile time.
  • Classes and virtual functions: the driver-interface pattern from Organizing a real C codebase, with language support instead of hand-built function-pointer tables.
  • Namespaces, overloading, and a richer standard library (std::array, std::span, std::optional) for those parts that do not allocate.

What it costs:

  • Exceptions and RTTI add code size and unpredictable timing; firmware builds disable them with -fno-exceptions -fno-rtti, which also means much of the standard library is off limits.
  • Hidden dynamic allocation: std::vector, std::string and std::function may call new. Embedded C++ uses a restricted subset with no heap.
  • Global constructors run before main through __libc_init_array, in an order that is unspecified across files; startup code must call them, as Startup code and the vector table shows.
  • Template bloat and opacity: careless templates multiply code size, and the generated assembly is harder to map back to source.
  • Ecosystem: vendor SDKs, RTOS kernels and most driver code are C. C++ code calls them through extern "C" headers, which is easy, but your team must be fluent in both.

Why this course is C. C is the language of vendor HALs, CMSIS, FreeRTOS, Zephyr, the Linux kernel, and almost every firmware job interview. Every C++ firmware engineer still reads C every day, and understanding what C does at the machine level (the subject of this whole part) is the prerequisite for using C++ well on a microcontroller, not an alternative to it. Rust is also gaining ground in embedded work and is worth watching; the hardware knowledge in the rest of this course transfers to it unchanged.

  • No -std flag. The default changes with toolchain versions (GCC 15 moved to gnu23), and code that compiled yesterday breaks. Fix: set -std=c11 (or your choice) explicitly in every build.
  • Using char for byte data. Signed on the host, unsigned on Arm: comparisons and shifts differ. Fix: uint8_t.
  • void f(); as a “no-argument” prototype before C23. It disables argument checking. Fix: void f(void);, and -Wstrict-prototypes.
  • Zero-length arrays in new code. Fix: C99 flexible array members.
  • __attribute__ spread through every file. Porting means editing hundreds of lines. Fix: macros in compiler.h.
  • Assuming long is 32 bits. True on Arm, false on 64-bit Linux host tests. Fix: fixed-width types.
  • Bitfields or enums in wire formats. Layout changes between compilers. Fix: explicit integer fields and shifts.
  • Adding -ffreestanding to fix a link error. It does not remove the need for memcpy and friends. Fix: link a C library or provide the four functions.
  1. Write a program that prints __STDC_VERSION__ and __STDC_HOSTED__. Compile it with -std=c99, -std=c11, -std=c17, -std=c23 (if your GCC supports it), and with no -std flag at all, and record the values. Then add -ffreestanding and observe __STDC_HOSTED__.
  2. Compile the MAX statement-expression macro with -std=c11 -Wpedantic and record the warning. Replace it with static inline functions max_u32 and max_i32, and use Compiler Explorer to confirm that -O2 generates the same instructions for both versions on Cortex-M4.
  3. Compile platform_check.c for the target with arm-none-eabi-gcc -c (it should produce no errors) and for your 64-bit host with gcc -c. Record which assertions fail on the host and explain each failure in one sentence.
  4. Build compiler.h and a small test that uses FW_PACKED_STRUCT, FW_WEAK and fw_clz32 with both GCC and Clang on your host. Then force the fallback path by editing the GCC branch of compiler.h to set FW_HAVE_BUILTIN_CLZ to 0, and write a test that compares fw_clz32 against the builtin for 0, 1, 0x80000000 and 10,000 random values.
  5. Take c23_demo.c and compile it with -std=c17 -Wpedantic. List every error, map each one to the C23 feature that caused it, and rewrite the program in C11 with the same behavior (#define or enum for the constant, __attribute__((warn_unused_result)) behind a macro, NULL, stdbool.h).

Question 1:Old vendor code contains typedef unsigned char bool; and builds fine with GCC 13. After upgrading to GCC 15 with no -std flag, it fails to compile. Why?

Question 2:Which declaration means "takes no arguments" in every C standard from C89 to C23?

Question 3:A protocol header is defined with bitfields and works between two STM32 boards built with GCC. A PC tool built with MSVC decodes it wrongly. What is the root cause?

Question 4:What does -ffreestanding change in GCC?

Question 5:In a portable compiler.h, why is defined(__ICCARM__) tested before defined(__GNUC__) || defined(__clang__)?

  • C99 gave firmware stdint.h, stdbool.h, designated initializers, inline and flexible array members; C11 added _Static_assert, _Alignas, _Atomic, _Noreturn and anonymous unions; C17 fixed defects; C23 brings constexpr, nullptr, typeof, attributes, binary literals and #embed.
  • Pick the standard explicitly: -std=c11 (or gnu11 when vendor code requires it) is the safe firmware default today, C23 when every toolchain in the project supports it.
  • GNU extensions (attributes, statement expressions, __builtin_*, inline assembly) are everywhere in embedded code; confine them to one compiler.h and prefer standard equivalents where they exist.
  • Plain char signedness, int and long sizes, bitfield layout, enum size and right shifts of negatives are implementation-defined; avoid relying on them or pin them with _Static_assert.
  • Code that builds on GCC, Clang, IAR and Keil is standard C plus a small, explicit port layer, verified by building with at least two compilers in CI.
  • -ffreestanding means “no hosted library assumptions”, not “no library needed”: memcpy, memmove, memset and memcmp are still required.
  • C++ offers RAII, templates and stronger typing at the cost of discipline about exceptions, allocation and constructors; C remains the common language of firmware, and mastering it is the foundation either way.

Next: Reading what the compiler wrote, the first lesson of C Meets the Metal, where you learn to read the Arm assembly behind your C.