Skip to content

Part 1 · The C Language28 min read

Control Flow That Holds Up in Production

Control flow is where firmware decides what to do: which state the protocol is in, whether a peripheral answered before its deadline, which resources to release when initialization fails halfway. The syntax of if, switch and loops takes ten minutes to learn. Writing control flow that stays correct when a colleague adds a new enum value, when a sensor stops responding, or when someone indents a line wrongly takes discipline, and that discipline is what this lesson is about. You will also see what each construct becomes in Cortex-M machine code, because branches and jump tables have real costs on a CPU with no branch predictor.

if (temperature_c > 85) {
shutdown();
} else if (temperature_c > 70) {
fan_set(FAN_HIGH);
} else {
fan_set(FAN_LOW);
}

The condition is any scalar expression. Zero is false; anything else is true. An else if chain is just an if statement nested inside the else of the previous one, evaluated top to bottom, and the first true condition wins. Put the conditions in an order where each one only needs to handle what the previous ones did not, as above: once temperature_c > 85 is ruled out, the second test means “between 71 and 85”.

The body of an if can be a single statement without braces. Do not use that form. In February 2014, Apple patched a flaw in its TLS certificate check, in code that looked like this (names simplified):

if ((err = hash_update(&ctx, &server_random)) != 0)
goto fail;
if ((err = hash_update(&ctx, &signed_params)) != 0)
goto fail;
goto fail; /* duplicated line: always executes */
if ((err = hash_final(&ctx, &hash_out)) != 0)
goto fail;
/* ... the signature check that should follow is never reached ... */

The duplicated goto fail; is indented as if it belonged to the if, but without braces only the first statement is the body. The second goto ran unconditionally, skipping the signature verification with err equal to 0 (“success”). With braces on every body, the duplicate line would have been harmless dead code. GCC’s -Wall now includes -Wmisleading-indentation, which flags exactly this pattern. Use both: braces always, and the warning as a backstop.

Braces also settle the dangling else question. In if (a) if (b) x(); else y(); the else belongs to the nearest unmatched if (the inner one), whatever the indentation suggests. With braces there is nothing to guess.

if (mode = MODE_SAFE) assigns, then tests the assigned value, which is always true here. -Wall warns suggest parentheses around assignment used as truth value. When you really do want to assign and test, as in while ((c = uart_getc()) >= 0), the extra parentheses tell both the compiler and the reader that it is intentional, and it is still clearer to compare against something explicitly, as that example does.

A stray semicolon creates an empty body: if (fault); { reset(); } resets unconditionally. -Wextra catches the if case (-Wempty-body). A stray semicolon after a while or for header often goes unreported, which is one more reason to put the opening brace on the same line as the header, where a semicolon looks wrong.

switch selects among integer constants:

switch (cmd) {
case CMD_PING:
send_pong();
break;
case CMD_READ:
send_reading();
break;
default:
send_error(ERR_UNKNOWN_CMD);
break;
}

The rules:

  • The controlling expression must have integer type (after promotion). You cannot switch on strings or floats.
  • Each case label must be an integer constant expression, and no two may have the same value. As you saw in Variables, scope, lifetime, and storage classes, a const variable does not qualify; an enum constant or a #define does.
  • Control jumps to the matching label, or to default if none matches, or past the whole switch if there is no default.
  • Execution then continues until a break, straight through any following labels. This is fallthrough.

Forgetting a break is the classic switch bug: the code for the next case runs as well. Sometimes fallthrough is intended, and there are two legitimate patterns. Several labels sharing one body is common and never warned about:

case 'a':
case 'A':
select_channel_a();
break;

A case that does extra work and then continues into the next case’s code is rarer, and must be marked:

switch (cmd) {
case CMD_RESET:
clear_statistics();
/* fall through */
case CMD_START:
start_acquisition();
break;
default:
break;
}

-Wextra enables -Wimplicit-fallthrough, which reports this statement may fall through whenever a non-empty case runs into the next label without a marker. GCC accepts three markers: a comment such as /* fall through */ immediately before the next label, the GNU statement attribute __attribute__((fallthrough));, and in C23 the standard attribute [[fallthrough]];. Comments are recognized by matching their text against a pattern, so keep the wording standard. Where a codebase must also build with compilers that do not understand comments, a macro that expands to the attribute is common.

A case label must label a statement, and before C23 a declaration is not a statement. Writing case CMD_SET: uint32_t v = parse_arg(); fails to compile. Wrap the case body in braces, which also limits the variable’s scope to that case:

case CMD_SET: {
uint32_t value = parse_arg();
apply_setting(value);
break;
}

Switching on an enum lets the compiler check that every value is handled. Given:

#include <stdint.h>
typedef enum {
LED_OFF,
LED_ON,
LED_BLINK_SLOW,
LED_BLINK_FAST,
} led_mode_t;
uint32_t led_period_ms(led_mode_t mode)
{
switch (mode) {
case LED_OFF:
case LED_ON:
return 0u; /* steady: no blinking */
case LED_BLINK_SLOW:
return 1000u;
case LED_BLINK_FAST:
return 100u;
}
return 0u; /* only reached with an invalid mode value */
}

Now a colleague adds LED_BREATHE to the enum and forgets this function. Because the switch has no default, -Wswitch (part of -Wall) reports:

warning: enumeration value 'LED_BREATHE' not handled in switch [-Wswitch]

If the switch had a default, -Wswitch would stay silent, and the new mode would silently get the default behavior. -Wswitch-enum (not in -Wall or -Wextra) warns about unhandled enumerators even when a default is present.

That gives two defensible styles:

  1. No default in enum switches, handle invalid values after the switch (as the trailing return 0u; does), and rely on -Wswitch. The compiler then tells you about every new enumerator.
  2. Always a default, and enable -Wswitch-enum so that a default does not hide missing cases.

Either way, do not assume a variable of enum type holds a valid enumerator. In C an enum variable is just an integer; corrupted RAM, an uninitialized struct or a value received over a wire can put anything in it. Firmware should handle the impossible value explicitly, usually by logging and entering a safe state, rather than falling off the end.

The most important firmware use of switch is the state machine: a variable remembers where you are, and each event is handled according to the current state. Here is a receiver for frames of the form 0x7E, length, payload:

#include <stdint.h>
#define FRAME_START 0x7Eu
#define PAYLOAD_MAX 32u
typedef enum { RX_IDLE, RX_LENGTH, RX_PAYLOAD } rx_state_t;
void frame_received(const uint8_t *data, uint8_t len); /* implemented elsewhere */
static rx_state_t rx_state = RX_IDLE;
static uint8_t rx_len;
static uint8_t rx_count;
static uint8_t rx_buf[PAYLOAD_MAX];
void rx_byte(uint8_t b) /* called once per received byte */
{
switch (rx_state) {
case RX_IDLE:
if (b == FRAME_START) {
rx_state = RX_LENGTH;
}
break;
case RX_LENGTH:
if (b == 0u || b > PAYLOAD_MAX) {
rx_state = RX_IDLE; /* invalid length: resynchronize */
} else {
rx_len = b;
rx_count = 0u;
rx_state = RX_PAYLOAD;
}
break;
case RX_PAYLOAD:
rx_buf[rx_count++] = b;
if (rx_count == rx_len) {
frame_received(rx_buf, rx_len);
rx_state = RX_IDLE;
}
break;
}
}

Each call does a small amount of work and returns: the function never waits for the next byte. That property, called run to completion, is what lets one main loop service a UART, a button and a sensor at the same time. Notice also the defensive length check: without it, a corrupted length byte would let rx_count run past the end of rx_buf. State machines get a full treatment, including table-driven designs, in State machines and event-driven firmware.

C has three loop statements.

while (condition) { /* test first: body runs zero or more times */
body();
}
do { /* test last: body runs at least once */
body();
} while (condition);
for (init; condition; step) {
body();
}

The for loop is shorthand for a while loop with the initialization, test and step on one line, which keeps the loop’s control in one place. Any of the three parts may be empty, and an empty condition means “true”. A variable declared in init (allowed since C99) is scoped to the loop.

do…while is the right tool when the body must run before the test makes sense, such as “send, then check whether to retry”. It is also the basis of the do { ... } while (0) macro idiom described in The preprocessor, used well.

#include <stddef.h>
#include <stdint.h>
static const uint16_t thresholds[] = { 100, 250, 500, 1000 };
size_t level_for(uint16_t reading)
{
size_t i;
for (i = 0; i < sizeof thresholds / sizeof thresholds[0]; i++) {
if (reading < thresholds[i]) {
break;
}
}
return i; /* 0 to 4: 4 means above every threshold */
}

Use a half-open range: start at 0, test with < against the count, never with <=. The element count is computed from sizeof, so adding a threshold cannot desynchronize the loop. Arrays, strings, and tables wraps this in an ARRAY_SIZE macro and explains when the trick does not work. Declaring i outside the loop here is deliberate: its final value is the result.

Counting down with an unsigned index has a famous trap:

for (uint32_t i = n - 1; i >= 0; i--) { /* bug: never terminates */
process(v[i]);
}

An unsigned value is always >= 0. When i is 0, i-- wraps it to 4294967295 and the loop carries on reading far outside the array. -Wextra catches it (comparison of unsigned expression in '>= 0' is always true). If n is 0, n - 1 is already 4294967295 before the first iteration. The idiomatic correct version tests before decrementing:

for (uint32_t i = n; i-- > 0; ) {
process(v[i]); /* sees n-1, n-2, ..., 0; runs zero times if n is 0 */
}

The condition i-- > 0 compares the old value, then decrements, so the body sees n - 1 down to 0 and the loop ends after the body has seen 0.

Polling a hardware flag in an unbounded loop is the most common way firmware hangs in the field. If the peripheral is not clocked, has faulted, or the external chip is unplugged, the flag never changes and the device locks up until the watchdog bites, if there is one. Every wait needs a bound:

#include <stdbool.h>
#include <stdint.h>
#define USART2_SR (*(volatile uint32_t *)0x40004400u)
#define USART2_DR (*(volatile uint32_t *)0x40004404u)
#define USART_SR_TXE (1u << 7) /* transmit data register empty */
uint32_t millis(void); /* 1 ms tick counter */
bool uart2_putc(uint8_t c, uint32_t timeout_ms)
{
const uint32_t start = millis();
while ((USART2_SR & USART_SR_TXE) == 0u) {
if ((uint32_t)(millis() - start) >= timeout_ms) {
return false; /* peripheral never became ready */
}
}
USART2_DR = c;
return true;
}

The loop condition reads the status register (through a volatile access, so it really is re-read every iteration). The elapsed time is computed with unsigned subtraction, which stays correct when the millisecond counter wraps, as you learned in Types, sizes, and how numbers are stored. On timeout the function reports failure and lets the caller decide what to do. Clocks, reset, and timing builds the millis() tick this relies on. Where no time base exists yet (very early in startup), a bounded iteration count is the fallback: for (uint32_t n = 0; n < 100000u; n++) { if (ready()) { break; } }, followed by a check of whether the loop ended early.

break leaves the innermost enclosing loop or switch. continue skips to the next iteration of the innermost loop: in a for loop it jumps to the step expression, in while and do loops to the test.

The “or switch” is the trap. A break inside a switch inside a loop only leaves the switch:

for (;;) {
uint8_t cmd = next_command();
switch (cmd) {
case CMD_QUIT:
break; /* leaves the switch, not the loop */
default:
execute(cmd);
break;
}
}

This loop never ends. A misplaced break of this kind (one that exited a switch earlier than the author intended) was the root cause of the AT&T long-distance network collapse in January 1990. To leave the loop from inside the switch, use a flag in the loop condition, return from the function, or restructure so the switch is in its own function that returns a decision.

continue is useful for skipping invalid items at the top of a loop body without nesting the rest:

for (uint32_t ch = 0; ch < CHANNEL_COUNT; ch++) {
if (!channel_enabled(ch)) {
continue;
}
sample_channel(ch);
}

goto label; jumps to a label in the same function. Unrestricted goto produces unreadable code, and most coding standards discourage it. One pattern is nevertheless accepted almost everywhere, including in the Linux kernel: forward jumps to cleanup code at the end of a function.

Consider an initialization that acquires several resources, any of which can fail, where everything already acquired must be released in reverse order:

#include <stdbool.h>
typedef enum { ERR_OK = 0, ERR_CLOCK, ERR_UART, ERR_DMA, ERR_SENSOR } err_t;
bool clock_init(void);
bool uart_init(void);
void uart_deinit(void);
bool dma_init(void);
void dma_deinit(void);
bool sensor_init(void);
err_t system_init(void)
{
err_t err;
if (!clock_init()) {
return ERR_CLOCK; /* nothing acquired yet, nothing to undo */
}
if (!uart_init()) {
err = ERR_UART;
goto fail_uart;
}
if (!dma_init()) {
err = ERR_DMA;
goto fail_dma;
}
if (!sensor_init()) {
err = ERR_SENSOR;
goto fail_sensor;
}
return ERR_OK;
fail_sensor:
dma_deinit();
fail_dma:
uart_deinit();
fail_uart:
return err;
}

The labels are in reverse order of acquisition and the cleanup code deliberately falls through from one label to the next: failing at the sensor undoes the DMA and then the UART; failing at the DMA undoes only the UART. The success path reads straight down with no nesting. The alternatives are worse: nested ifs four levels deep with the cleanup duplicated in each else, or a flag variable per resource.

The rules that keep goto acceptable:

  • Jump forward only, to labels at the end of the function.
  • Use it only for cleanup and error exits, never for loops.
  • Never jump into a block or past the declaration of a variable that the target code uses. (Jumping into the scope of a variable-length array is a compile error; jumping past an ordinary initialized variable compiles and leaves it uninitialized.)

Recall from Variables, scope, lifetime, and storage classes that labels have function scope, which is what allows a goto to reach a label that appears later in the function.

A bare-metal firmware’s main never returns, so it ends in an infinite loop. The classic structure is the superloop:

void board_init(void);
void button_poll(void);
void led_update(void);
void uart_process(void);
int main(void)
{
board_init();
for (;;) {
button_poll(); /* each task does a little work and returns */
led_update();
uart_process();
}
}

for (;;) and while (1) generate identical code. for (;;) is often preferred because some static analyzers and older compilers warn about a constant condition in while (1). Each function called from the loop must return quickly, which is why rx_byte above was written as a state machine rather than a function that waits for a whole frame. State machines and event-driven firmware turns this into a cooperative scheduler, and Power management adds sleeping in the loop when there is no work.

You will also write infinite loops that do nothing: a default interrupt handler or a fatal error trap that stops execution so a debugger can inspect the state.

void Default_Handler(void)
{
for (;;) {
/* unexpected interrupt: halt here for the debugger */
}
}

A detail of the standard matters here. C11 allows the compiler to assume that a loop terminates if its controlling expression is not a constant expression and its body performs no I/O, no volatile accesses and no synchronization. A loop like while (x != 0) {} on a non-volatile x may therefore be removed entirely. Loops with a constant condition, such as for (;;) and while (1), are exempt and are guaranteed to spin. (C++ has a stricter rule, which is one of several reasons C and C++ firmware occasionally behave differently.)

Two schools exist. Single exit says each function has exactly one return, at the end. It made sense when cleanup had to happen before every return and is still recommended (as an advisory rule) by MISRA C. Early return says to reject invalid inputs at the top with guard clauses and return immediately, leaving the main logic unindented.

In practice, firmware teams increasingly use guard clauses for validation and the goto cleanup pattern when there are resources to release, which gives you the readability of early returns and the single cleanup path of single exit. If your project follows MISRA, follow its rule consistently; otherwise prefer whichever makes the success path easiest to read.

Code that walks off the right edge of the screen hides its logic. Compare:

bool packet_handle(const uint8_t *buf, uint32_t len)
{
if (buf != NULL) {
if (len >= HEADER_LEN) {
if (buf[0] == PACKET_MAGIC) {
if (checksum_ok(buf, len)) {
packet_dispatch(buf, len);
return true;
}
}
}
}
return false;
}

with the guard-clause version:

bool packet_handle(const uint8_t *buf, uint32_t len)
{
if (buf == NULL || len < HEADER_LEN) {
return false;
}
if (buf[0] != PACKET_MAGIC) {
return false;
}
if (!checksum_ok(buf, len)) {
return false;
}
packet_dispatch(buf, len);
return true;
}

Each precondition is checked once, in order, and the real work sits at the left margin. The short-circuit || in the first test guarantees len is checked only if buf is valid (here both checks are cheap, but the order would matter if the second test read buf[0]). Other tools against nesting: extract the inner part of a loop into a function, replace long if chains on a value with a switch or a lookup table, and replace a switch that only maps values to values with a const array.

On a Cortex-M4 every control structure becomes compares and branches. Understanding the shape helps you read disassembly when debugging and reason about timing.

An if becomes a compare (cmp, or tst for bit tests) followed by a conditional branch around the body. For short bodies, Thumb-2 can avoid the branch entirely with an IT (if-then) block that makes up to four following instructions conditional, as in cmp r0, r1 then it gt then movgt r0, r1. Short-circuit && and || become chains of such branches.

Loops are usually rotated: the test is moved to the bottom so that each iteration costs one backward branch. Here is a summing loop and the shape GCC produces at -O2 (details vary by version):

#include <stdint.h>
uint32_t sum(const uint32_t *v, uint32_t n)
{
uint32_t s = 0;
for (uint32_t i = 0; i < n; i++) {
s += v[i];
}
return s;
}
sum:
mov r3, r0 @ r3 = v
cbz r1, .Lempty @ n == 0: skip the loop entirely
add r1, r3, r1, lsl #2 @ r1 = v + n, the end pointer
movs r0, #0 @ s = 0
.Lloop:
ldr r2, [r3], #4 @ r2 = *v, then v advances by 4 bytes
cmp r3, r1
add r0, r0, r2 @ s += r2
bne .Lloop @ one backward branch per iteration
bx lr
.Lempty:
mov r0, r1 @ return 0
bx lr

The compiler replaced the index i with a pointer that walks to an end pointer, folded the pointer increment into the load ([r3], #4 is post-indexed addressing) and checked n == 0 once, up front. Each iteration is four instructions.

The Cortex-M4 has a three-stage pipeline and no dynamic branch predictor. A taken branch costs 1 cycle plus a pipeline refill of 1 to 3 cycles; a branch not taken costs 1. With flash wait states at 168 MHz, a refill can cost more when the target is not already in the prefetch buffer or the flash accelerator’s cache. This is why the compiler likes IT blocks for short conditionals and why hot loops benefit from being short. You will measure this yourself in Performance and code size optimization.

The compiler chooses among several strategies depending on how many cases there are and how their values are distributed:

  • A few cases: a chain of compares and branches, just like if/else if.
  • Many sparse values (say 1, 100, 5000, 70000): a binary search tree of compares.
  • Dense values (0 to 4, or 10 to 30): a jump table.
  • Cases that only produce values: a lookup table in .rodata and a single load (GCC calls this switch conversion).

The jump table is worth seeing once:

#include <stdint.h>
void cmd_reset(void);
void cmd_start(void);
void cmd_stop(void);
void cmd_status(void);
void cmd_config(void);
void cmd_unknown(void);
void dispatch(uint32_t cmd)
{
switch (cmd) {
case 0: cmd_reset(); break;
case 1: cmd_start(); break;
case 2: cmd_stop(); break;
case 3: cmd_status(); break;
case 4: cmd_config(); break;
default: cmd_unknown(); break;
}
}
dispatch:
cmp r0, #4
bhi .Ldefault @ unsigned: cmd > 4 goes to default
tbb [pc, r0] @ table branch byte: jump by table[cmd] * 2
.Ltable:
.byte (.Lc0-.Ltable)/2
.byte (.Lc1-.Ltable)/2
.byte (.Lc2-.Ltable)/2
.byte (.Lc3-.Ltable)/2
.byte (.Lc4-.Ltable)/2
.Lc0: b cmd_reset @ tail calls: plain branch, the callee returns for us
.Lc1: b cmd_start
.Lc2: b cmd_stop
.Lc3: b cmd_status
.Lc4: b cmd_config
.Ldefault:
b cmd_unknown

One unsigned compare handles the range check (any value above 4, including what would be negative numbers if cmd were signed, goes to default), then tbb reads a byte offset from the table that immediately follows it and branches. The time is the same for every case. tbh is the halfword version for larger tables. The calls became plain branches because each was the last thing the function did, a tail-call optimization you will meet again in Functions and the calling convention.

In 1983 Tom Duff unrolled a copy loop by interleaving a switch with a do…while, which C permits because case labels may appear anywhere inside the switch body, even inside a nested loop:

#include <stddef.h>
#include <stdint.h>
void copy_bytes(uint8_t *to, const uint8_t *from, size_t count)
{
if (count == 0u) {
return;
}
size_t n = (count + 7u) / 8u; /* number of passes through the loop */
switch (count % 8u) {
case 0: do { *to++ = *from++;
case 7: *to++ = *from++;
case 6: *to++ = *from++;
case 5: *to++ = *from++;
case 4: *to++ = *from++;
case 3: *to++ = *from++;
case 2: *to++ = *from++;
case 1: *to++ = *from++;
} while (--n > 0u);
}
}

The switch jumps into the middle of the unrolled loop to handle the remainder, then the loop does full groups of eight. It is legal C, and it is a good test of whether you truly understand that case labels are just jump targets. Do not write it: modern compilers unroll loops themselves when it helps, memcpy is faster still, and with -Wextra this function produces an implicit-fallthrough warning on every case. (Duff’s original wrote every byte to the same memory-mapped output register, without incrementing to.)

  • Body without braces: a second indented line is not part of the if. Always use braces; keep -Wall for -Wmisleading-indentation.
  • Missing break: execution falls into the next case. Let -Wextra find it and mark intentional fallthrough.
  • break inside a switch meant to leave a loop: it only leaves the switch. Use a flag, return, or a separate function.
  • default hiding a new enumerator: omit default in enum switches and rely on -Wswitch, or enable -Wswitch-enum.
  • Trusting an enum variable to hold a valid value: it is an integer. Handle out-of-range values explicitly.
  • Unsigned countdown with i >= 0: never terminates. Write for (i = n; i-- > 0; ).
  • Polling a flag without a timeout: hangs forever when hardware misbehaves. Bound every wait and report failure.
  • Declaring a variable directly after a case label: a compile error before C23. Wrap the case body in braces.
  • Deeply nested validation: hides the logic. Use guard clauses and the goto cleanup pattern.
  • if (x = 5): assigns. Compare with ==; heed -Wparentheses.
  1. Write void print_reverse(const int32_t *v, uint32_t n) that prints the elements in reverse order using an unsigned index. Test it with n equal to 0, 1 and 5. Then rewrite it with the buggy i >= 0 form, compile with -Wall -Wextra, and record the warning.
  2. Copy led_period_ms and its enum into a file. Add LED_BREATHE to the enum and compile with -Wall: note the warning. Add a default: case and confirm the warning disappears. Then add -Wswitch-enum and confirm it comes back. Decide which style you prefer and write two sentences justifying it.
  3. Compile dispatch with arm-none-eabi-gcc -std=c11 -O2 -mcpu=cortex-m4 -mthumb -S and find the tbb instruction and its table. Then change the case values to 0, 10, 200, 3000 and 40000 and describe the structure of the new code. Finally, change the function so each case returns a different constant instead of calling a function, and find the lookup table in .rodata.
  4. Extend the rx_byte state machine so each frame ends with a checksum byte equal to the XOR of all payload bytes, and frames with a bad checksum are dropped. Test it on your host by feeding it a byte array containing a valid frame, a frame with a corrupted payload byte, garbage, and another valid frame. frame_received should be called exactly twice.
  5. Implement system_init on your host with stub functions that print their names and return success or failure according to a global “fail at step N” variable. For each N from 0 to 4, run it and check that exactly the right deinit functions are called, in reverse order. Then rewrite it without goto and compare the two versions for length and clarity.

Question 1:What does this print when x is 1? switch (x) { case 1: printf("a"); case 2: printf("b"); break; case 3: printf("c"); }

Question 2:Inside for (;;) { switch (cmd) { case STOP: break; } }, what does the break do?

Question 3:What happens with for (uint32_t i = 9; i >= 0; i--) { work(i); }?

Question 4:An enum switch has a default: case. A new enumerator is added and not handled. Which flag reports it?

Question 5:At -O2, how does GCC typically compile a switch on uint32_t cmd with cases 0 to 7, each calling a different function, for a Cortex-M4?

Question 6:Why is goto to cleanup labels at the end of a function widely accepted in firmware?

  • Put braces on every if, else and loop body. Indentation does not create blocks, and -Wmisleading-indentation exists because people keep learning this the hard way.
  • switch falls through by default: end each case with break and mark intentional fallthrough. break inside a switch leaves only the switch.
  • For enum switches, either omit default and rely on -Wswitch, or use -Wswitch-enum. Always handle the out-of-range value an enum variable can still contain.
  • Every wait on hardware needs a timeout. Unbounded polling is how firmware hangs in the field.
  • Count down with unsigned indexes as for (i = n; i-- > 0; ).
  • goto forward to cleanup labels in reverse acquisition order is the accepted C idiom for error handling with multiple resources.
  • The Cortex-M4 has no branch predictor: taken branches cost pipeline refills. Dense switch statements become constant-time tbb/tbh jump tables.

Next: Functions and the calling convention, where you see exactly how arguments travel in registers, what a stack frame looks like, and which GCC attributes firmware relies on.