Types / Promotion
Understand integer promotion and type conversion in embedded C. Interactive lab demonstrates implicit and explicit casting with signed/unsigned types.
Integer Types, Promotion, and Conversion
C almost never computes with the type you wrote. Any operand narrower than int is converted to int before an arithmetic operator sees it - the integer promotions - and when the two operands of a binary operator have different types after that, the usual arithmetic conversions bring them to a common one. Both rules are invisible in the source and decisive in the result. A uint8_t times a uint8_t is computed as int and can therefore exceed 255 without wrapping; a comparison between an int and an unsigned int converts the int to unsigned, so a negative value becomes enormous. On a microcontroller these rules meet fixed-width hardware registers and wire formats where the width is the specification, and the mismatch is where a large share of firmware defects come from.
How it is built
- The integer promotions apply to any operand of rank lower than int: char, signed char, unsigned char, short, unsigned short, and bit-fields. If int can represent every value of the original type, it becomes int; otherwise unsigned int. On a typical 32-bit target every uint8_t and uint16_t therefore becomes a signed int, which is the origin of most promotion surprises.
- The usual arithmetic conversions then reconcile the two operands. Broadly: if either is unsigned and of rank at least as high as the other, the signed operand converts to unsigned. This is why comparing a signed int against an unsigned int silently reinterprets a negative value as a large positive one, and why the comparison is true when you expect it to be false.
- Conversion to a narrower unsigned type is well defined and wraps modulo two to the width. Conversion to a narrower signed type is implementation-defined when the value does not fit - not undefined, but not portable either. Arithmetic that overflows a signed type is undefined outright, and unlike the conversion case the compiler may optimise on the assumption it never happens.
- Bit-fields have their own promotion behaviour and their layout - order, padding, straddling - is implementation-defined. That combination makes them unsuitable for describing a hardware register or a wire format, whatever their apparent convenience.
- The shift operators do not perform the usual arithmetic conversions on their operands: each is promoted independently, and the result type is that of the promoted left operand. A shift count greater than or equal to the promoted left operand's width is undefined, which is why shifting a uint8_t by twenty is a bug on a sixteen-bit-int target and fine on a thirty-two-bit one.
- sizeof yields size_t, which is unsigned. Subtracting two sizes and comparing the result against zero therefore never yields a negative number - the classic buffer-length bug where a length check passes because the underflowed difference is astronomically large.
Design procedure
- Use exact-width types from stdint.h at every boundary: hardware registers, wire formats, and anything whose size is part of the contract.
- Cast the operand, not the result, when you need a wider computation. Casting after the multiply is too late - the overflow has already happened in int.
- Never mix signed and unsigned in a comparison. Enable -Wsign-compare and treat every instance as a defect rather than noise.
- Do size arithmetic in a way that cannot underflow: compare a + b against a limit rather than limit - b against a value, or check the subtraction's operands first.
- Validate shift counts against the promoted operand's width, and use exact-width types so that width is known rather than inferred.
- Describe registers with explicit masks and shifts rather than bit-fields, so the layout is in your source and not in the compiler's discretion.
Key terms
- Integer promotion
- Any type narrower than int becomes int (or unsigned int) before an operator sees it.
- Usual arithmetic conversions
- How two differently typed operands reach a common type. Usually the signed one converts to unsigned.
- Rank
- The ordering of integer types that decides which converts to which.
- Implementation-defined
- The behaviour is fixed by the compiler and documented, unlike undefined behaviour. Narrowing signed conversion is in this class.
- size_t
- An unsigned type. Subtracting sizes cannot go negative, it goes very large.
- Bit-field
- Convenient syntax with implementation-defined layout and promotion. Wrong for registers and wire formats.
- Exact-width types
- uint8_t and friends. The only integer types whose width is part of the contract.
Worked example
#include <stdint.h>
uint8_t a = 200, b = 100;
uint8_t c = a + b; /* a and b promote to int: 300, then
narrows to 44. Well defined, surprising. */
uint16_t w = a * b; /* 20000 in int, fits uint16_t: correct */
int i = -1;
unsigned u = 1;
if (i < u) { } /* FALSE: i converts to a huge unsigned */
size_t len = 4, want = 8;
if (len - want > 0) { } /* TRUE: underflows to ~2^64, not negative */
if (want > len) { } /* what was actually meant */
uint8_t flags = 1;
uint32_t v = (uint32_t)flags << 20; /* cast the OPERAND, not later */
# What promotes to what, on a 32-bit-int target:
#
# uint8_t -> int (int holds every uint8_t value)
# uint16_t -> int (int holds every uint16_t value)
# uint32_t -> unsigned int (unchanged: same rank)
#
# So this is signed arithmetic: uint8_t x, y; x * y
# and this is unsigned: uint32_t x, y; x * yCommon pitfalls
The complete set of C data types
C has four families of type and nothing else: object types that hold a value, function types, pointer types that hold an address, and void, which holds nothing and exists to say so. Every type in any program is one of these or a composition of them. What surprises people coming from other languages is how little the standard fixes: it constrains minimum ranges and relative sizes, not exact widths, so the same declaration means different things on different targets.
How it is built
- Integer types: char, short, int, long, long long, each in signed and unsigned form. char is a third, distinct type from signed char and unsigned char.
- Floating types: float, double, long double. Many microcontrollers have hardware for float only, so double silently costs a software library.
- _Bool, from C99, holds exactly 0 or 1; any nonzero value assigned to it becomes 1, which is a conversion rather than a truncation.
- Enumerated types are integer types whose values you name. Derived types are arrays, structures, unions, pointers and functions.
- void is an incomplete type that can never be completed: you cannot declare a void object, only a void return, a void parameter list, or a void pointer.
Design procedure
- Decide what range and signedness the value genuinely needs, before thinking about which keyword to use.
- On a microcontroller prefer the fixed-width names from stdint.h for anything stored, transmitted, or mapped to hardware.
- Keep plain int for loop counters and small local arithmetic, where it is the target's natural register width and therefore the fastest choice.
- Use size_t for anything that counts objects or indexes memory, so the type cannot be too narrow to address the array.
- Confirm every assumption with sizeof and the limits in stdint.h rather than from memory or from another target.
Key terms
- Object type
- A type that describes an actual stored value, as opposed to a function type or an incomplete type.
- Incomplete type
- A type whose size is not yet known - void, an array of unspecified length, or a declared-but-undefined struct.
- Implementation-defined
- Behaviour the standard requires the compiler to choose and document, such as whether plain char is signed.
- Natural width
- The width the target handles in one register. On a 32-bit MCU, int; using a narrower type can cost extra instructions.
- stdint.h
- The header providing exact-width, minimum-width and fastest-width integer names.
Worked example
What the standard actually guarantees, which is less than most people assume:
char at least 8 bits sizeof(char) is 1 BY DEFINITION
short at least 16 bits
int at least 16 bits <- not 32
long at least 32 bits
long long at least 64 bits
and the ordering:
sizeof(char) <= sizeof(short) <= sizeof(int)
<= sizeof(long) <= sizeof(long long)
On a typical 32-bit MCU int is 4 bytes; on an 8-bit AVR it is 2. Code that
assumed 4 bytes does not fail to compile there - it silently overflows at
32,767.Common pitfalls
Fixed-width integers, and when not to use them
stdint.h exists because the built-in type names describe minimums rather than sizes, and embedded code usually needs exact sizes: a register is 32 bits, a protocol field is 16, a flash record has a fixed layout. The header provides three families for three different questions - what width exactly, what width at least, and what is fastest for at least this width - and using the wrong family is a real, if quiet, cost.
How it is built
- intN_t / uintN_t: exactly N bits, no padding, two's complement. Optional in principle; present on every practical embedded target.
- int_leastN_t: the smallest type with at least N bits. Always available, and the portable choice when you only need capacity.
- int_fastN_t: the target's fastest type with at least N bits. On a 32-bit MCU, uint_fast8_t is usually 32 bits wide - it trades memory for speed.
- intptr_t and uintptr_t hold a pointer converted to an integer; they are the only integer types the standard permits that round trip.
- intmax_t and uintmax_t are the widest integers available, used mainly by printf-family implementations.
Design procedure
- Use exact-width types for anything whose layout is externally visible: registers, packet fields, stored structures, DMA buffers.
- Use int_fastN_t for hot loop counters where only the range matters and the memory cost does not.
- Use plain int where the value is small, local, and short-lived; forcing uint8_t there often generates masking instructions on a 32-bit core.
- Print fixed-width types with the PRIu32 / PRId16 macros from inttypes.h, not with a guessed %d or %lu.
- Never store a pointer in an int; use uintptr_t, which is the only type guaranteed to survive the round trip.
Key terms
- uint8_t
- Exactly 8 bits, unsigned. Usually a typedef of unsigned char, which is why it can alias any object.
- uint_fast8_t
- At least 8 bits, chosen for speed. Frequently 32 bits on a 32-bit core.
- uintptr_t
- An unsigned integer wide enough to hold a pointer and convert back unchanged.
- PRIu32
- The printf conversion specifier for uint32_t, from inttypes.h. Correct on every target; %lu is not.
- size_t
- The unsigned type returned by sizeof. Wide enough to index any object the target can create.
Worked example
Choosing uint8_t for a loop counter is not free on a 32-bit core:
for (uint8_t i = 0; i < 200; i++) sum += buf[i];
Arm Thumb-2, -O2:
the compiler must keep i wrapping at 8 bits, so it emits
UXTB (zero-extend byte) after each increment
for (uint_fast8_t i = 0; i < 200; i++) sum += buf[i];
i is a 32-bit register; no masking instruction is needed
One byte of RAM saved, one instruction per iteration spent. In a buffer of a
million elements that is the wrong trade; in a struct held 10,000 times it is
the right one. The types exist so the choice is explicit.Common pitfalls
Integer promotion: the rule that changes your arithmetic
Before almost any arithmetic, C converts operands narrower than int up to int. This is integer promotion, it is automatic, and it is the single most common source of surprising results in embedded C, because it means arithmetic on two uint8_t values is not uint8_t arithmetic - it is int arithmetic, with an int's range and an int's signedness. Sometimes that saves you from an overflow; sometimes it turns a comparison you were sure about into its opposite.
How it is built
- Any type narrower than int - char, short, _Bool, bit-fields, and their unsigned forms - is promoted to int if int can represent all its values, otherwise to unsigned int.
- On a 32-bit target every uint8_t and uint16_t promotes to signed int, because a 32-bit int represents every value they hold.
- After promotion, the usual arithmetic conversions bring both operands to a common type, and here unsigned wins over signed of the same rank.
- The result of the expression is then converted back on assignment, which is where the truncation people expected finally happens.
- ~ and << are the operators where promotion bites hardest, because both produce values outside the original narrow range.
Design procedure
- Assume every narrow operand becomes int the moment it enters an expression.
- Cast back explicitly when assigning to a narrow type, both to document the truncation and to silence -Wconversion.
- Mask after complementing a narrow value: (uint8_t)(~flags) or (~flags & 0xFFu), never bare ~flags.
- Shift on an explicitly unsigned type of the intended width; shifting a signed value left into the sign bit is undefined behaviour.
- Build with -Wconversion and -Wsign-conversion, which exist specifically to make these silent conversions visible.
Key terms
- Integer promotion
- Automatic conversion of narrow types to int before arithmetic.
- Usual arithmetic conversions
- The rules bringing two operands to a common type; unsigned outranks signed at equal rank.
- Rank
- The ordering of integer types used by conversion rules: char < short < int < long < long long.
- Truncation
- Discarding high bits when a wider value is stored into a narrower type. Well defined for unsigned; implementation-defined historically for signed.
- -Wconversion
- The warning that reports every implicit narrowing. Noisy at first, and it finds real defects.
Worked example
Two cases where promotion decides the answer:
uint8_t a = 200, b = 100;
uint8_t sum = a + b;
a and b promote to int, 200 + 100 = 300 in int arithmetic,
then 300 is truncated to 44 on assignment.
No overflow occurred; a conversion did.
uint8_t flags = 0x0F;
if (~flags == 0xF0) { ... } // NEVER TRUE
flags promotes to int 0x0000000F
~ gives 0xFFFFFFF0, which is not 0xF0
correct: if ((uint8_t)~flags == 0xF0)
or: if ((~flags & 0xFFu) == 0xF0)
The second compiles clean, reads correctly, and is always false.Common pitfalls
Type qualifiers: const, volatile, restrict, _Atomic
A qualifier does not change what a type holds; it changes what the compiler is permitted to assume about accesses to it. That makes qualifiers the part of the type system that matters most in embedded code, because every one of them is a statement about the world outside the current function: this will not change, this changes on its own, these do not overlap, this is safe to touch concurrently.
How it is built
- const means this access path may not modify the object. It is a promise about the pointer, not necessarily about the object, so casting it away and writing is undefined when the object really is constant.
- volatile means the value may change outside the program's control, so every read must actually read and every write must actually write. It forbids caching in a register and forbids reordering with respect to other volatile accesses.
- restrict is a promise that, for the lifetime of the pointer, the object it points to is reached only through it. It enables optimisations that aliasing would otherwise forbid.
- _Atomic gives indivisible access with defined ordering; it is a different and stronger guarantee than volatile, which says nothing about atomicity.
- Qualifiers apply to the thing on their left, which is why const char * and char * const mean different things.
Design procedure
- Mark every hardware register volatile, and every pointer parameter the function does not modify const.
- Do not reach for volatile to make something thread-safe; it prevents caching, not tearing, and gives no ordering against non-volatile accesses.
- Use _Atomic or the target's interrupt-disable primitives for data shared with an ISR that is wider than one atomic access.
- Add restrict only where you can genuinely guarantee no overlap; a violated restrict is undefined behaviour with no diagnostic.
- Read a declaration right-to-left from the identifier when the qualifier placement is ambiguous.
Key terms
- const
- This access path does not modify the object. Enables optimisation and documents intent.
- volatile
- Every access must happen exactly as written. Required for MMIO and for variables an ISR changes.
- restrict
- A no-aliasing promise. memcpy has it; memmove deliberately does not.
- _Atomic
- Indivisible access with defined ordering. Solves what volatile does not.
- Tearing
- A wide value read or written in pieces, so an observer sees half of one value and half of another.
Worked example
The three const pointers, which are three different promises:
const char *p; p points to const chars
*p = 'x'; ERROR
p++; fine
char * const p; p is a const pointer
*p = 'x'; fine
p++; ERROR
const char * const p; both
And why volatile is not a concurrency tool:
volatile uint32_t counter; // 32-bit MCU: read/write is one instruction
counter++; // read, add, write - THREE instructions
// an ISR between them loses an increment
volatile guaranteed the accesses happened. It never promised they were one
operation.Common pitfalls
Floating point on a microcontroller
float and double are ordinary C types with an extraordinary cost profile on embedded targets. A core with a single-precision FPU executes float arithmetic in a few cycles and double arithmetic in a software library that may take hundreds. The difference is invisible in the source - the promotion rules will hand you a double without any explicit conversion - so knowing the rules is what keeps the fast path fast.
How it is built
- float is IEEE-754 single precision: 32 bits, 24 bits of significand, about 7 decimal digits.
- double is double precision: 64 bits, 53 bits of significand, about 15 decimal digits.
- An unsuffixed floating constant such as 1.5 has type double. Writing 1.5f makes it a float.
- Cortex-M4F and M33 have a single-precision FPU only. Every double operation is a library call, typically 10 to 100 times slower.
- Default argument promotion converts a float to double when passed to a variadic function such as printf, whatever the FPU supports.
Design procedure
- Suffix every floating constant with f in float code, or a single unsuffixed constant will promote the whole expression to double.
- Enable the FPU in both the compiler flags and the startup code; -mfpu without the CPACR write faults on the first FPU instruction.
- Prefer fixed-point arithmetic where the range is known and determinism matters, particularly inside an ISR.
- Save the FPU context in an RTOS task switch, or disable the FPU for ISRs, since the register file is extra state.
- Never compare floats with ==; compare against a tolerance appropriate to the magnitude involved.
Key terms
- IEEE-754
- The floating-point standard: sign, exponent and significand, with defined rounding and special values.
- Single precision
- 32-bit float, roughly 7 significant decimal digits.
- Subnormal
- A very small value below the normal exponent range. Often trapped to software and dramatically slow.
- Default argument promotion
- float becomes double when passed through ..., which is why printf takes doubles.
- Lazy stacking
- The Cortex-M FPU feature that defers saving FP registers on exception entry until they are used.
Worked example
One missing character changes the generated code completely:
float scale(float x) { return x * 1.5; }
1.5 is a DOUBLE. x is promoted to double, multiplied in
software, and the result converted back to float.
Cortex-M4F: two conversions plus a __aeabi_dmul call.
float scale(float x) { return x * 1.5f; }
one VMUL.F32 instruction, single cycle.
And why equality fails:
0.1f + 0.2f == 0.3f is FALSE
neither 0.1 nor 0.2 is representable in binary, so the sum
differs from the literal 0.3 in the last bitsCommon pitfalls
Signedness, overflow, and what the standard actually permits
Signed and unsigned integers differ in more than range: they differ in what happens when you exceed it. Unsigned arithmetic wraps, and the standard says so - it is modular arithmetic and completely defined. Signed overflow is undefined behaviour, which does not mean it wraps quietly; it means the compiler may assume it never happens and optimise accordingly. That assumption has removed real bounds checks from real firmware.
How it is built
- Unsigned arithmetic is modulo 2^N. UINT8_MAX + 1 is 0, guaranteed on every conforming implementation.
- Signed overflow is undefined. In practice the hardware wraps, but the compiler is entitled to assume it cannot happen and delete code that only matters if it does.
- Since C23 signed integers are two's complement, which fixes the representation but does not make overflow defined.
- Converting out-of-range to unsigned is defined by modular reduction; converting out-of-range to signed is implementation-defined.
- Division by zero and INT_MIN / -1 are both undefined; the second overflows because the positive result is not representable.
Design procedure
- Check before you overflow, not after: if (a > UINT32_MAX - b) rather than testing whether a + b wrapped.
- For signed, test the operands against the limits in limits.h, since testing the result is a test the optimiser may delete.
- Use unsigned deliberately for bit manipulation, hardware registers and modular counters, where wrapping is the intent.
- Use signed for values that can genuinely be negative, and for loop indices that count down past zero.
- Build with -fsanitize=undefined during development; it catches signed overflow at the moment it happens.
Key terms
- Modular arithmetic
- Unsigned wrapping: results are reduced modulo 2^N, always defined.
- Undefined behaviour
- The standard imposes no requirement. The compiler may assume it does not occur.
- Two's complement
- The signed representation where the top bit has negative weight. Mandatory from C23.
- INT_MIN
- The most negative int. Its negation is not representable, so -INT_MIN overflows.
- UBSan
- The undefined-behaviour sanitizer, which traps signed overflow and similar at runtime.
Worked example
Why a post-hoc signed overflow check does not survive optimisation:
int add(int a, int b) {
int sum = a + b;
if (sum < a) return -1; // intended overflow check
return sum;
}
Signed overflow is undefined, so the compiler reasons: if no
overflow occurs then a + b >= a whenever b >= 0, and overflow
cannot occur because it is undefined. The comparison is therefore
provably false and the branch is deleted. -O2 emits no check.
The unsigned version is defined and survives:
unsigned sum = a + b;
if (sum < a) return -1; // real wraparound test
The correct signed version checks first:
if (b > 0 && a > INT_MAX - b) return -1;
if (b < 0 && a < INT_MIN - b) return -1;Common pitfalls
Storage duration, linkage, and scope
Three independent properties decide where a variable lives, how long it lives, and who can see it - and they are commonly confused because one keyword, static, controls two of them depending on where it appears. Getting them right is what places an object in .bss rather than on the stack, and what keeps a symbol out of another translation unit's namespace.
How it is built
- Storage duration is lifetime: automatic (until the block exits), static (the whole program run), allocated (until freed), and thread-local.
- Scope is visibility in the source: block, file, function prototype, or function scope for labels.
- Linkage is whether the same name in another translation unit refers to the same object: external, internal, or none.
- static at file scope means internal linkage - the symbol is private to the file. static inside a function means static storage duration - one instance, retained across calls.
- extern declares without defining, so the definition is elsewhere; it is a declaration of external linkage, not a storage class in the usual sense.
Design procedure
- Mark every file-scope variable and helper function static unless another file genuinely needs it; this is the closest thing C has to a private keyword.
- Use a function-local static for state that must persist between calls but belongs to nobody else.
- Remember an initialised static goes in .data, which costs both flash and RAM, while a zero-initialised one goes in .bss, which costs RAM only.
- Declare shared objects extern in a header and define them in exactly one source file.
- Do not return a pointer to an automatic object; its lifetime ends at the closing brace, whatever the stack still contains.
Key terms
- Automatic storage
- The default for locals: created on entry to the block, destroyed on exit. Usually the stack.
- Static storage
- Exists for the whole program. Zero-initialised into .bss, or explicitly initialised into .data.
- Internal linkage
- Visible only within its translation unit. What file-scope static gives.
- Translation unit
- One source file plus everything it includes, after preprocessing.
- .bss
- The zero-initialised data section. It occupies RAM but no flash; startup code clears it.
Worked example
The same keyword, two entirely different meanings:
static int counter; // FILE SCOPE
internal linkage: no other .c file can reach this name
static storage: lives for the whole program, in .bss
void tick(void) {
static int calls = 0; // BLOCK SCOPE
calls++; // static storage: survives the call
} // no linkage: nothing else can name it
And the sections they land in:
static uint32_t a; .bss 4 B RAM, 0 B flash
static uint32_t b = 0; .bss still zero, so still .bss
static uint32_t c = 7; .data 4 B RAM + 4 B flash for the
initialiser that startup copies
static const uint32_t d = 7; .rodata 4 B flash onlyCommon pitfalls
sizeof, alignment, and the layout the compiler chooses
sizeof gives the storage a type occupies, including any padding the compiler inserts to satisfy alignment, and alignment is a hardware requirement rather than a compiler preference: many cores fault or slow down on a misaligned access. Together they explain why a struct is often larger than the sum of its members, and why reordering fields can shrink it with no change in meaning.
How it is built
- sizeof yields size_t and is evaluated at compile time for everything except a variable-length array.
- sizeof(char) is 1 by definition, so sizeof measures in char-sized units rather than in octets on exotic targets.
- Every type has an alignment: an object of that type must sit at an address that is a multiple of it. _Alignof reports it.
- A struct is aligned to its strictest member, and padded at the end so that an array of them keeps every element aligned.
- sizeof on an array parameter gives the size of a pointer, because array parameters decay - a distinct source of wrong buffer sizes.
Design procedure
- Order struct members from largest alignment to smallest; this usually removes internal padding without changing anything else.
- Use sizeof on the object rather than repeating its type: memcpy(dst, src, sizeof *dst) survives a later type change.
- Never rely on sizeof for a wire format; serialise field by field, because padding is not portable and is not transmitted meaningfully.
- Check assumptions at compile time with _Static_assert(sizeof(Frame) == 12, "layout changed").
- Use offsetof from stddef.h rather than computing member offsets by hand.
Key terms
- Alignment
- The address multiple an object must satisfy. 4 for a 32-bit word on most 32-bit targets.
- Padding
- Unused bytes the compiler inserts to keep members aligned. Their contents are unspecified.
- Tail padding
- Padding after the last member, so an array of the struct stays aligned.
- offsetof
- The macro giving a member's byte offset within its struct.
- _Static_assert
- A compile-time assertion. The right way to pin a layout you depend on.
Worked example
The same three members, two sizes:
struct Bad { struct Good {
uint8_t a; uint32_t b;
uint32_t b; uint16_t c;
uint16_t c; uint8_t a;
}; };
Bad: a at 0, 3 bytes padding, b at 4, c at 8, 2 tail = 12 B
Good: b at 0, c at 4, a at 6, 1 tail = 8 B
A third smaller, identical meaning, and in an array of 1000 that is 4 kB of
RAM on a part that may only have 64.
And the decay trap:
void f(uint8_t buf[64]) {
sizeof buf; // 4, not 64 - buf is a pointer here
}Common pitfalls
More in Embedded C
- Interrupts, Rings & ConcurrencyThe handler is a second thread you did not declare. What is and is not atomic on a single core, the lock-free ring buffer and the conditions it requires, DMA and cache coherency, low-power modes and their wake sources, priority inversion, and debugging a race you cannot reproduce.
- Volatile RegistersMaster volatile keyword usage for memory-mapped registers in embedded C. Interactive simulator shows compiler optimization effects on register reads.
- User-Defined TypesDeclare your own types in embedded C: struct, union, enum, typedef, bitfields, designated initialisers, flexible array members and opaque handles.
- Embedded C + DSA 0 → 100One self-sufficient course connecting beginner C, Embedded C, hardware-facing APIs, bounded data structures, Embedded DSA practice, compiled code, diagnostics and production capstones.
- Bits, Fields & Fixed PointRegister fields and the mask conventions that silently disagree, Gray code and where one-bit-at-a-time matters, fixed-point arithmetic and the intermediate width a multiply needs, the undefined-behaviour traps in ordinary bit idioms, wire-format packing, and what each checksum detects.
- Compiler Workbench & TestingCompile real C for an embedded target and inspect what the compiler produced, then the discipline around it: where to draw the host-testable boundary, reading the generated assembly, undefined behaviour and the sanitizers, the warnings worth enabling, and measuring size and stack.
- Functions & ContractsDesign production C functions and modules with explicit preconditions, postconditions, ownership, status codes, opaque types, reentrancy, HAL seams and failure-state behavior.
- Arrays, Strings & BuffersArrays decay and the length does not travel; strings are a convention, not a type; and every length in a received packet is data rather than fact. Spans, the three string copies, framing and resynchronisation, serialisation, and parsing untrusted input safely.
- Object Layout & StorageWhere an object lives and what it costs: storage duration and linkage, the sections a declaration lands in, struct layout and the padding that makes a struct larger than its members, allocation without a heap, and integrity checks over stored data.
- C Basics & the Translation UnitFrom source text to a linked image: declarations against definitions, the translation unit the compiler actually sees, the preprocessor and what a macro can and cannot do, and the four build stages with the error vocabulary each one produces.
- FSM / DispatchDesign finite state machines with dispatch tables in embedded C. Interactive lab demonstrates state transitions and event handling for firmware.
- Embedded CA complete source-to-silicon workbench: C semantics, arrays and APIs, compiler and startup, MMIO, interrupts, DMA and caches, bounded systems, testing, safety evidence and production defense.