RayBench EmbeddedInteractive engineering labs
EMBEDDED C

Types / Promotion

Understand integer promotion and type conversion in embedded C. Interactive lab demonstrates implicit and explicit casting with signed/unsigned types.

Reviewed 2026-08-224,528 words

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

  1. Use exact-width types from stdint.h at every boundary: hardware registers, wire formats, and anything whose size is part of the contract.
  2. 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.
  3. Never mix signed and unsigned in a comparison. Enable -Wsign-compare and treat every instance as a defect rather than noise.
  4. 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.
  5. Validate shift counts against the promoted operand's width, and use exact-width types so that width is known rather than inferred.
  6. 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 * y

Common 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

  1. Decide what range and signedness the value genuinely needs, before thinking about which keyword to use.
  2. On a microcontroller prefer the fixed-width names from stdint.h for anything stored, transmitted, or mapped to hardware.
  3. Keep plain int for loop counters and small local arithmetic, where it is the target's natural register width and therefore the fastest choice.
  4. Use size_t for anything that counts objects or indexes memory, so the type cannot be too narrow to address the array.
  5. 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

  1. Use exact-width types for anything whose layout is externally visible: registers, packet fields, stored structures, DMA buffers.
  2. Use int_fastN_t for hot loop counters where only the range matters and the memory cost does not.
  3. 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.
  4. Print fixed-width types with the PRIu32 / PRId16 macros from inttypes.h, not with a guessed %d or %lu.
  5. 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

  1. Assume every narrow operand becomes int the moment it enters an expression.
  2. Cast back explicitly when assigning to a narrow type, both to document the truncation and to silence -Wconversion.
  3. Mask after complementing a narrow value: (uint8_t)(~flags) or (~flags & 0xFFu), never bare ~flags.
  4. Shift on an explicitly unsigned type of the intended width; shifting a signed value left into the sign bit is undefined behaviour.
  5. 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

  1. Mark every hardware register volatile, and every pointer parameter the function does not modify const.
  2. Do not reach for volatile to make something thread-safe; it prevents caching, not tearing, and gives no ordering against non-volatile accesses.
  3. Use _Atomic or the target's interrupt-disable primitives for data shared with an ISR that is wider than one atomic access.
  4. Add restrict only where you can genuinely guarantee no overlap; a violated restrict is undefined behaviour with no diagnostic.
  5. 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

  1. Suffix every floating constant with f in float code, or a single unsuffixed constant will promote the whole expression to double.
  2. Enable the FPU in both the compiler flags and the startup code; -mfpu without the CPACR write faults on the first FPU instruction.
  3. Prefer fixed-point arithmetic where the range is known and determinism matters, particularly inside an ISR.
  4. Save the FPU context in an RTOS task switch, or disable the FPU for ISRs, since the register file is extra state.
  5. 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 bits

Common 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

  1. Check before you overflow, not after: if (a > UINT32_MAX - b) rather than testing whether a + b wrapped.
  2. For signed, test the operands against the limits in limits.h, since testing the result is a test the optimiser may delete.
  3. Use unsigned deliberately for bit manipulation, hardware registers and modular counters, where wrapping is the intent.
  4. Use signed for values that can genuinely be negative, and for loop indices that count down past zero.
  5. 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

  1. 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.
  2. Use a function-local static for state that must persist between calls but belongs to nobody else.
  3. 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.
  4. Declare shared objects extern in a header and define them in exactly one source file.
  5. 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 only

Common 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

  1. Order struct members from largest alignment to smallest; this usually removes internal padding without changing anything else.
  2. Use sizeof on the object rather than repeating its type: memcpy(dst, src, sizeof *dst) survives a later type change.
  3. Never rely on sizeof for a wire format; serialise field by field, because padding is not portable and is not transmitted meaningfully.
  4. Check assumptions at compile time with _Static_assert(sizeof(Frame) == 12, "layout changed").
  5. 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.