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.
The standards in one table
Section titled “The standards in one table”| Standard | Year | __STDC_VERSION__ | Headline additions for firmware |
|---|---|---|---|
| C89/C90 | 1989/1990 | (not defined) / 199409L for the 1995 amendment | the first ISO C: prototypes, const, volatile, void * |
| C99 | 1999 | 199901L | stdint.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 |
| C11 | 2011 | 201112L | _Static_assert, _Atomic and stdatomic.h, _Generic, _Alignas/_Alignof, anonymous structs and unions, _Noreturn, optional threads; VLAs made optional; gets removed |
| C17 | 2018 | 201710L | defect fixes only, no new features |
| C23 | 2024 | 202311L | nullptr, 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"#endifC99: the baseline everyone assumes
Section titled “C99: the baseline everyone assumes”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: the features firmware needs
Section titled “C11: the features firmware needs”C11 is the minimum this course targets, because of four features:
_Static_assertfor compile-time checks of struct layouts, register offsets and configuration values. Includingassert.hgives the friendlier spellingstatic_assert._Alignasand_Alignoffor DMA buffers and allocators, from Memory: where everything lives._Atomicandstdatomic.hfor 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 toldrex/strexloops; on Cortex-M0, which lacks those instructions, many of them become library calls._Noreturnfor 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: what changes
Section titled “C23: what changes”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:
#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;}gcc -std=c23 -Wall -Wextra -Wpedantic c23_demo.c -o c23_demo && ./c23_demoGCC 14 and later accept -std=c23; GCC 13 spells the same mode -std=c2x.
364 165 1The features most useful to firmware:
| C23 feature | Replaces | Why it matters |
|---|---|---|
constexpr objects | #define constants, enum hacks | typed, scoped compile-time constants that work in array sizes and static_assert |
nullptr | NULL (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_unqual | GNU __typeof__ | standard way to write type-generic macros |
[[nodiscard]], [[maybe_unused]], [[fallthrough]], [[deprecated]], [[noreturn]] | __attribute__((...)) | portable attribute syntax |
0b literals, ' separators | hex and comments | readable register masks: 0b0000'0011'0000'0000 |
#embed "file.bin" | xxd -i generated arrays | include binary data (fonts, certificates, FPGA images) directly |
stdckdint.h: ckd_add, ckd_mul | hand-written overflow checks, __builtin_add_overflow | standard 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.
Which standard to pick for firmware
Section titled “Which standard to pick for firmware”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
__asmand statement expressions. Under strict-std=c11 -Wpedanticsome of them produce warnings or errors. The usual fix is-std=gnu11for the whole build, or strict C11 for your own code andgnu11for 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.
GNU extensions you will see
Section titled “GNU extensions you will see”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:
| Extension | Example | Standard alternative |
|---|---|---|
__attribute__((...)) | __attribute__((packed, aligned(4))), weak, section(".ramfunc"), used, noinline | C23 [[...]] 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 arrays | uint8_t data[0]; at the end of a struct | C99 flexible array member uint8_t data[]; |
| case ranges | case '0' ... '9': | list the cases, or an if |
__builtin_* functions | __builtin_clz, __builtin_ctz, __builtin_popcount, __builtin_bswap32, __builtin_expect, __builtin_unreachable, __builtin_add_overflow | portable C (often slower) or C23 stdbit.h/stdckdint.h |
| inline assembly | __asm volatile ("cpsid i" ::: "memory"); | none |
| conditional with omitted middle | x ?: y (means x ? x : y, evaluating x once) | spell it out |
| labels as values | goto *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.
Wrapping extensions in macros
Section titled “Wrapping extensions in macros”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:
#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}
#endifThings 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#errormakes an unknown compiler fail loudly instead of silently building with missing attributes. FW_PACKED_STRUCTwraps thestructkeyword, because compilers disagree on where the packing keyword goes: GCC puts the attribute afterstruct, IAR puts__packedbefore it. Usage istypedef FW_PACKED_STRUCT { uint8_t a; uint32_t b; } wire_t;.fw_clz32hides a builtin behind a function. On GCC and Clang it compiles to the singleclzinstruction. The fallback is slower but correct, and it also defines thex == 0case, which__builtin_clzleaves 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 incompiler.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:
| Behavior | Typical values | What to do |
|---|---|---|
signedness of plain char | unsigned on Arm (AAPCS), signed on x86 | use uint8_t/int8_t for data, char only for text; GCC’s -fsigned-char/-funsigned-char force it |
size of int | 32 bits on Arm, 16 bits on AVR and MSP430 | use stdint.h types; remember integer promotion follows int’s size |
size of long | 32 bits on Arm and 64-bit Windows, 64 bits on 64-bit Linux/macOS | never use long for a fixed width |
| right shift of a negative value | arithmetic on every mainstream compiler | shift unsigned values, or document and assert |
| bitfield layout, order and padding | compiler and ABI specific | masks and shifts for anything that crosses a wire or maps a register (Bit manipulation) |
signedness of plain int bitfields | either | always write unsigned or signed explicitly |
size of an enum | ABI-dependent; changed by -fshort-enums | do not put enum types in wire formats; mixing objects built with different settings gives a linker warning |
alignment of uint64_t and double | 8 on Arm, 4 on 32-bit x86 Linux | affects struct layout: use offsetof checks |
| converting an out-of-range value to a signed type | wraps on GCC/Clang | avoid, or check the range first |
| byte order | little-endian on Cortex-M as configured by vendors | serialize with shifts, never by casting |
Assumptions you cannot avoid should become compile-time checks in one file that every build compiles:
#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"#endifThis 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:
- Write standard C11 and compile your own code with
-Wpedantic(or the equivalent strict mode on other compilers) so accidental extensions are reported. - Use
stdint.handstdbool.htypes for everything with a size. - 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. - Keep inline assembly in the port layer, a few files under
platform/. Inline assembly syntax differs between compilers far more than C does. - Accept that startup and linking are compiler-specific. GCC uses a GNU linker script (
.ld), IAR an.icffile, Keil a scatter file (.sct); startup files differ too. They live inplatform/per toolchain, behind the same vector table names. - Pin implementation-defined assumptions with
_Static_assertas above. - 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.
- Avoid
#pragmaoutsidecompiler.h. Pragmas are entirely implementation-defined; C99’s_Pragma("...")lets you put one inside a macro.
-ffreestanding and what it guarantees
Section titled “-ffreestanding and what it guarantees”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 calledprintforstrlenis the standard one, so it will not replaceprintf("x\n")withputs, orstrlen("abc")with 3. You can still request the optimized version explicitly as__builtin_strlen. mainis 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.
C or C++ for firmware
Section titled “C or C++ for firmware”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 implicitvoid *conversions, andconstexprfunctions 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
strinstruction 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::stringandstd::functionmay callnew. Embedded C++ uses a restricted subset with no heap. - Global constructors run before
mainthrough__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.
Common mistakes
Section titled “Common mistakes”- No
-stdflag. The default changes with toolchain versions (GCC 15 moved tognu23), and code that compiled yesterday breaks. Fix: set-std=c11(or your choice) explicitly in every build. - Using
charfor 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 incompiler.h.- Assuming
longis 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
-ffreestandingto fix a link error. It does not remove the need formemcpyand friends. Fix: link a C library or provide the four functions.
Exercises
Section titled “Exercises”- 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-stdflag at all, and record the values. Then add-ffreestandingand observe__STDC_HOSTED__. - Compile the
MAXstatement-expression macro with-std=c11 -Wpedanticand record the warning. Replace it withstatic inlinefunctionsmax_u32andmax_i32, and use Compiler Explorer to confirm that-O2generates the same instructions for both versions on Cortex-M4. - Compile
platform_check.cfor the target witharm-none-eabi-gcc -c(it should produce no errors) and for your 64-bit host withgcc -c. Record which assertions fail on the host and explain each failure in one sentence. - Build
compiler.hand a small test that usesFW_PACKED_STRUCT,FW_WEAKandfw_clz32with both GCC and Clang on your host. Then force the fallback path by editing the GCC branch ofcompiler.hto setFW_HAVE_BUILTIN_CLZto 0, and write a test that comparesfw_clz32against the builtin for 0, 1,0x80000000and 10,000 random values. - Take
c23_demo.cand 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 (#defineorenumfor the constant,__attribute__((warn_unused_result))behind a macro,NULL,stdbool.h).
Check yourself
Section titled “Check yourself”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?
C23 made bool, true and false keywords. The new default standard turns the old typedef into an error; pinning -std=c11 or gnu11 restores the old behavior.
Question 2:Which declaration means "takes no arguments" in every C standard from C89 to C23?
Before C23, empty parentheses meant "unspecified parameters" and disabled argument checking. (void) has always meant no parameters.
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?
The standard leaves bitfield allocation order, padding and straddling rules to the implementation. Wire formats should use explicit integer fields with masks and shifts.
Question 4:What does -ffreestanding change in GCC?
-ffreestanding implies -fno-builtin and marks the environment as not hosted. GCC may still emit calls to memcpy, memmove, memset and memcmp, and linking is controlled separately.
Question 5:In a portable compiler.h, why is defined(__ICCARM__) tested before defined(__GNUC__) || defined(__clang__)?
Compilers such as Clang and armclang define __GNUC__ to claim GCC compatibility. Testing the most specific identifiers first avoids picking the wrong set of definitions.
Key takeaways
Section titled “Key takeaways”- C99 gave firmware
stdint.h,stdbool.h, designated initializers,inlineand flexible array members; C11 added_Static_assert,_Alignas,_Atomic,_Noreturnand anonymous unions; C17 fixed defects; C23 bringsconstexpr,nullptr,typeof, attributes, binary literals and#embed. - Pick the standard explicitly:
-std=c11(orgnu11when 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 onecompiler.hand prefer standard equivalents where they exist. - Plain
charsignedness,intandlongsizes, 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.
-ffreestandingmeans “no hosted library assumptions”, not “no library needed”:memcpy,memmove,memsetandmemcmpare 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.