C Language Essentials for Embedded
C is the lingua franca of embedded systems. Learn the parts of C that matter most on microcontrollers: fixed-width types from <stdint.h>, integer promotion and overflow, signed vs unsigned, operators and bitwise manipulation, pointers and arrays, structs, unions, enums, padding and alignment, the preprocessor, storage classes (static, extern, const, volatile) and the undefined behaviours that silently break firmware.
C gives you direct access to memory and hardware with almost no runtime overhead — exactly what a 64 KB microcontroller needs. The price is that C trusts you completely: it will happily overflow a buffer, read an uninitialised variable or truncate a value without warning.
This lesson focuses on the C you will write every day in firmware and the traps that cause most embedded bugs. Later lessons build on it for register access, drivers and RTOS code.
An analogy
C is a manual-transmission race car: no automatic safety features, but total control. You can shift at exactly the right moment — or grind the gears if you are careless.
Use exact-width types
int is 16 bits on some MCUs (AVR, MSP430) and 32 bits on ARM. Registers and protocols need exact widths, so embedded code uses uint8_t, int16_t, uint32_t from <stdint.h> and bool from <stdbool.h>. Use size_t for sizes and indexes.
Pointers and arrays
A pointer holds an address. *p reads/writes the object it points to; p + 1 advances by sizeof(*p) bytes. Arrays decay to pointers when passed to functions, so always pass the length too. Pointers are how C reaches hardware: a register is just a memory address cast to a pointer.
- uint8_t buf[16]; uint8_t *p = buf; // p[3] == *(p + 3)
- void fill(uint8_t *dst, size_t n, uint8_t v);
- Never return a pointer to a local (stack) variable
Storage classes and qualifiers
Where a variable lives and how it may change:
- static inside a function: keeps its value between calls (stored in .data/.bss, not stack)
- static at file scope: private to that .c file (module encapsulation)
- extern: declared here, defined in another file
- const: read-only — const tables live in Flash, saving RAM
- volatile: may change outside the program's view (hardware register, ISR-shared flag) — never cache it
Structs, unions, enums and the preprocessor
Structs group related data (a sensor's config); unions overlay types on the same bytes (packet parsing); enums name states and error codes; #define and inline functions create constants and small helpers. Use include guards (#ifndef FOO_H) or #pragma once in headers, and prefer static inline functions over complex macros.
Undefined behaviour and coding standards
Signed overflow, out-of-bounds access, using uninitialised variables, shifting by ≥ the type width, and misaligned or aliased pointer accesses are undefined behaviour — the optimiser may assume they never happen and generate surprising code. MISRA C and CERT C restrict the language to a safer subset; static analysers (cppcheck, PC-lint, Polyspace) and -Wall -Wextra -Werror catch many issues before hardware testing.
Formulas
- Unsigned range:
uintN_t: 0 … 2ᴺ − 1 (uint8_t 0…255, uint16_t 0…65 535) - Signed range (two's complement):
intN_t: −2ᴺ⁻¹ … 2ᴺ⁻¹ − 1 (int8_t −128…127) - Set / clear / toggle / test bit n:
x |= (1u << n); x &= ~(1u << n); x ^= (1u << n); (x >> n) & 1u - Write a k-bit field at position p:
x = (x & ~(MASK << p)) | ((val & MASK) << p), MASK = (1u << k) − 1 - Unsigned wrap-around:
(a + b) mod 2ᴺ — defined; signed overflow is UNDEFINED - Struct size:
Σ member sizes + padding to satisfy each member's alignment
Worked example: Unsigned wrap-around
Given: uint8_t c = 250; c += 10;
Find: c.
- 250 + 10 = 260
- 260 mod 256 = 4
Answer: c = 4 (defined behaviour for unsigned)
Worked example: Extracting a field
Given: status = 0x3A5, bits [7:4] hold a 4-bit code
Find: The code.
- (0x3A5 >> 4) = 0x3A
- 0x3A & 0xF = 0xA
Answer: code = 10
Worked example: Struct padding
Given: struct { uint8_t a; uint32_t b; uint8_t c; }
Find: sizeof on Cortex-M.
- a at 0, 3 bytes pad, b at 4–7, c at 8
- Pad to multiple of 4 → 12
Answer: 12 bytes (8 if reordered b, a, c)
Types, promotion, bit fields and padding
#include <stdio.h>
#include <stdint.h>
struct bad { uint8_t a; uint32_t b; uint8_t c; }; /* 1 + 3 pad + 4 + 1 + 3 pad */
struct good { uint32_t b; uint8_t a; uint8_t c; }; /* 4 + 1 + 1 + 2 pad */
int main(void)
{
uint8_t x = 200, y = 100;
printf("x + y as int = %d\n", x + y); /* promoted to int: 300 */
uint8_t z = x + y; /* truncated mod 256 */
printf("stored in uint8 = %u\n", z);
uint32_t reg = 0;
reg |= (1u << 5); /* set bit 5 */
printf("set bit 5 = 0x%08X\n", reg);
reg = (reg & ~(0x7u << 8)) | (0x5u << 8); /* write 3-bit field */
printf("field[10:8] = 5 = 0x%08X\n", reg);
printf("bit 5 is %s\n", (reg & (1u << 5)) ? "set" : "clear");
reg &= ~(1u << 5); /* clear bit 5 */
printf("clear bit 5 = 0x%08X\n", reg);
printf("sizeof bad = %u good = %u\n",
(unsigned)sizeof(struct bad), (unsigned)sizeof(struct good));
printf("-1 < 1u is %s\n", (-1 < 1u) ? "true" : "false"); /* surprise! */
return 0;
}
/* Output (32-bit Cortex-M, also x86-64):
x + y as int = 300
stored in uint8 = 44
set bit 5 = 0x00000020
field[10:8] = 5 = 0x00000520
bit 5 is set
clear bit 5 = 0x00000500
sizeof bad = 12 good = 8
-1 < 1u is false */Arithmetic on small types is done in int, then truncated on assignment. Comparing signed −1 with an unsigned value converts −1 to 0xFFFFFFFF — a classic bug. Ordering struct members from largest to smallest removes padding (12 → 8 bytes).
Common mistakes
- Wrong: 1 << 31 on a signed int. — Right: Use 1u << 31 (UB for signed).
- Wrong: Comparing signed and unsigned values. — Right: Cast explicitly or keep types consistent; enable -Wsign-compare.
- Wrong: Assuming sizeof(int) == 4 everywhere. — Right: Use <stdint.h> exact-width types.
- Wrong: Returning the address of a local array. — Right: Pass a caller-owned buffer or use static storage.
Applications
- Register programming: Every peripheral is configured with bit and field operations.
- Protocol parsing: Structs, unions and shifts decode packets from UART/CAN.
- Memory-efficient data: const lookup tables in Flash, bit-packed flags.
- Safety-critical code: MISRA-C compliant subsets in automotive and medical devices.
Key takeaways
- Use <stdint.h> types; know promotion rules.
- Master set/clear/toggle/test and field writes.
- const → Flash; volatile → hardware/ISR data.
- Avoid undefined behaviour; follow MISRA/CERT and enable warnings.
Going further
- Strict aliasing: Accessing an object through an incompatible pointer type is UB; use memcpy or unions carefully.
- Bit-fields in structs: Layout is implementation-defined — avoid them for registers and wire formats.
- C11/C17 features: _Static_assert, stdatomic.h and _Alignas help embedded correctness.