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

#ADC, DAC and Sensor Interfacing#
Study notes: how a microcontroller listens to, and speaks with, the analog world.
Table of Contents
- The Big Picture
- Analog-to-Digital Conversion (ADC)
- Resolution, Reference Voltage and Quantization
- Sampling Rate and Conversion Time
- ADC Channels and Configuration
- Analog Noise and Filtering
- Digital-to-Analog Conversion (DAC)
- Digital Sensors vs Analog Sensors
- Temperature, Humidity and Other Sensor Interfacing
- Calibration and Sensor Error
- Sensor Data Conversion and Scaling
- Putting It All Together
- Cheat Sheet and Common Mistakes
1. The Big Picture
A microcontroller (MCU) lives in a digital world: everything is 0s and 1s. The real world is analog: temperature, light, pressure, sound and voltage change smoothly and continuously.
To bridge the gap we need translators:
REAL WORLD FIRMWARE WORLD REAL WORLD
(analog) (digital) (analog)
Physical Sensor ADC DAC Actuator /
quantity --> (voltage) --> (numbers) --> MCU --> (voltage) --> output
(heat, light) (counts) logic
| Block | Job |
|---|---|
| Sensor | Converts a physical quantity (temperature, light...) into an electrical signal |
| ADC | Converts an analog voltage into a digital number |
| MCU firmware | Filters, scales, decides |
| DAC | Converts a digital number back into an analog voltage |
Almost every embedded product, whether a thermostat, a weighing scale or a smart-farm node, runs on this chain. Getting each link right is what separates a "works on my desk" project from a reliable product.
2. Analog-to-Digital Conversion (ADC)
2.1 What is an ADC?
An Analog-to-Digital Converter measures an input voltage and returns an integer proportional to it.
- Input: a voltage between 0 V and the reference voltage (VREF)
- Output: an N-bit integer between
0and2^N - 1
Think of it like measuring a person's height with a ruler: the ruler has finite markings (say, every 1 mm), so you round to the nearest mark. The ADC does the same with voltage.
2.2 The three steps inside every conversion
- Sampling: grab the voltage at one instant and hold it steady (a sample-and-hold circuit with a tiny capacitor does this).
- Quantization: round that voltage to the nearest of the available levels.
- Encoding: write the chosen level as a binary number.
2.3 Common ADC architectures (just enough to know)
| Type | Idea | Typical use |
|---|---|---|
| SAR (Successive Approximation) | Binary-search the answer, one bit per clock | Most MCU built-in ADCs, a good speed/accuracy balance |
| Sigma-Delta | Oversample heavily and filter | High-resolution, slow signals (weigh scales, precision temperature) |
| Flash | Many comparators in parallel | Extremely fast, video and radio, rare in MCUs |
Most MCUs you will use (STM32, AVR/Arduino, ESP32, PIC) have a built-in SAR ADC, so the rest of these notes focus on that.
3. Resolution, Reference Voltage and Quantization
3.1 Resolution
Resolution is the number of bits the ADC outputs. More bits means finer steps.
Number of levels = 2^N
| Bits (N) | Levels | Counts range |
|---|---|---|
| 8 | 256 | 0 to 255 |
| 10 | 1,024 | 0 to 1023 (Arduino Uno) |
| 12 | 4,096 | 0 to 4095 (many STM32, ESP32) |
| 16 | 65,536 | 0 to 65535 |
3.2 Reference voltage (VREF)
VREF defines the full-scale range: the input voltage that gives the maximum output code. Everything the ADC reports is measured relative to it.
- Could be the supply (e.g. 3.3 V or 5 V), an internal reference (e.g. 1.1 V, 2.5 V) or an external precision reference chip.
- If VREF is noisy or drifts, every reading is noisy or drifts. For accurate work, use a stable dedicated reference rather than the raw supply rail.
3.3 LSB: the size of one step
LSB (step size) = V_REF / 2^N
Example: 12-bit ADC, VREF = 3.3 V
LSB = 3.3 V / 4096 = 0.000806 V β 0.806 mV
So the ADC cannot tell apart two voltages that differ by less than about 0.8 mV.
3.4 Converting between voltage and counts
ADC_code = floor( V_in / V_REF Γ 2^N ) (clamped to 2^N - 1)
V_in = ADC_code Γ V_REF / 2^N
Example: 12-bit, 3.3 V reference, reading = 2048
V_in = 2048 Γ 3.3 / 4096 = 1.65 V
Note: with this formula the largest possible code (4095) corresponds to one LSB below VREF. Some datasheets divide by
2^N - 1instead. The difference is tiny but check your chip's documentation.
3.5 Quantization and quantization error
Because a continuous voltage is rounded to a finite step, some information is lost. That loss is quantization error.
- Maximum error for an ideal ADC: Β±Β½ LSB
- It looks like a small, random-ish noise added to the signal ("quantization noise").
- Higher resolution reduces it; it can never be removed entirely.
Voltage
^
| ___________
| ____| <-- staircase output of the ADC
| / .'
| / .' <-- real, smooth input
|/.'
+----------------------> time
3.6 Ideal dynamic range
For an ideal N-bit ADC with a full-scale sine wave:
SNR β 6.02 Γ N + 1.76 dB
12-bit gives about 74 dB. Real ADCs do worse because of noise and distortion, so we talk about ENOB (Effective Number Of Bits): the resolution you actually get. A "12-bit" MCU ADC often delivers only about 10 to 11 effective bits in practice.
4. Sampling Rate and Conversion Time
4.1 Sampling rate (fs)
Sampling rate is how many samples per second you take, in samples per second (SPS) or Hz.
4.2 Nyquist theorem
To capture a signal faithfully:
f_s > 2 Γ f_max
where f_max is the highest frequency present in the signal. Audio up to 20 kHz needs more than 40 kSPS. A slow temperature signal changing over seconds needs only a few samples per second.
4.3 Aliasing
If you sample too slowly, fast signals masquerade as slower ones, and the error cannot be undone afterwards.
Real signal (fast): /\ /\ /\ /\ /\
Samples (too few): * * *
Reconstructed: \_____/ (a false, slow wave)
Fix: place an analog anti-aliasing low-pass filter before the ADC that removes content above fs/2, and sample fast enough.
4.4 Conversion time
Conversion time is how long the ADC takes to produce one result. For a SAR ADC it has two parts:
T_total = T_sampling + T_conversion
- Tsampling (acquisition time): time the sample-and-hold capacitor needs to charge to the input voltage. It is configurable on most MCUs, in ADC clock cycles.
- Tconversion: about N+0.5 ADC clock cycles for an N-bit SAR (the "binary search").
Example (typical): ADC clock 14 MHz, 1.5 cycles sampling + 12.5 cycles conversion
14 cycles / 14 MHz = 1 Β΅s β max β 1 MSPS
4.5 Why sampling time matters: source impedance
The sample-and-hold capacitor must charge through the source resistance of whatever drives the pin. A high-impedance source (big resistor divider, high-value thermistor network) charges the cap slowly. If the sampling time is too short, the reading is too low or "remembers" the previous channel.
Rules of thumb:
- High source impedance leads to a longer sampling time.
- Or add a buffer (op-amp voltage follower) between the sensor and the ADC.
- Or add a small capacitor (e.g. 10 to 100 nF) at the pin to act as a charge reservoir.
4.6 Sampling vs. throughput
With multiple channels in use, the per-channel rate is the total ADC rate divided by the number of channels (see multiplexing below).
5. ADC Channels and Configuration
5.1 Channels and the analog multiplexer
An MCU usually has one ADC core but many input pins. An internal analog multiplexer (MUX) selects which pin connects to the converter at a time. Each selectable input is a channel.
Pin A0 βββ
Pin A1 βββ€
Pin A2 βββΌββ[ MUX ]βββΊ [Sample&Hold] βββΊ [ADC core] βββΊ Result register
Pin A3 βββ€
Temp sensor (internal) ββ€
V_REF internal ββββββββββ
Many MCUs also expose internal channels: an on-chip temperature sensor, the internal reference voltage (handy for measuring the real supply), and battery-monitor inputs.
Switching channels quickly can leave residual charge from the previous channel. Allow settling time, or discard the first reading after a channel change.
5.2 Single-ended vs differential inputs
| Mode | Measures | When to use |
|---|---|---|
| Single-ended | Voltage between the pin and ground | Most simple sensors |
| Differential | Voltage difference between two pins | Small signals riding on noise, bridge sensors (load cells), long cables, because noise common to both wires cancels out |
5.3 Key configuration settings
When you configure an ADC (in registers or via a HAL/driver), you almost always decide:
| Setting | Meaning |
|---|---|
| Resolution | 8/10/12 bits etc. (sometimes trades with conversion time) |
| Reference | Which VREF source |
| Clock / prescaler | ADC clock speed (often capped by the datasheet; too fast hurts accuracy) |
| Sampling time | Cycles allowed to charge the S&H capacitor |
| Channel(s) | Which pins to convert, and in what order |
| Conversion mode | Single, continuous, scan, discontinuous (below) |
| Trigger | Software start, timer, or external event |
| Data alignment | Right-aligned or left-aligned result in the register |
| Data transfer | Polling, interrupt, or DMA |
| Averaging/oversampling | Hardware option on many newer MCUs |
5.4 Conversion modes
- Single conversion: convert once, then stop. You start each conversion yourself.
- Continuous conversion: as soon as one finishes, the next begins automatically.
- Scan mode: converts a list of channels in sequence, one after another.
- Discontinuous mode: converts a few channels of the list per trigger.
- Timer-triggered: a hardware timer starts each conversion at an exact interval, which gives precise, jitter-free sampling rates. This is the right choice for anything where timing matters (audio, vibration, control loops).
5.5 Getting the data out
| Method | How | Trade-off |
|---|---|---|
| Polling | Start, wait for the "done" flag, read | Simplest; blocks the CPU |
| Interrupt | ADC raises an interrupt when done | CPU free in between; fine for moderate rates |
| DMA | Hardware copies results to a RAM buffer automatically | Best for continuous or multi-channel streams; almost zero CPU load |
5.6 Data alignment
A 12-bit result sits inside a 16-bit register. Right-aligned puts it in bits 0 to 11 (the value you expect). Left-aligned puts it in bits 4 to 15 (handy if you want an effective 16-bit scale). Make sure your code matches your configuration.
5.7 Example: reading an ADC (Arduino-style)
const float VREF = 5.0; // volts
const int ADC_BITS = 10; // Arduino Uno
void setup() {
Serial.begin(9600);
}
void loop() {
int raw = analogRead(A0); // 0 .. 1023
float volts = raw * VREF / (1UL << ADC_BITS); // counts β volts
Serial.println(volts, 3);
delay(500);
}
5.8 Example: generic MCU pattern (polling, HAL-style pseudocode)
uint16_t adc_read_channel(uint8_t ch)
{
adc_select_channel(ch); // set MUX
adc_start_conversion(); // software trigger
while (!adc_conversion_done()) // poll "end of conversion" flag
;
return adc_get_result(); // 0 .. 4095 for 12-bit
}
6. Analog Noise and Filtering
Real signals are never clean. Noise in = noise out.
6.1 Where does noise come from?
| Source | Example |
|---|---|
| Thermal noise | Random electron motion in resistors, which is unavoidable |
| Power-supply noise | Switching regulators, motors, digital logic spikes on VDD / VREF |
| Electromagnetic interference (EMI) | Nearby motors, relays, radios, mains hum (50/60 Hz) |
| Ground bounce / poor grounding | Digital return currents flowing through analog ground |
| Crosstalk | Fast digital traces running next to analog traces |
| Quantization noise | From the ADC itself (section 3.5) |
| Long wires | Act like antennas |
6.2 Hardware techniques (fix it before it reaches the ADC)
- Decoupling capacitors: 100 nF ceramic right at the MCU's supply and analog-supply pins (plus a bulk capacitor on the rail).
- Separate analog and digital grounds/supply filtering: use ferrite beads or an LC filter on VDDA, joining grounds at one point (star ground).
- Stable reference: low-noise VREF with its own bypass capacitor.
- RC low-pass filter at the input:
Sensor ββ[ R ]βββ¬βββΊ ADC pin
β
[ C ]
β
GND
Cut-off frequency: f_c = 1 / (2Ο R C)
Choose fc above the signal's useful bandwidth but well below fs/2. Keep R small enough that it doesn't violate the source-impedance limit from section 4.5.
- Op-amp buffer / amplifier: drives the ADC with low impedance and scales small signals to use the full input range.
- Shielded or twisted-pair cable for long runs, and differential measurement where possible.
- Layout: keep analog traces short, away from clocks and switching nodes, over a solid ground plane.
- Avoid sampling during noisy events: e.g. do the conversion when a motor/relay isn't switching, or put the MCU in a quiet "ADC noise-reduction" sleep mode if available.
6.3 Software filtering (clean it after conversion)
a) Averaging (boxcar / moving average)
Average the last N samples. Reduces random noise by about βN.
#define N 16
static uint16_t buf[N];
static uint8_t idx = 0;
static uint32_t sum = 0;
uint16_t moving_average(uint16_t new_sample)
{
sum -= buf[idx];
buf[idx] = new_sample;
sum += new_sample;
idx = (idx + 1) % N;
return sum / N;
}
Trade-off: smoother but slower to respond.
b) Median filter
Take the middle value of the last few samples (e.g. 3, 5 or 7). Excellent at rejecting single spikes/glitches that an average would smear out.
c) Exponential moving average (IIR / low-pass)
Needs just one variable, so it is very cheap in RAM and CPU:
// alpha between 0 and 1: smaller = smoother but slower
filtered = filtered + alpha * (new_sample - filtered);
// integer version with alpha = 1/8
filtered += (new_sample - filtered) >> 3;
d) Oversampling and decimation
Take more samples than needed and combine them to gain effective resolution:
Extra bits n β take 4^n samples, sum them, then right-shift by n
Example: to turn a 12-bit ADC into about 14-bit, take 16 samples (4Β²), add them, shift right by 2.
This works only if there is a little random noise (about 1 LSB) in the signal to "dither" the readings. It will not fix a systematic error.
e) Rejecting mains hum
Average over exactly one or more full mains cycles (20 ms for 50 Hz, about 16.7 ms for 60 Hz) to cancel 50/60 Hz pickup.
6.4 Filtering vs. sampling: key rules
- Filter before the ADC to prevent aliasing (analog filter).
- Filter after the ADC to reduce residual noise (digital filter).
- More filtering means a slower response, so match the filter to how fast the real quantity can change.
7. Digital-to-Analog Conversion (DAC)
7.1 What is a DAC?
A Digital-to-Analog Converter does the reverse of an ADC: it turns an N-bit number into a proportional voltage (or current).
V_out = (code / 2^N) Γ V_REF
Example: 12-bit DAC, VREF = 3.3 V, code = 1024
V_out = 1024 / 4096 Γ 3.3 = 0.825 V
Same terms as the ADC apply: resolution, reference voltage, LSB (= VREF/2N). The largest output is one LSB below VREF.
7.2 What do we use a DAC for?
- Generating analog control voltages (set a reference level, bias, motor-speed command, laser/LED current setting)
- Waveform generation (sine, triangle, sawtooth, arbitrary)
- Audio output
- Calibration/test signals
- Setting a comparator threshold
7.3 Important DAC specifications
| Spec | Meaning |
|---|---|
| Resolution | Number of bits, hence step size |
| Settling time | Time for the output to reach its final value within tolerance after a code change. This limits max update rate |
| Update rate | How often a new code can be applied |
| Output buffer | Many MCU DACs have one; it lowers output impedance but may limit swing very close to 0 V and VREF |
| INL / DNL | Integral / Differential non-linearity: how far the real steps deviate from ideal |
| Monotonicity | Increasing the code never decreases the output |
| Glitch | Brief spike when many bits change at once (e.g. 0111 β 1000) |
7.4 How a DAC works (conceptually)
- R-2R ladder: a network of resistors where each bit switches a binary-weighted contribution into the output. Common and simple to build.
- Binary-weighted / resistor string: a string of equal resistors with a switch tree selects a tap.
You rarely need the internal detail, just the specs above.
7.5 No DAC on your MCU? Use PWM + RC filter
Many low-cost MCUs have no true DAC. A common workaround:
PWM pin ββ[ R ]βββ¬βββΊ (smooth analog voltage)
β
[ C ]
β
GND
The average of a PWM signal equals duty Γ V_high. The RC filter smooths out the pulses.
V_out β duty_cycle Γ V_DD
Limits: slow response, ripple on the output, and the effective resolution depends on PWM resolution and filter design.
7.6 Example: generating a sine wave with a DAC
#define SAMPLES 64
uint16_t sine_table[SAMPLES]; // precomputed, 12-bit values (0 .. 4095)
void build_table(void)
{
for (int i = 0; i < SAMPLES; i++)
sine_table[i] = (uint16_t)(2047.5 + 2047.5 * sinf(2.0f * 3.14159265f * i / SAMPLES));
}
// Called by a timer interrupt (or timer+DMA) at a fixed rate
void timer_isr(void)
{
static uint8_t i = 0;
dac_write(sine_table[i]);
i = (i + 1) % SAMPLES;
}
// Output frequency = timer_rate / SAMPLES
Use a timer + DMA for clean, jitter-free waveforms, and a lookup table so the CPU doesn't compute sin() live.
7.7 Driving real loads
DAC output pins are weak. For anything beyond a high-impedance input, add an op-amp buffer, and for amplification use a non-inverting gain stage.
8. Digital Sensors and Analog Sensors
8.1 Analog sensors
Output a voltage (or current) that varies continuously with the physical quantity. You need an ADC to read them.
Examples: LM35/TMP36 (temperature), LDR (light), potentiometer (position), analog microphones, MQ-series gas sensors, soil-moisture probes, analog current sensors.
8.2 Digital sensors
Contain the sensing element plus their own ADC, signal conditioning and sometimes calibration data, and send you the result as numbers over a digital interface.
Examples: DS18B20, SHT3x, BME280, MPU6050 (motion), BH1750 (light), many pressure sensors.
8.3 Common digital interfaces
| Interface | Wires | Notes |
|---|---|---|
| IΒ²C | 2 (SDA, SCL) | Many devices on one bus, addressed (e.g. 0x44); needs pull-up resistors |
| SPI | 4 (MOSI, MISO, SCK, CS) | Fast; one chip-select per device |
| 1-Wire | 1 data (+ power/ground) | Slow, long-ish cables (e.g. DS18B20), each device has a unique ID |
| UART | 2 (TX, RX) | Simple serial; used by some GPS/gas/particulate sensors |
| PWM / pulse / single-wire proprietary | 1 | Duty cycle or timing encodes the value (e.g. DHT11/DHT22 use a timing-based single-wire protocol) |
8.4 Comparison
| Aspect | Analog sensor | Digital sensor |
|---|---|---|
| Output | Continuous voltage/current | Numeric data over a bus |
| Needs ADC? | Yes (yours) | No (built in) |
| Noise immunity | Weak, so keep wires short | Strong, since digital data travels cleanly |
| Cable length | Short unless conditioned | Longer, depending on bus |
| Calibration | Mostly your job | Often factory-calibrated |
| Firmware complexity | Simple (read, scale) | Protocol handling required (datasheet, timing, CRC) |
| Cost | Often cheaper | Often pricier, but saves external parts |
| Pins used | One ADC pin per sensor | Shared bus for many sensors |
| Flexibility | You control filtering, speed | Fixed internal filtering/rate |
Rule of thumb: go digital when you want robustness, accuracy and easy integration; go analog when you need speed, low cost, or full control of the signal chain.
9. Temperature, Humidity and Other Sensor Interfacing
9.1 Universal interfacing steps
- Read the datasheet (supply range, output range, interface, response time, accuracy).
- Make sure the sensor's output range fits the ADC's input range (never exceed VREF/VDD on the pin).
- Provide clean power and decoupling.
- Add conditioning: divider, filter, buffer or amplifier.
- Sample correctly, then filter.
- Scale to physical units (section 11).
- Calibrate (section 10).
9.2 Temperature sensors
a) LM35 (analog, linear)
- Output: 10 mV per Β°C, 0 V at 0 Β°C.
T(Β°C) = V_out / 0.010- Example: 0.250 V gives 25 Β°C.
b) TMP36 (analog, linear with offset)
- Output: 10 mV per Β°C, with 500 mV at 0 Β°C (allows negative temperatures).
T(Β°C) = (V_out β 0.5) / 0.010- Example: 0.750 V gives 25 Β°C.
c) NTC thermistor (analog, non-linear)
A resistor whose resistance drops as temperature rises. Cheap and sensitive, but non-linear.
Wiring: a voltage divider with a fixed resistor.
V_REF ββ[ R_fixed ]βββ¬βββΊ ADC pin
β
[ NTC ]
β
GND
Compute the thermistor resistance from the ADC code:
R_ntc = R_fixed Γ ADC / (ADC_max β ADC)
Convert resistance to temperature with the Beta equation (simple) or Steinhart-Hart (more accurate):
Beta: 1/T = 1/T0 + (1/B) Γ ln(R / R0) (T in kelvin)
T0 = 298.15 K (25 Β°C), R0 = resistance at 25 Β°C, B = beta value
#include <math.h>
float ntc_to_celsius(uint16_t adc, uint16_t adc_max)
{
const float R_FIXED = 10000.0f; // 10k
const float R0 = 10000.0f; // NTC at 25 Β°C
const float B = 3950.0f; // beta from datasheet
const float T0 = 298.15f;
float r_ntc = R_FIXED * adc / (float)(adc_max - adc);
float inv_t = 1.0f / T0 + logf(r_ntc / R0) / B;
return (1.0f / inv_t) - 273.15f;
}
Tips: best accuracy is when Rfixed β the NTC's resistance at the middle of your temperature range; avoid self-heating by not driving too much current through it (or power it only during measurement).
d) RTD (e.g. PT100/PT1000), thermocouple, brief note
- RTD: resistance rises with temperature, very accurate and stable, but needs precise excitation and amplification.
- Thermocouple: generates a tiny voltage (microvolts per Β°C) across a junction. It needs a cold-junction compensation and a special amplifier/converter chip (e.g. MAX31855-style), and is used for high temperatures.
e) Digital temperature sensors (e.g. DS18B20)
Return calibrated temperature directly. DS18B20 over 1-Wire offers 9 to 12-bit resolution; the 12-bit result is in steps of 0.0625 Β°C and must be read after the conversion time (up to about 750 ms at 12 bits).
f) On-chip temperature sensor
Many MCUs have an internal ADC channel for die temperature. Good for monitoring the chip itself, not for accurate ambient temperature (the chip self-heats), and it needs the datasheet's calibration constants.
9.3 Humidity sensors
Humidity is expressed as %RH (relative humidity).
- DHT11 / DHT22 (AM2302): low-cost digital sensors on a single-wire timing protocol. Both give humidity and temperature. The DHT22 is more accurate and has a wider range than the DHT11. They are slow (roughly one reading every 1 to 2 seconds), so don't poll them faster than the datasheet allows.
- SHT3x / SHT4x, HDC series, BME280: IΒ²C (some also SPI) digital sensors with better accuracy; BME280 also provides pressure.
- Raw analog humidity sensors exist but need temperature compensation and careful conditioning, so digital parts are usually preferred.
Example conversion (SHT3x, 16-bit raw values):
Temperature (Β°C) = β45 + 175 Γ (raw_T / 65535)
Humidity (%RH) = 100 Γ (raw_RH / 65535)
Practical notes: humidity depends on temperature, so read both together; keep the sensor away from heat sources (regulators, the MCU); give it airflow and time to equilibrate.
9.4 Other common sensors
| Sensor | Type | Interfacing idea |
|---|---|---|
| LDR (light-dependent resistor) | Analog resistive | Voltage divider to ADC; non-linear; good for "dark/bright" decisions |
| Photodiode / phototransistor | Analog | Current-to-voltage (transimpedance) stage, then ADC |
| BH1750 / other light ICs | Digital (IΒ²C) | Reports lux directly |
| Potentiometer | Analog | Wiper to ADC, simple position/knob input |
| Soil-moisture (capacitive/resistive) | Analog | Read voltage; calibrate dry/wet end points. Capacitive types last longer than resistive ones, which corrode |
| Gas sensors (e.g. MQ-series) | Analog | Heater needs warm-up; output depends on temperature/humidity; calibrate in clean air |
| Pressure sensor | Analog or digital | Bridge-type analog output is small and needs amplification; digital ones (e.g. BMP/BME) handle it internally |
| Current sensor (shunt/Hall) | Analog | Output is voltage β current; may have a mid-supply offset (e.g. 2.5 V at 0 A) |
| Accelerometer / gyro (e.g. MPU6050) | Digital (IΒ²C/SPI) | Read raw registers, apply sensitivity scale factor |
| Battery voltage | Analog | Resistor divider to bring volts into ADC range, scale back in firmware |
9.5 Voltage divider for measuring higher voltages
V_in ββ[ R1 ]βββ¬βββΊ ADC pin
β
[ R2 ]
β
GND
V_adc = V_in Γ R2 / (R1 + R2)
V_in = V_adc Γ (R1 + R2) / R2
Use resistors with 1% (or better) tolerance, and remember a large divider impedance calls for a longer sampling time or a small capacitor (section 4.5). Always make sure the maximum Vin keeps the ADC pin within its allowed range.
10. Calibration and Sensor Error
10.1 Accuracy, precision and resolution are different things
| Term | Question it answers | Example |
|---|---|---|
| Resolution | What is the smallest change I can see? | 0.1 Β°C steps |
| Precision (repeatability) | Do repeated readings agree with each other? | Always 24.7 to 24.8 Β°C |
| Accuracy | Do readings agree with the true value? | True temp 25.0 Β°C, reads 24.8 Β°C |
A sensor can be precise but inaccurate (consistently off) or have a fine resolution while being wildly inaccurate. Calibration fixes accuracy, not noise.
10.2 Types of error
| Error | What it is | Looks like |
|---|---|---|
| Offset error | Reading is shifted by a constant amount | Reads +0.3 V at zero input |
| Gain (scale) error | Slope is wrong | Error grows with the input |
| Non-linearity (INL/DNL) | Response isn't a perfect straight line | Error varies along the range |
| Hysteresis | Reading differs depending on whether the input is rising or falling | Different values going up vs. down |
| Drift | Slow change over time or temperature | Calibration slowly goes stale |
| Noise | Random fluctuation | Jittery readings |
| Quantization | ADC step limit | Staircase output |
| Cross-sensitivity | Responds to other quantities | Humidity sensor affected by temperature |
| Response time / lag | Sensor can't follow fast changes | Thermistor takes seconds to catch up |
| Reference/supply error | VREF not what you assume | All ADC readings scale together |
Error in a measurement is usually systematic (repeatable, so can be corrected by calibration) or random (noise, so reduce with filtering/averaging).
10.3 ADC-specific errors
- Offset error: the transfer curve is shifted, so zero input doesn't give code 0.
- Gain error: full-scale doesn't give the maximum code exactly.
- INL / DNL: step-size and straightness imperfections.
- Missing codes: some output codes never appear.
Many MCUs offer a self-calibration routine that you can run at start-up or when temperature changes. Run it if your chip supports it.
10.4 Calibration methods
a) One-point calibration (offset only)
Measure at a single known reference and subtract the difference.
corrected = measured β offset
offset = measured_at_reference β true_reference
Example: ice-bath should read 0.0 Β°C but reads 0.4 Β°C, so subtract 0.4.
b) Two-point calibration (offset + gain)
Measure at two known points (low and high) and fit a straight line.
slope = (true_high β true_low) / (meas_high β meas_low)
corrected = true_low + (measured β meas_low) Γ slope
Example: ice water (true 0 Β°C) reads 1.0 Β°C, boiling water (true 100 Β°C, adjust for altitude) reads 98.0 Β°C.
slope = (100 β 0) / (98.0 β 1.0) = 1.031
corrected = 0 + (measured β 1.0) Γ 1.031
c) Multi-point / lookup table (non-linear sensors)
Measure several points and interpolate between them linearly.
d) Polynomial fit
Fit y = a + bΒ·x + cΒ·xΒ² ... to calibration data (e.g. Steinhart-Hart for thermistors).
e) Compensation using other sensors
E.g. correct a gas or humidity reading using the measured temperature.
10.5 Good calibration practice
- Use a trusted reference that is better than your sensor (ideally 3 to 10Γ better).
- Let the setup stabilize before recording each point.
- Calibrate across the actual operating range.
- Store coefficients in non-volatile memory (flash/EEPROM), not hard-coded, so each unit can have its own calibration.
- Re-calibrate periodically if the sensor drifts.
- Use a known-good stable VREF when calibrating the ADC.
11. Sensor Data Conversion and Scaling
This is the last link: turning raw counts into a number a human or another part of the program can use.
11.1 The standard conversion chain
Raw ADC counts β Voltage β (Undo hardware gain/divider) β Physical unit β Calibration correction β Filtered/Rounded output
11.2 Counts β voltage
V = counts Γ V_REF / 2^N
11.3 Voltage β physical unit (linear sensors)
Most analog sensors follow a straight line:
value = (V β V_offset) / sensitivity
| Sensor | Sensitivity | Offset |
|---|---|---|
| LM35 | 10 mV/Β°C | 0 V |
| TMP36 | 10 mV/Β°C | 0.5 V |
| Current sensor (example) | e.g. 100 mV/A | e.g. VCC/2 |
11.4 Generic linear mapping (the "map" function)
When you know two end points (input range β output range):
out = out_min + (in β in_min) Γ (out_max β out_min) / (in_max β in_min)
Example: map a soil-moisture ADC range, dry = 3000 counts β 0 %, wet = 1200 counts β 100 %.
float map_range(float x, float in_min, float in_max, float out_min, float out_max)
{
return out_min + (x - in_min) * (out_max - out_min) / (in_max - in_min);
}
float moisture = map_range(raw, 3000, 1200, 0, 100);
// clamp to 0..100 afterwards
if (moisture < 0) moisture = 0;
if (moisture > 100) moisture = 100;
11.5 Accounting for hardware in front of the ADC
- Voltage divider: multiply back by
(R1 + R2) / R2. - Amplifier gain: divide by the gain.
- Level shifting/offset: subtract the offset first.
11.6 Non-linear sensors
Use the sensor's formula (thermistor Beta/Steinhart-Hart), a lookup table with interpolation, or a polynomial.
11.7 Float vs. fixed-point (integer) math
| Approach | Pros | Cons |
|---|---|---|
| Floating point | Easy to write and read | Slow and bigger code on MCUs without an FPU; precision loss for huge/tiny values |
| Fixed-point / integers | Fast, deterministic, small | Must manage scaling, overflow and rounding yourself |
Integer trick: store values as scaled integers, e.g. temperature in hundredths of a degree (2537 means 25.37 Β°C), and only format as decimal when printing.
// 12-bit ADC, 3300 mV reference, LM35 (10 mV/Β°C) β temperature in 0.1 Β°C units
int32_t mv = ((int32_t)raw * 3300) / 4096; // millivolts
int32_t temp_x10 = mv; // LM35: 10 mV/Β°C, so 1 mV = 0.1 Β°C β mV value is already tenths of a Β°C
Watch out for:
- Overflow: multiply before dividing (but use a wide enough type, such as
int32_t). - Integer division truncating to zero. Add half the divisor first to round.
- Signed vs. unsigned errors with negative temperatures.
11.8 Digital sensor data
Digital sensors give raw register values, and you convert them using the datasheet formula:
- Combine bytes (
raw = (high << 8) | low). - Treat as signed (two's complement) where required (e.g. accelerometers, temperature below zero).
- Apply the sensitivity/LSB-per-unit factor (e.g. Β±2 g range, so divide by the datasheet's LSB/g).
- Check CRC/status bits if provided.
11.9 Final touches
- Clamp values to a sensible physical range.
- Round to a meaningful number of decimals; don't print a 0.01 Β°C display from a Β±1 Β°C sensor.
- Detect faults: a reading stuck at 0 or full-scale usually means an unplugged or shorted sensor. Handle it with an error flag instead of showing a bogus value.
- Apply filtering (section 6.3) and calibration (section 10.4) in a consistent order.
12. Putting It All Together
Worked example: a digital thermometer using an LM35 on a 12-bit, 3.3 V ADC
- Hardware: LM35 output β small RC low-pass (e.g. 1 kΞ© + 100 nF) β ADC pin; decoupling cap at the LM35's supply.
- Configuration: 12-bit, VREF = 3.3 V, long sampling time (because of the RC network), single-ended channel.
- Sampling: 1 sample every 10 ms via timer trigger; take 16 samples.
- Filtering: average the 16 samples (a mean, optionally median first to remove spikes).
- Conversion:
V = counts Γ 3.3 / 4096 T = V / 0.010 - Calibration: apply stored offset/gain from a two-point calibration.
- Output: clamp, round to 0.1 Β°C, display or transmit.
Sample code:
#define ADC_MAX 4095.0f
#define VREF 3.3f
#define N_SAMPLES 16
extern float cal_gain; // from non-volatile memory (default 1.0)
extern float cal_offset; // from non-volatile memory (default 0.0)
float read_temperature_c(void)
{
uint32_t sum = 0;
for (int i = 0; i < N_SAMPLES; i++)
sum += adc_read_channel(0);
float counts = sum / (float)N_SAMPLES;
float volts = counts * VREF / (ADC_MAX + 1.0f);
float temp_c = volts / 0.010f; // LM35: 10 mV/Β°C
temp_c = temp_c * cal_gain + cal_offset; // calibration
if (temp_c < -55.0f) temp_c = -55.0f; // sensor's valid range
if (temp_c > 150.0f) temp_c = 150.0f;
return temp_c;
}
Design checklist (mental flow)
Sensor choice β Range matching β Power & decoupling β Anti-alias/RC filter β
ADC config (res, Vref, sample time, trigger) β Acquire (DMA/interrupt) β
Digital filter β Scale to units β Calibrate β Validate/limit β Use/Display
13. Cheat Sheet and Common Mistakes
Key formulas
| What | Formula |
|---|---|
| Levels | 2^N |
| LSB / step size | V_REF / 2^N |
| ADC code | floor(V_in / V_REF Γ 2^N) |
| Voltage from code | code Γ V_REF / 2^N |
| Max quantization error | Β±Β½ LSB |
| Ideal SNR | 6.02Β·N + 1.76 dB |
| Nyquist | f_s > 2Β·f_max |
| ADC conversion time | T_sample + (β N + 0.5) ADC clocks |
| RC cut-off | f_c = 1 / (2ΟRC) |
| Voltage divider | V_out = V_inΒ·R2/(R1+R2) |
| DAC output | V_out = code / 2^N Γ V_REF |
| Oversampling for n extra bits | 4^n samples, sum, then >> n |
| NTC resistance (divider to GND) | R = R_fixedΒ·ADC/(ADC_max β ADC) |
| Beta equation | 1/T = 1/T0 + ln(R/R0)/B |
| Two-point calibration slope | (true_hi β true_lo)/(meas_hi β meas_lo) |
| Linear map | out_min + (in β in_min)(out_max β out_min)/(in_max β in_min) |
Common mistakes
- β Exceeding the ADC input range, which can damage the pin and clips readings. Use dividers/clamps.
- β Ignoring source impedance and sampling time, leading to low or cross-talking readings.
- β Noisy VREF/supply used as the reference without decoupling.
- β Sampling too slowly and getting aliasing; no anti-alias filter.
- β Assuming resolution = accuracy. A 12-bit reading isn't 12-bit accurate.
- β Integer overflow or truncation in scaling math.
- β Forgetting sensor warm-up/response time (gas sensors, humidity, thermistors).
- β Polling digital sensors faster than allowed (e.g. DHT22).
- β Sensors placed near heat sources, so they read the board, not the environment.
- β No fault handling for disconnected sensors.
- β Calibrating once and forgetting drift; hard-coding calibration constants.
- β Over-filtering, so the system reacts too slowly to real changes.
Quick revision summary
- ADC = sample β quantize β encode. Step size is
V_REF / 2^N; error is up to Β±Β½ LSB. - Sampling rate must exceed twice the highest signal frequency; filter first to avoid aliasing.
- Channels are multiplexed onto one converter; configure resolution, reference, sampling time, mode, trigger and data transfer (polling/interrupt/DMA).
- Noise is fought with good layout, decoupling and RC filters in hardware, and averaging, median, IIR and oversampling in software.
- DAC turns a code into a voltage using
code/2^N Γ V_REF; PWM + RC is a cheap alternative. - Analog sensors need ADC + conditioning; digital sensors deliver ready numbers via IΒ²C/SPI/1-Wire/UART.
- Calibration (one-point, two-point, table, polynomial) corrects systematic error; filtering reduces random error.
- Scaling converts counts β volts β physical units, with careful integer/float math and sanity checks.
#DMA and Data Transfer#
Study notes: how to move data around a microcontroller without making the CPU do the heavy lifting.
Table of Contents
- Why DMA Exists
- Direct Memory Access Fundamentals
- DMA Controllers and Channels
- Peripheral-to-Memory Transfers
- Memory-to-Peripheral Transfers
- Circular and Double-Buffered DMA
- DMA with ADC, UART and SPI
- Interrupts and DMA Completion Callbacks
- Cache Coherency on Applicable MCUs
- CPU Load Reduction and Throughput Optimization
- Cheat Sheet and Common Mistakes
A note on code: examples use generic C with STM32 HAL-style function names because they are the most widely seen. Register names and exact APIs differ between vendors (STM32, NXP, TI, Microchip, Nordic...), but the ideas are the same everywhere.
1. Why DMA Exists
Imagine a UART receiving data at 1 Mbaud. That is roughly 100,000 bytes per second. If the CPU handles each byte itself, it gets interrupted 100,000 times a second: save registers, run the handler, copy one byte, restore registers, repeat. Most of the CPU's time goes on being a courier, not doing your actual application work.
DMA (Direct Memory Access) is a small, dedicated hardware engine whose entire job is to be that courier.
WITHOUT DMA WITH DMA
----------- --------
Peripheral βββΊ CPU βββΊ RAM Peripheral βββΊ DMA βββΊ RAM
(every byte passes through the CPU) (CPU only sets it up, then does other work)
The deal is simple: you tell the DMA what to move, from where, to where and how much. Then you leave it alone. It interrupts you only when something interesting has happened (usually "I'm done" or "I'm halfway").
2. Direct Memory Access Fundamentals
2.1 What DMA does
DMA copies data between two places without CPU involvement in each transfer. Those places can be:
- a peripheral register (UART data register, ADC result register, SPI data register...)
- memory (SRAM, sometimes flash as a read-only source)
2.2 The three transfer directions
| Direction | Source | Destination | Typical example |
|---|---|---|---|
| Peripheral β Memory | Peripheral register | RAM buffer | ADC samples into an array, UART receive |
| Memory β Peripheral | RAM buffer | Peripheral register | UART transmit, DAC waveform, SPI display data |
| Memory β Memory | RAM/flash | RAM | Fast block copy (some controllers only) |
2.3 What you must tell the DMA (the "transfer descriptor")
Every DMA transfer is described by the same handful of parameters:
| Parameter | Meaning |
|---|---|
| Source address | Where to read from |
| Destination address | Where to write to |
| Number of data items | How many units to move (the count) |
| Data width | Size of each unit: byte (8-bit), half-word (16-bit), word (32-bit) |
| Address increment | Whether the address moves to the next item after each transfer |
| Direction | PβM, MβP or MβM |
| Mode | Normal (one-shot) or Circular (auto-restart) |
| Priority | Importance relative to other channels |
| Trigger / request | What event tells the DMA "go" (e.g. "UART has a byte") |
Address increment is the one beginners trip over. A peripheral data register is a single fixed address, so its address must not increment. A memory buffer is an array, so its address must increment.
Peripheral address: FIXED (always the same register)
Memory address: INCREMENT (walks along the buffer)
2.4 How a single transfer works
- A request arrives (e.g. "ADC conversion finished, a result is ready").
- The DMA controller wins arbitration for the bus.
- It reads one item from the source.
- It writes that item to the destination.
- It updates addresses (if increment is on) and decrements the remaining count.
- When the count hits zero, the transfer is complete: flag set, interrupt fired (if enabled), and in circular mode the count and addresses reload automatically.
2.5 CPU and DMA share the bus
Both the CPU and the DMA need to access memory and peripherals through the same internal bus system. A bus arbiter decides who goes first when both want the same bus at once.
- The DMA typically "steals cycles" from the CPU: occasionally the CPU waits a cycle or two.
- Modern MCUs use a multi-layer bus matrix so the CPU and DMA often access different memories at the same time without clashing.
- Heavy DMA traffic to the same RAM block the CPU is using can still slow the CPU down slightly. This is called bus contention.
2.6 What DMA can't do
- It doesn't process data, only moves it. Your code (or a hardware accelerator) must do any math or parsing.
- It can only reach memory regions that are wired to it. On many MCUs certain RAMs (e.g. tightly coupled memory) are CPU-only. Always check which memories your DMA can reach.
- It has no idea what your data means. If you change a buffer while DMA is using it, it will happily move the half-changed data.
3. DMA Controllers and Channels
3.1 Terminology
- DMA controller: the hardware block. An MCU may have one, two or more (e.g. DMA1 and DMA2).
- Channel (or stream, depending on vendor): one independent "lane" inside a controller. Each channel can run one transfer configuration at a time.
- Request line / trigger: the signal from a peripheral that asks for a transfer.
βββββββββββββββ DMA Controller βββββββββββββββ
UART_RX ββ€ β
ADC ββ€ Request ββ Channel 1 ββ β
SPI_TX ββ€ routing ββΊββ Channel 2 ββΌβΊ Arbiter βΊ Bus ββΌβββΊ RAM / Peripherals
TIMx ββ€ (mux) ββ Channel 3 ββ€ β
... ββ€ ββ Channel N ββ β
ββββββββββββββββββββββββββββββββββββββββββββββ
3.2 Request mapping: which peripheral uses which channel?
DMA channels are not freely assignable on every MCU:
- Fixed mapping (older/smaller parts): each peripheral request is hard-wired to specific channels. Two peripherals sharing a channel cannot use DMA at the same time. Check the reference manual's DMA request table.
- Flexible routing (e.g. a DMA request multiplexer such as DMAMUX): any request can be routed to any channel, which is far easier to plan around.
Practical tip: before designing your firmware, list every peripheral you want on DMA and check the mapping table for conflicts. Rework later is painful.
3.3 Channel priority and arbitration
If several channels request at once, the controller needs a rule:
- Software priority levels (e.g. low / medium / high / very high) set by you.
- Hardware tie-breaker: when priorities are equal, the lower channel number usually wins.
Give priority to transfers that can't tolerate delay or loss (e.g. a continuously receiving UART or a fast ADC) over bulk, relaxed transfers (e.g. a display refresh).
3.4 FIFO and burst transfers (on more advanced controllers)
Some DMA controllers (especially in higher-end MCUs) include a small FIFO buffer per channel and support burst transfers:
- Single transfer: one item per bus access.
- Burst transfer (e.g. 4, 8 or 16 beats): several items in one back-to-back bus occupancy. This uses the bus more efficiently, so total throughput goes up and the CPU is blocked less often overall.
- The FIFO can also repack data between different widths (e.g. a peripheral gives 16-bit values, memory stores packed 32-bit words).
3.5 Data width matching
The peripheral data width and memory data width are configured separately. They should normally match the real data: e.g. a 12-bit ADC result sits in a 16-bit register, so use half-word (16-bit) on both sides. Mismatches cause truncated, padded or misaligned data.
Also remember alignment: a 32-bit transfer expects a buffer address divisible by 4; 16-bit expects an even address. Misaligned buffers can cause a fault or garbage.
3.6 Typical configuration checklist (any channel)
- Enable the DMA controller's clock.
- Select the channel / route the request.
- Set direction.
- Set source and destination addresses.
- Set increment flags (peripheral: off; memory: on).
- Set data widths.
- Set transfer count.
- Set mode (normal or circular) and priority.
- Enable DMA interrupts you care about.
- Enable the peripheral's own "DMA request" bit (easy to forget!).
- Enable the channel.
4. Peripheral-to-Memory Transfers
4.1 What it is
Data flows from a peripheral's data register into a RAM buffer, with the CPU not involved per item. This is receiving or capturing.
Typical sources: ADC results, UART/SPI/IΒ²C received bytes, timer input-capture values, camera interface data, GPIO snapshots.
4.2 Configuration pattern
| Setting | Value |
|---|---|
| Direction | Peripheral β Memory |
| Source (peripheral) address | The peripheral's data register, fixed |
| Peripheral increment | Off |
| Destination (memory) address | Start of your buffer |
| Memory increment | On |
| Count | Number of items you expect |
4.3 How it flows
Peripheral gets new data
β
βΌ
Peripheral raises DMA request
β
βΌ
DMA reads peripheral data register βββΊ writes to buffer[i] βββΊ i++, count--
β
βββ repeat until count == 0 β "Transfer Complete" event
4.4 Example: capture 256 ADC samples
uint16_t adc_buf[256];
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, 256);
// ... CPU is free to do other things ...
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
// All 256 samples are now in adc_buf
data_ready = 1;
}
4.5 Things to watch out for
- Know when the data is valid. Don't read the buffer before the transfer-complete (or half-transfer) event.
- Unknown length reception (e.g. a UART receiving variable-size messages) needs extra tricks, such as the UART idle-line interrupt (section 7.3).
- Overrun: if the peripheral produces data faster than DMA (or the bus) can move it, the peripheral's data register is overwritten before being read, and data is lost. Correct priorities and adequate bus bandwidth prevent this.
5. Memory-to-Peripheral Transfers
5.1 What it is
Data flows from a RAM buffer into a peripheral's data register, one item at a time, paced by the peripheral. This is sending or outputting.
Typical destinations: UART/SPI transmit, DAC output (waveforms/audio), PWM duty-cycle updates (via a timer), LED strip drivers, display controllers.
5.2 Configuration pattern
| Setting | Value |
|---|---|
| Direction | Memory β Peripheral |
| Source (memory) address | Start of your buffer |
| Memory increment | On |
| Destination (peripheral) address | The peripheral's data register, fixed |
| Peripheral increment | Off |
| Count | Number of items to send |
5.3 How it flows
The peripheral decides when the next item is wanted (e.g. "my transmit register is empty"). The DMA responds by fetching the next item from the buffer and writing it into the peripheral. This is paced by the peripheral, so the DMA never overruns a slow peripheral like a UART at 9600 baud.
5.4 Example: send a message over UART
static const char msg[] = "Hello from DMA!\r\n";
HAL_UART_Transmit_DMA(&huart2, (uint8_t*)msg, sizeof(msg) - 1);
// returns immediately; the CPU keeps running
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
// Transmission finished; buffer may now be reused or a new transfer started
tx_busy = 0;
}
5.5 Things to watch out for
- Do not modify or free the buffer until the transfer-complete event. The DMA is still reading from it. Using a local (stack) array that goes out of scope is a classic bug.
- Check "busy" before starting another transfer on the same channel.
- Buffer lifetime: make it
static, global, or otherwise guaranteed to persist. - With a cache present, the CPU must flush its writes to RAM before DMA reads the buffer (section 9).
5.6 Special case: waveform generation
DMA + a DAC + a timer is a classic pattern:
Timer tick (fixed rate) ββΊ DAC requests next sample ββΊ DMA moves sine_table[i] ββΊ DAC output
Use circular mode so the table repeats forever with zero CPU load. The output frequency is set purely by the timer rate and table length.
6. Circular and Double-Buffered DMA
6.1 Normal mode vs. circular mode
| Mode | Behaviour at end of transfer |
|---|---|
| Normal | Stops. You must re-arm it to run again. |
| Circular | Automatically reloads the count and addresses and keeps going. |
Circular mode is ideal for continuous streams: sensors sampled forever, a UART that must always listen, an audio stream.
6.2 The "ring buffer" idea
In circular mode, the DMA writes to a fixed buffer and wraps back to the start when it reaches the end:
buffer: [ 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 ]
β² β
βββββββββββ wraps βββββββββββ
DMA write position keeps chasing around the ring.
Your code keeps its own read position chasing after it.
The CPU must read data before the DMA overwrites it. If the CPU is too slow, old data is silently lost.
6.3 The problem: when is it safe to read?
If the DMA continuously fills the buffer, how does your code process data without reading half-written values or racing with the DMA?
6.4 Solution: Half-Transfer and Transfer-Complete events (ping-pong)
Split the circular buffer into two halves. The DMA raises:
- Half-Transfer (HT) interrupt when the first half has just been filled
- Transfer-Complete (TC) interrupt when the second half has just been filled
While the DMA fills one half, the CPU safely processes the other:
Buffer: [ βββββ Half A βββββ | βββββ Half B βββββ ]
Time ββββββββββββββββββββββββββββββββββββββββββββββββββΊ
DMA writes: Half A ........ | Half B ........ | Half A ........ ...
HT event: β (A is full)
TC event: β (B is full)
CPU processes: (earlier B) | Half A .........| Half B ......... ...
#define BUF_LEN 512
uint16_t adc_buf[BUF_LEN]; // two halves of 256
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, BUF_LEN); // circular mode configured
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
process_samples(&adc_buf[0], BUF_LEN / 2); // first half is ready
}
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
process_samples(&adc_buf[BUF_LEN / 2], BUF_LEN / 2); // second half is ready
}
Rule: your processing of one half must finish before the DMA comes back around to that half (one half-buffer period). If it doesn't, you lose or corrupt data.
Timing example: 512-sample buffer at 10 kSPS gives a half-buffer of 256 samples = 25.6 ms to process each half.
6.5 Hardware double-buffer mode
Some DMA controllers (e.g. certain STM32 stream-based DMAs) have a dedicated double-buffer mode with two separate memory addresses (Memory 0 and Memory 1). The DMA automatically switches between two independent buffers after each completion:
DMA β fills Buffer 0 β switch β fills Buffer 1 β switch β fills Buffer 0 β ...
CPU β processes Buffer 1 while DMA fills Buffer 0, and vice versa
| Software ping-pong (half/full) | Hardware double-buffer | |
|---|---|---|
| Buffers | One buffer split in two | Two separate buffers at independent addresses |
| Support | Almost every DMA | Only some controllers |
| Flexibility | Halves must be adjacent | Buffers can be anywhere; you can even change the idle buffer's address on the fly (e.g. swap in a fresh buffer from a pool) |
6.6 Circular DMA for UART receive (always-listening)
Set up circular RX DMA into a ring buffer. The DMA's remaining-count register tells you where the write position is:
#define RX_LEN 256
uint8_t rx_buf[RX_LEN];
uint16_t last_pos = 0;
// start once at boot
HAL_UART_Receive_DMA(&huart1, rx_buf, RX_LEN); // circular
// write position = how many items have been written
uint16_t write_pos = RX_LEN - __HAL_DMA_GET_COUNTER(huart1.hdmaRx);
// new data is from last_pos up to write_pos (handle wrap-around)
6.7 Choosing the buffer size
- Too small: you must service events too often (more interrupts) and risk overrun.
- Too large: wastes RAM and increases latency (you wait longer for a half to fill).
- A good rule is to pick a size so the half-buffer period is comfortably longer than your worst-case processing time, with margin for interrupt latency and other tasks.
7. DMA with ADC, UART and SPI
7.1 DMA with ADC
Why: ADCs produce results continuously and often at high rates. Without DMA, every conversion needs an interrupt or a polled read.
Typical setup (continuous multi-sample acquisition):
- Configure the ADC for scan and/or continuous mode, or trigger it from a timer (for an exact sampling rate, with no jitter).
- Configure DMA as Peripheral β Memory, half-word width, circular, memory increment on.
- Enable the ADC's DMA request bit (many ADCs have a separate "DMA continuous requests" option; without it you'll get only one transfer).
- Process data in the HT/TC callbacks.
Multi-channel scan layout: with several channels in a scan, the DMA stores results interleaved:
Channels: CH0, CH1, CH2 scanned in order, repeated.
buffer: [ CH0 CH1 CH2 | CH0 CH1 CH2 | CH0 CH1 CH2 | ... ]
sample 0 sample 1 sample 2
Value of channel c at sample n: buffer[n * NUM_CH + c]
#define NUM_CH 3
#define SAMPLES 64
uint16_t adc_buf[NUM_CH * SAMPLES];
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, NUM_CH * SAMPLES);
uint16_t get(int n, int ch) { return adc_buf[n * NUM_CH + ch]; }
Tips:
- Keep the sample count a multiple of the channel count so the channels never get out of alignment after a wrap or restart.
- Use a timer trigger if sampling rate accuracy matters.
- Make sure the DMA data width matches the ADC result width (usually 16-bit).
7.2 DMA with UART
Transmit (Memory β Peripheral)
Simple: give DMA a buffer and a length. UART pacing is automatic. Great for logging and sending packets without blocking.
HAL_UART_Transmit_DMA(&huart2, tx_buf, len);
For continuous logging, use a TX queue / ring buffer: while one DMA transfer is in progress, new messages are appended to the queue; in the TX-complete callback, start another DMA transfer with whatever has accumulated.
Receive (Peripheral β Memory), the interesting part
UART data often arrives as messages of unknown length. DMA needs a count up front, so what do you do?
| Approach | How | Notes |
|---|---|---|
| Fixed-size packets | Set DMA count = packet size | Simple; only if all messages are the same length |
| Circular buffer + polling | Check the write position periodically | Needs regular polling; may add latency |
| Circular/normal DMA + IDLE line interrupt | The UART detects the line has been quiet for one character time after a burst and raises an IDLE interrupt; you then compute how many bytes arrived | Most popular for variable-length frames |
| Half/Full callbacks plus idle | Combine for safety | Robust against long bursts |
IDLE-line recipe:
#define RX_LEN 256
uint8_t rx_buf[RX_LEN];
uint16_t old_pos = 0;
// start
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, RX_LEN);
// called on IDLE, half-transfer or full-transfer
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t pos)
{
if (pos != old_pos) {
if (pos > old_pos) {
handle_data(&rx_buf[old_pos], pos - old_pos);
} else { // wrapped around the end of the buffer
handle_data(&rx_buf[old_pos], RX_LEN - old_pos);
if (pos > 0) handle_data(&rx_buf[0], pos);
}
old_pos = pos;
}
}
Exact function names vary between library versions, but the principle is the same: IDLE tells you a frame ended; the DMA counter tells you how long it was.
Tips:
- Handle UART error flags (overrun, framing, noise) so reception doesn't silently stop.
- Keep the receive buffer big enough that the CPU can fall behind a little without data loss.
7.3 DMA with SPI
SPI is full-duplex: every clock pulse sends a bit out and brings a bit in. That has a key consequence for DMA:
| SPI use | DMA channels needed |
|---|---|
| Transmit only (e.g. display, DAC, LED driver) | TX channel (Memory β Peripheral). Received bytes are discarded; the RX register may overflow, so some peripherals need that handled or disabled. |
| Receive only (e.g. reading a sensor/flash) | RX channel, but the master still has to generate clocks, which means sending dummy bytes, so TX is usually needed too (some drivers do this internally) |
| Full-duplex exchange (e.g. SD card, flash, sensor registers) | Both TX and RX channels |
// full duplex: send tx_buf while capturing rx_buf
HAL_SPI_TransmitReceive_DMA(&hspi1, tx_buf, rx_buf, len);
void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi)
{
// both buffers done
cs_high(); // release chip-select only now
spi_done = 1;
}
SPI tips:
- Chip-select (CS) timing is yours to manage: pull CS low before starting DMA and release it only in the completion callback, not right after starting the transfer (the transfer hasn't finished yet!).
- For bulk SPI (displays, flash), DMA can give a huge speed and CPU-load benefit.
- Data-width alignment matters: 8-bit vs 16-bit frames, and byte order for 16-bit pixels on displays.
- For tiny transfers (a couple of bytes), plain blocking SPI can be quicker than DMA setup (see section 10.5).
8. Interrupts and DMA Completion Callbacks
8.1 The key DMA events
| Event | Meaning |
|---|---|
| Half-Transfer (HT) | Half the programmed count has been transferred |
| Transfer-Complete (TC) | The whole programmed count has been transferred |
| Transfer-Error (TE) | A bus error occurred (bad address, forbidden memory, etc.) |
| FIFO error / Direct-mode error (advanced controllers) | FIFO under/overrun or config mismatch |
Each event sets a flag and can optionally trigger an interrupt. You choose which to enable. Don't enable HT if you don't need it, since it only adds interrupt load.
8.2 The interrupt flow
DMA event (e.g. TC)
β
βΌ
DMA interrupt (IRQ handler)
β - checks which flag(s) are set
β - clears the flag(s)
βΌ
Driver/HAL calls your CALLBACK (e.g. HAL_..._CpltCallback)
β
βΌ
Your code: process data / start next transfer / set a flag
Many libraries hide the raw interrupt handler and give you callbacks, which are functions you write that are called when an event occurs. Names differ between vendors, but the roles are common: half-complete, complete and error.
8.3 Two levels of interrupts: DMA vs. peripheral
Be careful not to mix these up:
- DMA interrupts report on the transfer ("the whole buffer is done").
- Peripheral interrupts report on the peripheral (e.g. UART IDLE line, ADC end of sequence, SPI error).
For example, with UART DMA RX you often use both: DMA HT/TC for the buffer, and the UART IDLE interrupt for end-of-frame detection.
8.4 Writing good callbacks
A callback runs in interrupt context, so:
- Keep it short. Set a flag, copy a pointer/length, post to a queue, or toggle a state. Do heavy processing in the main loop or a task.
- Don't block (no long delays, no waiting for other peripherals).
- Mark variables shared with the main code as
volatile, and protect multi-step shared updates (disable the interrupt briefly or use an atomic/queue mechanism). - In an RTOS, use the "FromISR" variants of queue/semaphore calls.
volatile bool half_ready = false, full_ready = false;
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { half_ready = true; }
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { full_ready = true; }
int main(void)
{
// init, start DMA ...
while (1) {
if (half_ready) { half_ready = false; process(&buf[0], HALF); }
if (full_ready) { full_ready = false; process(&buf[HALF], HALF); }
// other work ...
}
}
Detect overruns: if a flag is set again before you've cleared it, you have missed a period. Count these events during development; it tells you your processing is too slow.
8.5 Handling errors
- Transfer error: abort/restart the channel, flag it, and re-check your addresses, alignment and region access.
- Peripheral errors: (UART overrun, SPI mode fault) clear them, or the peripheral may stop issuing DMA requests.
- After an error, re-initialise the transfer (re-arm count and addresses) in a known-good state; don't assume it carries on correctly.
8.6 Interrupt priority
DMA interrupts compete with other interrupts. Give time-critical DMA events (e.g. a fast audio/ADC stream) high enough NVIC priority that a low-priority task can't delay them past the buffer deadline.
8.7 When no interrupt is needed at all
If you only need to start a transfer and later check "is it done?", you can poll the flag or the remaining-count register, or let a timer/other peripheral pace everything. For purely continuous circular streams, you can sometimes run with no DMA interrupts and simply read the latest data when you need it, as long as you tolerate occasional reads between updates.
9. Cache Coherency on Applicable MCUs
Applicability: this section matters only for MCUs with a data cache (D-cache), such as parts with Cortex-M7 cores (and some higher-end Cortex-M33/M55 variants). Typical smaller MCUs (Cortex-M0/M0+/M3/M4 and similar) have no data cache, so none of this applies there. Always check your device.
9.1 What is the problem?
A cache is a small, fast memory that keeps copies of recent RAM contents so the CPU doesn't have to wait for slow RAM. The DMA bypasses the cache and reads/writes RAM directly. That creates two mismatches:
CPU ββββΊ [ D-CACHE ] ββββΊ RAM ββββΊ DMA
(copy) (goes straight to RAM)
Case 1: DMA writes, CPU reads (Peripheral β Memory)
- DMA writes fresh data into RAM.
- The cache still holds an old copy of that buffer.
- The CPU reads the buffer and gets the stale cached values instead of the new data.
Case 2: CPU writes, DMA reads (Memory β Peripheral)
- The CPU fills a buffer. With a write-back cache, the new data may sit only in the cache and not yet in RAM.
- The DMA reads RAM and sends old/garbage data.
9.2 The two cache maintenance operations
| Operation | What it does | Use it when |
|---|---|---|
| Clean (flush) | Writes cache contents out to RAM | Before DMA reads a buffer the CPU wrote (Memory β Peripheral) |
| Invalidate | Discards cache copies so the next CPU read fetches from RAM | Before the CPU reads a buffer the DMA wrote (Peripheral β Memory) |
TX (CPU β DMA): CPU fills buffer β CLEAN β start DMA
RX (DMA β CPU): start DMA β DMA done β INVALIDATE β CPU reads buffer
CMSIS provides helper functions for Cortex-M:
// Before DMA transmit
SCB_CleanDCache_by_Addr((uint32_t*)tx_buf, sizeof(tx_buf));
HAL_UART_Transmit_DMA(&huart2, tx_buf, sizeof(tx_buf));
// After DMA receive is complete (e.g. in the TC callback)
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, sizeof(rx_buf));
process(rx_buf);
9.3 The cache-line trap
The cache works in lines (commonly 32 bytes on Cortex-M7). Maintenance operations act on whole lines. This leads to nasty bugs if a buffer shares a line with other data:
- Invalidating a line that also holds unrelated variables throws away the CPU's unsaved changes to those variables.
- Cleaning/invalidating misaligned ranges may touch neighbouring data unexpectedly.
Rules for DMA buffers on cached systems:
- Align buffers to the cache-line size (e.g. 32 bytes).
- Make buffer sizes a multiple of the cache-line size.
- Don't place other variables inside those cache lines.
// 32-byte aligned, size multiple of 32
__attribute__((aligned(32))) uint8_t rx_buf[256];
9.4 Alternatives to manual cache maintenance
| Approach | Idea | Trade-off |
|---|---|---|
| Non-cacheable memory region (via MPU) | Mark the DMA buffer memory as non-cacheable | Simplest and safest for DMA buffers; no maintenance calls; CPU access to that region is slower |
| Write-through region | Writes go to RAM immediately, so clean isn't needed for TX | You still need to invalidate for RX |
| Use RAM that isn't cached / separate DMA-friendly RAM bank | Place buffers in a specific linker section | Needs linker script/section setup |
| Disable D-cache | No coherency issue | Large performance penalty; usually a last resort |
A common professional approach: put all DMA buffers in a dedicated, MPU-configured non-cacheable section.
9.5 Other "coherency" gotchas (apply even without a cache)
- Compiler optimisation: the compiler may assume a buffer doesn't change on its own. Declare DMA-updated buffers
volatileor use memory barriers so reads aren't optimised away or reordered. - Memory barriers: after cache maintenance or before enabling DMA, a barrier (e.g.
__DSB()) ensures operations have completed in order. - Write buffers: even without a cache, CPU writes may be buffered; a barrier before starting DMA makes sure the data has really reached memory.
9.6 Symptoms of a coherency bug
- Data looks correct in the debugger (debuggers can bypass the cache) but wrong at run time.
- Old data repeats, or only the first transfer works.
- Errors appear only when the cache is on, or at higher clock speeds / optimisation levels.
- The result changes when you add a
printfor a breakpoint.
10. CPU Load Reduction and Throughput Optimization
10.1 How DMA reduces CPU load
Compare handling a continuous 1 Mbit/s UART stream (about 100 kB/s):
| Method | CPU involvement |
|---|---|
| Polling | CPU spins waiting, with no time for other work |
| Interrupt per byte | ~100,000 interrupts/s; each costs entry/exit overhead plus handler code, which can be a large share of the CPU |
| DMA + HT/TC (or idle) interrupts | Only a handful of interrupts per second (one per half-buffer or frame); the data copy costs the CPU nothing |
Rule of thumb: with DMA the number of interrupts falls from "per item" to "per buffer (or per frame)". Doubling the buffer size roughly halves the interrupt rate.
Benefits beyond free CPU cycles:
- Lower power: the CPU can sleep while DMA works (many MCUs keep DMA running in low-power sleep modes).
- More deterministic timing: the CPU is not constantly interrupted, so real-time tasks run more predictably.
- Fewer missed samples: the DMA responds in hardware, much faster than interrupt latency.
10.2 Estimating DMA throughput
Throughput depends on:
Bytes/s β transfer width (bytes) Γ transfers per second
- The peripheral's data rate often sets the cap (a 115200-baud UART moves only ~11.5 kB/s, so DMA throughput is not the bottleneck).
- For fast transfers (memory-to-memory, SPI at tens of MHz, camera/display interfaces), the limits are the bus clock, bus width, bus contention and memory wait states.
10.3 Optimisation techniques
a) Use the widest sensible data width
Moving 32-bit words instead of bytes can move 4x the data per bus cycle (for memory-to-memory or when the peripheral allows it). Keep widths consistent with the real data format.
b) Use burst transfers and FIFOs (where available)
Bursts keep the bus occupied for a short continuous run instead of releasing and re-arbitrating after every item, which lowers per-item overhead.
c) Use larger buffers and fewer interrupts
Handle data in blocks rather than item-by-item. Pick the buffer size so interrupts happen at a comfortable rate (see 6.7).
d) Choose priorities wisely
- High priority for time-critical, loss-intolerant streams (fast ADC, continuous UART RX).
- Lower priority for bulk or relaxed transfers (display refresh, logging).
- Avoid starving a low-priority channel completely if it has a deadline.
e) Reduce bus contention
- Place buffers in different RAM banks/regions from those the CPU is hammering, so CPU and DMA can work in parallel on separate bus paths.
- Avoid having many high-rate DMA channels all hitting the same memory block.
- Don't leave the CPU busy-polling the same memory region the DMA is writing.
f) Use hardware triggers
Let a timer trigger the ADC/DAC/DMA directly. Sampling becomes exact and the CPU isn't involved at all in pacing.
g) Chain work with minimal CPU glue
Where the controller supports it (linked-list or scatter-gather descriptors, or one DMA triggering another), set up multi-step sequences once, so the hardware runs the whole chain.
h) Be careful with cache-related cost
On cached MCUs, cache maintenance operations take time. Maintain only the needed range, not the entire cache.
10.4 Measuring: don't guess
- Toggle a GPIO at the start and end of your callback or processing and watch it on a scope/logic analyser to measure real duration and jitter.
- Use a cycle counter (e.g. the Cortex-M DWT cycle counter) to time code sections.
- Measure CPU idle percentage with and without DMA.
- Add overrun counters to detect missed deadlines.
10.5 When DMA is not worth it
DMA has a fixed setup cost (configure, start, handle the completion event). For very small transfers, the overhead can exceed the cost of copying directly.
| Situation | Better choice |
|---|---|
| A few bytes, occasional | Plain polling or interrupt |
| Bulk data, continuous streams, high rates | DMA |
| Very latency-sensitive single access | Direct CPU access |
| Variable, rare short messages at low baud | Interrupt-driven is often simpler |
10.6 Throughput vs. latency
- Bigger buffers give higher efficiency (fewer interrupts) but higher latency (you wait longer before the first data is available).
- Smaller buffers give lower latency but more interrupts.
- Choose based on the application: audio and control loops care about latency; bulk logging cares about efficiency.
11. Cheat Sheet and Common Mistakes
Quick configuration table
| Task | Direction | Peripheral addr inc | Memory addr inc | Mode | Events used |
|---|---|---|---|---|---|
| Capture N ADC samples | P β M | Off | On | Normal | TC |
| Continuous ADC stream | P β M | Off | On | Circular | HT + TC |
| UART transmit buffer | M β P | Off | On | Normal | TC |
| UART always-listening RX | P β M | Off | On | Circular | HT + TC + IDLE |
| SPI full-duplex | RX: P β M and TX: M β P | Off | On (both) | Normal | RX TC |
| DAC waveform | M β P | Off | On | Circular | None / optional |
Key concepts in one line each
- DMA = hardware courier that moves data without the CPU.
- Channel/stream = one independent transfer lane; check the request mapping table.
- P β M = capture/receive; M β P = output/send.
- Peripheral address fixed, memory address increments (the usual rule).
- Circular mode = automatic restart for continuous streams.
- Half + Complete events = safe ping-pong processing of a single circular buffer.
- Double-buffer mode = hardware switching between two separate buffers (where supported).
- Callbacks run in interrupt context: keep them short, use
volatile, defer heavy work. - Cache (D-cache MCUs only): clean before DMA reads; invalidate before CPU reads DMA-written data; align buffers to cache lines, or use a non-cacheable region.
- CPU load drops because interrupts fall from per-item to per-buffer.
Common mistakes
- β Forgetting to enable the peripheral's DMA request bit (the DMA channel is armed but never triggered).
- β Wrong increment settings, such as incrementing the peripheral address or forgetting to increment memory.
- β Mismatched data widths between peripheral, memory and buffer type (e.g. 16-bit ADC into a
uint8_tbuffer). - β Two peripherals sharing a channel that can't be used together (mapping conflict).
- β Using a buffer that goes out of scope (a local array) for DMA transmit.
- β Modifying a buffer while DMA is still using it.
- β Reading the buffer before the transfer-complete/half-complete event.
- β Processing too slowly in circular mode, so the DMA overwrites data you haven't read.
- β Doing heavy work inside the callback/ISR.
- β Releasing SPI chip-select right after starting DMA instead of in the completion callback.
- β Ignoring peripheral error flags (e.g. UART overrun), which can silently stop DMA requests.
- β Skipping cache maintenance on cached MCUs, or using unaligned buffers / sizes that share cache lines with other data.
- β Placing buffers in memory the DMA cannot reach (CPU-only RAM).
- β Assuming DMA is always faster, when for tiny transfers its setup cost isn't worth it.
- β Forgetting to re-arm a normal-mode transfer after completion.
Mini design checklist
1. List all peripherals needing DMA β check request mapping for conflicts
2. Choose direction, widths, increments, mode (normal / circular)
3. Place buffers in DMA-reachable RAM (aligned; cache-safe if D-cache present)
4. Pick buffer size: balance interrupt rate, latency and RAM
5. Enable peripheral DMA request + DMA channel + needed interrupts (HT/TC/TE)
6. Set priorities (DMA channel + NVIC)
7. Write short callbacks β flag/queue β process in main loop
8. Handle errors and re-arm correctly
9. Measure CPU load, deadlines and overruns
#Embedded Software Architecture#
Study notes: how to organise firmware so it stays correct, readable, portable and reliable as it grows.
Table of Contents
- Why Architecture Matters
- Superloop Architecture
- Finite-State Machines (FSM)
- Event-Driven Programming
- Cooperative and Preemptive Scheduling
- Layered Architecture
- Hardware Abstraction Layers (HAL)
- Board Support Packages (BSP)
- Device Drivers and APIs
- Modular Code and Reusable Components
- State Machines for Communication and Control
- Error Handling and Fault Recovery
- Watchdog-Based Recovery
- Low-Power Modes and Sleep States
- Putting It All Together
- Cheat Sheet and Common Mistakes
A note on code: examples are plain C and deliberately simplified. Function names like
hal_gpio_write()are illustrative, not from one specific vendor.
1. Why Architecture Matters
A first embedded project is usually one main.c: blink an LED, read a sensor, print a value. It works. Then the product grows: more sensors, a display, a communication link, error cases, low-power requirements, a second hardware revision... and suddenly the code is a tangle where changing one thing breaks three others.
Software architecture is the set of decisions about how the code is organised: how it is split into parts, how those parts talk to each other, and how time and events are handled.
Good embedded architecture aims for:
| Goal | Meaning |
|---|---|
| Correctness | Does the right thing at the right time |
| Responsiveness | Reacts to events within deadlines |
| Readability / maintainability | Another engineer (or you in six months) can understand it |
| Portability | Moves to another MCU or board with little rework |
| Reusability | Components can be dropped into the next product |
| Testability | Parts can be tested in isolation |
| Robustness | Survives bad input, faults and glitches |
| Efficiency | Fits the memory, speed and power budget |
The sections below build from the simplest structure (the superloop) up to reliability and power management.
2. Superloop Architecture
2.1 What it is
The superloop (also called "super loop", "main loop" or "bare-metal loop") is the simplest architecture: after initialisation, main() runs an endless loop that calls every task in turn.
int main(void)
{
system_init();
sensors_init();
comms_init();
while (1) {
read_sensors();
update_control();
handle_communication();
update_display();
}
}
ββββββββββββββββββββββββββββββββββββββββββββββββ
β init β
β β β
β βΌ β
β βββΊ task A ββΊ task B ββΊ task C ββΊ task D βββ β
β ββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββ
2.2 Superloop + interrupts ("foreground/background")
Pure superloops are slow to react to urgent events, so they are almost always combined with interrupts:
- Background: the main loop doing routine work.
- Foreground: interrupt service routines (ISRs) responding to urgent events immediately.
The ISR does the minimum (grab the data, set a flag) and the main loop does the processing later:
volatile bool data_ready = false;
volatile uint16_t latest_sample;
void ADC_IRQHandler(void) // foreground
{
latest_sample = adc_read();
data_ready = true;
}
int main(void) // background
{
init();
while (1) {
if (data_ready) {
data_ready = false;
process(latest_sample);
}
other_work();
}
}
Variables shared between ISR and main loop must be
volatile, and multi-byte updates may need protection (briefly disabling the interrupt) to avoid reading half-updated values.
2.3 Timing in a superloop
Tasks often need to run at different rates. A common trick is a timer tick (e.g. 1 ms) that increments a counter, then tasks check elapsed time:
volatile uint32_t ms_ticks; // incremented in a timer ISR every 1 ms
uint32_t last_sensor = 0, last_display = 0;
while (1) {
uint32_t now = ms_ticks;
if (now - last_sensor >= 10) { // every 10 ms
last_sensor = now;
read_sensors();
}
if (now - last_display >= 200) { // every 200 ms
last_display = now;
update_display();
}
}
Note the subtraction now - last handles counter wrap-around correctly with unsigned arithmetic.
2.4 Strengths and weaknesses
| Strengths | Weaknesses |
|---|---|
| Very simple, easy to understand | Response time depends on the longest task; one slow task delays everything |
| Tiny memory footprint, no OS needed | Blocking code (delay(), waiting loops) freezes the whole system |
| Easy to debug | Hard to scale as tasks and timing needs multiply |
| Predictable, no context switching | Hard to guarantee deadlines |
| Great for small, simple devices | Priorities are only "order in the loop" |
2.5 Rules to keep a superloop healthy
- Never block. Replace
delay_ms(500)with "check the time, do it later" or a state machine. - Keep each task short, doing a small slice of work and returning.
- Do the urgent bit in ISRs, the rest in the loop.
- Use state machines (next section) to break long operations into small steps.
3. Finite-State Machines (FSM)
3.1 The idea
A finite-state machine is a model where a system is, at any moment, in exactly one state out of a limited set. It moves between states when certain events (or conditions) occur, optionally performing actions along the way.
Think of a traffic light, a vending machine or a microwave: what you do depends on what state you're in.
3.2 The core elements
| Element | Meaning | Example (door lock) |
|---|---|---|
| State | A situation the system can be in | LOCKED, UNLOCKED, ALARM |
| Event / input | Something that happens | CORRECT_CODE, WRONG_CODE, TIMEOUT |
| Transition | Moving from one state to another | LOCKED β UNLOCKED on CORRECT_CODE |
| Action | Work done on a transition or inside a state | Turn the motor, beep, log |
| Initial state | Where it starts | LOCKED |
| Entry / exit actions | Code run when entering or leaving a state | Start a timer on entry |
3.3 State diagram example
correct code
ββββββββββββββββββββββββββββββββ
β βΌ
βββββββββββ βββββββββββββ
β LOCKED ββββββββββββββββββββββ UNLOCKED β
ββββββ¬βββββ timeout βββββββββββββ
β 3 wrong codes
βΌ
βββββββββββ
β ALARM βββ reset βββΊ LOCKED
βββββββββββ
3.4 Why FSMs are so useful in embedded
- They make complex behaviour explicit and easy to review.
- They turn long, blocking sequences into small non-blocking steps (ideal for a superloop).
- They make it clear what happens for every event in every state, including the unexpected ones.
- They are easy to test: feed events, check states.
3.5 Implementation style 1: switch-case
typedef enum { ST_LOCKED, ST_UNLOCKED, ST_ALARM } lock_state_t;
typedef enum { EV_CORRECT, EV_WRONG, EV_TIMEOUT, EV_RESET } lock_event_t;
static lock_state_t state = ST_LOCKED;
static uint8_t wrong_count = 0;
void lock_handle_event(lock_event_t ev)
{
switch (state) {
case ST_LOCKED:
if (ev == EV_CORRECT) {
motor_unlock();
timer_start(5000);
wrong_count = 0;
state = ST_UNLOCKED;
} else if (ev == EV_WRONG) {
if (++wrong_count >= 3) {
alarm_on();
state = ST_ALARM;
}
}
break;
case ST_UNLOCKED:
if (ev == EV_TIMEOUT) {
motor_lock();
state = ST_LOCKED;
}
break;
case ST_ALARM:
if (ev == EV_RESET) {
alarm_off();
wrong_count = 0;
state = ST_LOCKED;
}
break;
}
}
Good for: small, simple machines. Downside: can become a huge, nested switch as it grows.
3.6 Implementation style 2: state-transition table
Describe the machine as data, with the code just a lookup:
typedef struct {
lock_state_t current;
lock_event_t event;
lock_state_t next;
void (*action)(void);
} transition_t;
static const transition_t table[] = {
{ ST_LOCKED, EV_CORRECT, ST_UNLOCKED, act_unlock },
{ ST_UNLOCKED, EV_TIMEOUT, ST_LOCKED, act_lock },
{ ST_ALARM, EV_RESET, ST_LOCKED, act_reset },
};
void fsm_dispatch(lock_event_t ev)
{
for (size_t i = 0; i < sizeof(table)/sizeof(table[0]); i++) {
if (table[i].current == state && table[i].event == ev) {
if (table[i].action) table[i].action();
state = table[i].next;
return;
}
}
// no matching transition: ignore, or flag unexpected event
}
Good for: many transitions, easy to read as a table, easy to modify. Downside: a little slower and more RAM/flash than a plain switch.
3.7 Implementation style 3: state-function pointers
Each state is a function; the current state is a function pointer:
typedef void (*state_fn)(lock_event_t);
void st_locked(lock_event_t ev);
void st_unlocked(lock_event_t ev);
static state_fn current = st_locked;
void fsm_run(lock_event_t ev) { current(ev); }
// inside a state function, change state with: current = st_unlocked;
Readable and modular: each state's logic lives in its own function.
3.8 Moore vs. Mealy machines
| Type | Output depends on |
|---|---|
| Moore | The state only (actions happen on entering/while in a state) |
| Mealy | The state and the input (actions happen on transitions) |
Real firmware mixes both freely; the distinction mostly helps when reasoning about timing of outputs.
3.9 Practical FSM advice
- Always define behaviour for unexpected events (ignore them deliberately or log them).
- Keep states few and meaningful; if you have dozens, consider splitting into hierarchical (nested) state machines (e.g. a parent "Operating" state with sub-states).
- Use entry/exit actions to avoid repeating setup/teardown code on every incoming/outgoing transition.
- Include timeouts as events so no state can wait forever.
- Write the state diagram first, then the code.
- Don't block inside a state. Return and wait for the next event/tick.
4. Event-Driven Programming
4.1 The idea
In event-driven programming, the program's flow is controlled by events rather than by a fixed sequence. The system mostly waits; when something happens (a button press, a received byte, a timer expiry, a sensor reading), the right code runs in response.
Polling (checking constantly) Event-driven (reacting)
βββββββββββββββββββββββββββββ βββββββββββββββββββββββ
"Has anything happened? no... Do nothing / sleep until
has anything happened? no... something happens, then
has anything happened? yes!" handle exactly that event.
4.2 Typical building blocks
| Block | Role |
|---|---|
| Event source | Interrupts, timers, communication peripherals, other tasks |
| Event | A small message: what happened, plus optional data |
| Event queue | FIFO buffer that holds events until they're processed |
| Dispatcher / event loop | Takes events from the queue and routes them to handlers |
| Event handler | Code that reacts to a specific event |
[Button ISR] βββ
[UART ISR] ββββΌβββΊ [ Event Queue ] βββΊ [ Dispatcher ] βββΊ handler_button()
[Timer ISR] ββββ ββββΊ handler_uart()
ββββΊ handler_timeout()
4.3 A minimal event loop
typedef enum { EV_NONE, EV_BUTTON, EV_UART_BYTE, EV_TIMEOUT } event_id_t;
typedef struct {
event_id_t id;
uint32_t data;
} event_t;
// queue_push() is called from ISRs; queue_pop() from the main loop
// (queue must be safe for ISRβmain use: e.g. single producer / single consumer ring buffer)
int main(void)
{
init();
event_t ev;
while (1) {
if (queue_pop(&ev)) {
dispatch(&ev); // route to the correct handler
} else {
sleep_until_interrupt(); // nothing to do: save power
}
}
}
void dispatch(const event_t *ev)
{
switch (ev->id) {
case EV_BUTTON: on_button(ev->data); break;
case EV_UART_BYTE: on_uart_byte(ev->data);break;
case EV_TIMEOUT: on_timeout(); break;
default: break;
}
}
4.4 Why it combines so well with FSMs
Events are exactly the inputs that make a state machine move. The common pattern is:
Event arrives β dispatcher β state machine's handler(event) β transition + action
4.5 Run-to-completion (RTC) principle
Each handler must finish completely before the next event is processed. Handlers must be short and non-blocking. This keeps behaviour predictable and removes many concurrency problems, since only one handler runs at a time.
4.6 Callbacks and the observer idea
Instead of one giant switch, modules can register callbacks (function pointers) for events they care about:
typedef void (*event_cb_t)(const event_t *);
void event_register(event_id_t id, event_cb_t cb);
// elsewhere
event_register(EV_BUTTON, on_button);
This decouples the producer of an event from the code that reacts to it.
4.7 Advantages and pitfalls
| Advantages | Pitfalls |
|---|---|
| Responsive; reacts only when needed | Queue overflow if events arrive faster than they're handled |
| Naturally supports low power (sleep when idle) | Long handlers delay all other events |
| Loose coupling between modules | Control flow is less obvious to read ("what called this?") |
| Pairs well with state machines | Priority handling needs thought (urgent events behind slow ones) |
| Scales better than a plain superloop | Debugging event sequences needs logging/tracing |
Tips:
- Size the queue for the worst-case burst, and decide what to do on overflow (drop oldest? drop newest? flag an error?).
- Keep events small; pass pointers/indices for large data rather than copying it.
- Consider separate priority queues for urgent events.
5. Cooperative and Preemptive Scheduling
When a system must do several things "at once", a scheduler decides which task runs when. A task is an independent piece of work (e.g. "read sensor", "update display", "handle communication").
5.1 Key terms
| Term | Meaning |
|---|---|
| Task / thread | A unit of work with its own execution context |
| Scheduler | Chooses the next task to run |
| Context switch | Saving one task's state (registers, stack pointer) and restoring another's |
| Priority | Relative importance used to choose between ready tasks |
| Tick | Periodic timer interrupt that drives scheduling and timekeeping |
| Task states | Typically Ready, Running, Blocked/Waiting, Suspended |
| Latency | Delay between an event and the start of its handling |
| Deadline / jitter | Time by which work must finish; variation in timing |
5.2 Cooperative scheduling
Tasks run until they voluntarily give up the CPU (by returning, yielding or blocking on a wait). The scheduler cannot forcibly interrupt a task.
Task A runs βββΊ yields βββΊ Task B runs βββΊ yields βββΊ Task C runs βββΊ yields βββΊ Task A ...
(A decides when to give up control)
A very common cooperative design is a simple task loop with timing (a "run-to-completion" scheduler):
typedef struct {
void (*run)(void);
uint32_t period_ms;
uint32_t last_run;
} task_t;
static task_t tasks[] = {
{ read_sensors, 10, 0 },
{ update_control, 20, 0 },
{ update_display, 200,0 },
};
void scheduler_run(void)
{
uint32_t now = ms_ticks;
for (size_t i = 0; i < sizeof(tasks)/sizeof(tasks[0]); i++) {
if (now - tasks[i].last_run >= tasks[i].period_ms) {
tasks[i].last_run = now;
tasks[i].run(); // runs to completion
}
}
}
Strengths
- Simple, small, little RAM (no per-task stacks in the basic form).
- No race conditions between tasks (they never interrupt each other), so far fewer locking problems.
- Deterministic context switching points.
Weaknesses
- One misbehaving or slow task delays everything, so a long task ruins response time for all others.
- No real priorities or true pre-emption for urgent events (ISRs help but only partly).
- Worst-case response time = the longest task runtime.
5.3 Preemptive scheduling
The scheduler can interrupt a running task at any time (typically on a tick or when a higher-priority task becomes ready) and switch to another task. This is what a Real-Time Operating System (RTOS) commonly provides.
Priority
High β βββBβββ
β β β
Low β ββAβββββββ βββββA (resumes)ββββ
ββββββββββββββββββββββββββββββββββββΊ time
A running B becomes ready β A is preempted β B runs β A resumes
Strengths
- Fast response to high-priority events, with bounded latency regardless of lower-priority work.
- Tasks can be written as independent loops, so the code is conceptually clean.
- Good for hard/soft real-time systems.
Weaknesses
- Needs a stack per task (more RAM) and a scheduler (more flash).
- Shared-data hazards: race conditions, inconsistent updates.
- Risk of priority inversion and deadlock (below).
- Harder to debug (timing-dependent bugs).
5.4 Comparison
| Aspect | Cooperative | Preemptive |
|---|---|---|
| Who switches tasks? | The task itself (yield/return/block) | The scheduler (tick/event-triggered) |
| Response time | Depends on the longest task | Bounded by priority and ISR/tick latency |
| RAM use | Low | Higher (a stack per task) |
| Shared data safety | Simple (tasks don't interrupt each other) | Needs protection (mutex/critical sections) |
| Complexity | Low | Higher |
| Best for | Simple, light systems with short tasks | Systems with hard deadlines, many concurrent activities |
Hybrid designs are common: preemptive between RTOS tasks, cooperative (run-to-completion) event handling inside a task, and ISRs on top of everything.
5.5 Scheduling policies (briefly)
- Priority-based preemptive (most RTOSes): the highest-priority ready task always runs. Equal priorities often use round-robin time slicing.
- Rate-monotonic: a way of assigning priorities so that tasks with shorter periods get higher priority.
- Round-robin: each ready task gets a turn in rotation.
5.6 Synchronisation and communication between tasks (preemptive world)
| Mechanism | Purpose |
|---|---|
| Mutex | Protect a shared resource so one task uses it at a time |
| Semaphore | Signal events / count resources (binary or counting) |
| Queue / mailbox | Pass data or messages safely between tasks (and from ISRs) |
| Event flags | Tasks wait for one or more flags/bits |
| Critical section | Briefly disable interrupts/scheduling to update shared data |
Classic hazards
- Race condition: result depends on unlucky timing between tasks.
- Deadlock: two tasks each hold something the other needs, so both wait forever.
- Priority inversion: a low-priority task holds a lock needed by a high-priority task, while a medium task keeps running; fixed by priority inheritance (supported by many RTOS mutexes).
- Starvation: a low-priority task never gets CPU time.
5.7 Choosing
- Few simple tasks, loose timing β superloop / cooperative.
- Many concurrent activities, mixed deadlines, communication stacks β RTOS with preemptive scheduling.
- Don't use an RTOS just because it's fashionable; use it when the timing and structure problems justify the overhead.
5.8 ISR rules (apply to every architecture)
- Keep ISRs short; defer work to tasks or the main loop.
- Use RTOS "from ISR" API variants when signalling tasks.
- Don't call blocking functions in ISRs.
- Set interrupt priorities deliberately.
6. Layered Architecture
6.1 The idea
Split the firmware into layers, where each layer uses only the services of the layer directly beneath it and hides its own details from the layer above.
βββββββββββββββββββββββββββββββββββββββββββββ
β Application layer β what the product DOES
βββββββββββββββββββββββββββββββββββββββββββββ€
β Middleware / Services β protocols, filesystem, RTOS, stacks
βββββββββββββββββββββββββββββββββββββββββββββ€
β Drivers (device drivers) β talk to external chips/devices
βββββββββββββββββββββββββββββββββββββββββββββ€
β HAL / BSP (hardware abstraction) β talk to MCU peripherals & the board
βββββββββββββββββββββββββββββββββββββββββββββ€
β Hardware (MCU, board, sensors) β
βββββββββββββββββββββββββββββββββββββββββββββ
Rule: calls go down, never up directly. When the lower layer needs to tell the upper one something, it uses callbacks or events, not direct calls upward.
6.2 What each layer is responsible for
| Layer | Responsibility | Knows about |
|---|---|---|
| Application | Business logic, product behaviour, state machines | Services/drivers via clean APIs; not registers |
| Middleware / services | Reusable stacks: communication protocols, file systems, logging, scheduling, configuration storage | Drivers/HAL APIs |
| Device drivers | Control a specific external component (sensor, display, flash chip, radio) | HAL (bus access) |
| HAL | Uniform interface to MCU peripherals (GPIO, UART, SPI, IΒ²C, ADC, timers) | MCU registers |
| BSP | Board-specific setup: pin assignments, clocks, which peripheral connects to what | HAL, board schematic |
6.3 Why layering helps
- Portability: change the MCU or board and rewrite only the low layers.
- Separation of concerns: application engineers don't need register-level knowledge.
- Testability: you can test application logic on a PC by replacing lower layers with mocks/stubs.
- Parallel work: teams can develop layers separately against agreed interfaces.
- Reduced coupling: a change in one place doesn't ripple everywhere.
6.4 Costs and pitfalls
- Overhead: extra function calls (usually small; can matter on very tight systems or in hot paths).
- Over-layering: too many thin layers with no real value make the code harder to follow.
- Leaky abstractions: if upper layers must know low-level details, the layering is failing.
- Layer skipping: application code poking registers directly breaks portability. Resist "just this once".
- Strictness vs. pragmatism: performance-critical paths sometimes justify controlled shortcuts. Document them.
7. Hardware Abstraction Layers (HAL)
7.1 What a HAL is
A Hardware Abstraction Layer provides a consistent, hardware-independent API for using the MCU's peripherals. Code above the HAL says "write this pin", "send these bytes on UART 1", "start ADC conversion", without knowing which registers or which vendor's chip is below.
Application: hal_uart_send(UART_DEBUG, data, len);
β
ββββββββββββββββββΌβββββββββββββββββ
βΌ βΌ βΌ
STM32 impl NXP impl Nordic impl
(registers A) (registers B) (registers C)
7.2 Why use a HAL
- Portability across MCUs/vendors with the same upper-layer code.
- Productivity: hides register complexity, bit fields and init sequences.
- Consistency: the same API style for all peripherals.
- Testability: replace the HAL with a simulated version for PC-based tests.
7.3 What a HAL typically covers
GPIO, UART/USART, SPI, IΒ²C, ADC, DAC, timers/PWM, DMA, interrupts, flash/EEPROM access, watchdog, RTC, clock/power control.
7.4 Example: GPIO HAL interface
// hal_gpio.h (the contract: hardware independent)
typedef enum { HAL_GPIO_LOW = 0, HAL_GPIO_HIGH = 1 } hal_gpio_level_t;
typedef enum { HAL_GPIO_INPUT, HAL_GPIO_OUTPUT } hal_gpio_dir_t;
typedef struct {
uint8_t port;
uint8_t pin;
} hal_gpio_pin_t;
void hal_gpio_init(hal_gpio_pin_t pin, hal_gpio_dir_t dir);
void hal_gpio_write(hal_gpio_pin_t pin, hal_gpio_level_t level);
hal_gpio_level_t hal_gpio_read(hal_gpio_pin_t pin);
// hal_gpio_stm32.c (one implementation of that contract)
void hal_gpio_write(hal_gpio_pin_t pin, hal_gpio_level_t level)
{
GPIO_TypeDef *port = port_table[pin.port];
if (level) port->BSRR = (1u << pin.pin);
else port->BSRR = (1u << (pin.pin + 16));
}
Port to another chip: write hal_gpio_nxp.c; the application is unchanged.
7.5 Vendor HALs vs. your own HAL
| Vendor HAL (e.g. provided libraries) | Your own abstraction layer on top | |
|---|---|---|
| Source | MCU vendor | You |
| Purpose | Make that vendor's peripherals easy | Make any vendor's peripherals look the same to your app |
| Portability | Tied to the vendor's API style | Designed for portability |
A common, practical design: use the vendor library inside your HAL implementation, but expose your own thin interface to the rest of the code.
7.6 HAL design guidelines
- Keep the interface small and stable; implementations may change freely.
- Hide hardware types (don't expose vendor register structures in public headers).
- Define clear semantics: blocking or non-blocking? timeouts? error codes? thread-safe? callbacks from ISR context?
- Return status codes rather than silently failing.
- Provide both blocking and non-blocking (interrupt/DMA) variants where needed.
- Pick a lowest-common-denominator API that all target chips can honour; allow optional extensions for advanced features.
7.7 Trade-offs
| Benefit | Cost |
|---|---|
| Portability, readability | Slight overhead (function calls, abstraction) |
| Faster development | May not expose every hardware feature (fine-tuning, special modes) |
| Easier testing | Needs upfront design effort |
8. Board Support Packages (BSP)
8.1 What a BSP is
A Board Support Package is the code that adapts the software to a specific board: it knows this particular board's wiring, clocks, memory and onboard parts. If a HAL answers "how do I use a UART?", the BSP answers "which UART is the debug console on this board, at what baud rate, on which pins?"
8.2 Typical BSP contents
| Item | Examples |
|---|---|
| Startup code | Vector table, reset handler, stack/heap setup, copying .data, zeroing .bss, then calling main() |
| Clock/PLL configuration | System clock frequency, peripheral clock enables |
| Pin mapping / pin multiplexing | Which pin is LED1, button, UART TX/RX, SPI CS, sensor interrupt |
| Memory map / linker script | Flash and RAM regions, special sections |
| Board init | Power rails, external RAM/flash, oscillators, peripherals required at boot |
| Onboard device definitions | LEDs, buttons, display, sensors, radio, with the bus and address each one uses |
| Board-specific constants | Crystal frequency, ADC reference voltage, divider ratios |
| Interrupt priority setup / board-level policies | Which interrupts matter most on this board |
8.3 Example: BSP pin and device definitions
// bsp_board_v2.h
// Pin mapping
#define BSP_LED_STATUS ((hal_gpio_pin_t){ .port = PORT_A, .pin = 5 })
#define BSP_BUTTON_USER ((hal_gpio_pin_t){ .port = PORT_C, .pin = 13 })
// Peripheral assignment
#define BSP_DEBUG_UART HAL_UART_2
#define BSP_SENSOR_I2C HAL_I2C_1
#define BSP_TEMP_SENSOR_ADDR 0x48
// Electrical facts about this board
#define BSP_ADC_VREF_MV 3300
#define BSP_HSE_FREQ_HZ 8000000UL
// bsp.c
void bsp_init(void)
{
clock_config(); // set PLL and bus clocks for this board
hal_gpio_init(BSP_LED_STATUS, HAL_GPIO_OUTPUT);
hal_gpio_init(BSP_BUTTON_USER, HAL_GPIO_INPUT);
hal_uart_init(BSP_DEBUG_UART, 115200);
// ... power up board peripherals in the right order
}
void bsp_led_on(void) { hal_gpio_write(BSP_LED_STATUS, HAL_GPIO_HIGH); }
void bsp_led_off(void) { hal_gpio_write(BSP_LED_STATUS, HAL_GPIO_LOW); }
The application then says bsp_led_on(), with no knowledge of which pin, port or polarity is used.
8.4 HAL vs. BSP: the difference
| HAL | BSP | |
|---|---|---|
| Abstracts | The MCU (its peripherals) | The board (how the MCU is wired and what's on it) |
| Changes when you... | Change MCU/vendor | Change the board / hardware revision |
| Example | hal_uart_send() | BSP_DEBUG_UART is UART2 on pins PA2/PA3 |
Moving the same MCU to a new PCB: only the BSP changes. Moving to a different MCU: HAL implementation (and BSP) change.
8.5 Good BSP practice
- Keep all board-specific facts in one place (pin names, addresses, constants). No magic numbers scattered through the code.
- Support multiple board variants/revisions with separate BSP folders or compile-time selection (e.g.
board_v1,board_v2), with the same BSP function names. - Provide a clean init sequence (
bsp_init()), respecting power-up ordering and dependencies. - Give meaningful names (
BSP_LED_STATUS, notPA5), so a pin change doesn't ripple into the app.
9. Device Drivers and APIs
9.1 What a device driver is
A device driver is the software module that controls a specific device (an external sensor, display, flash chip, radio, or an on-chip peripheral) and exposes a clean interface to the rest of the firmware. It hides how to talk to the device (commands, registers, timing) and offers what you can do with it.
Application: temp = tmp102_read_celsius();
β
Driver (tmp102.c): builds IΒ²C transaction, reads 2 bytes, converts to Β°C
β
HAL: hal_i2c_read(...)
β
Hardware: TMP102 chip on the IΒ²C bus
9.2 Principles of a good driver
- Hardware access via the HAL/BSP, never directly poking MCU registers (so the driver is portable across boards/MCUs).
- Clear, minimal API that describes what the user wants, not how the chip does it.
- Hide internals (register maps, magic bytes, timing) inside the
.cfile. - Own its own state (a handle/context struct), so multiple instances of the same device are possible.
- Report errors; never ignore failed bus transactions.
- Be non-blocking where possible, or document blocking behaviour and timeouts.
- Document assumptions: thread safety, ISR safety, init order, units.
9.3 Typical driver API shape
| Function | Purpose |
|---|---|
xxx_init() | Configure the device, verify presence (e.g. read an ID register) |
xxx_deinit() | Release/disable the device |
xxx_read() / xxx_write() | Transfer data |
xxx_configure() / xxx_set_...() | Change settings (range, rate, mode) |
xxx_get_status() | Query state or error flags |
xxx_sleep() / xxx_wake() | Power control |
xxx_irq_handler() | Called on the device's interrupt line |
9.4 Example driver interface (handle-based, instance-friendly)
// tmp102.h -- public API
typedef struct {
hal_i2c_id_t i2c;
uint8_t address;
// internal state could be private or hidden behind an opaque pointer
} tmp102_t;
typedef enum {
TMP102_OK = 0,
TMP102_ERR_BUS,
TMP102_ERR_NOT_FOUND,
TMP102_ERR_PARAM
} tmp102_status_t;
tmp102_status_t tmp102_init(tmp102_t *dev, hal_i2c_id_t i2c, uint8_t addr);
tmp102_status_t tmp102_read_celsius(tmp102_t *dev, float *out_c);
// tmp102.c
tmp102_status_t tmp102_read_celsius(tmp102_t *dev, float *out_c)
{
uint8_t raw[2];
if (!dev || !out_c) return TMP102_ERR_PARAM;
if (hal_i2c_read_reg(dev->i2c, dev->address, 0x00, raw, 2) != HAL_OK)
return TMP102_ERR_BUS;
int16_t v = ((int16_t)raw[0] << 4) | (raw[1] >> 4); // 12-bit value
if (v & 0x800) v |= 0xF000; // sign-extend
*out_c = v * 0.0625f;
return TMP102_OK;
}
9.5 Making drivers swappable: function-pointer interfaces
To allow different implementations behind one interface (e.g. different temperature sensors, or a real driver vs. a test mock), use a table of function pointers (a "driver ops" structure):
typedef struct {
int (*init)(void *ctx);
int (*read)(void *ctx, float *value);
int (*sleep)(void *ctx);
} sensor_ops_t;
typedef struct {
const sensor_ops_t *ops;
void *ctx;
} sensor_t;
// application code works with any sensor_t
int sensor_read(sensor_t *s, float *v) { return s->ops->read(s->ctx, v); }
This is how OS-style driver models achieve a common interface across many devices (an object-oriented style in C).
9.6 Blocking vs. non-blocking vs. callback APIs
| Style | How it works | Use when |
|---|---|---|
| Blocking | Function returns after the operation completes | Simple, short operations; non-time-critical |
| Non-blocking (polling status) | Start the operation; later check is_done() | Superloop/cooperative designs |
| Asynchronous with callback | Start; driver calls your callback on completion | Event-driven designs, DMA/interrupt-based transfers |
| RTOS-aware | Blocks the task (not the CPU) on a semaphore/queue | Preemptive RTOS systems |
9.7 API design checklist
- Naming: consistent prefix (
module_verb_noun). - Ownership & lifetime: who allocates the handle/buffers?
- Return values: consistent error codes; avoid mixing booleans, negative numbers and enums arbitrarily.
- Units: state them (Β°C, mV, ms) and use them consistently.
- Thread/ISR safety: document it.
- Configuration: use config structs rather than long parameter lists.
- Const-correctness: mark read-only inputs
const. - Stability: once published, avoid breaking changes.
10. Modular Code and Reusable Components
10.1 What "modular" means
A module is a self-contained unit with a clear purpose, a small public interface and hidden internals. In C, a module is typically a pair: a header (.h) that declares the interface, and a source file (.c) that implements it.
10.2 Core principles
| Principle | Meaning |
|---|---|
| High cohesion | Everything inside a module is closely related to one purpose |
| Low coupling | Modules depend on each other as little as possible |
| Information hiding / encapsulation | Internals are private; only the interface is public |
| Single responsibility | One reason to change |
| Clear dependencies | Dependencies flow in one direction (no circular includes) |
| Stable interfaces | The .h rarely changes, while the .c can change freely |
10.3 Encapsulation techniques in C
staticfor functions and variables that must stay private to the.cfile.- Opaque types: declare
typedef struct module_s module_t;in the header and define the struct only in the.cfile; users hold a pointer without seeing the fields. - Minimal headers: expose only what callers need.
- Include guards (
#ifndef ... #define ... #endifor#pragma once) to avoid double inclusion.
// ringbuf.h
#ifndef RINGBUF_H
#define RINGBUF_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
typedef struct ringbuf ringbuf_t; // opaque: fields hidden
ringbuf_t *ringbuf_create(uint8_t *storage, size_t size);
bool ringbuf_put(ringbuf_t *rb, uint8_t byte);
bool ringbuf_get(ringbuf_t *rb, uint8_t *byte);
size_t ringbuf_count(const ringbuf_t *rb);
#endif
// ringbuf.c
struct ringbuf {
uint8_t *buf;
size_t size;
volatile size_t head, tail;
};
// ... implementation hidden from users
10.4 Designing for reuse
A component is reusable when it:
- Has no hidden dependency on a specific board or MCU, getting its hardware through the HAL or via function pointers/callbacks you pass in.
- Is configurable (sizes, timeouts, options via a config struct or compile-time
#defines in a separate config header), without editing its source. - Has no global state, or keeps it inside a handle so you can create multiple instances.
- Allocates memory predictably (caller supplies buffers, or uses static allocation), which is ideal for embedded where dynamic allocation (
malloc) is often avoided. - Has a clean, documented interface and ideally unit tests.
- Returns error codes rather than halting or printing.
10.5 Dependency injection (passing in what a module needs)
Instead of hard-wiring a module to a specific UART, give it function pointers:
typedef struct {
int (*write)(const uint8_t *data, size_t len);
uint32_t (*millis)(void);
} logger_io_t;
void logger_init(const logger_io_t *io);
Now the logger works with a UART, a USB port, a file or a test stub, and it needs no changes.
10.6 Configuration management
- Keep tunable options in a dedicated
*_config.hper module (e.g.BUFFER_SIZE,ENABLE_FEATURE_X). - Avoid magic numbers: use named constants and enums.
- Use conditional compilation (
#if) carefully; too many variants make code hard to test.
10.7 Project/folder organisation (example)
project/
βββ app/ application logic, state machines
βββ middleware/ protocols, logging, ring buffers, scheduler
βββ drivers/ sensor/display/flash drivers
βββ hal/ hardware-independent API + per-MCU implementations
βββ bsp/ board-specific code (board_v1/, board_v2/)
βββ config/ project configuration headers
βββ tests/ unit tests and mocks
10.8 Code hygiene supporting modularity
- Consistent naming conventions and a coding standard (e.g. MISRA C guidelines are commonly followed in safety-related work).
- No circular dependencies between modules.
- Avoid global variables; if needed, keep them
staticinside a module and provide accessor functions. - Small functions with one job.
- Version components and note changes so reuse across products stays manageable.
- Write tests: modular code can be unit-tested on a PC with mocks for HAL/drivers.
11. State Machines for Communication and Control
FSMs (section 3) are especially valuable in two big embedded areas: communication protocols and control logic.
11.1 State machines for communication
Communication is naturally sequential and event-driven: bytes arrive one at a time, messages have structure, devices have handshakes and timeouts. FSMs fit perfectly.
a) Frame / packet parsing (byte-by-byte)
A very common use: parse an incoming byte stream into messages without blocking. Example frame format:
| START (0x7E) | LENGTH | PAYLOAD (LENGTH bytes) | CHECKSUM |
0x7E any byte N bytes checksum byte
ββββΊ WAIT_START βββΊ WAIT_LEN βββββββΊ READ_PAYLOAD βββββββΊ WAIT_CHECKSUM ββ
β β
β bad length / timeout / bad checksum β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
(resync, back to WAIT_START)
typedef enum { P_WAIT_START, P_WAIT_LEN, P_PAYLOAD, P_CHECKSUM } parse_state_t;
static parse_state_t pstate = P_WAIT_START;
static uint8_t payload[64], len, idx, sum;
void parser_feed(uint8_t b) // called for each received byte
{
switch (pstate) {
case P_WAIT_START:
if (b == 0x7E) { sum = 0; pstate = P_WAIT_LEN; }
break;
case P_WAIT_LEN:
if (b == 0 || b > sizeof(payload)) { pstate = P_WAIT_START; break; }
len = b; idx = 0; sum = b;
pstate = P_PAYLOAD;
break;
case P_PAYLOAD:
payload[idx++] = b;
sum += b;
if (idx == len) pstate = P_CHECKSUM;
break;
case P_CHECKSUM:
if (b == (uint8_t)(~sum + 1)) message_received(payload, len);
else frame_error();
pstate = P_WAIT_START;
break;
}
}
Why this is good: it never blocks, uses fixed memory, handles garbage and recovers (resynchronises) after errors, and handles partial frames across several interrupts.
b) Protocol handshakes and sessions
Connection-oriented protocols (modems, wireless modules, bootloaders, custom links) follow state sequences:
IDLE ββconnectβββΊ CONNECTING ββackβββΊ CONNECTED ββdataβββΊ TRANSFERRING
β² β timeout β error/close β done
ββββββββββββββββββββββ΄ββββββββββββββββββββ΄ββββββββββββββββββββββ
Typical elements: request/response, acknowledgements, retries with limits, timeouts, sequence numbers, error states.
c) AT-command / modem control
Send a command, wait for OK/ERROR/a timeout, then decide the next step, a classic request-response FSM:
SEND_CMD βββΊ WAIT_RESPONSE ββOKβββΊ NEXT_CMD
β ERROR/timeout
βΌ
RETRY (n times) βββΊ FAIL
Practical tips for communication FSMs
- Every waiting state needs a timeout event.
- Limit retries (and use back-off) so a dead peer doesn't lock the system.
- Always provide a path to resynchronise after corrupted data.
- Validate everything: length, checksum/CRC, range.
- Separate byte-level parsing (FSM) from message-level handling (application).
- Make states and transitions loggable for debugging.
11.2 State machines for control
Control logic often involves modes and sequences: start-up, normal run, fault, shutdown.
Example: a motor controller
start_cmd speed reached
ββββββββ ββββββββββββββΊ ββββββββββββ ββββββββββββββββΊ βββββββββββ
β IDLE β β RAMP_UP β β RUNNING β
ββββββββ ββββββββββββββ ββββββββββββ ββββββ¬βββββ
β² stop_cmd β overcurrent β stop_cmd
β βΌ βΌ
β βββββββββββ ββββββββββββ
ββββ reset ββββββββββ FAULT ββββ overcurrent ββββββ RAMP_DOWNβ
βββββββββββ ββββββββββββ
Key points:
- The FSM governs the mode (what is allowed in each state); the control algorithm (e.g. PID) runs within states like
RUNNING. - Safety transitions (fault, emergency stop) should be possible from every state and take priority.
- Interlocks: don't allow a transition unless conditions hold (e.g. don't start heating unless the fan is running and the sensor is valid).
- Debounce / hysteresis on transition conditions prevents rapid flipping between states (e.g. thermostat turning on at 24 Β°C and off at 22 Β°C).
- Timed states (warm-up, cool-down, minimum on/off times) are handled with timeouts.
Example: thermostat with hysteresis
typedef enum { HEAT_OFF, HEAT_ON } heater_state_t;
void thermostat_update(float temp_c, float setpoint)
{
static heater_state_t st = HEAT_OFF;
switch (st) {
case HEAT_OFF:
if (temp_c < setpoint - 1.0f) { heater_on(); st = HEAT_ON; }
break;
case HEAT_ON:
if (temp_c > setpoint + 1.0f) { heater_off(); st = HEAT_OFF; }
break;
}
}
11.3 Combining FSMs
Real systems usually run several small FSMs side by side (e.g. one for communication, one for the main control mode, one for a display UI), coordinating through events/messages. Prefer several small machines over one giant one.
12. Error Handling and Fault Recovery
12.1 The mindset
In the field, things will go wrong: sensors disconnect, buses glitch, memory fills, power sags, radio links drop, inputs are out of range, cosmic-ray bit flips happen. Robust firmware expects failure, detects it, contains it and recovers (or fails safely).
12.2 Types of errors/faults
| Category | Examples |
|---|---|
| Hardware faults | Sensor failure, open/short circuit, bus stuck, clock failure, brown-out |
| Communication errors | Timeout, checksum/CRC failure, framing errors, dropped packets |
| Software errors | Invalid parameters, null pointers, buffer overflow, stack overflow, divide by zero, unexpected state |
| Resource errors | Out of memory, queue full, timeout waiting for a resource |
| Environmental | Out-of-range temperature/voltage, electrical noise |
| Processor exceptions | Hard fault, bus fault, memory-management fault, usage fault |
| Logic / system errors | Deadlock, missed deadlines, infinite loops |
12.3 Error detection techniques
- Return-value checking on every function that can fail.
- Input validation and range/plausibility checks (a room temperature sensor reading 400 Β°C is a sensor fault, not weather).
- Assertions (
assert()-style) for conditions that "must never happen", used during development, and often reduced to safe handling in production. - Timeouts on every wait (bus, response, handshake).
- CRC/checksum on messages and stored data (flash/EEPROM configuration).
- Redundancy: two sensors compared, or the same value computed two ways.
- Stack canaries / stack-usage monitoring to catch overflows.
- Self-tests at power-up (RAM test, flash CRC, peripheral presence checks).
- Supply monitoring (brown-out detect, voltage checks).
- Heartbeat/alive monitoring of tasks (see watchdog section).
12.4 Error reporting strategies
| Strategy | Description | Use when |
|---|---|---|
| Return codes | Functions return a status enum | Default in C; explicit and cheap |
| Output parameters + status | Value via pointer, status as return | When value range uses all bit patterns |
| Error flags / error register | Global/module status bits | Persistent conditions, status reporting |
| Callbacks | Notify the application asynchronously | Event-driven designs |
| Error log (RAM/flash) | Record error codes, timestamps, context | Post-mortem diagnostics |
| Assertions / fault hooks | Trap programming errors | Development, plus safe handler in production |
Avoid mixing styles randomly. Choose a consistent error convention across modules (e.g. one shared
status_tenum).
typedef enum {
STATUS_OK = 0,
STATUS_ERR_PARAM,
STATUS_ERR_TIMEOUT,
STATUS_ERR_BUS,
STATUS_ERR_CRC,
STATUS_ERR_NO_MEM,
STATUS_ERR_BUSY,
STATUS_ERR_STATE
} status_t;
status_t result = sensor_read(&value);
if (result != STATUS_OK) {
handle_sensor_error(result);
}
12.5 Error handling patterns
a) Fail fast / defensive checks
Validate parameters at module boundaries; reject bad input early with a clear error code.
b) Retry with limits
for (int attempt = 0; attempt < 3; attempt++) {
if (bus_transfer() == STATUS_OK) break;
delay_short();
}
Always cap retries and consider back-off, so errors never cause infinite loops.
c) Graceful degradation
Keep the system useful with reduced function: lose the display but keep controlling; lose the radio but log locally; lose a sensor and fall back to a safe default or a backup.
d) Fallback / default values
Use the last known good value (with an age limit) or a safe default when a reading fails. Mark data as invalid/stale when you can't trust it.
e) Error escalation
Handle at the lowest layer that can handle it; pass up what it can't. Each layer either recovers or translates the error into something meaningful for the layer above.
Driver: IΒ²C NACK β retry 3x β still failing β return ERR_BUS
Service: sensor unavailable β mark data invalid, raise event
Application: switch to backup sensor / safe mode / alert user
f) Safe state
Define for every system what "safe" means: outputs off, valve closed, heater disabled, motor braking. On unrecoverable errors, go to the safe state first, then try to recover or report.
12.6 Fault recovery strategies (from mild to severe)
| Level | Action | Example |
|---|---|---|
| 1 | Retry the operation | Resend a message |
| 2 | Reinitialise the module | Reset a stuck peripheral/bus, re-init the sensor |
| 3 | Switch to backup/fallback | Backup sensor, alternative communication path |
| 4 | Degraded mode | Reduced functionality, limited power |
| 5 | Safe state | Outputs off, wait for operator/reset |
| 6 | System reset | Software reset or watchdog reset |
| 7 | Persistent logging + reset | Record reason in non-volatile memory, then restart |
Recover at the least disruptive level that works.
Recovering stuck buses (example)
An IΒ²C slave holding SDA low can hang the bus. Typical recovery: toggle SCL up to 9 times to let the slave finish, generate a STOP, then re-initialise the peripheral.
12.7 Handling processor faults (e.g. hard fault)
On many MCUs, serious errors trigger a fault exception (invalid memory access, illegal instruction, etc.). A good fault handler should:
- Capture diagnostic data: saved registers, program counter, fault status registers, stack pointer.
- Store it (RAM that survives reset, or non-volatile memory) for later retrieval.
- Put outputs in a safe state.
- Reset the system (or enter a defined failure mode), and not loop forever silently in a production product (unless a watchdog is guaranteed to reset it).
12.8 Runtime checks and robustness practices
- Stack-overflow detection (fill the stack with a pattern at start and check the high-water mark; use MPU guards where available).
- Avoid unbounded loops (
while(!flag);without a timeout is a hang waiting to happen). - Static allocation is preferred over
malloc/freeat runtime (avoids fragmentation and out-of-memory surprises). - Defensive state-machine handling: unknown state or event leads to a safe reset path.
- Data integrity for stored settings: CRC + version + defaults on mismatch; redundant copies (A/B) or atomic update schemes so a power loss during a write doesn't corrupt configuration.
- Power-fail handling: detect falling supply, save critical data and shut down cleanly.
- Fail-safe defaults on power-up before the application has initialised everything.
- Boot-time reason checks: read reset-cause flags (power-on, watchdog, brown-out, software, pin) and react or log accordingly.
12.9 Testing error handling
Errors are rarely tested because they're hard to trigger. Make them testable:
- Fault injection: force bus errors, corrupt checksums, fake sensor failures.
- Test timeouts and recovery paths deliberately.
- Run with a simulated unplugged sensor.
- Review every
elsebranch and every error return for what actually happens.
13. Watchdog-Based Recovery
13.1 What a watchdog is
A watchdog timer (WDT) is a hardware timer that resets the MCU if the firmware doesn't "pet" (kick/refresh/feed) it within a set time. It is the last line of defence against hangs, infinite loops, deadlocks and runaway code.
Watchdog counter counts down...
ββββββββββββββββββββββββββββββββββββββββββ
β firmware kicks WDT in time β counter reloads β keeps running β
β firmware hangs, no kick β counter hits 0 β HARDWARE RESET β
ββββββββββββββββββββββββββββββββββββββββββ
13.2 Types
| Type | Behaviour |
|---|---|
| Independent watchdog (IWDG) | Runs from its own separate clock source, so it keeps working even if the main clock fails; usually cannot be stopped once started |
| Window watchdog (WWDG) | Must be refreshed within a time window (not too early and not too late), which catches runaway code that kicks it too fast |
| External watchdog IC | Separate chip; survives a total MCU lockup; may control reset line and power |
| Software watchdog | Implemented in firmware/RTOS; cannot save you if the CPU itself is hung |
13.3 Basic usage
void main(void)
{
init();
wdt_start(500); // timeout 500 ms
while (1) {
do_work(); // must complete well within the timeout
wdt_kick(); // "I'm alive"
}
}
Choose the timeout longer than the worst-case time between legitimate kicks (with margin), but short enough to recover quickly after a fault.
13.4 The big mistake: kicking blindly
A watchdog that is kicked from a timer ISR, or in a way that doesn't depend on the real work being healthy, just hides the fault. The loop is stuck, but the ISR keeps kicking, and the watchdog never fires.
Kick the watchdog only when you've verified that the system is actually healthy.
13.5 Smart watchdog patterns
a) Single-loop health check
Kick once per main-loop cycle, after the critical work completes.
b) Multi-task "checkpoint" (alive flags) pattern
Each critical task sets its own "I'm alive" flag; one supervisor kicks the watchdog only if all flags are set, then clears them.
#define TASK_SENSOR (1u << 0)
#define TASK_COMMS (1u << 1)
#define TASK_CONTROL (1u << 2)
#define ALL_TASKS (TASK_SENSOR | TASK_COMMS | TASK_CONTROL)
static volatile uint32_t alive_flags;
void report_alive(uint32_t task_bit) { alive_flags |= task_bit; }
void watchdog_supervisor(void) // runs periodically
{
if ((alive_flags & ALL_TASKS) == ALL_TASKS) {
alive_flags = 0;
wdt_kick();
}
// else: don't kick, so the hardware reset will happen if it persists
}
c) Sequence/flow monitoring
Verify that code executes steps in the expected order/within expected time (e.g. checkpoints along a processing chain), catching "alive but not doing the right thing".
d) Window watchdog for runaway code
Refresh only inside the allowed window, so code that races or skips timing is also caught.
13.6 What happens after a watchdog reset?
A reset alone isn't recovery; the system must come back safely and informatively:
- Read the reset-cause flag at start-up (watchdog vs. power-on vs. brown-out ...).
- Record the event (counter/log in non-volatile or retained memory, plus which task failed to check in, if known).
- Start in a safe state: outputs off until initialisation is complete and checks pass.
- Re-initialise everything properly (don't assume peripheral state survived).
- Count consecutive watchdog resets. If it keeps resetting, escalate: enter a safe/limp mode, run minimal functions, signal the fault, or fall back to a known-good firmware image/configuration instead of looping forever.
void boot_check(void)
{
if (reset_caused_by_watchdog()) {
uint32_t n = retained_wdt_reset_count++;
log_event(EVENT_WDT_RESET, n);
if (n >= 3) {
enter_safe_mode(); // stop repeating the same failing sequence
}
} else {
retained_wdt_reset_count = 0;
}
}
13.7 Watchdog design guidelines
- Enable it early and in production builds (it's often disabled while debugging, since breakpoints would trigger resets; there's usually an option to freeze the WDT in debug mode).
- Prefer an independent (separate-clock) watchdog for safety.
- Don't kick from ISRs or from a "heartbeat" task that can run while real tasks are stuck.
- Don't kick inside long blocking loops just to keep it quiet; fix the long loop.
- Account for long legitimate operations (flash erase/write, firmware update, long computations): either restructure, kick safely at checkpoints, or temporarily extend the timeout, and keep this exception small.
- Make sure that low-power sleep doesn't accidentally prevent kicking (see next section): wake periodically or use a watchdog that runs in sleep with a suitable timeout.
- Consider layered protection: window watchdog + independent watchdog + external supervisor for critical systems.
- Test it: deliberately hang a task and confirm the reset and the recovery behaviour.
13.8 Watchdog vs. brown-out detection
| Watchdog | Brown-out reset (BOR) | |
|---|---|---|
| Protects against | Software hangs/lock-ups | Supply voltage dropping too low |
| Trigger | Missed kick | Voltage below threshold |
Use both: unstable supply voltage can make a CPU execute garbage, which a watchdog alone may not catch quickly.
14. Low-Power Modes and Sleep States
14.1 Why power matters
Battery-powered and energy-harvesting devices live or die by average current. A sensor node that runs for years on a coin cell does so because the MCU is asleep almost all the time.
Average current (key idea):
I_avg = (I_active Γ t_active + I_sleep Γ t_sleep) / (t_active + t_sleep)
Example: active 10 mA for 10 ms once per second, sleeping at 5 Β΅A the rest of the time:
I_avg β (10 mA Γ 0.010 s + 0.005 mA Γ 0.990 s) / 1 s β 0.105 mA β 105 Β΅A
The active burst dominates, so shortening the active time is usually the biggest lever, then reducing sleep current.
14.2 Typical MCU power modes
Names differ by vendor, but the pattern is similar. From lightest to deepest sleep:
| Mode (generic) | CPU | Peripherals | Clocks | Wake-up | Power | Wake latency |
|---|---|---|---|---|---|---|
| Run / Active | Running | On | All on | n/a | Highest | n/a |
| Sleep (idle) | Stopped | Running | Peripheral clocks on | Any enabled interrupt | Medium | Very short (Β΅s) |
| Deep sleep / Stop | Stopped | Mostly off (some low-power peripherals active) | Main clocks off; low-speed clock may run | Selected interrupts/events (RTC, GPIO, some peripherals) | Low | Longer (Β΅s to ms); clocks must restart/stabilise |
| Standby / Hibernate / Shutdown | Off | Almost everything off | Almost all off | RTC alarm, wake pin, reset | Lowest (nA to few Β΅A) | Longest; usually a reset-like restart |
Retention: in the deepest modes, RAM and register contents may be lost (or only partly retained). Know what your chip keeps, and what you must save and restore yourself. Some MCUs offer RAM retention options or a small backup domain that survives.
14.3 Entering sleep
Typically done with a wait-for-interrupt instruction (e.g. WFI) or wait-for-event (WFE), after setting the power-mode configuration:
void enter_sleep(void)
{
// configure desired low-power mode (vendor-specific)
__WFI(); // CPU halts until an interrupt occurs
}
14.4 Superloop/event-loop idle pattern
The most common integration: sleep when there's nothing to do.
while (1) {
if (event_pending()) {
handle_events();
} else {
enter_sleep(); // woken by any interrupt that creates an event
}
}
A subtle race to avoid: if an interrupt arrives between checking "no events" and executing the sleep instruction, you might sleep with work pending. Solutions: disable interrupts around the check and the sleep instruction (the wake-up still works even with interrupts masked on many cores, e.g. via WFI semantics), or use a mechanism designed for it. Check your architecture's guidance.
14.5 Tickless idle (RTOS and timers)
A periodic 1 ms tick wakes the CPU 1000 times per second, which wastes energy. Tickless idle stops the tick when all tasks are idle, programs a low-power timer to wake exactly when the next task is due, and compensates the system time on wake. Many RTOSes support this.
14.6 Wake-up sources
| Source | Notes |
|---|---|
| External pin/button | Sensor interrupt line, user input |
| RTC alarm / low-power timer | Periodic wake-ups (e.g. measure every minute) |
| Communication peripheral | UART start bit/address match, IΒ²C address match, radio packet |
| Comparator/analog watchdog | Wake when a signal crosses a threshold |
| Watchdog | Periodic wake/reset |
| Other peripherals | Depends on the MCU and the chosen mode |
Match mode to wake source: a deep mode may disable the clock the wake peripheral needs, so check what is available in each mode.
14.7 Practical low-power techniques
a) Run less, sleep more
Do work in short bursts and return to sleep promptly ("race to sleep"). Event-driven designs suit this naturally.
b) Lower the clock/voltage when active
Dynamic power scales with frequency (and voltage); use only the speed needed. But sometimes a faster clock finishes sooner and sleeps longer, so measure rather than assume.
c) Disable unused peripherals and clocks
Turn off clock gates for peripherals not in use; power down external chips (sensors, radios, displays), ideally via a load switch / enable pin.
d) Configure GPIO carefully
- Floating inputs can draw current (oscillating input stages); set unused pins to a defined state (output low, or input with pull-up/down, per datasheet).
- Avoid leakage paths: a pin driving high into a powered-down external chip can back-power it or leak current.
e) Use DMA and peripherals with autonomy
Let peripherals (ADC with timer trigger, DMA, comparators) work without waking the CPU and wake it only when a batch of data or a threshold event is ready.
f) Reduce wake-ups
Batch work: collect several samples, then process/transmit together instead of waking for each.
g) Choose efficient communication patterns
Radios are often the biggest power consumer; minimise transmit time, use low-power listening/duty-cycling, and bigger, rarer packets.
h) Use low-power peripherals and clocks
Run the RTC/timers from a low-frequency oscillator in sleep. Use low-power variants of peripherals where available.
i) Power-up delays and sequencing
Account for oscillator start-up time and sensor settling time after wake; budget that energy and time.
14.8 Sleep, peripherals, debug and watchdog: things that bite
- Debugger connection may keep clocks running or block deep sleep, so measure power without the debugger attached.
- Watchdog interactions: if the watchdog keeps running during sleep, wake up often enough to kick it, or use a mode/timeout where it's suitable; some watchdogs freeze in certain modes (check your chip). Don't just disable the watchdog to save power in a product that needs protection.
- Peripheral state on wake: after deep sleep, clocks and some peripheral registers may need re-initialisation.
- In-flight transfers: don't enter deep sleep with a UART/SPI/DMA transfer pending, or you'll corrupt it. Check peripheral idle status first.
- Wake-up latency vs. responsiveness: a deep mode may be too slow to catch a fast event; pick the deepest mode that still meets timing.
- Interrupt flags: make sure pending interrupts are handled or cleared so you don't wake immediately in a loop.
- Retention and restoration: after the deepest modes, you may restart through the reset path; use a flag/retained memory to tell "woke from standby" from "cold boot" and restore state quickly.
14.9 Designing a low-power state machine
Power management itself is often an FSM:
βββββββββββ event / timer ββββββββββββ
βΌ β
βββββββββββ work done ββββββββββββ idle timeout βββββββββββββ
β ACTIVE ββββββββββββββββΊβ IDLE ββββββββββββββββΊβ DEEP SLEEPβ
βββββββββββ β (sleep) β βββββββ¬ββββββ
β² ββββββ¬ββββββ β wake source
βββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββ
Define clearly: which activities forbid sleep (e.g. radio transaction in progress), which modes are allowed at each moment, and what wakes the system. A common technique is a "power lock/vote" system: each module can request "don't go deeper than X", and the idle code picks the deepest mode all modules allow.
14.10 Measuring and verifying power
- Measure real current with a power analyser or a shunt + scope; datasheet numbers are best-case.
- Capture the current profile over time to see wake bursts, radio spikes and leftover consumption.
- Compare estimated vs. measured battery life.
- Test over temperature and supply voltage, since leakage rises with temperature.
15. Putting It All Together
A typical well-structured product combines everything above:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β APPLICATION β
β β’ Control FSM (modes: idle / run / fault) β
β β’ Communication FSM (parser, handshake, retries) β
β β’ Power-management FSM (active / idle / deep sleep) β
ββββββββββββββββ²βββββββββββββββββββββββββββββββββββββββββββββββ
β events (queue) errors (status_t)
ββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββββββββββ
β MIDDLEWARE: event dispatcher / scheduler (or RTOS), β
β logging, config storage (CRC), ring buffers β
ββββββββββββββββ²βββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββββββββββ
β DRIVERS: sensor, display, radio, flash (use HAL, return codes)β
ββββββββββββββββ²βββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββββββββββ
β HAL (GPIO, UART, SPI, IΒ²C, ADC, timers, WDT, power) + BSP β
ββββββββββββββββ²βββββββββββββββββββββββββββββββββββββββββββββββ
β
HARDWARE (+ watchdog + brown-out + low-power modes)
Example: a battery-powered sensor node
- Boot (BSP + HAL): read reset cause, init clocks and pins, start the watchdog.
- Event-driven main loop: a timer (RTC) event wakes the system every 60 s.
- Sensor FSM: power the sensor, wait for settling (timer event, not a blocking delay), read it via the driver, validate (range/plausibility), power it down.
- Comms FSM: build a packet, send, wait for ack with timeout, retry up to N times, otherwise store for later.
- Error handling: failures return
status_t; repeated sensor errors lead to a fallback and a logged fault. - Watchdog supervisor: kicks only when sensor, comms and control checkpoints all reported alive.
- Power management: with no pending events, enter deep sleep; wake on RTC or button.
- Recovery: on a watchdog reset, log the cause, count resets, start safe, escalate to safe mode if it repeats.
Choosing an architecture (quick guide)
| System | Suggested approach |
|---|---|
| Very small, simple device | Superloop + ISR flags + a few FSMs |
| Several periodic tasks, light timing | Cooperative scheduler / event-driven loop |
| Many concurrent activities, hard deadlines, protocol stacks | RTOS with preemptive scheduling, plus event-driven/FSM logic inside tasks |
| Must support multiple boards/MCUs | Layered design with HAL + BSP |
| Safety/reliability critical | All of the above + defensive error handling + watchdog layers + safe states |
| Battery powered | Event-driven, sleep-first design with tickless idle |
16. Cheat Sheet and Common Mistakes
Key concepts in one line each
- Superloop: one endless loop calls all tasks; simple, but blocking code hurts everything.
- FSM: one state at a time; events cause transitions; makes behaviour explicit and non-blocking.
- Event-driven: react to events via a queue and dispatcher; sleep when idle.
- Cooperative scheduling: tasks yield voluntarily; simple, no races, but slow tasks hurt.
- Preemptive scheduling: scheduler interrupts tasks by priority; fast response, but needs synchronisation.
- Layered architecture: application β middleware β drivers β HAL/BSP β hardware; calls go downward.
- HAL: hardware-independent API to MCU peripherals.
- BSP: board-specific setup: pins, clocks, memory map, onboard devices.
- Driver: controls one device behind a clean API; uses HAL, not registers.
- Modular code: high cohesion, low coupling, hidden internals, reusable via configuration and injected dependencies.
- Communication FSM: parse byte streams, handshake, timeout, retry, resync.
- Control FSM: modes, interlocks, safety transitions, hysteresis.
- Error handling: detect, contain, report consistently, recover at the least disruptive level, fall back to a safe state.
- Watchdog: reset on missed kick; kick only when the system is truly healthy; handle the reset cause.
- Low power: sleep as much as possible, wake on events, shorten active time, and manage peripherals, pins and clocks.
Common mistakes
- β Blocking delays and busy-waits in a superloop or event handler.
- β Heavy work inside ISRs instead of deferring it.
- β Shared variables without
volatileor protection between ISR/tasks. - β State machines with no timeout or no handling of unexpected events.
- β One giant switch statement that no one can follow; no diagram or documentation.
- β Using an RTOS without understanding priority inversion, deadlock and stack sizing.
- β Layer skipping: application code touching registers directly.
- β Board details (pins, addresses) hard-coded all over the code instead of in the BSP.
- β Drivers with hidden global state, preventing multiple instances or testing.
- β Ignoring return values, with no consistent error-code convention.
- β Unbounded retries or waits, which turn a fault into a permanent hang.
- β Kicking the watchdog from a timer ISR (or anywhere that doesn't prove the system is healthy).
- β Disabling the watchdog in production builds, or never testing it.
- β Not checking the reset cause or not logging faults.
- β Entering deep sleep with transfers pending, or forgetting to re-initialise after wake-up.
- β Leaving unused pins floating or external chips powered, with sleep current far above the datasheet figure.
- β Optimising power by guessing instead of measuring real current.
- β Over-engineering: adding layers/RTOS/abstractions the project doesn't need.
Mini design checklist
1. Pick the execution model: superloop / cooperative / event-driven / RTOS (justify it)
2. Draw state diagrams for control, communication and power behaviour
3. Define layers: app β services β drivers β HAL β BSP; no upward calls
4. Define module interfaces (.h) first; hide internals (static / opaque types)
5. Keep ISRs short; pass events through queues/flags
6. Standardise error codes; add timeouts, retries (bounded), and safe states
7. Add watchdog with health-based kicking + reset-cause handling
8. Design sleep strategy: modes, wake sources, retention, peripheral shutdown
9. Test: unit tests with mocks, fault injection, watchdog trigger, power measurement
#RTOS β Real-Time Operating Systems#
Study notes: how an RTOS lets one small processor juggle many jobs on time, and how to use one safely, with FreeRTOS as the worked example.
Table of Contents
- RTOS Fundamentals and Use Cases
- Tasks and Threads
- Task States and Scheduling
- Priority-Based Preemption
- Context Switching
- Task Priorities and Time Slicing
- Semaphores and Mutexes
- Queues and Message Passing
- Event Groups and Task Notifications
- Software Timers
- Interrupt Interaction with an RTOS
- Priority Inversion and Priority Inheritance
- Deadlocks and Race Conditions
- Stack Sizing and Task Memory
- Real-Time Deadlines and Latency
- FreeRTOS Fundamentals
- Cheat Sheet and Common Mistakes
A note on code: the examples use FreeRTOS names because it is the most widely used open-source RTOS for microcontrollers. The concepts (tasks, queues, mutexes, priorities...) are the same in other RTOSes such as Zephyr, ThreadX, RT-Thread, embOS or CMSIS-RTOS. Only the function names change.
1. RTOS Fundamentals and Use Cases
1.1 What is an RTOS?
A Real-Time Operating System is a small operating system kernel for embedded devices whose main job is to run several activities (tasks) at once while guaranteeing predictable timing.
The word real-time does not mean "fast". It means predictable and on time:
A real-time system is correct only if it produces the right result at the right time. A correct answer that arrives late is a wrong answer.
A fast but unpredictable system is not real-time. A slow but always-on-time system is.
1.2 Hard, firm and soft real-time
| Type | If a deadline is missed... | Examples |
|---|---|---|
| Hard real-time | Failure is catastrophic or unacceptable | Airbag deployment, motor control loop, pacemaker, anti-lock brakes |
| Firm real-time | The result is useless, but the system survives | A video frame that arrives too late is dropped |
| Soft real-time | Quality degrades gradually | Display refresh, audio glitch, UI response |
1.3 What an RTOS gives you (and what it doesn't)
An RTOS typically provides:
- Multitasking: a scheduler that shares the CPU among tasks
- Deterministic scheduling based on priorities
- Inter-task communication: queues, semaphores, mutexes, event flags, notifications
- Timing services: delays, timeouts, software timers
- Memory management services (dynamic allocation schemes, optionally static)
- A clear way to structure a complex application into independent pieces
It does not (by itself) give you:
- Automatic correctness; bad priorities or locking still cause failures
- Magic speed; it adds overhead (RAM, flash, context-switch time)
- Memory protection (on most small MCU RTOS builds, all tasks share one address space; an MPU-enabled variant is optional)
1.4 RTOS vs. superloop (bare metal)
| Aspect | Superloop (bare metal) | RTOS |
|---|---|---|
| Structure | One loop + interrupts + state machines | Independent tasks, each often with its own loop |
| Timing | Worst-case response = longest loop pass | Bounded by priority and scheduler/ISR latency |
| Adding features | Can disturb timing of existing code | Add a task; high-priority work stays protected |
| RAM | Low | Higher (a stack per task + kernel objects) |
| Flash | Tiny | Kernel adds a few KB to tens of KB |
| Complexity | Simple to start, hard to scale | More concepts (sync, priorities), easier to scale |
| Concurrency bugs | Fewer | Race conditions, deadlocks, priority inversion become possible |
1.5 When to use an RTOS
Good reasons:
- Several activities with different timing needs (a 1 kHz control loop plus a slow UI plus a network stack)
- Protocol stacks (TCP/IP, USB, BLE, file systems) that expect an OS environment
- Hard deadlines that must be met even as features are added
- Team development: tasks are natural boundaries for modules
- Blocking-style code is clearer than a web of state machines
- Power management with tickless idle (sleep when no task is ready)
Probably not needed:
- A tiny device with one or two simple jobs
- Extremely tight RAM/flash budgets
- Applications where a superloop + FSM is already clean and meets timing
1.6 Typical use cases
| Domain | Example |
|---|---|
| Industrial / motor control | Fast control loop, communication, safety monitoring in parallel |
| IoT / connected devices | Sensor sampling, radio stack, cloud communication, OTA updates |
| Consumer devices | UI + audio + connectivity + battery management |
| Automotive / medical | Deterministic sensor processing with strict deadlines |
| Wearables | Low-power sensing with burst processing and BLE |
2. Tasks and Threads
2.1 What is a task?
A task (also called a thread in some RTOSes) is an independent piece of code that runs as if it had the CPU to itself. Each task typically contains an infinite loop that does one job, such as "read the sensor every 10 ms" or "handle incoming UART messages".
The RTOS scheduler creates the illusion of parallelism by switching rapidly between tasks on a single CPU core.
2.2 What each task owns
| Resource | Explanation |
|---|---|
| Its own stack | Holds local variables, function-call return addresses and saved registers |
| Its own saved context | CPU register values saved when it's not running |
| A priority | Decides who runs when several tasks are ready |
| A state | Running, Ready, Blocked, Suspended |
| A control block (TCB) | Kernel data structure holding all of the above |
Tasks share: global variables, the heap, peripherals, and the code itself. Sharing is convenient and also the source of most concurrency bugs.
2.3 Task vs. thread vs. process
| Term | Meaning (in the MCU/RTOS world) |
|---|---|
| Task | Common RTOS term (FreeRTOS, uC/OS) |
| Thread | Same idea; used by Zephyr, ThreadX, CMSIS-RTOS |
| Process | A separate address space with memory protection, typical of big OSes (Linux); rare on small MCUs |
2.4 Shape of a typical task
void vSensorTask(void *pvParameters)
{
// one-time setup for this task
TickType_t xLastWake = xTaskGetTickCount();
for (;;) { // never returns
read_sensor();
process_data();
vTaskDelayUntil(&xLastWake, pdMS_TO_TICKS(10)); // run every 10 ms
}
}
Key rules:
- A task function normally never returns. If a task is finished, it should delete itself explicitly.
- A task spends most of its time blocked (waiting for time, data or a signal), which lets lower-priority tasks and idle run.
- Never busy-wait for something an RTOS object can wait for. Block instead.
2.5 Designing tasks: how to split the work
Good task boundaries usually follow:
- Timing: things with different periods or deadlines (1 ms control vs. 1 s logging)
- Event source: one task per major input (UART handler, button/UI, network)
- Functionality / ownership: one task owns a device (e.g. a display task, a flash-writing task)
- Blocking behaviour: put slow/blocking operations in their own task so they don't delay urgent work
Don't create too many tasks. Each costs RAM (stack, TCB) and adds context-switch overhead and synchronisation complexity. Combine things that always run together or always share the same timing.
2.6 The idle task
When no application task is ready, the RTOS runs a built-in idle task (lowest priority). It is used for:
- Housekeeping (freeing memory of deleted tasks)
- An optional idle hook (small background jobs)
- Entering low-power sleep (
WFI) when nothing is ready
Never block or delay inside an idle hook. The idle task must always be able to run.
3. Task States and Scheduling
3.1 The four main states
| State | Meaning |
|---|---|
| Running | Currently executing on the CPU (only one task per core) |
| Ready | Able to run, waiting for the CPU because a higher-priority (or equal and currently running) task has it |
| Blocked | Waiting for an event (a delay to expire, data on a queue, a semaphore, a notification), with an optional timeout |
| Suspended | Explicitly taken out of scheduling; stays so until another task/ISR resumes it |
3.2 State transition diagram
βββββββββββββ scheduler picks it ββββββββββββ
β βΌ
βββββββββββββ βββββββββββββ
created βββΊβ READY ββββββ preempted / time slice βββββ RUNNING β
βββββββββββββ βββββββββββββ
β² β² β β
β β event occurred / timeout β β calls blocking API
β β β β (delay, take, receive...)
β ββββββββββββββββ β βΌ
β βββββββββββββ βββββββββββββββββ (waits)
β β BLOCKED β
β βββββββββββββ
β resume vTaskSuspend()
β ββββββββββββββββ ββββββββββββββββ
ββββββββββββββββββββββ SUSPENDED β
ββββββββββββββββ
Practical reading:
- A task blocks itself by calling a waiting API (delay, queue receive, semaphore take...).
- When the event happens or the timeout expires, it moves to Ready, and the scheduler decides whether it runs now.
- Suspended is different from Blocked: there is no timeout or event that wakes it; someone must explicitly resume it.
3.3 What the scheduler does
The scheduler is the kernel component that decides, at certain moments, which Ready task becomes Running. It runs when:
- The tick interrupt fires (periodic timer, e.g. every 1 ms)
- A running task blocks, yields or suspends
- A task or ISR makes a higher-priority task ready (e.g. by giving a semaphore or sending to a queue)
- A task is created or deleted
3.4 The system tick
The tick is a periodic timer interrupt (e.g. 1000 Hz, so 1 ms). It:
- Keeps time (tick count) for delays and timeouts
- Moves tasks whose delay/timeout expired from Blocked to Ready
- Triggers time slicing between equal-priority tasks
- Runs the scheduler check
Tick rate trade-off: a higher rate gives finer time resolution but more overhead and more wake-ups (worse for power). Common values are 100 to 1000 Hz. Tickless idle suppresses the tick during long idle periods to save power.
3.5 Scheduling policies (how the next task is chosen)
| Policy | Idea | Used in |
|---|---|---|
| Fixed-priority preemptive | Highest-priority Ready task always runs | Most RTOSes (the usual default) |
| Round-robin / time slicing | Equal-priority tasks take turns each tick | Combined with priority-based scheduling |
| Cooperative | Task switches only when a task yields/blocks | Optional in many RTOSes |
| Earliest Deadline First (EDF) | Task with the nearest deadline runs first | Less common in MCU RTOSes |
Most embedded RTOS work uses fixed-priority preemptive scheduling with round-robin among equal priorities.
4. Priority-Based Preemption
4.1 The rule
At any moment, the Running task is the highest-priority task that is Ready.
If a higher-priority task becomes Ready, it preempts (immediately takes the CPU from) the lower-priority running task, even in the middle of that task's code. The preempted task goes back to Ready, with its context saved, and resumes later exactly where it left off.
4.2 Timeline example
Priority
High β ββββββ B ββββββ
β β (waited for β
Medium β ββ A βββββββββ an event) ββββββ A continues ββββββ
β
Low β ββ C ββ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββΊ time
A runs t1: event makes B ready t2: B blocks again
β B PREEMPTS A β A resumes
4.3 Why this gives real-time behaviour
- The response time of the highest-priority task is almost independent of what lower-priority tasks are doing. It is bounded by interrupt/scheduler latency (microseconds).
- You protect critical work by priority, not by hoping other code behaves.
- Adding new low-priority features cannot (normally) delay high-priority deadlines.
4.4 Consequences you must design for
- Starvation: a high-priority task that never blocks starves everything below it. High-priority tasks must block regularly (wait for events/time) and do short work.
- Shared data is at risk: a preempting task can interrupt another in the middle of updating shared data (see race conditions).
- Priority assignment matters: wrong priorities lead to missed deadlines (section 6).
4.5 Preemptive vs. cooperative (quick recap)
| Preemptive | Cooperative | |
|---|---|---|
| Switch on higher-priority ready | Immediately | Only when the running task yields/blocks |
| Latency for urgent tasks | Low and bounded | As long as the running task keeps the CPU |
| Race-condition exposure | Higher | Lower |
Preemptive scheduling is the normal choice for real-time work.
5. Context Switching
5.1 What it is
A context switch is the act of stopping one task and starting another. The CPU has only one set of registers, so the kernel must:
- Save the outgoing task's context (CPU registers, program counter, status register, stack pointer; plus floating-point registers if used)
- Choose the next task (scheduler decision)
- Restore the incoming task's saved context
- Resume execution where that task left off
Task A running
β (tick / event / yield)
βΌ
save A's registers onto A's stack, store A's stack pointer in A's TCB
β
βΌ
scheduler: pick highest-priority Ready task β B
β
βΌ
load B's stack pointer from B's TCB, restore B's registers from B's stack
β
βΌ
Task B running (exactly where it last stopped)
Each task's stack is where its context lives while it isn't running. That's one reason stack size matters.
5.2 How it's done on ARM Cortex-M (typical)
- The context switch is performed in a low-priority exception handler, usually PendSV, so it never interrupts a more urgent ISR.
- SysTick provides the periodic tick.
- SVC is commonly used to start the first task.
- The hardware automatically pushes a basic set of registers on exception entry, and the kernel saves the remaining registers by software.
- Floating-point (FPU) registers add to context size and switch time if tasks use floating point, and may be saved lazily.
(Other architectures have equivalent mechanisms.)
5.3 The cost of context switching
| Cost | Description |
|---|---|
| Direct time | Typically a few microseconds on modern MCUs (varies with clock, registers, FPU) |
| CPU overhead | Proportional to switch frequency |
| Cache effects | On cached CPUs, switching can reduce cache efficiency |
| RAM | Context saved per task on its stack |
Reducing needless switching:
- Don't create unnecessary tasks.
- Avoid very high tick rates unless needed.
- Avoid tight ping-pong communication between tasks.
- Batch work (process several items per wake-up).
- Don't use round-robin time slicing between equal-priority tasks unless required.
5.4 When does a switch happen?
- Tick interrupt finds a higher-priority task ready, or time slice expired
- Running task blocks (delay, wait, take, receive)
- Running task yields voluntarily
- An ISR or task makes a higher-priority task ready (a switch happens on ISR exit if requested)
- A task is created/deleted/suspended/resumed in ways that change who should run
6. Task Priorities and Time Slicing
6.1 Priorities
Every task has a priority number. In FreeRTOS, a larger number means higher priority (idle is 0). Other RTOSes may use the opposite convention (lower number = more urgent), so always check.
#define PRIO_IDLE_WORK 1 // lowest app priority
#define PRIO_UI 2
#define PRIO_COMMS 3
#define PRIO_CONTROL 5 // most urgent app task
6.2 How to assign priorities
Principles:
- Shorter deadline / shorter period gets higher priority. This is the idea behind Rate-Monotonic Scheduling (RMS): tasks that run more often get higher priority.
- Tasks that handle urgent hardware events (to avoid losing data) go higher.
- Tasks that run briefly and block often can safely be high priority.
- Long-running or background tasks (logging, UI refresh, housekeeping) go low.
- Don't put many tasks at the same priority unless they truly are equals.
- Leave gaps in numbering (e.g. 2, 4, 6) so new tasks can be inserted later.
- Don't use priority as a substitute for synchronisation. "It's higher priority so it runs first" is a fragile assumption.
Example assignment
| Task | Period / trigger | Work time | Priority |
|---|---|---|---|
| Motor control loop | 1 ms | 100 Β΅s | High |
| Sensor acquisition | 10 ms | 1 ms | Medium-high |
| Communication handler | Event (UART) | short bursts | Medium |
| UI / display update | 100 ms | 5 ms | Low |
| Logging to flash | Background | long | Lowest |
6.3 Time slicing (round-robin)
When several Ready tasks share the same (highest) priority, the scheduler can share the CPU among them in turns, each for one time slice (usually one tick).
Priority 3: A β B β C β A β B β C β ... (one tick each, if all stay ready)
Priority 2: (never runs while any priority-3 task is Ready)
Important details
- Time slicing happens only among equal priorities.
- A higher-priority Ready task always beats the whole group.
- Tasks that block early give up their slice.
- If time slicing is disabled, equal-priority tasks run until they block (cooperative among equals).
When time slicing is useful: several roughly equal, CPU-heavy background tasks that should all make progress.
When to avoid it: it causes extra context switches and makes timing less predictable. For hard real-time tasks, give them distinct priorities and let them block.
6.4 Starvation and priority pitfalls
- Starvation: a high-priority task that never blocks (e.g. a busy loop) prevents all lower ones from running, including idle (and watchdog feeding!).
- Priority creep: raising priorities "to fix a problem" until everything is high and priorities mean nothing. Find the real timing issue instead.
- Too many priority levels complicate analysis, so keep the scheme simple.
6.5 Delays vs. busy waiting
// BAD: wastes CPU and starves lower priorities
while (!flag) { }
// GOOD: blocks, letting others run
vTaskDelay(pdMS_TO_TICKS(10));
// BETTER: wait for the actual event
xSemaphoreTake(sem, portMAX_DELAY);
6.6 Periodic timing: vTaskDelay vs. vTaskDelayUntil
| Function | Behaviour | Use |
|---|---|---|
vTaskDelay(n) | Blocks for n ticks from now. The period = n + work time, so it drifts | Simple pauses |
vTaskDelayUntil(&last, n) | Wakes at fixed intervals measured from the previous wake time | Periodic tasks (control loops, sampling) |
TickType_t last = xTaskGetTickCount();
for (;;) {
do_periodic_work();
vTaskDelayUntil(&last, pdMS_TO_TICKS(20)); // exactly every 20 ms (if work < 20 ms)
}
7. Semaphores and Mutexes
Tasks often need to synchronise (signal each other) or protect shared resources. Semaphores and mutexes are the standard tools.
7.1 Binary semaphore
A semaphore with just two states (available / not available). The classic uses:
- Signalling: one task or ISR gives it to tell another task "something happened".
- Not meant for protecting resources (use a mutex for that; see priority inversion).
ISR / Task A ββ give βββΊ[ binary semaphore ]βββ take (blocks until given) ββ Task B
Deferred interrupt processing: the most common use:
SemaphoreHandle_t xDataSem;
void vTask(void *p)
{
for (;;) {
if (xSemaphoreTake(xDataSem, portMAX_DELAY) == pdTRUE) {
process_the_event(); // long work happens in task context
}
}
}
void EXTI_IRQHandler(void)
{
BaseType_t xWoken = pdFALSE;
clear_interrupt_flag();
xSemaphoreGiveFromISR(xDataSem, &xWoken);
portYIELD_FROM_ISR(xWoken); // switch immediately if a higher-priority task was woken
}
(In FreeRTOS a binary semaphore is created empty, so the first take blocks until someone gives.)
7.2 Counting semaphore
Holds a count (0 to a maximum).
| Use | How |
|---|---|
| Counting events | Each event gives once; the task takes once per event, so no events are lost if they arrive in bursts |
| Managing a pool of resources | Initial count = number of identical resources (e.g. 3 DMA channels); take to acquire, give to release |
SemaphoreHandle_t xPool = xSemaphoreCreateCounting(3, 3); // max 3, start with 3 available
7.3 Mutex (mutual exclusion)
A mutex protects a shared resource so that only one task at a time uses it. It has the concept of an owner: the task that took it must be the one to give it back.
SemaphoreHandle_t xUartMutex;
void log_message(const char *msg)
{
if (xSemaphoreTake(xUartMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
uart_send(msg); // critical section: one task at a time
xSemaphoreGive(xUartMutex);
} else {
// timeout: handle failure (don't hang forever)
}
}
Protects things like: a UART shared by several tasks, an SPI/IΒ²C bus, a display, a shared data structure, a flash driver.
7.4 Mutex vs. binary semaphore
| Mutex | Binary semaphore | |
|---|---|---|
| Purpose | Protect a resource (mutual exclusion) | Signal an event (synchronisation) |
| Ownership | Yes; the taker should be the giver | No; any task or ISR can give |
| Priority inheritance | Yes (in most RTOSes, including FreeRTOS) | No |
| Usable from ISR | No (not allowed to take/give in ISRs) | Yes (ISR-safe give) |
| Typical pattern | take, use resource, give (same task) | one side gives, other side takes |
Rule of thumb: mutex = "lock"; semaphore = "signal". Using a binary semaphore as a lock invites priority inversion.
7.5 Recursive mutex
A normal mutex locked twice by the same task deadlocks it. A recursive mutex can be taken multiple times by its owner and must be given back the same number of times. It's useful when a function that holds the lock calls another function that also takes it, but redesigning to avoid nested locking is usually cleaner.
7.6 Good practice
- Keep critical sections short. Never do slow things (long computations, waiting on other objects) while holding a mutex.
- Always use timeouts in production code where a hang is unacceptable, and handle the timeout.
- Take one mutex at a time, or always in a fixed global order (avoids deadlock).
- Never take a mutex from an ISR.
- Give back what you took on every path (including error paths).
- Prefer designing away sharing (see gatekeeper tasks, section 8) over locking everywhere.
8. Queues and Message Passing
8.1 What a queue is
A queue is a thread-safe FIFO buffer for passing data between tasks (and between ISRs and tasks). Tasks can block waiting to read from an empty queue or write to a full one, with optional timeouts.
Producer task / ISR βββΊ [ item ][ item ][ item ][ ][ ] βββΊ Consumer task
send FIFO (fixed length) receive (blocks if empty)
8.2 Copy by value
In FreeRTOS, queues copy the item into the queue (the item is passed by value), so:
- The sender's variable can be reused or go out of scope right after sending (safe, no lifetime problems).
- Large items are expensive to copy. For big data, send a pointer or index to a buffer, and make sure that buffer's ownership and lifetime are clearly managed (see below).
8.3 Basic use
typedef struct {
uint8_t sensor_id;
uint16_t value;
uint32_t timestamp;
} sample_t;
QueueHandle_t xSampleQ;
// create (before use)
xSampleQ = xQueueCreate(10, sizeof(sample_t)); // up to 10 items
// producer task
void vProducer(void *p)
{
sample_t s;
for (;;) {
s.sensor_id = 1;
s.value = read_adc();
s.timestamp = xTaskGetTickCount();
xQueueSend(xSampleQ, &s, pdMS_TO_TICKS(10)); // wait up to 10 ms if full
vTaskDelay(pdMS_TO_TICKS(5));
}
}
// consumer task
void vConsumer(void *p)
{
sample_t s;
for (;;) {
if (xQueueReceive(xSampleQ, &s, portMAX_DELAY) == pdTRUE) {
handle_sample(&s);
}
}
}
8.4 Common queue operations
| Operation | Meaning |
|---|---|
Send to back (xQueueSend) | Normal FIFO insertion |
Send to front (xQueueSendToFront) | Urgent item jumps the line |
Receive (xQueueReceive) | Remove oldest item |
Peek (xQueuePeek) | Look at the oldest item without removing it |
ISR variants (...FromISR) | Safe versions for interrupt handlers |
Overwrite (xQueueOverwrite) | For a length-1 queue holding the latest value ("mailbox") |
Messages waiting (uxQueueMessagesWaiting) | How many items are queued |
8.5 Blocking on queues and priorities
- A task blocked on an empty queue wakes when data arrives. If several tasks wait, the highest-priority waiting task is served first (ties go to the one that waited longest).
- A full queue blocks the sender (for the specified timeout), which gives natural back-pressure so a fast producer can't overrun a slow consumer.
8.6 Queue design guidance
- Size queues for the worst-case burst, not the average.
- Decide what happens on overflow: block, drop newest, drop oldest, or raise an error. Never ignore the return value of a send.
- Keep item types small and fixed. A typical pattern is a message struct with an ID/type field plus a small payload.
- For large buffers, pass pointers (or indices into a buffer pool) and define who owns and frees the buffer. Avoid sending pointers to stack variables of the sender.
- A queue can also be a mailbox (length 1, overwrite for "latest value" semantics).
8.7 Message-passing architecture: avoid sharing, send messages
Instead of many tasks touching shared data under locks, give each piece of data or hardware one owner task and let others send it requests:
Task A βββ
Task B βββΌβββΊ [ request queue ] βββΊ Gatekeeper task βββΊ owns UART / display / flash
Task C βββ
Gatekeeper task pattern: only the gatekeeper touches the resource, so no mutex is needed, there is no priority inversion on that resource, and operations are naturally serialised. This is a clean way to share a printf/UART, a display or a flash device.
8.8 Queue sets and alternatives
If a task must wait on several sources at once, options include queue sets, event groups, task notifications, or one queue that carries tagged messages from all sources. A single queue with message types is often the simplest.
9. Event Groups and Task Notifications
9.1 Event groups
An event group is a set of bits (flags). Each bit represents an event or condition. Tasks can wait for one bit, any of several, or all of several, and multiple tasks can wait on the same group.
Event group bits: [ bit2: NET_UP ][ bit1: SENSOR_READY ][ bit0: CONFIG_LOADED ]
#define BIT_CONFIG_LOADED (1u << 0)
#define BIT_SENSOR_READY (1u << 1)
#define BIT_NET_UP (1u << 2)
EventGroupHandle_t xEvents;
xEvents = xEventGroupCreate();
// producer(s) set bits
xEventGroupSetBits(xEvents, BIT_SENSOR_READY);
// a task waits until ALL three are set, clearing them on exit
EventBits_t got = xEventGroupWaitBits(
xEvents,
BIT_CONFIG_LOADED | BIT_SENSOR_READY | BIT_NET_UP,
pdTRUE, // clear the bits on exit
pdTRUE, // wait for ALL bits (pdFALSE = wait for ANY)
pdMS_TO_TICKS(5000));
if ((got & (BIT_CONFIG_LOADED | BIT_SENSOR_READY | BIT_NET_UP)) ==
(BIT_CONFIG_LOADED | BIT_SENSOR_READY | BIT_NET_UP)) {
start_main_operation();
}
Good for
- Waiting for several conditions (e.g. system start-up dependencies)
- Broadcasting an event to many tasks at once
- Task rendezvous / synchronisation (
xEventGroupSync): several tasks wait for each other at a barrier
Cautions
- Only a limited number of bits are usable (e.g. 24 on a 32-bit-tick FreeRTOS configuration).
- Setting bits from an ISR is deferred through the timer service task in FreeRTOS, so it is slower and relies on that task's priority.
- Events carry no data, only "this happened". Use a queue to carry values.
- Clearing is your responsibility; know who clears which bits (clear-on-exit or manual).
9.2 Task notifications
A task notification is a lightweight signal sent directly to a specific task. Every task has a built-in notification value, so no separate object needs to be created.
Advantages: much faster and uses less RAM than a semaphore/queue/event group, and it can act as a binary semaphore, counting semaphore, event flags or a 32-bit mailbox value.
TaskHandle_t xHandlerTask;
// Waiting task: acts like a counting semaphore "take"
void vHandlerTask(void *p)
{
for (;;) {
uint32_t count = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // wait, clear on exit
while (count--) {
process_one_event();
}
}
}
// ISR: acts like "give"
void UART_IRQHandler(void)
{
BaseType_t xWoken = pdFALSE;
vTaskNotifyGiveFromISR(xHandlerTask, &xWoken);
portYIELD_FROM_ISR(xWoken);
}
Notification variants also let you set bits (like event flags) or send a 32-bit value (like a one-slot mailbox).
Limitations
- Only one receiving task (the one being notified); can't broadcast to many.
- The receiver can block, but the sender cannot block waiting (if the previous value hasn't been consumed, the send may fail or overwrite depending on the action chosen).
- Not for passing arbitrary data streams. Use a queue.
9.3 Which tool to use?
| Need | Best choice |
|---|---|
| ISR/task wakes one specific task | Task notification (fastest, lightest) |
| Signal that may be waited on by several tasks | Binary semaphore or event group |
| Count events / manage a pool of resources | Counting semaphore |
| Protect a shared resource | Mutex |
| Pass data between tasks | Queue |
| Wait for combinations of conditions | Event group |
| Broadcast to many tasks | Event group |
| Latest-value "mailbox" | Length-1 queue with overwrite, or notification with value |
10. Software Timers
10.1 What they are
A software timer calls a callback function after a set time (one-shot) or repeatedly (auto-reload), without needing a dedicated task or hardware timer per timer. They're managed by the RTOS, based on the system tick.
| Kind | Behaviour |
|---|---|
| One-shot | Fires once, then stops (until restarted) |
| Auto-reload (periodic) | Fires every period until stopped |
10.2 How they work in FreeRTOS
FreeRTOS runs a hidden timer service (daemon) task. Timer commands (start, stop, reset, change period) are sent to it through a command queue, and callbacks execute in the context of the timer service task, not in an ISR and not in your own task.
TimerHandle_t xBlinkTimer;
void vBlinkCallback(TimerHandle_t xTimer)
{
toggle_led(); // keep it SHORT and NON-BLOCKING
}
void setup(void)
{
xBlinkTimer = xTimerCreate(
"blink", // name (debug only)
pdMS_TO_TICKS(500), // period
pdTRUE, // auto-reload
NULL, // timer ID
vBlinkCallback);
xTimerStart(xBlinkTimer, 0);
}
Common operations: xTimerStart, xTimerStop, xTimerReset (restart the countdown, handy for timeouts that are refreshed by activity), xTimerChangePeriod, plus ...FromISR versions.
10.3 Rules for timer callbacks
- Never block inside a callback (no
vTaskDelay, no waiting on queues/semaphores with a timeout), because that would stall all software timers. - Keep callbacks short. For heavier work, notify or send an event to a normal task.
- The timer daemon task has its own priority and queue length (configuration options). If its priority is low, callbacks can run late; if the command queue is too small, commands can be lost.
- Timer accuracy is limited by the tick resolution and by how busy higher-priority tasks are; software timers are not for precise or hard real-time timing. Use a hardware timer for that.
10.4 Typical uses
- Timeouts (e.g. "if no response in 2 s, retry")
- Periodic housekeeping (LED blink, heartbeat, battery check)
- Debouncing (start a short timer after a button edge, then read the stable level)
- Delayed actions ("turn off the backlight after 10 s of no activity", where you
resetthe timer on each touch)
10.5 Software timer vs. task with delay vs. hardware timer
| Software timer | Task with vTaskDelayUntil | Hardware timer | |
|---|---|---|---|
| Resource cost | Very low per timer | A whole task (stack + TCB) | Limited number of timers |
| Precision | Tick-limited, jitter | Tick-limited, but task priority is your choice | Highest |
| Can block? | No | Yes | n/a (ISR) |
| Best for | Many light timeouts/periodic jobs | Periodic work needing blocking calls or tight priority control | Precise PWM, capture, Β΅s timing, fast sampling |
11. Interrupt Interaction with an RTOS
11.1 Two separate worlds: ISRs and tasks
- ISRs run in response to hardware, above all tasks regardless of task priority. They can't block.
- Tasks are scheduled by the RTOS.
The golden rule:
Keep ISRs tiny. Capture the data, acknowledge the hardware, then hand the real work to a task. (This is deferred interrupt processing.)
11.2 Deferred interrupt processing pattern
Hardware event
β
βΌ
ISR (short: clear flag, grab data, signal) βββΊ semaphore / queue / notification
β
βΌ (on return, scheduler switches if a higher-priority task was woken)
Handler TASK (blocked until signalled): does the long processing
This keeps interrupt latency low for other interrupts and puts the heavy work under the scheduler's priority control.
11.3 Use only FromISR API calls inside ISRs
RTOS functions are different inside ISRs. In FreeRTOS, ISR-safe versions end in FromISR (e.g. xQueueSendFromISR, xSemaphoreGiveFromISR, vTaskNotifyGiveFromISR, xTimerStartFromISR). Reasons:
- Normal calls may block, which is illegal in an ISR.
- They assume a task context (current task identity, etc.).
- ISR versions take a
BaseType_t *pxHigherPriorityTaskWokenparameter.
11.4 The "higher priority task woken" flag and yielding
When an ISR unblocks a task, a higher-priority one than the interrupted task might now be ready. The ISR reports this via the flag, and requests a context switch at the end of the ISR:
void ADC_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
sample_t s = read_adc_result();
xQueueSendFromISR(xSampleQ, &s, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken); // switch now if needed
}
Without the yield, the woken task would only run at the next tick or scheduling point, adding avoidable latency.
11.5 Interrupt priorities vs. task priorities (important on Cortex-M)
- Every interrupt has a hardware priority that is completely separate from task priorities. Any ISR beats any task.
- On Cortex-M, a lower numeric value means more urgent.
- The RTOS defines a threshold (in FreeRTOS:
configMAX_SYSCALL_INTERRUPT_PRIORITY, also seen asconfigMAX_API_CALL_INTERRUPT_PRIORITY):- Interrupts at or below this urgency (numerically equal or higher value) may call
...FromISRfunctions. - Interrupts more urgent than it (numerically lower) are never masked by the kernel, giving very low latency, but they must not call any RTOS API.
- Interrupts at or below this urgency (numerically equal or higher value) may call
- The kernel's own interrupts (tick, context switch) use the lowest priority so they never delay real interrupts.
- Use a priority grouping with no sub-priorities (all bits as preempt priority) as the port requires.
More urgent ββββββββββββββββββββββββββββββββββββββββββββββββΊ Less urgent
(numerically low) (numerically high)
[ Zero-latency ISRs: NO RTOS calls ] | [ ISRs allowed to call FromISR APIs ] | kernel (PendSV/SysTick)
β²
configMAX_SYSCALL_INTERRUPT_PRIORITY
Typical bug: an ISR calls ...FromISR but is configured more urgent than the allowed threshold. This causes rare, hard-to-reproduce corruption. Enable the port's assertions (configASSERT) to catch it.
11.6 Critical sections and disabling interrupts
To protect short sequences from interruption:
- Task code:
taskENTER_CRITICAL()/taskEXIT_CRITICAL()(masks interrupts up to the syscall priority level, with nesting support). - ISR code:
taskENTER_CRITICAL_FROM_ISR()/taskEXIT_CRITICAL_FROM_ISR(). - Keep critical sections extremely short (a few instructions), because they add to interrupt latency for everything masked.
- Longer protection should use mutexes or scheduler suspension (
vTaskSuspendAll(), which stops task switches but still lets ISRs run).
11.7 ISR design checklist with an RTOS
- ISR does the minimum: clear the source, grab data, signal.
- Use only
FromISRAPIs; never block, never take a mutex, never callprintf-style heavy functions. - Pass the woken flag and yield at the end.
- Set the interrupt priority within the allowed range.
- Variables shared with tasks:
volatileplus proper synchronisation. - Remember DMA/peripheral-driven designs (e.g. DMA half/full-transfer callbacks) fit this model perfectly: callback sends a notification, a task processes the block.
12. Priority Inversion and Priority Inheritance
12.1 The problem: priority inversion
Priority inversion happens when a high-priority task is made to wait for a low-priority task, and a medium-priority task, unrelated to the shared resource, then keeps the low-priority task from finishing. The high-priority task is effectively delayed by the medium one, which defeats the priority scheme.
12.2 The classic scenario
Three tasks: H (high), M (medium), L (low). H and L share a resource protected by a lock.
Time ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββΊ
L: ββ takes lock βββ
β H becomes ready, preempts L...
H: βββ runs ββ tries to take lock ββ BLOCKED (L holds it) βββββββββββββββ (still waiting) ββ
M: M becomes ready: higher than L, so it RUNS βββββββββββββββββββββ
L: (preempted by M, cannot run, cannot finish and release the lock) ................................
Result: H is stuck waiting for L, and L is stuck behind M.
M (medium) effectively blocks H (high), for an unbounded time.
The delay for H is unbounded, since M can run as long as it likes. In a hard real-time system this means a missed deadline.
Famous real example: the 1997 Mars Pathfinder lander suffered repeated system resets caused by priority inversion. The fix was to enable priority inheritance on the relevant mutex.
12.3 The solution: priority inheritance
With priority inheritance, when a high-priority task blocks on a lock held by a lower-priority task, the holder temporarily inherits the higher priority. It can then no longer be preempted by medium-priority tasks, finishes its critical section quickly, releases the lock, and drops back to its original priority.
L: ββ takes lock βββ
H: βββ tries to take lock ββ blocks
β L's priority is raised to H's
L: ββ runs (not preemptible by M) ββ gives lock βββ (priority restored)
H: βββ runs ββ
M: (kept waiting until H is done or blocks)
Key facts
- In FreeRTOS, mutexes implement priority inheritance; binary semaphores do not. That's a major reason to use a mutex, not a binary semaphore, for resource protection.
- Priority inheritance bounds the inversion, but doesn't eliminate it: H still waits for L's critical section to finish. Keep critical sections short.
- FreeRTOS's inheritance is a simplified implementation. It does not model every chained or multi-mutex scenario perfectly, so avoid holding several mutexes at once.
12.4 Other approaches
| Technique | Idea |
|---|---|
| Priority inheritance | Holder inherits the priority of the highest waiter (dynamic) |
| Priority ceiling | Each lock has a ceiling priority (the highest priority of any task that uses it); a task holding it runs at the ceiling. Also helps prevent deadlock; available in some RTOSes/standards (e.g. POSIX), not a built-in FreeRTOS feature |
| Avoid sharing | Use a gatekeeper task or message passing, so no lock is needed |
| Disable preemption briefly | Scheduler lock/critical section for very short operations |
| Design priorities well | Minimise sharing between very different priority levels |
12.5 Practical advice
- Use a mutex (with inheritance) for shared resources, never a binary semaphore.
- Minimise the time a lock is held and never block while holding one.
- Limit the number of tasks that share a resource, especially across widely different priorities.
- Don't hold a lock across calls that might take unpredictable time (flash writes, network I/O).
13. Deadlocks and Race Conditions
13.1 Race conditions
A race condition occurs when the result depends on the unpredictable timing of tasks/interrupts accessing shared data. One context may be interrupted in the middle of a read-modify-write sequence by another that changes the same data.
Classic example: a shared counter
volatile uint32_t counter;
void vTaskA(void *p) { for (;;) { counter++; /* ... */ } }
void vTaskB(void *p) { for (;;) { counter++; /* ... */ } }
counter++ is really three steps: load, add, store.
Task A: LOAD counter (5)
β preempted here
Task B: LOAD counter (5), ADD β 6, STORE counter = 6
β back to A
Task A: ADD β 6, STORE counter = 6 (one increment is LOST; it should be 7)
Where races hide
- Read-modify-write on globals (
++,|=,&=) - Multi-word data (a struct or 64-bit value) read while being updated, causing torn reads (half old, half new)
- ISR β task shared variables
- Check-then-act logic ("if the buffer is empty then ..." where it changes in between)
- Shared peripherals (two tasks interleaving bytes on one UART)
- Non-reentrant library functions (
strtok, someprintfimplementations,randusing hidden global state)
How to prevent races
| Technique | When |
|---|---|
| Mutex | Protect longer sections or shared resources between tasks |
| Critical section (briefly disable interrupts) | Very short updates that ISRs also touch |
| Scheduler suspension | Short section that only tasks (not ISRs) touch |
| Atomic operations | Single-variable operations supported by hardware/compiler |
| Queues / message passing | Transfer ownership of data instead of sharing it |
| Task notifications / events | Signal instead of polling shared flags |
| Single owner / gatekeeper task | Only one context ever touches the resource |
| Immutable or copy-on-write data | Readers never see in-progress changes |
volatile | Required for ISR-shared variables, but volatile alone does NOT make an access atomic or thread-safe |
// protecting the counter with a short critical section
taskENTER_CRITICAL();
counter++;
taskEXIT_CRITICAL();
Reentrancy
A function is reentrant if it can be safely called by several tasks at once: it uses only local variables or properly protected shared state. Use reentrant library variants, or protect non-reentrant calls with a mutex.
13.2 Deadlocks
A deadlock is when two or more tasks wait for each other forever, each holding something the other needs. None can proceed.
Classic scenario: two mutexes, opposite order
Task A: take(M1) βββΊ ... βββΊ take(M2) waits for B to release M2
Task B: take(M2) βββΊ ... βββΊ take(M1) waits for A to release M1
A holds M1, wants M2
B holds M2, wants M1 β both blocked forever
The four conditions (all must hold for a deadlock)
- Mutual exclusion: resources can't be shared
- Hold and wait: a task holds one resource while waiting for another
- No preemption of resources: a resource can't be forcibly taken away
- Circular wait: a cycle of tasks each waiting on the next
Break any one condition and deadlock cannot occur.
How to avoid deadlocks
| Strategy | How |
|---|---|
| Lock ordering | Define a global order for acquiring locks (always M1 before M2) and follow it everywhere |
| Take one lock at a time | Avoid nested locking altogether |
| Timeouts | Use xSemaphoreTake(m, timeout); on timeout, release held locks, back off and retry or report an error |
| Acquire all at once / try-lock and back off | If you can't get everything, release everything |
| Gatekeeper / message passing | Remove locks by giving one task ownership |
| Keep critical sections tiny | Fewer chances to overlap |
| Avoid calling unknown code while holding a lock | A callback might try to take another lock |
| Priority ceiling protocols | Some designs prevent deadlock this way |
Related problems
- Livelock: tasks keep reacting to each other and make no progress (both retry in lockstep).
- Starvation: a task never gets the resource or CPU because others always win.
- Self-deadlock: a task takes a non-recursive mutex it already holds, or waits on something only it could provide.
- Waiting on a lock forever (
portMAX_DELAY) when the holder crashed or never gives it back.
Detecting them
- A system that silently stops doing parts of its work while the rest runs
- Watchdog resets because a task never checks in (a good safety net; see below)
- RTOS-aware debugger: inspect each task's state and what it's blocked on
- Trace tools showing task/lock timelines
- Code review focused on lock ordering
A watchdog (with a health-checking design where each task reports "alive") is the practical last line of defence against deadlocks in the field.
14. Stack Sizing and Task Memory
14.1 Where memory goes in an RTOS system
| Memory | Contents | Notes |
|---|---|---|
| Task stack (one per task) | Local variables, function call frames, saved context, interrupt frames (on some ports) | Biggest RAM consumer; sized per task |
| TCB (task control block) | Priority, state, stack pointer, name, list links... | Small, per task |
| Kernel objects | Queues (with storage), semaphores, mutexes, event groups, timers | Per object |
| Heap | Space from which dynamic objects are allocated (pvPortMalloc) | Size set at build time in many configs |
| Idle task stack and timer task stack | Created by the kernel | Count them in your budget |
| Main/interrupt stack | On Cortex-M, interrupts and startup use the main stack (MSP), while tasks use the process stack (PSP) | ISR stack use does not come out of task stacks (on typical Cortex-M ports) |
14.2 Stack size units: a classic trap
In FreeRTOS, the usStackDepth parameter of xTaskCreate is in words (the width of the stack type, e.g. 4 bytes on 32-bit Cortex-M), not bytes.
xTaskCreate(vTask, "name", 256, NULL, 2, NULL); // 256 WORDS = 1024 bytes on a 32-bit MCU
14.3 What determines how big a stack must be
- Deepest function call chain the task can reach (each call pushes a frame)
- Local variables and arrays in those functions (large local buffers are the usual culprit)
- Library calls:
printf/sprintfwith float support, file systems, crypto, TLS, JSON, etc. can use hundreds to thousands of bytes - Recursion: depth is data-dependent, so avoid it in embedded code
- Context-save area: the saved register frame (larger if the FPU is used)
- Interrupt nesting: on some ports, interrupt frames also land on the task stack of the interrupted task
- Compiler optimisation level and debug settings: unoptimised code often uses more stack
- Worst case, not typical: the rarely taken error path may be the heaviest
14.4 How to determine stack size (practical method)
- Start generously (e.g. 512 to 1024 words for tasks using libraries, fewer for simple ones).
- Run the system under worst-case conditions (all features, error paths, maximum message sizes).
- Measure the high-water mark: the minimum free stack ever seen.
UBaseType_t freeWords = uxTaskGetStackHighWaterMark(NULL); // NULL = this task
// FreeRTOS fills the stack with a known pattern at creation and finds how much was never touched
- Reduce to usage plus a safety margin (a common practice is keeping 25 to 50% headroom, or more for safety-critical code).
- Re-measure after code changes; stack use grows silently when someone adds a large local buffer.
Other aids: static analysis / compiler stack-usage reports (e.g. -fstack-usage) and call-graph tools to compute worst-case stack depth.
14.5 Stack overflow: what happens
Stacks usually grow downward (on Cortex-M). An overflow writes past the stack's end into whatever memory is next, which could be another task's stack, kernel data or variables. Results: random crashes, corrupted data, hard faults, often far from the real cause and not reproducible.
Detection options
- FreeRTOS stack-overflow checking (
configCHECK_FOR_STACK_OVERFLOW):- Method 1: checks whether the stack pointer went out of bounds at context switch.
- Method 2: also checks that a known pattern at the stack end is intact (catches more cases).
- Both call
vApplicationStackOverflowHook(). Overflows that happen between context switches (and recover) can slip through, so it's a safety net, not a guarantee.
- MPU guard regions at the end of each stack (hardware-detected overflow, if the MCU and port support it)
- High-water-mark logging during testing
- Runtime assertions and watchdog + fault handler recording
14.6 Reducing stack and RAM usage
- Avoid big local arrays: make them
static(RAM in.bss, but not stack; note: then no longer reentrant), allocate from a buffer pool, or put them in a struct owned by the task. - Avoid
printf-family functions inside deep stacks, or use a lightweight logging approach, or a gatekeeper logging task with a bigger stack that only that task needs. - Avoid recursion and deep call hierarchies.
- Merge tasks that don't need to be separate.
- Use task notifications instead of semaphores/queues where possible (less RAM).
- Keep queue item sizes and depths realistic.
14.7 Static vs. dynamic allocation
Dynamic (xTaskCreate, xQueueCreate) | Static (xTaskCreateStatic, xQueueCreateStatic) | |
|---|---|---|
| Memory comes from | The RTOS heap at run time | Buffers you provide at compile time |
| Convenience | Easier | More code (you supply buffers) |
| Failure mode | Creation can fail at run time if heap is exhausted or fragmented | RAM usage is known at link time, with no run-time allocation failures |
| Typical in | General projects | Safety-critical / high-reliability systems, or when malloc-style allocation is banned |
14.8 FreeRTOS heap schemes (briefly)
| Scheme | Characteristics |
|---|---|
heap_1 | Allocate only, never free; simplest, deterministic |
heap_2 | Allows free, but doesn't merge neighbouring free blocks (fragmentation risk); mostly superseded |
heap_3 | Wraps the standard library malloc/free (thread-safe wrapper) |
heap_4 | Allocate and free with coalescing of adjacent free blocks; a common default |
heap_5 | Like heap_4 but can use several separate memory regions |
Monitor with xPortGetFreeHeapSize() and xPortGetMinimumEverFreeHeapSize(), and check return values of creation functions for NULL/failure (and consider a malloc-failed hook).
15. Real-Time Deadlines and Latency
15.1 Key timing terms
| Term | Meaning |
|---|---|
| Deadline | The time by which a task's work must be finished |
| Period | Interval between repeated activations of a periodic task |
| Response time | Time from the event/activation until the work is finished |
| WCET (Worst-Case Execution Time) | Longest time a piece of code can take to run (without interruption) |
| Latency | Delay between an event and the start of its handling |
| Jitter | Variation in timing (e.g. a "10 ms" task that sometimes runs at 9.8 or 10.3 ms) |
| Determinism | The property that timing is predictable and bounded |
| CPU utilisation | Fraction of CPU time used by tasks: U = Ξ£ (Ci / Ti) where Ci = execution time, Ti = period |
15.2 Types of latency in an RTOS system
Hardware event
β
β β Interrupt latency (hardware event β first ISR instruction runs)
βΌ
ISR runs, signals a task
β
β β‘ Scheduling / dispatch latency (task made ready β task actually starts running)
βΌ
Task starts running
β
β β’ Task execution time (work itself, plus preemption by higher-priority tasks/ISRs)
βΌ
Response complete β must be before the deadline
| Latency | What affects it |
|---|---|
| Interrupt latency | Longest period with interrupts disabled (critical sections), higher-priority ISRs, hardware/clock/wait states, flash cache misses |
| Scheduling latency | Context-switch time, scheduler lock duration, whether a higher-priority task or ISR is running, tick-based wake-ups (task may only be released on the next tick if it was waiting on a timeout) |
| Execution/response time | Task WCET, preemption by higher-priority tasks, blocking on resources (mutexes, with priority inversion bounded by inheritance) |
Total response time (simplified):
Response time β interrupt latency + scheduling latency
+ task execution time
+ interference (time taken by higher-priority tasks/ISRs)
+ blocking (time waiting on shared resources held by lower-priority tasks)
15.3 Meeting deadlines: design practices
- Know your WCETs. Measure (with timing pins, cycle counters, trace tools) and add margin; static analysis tools exist for rigorous work.
- Assign priorities by deadline/period (rate-monotonic or deadline-monotonic).
- Keep high-priority tasks short and always blocking on events.
- Keep critical sections and ISRs short. They add directly to everyone's latency.
- Bound blocking: short mutex hold times, priority inheritance, no long operations under locks.
- Avoid unbounded or unpredictable operations in time-critical paths: dynamic memory allocation, unbounded loops, blocking I/O,
printf, flash erase/write (which can stall the CPU on some MCUs). - Use hardware for tight timing: hardware timers, DMA, PWM, input capture, instead of software loops.
- Mind the tick resolution: a delay of 1 tick can mean between 0 and 1 tick period of actual time. For sub-tick precision, use a hardware timer.
- Leave CPU headroom (don't plan for 100% load).
15.4 Schedulability: can all deadlines be met?
CPU utilisation test (Rate-Monotonic Scheduling, simplified)
For n independent periodic tasks with deadlines equal to periods and priorities assigned rate-monotonically, a sufficient (not necessary) condition for schedulability is:
U = Ξ£ (Ci / Ti) β€ n Γ (2^(1/n) β 1)
For large n the bound approaches about 69.3% (ln 2). Utilisation below the bound guarantees schedulability; above it, the set may or may not be schedulable and needs a finer analysis (such as response-time analysis).
Example
| Task | C (exec) | T (period) | C/T |
|---|---|---|---|
| Control | 1 ms | 5 ms | 0.20 |
| Sensor | 2 ms | 20 ms | 0.10 |
| Comms | 5 ms | 50 ms | 0.10 |
| Total U | 0.40 |
The bound for n = 3 is 3 Γ (2^(1/3) β 1) β 0.78. Since 0.40 < 0.78, the set is schedulable (before accounting for ISR load, OS overhead and blocking, which must be added in).
Response-time analysis (idea)
For a task, its response time R = its own execution time + blocking by lower-priority resource holders + interference from higher-priority tasks that arrive during R. Compare R to the deadline. This gives a tighter and more precise answer than the utilisation bound.
15.5 Measuring real-time behaviour
- Toggle a GPIO at the start/end of an ISR or task and look at it on a logic analyser or scope: measures latency, execution time and jitter directly.
- Use the CPU cycle counter (e.g. DWT) for precise software timing.
- RTOS trace tools (event tracing) show task switches, ISRs, blocking and priority inheritance on a timeline.
- Runtime statistics (CPU usage per task) show who consumes the time.
- Stress-test: worst-case load, all interrupts firing, error paths, and slow peripherals.
15.6 Typical timing mistakes
- Assuming the average case instead of worst case
- Forgetting ISR and kernel overhead in utilisation
- Letting a low-priority task hold a lock for a long time while a high-priority one needs it
- Long interrupt-disabled sections (e.g. big critical sections around flash writes)
- Over-reliance on the tick for fine-grained timing
- Priority assignments that don't reflect deadlines
- Running at near-100% CPU load, so any extra work causes missed deadlines
16. FreeRTOS Fundamentals
16.1 What FreeRTOS is
FreeRTOS is a small, widely used, open-source (MIT-licensed) real-time kernel for microcontrollers, with ports for many architectures (ARM Cortex-M, RISC-V, ESP32 and many more). It provides tasks, queues, semaphores, mutexes, event groups, task notifications, software timers, and memory management. It's a kernel only. Networking, file systems and other libraries are separate add-ons.
16.2 Main source structure (typical project)
| Component | Role |
|---|---|
tasks.c | Core scheduler, task management |
queue.c | Queues, semaphores, mutexes |
list.c | Internal list handling |
timers.c | Software timers (timer service task) |
event_groups.c | Event groups |
stream_buffer.c | Stream/message buffers |
heap_x.c | One chosen memory allocation scheme |
port.c / portmacro.h | Hardware-specific port: context switch, tick setup, critical sections |
FreeRTOSConfig.h | Your configuration of the kernel |
16.3 Naming conventions (helps reading code)
Function name prefix = return type, then the module:
| Prefix | Meaning | Example |
|---|---|---|
v | returns void | vTaskDelay() |
x | returns BaseType_t or a handle/other | xQueueSend(), xTaskCreate() |
ul | returns uint32_t | ulTaskNotifyTake() |
pv | returns a pointer | pvPortMalloc() |
Module names are in the function: xTask..., xQueue..., xSemaphore..., xTimer..., xEventGroup.... Variables follow type prefixes too (x for BaseType_t/handles, ul for uint32_t, pc for char*, pv for void*...). Constants like pdTRUE, pdFALSE, pdPASS, portMAX_DELAY, configXXX are prefixed by their origin.
16.4 A minimal FreeRTOS program
#include "FreeRTOS.h"
#include "task.h"
void vBlinkTask(void *pvParameters)
{
for (;;) {
led_toggle();
vTaskDelay(pdMS_TO_TICKS(500)); // block 500 ms
}
}
void vPrintTask(void *pvParameters)
{
for (;;) {
printf("tick count: %lu\r\n", (unsigned long)xTaskGetTickCount());
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
int main(void)
{
hardware_init(); // clocks, GPIO, UART...
xTaskCreate(vBlinkTask, "Blink", 128, NULL, 1, NULL);
xTaskCreate(vPrintTask, "Print", 256, NULL, 2, NULL);
vTaskStartScheduler(); // never returns if all went well
for (;;) { } // only reached if there was not enough heap for idle task
}
Task creation prototype (key parameters):
BaseType_t xTaskCreate(
TaskFunction_t pvTaskCode, // the task function
const char *pcName, // descriptive name (debugging)
configSTACK_DEPTH_TYPE usStackDepth, // stack size in WORDS
void *pvParameters, // passed to the task
UBaseType_t uxPriority, // priority (higher number = higher priority)
TaskHandle_t *pxCreatedTask); // optional handle out (can be NULL)
// returns pdPASS on success; check it!
16.5 Starting the scheduler
vTaskStartScheduler() creates the idle task (and the timer task if software timers are enabled), configures the tick interrupt, and starts the first task. It only returns if there isn't enough memory to start.
16.6 Essential task control APIs
| API | Purpose |
|---|---|
xTaskCreate / xTaskCreateStatic | Create a task (dynamic / static memory) |
vTaskDelete | Delete a task (NULL = self) |
vTaskDelay | Block for a number of ticks |
vTaskDelayUntil / xTaskDelayUntil | Fixed-period wake-ups |
vTaskSuspend / vTaskResume | Suspend / resume a task |
vTaskPrioritySet / uxTaskPriorityGet | Change / read priority at run time |
xTaskGetTickCount | Current tick count |
taskYIELD | Voluntarily give up the CPU to equal-priority tasks |
uxTaskGetStackHighWaterMark | Remaining minimum stack |
vTaskGetInfo, vTaskList, vTaskGetRunTimeStats | Diagnostics (if enabled) |
xTaskGetCurrentTaskHandle | Handle of the calling task |
16.7 Time conversion
Delays are in ticks. Always convert with pdMS_TO_TICKS() so the code survives tick-rate changes:
vTaskDelay(pdMS_TO_TICKS(100)); // 100 ms regardless of configTICK_RATE_HZ
(If the tick period is 1 ms, 1 tick = 1 ms. Delays shorter than one tick period round to 0 ticks.)
16.8 Important FreeRTOSConfig.h options
| Option | Meaning |
|---|---|
configUSE_PREEMPTION | 1 = preemptive scheduling; 0 = cooperative |
configUSE_TIME_SLICING | Round-robin among equal priorities |
configTICK_RATE_HZ | Tick frequency (e.g. 1000) |
configCPU_CLOCK_HZ | CPU clock, used to set up the tick timer |
configMAX_PRIORITIES | Number of priority levels (0 .. n-1) |
configMINIMAL_STACK_SIZE | Stack size (words) for the idle task |
configTOTAL_HEAP_SIZE | Size of the FreeRTOS heap (for heap_1/2/4/5) |
configMAX_TASK_NAME_LEN | Max characters in a task name |
configUSE_MUTEXES / configUSE_RECURSIVE_MUTEXES | Enable mutex features |
configUSE_COUNTING_SEMAPHORES | Enable counting semaphores |
configUSE_TIMERS, configTIMER_TASK_PRIORITY, configTIMER_QUEUE_LENGTH, configTIMER_TASK_STACK_DEPTH | Software-timer service task |
configCHECK_FOR_STACK_OVERFLOW | Stack-overflow detection method (0/1/2) |
configUSE_MALLOC_FAILED_HOOK | Hook called on allocation failure |
configUSE_IDLE_HOOK / configUSE_TICK_HOOK | Application hooks in idle task / tick |
configUSE_TICKLESS_IDLE | Suspend the tick during idle for low power |
configKERNEL_INTERRUPT_PRIORITY | Interrupt priority of the kernel (lowest) |
configMAX_SYSCALL_INTERRUPT_PRIORITY | Highest interrupt priority allowed to call FromISR APIs |
configASSERT(x) | Assertion macro, strongly recommended to define during development |
configUSE_TRACE_FACILITY, configGENERATE_RUN_TIME_STATS | Diagnostics and statistics |
configSUPPORT_STATIC_ALLOCATION / configSUPPORT_DYNAMIC_ALLOCATION | Allocation styles available |
16.9 Object creation and common patterns, summarised
| Object | Create | Use |
|---|---|---|
| Queue | xQueueCreate(length, itemSize) | xQueueSend, xQueueReceive, ...FromISR |
| Binary semaphore | xSemaphoreCreateBinary() | xSemaphoreGive/Take, ...FromISR |
| Counting semaphore | xSemaphoreCreateCounting(max, initial) | same |
| Mutex | xSemaphoreCreateMutex() | xSemaphoreTake/Give (tasks only) |
| Recursive mutex | xSemaphoreCreateRecursiveMutex() | xSemaphoreTakeRecursive/GiveRecursive |
| Event group | xEventGroupCreate() | xEventGroupSetBits, xEventGroupWaitBits |
| Software timer | xTimerCreate(...) | xTimerStart/Stop/Reset/ChangePeriod |
| Task notification | (built into every task) | xTaskNotifyGive, ulTaskNotifyTake, xTaskNotify, xTaskNotifyWait |
| Stream/message buffer | xStreamBufferCreate(...) | Byte-stream or message passing, one writer and one reader |
16.10 Hooks (application callbacks into the kernel)
- Idle hook (
vApplicationIdleHook): runs in the idle task; must not block. - Tick hook (
vApplicationTickHook): runs in the tick interrupt; must be very short and useFromISRcalls only. - Malloc-failed hook (
vApplicationMallocFailedHook): heap exhausted. - Stack-overflow hook (
vApplicationStackOverflowHook): a stack overflow was detected. Treat it as fatal (log and reset).
16.11 Low power with FreeRTOS
- The idle task is the natural place to enter sleep (
WFI). - Tickless idle (
configUSE_TICKLESS_IDLE) stops the periodic tick during long idle periods and wakes at the next task deadline, saving power. Make sure wake-up sources and timing sources are configured properly for the sleep mode used.
16.12 A fuller example: producer, consumer, ISR, mutex, and timer together
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "semphr.h"
#include "timers.h"
static QueueHandle_t xDataQ;
static SemaphoreHandle_t xLogMutex;
static TimerHandle_t xHeartbeat;
static TaskHandle_t xRxTask;
/* ISR: capture and defer */
void UART_RX_IRQHandler(void)
{
BaseType_t xWoken = pdFALSE;
uint8_t b = uart_read_byte();
xQueueSendFromISR(xDataQ, &b, &xWoken);
portYIELD_FROM_ISR(xWoken);
}
/* High-priority task: handle received bytes */
static void vRxTask(void *p)
{
uint8_t b;
for (;;) {
if (xQueueReceive(xDataQ, &b, portMAX_DELAY) == pdTRUE) {
parse_byte(b);
}
}
}
/* Medium-priority periodic task using a mutex-protected shared resource */
static void vControlTask(void *p)
{
TickType_t last = xTaskGetTickCount();
for (;;) {
run_control_step();
if (xSemaphoreTake(xLogMutex, pdMS_TO_TICKS(10)) == pdTRUE) {
log_line("control step done");
xSemaphoreGive(xLogMutex);
}
vTaskDelayUntil(&last, pdMS_TO_TICKS(10));
}
}
/* Software timer callback: short and non-blocking */
static void vHeartbeatCb(TimerHandle_t t) { led_toggle(); }
int main(void)
{
hardware_init();
xDataQ = xQueueCreate(64, sizeof(uint8_t));
xLogMutex = xSemaphoreCreateMutex();
xHeartbeat = xTimerCreate("hb", pdMS_TO_TICKS(1000), pdTRUE, NULL, vHeartbeatCb);
configASSERT(xDataQ && xLogMutex && xHeartbeat); // catch creation failures early
xTaskCreate(vRxTask, "Rx", 256, NULL, 4, &xRxTask);
xTaskCreate(vControlTask, "Control", 256, NULL, 3, NULL);
xTimerStart(xHeartbeat, 0);
vTaskStartScheduler();
for (;;) { }
}
16.13 Debugging and diagnostics in FreeRTOS
- RTOS-aware debugging (task lists, states, stack usage in the IDE).
vTaskList()for a text table of tasks, states, priorities and stack watermarks;vTaskGetRunTimeStats()for CPU use per task (needs a run-time stats timer).- Trace tools to visualise scheduling, ISRs and blocking.
configASSERTto catch invalid API usage early (wrong interrupt priority, wrong call context).- Common hang hunt: check which objects each blocked task is waiting on.
16.14 FreeRTOS gotchas
- Calling non-
FromISRAPIs from an ISR (orFromISRAPIs from too-urgent interrupts). - Forgetting
portYIELD_FROM_ISR, which adds latency. - Treating stack depth as bytes instead of words.
- Using a binary semaphore as a lock (no priority inheritance).
- Creating objects after the scheduler starts without checking for allocation failure.
- Heap too small, or fragmented (
heap_2), so creation fails at run time. - Task function returning without deleting itself.
- Blocking in software-timer callbacks.
- Blocking or delaying in the idle hook.
- Misconfigured NVIC priority grouping or interrupt priorities.
- Using
vTaskDelayfor periodic timing, which drifts (usevTaskDelayUntil).
17. Cheat Sheet and Common Mistakes
Key concepts in one line each
- RTOS: a kernel that schedules tasks predictably so deadlines are met; "real-time" means on time, not necessarily fast.
- Task: independent infinite loop with its own stack and priority.
- States: Running, Ready, Blocked, Suspended.
- Preemption: the highest-priority Ready task always runs.
- Context switch: save one task's registers, restore another's (done by PendSV on Cortex-M).
- Time slicing: round-robin among equal priorities only.
- Binary semaphore: signalling. Counting semaphore: counting events/resources. Mutex: protecting resources, with priority inheritance.
- Queue: thread-safe FIFO that copies data and blocks with timeouts.
- Event group: a set of flags to wait on combinations/broadcast. Task notification: the fast, light, one-receiver signal.
- Software timer: callbacks run in the timer task, must never block.
- ISR rules: short,
FromISRAPIs only, pass the woken flag, yield at the end; respect the max syscall priority. - Priority inversion: a high task waits on a low task that a medium task starves; fixed by priority inheritance.
- Race condition: unsynchronised shared-data access; deadlock: circular waiting. Prevent with ownership, ordering and timeouts.
- Stack sizing: measure the high-water mark, add margin; stack depth in FreeRTOS is in words.
- Real-time analysis: know WCET, bound latency and blocking, keep utilisation well below 100%.
Quick API reference (FreeRTOS)
| Goal | Call |
|---|---|
| Create task | xTaskCreate(fn, "name", stackWords, arg, prio, &handle) |
| Periodic delay | vTaskDelayUntil(&last, pdMS_TO_TICKS(ms)) |
| Queue send/receive | xQueueSend(q,&item,ticks) / xQueueReceive(q,&item,ticks) |
| Queue from ISR | xQueueSendFromISR(q,&item,&woken) then portYIELD_FROM_ISR(woken) |
| Mutex lock/unlock | xSemaphoreTake(m,ticks) / xSemaphoreGive(m) |
| Signal from ISR | xSemaphoreGiveFromISR(s,&woken) or vTaskNotifyGiveFromISR(task,&woken) |
| Wait for notification | ulTaskNotifyTake(pdTRUE, portMAX_DELAY) |
| Event group wait | xEventGroupWaitBits(g, bits, clearOnExit, waitAll, ticks) |
| Software timer | xTimerCreate(...) then xTimerStart(t, 0) |
| Stack check | uxTaskGetStackHighWaterMark(NULL) |
| Short critical section | taskENTER_CRITICAL() / taskEXIT_CRITICAL() |
Choosing the right synchronisation tool
Need to pass DATA? β Queue (or stream/message buffer for byte streams)
Need to SIGNAL one task, fast & cheap? β Task notification
Need to signal/broadcast to several? β Event group / semaphore
Need to PROTECT a shared resource? β Mutex (priority inheritance)
Need to count events or pool resources? β Counting semaphore
Can ONE task own the resource instead? β Gatekeeper task + queue (no lock at all)
Common mistakes
- β Busy-waiting instead of blocking on an RTOS object.
- β High-priority task that never blocks, which starves everything below it.
- β Using a binary semaphore to protect a resource, causing priority inversion.
- β Holding a mutex during long operations or while waiting for something else.
- β Taking a mutex in an ISR, or calling non-
FromISRfunctions in ISRs. - β Interrupt priority more urgent than the max syscall priority but still calling RTOS APIs.
- β Stack too small, or confusing words with bytes; no overflow checking or high-water-mark review.
- β Large local arrays and
printfinside small-stack tasks. - β Sharing globals between tasks/ISRs with no protection, or believing
volatilealone is enough. - β Locking in inconsistent order, causing deadlocks; infinite waits (
portMAX_DELAY) where a timeout and recovery are needed. - β Ignoring return values of create/send/take calls; not handling queue-full or allocation failure.
- β Blocking inside software-timer callbacks or the idle hook.
- β Using
vTaskDelayfor exact periods, which drifts. - β Too many tasks or too many equal priorities, with needless context switching.
- β Planning for ~100% CPU utilisation with no margin for ISRs, kernel overhead and blocking.
- β Letting a task function return without deleting itself.
- β Not testing the worst case, such as every interrupt firing and error paths together.
- β No watchdog or health monitoring, so deadlocks in the field go unnoticed.
Mini design checklist
1. List activities; group them into tasks by timing, event source and ownership
2. Assign priorities by deadline/period (shorter = higher); leave gaps
3. Choose communication: queues for data, notifications/semaphores for signals,
mutexes (or gatekeepers) for shared resources
4. Keep ISRs minimal: capture β FromISR signal β yield
5. Verify interrupt priorities against the max syscall priority
6. Estimate WCET and CPU utilisation; account for ISR and kernel overhead
7. Size stacks by measurement + margin; enable overflow checking and configASSERT
8. Plan memory: heap scheme or static allocation; check every creation result
9. Prevent deadlock (lock order, timeouts) and bound priority inversion (mutexes)
10. Add a watchdog/health monitor; test worst-case load and fault paths
#Embedded Build Process and Linker#
Study notes: how your C code becomes a binary that actually runs on a microcontroller, and how to control and inspect every step of that journey.
Table of Contents
- The Big Picture
- Cross-Compilation and Target Toolchains
- Preprocessor, Compiler, Assembler and Linker
- Object Files, Libraries and Executable Formats
- ELF, HEX and BIN Files
- Linker Scripts and Memory Sections
- Startup Code and Vector Tables
- Boot Sequence and Initialization
- Static vs Dynamic Linking
- Compiler Optimization Levels
- Debug and Release Builds
- Map Files and Memory Footprint Analysis
- Build Systems: Make and CMake
- Cheat Sheet and Common Mistakes
A note on tools: examples use the GNU Arm Embedded toolchain (
arm-none-eabi-gcc) and a Cortex-M microcontroller, because that is the most common setup. Other toolchains (IAR, Keil/Arm Compiler, LLVM/Clang) follow the same ideas with different flag names and linker-script syntax.
1. The Big Picture
On a PC you press "Run" and the operating system loads your program. On a microcontroller there is no OS to load anything. Your build must produce a file that, once written into flash memory, is complete and self-starting: it must contain your code, your constant data, the initial values of your variables, and the tiny bit of startup code that makes the chip begin running your main().
.c / .h / .s source files
β
βΌ Preprocessor (expand #include, #define, #if)
preprocessed source
β
βΌ Compiler (C β assembly, with optimisation)
assembly (.s)
β
βΌ Assembler (assembly β machine code)
object files (.o) + libraries (.a)
β
βΌ Linker βββ linker script (.ld): memory layout
executable ELF (.elf) β contains code, data, symbols, debug info
β
βΌ objcopy
.hex / .bin βββΊ flashing tool / bootloader βββΊ MCU flash memory
Why this matters for firmware engineers: the build process is where you decide where each byte lives in memory, how big the firmware is, how fast it runs, how debuggable it is and whether it boots correctly. Most "mysterious" embedded bugs (crashes at startup, variables with wrong initial values, flash overflow, code that behaves differently in release builds) are explained by understanding this chapter.
2. Cross-Compilation and Target Toolchains
2.1 What is cross-compilation?
- Host: the computer you build on (a PC running Windows, Linux or macOS, usually x86-64 or Arm64).
- Target: the device the program will run on (e.g. an Arm Cortex-M4 microcontroller).
Native compilation: host = target (compiling a PC program on a PC). Cross-compilation: host β target. The compiler runs on your PC but produces machine code for a different CPU architecture.
A microcontroller can't run a compiler (too little memory, no OS, no file system), so embedded development is cross-compilation almost by definition.
2.2 What is a toolchain?
A toolchain is the collection of programs needed to turn source code into a target binary and to inspect/debug it:
| Tool | Role | GNU Arm example |
|---|---|---|
| Compiler | C/C++ to assembly or object code | arm-none-eabi-gcc, arm-none-eabi-g++ |
| Assembler | Assembly to machine code | arm-none-eabi-as |
| Linker | Combines object files into one executable | arm-none-eabi-ld (usually invoked via gcc) |
| Archiver | Packs object files into static libraries | arm-none-eabi-ar |
| Object copy | Converts between formats (ELF β HEX/BIN) | arm-none-eabi-objcopy |
| Object dump / disassembler | Inspect sections, disassemble code | arm-none-eabi-objdump |
| Size | Report memory usage by section | arm-none-eabi-size |
| Symbol lister | List symbols and their sizes/addresses | arm-none-eabi-nm |
| ELF reader | Detailed ELF inspection | arm-none-eabi-readelf |
| Debugger | Source-level debugging via a probe | arm-none-eabi-gdb |
| C library | Standard library implementation for bare metal | Newlib / Newlib-nano (or Picolibc) |
2.3 Reading the toolchain name (the "target triplet")
arm - none - eabi
β β β
β β βββ ABI: Embedded ABI (the calling/data conventions)
β ββββββββββ OS/vendor: "none" = bare metal, no operating system
βββββββββββββββββ Architecture: Arm
Other examples: arm-linux-gnueabihf (Arm Linux with hardware float), riscv32-unknown-elf (bare-metal RISC-V), xtensa-esp32-elf (ESP32).
2.4 Telling the compiler about your target
The compiler must know the exact CPU so it generates valid instructions and uses the right hardware features.
arm-none-eabi-gcc \
-mcpu=cortex-m4 \ # target core
-mthumb \ # use the Thumb instruction set (the only one Cortex-M supports)
-mfpu=fpv4-sp-d16 \ # which floating-point unit (if any)
-mfloat-abi=hard \ # how floats are passed: soft, softfp or hard
-c main.c -o main.o
| Flag | Meaning |
|---|---|
-mcpu= | The core: cortex-m0, cortex-m0plus, cortex-m3, cortex-m4, cortex-m7, cortex-m33 ... |
-mthumb | Generate Thumb/Thumb-2 code |
-mfpu= | Which FPU hardware is available (omit for cores without an FPU) |
-mfloat-abi=soft | No FPU instructions; floating-point done in software library calls |
-mfloat-abi=softfp | Uses FPU instructions, but passes float arguments in integer registers (compatible with soft code) |
-mfloat-abi=hard | Uses the FPU and passes floats in FPU registers (fastest, but all linked code and libraries must use the same ABI) |
Critical rule: every object file and library linked into the same program must be built with compatible
-mcpu/-mfpu/-mfloat-abisettings. Mixing them causes link errors or very strange runtime behaviour.
2.5 The C library on bare metal
There is no operating system, so functions like printf, malloc, fopen need a C library built for bare metal:
- Newlib is common, and Newlib-nano is a smaller-footprint version (selected with
--specs=nano.specs). - Picolibc is another small library alternative.
- Functions that need OS services (
_write,_read,_sbrk,_close,_exit...) are called syscall stubs. You either implement them yourself (e.g._writesends characters to a UART;_sbrkgrows the heap) or link a stub library:--specs=nosys.specs: provides empty stubs so the program links.--specs=rdimon.specs: semihosting (talks to the debugger host).
// minimal retargeting so printf goes out the UART
int _write(int fd, char *buf, int len)
{
for (int i = 0; i < len; i++) uart_send_byte(buf[i]);
return len;
}
Tip:
printfwith%fpulls in a lot of code in Newlib-nano. Float formatting inprintf/scanfis disabled by default in nano and must be enabled with linker options (-u _printf_float,-u _scanf_float), at a noticeable flash cost.
2.6 Toolchain selection concerns
- Version pinning: different compiler versions generate different code (and sometimes different bugs or warnings). Record the exact toolchain version for reproducible builds.
- Commercial vs open-source: IAR, Keil/Arm Compiler, GCC and LLVM/Clang all exist; differences appear in optimisation quality, code size, linker-script syntax, extensions (
#pragma, attributes) and certification (safety-qualified compilers for some industries). - Install location / PATH: build systems must find the cross-compiler, not the host compiler.
3. Preprocessor, Compiler, Assembler and Linker
arm-none-eabi-gcc is really a driver program that runs four separate stages in sequence. You can run each stage by hand to see what happens.
main.c βββΊ [1 Preprocess] βββΊ main.i βββΊ [2 Compile] βββΊ main.s βββΊ [3 Assemble] βββΊ main.o βββ
uart.c βββΊ ... uart.o βββΌβββΊ [4 Link] βββΊ app.elf
startup.s ββββββββββββββββββββββββββββββββββββββββββββββββββΊ [3 Assemble] βββΊ startup.o βββ€
libc.a / libm.a / libgcc.a ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| Stage | Command to stop here | Input β Output |
|---|---|---|
| Preprocess | gcc -E main.c -o main.i | .c β .i |
| Compile | gcc -S main.c -o main.s | .c β .s (assembly) |
| Assemble | gcc -c main.c -o main.o | .c/.s β .o |
| Link | gcc main.o uart.o ... -o app.elf | .o + libs β .elf |
3.1 The preprocessor
A text-substitution pass that runs before the compiler proper. It doesn't understand C; it only processes lines beginning with # and replaces macros.
| Directive | What it does |
|---|---|
#include <x.h> / #include "x.h" | Pastes the contents of a header file in place |
#define NAME value | Defines an object-like macro (text substitution) |
#define SQUARE(x) ((x)*(x)) | Function-like macro (always parenthesise arguments) |
#undef | Removes a macro |
#if, #ifdef, #ifndef, #else, #elif, #endif | Conditional compilation: include or exclude code |
#pragma | Compiler-specific hints |
#error, #warning | Force a message or a build stop |
__FILE__, __LINE__, __DATE__ | Predefined macros |
Typical embedded uses
// 1. Include guards (avoid double inclusion)
#ifndef UART_H
#define UART_H
/* ... declarations ... */
#endif
// 2. Selecting hardware variants / features at build time
#if defined(BOARD_V2)
#define LED_PIN 7
#else
#define LED_PIN 5
#endif
// 3. Debug-only code
#ifdef DEBUG
#define LOG(x) uart_puts(x)
#else
#define LOG(x) ((void)0)
#endif
// 4. Defining macros from the command line: gcc -DDEBUG -DBOARD_V2 ...
Pitfalls: macros have no type checking and no scope; multiple evaluation of arguments (SQUARE(i++)); missing parentheses; excessive #if nests make code untestable because only one variant is compiled at a time. Prefer static inline functions and const/enum where possible.
Include-search paths are set with -I<dir>.
3.2 The compiler
Translates each translation unit (one .c file plus everything it includes) independently into assembly/machine code. Roughly:
- Parsing / semantic analysis: syntax and type checking, with warnings and errors.
- Intermediate representation (IR) and optimisation: inlining, constant folding, dead-code removal, loop optimisations, register allocation (see section 10).
- Code generation: emit instructions for the target.
Key points:
- The compiler works on one file at a time. It doesn't know about other
.cfiles. When code calls a function defined elsewhere, it leaves a placeholder (relocation) for the linker to fill in. - Declarations in headers tell the compiler a symbol exists (so it can type-check); they don't provide the definition.
- Warnings are valuable. Use
-Wall -Wextra(and consider-Werroron CI). - Language standard:
-std=c11,-std=gnu11,-std=c++17etc.
3.3 The assembler
Converts assembly (generated by the compiler, or hand-written, e.g. in startup files) into machine code stored in an object file. Assembly files use .s or .S; capital .S is first run through the preprocessor, so you can use #include and #define in it.
3.4 The linker
The linker takes all the object files and libraries and produces one executable image. Its jobs:
| Job | Explanation |
|---|---|
| Symbol resolution | Matches each "I use function foo" reference to exactly one "I define foo" definition; reports undefined reference or multiple definition errors |
| Section merging | Combines all .text sections from all objects into one output .text, all .data together, etc. |
| Address assignment (placement) | Decides the final address of each section/symbol, following the linker script |
| Relocation | Patches every placeholder in the code with the final address |
| Library extraction | Pulls from static libraries only the object members that are actually needed |
| Garbage collection | Optionally discards unreferenced sections (--gc-sections) |
| Output generation | Writes the ELF file (and optional map file) |
Common link errors and meaning
| Error | Typical cause |
|---|---|
undefined reference to 'foo' | foo declared but never defined/linked (missing .c file, library, or wrong link order) |
multiple definition of 'x' | Variable defined (not just declared) in a header included by several .c files; or the same function in two files |
region 'FLASH' overflowed by N bytes | Code + constants + initial data don't fit |
region 'RAM' overflowed | Variables + heap + stack reservation don't fit |
cannot find entry symbol Reset_Handler | Startup file missing or wrong name |
... uses VFP register arguments, ... does not | Mismatched float ABI between objects/libraries |
3.5 Section-level garbage collection (important for size)
By default, the compiler groups all functions of a file into one .text section, so the linker can't drop one unused function. Enable per-function/per-variable sections and let the linker discard unused ones:
# compile:
-ffunction-sections -fdata-sections
# link:
-Wl,--gc-sections
Anything that must survive even if unreferenced (e.g. the interrupt vector table) is protected in the linker script with KEEP(...).
4. Object Files, Libraries and Executable Formats
4.1 Object files (.o): relocatable code
An object file is the output of compiling one translation unit. It contains machine code that is not yet placed at final addresses (it is relocatable). Typically it is in ELF format and contains:
| Content | Description |
|---|---|
| Sections | Chunks of code/data (see below) |
| Symbol table | Names defined here (exports) and names needed from elsewhere (imports) |
| Relocation entries | "At this offset, patch in the final address of symbol X" |
| Debug information | If compiled with -g: line numbers, types, variable locations |
4.2 The standard sections
| Section | Contents | Stored in flash? | Lives in RAM at run time? |
|---|---|---|---|
.text | Executable code (machine instructions) | Yes | No (executes in place from flash) |
.rodata | Read-only data: const variables, string literals, lookup tables | Yes | No (read from flash) |
.data | Initialised global/static variables (int x = 5;) | Yes (initial values) | Yes (working copy; startup copies flash β RAM) |
.bss | Uninitialised / zero-initialised globals/statics (int y;) | No (only its size is recorded) | Yes (startup zero-fills it) |
| Stack | Local variables, call frames | No | Yes (RAM region, managed at run time) |
| Heap | malloc-allocated memory | No | Yes (RAM region, managed at run time) |
int a = 10; // .data (value 10 stored in flash, copy in RAM)
int b; // .bss (zeroed at startup, costs RAM only)
const int c = 20; // .rodata (flash only)
static int d = 0; // .bss (zero-initialised β usually .bss)
const char msg[] = "hello"; // .rodata
void f(void) {
int local = 3; // stack (or register)
static int count; // .bss
int *p = malloc(16); // heap
}
Why
.datacosts both flash and RAM: the initial values must survive power-off (flash), but the variable must be writable (RAM). That's why startup code copies.datafrom flash to RAM, and why many initialised variables increase both numbers.
4.3 Symbols and linkage
| Concept | Meaning |
|---|---|
| Global symbol | Visible to other object files (default for non-static functions/variables) |
static (file scope) | Local to the translation unit (internal linkage); not exported |
extern | Declares that the symbol is defined elsewhere |
| Weak symbol | A definition that is overridden by any normal (strong) definition of the same name. Used for default interrupt handlers (__attribute__((weak))) |
| Undefined symbol | Referenced but not defined in this object |
4.4 Static libraries (.a)
A static library is simply an archive of object files, created with ar:
arm-none-eabi-ar rcs libdrivers.a uart.o spi.o i2c.o
At link time the linker pulls only the members that satisfy unresolved symbols; unused members stay out. Link-order consequences (classic trap):
# Libraries are searched in command-line order, resolving only symbols that are
# unresolved AT THAT POINT. Put objects first, libraries after, and dependent libs
# after the libs that use them:
gcc main.o uart.o -L. -ldrivers -lmiddleware -o app.elf
# For circular dependencies between libraries:
gcc main.o -Wl,--start-group -ldrivers -lmiddleware -Wl,--end-group -o app.elf
Standard libraries involved in bare-metal builds: libc (Newlib), libm (math), libgcc (compiler support routines such as 64-bit division, software float), libnosys/stubs.
4.5 Executable image vs. executable formats
| Kind | Used for | Notes |
|---|---|---|
| ELF (Executable and Linkable Format) | The linker's output; debugging; analysis | Rich: sections, symbols, addresses, debug info |
| Intel HEX | Programming/flash tools | Text format with addresses |
Motorola S-record (.srec/.s19) | Some programmers/bootloaders | Similar idea to HEX, different format |
Raw binary (.bin) | Bootloaders, OTA updates, simple programming | Just the bytes; no address information |
Vendor-specific (e.g. .axf, .out, .dfu, .uf2) | Particular IDEs/tools/bootloaders | Often ELF-based or wrappers around BIN/HEX |
The next section covers the three you'll use most.
5. ELF, HEX and BIN Files
5.1 ELF: the "master" file
The linker's native output. It contains everything and is the file you give to the debugger.
ELF file
βββ ELF header (architecture, entry point, table locations)
βββ Program headers (segments: what to load where; load addresses, sizes)
βββ Sections (.isr_vector, .text, .rodata, .data, .bss, ...)
βββ Symbol table (function/variable names, addresses, sizes)
βββ Debug sections (.debug_info, .debug_line ... for source-level debugging)
βββ Section header table
- Not what is written byte-for-byte into flash; it carries metadata the chip doesn't need.
- Debug information doesn't occupy target memory; it's only on the PC in the ELF file.
- Many flash tools (OpenOCD, vendor programmers, IDEs) can program an ELF directly, since the segment headers say where each part goes.
5.2 Intel HEX (.hex)
A text format listing data in records, each with an address. Because addresses are included, HEX can describe non-contiguous regions (e.g. a bootloader at one address and an application at another, with a gap).
Record structure:
: LL AAAA TT DD...DD CC
β β β β β ββ checksum (two's complement of the sum of all bytes)
β β β β βββββββββββ data bytes
β β β βββββββββββββββ record type
β β βββββββββββββββββββββ 16-bit address (offset)
β βββββββββββββββββββββββββ number of data bytes
ββββββββββββββββββββββββββββ start code
| Record type | Meaning |
|---|---|
00 | Data |
01 | End of file |
02 | Extended segment address |
04 | Extended linear address (upper 16 bits of a 32-bit address; used for ARM) |
05 | Start linear address (entry point) |
Example data record and end-of-file record:
:10010000214601360121470136007EFE09D2190140
:00000001FF
(16 data bytes at offset 0x0100, then the EOF record.)
Pros: includes addresses; human-readable; has a built-in checksum per line; widely supported by programmers. Cons: about 2.5Γ larger than raw bytes (text encoding); no debug info.
5.3 BIN (.bin)
A raw memory image: the exact bytes to be placed in memory starting from a base address, with no address information at all.
- The start address is implied (you tell the programmer or bootloader where to write it, e.g.
0x08000000). - If your image has gaps between sections (e.g. flash code and a separate region far away), the BIN file pads the gap with filler bytes and can become very large.
- Smallest, simplest format, used for bootloaders, over-the-air updates, file-based flashing (e.g. drag-and-drop) and CRC/signature computation.
5.4 Converting with objcopy
arm-none-eabi-objcopy -O ihex app.elf app.hex
arm-none-eabi-objcopy -O binary app.elf app.bin
arm-none-eabi-objcopy -O srec app.elf app.srec
# Only some sections (e.g. exclude a section you don't want in flash):
arm-none-eabi-objcopy -O binary -R .eeprom app.elf app.bin
Related inspection commands:
arm-none-eabi-objdump -h app.elf # list sections with sizes and addresses
arm-none-eabi-objdump -d app.elf # disassemble code
arm-none-eabi-readelf -a app.elf # full ELF details
arm-none-eabi-nm --size-sort -S app.elf # symbols sorted by size
arm-none-eabi-size app.elf # text/data/bss summary
5.5 Comparison
| ELF | HEX | BIN | |
|---|---|---|---|
| Contains addresses | Yes | Yes (per record) | No (implied by you) |
| Contains symbols / debug info | Yes | No | No |
| Supports non-contiguous regions | Yes | Yes | Only by padding |
| Human-readable | No (binary) | Yes (text) | No |
| Built-in integrity check | No | Per-line checksum | None (add CRC yourself) |
| Size | Largest | Medium (~2.5Γ BIN) | Smallest |
| Typical use | Debugging, analysis, many flash tools | Production programming, multi-region images | Bootloaders, OTA, simple flashing |
5.6 Related practicalities
- Embedding a CRC/checksum or version in the image: compute over the final BIN/HEX and patch it in (post-build step) or have the bootloader check it.
- Combining images (bootloader + application + configuration) into one programming file: use HEX merge tools (e.g.
srec_cat) or scripts, taking care with address ranges. - Which file to flash and which to debug: flash HEX/BIN (or ELF), but always keep the matching ELF of every released firmware; without it you can't symbolise crash addresses later.
6. Linker Scripts and Memory Sections
6.1 What a linker script does
A linker script (.ld file) tells the linker:
- What memories exist on this chip (name, start address, size, attributes)
- Which input sections go into which output sections
- Where each output section is placed (in which memory, in what order)
- Which symbols to define (start/end addresses of sections, stack top, etc.) so that code such as the startup routine can use them
It encodes the memory map of your product. The same C code can be placed completely differently by changing only the linker script.
6.2 Two key concepts: VMA and LMA
| Term | Meaning |
|---|---|
| VMA (Virtual Memory Address) | The address a section has when the program runs (where the code expects to find it) |
| LMA (Load Memory Address) | The address where the section is stored in the image/flash |
For most sections (.text, .rodata) VMA = LMA (they run from flash). For .data they differ: it is stored in flash (LMA) but lives in RAM (VMA). The linker syntax >RAM AT> FLASH expresses exactly that, and the startup code copies from LMA to VMA.
6.3 Main parts of a linker script
/* 1. Entry point (for debuggers; hardware actually uses the vector table) */
ENTRY(Reset_Handler)
/* 2. Memory regions */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
}
/* 3. Symbols and constants */
_estack = ORIGIN(RAM) + LENGTH(RAM); /* initial stack pointer: top of RAM */
_Min_Heap_Size = 0x400;
_Min_Stack_Size = 0x800;
/* 4. Output sections */
SECTIONS
{
/* Vector table must come first in flash */
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector)) /* KEEP = never discard, even with --gc-sections */
. = ALIGN(4);
} >FLASH
/* Code and read-only data */
.text :
{
. = ALIGN(4);
*(.text)
*(.text*)
KEEP(*(.init))
KEEP(*(.fini))
. = ALIGN(4);
_etext = .;
} >FLASH
.rodata :
{
. = ALIGN(4);
*(.rodata)
*(.rodata*)
. = ALIGN(4);
} >FLASH
/* Constructor/init tables (used by C++ and __attribute__((constructor))) */
.init_array :
{
PROVIDE_HIDDEN(__init_array_start = .);
KEEP(*(SORT(.init_array.*)))
KEEP(*(.init_array*))
PROVIDE_HIDDEN(__init_array_end = .);
} >FLASH
/* Where .data's initial values are stored (its LMA) */
_sidata = LOADADDR(.data);
/* Initialised data: runs in RAM, stored in FLASH */
.data :
{
. = ALIGN(4);
_sdata = .; /* start of .data in RAM */
*(.data)
*(.data*)
. = ALIGN(4);
_edata = .; /* end of .data in RAM */
} >RAM AT> FLASH
/* Zero-initialised data */
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss)
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} >RAM
/* Reserve heap and stack space so the linker reports overflow */
._user_heap_stack :
{
. = ALIGN(8);
PROVIDE(end = .);
PROVIDE(_end = .); /* used by the _sbrk syscall to find heap start */
. = . + _Min_Heap_Size;
. = . + _Min_Stack_Size;
. = ALIGN(8);
} >RAM
}
6.4 Linker-script building blocks explained
| Element | Meaning |
|---|---|
MEMORY { NAME (attrs) : ORIGIN = ..., LENGTH = ... } | Declares memory regions. Attributes: r read, w write, x execute |
SECTIONS { ... } | Defines output sections and what goes into them |
. (dot) | The location counter: the current address being assigned |
*(.text*) | "Take every input section matching .text* from all input files" |
file.o(.text) | Take that section from one specific file only |
>FLASH | Place this output section in the FLASH region (VMA) |
AT> FLASH | Store the section's load image (LMA) in FLASH |
ALIGN(n) | Round the location counter up to a multiple of n |
KEEP(...) | Protect from --gc-sections removal |
PROVIDE(sym = ...) | Define a symbol only if nothing else defines it |
LOADADDR(.data) | The LMA of the .data section |
SORT(...) | Sort matching input sections by name (used for init arrays) |
ENTRY(sym) | Specifies the program entry symbol |
ASSERT(cond, "msg") | Fail the link if a condition is false |
6.5 How code uses linker-script symbols
Symbols defined in the linker script are addresses, not variables. In C you declare them as extern variables and take their address (&sym):
extern uint32_t _sidata; // load address of .data in flash
extern uint32_t _sdata; // start of .data in RAM
extern uint32_t _edata; // end of .data in RAM
extern uint32_t _sbss, _ebss;
uint32_t *src = &_sidata; // use the ADDRESS, not the value stored there
uint32_t *dst = &_sdata;
This is the glue between the linker script and the startup code.
6.6 Typical memory regions on real MCUs
Different MCUs have several RAMs and special memories, and the script can place things deliberately:
| Region | Typical use |
|---|---|
| Flash | Code, constants, .data initial image |
| Main SRAM | General variables, stack, heap |
| CCM / DTCM (tightly coupled memory) | Fast, zero-wait-state data (e.g. stack or time-critical variables); often not reachable by DMA |
| ITCM | Fast code execution (time-critical routines) |
| Backup SRAM / retention RAM | Survives low-power modes or resets |
| External RAM / QSPI flash | Large buffers, assets, execute-in-place code |
| EEPROM / data flash | Non-volatile settings (often handled via a separate section/address) |
6.7 Placing things in custom sections
In C, tag a function or variable with a section attribute:
__attribute__((section(".ccm_data"))) uint32_t fast_buffer[256];
__attribute__((section(".ramfunc"))) void time_critical_isr(void) { ... }
__attribute__((section(".config"))) const config_t default_config = { ... };
__attribute__((used)) /* keep even if unreferenced */
__attribute__((aligned(32))) uint8_t dma_buf[256];
In the linker script, give that section a home:
MEMORY { CCMRAM (xrw) : ORIGIN = 0x10000000, LENGTH = 64K /* ... */ }
.ccm_data (NOLOAD) : /* NOLOAD = reserve space only, not stored in flash */
{
. = ALIGN(4);
*(.ccm_data)
. = ALIGN(4);
} >CCMRAM
For code that must run from RAM (e.g. flash-programming routines, or ultra-fast ISRs), the section is stored in flash (LMA) and copied to RAM (VMA) at startup, just like .data: needs >RAM AT> FLASH plus a copy loop in startup.
6.8 Common linker-script uses
| Goal | Technique |
|---|---|
| Bootloader + application | Two scripts with different ORIGIN/LENGTH for FLASH (e.g. bootloader at 0x08000000, app at 0x08008000) |
| Reserve a flash page for settings | Shrink the code FLASH region and define a separate CONFIG region |
| Place a firmware version/CRC at a fixed address | Dedicated section at a known location |
| Put critical code in fast memory | Custom section into ITCM/RAM |
| Catch overflows at link time | Accurate LENGTH, plus reserved heap/stack, plus ASSERT |
| Memory-map shared with a bootloader | A fixed, shared RAM section (NOLOAD) that survives reset for passing data |
| Alignment for DMA/cache | ALIGN() and aligned attributes |
6.9 Stack and heap in the layout
High addresses
βββββββββββββββββββββ βββ _estack (top of RAM; initial stack pointer)
β STACK β grows DOWNWARD
β β β
β β
β (free space) β
β β
β β β
β HEAP β grows UPWARD (via _sbrk)
βββββββββββββββββββββ€ βββ _end
β .bss β
βββββββββββββββββββββ€
β .data β
βββββββββββββββββββββ βββ start of RAM
Low addresses
If the stack and heap collide, or the stack overflows into .bss/.data, you get silent corruption. Reserving _Min_Heap_Size and _Min_Stack_Size in the linker script causes the linker to fail if static RAM leaves too little room, which catches only the static part of the problem. The actual run-time use still needs measurement.
7. Startup Code and Vector Tables
7.1 Why startup code exists
When the CPU comes out of reset, nothing has been initialised: RAM contains random data, global variables are garbage, the C library isn't set up, and the clock is at its slow default. main() assumes all of that is ready. The startup code is the small piece that runs before main() and makes the C environment valid.
It's usually a file called startup_<chip>.s (assembly) or startup_<chip>.c, supplied by the vendor or toolchain.
7.2 The vector table
On ARM Cortex-M, the vector table is an array of 32-bit words located at the start of the code memory. The CPU hardware reads it directly:
| Index (word) | Contents | Notes |
|---|---|---|
| 0 | Initial stack pointer (MSP) value | Loaded into SP at reset |
| 1 | Reset handler address | CPU jumps here at reset (Thumb bit set) |
| 2 | NMI handler | Non-maskable interrupt |
| 3 | HardFault handler | Serious faults |
| 4 | MemManage fault | (cores with MPU) |
| 5 | BusFault | (Cortex-M3 and above) |
| 6 | UsageFault | (Cortex-M3 and above) |
| 7 to 10 | Reserved | |
| 11 | SVCall handler | Supervisor call (used by RTOS) |
| 12 | Debug monitor | |
| 13 | Reserved | |
| 14 | PendSV handler | Pending service call (RTOS context switch) |
| 15 | SysTick handler | System tick timer |
| 16 and above | External interrupts IRQ0, IRQ1, ... | Vendor/peripheral-specific (UART, timers, DMA...) |
When an interrupt occurs, the hardware looks up the handler address at vector_table[exception_number] and jumps there.
Thumb bit: function addresses stored in the table have bit 0 set (
address | 1) to indicate Thumb state. The toolchain handles this automatically when you put function pointers in the table.
7.3 VTOR: relocating the vector table
At reset the table is taken from the fixed boot address, but most Cortex-M cores (M3/M4/M7/M33, and optionally M0+) have a Vector Table Offset Register (SCB->VTOR) that points to where the CPU looks for the table. Uses:
- Bootloader β application hand-over: the application has its own vector table at a different flash address and sets VTOR to it.
- Moving the table to RAM, to change handlers at run time.
- The table must be aligned (to a power of two large enough for the number of vectors, as per the core's documentation).
7.4 Example vector table in C
extern uint32_t _estack; // from linker script
void Reset_Handler(void);
void Default_Handler(void);
// Weak aliases: any real handler of the same name overrides these defaults
void NMI_Handler(void) __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void) __attribute__((weak, alias("Default_Handler")));
void SVC_Handler(void) __attribute__((weak, alias("Default_Handler")));
void PendSV_Handler(void) __attribute__((weak, alias("Default_Handler")));
void SysTick_Handler(void) __attribute__((weak, alias("Default_Handler")));
void USART1_IRQHandler(void) __attribute__((weak, alias("Default_Handler")));
/* ... one per interrupt your chip has ... */
__attribute__((section(".isr_vector"), used))
void (* const vector_table[])(void) = {
(void (*)(void))&_estack, // 0: initial stack pointer
Reset_Handler, // 1: reset
NMI_Handler, // 2
HardFault_Handler, // 3
0, 0, 0, 0, 0, 0, 0, // 4..10: (memory/bus/usage faults or reserved)
SVC_Handler, // 11
0, 0, // 12, 13
PendSV_Handler, // 14
SysTick_Handler, // 15
/* external interrupts start here */
USART1_IRQHandler, // 16 + n ...
};
void Default_Handler(void) // unexpected interrupt: trap it so you can debug
{
while (1) { }
}
Why weak aliases are useful: every interrupt vector always has some valid handler (so a stray interrupt doesn't jump to garbage), and you define a real handler simply by writing a function with the exact same name (e.g. void USART1_IRQHandler(void) { ... }), with no registration needed. A misspelled handler name silently falls back to the default handler, a very common bug.
7.5 The Reset handler
extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss;
extern int main(void);
extern void SystemInit(void);
extern void __libc_init_array(void);
void Reset_Handler(void)
{
// 1. Copy initialised data from flash (LMA) to RAM (VMA)
uint32_t *src = &_sidata;
uint32_t *dst = &_sdata;
while (dst < &_edata) {
*dst++ = *src++;
}
// 2. Zero the .bss section
for (dst = &_sbss; dst < &_ebss; ) {
*dst++ = 0;
}
// 3. Low-level system init (clocks, FPU enable, VTOR, ...)
SystemInit();
// 4. Run C library init and static constructors (C++ globals, __attribute__((constructor)))
__libc_init_array();
// 5. Call the application
main();
// 6. If main ever returns, trap here
while (1) { }
}
7.6 What each step accomplishes
| Step | Why it's needed |
|---|---|
| Stack pointer set (by hardware) | Function calls need a stack before any C code runs |
Copy .data | So initialised globals have their declared initial values |
Zero .bss | C guarantees uninitialised globals/statics start at 0 |
SystemInit() | Sets clock tree/PLL, enables the FPU (if used), configures the vector table offset, flash wait states, etc. Often vendor-supplied (CMSIS) |
__libc_init_array() | Calls the C library setup and the function pointers in .init_array (C++ static object constructors) |
main() | Your application starts |
Trap after main | Embedded main should never return; if it does, don't run into random memory |
Gotcha: code that runs before
.data/.bssare initialised (e.g. a very earlySystemInit()that uses global variables) can't rely on those variables. Keep very-early code free of globals, or do the copy/zero first.
7.7 Other startup responsibilities (when needed)
- Enable the FPU (set CPACR bits) before any floating-point instruction runs; otherwise the first float instruction triggers a UsageFault.
- Set up the stack limit / MPU regions or enable caches on Cortex-M7 class parts.
- Initialise external RAM early if variables or the stack live there.
- Copy RAM-executed code (
.ramfunc) from flash to RAM. - Fill the stack with a known pattern to allow measuring maximum stack use later.
- Select the clock before using timing-dependent peripherals.
- Read and save the reset cause before anything clears it.
8. Boot Sequence and Initialization
8.1 From power-on to main(): the whole chain
Power applied
β
βΌ
β Power-on reset / brown-out logic holds the CPU in reset until supply is stable
β
βΌ
β‘ Internal oscillator starts (slow default clock, e.g. 8β16 MHz RC)
β
βΌ
β’ Boot mode selection (boot pins / option bytes / fuses):
choose where code comes from β user flash | system bootloader (ROM) | SRAM | external memory
β
βΌ
β£ CPU hardware fetches: SP β word at boot address (vector 0)
PC β word at boot address + 4 (vector 1 = Reset_Handler)
β
βΌ
β€ Reset_Handler runs: copy .data, zero .bss
β
βΌ
β₯ SystemInit(): clocks/PLL, flash wait states, FPU, VTOR
β
βΌ
β¦ C library init, static constructors
β
βΌ
β§ main(): initialise HAL/BSP, drivers, RTOS; start application
8.2 Boot modes (typical MCU concepts)
| Boot source | Description |
|---|---|
| Main flash | Normal operation; runs your firmware |
| System memory / ROM bootloader | Factory-programmed loader in ROM, used to program the chip over UART/USB/CAN/IΒ²C/SPI without a debug probe |
| Embedded SRAM | Run code from RAM (useful for debugging/testing or special recovery) |
| External memory | On some parts, boot from external flash/QSPI |
The boot address is aliased to address 0: the hardware maps the chosen boot memory so that "address 0" (where the Cortex-M reads its initial stack pointer and reset vector) shows that memory. Exact pin/option names and addresses are chip-specific; always check the reference manual.
8.3 Reset causes
An MCU can restart for many reasons, and the reset-cause flags (in a reset/status register) tell you which one: power-on, brown-out, external reset pin, software reset, watchdog (independent/window), low-power wake reset. Read and store them early (then clear them), because they're valuable for diagnostics and recovery decisions.
8.4 Multi-stage boot: bootloader + application
Many products have two (or more) firmware images:
Flash memory map
0x0800_0000 βββββββββββββββββββ
β Bootloader β small, rarely updated, always runs first
0x0800_8000 βββββββββββββββββββ€
β Application β main firmware (updated in the field)
β β
βββββββββββββββββββ€
β Config / data β settings, calibration
βββββββββββββββββββ
Bootloader responsibilities: check whether a valid application exists (CRC/signature/magic number), optionally receive and write a new firmware image (UART, USB, CAN, wireless/OTA), then hand over control to the application.
Jumping to the application (the standard sequence):
typedef void (*app_entry_t)(void);
void jump_to_application(uint32_t app_address)
{
uint32_t app_sp = *(volatile uint32_t *)(app_address); // vector 0
uint32_t app_reset = *(volatile uint32_t *)(app_address + 4); // vector 1
// Sanity-check the application: stack pointer should point into RAM, etc.
if ((app_sp & 0x2FFE0000u) != 0x20000000u) return;
__disable_irq(); // no interrupts during the switch
// disable SysTick and pending interrupts, de-initialise peripherals used by the bootloader
SCB->VTOR = app_address; // application's vector table
__set_MSP(app_sp); // application's initial stack pointer
((app_entry_t)app_reset)(); // jump to the application's Reset_Handler (never returns)
}
What the application's build must change: its linker script's FLASH ORIGIN must be the app's address (e.g. 0x08008000), and its vector-table offset must be set to match (VTOR, usually done in SystemInit() via a VECT_TAB_OFFSET-style setting). If you forget either, interrupts jump to the wrong handlers and the system crashes.
8.5 Initialization order inside your application
After main() begins, a sensible order is:
- Early hardware safety: make outputs safe, read reset cause, start/configure the watchdog if required.
- Clocks and power (if not already done by
SystemInit). - Pins/GPIO and board support (BSP init).
- Core peripherals/drivers (UART for logging, timers).
- Memory/storage (flash/EEPROM config load, integrity check).
- Middleware and services (file system, protocol stack).
- RTOS start (create tasks/queues, then start the scheduler) or the superloop.
- Application logic.
8.6 Initialization pitfalls
- Static initialisation order of C++ globals across files is undefined. Don't make one global's constructor depend on another file's global. Prefer explicit
init()functions. - Constructors that touch hardware run before
main()(via__libc_init_array), possibly before clocks are configured. - Interrupts firing before their handlers' data is ready. Enable interrupts only after the related state is initialised.
- Clock speed assumptions: delays and baud-rate calculations depend on the actual clock after
SystemInit. - Heavy initialisation delaying startup (and watchdog timeouts during it).
- A bootloader leaving peripherals/interrupts enabled that confuse the application. De-initialise before the jump.
9. Static vs Dynamic Linking
9.1 Static linking
All needed code from your objects and libraries is copied into one self-contained executable at link time. The final addresses are fixed when you build.
main.o + uart.o + libc.a members + libgcc.a members ββ[ link ]βββΊ app.elf (everything inside)
9.2 Dynamic linking
The executable contains references to shared libraries (.so on Linux, .dll on Windows) that are loaded and connected at load time or run time by an OS component (the dynamic loader). Several programs can share a single copy of the library in memory, and the library can be updated without relinking the program.
app (references to libfoo.so) ββ[ OS loader at launch ]βββΊ maps libfoo.so, resolves addresses (GOT/PLT)
Dynamic linking relies on: an operating system with a loader, a file system, virtual memory/MMU (typically), position-independent code (PIC, -fPIC), and indirection tables such as the GOT/PLT to resolve addresses at run time.
9.3 Comparison
| Aspect | Static linking | Dynamic linking |
|---|---|---|
| When library code is bound | Build time | Load/run time |
| Executable contents | Self-contained | Needs shared libraries available at run time |
| Size of one executable | Larger | Smaller |
| Memory across many programs | Duplicated per program | Shared between programs |
| Startup | Fast, simple, deterministic | Loader work at start; extra indirection |
| Run-time speed | Direct calls | Slight overhead (PLT/GOT indirection) |
| Updating a library | Relink and reflash the whole firmware | Replace the shared library only |
| Dependencies / "version hell" | None at run time | Possible mismatches |
| OS requirement | None | Needs an OS with a dynamic loader |
| Predictability / determinism | High | Lower |
9.4 What embedded systems use
- Microcontrollers (bare-metal and most RTOS firmware): static linking. There is typically no loader, no file system, limited RAM and flash, and you want deterministic, self-contained, certifiable firmware. The entire image (application + RTOS + drivers + C library) is one statically linked ELF.
- Embedded Linux (Cortex-A class, SoCs): dynamic linking is normal (glibc/musl, shared libraries), although static linking is sometimes chosen for tiny or single-purpose systems.
- Middle ground on MCUs: some systems support loadable modules/plug-ins or position-independent, relocatable code, using an RTOS-provided or custom loader. These are advanced and rare. More common is splitting bootloader + application images that share only a small, fixed interface (such as a function-pointer table at a known address).
9.5 Static-linking practicalities for firmware
- Static libraries (
.a) are still "static linking": members get copied into your image at link time. - Reduce footprint:
-ffunction-sections -fdata-sections -Wl,--gc-sections, link only what's needed, use Newlib-nano. - Link-time optimisation (
-flto) lets the compiler optimise across files at link time (see section 10). - Licensing note: statically linking third-party code means its licence terms apply to the combined firmware, so check licences (a real concern with some open-source licences).
- Binary size accountability: since everything is inside, a surprising size increase is almost always traceable to a particular library function (see map file section).
10. Compiler Optimization Levels
10.1 What the optimiser does
Given the same source, the compiler can generate very different machine code. The optimiser transforms the program to make it faster, smaller or easier to debug, while preserving the behaviour defined by the C language.
Common transformations:
| Optimisation | Effect |
|---|---|
| Constant folding / propagation | Computes 3*4 at compile time |
| Dead-code elimination | Removes code that can't affect results |
| Inlining | Replaces a call with the function body (speed up, size may grow) |
| Loop unrolling / strength reduction | Fewer loop overheads, cheaper operations |
| Register allocation | Keeps variables in CPU registers instead of memory |
| Common-subexpression elimination | Avoids recomputing the same value |
| Instruction scheduling | Reorders instructions for the pipeline |
| Tail-call optimisation | Reuses stack frames for tail calls |
| Function merging / identical code folding | Shrinks size |
10.2 GCC/Clang optimisation levels
| Flag | Goal | Notes |
|---|---|---|
-O0 | No optimisation (default) | Fastest compile; code mirrors your source; easiest debugging; largest and slowest code |
-O1 | Basic optimisations | Moderate size/speed gain; fast compile |
-O2 | Most optimisations that don't trade size for speed heavily | Good general-purpose "release" choice |
-O3 | -O2 plus aggressive optimisation (more inlining, unrolling, vectorisation) | Fastest code, often larger; not always faster on small MCUs (flash wait states/caches) |
-Os | Optimise for size | Very common for MCUs with limited flash; may also be faster than -O2 when code fits in cache better |
-Og | Optimise for debugging experience | Light optimisation that keeps debugging mostly sane; recommended for debug builds |
-Oz (Clang) | Even smaller than -Os | Aggressive size reduction (GCC has no -Oz; it uses -Os) |
-Ofast | -O3 plus unsafe math (-ffast-math) | Breaks strict floating-point/IEEE behaviour; use with care |
10.3 Link-time optimisation (LTO)
-flto defers some optimisation to link time, when the compiler can see all files together, enabling cross-file inlining and dead-code removal. It often reduces size noticeably. Cautions: slower builds, harder debugging, and occasional surprises with startup code, interrupt handlers or section attributes (mark things used/KEEP appropriately).
10.4 Size vs. speed trade-offs on MCUs
- Flash size is a hard limit:
-Osis often the practical default. - Speed may be limited by flash wait states, caches or ART/prefetch features, so smaller code can sometimes run faster because it fits the cache.
- Apply different optimisation levels per file or per function when needed:
__attribute__((optimize("O3"))) void hot_loop(void) { ... } // speed-critical routine
__attribute__((optimize("Os"))) void rarely_used(void) { ... }
// and per-file flags in the build system
(Use function-level optimize attributes sparingly; behaviour can vary between compiler versions.)
10.5 How optimisation can expose or break embedded code
Optimisation follows the C language rules, not your assumptions about hardware. Typical problems when code that "works at -O0" fails at -O2:
| Issue | What happens | Fix |
|---|---|---|
Missing volatile | The compiler caches a value in a register, or removes "redundant" reads. A polling loop while(!flag); becomes an infinite loop or is optimised away | Declare hardware registers and ISR-shared variables volatile |
| Busy-wait delay loops | for(i=0;i<1000000;i++); is deleted because it has no effect | Use hardware timers, or make the loop variable volatile (still not accurate) |
| Undefined behaviour (UB) | Signed overflow, out-of-bounds access, uninitialised variables, strict-aliasing violations: the optimiser may assume UB never happens and generate surprising code | Fix the bug; use -Wall -Wextra; consider -fno-strict-aliasing only as a workaround |
| Reordering of memory accesses | Compiler/CPU reorders reads/writes around peripheral accesses or DMA setup | Use volatile, memory barriers (__DSB(), __DMB(), __ISB(), compiler barriers) |
| Inlining or removal of "unused" functions | A function only called from assembly or via a vector table gets dropped | __attribute__((used)), KEEP() in the linker script |
| Stack usage changes | Inlining and different register allocation change stack frame sizes | Re-measure stack usage for each build configuration |
| Timing changes | Code runs faster/slower, exposing race conditions or removing accidental delays | Don't depend on code speed for correctness |
| Alignment/packed struct issues | Optimised code assumes alignment rules | Use proper memcpy/packed-access techniques |
Principle: if a bug appears only with optimisation, assume your code has a bug (usually
volatile, UB or a race) before suspecting the compiler.
10.6 Choosing levels in practice
| Situation | Typical choice |
|---|---|
| Day-to-day development, debugging | -Og (or -O0 if you need fully literal code) |
| Production, flash-limited | -Os (optionally with LTO) |
| Production, performance-critical | -O2 (or selective -O3) |
| DSP/number-crunching hot paths | -O3 on specific files, measured |
| Safety-critical | Follow your standard (some require fixed, validated optimisation levels and identical flags for test and release builds) |
Always test the exact build you ship. A release build can differ in behaviour from the debug build you tested.
11. Debug and Release Builds
11.1 Two configurations of the same code
| Debug build | Release build | |
|---|---|---|
| Purpose | Development, step-through debugging, finding bugs | Shipping to production/customers |
| Optimisation | -O0 or -Og | -Os / -O2 (+ LTO optionally) |
| Debug symbols | -g (or -g3 for macros too) | Often still produced (for post-mortem analysis), but not loaded onto the target |
| Assertions / extra checks | Enabled (assert, argument checks) | Often disabled via NDEBUG (or deliberately kept, as policy) |
| Logging | Verbose (UART/RTT/semihosting) | Minimal or off |
| Code size / speed | Larger and slower | Smallest/fastest |
| Watchdog | Often disabled/frozen while halted | Enabled |
| Read-out/debug-port protection | Open | Often locked down |
| Warnings | Treated seriously, often -Werror | Same (clean builds expected) |
11.2 Common flags and defines
# Debug
-O0 -g3 -DDEBUG
# (or -Og -g3 -DDEBUG)
# Release
-Os -DNDEBUG # no -g needed on target, but see 11.4 about keeping symbols
# often: -Os -g -DNDEBUG (symbols stay in the ELF only; target flash image unchanged)
# Warnings for both
-Wall -Wextra -Wshadow -Wconversion (and -Werror in CI)
| Define | Meaning |
|---|---|
DEBUG | Your own macro: enable debug-only code/logging |
NDEBUG | Standard macro: disables assert() |
-DVERSION_STRING=..., build date/commit hash | Embed version info for traceability |
11.3 Important point: -g does not make the firmware bigger on the chip
Debug information lives in ELF sections (.debug_*) that are not loaded into the target's memory. Converting to HEX/BIN strips them. So -g increases the ELF file size but not the flashed size. What does change flash size between builds is optimisation level, assertions, logging strings, and DEBUG code.
11.4 Keep symbols for released firmware
Always archive the ELF (with debug info) matching each released binary. Without it, a crash report containing only addresses (e.g. a hard-fault PC value) can't be mapped to source lines. A good release process stores:
- The
.elf(with symbols),.map, and.hex/.binfor each release - The exact toolchain version, build flags and source revision (tag/commit)
- Optionally a stripped copy for distribution (
arm-none-eabi-strip)
11.5 Debugging optimised (release) code
- Variables may be "optimized out", and execution order may look jumbled; single-stepping jumps around.
- Use
-Ogor a mixed config likeRelWithDebInfo(optimised with symbols) to debug closer to production behaviour. - Use logging, trace (ITM/SWO, RTT), hardware breakpoints/watchpoints, and fault-handler dumps that are cheap enough to keep in release builds.
- Compare the disassembly (
objdump -d) when something looks wrong.
11.6 "Works in debug, fails in release" checklist
- Missing
volatileon hardware registers or ISR-shared data - Timing: debug builds are slower, so races and timeouts behave differently
- Undefined behaviour exposed by optimisation
- Uninitialised variables (debug builds may zero or fill stack differently)
- Assertions removed (
NDEBUG) along with side-effects hidden insideassert(...); never put required code inside anassert - Stack usage differences, causing overflow in one configuration only
- Different watchdog behaviour (frozen in debug)
- Code or data placement differences (alignment, section placement) exposing latent bugs
- Debugger masks problems (it may initialise or hold registers, keep clocks running in sleep)
- Different compiler flags, library versions or defines between the two configurations
11.7 Build hygiene and reproducibility
- Warnings as errors in CI keeps code quality high.
- Static analysis (compiler analyzers, cppcheck, clang-tidy, MISRA checkers) complements compilation.
- Reproducible builds: pin the toolchain version, avoid embedding timestamps unless intended, and keep the build environment scripted (containers help).
- Version stamping: embed version number, build ID or commit hash into the image (e.g. a dedicated
.versionsection or constant) so that field units are traceable. - Post-build steps: generate HEX/BIN, compute and insert CRC/signature, print the size summary.
- Separate configurations (Debug, Release, sometimes Test/Production/Factory) should be defined in the build system, not by hand-editing flags.
12. Map Files and Memory Footprint Analysis
12.1 What is a map file?
A map file is a text report the linker produces describing exactly what it put where: every section, every symbol's address and size, which object file contributed what, and which input sections were discarded. It's the best tool for answering "where did my flash and RAM go?".
Generate it with:
-Wl,-Map=build/app.map # GCC: pass -Map to the linker
-Wl,--cref # also add a cross-reference table of symbols
-Wl,--print-memory-usage # print a FLASH/RAM usage summary at the end of linking
12.2 What's inside a map file (typical structure)
1. Archive members included to satisfy references
(which library object pulled in which other member, and why)
2. Discarded input sections
(what --gc-sections removed)
3. Memory Configuration
Name Origin Length Attributes
FLASH 0x08000000 0x00080000 xr
RAM 0x20000000 0x00020000 xrw
4. Linker script and memory map
.isr_vector 0x08000000 0x1c4
.text 0x080001c4 0x3a28
.text 0x080001c4 0x6c build/main.o
.text.uart_send 0x08000230 0x34 build/uart.o
...
.data 0x20000000 0x40 load address 0x08003bec
.bss 0x20000040 0x1a8
...
5. Cross reference table (if --cref)
symbol defined in used in
Useful details to read:
| Question | Where to look |
|---|---|
| Which function is biggest? | .text entries sorted by size (or use nm --size-sort) |
| Which file consumes the most flash/RAM? | Sizes per object-file contribution under each section |
| Why is a library function in my image? | "Archive members included" section and --cref |
| Did my section land in the right memory? | Address of the section vs. the region origins |
| What got garbage-collected? | "Discarded input sections" |
Where does the .data initial image live? | "load address" next to .data |
| Is the vector table at the start of flash? | .isr_vector address |
12.3 The size command (quick summary)
$ arm-none-eabi-size app.elf
text data bss dec hex filename
28936 312 4120 33368 8258 app.elf
| Column | Meaning |
|---|---|
| text | Code + read-only data (.isr_vector, .text, .rodata, ...). Lives in flash |
| data | Initialised variables. Takes flash (initial values) and RAM |
| bss | Zero-initialised variables. Takes RAM only |
| dec / hex | Sum of the three |
Resource formulas:
Flash used = text + data
RAM used = data + bss (+ heap + stack that you reserve or use at run time)
sizedoes not show the stack or the heap's real usage (they're run-time quantities). The linker script's reserved heap/stack space is counted in the RAM region check, but actual consumption must be measured (stack high-water marks, heap statistics).
For per-section detail: arm-none-eabi-size -A app.elf. With the map/linker option --print-memory-usage you also get a clean summary such as:
Memory region Used Size Region Size %age Used
FLASH: 29248 B 512 KB 5.58%
RAM: 4432 B 128 KB 3.38%
12.4 Finding the big consumers
# Largest symbols (functions and variables), human-friendly:
arm-none-eabi-nm --size-sort -S --radix=d -C app.elf | tail -n 20
# Section list with sizes and addresses:
arm-none-eabi-objdump -h app.elf
# What's in a specific object:
arm-none-eabi-size -t build/*.o
Third-party/visual tools can help too (for example, tools that break flash/RAM usage down by function, file, library or call tree, and IDE memory-analysis views), but the map file + nm + size combination is universal.
12.5 Typical culprits when size explodes
| Culprit | Why | What to do |
|---|---|---|
printf family with float | Pulls in large formatting code | Avoid float printf; use integer formatting or a lightweight print library; nano specs |
malloc/heap use | Pulls in allocator code, adds heap reservation | Prefer static allocation |
| C++ exceptions / RTTI / iostream | Large runtime support | -fno-exceptions -fno-rtti; avoid iostream; use lightweight alternatives |
Large const tables / fonts / images | Fill flash | Compress, trim, move to external flash |
| Big global buffers | Use RAM from .bss | Right-size; share buffers; use smaller types |
| Unused functions kept | Missing section GC | -ffunction-sections -fdata-sections -Wl,--gc-sections |
| Debug/log strings | Strings live in .rodata | Use compact log IDs or disable in release |
-O3 / aggressive inlining | Code growth | Use -Os, check inlining |
| Math library | libm functions are big (especially double precision) | Use float variants (sinf), and avoid unnecessary double (the compiler promotes floats to double in many expressions: add the f suffix) |
| Software floating point on an FPU-less core | Large runtime routines (__aeabi_dmul...) | Use integer/fixed-point math in hot paths |
| Whole libraries linked unintentionally | One reference pulls in a large object | Check the "archive members included" section |
| RTOS and middleware configuration | Everything enabled by default | Disable unused features in config headers |
12.6 RAM analysis specifics
- Static RAM =
.data+.bss(visible in the map/size). - Stack: estimate worst-case depth (static analysis,
-fstack-usage, call graphs) and measure the high-water mark at run time (fill with a pattern; RTOS APIs do this per task). - Heap: measure peak usage and fragmentation if you use
malloc(and ideally avoid it in long-running, reliability-critical firmware). - Alignment padding adds hidden bytes. Reorder struct members (large to small) to reduce padding.
- Watch for duplicate buffers and oversize DMA/communication buffers.
- Leave headroom (e.g. 20 to 30% of RAM free at the planned end of development) to absorb future features.
12.7 Using this in the workflow
- Print the size summary at the end of every build (post-build step).
- Track size over time in CI, and fail the build or alert if flash/RAM grows beyond a threshold or the region is more than a set percentage full.
- When a build suddenly grows, diff map files between commits to see the change.
- Review
.rodataand.dataseparately from.text; data growth is often unnoticed.
13. Build Systems: Make and CMake
13.1 Why a build system?
Compiling by typing gcc commands doesn't scale. A build system:
- Knows which files make up the project and how to build each
- Rebuilds only what changed (incremental builds), saving time
- Manages configurations (debug/release), flags, include paths, defines
- Runs post-build steps (objcopy, size, CRC)
- Makes builds repeatable on every machine and in CI
13.2 Make
GNU Make reads a Makefile describing targets, their prerequisites (dependencies) and the recipes (commands) to build them. It compares file timestamps and rebuilds only targets whose prerequisites are newer.
Core syntax
target: prerequisite1 prerequisite2
<TAB>command to build target
Recipe lines must start with a TAB character, not spaces. A classic beginner error.
| Element | Meaning |
|---|---|
| Target | A file to produce (or a name like clean) |
| Prerequisites | Files the target depends on |
| Recipe | Shell commands run to produce the target |
| Variables | CC = arm-none-eabi-gcc and use as $(CC) |
| Automatic variables | $@ target name, $< first prerequisite, $^ all prerequisites |
| Pattern rules | %.o: %.c generic rule: build any .o from the matching .c |
.PHONY | Targets that aren't real files (all, clean, flash) |
| Functions | $(wildcard src/*.c), $(patsubst %.c,%.o,$(SRCS)) |
A complete embedded Makefile (Cortex-M example)
# ---------- Project ----------
TARGET := firmware
BUILD_DIR := build
# ---------- Toolchain ----------
PREFIX := arm-none-eabi-
CC := $(PREFIX)gcc
OBJCOPY:= $(PREFIX)objcopy
SIZE := $(PREFIX)size
# ---------- Sources ----------
C_SRCS := $(wildcard src/*.c) $(wildcard drivers/*.c)
ASM_SRCS:= startup/startup_stm32f407xx.s
OBJS := $(patsubst %.c,$(BUILD_DIR)/%.o,$(C_SRCS)) \
$(patsubst %.s,$(BUILD_DIR)/%.o,$(ASM_SRCS))
# ---------- Flags ----------
CPU := -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard
INCLUDES:= -Iinc -Idrivers/inc
DEFINES := -DSTM32F407xx
# Build configuration: make DEBUG=1 (default is release)
ifeq ($(DEBUG),1)
OPT := -Og -g3 -DDEBUG
else
OPT := -Os -DNDEBUG
endif
CFLAGS := $(CPU) $(OPT) $(INCLUDES) $(DEFINES) \
-Wall -Wextra -std=gnu11 \
-ffunction-sections -fdata-sections \
-MMD -MP # auto-generate header dependency files (.d)
ASFLAGS := $(CPU) -x assembler-with-cpp
LDFLAGS := $(CPU) -T linker/STM32F407VG_FLASH.ld \
-specs=nano.specs -specs=nosys.specs \
-Wl,-Map=$(BUILD_DIR)/$(TARGET).map,--cref \
-Wl,--gc-sections \
-Wl,--print-memory-usage
# ---------- Rules ----------
.PHONY: all clean flash size
all: $(BUILD_DIR)/$(TARGET).elf $(BUILD_DIR)/$(TARGET).hex $(BUILD_DIR)/$(TARGET).bin size
$(BUILD_DIR)/$(TARGET).elf: $(OBJS)
$(CC) $(OBJS) $(LDFLAGS) -o $@
$(BUILD_DIR)/%.hex: $(BUILD_DIR)/%.elf
$(OBJCOPY) -O ihex $< $@
$(BUILD_DIR)/%.bin: $(BUILD_DIR)/%.elf
$(OBJCOPY) -O binary $< $@
$(BUILD_DIR)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
$(BUILD_DIR)/%.o: %.s
@mkdir -p $(dir $@)
$(CC) $(ASFLAGS) -c $< -o $@
size: $(BUILD_DIR)/$(TARGET).elf
$(SIZE) $<
clean:
rm -rf $(BUILD_DIR)
# Include auto-generated dependency files so header changes trigger rebuilds
-include $(OBJS:.o=.d)
Important Make concepts shown above
- Header dependency tracking (
-MMD -MP): the compiler writes.dfiles listing the headers each.cincludes; including them (-include) makes Make rebuild a file when a header it uses changes. Without this, header edits silently don't trigger rebuilds (a nasty source of stale builds). - Out-of-source build directory (
build/) keeps the source tree clean and makescleantrivial. .PHONYprevents confusion if a file namedcleanexists.- Config via variables:
make DEBUG=1, ormake -j8for parallel jobs. - Flash target example:
flash: allthen run OpenOCD/vendor tool (project-specific).
Strengths and weaknesses of Make
| Strengths | Weaknesses |
|---|---|
| Ubiquitous, simple, transparent | Syntax quirks (TABs, whitespace sensitivity) |
| No extra dependency; works everywhere | Manual dependency/config management; scales poorly for large projects |
| Easy to read each command | Platform-specific shell commands (Windows vs. Linux) |
| Great for small/medium firmware | No built-in dependency discovery, configuration checks or IDE project generation |
13.3 CMake
CMake is a build-system generator: you describe the project once in CMakeLists.txt, and CMake generates the actual build files (Makefiles, Ninja files, or IDE projects) for you. It handles cross-platform differences, configurations, dependency graphs between libraries, and integrates well with IDEs and CI.
CMakeLists.txt + toolchain file
β
βΌ cmake (configure + generate)
build files (Makefile / build.ninja / IDE project)
β
βΌ cmake --build (runs make/ninja)
firmware.elf, .hex, .bin
Cross-compiling with a toolchain file
CMake must be told it is not building for the host. This is done in a toolchain file:
# arm-none-eabi-toolchain.cmake
set(CMAKE_SYSTEM_NAME Generic) # bare metal: no OS
set(CMAKE_SYSTEM_PROCESSOR arm)
set(TOOLCHAIN_PREFIX arm-none-eabi-)
set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}gcc)
set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}g++)
set(CMAKE_ASM_COMPILER ${TOOLCHAIN_PREFIX}gcc)
set(CMAKE_OBJCOPY ${TOOLCHAIN_PREFIX}objcopy)
set(CMAKE_SIZE ${TOOLCHAIN_PREFIX}size)
# CMake's compiler checks try to link a full executable, which fails on bare metal.
# Compile a static library instead during the checks:
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
A project CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(firmware C ASM) # languages used; ASM for the startup file
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CPU_FLAGS -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard)
add_executable(${PROJECT_NAME}.elf
src/main.c
src/uart.c
drivers/spi.c
startup/startup_stm32f407xx.s
)
target_include_directories(${PROJECT_NAME}.elf PRIVATE inc drivers/inc)
target_compile_definitions(${PROJECT_NAME}.elf PRIVATE STM32F407xx)
target_compile_options(${PROJECT_NAME}.elf PRIVATE
${CPU_FLAGS}
-Wall -Wextra
-ffunction-sections -fdata-sections
)
target_link_options(${PROJECT_NAME}.elf PRIVATE
${CPU_FLAGS}
-T${CMAKE_SOURCE_DIR}/linker/STM32F407VG_FLASH.ld
-specs=nano.specs -specs=nosys.specs
-Wl,-Map=${PROJECT_NAME}.map,--cref
-Wl,--gc-sections
-Wl,--print-memory-usage
)
# Post-build: HEX, BIN and size report
add_custom_command(TARGET ${PROJECT_NAME}.elf POST_BUILD
COMMAND ${CMAKE_OBJCOPY} -O ihex $<TARGET_FILE:${PROJECT_NAME}.elf> ${PROJECT_NAME}.hex
COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:${PROJECT_NAME}.elf> ${PROJECT_NAME}.bin
COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${PROJECT_NAME}.elf>
)
Building
# Configure (out-of-source, with toolchain file and build type)
cmake -S . -B build \
-DCMAKE_TOOLCHAIN_FILE=arm-none-eabi-toolchain.cmake \
-DCMAKE_BUILD_TYPE=Release \
-G Ninja
# Build
cmake --build build -j
# Other configuration
cmake -S . -B build-debug -DCMAKE_TOOLCHAIN_FILE=... -DCMAKE_BUILD_TYPE=Debug -G Ninja
Build types
CMake has built-in configurations that add default flags for GCC-style compilers:
CMAKE_BUILD_TYPE | Default flags | Typical use |
|---|---|---|
Debug | -g | Development (CMake's default adds no optimisation, i.e. -O0) |
Release | -O3 -DNDEBUG | Speed-focused release |
MinSizeRel | -Os -DNDEBUG | Size-focused release (often best for MCUs) |
RelWithDebInfo | -O2 -g -DNDEBUG | Optimised with symbols |
Embedded projects frequently override these defaults with their own flags (e.g. -Og for debug, -Os for release) to match project policy.
Useful CMake concepts
| Concept | Purpose |
|---|---|
Targets (add_executable, add_library) | The unit of building; carry their own sources, flags and dependencies |
target_* commands (target_include_directories, target_compile_options, target_link_libraries ...) | Attach settings to a target; PRIVATE/PUBLIC/INTERFACE control propagation to dependents |
| Libraries as modules | add_library(drivers STATIC ...) then target_link_libraries(app PRIVATE drivers), making modular code (section on architecture) natural in the build |
Generator expressions $<...> | Evaluate at generate time, e.g. $<TARGET_FILE:app> |
| Custom commands/targets | Post-build objcopy, CRC insertion, flashing targets |
| Out-of-source builds | Build files separate from the source tree |
| Generators | Makefiles, Ninja (fast), IDE projects |
Presets (CMakePresets.json) | Save configure/build configurations (toolchain, build type, generator) for reproducible builds |
| Variables & cache | -DNAME=value options at configure time |
CMAKE_EXPORT_COMPILE_COMMANDS | Produces compile_commands.json for IDE/IntelliSense/static-analysis tools |
Strengths and weaknesses of CMake
| Strengths | Weaknesses |
|---|---|
| Cross-platform; one description, many backends (Make, Ninja, IDEs) | Steeper learning curve and its own scripting language |
| Scales to large, modular, multi-library projects | Cross-compile setup (toolchain file) takes care |
| Automatic dependency tracking incl. headers | Generated build files are harder to read than a hand-written Makefile |
| Great IDE and CI integration; presets; testing (CTest) | Easy to over-engineer small projects |
| Handles multiple configurations cleanly | Linker-script/post-build specifics still need explicit rules |
13.4 Make vs. CMake
| Aspect | Make | CMake |
|---|---|---|
| Role | Executes the build directly | Generates build files (then Make/Ninja executes) |
| Best for | Small/medium projects, full control, simplicity | Larger, modular, multi-platform, team and CI projects |
| Dependency handling | You wire it (-MMD -MP) | Built in |
| Cross-compilation | Set CC etc. yourself | Toolchain file |
| Multiple configs | Variables / separate dirs | Built-in build types + presets |
| IDE integration | Manual | Strong (VS Code, CLion, etc.) |
| Learning curve | Low to moderate | Moderate to steep |
Typical advice: start with Make to understand what really happens; adopt CMake when the project grows into several modules, targets, platforms or CI pipelines. Many vendor IDEs generate Makefiles or CMake projects for you, so knowing both lets you read and fix them.
13.5 Build-system best practices (both)
- One command builds everything (
make/cmake --build). A new teammate should be able to build on day one. - Out-of-source builds, with a clean
cleantarget. - Track header dependencies correctly (stale builds waste hours).
- Parallel builds (
-j) for speed. - Keep flags centralised (CPU flags, warnings, optimisation) rather than scattered.
- Print size and memory usage after each link; fail on overflow.
- Pin tool versions (and ideally build in a container) for reproducibility.
- Separate configurations (Debug/Release) in separate build directories.
- Version-control the build scripts with the code, but not the build outputs.
- Automate in CI: build all configurations, run static analysis/tests, archive ELF + MAP + HEX/BIN for each release.
14. Cheat Sheet and Common Mistakes
The pipeline in one glance
.c βpreprocessβ .i βcompileβ .s βassembleβ .o ββ
.s βββββββββββββββββββββββββββββassembleβ .o βββΌβlink (+ libs, + linker script)β .elf βobjcopyβ .hex / .bin
lib .a βββββββββββββββββββββββββββββββββββββββββ β
(debug, map, size analysis)
Key concepts in one line each
- Cross-compilation: build on a host PC for a different target CPU (
arm-none-eabi-toolchain;-mcpu,-mthumb, float ABI flags must match everywhere). - Preprocessor: text substitution (
#include,#define,#if). - Compiler/assembler: each
.cβ.oindependently, with unresolved references left for the linker. - Linker: resolves symbols, merges sections, assigns addresses, relocates, discards unused code.
- Sections:
.textcode,.rodataconstants (flash);.datainitialised vars (flash + RAM);.bsszeroed vars (RAM). - ELF: full file with symbols/debug; HEX: text with addresses; BIN: raw bytes, address implied.
- Linker script:
MEMORYregions +SECTIONSplacement; VMA vs LMA (>RAM AT> FLASH); defines symbols like_sdata,_estack. - Vector table: word 0 = initial SP, word 1 = reset handler, then exceptions and IRQs; relocatable via
VTOR; weak default handlers. - Startup code: copy
.data, zero.bss,SystemInit, C-library init, callmain. - Boot: reset β boot-mode select β fetch SP/PC β Reset_Handler β
main; bootloader jumps by setting VTOR + MSP + branch. - Static linking: everything in one image (normal for MCUs); dynamic: shared libraries loaded by an OS (embedded Linux, not bare metal).
- Optimisation:
-O0/-Ogdebug;-Os/-O2release;-O3for speed; LTO shrinks; usevolatile, avoid UB. - Debug vs release: optimisation, symbols, assertions, logging, watchdog, protection differ; keep the ELF for every release.
- Map file/size: Flash = text + data; RAM = data + bss (+ heap + stack); hunt big symbols with
nm --size-sortand the map file. - Make: targets, prerequisites, TAB-indented recipes,
-MMD -MP. CMake: generates Make/Ninja files; needs a toolchain file for cross-compiling.
Essential commands
| Task | Command |
|---|---|
| Compile one file | arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -Os -c main.c -o main.o |
| Link | arm-none-eabi-gcc objs... -T script.ld -Wl,-Map=app.map -Wl,--gc-sections -o app.elf |
| ELF β HEX / BIN | arm-none-eabi-objcopy -O ihex app.elf app.hex / -O binary app.elf app.bin |
| Memory summary | arm-none-eabi-size app.elf |
| Largest symbols | arm-none-eabi-nm --size-sort -S -C app.elf |
| Section table | arm-none-eabi-objdump -h app.elf |
| Disassemble | arm-none-eabi-objdump -d app.elf |
| Preprocess only | arm-none-eabi-gcc -E main.c |
| Show assembly | arm-none-eabi-gcc -S -Os main.c |
Common mistakes
- β Mismatched
-mcpu/-mfpu/-mfloat-abibetween files and libraries. - β Defining variables in headers, causing "multiple definition" errors (declare
externin the header, define once in a.cfile). - β Forgetting
volatileon hardware registers/ISR-shared variables, so optimisation breaks polling loops. - β Wrong library link order (libraries before the objects that need them).
- β No
-ffunction-sections -fdata-sections --gc-sections, leaving dead code in the image. - β Vector table not first in flash, missing
KEEP, or wrongVTORafter a bootloader jump. - β Misspelled interrupt handler name, which silently falls to
Default_Handler. - β Linker script
ORIGIN/LENGTHnot matching the real chip, or the application not relocated after the bootloader. - β Forgetting that
.datacosts flash and RAM when budgeting memory. - β Using globals before startup has initialised
.data/.bssin very early code. - β Assuming
sizeincludes stack and heap usage. - β
printf("%f")with no float support enabled (or enabling it without noticing the flash cost). - β Making a Makefile recipe with spaces instead of a TAB, or omitting header dependency tracking, so stale builds hide bugs.
- β CMake with no toolchain file (builds for the host instead of the target) or no
CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY(compiler check fails). - β Putting essential code inside
assert(), which vanishes whenNDEBUGis defined. - β Shipping without archiving the matching ELF and map file, so field crashes can't be traced.
- β Testing only the debug build, but shipping a differently optimised release build.
- β Blaming the compiler first: optimisation-only bugs are usually UB, a race or a missing
volatile.
Mini checklist for a healthy embedded build
1. Pin the toolchain version; set CPU/FPU/ABI flags once, centrally
2. Compile with -Wall -Wextra; treat warnings as errors in CI
3. Use -ffunction-sections -fdata-sections and link with --gc-sections
4. Provide a correct linker script: real memory sizes, KEEP vector table, heap/stack reserve
5. Verify startup: .data copy, .bss zero, SystemInit, VTOR, init_array
6. Generate ELF + MAP + HEX/BIN every build; print memory usage; fail on overflow
7. Define Debug (-Og -g) and Release (-Os, NDEBUG) configurations; test the release build
8. Track header dependencies; build out-of-source; build in parallel
9. Archive ELF/MAP/toolchain/commit for each release
10. Review the map file and largest symbols regularly to control flash/RAM growth
*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,...

Interrupts and Communication Protocols
Interrupts and Exception Handling, UART / USART Serial Communication, SPI β Serial Peripheral Interface, IΒ²C β Inter-Integrated Circuit, Other Communication Interfaces