C Programming Fundamentals
Embedded C is C programming applied to microcontrollers and embedded systems. It is the language that is used to develop software for hardware.

#Introduction to Embedded Systems and Embedded C#
Foundation notes: the stuff you should be comfortable with before diving into hardware-specific Embedded C.
Table of Contents
- What is an Embedded System?
- Embedded Systems vs General-Purpose Computers
- Embedded C vs Standard C
- Firmware, Hardware and Software
- Microcontrollers vs Microprocessors
- Harvard vs Von Neumann Architecture
- Bare-Metal Programming
- Cross-Compilation and Toolchains
- Compiler, Assembler, Linker and Loader
- IDE, Debugger, Flashing and Deployment
- Quick Revision Cheat Sheet
1. What is an Embedded System?
The simple idea
An embedded system is a small computer system built into a larger device to do one specific job (or a small set of jobs). You usually don't think of it as a "computer". You just think of it as the microwave, the washing machine, or the car's airbag.
Think of it like this: a laptop is a Swiss Army knife, an embedded system is a dedicated bottle opener. It does one thing, and it does it reliably.
Core building blocks
Inputs ---> [ Processor + Memory + Software ] ---> Outputs
(sensors, (the "brain" of the system) (motors, LEDs,
buttons) displays, buzzers)
- Processor: microcontroller or microprocessor that executes instructions.
- Memory: stores the program (Flash/ROM) and working data (RAM).
- Input/Output peripherals: GPIO, timers, ADC, UART, etc.
- Software (firmware): the code that defines behaviour.
- Power supply: often battery or a tightly regulated source.
Everyday examples
| Domain | Example |
|---|---|
| Home | Microwave, washing machine, TV remote, smart bulb |
| Automotive | ABS, airbag controller, engine control unit (ECU) |
| Medical | Pacemaker, glucose monitor, digital thermometer |
| Industrial | PLCs, robotic arms, motor controllers |
| Consumer | Smartwatch, fitness band, digital camera |
Key characteristics
- Task-specific: designed for a dedicated purpose.
- Resource-constrained: limited memory, CPU speed, and power.
- Real-time behaviour: often must respond within a guaranteed time (e.g., an airbag must fire in milliseconds).
- Reliable and long-running: expected to run for years without a reboot.
- Low power: especially for battery devices.
- Cost-sensitive: when you make millions of units, saving a few cents matters.
- Tightly coupled with hardware: software is written for specific hardware.
Classification (quick view)
- By performance: small-scale (8-bit MCU), medium-scale (16/32-bit), sophisticated (multi-core, SoC).
- By timing: real-time (hard / soft) vs non-real-time.
- Hard real-time: missing a deadline = failure (airbag, pacemaker).
- Soft real-time: missing a deadline = degraded quality (video playback glitch).
2. Embedded Systems vs General-Purpose Computers
| Feature | Embedded System | General-Purpose Computer |
|---|---|---|
| Purpose | Dedicated, specific task | Many different tasks |
| Software | Fixed firmware, rarely changed | Installable apps, OS updates |
| Operating system | None (bare-metal), RTOS, or lightweight OS | Full OS (Windows, Linux, macOS) |
| Resources | Very limited (KBs to a few MBs of memory) | Abundant (GBs of RAM, TBs of storage) |
| User interface | Minimal (buttons, LEDs, small display) or none | Rich (keyboard, mouse, big screen) |
| Timing | Often real-time critical | Usually best-effort |
| Power use | Low, sometimes microwatts in sleep | High |
| Cost | Low per unit | Higher |
| Reliability | Must run for years unattended | Restarts are acceptable |
| Development | Cross-compiled on a PC, flashed to target | Developed and run on the same machine |
Why the difference matters to programmers
In a PC, you can casually use malloc, big libraries, and lots of RAM. In an embedded system you count every byte, avoid unpredictable behaviour, and often talk directly to hardware registers.
3. Embedded C vs Standard C
First, a clarification
Embedded C is not a different language. It's the same C language used in a different environment, with extra practices and (sometimes) compiler extensions for hardware access. Think of it as "C with a hardware-aware mindset".
Comparison
| Aspect | Standard C (desktop) | Embedded C |
|---|---|---|
| Runs on | PC with an OS | Microcontroller, often no OS |
| Output | printf to a console | LEDs, displays, UART, registers |
| Memory | Plenty, virtual memory | Tiny, fixed, no virtual memory |
| Dynamic memory | malloc/free used freely | Usually avoided (fragmentation, unpredictability) |
| Hardware access | Through the OS | Directly via registers and memory-mapped I/O |
| Program exit | main() returns to OS | main() never returns, infinite loop |
| Standard library | Fully available | Reduced or custom (e.g., newlib-nano) |
| Optimization focus | Speed / readability | Size, speed, and power together |
| Portability | High | Lower, tied to the chip |
The typical Embedded C program shape
int main(void)
{
system_init(); // set up clocks, peripherals
gpio_init();
while (1) // super-loop: never exits
{
read_sensors();
process_data();
update_outputs();
}
}
Features commonly seen in Embedded C
volatile: tells the compiler "this value can change outside the program's control" (hardware registers, ISR-modified variables). Without it, the compiler may optimize away important reads.- Bitwise operations: setting, clearing, toggling, and testing individual bits in registers.
- Fixed-width types (
uint8_t,uint16_t,uint32_tfrom<stdint.h>): so sizes are predictable across compilers. - Pointers to fixed addresses: accessing memory-mapped hardware registers.
- Interrupt Service Routines (ISRs): functions triggered by hardware events.
constand placement in Flash: saving scarce RAM by keeping constants in program memory.static: limiting scope and preserving values.- Compiler-specific extensions: attributes,
#pragma, inline assembly, special keywords for memory placement.
Small example: setting a bit in a register
#define PORTB (*(volatile unsigned char *)0x25)
PORTB |= (1 << 5); // set bit 5 (e.g., turn an LED on)
PORTB &= ~(1 << 5); // clear bit 5 (LED off)
PORTB ^= (1 << 5); // toggle bit 5
This is the bread and butter of Embedded C: talk to hardware by manipulating bits at specific memory addresses.
4. Firmware, Hardware and Software
Hardware
The physical parts: microcontroller, sensors, resistors, PCB, memory chips, display, power circuitry. Things you can touch.
Software
A general term for programs that run on hardware. On a PC: apps, OS, drivers. Software is easy to change.
Firmware
Firmware is software that lives permanently on the hardware (typically in non-volatile memory like Flash) and is closely tied to controlling it. It's the middle ground between hardware and software, which is why it's called firm.
Analogy: Hardware is the body, firmware is the instincts and reflexes (breathing, heartbeat), and application software is the learned skills.
Key differences
| Hardware | Firmware | Software | |
|---|---|---|---|
| Nature | Physical | Low-level code stored in chip | Higher-level programs |
| Stored in | N/A | Flash / ROM / EEPROM | Disk / SSD / RAM |
| Changeability | Needs physical change | Updated rarely (firmware update / OTA) | Changed often |
| Closeness to hardware | It is the hardware | Very close | Usually abstracted by an OS |
| Example | MCU, sensor | Router firmware, BIOS, washing machine controller code | Browser, games |
How they work together
- Power is applied (hardware).
- The MCU starts executing firmware from a fixed start address.
- Firmware initializes peripherals and reads sensors.
- Firmware decides and drives outputs (motors, LEDs).
Layers of embedded firmware (common model)
Application layer <- your product logic
Middleware / RTOS <- optional (scheduler, protocols)
HAL / Drivers <- talks to peripherals
Startup code / Bootloader
Hardware (MCU + peripherals)
Bootloader (brief)
A small program that runs first on power-up. It may load or update the main firmware, enabling updates without special hardware.
5. Microcontrollers vs Microprocessors
Microprocessor (MPU / CPU)
A chip that contains only the processing core (ALU, registers, control unit). To make a working system you must add external RAM, ROM, I/O chips, and so on. Examples: Intel Core, AMD Ryzen; in embedded: ARM Cortex-A series, used in boards like Raspberry Pi.
Microcontroller (MCU)
A complete small computer on a single chip: CPU + RAM + Flash + timers + I/O ports + communication interfaces (UART, SPI, I2C) + often ADC/DAC. Examples: AVR (ATmega328P on Arduino Uno), PIC, STM32 (ARM Cortex-M), ESP32, 8051.
Microprocessor system Microcontroller (one chip)
------------------------- ---------------------------
CPU | RAM | ROM | I/O | | CPU + RAM + Flash + I/O |
(separate chips on PCB) | + Timers + ADC + UART |
Comparison
| Feature | Microprocessor | Microcontroller |
|---|---|---|
| Integration | CPU only | CPU + memory + peripherals |
| External components | Many needed | Few needed |
| Clock speed | High (GHz) | Lower (MHz range) |
| Memory | External, large (GBs) | Internal, small (KBs to MBs) |
| Power consumption | Higher | Very low, with sleep modes |
| Cost | Higher total system cost | Low total system cost |
| Size | Larger board | Compact |
| OS support | Runs full OS (Linux, Windows) | Bare-metal or RTOS |
| Typical use | Computers, smartphones, complex devices | Appliances, sensors, controllers |
| Designed for | General compute-heavy tasks | Control-oriented tasks |
Rule of thumb
- Need simple, low-cost, low-power control? Choose an MCU.
- Need heavy processing, a full OS, or lots of memory? Choose an MPU.
Common MCU peripherals (just to know the vocabulary)
- GPIO: general-purpose pins (digital in/out)
- Timers/Counters: delays, PWM, measuring time
- ADC/DAC: analog to digital and back
- UART, SPI, I2C: communication protocols
- Interrupt controller: handles hardware events
- Watchdog timer: resets the system if software hangs
Word size note
MCUs come in 8-bit, 16-bit, and 32-bit variants. The "bit" size is the width of the CPU's data path. 8-bit is cheap and simple, while 32-bit (like ARM Cortex-M) is more powerful and most common in modern designs.
6. Harvard vs Von Neumann Architecture
These describe how a processor connects to memory for instructions and data.
Von Neumann architecture
- One memory for both instructions (program) and data.
- One bus (address + data) shared between them.
- The CPU can't fetch an instruction and read/write data at the same time, which causes the Von Neumann bottleneck.
+-----------+ +--------------------+
| CPU |<------>| Single Memory |
| | one | (Program + Data) |
+-----------+ bus +--------------------+
Harvard architecture
- Separate memories for program and data.
- Separate buses for each.
- The CPU can fetch the next instruction while accessing data, so it is faster and more parallel.
+-------------------+
+-------+ <--> | Program Memory | (instruction bus)
| CPU | +-------------------+
| | <--> +-------------------+
+-------+ | Data Memory | (data bus)
+-------------------+
Comparison
| Feature | Von Neumann | Harvard |
|---|---|---|
| Memory | Unified | Separate program & data |
| Buses | Shared | Separate |
| Simultaneous fetch + data access | No | Yes |
| Speed | Slower (bottleneck) | Faster |
| Hardware complexity | Simpler | More complex |
| Flexibility | Program can be treated as data (self-modifying code possible) | Strict separation, safer |
| Typical use | General-purpose computers | Many MCUs and DSPs |
| Examples | x86 PCs, classic 8051 (separate address spaces but see note) | AVR, PIC, many DSPs |
Note: Real-world chips often blend both. The Modified Harvard architecture (used by ARM Cortex-M and modern CPUs) has separate instruction/data paths (such as separate caches or buses) but a unified address space. This gives Harvard-style speed with Von Neumann-style flexibility.
Why this matters in Embedded C
On pure Harvard MCUs (e.g., AVR), program memory (Flash) and data memory (RAM) have different address spaces. A normal pointer can't directly read constants stored in Flash. You need special keywords or functions (e.g., PROGMEM in AVR) to place and read constant data from Flash. This is a classic embedded gotcha.
7. Bare-Metal Programming
What it means
Bare-metal programming means writing code that runs directly on the hardware with no operating system in between. Your code is in charge of everything: startup, clocks, peripherals, interrupts, and the main control flow.
Analogy: Cooking on a campfire (bare-metal) vs ordering through a restaurant kitchen (OS). You get full control but you do all the work.
What you are responsible for
- Setting up the startup code (stack pointer, copying initialized data from Flash to RAM, zeroing uninitialized data, then calling
main()). - Configuring clock sources and speeds.
- Initializing and controlling peripherals by writing to registers.
- Handling interrupts manually.
- Managing timing and scheduling yourself.
The classic structure: the super-loop
int main(void)
{
init_hardware();
while (1)
{
task_read_sensor();
task_update_display();
task_check_buttons();
}
}
Everything runs in one endless loop; time-critical events are handled by interrupts.
Polling vs interrupts (important concept)
| Polling | Interrupt-driven | |
|---|---|---|
| How | CPU repeatedly checks a flag/status | Hardware signals the CPU when an event occurs |
| CPU usage | Wasteful | Efficient, CPU can do other work or sleep |
| Complexity | Simple | More complex (ISR rules, shared data) |
| Response time | Depends on loop length | Fast and predictable |
ISR tips: keep them short, avoid blocking calls, mark shared variables volatile, and set a flag for the main loop to handle heavy work.
Advantages
- Full control and maximum performance
- Smallest memory footprint (no OS overhead)
- Predictable timing
- Great for learning how hardware really works
Disadvantages
- You manage everything, which means more code and more bugs
- Hard to scale for complex, multi-task applications
- Less portable; code is tied to the chip
- No built-in features such as multitasking, file systems, or networking stacks
Bare-metal vs RTOS vs Embedded Linux (quick view)
| Bare-metal | RTOS | Embedded Linux | |
|---|---|---|---|
| Overhead | None | Small | Large |
| Multitasking | Manual | Built-in scheduler | Full |
| Timing | Fully controlled | Deterministic | Not strictly real-time |
| Best for | Simple, tiny, or ultra-low-power devices | Multiple timed tasks | Complex devices with rich features |
8. Cross-Compilation and Toolchains
The problem
You write code on a PC (x86/x64), but it must run on a microcontroller (e.g., ARM Cortex-M, AVR). Those have different instruction sets. A normal compiler on your PC produces PC machine code, which is useless for the MCU.
The solution: cross-compilation
Cross-compilation means compiling code on one machine (host) to produce executable code for a different machine (target).
- Host: where you build (your laptop).
- Target: where the program runs (the MCU).
Host (PC, x86) --[ cross compiler ]--> Target binary (ARM / AVR machine code)
--> flashed onto the MCU
A native compiler builds for the same machine it runs on. A cross compiler builds for a different one.
What is a toolchain?
A toolchain is the complete set of tools needed to turn source code into a program for the target. Typical contents:
| Tool | Role |
|---|---|
| Compiler | C source to assembly/object code |
| Assembler | Assembly to machine-code object files |
| Linker | Combines objects and libraries into one image |
| Librarian / archiver | Bundles object files into libraries |
| Binary utilities | Convert/inspect output (objcopy, objdump, size, nm) |
| Standard library | Embedded-friendly C library (e.g., newlib, newlib-nano) |
| Debugger | Debugs on the target (e.g., GDB) |
Common toolchains
| Target | Toolchain |
|---|---|
| ARM Cortex-M / Cortex-R | GNU Arm Embedded Toolchain (arm-none-eabi-gcc) |
| AVR (Arduino Uno etc.) | avr-gcc (AVR-GCC) |
| ESP32 | Xtensa/RISC-V GCC via ESP-IDF |
| 8051 | SDCC, Keil C51 |
| Various | IAR Embedded Workbench, Keil MDK (armcc / armclang), LLVM/Clang |
Understanding the toolchain name: arm-none-eabi-gcc
armis the target architecturenonemeans no operating system (bare-metal)eabiis the Embedded Application Binary Interface (rules for how functions pass arguments, how data is laid out, and so on)gccis the compiler
Typical command-line usage
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -c main.c -o main.o
arm-none-eabi-gcc main.o startup.o -T linker.ld -o firmware.elf
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin
arm-none-eabi-size firmware.elf
Important compiler flags (conceptual)
-mcpu,-mthumb: specify the exact core and instruction set-O0 / -O1 / -O2 / -Os: optimization levels (-Osoptimizes for size, popular in embedded)-g: include debug info-Wall -Wextra: enable useful warnings-T: use a custom linker script
9. Compiler, Assembler, Linker and Loader
This is the journey from your .c file to a running program.
The big picture (build flow)
main.c --> [Preprocessor] --> [Compiler] --> main.s --> [Assembler] --> main.o
|
other.o, libs.a ----------------------------------------------------------+
v
[Linker] <-- linker script
v
firmware.elf
v
[objcopy] --> .bin / .hex
v
[Flash programmer / Loader]
v
Runs on MCU
Step 0: Preprocessor
Handles lines starting with #: #include (pastes header contents), #define (macro substitution), #ifdef (conditional compilation). It outputs pure C code.
Step 1: Compiler
Translates the preprocessed C code into assembly language for the target CPU. It also performs optimization (removing dead code, inlining, simplifying expressions).
Step 2: Assembler
Converts assembly into machine code, producing object files (.o). Object files contain machine code plus symbol tables and placeholders for addresses that aren't known yet (for example, calls to functions in other files).
Step 3: Linker
Combines all object files and libraries into one final executable image. Its jobs:
- Symbol resolution: matches a function call in one file to its definition in another. Missing definitions cause the classic "undefined reference" error.
- Relocation: assigns final memory addresses to code and data.
- Memory layout: places sections into the correct memory regions using a linker script (
.ld).
Common sections in the output:
| Section | Contains | Lives in |
|---|---|---|
.text | Program code (and often constants) | Flash |
.rodata | Read-only data (const arrays, strings) | Flash |
.data | Initialized global/static variables | Stored in Flash, copied to RAM at startup |
.bss | Uninitialized (zero-initialized) globals/statics | RAM (zeroed at startup) |
.stack | Function calls, local variables | RAM |
.heap | Dynamic memory (malloc) | RAM |
Linker script tells the linker the memory map: where Flash starts, how big it is, where RAM starts, and which section goes where. In embedded work this is essential because the linker doesn't automatically know your chip's layout.
Map file (.map): a report generated by the linker showing where each function and variable ended up and how much memory is used. Very useful for debugging size problems.
Step 4: Loader
A loader places the program into memory so it can execute.
- On a PC: the OS loader reads the executable, allocates memory, sets up the stack, and starts the program.
- On an embedded system (bare-metal): there's usually no OS loader. Instead:
- A programmer/flasher writes the final binary into Flash memory.
- On reset, the CPU starts executing from a fixed address (the reset vector).
- A small startup code copies
.datafrom Flash to RAM, zeros.bss, sets the stack pointer, and callsmain(). - A bootloader, if present, can act as a loader for new firmware.
Output file formats
| Format | Description |
|---|---|
| .elf | Full output with symbols and debug info; used for debugging |
| .bin | Raw binary image, no addressing info |
| .hex (Intel HEX) | Text format with addresses and checksums; common for flashing |
| .map | Memory usage report, not a program file |
Quick comparison
| Tool | Input | Output | Job |
|---|---|---|---|
| Preprocessor | .c / .h | Expanded .i | Handle # directives |
| Compiler | .i | .s (assembly) | Translate and optimize |
| Assembler | .s | .o | Produce machine code |
| Linker | .o + libs + linker script | .elf | Combine, resolve, place |
| Loader / Flasher | .bin / .hex / .elf | Program in memory | Get code into the device |
10. IDE, Debugger, Flashing and Deployment
IDE (Integrated Development Environment)
An IDE bundles everything into one application: code editor, compiler/toolchain integration, build system, debugger, and flashing tools.
| IDE | Commonly used for |
|---|---|
| Keil µVision | ARM, 8051 |
| STM32CubeIDE | STM32 microcontrollers |
| IAR Embedded Workbench | Many architectures, professional use |
| MPLAB X | PIC / AVR (Microchip) |
| Arduino IDE | Beginner-friendly AVR/ESP boards |
| Atmel/Microchip Studio | AVR/SAM |
| PlatformIO (VS Code) | Multi-platform, flexible |
| ESP-IDF / VS Code | ESP32 |
You can also work without an IDE, using a text editor + Makefile/CMake + command-line toolchain. Many professionals prefer this for automation and CI.
Build system
Tools like Make and CMake automate compiling only what changed, and ensure consistent builds.
Debugger
Debugging embedded code is different from debugging on a PC because the program runs on separate hardware.
Hardware debug interface (the bridge between PC and MCU):
- JTAG: standard multi-pin debug/test interface
- SWD (Serial Wire Debug): ARM's 2-wire alternative, which is very popular
- ISP / debugWIRE: AVR programming/debug options
Debug probes/adapters: ST-Link, J-Link (SEGGER), CMSIS-DAP, ICE devices.
What a debugger lets you do:
- Breakpoints: pause execution at a line
- Single-step: run one instruction/line at a time
- Watch variables / registers / memory
- Inspect the call stack
- Hardware breakpoints and watchpoints (limited in number, but don't modify code)
- Trace (on some chips)
Debug software: GDB with OpenOCD, or the debugger built into your IDE.
Other debugging methods (when no debugger is handy):
- LED blinking: crude but effective
- UART
printflogging: send messages to a serial terminal - Logic analyzer / oscilloscope: observe real signals
- Semihosting / SWO (ITM) trace: print via the debug connection
Flashing (programming the chip)
Flashing means writing your compiled binary into the MCU's non-volatile Flash memory.
Common methods:
| Method | How it works |
|---|---|
| Debug/programming probe | Via SWD/JTAG (ST-Link, J-Link) |
| ISP (In-System Programming) | Program through dedicated pins while the chip is on the board (common on AVR) |
| Serial bootloader | Pre-installed bootloader receives firmware over UART/USB |
| USB mass-storage / drag-and-drop | Board appears as a drive (UF2, some dev boards) |
| OTA (Over-The-Air) | Wireless firmware update (Wi-Fi/BLE/cellular), common in IoT |
Typical flash flow:
- Build produces
.hex/.bin/.elf. - The programmer tool connects to the MCU.
- It erases the necessary Flash sectors.
- It writes the new image and verifies it.
- It resets the MCU, and the new firmware starts running.
Related ideas:
- Flash erase/write cycles are limited (typically thousands to hundreds of thousands), so avoid unnecessary rewrites.
- Fuse bits / option bytes: configuration settings (clock source, brown-out detection, boot mode). Setting them wrongly can "brick" a chip.
- Read-out protection / lock bits: prevent others from reading your firmware.
Deployment
Deployment is getting tested firmware into the real product and keeping it working.
Typical stages:
- Development: code, simulate, test on a dev board.
- Testing: unit tests, hardware-in-the-loop, integration tests, stress and edge cases.
- Build for release: use release configuration (optimized, debug symbols off or separated), a fixed version number.
- Production programming: flash devices in bulk with a gang programmer or on the factory line.
- Field updates: bootloader or OTA to patch bugs and add features.
- Maintenance: monitor, log, update.
Debug vs Release build
| Debug | Release | |
|---|---|---|
| Optimization | Low (-O0) | High (-O2/-Os) |
| Debug info | Included | Stripped or minimal |
| Size | Bigger | Smaller |
| Speed | Slower | Faster |
| Use | Development | Final product |
Good deployment practices
- Use version control (Git) and tag every release.
- Keep a versioned firmware number inside the code.
- Use a watchdog timer to recover from hangs.
- Include a safe update strategy (e.g., verify checksum before running new firmware, keep a fallback image).
- Protect firmware with read-out protection or encryption where needed.
Quick Revision Cheat Sheet
| Concept | One-line summary |
|---|---|
| Embedded system | Dedicated computer inside a larger device, doing a specific job |
| vs General-purpose | Embedded = specific, constrained, real-time; PC = flexible, powerful |
| Embedded C | Same C language, used with hardware awareness (registers, volatile, bit-ops) |
| Firmware | Software stored permanently in the device's non-volatile memory |
| MCU | CPU + memory + peripherals on one chip |
| MPU | CPU only; needs external memory and I/O |
| Von Neumann | Single memory and bus for code and data |
| Harvard | Separate memory and buses for code and data |
| Bare-metal | Code runs directly on hardware, no OS |
| Cross-compilation | Build on a PC for a different target CPU |
| Toolchain | Compiler + assembler + linker + utilities + libraries + debugger |
| Compiler | C to assembly |
| Assembler | Assembly to machine-code object files |
| Linker | Combines objects, resolves symbols, assigns addresses |
| Loader / Flasher | Gets the final image into memory |
| Debugger | Pause, step, and inspect the program on the real chip |
| Flashing | Writing the compiled program into the MCU's Flash |
| Deployment | Releasing, programming, and updating firmware in real products |
Common interview-style questions
- What's the difference between a microcontroller and a microprocessor?
- Why is
volatileimportant in embedded programming? - Why do embedded programs usually run in an infinite loop?
- What's the difference between Harvard and Von Neumann architecture?
- What does the linker do that the compiler does not?
- What are
.dataand.bss, and how are they initialized at startup? - What is cross-compilation and why is it needed?
- What does the startup code do before
main()? - Polling vs interrupts: when would you use each?
- What is the difference between a bootloader and a debugger?
#C Language Fundamentals#
Notes covering the core of C: how a program is built, how data is stored and manipulated, how control flows, and how functions and memory behave.
Table of Contents
- C Program Structure and Compilation Process
- Keywords, Identifiers and Constants
- Variables and Data Types
- Integer Types: char, short, int, long
- Signed vs Unsigned Data Types
- Floating-Point Types
- Type Modifiers and Type Conversion
- Operators and Operator Precedence
- Conditional Statements: if, else, switch
- Loops: for, while, do-while
- break, continue and goto
- Functions, Declarations and Definitions
- Function Arguments and Return Values
- Storage Classes: auto, static, extern, register
- Scope and Lifetime of Variables
- Recursion and Inline Functions
- Quick Revision Cheat Sheet
1. C Program Structure and Compilation Process
Structure of a C program
/* 1. Documentation section: comments describing the program */
#include <stdio.h> // 2. Preprocessor directives (link section)
#define MAX 10 // Macros / definitions
int counter = 0; // 3. Global declarations
int add(int a, int b); // 4. Function prototypes
int main(void) // 5. main(): where execution begins
{
int x = 5, y = 7; // Local declarations
int sum = add(x, y); // Statements
printf("Sum = %d\n", sum);
return 0; // Return status to caller (0 = success)
}
int add(int a, int b) // 6. Function definitions
{
return a + b;
}
| Part | Purpose |
|---|---|
| Comments | // single-line, /* ... */ multi-line. Ignored by the compiler. |
| Preprocessor directives | Lines starting with #: #include, #define, #ifdef. |
| Global declarations | Variables/types visible to the whole file. |
| Prototypes | Tell the compiler a function's signature before it is used. |
main() | The entry point. Every C program must have exactly one. |
| Statements | Instructions ending with a semicolon ;. |
| Function definitions | The actual bodies of functions. |
Key rules
- C is case sensitive (
Countandcountare different). - Every statement ends with
;. - Blocks of code are wrapped in
{ }. - Execution starts in
main()and runs top to bottom unless control flow changes it.
Compilation process (source to executable)
source.c
| 1. Preprocessing -> expands #include, #define, removes comments
v
source.i
| 2. Compilation -> converts C into assembly code
v
source.s
| 3. Assembly -> converts assembly into machine code
v
source.o (object file)
| 4. Linking -> combines object files + libraries
v
a.out / program.exe (executable)
| Stage | What happens | Typical GCC command |
|---|---|---|
| Preprocessing | Header files pasted in, macros replaced | gcc -E file.c -o file.i |
| Compilation | Syntax checked, translated to assembly, optimized | gcc -S file.c |
| Assembly | Assembly becomes machine code in an object file | gcc -c file.c |
| Linking | Resolves function calls across files and libraries | gcc file.o -o program |
Common errors by stage
- Preprocessor: missing header file.
- Compiler: syntax errors, undeclared variables, type mismatches.
- Linker:
undefined reference to ...(function declared but never defined). - Runtime: crashes, segmentation faults, wrong output.
2. Keywords, Identifiers and Constants
Keywords
Keywords are reserved words with special meaning to the compiler. You cannot use them as names.
C (C99/C11) keywords include:
| Category | Keywords |
|---|---|
| Data types | int, char, float, double, void, _Bool |
| Modifiers | short, long, signed, unsigned |
| Storage classes | auto, static, extern, register |
| Qualifiers | const, volatile, restrict |
| Control flow | if, else, switch, case, default, for, while, do, break, continue, goto, return |
| User-defined types | struct, union, enum, typedef |
| Others | sizeof, inline |
Identifiers
Identifiers are names you give to variables, functions, arrays, structures, and so on.
Rules
- Can contain letters, digits, and underscore
_. - Must start with a letter or underscore (not a digit).
- Cannot be a keyword.
- Case sensitive.
- No spaces or special characters (
@,#,-, ...).
int age; // valid
int _count; // valid
int total2; // valid
int 2total; // invalid: starts with digit
int my-var; // invalid: hyphen not allowed
int float; // invalid: keyword
Good practice: use meaningful names (temperature, not t). Avoid names starting with double underscore or underscore + capital letter, since those are reserved for the implementation.
Constants
A constant is a fixed value that doesn't change during execution.
Types of literal constants
| Type | Examples |
|---|---|
| Integer | 10, -5, 0x1F (hex), 075 (octal), 0b1010 (binary, compiler extension / C23) |
| Floating-point | 3.14, 2.5e3 |
| Character | 'A', '\n', '0' |
| String | "Hello" (array of chars ending with '\0') |
Suffixes
10Uunsigned,10Llong,10ULunsigned long,10LLlong long3.14ffloat,3.14Llong double (no suffix meansdouble)
Escape sequences: \n newline, \t tab, \\ backslash, \' single quote, \" double quote, \0 null character.
Ways to define constants
#define PI 3.14159 // preprocessor macro: text replacement, no type
const int MAX_USERS = 100; // typed constant: checked by compiler
enum { RED, GREEN, BLUE }; // enumeration constants: 0, 1, 2
#define | const | |
|---|---|---|
| Type safety | None | Yes |
| Handled by | Preprocessor | Compiler |
| Has a memory address | No | Yes (generally) |
| Scope | From definition to end of file | Follows normal scope rules |
3. Variables and Data Types
Variables
A variable is a named location in memory that holds a value that can change.
int age; // declaration (memory reserved)
age = 25; // assignment
int score = 90; // declaration + initialization
int a = 1, b = 2, c; // multiple declarations
- Declaration: tells the compiler the name and type.
- Definition: reserves memory (for local variables, declaration and definition happen together).
- Initialization: giving it a first value.
Warning: An uninitialized local variable contains garbage (an indeterminate value). Always initialize.
Data type categories
| Category | Types |
|---|---|
| Basic (primary) | char, int, float, double, void |
| Derived | arrays, pointers, functions |
| User-defined | struct, union, enum, typedef |
Basic types at a glance
| Type | Typical size | Used for |
|---|---|---|
char | 1 byte | A character or small integer |
int | 2 or 4 bytes | Whole numbers |
float | 4 bytes | Single-precision decimals |
double | 8 bytes | Double-precision decimals |
void | none | "No value" (function returns, generic pointers) |
Sizes depend on the compiler and platform. Use
sizeofto check:printf("%zu\n", sizeof(int));
Format specifiers (for printf / scanf)
| Specifier | Type |
|---|---|
%d / %i | int |
%u | unsigned int |
%ld / %lu | long / unsigned long |
%f | float / double (printf) |
%lf | double (scanf) |
%c | char |
%s | string |
%x | hexadecimal |
%p | pointer |
%zu | size_t (result of sizeof) |
Fixed-width types (<stdint.h>)
Because int size varies, fixed-width types give predictable sizes:
int8_t, int16_t, int32_t, int64_t, uint8_t, uint16_t, uint32_t, uint64_t
These are especially valuable in Embedded C where exact sizes matter.
4. Integer Types: char, short, int, long
Integer types store whole numbers. They differ in size and range.
| Type | Typical size | Typical range (signed) |
|---|---|---|
char | 1 byte (8 bits) | -128 to 127 |
short (short int) | 2 bytes | -32,768 to 32,767 |
int | 4 bytes (2 on some small MCUs) | -2,147,483,648 to 2,147,483,647 |
long (long int) | 4 or 8 bytes | at least -2,147,483,648 to 2,147,483,647 |
long long | 8 bytes | -9.22 x 10^18 to 9.22 x 10^18 |
What the C standard guarantees
sizeof(char) == 1always.shortis at least 16 bits;intis at least 16 bits;longis at least 32 bits;long longis at least 64 bits.sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long).
Range formula
For an n-bit type:
- Signed:
-2^(n-1)to2^(n-1) - 1 - Unsigned:
0to2^n - 1
The char type
char is an integer type that is normally used for characters. It holds the numeric (ASCII) code:
char c = 'A';
printf("%c %d\n", c, c); // A 65
Whether plain
charis signed or unsigned is implementation-defined. Usesigned charorunsigned char(oruint8_t) when it matters.
Choosing an integer type
- Counting small values or raw bytes:
unsigned char/uint8_t. - General-purpose counters:
int. - Large values (e.g., timestamps, big sums):
longorlong long. - Memory-limited or hardware-mapped data: fixed-width types.
Overflow
Going past the maximum value:
- Unsigned overflow wraps around (well-defined):
255 + 1in 8 bits becomes0. - Signed overflow is undefined behaviour: the result is unpredictable.
5. Signed vs Unsigned Data Types
The difference
- Signed: can hold negative, zero, and positive values.
- Unsigned: can hold only zero and positive values, with twice the positive range.
How negatives are stored: two's complement
Most systems use two's complement. The most significant bit (MSB) acts as the sign bit (1 means negative).
To get the negative of a number: invert all bits, then add 1.
Example in 8 bits: +5 = 00000101; invert = 11111010; add 1 = 11111011 = -5.
8-bit comparison
| Type | Bits | Range |
|---|---|---|
signed char | 8 | -128 to 127 |
unsigned char | 8 | 0 to 255 |
Same bit pattern, different meaning: 11111111 is -1 as signed but 255 as unsigned.
Declaring
int a = -10; // signed (default)
unsigned int b = 10; // unsigned
unsigned char c = 200;
Format specifiers
%d for signed, %u for unsigned.
Common pitfalls
1. Mixing signed and unsigned in comparisons
int a = -1;
unsigned int b = 1;
if (a < b) { /* ... */ } // FALSE! a is converted to a huge unsigned value
2. Loop that never ends
for (unsigned int i = 5; i >= 0; i--) { } // i >= 0 is always true for unsigned
3. Right shift behaviour
- Right-shifting an unsigned value fills with zeros (logical shift).
- Right-shifting a negative signed value is implementation-defined (often an arithmetic shift that keeps the sign).
Which to use?
- Unsigned for: bit manipulation, register values, raw bytes, sizes, counters that can't be negative.
- Signed for: values that can be negative (temperature, differences, offsets).
6. Floating-Point Types
Used for numbers with fractional parts or very large/small magnitudes.
| Type | Typical size | Precision (decimal digits) | Approx. range |
|---|---|---|---|
float | 4 bytes | ~6-7 | 1.2e-38 to 3.4e+38 |
double | 8 bytes | ~15-16 | 2.3e-308 to 1.7e+308 |
long double | 8, 12 or 16 bytes | 18+ (platform dependent) | Larger still |
How they're stored (IEEE 754)
A floating-point number is stored in three parts:
[ Sign | Exponent | Mantissa (fraction) ]
Value is roughly: (-1)^sign x 1.mantissa x 2^exponent
- float: 1 sign bit, 8 exponent bits, 23 mantissa bits.
- double: 1 sign bit, 11 exponent bits, 52 mantissa bits.
Using them
float f = 3.14f;
double d = 3.141592653589793;
printf("%f\n", f); // %f for float/double in printf
printf("%.2f\n", d); // 2 digits after decimal point
printf("%e\n", d); // scientific notation
Important cautions
1. Precision is limited and not exact
float x = 0.1f + 0.2f;
// x is not exactly 0.3
Many decimal fractions (like 0.1) cannot be represented exactly in binary.
2. Never compare floats with ==
#include <math.h>
if (fabs(a - b) < 1e-6) { /* treat as equal */ }
3. Integer vs float division
int r1 = 5 / 2; // 2 (integer division)
float r2 = 5 / 2; // 2.0 (division done on ints first!)
float r3 = 5 / 2.0; // 2.5
4. Special values: infinity (inf), not-a-number (NaN), and negative zero exist in IEEE 754.
Embedded note
Many small microcontrollers have no floating-point hardware (FPU). Floating-point is then emulated in software, which is slow and uses more code space. Embedded code often prefers integers or fixed-point arithmetic.
7. Type Modifiers and Type Conversion
Type modifiers
Modifiers change the size or sign of a basic type.
| Modifier | Effect | Applies to |
|---|---|---|
signed | Allows negative values (default for int) | char, int |
unsigned | Only non-negative values | char, int |
short | Reduces size | int |
long | Increases size | int, double |
long long | Even larger integer | int |
unsigned short s;
long int l; // same as: long l;
unsigned long long u;
long double ld;
Type qualifiers (related and commonly asked)
| Qualifier | Meaning |
|---|---|
const | The value can't be modified after initialization |
volatile | Value may change unexpectedly (hardware, ISR), so the compiler must not optimize reads/writes away |
restrict | Pointer is the only way to access that data (optimization hint, C99) |
const int limit = 100;
volatile unsigned char *status_reg; // hardware register
Type conversion
Converting a value from one type to another. Two kinds: implicit and explicit.
Implicit conversion (automatic / coercion)
The compiler converts automatically, following these ideas:
Integer promotion: types smaller than int (char, short) are promoted to int before arithmetic.
Usual arithmetic conversions (when types differ, the "lower" type is converted up):
int -> unsigned int -> long -> unsigned long -> long long -> float -> double -> long double
int i = 5;
double d = 2.5;
double result = i + d; // i is converted to double -> 7.5
Assignment conversion: the value is converted to the type of the variable on the left.
int x = 3.9; // x becomes 3 (fraction truncated, not rounded)
float f = 10; // f becomes 10.0
Explicit conversion (type casting)
You force the conversion.
(type) expression
int a = 7, b = 2;
float r = (float)a / b; // 3.5, since a is cast first
Pitfalls
- Narrowing (e.g.,
doubletoint,inttochar) can lose data. - Signed/unsigned mixing produces surprising results (see section 5).
- Casting before vs after operation matters:
float r1 = (float)(a / b); // 3.0 (integer division happened first)
float r2 = (float)a / b; // 3.5
8. Operators and Operator Precedence
Categories of operators
1. Arithmetic
| Operator | Meaning |
|---|---|
+ - * / | add, subtract, multiply, divide |
% | remainder (integers only) |
++ -- | increment / decrement (prefix and postfix) |
int a = 5;
int b = a++; // b = 5, then a = 6 (postfix: use, then change)
int c = ++a; // a = 7, then c = 7 (prefix: change, then use)
2. Relational
== != > < >= <=. They produce 1 (true) or 0 (false).
Don't confuse
=(assignment) with==(comparison).
3. Logical
| Operator | Meaning |
|---|---|
&& | AND |
|| | OR |
! | NOT |
Short-circuit evaluation: in a && b, if a is false, b is not evaluated. In a || b, if a is true, b is not evaluated.
4. Bitwise
| Operator | Meaning | Example |
|---|---|---|
& | AND | 5 & 3 = 1 |
| | OR | 5 | 3 = 7 |
^ | XOR | 5 ^ 3 = 6 |
~ | NOT (invert bits) | ~5 = -6 |
<< | Left shift (multiply by 2^n) | 1 << 3 = 8 |
>> | Right shift (divide by 2^n) | 8 >> 2 = 2 |
Common bit tricks:
x |= (1 << n); // set bit n
x &= ~(1 << n); // clear bit n
x ^= (1 << n); // toggle bit n
if (x & (1 << n)) // test bit n
5. Assignment
= += -= *= /= %= &= |= ^= <<= >>=
x += 5 is the same as x = x + 5.
6. Conditional (ternary)
result = (condition) ? value_if_true : value_if_false;
int max = (a > b) ? a : b;
7. Special operators
| Operator | Use |
|---|---|
sizeof | Size in bytes of a type/variable |
, (comma) | Evaluates left to right, result is the last expression |
& (address-of) | Address of a variable |
* (dereference) | Value at an address |
. and -> | Access struct/union members |
(type) | Cast |
[] | Array subscript |
() | Function call / grouping |
Operator precedence and associativity
Precedence decides which operator is applied first. Associativity decides the order when operators have the same precedence.
| Level | Operators | Associativity |
|---|---|---|
| 1 (highest) | () [] . -> postfix ++ -- | Left to right |
| 2 | prefix ++ --, unary + -, ! ~, (type), * (deref), &, sizeof | Right to left |
| 3 | * / % | Left to right |
| 4 | + - | Left to right |
| 5 | << >> | Left to right |
| 6 | < <= > >= | Left to right |
| 7 | == != | Left to right |
| 8 | & (bitwise AND) | Left to right |
| 9 | ^ | Left to right |
| 10 | | | Left to right |
| 11 | && | Left to right |
| 12 | || | Left to right |
| 13 | ?: | Right to left |
| 14 | = += -= ... | Right to left |
| 15 (lowest) | , | Left to right |
Examples
int r = 2 + 3 * 4; // 14 (multiplication first)
int s = (2 + 3) * 4; // 20 (parentheses first)
int t = 10 - 4 - 2; // 4 (left to right)
int u = a = b = 5; // right to left: b = 5, then a = b
Classic gotchas
if (x & 1 == 0) // WRONG: == binds tighter than &, so it's x & (1 == 0)
if ((x & 1) == 0) // correct
- Bitwise operators have lower precedence than comparison operators. Always use parentheses.
- Avoid modifying a variable twice in one expression (
i = i++ + ++i): this is undefined behaviour. - When in doubt, use parentheses. They make code clear.
9. Conditional Statements: if, else, switch
Conditional statements let the program make decisions. In C, any non-zero value is true; zero is false.
if
if (condition) {
// runs if condition is true
}
if-else
if (marks >= 40) {
printf("Pass\n");
} else {
printf("Fail\n");
}
else if ladder
if (marks >= 90) grade = 'A';
else if (marks >= 75) grade = 'B';
else if (marks >= 50) grade = 'C';
else grade = 'F';
Conditions are checked top to bottom; the first true one runs and the rest are skipped.
Nested if
An if inside another if. Keep nesting shallow for readability.
Dangling else
An else belongs to the nearest unmatched if. Use braces to avoid confusion.
Common mistakes
if (x = 5) // assignment, not comparison! always true
if (x == 5); // stray semicolon: the body is empty
{ ... } // this block now always runs
Tip: write constants on the left (
if (5 == x)), so a typo5 = xbecomes a compile error. Or just rely on compiler warnings (-Wall).
switch
Chooses between many fixed values of an integer or character expression.
switch (choice) {
case 1:
printf("One\n");
break;
case 2:
case 3: // fall-through: 2 and 3 share code
printf("Two or Three\n");
break;
default:
printf("Other\n");
}
Rules
- The expression must be an integer type (including
char,enum). No floats or strings. caselabels must be constant expressions and unique.breakexits the switch. Without it, execution falls through to the next case.defaultruns if nothing matches (optional, can be placed anywhere).
if-else vs switch
if-else | switch | |
|---|---|---|
| Condition type | Any expression (ranges, floats, logic) | Equality against integer constants |
| Readability for many options | Gets long | Clean |
| Speed | Sequential checks | Often compiled to a jump table (faster) |
| Ranges | Yes (x > 5 && x < 10) | No |
Ternary shortcut
(a > b) ? a : b is a compact form of a simple if-else.
10. Loops: for, while, do-while
Loops repeat a block of code.
for loop
Best when you know the number of iterations.
for (initialization; condition; update) {
// body
}
for (int i = 0; i < 5; i++) {
printf("%d ", i); // 0 1 2 3 4
}
Order: initialization (once), then check condition, run body, run update, check condition again, and so on.
All three parts are optional: for (;;) is an infinite loop.
while loop
Best when the number of iterations is not known, and the condition is checked before each iteration.
int i = 0;
while (i < 5) {
printf("%d ", i);
i++;
}
May run zero times.
do-while loop
The condition is checked after the body, so it runs at least once.
int n;
do {
printf("Enter a positive number: ");
scanf("%d", &n);
} while (n <= 0);
Note the semicolon after while(...).
Comparison
for | while | do-while | |
|---|---|---|---|
| Condition checked | Before | Before | After |
| Minimum runs | 0 | 0 | 1 |
| Typical use | Counted loops | Condition-driven loops | Menus, input validation |
Infinite loops
while (1) { ... }
for (;;) { ... }
Very common in embedded systems, where the main program loops forever. Exit with break or return if needed.
Nested loops
A loop inside a loop. The inner loop runs fully for each outer iteration.
for (int i = 1; i <= 3; i++) {
for (int j = 1; j <= i; j++) {
printf("* ");
}
printf("\n");
}
/* Output:
*
* *
* * *
*/
Common mistakes
- Off-by-one errors (
<vs<=). - Forgetting to update the loop variable, giving an accidental infinite loop.
- Stray semicolon:
for (...);orwhile (...);makes the body empty. - Modifying the loop counter inside the body unintentionally.
11. break, continue and goto
These change the normal flow of loops and code.
break
- Immediately exits the nearest enclosing loop or switch.
for (int i = 0; i < 10; i++) {
if (i == 5) break; // loop ends when i is 5
printf("%d ", i); // 0 1 2 3 4
}
Inside nested loops, break only exits the innermost loop.
continue
- Skips the rest of the current iteration and moves to the next one.
for (int i = 0; i < 5; i++) {
if (i == 2) continue; // skip when i is 2
printf("%d ", i); // 0 1 3 4
}
- In a
forloop,continuejumps to the update step. - In
while/do-while, it jumps to the condition check (make sure the counter is updated beforecontinue, or you'll loop forever).
goto
- Jumps unconditionally to a labeled statement in the same function.
int i = 0;
loop:
if (i < 3) {
printf("%d ", i);
i++;
goto loop;
}
Why goto is usually discouraged
- Makes code hard to read and maintain ("spaghetti code").
- Can bypass variable initialization.
- Can almost always be replaced by loops, functions, or
break/continue.
Where goto is acceptable
- Breaking out of deeply nested loops.
- Centralized error handling and cleanup (common in system and embedded code):
int process(void) {
int status = -1;
if (!init_a()) goto cleanup;
if (!init_b()) goto cleanup_a;
status = 0;
/* ... */
cleanup_a:
release_a();
cleanup:
return status;
}
Summary
| Statement | Effect | Works in |
|---|---|---|
break | Exit loop/switch | loops, switch |
continue | Skip to next iteration | loops only |
goto label | Jump to label | anywhere in a function |
return | Exit function (with optional value) | functions |
12. Functions, Declarations and Definitions
What is a function?
A function is a named, reusable block of code that performs a specific task.
Benefits: reusability, modularity, easier debugging and testing, readability.
Function anatomy
return_type function_name(parameter_list)
{
// body
return value;
}
int add(int a, int b)
{
return a + b;
}
Declaration (prototype) vs definition
| Declaration (prototype) | Definition | |
|---|---|---|
| Purpose | Tells the compiler the function's name, return type, parameters | Provides the actual code |
| Has body? | No (ends with ;) | Yes ({ ... }) |
| How many times | Can appear multiple times | Exactly once in the program |
| Memory | None | Code is generated |
int add(int a, int b); // declaration / prototype
int main(void) {
printf("%d", add(2, 3)); // OK: compiler knows the signature
return 0;
}
int add(int a, int b) // definition
{
return a + b;
}
Why prototypes matter: the compiler checks that calls match the expected argument types and count. Without one, older C assumed int return (error-prone) and modern compilers warn or fail.
Tip: Prototypes usually go in header files (
.h) and definitions in source files (.c).
Calling a function
int result = add(4, 6);
Function types by form
| Form | Example |
|---|---|
| No args, no return | void hello(void) |
| Args, no return | void print(int x) |
| No args, returns value | int get_value(void) |
| Args and returns value | int add(int a, int b) |
void in parameters
int f(void)means takes no arguments.int f()in C means unspecified arguments (old style), so prefer(void).
The main function
int main(void)
int main(int argc, char *argv[]) // with command-line arguments
return 0; signals success to the operating system. In bare-metal embedded systems, main() typically never returns.
Library vs user-defined functions
- Library functions:
printf,strlen,sqrt, defined in standard headers. - User-defined functions: the ones you write.
13. Function Arguments and Return Values
Terminology
- Parameters (formal): variables in the function definition.
- Arguments (actual): values passed when calling.
int square(int n) // n is a parameter
{ return n * n; }
square(5); // 5 is an argument
Pass by value (default in C)
C copies the argument's value into the parameter. Changes inside the function do not affect the original.
void change(int x) { x = 99; }
int a = 5;
change(a);
printf("%d", a); // still 5
Pass by address (simulating pass by reference)
To modify the caller's variable, pass its address using pointers.
void change(int *x) { *x = 99; }
int a = 5;
change(&a);
printf("%d", a); // 99
Classic example, swap:
void swap(int *a, int *b) {
int t = *a;
*a = *b;
*b = t;
}
swap(&x, &y);
Passing arrays
Arrays are passed as a pointer to the first element (no copy). The function can modify the original, and it doesn't know the size unless you pass it.
void print_array(int arr[], int size) // same as int *arr
{
for (int i = 0; i < size; i++)
printf("%d ", arr[i]);
}
Inside the function, sizeof(arr) gives the pointer size, not the array size.
Passing structures
Passed by value (full copy) unless you pass a pointer. For large structs, pass a pointer (use const if no modification is intended) to save time and stack space.
Return values
- A function returns at most one value via
return. - The returned value must match (or be convertible to) the return type.
voidfunctions don't return a value (return;can be used to exit early).
int max(int a, int b) {
return (a > b) ? a : b;
}
Returning multiple values: use pointer parameters or return a struct.
void min_max(int a, int b, int *min, int *max) {
*min = (a < b) ? a : b;
*max = (a > b) ? a : b;
}
Dangerous: returning address of a local variable
int *bad(void) {
int x = 10;
return &x; // WRONG: x is destroyed when function ends
}
Safe alternatives: return a static variable's address, dynamically allocated memory, or write into a caller-supplied buffer.
Other ideas
- Default arguments / overloading: not supported in C.
- Variable arguments:
printf-style functions use<stdarg.h>(int f(int n, ...)). constparameters:void print(const char *s)promises not to modify the data.- Order of argument evaluation is unspecified, so avoid side effects like
f(i++, i++).
14. Storage Classes: auto, static, extern, register
A storage class defines a variable's scope, lifetime, storage location, and default value.
Summary table
| Storage class | Stored in | Default value | Scope | Lifetime |
|---|---|---|---|---|
auto | Stack | Garbage | Block (local) | Until block ends |
register | CPU register (if possible) | Garbage | Block (local) | Until block ends |
static (local) | Data segment (.data / .bss) | 0 | Block (local) | Entire program |
static (global) | Data segment | 0 | File only | Entire program |
extern | Data segment | 0 (for the definition) | Global (across files) | Entire program |
auto
- The default for local variables. Rarely written explicitly.
void f(void) {
auto int x = 10; // same as: int x = 10;
}
- Created on function entry, destroyed on exit.
register
- A hint to the compiler to keep the variable in a CPU register for speed.
- You cannot take its address (
&) because registers have no memory address. - Modern compilers usually ignore the hint and optimize on their own.
register int i;
for (i = 0; i < 1000; i++) { ... }
static
Has two different uses:
1. Static local variable: keeps its value between function calls.
void counter(void) {
static int count = 0; // initialized only once
count++;
printf("%d\n", count);
}
// Calls print 1, 2, 3, ...
2. Static global variable or function: limited to the current file (internal linkage), which acts like "private" to the file and avoids name clashes.
static int helper_value; // visible only in this .c file
static void helper_function(void); // same for functions
extern
Declares that a variable or function is defined elsewhere (another file or later in the file). It does not allocate memory.
file1.c
int shared = 42; // definition
file2.c
extern int shared; // declaration: refers to the one in file1.c
void show(void) { printf("%d", shared); }
Functions are
externby default, soexternis optional for function declarations.
Declaration vs definition for variables
int x;(at file scope): definition.extern int x;: declaration only (no memory).
Related qualifiers (not storage classes, but often discussed together)
const: read-only.volatile: value can change outside the program's control (hardware registers, ISRs). Prevents unwanted compiler optimization.
When to use what
| Need | Use |
|---|---|
| Normal temporary variable | auto (default local) |
| Remember a value between calls | static local |
| Hide a global/function within one file | static global |
| Share a global across files | extern |
| Hint for a speed-critical variable | register (rarely needed today) |
15. Scope and Lifetime of Variables
Scope: where a variable can be accessed
Scope is the region of code in which the name is visible.
| Scope | Where declared | Visible |
|---|---|---|
| Block scope | Inside { } (including function bodies and loops) | Only within that block, after declaration |
| Function scope | Applies only to labels (for goto) | Entire function |
| File scope (global) | Outside all functions | From declaration to end of the file |
| Function prototype scope | Parameter names in a prototype | Only within the prototype |
Lifetime (storage duration): how long it exists in memory
| Duration | Description | Examples |
|---|---|---|
| Automatic | Created on entering the block, destroyed on leaving | Normal locals, parameters |
| Static | Exists for the entire program run | Globals, static variables |
| Dynamic (allocated) | Exists from malloc until free | Heap memory |
Scope and lifetime are different ideas
void f(void) {
static int x = 0; // lifetime: whole program; scope: only inside f
int y = 0; // lifetime: during call; scope: only inside f
}
A static local variable is alive between calls but not visible outside its function.
Local vs global variables
| Local | Global | |
|---|---|---|
| Declared | Inside a function/block | Outside all functions |
| Default value | Garbage (if not static) | 0 |
| Lifetime | During block execution | Whole program |
| Memory | Stack | Data segment |
| Accessible | Only in its block | Anywhere in file (or other files with extern) |
Variable shadowing
An inner-scope variable with the same name hides the outer one.
int x = 10; // global
int main(void) {
int x = 20; // shadows global x
{
int x = 30; // shadows the previous x
printf("%d", x); // 30
}
printf("%d", x); // 20
}
To access the global one when shadowed in C you would need to avoid the clash (rename, or use a function in another scope), since C has no :: operator. Best practice: avoid shadowing.
Memory layout of a C program (where things live)
High address
+--------------------+
| Stack | local variables, function calls (grows down)
| | |
| v |
| |
| ^ |
| | |
| Heap | malloc / free (grows up)
+--------------------+
| .bss | uninitialized globals/statics (zeroed)
+--------------------+
| .data | initialized globals/statics
+--------------------+
| .rodata / .text | constants, string literals, code
+--------------------+
Low address
Why global variables are risky
- Any function can change them, causing hard-to-trace bugs.
- Reduce reusability and testability.
- Not thread-safe.
Good practice: prefer local variables and pass data via parameters. When shared state is needed, use static at file scope to limit exposure. In embedded code, globals are common (for ISR-shared data and limited RAM), but should be volatile where hardware or interrupts can modify them.
Common bugs
- Using a local variable's address after the function returns (dangling pointer).
- Using uninitialized locals.
- Accidentally shadowing a variable.
16. Recursion and Inline Functions
Recursion
Recursion is when a function calls itself to solve a smaller version of the same problem.
Every recursive function needs:
- Base case: the condition that stops the recursion.
- Recursive case: the call that moves toward the base case.
Example: factorial
unsigned long factorial(unsigned int n) {
if (n == 0 || n == 1) // base case
return 1;
return n * factorial(n - 1); // recursive case
}
Trace for factorial(3):
factorial(3) = 3 * factorial(2)
= 3 * (2 * factorial(1))
= 3 * (2 * 1) = 6
Example: Fibonacci
int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
(Simple but slow, since it recomputes values many times.)
How recursion works in memory
Each call creates a new stack frame holding its parameters, local variables, and return address. Calls pile up on the stack until the base case is hit, then they unwind one by one.
Stack growth -> fact(1) | fact(2) | fact(3) | main
Types of recursion
| Type | Meaning |
|---|---|
| Direct | A function calls itself |
| Indirect | A calls B, and B calls A |
| Tail recursion | The recursive call is the last operation (can be optimized into a loop by some compilers) |
| Non-tail recursion | Work remains after the recursive call returns |
// Tail-recursive factorial
unsigned long fact_tail(unsigned int n, unsigned long acc) {
if (n <= 1) return acc;
return fact_tail(n - 1, n * acc);
}
Recursion vs iteration
| Recursion | Iteration (loops) | |
|---|---|---|
| Code | Often shorter, elegant for naturally recursive problems | Longer but straightforward |
| Memory | Uses stack for every call | Constant memory |
| Speed | Function call overhead | Faster |
| Risk | Stack overflow if too deep or missing base case | Infinite loop if condition is wrong |
| Good for | Trees, divide and conquer, backtracking | Simple repetition |
Cautions
- Missing or wrong base case leads to infinite recursion and a stack overflow (crash).
- Deep recursion can exhaust the stack.
- In embedded systems, recursion is generally avoided because stack memory is tiny and stack usage must be predictable. Safety standards (like MISRA C) discourage or forbid it.
Inline functions
An inline function is a hint to the compiler to replace the function call with the function's body at the call site, avoiding call overhead.
static inline int square(int x) {
return x * x;
}
int y = square(5); // compiler may replace with: int y = 5 * 5;
Why use inline?
- Removes function-call overhead (pushing arguments, jumping, returning).
- Good for small, frequently called functions.
- Unlike macros, inline functions are type-checked and have no double-evaluation problem.
Inline function vs macro
#define SQUARE(x) ((x) * (x))
SQUARE(i++) // i++ evaluated twice: bug!
static inline int square(int x) { return x * x; }
square(i++) // i++ evaluated once: safe
| Macro | Inline function | |
|---|---|---|
| Handled by | Preprocessor (text substitution) | Compiler |
| Type checking | None | Yes |
| Debugging | Hard | Easier |
| Side-effect safety | Risky | Safe |
| Scope rules | Ignores them | Follows them |
Important points
inlineis only a request. The compiler can ignore it, and may also inline functions you didn't mark.inlinewas introduced in C99.- Typical safe usage: define it as
static inlinein a header file. This avoids linker problems (multiple definition or undefined reference) that can arise with plaininlinerules in C99. - Trade-off: inlining increases code size (code bloat) but can improve speed. In embedded systems you balance flash size vs speed.
- Large functions, recursive functions, and functions whose address is taken are poor inline candidates.
Quick Revision Cheat Sheet
| Topic | Key points |
|---|---|
| Program structure | Preprocessor, globals, prototypes, main(), functions. Execution starts at main(). |
| Compilation | Preprocess, compile, assemble, link. |
| Keywords / identifiers | Reserved words can't be names. Identifiers: letters/digits/_, not starting with a digit. |
| Constants | Literals, #define, const, enum. |
| Variables | Named memory locations; initialize locals. |
| Integer types | char(1) <= short <= int <= long <= long long. Use <stdint.h> for exact sizes. |
| Signed/unsigned | Two's complement; unsigned wraps, signed overflow is undefined. Beware mixed comparisons. |
| Floating point | float ~7 digits, double ~15 digits. Don't use ==. |
| Modifiers / conversion | short/long/signed/unsigned. Implicit promotion vs explicit cast. |
| Operators | Know bitwise tricks; parenthesize to avoid precedence bugs. |
| if / switch | switch needs integer constants and break. |
| Loops | for (counted), while (pre-check), do-while (runs once at least). |
| break / continue / goto | Exit, skip, jump. goto best for cleanup only. |
| Functions | Prototype vs definition. Pass by value; use pointers to modify. |
| Storage classes | auto (default), static (persistent / file-private), extern (defined elsewhere), register (hint). |
| Scope / lifetime | Scope = visibility; lifetime = existence. static locals: long life, small scope. |
| Recursion | Needs a base case; uses stack; avoid in embedded. |
| Inline | Request to expand in place; use static inline; safer than macros. |
Common interview-style questions
- What are the stages of compilation in C?
- What is the difference between a declaration and a definition?
- Why must you not compare floating-point numbers with
==? - What happens when you mix signed and unsigned integers in an expression?
- What is the difference between
++iandi++? - What does
staticmean for a local variable vs a global variable? - What does
externdo, and does it allocate memory? - What is the difference between scope and lifetime?
- Why is C always pass-by-value, and how do you modify a caller's variable?
- What is the difference between a macro and an inline function?
- Why is recursion discouraged in embedded systems?
- What does
volatiletell the compiler?
#Arrays, Strings, Pointers and Memory#
Notes on how C stores groups of data, how pointers give you direct access to memory, and how to manage that memory safely.
Table of Contents
- One-Dimensional and Multidimensional Arrays
- Character Arrays and Strings
- String Handling Functions
- Pointer Fundamentals and Dereferencing
- Pointer Arithmetic
- NULL and void Pointers
- Wild Pointers and Dangling Pointers
- Pointer to Pointer
- Arrays vs Pointers
- Pointers to Arrays and Arrays of Pointers
- Function Pointers and Callback Functions
- Passing Pointers to Functions
- Dynamic Memory Allocation: malloc, calloc, realloc, free
- Stack vs Heap Memory
- Memory Leaks and Buffer Overflow
- Const Pointers and Pointers to Const Data
- Quick Revision Cheat Sheet
1. One-Dimensional and Multidimensional Arrays
What is an array?
An array is a collection of elements of the same type stored in contiguous (side-by-side) memory locations, accessed by an index.
Think of it as a row of numbered lockers, all the same size, one after another.
One-dimensional (1D) arrays
Declaration and initialization
int marks[5]; // 5 ints, values are garbage (if local)
int a[5] = {10, 20, 30, 40, 50}; // fully initialized
int b[] = {1, 2, 3}; // size deduced = 3
int c[5] = {1, 2}; // rest become 0 -> {1, 2, 0, 0, 0}
int d[5] = {0}; // all zeros
Accessing elements
a[0] // first element (indexing starts at 0)
a[4] // last element of a 5-element array
a[2] = 99;
Memory picture (assume 4-byte int, base address 1000)
Index: [0] [1] [2] [3] [4]
Value: 10 20 30 40 50
Address: 1000 1004 1008 1012 1016
Address of a[i] = base address + i * sizeof(element).
Important facts
- Valid indexes are
0tosize - 1. - C does not check array bounds. Going out of range is undefined behaviour (it may crash or silently corrupt data).
- The array size must be known at compile time (or use variable-length arrays in C99, which are optional in later standards and generally avoided).
- Arrays cannot be assigned or compared directly:
b = a;anda == bdon't copy or compare contents. Use loops ormemcpy/memcmp. sizeof(a)gives the total bytes; the element count issizeof(a) / sizeof(a[0]).
Traversing
int n = sizeof(a) / sizeof(a[0]);
for (int i = 0; i < n; i++)
printf("%d ", a[i]);
Multidimensional arrays
Two-dimensional (2D) arrays
Think of a table / matrix with rows and columns.
int m[3][4]; // 3 rows, 4 columns
int g[2][3] = { {1, 2, 3},
{4, 5, 6} };
int h[2][3] = {1, 2, 3, 4, 5, 6}; // same, filled row by row
Accessing: g[row][col], so g[1][2] is 6.
Row-major order: C stores 2D arrays row after row in memory:
g[0][0] g[0][1] g[0][2] g[1][0] g[1][1] g[1][2]
1 2 3 4 5 6
Address of g[i][j] = base + (i * COLS + j) * sizeof(element).
Traversing
for (int i = 0; i < 2; i++) {
for (int j = 0; j < 3; j++)
printf("%d ", g[i][j]);
printf("\n");
}
Rules
- When initializing, you may omit the first dimension only:
int g[][3] = {...}; - When passing to a function, all dimensions except the first must be given:
void print(int arr[][3], int rows);
Three-dimensional (3D) and higher
int cube[2][3][4]; is "2 blocks of 3 rows of 4 columns". Same logic: stored contiguously, last index changes fastest.
Common mistakes
- Off-by-one:
for (i = 0; i <= n; i++)accessesa[n], which is out of bounds. - Reading uninitialized local arrays.
- Assuming
sizeofworks on an array passed into a function (it doesn't; see section 9).
2. Character Arrays and Strings
Strings in C
C has no built-in string type. A string is a character array terminated by the null character '\0' (ASCII value 0).
"Hello" is stored as:
[ 'H' | 'e' | 'l' | 'l' | 'o' | '\0' ]
0 1 2 3 4 5
So a string of length 5 needs 6 bytes. Forgetting space for '\0' is a classic bug.
Declaring and initializing
char s1[] = "Hello"; // size 6, modifiable copy
char s2[10] = "Hi"; // rest filled with '\0'
char s3[] = {'H','i','\0'}; // manual: must add '\0'
char s4[] = {'H','i'}; // NOT a string (no '\0')
const char *s5 = "Hello"; // pointer to a string literal
Array vs pointer to a string literal
char s[] = "Hi"; | char *p = "Hi"; | |
|---|---|---|
| What it is | Array holding a copy | Pointer to a string literal |
| Stored in | Stack (or data segment if global) | Pointer in stack; text in read-only memory |
| Modifiable | Yes: s[0] = 'h'; | No: modifying is undefined behaviour |
sizeof | Array size (3) | Pointer size (4 or 8) |
| Can be reassigned | No (array name is fixed) | Yes (p = "Bye";) |
Prefer
const char *p = "Hi";so the compiler stops accidental modification.
Input and output
char name[20];
printf("%s\n", name); // print a string
scanf("%19s", name); // reads one word; stops at whitespace; limit width!
fgets(name, sizeof(name), stdin); // reads a whole line (safe, includes '\n')
puts(name); // prints string + newline
putchar('A'); getchar(); // single character I/O
scanf("%s", ...)has no bounds checking unless you give a width, so it's unsafe.gets()is removed from the standard because it can't be used safely. Never use it.fgetskeeps the trailing'\n', so strip it if needed:
name[strcspn(name, "\n")] = '\0';
Walking a string manually
int len = 0;
while (s1[len] != '\0')
len++;
Array of strings
char names[3][10] = {"Ann", "Bob", "Cara"}; // 2D char array: fixed width rows
const char *names2[] = {"Ann", "Bob", "Cara"}; // array of pointers: flexible lengths
3. String Handling Functions
Declared in <string.h> (character helpers like isalpha are in <ctype.h>; conversions like atoi are in <stdlib.h>).
Core functions
| Function | Purpose | Notes |
|---|---|---|
strlen(s) | Length (excludes '\0') | Returns size_t |
strcpy(dest, src) | Copy src into dest | No bounds check |
strncpy(dest, src, n) | Copy at most n chars | May not null-terminate if src is n or longer |
strcat(dest, src) | Append src to end of dest | dest needs enough room |
strncat(dest, src, n) | Append at most n chars | Always null-terminates |
strcmp(a, b) | Compare two strings | 0 equal, <0 if a < b, >0 if a > b |
strncmp(a, b, n) | Compare first n chars | |
strchr(s, c) | First occurrence of char | Returns pointer or NULL |
strrchr(s, c) | Last occurrence of char | |
strstr(hay, needle) | Find substring | Returns pointer or NULL |
strtok(s, delim) | Split string into tokens | Modifies the string; not thread-safe |
strspn / strcspn | Length of span of / not of given characters |
Memory (byte) functions, same header
| Function | Purpose |
|---|---|
memcpy(dest, src, n) | Copy n bytes (areas must not overlap) |
memmove(dest, src, n) | Copy n bytes (safe for overlap) |
memset(ptr, value, n) | Fill n bytes with a value |
memcmp(a, b, n) | Compare n bytes |
Examples
#include <string.h>
char a[20] = "Hello";
char b[] = " World";
strcat(a, b); // a = "Hello World"
printf("%zu\n", strlen(a)); // 11
if (strcmp("abc", "abd") < 0) // true: 'c' < 'd'
printf("abc comes first\n");
char *p = strstr(a, "World"); // p points to "World"
char line[] = "red,green,blue";
for (char *t = strtok(line, ","); t != NULL; t = strtok(NULL, ","))
printf("%s\n", t);
Writing your own (understanding the idea)
size_t my_strlen(const char *s) {
const char *p = s;
while (*p) p++;
return p - s;
}
void my_strcpy(char *dst, const char *src) {
while ((*dst++ = *src++) != '\0')
;
}
Pitfalls
- Never compare strings with
==: that compares addresses, not contents. Usestrcmp. - Never assign strings with
=: usestrcpy/strncpy. strcpy,strcatcan overflow the destination, so always ensure it is large enough or use the bounded versions carefully.strncpydoesn't guarantee a'\0', so add it yourself:
strncpy(dst, src, sizeof(dst) - 1);
dst[sizeof(dst) - 1] = '\0';
snprintf(buf, size, "...", ...)is a safer way to build strings.- Modifying a string literal is undefined behaviour.
4. Pointer Fundamentals and Dereferencing
What is a pointer?
A pointer is a variable that stores the memory address of another variable.
int x = 10; int *p = &x;
Variable Address Value
x 2000 10
p 3000 2000 <- p holds the address of x
The two key operators
| Operator | Name | Meaning |
|---|---|---|
& | Address-of | Gives the address of a variable |
* | Dereference (indirection) | Gives the value stored at an address |
int x = 10;
int *p = &x; // p points to x
printf("%d\n", x); // 10
printf("%p\n", (void *)p); // address of x
printf("%d\n", *p); // 10 (value at the address)
*p = 25; // changes x through the pointer
printf("%d\n", x); // 25
Declaration syntax
int *p; // pointer to int
char *c; // pointer to char
float *f; // pointer to float
int *a, *b; // both are pointers
int *x, y; // x is a pointer, y is a plain int (the * binds to the name)
The type matters: it tells the compiler how many bytes to read on dereference and how far to move in pointer arithmetic.
Size of a pointer
All pointers on a given platform are the same size (e.g., 4 bytes on 32-bit, 8 bytes on 64-bit), regardless of the type they point to. (On small 8/16-bit MCUs it may be 1-2 bytes.)
Why use pointers?
- Modify a caller's variables from a function.
- Work with arrays and strings efficiently.
- Allocate memory dynamically.
- Build data structures (linked lists, trees).
- Access hardware registers at fixed addresses (very important in Embedded C):
#define GPIO_PORT (*(volatile unsigned int *)0x40020014)
GPIO_PORT |= (1 << 5);
Structure pointers
struct Point { int x, y; };
struct Point pt = {1, 2};
struct Point *pp = &pt;
pp->x = 10; // same as (*pp).x = 10;
Always initialize
int *p = NULL; // safe default; check before using
5. Pointer Arithmetic
Pointers support a limited set of arithmetic operations, and they are scaled by the size of the pointed-to type.
Allowed operations
| Operation | Meaning |
|---|---|
p + n, p - n | Move forward/backward by n elements |
p++, p-- | Move to next/previous element |
p2 - p1 | Number of elements between two pointers (same array); type ptrdiff_t |
p1 < p2, ==, != | Comparison (meaningful within the same array) |
Not allowed: adding two pointers, multiplying, dividing.
Scaling
int a[5] = {10, 20, 30, 40, 50};
int *p = a; // points to a[0], address 1000
p + 1 // address 1004 (moves 1 * sizeof(int) = 4 bytes)
p + 2 // address 1008
New address = old address + n * sizeof(type).
For a char *, p + 1 moves 1 byte. For a double *, it moves 8 bytes.
Relationship with array indexing
a[i] == *(a + i) == *(p + i) == p[i]
for (int *p = a; p < a + 5; p++)
printf("%d ", *p);
Operator precedence with pointers (a common trap)
| Expression | Meaning |
|---|---|
*p++ | Return *p, then move pointer (postfix ++ binds first, but yields the old pointer) |
(*p)++ | Increment the value pointed to |
*++p | Move pointer first, then read |
++*p | Increment the value pointed to, then read |
int a[] = {10, 20, 30};
int *p = a;
int v = *p++; // v = 10, p now points to a[1]
Difference between pointers
int *start = &a[0];
int *end = &a[4];
ptrdiff_t n = end - start; // 4 (elements, not bytes)
Rules and cautions
- Arithmetic is only valid inside the same array (or one element past the end). Going beyond is undefined behaviour.
- You may form a pointer one past the last element (for loop end checks) but must not dereference it.
void *arithmetic is not standard C (GCC allows it as an extension, treating size as 1). Cast tochar *for byte-wise movement.- Pointer arithmetic is used heavily in parsing buffers, memory-mapped registers, and byte-level protocols.
6. NULL and void Pointers
NULL pointer
A null pointer is a pointer that points to nothing. NULL is a macro (from <stddef.h>, <stdio.h>, <stdlib.h>) representing a null pointer constant, usually ((void *)0).
int *p = NULL;
if (p != NULL) {
printf("%d", *p); // safe: only dereference when valid
}
Uses
- Indicates "no valid address yet" or "not set".
- Return value for failure (
mallocreturnsNULLwhen allocation fails;strchrreturnsNULLif not found). - End marker in linked lists and trees.
- Safe default for uninitialized pointers.
Key facts
- Dereferencing
NULLis undefined behaviour and usually crashes (segmentation fault on systems with an MMU). NULLis not the same as an uninitialized pointer, and not the same as'\0'(that's a character) or an integer0variable. A literal0in pointer context is a null pointer constant.- Setting freed pointers to
NULLprevents dangling pointer bugs (see section 7). if (p)andif (!p)are common shorthand for non-NULL / NULL checks.
Embedded note: On some microcontrollers, address
0is a valid location (e.g., the vector table or a register). The compiler still treats a null pointer constant as null, but be careful when you genuinely need to access address 0.
void pointer (generic pointer)
A void * can hold the address of any data type, but it has no type information, so it can't be dereferenced directly.
int x = 5;
float f = 2.5f;
void *vp;
vp = &x; // OK
printf("%d\n", *(int *)vp); // cast before dereferencing
vp = &f;
printf("%f\n", *(float *)vp);
Rules
- In C,
void *converts to/from any object pointer type implicitly (no cast needed; in C++ you need one). - You must cast to a proper type before dereferencing.
- Pointer arithmetic on
void *is not standard (see section 5). - Cannot hold function pointers portably.
Where void * is used
malloc/calloc/reallocreturnvoid *.memcpy(void *dest, const void *src, size_t n),memset,qsort, which work on any data type.- Generic containers, callbacks with user data (
void *user_data).
Generic swap example
void swap(void *a, void *b, size_t size) {
unsigned char tmp[size]; // VLA; or use malloc
memcpy(tmp, a, size);
memcpy(a, b, size);
memcpy(b, tmp, size);
}
NULL vs void pointer
| NULL pointer | void pointer | |
|---|---|---|
| Meaning | Points to nothing | Points to something of unknown type |
| Dereference | Never | After casting |
| Purpose | Invalid/empty marker | Generic data handling |
7. Wild Pointers and Dangling Pointers
Two of the most common sources of crashes and security bugs.
Wild pointer
A pointer that has been declared but not initialized, so it holds a random garbage address.
int *p; // wild pointer: points somewhere unknown
*p = 10; // undefined behaviour: may crash or corrupt memory
Fixes
int *p = NULL; // initialize to NULL
int x; int *q = &x; // or point to a valid object
Always initialize pointers at declaration.
Dangling pointer
A pointer that points to memory that has already been freed or has gone out of scope. The address looks valid but the memory is no longer yours.
Cause 1: freeing memory and still using the pointer
int *p = malloc(sizeof(int));
*p = 5;
free(p);
*p = 10; // dangling: memory was freed
Fix:
free(p);
p = NULL; // now any accidental use is a clean NULL deref, easy to detect
Cause 2: returning the address of a local variable
int *bad(void) {
int x = 10;
return &x; // x dies when the function returns
}
Fixes: make it static, allocate with malloc, or have the caller supply the buffer.
Cause 3: variable goes out of scope
int *p;
{
int x = 7;
p = &x;
} // x is destroyed here
*p; // dangling
Related problem: double free
Calling free twice on the same pointer is undefined behaviour and can corrupt the heap. Setting p = NULL after free makes a second free(p) harmless (free(NULL) does nothing).
Comparison
| Wild pointer | Dangling pointer | |
|---|---|---|
| Cause | Never initialized | Pointed memory is no longer valid |
| Holds | Random address | A once-valid address |
| Prevention | Initialize at declaration | Set to NULL after free; avoid returning local addresses |
Good habits
- Initialize every pointer.
- Set to
NULLright afterfree. - Never return pointers to local (automatic) variables.
- Use compiler warnings (
-Wall -Wextra) and tools like Valgrind / AddressSanitizer to catch these.
8. Pointer to Pointer
A pointer to pointer (double pointer) stores the address of another pointer.
int x = 10;
int *p = &x; // p holds address of x
int **pp = &p; // pp holds address of p
pp ---> p ---> x
(int **) (int *) (int)
4000 3000 2000
holds holds holds
3000 2000 10
Dereferencing levels
**pp // value of x (10)
*pp // value of p (address of x)
pp // address of p
**pp = 50; // changes x to 50
Why use it?
1. Modify a pointer inside a function (since C passes by value, you need the pointer's address)
void allocate(int **ptr) {
*ptr = malloc(sizeof(int));
**ptr = 42;
}
int *p = NULL;
allocate(&p); // p now points to allocated memory
Without the double pointer, the function would only change a local copy of p.
2. Dynamic 2D arrays / array of strings
int **matrix = malloc(rows * sizeof(int *));
for (int i = 0; i < rows; i++)
matrix[i] = malloc(cols * sizeof(int));
3. Command-line arguments
int main(int argc, char **argv) // same as char *argv[]
argv is an array of char * (strings).
4. Linked-list head modification
void push(struct Node **head, int value) { /* may change *head */ }
Pointer to pointer to pointer
int ***ppp; is possible but rarely needed. Each level adds one *.
Cautions
- Each level must be properly initialized and allocated.
- Free in the reverse order of allocation (inner blocks first, then the outer array).
9. Arrays vs Pointers
Arrays and pointers are closely related but not the same thing. This is one of the most-asked topics.
Why they look the same
In most expressions, an array name decays into a pointer to its first element.
int a[5];
int *p = a; // same as: int *p = &a[0];
That's why a[i] and *(a + i) work the same, and why p[i] works on a pointer.
Key differences
| Feature | Array | Pointer |
|---|---|---|
| What it is | Block of contiguous elements | Variable holding an address |
| Memory allocated | For all elements | Only for the address itself |
sizeof | Total bytes (5 * 4 = 20) | Pointer size (4 or 8) |
| Reassignment | Not allowed (a = b; error) | Allowed (p = q;) |
| Name as value | Constant address of first element | Variable holding any address |
&name | Address of the whole array (type int (*)[5]) | Address of the pointer variable (type int **) |
| Increment | a++ is an error | p++ is fine |
| Initialization | int a[3] = {1,2,3}; | int *p = a; or malloc |
| Size known | Yes, at compile time | No, doesn't know what it points to |
When an array does NOT decay
- As an operand of
sizeof. - As an operand of
&. - As a string literal used to initialize a
chararray.
int a[5];
sizeof(a); // 20
sizeof(&a[0]); // 4 or 8 (pointer)
Arrays as function parameters
void f(int arr[]) // identical to: void f(int *arr)
{
sizeof(arr); // size of a pointer, NOT the array
}
The array is passed as a pointer, and the size is lost, so always pass the length separately:
void f(int *arr, size_t n);
a vs &a vs &a[0]
int a[5];
a // int * (decayed), points to first element
&a[0] // int * same address as a
&a // int (*)[5] same address, but type = pointer to whole array
a + 1 moves by one int; &a + 1 moves by the whole array (20 bytes).
Strings: the same idea
char s[] = "Hi"; // modifiable array copy
char *p = "Hi"; // pointer to read-only literal
(See section 2.)
Summary
An array is a block of memory. A pointer holds the address of a block of memory. Arrays convert to pointers automatically in most expressions, which creates the illusion that they're the same.
10. Pointers to Arrays and Arrays of Pointers
These two sound alike but are different. The key is operator precedence: [] binds tighter than *.
Array of pointers
An array whose elements are pointers.
int *arr[3]; // arr is an array of 3 pointers to int
int a = 1, b = 2, c = 3;
int *arr[3] = { &a, &b, &c };
printf("%d\n", *arr[1]); // 2
Most common use: array of strings
const char *days[] = {"Mon", "Tue", "Wed"};
printf("%s\n", days[1]); // Tue
Each element points to a string of different length. This saves space compared to a 2D char array with fixed width.
Other uses
- Jagged (irregular) 2D arrays.
- Lookup tables of strings or structures.
- Array of function pointers (see section 11).
Memory picture
arr[0] ---> 1
arr[1] ---> 2
arr[2] ---> 3
Pointer to an array
A single pointer that points to an entire array (including its size in the type).
int (*p)[3]; // p is a pointer to an array of 3 ints
Parentheses are required. Without them, int *p[3] is an array of pointers.
int a[3] = {10, 20, 30};
int (*p)[3] = &a;
printf("%d\n", (*p)[1]); // 20
p + 1 moves by 3 * sizeof(int) bytes, the whole array.
Main use: 2D arrays
int m[2][3] = { {1,2,3}, {4,5,6} };
int (*row)[3] = m; // pointer to the first row (an array of 3 ints)
printf("%d\n", row[1][2]); // 6
printf("%d\n", *(*(row + 1) + 2)); // 6, same thing
Here m decays to int (*)[3] (pointer to a row), not int **.
A 2D array
int m[2][3]is not compatible withint **. The memory layout differs: one is a contiguous block, the other is an array of separate pointers.
Comparison
| Array of pointers | Pointer to array | |
|---|---|---|
| Declaration | int *p[3]; | int (*p)[3]; |
| Meaning | 3 pointers | 1 pointer to a 3-int array |
| Storage | 3 pointer slots | 1 pointer |
| Typical use | Array of strings, jagged arrays | Walking rows of a 2D array |
p + 1 moves | One pointer size | The whole array size |
Reading complex declarations (right-left rule)
Start at the name, go right when you see [] or (), go left when you see *.
| Declaration | Reading |
|---|---|
int *p[3] | p is an array of 3 pointers to int |
int (*p)[3] | p is a pointer to an array of 3 ints |
int *f(void) | f is a function returning a pointer to int |
int (*f)(void) | f is a pointer to a function returning int |
11. Function Pointers and Callback Functions
Function pointer
Functions live in memory too, so they have addresses. A function pointer stores the address of a function so you can call it indirectly.
Declaration syntax
return_type (*pointer_name)(parameter_types);
int (*op)(int, int); // pointer to a function taking (int, int) returning int
Parentheses around *op are essential. int *op(int, int); would declare a function returning int *.
Using it
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int (*op)(int, int);
op = add; // or op = &add; (both work)
printf("%d\n", op(5, 3)); // 8 (or (*op)(5, 3))
op = sub;
printf("%d\n", op(5, 3)); // 2
typedef makes it readable
typedef int (*BinaryOp)(int, int);
BinaryOp op = add;
Array of function pointers (jump table / menu / state machine)
int (*ops[])(int, int) = { add, sub };
printf("%d\n", ops[0](8, 2)); // 10
printf("%d\n", ops[1](8, 2)); // 6
This is often used to replace long switch statements, and to implement state machines and command dispatchers.
Callback functions
A callback is a function that you pass as an argument to another function, which then "calls back" your function at the right time.
void apply(int *arr, int n, int (*fn)(int)) {
for (int i = 0; i < n; i++)
arr[i] = fn(arr[i]);
}
int square(int x) { return x * x; }
int negate(int x) { return -x; }
int data[] = {1, 2, 3};
apply(data, 3, square); // {1, 4, 9}
apply(data, 3, negate); // {-1, -4, -9}
Real-world example: qsort
int compare(const void *a, const void *b) {
return *(const int *)a - *(const int *)b; // (beware overflow for extreme values)
}
int nums[] = {5, 2, 9, 1};
qsort(nums, 4, sizeof(int), compare);
qsort calls your compare callback each time it needs to order two elements.
Callbacks with user data
typedef void (*Callback)(int event, void *user_data);
void register_handler(Callback cb, void *ctx);
The void *user_data lets the callback receive context without global variables.
Why they matter, especially in embedded
- Interrupt handlers and vector tables are arrays of function pointers.
- Event-driven code: register a function to run when a button is pressed or a timer fires.
- Hardware abstraction / drivers: swap implementations (e.g., different UART or sensor drivers) behind the same interface.
- State machines: state table of handler functions.
- Generic algorithms (sorting, searching, filtering) that accept custom behaviour.
Cautions
- The signature of the function must exactly match the pointer type.
- Calling an uninitialized or
NULLfunction pointer crashes. Check before calling:
if (cb != NULL) cb(data);
- Casting function pointers to other types and calling them is undefined behaviour.
12. Passing Pointers to Functions
C passes arguments by value (a copy). To let a function modify the caller's data or avoid copying big data, pass a pointer.
1. Modify a variable
void increment(int *n) {
(*n)++;
}
int x = 5;
increment(&x); // x becomes 6
2. Return multiple results
void divide(int a, int b, int *quotient, int *remainder) {
*quotient = a / b;
*remainder = a % b;
}
3. Pass arrays (decays to a pointer)
void fill(int *arr, size_t n, int value) {
for (size_t i = 0; i < n; i++)
arr[i] = value;
}
Always pass the size along with the pointer.
4. Pass large structures efficiently
void print_student(const struct Student *s) { // avoids copying the whole struct
printf("%s\n", s->name);
}
5. Pass strings
size_t count_vowels(const char *s);
6. Pass pointer to pointer (change the pointer itself)
void create(int **p) {
*p = malloc(sizeof(int));
}
7. Pass function pointers
(Callbacks, see section 11.)
Using const for safety
| Prototype | Meaning |
|---|---|
void f(int *p) | Function may modify *p |
void f(const int *p) | Function promises not to modify *p |
This documents intent and lets the compiler catch mistakes.
Pass by value vs pass by pointer
| By value | By pointer | |
|---|---|---|
| Copy made | Yes (the value) | Yes (the address only) |
| Can modify original | No | Yes |
| Cost for big data | High | Low |
| Syntax | f(x) | f(&x), *p inside |
C has no true "pass by reference". A pointer is the address passed by value, which achieves a similar effect.
Checklist for functions that take pointers
- Check for
NULLif it's possible. - Pass sizes for arrays and buffers.
- Use
constwhen not modifying. - Don't return pointers to local variables.
- Document who owns (allocates and frees) the memory.
13. Dynamic Memory Allocation: malloc, calloc, realloc, free
Why dynamic allocation?
Normal variables and arrays have a size fixed at compile time and live on the stack. Dynamic allocation lets you request memory at runtime from the heap, with a size and lifetime you control.
All four functions are in <stdlib.h>.
malloc: memory allocation
void *malloc(size_t size);
Allocates size bytes. Memory is uninitialized (garbage). Returns a pointer, or NULL on failure.
int *p = malloc(5 * sizeof(int));
if (p == NULL) {
// handle allocation failure
}
p[0] = 10;
Best practice: int *p = malloc(n * sizeof *p); which stays correct if the type changes.
calloc: contiguous allocation
void *calloc(size_t count, size_t size);
Allocates count * size bytes and zero-initializes them. It also checks for multiplication overflow.
int *p = calloc(5, sizeof(int)); // five ints, all 0
realloc: resize
void *realloc(void *ptr, size_t new_size);
Changes the size of a previously allocated block. May move it to a new address (contents are copied). Old contents up to the smaller size are preserved.
int *tmp = realloc(p, 10 * sizeof(int));
if (tmp == NULL) {
// p is still valid; handle error and free(p) when done
} else {
p = tmp;
}
Never write p = realloc(p, ...) directly. If it fails you lose the original pointer and leak memory.
Special cases: realloc(NULL, n) behaves like malloc(n). Behaviour of realloc(p, 0) is implementation-defined, so avoid it.
free: release
void free(void *ptr);
Returns the memory to the heap. Only pass pointers returned by malloc / calloc / realloc (or NULL, which is safe).
free(p);
p = NULL; // avoid dangling pointer
Comparison
malloc | calloc | realloc | free | |
|---|---|---|---|---|
| Purpose | Allocate | Allocate + zero | Resize | Release |
| Arguments | 1 (total bytes) | 2 (count, size) | 2 (pointer, new size) | 1 (pointer) |
| Initialized | No | Yes (zeros) | New part: No | N/A |
| Returns | void * or NULL | void * or NULL | void * or NULL | nothing |
Complete example: dynamic array
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int n = 3;
int *arr = malloc(n * sizeof *arr);
if (!arr) return 1;
for (int i = 0; i < n; i++) arr[i] = i + 1;
n = 5;
int *tmp = realloc(arr, n * sizeof *arr);
if (!tmp) { free(arr); return 1; }
arr = tmp;
arr[3] = 4; arr[4] = 5;
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
free(arr);
arr = NULL;
return 0;
}
Dynamic 2D array
int **m = malloc(rows * sizeof *m);
for (int i = 0; i < rows; i++)
m[i] = malloc(cols * sizeof **m);
/* free */
for (int i = 0; i < rows; i++) free(m[i]);
free(m);
Rules to remember
- Always check for
NULLafter allocation. - Every allocation needs exactly one
free. - Don't use memory after freeing it.
- Don't free twice.
- Don't free memory not from the heap (stack variables, string literals).
- Don't access beyond the allocated size.
- Allocated memory is not automatically zeroed (except with
calloc).
Embedded note
In many embedded systems, dynamic allocation is avoided or banned (e.g., MISRA C) because of limited RAM, heap fragmentation, non-deterministic timing, and the difficulty of handling allocation failure. Static allocation or fixed-size memory pools are preferred.
14. Stack vs Heap Memory
Memory layout of a running program
High address
+-----------------------+
| Stack | local variables, function calls
| | | (grows downward)
| v |
| |
| ^ |
| | |
| Heap | malloc / calloc / realloc
| | (grows upward)
+-----------------------+
| .bss | uninitialized globals/statics (zeroed)
+-----------------------+
| .data | initialized globals/statics
+-----------------------+
| .text / .rodata | code and constants
+-----------------------+
Low address
Stack
- Stores local variables, function parameters, return addresses, and saved registers.
- Managed automatically: memory is pushed when a function is called and popped when it returns.
- LIFO (Last In, First Out).
- Each function call creates a stack frame.
void f(void) {
int x = 5; // on the stack
int arr[100]; // on the stack
} // all freed automatically here
Heap
- Used for dynamic allocation (
mallocetc.). - Managed manually by the programmer: you allocate and free.
- Memory stays until you
freeit (or the program ends).
int *p = malloc(100 * sizeof(int)); // block on the heap, p itself on the stack
free(p);
Comparison
| Feature | Stack | Heap |
|---|---|---|
| Management | Automatic (compiler) | Manual (programmer) |
| Speed | Very fast (just move the stack pointer) | Slower (allocator searches for free blocks) |
| Size | Small, limited (e.g., 1-8 MB on PCs; KBs on MCUs) | Large (limited by RAM) |
| Lifetime | Until the function/block ends | Until free is called |
| Fragmentation | None | Can fragment over time |
| Access | Direct, cache-friendly | Through pointers |
| Resizable | No | Yes (realloc) |
| Typical error | Stack overflow (too deep recursion or huge locals) | Memory leak, dangling pointer, fragmentation |
| Thread-safety | Each thread has its own stack | Shared (needs care) |
Common stack problems
- Stack overflow: infinite or very deep recursion, or very large local arrays.
- Returning the address of a local variable.
Common heap problems
- Memory leaks (forgetting to free).
- Fragmentation: many small allocations and frees leave unusable gaps.
- Use-after-free and double free.
- Allocation failure (
NULL).
Where do variables live?
| Variable | Location |
|---|---|
| Local (non-static) variable | Stack |
| Function parameter | Stack (or registers) |
malloc'd block | Heap |
| The pointer that holds the heap address | Stack (if local) |
| Global / static initialized | .data |
| Global / static uninitialized | .bss |
String literals, const globals | Read-only (.rodata) |
When to choose which
- Stack: small, short-lived, known-size data (the default).
- Heap: large data, size known only at runtime, or data that must outlive the function that created it.
Embedded perspective
- The stack is tiny and must be sized carefully in the linker script/startup code. Overflow can silently corrupt other data.
- The heap may be small or absent. Prefer static allocation and memory pools.
15. Memory Leaks and Buffer Overflow
Memory leak
A memory leak happens when dynamically allocated memory is never freed and no pointer to it remains, so it can't be reclaimed until the program ends. Over time, a long-running program runs out of memory.
Common causes
// 1. Forgetting to free
void f(void) {
int *p = malloc(100);
// ... never freed
}
// 2. Losing the only pointer
int *p = malloc(100);
p = malloc(200); // first block is now unreachable: leaked
// 3. Early return before free
char *buf = malloc(256);
if (error) return; // leaked
free(buf);
// 4. realloc misuse
p = realloc(p, big); // if it fails, original p is lost
// 5. Freeing the outer container but not inner blocks (2D arrays, linked lists, structs)
Consequences
- Memory usage grows steadily.
- Slowdowns, allocation failures, crashes.
- Especially serious in embedded devices that run for months or years.
Prevention
- Pair every allocation with a
freeand decide who owns each block. - Use a single exit path (e.g.,
goto cleanup;) to free resources. - Free nested allocations in the right order.
- Set pointers to
NULLafter freeing. - Use tools: Valgrind (
valgrind --leak-check=full ./prog), AddressSanitizer (-fsanitize=address), static analyzers. - In constrained systems, avoid dynamic allocation or use fixed pools.
Buffer overflow
A buffer overflow occurs when a program writes data beyond the end of a buffer (array), overwriting adjacent memory. Reading past the end (buffer over-read) is similarly dangerous.
Classic example
char buf[8];
strcpy(buf, "This string is too long"); // writes beyond 8 bytes
Other causes
int a[5];
a[5] = 1; // off-by-one: out of bounds
char name[10];
scanf("%s", name); // no width limit
gets(name); // no limit at all (removed from C standard)
memcpy(dst, src, wrong_size); // wrong length calculation
Types
| Type | Where | Effect |
|---|---|---|
| Stack overflow (stack buffer overflow) | Local arrays on the stack | Can overwrite return address, saved registers, other locals |
| Heap overflow | malloc'd blocks | Corrupts heap metadata / neighbouring blocks |
| Global/static overflow | .data / .bss | Corrupts neighbouring variables |
Why it's dangerous
- Crashes (segmentation fault) or unpredictable behaviour.
- Data corruption that appears far from the real bug.
- Security vulnerability: attackers can overwrite the return address and hijack execution (code injection / return-oriented attacks).
Stack frame (simplified):
| ... |
| return address | <- overflow can overwrite this
| saved frame ptr|
| buf[8] | <- writing past the end moves upward
| ... |
Prevention
- Check bounds before every write to an array or buffer.
- Use bounded functions:
fgetsinstead ofgetssnprintfinstead ofsprintfstrncpy/strncat(with manual null-termination), or betterstrlcpy-style helpers where availablescanf("%9s", buf)with a field width
- Always leave room for
'\0'. - Validate sizes and lengths from external input (user, network, files).
- Use
sizeof(buf)rather than hard-coded numbers. - Compiler and OS protections: stack canaries (
-fstack-protector), ASLR, non-executable stack (NX),-D_FORTIFY_SOURCE. - Tools: AddressSanitizer, Valgrind, static analysis, fuzzing.
- Follow coding standards such as MISRA C and CERT C.
Safe version of the earlier example
char buf[8];
snprintf(buf, sizeof(buf), "%s", "This string is too long"); // truncates safely
Quick comparison
| Memory leak | Buffer overflow | |
|---|---|---|
| What happens | Allocated memory never released | Writing past a buffer's end |
| Memory affected | Heap | Stack, heap, or globals |
| Symptom | Gradual memory growth | Crash, corruption, security hole |
| Fix | Free exactly once; track ownership | Bounds checking; safe functions |
16. Const Pointers and Pointers to Const Data
const combined with pointers gives two separate things you can lock: the data being pointed to, and the pointer itself.
Read declarations right to left
Read from the identifier leftwards: const applies to what is immediately to its left (or to its right if it's at the very start).
The four combinations
| Declaration | Reading | Change data (*p = x)? | Change pointer (p = &y)? |
|---|---|---|---|
int *p | pointer to int | Yes | Yes |
const int *p or int const *p | pointer to const int | No | Yes |
int *const p | const pointer to int | Yes | No |
const int *const p | const pointer to const int | No | No |
1. Pointer to const data
You can't modify the data through this pointer, but the pointer can point elsewhere.
int a = 10, b = 20;
const int *p = &a;
// *p = 15; // ERROR: can't modify through p
p = &b; // OK: can repoint
Note: a itself may still be changed directly (a = 15;). The pointer just promises not to.
2. Const pointer to data
The pointer is fixed to one address, but the data it points to can change.
int a = 10, b = 20;
int *const p = &a;
*p = 15; // OK: modify data
// p = &b; // ERROR: can't repoint
Must be initialized at declaration.
3. Const pointer to const data
Neither can change.
const int *const p = &a;
// *p = 1; // ERROR
// p = &b; // ERROR
Where this is used
Function parameters: promise not to modify the caller's data.
size_t my_strlen(const char *s);
int sum(const int *arr, size_t n);
That's why strlen, strcmp, and strcpy's source parameter are all const char *.
String literals
const char *msg = "Hello"; // literal is read-only
Read-only tables in Flash (important in embedded)
const uint8_t lookup[] = {1, 2, 3}; // usually placed in Flash, saving RAM
Hardware registers
volatile uint32_t *const GPIO_REG = (volatile uint32_t *)0x40020014;
The register's address is fixed (const pointer), but the value changes (volatile, modifiable).
Read-only status registers
const volatile uint32_t *const STATUS_REG = (const volatile uint32_t *)0x40020018;
Read-only to your code (const), yet can change by hardware (volatile).
Casting away const
const int x = 5;
int *p = (int *)&x;
*p = 10; // undefined behaviour if x was defined as const
Don't do this. It defeats the purpose of const and may crash (data placed in read-only memory).
Quick tricks to remember
constbefore*means the data is constant.constafter*means the pointer is constant.- Both = both constant.
const int *p -> "const" is left of * -> data is const
int *const p -> "const" is right of * -> pointer is const
Passing pointers around with const
- You can pass a non-const pointer to a function expecting a
constpointer (safe). - You can't pass a
const int *to a function expectingint *without a cast, because it would break the promise.
Quick Revision Cheat Sheet
| Topic | Key points |
|---|---|
| 1D arrays | Contiguous, same type, index 0..n-1, no bounds checking |
| 2D arrays | Row-major; omit only the first dimension when initializing/passing |
| Strings | char array ending in '\0'; need length + 1 bytes |
| String functions | strlen, strcpy, strcat, strcmp, strstr; never use == to compare strings |
| Pointer basics | Stores an address; & gets address, * dereferences |
| Pointer arithmetic | Scaled by type size; a[i] == *(a + i) |
| NULL / void | NULL = nothing; void * = generic, cast before dereference |
| Wild / dangling | Wild: uninitialized. Dangling: points to freed/dead memory. Set to NULL |
| Pointer to pointer | int **pp; used to modify a pointer in a function, dynamic 2D arrays, argv |
| Array vs pointer | Array decays to pointer; sizeof differs; array can't be reassigned |
int *a[3] vs int (*a)[3] | Array of pointers vs pointer to array |
| Function pointers | int (*fp)(int, int); used for callbacks, tables, state machines |
| Passing pointers | Modify caller's data, avoid copying; pass size; use const |
| malloc / calloc / realloc / free | Heap allocation; check NULL; one free per allocation; never p = realloc(p, n) |
| Stack vs heap | Stack: automatic, fast, small. Heap: manual, flexible, can leak/fragment |
| Leak / overflow | Leak: not freed. Overflow: write past buffer. Use bounds checks and safe functions |
| const pointers | const int *p (data locked), int *const p (pointer locked), both |
Common interview-style questions
- Why does C use
'\0'to end strings, and how many bytes does"Hello"need? - What is the difference between
char s[] = "Hi"andchar *s = "Hi"? - Why can't you compare two strings with
==? - What is pointer arithmetic, and why is it scaled by type size?
- Difference between a NULL pointer and a void pointer?
- What is a wild pointer? What is a dangling pointer? How do you avoid each?
- Why would you pass a pointer to a pointer into a function?
- What is the difference between
int *a[5]andint (*a)[5]? - What does
sizeofreturn for an array vs a pointer, and why does it change inside a function? - What is the difference between
mallocandcalloc? - Why is
p = realloc(p, n)considered unsafe? - How do stack and heap differ? What goes wrong on each?
- What are memory leaks and buffer overflows, and how do you prevent them?
- What is the difference between
const int *p,int *const p, andconst int *const p? - What is a callback function, and where are function pointers used in embedded systems?
#Structures, Unions, Enums and Preprocessor#
Notes on building your own data types, controlling how they sit in memory, and organizing C code with the preprocessor and multiple files.
Table of Contents
- Structures and Nested Structures
- Unions and Memory Sharing
- Enumerations (enum)
- typedef and Type Aliases
- Structure Padding and Alignment
- Bit-Fields and Their Limitations
- Macros and Function-Like Macros
- Header Files and Include Guards
- Conditional Compilation: #if, #ifdef, #ifndef
- Predefined Macros
- Multi-File C Projects
- extern Declarations and Linkage
- Static Functions and Global Variables
- Inline Functions vs Macros
- Quick Revision Cheat Sheet
1. Structures and Nested Structures
What is a structure?
Arrays group items of the same type. A structure (struct) groups items of different types under one name. Each item is called a member (or field).
Think of it as a form: name, age, and marks all belong to one student.
Declaring and creating
struct Student {
char name[20];
int roll;
float marks;
}; // note the semicolon
struct Student s1; // create a variable
You can also declare a variable together with the definition:
struct Point { int x; int y; } p1, p2;
Initializing
struct Student s1 = {"Asha", 101, 88.5f}; // in order
struct Student s2 = { .roll = 102, .marks = 91.0f, // designated initializers (C99)
.name = "Ravi" }; // any order; unlisted members become 0
Accessing members
| Operator | Used with | Example |
|---|---|---|
. (dot) | A structure variable | s1.roll = 5; |
-> (arrow) | A pointer to a structure | ps->roll = 5; (same as (*ps).roll) |
struct Student *ps = &s1;
printf("%d\n", ps->roll);
Structure operations
- Assignment copies everything (a member-wise copy), even arrays inside:
s2 = s1; - Cannot compare with
==. Compare member by member (ormemcmp, with caution because of padding, see section 5). sizeof(struct Student)gives the total size, which can be larger than the sum of the members (padding).
Arrays of structures
struct Student class[3] = { {"A", 1, 80}, {"B", 2, 75}, {"C", 3, 90} };
for (int i = 0; i < 3; i++)
printf("%s %d\n", class[i].name, class[i].roll);
Structures and functions
void print_by_value(struct Student s); // copies the whole struct (costly if large)
void print_by_pointer(const struct Student *s); // passes only an address (preferred)
void birthday(struct Person *p) { p->age++; } // pointer lets the function modify the original
struct Point make_point(int x, int y); // structs can also be returned by value
Nested structures
A structure can contain another structure as a member.
Method 1: separate definition
struct Date { int day, month, year; };
struct Employee {
char name[20];
struct Date joining; // nested structure
};
struct Employee e = {"Meera", {15, 8, 2024}};
printf("%d\n", e.joining.year); // 2024
Method 2: defined inside
struct Employee {
char name[20];
struct {
int day, month, year;
} joining;
};
Access with chained dots: e.joining.day. Through a pointer: pe->joining.day.
Self-referential structures
A structure can hold a pointer to its own type (the basis of linked lists and trees). It cannot contain itself directly (infinite size).
struct Node {
int data;
struct Node *next; // OK: pointer to same type
};
Other points
- Members can be arrays, pointers, other structs, unions, function pointers, and bit-fields.
- A structure can have a flexible array member as its last member (
int items[];), used with dynamic allocation (C99). - Embedded use: model hardware register maps, sensor data, protocol packets, configuration blocks.
typedef struct {
volatile uint32_t CTRL;
volatile uint32_t STATUS;
volatile uint32_t DATA;
} UART_Regs;
#define UART0 ((UART_Regs *)0x40001000)
UART0->DATA = 'A';
2. Unions and Memory Sharing
What is a union?
A union looks like a structure, but all members share the same memory. Only one member holds a valid value at a time.
union Data {
int i;
float f;
char c;
};
union Data d;
Memory sharing
- Size of a union = size of its largest member (plus any padding needed for alignment).
- Every member starts at the same address.
union Data (size 4 bytes, assuming int/float are 4 bytes)
address: | b0 | b1 | b2 | b3 |
i uses: |---- 4 bytes ------|
f uses: |---- 4 bytes ------|
c uses: | b0 |
d.i = 65;
printf("%c\n", d.c); // reads the same first byte: 'A' (on little-endian)
d.f = 3.14f; // overwrites the bytes; d.i is now meaningless as an int
Structure vs union
| Feature | Structure | Union |
|---|---|---|
| Memory | Each member has its own space | All members share space |
| Size | Sum of members (plus padding) | Size of largest member |
| Active members | All at once | One at a time |
| Changing one member | Others unaffected | Overwrites the others |
| Purpose | Group related data | Save memory / reinterpret same bytes |
Why use unions?
1. Save memory when only one of several values is needed at a time.
2. Tagged union (variant type): a tag says which member is valid.
typedef enum { T_INT, T_FLOAT, T_CHAR } Type;
typedef struct {
Type type; // the tag
union {
int i;
float f;
char c;
} value;
} Variant;
Variant v = { .type = T_FLOAT, .value.f = 2.5f };
3. Access the bytes of a value (very useful in embedded).
union {
uint32_t word;
uint8_t bytes[4];
} x;
x.word = 0x12345678;
// x.bytes[0] is 0x78 on a little-endian CPU, 0x12 on a big-endian CPU
This can also be used to detect endianness.
4. Registers viewed as a whole or as individual fields:
typedef union {
uint8_t reg;
struct {
uint8_t enable : 1;
uint8_t mode : 2;
uint8_t : 5;
} bits;
} ControlReg;
Important cautions
- Reading a member different from the one last written (type punning) is allowed in C99/C11 (reinterprets the bytes), but the result depends on endianness, size, and representation, so it's not portable. (In C++, it's undefined.)
- Only one member is valid at a time, so you must track which one (a tag).
- Only the first member can be initialized with a plain
{}initializer (or use a designated initializer:union Data d = { .f = 1.5f };). - Unions can be nested inside structures and vice versa.
3. Enumerations (enum)
What is an enum?
An enumeration is a type that defines a set of named integer constants. It makes code readable by replacing "magic numbers" with names.
enum Color { RED, GREEN, BLUE };
enum Color c = GREEN;
Values
By default, the first name is 0, and each next is one more.
enum Day { MON, TUE, WED }; // 0, 1, 2
enum Level { LOW = 10, MID = 20, HIGH = 30 }; // explicit values
enum Mix { A = 5, B, C, D = 20, E }; // 5, 6, 7, 20, 21
Duplicates are allowed, but usually unintended.
Using enums
enum State { IDLE, RUNNING, ERROR };
enum State s = IDLE;
switch (s) {
case IDLE: /* ... */ break;
case RUNNING: /* ... */ break;
case ERROR: /* ... */ break;
}
Great for state machines, error/status codes, modes, command IDs.
Key facts
- Enumeration constants are of type
intin C, and they live in the ordinary identifier namespace. Two enums in the same scope cannot reuse a name (REDin two enums clashes). - The enum variable's underlying type is implementation-defined (an integer type big enough to hold the values; commonly
int). - C does not enforce enum safety. You can assign any integer to an enum variable (
c = 99;compiles), so the compiler only warns in stricter modes. - Enum constants are compile-time constants, usable as array sizes and
caselabels, unlikeconst intvariables in C.
Enum vs #define
enum | #define | |
|---|---|---|
| Handled by | Compiler | Preprocessor |
| Scope | Follows normal scope rules | Global from definition on |
| Debugger shows names | Yes | No (replaced before compile) |
| Auto-numbering | Yes | No |
| Type | int constants | Text substitution |
Handy patterns
// Count of items using a final sentinel
typedef enum { SENSOR_TEMP, SENSOR_HUM, SENSOR_PRESS, SENSOR_COUNT } SensorId;
float readings[SENSOR_COUNT];
// Bit flags with shifts
enum {
FLAG_READY = (1 << 0),
FLAG_ERROR = (1 << 1),
FLAG_BUSY = (1 << 2)
};
uint8_t status = FLAG_READY | FLAG_BUSY;
if (status & FLAG_ERROR) { /* ... */ }
With typedef
typedef enum { OFF, ON } Switch;
Switch sw = ON;
4. typedef and Type Aliases
What is typedef?
typedef creates a new name (alias) for an existing type. It does not create a new type, just another name for it.
typedef unsigned int uint;
uint count = 5; // same as: unsigned int count = 5;
Syntax tip
Write it like a normal declaration of a variable, then put typedef in front: the variable's name becomes the alias.
unsigned char byte; // variable declaration
typedef unsigned char byte; // now 'byte' is a type name
With structures, unions, enums
typedef struct {
int x, y;
} Point; // no need to write 'struct' every time
Point p = {1, 2};
typedef struct Node { // name needed for self-reference
int data;
struct Node *next;
} Node;
typedef enum { LOW, HIGH } Level;
typedef union { int i; float f; } Number;
With pointers and arrays
typedef int *IntPtr;
IntPtr a, b; // both are int * (unlike int *a, b; where b is int)
typedef int Row[4]; // Row is "array of 4 ints"
Row r = {1, 2, 3, 4};
Hiding a pointer behind a typedef can confuse readers. Use it carefully.
With function pointers (a big readability win)
typedef void (*Callback)(int);
void register_cb(Callback cb); // instead of: void register_cb(void (*cb)(int));
Fixed-width types
<stdint.h> provides typedefs such as uint8_t, int16_t, uint32_t. You can build your own on top:
typedef uint8_t u8;
typedef uint16_t u16;
typedef uint32_t u32;
Why use typedef?
- Readability: shorter and clearer (
Pointinstead ofstruct Point). - Portability: change the type in one place (
typedef int32_t Counter;). - Abstraction: hide implementation details (
typedef struct Device Device;in a header). - Simplifies complex declarations (function pointers, arrays).
typedef vs #define
typedef | #define | |
|---|---|---|
| Handled by | Compiler | Preprocessor (text replacement) |
| Pointer declarations | typedef int *P; P a, b; both are pointers | #define P int * then P a, b; becomes int *a, b; (only a is a pointer) |
| Scope | Follows scope rules | None |
| Can alias only types | Yes | Anything |
Naming conventions
Many projects use suffixes like _t (uart_config_t). Note that names ending in _t are reserved by POSIX, so some style guides avoid them for user types.
5. Structure Padding and Alignment
The problem
struct A {
char c; // 1 byte
int i; // 4 bytes
char d; // 1 byte
};
The members add up to 6 bytes, but sizeof(struct A) is typically 12. Why?
Alignment
CPUs read memory most efficiently (or, on some architectures, only) when data sits at an address that is a multiple of its size. This is alignment.
| Type | Typical alignment |
|---|---|
char | 1 |
short | 2 |
int, float | 4 |
double, 64-bit pointer | 8 |
- A 4-byte
intshould start at an address divisible by 4. - Misaligned access can be slower, or cause a hardware fault on some MCUs (e.g., certain ARM Cortex-M cores).
Padding
The compiler inserts unused padding bytes so each member is properly aligned.
Rules
- Each member is placed at an offset that is a multiple of its own alignment.
- The total size is rounded up to a multiple of the largest member's alignment (so arrays of the struct keep every element aligned).
Layout of struct A (4-byte int):
offset: 0 1 2 3 4 5 6 7 8 9 10 11
| c | . | . | . | i (4) | d | . | . | . |
^ padding ^ ^ tail padding
Total = 12 bytes ( . means padding).
Reordering saves space
Place members from largest to smallest alignment:
struct B {
int i; // offset 0
char c; // offset 4
char d; // offset 5
}; // 2 bytes of tail padding -> size 8
struct B is 8 bytes vs 12 for struct A, with the same members.
Useful tools
#include <stddef.h>
offsetof(struct A, i); // byte offset of a member
sizeof(struct A); // total size including padding
_Alignof(struct A); // alignment requirement (C11); alignof in <stdalign.h>
Packing (removing padding)
Compiler-specific features can remove padding:
// GCC / Clang
struct __attribute__((packed)) P {
char c;
int i;
}; // size 5
// Many compilers
#pragma pack(push, 1)
struct Q { char c; int i; }; // size 5
#pragma pack(pop)
When packing is used: matching a wire/file format, protocol headers, or hardware register layouts so the struct maps byte-for-byte.
Costs and risks
- Misaligned member access can be slow or fault on some CPUs.
- Taking the address of a packed member and using it through a normal pointer can crash or cause undefined behaviour.
- Packed structs are not portable (compiler extension).
Other important points
- Padding contents are indeterminate, so comparing structs with
memcmpmay give false differences. Compare member by member, ormemsetthe struct to 0 before filling it. - Sending a raw struct over a network or storing it in a file is risky: padding, endianness, and type sizes differ between systems. Serialize field by field instead.
- Nested structs follow the same rules (the inner struct's alignment is that of its largest member).
- Unions are also padded to the alignment of their strictest member.
Embedded takeaway
RAM is scarce, so ordering members smartly can save a lot across arrays of structs. But hardware register structs must match the exact memory map, so check for padding with offsetof or static_assert(sizeof(...) == N).
6. Bit-Fields and Their Limitations
What is a bit-field?
A bit-field lets a structure member occupy a specific number of bits rather than a whole byte or word. It's used to pack small values (flags, modes, hardware fields) tightly.
struct Flags {
unsigned int enable : 1; // 1 bit
unsigned int mode : 3; // 3 bits (0 to 7)
unsigned int level : 4; // 4 bits (0 to 15)
};
struct Flags f = {0};
f.enable = 1;
f.mode = 5;
Total here: 8 bits, which likely fits in one unsigned int storage unit (so the struct size is typically 4 bytes, not 1).
Syntax
type member_name : width;
typemust beint,signed int,unsigned int, or_Bool(and implementations may allow others such aschar,short).widthis a constant expression, not larger than the type's bit count.
Special forms
struct Reg {
unsigned a : 4;
unsigned : 4; // unnamed bit-field: padding (unusable bits)
unsigned b : 8;
unsigned : 0; // zero width: force the next field to start at a new storage unit
unsigned c : 4;
};
Example: decoding a hardware status byte
typedef union {
uint8_t value;
struct {
uint8_t ready : 1;
uint8_t error : 1;
uint8_t mode : 2;
uint8_t : 4;
} bits;
} Status;
Status s;
s.value = 0x05;
if (s.bits.ready) { /* ... */ }
Limitations (very important, and a common interview question)
| Limitation | Details |
|---|---|
| Cannot take the address | &f.mode is illegal; bit-fields have no byte address, so you can't have pointers to them |
| Bit ordering is implementation-defined | Whether the first field is the least significant or most significant bit varies by compiler and endianness |
| Storage layout is implementation-defined | Whether fields can straddle a storage-unit boundary, and how they're packed, differs per compiler |
| Alignment/padding unpredictable | Struct size can differ between compilers/platforms |
| Allowed types limited | Only integer types (and _Bool); no floats, arrays, or pointers |
Signedness of plain int bit-fields is implementation-defined | Use signed int or unsigned int explicitly |
| Not portable to other systems | Unsafe for network protocols, file formats, and shared memory between different compilers/CPUs |
| Overflow behaviour | Assigning a value too large for the width is implementation-defined (it is truncated) |
sizeof doesn't work on a bit-field | You can use it on the containing struct only |
| Not atomic | Writing one bit-field is typically a read-modify-write of the whole unit, which can race with interrupts or other threads |
Poor with volatile hardware registers | The compiler may access the register in unexpected widths or multiple times, so some coding standards (e.g., MISRA) discourage them for registers |
| Code size / speed | The compiler generates shift-and-mask code; sometimes less efficient than manual masking |
Bit-fields vs manual masking
| Bit-fields | Bitwise masks and shifts | |
|---|---|---|
| Readability | High | Lower |
| Portability | Low | High |
| Control of exact layout | Poor | Full |
| Typical use | Convenience, internal flags | Hardware registers, protocols |
// Manual alternative (portable and explicit)
#define MODE_SHIFT 1
#define MODE_MASK (0x3u << MODE_SHIFT)
reg = (reg & ~MODE_MASK) | ((mode << MODE_SHIFT) & MODE_MASK);
mode = (reg & MODE_MASK) >> MODE_SHIFT;
Rule of thumb: use bit-fields for convenience when layout doesn't matter. Use explicit masks and shifts when you must match an exact bit layout (registers, protocols).
7. Macros and Function-Like Macros
What is a macro?
A macro is a name defined with #define that the preprocessor replaces with other text before compilation (pure text substitution, no type checking).
Object-like macros (constants)
#define PI 3.14159
#define MAX_SIZE 100
#define LED_PIN 5
#define GREETING "Hello"
int arr[MAX_SIZE];
Remove a macro with #undef:
#undef MAX_SIZE
Function-like macros
Macros that take arguments, like functions, but expand inline as text.
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int r = SQUARE(5); // becomes ((5) * (5))
No space between the macro name and ( in the definition.
Why parentheses matter
#define SQUARE_BAD(x) x * x
SQUARE_BAD(2 + 3) // 2 + 3 * 2 + 3 = 11 (wrong)
#define SQUARE(x) ((x) * (x))
SQUARE(2 + 3) // ((2 + 3) * (2 + 3)) = 25 (right)
Rule: wrap each argument and the whole expression in parentheses.
Side effects: double evaluation
#define SQUARE(x) ((x) * (x))
int i = 3;
int r = SQUARE(i++); // ((i++) * (i++)): i is modified twice, undefined behaviour
Never pass expressions with side effects to function-like macros.
Multi-statement macros: do { } while (0)
#define SWAP(a, b) do { int t = (a); (a) = (b); (b) = t; } while (0)
if (x > y)
SWAP(x, y); // works correctly with if/else and the trailing semicolon
else
...
Without the do { } while (0) wrapper, the macro can break if/else structure.
Preprocessor operators
| Operator | Name | Effect | Example |
|---|---|---|---|
# | Stringification | Turns an argument into a string literal | #define STR(x) #x → STR(hello) becomes "hello" |
## | Token pasting | Joins two tokens into one | #define VAR(n) var##n → VAR(1) becomes var1 |
#define PRINT_INT(x) printf(#x " = %d\n", (x))
PRINT_INT(count); // printf("count" " = %d\n", (count));
#define REG(n) GPIO##n##_REG
REG(A) // GPIOA_REG
Variadic macros (C99)
#define LOG(fmt, ...) printf("[LOG] " fmt "\n", __VA_ARGS__)
LOG("value=%d", x);
Bit-manipulation macros (embedded favourites)
#define BIT(n) (1UL << (n))
#define SET_BIT(r, n) ((r) |= BIT(n))
#define CLEAR_BIT(r, n) ((r) &= ~BIT(n))
#define TOGGLE_BIT(r, n) ((r) ^= BIT(n))
#define READ_BIT(r, n) (((r) >> (n)) & 1UL)
Multi-line macros
Use a backslash \ at the end of each line (nothing after it):
#define CHECK(cond) \
do { \
if (!(cond)) { \
handle_error(#cond); \
} \
} while (0)
Advantages and disadvantages
| Advantages | Disadvantages |
|---|---|
| No function-call overhead | No type checking |
| Work with any type (generic) | Side effects and double evaluation |
| Compile-time constants and conditional code | Harder to debug (debugger doesn't see them) |
Can build code from tokens (##, #) | Easy to get precedence errors |
| Larger code if heavily expanded | |
| No scope (global from the point of definition) |
Good practice
- Name macros in UPPER_CASE to distinguish them.
- Parenthesize everything.
- Prefer
const,enum, andstatic inlinefunctions when possible. - Don't end a macro definition with a semicolon (unless intended).
8. Header Files and Include Guards
Header files
A header file (.h) holds declarations that several source files share, while the definitions (actual code and variable storage) live in .c files.
Typically contains
- Function prototypes
struct,union,enum,typedefdeclarations#defineconstants and macrosexternvariable declarationsstatic inlinefunction definitions
Should NOT contain
- Function definitions (non-inline)
- Variable definitions (they'd be duplicated in every file that includes it, causing linker errors)
#include forms
| Form | Search order | Use for |
|---|---|---|
#include <stdio.h> | System/compiler include paths | Standard and system headers |
#include "mylib.h" | Current file's directory first, then system paths | Your own headers |
#include simply pastes the contents of the file at that point.
The problem: multiple inclusion
// a.h
struct Point { int x, y; };
// b.h
#include "a.h"
// main.c
#include "a.h"
#include "b.h" // a.h now included twice -> "redefinition of struct Point" error
Solution 1: include guards (standard and portable)
// mylib.h
#ifndef MYLIB_H
#define MYLIB_H
/* declarations here */
int add(int a, int b);
#endif /* MYLIB_H */
How it works: the first time, MYLIB_H isn't defined, so the content is included and MYLIB_H gets defined. The next time, #ifndef fails and the content is skipped.
Naming: make guard names unique, such as PROJECT_MODULE_H.
Solution 2: #pragma once
#pragma once
Shorter and supported by nearly all compilers, but not part of the C standard. Include guards remain the most portable.
A well-formed header: example
// sensor.h
#ifndef SENSOR_H
#define SENSOR_H
#include <stdint.h> // headers needed by this header
#define SENSOR_MAX_READINGS 16
typedef struct {
uint16_t raw;
float value;
} SensorReading;
extern int sensor_error_count; // declaration only
void sensor_init(void);
SensorReading sensor_read(void);
#endif /* SENSOR_H */
Good practices
- Each
.cfile includes its own header first (this proves the header is self-sufficient). - Include only what you need to reduce compile time and coupling.
- Use forward declarations (
struct Foo;) when only a pointer is needed, to avoid heavy includes. - Avoid circular includes (A includes B, B includes A).
- Keep headers free of definitions with external linkage.
- Place
extern "C"wrappers only if mixing with C++ (outside C-only notes).
9. Conditional Compilation: #if, #ifdef, #ifndef
Conditional compilation lets the preprocessor include or exclude parts of code before the compiler sees it. Excluded code produces no machine code at all.
Directives
| Directive | Meaning |
|---|---|
#ifdef NAME | Include if NAME is defined |
#ifndef NAME | Include if NAME is not defined |
#if expr | Include if the constant expression is non-zero |
#elif expr | Else-if |
#else | Else |
#endif | Ends the block |
defined(NAME) | Operator usable inside #if / #elif |
#undef NAME | Removes a definition |
#error "msg" | Stop compiling with a message |
#warning "msg" | Print a warning (common extension) |
Basic examples
#define DEBUG
#ifdef DEBUG
printf("Debug mode\n");
#endif
#ifndef BUFFER_SIZE
#define BUFFER_SIZE 128 // default if not already defined
#endif
#if with expressions
#define VERSION 3
#if VERSION >= 2
/* new features */
#elif VERSION == 1
/* old features */
#else
/* fallback */
#endif
#if defined(USE_UART) && !defined(USE_SPI)
/* ... */
#endif
- Only integer constant expressions are allowed (no
sizeof, no casts, no variables). - Undefined names in
#ifare treated as 0.
#ifdef vs #if defined() vs #if
#ifdef X= "is X defined at all?" (even#define X 0counts as defined).#if X= "is X non-zero?"#if defined(X) && ...lets you combine conditions.
Commenting out code blocks
#if 0
/* this code is disabled; nested comments are fine here */
#endif
Common uses
1. Include guards (see section 8).
2. Debug vs release builds
#ifdef DEBUG
#define LOG(msg) printf("%s\n", msg)
#else
#define LOG(msg) ((void)0)
#endif
3. Platform and hardware selection
#if defined(STM32F4)
#include "stm32f4xx.h"
#elif defined(ATMEGA328P)
#include <avr/io.h>
#else
#error "Unsupported target MCU"
#endif
4. Feature toggles
#ifdef ENABLE_LOGGING
log_init();
#endif
5. Compiler-specific code
#if defined(__GNUC__)
#define PACKED __attribute__((packed))
#elif defined(_MSC_VER)
#define PACKED
#endif
6. Standard-version checks
#if __STDC_VERSION__ >= 201112L
/* C11 features available */
#endif
Defining macros from the command line
gcc -DDEBUG -DBUFFER_SIZE=256 main.c # same as #define DEBUG, #define BUFFER_SIZE 256
gcc -UDEBUG main.c # undefine
This is how build systems switch configurations without editing source.
Cautions
- Too many
#ifdefs make code hard to read and test (all combinations must be built and checked). - Always match every
#ifwith an#endif, and add comments to long blocks:
#endif /* ENABLE_LOGGING */
- Excluded code is not compiled at all, so errors inside it go unnoticed until it's enabled.
- Prefer a few well-named configuration macros, ideally in one config header.
10. Predefined Macros
Predefined macros are defined automatically by the compiler or standard. You don't define them yourself.
Standard predefined macros
| Macro | Meaning | Type |
|---|---|---|
__FILE__ | Name of the current source file | string literal |
__LINE__ | Current line number | integer |
__DATE__ | Compilation date ("Oct 10 2026") | string literal |
__TIME__ | Compilation time ("14:30:05") | string literal |
__STDC__ | 1 if the compiler conforms to standard C | integer |
__STDC_VERSION__ | C standard version: 199901L (C99), 201112L (C11), 201710L (C17) | long integer |
__STDC_HOSTED__ | 1 for a hosted environment (with an OS and full library), 0 for freestanding (typical bare-metal) | integer |
__func__ | Name of the current function (a predefined identifier, not a macro, since C99) | static const char[] |
__FILE__and__LINE__can be altered with the#linedirective.
Example: simple logging and assertions
#include <stdio.h>
#define LOG(msg) \
printf("[%s:%d in %s] %s\n", __FILE__, __LINE__, __func__, msg)
#define ASSERT(cond) \
do { \
if (!(cond)) { \
printf("Assertion failed: %s (%s:%d)\n", \
#cond, __FILE__, __LINE__); \
} \
} while (0)
int main(void) {
LOG("Starting");
ASSERT(1 + 1 == 3);
printf("Built on %s at %s\n", __DATE__, __TIME__);
return 0;
}
Compiler- and platform-specific macros (not standard, but common)
| Macro | Indicates |
|---|---|
__GNUC__, __GNUC_MINOR__ | GCC (also Clang defines it) |
__clang__ | Clang |
_MSC_VER | Microsoft Visual C++ |
__ARMCC_VERSION | Arm Compiler |
__arm__, __ARM_ARCH | ARM targets |
__AVR__ | AVR targets |
__x86_64__, _WIN32, __linux__, __APPLE__ | Architecture / OS |
__OPTIMIZE__ | Optimization enabled (GCC) |
You can list a GCC compiler's predefined macros:
gcc -dM -E - < /dev/null
Other standard-related macros
__STDC_IEC_559__,__STDC_NO_VLA__,__STDC_NO_ATOMICS__: feature indicators.__COUNTER__: non-standard but widely supported (GCC, Clang, MSVC), increments each time it's used (useful for unique names).
Typical uses
- Debug logging with file, line, and function.
- Custom assert and error-reporting macros.
- Version/build stamping (
__DATE__,__TIME__in firmware version strings). Note these make builds non-reproducible (the binary changes every build). - Portability checks (compiler/OS/architecture detection).
- Standard-version-specific code paths.
Caveat
__DATE__ / __TIME__ give the time the file was compiled, not when the program runs.
11. Multi-File C Projects
Why split code into multiple files?
- Organization: one module per responsibility (UART, sensor, display).
- Reusability and testing of modules in isolation.
- Faster rebuilds: only changed files recompile.
- Teamwork: people work on different modules.
- Information hiding: expose a small public interface, keep the rest private.
The module pattern: .h + .c pair
module.h: the public interface (prototypes, types, constants, extern declarations).module.c: the implementation, which includes its own header.
Example project layout
project/
├── main.c
├── math_utils.h
├── math_utils.c
├── sensor.h
├── sensor.c
└── Makefile
math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
int add(int a, int b);
int multiply(int a, int b);
#endif
math_utils.c
#include "math_utils.h"
int add(int a, int b) { return a + b; }
int multiply(int a, int b) { return a * b; }
main.c
#include <stdio.h>
#include "math_utils.h"
int main(void) {
printf("%d\n", add(2, 3));
return 0;
}
Building
Each .c file is compiled separately into an object file, then the linker combines them.
main.c --compile--> main.o \
math_utils.c --compile--> math_utils.o --link--> program
sensor.c --compile--> sensor.o /
gcc -c main.c # main.o
gcc -c math_utils.c # math_utils.o
gcc main.o math_utils.o -o program
# or in one step
gcc main.c math_utils.c -o program
Translation unit
A translation unit is one .c file after preprocessing (with all its #includes expanded). The compiler works on one translation unit at a time and knows nothing about the others, which is why declarations in headers are needed.
How the pieces connect
- A function defined in
a.cand declared ina.hcan be called fromb.c(which includesa.h). The linker matches the call to the definition. - Global variables: define once in a
.cfile, declare withexternin the header (see section 12).
A simple Makefile
CC = gcc
CFLAGS = -Wall -Wextra -g
OBJS = main.o math_utils.o sensor.o
program: $(OBJS)
$(CC) $(OBJS) -o program
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) program
(Recipe lines must start with a tab.) Make rebuilds only what changed.
Common errors
| Error | Likely cause |
|---|---|
undefined reference to 'foo' | Declared but never defined, or the .c file isn't in the build/link |
multiple definition of 'x' | Variable or function defined in a header included by several .c files |
redefinition of struct ... | Missing include guards |
implicit declaration of function | Missing prototype/header include |
conflicting types | Declaration and definition don't match |
Good practices
- One header per module, with include guards.
- Each
.cincludes its own header first. - Put only declarations in headers.
- Make internal helpers and variables
static(section 13). - Keep the public interface small and well-commented.
- Use a build system (Make, CMake) and compiler warnings (
-Wall -Wextra).
Embedded project flavour
A typical firmware project adds: startup code, a linker script, HAL/driver modules, configuration headers, and main.c, all linked into one firmware image.
12. extern Declarations and Linkage
Declaration vs definition (recap)
| Declaration | Definition | |
|---|---|---|
| Purpose | Announces name and type | Creates the object (reserves memory) / provides the function body |
| How many times | Many | Exactly once in the whole program (One Definition Rule) |
| Variable example | extern int count; | int count = 0; |
extern
extern tells the compiler: "this variable/function exists, defined in another translation unit (or later); don't allocate memory here."
Sharing a global across files
config.h
extern int retry_count; // declaration: no memory allocated
config.c
#include "config.h"
int retry_count = 3; // the one definition
main.c
#include "config.h"
void f(void) { retry_count++; } // uses the shared variable
Pitfall: defining int retry_count = 3; inside the header would create one copy per file that includes it and cause multiple definition linker errors.
Functions are extern by default, so extern is optional on prototypes:
int add(int, int); // same as extern int add(int, int);
Linkage: who can see a name?
Linkage determines whether the same name in different scopes/files refers to the same object.
| Linkage | Meaning | How you get it |
|---|---|---|
| External | Name refers to the same entity across all translation units | File-scope variables and functions by default; extern |
| Internal | Visible only within one translation unit | static at file scope |
| None | Unique to its scope, can't be linked | Local variables, parameters, typedef names, members |
Quick table
| Declaration at file scope | Linkage |
|---|---|
int x = 5; | External (definition) |
extern int x; | External (declaration of x defined elsewhere) |
static int x = 5; | Internal |
int f(void) { ... } | External |
static int f(void) { ... } | Internal |
const int x = 5; | External in C (differs in C++, where const has internal linkage) |
Local variable int x; | None |
Details and gotchas
- Tentative definitions:
int x;at file scope (no initializer) is a tentative definition. Older compilers allowed multiple files to each haveint x;and merged them (common symbol). Modern GCC (10+) defaults to-fno-common, which flags this as a multiple-definition error. Always define once and declare withexternelsewhere. extern int x = 5;at file scope is effectively a definition (with a warning).- Using
externinside a function declares a name with external linkage:
void f(void) {
extern int count; // refers to a global defined elsewhere
}
- Declarations and definitions must have matching types (e.g.,
extern int x;vslong x;is undefined behaviour and the linker won't catch it). externarrays:extern int table[];is fine (incomplete type), butsizeof(table)isn't available there.
Using extern with volatile and const in embedded code
extern volatile uint8_t uart_rx_flag; // set in ISR (defined in another file)
extern const uint8_t font_table[]; // lookup table stored in Flash
Why use extern globals sparingly
- Hidden coupling between modules.
- Harder to test and reason about.
- Race conditions with interrupts.
Prefer accessor functions (
get_count(),set_count()) or pass data via parameters, and keep module statestatic.
13. Static Functions and Global Variables
static at file scope gives a name internal linkage: it is visible only inside that .c file. Think "private to this file".
Static global variables
// counter.c
static int count = 0; // only visible inside counter.c
void counter_increment(void) { count++; }
int counter_get(void) { return count; }
- Other files cannot access
count, even withextern. - Lifetime: whole program. Default value: 0.
- Prevents name collisions between files that happen to use the same variable name.
- Enables encapsulation: expose a controlled interface (functions) instead of raw data.
Static functions
// uart.c
static void uart_wait_ready(void) // helper, not part of the public API
{
/* ... */
}
void uart_send(char c) {
uart_wait_ready();
/* ... */
}
- Cannot be called from other files.
- Safe to reuse the same helper name (
init,helper) in many files. - Lets the compiler optimize more (it knows all the callers; unused static functions can be removed or inlined).
- Unused static functions trigger a compiler warning, which helps catch dead code.
Three different uses of static (don't mix them up)
| Where | Effect |
|---|---|
| Local variable | Keeps its value between calls (static storage duration); still visible only inside the function |
| Global variable | Internal linkage (file-private) |
| Function | Internal linkage (file-private) |
static int file_private = 0; // file-private global
void f(void) {
static int calls = 0; // persistent local
calls++;
}
static void helper(void) { } // file-private function
Global (external) vs static global
| Global (non-static) | Static global | |
|---|---|---|
| Visible in other files | Yes (via extern) | No |
| Linkage | External | Internal |
| Name collisions across files | Possible (link error) | Impossible |
| Encapsulation | None | Good |
| Storage | .data / .bss | .data / .bss |
Encapsulation pattern in C (module with private state)
// stack.h (public interface)
void stack_push(int v);
int stack_pop(void);
// stack.c (private implementation)
static int data[100];
static int top = 0;
void stack_push(int v) { data[top++] = v; }
int stack_pop(void) { return data[--top]; }
Users of stack.h can't touch data or top directly.
Static in headers
A static function or variable defined in a header gives each including file its own private copy. That's fine for static inline functions (section 14) but wasteful for data.
Static variable initialization
- Initialized once, before
mainruns, with a constant expression. - Uninitialized statics are set to zero.
static int a; // 0
static int *p; // NULL
static float f = 2 * 3; // 6.0 (constant expression)
Embedded perspective
- Mark module-internal variables and helpers
staticto keep a clean architecture. - Variables shared with an ISR should be
static volatileif private to the file. - Statics occupy RAM permanently (
.data/.bss), so count them in your RAM budget. - Static locals make functions non-reentrant (shared state across calls and tasks), which matters for ISRs and RTOS threads.
14. Inline Functions vs Macros
Both aim to avoid function-call overhead for small pieces of code, but they work very differently.
Inline function (C99)
A hint to the compiler to replace a call with the function body. The compiler decides.
static inline int square(int x) {
return x * x;
}
Macro
Text substitution by the preprocessor, with no compiler involvement.
#define SQUARE(x) ((x) * (x))
Comparison
| Feature | Macro | Inline function |
|---|---|---|
| Processed by | Preprocessor (text replace) | Compiler |
| Type checking | None | Yes |
| Argument evaluation | Textual, so an argument may be evaluated multiple times | Once (like a normal function) |
| Side-effect safety | Unsafe (SQUARE(i++)) | Safe |
| Scope rules | Ignored (global from definition) | Follows normal scope |
| Debugging | Hard (no symbol; the debugger sees expanded code) | Easier (can step in if not inlined) |
| Can take its address | No | Yes |
| Works with any type | Yes (generic) | No (fixed types, unless using _Generic) |
| Recursion | No | Possible (compiler won't fully inline it) |
| Guaranteed expansion | Always | Not guaranteed; the compiler may ignore the hint |
| Code size | Grows with each use | Grows if inlined |
| Can generate code constructs (names, declarations, stringify) | Yes (#, ##) | No |
| Error messages | Confusing | Clear |
| Return value / local variables | Awkward (needs statement tricks) | Natural |
| Usable outside function bodies (constants, declarations) | Yes | No |
Demonstration of the classic bug
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int x = 5, y = 3;
int m = MAX(x++, y); // x++ may be evaluated twice: undefined / surprising
static inline int max_int(int a, int b) { return a > b ? a : b; }
int n = max_int(x++, y); // x++ evaluated exactly once
Typing example
#define ADD(a, b) ((a) + (b))
ADD(1, 2) // int
ADD(1.5, 2.5) // double (works for any type, but no checking)
ADD("a", 2) // compiles into nonsense or warnings, no clear error
static inline int add_i(int a, int b) { return a + b; }
add_i("a", 2); // compile error/warning: type mismatch caught
Correct way to define inline functions in C
Plain inline in C99/C11 has tricky linkage rules (it may produce undefined reference errors). The simple, reliable pattern:
// in a header file
static inline int clamp(int v, int lo, int hi) {
return v < lo ? lo : (v > hi ? hi : v);
}
Each file gets its own copy if needed, with no link problems.
When to choose which
Use inline functions for
- Small functions called often (getters, simple math, register access helpers).
- When you want type safety and debuggability.
- Replacing function-like macros in new code.
Use macros for
- Constants and configuration (
#define BAUD 9600), thoughenumorconstis often better. - Conditional compilation and include guards.
- Stringification / token pasting and code generation.
- Generic operations over many types (when
_Genericisn't suitable). - Things that must be compile-time constants in the preprocessor.
- Capturing
__FILE__/__LINE__at the call site.
Trade-offs in embedded systems
- Inlining removes call overhead (saves cycles, stack) but increases code size, which matters when Flash is tight.
- Compilers can inline without the keyword at higher optimization levels (
-O2,-O3), and may ignoreinlineat-O0. Use__attribute__((always_inline))(compiler-specific) to force it. - Very short ISR helpers and register accessors are ideal inline candidates.
- Large functions, recursive functions, and functions whose address is taken are poor candidates.
Summary rule of thumb
Prefer
static inlinefunctions over function-like macros whenever possible. Keep macros for what only the preprocessor can do.
Quick Revision Cheat Sheet
| Topic | Key points |
|---|---|
| Structure | Groups different types; . for variables, -> for pointers; can be nested; size may include padding |
| Union | Members share memory; size = largest member; one valid member at a time; use a tag |
| Enum | Named integer constants, start at 0 and auto-increment; no type safety in C |
| typedef | Alias for a type; no new type; great for structs, function pointers, fixed-width types |
| Padding / alignment | Members aligned to their size; reorder largest to smallest; packed removes padding at a cost |
| Bit-fields | type name : bits; can't take the address; layout is implementation-defined; not portable |
| Macros | Text substitution; parenthesize everything; beware double evaluation; do {} while (0) for multi-statement |
# / ## | Stringify / paste tokens |
| Header + guards | Declarations in .h; #ifndef / #define / #endif or #pragma once |
| Conditional compilation | #if, #ifdef, #ifndef, #elif, #else, #endif; -D flags from the command line |
| Predefined macros | __FILE__, __LINE__, __DATE__, __TIME__, __STDC_VERSION__, __func__ |
| Multi-file projects | .h = interface, .c = implementation; compile separately, then link |
| extern / linkage | Declare with extern, define once; external / internal / none linkage |
| static (file scope) | Internal linkage: file-private variables and functions |
| Inline vs macro | Prefer static inline: type-safe, no double evaluation; macros for constants, stringify, conditional code |
Common interview-style questions
- What is the difference between a structure and a union?
- How is the size of a structure and of a union determined?
- Why can
sizeof(struct)be larger than the sum of its members? How can you reduce it? - What is structure padding, and what are the risks of using
packed? - What are bit-fields, and why are they considered non-portable?
- Difference between
enumand#defineconstants? - What does
typedefdo, and how is it different from#define? - Why do function-like macros need so many parentheses? What is the
do { } while (0)idiom? - What are include guards, and what problem do they solve?
- Explain
#if,#ifdef,#ifndef, and give a real use case. - What are
__FILE__,__LINE__, and__func__used for? - How do you share a global variable between two
.cfiles correctly? - What is the difference between declaration and definition?
- What does
staticdo on a global variable or function? - When would you choose a macro over an inline function, and vice versa?
#Advanced C Concepts for Embedded Systems#
Notes on the corners of C that cause the most real-world embedded bugs: qualifiers, type conversions, undefined behavior, memory representation, bit manipulation, concurrency, and safe coding rules.
Table of Contents
- const, volatile, restrict and static
- Integer Promotion and Usual Arithmetic Conversions
- Fixed-Width Integers: uint8_t, uint16_t, uint32_t
- sizeof Operator and Data Model
- Undefined, Unspecified and Implementation-Defined Behavior
- Sequence Points and Expression Evaluation
- Endianness: Little-Endian vs Big-Endian
- Bitwise Operators and Bit Manipulation
- Shifting and Masking
- Integer Overflow and Signedness Issues
- Reentrancy and Thread Safety
- Atomic Operations and Race Conditions
- Function Pointers and Jump Tables
- C Standard Library Limitations in Embedded Systems
- MISRA C Overview and Coding Guidelines
- Quick Revision Cheat Sheet
1. const, volatile, restrict and static
These four keywords tell the compiler (and the reader) how a variable may be used or where it lives. In embedded code they matter far more than on a PC.
const: "I won't modify this"
Marks data as read-only through that name.
const int limit = 100;
// limit = 5; // compile error
const char *msg = "Hi"; // pointer to const data
char *const p = buf; // const pointer to data
Embedded benefits
- Global
constdata is typically placed in Flash (read-only memory) instead of RAM, saving scarce RAM.
const uint8_t sine_table[256] = { /* ... */ };
- Documents intent:
void send(const uint8_t *data, size_t n);promises not to change the buffer. - Catches accidental writes at compile time.
On Harvard-architecture MCUs (such as AVR),
constalone may not keep data out of RAM. You may also need a special keyword (likePROGMEM) to place it in Flash.
Important: const does not mean the value can never change. A const object can still be changed by hardware or by another name for the same memory. Casting const away and modifying a truly const object is undefined behaviour.
volatile: "this can change behind your back"
Tells the compiler that a variable's value can change without any code in the current flow modifying it, so every read and write must actually happen and not be optimized away or reordered across other volatile accesses.
When to use volatile
| Situation | Example |
|---|---|
| Memory-mapped hardware registers | #define STATUS (*(volatile uint32_t *)0x40010000) |
Variables modified in an ISR and read in main | volatile bool data_ready; |
| Variables shared between threads/tasks (as part of a larger strategy; see section 12) | flags set by another task |
| Busy-wait / polling loops | while (!(STATUS & READY)) { } |
What goes wrong without it
bool flag = false; // NOT volatile
void ISR(void) { flag = true; }
int main(void) {
while (!flag) { } // the compiler may read 'flag' once and loop forever
}
At optimization levels like -O2, the compiler sees nothing in the loop that changes flag, so it caches the value in a register. Declaring volatile bool flag; forces a fresh read each time.
const volatile: a read-only value that the hardware can change.
const volatile uint32_t *const ADC_RESULT = (const volatile uint32_t *)0x40012000;
Your code can't write it (const), but its value changes on its own (volatile).
Limits of volatile (very commonly misunderstood)
- It does not make operations atomic.
counter++on a volatile variable is still read, modify, write. - It does not provide memory barriers or guarantee ordering between different variables on multi-core or cache-based systems.
- It does not make code thread-safe by itself.
- It prevents optimization, so overusing it hurts performance.
restrict (C99): "no other pointer touches this data"
A pointer qualifier promising the compiler that, during the pointer's lifetime, the object it points to is accessed only through that pointer (no aliasing).
void add_arrays(int *restrict dst, const int *restrict a, const int *restrict b, size_t n) {
for (size_t i = 0; i < n; i++)
dst[i] = a[i] + b[i];
}
Why it helps: the compiler can safely keep values in registers, reorder, unroll, and vectorize loops, because writes through dst can't change what a or b see.
Rules and risks
- It is a promise from you. If the pointers actually overlap, the behaviour is undefined.
- Standard library example:
memcpyusesrestrict(source and destination must not overlap), whilememmovedoes not (overlap is allowed). - Only a hint for optimization; it never changes correct code's meaning if the promise is true.
static: lifetime and visibility
(Covered in depth in earlier notes; here is the recap focused on embedded.)
| Use | Effect | Embedded relevance |
|---|---|---|
| Static local variable | Keeps value between calls; lives for the whole program | Persistent state without a global; but makes the function non-reentrant |
| Static global variable | File-private (internal linkage) | Module-private state, avoids name clashes |
| Static function | File-private | Hides helpers; compiler can optimize better |
static inline in headers | Safe inline helper per file | Fast register-access helpers |
All static objects live in .data or .bss and use RAM permanently, so count them in your RAM budget.
Combined summary
| Keyword | Promise / purpose | Typical embedded use |
|---|---|---|
const | Won't be modified via this name | Flash lookup tables, read-only parameters |
volatile | May change unexpectedly; always access memory | Hardware registers, ISR-shared flags |
restrict | No aliasing through other pointers | Optimizing DSP / buffer loops |
static | Persistent and/or file-private | Module state, private helpers |
2. Integer Promotion and Usual Arithmetic Conversions
C silently converts operand types before doing arithmetic. Not knowing the rules causes some of the most confusing bugs, especially with 8-bit and 16-bit data on MCUs.
Rule 1: Integer promotion
Any integer type smaller than int (char, signed char, unsigned char, short, unsigned short, _Bool, bit-fields) is promoted to int (or to unsigned int if int can't hold all its values) before it's used in an arithmetic, bitwise, or comparison operation.
uint8_t a = 200, b = 100;
int sum = a + b; // a and b are promoted to int, so sum = 300 (no 8-bit wraparound)
uint8_t c = a + b; // 300 is computed as int, then converted to uint8_t: 44 (300 - 256)
Rule 2: Usual arithmetic conversions
When the two operands of a binary operator have different types (after promotion), they are converted to a common type:
- If either is
long double,double, orfloat, convert to the higher floating type. - Otherwise, apply integer promotion to both, then:
- If both have the same type, done.
- If both are signed or both are unsigned, convert to the higher rank.
- If the unsigned operand has rank greater or equal to the signed one, convert the signed operand to unsigned.
- Else if the signed type can represent all values of the unsigned type, convert to the signed type.
- Else convert both to the unsigned version of the signed type.
Rank order (low to high): char < short < int < long < long long
Classic traps
Trap 1: signed vs unsigned comparison
int a = -1;
unsigned int b = 1;
if (a < b) { /* NOT executed */ } // a becomes 4294967295 (unsigned)
Trap 2: ~ on small unsigned types
uint8_t x = 0x0F;
if (~x == 0xF0) { /* FALSE */ }
// x is promoted to int (0x0000000F), ~x = 0xFFFFFFF0, not 0xF0
Fix: cast the result: if ((uint8_t)~x == 0xF0).
Trap 3: left shift of promoted values
uint8_t b = 0x80;
uint16_t r = b << 1; // computed as int: 0x100, fits uint16_t, fine
uint8_t s = b << 1; // 0x100 truncated to uint8_t = 0x00
Trap 4: uint16_t multiplication on a 32-bit int system
uint16_t a = 65535, b = 65535;
uint32_t p = a * b; // promoted to (signed) int: 4294836225 overflows int, undefined behaviour!
Fix: uint32_t p = (uint32_t)a * b;
Trap 5: integer division then float
float f = 5 / 2; // 2.0, because the division is done in int
float g = 5 / 2.0f; // 2.5
Trap 6: signed char arithmetic
char c = 200; // if char is signed, this holds -56
Platform note
Promotion depends on the width of int. On a 16-bit MCU, int is 16 bits, so a uint16_t is promoted to unsigned int (not int) and the same code can behave differently from a 32-bit MCU.
Safe habits
- Do arithmetic in a type at least as wide as the result: cast before the operation.
- Avoid mixing signed and unsigned types.
- Cast the result of
~and shifts back to the target type. - Use explicit casts to show intent.
- Enable compiler warnings:
-Wconversion -Wsign-compare -Wsign-conversion.
3. Fixed-Width Integers: uint8_t, uint16_t, uint32_t
The problem
The sizes of int, long, and sometimes char change between platforms:
| Type | 8-bit MCU | 16-bit MCU | 32-bit MCU | 64-bit PC (Linux) |
|---|---|---|---|---|
int | 16 | 16 | 32 | 32 |
long | 32 | 32 | 32 | 64 |
| pointer | 16 | 16 | 32 | 64 |
Code that assumes int is 32-bit breaks when ported.
The solution: <stdint.h> (C99)
Fixed-width types with exact sizes.
| Type | Size | Range |
|---|---|---|
int8_t / uint8_t | 8 bits | -128 to 127 / 0 to 255 |
int16_t / uint16_t | 16 bits | -32,768 to 32,767 / 0 to 65,535 |
int32_t / uint32_t | 32 bits | about +/-2.1 billion / 0 to 4.29 billion |
int64_t / uint64_t | 64 bits | very large |
#include <stdint.h>
uint8_t gpio_state; // exactly 8 bits
uint16_t adc_value; // exactly 16 bits
uint32_t timestamp_ms; // exactly 32 bits
Other families in <stdint.h>
| Family | Meaning | Example |
|---|---|---|
| Exact-width | Exactly N bits (only if the hardware supports it) | uint32_t |
| Least-width | Smallest type with at least N bits | uint_least8_t |
| Fast | Fastest type with at least N bits | uint_fast16_t |
| Max-width | Largest integer type | intmax_t, uintmax_t |
| Pointer-sized | Can hold a pointer | intptr_t, uintptr_t |
| Size types | Sizes and differences | size_t (<stddef.h>), ptrdiff_t |
Limits macros
UINT8_MAX // 255
INT16_MIN // -32768
UINT32_MAX // 4294967295
Format macros (<inttypes.h>) for printf
#include <inttypes.h>
uint32_t v = 123;
printf("v = %" PRIu32 "\n", v); // %u, %lu, etc. depending on platform
printf("hex = 0x%" PRIX16 "\n", (uint16_t)0xABCD);
Using the wrong specifier is undefined behaviour, so these macros keep it portable.
Why they matter in embedded
- Hardware registers are exactly 8, 16, or 32 bits wide.
- Communication protocols and file/EEPROM formats have exact field sizes.
- Memory efficiency: pick the smallest type that fits (a flag as
uint8_t, notint). - Portability across MCU families.
- Predictable overflow/wraparound (for unsigned types).
Other related types
bool(<stdbool.h>) for true/false.size_tfor sizes and array indexes (unsigned, platform-dependent width).uintptr_tfor storing addresses as integers.
Cautions
- Exact-width types are optional: they exist only if the platform has such a type (e.g., some DSPs have no 8-bit type).
- The promotion rules still apply:
uint8_tis promoted tointin expressions (see section 2). - Don't use
uint8_tas the loop counter if the count could exceed 255. - Mind endianness and alignment when overlaying them on byte buffers.
- On AVR/8-bit targets, 32-bit operations are slow, so choose widths consciously.
4. sizeof Operator and Data Model
sizeof
sizeof is a compile-time operator (not a function) that returns the size in bytes of a type or object. The result is of type size_t (unsigned).
sizeof(int) // size of a type (parentheses required)
sizeof x // size of an expression (parentheses optional)
sizeof(arr) // total bytes of an array
sizeof(arr) / sizeof(arr[0]) // number of elements
sizeof(struct S) // includes padding
printf("%zu\n", sizeof(int)); // %zu is the correct format for size_t
Key properties
sizeof(char) == 1always (by definition; "byte" here means onechar, which is usually 8 bits).- Evaluated at compile time (except for variable-length arrays).
- The operand expression is not evaluated:
sizeof(i++)does not incrementi. - For arrays: the whole array size. But an array parameter in a function is actually a pointer, so
sizeofreturns the pointer size:
void f(int arr[]) { sizeof(arr); } // pointer size, NOT the array size
- For structs: includes padding (see earlier notes on alignment).
- For strings:
sizeof("Hi")is 3 (includes'\0'), whilestrlen("Hi")is 2. sizeofcannot be applied to a function type, an incomplete type, or a bit-field.
Common idiom: count array elements
#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0]))
Only works on a real array, not on a pointer.
Allocation idiom
int *p = malloc(n * sizeof *p); // stays correct if the type of p changes
Data model
A data model defines the sizes of the basic types (int, long, pointers) on a platform. The C standard only guarantees minimum sizes and relative ordering, so different platforms choose different models.
The standard guarantees
sizeof(char) == 1charis at least 8 bits (CHAR_BIT)short>= 16 bits,int>= 16 bits,long>= 32 bits,long long>= 64 bitssizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)
Common data models
| Model | short | int | long | long long | pointer | Used by |
|---|---|---|---|---|---|---|
| ILP32 | 16 | 32 | 32 | 64 | 32 | 32-bit systems, most 32-bit MCUs (ARM Cortex-M) |
| LP64 | 16 | 32 | 64 | 64 | 64 | 64-bit Linux, macOS |
| LLP64 | 16 | 32 | 32 | 64 | 64 | 64-bit Windows |
| LP32 / IP16-style | 16 | 16 | 32 | 64 | 16 | Many 8/16-bit MCUs (AVR: int is 16 bits) |
The letters mean which types are 32/64-bit: Int, Long, Pointer.
Why it matters
sizeof(long)is 4 on a Cortex-M but 8 on 64-bit Linux, so a program tested on a PC may behave differently on the MCU.sizeof(int *)may be 2, 4, or 8 bytes.- Code that stores a pointer in an
intbreaks on 64-bit systems (useuintptr_t). size_t,ptrdiff_t, andintptr_tchange width with the platform.
Useful tools
#include <limits.h>
CHAR_BIT // bits per byte (usually 8)
INT_MAX, LONG_MAX, UINT_MAX, ...
_Static_assert(sizeof(uint32_t) == 4, "uint32_t must be 4 bytes"); // C11
_Static_assert(sizeof(MyStruct) == 8, "unexpected padding");
Use compile-time assertions to catch wrong assumptions at build time.
5. Undefined, Unspecified and Implementation-Defined Behavior
The C standard doesn't define exactly what happens in every situation. Three different categories describe this. Knowing the difference is essential for writing portable and safe code.
Comparison
| Category | The standard says | Compiler must... | Result |
|---|---|---|---|
| Implementation-defined | Several behaviours allowed; the implementation picks one | Document its choice | Consistent and documented per compiler |
| Unspecified | Several behaviours allowed | Choose one, no need to document, can vary | Valid result but you can't rely on which |
| Undefined (UB) | Anything can happen | Nothing at all | Crash, wrong result, "works", or exploited by the optimizer |
Implementation-defined behavior
Behaviour that varies by compiler/platform but is documented.
Examples:
- Size of
int,long, pointer (data model). - Whether plain
charis signed or unsigned. - Right shift of a negative signed number (arithmetic vs logical).
- Struct padding and alignment.
- Bit-field layout and ordering.
- Result of converting an out-of-range value to a signed type.
- Endianness.
- Behaviour of
#pragma.
Unspecified behavior
The compiler may pick any valid option and needn't be consistent.
Examples:
- Order of evaluation of function arguments:
f(a(), b())may callaorbfirst. - Order of evaluation of operands: in
a() + b(), which is called first is unspecified. - Order in which
staticobjects in different translation units are initialized (in C++; in C, constant initializers avoid this). - Value of padding bytes.
Undefined behavior (UB)
The standard imposes no requirements at all. The program may crash, produce garbage, appear to work, or behave differently after you change optimization level or compiler version.
Common examples of UB
| Category | Example |
|---|---|
| Signed integer overflow | INT_MAX + 1 |
| Out-of-bounds array access | a[10] on int a[5] |
| Null pointer dereference | *(int *)0 |
| Dangling/wild pointer use | use after free, returning address of a local |
| Uninitialized variable read | int x; printf("%d", x); (indeterminate value) |
| Division by zero | x / 0 |
| Shift errors | Shifting by a negative amount or by >= the type's width; left-shifting a negative value |
| Modifying a variable twice between sequence points | i = i++ + ++i; |
| Strict aliasing violation | Reading a float through an int * |
| Modifying a string literal | char *s = "abc"; s[0] = 'x'; |
Modifying a const object | casting away const and writing |
| Misaligned pointer access | accessing a uint32_t at an odd address (on some systems) |
Missing return value used | non-void function that falls off the end and the value is used |
free of invalid pointer / double free | |
Overlapping memcpy | use memmove instead |
Reaching volatile / signal / thread data races | unsynchronized concurrent access |
Why UB is dangerous: the optimizer assumes it never happens
int check(int x) {
if (x + 1 > x) // compiler assumes signed overflow never occurs,
return 1; // so it may replace this with: return 1;
return 0;
}
Because signed overflow is UB, the compiler is allowed to assume x + 1 > x is always true and delete the check. Bugs from UB can appear only at higher optimization levels or in a different compiler version.
Defensive practices
- Compile with warnings:
-Wall -Wextra -Wconversion. - Use sanitizers during testing:
-fsanitize=undefined,address. - Use static analysis tools and follow MISRA C (section 15).
- Initialize all variables; check array bounds and pointers.
- Prefer unsigned types for bit manipulation (well-defined wraparound).
- Use
_Static_assertfor assumptions about sizes. - Avoid relying on implementation-defined behaviour, or isolate and document it.
6. Sequence Points and Expression Evaluation
The idea
C often leaves the order of evaluation of sub-expressions unspecified. A sequence point is a point in execution at which all side effects of previous evaluations are complete and no side effects of later evaluations have started.
Side effect = modifying a variable (=, ++, --, +=), accessing a volatile object, writing to a file, or calling a function that does these.
The key rule (pre-C11 wording)
Between two sequence points, a variable's value may be modified at most once, and if it's modified, it may be read only to determine its new value.
Breaking this is undefined behaviour.
Where sequence points occur
| Location | Example |
|---|---|
| End of a full expression | the ; at the end of a statement |
&& (after the left operand) | a && b: a is fully evaluated first |
|| (after the left operand) | a || b |
?: (after the condition) | c ? x : y |
, comma operator (between operands) | a, b |
| Before a function call, after all arguments are evaluated | f(x, y) |
After the controlling expressions of if, switch, while, for, do-while | |
| At the end of a full declarator / initializer | int a = f(), b = a; |
| Before a function returns |
Important: the commas separating function arguments and declarations (
f(a, b),int x, y;) are not the comma operator and don't create sequence points between arguments.
C11 terminology
C11 replaced "sequence points" with "sequenced before / indeterminately sequenced / unsequenced" relationships, but the practical rules are the same: unsequenced modifications of the same object are UB.
Undefined-behaviour examples
int i = 5;
i = i++; // UB: i modified twice (assignment and ++) with no sequence point
int a = i++ + i++; // UB
int b = i + i++; // UB: reading i and modifying it, unrelated
a[i] = i++; // UB
printf("%d %d", i, i++); // UB (and the arguments' order is unspecified)
Unspecified-order examples (valid but unpredictable)
int r = f() + g(); // f and g may run in either order
foo(h(), k()); // h and k may run in either order
If f and g have side effects that depend on each other (e.g., both modify a global), the result can differ between compilers.
Well-defined thanks to sequence points
if (p != NULL && p->value > 0) { } // safe: left side is checked first
x = (a > 0) ? a : -a; // condition first
for (i = 0, j = 10; i < j; i++, j--) { } // comma operator is a sequence point
This is why short-circuit evaluation is relied upon for guard conditions.
Precedence and associativity vs order of evaluation
They are different things.
- Precedence/associativity decide how an expression is grouped (
a + b * cgroups asa + (b * c)). - Order of evaluation decides when the operands are evaluated and is mostly unspecified. Grouping doesn't imply a left-to-right evaluation.
Rules to follow
- Don't modify a variable more than once in one expression.
- Don't read and modify the same variable in one expression (except to compute its own new value).
- Avoid side effects in function arguments and in macro arguments.
- Split complex expressions into separate statements.
- Don't depend on the evaluation order of function arguments or operands.
// Instead of: a[i] = i++;
a[i] = i;
i++;
Volatile accesses
Accesses to volatile objects are side effects, so the compiler won't remove or reorder them relative to other volatile accesses (but ordering relative to ordinary variables isn't guaranteed).
7. Endianness: Little-Endian vs Big-Endian
What is endianness?
Endianness is the order in which the bytes of a multi-byte value are stored in memory.
Take the 32-bit value 0x12345678. Its bytes are 12, 34, 56, 78 (most significant byte = MSB 0x12, least significant byte = LSB 0x78).
| Address 0x100 | 0x101 | 0x102 | 0x103 | |
|---|---|---|---|---|
| Big-endian (MSB first, lowest address) | 12 | 34 | 56 | 78 |
| Little-endian (LSB first, lowest address) | 78 | 56 | 34 | 12 |
Memory trick: Little-endian stores the little end (least significant byte) at the lowest address. Big-endian reads like a normal number written left to right.
Who uses what
| Endianness | Examples |
|---|---|
| Little-endian | x86/x64 PCs, most ARM Cortex-M (ARM is bi-endian but almost always little in practice), AVR, RISC-V (default), ESP32 |
| Big-endian | Older Motorola 68k, some PowerPC configurations, SPARC, many network protocols (so it's called "network byte order") |
| Bi-endian | ARM, MIPS, PowerPC can be configured |
Endianness affects only byte order within multi-byte values. A single byte, a string of chars, and the order of array elements are not affected. Bit order within a byte is also not affected in memory (it's just a number).
Detecting endianness at runtime
// Method 1: pointer to the first byte
uint32_t x = 1;
if (*(uint8_t *)&x == 1)
printf("Little-endian\n");
else
printf("Big-endian\n");
// Method 2: union
union { uint32_t word; uint8_t bytes[4]; } u = { .word = 1 };
// u.bytes[0] == 1 means little-endian
Many compilers also define macros, such as __BYTE_ORDER__ and __ORDER_LITTLE_ENDIAN__ (GCC/Clang), which allow compile-time detection.
Where endianness causes problems
- Network communication: protocols like TCP/IP use big-endian. Convert with
htons,htonl(host to network) andntohs,ntohl. - Binary file formats and EEPROM/Flash data written on one system and read on another.
- Communication with peripherals/sensors that send multi-byte registers (e.g., a sensor sends 16-bit values MSB first).
- Casting a byte buffer to a wider pointer (
*(uint32_t *)buf), which breaks both on endianness and alignment and can be undefined behaviour. - Overlaying structs/unions on raw data.
- Bit-fields and register layouts across compilers.
Portable ways to read multi-byte data
Build values from bytes with shifts, which is correct on any CPU:
// Read 32-bit big-endian value from a buffer
uint32_t read_be32(const uint8_t *b) {
return ((uint32_t)b[0] << 24) |
((uint32_t)b[1] << 16) |
((uint32_t)b[2] << 8) |
((uint32_t)b[3]);
}
// Read 32-bit little-endian value
uint32_t read_le32(const uint8_t *b) {
return ((uint32_t)b[3] << 24) |
((uint32_t)b[2] << 16) |
((uint32_t)b[1] << 8) |
((uint32_t)b[0]);
}
// Write 16-bit value as big-endian
void write_be16(uint8_t *b, uint16_t v) {
b[0] = (uint8_t)(v >> 8);
b[1] = (uint8_t)(v & 0xFF);
}
Byte-swap helper
uint32_t swap32(uint32_t x) {
return ((x & 0x000000FFu) << 24) |
((x & 0x0000FF00u) << 8) |
((x & 0x00FF0000u) >> 8) |
((x & 0xFF000000u) >> 24);
}
(GCC/Clang offer __builtin_bswap32; ARM offers hardware REV instructions.)
Quick comparison
| Little-endian | Big-endian | |
|---|---|---|
| LSB stored at | Lowest address | Highest address |
| Reading a hex dump | Bytes appear reversed | Matches the written number |
| Adding multi-byte numbers | Natural start with the low byte | Needs the highest address first |
| Common in | CPUs (x86, ARM Cortex-M) | Network protocols |
8. Bitwise Operators and Bit Manipulation
Embedded programming is largely about controlling individual bits in hardware registers.
The bitwise operators
| Operator | Name | Rule | Example (8-bit) |
|---|---|---|---|
& | AND | 1 only if both bits are 1 | 0b1100 & 0b1010 = 0b1000 |
| | OR | 1 if either bit is 1 | 0b1100 | 0b1010 = 0b1110 |
^ | XOR | 1 if bits differ | 0b1100 ^ 0b1010 = 0b0110 |
~ | NOT (complement) | Inverts every bit | ~0b00001100 = 0b11110011 |
<< | Left shift | Shifts bits left, fills with 0 | 0b0011 << 2 = 0b1100 |
>> | Right shift | Shifts bits right | 0b1100 >> 2 = 0b0011 |
Don't confuse: & (bitwise) vs && (logical); | vs ||.
The essential bit operations
Using n as the bit position (0 = least significant bit):
| Task | Code | How it works |
|---|---|---|
| Set bit n | reg |= (1u << n); | OR with a 1 in that position |
| Clear bit n | reg &= ~(1u << n); | AND with a 0 in that position |
| Toggle bit n | reg ^= (1u << n); | XOR flips that bit |
| Test bit n | if (reg & (1u << n)) | AND isolates the bit |
| Read bit n as 0/1 | (reg >> n) & 1u | Shift down, then mask |
| Write bit n = v | reg = (reg & ~(1u << n)) | ((v & 1u) << n); | Clear, then set |
Multi-bit fields
// Set bits [5:3] (a 3-bit field) to value 5 (0b101)
reg &= ~(0x7u << 3); // clear the field
reg |= (5u << 3); // write the new value
// Read the field
uint8_t field = (reg >> 3) & 0x7u;
Handy macros
#define BIT(n) (1UL << (n))
#define SET_BIT(r, n) ((r) |= BIT(n))
#define CLEAR_BIT(r, n) ((r) &= ~BIT(n))
#define TOGGLE_BIT(r, n) ((r) ^= BIT(n))
#define TEST_BIT(r, n) (((r) & BIT(n)) != 0)
#define MASK(width) ((1UL << (width)) - 1UL)
Common tricks
| Trick | Code |
|---|---|
Is x odd? | x & 1 |
| Multiply by 2^n | x << n |
| Divide by 2^n (unsigned) | x >> n |
Is x a power of 2? | x != 0 && (x & (x - 1)) == 0 |
| Clear the lowest set bit | x & (x - 1) |
| Isolate the lowest set bit | x & -x (for unsigned, use x & (~x + 1)) |
| Swap without a temp | a ^= b; b ^= a; a ^= b; (fails if both refer to the same object) |
| Count set bits (Kernighan) | while (x) { x &= x - 1; count++; } |
| Round up to a multiple of 8 | (x + 7) & ~7u |
| Check if two values differ in a bit | (a ^ b) & BIT(n) |
| Toggle with a mask | reg ^= mask |
Embedded example: controlling an LED port
#define PORTB (*(volatile uint8_t *)0x25)
PORTB |= (1u << 5); // LED on (pin 5 high)
PORTB &= ~(1u << 5); // LED off
PORTB ^= (1u << 5); // blink (toggle)
if (PINB & (1u << 2)) { // read button on pin 2
/* pressed */
}
Read-modify-write caution
reg |= bit; is three steps: read, modify, write. If an interrupt changes the same register in between, the update can be lost (see section 12). Many MCUs provide atomic set/clear registers (e.g., BSRR, SET/CLR/TOGGLE registers) to avoid this.
Important rules
- Use unsigned types (
1u,1UL) when manipulating bits. Using the literal1(a signedint) in1 << 31is undefined behaviour in older standards. - Remember integer promotion (section 2):
~and<<onuint8_tare done inint. - Parenthesize:
==binds tighter than&, soif (x & MASK == 0)is wrong. Writeif ((x & MASK) == 0). - Use explicit casts to narrow the result.
9. Shifting and Masking
Shifting and masking are the two basic building blocks of bit manipulation. Together they let you extract, insert, and rearrange fields inside a word.
Shifting
| Shift | Effect |
|---|---|
Left shift x << n | Moves bits toward the MSB; vacated low bits are 0. Equivalent to multiplying by 2^n (if no overflow) |
Right shift x >> n | Moves bits toward the LSB |
Types of right shift
- Logical shift: fills the vacated high bits with 0 (always used for unsigned values).
- Arithmetic shift: fills with the sign bit (keeps negative numbers negative). For negative signed values, whether C does this is implementation-defined; most compilers do arithmetic shift.
uint8_t 0b10110000 >> 2 = 0b00101100 (logical)
int8_t 0b10110000 >> 2 = 0b11101100 (arithmetic, sign-extended, typical)
Shift pitfalls (UB / traps)
- Shifting by a negative amount: undefined.
- Shifting by >= the width of the (promoted) type: undefined.
uint32_t x = 1; x << 32; // UB on a 32-bit type - Left-shifting a negative signed value: undefined.
- Left-shifting into or past the sign bit of a signed type (e.g.,
1 << 31with 32-bit signedint) is undefined (or implementation-defined in later standards). Use1u << 31or1UL << 31. - Promotion:
uint8_tis promoted toint, so(b << 8)doesn't overflow 8 bits but truncates when stored back. - Shifting is not a safe replacement for division with negative values (
-5 >> 1is -3 with an arithmetic shift, but-5 / 2is -2).
Performance: on MCUs without a barrel shifter (8-bit AVR), a shift by n can take n cycles. Shifting by constants is usually cheap.
Masking
A mask is a bit pattern used with &, |, or ^ to select or change specific bits.
| Goal | Operation | Mask meaning |
|---|---|---|
| Keep only certain bits | x & mask | 1 = keep, 0 = clear |
| Clear certain bits | x & ~mask | 1 = bit to clear |
| Set certain bits | x | mask | 1 = bit to set |
| Toggle certain bits | x ^ mask | 1 = bit to flip |
Creating masks
0xFFu // low 8 bits
(1u << n) // single bit n
((1u << width) - 1u) // width bits of 1s (from bit 0), e.g., width 4 -> 0x0F
(((1u << width) - 1u) << pos) // field of 'width' bits starting at bit 'pos'
~0u // all bits set
Careful: (1u << width) is undefined when width equals the type's width (e.g., 32). Handle full-width masks specially.
Extract and insert a field (the classic combination)
Suppose bits [6:4] of a register hold a mode value:
#define MODE_POS 4u
#define MODE_WIDTH 3u
#define MODE_MASK (((1u << MODE_WIDTH) - 1u) << MODE_POS) // 0x70
// EXTRACT: mask then shift down
uint32_t mode = (reg & MODE_MASK) >> MODE_POS;
// INSERT: clear the field, then shift up and mask the new value
reg = (reg & ~MODE_MASK) | ((new_mode << MODE_POS) & MODE_MASK);
Why mask the new value? So an oversized new_mode can't spill into neighbouring bits.
Generic helper macros
#define GET_FIELD(reg, mask, pos) (((reg) & (mask)) >> (pos))
#define SET_FIELD(reg, mask, pos, val) ((reg) = ((reg) & ~(mask)) | (((val) << (pos)) & (mask)))
Splitting and combining bytes
uint16_t value = 0xABCD;
uint8_t high = (value >> 8) & 0xFF; // 0xAB
uint8_t low = value & 0xFF; // 0xCD
uint16_t combined = ((uint16_t)high << 8) | low;
Casting before shifting avoids promotion surprises.
Sign extension (extending a small signed field)
// Convert a 12-bit two's-complement value stored in the low bits of a word
int32_t sign_extend_12(uint32_t v) {
v &= 0xFFFu;
if (v & 0x800u) v |= 0xFFFFF000u; // propagate the sign bit
return (int32_t)v;
}
Common when reading ADCs and sensors with non-standard bit widths.
Rotate (no direct C operator)
uint32_t rotl32(uint32_t x, unsigned n) {
n &= 31u;
return (x << n) | (x >> ((32u - n) & 31u)); // masking avoids UB for n == 0
}
Best practices
- Define position and mask constants (or use macros/enums) instead of magic numbers.
- Use unsigned types for any shifting and masking.
- Cast before shifting into a wider type.
- Check shift amounts are
0 <= n < width. - Prefer shift-and-mask over bit-fields when exact layout matters (registers, protocols).
10. Integer Overflow and Signedness Issues
What is overflow?
Overflow happens when an arithmetic result is too large (or small) for the type to represent.
Two very different rules
| Unsigned overflow | Signed overflow | |
|---|---|---|
| Standard says | Well-defined: wraps around modulo 2^N | Undefined behaviour |
| Example | uint8_t x = 255; x++; gives 0 | int x = INT_MAX; x++; is UB |
| Compiler may assume | nothing special | It never happens (and optimize on that assumption) |
uint8_t u = 250;
u += 10; // wraps: 4
int32_t s = INT32_MAX;
s += 1; // UB: could wrap to negative, trap, or be optimized away
Real-world effects
- Counters and timers wrapping (e.g., a 32-bit millisecond counter wraps every ~49.7 days).
- Wrong sensor calculations, buffer-size miscalculations, and security vulnerabilities.
- Famous failures come from unnoticed overflow/conversion bugs.
Dealing with timer wraparound (unsigned arithmetic is your friend)
uint32_t start = millis();
// ... later:
if ((uint32_t)(millis() - start) >= 1000u) { // works correctly even across the wrap
/* 1 second elapsed */
}
Compare differences, not absolute timestamps.
Signedness issues
1. Mixed signed/unsigned comparison
int i = -1;
size_t n = 10;
if (i < n) { } // FALSE: i becomes a huge unsigned value
2. Unsigned loop that never ends
for (unsigned i = 10; i >= 0; i--) { } // always true; wraps to UINT_MAX
// Fix:
for (unsigned i = 10; i-- > 0; ) { }
3. Unsigned subtraction wraps
size_t a = 3, b = 5;
size_t d = a - b; // huge positive number, not -2
4. Plain char signedness
char c = 0xFF; // -1 if char is signed; 255 if unsigned (implementation-defined)
int v = c; // sign extension may give -1
Use uint8_t / unsigned char for raw bytes.
5. Implicit narrowing conversions
int big = 70000;
int16_t small = big; // implementation-defined result (typically wraps to 4464)
6. abs(INT_MIN) overflows (the positive value isn't representable). Negating INT_MIN is UB.
7. Multiplication overflow in size calculations
size_t bytes = count * size; // may wrap, leading to a tiny allocation and a buffer overflow
Use calloc(count, size) or check first.
8. Right shift of negative numbers (implementation-defined) and division INT_MIN / -1 (overflow, UB).
How to detect overflow before it happens
// Signed addition
bool add_overflows(int a, int b) {
return (b > 0 && a > INT_MAX - b) || (b < 0 && a < INT_MIN - b);
}
// Unsigned addition
bool uadd_overflows(unsigned a, unsigned b) {
return a > UINT_MAX - b; // or: (a + b) < a
}
// Multiplication
bool umul_overflows(size_t a, size_t b) {
return b != 0 && a > SIZE_MAX / b;
}
GCC/Clang built-ins: __builtin_add_overflow(a, b, &result), __builtin_mul_overflow(...).
Saturating arithmetic (clamping instead of wrapping)
Useful for audio, control loops, and sensor values:
uint8_t sat_add_u8(uint8_t a, uint8_t b) {
unsigned s = (unsigned)a + b;
return (s > 255u) ? 255u : (uint8_t)s;
}
Rules of thumb
- Use unsigned types for bit operations, counters that wrap by design, and raw data.
- Use signed types for quantities that can be negative (differences, offsets).
- Never rely on signed overflow.
- Don't mix signed and unsigned in comparisons and arithmetic; if you must, cast deliberately and check ranges.
- Choose a type wide enough for intermediate results (cast before multiplying).
- Validate ranges of external inputs before arithmetic.
- Turn on warnings:
-Wall -Wextra -Wconversion -Wsign-compare; test with-fsanitize=signed-integer-overflow(or-ftrapv).
11. Reentrancy and Thread Safety
Reentrancy
A function is reentrant if it can be safely interrupted while running and called again (by an ISR, another task, or itself) before the first call finishes, and both calls complete correctly.
Why it matters in embedded: an ISR can interrupt main() at any instruction, and an RTOS can switch tasks at any time. If both use the same non-reentrant function, data can be corrupted.
What makes a function reentrant?
A reentrant function:
- Uses only local (stack) variables and its parameters.
- Does not modify global or static data (or protects such access).
- Does not call non-reentrant functions.
- Does not keep hidden state between calls.
- Does not rely on unprotected shared hardware.
Non-reentrant example
int result; // global
int add_one(int x) {
result = x + 1; // shared state
return result;
}
// If an ISR calls add_one() while main() is between the assignment and the return,
// main() may return the ISR's result.
char *format(int n) {
static char buf[16]; // static buffer shared by all callers
sprintf(buf, "%d", n);
return buf;
}
Reentrant version
int add_one(int x) {
return x + 1; // only uses parameters/locals
}
void format(int n, char *buf, size_t size) { // caller provides the buffer
snprintf(buf, size, "%d", n);
}
Standard-library examples
| Non-reentrant | Reentrant alternative |
|---|---|
strtok (static state) | strtok_r |
rand (hidden seed) | rand_r |
localtime, asctime (static buffers) | localtime_r, asctime_r |
malloc / free (shared heap; usually not safe from an ISR) | Memory pool with locking, or no allocation in ISRs |
printf (shared buffers, may use malloc) | Avoid in ISRs |
errno (single global in simple libraries) | Per-thread errno in RTOS-aware libraries |
Thread safety
A function or module is thread-safe if it behaves correctly when called simultaneously from multiple threads/tasks, so shared data is protected from race conditions.
Reentrant vs thread-safe
| Reentrant | Thread-safe | |
|---|---|---|
| Meaning | Safe to re-enter (even from an ISR or recursion) before the earlier call finishes | Safe with concurrent threads |
| How to achieve | Avoid shared state entirely | Avoid shared state or protect it (mutex, critical section) |
| Locking | None needed | May use locks |
| Safe in an ISR | Yes | Not necessarily: a mutex can deadlock an ISR (it can't block) |
| Relationship | Reentrant implies thread-safe (if it has no shared state) | Thread-safe does not imply reentrant |
Example: a function that locks a mutex to update a global is thread-safe, but not reentrant. If an ISR interrupts the holder and calls it, the ISR waits for a lock that the interrupted code can never release: deadlock.
Techniques to make code safe
| Technique | How | Notes |
|---|---|---|
| Avoid shared state | Use locals and parameters; pass context via pointers | Best option, gives reentrancy |
| Critical section | Disable interrupts briefly around shared accesses | Keep very short; restores prior state |
| Mutex / semaphore (RTOS) | Lock around shared resources | For tasks; not for ISRs; beware priority inversion and deadlock |
| Thread-local storage | Each thread has its own copy | RTOS/OS dependent |
| Atomic operations | Hardware-indivisible updates | See section 12 |
| Message queues / ring buffers | Pass data between ISR and tasks | Single producer, single consumer needs no locks if designed properly |
| Disable preemption (scheduler lock) | Prevent task switches | Doesn't stop ISRs |
Critical section example
uint32_t primask = save_and_disable_interrupts();
shared_counter++; // protected
restore_interrupts(primask);
Save and restore the previous state (don't blindly re-enable) so nested critical sections work.
Guidelines for ISRs
- Keep them short and fast.
- Call only reentrant functions.
- Don't use
malloc,printf, or blocking calls. - Communicate with
mainviavolatileflags or queues; do heavy work in the main loop or a task.
Quick checklist for a reentrant function
- No
staticlocals or globals that change. - No
staticreturn buffers. - Calls only reentrant functions.
- Hardware access is protected or atomic.
12. Atomic Operations and Race Conditions
Race condition
A race condition occurs when the result depends on the timing or interleaving of concurrent execution paths (main code, ISRs, threads, DMA, other cores) that access shared data, and at least one of them writes.
Why even simple C statements aren't atomic
counter++; // looks like one step, but compiles to three:
1. LOAD counter -> register
2. ADD register, 1
3. STORE register -> counter
Race example (timeline):
| Step | main() | ISR | counter |
|---|---|---|---|
| 1 | LOAD counter (5) | 5 | |
| 2 | interrupt: LOAD (5), ADD, STORE (6) | 6 | |
| 3 | ADD (5+1 = 6) | 6 | |
| 4 | STORE 6 | 6 (should be 7) |
An increment was lost.
Non-atomic accesses to watch
- Read-modify-write:
x++,x += n,reg |= bit,flags &= ~mask. - Multi-byte access on a narrower bus: reading a 32-bit variable on an 8/16-bit MCU takes several instructions. If an ISR updates it in between, you may read half-old/half-new data (torn read).
volatile uint32_t ticks; // updated in a timer ISR
uint32_t get_ticks(void) {
return ticks; // on an 8-bit MCU, this may be 4 separate byte reads: torn!
}
- Structs, arrays, 64-bit values.
- Check-then-act:
if (!locked) { locked = true; ... }.
Atomic operation
An atomic operation is indivisible: it appears to happen all at once, with no other code able to observe or interrupt it halfway.
How to achieve atomicity
1. Disable interrupts (critical section): the most common on single-core MCUs.
uint32_t get_ticks(void) {
uint32_t t;
uint32_t saved = disable_irq_and_save();
t = ticks;
restore_irq(saved);
return t;
}
2. Hardware single-instruction operations: single aligned loads/stores of the native word size are typically atomic; some CPUs have instructions to atomically set/clear bits or write-through bit-band/bit-set-clear registers.
3. C11 atomics (<stdatomic.h>)
#include <stdatomic.h>
atomic_int counter = 0;
atomic_fetch_add(&counter, 1); // atomic increment
int v = atomic_load(&counter);
atomic_store(&counter, 0);
atomic_exchange(&counter, 5);
atomic_compare_exchange_strong(&counter, &expected, desired); // CAS
Support depends on the compiler and CPU (some MCUs have no native instructions for these, so the compiler or library emulates them).
4. Exclusive-access instructions (ARM LDREX/STREX, RISC-V LR/SC) used to build lock-free operations.
5. Compare-and-swap (CAS) loops for lock-free updates.
6. Mutexes/semaphores (RTOS) for protecting larger sections between tasks.
Memory ordering (brief)
On CPUs with caches, write buffers, or multiple cores, the order in which writes become visible may differ from program order. C11 atomics let you specify ordering (memory_order_relaxed, acquire, release, seq_cst). Plain volatile does not provide these guarantees. Barriers (DMB, DSB, ISB on ARM) may be required when dealing with DMA, peripherals, and multi-core.
volatile is not atomic
volatile int counter;
counter++; // still NOT atomic: it's still load, add, store
volatile only ensures the compiler performs each access. It says nothing about indivisibility.
Common safe patterns
Pattern 1: ISR sets a flag, main clears it
volatile bool data_ready = false;
void ISR(void) { data_ready = true; } // single-byte write, effectively atomic
int main(void) {
for (;;) {
if (data_ready) {
data_ready = false;
process();
}
}
}
Fine for a single writer in each direction and a single native-width variable.
Pattern 2: Single-producer single-consumer ring buffer
- ISR writes
head, main readsheadand writestail. - Each index is written by only one side, so no lock is needed (with proper
volatileand, on multi-core, barriers).
Pattern 3: Double buffering / ping-pong buffers to avoid sharing the buffer being updated.
Pattern 4: Copy under protection
disable_irq();
local_copy = shared_struct;
enable_irq();
use(local_copy);
Deadlock, priority inversion (related hazards when locking)
- Deadlock: two contexts each wait for a resource held by the other.
- Priority inversion: a high-priority task waits on a lock held by a low-priority task that's preempted by a medium-priority one. RTOS mechanisms like priority inheritance mitigate this.
Guidelines
- Minimize shared data between ISRs and main code.
- Make every shared variable
volatile(necessary, but not sufficient). - Protect read-modify-write and multi-word accesses with a critical section or atomics.
- Keep critical sections very short.
- Never block or wait inside an ISR.
- Prefer lock-free single-producer/single-consumer structures for ISR to main communication.
- Prefer hardware set/clear registers for GPIO bit changes.
- Test under stress: race conditions are intermittent and hard to reproduce.
13. Function Pointers and Jump Tables
Function pointer recap
A function pointer stores the address of a function, so you can choose which function to call at runtime.
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int (*op)(int, int) = add;
int r = op(3, 4); // calls add -> 7
Use typedef for readability:
typedef void (*Handler)(void);
Jump table (dispatch table)
A jump table is an array of function pointers indexed by a value (a command, state, or event). Instead of a long if-else or switch, you look up the handler directly.
typedef void (*CmdHandler)(void);
static void cmd_start(void) { /* ... */ }
static void cmd_stop(void) { /* ... */ }
static void cmd_reset(void) { /* ... */ }
enum { CMD_START, CMD_STOP, CMD_RESET, CMD_COUNT };
static const CmdHandler handlers[CMD_COUNT] = {
[CMD_START] = cmd_start, // designated initializers (C99) keep it readable
[CMD_STOP] = cmd_stop,
[CMD_RESET] = cmd_reset,
};
void dispatch(unsigned cmd) {
if (cmd < CMD_COUNT && handlers[cmd] != NULL) // always validate the index
handlers[cmd]();
}
Declaring the table const places it in Flash, saving RAM and protecting it from accidental modification.
Advantages
- O(1) dispatch: constant time regardless of the number of entries.
- Clean, extensible code: adding a command means adding one entry.
- Smaller or more readable than huge
switchblocks. - Run-time flexibility: swap handlers dynamically.
- Compilers often turn a dense
switchinto a jump table automatically.
Disadvantages
- Indirect calls can't be inlined and may inhibit optimization; slight call overhead.
- Harder to trace statically (call graphs, stack-usage analysis).
- Index validation is mandatory (an out-of-range index jumps to a random address).
- NULL entries would crash if called.
- Security-sensitive: a corrupted table or pointer enables control-flow hijacking.
- MISRA C restricts some function pointer uses (e.g., conversions between function pointers and other types).
Embedded use cases
1. Interrupt vector table The first part of memory (or a configurable region) holds an array of function addresses: the initial stack pointer, the reset handler, and each interrupt handler.
__attribute__((section(".isr_vector")))
const void *vector_table[] = {
&_estack, // initial stack pointer
Reset_Handler,
NMI_Handler,
HardFault_Handler,
/* ... peripheral interrupt handlers ... */
};
When an interrupt fires, the CPU fetches the handler address from this table and jumps to it.
2. State machines
typedef enum { ST_IDLE, ST_RUN, ST_ERROR, ST_COUNT } State;
typedef State (*StateFn)(Event);
static State state_idle(Event e);
static State state_run(Event e);
static State state_error(Event e);
static const StateFn state_table[ST_COUNT] = { state_idle, state_run, state_error };
State current = ST_IDLE;
current = state_table[current](event);
3. Command interpreters / protocol parsers: map command IDs or text commands to handlers.
typedef struct { const char *name; void (*fn)(const char *args); } Command;
static const Command commands[] = {
{"help", cmd_help},
{"reset", cmd_reset},
{"led", cmd_led},
};
4. Callbacks and drivers: registered handlers for events (timer expiry, UART received, button press) and driver interfaces (a struct of function pointers acting like an "interface").
typedef struct {
int (*init)(void);
int (*read)(uint8_t *buf, size_t n);
int (*write)(const uint8_t *buf, size_t n);
} SerialDriver;
5. Bootloaders: jump to application code at a known address.
typedef void (*AppEntry)(void);
uint32_t app_addr = 0x08008000;
AppEntry app = (AppEntry)(*(volatile uint32_t *)(app_addr + 4)); // reset vector
set_msp(*(volatile uint32_t *)app_addr); // load the new stack pointer
app();
(Casting integers to function pointers is implementation-defined and typically allowed on embedded targets, but flagged by MISRA.)
6. Task tables in simple schedulers: each entry holds a function and its period.
switch vs jump table
switch | Jump table | |
|---|---|---|
| Written by | Compiler decides the strategy (if-chain, binary search, or table) | You, explicitly |
| Flexibility at runtime | None | Can change entries (if non-const) |
| Readability for sparse values | Better | Table needs sentinel/NULL entries |
| Compile-time checking | Handles all cases / warns missing enum values | You must check bounds |
| Code size | Often similar | Depends |
Safety checklist
- Bounds-check the index.
- Check for NULL before calling.
- Keep the table
constwhen possible. - Make sure function signatures exactly match the pointer type.
- Don't cast between incompatible function pointer types.
- Document ownership and call context (ISR vs thread).
14. C Standard Library Limitations in Embedded Systems
Hosted vs freestanding
- Hosted environment: has an operating system and the full standard library (typical on PCs).
- Freestanding environment: no OS and only a minimal subset of the library (typical for bare-metal). A freestanding implementation guarantees only a few headers, such as
<stddef.h>,<stdint.h>,<stdbool.h>,<limits.h>,<float.h>,<stdarg.h>,<iso646.h>.
The standard library assumes things embedded systems often don't have: an OS, a file system, a console, a heap, a clock, and plenty of memory.
Main limitations and problems
| Area | Problem | Typical workaround |
|---|---|---|
| Code size | printf/scanf (especially with floating point) can add many KB of Flash | Use a minimal/lightweight printf, newlib-nano, or write simple conversion functions |
| RAM / stack use | printf may need big stack buffers; many functions use deep call chains | Avoid in constrained or ISR contexts; check stack usage |
Dynamic memory (malloc, calloc, realloc, free) | Heap fragmentation, non-deterministic timing, allocation failure, no heap by default, not reentrant/thread-safe | Static allocation, fixed-size memory pools, avoid after initialization |
I/O functions (printf, scanf, fopen, puts) | Need an OS/console/filesystem; stubs are missing at link time | Provide retargeting (e.g., implement _write, _read, _sbrk, _close), or use semihosting/UART/SWO output |
| Floating point | Without an FPU, math is emulated in software (slow and big). double is slower than float | Fixed-point arithmetic, float instead of double, avoid <math.h> where possible |
<math.h> functions (sin, sqrt, pow) | Large, slow without hardware support; may set errno | Lookup tables, CORDIC, approximation, hardware accelerators |
| Non-reentrant functions | strtok, rand, localtime, errno-using functions use static or global state | Use _r versions or write your own |
Time functions (time, clock, localtime) | Need an RTC/OS support; may be unimplemented stubs | Use hardware timers or an RTC driver |
rand() | Low quality, shared hidden state; not suited for cryptography | Hardware RNG, custom PRNG |
exit, abort, atexit, signal | Meaningless without an OS; main never returns | Custom fault/reset handler |
errno | Single global; not safe with threads/ISRs | Per-thread errno (RTOS-aware C library) or avoid reliance |
Locale, wide chars, setlocale | Large tables and overhead | Not used |
| String/memory functions | No bounds checking (strcpy, strcat, sprintf, gets) causing overflow bugs; generic versions may be slow | Use bounded versions (snprintf, strncpy carefully); hand-optimized or DMA-based memcpy |
| Startup initialization | The library requires the runtime startup (.data copy, .bss zero, constructors) to be set up correctly | Proper startup code and linker script |
| Unpredictable timing | Many library calls have variable execution time, bad for hard real-time code | Use deterministic custom code in time-critical paths |
| Portability and certification | Library behaviour varies; safety standards restrict many functions | Follow MISRA/CERT rules; use qualified libraries |
Why printf is a special problem
- Large implementation (formatting engine, floating-point support).
- Uses a lot of stack.
- May call
mallocinternally (in some libraries). - Slow (blocking output over UART).
- Not safe to call from an ISR.
- Format string mismatches are undefined behaviour.
Practical alternatives: snprintf into a buffer, lightweight printf implementations (e.g., "tiny printf"), a custom logging module, or sending binary data.
Retargeting (making the library work on your hardware)
The C library calls low-level system call stubs that you must supply (names vary by library, e.g., newlib):
int _write(int fd, char *buf, int len) { // redirect printf to the UART
for (int i = 0; i < len; i++)
uart_send_byte(buf[i]);
return len;
}
Others: _read, _sbrk (heap growth), _close, _lseek, _fstat, _isatty, _exit, _getpid, _kill.
Embedded-friendly C libraries
- newlib-nano: smaller newlib build.
- picolibc, musl (smaller footprint), vendor-provided libraries (e.g., Arm's microlib in Keil).
- Custom minimal libraries containing just what you need.
Compiler/linker options relevant to this
-ffreestanding: don't assume the standard library exists.-nostdlib: don't link the standard library.-ffunction-sections -fdata-sections -Wl,--gc-sections: drop unused code.--specs=nano.specs: use newlib-nano (GCC for ARM).-fno-builtin: don't treat library names specially.
Best practices
- Know what you link: inspect the
.mapfile for big library functions. - Avoid
malloc/free,printf/scanf(floating-point formats especially), and<math.h>in constrained or time-critical code. - Never use library functions that cannot be bounded (
gets, unboundedstrcpy). - Don't call non-reentrant library functions from ISRs.
- Provide your own small implementations of the few functions you need (
memcpy,strlen, number-to-string conversion). - Use fixed-point math if there is no FPU.
- Prefer static buffers with known sizes.
- Check each library function's stack usage, timing, and reentrancy before relying on it.
15. MISRA C Overview and Coding Guidelines
What is MISRA C?
MISRA C is a set of coding guidelines for C published by the Motor Industry Software Reliability Association (MISRA). It was created for the automotive industry and is now widely used in embedded, safety-critical, and high-reliability systems: aerospace, medical, rail, industrial, defense.
Purpose: define a safer subset of C that avoids the language's most error-prone features (undefined, unspecified, and implementation-defined behaviour), improving safety, security, portability, and reliability.
Versions
| Version | Notes |
|---|---|
| MISRA C:1998 | First edition |
| MISRA C:2004 | Widely adopted; C90-based |
| MISRA C:2012 | C99-based; introduced directives, decidable/undecidable rules; still the most common |
| MISRA C:2012 Amendments 1, 2, 3, 4 | Added security guidance and C11/C18 coverage |
| MISRA C:2023 | Consolidated edition including C11/C18 and amendments (check for newer editions in current practice) |
Structure of the guidelines
- Directives: guidance that can't be fully checked automatically (e.g., "all code shall be traceable to documented requirements").
- Rules: precisely defined, checkable by static analysis tools.
Categories (each guideline is one of)
| Category | Meaning |
|---|---|
| Mandatory | Must always be followed; no deviations permitted |
| Required | Must be followed; deviations are allowed only with a formal, documented deviation |
| Advisory | Recommended; may be disapplied without a formal deviation (though documented practice is good) |
Compliance
To claim compliance, a project must:
- Follow all mandatory rules.
- Follow all required rules, or have documented deviations.
- Follow the directives and keep a compliance matrix.
- Use a static analysis tool to check the rules.
Typical rule themes (illustrative examples, paraphrased)
| Theme | Example guidance |
|---|---|
| No undefined/unspecified behaviour | Avoid out-of-bounds access, uninitialized variables, unsequenced side effects |
| Types and conversions | Avoid implicit narrowing; use fixed-width types; don't mix signed and unsigned carelessly; "essential type model" restricts arithmetic mixing |
| Pointers | Limit pointer arithmetic; no conversion between pointers and integers (without deviation); don't cast away const/volatile; avoid function-pointer casts |
| Dynamic memory | Don't use <stdlib.h> malloc/calloc/realloc/free |
| Recursion | Don't use (functions shall not call themselves, directly or indirectly) |
| Control flow | Every switch must have a default, each case ends with break, no goto (advisory/required depending on version), single point of exit was in older versions, loops shall have clear termination |
| Bit-fields and unions | Restrict their use; use only specific types and sizes |
| Preprocessor | Limit function-like macros; use parentheses; no undefined identifiers in #if; include guards; no #undef surprises |
| Standard library | Ban or restrict <stdio.h> I/O in production, <setjmp.h>, <signal.h>, <time.h>, atoi/atof, abort/exit, system, rand, etc. |
| Identifiers | Unique names, no shadowing, no reuse of reserved names |
| Expressions | Don't rely on operator precedence (use parentheses), no assignments inside conditions, no side effects in &&/` |
| Functions | Prototypes required; check return values; don't ignore results; consistent parameter types; no variable-argument functions |
| Unreachable / dead code | Must not exist; every parameter used |
| Comments | No /* inside comments; avoid code in comments |
| Initialization | All variables initialized before use; array initializers complete |
const correctness | Pointers to const where data isn't modified |
| Language extensions | Documented and minimized |
Concrete before-and-after examples
/* Non-compliant: implicit conversion, assignment in condition, no braces */
if (x = read_sensor())
count += y;
/* Compliant style */
uint16_t value = read_sensor();
if (value != 0U)
{
count = count + (uint16_t)y;
}
/* Switch: every case terminated, default present */
switch (state)
{
case STATE_IDLE:
start();
break;
case STATE_RUN:
run();
break;
default:
handle_error();
break;
}
/* Unsigned constants get the U suffix to match the essential type */
uint32_t mask = 0xFFU;
Common practical conventions in MISRA-style code
- Always use braces
{ }aroundif/else/loop bodies. - Suffix constants appropriately (
U,UL). - Explicit casts whenever converting between types.
- One declaration per line; declare variables at the smallest scope.
- Check all return values (or explicitly cast to
(void)). - Single return at the end was preferred in older MISRA; newer versions relax it.
- Use
staticfor internal functions and file-scope variables. - Use
constwherever possible. - Wrap macros in parentheses; prefer
static inlineandenum.
Deviations
When a rule must be broken (e.g., hardware register access needs an integer-to-pointer cast), you create a formal deviation record:
- The rule being deviated from.
- The justification (why it's necessary).
- The risk assessment and mitigations.
- The scope (where it applies). Tools can suppress the specific diagnostic with an in-code comment tied to this record.
Tools
Static analyzers that check MISRA compliance include PC-lint / FlexeLint, Polyspace, Coverity, LDRA, Parasoft C/C++test, Klocwork, QA-C (Helix QAC), cppcheck (with a MISRA add-on), and compilers' own warnings. Static analysis catches many violations automatically; no tool can check everything (hence the directives and human review).
Related standards (for context)
- CERT C: secure coding standard focused on security vulnerabilities.
- Barr Group Embedded C Coding Standard: practical guidelines focused on bug reduction.
- JPL / NASA coding rules, Power of 10: very restrictive rules for mission-critical code.
- ISO 26262 (automotive), IEC 61508 (industrial), DO-178C (aerospace), IEC 62304 (medical) are safety standards that often require or recommend a coding standard such as MISRA.
Why follow it?
- Fewer bugs from undefined/unspecified behaviour.
- Portable code across compilers and MCUs.
- Readable and reviewable, consistent style.
- Required or expected by safety certification.
- Teaches disciplined, defensive C that helps in any embedded project.
Cost / criticisms
- Can feel restrictive and verbose.
- Needs tooling and discipline; false positives happen.
- Compliance doesn't guarantee correctness: it reduces risk, but design and testing are still required.
- Not all projects need full MISRA: adopt a tailored subset if full compliance isn't required.
Quick Revision Cheat Sheet
| Topic | Key points |
|---|---|
| const | Read-only; const globals typically live in Flash; documents intent |
| volatile | Value can change externally (registers, ISR flags); forces real access; not atomic |
| restrict | No-alias promise to the compiler; breaking it is UB |
| static | Persistent and/or file-private; uses RAM permanently; makes functions non-reentrant |
| Integer promotion | Types smaller than int become int before arithmetic; cast results of ~ and shifts |
| Usual arithmetic conversions | Mixed types convert to a common type; signed + unsigned becomes unsigned |
| Fixed-width ints | <stdint.h> types give exact sizes; <inttypes.h> gives printf macros |
| sizeof | Compile-time, returns size_t; sizeof(char)==1; doesn't evaluate the operand; arrays decay in functions |
| Data model | ILP32 (most MCUs), LP64 (Linux), LLP64 (Windows); sizes vary, so use _Static_assert |
| Behaviors | Implementation-defined (documented), unspecified (undocumented choice), undefined (anything goes) |
| Sequence points | Don't modify a variable twice or read-and-modify in one expression; &&, ||, ?:, , are sequenced |
| Endianness | Byte order in memory; build values with shifts for portability; networks use big-endian |
| Bitwise ops | Set |=, clear &= ~, toggle ^=, test &; use unsigned types and parentheses |
| Shift and mask | Extract: (x & mask) >> pos; insert: (x & ~mask) | ((v << pos) & mask); never shift by >= width |
| Overflow | Unsigned wraps (defined); signed overflow is UB; beware mixed signed/unsigned |
| Reentrancy | Only locals/params, no shared static state; safe from ISRs |
| Thread safety | Safe with concurrent threads via protection; not necessarily ISR-safe |
| Atomic ops / races | x++ is read-modify-write; protect with critical sections or atomics; volatile is not enough |
| Jump tables | Array of function pointers indexed by ID; validate the index; used for vectors, state machines, commands |
| Std lib limits | Avoid malloc, printf, <math.h> where constrained; retarget I/O; non-reentrant functions |
| MISRA C | Safer C subset: mandatory/required/advisory; no dynamic memory or recursion; documented deviations |
Common interview-style questions
- What is the difference between
constandvolatile, and what doesconst volatilemean? - Why do hardware registers and ISR-shared variables need
volatile? Doesvolatilemake access atomic? - What does
restrictpromise the compiler? - What are integer promotion and the usual arithmetic conversions? Why does
~x == 0xF0fail for auint8_t? - Why use
uint8_t/uint32_tinstead ofint/longin embedded code? - What does
sizeofreturn, and what is a data model (ILP32, LP64)? - Explain undefined, unspecified, and implementation-defined behavior with examples of each.
- Why is
i = i++ + ++i;undefined? What is a sequence point? - What is endianness, and how can you detect it? How do you write endian-independent code?
- How do you set, clear, toggle, and test a bit? How do you extract or insert a multi-bit field?
- What is the difference between signed and unsigned overflow?
- What makes a function reentrant? How does it differ from thread-safe?
- Why isn't
counter++atomic? How do you fix a race between an ISR and main code? - What is a jump table, and where is it used in embedded systems?
- Why is the standard library often problematic in embedded systems (
malloc,printf, floating point)? - What is MISRA C, and what are mandatory, required, and advisory rules?
End of notes.
🧭 Continue Learning
Related technical blueprints and practical guides in Firmware & Embedded Systems.

Embedded C Interview Preparation
Frequently tested concepts, Practical Embedded C programs

Microcontroller-Specific Embedded C
Embedded C is not completely platform-independent. You should learn the concepts above and then implement them on a real microcontroller

Debugging, Testing and Production Firmware
Hardware debugging, Firmware testing, Reliability, Security, Performance,...

Advanced Firmware Development
ADC, DAC and Sensor Interfacing, DMA and Data Transfer, Embedded Software Architecture, RTOS — Real-Time Operating Systems, Embedded Build Process and Linker