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

#8051 Microcontroller#
Why this topic exists: Embedded C is not fully platform-independent. The C language is the same everywhere, but registers, memory layout, ports, timers and interrupts differ from chip to chip. So after learning the general concepts, you must practise them on a real microcontroller. Here, that chip is the 8051, programmed with the Keil C51 compiler.
๐ Table of Contents
- Quick Introduction to the 8051
- Architecture, Registers and Memory Organization
- SFRs, Ports and Bit-Addressable Memory
sbit,sfrandbitin Keil C51- Timers and Counters
- External and Timer Interrupts
- UART Programming
- Interfacing: LED, Switch, LCD and Keypad
- Embedded C Programs Using Keil
- Quick Revision Cheat Sheet
1. Quick Introduction to the 8051
The 8051 is an 8-bit microcontroller originally designed by Intel in 1980. "8-bit" means its data bus and ALU work on 8 bits at a time.
Classic 8051 features (e.g., AT89C51 / 8051 core):
| Feature | Value |
|---|---|
| CPU | 8-bit |
| On-chip program memory (ROM/Flash) | 4 KB |
| On-chip data memory (RAM) | 128 bytes |
| I/O ports | 4 ports (P0โP3), 32 I/O lines |
| Timers/Counters | Two 16-bit (Timer 0, Timer 1) |
| Serial port | 1 full-duplex UART |
| Interrupt sources | 5 (2 external, 2 timer, 1 serial) |
| External memory | Up to 64 KB program + 64 KB data |
| Bit-addressable | Yes, a special strength of the 8051 |
๐ก Machine cycle: One machine cycle = 12 clock periods. With an 11.0592 MHz crystal, one machine cycle = 12 / 11.0592 MHz โ 1.085 ยตs. With a 12 MHz crystal it is exactly 1 ยตs. Timer calculations depend on this.
2. Architecture, Registers and Memory Organization
2.1 Block-level architecture
+----------------------------------------------------+
| 8051 |
| +---------+ +-----------+ +-----------------+ |
| | Program | | Internal | | Timers 0 & 1 | |
| | Memory | | RAM 128B | +-----------------+ |
| | (4KB) | +-----------+ +-----------------+ |
| +---------+ +-----------+ | Serial (UART) | |
| +---------+ | SFRs | +-----------------+ |
| | CPU | +-----------+ +-----------------+ |
| | (ALU,A, | | Interrupt ctrl | |
| | B, PSW)| +-----------+ +-----------------+ |
| +---------+ | Oscillator| |
| +-----------+ |
| P0 P1 P2 P3 |
+----------------------------------------------------+
The main blocks are: CPU, program memory, data memory, SFRs, 4 I/O ports, 2 timers, UART, interrupt logic and an on-chip oscillator (you only add a crystal and two ~30 pF capacitors).
2.2 Important pins (40-pin DIP)
| Pin | Name | Purpose |
|---|---|---|
| 40 | VCC | +5 V supply |
| 20 | GND | Ground |
| 18, 19 | XTAL2, XTAL1 | Crystal connections |
| 9 | RST | Reset (active HIGH, needs โฅ 2 machine cycles) |
| 31 | EA (External Access) | EA = 1 โ run from internal ROM; EA = 0 โ run from external ROM |
| 29 | PSEN | Program Store Enable (read strobe for external program memory) |
| 30 | ALE | Address Latch Enable (separates address/data on P0) |
| 1โ8 | P1.0โP1.7 | Port 1 |
| 10โ17 | P3.0โP3.7 | Port 3 (with special functions) |
| 21โ28 | P2.0โP2.7 | Port 2 |
| 32โ39 | P0.7โP0.0 | Port 0 |
2.3 Registers
Working / general registers
| Register | Size | Role |
|---|---|---|
| A (Accumulator) | 8-bit | Main register for arithmetic and logic; most operations use it |
| B | 8-bit | Used with A for MUL and DIV; otherwise a general register |
| R0โR7 | 8-bit each | General purpose registers (4 banks available) |
| PSW (Program Status Word) | 8-bit | Holds flags and register-bank select |
| SP (Stack Pointer) | 8-bit | Points to top of stack; reset value = 07H |
| DPTR (Data Pointer) | 16-bit | = DPH + DPL; used to access external/code memory |
| PC (Program Counter) | 16-bit | Address of the next instruction; reset value = 0000H |
PSW bit layout (address D0H)
| Bit | D7 | D6 | D5 | D4 | D3 | D2 | D1 | D0 |
|---|---|---|---|---|---|---|---|---|
| Name | CY | AC | F0 | RS1 | RS0 | OV | โ | P |
- CY: Carry flag (carry out of bit 7)
- AC: Auxiliary carry (carry from bit 3 to bit 4, used in BCD)
- F0: User-defined flag
- RS1, RS0: Select the active register bank
- OV: Overflow flag (signed arithmetic)
- P: Parity flag (1 if A has an odd number of 1s)
Register bank selection
| RS1 | RS0 | Bank | RAM addresses of R0โR7 |
|---|---|---|---|
| 0 | 0 | Bank 0 (default) | 00Hโ07H |
| 0 | 1 | Bank 1 | 08Hโ0FH |
| 1 | 0 | Bank 2 | 10Hโ17H |
| 1 | 1 | Bank 3 | 18Hโ1FH |
2.4 Memory organization
The 8051 uses a Harvard architecture: program memory and data memory are separate, with separate address spaces.
A. Program memory (code)
- 4 KB on-chip (0000Hโ0FFFH); expandable to 64 KB externally.
- Stores the program, constants and lookup tables.
- Accessed in C with the
codekeyword.
B. Data memory (RAM)
- 128 bytes on-chip (00Hโ7FH) plus the SFR area (80HโFFH).
- Expandable with up to 64 KB of external RAM (
xdata).
Internal RAM map (128 bytes)
Address Content
---------------------------------------------------
7FH
: General Purpose RAM (scratch pad) (30Hโ7FH = 80 bytes)
30H
---------------------------------------------------
2FH
: Bit-addressable area (20Hโ2FH = 16 bytes = 128 bits)
20H (bit addresses 00Hโ7FH)
---------------------------------------------------
1FH Bank 3 (R0โR7)
18H
17H Bank 2
10H
0FH Bank 1
08H
07H Bank 0 (default, also where SP starts)
00H
---------------------------------------------------
โ ๏ธ Since the stack pointer resets to 07H, the first PUSH goes to 08H, which overlaps register bank 1. If you use banks 1โ3 or the bit area, move the stack higher (e.g.,
SP = 0x60;). Keil's startup code normally handles this for you.
C. Memory types used in Keil C51
| Keyword | Meaning | Location |
|---|---|---|
data | Directly addressable internal RAM | 00Hโ7FH (fastest) |
idata | Indirectly addressable internal RAM | 00HโFFH (on 8052 all 256 bytes) |
bdata | Bit-addressable internal RAM | 20Hโ2FH |
xdata | External RAM | 0000HโFFFFH |
pdata | One page (256 bytes) of external RAM | via MOVX @Ri |
code | Program memory | ROM |
unsigned char data count; // internal RAM, fastest
unsigned char xdata buffer[100]; // external RAM
unsigned char code table[] = {0x3F, 0x06, 0x5B, 0x4F}; // stored in ROM
unsigned char bdata flags; // bit-addressable byte
Using
datafor frequently used variables makes your program faster and smaller. Keep large arrays inxdata/code.
3. SFRs, Ports and Bit-Addressable Memory
3.1 What are SFRs?
SFRs (Special Function Registers) are registers in the address range 80HโFFH that control the hardware: ports, timers, serial port, interrupts, etc. Writing to an SFR in C directly changes how the chip behaves.
Commonly used SFRs
| SFR | Address | Bit-addressable? | Purpose |
|---|---|---|---|
| P0 | 80H | Yes | Port 0 latch |
| SP | 81H | No | Stack pointer |
| DPL / DPH | 82H / 83H | No | Data pointer low / high |
| PCON | 87H | No | Power control (SMOD bit for baud rate) |
| TCON | 88H | Yes | Timer/interrupt control |
| TMOD | 89H | No | Timer mode |
| TL0 / TL1 | 8AH / 8BH | No | Timer low bytes |
| TH0 / TH1 | 8CH / 8DH | No | Timer high bytes |
| P1 | 90H | Yes | Port 1 latch |
| SCON | 98H | Yes | Serial control |
| SBUF | 99H | No | Serial data buffer |
| P2 | A0H | Yes | Port 2 latch |
| IE | A8H | Yes | Interrupt enable |
| P3 | B0H | Yes | Port 3 latch |
| IP | B8H | Yes | Interrupt priority |
| PSW | D0H | Yes | Program status word |
| ACC (A) | E0H | Yes | Accumulator |
| B | F0H | Yes | B register |
๐ Rule to remember: An SFR is bit-addressable only if its address is divisible by 8 (ends in 0H or 8H).
3.2 Ports
| Port | Pins | Special features |
|---|---|---|
| P0 | P0.0โP0.7 | Open-drain: needs external 10 kฮฉ pull-up resistors to work as normal I/O. Also the multiplexed low address/data bus (AD0โAD7) for external memory |
| P1 | P1.0โP1.7 | Pure I/O port with internal pull-ups. Simple and most used |
| P2 | P2.0โP2.7 | I/O with internal pull-ups. Also the high address byte (A8โA15) for external memory |
| P3 | P3.0โP3.7 | I/O with internal pull-ups. Each pin has an alternate function (below) |
Port 3 alternate functions
| Pin | Name | Function |
|---|---|---|
| P3.0 | RXD | Serial receive |
| P3.1 | TXD | Serial transmit |
| P3.2 | INT0 | External interrupt 0 |
| P3.3 | INT1 | External interrupt 1 |
| P3.4 | T0 | Timer 0 external input (counter) |
| P3.5 | T1 | Timer 1 external input (counter) |
| P3.6 | WR | External data memory write strobe |
| P3.7 | RD | External data memory read strobe |
Key points about ports
- On reset, all ports are set to 0xFF (all pins HIGH / configured as inputs).
- To use a pin as an input, first write 1 to it (the port latch must be 1 so the pin can be pulled low externally).
- Port pins are quasi-bidirectional: no separate direction register like DDR in AVR.
- Sink current (output LOW) is much higher than source current (output HIGH). So LEDs are often wired active-low (the 8051 sinks the current).
P1 = 0xFF; // make Port 1 an input (and all pins high)
P2 = 0x00; // all Port 2 pins LOW
P2 = P1; // copy input to output
3.3 Bit-addressable memory
The 8051 can set, clear, and test individual bits without touching the rest of the byte. This is very powerful for control applications.
There are two bit-addressable regions:
- Internal RAM 20Hโ2FH: 16 bytes ร 8 = 128 bits, with bit addresses 00Hโ7FH.
- Bit-addressable SFRs: bit addresses 80HโFFH (P0, TCON, P1, SCON, P2, IE, P3, IP, PSW, ACC, B).
Bit address mapping in RAM
| Byte address | Bit addresses |
|---|---|
| 20H | 00H โ 07H |
| 21H | 08H โ 0FH |
| 22H | 10H โ 17H |
| โฆ | โฆ |
| 2FH | 78H โ 7FH |
Example: Bit 5 of byte 24H โ byte offset (24H โ 20H) = 4 โ bit address = 4 ร 8 + 5 = 25H.
Bit addresses of some SFR bits
| SFR | Bit addresses |
|---|---|
| P1 (90H) | 90Hโ97H โ P1.0 = 90H โฆ P1.7 = 97H |
| TCON (88H) | 88Hโ8FH (IT0 = 88H, IE0 = 89H, IT1 = 8AH, IE1 = 8BH, TR0 = 8CH, TF0 = 8DH, TR1 = 8EH, TF1 = 8FH) |
| IE (A8H) | A8HโAFH (EX0 = A8H, ET0 = A9H, EX1 = AAH, ET1 = ABH, ES = ACH, EA = AFH) |
| PSW (D0H) | D0HโD7H (P = D0H โฆ CY = D7H) |
4. sbit, sfr and bit in Keil C51
Standard C has no way to name hardware registers or single bits. Keil C51 adds these extra keywords.
4.1 sfr: declare an 8-bit Special Function Register
sfr P1 = 0x90; // name P1 refers to SFR at address 90H
sfr PSW = 0xD0;
sfr TMOD = 0x89;
Usually you don't write these yourself; including the header does it:
#include <reg51.h> // declares P0โP3, TMOD, TCON, TH0, TL0, IE, SCON, SBUF, etc.
// or #include <reg52.h> (adds Timer 2 SFRs for the 8052)
4.2 sfr16: declare a 16-bit SFR
sfr16 DPTR = 0x82; // DPL at 82H, DPH at 83H
4.3 sbit: declare a single bit of an SFR (or bit-addressable byte)
Three ways to write it:
sbit LED = P1^0; // Method 1: SFR name ^ bit position (most common)
sbit LED = 0x90; // Method 2: absolute bit address (P1.0 = 90H)
sbit LED = 0x90 ^ 0; // Method 3: SFR address ^ bit position
Examples:
sbit LED = P1^0; // LED on P1.0
sbit SWITCH = P2^3; // switch on P2.3
sbit RS = P3^0; // LCD register-select pin
sbit CY = PSW^7; // carry flag
sbit TR0 = TCON^4; // Timer 0 run bit (already defined in reg51.h)
โ
sbitmust be declared globally, outside any function. It also works on abdatavariable:unsigned char bdata status; sbit flag0 = status^0; sbit flag7 = status^7;
4.4 bit: declare a bit variable in RAM
bit creates a normal 1-bit variable (in the bit-addressable area 20Hโ2FH). It's the 8051's boolean type.
bit done; // 1-bit variable
bit isPressed = 0;
done = 1;
if (done) { /* ... */ }
4.5 Comparison
| Keyword | What it declares | Where it lives | Example |
|---|---|---|---|
sfr | 8-bit hardware register | SFR area (80HโFFH) | sfr P1 = 0x90; |
sfr16 | 16-bit hardware register | SFR area | sfr16 DPTR = 0x82; |
sbit | One bit of an SFR / bdata byte (pin name) | Bit address space | sbit LED = P1^0; |
bit | An ordinary 1-bit variable | Bit-addressable RAM | bit flag; |
Common mistakes
- โ Using
bitfor a pin:bit LED = P1^0;is wrong. Usesbit. - โ Declaring
sbitinside a function; it must be global. - โ Declaring
sbiton a non-bit-addressable register (e.g., TMOD). That won't work. - โ Using pointers to
bitor arrays ofbit; not allowed.
5. Timers and Counters
The 8051 has two 16-bit timers, Timer 0 and Timer 1. Each is built from two 8-bit registers:
| Timer | High byte | Low byte |
|---|---|---|
| Timer 0 | TH0 | TL0 |
| Timer 1 | TH1 | TL1 |
5.1 Timer vs Counter
- Timer mode: counts internal machine cycles (clock รท 12). Used for delays and time measurement.
- Counter mode: counts external pulses on pin T0 (P3.4) / T1 (P3.5), on a 1โ0 transition. Used to count events (e.g., objects on a conveyor).
The timer counts up and sets an overflow flag (TF) when it rolls over from all 1s to 0.
5.2 TMOD register (89H, not bit-addressable)
Bit: 7 6 5 4 3 2 1 0
+-----+-----+----+----+-----+-----+----+----+
|GATE | C/T | M1 | M0 |GATE | C/T | M1 | M0 |
+-----+-----+----+----+-----+-----+----+----+
|<------ Timer 1 ------>|<------ Timer 0 ----->|
| Field | Meaning |
|---|---|
| GATE | 0 โ timer runs when TRx = 1. 1 โ runs only when TRx = 1 and INTx pin = 1 (hardware gating) |
| C/T | 0 โ Timer (internal clock). 1 โ Counter (external pin) |
| M1 M0 | Mode select |
5.3 Timer modes
| M1 M0 | Mode | Description |
|---|---|---|
| 0 0 | Mode 0 | 13-bit timer (8 bits of TH + 5 bits of TL), rarely used |
| 0 1 | Mode 1 | 16-bit timer/counter; max count 65536 |
| 1 0 | Mode 2 | 8-bit auto-reload: TH holds the reload value, TL counts. On overflow TL reloads from TH automatically |
| 1 1 | Mode 3 | Split timer: Timer 0 acts as two 8-bit timers (TL0 and TH0); Timer 1 stops. Rarely used |
Mode 1 is the choice for long, one-shot delays. Mode 2 is the choice for repetitive, accurate periods, and for generating baud rates for the UART.
5.4 TCON register (88H, bit-addressable)
Bit: 7 6 5 4 3 2 1 0
+-----+-----+-----+-----+-----+-----+-----+-----+
| TF1 | TR1 | TF0 | TR0 | IE1 | IT1 | IE0 | IT0 |
+-----+-----+-----+-----+-----+-----+-----+-----+
| Bit | Name | Meaning |
|---|---|---|
| TF1 / TF0 | Timer overflow flags | Set by hardware on overflow. Cleared by hardware when ISR runs, or clear by software when polling |
| TR1 / TR0 | Timer run control | 1 = start timer, 0 = stop |
| IE1 / IE0 | External interrupt edge flags | Set when an interrupt edge/level is detected |
| IT1 / IT0 | Interrupt type | 1 = falling-edge triggered, 0 = low-level triggered |
5.5 Calculating the delay (Mode 1)
Timer clock = Crystal / 12
Time per count = 12 / Crystal
Delay = (65536 โ initial_value) ร (12 / Crystal)
initial_value = 65536 โ (Delay / (12/Crystal))
Example: 50 ms delay with a 12 MHz crystal
- Time per count = 1 ยตs
- Counts needed = 50 ms / 1 ยตs = 50 000
- Initial value = 65536 โ 50000 = 15536 = 0x3CB0
- So TH0 = 0x3C, TL0 = 0xB0
Example: 1 ms delay with 12 MHz
- Counts = 1000, initial = 65536 โ 1000 = 64536 = 0xFC18
๐ Maximum delay (Mode 1) at 12 MHz = 65.536 ms. For longer delays (like 1 second), repeat a 50 ms delay 20 times.
5.6 Steps to program a timer (polling method)
- Load TMOD (choose timer, mode, C/T, GATE).
- Load TH / TL with the initial value.
- Start the timer: TRx = 1.
- Wait until TFx = 1 (poll the flag).
- Stop the timer: TRx = 0.
- Clear the flag: TFx = 0 (mandatory, or the next delay will finish instantly).
- Reload TH/TL before the next run (Mode 1 does not auto-reload).
5.7 Code: Delay using Timer 0, Mode 1
#include <reg51.h>
void delay_50ms(void)
{
TMOD = 0x01; // Timer 0, Mode 1 (16-bit), timer mode
TH0 = 0x3C; // 12 MHz crystal โ 50 ms
TL0 = 0xB0;
TR0 = 1; // start timer
while (TF0 == 0); // wait for overflow
TR0 = 0; // stop timer
TF0 = 0; // clear flag
}
void delay_ms(unsigned int ms) // approx. multiples of 50 ms
{
unsigned int i;
for (i = 0; i < ms / 50; i++)
delay_50ms();
}
5.8 Code: Square wave using Timer 0, Mode 2 (auto-reload)
Generate a continuous wave on P1.0 (โ 1 kHz wave โ half period 500 ยตs at 12 MHz; 8-bit max is 256 ยตs, so use 250 ยตs delay and toggle every 2 overflows, or use a 100 ยตs example below):
#include <reg51.h>
sbit WAVE = P1^0;
void main(void)
{
TMOD = 0x02; // Timer 0, Mode 2 (8-bit auto-reload)
TH0 = 0x9C; // 256 โ 100 = 156 = 0x9C โ 100 ยตs at 12 MHz
TR0 = 1;
while (1)
{
while (TF0 == 0); // wait 100 ยตs
TF0 = 0;
WAVE = ~WAVE; // toggle โ 200 ยตs period = 5 kHz
}
}
In Mode 2 you load TH0 only (TL0 gets it automatically at start and at every overflow).
5.9 Code: Counter mode (count external pulses)
#include <reg51.h>
void main(void)
{
TMOD = 0x05; // Timer 0, Mode 1, C/T = 1 โ counter (pulses on T0 / P3.4)
TH0 = 0; TL0 = 0;
TR0 = 1;
while (1)
{
P2 = TL0; // show low byte of count on LEDs at Port 2
}
}
TMOD = 0x05 โ 0000 0101 โ Timer 0 lower nibble: GATE = 0, C/T = 1, M1M0 = 01.
6. External and Timer Interrupts
6.1 Polling vs Interrupt
| Polling | Interrupt |
|---|---|
| CPU keeps checking a flag in a loop | Hardware notifies the CPU when an event occurs |
| Wastes CPU time | CPU can do other work |
| Slow response | Fast response |
6.2 The 5 interrupt sources of the 8051
| Interrupt | Source | Vector address | Interrupt number (Keil) | Default priority |
|---|---|---|---|---|
| Reset | RST pin | 0000H | โ | โ |
| INT0 | External, P3.2 | 0003H | 0 | Highest |
| Timer 0 | TF0 overflow | 000BH | 1 | 2nd |
| INT1 | External, P3.3 | 0013H | 2 | 3rd |
| Timer 1 | TF1 overflow | 001BH | 3 | 4th |
| Serial | RI or TI | 0023H | 4 | Lowest |
Each vector is only 8 bytes apart, so the ISR is normally a jump to the real routine (Keil does this for you).
6.3 IE register (A8H, bit-addressable): Interrupt Enable
Bit: 7 6 5 4 3 2 1 0
+----+----+----+----+-----+-----+-----+-----+
| EA | โ | ET2| ES | ET1 | EX1 | ET0 | EX0 |
+----+----+----+----+-----+-----+-----+-----+
| Bit | Name | Meaning |
|---|---|---|
| EA | Enable All | Master switch. 0 = all interrupts disabled |
| ES | Enable Serial | Serial interrupt |
| ET1 | Enable Timer 1 | Timer 1 overflow interrupt |
| EX1 | Enable External 1 | INT1 (P3.3) |
| ET0 | Enable Timer 0 | Timer 0 overflow interrupt |
| EX0 | Enable External 0 | INT0 (P3.2) |
| ET2 | Timer 2 (8052 only) | โ |
Everything is disabled on reset. To use an interrupt, you must set both its individual enable bit and
EA = 1.
6.4 IP register (B8H, bit-addressable): Interrupt Priority
Setting a bit to 1 gives that interrupt high priority.
| Bit | Name | Source |
|---|---|---|
| IP.0 | PX0 | INT0 |
| IP.1 | PT0 | Timer 0 |
| IP.2 | PX1 | INT1 |
| IP.3 | PT1 | Timer 1 |
| IP.4 | PS | Serial |
- A high-priority interrupt can interrupt a low-priority ISR, but not the reverse.
- If two interrupts of the same priority occur together, the default order (INT0 โ T0 โ INT1 โ T1 โ Serial) decides.
6.5 External interrupts: level vs edge
Set by IT0 / IT1 in TCON:
| ITx | Trigger |
|---|---|
| 0 | Low-level triggered. Interrupt repeats while pin stays LOW |
| 1 | Falling-edge triggered (1 โ 0 transition) |
Edge-triggered is preferred for push-buttons. IEx is cleared automatically when the ISR starts (for edge mode).
6.6 Writing an ISR in Keil C51
Syntax:
void function_name(void) interrupt N
{
// ISR code
}
where N is the interrupt number from the table (0โ4). Optionally add using n to pick a register bank (e.g., using 1).
Rules for ISRs
- No arguments, no return value.
- Keep them short and fast.
- Don't call them manually from
main(). - Variables shared with
main()should be declaredvolatile. - Avoid delay loops,
printf, etc., inside an ISR.
6.7 Code: External Interrupt 0 (button toggles LED)
#include <reg51.h>
sbit LED = P1^0;
void ext0_isr(void) interrupt 0 // INT0 on P3.2
{
LED = ~LED; // toggle LED on each button press
}
void main(void)
{
LED = 0;
IT0 = 1; // falling-edge triggered
EX0 = 1; // enable external interrupt 0
EA = 1; // enable global interrupts
while (1); // idle, the ISR does the work
}
Hardware: push-button between P3.2 and GND (the internal pull-up keeps the pin HIGH).
6.8 Code: Timer 0 interrupt (blink LED every 1 second)
#include <reg51.h>
sbit LED = P1^0;
volatile unsigned char tick = 0;
void timer0_isr(void) interrupt 1 // Timer 0 overflow
{
TH0 = 0x3C; // reload (Mode 1 needs manual reload)
TL0 = 0xB0; // 50 ms @ 12 MHz
tick++;
if (tick == 20) // 20 ร 50 ms = 1 s
{
tick = 0;
LED = ~LED;
}
}
void main(void)
{
TMOD = 0x01; // Timer 0, Mode 1
TH0 = 0x3C;
TL0 = 0xB0;
ET0 = 1; // enable Timer 0 interrupt
EA = 1; // enable global interrupt
TR0 = 1; // start timer
while (1);
}
In an interrupt-driven timer, TF0 is cleared automatically by hardware on entering the ISR. You don't clear it yourself.
6.9 Interrupt flow (what the CPU does)
- Event occurs โ flag is set.
- CPU finishes the current instruction.
- CPU pushes PC onto the stack.
- CPU loads the vector address into PC.
- ISR runs.
RETI(generated by Keil) pops PC and resumes the main program.
7. UART Programming
The 8051 has one full-duplex serial port (transmit and receive at the same time) using P3.0 (RXD) and P3.1 (TXD). It works as a UART: asynchronous, with start bit, data bits, stop bit.
Frame (Mode 1): 1 start bit (0) + 8 data bits (LSB first) + 1 stop bit (1) = 10 bits.
7.1 SBUF: Serial Buffer (99H)
One address, two separate physical registers:
- Writing to SBUF โ loads the transmit buffer and starts sending.
- Reading SBUF โ gets the received byte.
7.2 SCON register (98H, bit-addressable)
Bit: 7 6 5 4 3 2 1 0
+-----+-----+-----+-----+-----+-----+----+----+
| SM0 | SM1 | SM2 | REN | TB8 | RB8 | TI | RI |
+-----+-----+-----+-----+-----+-----+----+----+
| Bit | Meaning |
|---|---|
| SM0, SM1 | Serial mode select |
| SM2 | Multiprocessor communication enable |
| REN | Receive enable: must be 1 to receive |
| TB8 / RB8 | 9th data bit (Modes 2, 3) |
| TI | Transmit Interrupt flag: set when a byte is fully sent. Clear by software |
| RI | Receive Interrupt flag: set when a byte is received. Clear by software |
Serial modes
| SM0 SM1 | Mode | Type | Baud rate |
|---|---|---|---|
| 0 0 | 0 | Shift register (synchronous) | fosc / 12 |
| 0 1 | 1 | 8-bit UART | Variable (set by Timer 1) โ most used |
| 1 0 | 2 | 9-bit UART | fosc / 64 or fosc / 32 |
| 1 1 | 3 | 9-bit UART | Variable (set by Timer 1) |
7.3 Baud rate generation (Mode 1)
Timer 1 in Mode 2 (8-bit auto-reload) produces the baud clock.
Baud rate = (2^SMOD / 32) ร (Crystal / 12) / (256 โ TH1)
With SMOD = 0 (default) and an 11.0592 MHz crystal:
Baud = (1/32) ร (11059200 / 12) / (256 โ TH1) = 28800 / (256 โ TH1)
| Baud rate | TH1 (SMOD = 0) | TH1 (SMOD = 1) |
|---|---|---|
| 1200 | 0xE8 | โ |
| 2400 | 0xF4 | 0xE8 |
| 4800 | 0xFA | 0xF4 |
| 9600 | 0xFD | 0xFA |
| 19200 | โ | 0xFD |
๐ก Why 11.0592 MHz? This odd crystal value divides evenly to give exact standard baud rates (zero error). A 12 MHz crystal gives small baud errors and is unreliable for UART.
SMOD is bit 7 of PCON (87H). Setting it doubles the baud rate (PCON |= 0x80;).
7.4 Steps to configure UART (Mode 1, 9600 baud)
TMOD = 0x20;โ Timer 1, Mode 2.TH1 = 0xFD;โ 9600 baud.SCON = 0x50;โ Mode 1, REN = 1 (0101 0000).TR1 = 1;โ start Timer 1.
7.5 Code: Initialization, send and receive (polling)
#include <reg51.h>
void uart_init(void)
{
TMOD = 0x20; // Timer 1, Mode 2 (auto-reload)
TH1 = 0xFD; // 9600 baud @ 11.0592 MHz
SCON = 0x50; // Mode 1, 8-bit UART, REN = 1
TR1 = 1; // start Timer 1
}
void uart_send_char(unsigned char ch)
{
SBUF = ch; // load data โ transmission starts
while (TI == 0); // wait until fully transmitted
TI = 0; // clear flag
}
unsigned char uart_receive_char(void)
{
while (RI == 0); // wait until a byte is received
RI = 0; // clear flag
return SBUF; // read received byte
}
void uart_send_string(char *str)
{
while (*str)
uart_send_char(*str++);
}
void main(void)
{
unsigned char c;
uart_init();
uart_send_string("Hello 8051\r\n");
while (1)
{
c = uart_receive_char(); // echo program
uart_send_char(c);
}
}
7.6 Code: Serial interrupt (receive using ISR)
#include <reg51.h>
void serial_isr(void) interrupt 4
{
if (RI) // received a byte
{
RI = 0;
P2 = SBUF; // show it on LEDs
}
if (TI) // transmit finished
{
TI = 0;
}
}
void main(void)
{
TMOD = 0x20;
TH1 = 0xFD;
SCON = 0x50;
TR1 = 1;
ES = 1; // enable serial interrupt
EA = 1; // global enable
while (1);
}
The serial interrupt uses one shared vector for both TI and RI, so the ISR must check which flag caused it, and both flags must be cleared by software.
7.7 Connecting to a PC
The 8051 uses TTL levels (0 V / 5 V) while a PC's RS-232 uses ยฑ12 V. A MAX232 level-converter IC (or a USB-to-TTL module) is required between them.
8. Interfacing: LED, Switch, LCD and Keypad
8.1 LED interfacing
Hardware
- LED + current-limiting resistor (~330 ฮฉ) (limits current to ~10 mA).
- Active-low (recommended): +5 V โ resistor โ LED โ port pin. The pin sinks current; 0 = ON, 1 = OFF.
- Active-high: pin โ resistor โ LED โ GND. 1 = ON. Gives less current on the 8051, since source current is weak.
#include <reg51.h>
sbit LED = P1^0;
void delay(unsigned int t)
{
unsigned int i, j;
for (i = 0; i < t; i++)
for (j = 0; j < 1275; j++); // โ 1 ms (approx., tune for your clock)
}
void main(void)
{
while (1)
{
LED = 0; // ON (active-low)
delay(500);
LED = 1; // OFF
delay(500);
}
}
LED chaser (running light) on Port 2:
#include <reg51.h>
#include <intrins.h> // for _crol_()
void delay(unsigned int t)
{
unsigned int i, j;
for (i = 0; i < t; i++)
for (j = 0; j < 1275; j++);
}
void main(void)
{
unsigned char pattern = 0xFE; // 1111 1110
while (1)
{
P2 = pattern;
delay(200);
pattern = _crol_(pattern, 1); // rotate left by 1
}
}
_crol_(),_cror_()come from<intrins.h>. They rotate a char left/right.
8.2 Switch interfacing
Hardware
- One side of the switch โ port pin, other side โ GND.
- Internal pull-up on P1/P2/P3 keeps the pin HIGH when released and LOW when pressed (pull-up configuration).
- P0 has no internal pull-up, so add an external 10 kฮฉ resistor.
Rule: Write 1 to the pin first, then read it.
#include <reg51.h>
sbit SW = P2^0; // switch input
sbit LED = P1^0; // LED output
void main(void)
{
SW = 1; // make pin an input
while (1)
{
if (SW == 0) // pressed (active-low)
LED = 0; // LED ON
else
LED = 1; // LED OFF
}
}
Switch debouncing Mechanical switches "bounce" (make/break rapidly for ~5โ20 ms), causing multiple false reads. Fix in software: after detecting a press, wait ~20 ms, then re-check.
if (SW == 0)
{
delay_ms(20); // debounce delay
if (SW == 0)
{
// valid press
while (SW == 0); // wait for release (optional)
}
}
8.3 LCD (16ร2, HD44780) interfacing
Pins of a 16ร2 LCD
| Pin | Name | Function |
|---|---|---|
| 1 | VSS | GND |
| 2 | VCC | +5 V |
| 3 | VEE | Contrast (via 10 kฮฉ potentiometer) |
| 4 | RS | Register Select: 0 = command, 1 = data |
| 5 | RW | Read/Write: 0 = write, 1 = read (tie to GND if only writing) |
| 6 | EN | Enable: data latched on HIGH โ LOW pulse |
| 7โ14 | D0โD7 | Data pins |
| 15, 16 | LED+, LEDโ | Backlight |
Connections used in the code below
- Data pins D0โD7 โ Port 2
- RS โ P1.0, RW โ P1.1, EN โ P1.2
Common LCD commands
| Command (hex) | Function |
|---|---|
0x38 | 2 lines, 5ร7 matrix, 8-bit mode |
0x28 | 2 lines, 5ร7, 4-bit mode |
0x0E | Display ON, cursor ON |
0x0C | Display ON, cursor OFF |
0x0F | Display ON, cursor blinking |
0x01 | Clear display (needs ~2 ms) |
0x02 | Return home |
0x06 | Entry mode: increment cursor (shift right) |
0x80 | Force cursor to start of line 1 |
0xC0 | Force cursor to start of line 2 |
0x18 / 0x1C | Shift display left / right |
To place the cursor at a specific position: line 1 โ 0x80 + column, line 2 โ 0xC0 + column (column 0โ15).
Writing sequence
- Command: RS = 0, RW = 0, put data on D0โD7, EN = 1 then EN = 0.
- Data: RS = 1, RW = 0, put data, EN = 1 then EN = 0.
Initialization order: wait ~20 ms after power-up โ 0x38 โ 0x0C (or 0x0E) โ 0x06 โ 0x01.
#include <reg51.h>
#define LCD_DATA P2
sbit RS = P1^0;
sbit RW = P1^1;
sbit EN = P1^2;
void delay_ms(unsigned int ms)
{
unsigned int i, j;
for (i = 0; i < ms; i++)
for (j = 0; j < 123; j++); // โ 1 ms @ 12 MHz (approx.)
}
void lcd_cmd(unsigned char cmd)
{
LCD_DATA = cmd;
RS = 0; RW = 0;
EN = 1; delay_ms(1); EN = 0; // latch on falling edge
delay_ms(2);
}
void lcd_data(unsigned char ch)
{
LCD_DATA = ch;
RS = 1; RW = 0;
EN = 1; delay_ms(1); EN = 0;
delay_ms(2);
}
void lcd_init(void)
{
delay_ms(20); // power-on wait
lcd_cmd(0x38); // 8-bit, 2 lines, 5x7
lcd_cmd(0x0C); // display ON, cursor OFF
lcd_cmd(0x06); // auto-increment cursor
lcd_cmd(0x01); // clear screen
}
void lcd_string(char *s)
{
while (*s)
lcd_data(*s++);
}
void main(void)
{
lcd_init();
lcd_cmd(0x80); // line 1, column 0
lcd_string("Hello 8051");
lcd_cmd(0xC0); // line 2, column 0
lcd_string("Embedded C");
while (1);
}
4-bit mode uses only D4โD7 (4 port pins) and sends each byte as two nibbles (high first, then low). It saves pins but needs an extra initialization sequence (
0x02, then0x28).
8.4 4ร4 Keypad interfacing
A 4ร4 keypad has 16 keys arranged as a matrix of 4 rows ร 4 columns, needing only 8 pins instead of 16.
C0 C1 C2 C3
R0 1 2 3 A
R1 4 5 6 B
R2 7 8 9 C
R3 * 0 # D
Connections used below: Rows R0โR3 โ P1.0โP1.3 (outputs), Columns C0โC3 โ P1.4โP1.7 (inputs, internal pull-ups).
Scanning algorithm
- Make all columns inputs (write 1) and all rows HIGH.
- Ground one row at a time (drive it LOW), others HIGH.
- Read the columns. If a key in that row is pressed, its column reads 0.
- Row + column together identify the key.
- Repeat for all 4 rows. Add debouncing and wait for key release.
#include <reg51.h>
#define KEYPORT P1
unsigned char code keymap[4][4] =
{
{'1', '2', '3', 'A'},
{'4', '5', '6', 'B'},
{'7', '8', '9', 'C'},
{'*', '0', '#', 'D'}
};
void delay_ms(unsigned int ms)
{
unsigned int i, j;
for (i = 0; i < ms; i++)
for (j = 0; j < 123; j++);
}
char keypad_getkey(void)
{
unsigned char row, col, colval;
while (1)
{
for (row = 0; row < 4; row++)
{
KEYPORT = 0xFF; // all high (columns as inputs)
KEYPORT &= ~(1 << row); // ground the current row
colval = (KEYPORT >> 4) & 0x0F; // read column bits (upper nibble)
if (colval != 0x0F) // some column went LOW โ key pressed
{
delay_ms(20); // debounce
colval = (KEYPORT >> 4) & 0x0F;
if (colval != 0x0F)
{
for (col = 0; col < 4; col++)
if (!(colval & (1 << col)))
{
while (((KEYPORT >> 4) & 0x0F) != 0x0F); // wait for release
return keymap[row][col];
}
}
}
}
}
}
Using it with the LCD (typed keys appear on the display):
void main(void)
{
char key;
lcd_init(); // from the LCD example
lcd_cmd(0x80);
while (1)
{
key = keypad_getkey();
lcd_data(key);
}
}
9. Embedded C Programs Using Keil
9.1 Keil ยตVision workflow
- Project โ New ยตVision Project, choose a folder and name.
- Select a device: Atmel โ AT89C51 (or AT89S52 / NXP P89V51 etc.).
- Allow Keil to copy the startup code (
STARTUP.A51) when asked (Yes/No is fine for simple projects). - File โ New, write the C code and save it as
main.c. - Right-click Source Group 1 โ Add Existing Files โ add
main.c. - Project โ Options for Target โ Xtal (MHz): set your crystal (e.g., 11.0592 or 12).
- Options โ Output โ tick "Create HEX File".
- Build (F7). Fix errors and warnings.
- Debug (Ctrl+F5) to simulate: use Peripherals โ I/O Ports / Timer / Serial windows to watch the behaviour.
- Load the generated
.hexfile into the chip with a programmer, or into Proteus for virtual simulation.
9.2 Program skeleton
#include <reg51.h> // 8051 SFR definitions
sbit LED = P1^0; // pin names (global)
void delay(void); // function prototypes
void main(void) // program starts here
{
// 1. Initialise ports / timers / interrupts / UART
// 2. Super-loop
while (1)
{
// application code, never returns
}
}
Important points
main()must never return. An embedded program has no OS to go back to, so use an infinitewhile(1)loop.- Always include the proper header (
reg51.h/reg52.h). - Use
unsigned charinstead ofintwhen 8 bits suffice (faster, less RAM). - Use
codefor constant tables and strings to save scarce RAM.
9.3 Useful Keil-specific features
| Feature | Use |
|---|---|
#include <intrins.h> | _nop_(), _crol_(), _cror_() etc. |
_nop_(); | One machine cycle of "do nothing" (tiny delays) |
interrupt N | Declares an ISR |
using N | Selects register bank for an ISR |
code, data, xdata, bdata | Memory type qualifiers |
volatile | Prevents the compiler from optimizing away reads of ISR-modified variables / hardware |
9.4 More example programs
(a) Read switches and mirror them on LEDs
#include <reg51.h>
void main(void)
{
P1 = 0xFF; // Port 1 as input (switches)
while (1)
P2 = P1; // copy switch states to LEDs on Port 2
}
(b) Count 0โ9 on a common-cathode 7-segment display (Port 2)
#include <reg51.h>
unsigned char code seg[10] =
{0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F};
void delay(unsigned int t)
{
unsigned int i, j;
for (i = 0; i < t; i++)
for (j = 0; j < 123; j++);
}
void main(void)
{
unsigned char n;
while (1)
{
for (n = 0; n < 10; n++)
{
P2 = seg[n];
delay(500);
}
}
}
(c) Bit manipulation in C (set, clear, toggle, test)
P1 |= (1 << 3); // set bit 3
P1 &= ~(1 << 3); // clear bit 3
P1 ^= (1 << 3); // toggle bit 3
if (P1 & (1 << 3)) { /* bit 3 is high */ }
// Easier with sbit:
sbit PIN = P1^3;
PIN = 1; PIN = 0; PIN = ~PIN;
(d) Software delay using _nop_()
#include <intrins.h>
void tiny_delay(void)
{
_nop_(); _nop_(); _nop_(); _nop_(); // 4 machine cycles
}
9.5 Good practices checklist โ
- Comment your code, and mention the crystal frequency in comments.
- Set crystal frequency in Keil to match the hardware.
- Keep ISRs short; set a flag and handle the work in
main(). - Mark ISR-shared variables
volatile. - Always clear flags (TF, RI, TI) as needed.
- Initialize ports, timers and UART before the main loop.
- Use debouncing for mechanical switches and keypads.
- Use
#definefor pin names and constants, not magic numbers. - Put big constant data in
codememory.
10. Quick Revision Cheat Sheet
Reset values: PC = 0000H, SP = 07H, all ports = FFH, other SFRs = 00H.
Register quick reference
| Register | Quick settings |
|---|---|
| TMOD | 0x01 โ T0 Mode 1 ยท 0x02 โ T0 Mode 2 ยท 0x10 โ T1 Mode 1 ยท 0x20 โ T1 Mode 2 ยท 0x05 โ T0 counter, Mode 1 |
| TCON | TR0 = start T0 ยท TF0 = T0 overflow ยท IT0 = 1 โ INT0 edge-triggered |
| IE | EA (all) ยท ES (serial) ยท ET1 ยท EX1 ยท ET0 ยท EX0 |
| SCON | 0x50 โ Mode 1, receive enabled |
| TH1 (9600 baud, 11.0592 MHz) | 0xFD |
Interrupt vector table
| Source | Vector | Keil interrupt N |
|---|---|---|
| INT0 | 0003H | 0 |
| Timer 0 | 000BH | 1 |
| INT1 | 0013H | 2 |
| Timer 1 | 001BH | 3 |
| Serial | 0023H | 4 |
Timer delay formula (Mode 1):
Initial value = 65536 โ (Delay ร Crystal / 12)
Baud rate (Mode 1, SMOD = 0):
Baud = 28800 / (256 โ TH1) at 11.0592 MHz
Keyword summary: sfr (8-bit register) ยท sfr16 (16-bit register) ยท sbit (named bit/pin) ยท bit (1-bit variable) ยท interrupt N (ISR) ยท code / data / xdata / bdata (memory types)
Interfacing summary
| Device | Key idea |
|---|---|
| LED | Use series resistor; active-low is stronger on the 8051 |
| Switch | Write 1 to pin, read it; debounce ~20 ms |
| LCD 16ร2 | RS (cmd/data), EN pulse HIGHโLOW; init: 0x38, 0x0C, 0x06, 0x01 |
| 4ร4 keypad | Scan: ground one row, read columns; debounce and wait for release |
โ๏ธ Tip for learning: Read each section, then build the circuit in Proteus (or on a real AT89C51 board) and run the example in Keil. Change values (delays, baud rate, patterns) and observe what happens. The 8051 is learned best by doing.
#ESP32 / ESP32-S3#
How to use these notes: Each section explains the idea first, then shows a short working example. Examples are written for ESP-IDF v5.x (C) and Arduino-ESP32 core 3.x (C++). If your installed version is older, a few function names differ (noted where it matters). Pin numbers are for common dev boards, so always check your own board's pinout.
๐ Table of Contents
- Xtensa or RISC-V Architecture (Chip Variants)
- ESP-IDF vs Arduino Framework
- GPIO, Timers and Interrupts
- PWM using LEDC
- ADC and Sensor Interfacing
- UART, SPI and IยฒC
- FreeRTOS Tasks and Synchronization
- Wi-Fi and BLE
- Deep Sleep and Power Management
- Flash, NVS and OTA Firmware Updates
- Serial Debugging and Firmware Flashing
- Quick Revision Cheat Sheet
1. Xtensa or RISC-V Architecture (Chip Variants)
"ESP32" is really a family of chips from Espressif. They all have Wi-Fi and/or Bluetooth built in, but the CPU core inside depends on the variant:
| Variant | CPU architecture | Cores | Max clock | Wireless | Notes |
|---|---|---|---|---|---|
| ESP32 (original) | Xtensa LX6 | 2 | 240 MHz | Wi-Fi 4 + Bluetooth Classic + BLE 4.2 | The classic chip; 520 KB SRAM |
| ESP32-S2 | Xtensa LX7 | 1 | 240 MHz | Wi-Fi 4 only (no Bluetooth) | Native USB |
| ESP32-S3 | Xtensa LX7 | 2 | 240 MHz | Wi-Fi 4 + BLE 5 (no Classic) | AI/vector instructions, native USB, more GPIOs |
| ESP32-C3 | RISC-V (32-bit) | 1 | 160 MHz | Wi-Fi 4 + BLE 5 | Low cost, low power |
| ESP32-C6 | RISC-V | 1 (+ low-power core) | 160 MHz | Wi-Fi 6 + BLE 5 + 802.15.4 (Thread/Zigbee) | |
| ESP32-H2 | RISC-V | 1 | 96 MHz | BLE 5 + 802.15.4 (no Wi-Fi) |
What this means for you
- Xtensa (ESP32, S2, S3) is a configurable 32-bit architecture from Cadence. RISC-V (C and H series) is an open-standard instruction set. Both are 32-bit and both are used from C/C++ the same way. The toolchain differs (
xtensa-esp32-elf-gccvsriscv32-esp-elf-gcc), and ESP-IDF picks it for you. - Your C code is mostly portable across variants, but peripherals differ (number of GPIOs, ADC channels, LEDC channels, which Bluetooth versions exist). Always pick the correct target when building:
idf.py set-target esp32s3.
1.1 What's inside an ESP32 / ESP32-S3
+--------------------------------------------------------------+
| CPU core(s) (Xtensa LX6/LX7 or RISC-V) |
| Internal SRAM (+ ROM with bootloader & drivers) |
| Wi-Fi MAC/PHY Bluetooth/BLE controller RF front-end |
| Peripherals: GPIO, ADC, LEDC (PWM), UART, SPI, I2C, I2S, |
| timers, touch, RMT, TWAI (CAN), SDMMC ... |
| RTC / ULP low-power domain (keeps running in deep sleep) |
| Cryptographic hardware: AES, SHA, RSA, secure boot, flash enc|
+--------------------------------------------------------------+
| SPI bus
External flash (typically 4 MB+), optional PSRAM
Key points:
- Dual-core chips (ESP32, S3) are called PRO_CPU (core 0) and APP_CPU (core 1). By default, Wi-Fi/BLE stacks run on core 0 and your Arduino
loop()runs on core 1. - GPIO matrix: most peripheral signals (UART, SPI, IยฒC, LEDCโฆ) can be routed to almost any GPIO, so you are not locked to fixed pins like on the 8051.
- Logic level is 3.3 V and pins are not 5 V tolerant. Use a level shifter or voltage divider for 5 V signals.
- Flash is external (a separate chip, or a chip in the module) accessed over SPI. The code does not live inside the CPU die.
- ESP32-S3 extras: vector instructions for neural-network work, USB-OTG and built-in USB-Serial/JTAG, up to 45 GPIOs, optional octal PSRAM, BLE 5.
2. ESP-IDF vs Arduino Framework
Both frameworks run on the same chips, and in fact Arduino-ESP32 is built on top of ESP-IDF. So the real question is the level you want to work at.
| Feature | ESP-IDF (Espressif IoT Development Framework) | Arduino framework |
|---|---|---|
| Language | C (C++ supported) | C++ with simple helper functions |
| Entry point | void app_main(void) | void setup() and void loop() |
| Build system | CMake + Ninja, driven by idf.py | Arduino IDE / PlatformIO / Arduino CLI |
| Configuration | idf.py menuconfig (Kconfig, hundreds of options) | Mostly Tools menu (board, flash size, partition scheme) |
| FreeRTOS | Directly visible and used | Hidden behind loop() (which is itself a task) |
| Learning curve | Steeper | Gentle |
| Control and features | Full: power management, partitions, secure boot, flash encryption, all drivers, tracing | Good for common tasks; deeper features need IDF calls |
| Library ecosystem | ESP-IDF components, IDF Component Manager | Huge Arduino library ecosystem |
| Best for | Production firmware, tight resource/power control | Prototyping, learning, hobby projects |
Mixing them: You can call ESP-IDF functions (e.g., esp_deep_sleep_start(), esp_sleep_*, nvs_*) directly from Arduino sketches, because IDF is underneath. You can also use Arduino as an IDF component.
2.1 Same task in both: blink an LED
Arduino
#define LED 2 // many dev boards have an LED on GPIO2
void setup() {
pinMode(LED, OUTPUT);
}
void loop() {
digitalWrite(LED, HIGH);
delay(500);
digitalWrite(LED, LOW);
delay(500);
}
ESP-IDF
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#define LED GPIO_NUM_2
void app_main(void)
{
gpio_reset_pin(LED);
gpio_set_direction(LED, GPIO_MODE_OUTPUT);
while (1) {
gpio_set_level(LED, 1);
vTaskDelay(pdMS_TO_TICKS(500)); // yields the CPU (unlike busy-wait)
gpio_set_level(LED, 0);
vTaskDelay(pdMS_TO_TICKS(500));
}
}
Note: Arduino's
delay()also yields to FreeRTOS internally. It is not a busy loop, unlike on AVR/8051.
2.2 ESP-IDF project structure and workflow
my_project/
โโโ CMakeLists.txt # top-level project file
โโโ sdkconfig # generated settings (from menuconfig)
โโโ partitions.csv # (optional) custom partition table
โโโ main/
โโโ CMakeLists.txt # lists source files of the component
โโโ main.c # contains app_main()
Typical commands:
idf.py set-target esp32s3 # choose chip (esp32, esp32s3, esp32c3 ...)
idf.py menuconfig # configure project
idf.py build # compile
idf.py -p COM3 flash # upload (Linux: /dev/ttyUSB0)
idf.py -p COM3 monitor # serial monitor (exit: Ctrl+])
idf.py -p COM3 flash monitor # all in one
3. GPIO, Timers and Interrupts
3.1 GPIO basics
- Voltage: 3.3 V logic. Output current should be kept modest (about 20 mA per pin is a safe design target). Use transistors/drivers for motors, relays and bigger loads.
- Each GPIO can be input, output, with internal pull-up/pull-down (on most pins), and open-drain mode.
- Pins can be routed to peripherals through the GPIO matrix.
Pins to be careful with (original ESP32)
| Pins | Caution |
|---|---|
| GPIO 6โ11 | Connected to the on-module SPI flash. Never use. |
| GPIO 34โ39 | Input only, with no internal pull-up/pull-down |
| GPIO 0, 2, 5, 12, 15 | Strapping pins read at boot to choose boot mode/voltage. Wrong external levels can stop boot (e.g., GPIO0 LOW at reset enters download mode; GPIO12 HIGH at boot selects wrong flash voltage on some chips) |
| GPIO 1, 3 | UART0 TX/RX, used for the serial console and flashing |
| ADC2 pins | Cannot be used with analogRead while Wi-Fi is active (original ESP32) |
On the ESP32-S3, check the datasheet for its own strapping pins (GPIO0, 3, 45, 46) and the pins used by octal flash/PSRAM (roughly GPIO26โ37 on those modules). The exact set depends on the module you have.
3.2 GPIO in Arduino and ESP-IDF
Arduino
pinMode(4, OUTPUT);
pinMode(5, INPUT_PULLUP); // also INPUT, INPUT_PULLDOWN
digitalWrite(4, HIGH);
int v = digitalRead(5);
ESP-IDF (configure several settings at once)
gpio_config_t io = {
.pin_bit_mask = (1ULL << GPIO_NUM_4),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&io);
gpio_set_level(GPIO_NUM_4, 1);
int level = gpio_get_level(GPIO_NUM_5);
3.3 Hardware timers
The ESP32 has general-purpose hardware timers (ESP32: 4, in 2 groups; each 64-bit, clocked from the 80 MHz APB clock through a prescaler). Count counts up or down and can raise an alarm (interrupt) at a set value.
Three kinds of "timers" exist, so don't mix them up:
| Type | Resolution | Runs in | Use for |
|---|---|---|---|
| Hardware timer (GPTimer) | Down to ยตs/ns, set by prescaler | Hardware, ISR context | Precise periodic events |
esp_timer (high-resolution) | 1 ยตs | A dedicated task by default | Periodic/one-shot callbacks, timestamps (esp_timer_get_time()) |
| FreeRTOS software timer | RTOS tick (e.g., 1โ10 ms) | Timer service task | Slow, non-critical periodic jobs |
Arduino hardware timer (core 3.x)
hw_timer_t *timer = NULL;
volatile uint32_t ticks = 0;
void IRAM_ATTR onTimer() {
ticks++; // keep ISR tiny
}
void setup() {
Serial.begin(115200);
timer = timerBegin(1000000); // 1 MHz โ 1 tick = 1 ยตs
timerAttachInterrupt(timer, &onTimer);
timerAlarm(timer, 1000000, true, 0); // alarm every 1 s, auto-reload
}
void loop() {
static uint32_t last = 0;
if (ticks != last) { last = ticks; Serial.println(ticks); }
}
In Arduino core 2.x the API is
timerBegin(timerNumber, divider, countUp),timerAttachInterrupt(timer, &fn, true)andtimerAlarmWrite(...)+timerAlarmEnable(...). Same idea, different calls.
ESP-IDF esp_timer (periodic callback)
#include "esp_timer.h"
#include "esp_log.h"
static const char *TAG = "timer";
static void periodic_cb(void *arg)
{
ESP_LOGI(TAG, "tick at %lld us", esp_timer_get_time());
}
void app_main(void)
{
const esp_timer_create_args_t args = {
.callback = &periodic_cb,
.name = "periodic"
};
esp_timer_handle_t t;
esp_timer_create(&args, &t);
esp_timer_start_periodic(t, 1000000); // every 1,000,000 ยตs = 1 s
}
3.4 Interrupts
An interrupt pauses the main code, runs a short ISR (Interrupt Service Routine), then resumes. GPIO interrupt triggers:
| Mode | Triggers on |
|---|---|
RISING / GPIO_INTR_POSEDGE | LOW โ HIGH |
FALLING / GPIO_INTR_NEGEDGE | HIGH โ LOW |
CHANGE / GPIO_INTR_ANYEDGE | Any edge |
LOW / GPIO_INTR_LOW_LEVEL | While LOW |
HIGH / GPIO_INTR_HIGH_LEVEL | While HIGH |
ISR rules on ESP32
- Mark the function
IRAM_ATTRso it lives in internal RAM (it must still work while flash cache is disabled, e.g., during flash writes). - Keep it very short. Set a flag, give a semaphore or send to a queue, and do the real work in a task.
- Variables shared with normal code must be
volatile. - Don't call blocking functions,
Serial.print,printformallocinside an ISR. - Use the
...FromISRversions of FreeRTOS calls inside ISRs (see Section 7). - Debounce buttons (a time check inside the ISR, or a small delay in the task).
Arduino: button interrupt
const int BTN = 0; // BOOT button on many boards
volatile bool pressed = false;
volatile uint32_t lastMs = 0;
void IRAM_ATTR isr() {
uint32_t now = millis();
if (now - lastMs > 50) { // simple debounce
pressed = true;
lastMs = now;
}
}
void setup() {
Serial.begin(115200);
pinMode(BTN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(BTN), isr, FALLING);
}
void loop() {
if (pressed) {
pressed = false;
Serial.println("Button pressed");
}
}
ESP-IDF: GPIO interrupt that wakes a task through a semaphore
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "driver/gpio.h"
#include "esp_log.h"
#define BTN GPIO_NUM_0
static const char *TAG = "gpio_isr";
static SemaphoreHandle_t sem;
static void IRAM_ATTR gpio_isr(void *arg)
{
BaseType_t woken = pdFALSE;
xSemaphoreGiveFromISR(sem, &woken);
portYIELD_FROM_ISR(woken); // switch to the waiting task if needed
}
static void button_task(void *arg)
{
while (1) {
if (xSemaphoreTake(sem, portMAX_DELAY) == pdTRUE) {
ESP_LOGI(TAG, "button event");
}
}
}
void app_main(void)
{
sem = xSemaphoreCreateBinary();
gpio_config_t io = {
.pin_bit_mask = 1ULL << BTN,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.intr_type = GPIO_INTR_NEGEDGE,
};
gpio_config(&io);
gpio_install_isr_service(0); // once per program
gpio_isr_handler_add(BTN, gpio_isr, NULL);
xTaskCreate(button_task, "button", 2048, NULL, 5, NULL);
}
4. PWM using LEDC
The ESP32 has no classic "PWM peripheral". It has the LEDC (LED Control) peripheral, which is designed for LED dimming but is used for any PWM: motor speed, servo, buzzer tone, backlight.
4.1 Concepts
- Timer: sets the frequency and resolution.
- Channel: sets the duty cycle and the output GPIO. Several channels can share one timer (same frequency).
- Duty is an integer from
0to2^resolution โ 1.
duty_percent = duty / (2^resolution) ร 100
| Resolution | Duty range | Example: 50 % |
|---|---|---|
| 8-bit | 0โ255 | 128 |
| 10-bit | 0โ1023 | 512 |
| 12-bit | 0โ4095 | 2048 |
- Limit:
frequency ร 2^resolution โค source clock(typically 80 MHz APB clock). So higher frequency โ lower maximum resolution. At 5 kHz you can use up to ~13-bit; at 40 kHz only ~10-bit. - Number of channels depends on the chip: ESP32 has 16 (8 high-speed + 8 low-speed), ESP32-S3 has 8, ESP32-C3 has 6. All support hardware fading.
4.2 Arduino (core 3.x)
const int LED_PIN = 2;
const int FREQ = 5000; // 5 kHz
const int RES = 10; // 10-bit โ 0..1023
void setup() {
ledcAttach(LED_PIN, FREQ, RES); // pin, frequency, resolution
}
void loop() {
for (int d = 0; d <= 1023; d += 5) { ledcWrite(LED_PIN, d); delay(5); } // fade up
for (int d = 1023; d >= 0; d -= 5) { ledcWrite(LED_PIN, d); delay(5); } // fade down
}
In Arduino core 2.x you use
ledcSetup(channel, freq, res),ledcAttachPin(pin, channel)andledcWrite(channel, duty).
4.3 ESP-IDF
#include "driver/ledc.h"
void pwm_init(void)
{
ledc_timer_config_t timer = {
.speed_mode = LEDC_LOW_SPEED_MODE,
.duty_resolution = LEDC_TIMER_10_BIT,
.timer_num = LEDC_TIMER_0,
.freq_hz = 5000,
.clk_cfg = LEDC_AUTO_CLK,
};
ledc_timer_config(&timer);
ledc_channel_config_t ch = {
.gpio_num = 2,
.speed_mode = LEDC_LOW_SPEED_MODE,
.channel = LEDC_CHANNEL_0,
.timer_sel = LEDC_TIMER_0,
.intr_type = LEDC_INTR_DISABLE,
.duty = 0,
.hpoint = 0,
};
ledc_channel_config(&ch);
}
void set_brightness(uint32_t duty) // 0..1023
{
ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty);
ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0); // must call to apply
}
4.4 Common PWM uses
| Use | Frequency | Notes |
|---|---|---|
| LED dimming | 1โ20 kHz | Above ~1 kHz there is no visible flicker |
| DC motor (via driver/MOSFET) | 1โ20 kHz | Never drive a motor straight from a GPIO |
| Hobby servo | 50 Hz (20 ms period) | Pulse width ~1 ms (0ยฐ) to ~2 ms (180ยฐ); use โฅ 14-bit-ish resolution for smooth steps |
| Passive buzzer | Tone frequency (e.g., 2 kHz) | 50 % duty |
Servo example (Arduino, 50 Hz, 16-bit):
const int SERVO = 18;
void setup() { ledcAttach(SERVO, 50, 16); } // 16-bit: 0..65535 over 20 ms
void servoAngle(int deg) { // 0โ180ยฐ
float ms = 1.0 + (deg / 180.0); // 1.0 โ 2.0 ms
uint32_t duty = (uint32_t)(ms / 20.0 * 65535);
ledcWrite(SERVO, duty);
}
void loop() { servoAngle(0); delay(1000); servoAngle(90); delay(1000); servoAngle(180); delay(1000); }
5. ADC and Sensor Interfacing
5.1 ADC on the ESP32
- 12-bit SAR ADC: raw value
0โ4095. - Original ESP32 has two ADC units: ADC1 (8 channels: GPIO 32โ39) and ADC2 (10 channels). ADC2 cannot be used while Wi-Fi is on, so use ADC1 pins for analog sensors in Wi-Fi projects.
- Input range is small, set by attenuation:
| Attenuation (Arduino / IDF) | Approx. input range |
|---|---|
| 0 dB | 0 โ ~1.1 V |
| 2.5 dB | 0 โ ~1.5 V |
| 6 dB | 0 โ ~2.2 V |
11 dB (IDF 5: ADC_ATTEN_DB_12) | 0 โ ~3.1 V (most common choice) |
- Never apply more than 3.3 V to any ADC pin. Use a voltage divider for higher voltages.
- The ESP32 ADC is non-linear and noisy, especially near 0 V and near the top of range. Improve results with:
- Calibration (factory values stored in eFuse; IDF calibration API or
analogReadMilliVolts()in Arduino) - Averaging/oversampling (e.g., 16โ64 samples)
- A 0.1 ยตF capacitor across the input
- Keeping Wi-Fi TX bursts and noisy wiring away from analog lines
- Calibration (factory values stored in eFuse; IDF calibration API or
Vin = raw / 4095 ร Vref_range (range depends on attenuation)
5.2 Reading the ADC
Arduino
const int POT = 34; // ADC1 channel (input-only pin)
void setup() {
Serial.begin(115200);
analogReadResolution(12); // 0โ4095
analogSetAttenuation(ADC_11db); // ~0โ3.1 V
}
void loop() {
uint32_t sum = 0;
for (int i = 0; i < 32; i++) sum += analogRead(POT); // averaging
int raw = sum / 32;
int mV = analogReadMilliVolts(POT); // calibrated millivolts
Serial.printf("raw=%d mV=%d\n", raw, mV);
delay(500);
}
ESP-IDF (one-shot driver)
#include "esp_adc/adc_oneshot.h"
#include "esp_log.h"
void app_main(void)
{
adc_oneshot_unit_handle_t adc1;
adc_oneshot_unit_init_cfg_t unit = { .unit_id = ADC_UNIT_1 };
adc_oneshot_new_unit(&unit, &adc1);
adc_oneshot_chan_cfg_t cfg = {
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_DEFAULT, // 12-bit
};
adc_oneshot_config_channel(adc1, ADC_CHANNEL_6, &cfg); // ADC1_CH6 = GPIO34 on ESP32
int raw;
while (1) {
adc_oneshot_read(adc1, ADC_CHANNEL_6, &raw);
ESP_LOGI("adc", "raw = %d", raw);
vTaskDelay(pdMS_TO_TICKS(500));
}
}
The ADC channel โ GPIO mapping differs per chip (e.g., ESP32-S3 ADC1 uses GPIO1โ10). Look it up for your target.
5.3 Sensor types and how to interface them
| Sensor type | Examples | Interface | Key points |
|---|---|---|---|
| Analog | Potentiometer, LDR, NTC thermistor, LM35, soil moisture | ADC (ADC1 pin) | Voltage divider, โค 3.3 V, average samples |
| Digital on/off | PIR motion, reed switch, limit switch, IR obstacle | GPIO input (ยฑ interrupt) | Pull-up/down, debounce |
| Single-wire digital | DHT11, DHT22 | One GPIO with strict timing | Use a library; 2 s minimum between reads (DHT22) |
| Pulse/echo | HC-SR04 ultrasonic | Trigger GPIO + Echo GPIO | Echo is 5 V: divide to 3.3 V (e.g., 1 kฮฉ + 2 kฮฉ) |
| IยฒC digital | BME280, MPU6050, BH1750, SHT31, OLED | SDA + SCL | Pull-ups, unique address (Section 6) |
| SPI digital | MAX31855/MAX6675 thermocouple, SD card, many displays | MOSI/MISO/SCK/CS | (Section 6) |
| UART | GPS (NEO-6M), fingerprint, PMS5003 | TX/RX | Cross TXโRX, common GND (Section 6) |
5.4 Example: LDR (light) with voltage divider
3.3V โโ[ LDR ]โโโฌโโ[ 10 kฮฉ ]โโ GND
โ
GPIO34 (ADC1) brighter light โ LDR resistance โ โ voltage โ
5.5 Example: NTC thermistor (10 kฮฉ, ฮฒ = 3950) โ temperature
3.3V โโ[ 10 kฮฉ fixed ]โโโฌโโ[ NTC 10 kฮฉ ]โโ GND
โ
GPIO35
#include <math.h>
const int PIN = 35;
const float R_FIX = 10000.0; // fixed resistor
const float R0 = 10000.0; // NTC resistance at 25 ยฐC
const float T0_K = 298.15; // 25 ยฐC in Kelvin
const float BETA = 3950.0;
float readTempC() {
int raw = analogRead(PIN);
if (raw <= 0 || raw >= 4095) return NAN; // wiring fault / out of range
float rNtc = R_FIX * raw / (4095.0 - raw); // NTC is on the low side
float invT = 1.0 / T0_K + log(rNtc / R0) / BETA; // Beta equation
return (1.0 / invT) - 273.15;
}
void setup() { Serial.begin(115200); analogSetAttenuation(ADC_11db); }
void loop() { Serial.println(readTempC()); delay(1000); }
The formula above assumes a linear ADC. The ESP32's non-linearity adds error, so calibrate or use
analogReadMilliVolts()for better accuracy.
5.6 Sensor interfacing checklist โ
- Check the sensor's supply voltage and logic level (3.3 V vs 5 V).
- Share a common ground.
- Add pull-ups for IยฒC and open-drain outputs.
- Filter/average analog readings.
- Respect the sensor's minimum sampling interval.
- Don't use ADC2 pins if Wi-Fi is on.
6. UART, SPI and IยฒC
Thanks to the GPIO matrix, all three buses can be assigned to (almost) any free pins. Default pins exist for convenience.
6.1 Comparison
| Feature | UART | SPI | IยฒC |
|---|---|---|---|
| Wires | 2 (TX, RX) + GND | 4+ (SCK, MOSI, MISO, CS per device) | 2 (SDA, SCL) |
| Type | Asynchronous (no clock) | Synchronous, full-duplex | Synchronous, half-duplex |
| Topology | Point-to-point | One master, many slaves (one CS each) | Multi-device on shared bus by address |
| Speed | Up to ~5 Mbps; typical 115200 bps | Fastest: 10โ80 MHz | 100 kHz (standard), 400 kHz (fast) |
| Typical use | GPS, GSM, console, other MCU | Displays, SD cards, flash, fast ADCs | Sensors, RTC, OLED, EEPROM |
6.2 UART
- ESP32 has 3 UARTs (UART0, UART1, UART2; the S3 also has 3).
- UART0 is the console/flashing port (TX = GPIO1, RX = GPIO3 on ESP32).
- UART2 defaults: TX = GPIO17, RX = GPIO16 (ESP32). Remap UART1 (its default pins collide with flash).
- Parameters: baud rate, data bits, parity, stop bits (e.g.,
8N1). Both ends must match. Cross TX โ RX and RX โ TX, connect grounds, and watch the voltage (3.3 V).
Arduino
#define RXD2 16
#define TXD2 17
void setup() {
Serial.begin(115200); // UART0 โ PC
Serial2.begin(9600, SERIAL_8N1, RXD2, TXD2); // UART2 โ e.g., GPS module
}
void loop() {
while (Serial2.available()) {
Serial.write(Serial2.read()); // forward to PC
}
}
ESP-IDF
#include "driver/uart.h"
#define UART_NUM UART_NUM_2
#define TXD_PIN 17
#define RXD_PIN 16
#define BUF_SIZE 1024
void uart_setup(void)
{
const uart_config_t cfg = {
.baud_rate = 115200,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
.source_clk = UART_SCLK_DEFAULT,
};
uart_driver_install(UART_NUM, BUF_SIZE * 2, 0, 0, NULL, 0);
uart_param_config(UART_NUM, &cfg);
uart_set_pin(UART_NUM, TXD_PIN, RXD_PIN, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
}
void uart_echo_task(void *arg)
{
uint8_t data[128];
while (1) {
int len = uart_read_bytes(UART_NUM, data, sizeof(data), pdMS_TO_TICKS(100));
if (len > 0) uart_write_bytes(UART_NUM, (const char *)data, len);
}
}
6.3 SPI
- Signals:
SCLK(clock),MOSI(master out),MISO(master in),CS/SS(chip select, active LOW, one per slave). - SPI modes (CPOL, CPHA): Mode 0โ3. The slave's datasheet tells you which one to use. Mode 0 is the most common.
- The ESP32 exposes two general-purpose SPI controllers to the user: HSPI (SPI2) and VSPI (SPI3). SPI0/SPI1 are reserved for flash/PSRAM.
- Default VSPI pins (ESP32):
SCLK = 18, MISO = 19, MOSI = 23, CS = 5. Default HSPI:SCLK = 14, MISO = 12, MOSI = 13, CS = 15. - Supports DMA for large transfers (e.g., drawing a whole display).
Arduino: read from an SPI device
#include <SPI.h>
const int CS = 5;
SPISettings spiCfg(1000000, MSBFIRST, SPI_MODE0); // 1 MHz, MSB first, mode 0
void setup() {
Serial.begin(115200);
pinMode(CS, OUTPUT);
digitalWrite(CS, HIGH); // deselect
SPI.begin(18, 19, 23, CS); // SCK, MISO, MOSI, SS
}
uint8_t readRegister(uint8_t reg) {
SPI.beginTransaction(spiCfg);
digitalWrite(CS, LOW);
SPI.transfer(reg | 0x80); // many chips: MSB = 1 means "read"
uint8_t val = SPI.transfer(0x00); // clock out dummy byte to read reply
digitalWrite(CS, HIGH);
SPI.endTransaction();
return val;
}
void loop() {
Serial.println(readRegister(0x0F), HEX); // e.g., a WHO_AM_I register
delay(1000);
}
ESP-IDF: SPI master
#include "driver/spi_master.h"
spi_device_handle_t dev;
void spi_setup(void)
{
spi_bus_config_t bus = {
.mosi_io_num = 23,
.miso_io_num = 19,
.sclk_io_num = 18,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = 4096,
};
spi_bus_initialize(SPI3_HOST, &bus, SPI_DMA_CH_AUTO);
spi_device_interface_config_t devcfg = {
.clock_speed_hz = 1 * 1000 * 1000,
.mode = 0,
.spics_io_num = 5,
.queue_size = 4,
};
spi_bus_add_device(SPI3_HOST, &devcfg, &dev);
}
uint8_t spi_read_reg(uint8_t reg)
{
spi_transaction_t t = {
.length = 16, // bits: command + dummy
.flags = SPI_TRANS_USE_RXDATA | SPI_TRANS_USE_TXDATA,
.tx_data = { reg | 0x80, 0x00 },
};
spi_device_transmit(dev, &t);
return t.rx_data[1]; // second byte is the reply
}
6.4 IยฒC
- Two open-drain lines:
SDA(data) andSCL(clock). Both need pull-up resistors (typically 4.7 kฮฉ to 3.3 V; many breakout boards already include them). - Each slave has a 7-bit address (e.g., BME280 =
0x76/0x77, MPU6050 =0x68, BH1750 =0x23, SSD1306 OLED =0x3C). - Speeds: 100 kHz (standard) or 400 kHz (fast).
- Sequence: START โ address + R/W bit โ ACK โ data bytes (each ACKed) โ STOP.
- ESP32 has 2 IยฒC controllers. Default (ESP32):
SDA = GPIO21,SCL = GPIO22. On S3 and other boards the defaults differ, but any GPIO can be assigned. - Common bug: two devices with the same address on the same bus (change address pin, or use another bus).
Arduino: IยฒC scanner (a must-know first step)
#include <Wire.h>
void setup() {
Serial.begin(115200);
Wire.begin(21, 22); // SDA, SCL (choose your board's pins)
Serial.println("Scanning...");
for (uint8_t addr = 1; addr < 127; addr++) {
Wire.beginTransmission(addr);
if (Wire.endTransmission() == 0) { // 0 = device ACKed
Serial.printf("Found device at 0x%02X\n", addr);
}
}
}
void loop() {}
Arduino: read a sensor register by hand
uint8_t i2cReadByte(uint8_t addr, uint8_t reg) {
Wire.beginTransmission(addr);
Wire.write(reg); // which register
Wire.endTransmission(false); // repeated START (no STOP)
Wire.requestFrom(addr, (uint8_t)1); // ask for 1 byte
return Wire.read();
}
ESP-IDF (v5.2+ new master driver): outline
#include "driver/i2c_master.h"
i2c_master_bus_handle_t bus;
i2c_master_dev_handle_t dev;
void i2c_setup(void)
{
i2c_master_bus_config_t bus_cfg = {
.i2c_port = I2C_NUM_0,
.sda_io_num = 21,
.scl_io_num = 22,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.flags.enable_internal_pullup = true, // external 4.7k is better
};
i2c_new_master_bus(&bus_cfg, &bus);
i2c_device_config_t dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = 0x76,
.scl_speed_hz = 400000,
};
i2c_master_bus_add_device(bus, &dev_cfg, &dev);
}
// write register address, then read 1 byte
uint8_t i2c_read_reg(uint8_t reg)
{
uint8_t val;
i2c_master_transmit_receive(dev, ®, 1, &val, 1, 1000);
return val;
}
Older ESP-IDF (v4.x and early v5) uses
i2c_param_config()/i2c_driver_install()/i2c_master_write_read_device(). Check your version.
7. FreeRTOS Tasks and Synchronization
FreeRTOS is the real-time operating system that runs your code on the ESP32. In ESP-IDF, app_main() itself runs as a task. In Arduino, loop() runs inside a task (core 1, priority 1).
7.1 Why use an RTOS?
- Do several jobs "at the same time" (read sensors, run Wi-Fi, update display) without one big messy loop.
- Each job is a task with its own stack and priority.
- The scheduler is preemptive: a higher-priority ready task immediately interrupts a lower one. Equal priorities time-slice.
- Needed because the Wi-Fi/BLE stacks themselves run as tasks.
7.2 Task states
create
โ
โผ
+---------+ scheduler picks +---------+
| READY | โโโโโโโโโโโโโโโโโโโบ | RUNNING |
+---------+ โโโโโโโโโโโโโโโโโโโ +---------+
โฒ โฒ preempted โ โ
โ โ โ โ vTaskDelay / wait on
โ โโโโโโ event/timeout โโโโ โ โ queue/semaphore
โ +---------โผ--+
โ | BLOCKED |
โ vTaskResume +------------+
+-----------+ vTaskSuspend
| SUSPENDED | โโโโโโโโโโโโ
+-----------+
7.3 Creating tasks
BaseType_t xTaskCreate(TaskFunction_t fn, const char *name,
uint32_t stackDepth, void *param,
UBaseType_t priority, TaskHandle_t *handle);
BaseType_t xTaskCreatePinnedToCore(..., BaseType_t coreID); // 0, 1 or tskNO_AFFINITY
Important notes:
- Stack size is in BYTES in ESP-IDF (in vanilla FreeRTOS it is words). Start with 2048โ4096 and increase if you get a stack overflow. Wi-Fi/HTTP/TLS tasks usually need 4096โ8192+.
- Priority: higher number = higher priority. Idle task = 0. Typical app tasks use 1โ10.
- A task function must never return: use an infinite loop, or call
vTaskDelete(NULL)at the end. - Core pinning: on dual-core chips use
xTaskCreatePinnedToCoreto place a task on a specific core. Wi-Fi/BT run on core 0 by default, so put heavy user work on core 1. Single-core chips (C3, S2) ignore the core argument.
Arduino example: two tasks
void taskBlink(void *pv) {
pinMode(2, OUTPUT);
for (;;) {
digitalWrite(2, !digitalRead(2));
vTaskDelay(pdMS_TO_TICKS(500)); // block for 500 ms (lets other tasks run)
}
}
void taskPrint(void *pv) {
for (;;) {
Serial.printf("Running on core %d\n", xPortGetCoreID());
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void setup() {
Serial.begin(115200);
xTaskCreatePinnedToCore(taskBlink, "Blink", 2048, NULL, 1, NULL, 1);
xTaskCreatePinnedToCore(taskPrint, "Print", 4096, NULL, 1, NULL, 1);
}
void loop() { vTaskDelay(portMAX_DELAY); } // nothing to do here
Always use pdMS_TO_TICKS() instead of assuming the tick length, since it is configurable (CONFIG_FREERTOS_HZ).
7.4 Synchronization tools
| Tool | What it does | Typical use |
|---|---|---|
| Queue | Thread-safe FIFO for passing data (copies items) | Sensor task โ processing task; ISR โ task |
| Binary semaphore | Signal flag: give / take | ISR โ task notification ("event happened") |
| Counting semaphore | Counter of events/resources | Counting events; limiting access to N resources |
| Mutex | Lock for shared resources; has priority inheritance | Protect IยฒC bus, a global struct, a UART |
| Event group | Set of bit flags; wait for combinations | "Wi-Fi connected" AND "time synced" |
| Task notification | Lightweight signal built into every task (fastest) | Simple ISR-to-task wake-up |
Critical section (portENTER_CRITICAL) | Briefly disables interrupts (+ spinlock on dual core) | Protect very short updates of shared variables |
Binary semaphore vs mutex: A semaphore is for signalling (one task gives, another takes). A mutex is for mutual exclusion (the same task that takes it must give it back) and prevents priority inversion.
7.5 Queue example (producer โ consumer)
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_log.h"
static const char *TAG = "queue";
static QueueHandle_t q;
static void producer(void *arg)
{
int value = 0;
while (1) {
xQueueSend(q, &value, portMAX_DELAY); // blocks if the queue is full
value++;
vTaskDelay(pdMS_TO_TICKS(500));
}
}
static void consumer(void *arg)
{
int rx;
while (1) {
if (xQueueReceive(q, &rx, portMAX_DELAY) == pdTRUE) { // blocks until data
ESP_LOGI(TAG, "received %d", rx);
}
}
}
void app_main(void)
{
q = xQueueCreate(10, sizeof(int)); // 10 items of int
xTaskCreatePinnedToCore(producer, "prod", 2048, NULL, 5, NULL, 0);
xTaskCreatePinnedToCore(consumer, "cons", 2048, NULL, 5, NULL, 1);
}
7.6 Mutex example (protect a shared resource)
SemaphoreHandle_t i2cMutex;
void setup() {
i2cMutex = xSemaphoreCreateMutex();
}
void readSensorSafely() {
if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
// only one task at a time can use the I2C bus here
// ... Wire transactions ...
xSemaphoreGive(i2cMutex); // ALWAYS release it
} else {
// timed out: handle the failure
}
}
7.7 Using FreeRTOS from an ISR
Inside an ISR use only the ...FromISR() calls, e.g., xQueueSendFromISR, xSemaphoreGiveFromISR, vTaskNotifyGiveFromISR, and finish with portYIELD_FROM_ISR(woken). (See the full example in Section 3.4.)
7.8 Common problems
| Problem | Cause | Fix |
|---|---|---|
| Stack overflow / reboot | Stack too small, large local arrays | Increase stack size; use static/heap for big buffers; check with uxTaskGetStackHighWaterMark(NULL) |
| Task watchdog (TWDT) reset | A task hogs the CPU without yielding | Add vTaskDelay(); don't busy-wait; keep ISRs short |
| Deadlock | Two tasks wait for each other's mutexes | Take locks in the same order; use timeouts |
| Priority inversion | Low-priority task holds a lock needed by high-priority task | Use a mutex (priority inheritance), not a binary semaphore |
| Race condition | Shared variable changed by two tasks/ISR | Mutex, queue, critical section, or volatile + atomic access |
| Corrupted/odd output | Multiple tasks print at once | Protect Serial/UART with a mutex |
8. Wi-Fi and BLE
8.1 Wi-Fi
- 2.4 GHz radio (802.11 b/g/n; Wi-Fi 6 on C6). The ESP32 has no 5 GHz support.
- Built-in TCP/IP stack (lwIP), supporting TCP, UDP, HTTP(S), MQTT, WebSocket, mDNS, SNTP, etc.
Wi-Fi modes
| Mode | Meaning | Use |
|---|---|---|
| Station (STA) | ESP32 joins an existing router | Normal IoT device sending data to the cloud |
| Access Point (AP / SoftAP) | ESP32 creates its own network | Configuration portal, direct phone connection |
| STA + AP | Both at once | Provisioning, repeaters |
| ESP-NOW | Connectionless peer-to-peer using Wi-Fi radio | Fast, low-power device-to-device messages |
Station connection flow
- Initialize NVS and the network stack.
- Set mode = STA and give SSID/password.
- Start Wi-Fi and connect.
- Wait for "got IP" event (DHCP).
- Start your network code (HTTP, MQTTโฆ).
- Handle disconnects by reconnecting (routers drop out).
Arduino: connect to Wi-Fi + HTTP GET
#include <WiFi.h>
#include <HTTPClient.h>
const char *SSID = "YourWiFi";
const char *PASS = "YourPassword";
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASS);
Serial.print("Connecting");
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
Serial.printf("\nConnected! IP: %s RSSI: %d dBm\n",
WiFi.localIP().toString().c_str(), WiFi.RSSI());
HTTPClient http;
http.begin("http://example.com/");
int code = http.GET();
Serial.printf("HTTP code: %d\n", code);
if (code > 0) Serial.println(http.getString().substring(0, 200));
http.end();
}
void loop() {
if (WiFi.status() != WL_CONNECTED) { // simple auto-reconnect
WiFi.disconnect();
WiFi.reconnect();
delay(5000);
}
}
Arduino: SoftAP (create your own network)
WiFi.mode(WIFI_AP);
WiFi.softAP("ESP32-Config", "12345678"); // password must be โฅ 8 chars
Serial.println(WiFi.softAPIP()); // default 192.168.4.1
ESP-IDF station skeleton (event-driven)
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "nvs_flash.h"
#include "esp_log.h"
#include <string.h>
static const char *TAG = "wifi";
static void event_handler(void *arg, esp_event_base_t base, int32_t id, void *data)
{
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
ESP_LOGW(TAG, "disconnected, retrying");
esp_wifi_connect();
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *e = (ip_event_got_ip_t *)data;
ESP_LOGI(TAG, "got IP: " IPSTR, IP2STR(&e->ip_info.ip));
}
}
void app_main(void)
{
nvs_flash_init(); // Wi-Fi needs NVS
esp_netif_init();
esp_event_loop_create_default();
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
esp_wifi_init(&cfg);
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, NULL);
esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, NULL);
wifi_config_t wc = { 0 };
strcpy((char *)wc.sta.ssid, "YourWiFi");
strcpy((char *)wc.sta.password, "YourPassword");
esp_wifi_set_mode(WIFI_MODE_STA);
esp_wifi_set_config(WIFI_IF_STA, &wc);
esp_wifi_start();
}
โ ๏ธ Never hard-code real credentials in code you share. For production use provisioning (SoftAP/BLE) and store them in NVS.
Wi-Fi practical tips
- Wi-Fi TX bursts can draw 300+ mA peaks, so a weak USB cable/supply causes brownout resets.
- Use HTTPS/TLS for anything sensitive (it needs more RAM and larger task stacks).
- Wi-Fi + ADC2 conflict (original ESP32): use ADC1.
- The ESP32 can only connect to 2.4 GHz networks.
8.2 BLE (Bluetooth Low Energy)
- BLE is for short messages with very low power (sensors, wearables, phone โ device).
- Bluetooth Classic (audio, SPP serial) exists only on the original ESP32. S2 has no Bluetooth; S3, C3, C6, H2 are BLE-only.
- Wi-Fi and BLE share the same radio, so using both together works but reduces throughput and raises RAM use.
BLE roles
| Layer | Roles |
|---|---|
| GAP (Generic Access Profile): discovery & connection | Broadcaster/Peripheral (advertises, e.g., your ESP32 sensor) ยท Observer/Central (scans & connects, e.g., a phone) |
| GATT (Generic Attribute Profile): data exchange after connecting | Server (holds data, usually the peripheral) ยท Client (reads/writes data, usually the phone) |
GATT data model
Profile
โโโ Service (UUID) e.g., "Environmental Sensing"
โโโ Characteristic (UUID) e.g., "Temperature"
โโโ Value (the data bytes)
โโโ Properties READ / WRITE / NOTIFY / INDICATE
โโโ Descriptor(s) e.g., CCCD (0x2902) to enable notifications
- UUIDs identify services/characteristics. 16-bit UUIDs are standard (assigned by the Bluetooth SIG); 128-bit UUIDs are custom (generate your own).
- Properties:
READ(client pulls),WRITE(client sends),NOTIFY(server pushes updates, no acknowledgement),INDICATE(push with acknowledgement). - Advertising: the peripheral broadcasts small packets (name, service UUIDs) so central devices can find it, then connects.
Two BLE host stacks on ESP32: Bluedroid (larger, supports Classic + BLE) and NimBLE (smaller, BLE-only, less RAM, so it is preferred on constrained chips).
Arduino: BLE server that sends a notifying counter
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b" // custom UUIDs
#define CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"
BLECharacteristic *pChar;
uint32_t counter = 0;
void setup() {
Serial.begin(115200);
BLEDevice::init("ESP32-Sensor"); // device name seen by phones
BLEServer *server = BLEDevice::createServer();
BLEService *service = server->createService(SERVICE_UUID);
pChar = service->createCharacteristic(
CHAR_UUID,
BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY);
pChar->addDescriptor(new BLE2902()); // lets the client enable notifications
service->start();
BLEAdvertising *adv = BLEDevice::getAdvertising();
adv->addServiceUUID(SERVICE_UUID);
adv->start(); // begin advertising
Serial.println("Advertising...");
}
void loop() {
counter++;
pChar->setValue((uint8_t *)&counter, sizeof(counter));
pChar->notify(); // push to subscribed client
delay(1000);
}
Test it with a phone app such as nRF Connect or LightBlue: scan, connect, open the service and enable notifications.
BLE library class names and callback signatures vary between Arduino core versions and between the bundled library and NimBLE-Arduino. If an example fails to compile, check the version-specific examples for your installed core.
BLE tips
- Keep data small (default ATT payload is 20 bytes; negotiate a bigger MTU, up to ~512).
- Use notifications instead of polling to save power.
- Tune advertising interval and connection interval for power vs latency.
- Wi-Fi provisioning over BLE is a common real use: the phone sends the SSID/password via a BLE characteristic.
9. Deep Sleep and Power Management
Battery-powered devices must spend most of their time asleep. Active mode with Wi-Fi can consume 100โ240 mA, while deep sleep can be in the microamp range.
9.1 Power modes
| Mode | CPU | Wi-Fi/BLE radio | RAM | Typical idea |
|---|---|---|---|---|
| Active | Running | On | Kept | Full operation (highest consumption) |
| Modem-sleep | Running | Radio sleeps between beacons | Kept | Wi-Fi connected, light traffic |
| Light sleep | Paused (clock gated) | Off/limited | Kept; resumes exactly where it stopped | Short pauses, fast wake (hundreds of ยตA) |
| Deep sleep | Off | Off | Lost (except RTC memory) | Long sleeps; chip reboots on wake (~10 ยตA on the bare chip) |
| Hibernation | Off | Off | Even RTC slow memory off; only RTC timer/GPIO | Lowest consumption |
Dev boards draw far more than the chip alone because of the USB-UART chip, power LED and linear regulator. For real low-power numbers, measure a bare module on a good regulator.
9.2 Deep sleep behaviour: very important
- When the ESP32 wakes from deep sleep it restarts from the beginning (
app_main()/setup()), like a reset. - Normal variables are lost. To keep data across sleeps use:
- RTC memory: declare variables with
RTC_DATA_ATTR(kept while the RTC domain is powered). - NVS/flash for data that must survive power loss.
- RTC memory: declare variables with
- Typical firmware cycle: wake โ read sensor โ send data (Wi-Fi/BLE) โ sleep, and keep the awake time as short as possible.
9.3 Wake-up sources
| Source | Function (ESP-IDF names) | Notes |
|---|---|---|
| Timer | esp_sleep_enable_timer_wakeup(us) | Time in microseconds |
| External pin (ext0) | esp_sleep_enable_ext0_wakeup(gpio, level) | One RTC GPIO; original ESP32 (not on S3/C3) |
| External pins (ext1) | esp_sleep_enable_ext1_wakeup(mask, mode) | Several RTC GPIOs, wake on any HIGH / all LOW (on S3, newer IDF versions use esp_sleep_enable_ext1_wakeup_io) |
| Touch pad | esp_sleep_enable_touchpad_wakeup() | Capacitive touch pins (ESP32, S2, S3) |
| ULP coprocessor | esp_sleep_enable_ulp_wakeup() | Tiny low-power core can run while main CPUs sleep |
| GPIO / UART | esp_sleep_enable_gpio_wakeup(), esp_sleep_enable_uart_wakeup() | Light sleep only |
Only RTC-capable GPIOs can wake the chip from deep sleep. Check which pins those are for your chip.
9.4 Example: timer wake-up with a boot counter
#define uS_TO_S_FACTOR 1000000ULL
#define SLEEP_SECONDS 10
RTC_DATA_ATTR int bootCount = 0; // survives deep sleep
void printWakeReason() {
switch (esp_sleep_get_wakeup_cause()) {
case ESP_SLEEP_WAKEUP_TIMER: Serial.println("Woke by timer"); break;
case ESP_SLEEP_WAKEUP_EXT0: Serial.println("Woke by ext0"); break;
case ESP_SLEEP_WAKEUP_EXT1: Serial.println("Woke by ext1"); break;
case ESP_SLEEP_WAKEUP_TOUCHPAD: Serial.println("Woke by touch"); break;
default: Serial.println("Power-on / reset"); break;
}
}
void setup() {
Serial.begin(115200);
delay(500);
bootCount++;
Serial.printf("Boot number: %d\n", bootCount);
printWakeReason();
// ... read sensor, send data ...
esp_sleep_enable_timer_wakeup(SLEEP_SECONDS * uS_TO_S_FACTOR);
Serial.println("Going to sleep now");
Serial.flush(); // let the UART finish printing
esp_deep_sleep_start(); // never returns
}
void loop() {} // never reached
Wake by button (ext0, original ESP32):
esp_sleep_enable_ext0_wakeup(GPIO_NUM_33, 0); // wake when GPIO33 goes LOW (button to GND)
esp_deep_sleep_start();
9.5 Power management features
- Dynamic Frequency Scaling (DFS) + automatic light sleep: configured in ESP-IDF with
esp_pm_configure()(needsCONFIG_PM_ENABLE). The CPU lowers its frequency, and enters light sleep when idle. - Wi-Fi power save:
esp_wifi_set_ps(WIFI_PS_MIN_MODEM)orWIFI_PS_MAX_MODEM(the radio sleeps between router beacons; costs latency). - Lower CPU frequency (e.g., 80 MHz via
setCpuFrequencyMhz(80)in Arduino) when full speed isn't needed. - Turn off unused peripherals, use
gpio_hold/pull settings for pins to avoid leakage, and disconnect external sensors using a MOSFET/GPIO power switch. - BLE is far cheaper than Wi-Fi: use it when a phone is the only receiver.
- Wi-Fi reconnection is expensive: reduce time-to-connect by using a static IP and storing the AP channel/BSSID in RTC memory (fast connect).
9.6 Low-power design checklist โ
- Sleep as much as possible; stay awake only to measure and transmit.
- Batch data: send several readings per Wi-Fi session.
- Keep Wi-Fi connection time short (static IP, saved channel).
- Measure the real current with a multimeter/power profiler.
- Remove or avoid power-hungry board parts (LEDs, linear regulators) for final hardware.
- Provide stable power since Wi-Fi peaks cause brownouts.
10. Flash, NVS and OTA Firmware Updates
10.1 Flash memory and partition table
Program code and data live in external SPI flash (commonly 4 MB, up to 16 MB or more). It is split into partitions defined by a partition table stored at offset 0x8000.
Default flash layout (ESP32)
Offset Content
---------------------------------------------------------
0x0000 (Reserved / S3 & C3: bootloader starts here)
0x1000 Bootloader (2nd stage) [ESP32; at 0x0 on S3/C3/C6]
0x8000 Partition table
0x9000 NVS (non-volatile storage, key-value)
0xF000 PHY init data (RF calibration)
0x10000 Application ("factory" app firmware)
... (spiffs / littlefs / fat storage, if any)
Boot process: ROM code โ 2nd-stage bootloader (flash) โ reads partition table โ picks which app to run โ starts the app.
Partition table (CSV) with OTA
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
phy_init, data, phy, 0x10000, 0x1000,
ota_0, app, ota_0, 0x20000, 1536K,
ota_1, app, ota_1, , 1536K,
storage, data, spiffs, , 256K,
| Partition | Purpose |
|---|---|
| nvs | Key-value storage |
| otadata | Records which OTA slot is active |
| phy_init | Radio calibration |
| factory / ota_0 / ota_1 | Application images |
| spiffs / littlefs / fat | File system for files (web pages, logs, certificates) |
| coredump | Saves crash information |
Select the table with idf.py menuconfig โ Partition Table (custom CSV), or the Partition Scheme in the Arduino IDE Tools menu. The app image must fit its partition, and the flash size configured must match the real chip.
Flash wear: flash has a limited number of erase cycles (about 100,000 per sector). Don't write to it in a tight loop. NVS spreads writes to reduce wear.
10.2 NVS (Non-Volatile Storage)
NVS is a key-value store in flash that survives resets and power loss. It is ideal for settings: Wi-Fi credentials, calibration values, counters, device IDs.
- Organized in namespaces (like folders) โ keys (max 15 chars) โ values.
- Value types: integers (
i8โฆu64), strings, binary blobs. - Includes wear levelling and is power-fail safe.
- Write โ
nvs_commit()is required to make it persistent. - Not meant for large or very frequently changing data (use a file system or external storage).
ESP-IDF
#include "nvs_flash.h"
#include "nvs.h"
#include "esp_log.h"
static const char *TAG = "nvs";
void app_main(void)
{
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
nvs_flash_erase(); // format and retry
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
nvs_handle_t h;
ESP_ERROR_CHECK(nvs_open("storage", NVS_READWRITE, &h));
int32_t boots = 0;
err = nvs_get_i32(h, "boots", &boots); // may not exist yet
if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) ESP_LOGE(TAG, "read failed");
boots++;
nvs_set_i32(h, "boots", boots);
nvs_commit(h); // write to flash
nvs_close(h);
ESP_LOGI(TAG, "boot count = %ld", (long)boots);
}
Arduino (Preferences library: simple wrapper over NVS)
#include <Preferences.h>
Preferences prefs;
void setup() {
Serial.begin(115200);
prefs.begin("settings", false); // namespace, false = read/write
int count = prefs.getInt("boots", 0); // default 0 if missing
count++;
prefs.putInt("boots", count);
prefs.putString("device", "node-01");
Serial.printf("Boot count: %d, name: %s\n", count, prefs.getString("device", "").c_str());
prefs.end();
}
void loop() {}
10.3 OTA (Over-The-Air) firmware updates
OTA lets you update firmware over Wi-Fi without a USB cable, which is essential for devices already installed in the field.
How it works (dual-slot scheme)
Running from ota_0 After successful update
+-----------+ +-----------+
| ota_0 | โโโ running | ota_0 | (old, fallback)
+-----------+ +-----------+
| ota_1 | โโโ new image written | ota_1 | โโโ now running
+-----------+ +-----------+
| otadata | "boot ota_0" | otadata | "boot ota_1"
+-----------+ +-----------+
- The running firmware downloads the new image and writes it to the other (inactive) OTA slot.
- It verifies the image (size, checksum, optionally signature) and updates otadata to point to the new slot.
- It reboots into the new firmware.
- If the new firmware crashes/fails, rollback returns to the previous slot (when enabled).
Requirements: a partition table with two OTA app partitions + otadata; enough flash (โฅ 4 MB is practical).
Arduino: OTA over the local network (ArduinoOTA)
#include <WiFi.h>
#include <ArduinoOTA.h>
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
WiFi.begin("YourWiFi", "YourPassword");
while (WiFi.status() != WL_CONNECTED) delay(500);
ArduinoOTA.setHostname("esp32-node");
ArduinoOTA.setPassword("ota-secret"); // protect updates
ArduinoOTA.onStart([]() { Serial.println("OTA start"); });
ArduinoOTA.onEnd([]() { Serial.println("\nOTA done"); });
ArduinoOTA.onProgress([](unsigned int p, unsigned int t) {
Serial.printf("Progress: %u%%\r", p * 100 / t);
});
ArduinoOTA.onError([](ota_error_t e) { Serial.printf("OTA error[%u]\n", e); });
ArduinoOTA.begin();
}
void loop() {
ArduinoOTA.handle(); // must be called regularly
// ... your normal code ...
}
Upload from Arduino IDE by choosing the network port that appears for esp32-node.
ESP-IDF: OTA from an HTTPS server
#include "esp_https_ota.h"
#include "esp_http_client.h"
#include "esp_system.h"
#include "esp_log.h"
extern const char server_cert_pem_start[] asm("_binary_ca_cert_pem_start");
void ota_task(void *arg)
{
esp_http_client_config_t http = {
.url = "https://myserver.example.com/firmware.bin",
.cert_pem = server_cert_pem_start, // verify the server
};
esp_https_ota_config_t ota = { .http_config = &http };
esp_err_t ret = esp_https_ota(&ota);
if (ret == ESP_OK) {
ESP_LOGI("ota", "update OK, restarting");
esp_restart();
} else {
ESP_LOGE("ota", "update failed");
}
vTaskDelete(NULL);
}
Rollback (safety net): enable CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE. After an update the new image starts in a "pending verify" state. If your app decides it is healthy (e.g., connects to Wi-Fi), it must call:
esp_ota_mark_app_valid_cancel_rollback(); // otherwise the bootloader reverts on next reboot
OTA best practices
- Always use HTTPS and verify the certificate (never plain HTTP in production).
- Consider signed images / Secure Boot to block malicious firmware.
- Ensure stable power during update, as an interrupted write can leave a bad image (the dual-slot design protects the running firmware).
- Add a version check so the device only updates to newer firmware.
- Keep a fail-safe: rollback or a recovery partition.
Security extras (brief): Flash encryption protects firmware contents from being read; Secure Boot makes the bootloader accept only signed firmware. Both involve one-time eFuse settings, so test carefully on a spare board.
11. Serial Debugging and Firmware Flashing
11.1 Hardware connection
- Most dev boards have a USB-to-UART bridge (CP2102, CH340, FTDI) connected to UART0 (GPIO1/GPIO3 on ESP32). You may need the driver for CP210x/CH34x on your PC.
- ESP32-S3 / S2 / C3 / C6 can use their built-in native USB (USB-Serial/JTAG on S3/C3/C6, USB-OTG on S2/S3): the port may appear as a different COM port after boot.
- A bare module needs an external USB-UART adapter (3.3 V logic!) wired as: adapter TX โ ESP RX, adapter RX โ ESP TX, GND โ GND, 3.3 V โ 3V3.
11.2 Boot modes and the BOOT/EN buttons
| Button/pin | Function |
|---|---|
| EN (RST) | Resets the chip |
| BOOT (GPIO0) | If LOW during reset, the chip enters serial download mode (ready to receive firmware) |
Manual download mode: hold BOOT โ tap EN โ release BOOT. Most boards do this automatically through the DTR/RTS lines of the USB-UART chip. If upload says Failed to connectโฆ Wrong boot mode, use the manual sequence (also check the cable: some are charge-only).
11.3 Flashing firmware
Tools: esptool (Espressif's flashing utility) is used under the hood by idf.py, Arduino IDE and PlatformIO. There is also the Flash Download Tool (GUI, Windows).
With ESP-IDF
idf.py -p COM3 -b 460800 flash # build (if needed) + flash
idf.py -p COM3 erase-flash # wipe the whole flash (NVS too)
idf.py -p COM3 monitor # open serial monitor
With esptool directly (flash prebuilt binaries)
esptool.py --chip esp32 --port COM3 --baud 460800 write_flash \
0x1000 bootloader.bin \
0x8000 partition-table.bin \
0x10000 app.bin
Typical offsets: ESP32 โ bootloader 0x1000; ESP32-S3 / C3 / C6 โ bootloader 0x0; partition table 0x8000; app 0x10000 (offsets can change with a custom partition table).
Other handy esptool commands
esptool.py --port COM3 chip_id # identify chip
esptool.py --port COM3 flash_id # flash size and manufacturer
esptool.py --port COM3 read_mac # read MAC address
esptool.py --port COM3 erase_flash # erase all flash
With Arduino IDE: choose the correct Board (e.g., "ESP32 Dev Module", "ESP32S3 Dev Module"), Port, Flash size, Partition scheme โ click Upload.
11.4 Serial debugging
Arduino
Serial.begin(115200); // 115200 is the ESP32 default console speed
Serial.println("value = " + String(x));
Serial.printf("temp=%.2f id=%d\n", t, id); // printf-style (very handy)
Set the Serial Monitor baud to 115200 (the boot ROM prints at 115200; garbled text at boot is usually a wrong baud rate or wrong crystal setting).
ESP-IDF logging (levels)
static const char *TAG = "my_app";
ESP_LOGE(TAG, "Error: code %d", err); // Error (red)
ESP_LOGW(TAG, "Warning"); // Warning (yellow)
ESP_LOGI(TAG, "Info: %d", value); // Info (green)
ESP_LOGD(TAG, "Debug"); // Debug (hidden by default)
ESP_LOGV(TAG, "Verbose"); // Verbose
Choose the verbosity in menuconfig โ Component config โ Log output. Use esp_log_level_set("wifi", ESP_LOG_WARN) to quiet noisy components. Logging goes through UART and takes time, so heavy logging can slow time-critical code.
Reading a boot log
rst:0x1 (POWERON_RESET),boot:0x13 (SPI_FAST_FLASH_BOOT)
...
I (xxx) cpu_start: Pro cpu up.
I (xxx) cpu_start: Starting app cpu, entry point is 0x...
I (xxx) main: Application started
The rst: reason tells you why it restarted: POWERON_RESET, SW_CPU_RESET (software), DEEPSLEEP_RESET, TG0WDT_SYS_RESET (watchdog), BROWNOUT, etc.
11.5 Common errors and their meaning
| Message / symptom | Likely cause | What to do |
|---|---|---|
Failed to connect to ESP32: Wrong boot mode / Timed out waiting for packet header | Not in download mode, bad cable, wrong port, driver missing | Hold BOOT while connecting; use a data USB cable; install CP210x/CH340 driver; check port |
Brownout detector was triggered | Supply voltage dropped (Wi-Fi TX peak, weak USB/power) | Better cable/PSU, add 100โ470 ยตF capacitor near 3V3 |
Guru Meditation Error: Core 1 panic'ed (LoadProhibited) | Null/invalid pointer access | Decode the backtrace, check pointers |
Stack canary watchpoint triggered (taskname) | Stack overflow in a task | Increase that task's stack size |
Task watchdog got triggered | A task starves the CPU or never yields | Add vTaskDelay, shorten loops |
E (xxx) flash_parts: partition table not found / boot loop | Wrong partition table, wrong flash size, corrupt image | Reflash everything; erase-flash then flash |
| Garbage characters in monitor | Wrong baud rate | Set monitor to 115200 |
ADC2 โฆ not usable or erratic ADC with Wi-Fi | ADC2 conflicts with Wi-Fi | Move sensor to an ADC1 pin |
| Boot loops with a peripheral attached | A strapping pin held at a wrong level during reset | Disconnect it or move it to another pin |
Decoding crashes: idf.py monitor automatically decodes backtraces using the ELF file. In Arduino, use the ESP Exception Decoder tool or addr2line with the sketch's .elf file.
11.6 Advanced debugging
- JTAG + OpenOCD + GDB: hardware debugger giving breakpoints, stepping, variable inspection and watchpoints. The ESP32-S3/C3/C6 have built-in USB JTAG, so no extra probe is needed; for the original ESP32 use an adapter such as ESP-Prog.
- Core dump: save the crashed state to flash (
coredumppartition) and analyze later withidf.py coredump-info. - Heap monitoring:
esp_get_free_heap_size()andesp_get_minimum_free_heap_size()help catch memory leaks. - Task statistics:
vTaskList()/uxTaskGetStackHighWaterMark()show task state and stack margin. - Logic analyzer / oscilloscope to verify SPI/IยฒC/UART signals and PWM.
11.7 Debugging workflow
- Check power and wiring first (most "software bugs" are not software).
- Read the boot log, noting the reset reason.
- Add log messages at key points; check return codes (
ESP_ERROR_CHECK). - Isolate: test one peripheral at a time with a minimal sketch.
- Check stack sizes, heap, watchdogs.
- Use GDB/JTAG for tough problems.
12. Quick Revision Cheat Sheet
Chip families
| Chip | Core | Wireless |
|---|---|---|
| ESP32 | Xtensa LX6, dual | Wi-Fi + BT Classic + BLE |
| ESP32-S2 | Xtensa LX7, single | Wi-Fi |
| ESP32-S3 | Xtensa LX7, dual | Wi-Fi + BLE 5 |
| ESP32-C3 / C6 | RISC-V, single | Wi-Fi + BLE 5 (C6: Wi-Fi 6 + 802.15.4) |
| ESP32-H2 | RISC-V | BLE 5 + 802.15.4 |
Framework entry points: IDF โ app_main() ยท Arduino โ setup() + loop()
Pin rules (ESP32): 3.3 V only ยท GPIO6โ11 = flash (don't use) ยท GPIO34โ39 input-only ยท strapping pins 0, 2, 5, 12, 15 ยท ADC2 off-limits with Wi-Fi.
ISR rules: IRAM_ATTR ยท short ยท volatile shared data ยท ...FromISR() RTOS calls ยท no printing/blocking.
LEDC: duty = 0 โฆ 2^res โ 1 ยท freq ร 2^res โค 80 MHz ยท servo = 50 Hz, 1โ2 ms pulse.
ADC: 12-bit (0โ4095) ยท use ADC1 ยท 11 dB โ 0โ3.1 V ยท average & calibrate ยท max 3.3 V.
Buses
| Bus | Pins (ESP32 defaults) | Note |
|---|---|---|
| UART | UART2: TX17 / RX16 | cross TXโRX; match baud |
| SPI (VSPI) | SCK18, MISO19, MOSI23, CS5 | choose mode 0โ3 per device |
| IยฒC | SDA21, SCL22 | 4.7 kฮฉ pull-ups; unique 7-bit addresses |
FreeRTOS: task = infinite loop ยท stack in bytes ยท queue = data ยท semaphore = signal ยท mutex = protect shared resource ยท use pdMS_TO_TICKS() ยท vTaskDelay() to yield.
Wi-Fi: 2.4 GHz only ยท STA / AP / STA+AP / ESP-NOW ยท wait for got-IP event ยท handle reconnects.
BLE: GAP (advertise/connect) + GATT (Service โ Characteristic โ Value) ยท properties READ/WRITE/NOTIFY/INDICATE.
Deep sleep: reboots on wake ยท keep data with RTC_DATA_ATTR or NVS ยท esp_sleep_enable_timer_wakeup(ยตs) ยท esp_deep_sleep_start().
Flash/NVS/OTA: partition table at 0x8000 ยท NVS = key-value (nvs_commit) ยท OTA needs ota_0 + ota_1 + otadata ยท use HTTPS and rollback.
Flash & debug: hold BOOT, tap EN for download mode ยท idf.py -p PORT flash monitor ยท monitor at 115200 ยท read the reset reason and Guru Meditation messages.
โ๏ธ Learning tip: Work through the sections in order on a real board. First blink an LED, then add a button interrupt, PWM fade, an ADC sensor and an IยฒC scanner. After that, wrap each in a FreeRTOS task, connect to Wi-Fi, and finish by adding deep sleep and OTA. Each step builds on the previous one, and that is exactly how real ESP32 products are developed.
#ARM Cortex-M / STM32#
How to use these notes: Each section explains the idea first, then shows short examples. Examples are written for an STM32F4-class chip (Cortex-M4F) such as the Nucleo-F401RE (84 MHz, 512 KB flash, 96 KB SRAM, user LED on PA5, ST-LINK virtual COM port on USART2 = PA2/PA3) using the STM32 HAL (and CMSIS/LL where noted). Register names, pins, DMA streams and clock values change between STM32 families, so always check your own chip's reference manual (RM) and datasheet.
๐ Table of Contents
- Cortex-M Architecture and Register Model
- CPU Modes, Exceptions and NVIC
- SysTick and Interrupt Priorities
- CMSIS and Vendor HAL
- GPIO, Timers, PWM and ADC
- UART, SPI and IยฒC Drivers
- DMA and Memory Layout
- HardFault Debugging
- FreeRTOS on Cortex-M
- Quick Revision Cheat Sheet
1. Cortex-M Architecture and Register Model
ARM Cortex-M is a family of 32-bit processor cores designed by ARM. ARM does not make chips. It licenses the core, and companies like STMicroelectronics (STM32), NXP, TI and others put the core inside their microcontrollers together with memory and peripherals.
Key idea: The core (CPU, NVIC, SysTick, debug) is the same for every vendor. The peripherals (GPIO, UART, timers, ADCโฆ) are vendor-specific. That's why interrupt and register-model knowledge transfers between vendors, but peripheral code does not.
1.1 The Cortex-M family
| Core | Architecture | Typical features | Example STM32 series |
|---|---|---|---|
| Cortex-M0 / M0+ | ARMv6-M | Smallest, lowest power, simplest instruction set (Thumb subset) | STM32F0, G0, L0 |
| Cortex-M3 | ARMv7-M | Full Thumb-2, hardware divide, bit-banding | STM32F1, F2, L1 |
| Cortex-M4 / M4F | ARMv7E-M | M3 + DSP instructions and optional single-precision FPU | STM32F3, F4, G4, L4 |
| Cortex-M7 | ARMv7E-M | Higher performance, 6-stage pipeline, caches, TCM, optional double FPU | STM32F7, H7 |
| Cortex-M23 / M33 | ARMv8-M | Adds TrustZone security (+ M33 DSP/FPU) | STM32L5, U5 |
1.2 Core features
- 32-bit RISC, little-endian.
- Thumb / Thumb-2 instruction set only (mix of 16-bit and 32-bit instructions). There is no ARM-state; the T bit in xPSR must always be 1.
- Load/store architecture: data is processed in registers, and memory is touched only by load/store instructions.
- Pipeline: 3-stage on M0/M3/M4 (fetch, decode, execute).
- Single linear 4 GB address space (memory, peripherals and core registers all share it).
- Built-in interrupt controller (NVIC), SysTick timer, optional MPU (Memory Protection Unit), debug (SWD/JTAG, breakpoints, ITM/SWO trace).
- Bit-banding (M3/M4 only, not M7): each bit in certain SRAM/peripheral regions has its own word address for atomic single-bit access. (Many programs now use the
BSRRregister instead.)
1.3 Register model
All Cortex-M cores have 16 core registers accessible to the programmer, plus special registers.
R0 โโ
R1 โ Low registers (R0โR7): usable by all instructions
R2 โ
R3 โ
R4 โ
R5 โ
R6 โ
R7 โโ
R8 โโ
R9 โ High registers (R8โR12): used by some 32-bit instructions
R10 โ
R11 โ
R12 โโ
R13 SP Stack Pointer (banked: MSP and PSP)
R14 LR Link Register (return address / EXC_RETURN)
R15 PC Program Counter
| Register | Role |
|---|---|
| R0โR12 | General purpose |
| R13 (SP) | Stack pointer. Two copies: MSP (Main Stack Pointer) and PSP (Process Stack Pointer). Only one is active at a time |
| R14 (LR) | Holds the return address after BL/BLX; during exceptions it holds a special EXC_RETURN value |
| R15 (PC) | Address of the current instruction (bit 0 handling is automatic for Thumb) |
Special registers
| Register | Purpose |
|---|---|
| xPSR | Program Status Register, a combination of 3 views (below) |
| PRIMASK | 1-bit: when set, masks all exceptions with configurable priority (like a global interrupt disable) |
| FAULTMASK | (M3 and up) like PRIMASK but also masks HardFault |
| BASEPRI | (M3 and up) masks all interrupts at or below a priority value |
| CONTROL | Selects privilege level and which stack pointer thread mode uses (see ยง2) |
| FPU: S0โS31, FPSCR | Floating-point registers (on M4F/M7 with FPU) |
xPSR layout
| Part | Bits | Content |
|---|---|---|
| APSR (Application) | 31โ27 | N (negative), Z (zero), C (carry), V (overflow), Q (saturation, sticky) |
| IPSR (Interrupt) | 8โ0 | Exception number currently being handled (0 = thread mode) |
| EPSR (Execution) | 24 | T (Thumb) bit; must be 1, or you get a fault (INVSTATE) |
1.4 Calling convention (AAPCS): why it matters
When C calls C, ARM's standard says:
- R0โR3 carry the first four arguments (extra ones go on the stack); R0 (and R1) hold the return value.
- R0โR3, R12 are caller-saved (a function may destroy them).
- R4โR11 are callee-saved (a function must preserve them). This is exactly why exception entry hardware pushes only R0โR3, R12, LR, PC, xPSR: the rest is safe by convention, so ISRs can be plain C functions (no special keywords needed on Cortex-M).
1.5 Memory map (fixed by ARM for all Cortex-M)
| Address range | Region | Content |
|---|---|---|
0x0000_0000 โ 0x1FFF_FFFF | Code | Flash/ROM (boot area) |
0x2000_0000 โ 0x3FFF_FFFF | SRAM | On-chip RAM (bit-band region at the start on M3/M4) |
0x4000_0000 โ 0x5FFF_FFFF | Peripherals | Vendor peripheral registers (GPIO, UARTโฆ) |
0x6000_0000 โ 0x9FFF_FFFF | External RAM | e.g., FSMC/FMC memories |
0xA000_0000 โ 0xDFFF_FFFF | External device | |
0xE000_0000 โ 0xE00F_FFFF | Private Peripheral Bus (PPB) | SysTick, NVIC, SCB, MPU, debug |
0xE010_0000 โ 0xFFFF_FFFF | Vendor-specific |
On STM32, internal flash starts at 0x0800_0000. It is also aliased at 0x0000_0000 depending on the BOOT pins, because the core always fetches the vector table from address 0 at reset.
1.6 What happens at reset (boot sequence)
- The core reads word 0 of the vector table โ loads it into MSP (initial stack pointer).
- It reads word 1 โ loads it into PC (address of
Reset_Handler). Reset_Handler(in the startup file) then:- copies initialized globals (
.data) from flash to RAM, - zeroes the
.bsssection, - calls
SystemInit()(clock/FPU/vector-table setup), - calls
__libc_init_array()(C runtime/constructors), - calls
main().
- copies initialized globals (
Vector table at 0x0800_0000
+--------------------------+
| 0x00: Initial MSP value | โ loaded into SP
| 0x04: Reset_Handler | โ loaded into PC
| 0x08: NMI_Handler |
| 0x0C: HardFault_Handler |
| ... |
| 0x3C: SysTick_Handler |
| 0x40: WWDG_IRQHandler | โ first device interrupt (IRQ0)
| ... |
+--------------------------+
2. CPU Modes, Exceptions and NVIC
2.1 Modes and privilege levels
Cortex-M has two operating modes and two privilege levels:
| Mode | Used for | Privilege |
|---|---|---|
| Thread mode | Normal application code (main(), RTOS tasks) | Privileged or unprivileged (chosen by CONTROL) |
| Handler mode | Executing exception/interrupt handlers | Always privileged |
CONTROL register
| Bit | Name | 0 | 1 |
|---|---|---|---|
| 0 | nPRIV | Thread mode is privileged | Thread mode is unprivileged (restricted) |
| 1 | SPSEL | Thread mode uses MSP | Thread mode uses PSP |
| 2 | FPCA | No active FP context | FP context active (FPU used) |
- After reset: Thread mode, privileged, using MSP.
- Unprivileged code cannot access most system registers (NVIC, SCB, SysTick, MPU) and can be restricted by the MPU. It can return to privileged only via an exception (e.g., SVC).
- Typical RTOS arrangement: the kernel and ISRs use MSP, and each task uses its own PSP. A stack overflow in one task therefore doesn't corrupt the interrupt stack.
Reset
โ
โผ
+---------------+ exception taken +--------------+
| THREAD mode | โโโโโโโโโโโโโโโโโโโโบ | HANDLER mode |
| (priv/unpriv) | โโโโโโโโโโโโโโโโโโโโ | (privileged) |
+---------------+ exception return +--------------+
(BX LR with EXC_RETURN)
2.2 Exceptions
An exception is any event that makes the CPU stop what it's doing and run a handler: system faults, SysTick, supervisor calls, and external interrupts (IRQs). Every exception has a number, a priority, and a vector (handler address).
| Number | Exception | Priority | Notes |
|---|---|---|---|
| 1 | Reset | โ3 (fixed, highest) | |
| 2 | NMI | โ2 (fixed) | Non-maskable interrupt |
| 3 | HardFault | โ1 (fixed) | Catch-all for faults (see ยง8) |
| 4 | MemManage | Configurable | MPU violation (M3 and up) |
| 5 | BusFault | Configurable | Bus error on access (M3 and up) |
| 6 | UsageFault | Configurable | Undefined instruction, bad state, etc. (M3 and up) |
| 11 | SVCall | Configurable | Triggered by SVC instruction (RTOS system calls) |
| 12 | DebugMonitor | Configurable | |
| 14 | PendSV | Configurable | Pendable service call: used by RTOS for context switching |
| 15 | SysTick | Configurable | System timer tick |
| 16 and above | IRQ0, IRQ1, โฆ | Configurable | External/peripheral interrupts (vendor-defined, e.g., EXTI0, TIM2, USART2) |
On Cortex-M0/M0+, only Reset, NMI, HardFault, SVCall, PendSV and SysTick exist (no separate MemManage/BusFault/UsageFault), so every fault becomes a HardFault.
2.3 What the hardware does on an exception
Entry (automatic, ~12 cycles on M3/M4):
- Hardware stacks 8 registers onto the active stack, the exception stack frame:
Higher addresses
+---------+
| xPSR | โ SP + 28
| PC | โ SP + 24 (return address)
| LR | โ SP + 20
| R12 | โ SP + 16
| R3 | โ SP + 12
| R2 | โ SP + 8
| R1 | โ SP + 4
| R0 | โ SP + 0 (SP after stacking)
+---------+
Lower addresses (+ 18 extra words if FPU context is saved)
- Hardware fetches the handler address from the vector table (in parallel with stacking).
- Sets IPSR to the exception number, switches to Handler mode and uses MSP.
- Loads LR with an
EXC_RETURNvalue that records where to go back to:
| EXC_RETURN | Return to | Stack used on return |
|---|---|---|
0xFFFFFFF1 | Handler mode (nested exception) | MSP |
0xFFFFFFF9 | Thread mode | MSP |
0xFFFFFFFD | Thread mode | PSP |
0xFFFFFFE1 / E9 / ED | Same as above but with FPU context stacked (M4F/M7) |
Return: the handler returns with BX LR (a normal C return does this). The CPU sees the special EXC_RETURN value in LR, unstacks the 8 registers and resumes the interrupted code.
Speed optimizations (a big strength of Cortex-M):
- Tail-chaining: if another exception is pending when one finishes, the CPU jumps straight to it without a full unstack/restack (about 6 cycles).
- Late arrival: a higher-priority exception arriving during stacking is served first.
- Lazy FPU stacking: FPU registers are saved only if the interrupted code actually used the FPU.
โ Because of AAPCS and automatic stacking, an ISR is just a normal C function
void USART2_IRQHandler(void), with no__interruptkeyword needed (unlike 8051/AVR/PIC).
2.4 NVIC: Nested Vectored Interrupt Controller
The NVIC is part of the core. It manages up to 240 external interrupts (STM32 chips implement fewer: e.g., ~80โ100), plus the system exceptions.
Features
- Nested: a higher-priority interrupt can preempt a running lower-priority ISR.
- Vectored: each interrupt jumps directly to its own handler (no software dispatch).
- Per-interrupt enable, pending, active state and priority.
- Priorities are programmable, and the vector table can be relocated (
SCB->VTOR).
NVIC registers (base 0xE000E100)
| Register | Function |
|---|---|
| ISER[n] | Interrupt Set-Enable: write 1 to enable an IRQ |
| ICER[n] | Interrupt Clear-Enable: write 1 to disable |
| ISPR / ICPR | Set / clear pending state (software-trigger an IRQ) |
| IABR | Active-bit register (which IRQ is being served) |
| IPR[n] | Priority byte for each IRQ |
Typical CMSIS usage
NVIC_SetPriority(USART2_IRQn, 5); // set priority (CMSIS shifts it into the right bits)
NVIC_EnableIRQ(USART2_IRQn); // enable
NVIC_DisableIRQ(USART2_IRQn);
NVIC_SetPendingIRQ(EXTI0_IRQn); // trigger in software
NVIC_ClearPendingIRQ(EXTI0_IRQn);
Life of an interrupt (peripheral โ CPU)
Peripheral event โโบ peripheral's interrupt-enable bit set? โโบ NVIC pending bit set
โ โ
โ IRQ enabled in NVIC? โ priority high enough?
โผ โผ
Flag set in peripheral status reg CPU enters handler (xxx_IRQHandler)
โฒ โ
โโโโโโโโโโโ handler must CLEAR the peripheral flag โโโโโโโโโโ
There are three enables an interrupt must pass: (1) the peripheral's own interrupt-enable bit, (2) the NVIC enable, and (3) PRIMASK must be 0 (global). Forgetting to clear the flag inside the ISR causes the handler to re-trigger endlessly.
2.5 Global interrupt masking
__disable_irq(); // PRIMASK = 1 (CPSID i) โ blocks all configurable-priority interrupts
__enable_irq(); // PRIMASK = 0 (CPSIE i)
uint32_t primask = __get_PRIMASK(); // safe critical-section pattern
__disable_irq();
/* critical code */
__set_PRIMASK(primask); // restore previous state (works if nested)
__set_BASEPRI(5 << (8 - __NVIC_PRIO_BITS)); // mask only priorities โฅ 5 (M3/M4/M7)
Other useful intrinsics: __WFI() (sleep until interrupt), __WFE(), __NOP(), __DSB(), __ISB() (barriers).
3. SysTick and Interrupt Priorities
3.1 SysTick timer
SysTick is a 24-bit down-counter built into every Cortex-M core (so it's the same on every vendor's chip). It's meant to provide a periodic "tick" for an RTOS or a simple millisecond time base.
| Register | Address | Function |
|---|---|---|
| CTRL | 0xE000E010 | Bit 0 ENABLE, bit 1 TICKINT (interrupt on zero), bit 2 CLKSOURCE (1 = processor clock, 0 = clock/8), bit 16 COUNTFLAG |
| LOAD | 0xE000E014 | Reload value (24-bit) |
| VAL | 0xE000E018 | Current value (write clears it) |
| CALIB | 0xE000E01C | Calibration info |
How it counts: VAL counts down from LOAD to 0 โ on reaching 0 it sets COUNTFLAG, fires the SysTick exception (if TICKINT = 1), and reloads.
tick period = (LOAD + 1) / SysTick_clock
LOAD = (SysTick_clock ร desired_period) โ 1
Max LOAD = 0xFF_FFFF (16 777 215) โ at 84 MHz max period โ 199 ms; at 168 MHz โ 99.9 ms
CMSIS helper (does everything)
// 1 ms tick: SystemCoreClock = clock frequency in Hz (e.g., 84000000)
if (SysTick_Config(SystemCoreClock / 1000)) {
while (1); // returns 1 if the reload value doesn't fit in 24 bits
}
SysTick_Config() loads the reload value, sets the SysTick priority to the lowest, clears VAL and enables the counter + its interrupt using the processor clock.
3.2 A minimal millisecond time base
volatile uint32_t ms_ticks = 0;
void SysTick_Handler(void) // name comes from the vector table
{
ms_ticks++;
}
uint32_t millis(void) { return ms_ticks; }
void delay_ms(uint32_t ms)
{
uint32_t start = ms_ticks;
while ((ms_ticks - start) < ms); // subtraction handles 32-bit wrap-around correctly
}
int main(void)
{
SysTick_Config(SystemCoreClock / 1000);
while (1) {
/* ... */
delay_ms(500);
}
}
In the STM32 HAL, HAL_Init() starts SysTick at 1 ms and SysTick_Handler() calls HAL_IncTick(). HAL_GetTick() returns the millisecond counter and HAL_Delay(ms) uses it.
void SysTick_Handler(void)
{
HAL_IncTick();
}
โ ๏ธ HAL_Delay trap:
HAL_Delay()waits for SysTick to advance. If you call it inside an interrupt whose priority is equal to or higher than SysTick's, SysTick can never preempt it and the program hangs forever. Don't useHAL_Delayin ISRs, and keep SysTick's priority at least as urgent as the peripheral ISRs that use HAL timeouts. When an RTOS is used, SysTick belongs to the RTOS and the HAL timebase moves to another timer (see ยง9).
3.3 Interrupt priorities
Rule: a LOWER number means a HIGHER priority (more urgent). Priority 0 beats priority 5.
- Fixed: Reset (โ3), NMI (โ2), HardFault (โ1) are above everything configurable.
- Each configurable exception has an 8-bit priority field, but chips implement only the upper N bits (the lower bits read as 0). STM32 implements 4 bits (
__NVIC_PRIO_BITS = 4) โ 16 priority levels (0โ15). ARM allows 3โ8 bits.
8-bit priority register on STM32 (4 bits implemented):
bit7 bit6 bit5 bit4 | bit3 bit2 bit1 bit0
[ implemented bits ] [ always read 0 ]
โ priority value p is stored as (p << 4)
CMSIS and HAL functions do the shifting for you. In raw register code and in FreeRTOS configuration you must account for it.
Priority grouping (preempt vs sub-priority)
The PRIGROUP field in SCB->AIRCR splits the implemented bits into:
- Preemption (group) priority: decides whether one ISR can interrupt another.
- Sub-priority: only breaks ties when two interrupts are pending at the same time (it never causes preemption).
| HAL setting | Preempt bits | Sub bits | Levels (STM32, 4 bits) |
|---|---|---|---|
NVIC_PRIORITYGROUP_0 | 0 | 4 | 1 preempt level, 16 sub |
NVIC_PRIORITYGROUP_1 | 1 | 3 | 2 / 8 |
NVIC_PRIORITYGROUP_2 | 2 | 2 | 4 / 4 |
NVIC_PRIORITYGROUP_3 | 3 | 1 | 8 / 2 |
NVIC_PRIORITYGROUP_4 | 4 | 0 | 16 / 1: all bits are preemption (recommended, and required by FreeRTOS) |
HAL usage
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4); // usually done by HAL_Init()
HAL_NVIC_SetPriority(EXTI15_10_IRQn, 5, 0); // (IRQ, preempt priority, sub priority)
HAL_NVIC_EnableIRQ(EXTI15_10_IRQn);
Behaviour rules
- A higher-priority (lower number) exception preempts a lower one that is running.
- An equal or lower-priority exception waits, even if it arrived later (it becomes pending).
- If two pending interrupts have the same priority, the one with the lower IRQ number runs first.
- An exception can't preempt itself, and a pending flag is remembered once (multiple events while pending may merge into one).
Example scenario
| Interrupt | Priority |
|---|---|
| TIM2 (motor control loop) | 1 |
| USART2 (serial) | 5 |
| SysTick | 15 |
If USART2's ISR is running and TIM2 fires โ TIM2 preempts it. If SysTick fires during USART2's ISR โ it waits until USART2 finishes.
Design guidelines
- Give the most time-critical events (motor control, safety) the highest priority (lowest numbers).
- Give slow things (SysTick, logging, UART display) low priority.
- Keep ISRs short: set a flag, copy data, or signal an RTOS object, then return.
- Shared variables between ISR and main need
volatile, and multi-byte shared data needs protection (disable interrupts briefly, or use atomic access).
4. CMSIS and Vendor HAL
4.1 Software layers for an STM32 project
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Your application โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ STM32 HAL (high-level, portable API) โ โ easiest
โ STM32 LL (low-level, thin, fast) โ
โ Direct register access (CMSIS device header) โ โ most control
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ CMSIS-Core + CMSIS device header (stm32f4xx.hโ
โ startup file, system_stm32f4xx.c) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Hardware: Cortex-M core + STM32 peripherals โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
4.2 CMSIS (Cortex Microcontroller Software Interface Standard)
CMSIS is ARM's vendor-independent software standard so that code written for one Cortex-M chip looks the same on another.
| CMSIS component | What it provides |
|---|---|
| CMSIS-Core | Core access: core_cm4.h etc. Defines NVIC_xxx functions, SysTick_Config, intrinsics (__WFI, __disable_irq, __DSBโฆ), core register structures (SCB, NVIC, SysTick, MPU) |
| Device header | Vendor-supplied (e.g., stm32f401xe.h, stm32f4xx.h): peripheral register structs and bit masks, the IRQn_Type enum, base addresses |
| Startup file | startup_stm32f401xe.s: vector table + Reset_Handler + weak default handlers |
| system_stm32f4xx.c | SystemInit() and the global SystemCoreClock variable (+ SystemCoreClockUpdate()) |
| CMSIS-DSP | Optimized math/filter/FFT/matrix functions |
| CMSIS-RTOS2 | Standard RTOS API (a wrapper over FreeRTOS/RTX) |
| CMSIS-Pack / Driver | Packaging and driver interfaces |
Register access through the CMSIS device header: peripherals are struct pointers at fixed addresses:
GPIOA->MODER // GPIO mode register
GPIOA->ODR // output data register
RCC->AHB1ENR // clock enable register
TIM2->CNT // timer counter
USART2->SR // UART status register
Bit masks follow a pattern: PERIPH_REG_BIT_Pos (position) and PERIPH_REG_BIT_Msk (mask), e.g., RCC_AHB1ENR_GPIOAEN.
4.3 Vendor libraries: HAL vs LL vs registers
| Register level (CMSIS) | LL (Low Layer) | HAL (Hardware Abstraction Layer) | |
|---|---|---|---|
| Abstraction | None | Thin inline wrappers | High (handles, state machines) |
| Code size / speed | Smallest / fastest | Very close to registers | Largest / slowest |
| Portability between STM32 series | Low | Medium | High |
| Ease of use | Hard, needs reference manual | Medium | Easiest |
| Features | You write all | Single operations | Polling, interrupt and DMA APIs, error handling, callbacks |
| Typical use | Learning internals, tiny/fast code | Performance-critical parts | Most application code |
You can mix HAL and LL in the same project.
4.4 The same task three ways: blink the Nucleo LED (PA5)
Register level (CMSIS)
#include "stm32f4xx.h"
int main(void)
{
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; // 1. enable GPIOA clock
GPIOA->MODER &= ~(3U << (5 * 2)); // 2. clear mode bits for pin 5
GPIOA->MODER |= (1U << (5 * 2)); // 01 = general-purpose output
while (1) {
GPIOA->BSRR = (1U << 5); // set PA5 (BSRR low half = set)
for (volatile int i = 0; i < 500000; i++);
GPIOA->BSRR = (1U << (5 + 16)); // reset PA5 (BSRR high half = reset)
for (volatile int i = 0; i < 500000; i++);
}
}
LL
#include "stm32f4xx_ll_bus.h"
#include "stm32f4xx_ll_gpio.h"
#include "stm32f4xx_ll_utils.h"
int main(void)
{
LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_GPIOA);
LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_5, LL_GPIO_MODE_OUTPUT);
while (1) {
LL_GPIO_TogglePin(GPIOA, LL_GPIO_PIN_5);
LL_mDelay(500);
}
}
HAL
#include "main.h"
int main(void)
{
HAL_Init(); // reset peripherals, init flash/SysTick/NVIC
SystemClock_Config(); // configure PLL and bus clocks
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitTypeDef g = {0};
g.Pin = GPIO_PIN_5;
g.Mode = GPIO_MODE_OUTPUT_PP;
g.Pull = GPIO_NOPULL;
g.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &g);
while (1) {
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
HAL_Delay(500);
}
}
4.5 HAL structure and conventions
- Handle structure per peripheral (
UART_HandleTypeDef huart2,TIM_HandleTypeDef htim2,ADC_HandleTypeDef hadc1โฆ) holds the instance, init settings, state and (if used) DMA handles. - Init flow: fill
handle.Init.xxxโ callHAL_xxx_Init(&handle)โ the HAL callsHAL_xxx_MspInit()(a weak function, which you or CubeMX fill) to enable clocks, configure GPIO alternate functions, DMA and NVIC. - Three transfer styles for communication peripherals:
| Style | Example | CPU behaviour |
|---|---|---|
| Polling (blocking) | HAL_UART_Transmit() | CPU waits until done or timeout |
| Interrupt | HAL_UART_Transmit_IT() | Returns immediately; a callback runs when finished |
| DMA | HAL_UART_Transmit_DMA() | Returns immediately; DMA moves data; callback at completion |
- Return value:
HAL_StatusTypeDef=HAL_OK,HAL_ERROR,HAL_BUSY,HAL_TIMEOUT. Always check it. - Callbacks are
__weakfunctions you override, e.g.,HAL_UART_RxCpltCallback(),HAL_TIM_PeriodElapsedCallback(),HAL_GPIO_EXTI_Callback(),HAL_ADC_ConvCpltCallback(). - Interrupt flow in HAL:
xxx_IRQHandler()(instm32f4xx_it.c) โHAL_xxx_IRQHandler(&handle)โ clears flags and calls your callback. - Timeouts (ms) rely on
HAL_GetTick(). UseHAL_MAX_DELAYfor "wait forever".
4.6 STM32CubeMX / CubeIDE workflow
- Choose the MCU or board โ configure clock tree, pins (alternate functions), peripherals, DMA, NVIC priorities graphically.
- CubeMX generates
main.c,stm32f4xx_it.c,stm32f4xx_hal_msp.c, init functionsMX_xxx_Init()and the project. - Put your code only between
/* USER CODE BEGIN โฆ */and/* USER CODE END โฆ */markers, or regeneration will erase it. - Build, flash and debug via ST-LINK (SWD).
4.7 Clock tree basics (needed for timers, UART, ADC maths)
HSI (16 MHz internal RC) โโ
โโโบ PLL (รN รทM รทP) โโบ SYSCLK โโบ AHB prescaler โโบ HCLK (CPU, DMA, SysTick)
HSE (crystal/ST-LINK) โโโโโ โโโบ APB1 prescaler โโบ PCLK1 (slow peripherals)
โโโบ APB2 prescaler โโบ PCLK2 (fast peripherals)
SystemCoreClock= HCLK in Hz. SysTick uses it.- Timer clock rule (F4): if the APB prescaler is not 1, the timers on that bus run at 2 ร PCLK. (On Nucleo-F401RE at 84 MHz: PCLK1 = 42 MHz โ TIM2โ5 clock = 84 MHz; PCLK2 = 84 MHz โ TIM1/9/10/11 = 84 MHz.)
- Flash wait states (
FLASH_LATENCY) must increase with higher clock speed, and CubeMX sets this for you. - Peripheral clocks must be enabled (
__HAL_RCC_xxx_CLK_ENABLE()) before touching a peripheral's registers.
5. GPIO, Timers, PWM and ADC
5.1 GPIO
Each STM32 GPIO port (GPIOA, GPIOB, โฆ) has 16 pins, and each pin can be:
| Mode | Description |
|---|---|
| Input | Floating, pull-up, or pull-down |
| Output | Push-pull (drives 0 and 1) or open-drain (drives 0 only; needs pull-up, e.g., IยฒC) |
| Alternate function (AF) | Pin controlled by a peripheral (UART TX, SPI SCK, timer channelโฆ), selected with AF0โAF15 |
| Analog | Disconnects digital circuitry; used for ADC/DAC, and lowest leakage for unused pins |
F4 GPIO registers (per port)
| Register | Purpose |
|---|---|
| MODER | 2 bits per pin: 00 input, 01 output, 10 AF, 11 analog |
| OTYPER | Push-pull / open-drain |
| OSPEEDR | Output slew rate (low/medium/high/very high; faster = more EMI) |
| PUPDR | Pull-up / pull-down |
| IDR | Input data (read pins) |
| ODR | Output data (read-modify-write) |
| BSRR | Atomic bit set/reset (low 16 bits set, high 16 bits reset) |
| AFRL / AFRH | Alternate-function number for each pin |
| LCKR | Lock configuration |
โ Prefer BSRR (or
HAL_GPIO_WritePin) overODR |=, because BSRR is atomic, so an interrupt can't corrupt a read-modify-write.
HAL GPIO API
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
GPIO_PinState s = HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_13); // Nucleo user button (active-low)
Practical rules
- Enable the port clock before configuring pins.
- Most pins are 3.3 V; only pins marked "FT" in the datasheet are 5 V tolerant.
- Pin current limits are small (about 8โ25 mA per pin and a total limit per chip), so use drivers for loads.
External interrupts (EXTI)
- Pin n of any port can generate interrupt line EXTIn (e.g., PA0, PB0, PC0 all share EXTI0, but only one port per line number can be selected at a time via SYSCFG).
- Trigger: rising, falling or both edges.
- Vectors:
EXTI0_IRQnโฆEXTI4_IRQn(individual),EXTI9_5_IRQn(pins 5โ9 share one),EXTI15_10_IRQn(pins 10โ15 share one).
// Nucleo user button on PC13 (falling edge) โ configured as GPIO_MODE_IT_FALLING in CubeMX
void EXTI15_10_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_13); // clears the pending bit, calls the callback
}
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) // weak function in HAL
{
if (GPIO_Pin == GPIO_PIN_13) {
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
}
}
Remember to set the priority and enable the NVIC line (HAL_NVIC_SetPriority + HAL_NVIC_EnableIRQ), and debounce the button.
5.2 Timers
STM32 timers are powerful. Types (F4):
| Type | Examples | Counter | Features |
|---|---|---|---|
| Basic | TIM6, TIM7 | 16-bit | Pure time base, triggers DAC |
| General-purpose | TIM2โ5 (TIM2, TIM5 are 32-bit), TIM9โ14 | 16/32-bit | Input capture, output compare, PWM, encoder |
| Advanced-control | TIM1, TIM8 | 16-bit | Everything above + complementary outputs, dead-time, break input (motor/power control) |
Core registers
| Register | Meaning |
|---|---|
| PSC (prescaler) | Divides the timer clock by (PSC + 1) |
| ARR (auto-reload) | Counter counts up to ARR, then overflows (update event) |
| CNT | Current counter value |
| CCRx | Capture/compare value for channel x |
| CR1 | Enable (CEN), direction, ARR preload (ARPE) |
| DIER / SR | Interrupt enables / status flags |
Fundamental formula
f_counter = f_timer_clock / (PSC + 1)
f_update = f_timer_clock / ((PSC + 1) ร (ARR + 1))
period = (PSC + 1) ร (ARR + 1) / f_timer_clock
Example: timer interrupt every 1 s (TIM2 clock = 84 MHz)
- Choose
PSC = 8399โ counter clock = 84 MHz / 8400 = 10 kHz. - Choose
ARR = 9999โ 10 000 counts = 1 s. - Check: 84 000 000 / (8400 ร 10 000) = 1 Hz โ.
// In CubeMX: TIM2, internal clock, PSC=8399, ARR=9999, enable TIM2 global interrupt
HAL_TIM_Base_Start_IT(&htim2); // start timer with update interrupt
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM2) {
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); // runs every 1 s, in interrupt context
}
}
The vector TIM2_IRQHandler (in stm32f4xx_it.c) calls HAL_TIM_IRQHandler(&htim2).
Counter modes: up, down, center-aligned (up then down, used for symmetric PWM in motor control).
Other timer uses
- Input capture: latch CNT on an edge โ measure pulse width / frequency (
HAL_TIM_IC_Start_IT). - Output compare: toggle or set a pin when CNT = CCR.
- Encoder mode: counts quadrature encoder pulses in hardware (
HAL_TIM_Encoder_Start). - One-pulse mode, trigger other peripherals (e.g., start an ADC conversion at precise intervals).
5.3 PWM
PWM (Pulse Width Modulation) switches a pin on/off rapidly. The duty cycle (percentage of time ON) sets the average power. A timer generates it entirely in hardware, so the CPU is free.
- Counter counts 0 โ ARR; the output depends on comparing CNT with CCRx.
- PWM mode 1 (upcounting): output is HIGH while CNT < CCR, LOW otherwise. (Mode 2 is inverted.)
f_PWM = f_timer_clock / ((PSC + 1) ร (ARR + 1))
duty = CCR / (ARR + 1) (e.g., CCR = 250, ARR = 999 โ 25 %)
steps = ARR + 1 (resolution)
CNT ARR โค /| /| /|
โ / | / | / |
CCR โโโโโผโโ/โโโผโโ/โโโผโโ/โโโผโโ
โ / โ / โ / โ
0 โโโโโโโโโโโโโโโโโโโโโโโบ
OUT โโโโ โโโโโโโ โโโโโ
โโโโโโโ โโโโโโโ (HIGH while CNT < CCR)
Example: 1 kHz PWM, 1000 steps (timer clock 84 MHz, TIM2_CH1 on PA5 (AF1), which drives the Nucleo LED, so it dims!)
PSC = 83โ 1 MHz counter clock;ARR = 999โ 1 MHz / 1000 = 1 kHz.
// CubeMX: TIM2 โ Channel1 = "PWM Generation CH1", PSC = 83, ARR = 999, pulse = 0
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 250); // 25 % duty
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 750); // 75 % duty
// Fade loop
for (uint16_t d = 0; d <= 999; d += 5) {
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, d);
HAL_Delay(5);
}
Servo (50 Hz): PSC = 83 (1 MHz), ARR = 19999 (20 ms period); CCR = 1000โฆ2000 gives 1โ2 ms pulses (โ 0ยฐ to 180ยฐ).
Details to know
- Preload: with
OCxPE/ARPEenabled, new CCR/ARR values are applied at the next update event, which avoids glitches mid-period. - Advanced timers (TIM1/TIM8) need the Main Output Enable (MOE) bit set.
HAL_TIM_PWM_Start()does this. Complementary outputs (HAL_TIMEx_PWMN_Start) with dead-time insertion prevent shoot-through in half-bridges/inverters. - Higher PWM frequency โ fewer duty steps (resolution trade-off), exactly as the formula shows.
5.4 ADC
The STM32F4 ADC is a 12-bit successive-approximation (SAR) converter.
| Property | STM32F4 (typical) |
|---|---|
| Resolution | 12, 10, 8 or 6 bits (ADC_RESOLUTION_12Bโฆ) |
| Input range | 0 V to VREF+ (usually 3.3 V) |
| Channels | Up to 16 external + internal (temperature sensor, VREFINT, VBAT) |
| Max ADC clock | 36 MHz (from PCLK2 via prescaler /2, /4, /6, /8) |
| Modes | Single, continuous, scan (multiple channels in sequence), discontinuous |
| Triggers | Software, or timer/EXTI (for exact sampling intervals) |
| Data | 16-bit data register (left/right aligned), DMA support |
Digital value = (Vin / VREF+) ร (2^resolution โ 1) โ 12-bit: 0โฆ4095
Vin = raw ร VREF+ / 4095
Conversion time = (sampling cycles + 12) / f_ADC (12-bit)
Example: 84 sampling cycles at 21 MHz โ (84 + 12) / 21 MHz โ 4.6 ยตs.
Sampling time: the ADC needs time to charge its internal capacitor. High source impedance (e.g., a big potentiometer or a sensor) needs a longer sampling time, otherwise readings are wrong.
Polling example (single channel)
// CubeMX: ADC1, IN0 (PA0), 12-bit, sampling 84 cycles, single conversion
uint32_t read_adc(void)
{
HAL_ADC_Start(&hadc1);
if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) {
return HAL_ADC_GetValue(&hadc1);
}
return 0;
}
float volts = read_adc() * 3.3f / 4095.0f;
Continuous + DMA example (best for streaming)
#define N 64
volatile uint16_t adc_buf[N];
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, N); // DMA fills adc_buf continuously (circular)
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *h) { /* process adc_buf[0 .. N/2-1] */ }
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *h) { /* process adc_buf[N/2 .. N-1] */ }
Internal temperature sensor (F4 typical formula; check your datasheet)
Temperature (ยฐC) = ((V_sense โ V25) / Avg_Slope) + 25
with V25 โ 0.76 V and Avg_Slope โ 2.5 mV/ยฐC
ADC practice
- Put the analog pin in Analog mode.
- Reduce noise: stable VREF, short wires, a small capacitor at the input, oversampling/averaging.
- Some families (F1, L4, G4โฆ) need ADC calibration (
HAL_ADCEx_Calibration_Start) after power-up. - Trigger sampling from a timer when you need a fixed sample rate (audio, signal processing).
6. UART, SPI and IยฒC Drivers
All three follow the same HAL pattern: enable clocks โ configure pins as alternate function โ configure the peripheral (Init struct) โ transfer using polling / interrupt / DMA โ handle callbacks and errors.
| Feature | UART/USART | SPI | IยฒC |
|---|---|---|---|
| Wires | TX, RX | SCK, MOSI, MISO, CS (software GPIO) | SCL, SDA |
| Clock | Asynchronous | Master provides | Master provides |
| Pin mode | AF push-pull | AF push-pull | AF open-drain + pull-ups |
| F4 alternate function | AF7 (USART1/2/3) | AF5 (SPI1/2) | AF4 (I2C1/2/3) |
| HAL handle | UART_HandleTypeDef | SPI_HandleTypeDef | I2C_HandleTypeDef |
6.1 UART / USART
Key settings: baud rate, word length (8 or 9 bits), stop bits, parity, mode (TX/RX), hardware flow control, oversampling (16 or 8).
Baud rate = f_PCLK / (8 ร (2 โ OVER8) ร USARTDIV) โ with OVER8 = 0: f_PCLK / (16 ร USARTDIV)
HAL computes the baud-rate register (BRR) for you. Make sure the PCLK frequency is correct or the baud will be wrong. The error should be below ~2โ3 %.
USART2 on Nucleo (PA2 = TX, PA3 = RX, 115200 8N1) โ appears as a COM port on the PC
uint8_t msg[] = "Hello STM32\r\n";
// 1. Polling (blocking)
HAL_UART_Transmit(&huart2, msg, sizeof(msg) - 1, 100);
uint8_t c;
if (HAL_UART_Receive(&huart2, &c, 1, 100) == HAL_OK) { /* got a byte */ }
// 2. Interrupt-driven receive of one byte at a time
uint8_t rx_byte;
HAL_UART_Receive_IT(&huart2, &rx_byte, 1);
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART2) {
// process rx_byte ...
HAL_UART_Receive_IT(huart, &rx_byte, 1); // MUST re-arm for the next byte
}
}
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { /* TX done */ }
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { /* ORE, FE, NE, PE */ }
Variable-length receive with DMA + IDLE line detection (receive unknown-length packets efficiently):
uint8_t rx_buf[128];
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_buf, sizeof(rx_buf));
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
// 'Size' bytes were received (line went idle, or buffer half/full)
// process rx_buf[0..Size-1], then re-arm:
HAL_UARTEx_ReceiveToIdle_DMA(huart, rx_buf, sizeof(rx_buf));
}
(Available in newer HAL versions. Older ones use the IDLE flag manually.)
printf over UART (GCC/newlib)
#include <stdio.h>
int _write(int fd, char *ptr, int len)
{
HAL_UART_Transmit(&huart2, (uint8_t *)ptr, len, HAL_MAX_DELAY);
return len;
}
// printf("ADC = %lu\r\n", value); (end lines with \r\n for most terminals)
UART error flags: ORE (overrun: data not read in time, most common at high baud), FE (framing: wrong baud/noise), NE (noise), PE (parity). After an overrun, clear the flag and restart reception.
6.2 SPI
Key settings: mode (master/slave), CPOL/CPHA (clock polarity/phase โ SPI modes 0โ3, must match the device), data size (8/16-bit), baud-rate prescaler (SCK = PCLK / prescaler), bit order (MSB first usually), NSS (use software, controlling CS with a normal GPIO in master mode).
| SPI mode | CPOL | CPHA | Clock idle | Data sampled on |
|---|---|---|---|---|
| 0 | 0 | 0 | Low | Rising edge |
| 1 | 0 | 1 | Low | Falling edge |
| 2 | 1 | 0 | High | Falling edge |
| 3 | 1 | 1 | High | Rising edge |
HAL SPI API
HAL_SPI_Transmit(&hspi2, tx, len, timeout);
HAL_SPI_Receive(&hspi2, rx, len, timeout);
HAL_SPI_TransmitReceive(&hspi2, tx, rx, len, timeout); // full duplex
// also _IT and _DMA variants with HAL_SPI_TxCpltCallback / RxCpltCallback / TxRxCpltCallback
Example: read a register from an SPI sensor (mode 0, CS on PB12)
#define CS_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET)
#define CS_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET)
uint8_t spi_read_reg(uint8_t reg)
{
uint8_t tx[2] = { reg | 0x80, 0x00 }; // many chips: bit7 = 1 means "read"
uint8_t rx[2] = { 0 };
CS_LOW();
HAL_SPI_TransmitReceive(&hspi2, tx, rx, 2, 100);
CS_HIGH();
return rx[1]; // second byte clocked in = register value
}
Tips: keep CS HIGH when idle; add a pull-up on CS during boot; check for pin conflicts. For example, on the Nucleo-F401RE SPI1 SCK is PA5, which is also the user LED pin.
6.3 IยฒC
Key settings: clock speed (100 kHz standard, 400 kHz fast), addressing mode (7-bit/10-bit), own address (for slave). Newer families (L4, G0, G4, F7, H7) use a TIMINGR value that CubeMX calculates, while F1/F4 use ClockSpeed + duty cycle.
Wiring: SCL and SDA are open-drain and need pull-up resistors (4.7 kฮฉ typical, 2.2 kฮฉ for 400 kHz). Many breakout boards include them. Typical Nucleo pins: I2C1 SCL = PB8, SDA = PB9 (AF4).
The 7-bit address trap: the HAL expects the address shifted left by 1 (it leaves room for the R/W bit). A sensor with address 0x76 is used as 0x76 << 1 (= 0xEC).
#define SENSOR_ADDR (0x76 << 1)
// Scan the bus for devices
for (uint8_t a = 1; a < 128; a++) {
if (HAL_I2C_IsDeviceReady(&hi2c1, a << 1, 2, 10) == HAL_OK) {
printf("Found device at 0x%02X\r\n", a);
}
}
// Read 1 register (typical sensor: send register address, then read data)
uint8_t id;
HAL_I2C_Mem_Read(&hi2c1, SENSOR_ADDR, 0xD0, I2C_MEMADD_SIZE_8BIT, &id, 1, 100);
// Write 1 register
uint8_t val = 0x27;
HAL_I2C_Mem_Write(&hi2c1, SENSOR_ADDR, 0xF4, I2C_MEMADD_SIZE_8BIT, &val, 1, 100);
// Raw master transfers
HAL_I2C_Master_Transmit(&hi2c1, SENSOR_ADDR, buf, len, 100);
HAL_I2C_Master_Receive (&hi2c1, SENSOR_ADDR, buf, len, 100);
// Also _IT and _DMA variants, with HAL_I2C_MasterTxCpltCallback / MasterRxCpltCallback / ErrorCallback
Common IยฒC problems
| Symptom | Cause / fix |
|---|---|
HAL_ERROR / no ACK (AF flag) | Wrong address (forgot << 1), device unpowered, wires swapped |
Bus stuck, HAL_BUSY forever | A slave holds SDA low after an interrupted transfer. Toggle SCL up to 9 times (bit-bang recovery), then re-init the peripheral |
| Works at 100 kHz, fails at 400 kHz | Weak/missing pull-ups, long wires |
| Two sensors conflict | Same address; change the address pin or use a second IยฒC bus |
6.4 Driver design advice
- Always check
HAL_StatusTypeDef. Never ignore a timeout or error. - Use interrupt/DMA for long or background transfers; use polling only for short, simple ones.
- Keep callbacks short, just set flags or post to an RTOS queue.
- Don't start a new HAL transfer on the same handle while it is still
BUSY.
7. DMA and Memory Layout
7.1 DMA (Direct Memory Access)
DMA is a hardware engine that moves data without using the CPU: peripheral โ memory, or memory โ memory. The CPU just configures the transfer and gets an interrupt when it completes (or half-completes).
Why use it? Sending 1 KB over UART at 115200 baud takes ~90 ms. With polling, the CPU is blocked all that time. With DMA, the CPU is free and only gets one interrupt at the end. For fast ADC sampling or large SPI transfers, DMA is essentially mandatory.
Without DMA: CPU โโreadโโโบ Peripheral โโโบ CPU โโwriteโโโบ Memory (CPU in the loop for every byte)
With DMA: Peripheral โโโโโโโโโโโบ DMA โโโโโโโโโโโบ Memory (CPU only sets up and is notified)
STM32F4 DMA structure
- DMA1 and DMA2, each with 8 streams; each stream can be connected to one of 8 channels (request mappings). The mapping of "which peripheral uses which DMA stream/channel" is a fixed table in the reference manual (e.g., USART2_RX โ DMA1 Stream 5 Channel 4; ADC1 โ DMA2 Stream 0 Channel 0).
- Only DMA2 can do memory-to-memory transfers.
- Newer families (G0, G4, L4, H7โฆ) use DMAMUX, which lets any request be routed to any channel, so there is no fixed table.
- Each stream has FIFO (F4), priority (low/medium/high/very high), and generates Half-Transfer (HT), Transfer-Complete (TC) and Transfer-Error (TE) interrupts.
Configuration parameters
| Parameter | Options / meaning |
|---|---|
| Direction | PeripheralโMemory, MemoryโPeripheral, MemoryโMemory |
| Mode | Normal (stops after N items) or Circular (restarts automatically, ideal for continuous ADC/UART RX) |
| Peripheral increment | Usually disabled (a peripheral data register has a fixed address) |
| Memory increment | Usually enabled (walk through the buffer) |
| Data width | Byte / Half-word / Word, on both sides; must match the peripheral register and buffer type |
| Number of data items (NDTR) | How many transfers |
| Priority | Arbitration between streams |
HAL link between peripheral and DMA CubeMX generates the DMA init and ties it in the MSP init with a macro:
__HAL_LINKDMA(&huart2, hdmarx, hdma_usart2_rx);
and the DMA interrupt handlers (DMA1_Stream5_IRQHandler โ HAL_DMA_IRQHandler(&hdma_usart2_rx)). Order matters in main(): initialize DMA before the peripherals that use it (MX_DMA_Init() before MX_USART2_UART_Init()). Otherwise DMA transfers silently fail.
Examples
// UART TX with DMA
HAL_UART_Transmit_DMA(&huart2, (uint8_t *)msg, strlen(msg));
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { /* buffer can be reused */ }
// ADC circular DMA (see ยง5.4)
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, N);
// Memory-to-memory copy (DMA2 only)
HAL_DMA_Start_IT(&hdma_memtomem, (uint32_t)src, (uint32_t)dst, word_count);
DMA pitfalls
| Pitfall | Explanation / fix |
|---|---|
| Buffer must stay valid | Don't use a stack variable that goes out of scope. Use a global/static buffer |
| Shared data with CPU | Mark buffers volatile and use half/full-complete callbacks (double-buffering) so the CPU reads only the half DMA is not writing |
| Data width mismatch | A 16-bit ADC result needs Half-word on both sides (use uint16_t buffer) |
| Not all RAM is reachable | On some devices certain memory (e.g., CCM/DTCM) is not accessible by DMA. Check the reference manual |
| Cache coherency (Cortex-M7 only) | With the D-cache enabled the CPU and DMA can see different data. Before DMA reads from memory, clean the cache (SCB_CleanDCache_by_Addr); after DMA writes, invalidate it (SCB_InvalidateDCache_by_Addr), or place DMA buffers in a non-cacheable MPU region. Align buffers to the 32-byte cache line (__attribute__((aligned(32)))) |
| Forgetting to re-arm | In normal mode, DMA stops after N items and must be restarted |
7.2 Memory layout of an STM32 program
STM32F401RE physical memory
| Region | Address | Size | Use |
|---|---|---|---|
| Flash | 0x0800_0000 | 512 KB | Code, constants, initial values of globals |
| SRAM | 0x2000_0000 | 96 KB | Variables, stack, heap |
| System memory | 0x1FFF_0000 | ~30 KB | Factory ST bootloader |
| Peripherals | 0x4000_0000โฆ | APB1, APB2, AHB1, AHB2 registers | |
| Core peripherals | 0xE000_E000โฆ | SysTick, NVIC, SCB |
(Some F4 devices also have CCM RAM at 0x1000_0000, reachable only by the CPU, not DMA.)
Boot pins: the BOOT0 pin (with the option bytes) selects what appears at address 0: main flash (BOOT0 = 0, normal), system memory (BOOT0 = 1, serial/USB bootloader) or SRAM.
7.3 Where does each C object live?
| C construct | Section | Stored in | Notes |
|---|---|---|---|
| Machine code | .text | Flash | |
const data, string literals | .rodata | Flash | Saves RAM |
Initialized globals/statics (int a = 5;) | .data | RAM (initial values copied from flash at startup) | Costs both flash and RAM |
| Uninitialized/zero globals/statics | .bss | RAM (zeroed at startup) | |
| Local (automatic) variables, function call frames | Stack | RAM, grows downward | Per-task in an RTOS |
malloc() memory | Heap | RAM, grows upward | Avoid or limit in embedded |
| Vector table | .isr_vector | Start of flash |
RAM (0x2000_0000 โฆ) Flash (0x0800_0000 โฆ)
high โโโโโโโโโโโโโโโโโ โโโ _estack (initial MSP) โโโโโโโโโโโโโโโโโโโโ
โ Stack โ grows โ โ .isr_vector โ
โ โ โ .text (code) โ
โ (free gap) โ โ stack and heap collide โ .rodata โ
โ โ if this reaches 0 โ .data (init copy)โ โโ copied to RAM at reset
โ Heap โ grows โ โโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโค
โ .bss โ
โโโโโโโโโโโโโโโโโค
low โ .data โ
โโโโโโโโโโโโโโโโโ
7.4 The linker script
The linker script (STM32F401RETx_FLASH.ld) tells the linker the memory sizes and where each section goes.
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 96K
}
_estack = ORIGIN(RAM) + LENGTH(RAM); /* top of RAM = initial stack pointer */
_Min_Heap_Size = 0x200; /* linker errors if RAM can't fit these */
_Min_Stack_Size = 0x400;
SECTIONS
{
.isr_vector : { KEEP(*(.isr_vector)) } >FLASH
.text : { *(.text*) *(.rodata*) } >FLASH
_sidata = LOADADDR(.data); /* where .data initial values sit in flash */
.data : { _sdata = .; *(.data*) _edata = .; } >RAM AT> FLASH
.bss : { _sbss = .; *(.bss*) *(COMMON) _ebss = .; } >RAM
/* ._user_heap_stack reserves heap + stack space */
}
- VMA vs LMA:
.datahas a load address (LMA) in flash but a run address (VMA) in RAM, because the startup code copies it (_sidataโ_sdata โฆ _edata) and zeroes_sbss โฆ _ebss. - Symbols like
_estack,_sdata,_edata,_sbss,_ebssare used bystartup_xxx.s.
Useful tools and attributes
arm-none-eabi-size firmware.elf # prints text / data / bss sizes
# Flash used = text + data; RAM used = data + bss (+ heap + stack reserve)
Also inspect the .map file to see exactly where every symbol was placed.
uint8_t dma_buf[256] __attribute__((aligned(32))); // alignment (cache lines)
int fast_data[100] __attribute__((section(".ccmram"))); // put into a custom section/region
const uint8_t table[] = {1, 2, 3}; // const โ flash, no RAM used
Memory problems to watch
- Stack overflow / stack-heap collision: writes beyond the stack silently corrupt
.bss/.dataโ weird bugs or HardFaults. Reserve enough stack, avoid large local arrays and deep recursion, and check with-fstack-usage. - Heap fragmentation from repeated
malloc/free: prefer static allocation. - Alignment: Cortex-M3/M4/M7 handle most unaligned accesses (but not LDM/STM/LDRD, and they can be slower), while Cortex-M0/M0+ fault on any unaligned access. Use properly aligned types.
- Relocating the vector table (for a bootloader + application): set
SCB->VTOR = 0x08008000;(must be aligned to the table size) and place the app at that flash address in its linker script. - Flash sectors on F4 are uneven in size (e.g., 4 ร 16 KB, 1 ร 64 KB, then 128 KB), and a sector must be erased before it is re-programmed.
8. HardFault Debugging
A fault is an exception caused by the program itself doing something illegal. When it happens, the CPU jumps to a fault handler, and if you didn't handle it, the default handler is an infinite loop and your device seems to "freeze". Learning to read the fault registers turns that mystery into a precise diagnosis.
8.1 Fault types
| Fault | Cause |
|---|---|
| MemManage (MemFault) | MPU violation or access to an invalid/forbidden region (e.g., executing from a no-execute region) |
| BusFault | Error on the bus while accessing memory or peripherals (invalid address, unclocked peripheral on some devices, access errors) |
| UsageFault | Undefined instruction, invalid state (Thumb bit clear), invalid EXC_RETURN, no coprocessor (FPU disabled), unaligned access (if trapped), divide-by-zero (if trapped) |
| HardFault | Catch-all: (a) a fault occurred while handling another fault, (b) a fault whose own handler is disabled or can't run because of priority (escalation), (c) a vector-table read error |
By default only HardFault is enabled on reset. Enable the detailed ones to get more information:
SCB->SHCSR |= SCB_SHCSR_USGFAULTENA_Msk | SCB_SHCSR_BUSFAULTENA_Msk | SCB_SHCSR_MEMFAULTENA_Msk;
SCB->CCR |= SCB_CCR_DIV_0_TRP_Msk; // optional: fault on integer divide-by-zero
// SCB->CCR |= SCB_CCR_UNALIGN_TRP_Msk; // optional: fault on any unaligned access
Lockup: a fault inside the HardFault handler puts the core in an unrecoverable lockup state (only reset/watchdog escapes). A buggy fault handler is therefore doubly dangerous.
8.2 Fault status registers (System Control Block)
| Register | Address | Contents |
|---|---|---|
| CFSR (Configurable Fault Status) | 0xE000ED28 | Combination of MMFSR (byte 0), BFSR (byte 1), UFSR (bytes 2โ3) |
| HFSR (HardFault Status) | 0xE000ED2C | Bit 30 FORCED (escalated from a configurable fault, so look at CFSR), bit 1 VECTTBL (vector fetch failed) |
| MMFAR | 0xE000ED34 | Faulting address for MemManage (valid if MMARVALID = 1) |
| BFAR | 0xE000ED38 | Faulting address for BusFault (valid if BFARVALID = 1) |
| DFSR / AFSR | Debug / auxiliary fault info |
CFSR bit meanings (the ones you will see most)
| Bit | Name | Meaning |
|---|---|---|
| 0 | IACCVIOL | Instruction fetch from a forbidden/XN region |
| 1 | DACCVIOL | Data access violation (MPU) |
| 3 / 4 | MUNSTKERR / MSTKERR | MemManage fault during exception unstack / stack |
| 7 | MMARVALID | MMFAR holds a valid address |
| 8 | IBUSERR | Bus error on instruction fetch |
| 9 | PRECISERR | Precise data bus error: the stacked PC points at the faulting instruction; BFAR has the address |
| 10 | IMPRECISERR | Imprecise data bus error (a buffered write failed; the PC is after the real culprit) |
| 11 / 12 | UNSTKERR / STKERR | Bus fault during unstack / stack (often a stack overflow / bad SP) |
| 15 | BFARVALID | BFAR holds a valid address |
| 16 | UNDEFINSTR | Undefined instruction (e.g., executing data, corrupt function pointer) |
| 17 | INVSTATE | Invalid EPSR state: Thumb bit = 0 (e.g., jumping to an address with bit 0 clear, calling a bad function pointer) |
| 18 | INVPC | Invalid EXC_RETURN / PC load |
| 19 | NOCP | Coprocessor access without enabling: FPU used but not enabled |
| 24 | UNALIGNED | Unaligned access with trapping enabled |
| 25 | DIVBYZERO | Integer divide-by-zero with trapping enabled |
8.3 Common causes (checklist)
| Typical bug | Likely fault |
|---|---|
| Stack overflow (huge locals, deep recursion, small RTOS task stack) | BusFault/MemManage (STKERR), or silent corruption leading to later crash |
| NULL / wild pointer dereference: writes to bad addresses or access to unmapped regions | BusFault (PRECISERR with BFAR = bad address) |
| Calling a NULL / corrupted function pointer or returning with a smashed return address | UsageFault INVSTATE / UNDEFINSTR / IACCVIOL |
FPU not enabled (float code on M4F without `SCB->CPACR | = 0xF << 20;, normally done by SystemInit`) |
Unaligned access on Cortex-M0, or LDM/STM/LDRD on M3/M4 | HardFault / UNALIGNED |
| Accessing a peripheral whose clock is not enabled | On some families a BusFault or HardFault (others read 0 silently) |
| Wrong EXC_RETURN / corrupted stack frame | INVPC |
| Interrupt with no handler | Not a fault. It jumps to Default_Handler (infinite loop), so the program hangs |
| Writing to flash/ROM address as RAM | BusFault |
| Buffer overflow overwriting adjacent variables or return addresses | Any of the above, often later and far from the bug |
Imprecise faults: because of the write buffer, the CPU may carry on a few instructions before the bus error arrives, so the stacked PC is not exact. For debugging, you can disable write buffering (
SCB->ACTLR |= SCnSCB_ACTLR_DISDEFWBUF_Msk;) so every fault becomes precise.
8.4 Capturing the exception frame
At fault time the stacked registers (R0โR3, R12, LR, PC, xPSR) tell you what was running and where. The stacked PC is the address of the instruction that faulted (or just after it, for imprecise faults).
You must first work out which stack was in use (MSP or PSP) by testing bit 2 of LR (EXC_RETURN): 0 = MSP, 1 = PSP.
// HardFault entry: a tiny naked trampoline that picks the right stack pointer
__attribute__((naked)) void HardFault_Handler(void)
{
__asm volatile (
"tst lr, #4 \n" // test EXC_RETURN bit 2
"ite eq \n"
"mrseq r0, msp \n" // bit2 = 0 โ MSP was used
"mrsne r0, psp \n" // bit2 = 1 โ PSP was used
"b hardfault_c \n" // pass the frame pointer in R0 to the C function
);
}
void hardfault_c(uint32_t *frame)
{
volatile uint32_t r0 = frame[0];
volatile uint32_t r1 = frame[1];
volatile uint32_t r2 = frame[2];
volatile uint32_t r3 = frame[3];
volatile uint32_t r12 = frame[4];
volatile uint32_t lr = frame[5]; // return address of the function that was running
volatile uint32_t pc = frame[6]; // โ THE FAULTING INSTRUCTION
volatile uint32_t psr = frame[7];
volatile uint32_t cfsr = SCB->CFSR;
volatile uint32_t hfsr = SCB->HFSR;
volatile uint32_t mmfar = SCB->MMFAR;
volatile uint32_t bfar = SCB->BFAR;
(void)r0; (void)r1; (void)r2; (void)r3; (void)r12;
(void)lr; (void)pc; (void)psr; (void)cfsr; (void)hfsr; (void)mmfar; (void)bfar;
__BKPT(0); // stops here if a debugger is attached, so inspect the variables above
while (1); // or log to a retained RAM area, then NVIC_SystemReset()
}
8.5 Step-by-step debugging procedure
- Run under the debugger (SWD). When the fault occurs, halt at the
__BKPTor at the infinite loop in the handler. - Read CFSR โ decode bits using the table in ยง8.2 โ the type of error.
- If
BFARVALID/MMARVALIDis set โ BFAR / MMFAR is the bad address (0x0 or a small number means NULL pointer; a peripheral/unmapped address means a wrong register or unclocked block). - Locate the code: take the stacked PC, then map it to the source line.
- In the debugger: open the Disassembly view and go to that address, or
- On the command line:
arm-none-eabi-addr2line -e firmware.elf -f -i 0x0800'12AC # (type it without the ') - Or look it up in the
.mapfile / disassembly (arm-none-eabi-objdump -d).
- Check the stacked LR to see who called the function. Then use the debugger's call stack, or step back through return addresses. Check R0โR3 for suspicious argument values (NULL, 0xFFFFFFFF, 0xDEADBEEF).
- Check the stack pointer against the RAM limits. If SP is outside the stack region or inside
.bss, suspect stack overflow. - Reproduce with less optimization (
-O0/-Og) for easier line mapping, then confirm with the normal build. - Fix, then add prevention (below).
IDE helpers: STM32CubeIDE has a "Fault Analyzer" view that decodes these registers automatically after a fault. Other tools: Cortex-Debug (VS Code), Keil/IAR fault viewers, SEGGER Ozone/SystemView.
8.6 Prevention techniques
- Always check return codes and pointers before use; add
assert()-style checks (HAL hasassert_param, enabled withUSE_FULL_ASSERT). - Stack monitoring: paint the stack with a pattern and check the high-water mark; compile with
-fstack-usage; use-fstack-protector. - MPU guard region: configure a no-access region just below each stack so an overflow triggers an immediate MemManage fault instead of silent corruption.
- RTOS stack overflow hooks (see ยง9).
- Enable all fault handlers (MemManage/BusFault/UsageFault) and optionally divide-by-zero trapping early in
main(). - Mark ISR/DMA-shared variables
volatile, and be careful with casts and alignment when treating byte buffers as structs/words. - Use a watchdog plus saving fault info (PC, CFSR) to backup/retained RAM so field failures can be diagnosed after reset.
- Compile with warnings on (
-Wall -Wextra) and use static analysis.
9. FreeRTOS on Cortex-M
FreeRTOS is the most widely used RTOS on Cortex-M (STM32CubeMX can add it directly, often through the CMSIS-RTOS v2 wrapper). Cortex-M hardware was designed with RTOSes in mind, and FreeRTOS uses three core features:
| Cortex-M feature | Used by FreeRTOS for |
|---|---|
| SysTick | The periodic RTOS tick (default 1 ms) that drives delays, timeouts and time slicing |
| PendSV (lowest priority) | The context switch itself, which runs only when no other ISR is active |
| SVC | Starting the first task (vTaskStartScheduler) |
| MSP / PSP | Kernel + ISRs on MSP, each task on its own PSP stack |
| BASEPRI (M3/M4/M7) | Critical sections that mask only interrupts at or below a threshold (M0 uses PRIMASK) |
9.1 How a context switch works
Task A running (using PSP_A)
โ tick interrupt (SysTick) or a task yields / unblocks a higher-priority task
โผ
PendSV exception pended (lowest priority, so it waits for other ISRs to finish)
โผ
PendSV_Handler:
1. hardware already pushed R0-R3, R12, LR, PC, xPSR onto task A's stack
2. software pushes R4โR11 (and S16โS31 if FPU used) onto task A's stack
3. save A's stack pointer into A's TCB (Task Control Block)
4. scheduler picks the highest-priority READY task (Task B)
5. load B's saved stack pointer into PSP, pop R4โR11
6. return from exception โ hardware pops the rest โ Task B continues
9.2 Interrupt priority configuration: the critical part
FreeRTOS uses two constants in FreeRTOSConfig.h:
| Constant | Meaning |
|---|---|
configKERNEL_INTERRUPT_PRIORITY | Priority of SysTick and PendSV: always the lowest priority (e.g., 15 << 4 = 0xF0 on STM32) |
configMAX_SYSCALL_INTERRUPT_PRIORITY | The highest priority from which FreeRTOS API calls are allowed, e.g., 5 << 4 = 0x50 (that's priority 5) |
Priority number (lower = more urgent):
0 1 2 3 4 โ 5 6 7 8 ... 14 15
โโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโ
"above the "FreeRTOS-managed" interrupts:
RTOS line": may call xxxFromISR() APIs
NEVER masked โโโ configMAX_SYSCALL_INTERRUPT_PRIORITY (= 5)
by the kernel โโโ 15 = configKERNEL_INTERRUPT_PRIORITY (SysTick, PendSV)
โ must NOT call
ANY FreeRTOS API
Rules
- Set
NVIC_PRIORITYGROUP_4(all bits are preemption priority, no sub-priority). FreeRTOS assumes this. - An ISR that calls any FreeRTOS
...FromISR()function must have a numerical priority โฅconfigMAX_SYSCALL_INTERRUPT_PRIORITY(in numbers: โฅ 5, i.e., equally or less urgent). - ISRs with a numerically smaller priority (more urgent) are never delayed by the kernel, but they must not call any FreeRTOS API.
- Don't forget FreeRTOS priorities (for tasks) are the opposite convention: a higher number = a higher task priority (0 = idle).
๐ด Number-one cause of FreeRTOS-on-STM32 crashes: an ISR that calls
xQueueSendFromISR()etc. but has a too-urgent priority (e.g., 0โ4 when the limit is 5). EnableconfigASSERT(), which catches this immediately instead of causing mysterious faults.
9.3 Important configuration items (FreeRTOSConfig.h)
| Setting | Typical | Note |
|---|---|---|
configCPU_CLOCK_HZ | SystemCoreClock | Used to set the SysTick reload |
configTICK_RATE_HZ | 1000 | 1 ms tick |
configMAX_PRIORITIES | 5โ7 | Number of task priority levels |
configMINIMAL_STACK_SIZE | 128 | In words (4 bytes) for FreeRTOS APIs |
configTOTAL_HEAP_SIZE | e.g., 15 KB | RAM pool used by heap_4.c (what pvPortMalloc uses). Counts against your RAM |
configUSE_PREEMPTION | 1 | Preemptive scheduling |
configCHECK_FOR_STACK_OVERFLOW | 2 | Enables the overflow hook |
configUSE_MUTEXES, configUSE_COUNTING_SEMAPHORES, configUSE_TIMERS | 1 | Enable features |
configASSERT(x) | defined | Always enable during development |
configUSE_TICKLESS_IDLE | optional | Stops the tick and sleeps with WFI in idle for low power |
โ ๏ธ Stack-size units: raw FreeRTOS
xTaskCreate()takes the stack size in words (128 = 512 bytes), while the CMSIS-RTOS v2 wrapper (osThreadNew, as generated by CubeMX) takes it in bytes.
9.4 HAL timebase vs RTOS tick
Both HAL and FreeRTOS want SysTick. When FreeRTOS is used, CubeMX switches the HAL timebase source to another timer (e.g., TIM6/TIM7/TIM11), and that timer's interrupt calls HAL_IncTick(). Otherwise HAL_Delay() and the RTOS tick would conflict (and HAL_Delay() inside a task would be wrong anyway, so use vTaskDelay() / osDelay()).
9.5 First FreeRTOS program
#include "FreeRTOS.h"
#include "task.h"
#include "main.h"
void vBlinkTask(void *pv)
{
for (;;) {
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
vTaskDelay(pdMS_TO_TICKS(500)); // block 500 ms, other tasks run
}
}
void vPrintTask(void *pv)
{
for (;;) {
printf("tick = %lu\r\n", (unsigned long)xTaskGetTickCount());
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART2_UART_Init();
xTaskCreate(vBlinkTask, "blink", 128, NULL, 1, NULL); // stack = 128 words = 512 B
xTaskCreate(vPrintTask, "print", 256, NULL, 2, NULL);
vTaskStartScheduler(); // never returns if all is OK
while (1) {} // reached only if heap is too small
}
9.6 Synchronization tools (quick reference)
| Tool | Use | ISR-safe call |
|---|---|---|
| Queue | Pass data between tasks / from ISR to task | xQueueSendFromISR, xQueueReceiveFromISR |
| Binary semaphore | Signal an event (ISR โ task) | xSemaphoreGiveFromISR |
| Counting semaphore | Count events / manage N resources | xSemaphoreGiveFromISR |
| Mutex | Protect a shared resource (e.g., an SPI bus or UART). Priority inheritance avoids priority inversion | Not for use in ISRs |
| Event group | Wait on combinations of flags | xEventGroupSetBitsFromISR |
| Task notification | Fastest lightweight signal | vTaskNotifyGiveFromISR |
| Software timer | Callback after a delay or periodically (runs in the timer task) |
Pattern: UART interrupt โ queue โ processing task
QueueHandle_t rxQueue;
uint8_t rx_byte;
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) // runs in ISR context
{
BaseType_t woken = pdFALSE;
xQueueSendFromISR(rxQueue, &rx_byte, &woken);
HAL_UART_Receive_IT(huart, &rx_byte, 1); // re-arm
portYIELD_FROM_ISR(woken); // switch immediately if a higher-priority task woke
}
void vParserTask(void *pv)
{
uint8_t ch;
for (;;) {
if (xQueueReceive(rxQueue, &ch, portMAX_DELAY) == pdTRUE) {
// process the character safely in task context
}
}
}
// in main(): rxQueue = xQueueCreate(64, sizeof(uint8_t));
// HAL_UART_Receive_IT(&huart2, &rx_byte, 1);
// and USART2's NVIC priority must be >= configMAX_SYSCALL_INTERRUPT_PRIORITY (e.g., 5..15)
Pattern: mutex around a shared SPI bus
SemaphoreHandle_t spiMutex; // created with xSemaphoreCreateMutex()
void spi_safe_transfer(uint8_t *tx, uint8_t *rx, uint16_t n)
{
if (xSemaphoreTake(spiMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
CS_LOW();
HAL_SPI_TransmitReceive(&hspi2, tx, rx, n, 100);
CS_HIGH();
xSemaphoreGive(spiMutex); // always release
}
}
9.7 Debugging aids
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
{
// called when a task's stack overflow is detected (configCHECK_FOR_STACK_OVERFLOW = 2)
__BKPT(0);
for (;;);
}
void vApplicationMallocFailedHook(void)
{
// pvPortMalloc / xTaskCreate ran out of FreeRTOS heap
__BKPT(0);
for (;;);
}
UBaseType_t spare = uxTaskGetStackHighWaterMark(NULL); // minimum free stack (in words) ever seen
Use uxTaskGetStackHighWaterMark() to tune stack sizes (if the value gets near 0, increase that task's stack). Tools such as SEGGER SystemView/Ozone and IDE RTOS-aware views show tasks, stacks and CPU load.
9.8 Common FreeRTOS-on-Cortex-M problems
| Symptom | Cause | Fix |
|---|---|---|
HardFault at vTaskStartScheduler / immediately | Not enough heap, wrong handler names (SVC_Handler, PendSV_Handler, SysTick_Handler not mapped to the RTOS ones) | Increase configTOTAL_HEAP_SIZE; check FreeRTOSConfig.h maps vPortSVCHandler, xPortPendSVHandler, xPortSysTickHandler |
Random crashes / configASSERT fails in an ISR | ISR priority more urgent than configMAX_SYSCALL_INTERRUPT_PRIORITY while calling the RTOS API | Lower its priority (raise the number), or stop calling RTOS functions from it |
| Priority grouping wrong | Not NVIC_PRIORITYGROUP_4 | Set it in HAL_Init() / CubeMX |
| Task stack overflow | Stack too small (printf, floats and HAL calls use a lot) | Increase stack; check the high-water mark and overflow hook |
HAL_Delay() hangs or timing is wrong | HAL timebase on SysTick conflicts with the RTOS | Move the HAL timebase to a spare timer; use vTaskDelay in tasks |
| Floating-point corruption in tasks | FPU context not saved (misconfigured port) | Use the correct port (ARM_CM4F) and enable the FPU in startup |
| Everything locks up | Deadlock (mutex held while waiting for another), or task never blocks โ starves lower priorities | Take locks in a consistent order; use timeouts; always include a blocking call (vTaskDelay, queue wait) in loops |
| Using a mutex from an ISR | Not allowed | Use a semaphore, queue or task notification |
10. Quick Revision Cheat Sheet
Core model
| Item | Fact |
|---|---|
| Registers | R0โR12 (general), R13 SP (MSP/PSP), R14 LR, R15 PC, xPSR, PRIMASK, FAULTMASK, BASEPRI, CONTROL |
| Modes | Thread (app) and Handler (exceptions, always privileged) |
| Reset behaviour | Vector table word 0 โ MSP, word 1 โ PC (Reset_Handler) |
| Exception stacking | 8 words: R0โR3, R12, LR, PC, xPSR |
| EXC_RETURN | 0xFFFFFFF1 handler/MSP ยท 0xFFFFFFF9 thread/MSP ยท 0xFFFFFFFD thread/PSP |
| ISR in C | Plain function, no special keyword |
Exception numbers: 1 Reset ยท 2 NMI ยท 3 HardFault ยท 4 MemManage ยท 5 BusFault ยท 6 UsageFault ยท 11 SVCall ยท 14 PendSV ยท 15 SysTick ยท 16+ IRQs
Priority: lower number = more urgent ยท STM32 has 4 bits (0โ15) ยท use PRIORITYGROUP_4 ยท CMSIS NVIC_SetPriority / HAL HAL_NVIC_SetPriority.
SysTick: 24-bit down counter ยท SysTick_Config(SystemCoreClock / 1000) = 1 ms ยท HAL: HAL_IncTick() in SysTick_Handler.
Layers: CMSIS (core + device header) โ LL โ HAL โ application.
Timer maths: f = f_clk / ((PSC+1)(ARR+1)) ยท PWM duty = CCR/(ARR+1) ยท APB timer clock is ร2 when the APB prescaler โ 1.
ADC: 12-bit ยท V = raw ร 3.3 / 4095 ยท longer sampling for high-impedance sources ยท DMA circular for streaming.
HAL transfer styles: blocking HAL_xxx() ยท interrupt HAL_xxx_IT() ยท DMA HAL_xxx_DMA() ยท callbacks HAL_xxx_CpltCallback().
IยฒC: pull-ups (4.7 kฮฉ) ยท address << 1 in HAL ยท HAL_I2C_Mem_Read/Write for registers.
DMA: peripheral address fixed, memory increments ยท normal vs circular ยท init DMA before peripherals ยท M7 cache clean/invalidate.
Memory: .text/.rodata โ flash ยท .data โ flash (copied) + RAM ยท .bss โ RAM (zeroed) ยท stack โ from _estack ยท heap โ ยท check with arm-none-eabi-size.
HardFault checklist
- Get the frame (MSP/PSP by LR bit 2) โ stacked PC, LR.
- Read CFSR, HFSR, BFAR/MMFAR.
- Map PC to source with
addr2line/ disassembly. - Check for stack overflow, bad pointers, FPU not enabled, wrong function pointer (Thumb bit).
FreeRTOS on Cortex-M: SysTick = tick ยท PendSV = context switch ยท SVC = start ยท tasks on PSP, ISRs on MSP ยท ISR priority โฅ configMAX_SYSCALL_INTERRUPT_PRIORITY to call ...FromISR() ยท PRIORITYGROUP_4 ยท enable configASSERT and stack-overflow hooks ยท stack units: words (FreeRTOS) vs bytes (CMSIS-RTOS2).
โ๏ธ Learning tip: Progress in this order on a real Nucleo board: (1) blink an LED at register level, then with HAL; (2) add SysTick and a button interrupt; (3) PWM fade and ADC reading; (4) UART printf, then an SPI/IยฒC sensor; (5) move UART and ADC to DMA; (6) deliberately cause a HardFault (e.g., call a NULL function pointer) and decode it using CFSR and the stacked PC; (7) finally put everything into FreeRTOS tasks and queues. Doing the fault-debugging exercise yourself is the single most valuable step for becoming a confident Cortex-M developer.
๐งญ Continue Learning
Related technical blueprints and practical guides in Firmware & Embedded Systems.

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

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

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

Interrupts and Communication Protocols
Interrupts and Exception Handling, UART / USART Serial Communication, SPI โ Serial Peripheral Interface, IยฒC โ Inter-Integrated Circuit, Other Communication Interfaces