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.
#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 + bis computed inint→ 300. The wrap to 44 happens only if you store it back into auint8_t. - Signed → unsigned: in a mixed comparison, the signed operand converts to unsigned.
-1 < 1Uis 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
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 */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,++ithe new one. Never use the same variable twice with side effects in one expression:a[i++] = i++;is undefined behavior.- Ternary:
cond ? a : bevaluates exactly one branch — handy forconstinitialisers:const int limit = (mode == FAST) ? 1000 : 100;
1.4 Control flow: write the exit condition first
/* 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
breakfalls through to the next case. If fall-through is deliberate, say so in a comment — and compile with-Wimplicit-fallthrough. forloop scope:for (int i = 0; …)(C99+) keepsiinside 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.
gotohas 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
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
&localis a dangling-pointer bug — see Stage 3. staticlocals keep their value between calls but are still only visible inside the function. They are not thread-safe and they hide state — use sparingly.staticat 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
.cfile) 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
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 parametervoid f(int a[])is reallyvoid f(int *a)—sizeof ainsidefgives the pointer size. - Strings are
chararrays 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]are0 … N-1.buf[N]is out of bounds — the single most common C bug.