Stage 1

C Foundations

The ground every later lesson stands on: how C stores values, what the compiler silently converts, and how control flow and functions actually work. If you only read one new page, read this one.

1.1 Types: size is a property of the target, not the language

C guarantees minimum ranges, not exact sizes. int is at least 16 bits; on most 32- and 64-bit targets it happens to be 32 bits. long is 4 bytes on ILP32 (most 32-bit embedded ABIs) and on LLP64 (Windows 64-bit), and 8 bytes on LP64 (Linux/macOS 64-bit). Code that assumes a size breaks silently when it moves to a new MCU or compiler.

fixed-width.c — say what you mean
#include <stdint.h>
#include <stdbool.h>

int32_t  temperature_mdeg;  /* exactly 32 bits, signed */
uint16_t adc_counts;        /* exactly 16 bits, unsigned */
intptr_t address_as_int;    /* wide enough to hold a pointer */
bool     fault;             /* true / false, from stdbool.h */
✔

Embedded habit: use <stdint.h> types (int32_t, uint8_t, …) for anything whose width matters — registers, protocols, packed data. Reserve plain int for loop counters and local arithmetic where width does not matter.

📏 Type-size explorer

1.2 Integer promotion & conversion: the silent rewrites

Before most operators run, C promotes small integer types (char, short, bit-fields, _Bool) to int, and then applies the usual arithmetic conversions to make both operands a common type. The conversion rules, not the operator, decide the result.

  • Promotion: uint8_t a = 200, b = 100; a + b is computed in int → 300. The wrap to 44 happens only if you store it back into a uint8_t.
  • Signed → unsigned: in a mixed comparison, the signed operand converts to unsigned. -1 < 1U is false.
  • Truncation: assigning a wider value to a narrower type keeps the low bits — silently. uint8_t x = 300; gives 44, no warning by default.
  • Signed overflow is undefined behavior. Unsigned overflow wraps (modulo 2n). Never rely on signed wrap-around.

🧪 Promotion predictor

⚠︎

Compiler flags that catch these: -Wall -Wextra -Wconversion -Wsign-conversion -Wsign-compare. Turn them on for every new project; the noise at first is exactly the bugs you want to see.

1.3 Operators & precedence: the traps that survive code review

precedence.c — three classics
if (flags & MASK == READY)  /* == binds tighter than & ! */
    /* parses as: flags & (MASK == READY) — almost never what you meant */

if (x = get_value() != 0)       /* != binds tighter than = */
    /* assigns the comparison result (0/1), not the value */

int y = a << 2 + 1;                 /* + binds tighter than << */
    /* parses as a << 3, not (a << 2) + 1 */
fixed — parentheses are free
if ((flags & MASK) == READY) { ... }

int v = get_value();
if ((x = v) != 0) { ... }      /* or split into two statements */

int y = (a << 2) + 1;
  • = vs ==: if (x = 0) compiles and is always false. Compilers warn with -Wall; write the constant first (0 == x) only if your team style demands it.
  • Short-circuit: && and || stop as soon as the result is known. if (p && p->ready) is safe; if (p->ready && p) is not.
  • ++ / --: i++ yields the old value, ++i the new one. Never use the same variable twice with side effects in one expression: a[i++] = i++; is undefined behavior.
  • Ternary: cond ? a : b evaluates exactly one branch — handy for const initialisers: const int limit = (mode == FAST) ? 1000 : 100;

1.4 Control flow: write the exit condition first

control.c
/* for — when you know the count */
for (int i = 0; i < len; i++) { sum += buf[i]; }

/* while — when you wait for a condition */
while (!uart_rx_ready() && timeout-- > 0) { /* poll */ }
if (timeout == 0) { return ERR_TIMEOUT; }

/* switch — one value, many cases. break is load-bearing. */
switch (cmd) {
    case CMD_START: start(); break;
    case CMD_STOP:  stop();  break;
    default:        /* unknown command: log, do not ignore */ break;
}
  • Missing break falls through to the next case. If fall-through is deliberate, say so in a comment — and compile with -Wimplicit-fallthrough.
  • for loop scope: for (int i = 0; …) (C99+) keeps i inside the loop. Declaring it outside is only needed when you read it afterwards.
  • Early return beats deep nesting. Validate inputs at the top and return; the happy path stays at one indent level and stays readable.
  • goto has one legitimate home: the single cleanup label at the end of a function that acquires several resources. Everywhere else, restructure.

1.5 Functions, scope & storage: where variables live

scope.c
static int calls = 0;   /* file scope, internal linkage: this file only */

int next_id(void) {
    static int seq = 0; /* static local: keeps its value between calls */
    return ++seq;
}

int clamp(int v, int lo, int hi) {   /* parameters are locals */
    if (v < lo) return lo;         /* return exits immediately */
    if (v > hi) return hi;
    return v;
}
  • Automatic (local) variables live on the stack and die at the closing brace. Returning &local is a dangling-pointer bug — see Stage 3.
  • static locals keep their value between calls but are still only visible inside the function. They are not thread-safe and they hide state — use sparingly.
  • static at file scope means “private to this translation unit”. It is the C way to hide a helper function or module state.
  • One definition rule (informal): a function or global is defined once (in one .c file) and declared in a header wherever it is used. See Stage 10 for headers and linkage.
  • Pass by value: C always copies arguments. To let a function change the caller’s variable, pass a pointer: void reset(int *p) { *p = 0; } — the pointer itself is still copied.
💡

Try it: compile every snippet on this page with gcc -std=c11 -Wall -Wextra -Wconversion -Wsign-compare -Wimplicit-fallthrough and read the warnings before running anything. The warnings are the lesson.

1.6 Arrays & strings: the length is your job

strings.c
char name[16] = "sensor";   /* 7 chars + '\0', rest zero-filled */
char raw[4]   = { 'a', 'b', 'c', 'd' };  /* NOT a string: no terminator! */

size_t n = sizeof name / sizeof name[0];  /* 16 — works only in this scope */
size_t len = strlen(name);                /* 6 — counts until '\0' */
  • C arrays do not know their length. Pass the length alongside the pointer, or agree on a terminator ('\0' for strings). An array parameter void f(int a[]) is really void f(int *a) — sizeof a inside f gives the pointer size.
  • Strings are char arrays ending in '\0'. Every string function (strcpy, strcat, sprintf) trusts that terminator. Prefer the bounded forms: snprintf(dst, sizeof dst, …), strncpy + explicit terminator.
  • Initialisers: int a[4] = { 1, 2 }; zero-fills the rest. Designated initialisers make intent explicit: int a[4] = { [0]=1, [3]=9 };
  • Off-by-one: valid indices for char buf[N] are 0 … N-1. buf[N] is out of bounds — the single most common C bug.