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

#Interrupts and Exception Handling#
How to use these notes: Read top to bottom the first time. Each section builds on the one before it. The code snippets are written in generic embedded C, so register and function names (like
__disable_irq()orTIMER_FLAG) will differ on your chip. The ideas stay the same everywhere.
Table of Contents
- Polling vs Interrupt-Driven Programming
- Interrupt Service Routines (ISR)
- Interrupt Vector Tables
- Enabling and Disabling Interrupts
- Interrupt Priority and Nesting
- Edge-Triggered vs Level-Triggered Interrupts
- Interrupt Latency and Response Time
- Shared Data Between ISR and Main Code
volatileVariables in Interrupt Handlers- Atomic Access and Critical Sections
- ISR Best Practices and Restrictions
- Race Conditions and Lost Events
- Deferred Interrupt Processing
- Interrupt-Driven State Machines
- Quick Revision Cheat Sheet
The Big Picture
Imagine you are cooking and also waiting for a parcel.
- Option A: Walk to the door every 30 seconds to check if the delivery person has arrived. You get nothing else done properly.
- Option B: Keep cooking. When the doorbell rings, pause what you are doing, answer the door, then go back to exactly where you left off.
Option A is polling. Option B is an interrupt. An interrupt is a hardware signal that says, "Stop what you're doing, something needs attention right now." The CPU saves its place, runs a small piece of code that handles the event, and then returns to the main program as if nothing happened.
An exception is the same mechanism, but usually triggered by the CPU itself when something unusual happens (for example, a fault or an illegal instruction), rather than by an external peripheral. Both are handled through the same machinery: a handler function found through a table.
1. Polling vs Interrupt-Driven Programming
Polling
The main code repeatedly checks a flag or pin to see whether something has happened.
while (1) {
if (BUTTON_PIN == 0) { // keep asking: is the button pressed?
handle_button();
}
do_other_work(); // but only runs between checks
}
Pros
- Simple to write and easy to debug.
- Predictable flow: you always know where the code is.
- No shared-data problems with an ISR.
Cons
- Wastes CPU time asking the same question over and over.
- If
do_other_work()takes long, you may miss a short event. - Response time depends on how often you get around to checking.
- Poor for low-power designs because the CPU can never sleep.
Interrupt-driven
The hardware notifies the CPU when the event happens. The CPU runs the handler immediately.
void BUTTON_IRQHandler(void) {
button_pressed = 1; // just record the event
}
int main(void) {
enable_button_interrupt();
while (1) {
if (button_pressed) {
button_pressed = 0;
handle_button();
}
do_other_work();
}
}
Pros
- Fast, predictable reaction to events.
- CPU is free to do other work, or sleep, until needed.
- Better efficiency and lower power.
Cons
- More complex: you must think about timing, shared data and priorities.
- Harder to debug because execution can jump in at any moment.
- Bad ISR design can break the whole system.
Side-by-side comparison
| Aspect | Polling | Interrupt-driven |
|---|---|---|
| Who starts the action? | Software keeps asking | Hardware tells software |
| CPU usage | Wasted on repeated checks | Used only when needed |
| Response time | Depends on loop speed | Fast and bounded |
| Complexity | Low | Higher |
| Power saving | Difficult | Easy (CPU can sleep) |
| Risk of missed events | Higher | Lower (if designed well) |
| Best for | Simple, slow, non-critical checks | Time-critical or infrequent events |
Rule of thumb: Polling is fine when the event is frequent, the system is simple, and timing is relaxed. Use interrupts when events are rare, urgent, or when you want the CPU free.
2. Interrupt Service Routines (ISR)
An ISR (also called an interrupt handler) is the function that runs when a specific interrupt fires.
What happens when an interrupt occurs
- A peripheral or pin raises an interrupt request.
- The CPU finishes the current instruction.
- The CPU saves context: the return address and status flags (and sometimes some registers) are pushed onto the stack.
- The CPU looks up the handler address in the vector table.
- The ISR runs.
- The ISR ends with a special return from interrupt instruction, which restores context.
- The main program resumes exactly where it was.
Main code βββββββββββ βββββββββββ Main code resumes
β interrupt! β
βΌ β
[save context] β [ISR runs] β [restore context]
Basic ISR shape
// Generic style. The exact syntax depends on your compiler and MCU.
void TIMER_IRQHandler(void) {
clear_timer_flag(); // 1. acknowledge the interrupt source
tick_count++; // 2. do the minimum work
}
Key facts about ISRs
- No arguments, no return value. The hardware cannot pass parameters or receive a result.
- Not called by your code. You never write
TIMER_IRQHandler();inmain. The hardware triggers it. - Special entry and exit. Normal functions return with a plain return. ISRs return with a return-from-interrupt instruction. Compilers handle this using attributes or keywords such as
__interruptorISR(...). - Must clear or acknowledge the source (on most hardware), otherwise the interrupt fires again immediately.
- Should be short and fast. More on this in ISR Best Practices.
3. Interrupt Vector Tables
What is it?
The interrupt vector table is an array of addresses stored in memory (usually at the start of flash). Each entry points to the ISR for a particular interrupt or exception.
Address Entry
------- -------------------------------
0x0000 Initial stack pointer / reset
0x0004 Reset handler
0x0008 Fault / NMI handler
...
0x0040 Timer interrupt handler address
0x0044 UART interrupt handler address
0x0048 GPIO interrupt handler address
...
When interrupt number N fires, the CPU reads entry N, loads that address into the program counter, and the ISR runs. Think of it like a phone directory: the interrupt number is the name, and the handler address is the phone number.
Vector number to handler
| Interrupt source | Vector index | Handler |
|---|---|---|
| Reset | 0 / 1 | Reset_Handler |
| Fault / exception | low numbers | HardFault_Handler, etc. |
| Timer | some index | TIMER_IRQHandler |
| External pin | some index | EXTI_IRQHandler |
How it gets filled in
Typically, the startup code (an assembly or C file supplied with your toolchain) defines the table. It contains weak default handlers, which are simple infinite loops. When you write a function with the matching name, your version overrides the weak default automatically.
// In startup file (conceptually):
// void TIMER_IRQHandler(void) __attribute__((weak, alias("Default_Handler")));
// In your code, just define it with the same name:
void TIMER_IRQHandler(void) {
/* your handler */
}
Important points
- Name must match exactly what the startup file expects. A typo means your handler is silently ignored and the default (often an endless loop) runs instead.
- Some MCUs allow the vector table to be relocated (for example, to RAM or to a bootloader's application region) so handlers can be changed at runtime.
- Some architectures use a single interrupt entry point with a flag register that tells you which source fired (software dispatch). Others use hardware vectoring with a separate entry per source.
- Unused interrupts should still point somewhere safe (a default handler), not to garbage.
Exceptions in the table
The first part of the table usually holds system exceptions generated by the CPU itself:
| Exception | Meaning |
|---|---|
| Reset | CPU starts or restarts |
| NMI (Non-Maskable Interrupt) | Critical event that cannot be disabled |
| Hard fault / bus fault | Invalid memory access, illegal operation |
| Others (arch-specific) | Divide by zero, unaligned access, system call, etc. |
The rest of the table holds peripheral interrupts (timers, GPIO, communication blocks, and so on).
4. Enabling and Disabling Interrupts
For an interrupt to actually reach your ISR, several gates usually have to be open at the same time.
The gate chain
Peripheral event βββΊ [Peripheral enable bit] βββΊ [Interrupt controller enable] βββΊ [Global interrupt enable] βββΊ CPU runs ISR
- Peripheral-level enable: the timer, pin, or other block must be configured to generate an interrupt.
- Interrupt controller (or mask register) enable: this specific source must be unmasked.
- Global interrupt enable: the CPU's master switch must be on.
If any one of these is closed, the ISR will not run.
Global enable and disable
__disable_irq(); // master switch OFF: no maskable interrupts
// ... sensitive code ...
__enable_irq(); // master switch ON
The names vary by platform (cli() / sei(), __disable_irq() / __enable_irq(), and so on), but the idea is identical.
Per-source enable and disable (masking)
TIMER_INT_ENABLE = 1; // allow timer interrupts
TIMER_INT_ENABLE = 0; // mask (block) timer interrupts only
Pending flags
When an event happens while its interrupt is disabled or masked, the hardware usually remembers it by setting a pending flag. When you re-enable the interrupt, the ISR may run right away because of that stored request.
This matters for two reasons:
- You may get a surprise ISR call the moment you enable an interrupt.
- You often need to clear stale pending flags before enabling the interrupt if you do not want old events.
Clearing the interrupt flag
Most peripherals require the ISR (or setup code) to clear the flag explicitly. How it's cleared varies:
- Write
1to the flag bit (write-1-to-clear). - Write
0to the flag bit. - Read a status register, then read or write a data register.
- It clears automatically on entering the ISR (some AVR-style flags).
Always check your datasheet. Clearing it the wrong way is one of the most common reasons for an ISR firing nonstop.
Typical setup order
void setup_timer_interrupt(void) {
configure_timer(); // set up the peripheral itself
clear_timer_pending_flag(); // remove any stale event
set_timer_priority(2); // choose priority (see Section 5)
enable_timer_interrupt_source(); // unmask this source
__enable_irq(); // global enable (often done once, last)
}
Non-maskable interrupts
Some interrupts (such as NMI or reset) cannot be disabled by the global switch. These are reserved for critical events and should be used with great care.
5. Interrupt Priority and Nesting
Why priority is needed
What if two interrupts arrive at the same time? Or a second one arrives while the first ISR is running? The system needs rules. That is what priority is for.
Priority rules
- Every interrupt source gets a priority level.
- When two interrupts are pending at once, the higher-priority one is served first.
- Depending on the MCU, a lower number may mean a higher priority (common in ARM Cortex-M) or the other way round. Check your datasheet.
- Interrupts at the same priority are usually served in a fixed order, for example by vector number.
Nesting
Nested interrupts means a higher-priority interrupt can interrupt a lower-priority ISR that is already running.
Main ββββΊ ISR_A (low priority) running
β
β ISR_B (high priority) fires
βΌ
ISR_B runs to completion
β
βΌ
ISR_A continues ββββΊ returns to Main
Typical behaviour
| Situation | Result |
|---|---|
| Low-priority ISR running, high-priority interrupt arrives | High one preempts (nested) |
| High-priority ISR running, low-priority interrupt arrives | Low one is held pending until the high ISR finishes |
| Same priority arrives during an ISR | Usually waits (no nesting at equal level) |
Controlling nesting
- Some MCUs nest automatically based on priority.
- Some simpler MCUs disable all interrupts on ISR entry. You must explicitly re-enable them inside the ISR if you want nesting (this is risky and rarely recommended).
- You can also use a priority mask (such as a base-priority register) to block interrupts at or below a certain level instead of blocking everything.
Design guidelines
- Give time-critical or short-deadline interrupts the highest priority.
- Give slow or non-urgent ones lower priority.
- Keep ISRs short so high-priority events are never starved.
- Avoid deep nesting. Each nested ISR consumes more stack. Too many levels risks stack overflow.
- Think carefully before sharing data between ISRs of different priorities, because the higher one can interrupt the lower one in the middle of an update.
Priority inversion of thought: a common mistake
Giving everything the highest priority defeats the purpose. If everything is urgent, nothing is. Rank your interrupts by how quickly each must be served and how much damage a delay would do.
6. Edge-Triggered vs Level-Triggered Interrupts
This describes what condition on the signal line causes the interrupt.
Edge-triggered
The interrupt is triggered by a change in the signal.
- Rising edge: signal goes from low to high.
- Falling edge: signal goes from high to low.
- Both edges: any change.
Signal: ____ββββββββββ____
β β
rising falling
(one event each)
The interrupt fires once per edge, even if the signal stays high afterwards.
Level-triggered
The interrupt is requested as long as the signal stays at a certain level (high or low).
Signal: ____ββββββββββ____
|<β active β>|
interrupt is requested the whole time the line is high
If you return from the ISR while the line is still active, the interrupt fires again immediately.
Comparison
| Aspect | Edge-triggered | Level-triggered |
|---|---|---|
| Fires when | Signal changes | Signal is at active level |
| Number of requests | One per transition | Continuous until cleared at the source |
| Short pulse | Can be captured (if latched) | May be missed if it ends before CPU responds |
| Must clear at source? | Clear the pending flag | Must remove the cause at the device |
| Noise and bounce | Each glitch can cause an interrupt | Less sensitive to brief glitches (but still can be a problem) |
| Sharing one line | Hard (events can be lost) | Easier: multiple devices can share a line |
Practical implications
Edge-triggered
- Good for detecting events such as a button press or a pulse.
- A very short pulse might be missed if the hardware does not latch it.
- If two edges arrive before the first is handled, the hardware may only remember one (a lost event).
- Mechanical switches can bounce and generate multiple edges. You may need debouncing in hardware or software.
Level-triggered
- Good for states, such as "device has data ready."
- You must make the device deassert its signal (for example, by reading its data register) before leaving the ISR. Otherwise you get an interrupt storm.
- Naturally avoids missing a request, because the request stays active until it is serviced.
7. Interrupt Latency and Response Time
Definitions
Interrupt latency is the time between the event occurring and the first instruction of the ISR starting.
Response time (sometimes called total response or service time) is the time from the event until the work is actually done, including the ISR's own execution time.
Event βββΊ [latency] βββΊ ISR starts βββΊ [ISR execution] βββΊ ISR ends
|<ββββββββββββββββ response time βββββββββββββββββββββββββββββββ>|
What contributes to latency
| Factor | Why it adds delay |
|---|---|
| Current instruction must finish | Long instructions (such as multi-cycle ones) delay the response |
| Interrupts disabled | Critical sections hold off every interrupt |
| Higher-priority ISR running | Must wait for it to finish |
| Context saving | Pushing registers to the stack takes cycles |
| Vector fetch | Reading the table entry takes time |
| Pipeline flush, cache or flash wait states | Hardware overhead |
| Peripheral synchronisation | External signals may need a few clock cycles to be sampled |
Worst-case latency
For real-time systems what matters is the worst case, not the average.
Worst-case latency β longest critical section
+ longest higher-priority ISR (if any)
+ hardware entry overhead
How to keep latency low
- Keep critical sections (interrupts disabled) as short as possible.
- Keep ISRs short, especially high-priority ones.
- Give urgent sources higher priority.
- Avoid long multi-cycle operations where interrupts are blocked.
- Be aware of function call overhead and register saving inside the ISR. Small ISRs written efficiently help.
- Where available, use hardware features for fast context switching or dedicated register banks.
Latency vs jitter
Jitter is the variation in latency from one event to the next. Even if the average is small, large jitter can break timing-sensitive tasks (for example, generating precise waveforms from an ISR).
8. Shared Data Between ISR and Main Code
The ISR and main are like two people editing the same notebook. The ISR can barge in at any moment, even halfway through a sentence the main code is reading or writing.
Typical sharing patterns
- Flag: the ISR sets it, main code checks it and clears it.
- Counter: the ISR increments, main code reads.
- Buffer: the ISR writes data in, main code reads it out (or the reverse).
- Result value: the ISR stores a measurement, main code uses it.
Why it is dangerous
uint32_t tick; // shared
void TIMER_IRQHandler(void) {
tick++; // ISR writes
}
int main(void) {
uint32_t t = tick; // main reads: is this one machine instruction?
}
On an 8-bit MCU, reading a 32-bit variable takes several instructions. If the ISR fires in the middle, the main code might read half old bytes and half new bytes, giving a corrupt value.
Even tick++ in main is not atomic. It is really read β modify β write, and an ISR can slip in between.
Problems to watch for
- Torn reads and writes: the value is wider than the CPU can handle in one instruction.
- Read-modify-write conflicts: both ISR and main modify the same variable.
- Stale values: the compiler caches a value in a register (see volatile).
- Multi-variable inconsistency: several related variables updated together can be seen half-updated.
Safe sharing strategies
| Strategy | Idea |
|---|---|
Use volatile | Forces real memory access each time |
| Keep shared data small | Use a type the CPU can read and write atomically |
| Single writer | Only one side (ISR or main) modifies a given variable |
| Critical section | Briefly disable interrupts while accessing |
| Copy-then-use | Take a snapshot inside a critical section, then work on the copy |
| Ring (circular) buffer | One side writes the head, the other side only the tail |
| Flags for hand-off | ISR signals, main processes (see Deferred Processing) |
Example: safe snapshot
volatile uint32_t tick; // updated in ISR
uint32_t get_tick(void) {
uint32_t copy;
__disable_irq(); // enter critical section
copy = tick; // multi-byte read now safe
__enable_irq(); // leave critical section
return copy;
}
Example: single-producer, single-consumer ring buffer
#define BUF_SIZE 32
volatile uint8_t buf[BUF_SIZE];
volatile uint8_t head = 0; // written only by the ISR
volatile uint8_t tail = 0; // written only by main
void DATA_IRQHandler(void) { // producer
uint8_t next = (head + 1) % BUF_SIZE;
if (next != tail) { // not full
buf[head] = read_data_register();
head = next;
} // else: overflow, data dropped
}
int buffer_read(uint8_t *out) { // consumer (main)
if (tail == head) return 0; // empty
*out = buf[tail];
tail = (tail + 1) % BUF_SIZE;
return 1;
}
Because each index has exactly one writer, this works without disabling interrupts (as long as single-byte index reads and writes are atomic on your CPU).
9. volatile Variables in Interrupt Handlers
The problem: compiler optimisation
The compiler only sees one thread of execution at a time. It has no idea that an ISR can change a variable "behind its back." So it may optimise in ways that break your program.
int flag = 0; // NOT volatile
void SOME_IRQHandler(void) {
flag = 1;
}
int main(void) {
while (flag == 0) {
// wait
}
do_something();
}
The compiler reasons: "Nothing inside this loop changes flag, so it never changes. I will read it once and loop forever." It may turn the loop into an infinite loop that never sees the ISR's update.
The fix
volatile int flag = 0;
volatile tells the compiler: "This variable can change at any time outside normal program flow. Never cache it, and always read from or write to memory."
Where volatile is required
- Variables shared between an ISR and main code.
- Memory-mapped hardware registers (status and data registers can change by themselves).
- Variables used in polling loops that wait for a change.
What volatile does and does not do
volatile does | volatile does not |
|---|---|
| Force a real memory read or write every access | Make multi-byte access atomic |
| Prevent the compiler from removing or reordering accesses to that variable (relative to other volatile accesses) | Make x++ or `x |
| Keep the compiler from caching it in a register | Protect against race conditions |
| Provide a memory barrier for non-volatile data |
Key takeaway:
volatilesolves the visibility problem. It does not solve the atomicity problem. For those you need critical sections or atomic-capable types.
Practical tips
- Mark only what needs it. Over-using
volatilecan reduce performance. - Pointer syntax matters:
volatile uint8_t *p; // pointer to volatile data (the common case for registers) uint8_t * volatile q; // volatile pointer to normal data volatile uint8_t * volatile r; // both volatile - For performance inside a loop, you can copy a volatile variable into a local once and then use the local, if you only need one consistent snapshot.
10. Atomic Access and Critical Sections
What does "atomic" mean?
An operation is atomic if it cannot be interrupted partway. It either happens entirely, or not at all, from the point of view of everything else (including ISRs).
Which operations are atomic?
It depends on the CPU:
| Situation | Atomic? |
|---|---|
| Reading or writing a variable no wider than the CPU's native word, properly aligned | Usually yes |
| Reading a 16-bit or 32-bit variable on an 8-bit CPU | No (multiple instructions) |
x++, x--, x += n, `x | = mask` |
| Writing a struct or array | No |
| Several variables updated together | No |
Never assume. Check the CPU architecture and the compiler's generated assembly if it matters.
Critical section
A critical section is a piece of code that must not be interrupted by anything that touches the same shared data. The usual way to create one is to disable interrupts briefly.
__disable_irq(); // enter critical section
shared_counter++;
other_shared_value = compute();
__enable_irq(); // exit critical section
Save and restore the previous state
What if interrupts were already disabled before your function was called? Blindly calling "enable" at the end would turn them on when they should stay off. The safe pattern is to save and restore the previous state:
uint32_t saved = get_interrupt_state(); // remember (e.g. PRIMASK on Cortex-M)
__disable_irq();
/* ---- critical section ---- */
shared_counter++;
/* -------------------------- */
restore_interrupt_state(saved); // go back to how it was
This makes critical sections safe to nest inside each other and inside functions that may be called from either main or ISR context.
Rules for critical sections
- Keep them as short as possible. Every moment spent with interrupts off adds to worst-case latency.
- Do not call slow functions, wait for hardware, or loop for a long time inside one.
- Do not put blocking operations inside one.
- Protect only what needs protecting.
- Prefer masking only the relevant interrupt source if your hardware allows, instead of disabling everything.
Alternatives to disabling interrupts
- Single-writer design (see ring buffer above) needs no lock.
- Hardware atomic instructions (such as load-exclusive and store-exclusive, atomic bit-set and bit-clear registers, or compare-and-swap) on CPUs that provide them.
- Atomic bit-set and bit-clear registers on many MCUs let you change one bit without a read-modify-write.
- Double-read check: read the value twice and repeat until both reads match (useful for multi-byte values that only the ISR writes).
uint32_t read_tick_safely(void) {
uint32_t a, b;
do {
a = tick;
b = tick;
} while (a != b); // retry if an update slipped in between
return a;
}
11. ISR Best Practices and Restrictions
Golden rule
Get in, do the bare minimum, get out.
An ISR should capture the event and hand off the heavy work to the main code (deferred processing).
Do
- Keep it short and fast. Microseconds, not milliseconds.
- Clear the interrupt flag correctly and early (or at the right point for level-triggered sources).
- Use
volatilefor anything shared with other code. - Use fixed, small data types that the CPU handles atomically.
- Set a flag, store a value, or push into a buffer, then return.
- Save and restore anything that is not automatically preserved (the compiler usually handles this for proper ISR functions).
- Document each ISR: which source, what priority, what shared data it touches.
- Test the worst case: all interrupts firing back to back.
- Check stack usage, including the deepest possible nesting.
- Handle spurious or unexpected interrupts safely (with a default handler).
Do not
| Avoid | Why |
|---|---|
| Long loops or delays (such as busy-wait delay functions) | Blocks lower-priority interrupts and increases latency |
| Blocking calls (waiting for hardware, waiting for another event) | Can hang the whole system, because the thing you wait for may need an interrupt |
| Heavy computation (floating-point maths, big divisions, string processing) | Takes too long and may use extra registers |
printf or similar I/O functions | Slow, often not safe to call from an ISR, and may use a lot of stack |
Dynamic memory allocation (malloc, free) | Not safe, slow and non-deterministic |
| Calling non-reentrant functions | If main was in the middle of the same function, data gets corrupted |
| Calling functions you have not checked | They may be slow, blocking or non-reentrant |
| Large local variables or deep call chains | Risk of stack overflow |
| Enabling global interrupts casually inside the ISR | Creates unplanned nesting |
| Forgetting to clear the flag | ISR re-fires forever |
Reentrancy
A function is reentrant if it can be safely interrupted and called again (by an ISR) before the first call has finished. Functions that use static or global data without protection are not reentrant. If both the main code and an ISR need the same function, make sure it is reentrant, or protect it with a critical section.
Why ISRs are different from normal functions
- They are called by hardware, not by your code.
- They have no caller to receive a return value or error code.
- They steal time from whatever was running.
- A mistake can corrupt everything that was interrupted.
Quick ISR checklist
- Correct handler name and vector
- Flag cleared properly
- Short and non-blocking
- Shared variables are
volatile - Multi-byte shared data protected
- No forbidden calls (blocking,
malloc, heavy I/O) - Priority chosen deliberately
- Stack usage considered
12. Race Conditions and Lost Events
Race condition
A race condition happens when the result of the program depends on the exact timing of an interrupt relative to the main code. Usually it works fine in testing and fails rarely and mysteriously in the field. Those are the worst bugs to find.
Classic example: read-modify-write collision
volatile uint8_t count = 0;
void ISR(void) { count++; } // ISR increments
void main_task() { count--; } // main decrements
Both count++ and count-- expand into three steps: load, change, store.
main: LOAD count (5)
ISR: LOAD count (5), ADD 1, STORE count (6)
main: SUB 1 β 4, STORE count (4)
The ISR's increment is lost. The real answer should be 5, but the result is 4.
Fix: wrap the main-side access in a critical section.
Check-then-act race
if (flag) { // β ISR may set flag again right here
flag = 0; // β and this erases that new event
process();
}
If the ISR sets flag after the if but before flag = 0, the new event is erased without being processed.
Fix: clear the flag first, then process, so a new event set during processing will be seen next time.
if (flag) {
flag = 0; // clear first
process(); // new events during this are kept
}
(If clearing and testing must be a single indivisible step, use a critical section or an atomic test-and-clear.)
Lost events
An event is lost when something happens but the system never notices or never handles it.
Common causes:
| Cause | What happens |
|---|---|
| Interrupts disabled too long | An edge arrives and then departs, or two edges merge into one pending flag |
| ISR too slow | A second event arrives while the first is still being handled, and only one pending flag exists |
| Single flag for counting events | Two events set the same flag. Main sees only "one happened" |
| Overwritten data | A new data byte overwrites the old one before it is read (overrun) |
| Clearing a flag too late or too early | Erases an event that has just arrived |
| Buffer full | New data is dropped |
| Edge-triggered interrupt with short pulse | Pulse ended before it was latched |
Ways to prevent them
- Use a counter rather than a flag if multiple events may accumulate before handling.
- Use a queue or ring buffer to hold event data.
- Keep ISRs and critical sections short.
- Make sure hardware latches short pulses.
- Clear flags in the right order (see above).
- Make the main loop service pending work often enough.
- Detect and count overruns so you know when data is being lost.
- Prefer level-triggered or properly latched sources when missing a request is unacceptable.
Debugging tip
Race bugs are timing-related, so they may disappear when you add a breakpoint or print statement. Think through the interleaving: "What if the interrupt arrives right between these two lines?" Ask that question for every shared variable.
13. Deferred Interrupt Processing
The idea
Split the work into two parts:
- Top half (the ISR): very fast. Acknowledge the hardware, grab the essential data, and signal that more work is needed.
- Bottom half (deferred work): done later, outside the ISR, in the main loop or in a lower-priority context. This is where the slower processing happens.
Hardware event
β
βΌ
βββββββββββββ set flag / queue data ββββββββββββββββββββββ
β ISR β ββββββββββββββββββββββββββΊ β Main loop / task β
β (quick) β β (does the real work)β
βββββββββββββ ββββββββββββββββββββββ
Why defer?
- Keeps ISR short, which keeps latency low for other interrupts.
- Lets you use normal, slower, or non-reentrant functions safely.
- Reduces the risk of lost events from other sources.
- Makes the code easier to test and debug.
Common techniques
1. Flag + main-loop handling
volatile uint8_t data_ready = 0;
volatile uint8_t rx_byte;
void RX_IRQHandler(void) {
rx_byte = read_rx_register(); // grab data, clears the source
data_ready = 1; // tell main
}
int main(void) {
while (1) {
if (data_ready) {
data_ready = 0;
process_byte(rx_byte); // heavy work happens here
}
}
}
Note: if another byte can arrive before
process_byteruns,rx_byteis overwritten. That is a lost event. Use a buffer instead (next technique).
2. Event queue / ring buffer
The ISR pushes event data into a buffer. The main loop pops and processes. This handles bursts of events without loss (until the buffer is full).
3. Event counters
The ISR increments a counter. Main processes however many events have accumulated.
4. Low-priority software or "pending" interrupt
On some architectures, an ISR can trigger a lower-priority interrupt by software. The urgent part runs at high priority, and the follow-up work runs later at low priority, still interrupt-driven but not blocking the urgent stuff.
5. Task or RTOS signalling
In systems with an operating system, the ISR signals a task (using a semaphore, queue or event flag), and a task does the work. (Only the general idea matters here.)
Design guidelines
- ISR does: acknowledge, capture, signal.
- Main does: decide, compute, communicate, display.
- Always make sure the deferred side keeps up with the rate of incoming events.
- Size buffers for the worst-case burst.
- Use critical sections or single-writer rules when the main side reads the shared flag, counter or buffer.
14. Interrupt-Driven State Machines
Why combine them?
A state machine models a system as a set of states, with events that cause transitions between them. Interrupts are natural event sources: a pin changes, a timer expires, data arrives. Each interrupt becomes an event that moves the machine to its next state.
This keeps your logic organised, predictable and easy to reason about, instead of a tangle of flags.
Example: a simple button-press handler with timeout
States: IDLE β WAIT_RELEASE β DONE
button press button release
IDLE ββββββββββββββββΊ WAIT_RELEASE ββββββββββββββββΊ DONE
β² β β
β β timeout β handled
β βΌ β
βββββββββββββββββββββ IDLE ββββββββββββββββββββββββ
Pattern 1: ISR posts events, main runs the state machine (recommended)
The ISR only records the event. The main loop reads events and runs the state machine.
typedef enum { EV_NONE, EV_BUTTON, EV_TIMEOUT } event_t;
typedef enum { ST_IDLE, ST_WAIT, ST_DONE } state_t;
volatile event_t pending_event = EV_NONE;
static state_t state = ST_IDLE;
void BUTTON_IRQHandler(void) { pending_event = EV_BUTTON; }
void TIMER_IRQHandler(void) { pending_event = EV_TIMEOUT; }
static event_t get_event(void) {
event_t e;
__disable_irq();
e = pending_event;
pending_event = EV_NONE;
__enable_irq();
return e;
}
int main(void) {
while (1) {
event_t e = get_event();
switch (state) {
case ST_IDLE:
if (e == EV_BUTTON) { start_timer(); state = ST_WAIT; }
break;
case ST_WAIT:
if (e == EV_TIMEOUT) { state = ST_IDLE; }
else if (e == EV_BUTTON) { state = ST_DONE; }
break;
case ST_DONE:
handle_press();
state = ST_IDLE;
break;
}
}
}
Why this is good:
- The ISR stays tiny.
- All state transitions happen in one place, in main context.
- State variable is touched only by main, so no sharing problem there.
- Only the event variable crosses the ISR boundary (protected by a short critical section).
Single
pending_eventcan still lose events if two arrive before main reads. For bursty systems, use an event queue (ring buffer) instead.
Pattern 2: State machine advanced directly inside the ISR
For very simple and fast cases, the ISR itself updates the state, for example a software protocol decoder driven by a timer tick or pin-change interrupt.
volatile state_t state = ST_IDLE;
void PIN_IRQHandler(void) {
switch (state) {
case ST_IDLE: state = ST_WAIT; break;
case ST_WAIT: state = ST_DONE; break;
default: break;
}
}
Use with care:
- The state variable is now shared and must be
volatile. - Transitions must be short and non-blocking.
- Main code should only read the state (single writer = ISR).
- Any work attached to states should be deferred to main.
Design tips
- Write down the states, events and transitions on paper (or as a table) before coding.
- Handle unexpected events in every state (ignore, or move to an error state).
- Make sure every state has a clear way out (no dead ends).
- Use an enum for states and events, not "magic numbers".
- Think about what happens if an event arrives mid-transition.
- For events from several interrupts, use an event queue to preserve order.
- Use timeouts (driven by a timer interrupt) to recover from stuck states.
State-transition table (optional style)
| Current state | Event | Next state | Action |
|---|---|---|---|
| IDLE | BUTTON | WAIT | start timer |
| WAIT | TIMEOUT | IDLE | none |
| WAIT | BUTTON | DONE | none |
| DONE | (auto) | IDLE | handle press |
15. Quick Revision Cheat Sheet
| Topic | One-line summary |
|---|---|
| Polling | Software keeps checking. Simple but wasteful and can miss events. |
| Interrupt-driven | Hardware notifies the CPU. Efficient and responsive, but more complex. |
| ISR | Hardware-triggered function: no arguments, no return value, must be short. |
| Vector table | Array of handler addresses indexed by interrupt number. |
| Enable chain | Peripheral enable + interrupt-controller enable + global enable must all be on. |
| Pending flag | Hardware remembers an event; usually must be cleared explicitly. |
| Priority | Decides which interrupt is served first when several are pending. |
| Nesting | A higher-priority interrupt can preempt a running lower-priority ISR. Costs stack. |
| Edge-triggered | Fires on a signal change. Can lose short or merged pulses. |
| Level-triggered | Fires while the signal stays active. Must be cleared at the source. |
| Latency | Time from event to first ISR instruction. Worst case matters most. |
| Response time | Event until the work is actually done. |
| Shared data | Data touched by both ISR and main. A source of subtle bugs. |
volatile | Forces real memory access. Fixes visibility, not atomicity. |
| Atomic | An indivisible operation that cannot be interrupted partway. |
| Critical section | Code protected (usually by disabling interrupts) from interruption. Keep it tiny. |
| ISR best practice | Acknowledge, capture, signal, exit. No blocking, no malloc, no heavy work. |
| Race condition | Result depends on exact interrupt timing. Hard to reproduce. |
| Lost event | Something happened but was never handled. |
| Deferred processing | ISR does the minimum, main does the real work later. |
| Interrupt-driven state machine | Interrupts become events that drive state transitions. |
Ten things to remember
- Interrupts let the CPU react to events without constantly checking.
- An ISR is triggered by hardware, has no parameters or return value, and must be short.
- The vector table maps each interrupt number to its handler. A wrong handler name means it never runs.
- For an interrupt to work, all enable gates must be open, and the flag must be cleared properly.
- Priorities decide who goes first. Nesting allows preemption but costs stack.
- Edge triggers fire on changes (and can lose pulses). Level triggers fire while active (and must be cleared at the source).
- Latency is dominated by how long interrupts stay disabled and by higher-priority ISRs.
- Shared variables need
volatile, and multi-byte or read-modify-write access needs protection. volatileis not atomicity. Use critical sections (save and restore state) or single-writer designs.- Keep ISRs tiny: flag it, queue it, defer it. Let the main loop or a state machine do the real work.
#UART / USART Serial Communication#
How to use these notes: Read top to bottom the first time. Each section builds on the previous one. Code is written in generic embedded C, so register and function names (like
UART_DRorUART_SR) will differ on your chip. The ideas stay the same everywhere.
Table of Contents
- Serial Communication Fundamentals
- Asynchronous vs Synchronous Communication
- Baud Rate, Data Bits, Parity and Stop Bits
- UART Transmitter and Receiver
- TX/RX Pins and Serial Registers
- Polling, Interrupt and DMA-Based Transmission
- Receive Buffers and Ring Buffers
- Framing Errors, Overrun and Parity Errors
- Serial Debugging and Terminal Tools
- UART Protocol Implementation
- Quick Revision Cheat Sheet
1. Serial Communication Fundamentals
Parallel vs serial
To send a byte (8 bits) from one device to another you have two basic choices:
- Parallel: 8 wires, one per bit, all bits sent at the same instant.
- Serial: 1 wire, bits sent one after another in time.
Parallel: D0 D1 D2 D3 D4 D5 D6 D7 β 8 wires, 1 clock tick
Serial: D0 β D1 β D2 β ... β D7 β 1 wire, 8 time slots
| Aspect | Parallel | Serial |
|---|---|---|
| Wires needed | Many | Few (often 2 or 3) |
| Speed per clock | High | Lower per clock, but clocks can be much faster |
| Cable cost and size | Bulky, expensive | Slim, cheap |
| Distance | Short (signals drift out of sync) | Longer distances possible |
| Pin usage on MCU | High | Very low |
Microcontrollers have limited pins, so serial communication is the everyday choice for talking to PCs, sensors, modems, GPS modules, Bluetooth modules and other MCUs.
Key serial terms
| Term | Meaning |
|---|---|
| Bit | One binary value (0 or 1) |
| Frame | One complete unit of transmission (start bit + data + parity + stop bit) |
| Bit time | Duration of one bit = 1 / baud rate |
| Simplex | Data flows one way only |
| Half-duplex | Both directions possible, but only one at a time |
| Full-duplex | Both directions at the same time (UART with separate TX and RX wires) |
| Transmitter (Tx) | Sends data |
| Receiver (Rx) | Receives data |
Why serial needs agreement
When bits arrive one at a time, the receiver must know when each bit starts and ends, and which bit is which. The two sides must agree on:
- the speed (how long each bit lasts),
- how many bits make a character,
- how to recognise where a character begins and ends.
That agreement is exactly what UART's settings are for.
What is UART?
UART stands for Universal Asynchronous Receiver/Transmitter. It is a hardware block inside the MCU that:
- converts parallel data (a byte in a register) into a serial bit stream to send, and
- converts an incoming serial stream back into a parallel byte to receive.
USART is the same block with one extra ability: it can also run in synchronous mode using a clock line (see the next section). Many MCUs call their block "USART" and let you choose the mode.
2. Asynchronous vs Synchronous Communication
Asynchronous (UART)
There is no shared clock wire between the two devices. Each side has its own internal clock, and both are configured to the same baud rate.
Because the clocks are separate, the receiver must re-synchronise on every character. This is done with a start bit (and a stop bit to finish).
Idle (high) ββ ββββ¬βββ¬βββ¬βββ¬βββ¬βββ¬βββ¬βββ¬βββ βββ Idle (high)
ββββ β β β β β β β β βββ
START D0 D1 D2 D3 D4 D5 D6 D7 STOP
Pros
- Only two data wires needed for full duplex (TX and RX), plus ground.
- No clock wire to route.
- Very simple and widely supported.
Cons
- Both sides must be set to exactly matching settings.
- Each character carries overhead bits (start, stop, optional parity).
- Clock mismatch limits speed and reliability.
- Typically point-to-point (two devices).
Synchronous (USART in sync mode)
A separate clock line is shared. One device (the master) generates the clock, and data bits are read on a clock edge. Because both sides use the same clock, there is no need for start and stop bits per character, so data can be sent in continuous blocks.
CLK: βββ βββ βββ βββ βββ
β βββ βββ βββ βββ ββ
DATA: βD0ββD1ββD2ββD3ββD4β (sampled on a clock edge)
Pros
- Higher efficiency (no framing overhead per byte).
- No baud-rate mismatch problems, since the clock is shared.
Cons
- Extra wire for the clock.
- Needs a master that supplies the clock.
Comparison
| Aspect | Asynchronous | Synchronous |
|---|---|---|
| Clock line | None (each side has its own) | Shared clock line |
| Synchronisation by | Start and stop bits | The clock |
| Overhead per byte | Higher | Lower |
| Wires (data + clock) | 2 (TX, RX) | 3 or more (TX, RX, CLK) |
| Baud-rate match needed | Yes, strictly | Not needed (clock is shared) |
| Typical use | PC serial, debug console, modules | Fast block transfers between chips |
Note: The rest of these notes concentrate mainly on asynchronous UART, which is how the term "UART" is most commonly used.
3. Baud Rate, Data Bits, Parity and Stop Bits
These four settings together define the frame format. Both sides must use the same values, or the data turns into garbage.
Baud rate
Baud rate is the number of signal changes (symbols) per second. In basic UART, one symbol equals one bit, so baud rate is effectively bits per second (bps).
Common values: 9600, 19200, 38400, 57600, 115200 baud.
Bit time = 1 / baud rate
| Baud rate | Bit time |
|---|---|
| 9600 | ~104 Β΅s |
| 115200 | ~8.68 Β΅s |
Effective data speed is lower than baud rate because of overhead. For the common 8N1 format there are 10 bits per 8 data bits:
Bytes per second β baud rate / 10
115200 baud β 11,520 bytes per second
How the MCU creates the baud rate
The UART has a baud-rate generator: a divider that slows the peripheral clock down to the desired rate. Typically:
Baud rate = UART_clock / (16 Γ divisor) (with 16Γ oversampling)
so
divisor = UART_clock / (16 Γ desired baud rate)
The divisor is written to a baud-rate register.
Baud-rate error: the divisor must be a whole number (or limited fraction), so the actual baud rate is slightly off the target.
Error % = (actual β desired) / desired Γ 100
A common rule of thumb is to keep the total mismatch between both sides within about 2 to 3 percent, because the receiver samples each bit near its middle and the error accumulates across the frame. Smaller is better. Choosing a UART clock that divides nicely (for example, a crystal-friendly frequency) keeps the error low.
Data bits
The number of data bits per character, typically 8 (a full byte). Other options are 5, 6, 7 (older equipment, or 7-bit ASCII) and 9 (used for special addressing schemes or when parity is counted as a data bit).
Parity bit (optional error check)
An extra bit added after the data bits that lets the receiver detect some transmission errors.
| Parity mode | Rule |
|---|---|
| None | No parity bit sent |
| Even | Parity bit chosen so the total number of 1s (data + parity) is even |
| Odd | Parity bit chosen so the total number of 1s is odd |
Example (even parity): data = 0110 1001 has four 1s. Already even, so parity bit = 0. Data = 0110 1011 has five 1s, so parity bit = 1 to make six.
Limitations of parity
- Detects an odd number of flipped bits (1, 3, ...).
- Misses an even number of flipped bits (2, 4, ...).
- It can only detect, not correct.
Stop bits
One or more bits at the end of the frame that return the line to the idle (high) level. They give the receiver time to finish and prepare for the next start bit.
Options are 1, 1.5 or 2 stop bits. One is by far the most common. Two give extra recovery time with slower receivers or noisy lines.
The frame format
Idle START D0 D1 D2 D3 D4 D5 D6 D7 [PARITY] STOP Idle
1 0 <---- data, LSB first ----> opt. 1 1
- The line is high when idle.
- A start bit (always 0) marks the beginning.
- Data goes out LSB (least-significant bit) first.
- Optional parity bit.
- Stop bit(s) (always 1).
Shorthand: "8N1"
Settings are written as data bits, parity, stop bits:
| Notation | Meaning |
|---|---|
| 8N1 | 8 data bits, No parity, 1 stop bit (most common) |
| 8E1 | 8 data bits, Even parity, 1 stop bit |
| 8O2 | 8 data bits, Odd parity, 2 stop bits |
| 7E1 | 7 data bits, Even parity, 1 stop bit |
Bits per frame for 8N1: 1 start + 8 data + 1 stop = 10 bits.
Example: sending the letter A (0x41 = 0100 0001) in 8N1
LSB first: 1 0 0 0 0 0 1 0
Idle START 1 0 0 0 0 0 1 0 STOP Idle
1 0 D0 D1 D2 D3 D4 D5 D6 D7 1 1
4. UART Transmitter and Receiver
The transmitter (TX side)
Takes a byte from the CPU and shifts it out as a serial frame.
Main blocks
- Transmit data register (TDR / data register): where software writes the byte to send.
- Transmit shift register: takes the byte and shifts out bits one by one.
- Frame generator: adds start bit, parity bit and stop bit(s).
- Baud-rate generator: controls the timing of each bit.
- Status flags: tell software when it can write again.
How a transmission works
- Software writes a byte to the transmit data register.
- When the shift register is free, the byte is copied into it (the data register is now free again).
- The shifter outputs: start bit β data bits (LSB first) β parity (if enabled) β stop bit(s), one bit per bit-time.
- After the last stop bit, the line returns to idle (high).
Two important flags
- TX data register empty (TXE): the data register is free, so you can write the next byte now. (The previous byte may still be shifting out.)
- Transmission complete (TC): the shift register has finished sending the last frame and the line is idle.
The difference matters: TXE means "you may queue the next byte," TC means "everything is physically sent."
The receiver (RX side)
Watches the line, detects frames, and assembles bytes.
Main blocks
- Start-bit detector: watches for the high-to-low transition.
- Sampler (oversampling logic): samples each bit, usually several times, at the middle of the bit.
- Receive shift register: collects bits.
- Receive data register (RDR): holds the completed byte for software to read.
- Error detectors: check parity, stop bit and overrun.
- Status flags: tell software a byte has arrived or something went wrong.
How reception works
- The line sits high (idle).
- A falling edge (high β low) signals a possible start bit.
- The receiver waits half a bit time and re-checks that the line is still low. If so, it is a valid start bit. (This filters out short noise glitches.)
- It then samples the line once per bit time, ideally in the middle of each bit, shifting bits into the shift register.
- It samples the parity bit (if enabled) and checks it.
- It samples the stop bit, which should be high. If not, it is a framing error.
- The completed byte moves to the receive data register and the RX not empty (RXNE) flag is set.
- Software reads the byte, which clears the flag.
Line: ββββββ ββββ ββββ ...
βββββββ ββββ ββββββ
Sample: β β β β β β (each sample near the middle of a bit)
Oversampling
Most UARTs sample at 16Γ (or 8Γ) the baud rate and use the middle samples (often by majority vote) to decide each bit's value. This improves tolerance to noise and small clock errors.
Full duplex
The transmitter and receiver are independent. They can operate at the same time, which is why UART is full duplex.
5. TX/RX Pins and Serial Registers
The pins
| Pin | Name | Direction | Purpose |
|---|---|---|---|
| TX | Transmit | Output | Sends data out |
| RX | Receive | Input | Receives data in |
| GND | Ground | Common reference (essential!) |
Crossover connection
The golden wiring rule: TX of one device goes to RX of the other.
Device A Device B
ββββββββ ββββββββ
β TX ββββββββββββββββΊβ RX β
β RX βββββββββββββββββ€ TX β
β GND βββββββββββββββββ€ GND β
ββββββββ ββββββββ
Connecting TX to TX or RX to RX is a classic beginner mistake.
Other points about pins
- Common ground is required, or the voltage levels mean nothing to the receiver.
- Voltage levels must match. A 3.3 V device and a 5 V device need level shifting. Connecting mismatched logic levels can damage the chip.
- Pin multiplexing: on most MCUs, TX and RX share pins with GPIO. You must configure the pin for its alternate function so the UART controls it instead of the GPIO block.
- Idle level is high. The RX pin should not float. If nothing is connected, a pull-up resistor keeps it at a known high.
- Optional hardware flow control lines: RTS (request to send) and CTS (clear to send) let each side tell the other to pause when its buffer is full. Many projects do not use them.
- RS-232 vs TTL level: MCU UART pins use logic (TTL/CMOS) levels. The old PC-style RS-232 standard uses higher voltages with inverted logic, so a level converter chip is needed to connect to it. A USB-to-serial adapter handles this conversion for modern PCs.
Typical serial registers
Exact names vary by chip, but they all provide the same jobs:
| Register (generic name) | Purpose |
|---|---|
| Data register (DR / TDR / RDR / UDR) | Write to send a byte. Read to get a received byte. |
| Status register (SR / USR) | Flags: TX empty, TX complete, RX not empty, framing error, parity error, overrun error |
| Control register(s) (CR / UCR) | Enable transmitter and receiver, set data bits, parity, stop bits, enable interrupts, enable DMA |
| Baud-rate register (BRR / UBRR) | Holds the divisor that sets the baud rate |
Typical status flags
| Flag | Meaning |
|---|---|
| TXE | Transmit data register empty (safe to write next byte) |
| TC | Transmission complete |
| RXNE | Received data ready to read |
| FE | Framing error |
| PE | Parity error |
| ORE / OVR | Overrun error |
| NE (some chips) | Noise detected |
| IDLE (some chips) | Line has gone idle |
Typical initialisation steps
void uart_init(uint32_t baud) {
// 1. Enable the clock to the UART peripheral
enable_uart_clock();
// 2. Configure TX and RX pins for their alternate function
configure_tx_pin_alternate();
configure_rx_pin_alternate();
// 3. Set the baud rate
UART_BRR = UART_CLOCK / baud; // simplified; check your datasheet
// 4. Set frame format (8 data bits, no parity, 1 stop bit)
UART_CR_DATABITS = 8;
UART_CR_PARITY = NONE;
UART_CR_STOPBITS = 1;
// 5. Enable transmitter, receiver, and finally the UART itself
UART_CR_TX_ENABLE = 1;
UART_CR_RX_ENABLE = 1;
UART_CR_UART_ENABLE = 1;
}
Basic blocking send and receive (the simplest code)
void uart_send_byte(uint8_t b) {
while (!(UART_SR & TXE_FLAG)) { } // wait until data register is empty
UART_DR = b; // write the byte
}
uint8_t uart_receive_byte(void) {
while (!(UART_SR & RXNE_FLAG)) { } // wait until a byte arrives
return (uint8_t)UART_DR; // read clears RXNE
}
void uart_send_string(const char *s) {
while (*s) {
uart_send_byte((uint8_t)*s++);
}
}
6. Polling, Interrupt and DMA-Based Transmission
There are three ways for the CPU to move bytes to and from the UART. They trade simplicity against CPU efficiency.
6.1 Polling
The CPU waits and checks flags in a loop (as in the blocking code above).
for (int i = 0; i < len; i++) {
while (!(UART_SR & TXE_FLAG)) { } // CPU stuck here until ready
UART_DR = data[i];
}
Pros: simplest, no shared-data issues, easy to debug. Cons: the CPU is blocked while waiting. At 9600 baud, sending a byte takes about 1 ms, which is thousands of wasted CPU cycles. Incoming bytes can be missed if the CPU is busy elsewhere.
Good for: quick tests, simple debug output, tiny programs.
6.2 Interrupt-driven
The UART raises an interrupt when it needs attention. The CPU does other work in between.
Receive interrupt (RXNE): fires when a byte arrives. The ISR reads the byte and stores it in a buffer.
void UART_IRQHandler(void) {
if (UART_SR & RXNE_FLAG) {
uint8_t b = UART_DR; // read clears the flag
rx_buffer_put(b); // store in ring buffer
}
if ((UART_SR & TXE_FLAG) && tx_interrupt_enabled) {
if (!tx_buffer_empty()) {
UART_DR = tx_buffer_get(); // send next byte
} else {
disable_tx_interrupt(); // nothing left to send
}
}
}
Transmit interrupt (TXE): fires when the data register is empty and ready for the next byte. The ISR feeds the next byte from a transmit buffer. When the buffer runs out, the ISR disables the TX interrupt (otherwise it fires continuously).
Typical interrupt-driven transmit function:
void uart_send_buffered(const uint8_t *data, size_t len) {
for (size_t i = 0; i < len; i++) {
while (tx_buffer_full()) { } // wait for space (or drop, by design)
tx_buffer_put(data[i]);
}
enable_tx_interrupt(); // start the ISR feeding bytes
}
Pros: CPU is free between events, no missed bytes if the ISR is quick, efficient.
Cons: more complex, needs buffers and careful handling of shared data (see Interrupts topic: volatile, atomic access, race conditions).
Remember the ISR rules: keep the handler short, read the data register promptly, no long processing inside.
6.3 DMA-based
DMA (Direct Memory Access) is a hardware engine that moves data between memory and the UART without CPU involvement.
DMA transmit: you give DMA a memory address and a byte count. It feeds the UART automatically, byte by byte. The CPU gets one interrupt when the whole block is done.
void uart_send_dma(const uint8_t *data, size_t len) {
DMA_SOURCE_ADDR = (uint32_t)data;
DMA_DEST_ADDR = (uint32_t)&UART_DR;
DMA_COUNT = len;
DMA_ENABLE = 1; // transfer runs on its own
UART_CR_DMA_TX_ENABLE = 1;
}
DMA receive: DMA fills a memory buffer from the UART data register. It can interrupt when the buffer is full (or half full), or use a circular mode that wraps around forever. It is often combined with the UART IDLE-line flag to detect the end of a message of unknown length.
Pros: lowest CPU load, ideal for high baud rates and large blocks, fewer interrupts. Cons: more setup, extra peripheral to configure, the buffer must not be modified or freed while DMA is using it, and you must track how much data has actually arrived.
Comparison
| Aspect | Polling | Interrupt | DMA |
|---|---|---|---|
| CPU involvement | Constant (busy-wait) | Per byte (ISR) | Almost none (per block) |
| Complexity | Lowest | Medium | Highest |
| Missed bytes risk | High | Low | Very low |
| Best for | Simple tests, debug prints | General-purpose communication | High-speed or large transfers |
| Needs buffers | Not necessarily | Yes | Yes (DMA buffer) |
| Power efficiency | Poor | Good | Best |
Rule of thumb: start with polling to prove the hardware works, move to interrupts with ring buffers for real applications, and use DMA when speed or CPU load demands it.
7. Receive Buffers and Ring Buffers
Why a buffer is needed
The UART hardware usually holds only one byte (or a very small FIFO) of received data. If the next byte arrives before you read the previous one, the old byte is lost (an overrun). Software cannot always react instantly, so we store incoming bytes in a larger memory buffer and let the main code process them when ready.
Hardware ββΊ ISR ββΊ [ buffer in RAM ] ββΊ Main code reads at its own pace
Hardware FIFO
Some UARTs include a small hardware FIFO (first-in, first-out queue) that holds several bytes, giving software more time. You can often configure a threshold level that triggers the interrupt only when the FIFO reaches a certain number of bytes.
Ring (circular) buffer
A ring buffer is a fixed-size array used as if its end connects back to its start. Two indices track the state:
- head (write index): where the next incoming byte goes (moved by the ISR).
- tail (read index): where the next byte to be read comes from (moved by main code).
tail head
βΌ βΌ
[ _ ][ A ][ B ][ C ][ D ][ _ ][ _ ][ _ ]
β data waiting β
After the end of the array, indices wrap to 0.
Rules
- Empty:
head == tail - Full:
(head + 1) % SIZE == tail(one slot is left unused to tell full from empty) - Count of bytes:
(head - tail + SIZE) % SIZE
Implementation
#define RX_SIZE 64 // choose based on worst-case burst
static volatile uint8_t rx_buf[RX_SIZE];
static volatile uint16_t rx_head = 0; // written only by the ISR
static volatile uint16_t rx_tail = 0; // written only by main code
static volatile uint16_t rx_overflow = 0;
// Called from the ISR
void rx_buffer_put(uint8_t b) {
uint16_t next = (rx_head + 1) % RX_SIZE;
if (next != rx_tail) { // space available
rx_buf[rx_head] = b;
rx_head = next;
} else {
rx_overflow++; // buffer full: byte dropped, record it
}
}
// Called from main code
int rx_buffer_get(uint8_t *out) {
if (rx_tail == rx_head) return 0; // empty
*out = rx_buf[rx_tail];
rx_tail = (rx_tail + 1) % RX_SIZE;
return 1;
}
uint16_t rx_buffer_count(void) {
return (rx_head - rx_tail + RX_SIZE) % RX_SIZE;
}
Why this works safely
- One writer per index: only the ISR changes
head, only main changestail. No lock needed (provided single index reads and writes are atomic on your CPU). volatileensures the compiler re-reads the indices each time.
Sizing the buffer
Think about the worst case: how many bytes can arrive while the main code is busy?
Required size β₯ (bytes per second) Γ (longest time main code ignores the buffer)
At 115200 baud (~11,520 bytes per second), if main is busy for 10 ms, up to ~115 bytes may arrive. A 64-byte buffer would overflow, so choose a bigger one (or process data more often).
Tip: a power-of-two size (16, 32, 64, 128, 256) lets you replace % SIZE with a faster & (SIZE - 1).
The same idea for transmit
A TX ring buffer works in reverse: main code writes bytes in (head), and the TX interrupt reads bytes out (tail) to send them. This lets your program queue a whole message and move on instantly.
What happens when the buffer is full?
Your design must choose one:
- Drop the new byte (simple, shown above).
- Overwrite the oldest byte (keeps the latest data).
- Block the writer until space frees (not allowed inside an ISR).
- Use flow control (RTS/CTS or software handshake) to ask the sender to pause.
Always count overflows so you can tell when data is being lost.
8. Framing Errors, Overrun and Parity Errors
The UART hardware can detect several kinds of problems and report them in the status register.
Framing error (FE)
What: the receiver expected the stop bit to be high (1) but found it low (0).
Common causes
- Baud rate mismatch between sender and receiver (the most common cause).
- Wrong data-bits, parity or stop-bit settings.
- Electrical noise corrupting the line.
- A break condition (line held low for longer than a frame), which can also be intentional.
- Connecting at the wrong time (the receiver starts in the middle of a frame).
Symptoms: garbled characters, strange symbols, repeated 0x00 or 0xFF.
Overrun error (ORE / OVR)
What: a new byte arrived and finished shifting in before software read the previous byte from the data register. The old (or new) byte is lost.
Common causes
- Interrupt not serviced fast enough (ISR too slow, interrupts disabled too long, higher-priority ISR running).
- Polling loop that checks too rarely.
- High baud rate with slow software.
- No FIFO or DMA when needed.
Fixes: faster, shorter ISRs; use a ring buffer; give the UART interrupt appropriate priority; use DMA; lower the baud rate; use flow control.
Parity error (PE)
What: the parity bit received does not match the parity calculated from the data bits. Only occurs when parity is enabled.
Common causes
- Noise flipping a bit.
- Parity setting mismatch (one side even, other odd or none).
- Poor wiring, long cables, interference.
Noise error (on some chips)
Some UARTs also flag noise when the oversampled readings of a single bit disagree.
Summary table
| Error | Meaning | Typical cause | Typical fix |
|---|---|---|---|
| Framing (FE) | Stop bit not found where expected | Baud mismatch, wrong settings, noise | Match settings, check clock accuracy |
| Overrun (ORE) | New byte arrived before the old one was read | Slow software, no buffering | Faster ISR, ring buffer, DMA, flow control |
| Parity (PE) | Parity check failed | Noise, parity mismatch | Match settings, improve wiring, retry |
| Noise (NE) | Unstable samples within a bit | Interference | Shield, shorten cable, lower baud |
Handling errors in code
Error flags often must be cleared in a specific way (for example, by reading the status register and then the data register). Check your datasheet. If you fail to clear them, the UART may stop receiving, or the error interrupt may fire constantly.
void UART_IRQHandler(void) {
uint32_t status = UART_SR; // read status first
if (status & (FE_FLAG | PE_FLAG | ORE_FLAG)) {
if (status & FE_FLAG) framing_errors++;
if (status & PE_FLAG) parity_errors++;
if (status & ORE_FLAG) overrun_errors++;
(void)UART_DR; // dummy read clears the flags (chip dependent)
return; // discard the bad byte
}
if (status & RXNE_FLAG) {
rx_buffer_put((uint8_t)UART_DR);
}
}
Good error-handling habits
- Always read the status register before the data register. The status tells you whether the byte you are about to read is valid.
- Discard or flag bad bytes rather than silently using them.
- Keep counters for each error type. They are valuable for debugging.
- Decide what the application should do on errors: ignore, request a resend, or reset the receive state machine.
- Remember that parity only catches some errors. For important data, add a checksum or CRC at the protocol level (see Section 10).
9. Serial Debugging and Terminal Tools
UART is one of the most useful tools for seeing what your firmware is doing. A simple text message sent to a PC can reveal states, values and errors without needing a debugger.
Hardware needed
- A USB-to-serial (USB-UART) adapter, or an on-board USB bridge on a development board.
- Wires: MCU TX β adapter RX, MCU RX β adapter TX, and GND to GND.
- Make sure the adapter voltage level matches your MCU (3.3 V or 5 V).
Terminal programs
A serial terminal is a PC program that opens the serial port, lets you set the baud rate and frame format, shows incoming text, and sends what you type.
Common examples include PuTTY, Tera Term, minicom, screen, CoolTerm, and the serial monitor built into many IDEs.
Typical settings to configure
- Port name (for example,
COM3on Windows or/dev/ttyUSB0on Linux) - Baud rate (must match the firmware)
- Data bits, parity, stop bits (for example, 8N1)
- Flow control (usually none)
- Display mode: text (ASCII) or hex
Debug printing
void debug_print(const char *msg) {
uart_send_string(msg);
}
debug_print("System started\r\n");
- Use
\r\n(carriage return + line feed) at the end of lines so terminals display them properly. - You can implement simple helpers to print numbers in decimal or hex.
- Redirecting the standard
printfto the UART is possible on many toolchains, but it can be slow, large in code size and heavy on stack. Keep it out of ISRs.
Debugging tips
| Symptom | Likely cause |
|---|---|
| Nothing appears | TX/RX swapped or not crossed, no common ground, wrong port, UART clock or pin mux not configured |
| Garbage characters | Baud rate mismatch, wrong frame format, clock inaccuracy |
| Some characters missing | Overrun, buffer too small, ISR too slow, TX buffer overwritten |
| Works at low baud, fails at high | Baud-rate error too large, cable or noise, slow ISR |
| Messages merge or lines look wrong | Missing \r\n, terminal newline settings |
| Random errors | Noise, loose connection, floating RX pin |
| Only first byte sent | Not waiting for TXE before writing the next byte |
| Program hangs on send | Waiting forever on a flag that never sets (UART not enabled, clock off) |
Other helpful tools
- Logic analyser: captures the actual signal on the TX or RX line and can decode it as UART. It shows the real bit timing, start and stop bits, and frame errors.
- Oscilloscope: lets you check voltage levels, signal shape and the bit width, and measure the actual baud rate.
- Loopback test: connect the MCU's TX directly to its own RX. Anything you send should come straight back. If it does, the UART configuration and code are working.
// Loopback test idea
uart_send_byte('A');
uint8_t r = uart_receive_byte();
// r should equal 'A'
- Hex view: when the data is binary rather than text, view in hex mode to see exact byte values.
Debugging process
- Check the physical connections (crossed TX/RX, ground, voltage levels).
- Confirm identical settings on both sides.
- Run a loopback test.
- Send a simple repeating character (like
U=0x55=0101 0101, which gives a perfect alternating pattern useful for checking baud rate on a scope). - Add error counters and print them periodically.
- Use a logic analyser if the issue is still unclear.
10. UART Protocol Implementation
UART itself only moves raw bytes. It does not know where a message starts or ends, who it is for, or whether it is correct. A protocol is a set of rules you add on top, so both sides understand the byte stream.
Why a protocol is needed
- Messages have different lengths.
- Bytes can be lost or corrupted.
- The receiver may start listening mid-message.
- Multiple message types need to be distinguished.
Common approaches
1. Text line-based protocol
Messages are readable text ending with a newline.
LED ON\n
TEMP?\n
- Very easy to debug in a terminal.
- Uses more bytes and needs parsing.
- Receiver collects bytes until it sees the end character (
\n), then processes the line.
2. Fixed-length binary frames
Every message is the same size.
[ START ][ CMD ][ DATA1 ][ DATA2 ][ CHECKSUM ]
Simple to parse, but inflexible.
3. Variable-length binary frames
βββββββββ¬βββββββββ¬βββββββ¬βββββββββββββ¬βββββββββββ
β START β LENGTH β TYPE β PAYLOAD β CHECKSUM β
β 1 B β 1 B β 1 B β 0..N bytes β 1-2 B β
βββββββββ΄βββββββββ΄βββββββ΄βββββββββββββ΄βββββββββββ
| Field | Purpose |
|---|---|
| Start byte (header) | Marks the beginning of a frame (for example, 0xAA) |
| Length | Number of payload bytes, so the receiver knows when to stop |
| Type / command | What the message means |
| Payload | The actual data |
| Checksum or CRC | Detects corruption |
| End byte (optional) | Marks the end |
Delimiting messages (finding where frames start and end)
- Start/end markers (for example,
0x02...0x03, or a newline). - Length field after the start byte.
- Timeout: a gap of silence marks the end of a frame.
- Byte stuffing (escaping): if marker values can also appear in the data, special escape bytes are used so they are not mistaken for markers.
Error detection at protocol level
Since parity only catches limited errors, add a stronger check:
- Simple sum checksum: add up all bytes and send the total (modulo 256).
- XOR checksum: XOR all bytes together.
- CRC (cyclic redundancy check): much stronger, widely used (for example, CRC-16).
uint8_t checksum(const uint8_t *data, size_t len) {
uint8_t sum = 0;
for (size_t i = 0; i < len; i++) sum += data[i];
return sum; // sender and receiver compute the same way
}
Receiving with a state machine
The best way to parse an incoming byte stream is a small state machine that handles one byte at a time. (This builds directly on interrupt-driven state machines: the ISR stores bytes in the ring buffer and the main code feeds them to the parser.)
States: wait for start β read length β read type β read payload β read checksum β validate.
WAIT_START ββ0xAAβββΊ GET_LEN βββΊ GET_TYPE βββΊ GET_PAYLOAD βββΊ GET_CHECKSUM
β² β
βββββββββββ bad checksum / timeout / error βββββββββββββββββββββ€
βββββββββββ good frame: deliver message ββββββββββββββββββββββββ
typedef enum { S_START, S_LEN, S_TYPE, S_PAYLOAD, S_CHK } rx_state_t;
#define START_BYTE 0xAA
#define MAX_PAYLOAD 32
static rx_state_t state = S_START;
static uint8_t length, type, idx, payload[MAX_PAYLOAD], running_sum;
void parser_feed(uint8_t b) {
switch (state) {
case S_START:
if (b == START_BYTE) { running_sum = b; state = S_LEN; }
break;
case S_LEN:
if (b > MAX_PAYLOAD) { state = S_START; break; } // reject bad length
length = b; running_sum += b; state = S_TYPE;
break;
case S_TYPE:
type = b; running_sum += b; idx = 0;
state = (length == 0) ? S_CHK : S_PAYLOAD;
break;
case S_PAYLOAD:
payload[idx++] = b; running_sum += b;
if (idx == length) state = S_CHK;
break;
case S_CHK:
if (b == running_sum) {
handle_message(type, payload, length); // good frame
} else {
checksum_errors++; // bad frame
}
state = S_START; // ready for next
break;
}
}
// In main loop:
uint8_t byte;
while (rx_buffer_get(&byte)) {
parser_feed(byte);
}
Transmitting a frame
void send_frame(uint8_t type, const uint8_t *payload, uint8_t len) {
uint8_t sum = 0;
uint8_t header[3] = { START_BYTE, len, type };
for (int i = 0; i < 3; i++) { uart_send_byte(header[i]); sum += header[i]; }
for (int i = 0; i < len; i++) { uart_send_byte(payload[i]); sum += payload[i]; }
uart_send_byte(sum);
}
(In a real design, push these bytes into the TX ring buffer or hand the whole frame to DMA instead of blocking.)
Robustness features
| Feature | Why it matters |
|---|---|
| Timeouts | If a frame stalls half-way, reset the parser so it re-synchronises |
| Length limits | Prevents buffer overflow from a bad length field |
| Checksum/CRC | Detects corrupted frames |
| Resynchronisation | After an error, discard bytes until the next valid start marker |
| Acknowledge (ACK/NACK) | Receiver tells the sender whether the frame arrived correctly |
| Retry | Sender retransmits if no ACK arrives within a timeout |
| Sequence numbers | Detect lost or duplicated frames |
| Flow control | Prevents the sender from overrunning the receiver |
Flow control
- Hardware flow control (RTS/CTS): dedicated wires tell the sender to pause or continue.
- Software flow control (XON/XOFF): special bytes (
0x11to resume,0x13to pause) are sent in the data stream. This only works for text, since those byte values must not appear in the data.
Command-response example
PC β MCU : [0xAA][0x00][0x01][0xAB] (command: read temperature)
MCU β PC : [0xAA][0x02][0x81][0x00][0x19][checksum] (response with 2 data bytes)
Design checklist for a UART protocol
- Same baud rate and frame format on both sides
- Clear message boundaries (start byte, length or terminator)
- Error detection (checksum or CRC)
- Parser as a state machine, fed from a ring buffer
- Maximum length enforced to prevent overflow
- Timeout and resync after errors
- Interrupt or DMA used so bytes are not missed
- Error counters for FE, PE, ORE and checksum failures
- Optional ACK/NACK and retry for reliable delivery
- Documented message format (so both ends agree)
11. Quick Revision Cheat Sheet
| Topic | One-line summary |
|---|---|
| Serial communication | Sends bits one after another over a single wire; saves pins and wiring. |
| UART | Hardware block converting parallel bytes to serial frames and back. |
| USART | UART plus an optional synchronous mode with a clock line. |
| Asynchronous | No shared clock; start and stop bits keep sender and receiver in step. |
| Synchronous | Shared clock line; no start or stop bits needed. |
| Baud rate | Symbols (bits) per second; must match on both sides. |
| Data bits | Usually 8 per character, sent LSB first. |
| Parity | Optional error-check bit (even or odd); catches only odd numbers of flipped bits. |
| Stop bit(s) | High level(s) ending the frame; 1 is most common. |
| 8N1 | 8 data bits, no parity, 1 stop bit (10 bits per frame). |
| TX / RX / GND | TX connects to the other side's RX; common ground is essential. |
| Registers | Data, status, control, baud-rate registers. |
| TXE / TC | TXE: can write the next byte. TC: everything has finished sending. |
| RXNE | A received byte is ready to read. |
| Polling | Simple but blocks the CPU and can miss data. |
| Interrupt-driven | Efficient; needs buffers and careful shared-data handling. |
| DMA | Moves blocks with almost no CPU work; best for high speed. |
| Ring buffer | Circular FIFO in RAM; ISR writes head, main reads tail. |
| Framing error | Stop bit not high; usually baud or format mismatch. |
| Overrun error | New byte arrived before the old one was read. |
| Parity error | Received parity bit does not match the data. |
| Terminal tools | PuTTY, Tera Term, minicom, serial monitors, plus logic analysers and loopback tests. |
| Protocol | Start byte, length, type, payload, checksum, parsed by a state machine. |
Ten things to remember
- UART sends data one bit at a time, with no shared clock (asynchronous mode).
- A frame is start bit + data bits (LSB first) + optional parity + stop bit(s).
- Baud rate, data bits, parity and stop bits must match on both devices.
- TX connects to RX, RX connects to TX, and grounds are shared.
- Check TXE before writing and RXNE before reading; use TC to know the transmission has fully finished.
- Polling is simplest, interrupts are the practical default, DMA is for heavy loads.
- Use a ring buffer so bytes are not lost while the main code is busy.
- Framing errors usually mean a baud or format mismatch. Overrun means software was too slow.
- Parity is weak. Real protocols add a checksum or CRC.
- Parse incoming data with a state machine, and always include timeouts and resync logic.
#SPI β Serial Peripheral Interface#
How to use these notes: Read top to bottom the first time. Each section builds on the previous one. Code is written in generic embedded C, so register and function names (like
SPI_DRorCS_LOW()) will differ on your chip. The ideas stay the same everywhere.
Table of Contents
- What Is SPI? (Overview)
- SPI Master and Slave
- MOSI, MISO, SCLK and Chip Select
- Full-Duplex Communication
- Clock Polarity (CPOL) and Phase (CPHA)
- SPI Modes 0β3
- Multiple SPI Devices
- Data Frames and Transfer Timing
- SPI Registers and Driver Implementation
- SPI vs UART vs IΒ²C
- Quick Revision Cheat Sheet
1. What Is SPI? (Overview)
SPI (Serial Peripheral Interface) is a synchronous, serial, full-duplex communication bus used for short-distance communication between a microcontroller and other chips on the same board.
The name tells the story:
- Serial: bits are sent one after another on a wire.
- Peripheral: typically used to talk to peripherals such as sensors, flash memory, displays, SD cards and ADCs/DACs.
- Interface: a defined set of wires and timing rules.
The one-sentence idea
One device (the master) generates a clock, and on every clock tick one bit goes out and one bit comes in.
Because a clock line is shared, there are no start or stop bits and no baud-rate agreement problem like in asynchronous UART. SPI is also fast, often several MHz to tens of MHz.
Key characteristics
| Feature | SPI |
|---|---|
| Type | Synchronous (uses a clock line) |
| Direction | Full duplex (send and receive at the same time) |
| Topology | One master, one or more slaves |
| Wires | 4 (plus one extra chip-select per additional slave) |
| Speed | High (MHz range) |
| Addressing | None built in. Slaves are chosen with a hardware chip-select line |
| Acknowledge | None built in |
| Frame size | Typically 8 bits, but configurable on many MCUs (for example 4 to 16 bits) |
2. SPI Master and Slave
Master
The master is in charge. It:
- generates the clock (SCLK),
- starts and ends every transfer,
- selects which slave it wants to talk to,
- decides the speed and mode of communication.
In most embedded systems, the microcontroller is the master.
Slave
A slave is a device that:
- listens to the clock and chip-select from the master,
- only communicates when selected,
- cannot start a transfer on its own.
Sensors, flash chips, displays and SD cards are typically slaves.
Important rule
A slave can never talk unless the master clocks it. If the master wants to read from a slave, it must still generate clock pulses (usually by sending dummy bytes), and the slave's data comes back during those pulses.
Master/slave summary
| Feature | Master | Slave |
|---|---|---|
| Generates clock | Yes | No (receives it) |
| Starts transfers | Yes | No |
| Controls chip select | Yes (drives it) | No (receives it) |
| Typical device | Microcontroller | Sensor, memory, display, another MCU |
| Number on bus | Usually one | One or many |
Can an MCU be a slave?
Yes. Many MCUs can run their SPI block in slave mode, for example when another MCU or a processor needs to send commands to it. In slave mode the MCU's pin directions flip (see the next section), and it must be ready to shift data out when the master clocks it. This is harder to implement because the slave must have the next reply byte prepared in advance.
Multi-master?
SPI is designed for a single master. Some hardware supports multi-master with special fault detection, but it is uncommon and not covered by basic SPI.
3. MOSI, MISO, SCLK and Chip Select
SPI uses four signals.
| Signal | Full name | Direction | Purpose |
|---|---|---|---|
| SCLK (or SCK) | Serial Clock | Master β Slave | Clock that times every bit |
| MOSI | Master Out, Slave In | Master β Slave | Data from master to slave |
| MISO | Master In, Slave Out | Slave β Master | Data from slave to master |
| CS (or SS, CSN) | Chip Select / Slave Select | Master β Slave | Selects (enables) a particular slave |
Some datasheets use alternate names: SDI/SDO (serial data in/out), COPI/CIPO (controller out peripheral in, and so on). Always check which pin is the input and which is the output on the device you are reading about.
Wiring
MASTER SLAVE
ββββββββββββ ββββββββββββ
β SCLK ββββββββββββββββββββΊβ SCLK β
β MOSI ββββββββββββββββββββΊβ MOSI β
β MISO βββββββββββββββββββββ€ MISO β
β CS ββββββββββββββββββββΊβ CS β
β GND βββββββββββββββββββββ€ GND β
ββββββββββββ ββββββββββββ
Note: unlike UART, MOSI connects to MOSI and MISO to MISO (same names on both sides, because the names describe the master's viewpoint). A common ground is also required.
What each line does
SCLK (clock)
- Always driven by the master.
- Every clock cycle moves one bit.
- Idle level (high or low) depends on the SPI mode.
MOSI
- Carries the bits the master sends (commands, addresses, data to write).
MISO
- Carries the bits the slave sends back (status, data being read).
- When the slave is not selected, it should release MISO (high impedance, also called tri-state) so other slaves can use the shared line.
CS / SS (chip select)
- Usually active low: the slave listens only when CS is pulled low.
- Falling edge of CS = start of a transaction.
- Rising edge of CS = end of a transaction.
- When CS is high, the slave ignores SCLK and MOSI and keeps MISO released.
- CS also resets the slave's internal bit counter, so it knows where a frame begins. This is how SPI gets framing without start and stop bits.
Typical idle state
CS βββββ βββββ (active low)
βββββββββββββββββββββββββββ
SCLK βββββββββ βββ βββ βββ ββββββββββ (idle low, in Mode 0)
βββ βββ βββ βββ
Pin configuration on the MCU
- SCLK, MOSI, MISO are usually alternate-function pins controlled by the SPI peripheral.
- CS is often a normal GPIO output that software controls (even when the SPI hardware could manage it), because this gives full control over multi-byte transactions and multiple slaves.
- When the MCU is master: SCLK = output, MOSI = output, MISO = input, CS = output.
- When the MCU is slave: SCLK = input, MOSI = input, MISO = output, CS = input.
4. Full-Duplex Communication
The shift-register idea
Inside both master and slave there is a shift register (typically 8 bits). The two shift registers are connected in a ring through MOSI and MISO.
MASTER shift register SLAVE shift register
βββ¬ββ¬ββ¬ββ¬ββ¬ββ¬ββ¬ββ βββ¬ββ¬ββ¬ββ¬ββ¬ββ¬ββ¬ββ
β7β6β5β4β3β2β1β0βββββββΊ MOSI βββββββΊβ7β6β5β4β3β2β1β0β
βββ΄ββ΄ββ΄ββ΄ββ΄ββ΄ββ΄ββββββββ MISO βββββββββββ΄ββ΄ββ΄ββ΄ββ΄ββ΄ββ΄ββ
β² β²
βββββββββββββ SCLK βββββββββββββββββββ
On every clock pulse:
- the master shifts one bit out on MOSI and one bit in from MISO,
- the slave shifts one bit in from MOSI and one bit out on MISO.
After 8 clock pulses, the two shift registers have swapped contents. The master's byte is now in the slave, and the slave's byte is now in the master.
Example
Master sends 0xA5, slave's shift register holds 0x3C.
Before: Master = 0xA5 Slave = 0x3C
After 8 clocks:
Master = 0x3C Slave = 0xA5
What this means in practice
- Every transfer is both a send and a receive. You cannot send without also receiving something, and vice versa.
- If you only want to send, you simply ignore the received byte.
- If you only want to read, you send a dummy byte (often
0x00or0xFF) so the clock runs, and keep the byte that comes back. - Many devices use a command-then-response pattern: the master sends a command byte (the response byte received at the same time is meaningless), then sends dummy bytes to clock out the actual reply.
Master sends: [ READ_CMD ] [ dummy ] [ dummy ]
Slave replies: [ junk ] [ data1 ] [ data2 ]
β² β² β²
ignore this real data real data
Half-duplex use
Even though SPI hardware is full duplex, many devices use it in a half-duplex-like manner (one direction carries useful data while the other is ignored). Some devices share one bidirectional data line (a "3-wire" mode), but standard SPI uses separate MOSI and MISO.
5. Clock Polarity (CPOL) and Phase (CPHA)
The master and slave must agree on two things about the clock:
- What level the clock rests at when idle β CPOL
- Which clock edge is used to sample data (and which to change it) β CPHA
If they disagree, bits get sampled at the wrong moment and the data is garbage, often shifted by one bit.
CPOL: clock polarity
| CPOL | Idle level of SCLK | First edge (leaving idle) | Second edge (returning to idle) |
|---|---|---|---|
| 0 | Low | Rising | Falling |
| 1 | High | Falling | Rising |
CPOL = 0: ______βββ_βββ_βββ_______ idle LOW
β β
first second
(rise) (fall)
CPOL = 1: βββββββ_βββ_βββ_ββββββββ idle HIGH
β β
first second
(fall) (rise)
CPHA: clock phase
CPHA decides whether data is sampled on the first or the second clock edge of each bit.
| CPHA | Data is sampled (captured) on | Data is changed (shifted out) on |
|---|---|---|
| 0 | First clock edge | Second clock edge (and the first bit is already on the line when CS goes active) |
| 1 | Second clock edge | First clock edge |
The key idea: setup and sample
Each data bit needs to be stable when it is sampled. So the transmitter changes the data on one edge and the receiver samples it on the opposite edge. That gives the signal time to settle.
- CPHA = 0: data is already valid before the first clock edge β sample on the first edge, change on the second.
- CPHA = 1: data is put out on the first edge β sample on the second edge.
Combining CPOL and CPHA: sampling edge
| CPOL | CPHA | Sampling edge |
|---|---|---|
| 0 | 0 | Rising |
| 0 | 1 | Falling |
| 1 | 0 | Falling |
| 1 | 1 | Rising |
Memory trick: if CPOL and CPHA are equal (0,0 or 1,1) β sample on the rising edge. If they differ (0,1 or 1,0) β sample on the falling edge.
Bit order
Another setting: MSB first (most common) or LSB first. Most SPI devices use MSB first, but check the datasheet. This is a configuration option on the SPI peripheral, separate from CPOL and CPHA.
6. SPI Modes 0β3
The two settings (CPOL, CPHA) form four combinations, called SPI modes.
| Mode | CPOL | CPHA | Clock idle | Data sampled on | Data changed on |
|---|---|---|---|---|---|
| 0 | 0 | 0 | Low | Rising edge | Falling edge |
| 1 | 0 | 1 | Low | Falling edge | Rising edge |
| 2 | 1 | 0 | High | Falling edge | Rising edge |
| 3 | 1 | 1 | High | Rising edge | Falling edge |
Timing pictures
Mode 0 (CPOL=0, CPHA=0): idle low, sample on rising edge.
CS βΎβΎβΎ\_______________________________/βΎβΎβΎ
SCLK ____βββ_βββ_βββ_βββ_...____________
MOSI ===< D7 >< D6 >< D5 >< D4 >...=====
β β β β
sample on each rising edge
Mode 1 (CPOL=0, CPHA=1): idle low, sample on falling edge.
SCLK ____βββ_βββ_βββ_βββ_...____________
MOSI ====< D7 >< D6 >< D5 >...==========
β β β
sample on each falling edge
Mode 2 (CPOL=1, CPHA=0): idle high, sample on falling edge.
SCLK βΎβΎβΎβΎ\_/βΎ\_/βΎ\_/βΎ\_/βΎ...βΎβΎβΎβΎβΎβΎβΎβΎβΎβΎβΎβΎ
β β β sample on falling edges
Mode 3 (CPOL=1, CPHA=1): idle high, sample on rising edge.
SCLK βΎβΎβΎβΎ\_/βΎ\_/βΎ\_/βΎ\_/βΎ...βΎβΎβΎβΎβΎβΎβΎβΎβΎβΎβΎβΎ
β β β sample on rising edges
Most popular modes
Mode 0 and Mode 3 are the most commonly used (both sample on the rising edge). Many devices support more than one mode, so check the datasheet and choose one the device and your MCU both support.
How to choose the right mode
- Open the slave device's datasheet.
- Find its timing diagram or the "SPI mode" or "CPOL/CPHA" statement.
- Configure your master with the same CPOL and CPHA.
- Also match bit order (MSB/LSB first) and maximum clock speed.
Naming confusion warning
Different manufacturers use different naming for these settings (for example "clock polarity," "clock phase," "data edge," "clock edge"). A few MCUs number the modes differently, or define phase slightly differently. Always verify with the actual timing diagram, not just the mode number.
Common symptoms of the wrong mode
| Symptom | Meaning |
|---|---|
All bytes read as 0xFF or 0x00 | Slave not responding, or wrong mode / wiring / CS |
Data looks shifted by one bit (for example 0x55 becomes 0xAA or 0x2A) | CPHA wrong |
| Data inverted or random | CPOL wrong, or noise |
| Works at slow speed, fails at high speed | Timing limits, wiring too long, or mode edge timing issue |
7. Multiple SPI Devices
One SPI master can talk to several slaves. There are two standard wiring methods.
Method 1: Independent slave select (the usual way)
All slaves share SCLK, MOSI and MISO. Each slave has its own CS line from the master.
βββββββββββββββ SCLK βββββββββ¬βββββββββββββββββ
β βββββββββββββ MOSI βββββββββΌβββββββββ¬ββββββββΌββββββββ
β β βββββββββββ MISO βββββββββΌβββββββββΌββββββββΌββββ β
ββββββββββ΄ββ΄ββ΄βββ ββββ΄βββββββββ΄βββ ββββ΄ββββ΄ββββ΄βββ
β MASTER β β SLAVE 1 β β SLAVE 2 β
β CS1 ββΌβββββββββββββββββββ€ CS β β β
β CS2 ββΌβββββββββββββββββββΌβββββββββββββββΌβββ€ CS β
βββββββββββββββββ ββββββββββββββββ βββββββββββββββ
How it works
- Master pulls only one CS low at a time.
- Only the selected slave listens and drives MISO.
- All other slaves ignore the bus and keep MISO in high impedance.
// Talk to slave 1
CS1_LOW();
spi_transfer(0x9F);
CS1_HIGH();
// Talk to slave 2
CS2_LOW();
spi_transfer(0x03);
CS2_HIGH();
Pros: simple, fast, flexible, each slave can use a different mode if you reconfigure between transfers. Cons: needs one extra pin (CS) per slave. Many slaves means many pins. (A decoder chip can expand the CS lines.)
Method 2: Daisy chain
Slaves are linked in a chain: the master's MOSI goes to slave 1's input, slave 1's output goes to slave 2's input, and so on. The last slave's output returns to the master's MISO. SCLK and one shared CS go to all.
MASTER MOSI βββΊ SLAVE1 βββΊ SLAVE2 βββΊ SLAVE3 βββΊ MASTER MISO
SCLK and CS shared by all
How it works
- Data shifts through all devices like one long shift register.
- To send data to slave 3, you clock out three bytes (one for each device in the chain).
- Data only takes effect when CS goes high at the end.
Pros: only one CS pin regardless of the number of slaves. Cons: every transfer touches the entire chain, slower for one-device access, and only works with devices that support daisy chaining (such as shift registers and some LED or DAC chips).
Comparison
| Aspect | Independent CS | Daisy chain |
|---|---|---|
| Pins | 3 shared + 1 CS per slave | 3 shared + 1 CS total |
| Access one device | Easy | Must shift through all |
| Device support | Any SPI slave | Only chain-capable slaves |
| Speed for single access | Fast | Slower |
Rules for sharing a bus
- Only one CS low at a time. If two slaves are active together, both drive MISO and the signals collide.
- Slaves that are not selected must tri-state MISO. If a device does not do this, add a buffer or use a dedicated MISO line.
- Idle all CS lines high at startup, before configuring the bus, so no slave is accidentally selected.
- If slaves need different modes or speeds, reconfigure the master (CPOL, CPHA, clock divider) before each transaction.
- Keep wires short. Long wires on a shared fast clock cause ringing and errors.
8. Data Frames and Transfer Timing
The frame
SPI has no formal frame format like UART. A "frame" is simply:
The time CS is held low, during which the master clocks out however many bits it needs.
The device's datasheet defines the meaning of the bytes. A common layout is:
CS low ββΊ [ COMMAND ] [ ADDRESS ] [ DATA ... ] ββΊ CS high
| Part | Purpose |
|---|---|
| Command / opcode | Tells the slave what to do (read, write, enable, and so on) |
| Address | Which register or memory location |
| Data | The bytes to write, or dummy bytes while reading |
Example: reading a register from a sensor
CS βΎβΎ\_____________________________________/βΎβΎ
MOSI [ 0x80 | reg addr ] [ dummy ]
MISO [ don't care ] [ reg value ]
uint8_t sensor_read_reg(uint8_t reg) {
uint8_t value;
CS_LOW();
spi_transfer(reg | 0x80); // command: read bit set + register address
value = spi_transfer(0x00); // dummy byte clocks the reply out
CS_HIGH();
return value;
}
void sensor_write_reg(uint8_t reg, uint8_t value) {
CS_LOW();
spi_transfer(reg & 0x7F); // command: write
spi_transfer(value);
CS_HIGH();
}
(The 0x80 read-flag convention is just an example. Every device defines its own.)
Multi-byte (burst) transfers
Keep CS low across many bytes to read or write a block. Many devices auto-increment their address, so one command can read a whole sequence of registers or a memory page.
CS_LOW();
spi_transfer(CMD_READ_BLOCK);
for (int i = 0; i < len; i++) {
buf[i] = spi_transfer(0x00);
}
CS_HIGH();
Transfer timing
Clock speed
- Set by dividing the peripheral clock:
SCLK = peripheral_clock / divider. - Must stay below the slave's maximum SPI clock, and below what the wiring can carry.
- Check both the master's and the slave's limits.
Time for a transfer
Transfer time β (number of bits) / (SCLK frequency)
Example: 8 bits at 1 MHz = 8 Β΅s per byte
Timing details that matter (all in the slave datasheet)
| Timing item | Meaning |
|---|---|
| CS setup time | How long CS must be low before the first clock edge |
| CS hold time | How long CS must stay low after the last clock edge |
| CS disable (inactive) time | Minimum time CS must be high between transactions |
| Data setup time | Data must be stable this long before the sampling edge |
| Data hold time | Data must stay stable this long after the sampling edge |
| Max clock frequency | Fastest allowed SCLK |
| Clock high/low minimum widths | Pulse width limits |
| Output delay (MISO valid time) | Time after a clock edge before the slave's data is valid (limits max speed) |
Things that affect real transfer speed
- The gap between bytes: software overhead (polling flags, interrupt time) can add idle time between bytes even though the clock is fast.
- CS handling: toggling a GPIO pin in software takes time.
- Interrupt or DMA use (see the next section) changes how continuous the byte stream is.
Frame size
Some SPI peripherals allow non-8-bit frames (for example 4, 12 or 16 bits), which is handy for DACs and ADCs that use 12- or 16-bit words. In that case you set the data size in the control register.
9. SPI Registers and Driver Implementation
Typical SPI registers
Register names differ by chip, but they all provide the same functions.
| Register (generic) | Purpose |
|---|---|
| Control register(s) (CR / SPCR) | Enable SPI, choose master/slave, set CPOL, CPHA, bit order, clock divider, data size, chip-select mode, enable interrupts and DMA |
| Status register (SR / SPSR) | Flags: transmit buffer empty (TXE), receive buffer not empty (RXNE), busy (BSY), overrun, mode fault |
| Data register (DR / SPDR) | Write to send a byte. Read to get the received byte. |
| Baud-rate / clock-divider field | Sets SCLK speed |
| Interrupt enable bits | For TXE, RXNE and error events |
Typical status flags
| Flag | Meaning |
|---|---|
| TXE | Transmit buffer empty. Safe to write the next byte. |
| RXNE | Received byte is waiting to be read |
| BSY | SPI is currently transferring (check before releasing CS!) |
| OVR (overrun) | A new byte arrived before the old one was read |
| MODF (mode fault) | CS input went low while the SPI was set as master (another master or a wiring fault) |
Initialisation steps (master)
void spi_init_master(void) {
// 1. Enable the clock to the SPI peripheral and the GPIO port
enable_spi_clock();
enable_gpio_clock();
// 2. Configure pins
configure_pin_alternate(SCLK_PIN); // SCLK = alternate function output
configure_pin_alternate(MOSI_PIN); // MOSI = alternate function output
configure_pin_alternate(MISO_PIN); // MISO = alternate function input
configure_pin_output(CS_PIN); // CS = normal GPIO output
CS_HIGH(); // idle high (deselected)
// 3. Configure SPI
SPI_CR_MASTER = 1; // master mode
SPI_CR_CPOL = 0; // idle low
SPI_CR_CPHA = 0; // sample on first edge (Mode 0)
SPI_CR_MSBFIRST = 1; // MSB first
SPI_CR_DATASIZE = 8; // 8-bit frames
SPI_CR_DIVIDER = DIV_16; // SCLK = peripheral_clock / 16
SPI_CR_SOFT_CS = 1; // CS handled in software
// 4. Enable the SPI peripheral
SPI_CR_ENABLE = 1;
}
The core function: transfer one byte (polling)
uint8_t spi_transfer(uint8_t tx) {
while (!(SPI_SR & TXE_FLAG)) { } // wait until transmit buffer is empty
SPI_DR = tx; // writing the data register starts the clock
while (!(SPI_SR & RXNE_FLAG)) { } // wait until the byte has been fully shifted in
return (uint8_t)SPI_DR; // read received byte (also clears RXNE)
}
Why we always read after writing: in full-duplex SPI, a received byte arrives for every byte sent. If you do not read it, the receive register fills up and you may get an overrun that stalls later transfers.
Transfer a buffer
void spi_transfer_buffer(const uint8_t *tx, uint8_t *rx, size_t len) {
for (size_t i = 0; i < len; i++) {
uint8_t out = tx ? tx[i] : 0xFF; // dummy byte if only reading
uint8_t in = spi_transfer(out);
if (rx) rx[i] = in; // keep result if wanted
}
}
Wait for the bus to finish before releasing CS
When the last byte has been loaded, the hardware may still be shifting it out. If you raise CS too early, the last bits are cut off.
void spi_end_transaction(void) {
while (!(SPI_SR & TXE_FLAG)) { } // last byte moved to shift register
while (SPI_SR & BSY_FLAG) { } // shifting finished
CS_HIGH();
}
A neat driver structure
Group everything about one slave into a small structure, so the driver can handle multiple devices and per-device settings.
typedef struct {
void (*cs_low)(void);
void (*cs_high)(void);
uint8_t cpol;
uint8_t cpha;
uint32_t clock_divider;
} spi_device_t;
void spi_begin(const spi_device_t *dev) {
spi_set_mode(dev->cpol, dev->cpha); // reconfigure if needed
spi_set_divider(dev->clock_divider);
dev->cs_low();
}
void spi_end(const spi_device_t *dev) {
spi_wait_idle();
dev->cs_high();
}
Driver layering idea
βββββββββββββββββββββββββββββββ
β Device driver (sensor, β knows commands, registers, meaning
β flash, display, ...) β
βββββββββββββββββββββββββββββββ€
β SPI driver β init, transfer, begin/end transaction
βββββββββββββββββββββββββββββββ€
β SPI hardware registers β
βββββββββββββββββββββββββββββββ
Keeping the device-specific code separate from the generic SPI driver makes it easy to reuse the same SPI routines for many devices.
Interrupt-driven SPI
Polling blocks the CPU for the whole transfer. With interrupts, the CPU is free between bytes.
volatile uint8_t *tx_ptr, *rx_ptr;
volatile size_t remaining;
volatile uint8_t spi_done = 0;
void spi_start_transfer(uint8_t *tx, uint8_t *rx, size_t len) {
tx_ptr = tx; rx_ptr = rx; remaining = len; spi_done = 0;
SPI_DR = *tx_ptr++; // load the first byte
enable_spi_rxne_interrupt();
}
void SPI_IRQHandler(void) {
if (SPI_SR & RXNE_FLAG) {
*rx_ptr++ = SPI_DR; // store received byte
if (--remaining) {
SPI_DR = *tx_ptr++; // send the next byte
} else {
disable_spi_rxne_interrupt();
spi_done = 1; // tell main code
}
}
}
(Reminder from the Interrupts topic: shared variables are volatile, and the ISR stays short.)
DMA-based SPI
For large blocks (for example display data, flash pages, SD sectors), DMA moves bytes between memory and the SPI data register without CPU work. You configure a transmit channel and a receive channel, start them, and get one interrupt at the end. Remember that even when you only want to send, the receive side still needs servicing (or the overrun must be handled), because every byte clocked out also clocks one in.
Comparison of driver styles
| Style | CPU load | Complexity | Best for |
|---|---|---|---|
| Polling | High (blocks) | Lowest | Short transfers, simple register access |
| Interrupt | Medium | Medium | Moderate data, keep main loop responsive |
| DMA | Very low | Highest | Large, fast block transfers |
Common driver pitfalls
| Pitfall | Result |
|---|---|
| Forgetting to read the received byte | Overrun, later transfers fail |
| Releasing CS too early (before BSY clears) | Last byte truncated |
| CS left floating at start-up | Slave randomly selected, garbage |
| Wrong mode, bit order, or speed | Garbled or shifted data |
| Not waiting for slave recovery/busy time | Slave ignores or corrupts commands (check datasheet) |
| Changing the mode while a slave is selected | Spurious clock edges, bad data |
| SPI pins left as plain GPIO | No clock or data appears |
| Missing a clock-enable for the SPI block | Registers do not respond |
10. SPI vs UART vs IΒ²C
These are the three most common serial interfaces in embedded systems. Each suits different situations.
A quick intro to IΒ²C (only for comparison)
IΒ²C (Inter-Integrated Circuit) is a synchronous serial bus that uses just two wires: SDA (data) and SCL (clock). Both are shared by every device. Each device has a 7-bit address, and the master starts a transfer by sending the address of the device it wants. The receiver answers each byte with an acknowledge bit. The two lines use open-drain outputs with pull-up resistors.
Detailed comparison
| Feature | SPI | UART | IΒ²C |
|---|---|---|---|
| Type | Synchronous | Asynchronous | Synchronous |
| Wires | 4 (SCLK, MOSI, MISO, CS) plus 1 CS per extra slave | 2 (TX, RX) | 2 (SDA, SCL) |
| Clock line | Yes (master-driven) | No (agreed baud rate) | Yes (master-driven) |
| Duplex | Full duplex | Full duplex | Half duplex |
| Topology | One master, many slaves | Point to point (two devices) | Multi-device bus, multi-master possible |
| Device selection | Hardware chip-select line | Not needed (only two devices) | Software address in the data |
| Typical speed | Very high (MHz to tens of MHz) | Low to moderate (up to about 1 Mbps typically) | Moderate (100 kHz, 400 kHz, 1 MHz common) |
| Acknowledge / error check | None built in | Optional parity, framing detection | ACK/NACK per byte |
| Framing | CS low = frame | Start and stop bits per byte | Start and stop conditions, address byte |
| Hardware complexity | Simple shift register | Needs accurate baud generation | Open-drain, pull-ups, arbitration logic |
| Software complexity | Simple, but mode and CS setup | Simple, but needs buffering and protocol | More involved (addresses, ACKs, clock stretching) |
| Pin cost for many devices | High (CS per slave) | High (one UART per device) | Lowest (two wires for all) |
| Distance | Short (on board) | Longer possible | Short (on board) |
| Power use | Higher at high speed | Low to moderate | Low (but pull-ups consume current) |
| Typical use | Flash memory, SD cards, displays, ADCs/DACs, fast sensors | PC console, GPS, modems, module-to-MCU links | Sensors, EEPROMs, real-time clocks, port expanders |
Strengths and weaknesses
SPI
- + Fastest of the three, simple hardware, full duplex, no addressing overhead, flexible frame lengths.
- β More wires, one CS per slave, no acknowledge or error detection, no formal standard (every device differs slightly), short distance.
UART
- + Only two wires, no clock needed, simple, works over longer distances, great for PC and module links.
- β Both sides must match baud rate and format, point-to-point only, slower, overhead bits on every byte.
IΒ²C
- + Only two wires for many devices, built-in addressing and acknowledge, multi-master support.
- β Slower than SPI, half duplex only, pull-up resistors needed, more complex protocol and timing.
Which to choose?
| Situation | Best choice |
|---|---|
| Need maximum speed (display, flash, SD card, fast ADC) | SPI |
| Few pins available, many slow sensors | IΒ²C |
| Talking to a PC, GPS or wireless module, or a debug console | UART |
| Need device-level acknowledge | IΒ²C |
| Need simple, fast, full-duplex link to one chip | SPI |
| Links over longer cables (with proper line drivers) | UART |
| Many identical devices on one bus with little wiring | IΒ²C |
Pin count example for 4 devices
| Interface | Wires needed |
|---|---|
| SPI | SCLK + MOSI + MISO + 4 CS = 7 |
| IΒ²C | SDA + SCL = 2 |
| UART | 4 separate UARTs = 8 (TX and RX for each) |
11. Quick Revision Cheat Sheet
| Topic | One-line summary |
|---|---|
| SPI | Fast, synchronous, full-duplex serial bus: one master, one or more slaves. |
| Master | Generates the clock, controls CS, starts every transfer. |
| Slave | Responds only when selected and clocked. |
| SCLK | Clock from master. Each pulse moves one bit each way. |
| MOSI | Master Out, Slave In: data to the slave. |
| MISO | Master In, Slave Out: data from the slave (tri-stated when not selected). |
| CS / SS | Active-low select line. Falling edge starts a transaction, rising edge ends it. |
| Full duplex | Two shift registers swap contents. Every send is also a receive. |
| Dummy byte | Sent just to generate clocks when you only want to read. |
| CPOL | Idle level of the clock (0 = low, 1 = high). |
| CPHA | Which edge samples data (0 = first edge, 1 = second edge). |
| Modes 0β3 | Combinations of CPOL/CPHA. Modes 0 and 3 are most common (sample on rising edge). |
| Memory trick | CPOL = CPHA β sample on rising edge. CPOL β CPHA β sample on falling edge. |
| Multiple devices | Share SCLK, MOSI, MISO. One CS per slave (or a daisy chain). Only one CS low at a time. |
| Frame | Whatever happens while CS is low; the datasheet defines the meaning. |
| Registers | Control (mode, speed, enable), status (TXE, RXNE, BSY, overrun), data. |
| Driver core | Wait TXE β write data β wait RXNE β read data. Wait for BSY clear before raising CS. |
| Driver styles | Polling (simple), interrupt (balanced), DMA (large blocks). |
| SPI vs UART vs IΒ²C | SPI fastest and simplest hardware; UART no clock, point to point; IΒ²C fewest wires with addressing and ACK. |
Ten things to remember
- SPI is synchronous: the master's clock times every bit, so no baud-rate matching or start/stop bits.
- Four signals: SCLK, MOSI, MISO, CS. MOSI goes to MOSI and MISO to MISO (unlike UART's crossed wiring).
- The master always initiates. A slave can only reply while the master is clocking.
- Transfers are full duplex: every byte sent also brings one byte back. Send a dummy byte to read.
- CPOL sets the idle clock level; CPHA sets which edge samples. Together they define modes 0β3.
- Master and slave must use the same mode, bit order and a clock speed the slave supports.
- For many slaves, give each its own CS, and keep only one low at a time. Unselected slaves release MISO.
- A "frame" is simply CS low to CS high. Hold CS low for multi-byte (burst) transactions.
- In a driver, always read the received byte, and wait for BSY to clear before raising CS.
- Choose SPI for speed, IΒ²C for many devices on few pins, and UART for point-to-point or PC links.
#IΒ²C β Inter-Integrated Circuit#
How to use these notes: Read top to bottom the first time. Each section builds on the previous one. Code is written in generic embedded C, so register and function names (like
I2C_DRorsda_low()) will differ on your chip. The ideas stay the same everywhere.
Table of Contents
- IΒ²C Bus Architecture
- SDA and SCL Lines
- Pull-Up Resistors
- 7-Bit and 10-Bit Addressing
- Start and Stop Conditions
- ACK and NACK
- Read/Write Transactions
- Repeated Start
- Clock Stretching and Bus Arbitration
- Address Conflicts and Bus Recovery
- IΒ²C Driver Implementation
- Quick Revision Cheat Sheet
1. IΒ²C Bus Architecture
IΒ²C (say "I-squared-C", also written I2C, and called TWI or Two-Wire Interface by some vendors) is a synchronous, serial, multi-device bus that uses only two wires to connect many chips together.
Think of it like a shared conference call line: everyone is on the same line, each person has a name (the address), and the caller says the name of the person they want before speaking.
The big idea
- Two shared wires connect every device: one for data (SDA) and one for clock (SCL).
- Every device has a unique address.
- The master starts each conversation by calling out a slave's address.
- Only the addressed slave replies. All others stay quiet.
VCC
β β
ββ΄β ββ΄β
Rp β β β β Rp (pull-up resistors)
ββ¬β ββ¬β
SDA βββββββββΌββββββββΌβββββββββββ¬βββββββββββββββ¬βββββββββββββββ
SCL βββββββββΌββββββββ΄βββββββββββΌβββββββββββ¬ββββΌβββββββββββ¬ββββΌββββββ
β β β β β β
ββββββ΄βββββββ ββββββ΄βββββ β β βββββββ΄ββββ΄ββ
β MASTER β β SLAVE 1 β βββββ β β SLAVE 2 β
β (MCU) β β 0x48 β β β 0x68 β
βββββββββββββ βββββββββββ β βββββββββββββ
Roles
| Role | Meaning |
|---|---|
| Master | Starts and stops transfers, generates the clock (SCL), and sends the address |
| Slave | Responds when its address is called. Does not generate the clock (except for clock stretching) |
| Transmitter | Device that is currently sending a byte |
| Receiver | Device that is currently receiving a byte |
A device's role as transmitter or receiver can change during one transaction. For example, the master transmits the address, then the slave transmits data back. Master and slave are about who controls the bus; transmitter and receiver are about who is sending the current byte.
Multi-master support
Unlike SPI, IΒ²C officially supports several masters on the same bus. Because they share the lines, the standard defines rules to avoid collisions: arbitration. Most small systems still use one master and several slaves.
Speed modes
| Mode | Typical maximum clock |
|---|---|
| Standard mode | 100 kHz |
| Fast mode | 400 kHz |
| Fast mode plus | 1 MHz |
| High-speed mode | 3.4 MHz |
| Ultra-fast mode | 5 MHz (one-way, write only) |
All devices on a bus must support the speed you choose. The slowest device sets the limit. Standard and Fast modes are by far the most common.
Key characteristics
| Feature | IΒ²C |
|---|---|
| Type | Synchronous, serial |
| Wires | 2 (SDA, SCL) plus ground |
| Direction | Half duplex (one direction at a time) |
| Topology | Multi-device bus (multi-master possible) |
| Addressing | Built in (7-bit or 10-bit) |
| Acknowledge | Built in (every byte) |
| Output type | Open-drain with pull-up resistors |
| Distance | Short (on board or short cable) |
Frame at a glance
START β ADDRESS (7 bits) + R/W β ACK β DATA BYTE β ACK β DATA BYTE β ACK β STOP
2. SDA and SCL Lines
The two lines
| Line | Name | Purpose | Driven by |
|---|---|---|---|
| SDA | Serial Data | Carries addresses and data bits | Whoever is currently transmitting (master or slave) |
| SCL | Serial Clock | Times every bit | Master (a slave may hold it low to slow things down) |
Open-drain (wired-AND) behaviour
This is the single most important concept in IΒ²C hardware.
Every device connects to SDA and SCL through an open-drain (or open-collector) output. A device can only do one of two things to a line:
- Pull the line LOW (connect it to ground), or
- Release the line (stop driving it, letting it float).
No device ever drives the line high. The line goes high only because a pull-up resistor pulls it up when nobody is pulling it low.
Line state:
Nobody pulls low β line is HIGH (pulled up by the resistor)
Anyone pulls low β line is LOW
This is called a wired-AND: the line is high only if all devices release it. If even one device pulls low, the whole line is low.
Why this design?
- Safe sharing: two devices can never fight with one trying to drive high and the other low, so there is no short circuit and no damage.
- Makes arbitration and clock stretching possible (explained later).
- Allows devices running at different supply voltages to share a bus with simple level shifting.
How data bits are sent
- To send a 0, the transmitter pulls SDA low.
- To send a 1, the transmitter releases SDA (the pull-up makes it high).
The golden data rule
SDA may only change while SCL is LOW. While SCL is HIGH, SDA must stay stable.
SCL ___ββββββββ______ββββββββ______
β β β β
SDA ββ ββββββββ βββββ βββββββ
βββ bit=1 ββββbit=0 ...
β² β²
SDA changes SDA is stable while SCL is high
while SCL low (the receiver samples here)
- The transmitter changes SDA while SCL is low.
- The receiver samples SDA while SCL is high.
The only exceptions are the special START and STOP conditions, where SDA deliberately changes while SCL is high (see Section 5).
Bit order
Bytes are sent MSB first, one bit per clock pulse. Each byte is followed by a ninth clock pulse used for the ACK/NACK bit.
Idle state
When the bus is idle (no transfer in progress), both SDA and SCL are HIGH.
Pin configuration on the MCU
- SDA and SCL pins are configured as alternate function, open-drain outputs (the IΒ²C peripheral takes control).
- On many chips you also enable (or provide externally) pull-ups on both pins.
- If you "bit-bang" IΒ²C using GPIOs, set the pins to open-drain mode, or toggle direction between output-low and input (released).
3. Pull-Up Resistors
Why they are required
Because devices can only pull lines low (never drive them high), something must pull the lines high when nobody is pulling low. That is the job of the pull-up resistors, one on SDA and one on SCL, connected to the supply voltage (VCC).
No pull-ups = no IΒ²C. The lines would float and communication will not work at all.
How they affect timing
When a line is released, the pull-up resistor charges the bus capacitance (wires, pins and connected devices). This produces a rising edge that follows an RC curve rather than a sharp edge. Falling edges are fast, because the device actively pulls the line to ground.
Voltage
VCC β€ βββββββββββββ (slow, RC-shaped rise)
β β±
β β±
GND β€ββββββ (fast, active pull-down)
Rise time β 0.85 Γ R Γ C (for the 30% to 70% points specified by the standard). The standard sets a maximum rise time for each speed mode.
Choosing the resistor value
There is a trade-off:
| Resistor value | Effect |
|---|---|
| Too large (weak pull-up) | Rise time too slow, so signal edges are rounded and communication fails at high speed |
| Too small (strong pull-up) | Devices must sink too much current to pull the line low. The low level does not reach a valid logic low, and power is wasted |
Two limits
- Upper limit (set by speed):
R_max β t_rise / (0.85 Γ C_bus) - Lower limit (set by sink current):
R_min β (VCC β V_OL) / I_OLwhereV_OLis the maximum allowed low voltage andI_OLis the maximum current a device can sink (commonly 3 mA).
Typical values
| Bus speed | Common pull-up range |
|---|---|
| Standard mode (100 kHz) | 4.7 kΞ© (up to about 10 kΞ© for short, light buses) |
| Fast mode (400 kHz) | 2.2 kΞ© to 4.7 kΞ© |
| Fast mode plus (1 MHz) | 1 kΞ© to 2.2 kΞ© |
Practical guidance
- The more devices and the longer the wires, the higher the bus capacitance (the standard allows up to roughly 400 pF for standard and fast modes). Use smaller resistors for heavier loads.
- Use one set of pull-ups per bus, not one per device. Multiple pull-ups in parallel lower the effective resistance, which can become too strong. Many breakout boards include their own pull-ups, so check how many are in parallel.
- Pull up to the same voltage level as the logic of the devices on the bus (for example, 3.3 V for 3.3 V devices).
- Internal MCU pull-ups are usually too weak (tens of kΞ©), so use external resistors for reliable operation.
Mixed-voltage buses
If devices use different logic voltages (for example 3.3 V and 5 V), use a level shifter designed for open-drain bidirectional lines, rather than connecting them directly.
Symptoms of pull-up problems
| Symptom | Likely cause |
|---|---|
| Bus stuck low or no communication at all | Missing pull-ups, or too many parallel pull-ups |
| Works at 100 kHz but fails at 400 kHz | Pull-ups too weak, so rise time too slow |
| Devices fail to pull low properly, or are hot | Pull-ups too strong (resistance too low) |
| Random errors with long wires | Too much bus capacitance |
4. 7-Bit and 10-Bit Addressing
Why addresses exist
Every slave on the bus has an address, and the master calls it at the start of every transaction. All slaves listen, and only the one whose address matches responds. This is how IΒ²C selects devices without any chip-select wires.
7-bit addressing (the common form)
The first byte after START is:
Bit: 7 6 5 4 3 2 1 β 0
βββββ¬ββββ¬ββββ¬ββββ¬ββββ¬ββββ¬ββββ¬ββββββ
β A6β A5β A4β A3β A2β A1β A0β R/W β
βββββ΄ββββ΄ββββ΄ββββ΄ββββ΄ββββ΄ββββ΄ββββββ
βββββββ 7-bit address βββββββ β direction bit
- 7 address bits (A6 to A0) identify the slave.
- 1 R/W bit tells the slave the direction: 0 = write (master sends data), 1 = read (master receives data).
Address range: 7 bits give 128 values (0x00 to 0x7F), but some are reserved.
| Address (7-bit) | Reserved for |
|---|---|
0x00 | General call (address all devices) |
0x01 | CBUS address |
0x02, 0x03 | Reserved for other bus formats |
0x04 to 0x07 | High-speed mode master code |
0x78 to 0x7B | 10-bit addressing |
0x7C to 0x7F | Reserved for future use |
So the usable range is typically 0x08 to 0x77 (about 112 addresses).
Address representation: the classic confusion
The datasheet may quote the address in different ways:
| Form | Example | Meaning |
|---|---|---|
| 7-bit address | 0x48 | Just the address bits (most libraries use this) |
| 8-bit "write" address | 0x90 | 0x48 << 1 with R/W = 0 |
| 8-bit "read" address | 0x91 | `(0x48 << 1) |
#define SENSOR_ADDR_7BIT 0x48
uint8_t write_byte = (SENSOR_ADDR_7BIT << 1) | 0; // 0x90
uint8_t read_byte = (SENSOR_ADDR_7BIT << 1) | 1; // 0x91
Warning: some drivers expect the 7-bit form and shift it themselves; others expect the 8-bit form already shifted. Giving the wrong form is one of the most common IΒ²C mistakes (it shows up as "device not found").
Hardware address pins
Many devices have address-select pins (such as A0, A1, A2) that you wire to ground or VCC to choose among a few addresses. This lets you place several identical chips on one bus.
10-bit addressing
To provide more addresses, the standard offers an extended 10-bit mode. It uses a two-byte address.
First byte: 1 1 1 1 0 A9 A8 R/W
11110is a special pattern (this occupies the reserved0x78β0x7Brange).A9 A8are the two highest address bits.- R/W is the direction bit.
Second byte: A7 A6 A5 A4 A3 A2 A1 A0 (the lower eight address bits).
START β 1111 0 A9 A8 W β ACK β A7..A0 β ACK β DATA ... β ACK β STOP
β first address byte β β second address byte β
For a 10-bit read, the master first sends the full 10-bit address with write, then a repeated start and the first byte again with the read bit set:
START β 11110 A9 A8 W β ACK β A7..A0 β ACK β REPEATED START β 11110 A9 A8 R β ACK β DATA...
7-bit vs 10-bit
| Aspect | 7-bit | 10-bit |
|---|---|---|
| Address bytes | 1 | 2 |
| Number of addresses | ~112 usable | Up to 1024 |
| Overhead | Lower | Higher |
| Prevalence | Very common | Rare |
| Compatible on same bus? | Yes. Both types can share a bus, because 10-bit uses the reserved 11110xx prefix |
Most real devices (sensors, EEPROMs, real-time clocks) use 7-bit addresses. 10-bit is only needed on very large buses or special devices.
General call
Address 0x00 with the write bit is the general call: it addresses all devices at once. Devices that support it can respond (for example, to reset or reprogram their address).
5. Start and Stop Conditions
IΒ²C marks the beginning and end of a transfer with two special bus conditions that violate the golden data rule on purpose: SDA changes while SCL is high. Because normal data never does this, these are unmistakable.
START condition (S)
SDA goes from HIGH to LOW while SCL is HIGH.
SCL βΎβΎβΎβΎβΎβΎβΎβΎβΎβΎβΎβΎ\______
SDA βΎβΎβΎβΎβΎβΎ\____________
β²
START: SDA falls while SCL is high
- Generated by the master.
- Signals all slaves: "a transaction is beginning, listen for your address."
- After START, the bus is considered busy.
STOP condition (P)
SDA goes from LOW to HIGH while SCL is HIGH.
SCL ______/βΎβΎβΎβΎβΎβΎβΎβΎβΎβΎβΎβΎ
SDA ____________/βΎβΎβΎβΎβΎβΎ
β²
STOP: SDA rises while SCL is high
- Generated by the master.
- Ends the transaction and releases the bus.
- After STOP, the bus is free (idle) and any master may start a new transfer.
Summary
| Condition | SCL | SDA change | Meaning |
|---|---|---|---|
| START (S) | High | High β Low | Begin transaction, bus busy |
| STOP (P) | High | Low β High | End transaction, bus free |
| Repeated START (Sr) | High | High β Low (without a prior STOP) | New transaction, bus stays busy |
Bus state
- Idle (free): SDA and SCL both high.
- Busy: after a START and before the next STOP.
A master that wants to start must first confirm the bus is free. If it is busy, the master waits (or loses arbitration if two start at once).
Timing requirements
The datasheet specifies minimum times for START setup/hold and STOP setup, and a minimum bus free time between STOP and the next START. Hardware IΒ²C peripherals handle these automatically. In a software (bit-banged) implementation you must add delays to meet them.
6. ACK and NACK
Acknowledge after every byte
After the transmitter sends 8 bits, there is a ninth clock pulse reserved for the receiver to acknowledge the byte.
SCL pulses: 1 2 3 4 5 6 7 8 β 9
SDA: [D7 D6 D5 D4 D3 D2 D1 D0] β [ACK/NACK]
ββ transmitter drives βββ β receiver drives
During the ninth clock pulse:
- The transmitter releases SDA (stops driving it).
- The receiver either pulls SDA low or leaves it high.
ACK and NACK defined
| Signal | SDA during 9th clock | Meaning |
|---|---|---|
| ACK (acknowledge) | LOW | "I received the byte, ready for more" |
| NACK (not acknowledge) | HIGH | "I did not receive it / I cannot accept more / stop" |
When does a NACK occur?
While the master is writing (sending bytes to a slave):
| Case | Meaning |
|---|---|
| NACK after the address byte | No device responded at that address (wrong address, device missing, or device busy) |
| NACK after a data byte | Slave cannot accept more (buffer full, invalid command, or device busy) |
While the master is reading (receiving bytes from a slave):
- The master sends the ACK or NACK.
- The master sends ACK after each byte it wants more of.
- The master sends NACK after the last byte it wants. This tells the slave: "stop sending data now." The master then issues STOP (or repeated start).
Master reading 3 bytes:
S β ADDR+R β ACK(slave) β BYTE1 β ACK(master) β BYTE2 β ACK(master) β BYTE3 β NACK(master) β P
β² β² β²
"send more" "send more" "that's enough"
Why the final NACK matters
If the master kept ACKing, the slave would keep driving the next data bit on SDA, and the master would not be able to generate a clean STOP. The NACK tells the slave to release SDA so the master can issue STOP.
Using ACK to detect devices
A common trick is the bus scan: try every address from 0x08 to 0x77, send START + address + write, and see which ones ACK. Those addresses have devices.
for (uint8_t addr = 0x08; addr <= 0x77; addr++) {
i2c_start();
bool present = i2c_write_byte(addr << 1); // returns true if ACKed
i2c_stop();
if (present) { /* device found at addr */ }
}
Handling NACK in software
Always check the ACK result. A NACK is an error code your driver should report (for example, "device not found" or "device busy") rather than silently ignoring.
| Typical reason for NACK on address | Fix |
|---|---|
| Wrong address (7-bit vs 8-bit confusion) | Check the datasheet and shifting |
| Device not powered or not connected | Check wiring and supply |
| Missing pull-ups | Add them |
| Device busy (for example an EEPROM writing internally) | Retry after a delay ("ACK polling") |
| Device in reset or wrong mode | Check reset and enable pins |
7. Read/Write Transactions
All IΒ²C transfers are built from START, address, data bytes with ACKs, and STOP. The R/W bit in the address byte decides the direction.
Write transaction (master β slave)
S β ADDR + W(0) β A β DATA1 β A β DATA2 β A β ... β P
S = START A = ACK from slave P = STOP
Master drives SDA for address and data; slave drives only the ACK bits.
Step by step:
- Master sends START.
- Master sends 7-bit address + W bit (0).
- Slave ACKs if it recognises the address.
- Master sends one or more data bytes, each ACKed by the slave.
- Master sends STOP.
Read transaction (slave β master)
S β ADDR + R(1) β A β DATA1 β A' β DATA2 β A' β ... β DATAn β N' β P
A = ACK from slave A' = ACK from master N' = NACK from master
Step by step:
- Master sends START.
- Master sends 7-bit address + R bit (1).
- Slave ACKs.
- Slave sends a data byte. Master ACKs it (if it wants more).
- This repeats. For the last byte, master sends NACK.
- Master sends STOP.
Register-based devices (the common pattern)
Most sensors and memories contain registers (internal memory locations). To read a register you first tell the device which register you want, then read. This needs a write followed by a read, with a repeated start in between (see next section).
Writing a register
S β ADDR+W β A β REG_ADDR β A β VALUE β A β P
bool i2c_write_reg(uint8_t dev, uint8_t reg, uint8_t value) {
i2c_start();
if (!i2c_write_byte(dev << 1)) { i2c_stop(); return false; } // address + W
if (!i2c_write_byte(reg)) { i2c_stop(); return false; } // register pointer
if (!i2c_write_byte(value)) { i2c_stop(); return false; } // data
i2c_stop();
return true;
}
Reading a register
S β ADDR+W β A β REG_ADDR β A β Sr β ADDR+R β A β DATA β N β P
Multi-byte (burst) transfers
Many devices auto-increment the register pointer after each byte, so you can read or write several consecutive registers in one transaction.
S β ADDR+W β A β START_REG β A β Sr β ADDR+R β A β B0 β A' β B1 β A' β B2 β N' β P
EEPROM-style write cycles
Some memory devices (such as EEPROMs) need an internal write time after a write. During that time they do not ACK their address. The master can use ACK polling: repeatedly send START + address until the device ACKs, which means the write is finished.
Typical frame timing
Each byte takes 9 clock cycles (8 data + 1 ACK).
Time per byte β 9 / SCL frequency
At 100 kHz: 9 / 100,000 = 90 Β΅s per byte
At 400 kHz: 9 / 400,000 = 22.5 Β΅s per byte
The address byte, any START/STOP timing and any register byte add to the total, so real throughput is lower than the raw clock rate suggests.
8. Repeated Start
What is it?
A repeated START (Sr) is a START condition generated without first sending a STOP. The master keeps the bus and begins a new transfer immediately.
Normal: S ... P S ... P (bus is released between, others may take it)
Repeated: S ... Sr ... P (bus stays owned by this master)
Electrically it looks the same as a START (SDA falls while SCL is high). It simply happens while the bus is already busy.
Why it is needed
1. Changing direction without releasing the bus
The classic case is reading a register: write the register address, then read its value. Between those two parts the direction changes (W to R). Using a repeated start means:
- No other master can grab the bus in the middle.
- The slave's internal pointer (set by the write) is not disturbed.
2. Atomic combined operations
The register pointer is set and read back as one uninterrupted action.
Stop + Start vs repeated start
| Aspect | STOP then START | Repeated START |
|---|---|---|
| Bus released in between | Yes | No |
| Another master can interfere | Yes | No |
| Works with most simple devices | Usually | Yes |
| Required by some devices | Yes (they reset their pointer on STOP, or require it) |
Some devices require a repeated start for reads. Others tolerate a STOP + START. Check the datasheet. When in doubt, use repeated start.
Typical register read
S β 0x90 β A β 0x01 β A β Sr β 0x91 β A β DATA β N β P
β addr+W β β reg β β repeated start β β addr+R β
bool i2c_read_reg(uint8_t dev, uint8_t reg, uint8_t *out) {
i2c_start();
if (!i2c_write_byte(dev << 1)) { i2c_stop(); return false; } // addr + W
if (!i2c_write_byte(reg)) { i2c_stop(); return false; } // register
i2c_repeated_start(); // Sr
if (!i2c_write_byte((dev << 1) | 1)) { i2c_stop(); return false; } // addr + R
*out = i2c_read_byte(/*ack=*/false); // last byte: NACK
i2c_stop();
return true;
}
9. Clock Stretching and Bus Arbitration
These two features exist because IΒ²C is an open-drain, shared bus.
9.1 Clock stretching
What it is: a slave that needs more time can hold SCL low after the master releases it. The master sees that SCL did not rise and waits until the slave lets go.
Master releases SCL, but the slave keeps it low:
SCL (master wants): ___βββββ___βββββ___
SCL (actual): ___βββββ___ββββββββββββββββ___
β slave holds β
SCL low (stretch)
Why a slave would stretch the clock
- It is busy processing the last byte (for example reading a sensor value or running internal code).
- It needs to prepare the next byte.
- It is a simple device implemented in slow software or a small MCU in slave mode.
What the master must do
- After releasing SCL, check that SCL actually goes high before continuing.
- Hardware IΒ²C peripherals normally do this automatically.
- In bit-banged code, you must add a loop to wait for SCL to rise, with a timeout.
void scl_release_and_wait(void) {
scl_release(); // let the pull-up raise it
uint32_t timeout = TIMEOUT_LIMIT;
while (scl_read() == 0) { // slave is stretching
if (--timeout == 0) { // do not wait forever
error = I2C_ERR_TIMEOUT;
return;
}
}
}
Points to remember
- Stretching can occur after any bit or byte (most often after the 8th or 9th clock).
- It lowers the effective speed of the bus.
- Not every master supports it. Some simple hardware masters ignore it or do not handle it well, which causes data errors.
- A slave that never releases SCL hangs the bus, so use a timeout (see Section 10).
9.2 Bus arbitration
The problem: in a multi-master system, two masters might generate a START at nearly the same time. Both would send an address and data. Without a rule, the data would be corrupted.
The solution: because of the wired-AND behaviour, collisions are detected and resolved without destroying any data.
How it works
- Each master, while transmitting, monitors SDA at the same time as it drives it.
- If a master sends a 1 (releases SDA) but reads back a 0 (because the other master is pulling it low), it knows another master is also transmitting and has "won" that bit.
- The master that sees the mismatch loses arbitration. It immediately stops transmitting and releases the bus.
- The winning master continues, and its message is not corrupted.
Master A sends: 1 0 1 1 ...
Master B sends: 1 0 0 1 ... β B pulls SDA low at bit 3
SDA (actual): 1 0 0 1 ...
β²
A sent 1 but sees 0 β A LOSES, backs off.
B continues undisturbed.
Who wins? The master sending the lower value (more 0s earlier) at the first point of difference, which usually means the lowest address wins, or the write beats the read.
After losing
- The losing master must treat its transfer as failed.
- It may wait for a STOP and retry.
- If it is addressed as a slave by the winner, it should switch roles and respond.
Points to remember
- Arbitration can happen anywhere in the address, R/W or data bits, not only at the start.
- It also applies to the clock: if two masters generate different clocks, the slowest low period and shortest high period determine the resulting clock (a synchronisation effect of wired-AND).
- Arbitration only matters in multi-master systems. A single-master bus never experiences it (except in fault situations).
- Hardware peripherals usually set an arbitration lost (ARLO) flag. Your driver must check it and recover.
Summary
| Feature | Who uses it | Purpose |
|---|---|---|
| Clock stretching | Slave | Make the master wait until the slave is ready |
| Bus arbitration | Masters | Resolve simultaneous starts without corrupting data |
10. Address Conflicts and Bus Recovery
10.1 Address conflicts
What it is: two or more devices on the same bus have the same address. When the master calls that address, both respond at once. Because of the wired-AND bus, the result is a corrupt mixture (reads return garbled data, or writes affect both devices).
Common causes
- Using two identical sensors with a fixed address.
- Two different chips that happen to use the same default address.
- Misreading the datasheet (7-bit vs 8-bit form) so a device you think is at one address is somewhere else.
- A device with address-select pins wired incorrectly.
Symptoms
- Reading returns wrong, random or ORed data.
- A scan shows an address where you expected only one device.
- A device works alone but fails once another is added.
Solutions
| Solution | How |
|---|---|
| Address-select pins | Wire A0/A1/A2 differently on each identical chip |
| Choose different parts | Pick devices with different default addresses |
| Use multiple IΒ²C buses | Put the conflicting devices on separate buses (many MCUs have several IΒ²C peripherals) |
| IΒ²C multiplexer / switch | A multiplexer chip connects only one branch to the bus at a time, so identical devices on different branches do not clash |
| Programmable address | Some devices let you change their address in software or by an enable pin sequence at start-up |
Prevention tip: before building the system, list every device and its address in a table and check for duplicates.
10.2 Bus faults: stuck lines
A bus can get stuck and refuse to work.
Stuck-low SDA (the most common)
Happens when a slave is in the middle of sending a byte (it is holding SDA low for a 0 bit) when the master is reset, crashes, or loses sync. The slave waits for more clock pulses to finish sending, but the master has restarted and is not clocking. SDA stays low, so the master sees the bus as busy forever.
Stuck-low SCL
A slave is stretching the clock indefinitely, or has crashed with SCL pulled low. Or a short or a wiring fault holds the line low.
Both lines stuck / other causes
- A glitch or noise causes devices to misinterpret START or STOP.
- A power glitch leaves a device in an unknown state.
- Master reset in the middle of a transaction without resetting the slaves.
- A short circuit, a damaged device or missing pull-ups.
Symptoms
- The bus is always busy; START never succeeds.
- Hardware flag BUSY stays set.
- Transfers time out.
- A logic analyser shows SDA or SCL held at 0.
10.3 Bus recovery
The standard recovery procedure for a stuck SDA:
- Disable the IΒ²C peripheral and switch the SCL and SDA pins to GPIO mode (open-drain, released).
- Check SDA. If it is high, the bus is fine, and you can skip to step 5.
- If SDA is low, toggle SCL by hand: pull SCL low, release it, and repeat up to 9 times, checking SDA after each pulse. The stuck slave finishes shifting out its byte (at most 8 data bits plus the ACK slot), and releases SDA once it does.
- When SDA goes high, generate a STOP condition manually (SDA low then released while SCL is high) so all devices return to idle.
- Re-enable the IΒ²C peripheral and reinitialise it.
void i2c_bus_recover(void) {
i2c_peripheral_disable();
scl_gpio_open_drain(); sda_gpio_open_drain();
scl_release(); sda_release();
delay_us(5);
// Clock out up to 9 pulses until the slave releases SDA
for (int i = 0; i < 9 && sda_read() == 0; i++) {
scl_low(); delay_us(5);
scl_release(); delay_us(5);
}
// Generate a STOP condition manually
sda_low(); delay_us(5);
scl_release(); delay_us(5);
sda_release(); delay_us(5);
i2c_peripheral_init(); // hand the pins back to the IΒ²C hardware
}
If SCL is stuck low: the master cannot fix this by clocking, because it is not the one holding it. Options include:
- Waiting for the slave to finish (with a timeout).
- Power-cycling or resetting the offending device (using a reset or enable pin, or switching its power).
- Checking for hardware faults (shorts, damaged parts).
10.4 Preventing and handling bus problems
| Practice | Why |
|---|---|
| Always use timeouts on every wait (flag, SCL stretch, bus busy) | Prevents the program from hanging forever |
| Run bus recovery at start-up | The MCU may have been reset mid-transfer, leaving a slave stuck |
| Check and handle every NACK and error flag | Detects problems early |
| Detect arbitration lost | Retry or back off correctly |
| Retry a limited number of times, then recover the bus | Avoid endless loops |
| Reset slaves via a reset line if available | Last resort for locked-up devices |
| Use correct pull-ups and short wires | Cleaner signals, fewer glitches |
| Add a watchdog | System recovers if the driver ever hangs |
Quick troubleshooting table
| Symptom | Likely cause | Action |
|---|---|---|
| Address NACK | Wrong address, device missing, no power | Check address form, wiring, supply |
| Bus busy forever | SDA or SCL stuck low | Run bus recovery, check for stretching |
| Garbage or mixed data | Address conflict | Check address table, use mux or address pins |
| Works slow, fails fast | Weak pull-ups or high bus capacitance | Lower pull-up value, shorten wires |
| Random corruption | Noise, loose wires, multi-master collision | Improve wiring, check arbitration handling |
| Hangs in the middle | Clock stretch with no timeout | Add timeouts |
11. IΒ²C Driver Implementation
A good IΒ²C driver has layers: a low-level layer (START, STOP, byte read/write), a transaction layer (register read/write helpers), and the device driver on top.
ββββββββββββββββββββββββββββββββββ
β Device driver (sensor, RTC...) β knows registers and meaning
ββββββββββββββββββββββββββββββββββ€
β Transaction layer β read_reg, write_reg, burst
ββββββββββββββββββββββββββββββββββ€
β Low-level layer β start, stop, write byte, read byte
ββββββββββββββββββββββββββββββββββ€
β Hardware peripheral or GPIOs β
ββββββββββββββββββββββββββββββββββ
Typical IΒ²C peripheral registers
Names vary, but the functions are common:
| Register (generic) | Purpose |
|---|---|
| Control register(s) | Enable the peripheral, generate START/STOP, set ACK/NACK, enable interrupts and DMA |
| Status register(s) | Flags: bus busy, start sent, address sent, transmit empty, receive not empty, byte transfer finished, ACK failure, arbitration lost, bus error |
| Data register | Write to send a byte; read to get a received byte |
| Own-address register | The device's own address (when acting as slave) |
| Clock control register | Sets SCL frequency (standard, fast mode) via a divider |
| Interrupt enable bits | Event, buffer and error interrupts |
Typical status flags
| Flag | Meaning |
|---|---|
| BUSY | The bus is in use (START seen, no STOP yet) |
| SB (start bit) | START has been generated |
| ADDR | Address byte sent and ACKed |
| TXE | Transmit data register empty |
| RXNE | Received data ready |
| BTF | Byte transfer finished |
| AF (ACK failure) | A NACK was received |
| ARLO | Arbitration lost |
| BERR | Bus error (misplaced START or STOP) |
Initialisation (hardware master)
void i2c_init(uint32_t speed_hz) {
// 1. Enable clocks for the I2C peripheral and GPIO port
enable_i2c_clock();
enable_gpio_clock();
// 2. Configure SDA and SCL pins: alternate function, open-drain
configure_pin_alt_open_drain(SDA_PIN);
configure_pin_alt_open_drain(SCL_PIN);
// (external pull-up resistors are on the board)
// 3. Set the clock speed
I2C_CCR = I2C_PERIPHERAL_CLOCK / (2 * speed_hz); // simplified, check the datasheet
// 4. Enable the peripheral
I2C_CR_ENABLE = 1;
}
Low-level routines (hardware, polling style)
static bool wait_flag(uint32_t flag, uint32_t timeout) {
while (!(I2C_SR & flag)) {
if (--timeout == 0) return false; // never wait forever
}
return true;
}
bool i2c_start(void) {
I2C_CR_START = 1;
return wait_flag(SB_FLAG, TIMEOUT); // START generated
}
void i2c_stop(void) {
I2C_CR_STOP = 1;
}
bool i2c_write_byte(uint8_t b) {
I2C_DR = b;
if (!wait_flag(TXE_FLAG | BTF_FLAG, TIMEOUT)) return false;
if (I2C_SR & AF_FLAG) { // NACK received
I2C_SR &= ~AF_FLAG; // clear the error flag
return false;
}
return true;
}
uint8_t i2c_read_byte(bool ack) {
I2C_CR_ACK = ack ? 1 : 0; // ACK = more to come, NACK = last byte
wait_flag(RXNE_FLAG, TIMEOUT);
return (uint8_t)I2C_DR;
}
Software (bit-banged) IΒ²C
When no hardware peripheral is available or pins are fixed, IΒ²C can be done with two GPIO pins in open-drain mode.
// Helpers: "release" = let the pull-up raise the line; "low" = drive 0
void i2c_start_bb(void) {
sda_release(); scl_release_and_wait(); delay_us(5);
sda_low(); delay_us(5); // SDA falls while SCL high = START
scl_low(); delay_us(5);
}
void i2c_stop_bb(void) {
sda_low(); delay_us(5);
scl_release_and_wait(); delay_us(5);
sda_release(); delay_us(5); // SDA rises while SCL high = STOP
}
bool i2c_write_byte_bb(uint8_t b) {
for (int i = 7; i >= 0; i--) { // MSB first
if (b & (1 << i)) sda_release(); else sda_low();
delay_us(2);
scl_release_and_wait(); delay_us(5); // SCL high: receiver samples
scl_low(); delay_us(2);
}
sda_release(); // free SDA for the ACK bit
delay_us(2);
scl_release_and_wait(); delay_us(5);
bool ack = (sda_read() == 0); // slave pulls low = ACK
scl_low(); delay_us(2);
return ack;
}
Bit-banging gives full control and works on any pins, but uses CPU time and the speed is limited by delays and software overhead.
Polling, interrupt and DMA
| Style | How it works | Best for |
|---|---|---|
| Polling | CPU waits on status flags (with timeouts) | Simple, short transfers |
| Interrupt-driven | Peripheral interrupts on events (address sent, byte received, error). An ISR or state machine advances the transfer | Keeping the main loop responsive |
| DMA | DMA moves bytes between memory and the data register | Large or frequent transfers |
An interrupt-driven driver is naturally a state machine, where each event moves the transfer to the next state:
IDLE β START_SENT β ADDR_SENT β REG_SENT β RESTART_SENT β ADDR_R_SENT β READING β STOP_SENT β DONE
β (any error / NACK / ARLO / timeout) β ERROR β recover
Keep ISRs short, use volatile for shared state, and set a completion flag or call a callback when the transfer ends.
A clean transaction API
typedef enum {
I2C_OK = 0,
I2C_ERR_NACK_ADDR,
I2C_ERR_NACK_DATA,
I2C_ERR_TIMEOUT,
I2C_ERR_ARB_LOST,
I2C_ERR_BUS
} i2c_status_t;
i2c_status_t i2c_write(uint8_t dev, const uint8_t *data, size_t len);
i2c_status_t i2c_read(uint8_t dev, uint8_t *data, size_t len);
i2c_status_t i2c_write_read(uint8_t dev, const uint8_t *wr, size_t wlen,
uint8_t *rd, size_t rlen); // uses repeated start
Why write_read is important: register reads are so common that a single call that does write register address β repeated start β read data keeps the driver simple and correct.
Handling errors and recovery in the driver
i2c_status_t i2c_read_reg_safe(uint8_t dev, uint8_t reg, uint8_t *val) {
for (int attempt = 0; attempt < 3; attempt++) {
i2c_status_t s = i2c_write_read(dev, ®, 1, val, 1);
if (s == I2C_OK) return I2C_OK;
if (s == I2C_ERR_TIMEOUT || s == I2C_ERR_BUS) {
i2c_bus_recover(); // see Section 10
}
// NACK on address: device may be busy, so retry after a short delay
delay_ms(1);
}
return I2C_ERR_NACK_ADDR; // give up and report
}
Driver checklist
- SDA and SCL pins set to open-drain / alternate function
- External pull-ups present and sized correctly
- Clock speed set to what all devices support
- Address handled consistently (7-bit vs shifted 8-bit)
- Check the ACK result after the address and every data byte
- Master sends NACK on the last byte of a read
- Use repeated start for register reads
- Timeouts on every wait (flags, bus busy, clock stretch)
- Handle arbitration lost and bus error flags
- Run bus recovery at start-up and after repeated failures
- Keep ISRs short, shared state
volatile - Document each device's address and any special timing (for example, EEPROM write time)
Common driver mistakes
| Mistake | Result |
|---|---|
| Wrong address form (7-bit vs 8-bit) | "Device not found" |
| Forgetting the final NACK on reads | Bus cannot be released cleanly |
| STOP instead of repeated start where one is needed | Device returns wrong or stale data |
| No timeouts | System hangs on a stuck bus |
| Ignoring the ACK result | Silent failures |
| Pull-ups missing or doubled | Bus not working or poor signal |
| Not clearing error flags | Peripheral stays in an error state |
| Starting a transfer before the bus is free | Collisions or bus errors |
12. Quick Revision Cheat Sheet
| Topic | One-line summary |
|---|---|
| IΒ²C | Two-wire, synchronous, multi-device bus with built-in addressing and acknowledge. |
| Architecture | One or more masters, many slaves, all sharing SDA and SCL. |
| SDA | Serial data line. Changes only while SCL is low. |
| SCL | Serial clock line, driven by the master (slaves may stretch it). |
| Open-drain | Devices only pull low or release. Never drive high. Wired-AND. |
| Pull-ups | Required. Typically 2.2 kΞ© to 10 kΞ©. Too weak = slow edges, too strong = too much current. |
| 7-bit address | Seven address bits plus an R/W bit. Usable range about 0x08 to 0x77. |
| 10-bit address | Two address bytes starting with 11110xx. Rare. |
| START | SDA highβlow while SCL high. |
| STOP | SDA lowβhigh while SCL high. |
| ACK | Receiver pulls SDA low on the 9th clock. "Got it." |
| NACK | SDA left high on the 9th clock. "No device / no more." |
| Write | S, ADDR+W, data bytes (each ACKed), P. |
| Read | S, ADDR+R, data bytes (master ACKs), master NACKs the last byte, P. |
| Repeated start | New START with no STOP; used to switch from write to read while keeping the bus. |
| Clock stretching | Slave holds SCL low to make the master wait. |
| Arbitration | Multi-master collisions resolved bit by bit via wired-AND. A master that sees a mismatch backs off. |
| Address conflict | Two devices with the same address. Fix with address pins, a multiplexer or a separate bus. |
| Stuck bus | SDA or SCL held low. Recover with up to 9 SCL pulses plus a STOP. |
| Driver | Low level (start/stop/byte), transaction layer (read/write reg), error handling, timeouts. |
Ten things to remember
- IΒ²C uses two shared open-drain lines, SDA and SCL, and external pull-up resistors are mandatory.
- Devices only pull low or release, so the bus never has a drive conflict, which makes wired-AND, arbitration and clock stretching possible.
- SDA changes only while SCL is low, except for START and STOP, which are deliberate exceptions.
- Every transaction starts with START + 7-bit address + R/W bit. Watch out for 7-bit vs 8-bit address confusion.
- Every byte is followed by an ACK or NACK on the 9th clock. A NACK on the address usually means "no such device".
- When reading, the master ACKs each byte except the last, which it NACKs.
- Use a repeated start to switch from writing a register address to reading its value without releasing the bus.
- Clock stretching (slave) and arbitration (multi-master) are built-in ways to handle slow devices and collisions.
- Duplicate addresses corrupt data. Use address pins, a multiplexer or another bus.
- A hung bus is recoverable: clock SCL up to 9 times, send STOP, reinitialise. Always use timeouts in your driver.
#Other Communication Interfaces#
How to use these notes: UART, SPI and IΒ²C cover most chip-to-chip links on a board. But real products also talk to cars, factory machines, PCs, networks, speakers and phones. This topic is a guided tour of the other interfaces you will meet. For each one you get the big idea, how it works, key numbers, and when to use it. Read it in order the first time, then use the final section to compare them side by side.
Table of Contents
- CAN and CAN FD Fundamentals
- LIN Bus
- USB Fundamentals
- Ethernet Basics
- RS-232 and RS-485
- IΒ²S for Digital Audio
- 1-Wire Protocol
- Modbus RTU and TCP
- Bluetooth and BLE Fundamentals
- Wi-Fi Communication Basics
- Protocol Selection and Trade-offs
- Quick Revision Cheat Sheet
1. CAN and CAN FD Fundamentals
What is CAN?
CAN (Controller Area Network) is a robust multi-master, message-based serial bus designed for noisy environments, originally for cars. Many electronic units (engine controller, brakes, dashboard, door modules) share one pair of wires and exchange short messages.
Think of it as a meeting room where everyone hears everything. Nobody is addressed by name. Instead, each message carries a label (identifier) describing what it is (for example "engine speed"), and every node decides whether it cares about that label.
Physical layer
- Two wires, differential signalling: CAN_H and CAN_L. Information is the voltage difference between them, so noise that hits both wires equally cancels out. This gives excellent noise immunity.
- Twisted pair cable is typical.
- Termination: a 120 Ξ© resistor at each end of the bus (two in total) prevents signal reflections.
- A CAN transceiver chip sits between the MCU's CAN controller (pins TX/RX) and the bus.
- Topology: a linear bus with short stubs. Not a star.
Dominant and recessive bits
| Bus state | Logical value | Meaning |
|---|---|---|
| Dominant | 0 | A node actively drives the bus (wins over recessive) |
| Recessive | 1 | Nobody is driving (idle state) |
If one node sends dominant and another sends recessive at the same time, the bus shows dominant. This is a wired-AND behaviour, and it makes arbitration possible.
Arbitration: lowest identifier wins
Any node may start sending when the bus is idle. If two nodes start at the same moment, both send their identifier bit by bit while listening to the bus. A node that sends a recessive 1 but sees a dominant 0 knows a higher-priority message is present, so it stops and retries later. The winner's message continues unharmed.
The lower the identifier number, the higher the priority. Arbitration is non-destructive: no time or data is lost by the winner.
Classical CAN data frame
βββββββ¬βββββββββββββ¬ββββββ¬ββββββββββ¬ββββββββββββ¬ββββββββ¬ββββββ¬ββββββ¬ββββββ
β SOF β Identifier β RTR β Control β Data β CRC β ACK β EOF β IFS β
β 1b β 11 or 29 b β 1b β (DLC) β 0β8 bytes β 15 b β 2 b β 7 b β 3 b β
βββββββ΄βββββββββββββ΄ββββββ΄ββββββββββ΄ββββββββββββ΄ββββββββ΄ββββββ΄ββββββ΄ββββββ
| Field | Purpose |
|---|---|
| SOF | Start of frame (one dominant bit) |
| Identifier | Names the message and sets its priority. 11-bit (standard) or 29-bit (extended) |
| RTR | Remote transmission request (asks another node to send a message with this ID) |
| DLC | Data length code: number of data bytes (0 to 8) |
| Data | Up to 8 bytes in classical CAN |
| CRC | 15-bit checksum for error detection |
| ACK | Any receiver that got the frame correctly drives this slot dominant |
| EOF / IFS | End of frame and inter-frame space |
Key features
- Bit stuffing: after five identical bits in a row, the sender inserts a bit of the opposite value. This guarantees enough edges for receivers to stay synchronised. Receivers remove the stuffed bits.
- Acceptance filters: the CAN controller has filters and masks so it only passes messages with IDs the software cares about. This saves CPU time.
- Broadcast nature: all nodes see every message. There are no destination addresses.
- Speed vs length: up to 1 Mbit/s on short buses (around 40 m). Slower rates (for example 125 kbit/s) allow much longer cables (hundreds of metres).
Error handling (a big strength of CAN)
Each node continuously checks for:
| Error type | What is checked |
|---|---|
| Bit error | Sent bit differs from what the node reads back |
| Stuff error | Six identical bits in a row |
| CRC error | Checksum does not match |
| Form error | A fixed-format field has an illegal value |
| ACK error | No receiver acknowledged the frame |
When an error is seen, the node sends an error flag that destroys the frame, and the sender automatically retransmits. Each node keeps transmit and receive error counters. A node that causes too many errors moves from error-active to error-passive, and finally to bus-off, which disconnects it so a faulty node cannot jam the bus.
CAN FD (Flexible Data-rate)
CAN FD is an upgrade that keeps CAN's arbitration and robustness but fixes its two limits: small payload and limited speed.
| Feature | Classical CAN | CAN FD |
|---|---|---|
| Max data per frame | 8 bytes | 64 bytes |
| Bit rate | Same throughout, up to 1 Mbit/s | Arbitration phase at the normal rate, then data phase at a faster rate (commonly 2 to 5 Mbit/s, higher in some systems) |
| CRC | 15 bit | 17 or 21 bit (stronger, for longer data) |
| Extra control bits | None | FDF (marks FD frame), BRS (switch to fast rate), ESI (error state indicator) |
How the speed switch works: the arbitration field is sent at the slow rate (so all nodes can compete and stay synchronised), then the bit rate switches up for the data and CRC fields, and switches back for the ACK and end of frame.
Compatibility note: classical-CAN-only nodes cannot understand FD frames, so a network must be all FD-capable (or use FD-tolerant devices).
When to use CAN / CAN FD
Vehicles, industrial machines, robots, medical equipment, any noisy, multi-node, real-time environment where reliability matters more than raw speed.
2. LIN Bus
What is LIN?
LIN (Local Interconnect Network) is a low-cost, low-speed, single-wire bus used for simple devices in vehicles: window lifts, mirrors, seat controls, rain sensors, climate flaps. It complements CAN. CAN is the "backbone," and LIN handles cheap local sub-networks.
Key characteristics
| Feature | LIN |
|---|---|
| Wires | One signal wire (plus power and ground) |
| Topology | Single master, multiple slaves (typically up to 16) |
| Speed | Up to about 20 kbit/s |
| Voltage | Vehicle battery level (about 12 V), via a LIN transceiver |
| Hardware | Uses a standard UART (8N1) with extra logic, so it is cheap |
| Clock | Slaves do not need a precise crystal: they synchronise to the master each frame |
Master and slave
- The master controls all communication. Slaves never speak unless the master asks.
- The master follows a schedule table: a fixed list saying which frame is sent and when. This makes timing deterministic (predictable).
- A master node also contains a slave task that sends the header and responds like any other node when needed.
LIN frame
A frame is a header (sent by the master) followed by a response (sent by one node, either the master or a slave).
βββββββββββββββββ HEADER (master) ββββββββββββββββββββββ RESPONSE βββββ
β Break β Delimiter β Sync (0x55) β Protected ID ββ Data (1β8 B) β Checksum β
β β₯13 bits low β β for baud sync β 6-bit ID + 2 parity bits β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| Field | Purpose |
|---|---|
| Break | A long low pulse (at least 13 bit times) that tells slaves a new frame begins |
| Sync byte (0x55) | Alternating bits that let slaves measure the master's bit rate |
| Protected identifier | A 6-bit frame ID (what the frame means) plus 2 parity bits |
| Data | 1 to 8 bytes |
| Checksum | Sum of data (classic version) or data plus ID (enhanced version) |
How a transfer works: the master sends the header with an ID. The node assigned to publish that ID sends the data bytes and checksum. Other nodes interested in that ID subscribe and read it.
LIN vs CAN
| Aspect | LIN | CAN |
|---|---|---|
| Cost | Very low | Higher |
| Speed | β€ 20 kbit/s | Up to 1 Mbit/s (CAN FD faster) |
| Wires | 1 | 2 (differential) |
| Control | Single master, scheduled | Multi-master, arbitration |
| Noise immunity | Moderate | Excellent |
| Typical use | Mirrors, seats, windows | Engine, brakes, safety systems |
3. USB Fundamentals
What is USB?
USB (Universal Serial Bus) connects a host (PC, phone, embedded Linux board) to many devices (keyboards, storage, serial adapters, cameras). It also supplies power. For embedded work, you meet USB when your MCU acts as a device (appearing as a serial port, keyboard or storage drive to a PC) or, on more capable MCUs, as a host.
Architecture
- One host controls everything. Devices never talk on their own. The host polls each device.
- Tiered-star topology: the host connects to devices directly or through hubs. Up to 127 devices can share one host controller.
- Cable lines: VBUS (power, 5 V), GND, and D+ / Dβ (a differential data pair). USB 3.x adds extra high-speed pairs.
Speed classes
| Name | Speed | Typical use |
|---|---|---|
| Low speed | 1.5 Mbit/s | Mice, keyboards |
| Full speed | 12 Mbit/s | Many MCUs, serial adapters |
| High speed | 480 Mbit/s | Storage, cameras |
| SuperSpeed (USB 3.x) | 5 Gbit/s and above | Fast storage, high-end devices |
Device detection and enumeration
When you plug a device in:
- A pull-up resistor on D+ (full speed) or Dβ (low speed) tells the host a device is present and its speed.
- The host resets the bus.
- The host reads the device's descriptors using the default control endpoint.
- The host assigns a unique address (1 to 127).
- The host loads the matching driver and selects a configuration.
This whole process is called enumeration.
Descriptors
Data structures the device stores that describe itself:
| Descriptor | Tells the host |
|---|---|
| Device | Vendor ID (VID), product ID (PID), USB version, device class |
| Configuration | Power needs, number of interfaces |
| Interface | A function (for example, one serial port) and its class |
| Endpoint | Individual data channels and their types |
| String | Human-readable names |
Endpoints and transfer types
An endpoint is a data source or sink inside the device, identified by a number and a direction (IN = device to host, OUT = host to device). Endpoint 0 is always present and used for control.
| Transfer type | Purpose | Guarantee | Example |
|---|---|---|---|
| Control | Configuration and commands | Reliable | Enumeration |
| Bulk | Large data, no time limit | Reliable, uses leftover bandwidth | File transfer, serial data |
| Interrupt | Small, regular polling | Guaranteed maximum latency | Keyboard, mouse |
| Isochronous | Time-critical streams | Fixed bandwidth, no retries | Audio, video |
Packets
Transfers are built from packets: token (who and which endpoint), data, and handshake (ACK, NAK, STALL). Data packets carry a CRC for error checking.
Device classes
Standard classes let one generic driver work for many products:
| Class | Appears to the PC as |
|---|---|
| CDC (Communications Device Class) | A virtual COM (serial) port |
| HID (Human Interface Device) | Keyboard, mouse, game controller, custom data |
| MSC (Mass Storage Class) | A USB drive |
| Audio / Video | Sound card, camera |
Practical notes
- USB is complex to implement by hand. Most projects use a vendor USB stack or library, and you write only the class-level application code.
- Power: a device may draw up to a limited current per the negotiated configuration, so check the budget.
- USB OTG (On-The-Go) lets some devices act as either host or device.
- Compared to UART, USB gives much higher speed, plug-and-play and power, but needs a stack and more resources.
4. Ethernet Basics
What is Ethernet?
Ethernet (IEEE 802.3) is the standard wired network technology used in LANs. In embedded systems it connects devices to networks, the internet and servers, using the same protocols (IP, TCP, UDP) as computers.
Layers in an embedded system
βββββββββββββββββββββββββββββββββ
β Application (HTTP, MQTT, ...) β
βββββββββββββββββββββββββββββββββ€
β TCP / UDP β
βββββββββββββββββββββββββββββββββ€
β IP β β usually a software stack (for example lwIP)
βββββββββββββββββββββββββββββββββ€
β Ethernet MAC (in MCU) β β builds and checks frames
βββββββββββββ MII / RMII ββββββββ€
β Ethernet PHY (chip) β β analogue/line signalling
βββββββββββββββββββββββββββββββββ€
β Magnetics + RJ45 connector β
βββββββββββββββββββββββββββββββββ
| Block | Role |
|---|---|
| MAC (Media Access Controller) | Data-link layer: forms frames, adds addresses and CRC, uses DMA. Often built into the MCU |
| PHY (Physical layer chip) | Converts digital data to line signals and back, handles speed negotiation |
| MII / RMII | The digital interface between MAC and PHY (RMII uses fewer pins) |
| Magnetics and RJ45 | Isolation and the connector |
| MCU without built-in MAC | Use an external Ethernet controller chip, often over SPI |
Speeds and cabling
| Standard | Speed |
|---|---|
| 10BASE-T | 10 Mbit/s |
| 100BASE-TX (Fast Ethernet) | 100 Mbit/s |
| 1000BASE-T (Gigabit) | 1 Gbit/s |
Twisted-pair cable (Cat5e or better) with an RJ45 connector, up to about 100 m per segment. Auto-negotiation lets the two ends agree on speed and duplex. Modern networks are star-shaped, with devices connected to a switch, and use full duplex (send and receive simultaneously).
Addressing
- MAC address: a unique 48-bit hardware address (for example
00:1A:2B:3C:4D:5E) used inside the local network. - IP address: a logical network address used by IP for routing across networks. It can be set statically or assigned by DHCP.
The Ethernet frame
ββββββββββββ¬ββββββ¬ββββββββββββ¬ββββββββββββ¬ββββββββββββ¬βββββββββββββββββββ¬ββββββ
β Preamble β SFD β Dest MAC β Src MAC β EtherType β Payload β FCS β
β 7 bytes β 1 B β 6 bytes β 6 bytes β 2 bytes β 46β1500 bytes β 4 B β
ββββββββββββ΄ββββββ΄ββββββββββββ΄ββββββββββββ΄ββββββββββββ΄βββββββββββββββββββ΄ββββββ
| Field | Purpose |
|---|---|
| Preamble + SFD | Alternating bits that let the receiver synchronise, then mark the start of the frame |
| Destination / Source MAC | Who the frame is for and who sent it |
| EtherType | What the payload contains (for example IPv4 or ARP) |
| Payload | The data (typically up to 1500 bytes, the MTU) |
| FCS | 32-bit CRC for error detection |
Protocols you will meet on top
| Protocol | Purpose |
|---|---|
| ARP | Finds the MAC address for a given IP address |
| IP | Addressing and routing |
| UDP | Fast, connectionless, no delivery guarantee |
| TCP | Reliable, ordered, connection-based |
| DHCP | Automatically obtains an IP address |
Embedded devices typically run a lightweight stack such as lwIP, plus application protocols on top. Sockets let your code send and receive data over TCP or UDP.
Pros and cons
Pros: high speed, long reach (100 m per segment, more with switches or fibre), standard networking, easy remote access. Cons: needs more memory and CPU (protocol stack), more power and board space (PHY, magnetics, connector), more complex than serial links.
5. RS-232 and RS-485
Both are physical-layer standards for serial links. They carry UART-style data (start bit, data, parity, stop bit) over longer distances using special line drivers. The MCU's UART is the same. What changes is the voltage levels and wiring.
RS-232
The classic PC serial port.
- Single-ended signalling: each signal is a voltage relative to ground.
- Levels: logic 1 (mark) = β3 V to β15 V, logic 0 (space) = +3 V to +15 V (inverted compared with MCU logic, and much larger).
- Point-to-point: exactly two devices.
- Full duplex: separate TX and RX lines.
- Distance and speed: short cables, typically around 15 m at standard rates, with a lower limit at higher baud rates.
- Connector: DB9 or DB25.
- Level-shifter chip (for example MAX232 type) converts between MCU levels (0 to 3.3/5 V) and RS-232 levels.
| Common DB9 signals | Purpose |
|---|---|
| TXD / RXD | Transmit and receive data |
| GND | Signal ground |
| RTS / CTS | Hardware flow control |
| DTR / DSR / DCD / RI | Modem-control signals |
Cable note: a null-modem (crossover) cable swaps TX and RX when connecting two DTE devices.
Weakness: single-ended signals pick up noise easily, so it is not good for long or noisy runs.
RS-485
The industrial workhorse for serial networks.
- Differential signalling on a twisted pair, with lines A (β) and B (+). Noise that affects both wires equally is rejected, so it works over long distances and in noisy factories.
- Multi-drop: many devices share one bus (classically up to 32 unit loads, more with modern low-load transceivers).
- Distance and speed: up to about 1200 m at lower speeds, and up to 10 Mbit/s over short runs. Distance and speed trade off against each other.
- Termination: a 120 Ξ© resistor at each end of the cable.
- Bias (fail-safe) resistors: keep the lines in a defined idle state when nobody is driving, to prevent false data.
2-wire vs 4-wire
| Mode | Wires | Duplex | Notes |
|---|---|---|---|
| 2-wire | One pair shared | Half duplex | Only one device talks at a time. Most common |
| 4-wire | Two pairs | Full duplex (in a master-slave layout) | Master has its own transmit pair |
Direction control (half duplex)
An RS-485 transceiver has a driver enable (DE) and receiver enable (RE) pin. The MCU must enable the driver only while transmitting and switch back to receive afterwards.
void rs485_send(const uint8_t *data, size_t len) {
DE_PIN_HIGH(); // enable transmitter
uart_send_buffer(data, len);
uart_wait_transmission_complete(); // wait for the last stop bit (TC flag!)
DE_PIN_LOW(); // back to receive mode
}
Releasing the bus too early (before the last bit leaves) corrupts the end of the message. Many MCUs can drive DE automatically in hardware.
RS-485 is only the electrical layer
It does not define addresses, message formats or error checking. You build those on top, or use a higher-level protocol such as Modbus RTU (Section 8).
Comparison
| Feature | RS-232 | RS-485 |
|---|---|---|
| Signalling | Single-ended | Differential |
| Devices | 2 (point-to-point) | Many (multi-drop) |
| Duplex | Full | Half (2-wire) or full (4-wire) |
| Max distance | ~15 m | ~1200 m |
| Max speed | ~115 kbit/s typical | Up to ~10 Mbit/s (short) |
| Noise immunity | Low | High |
| Voltage levels | Β±3 to Β±15 V | About Β±1.5 to Β±5 V differential |
| Typical use | PC ports, setup consoles | Industrial buses, building automation |
6. IΒ²S for Digital Audio
What is IΒ²S?
IΒ²S (Inter-IC Sound) is a synchronous serial bus designed to carry digital audio samples between chips: MCU, microphones, DACs (digital-to-analogue converters), ADCs and audio codecs. (Do not confuse it with IΒ²C. They are unrelated.)
Signals
| Signal | Other names | Purpose |
|---|---|---|
| SCK | BCLK (bit clock) | One pulse per data bit |
| WS | LRCLK, FS (word select) | Says which channel is being sent. Low = left, high = right |
| SD | SDIN, SDOUT, DIN, DOUT | The serial audio data |
| MCLK | Master clock (optional) | A fast clock some codecs need for their internal operation |
One device is the master and generates BCLK and WS. The other is the slave. Data can flow in one or both directions using separate data lines.
How it works
- Data is sent MSB first.
- The word-select line toggles once per sample, so the left and right channel samples alternate.
- The data bit is delayed by one bit clock after WS changes. This is the signature of standard IΒ²S.
- Data changes on the falling edge of BCLK and is sampled on the rising edge.
BCLK ββββββββββββββββββββββββ
WS ___ββββββββββββββββββββββ (low = left, high = right)
SD x D(N-1) D(N-2) ... D0 β MSB follows one clock after WS change
Clock calculation
Bit clock (BCLK) = Sample rate Γ Bits per channel Γ Number of channels
Example: 48 kHz Γ 32 bits Γ 2 channels = 3.072 MHz
WS frequency = Sample rate (48 kHz)
A word length of 16, 24 or 32 bits is common. The slot width on the bus may be larger than the sample width (for example 24-bit samples in 32-bit slots).
Variants
| Format | Difference |
|---|---|
| Standard IΒ²S (Philips) | MSB appears one clock after the WS edge |
| Left-justified | MSB appears immediately at the WS edge |
| Right-justified | Data is aligned to the end of the slot |
| TDM (time-division multiplexing) | More than two channels share one data line in time slots |
| PCM / DSP mode | A short frame-sync pulse instead of a channel-select |
Both ends must use the same format.
Software side
- Audio is a continuous stream, so you cannot afford gaps. Drivers use DMA with double buffering (or circular buffers): while DMA sends one buffer, the CPU fills the other.
- Typical interrupts: half-transfer and transfer-complete, to refill each half in time.
- Underrun (not refilled in time) causes audible clicks. Overrun (not read in time) loses samples.
- Typical uses: playing sound through a DAC or amplifier, recording from a digital MEMS microphone, and connecting to an audio codec.
Pros and cons
Pros: clean digital audio, low jitter and precise timing, simple three-wire connection. Cons: only for audio streams, needs an exact clock relationship, no addressing or error checking (control of codecs is usually done separately over IΒ²C or SPI).
7. 1-Wire Protocol
What is 1-Wire?
1-Wire is a low-speed protocol from Dallas/Maxim that uses a single data line (plus ground) for communication and even for power. It is common for temperature sensors (like DS18B20), ID chips, and small memories.
Key characteristics
| Feature | 1-Wire |
|---|---|
| Wires | 1 data line + ground |
| Topology | One master, many slaves on the same line |
| Speed | Low (about 16 kbit/s standard) |
| Power | Devices can be parasitically powered from the data line through the pull-up |
| Pull-up | A resistor (typically 4.7 kΞ©) to VCC |
| Output | Open-drain, like IΒ²C |
| Addressing | Every device has a unique 64-bit ROM ID |
The 64-bit ROM ID
ββββββββββββββ¬ββββββββββββββββββββββββββ¬βββββββββββ
β Family codeβ 48-bit serial number β CRC β
β 8 bits β β 8 bits β
ββββββββββββββ΄ββββββββββββββββββββββββββ΄βββββββββββ
The family code identifies the type of device. The CRC lets the master verify the ID was read correctly. No two devices share the same ID, so many identical sensors can sit on one wire.
Timing-based communication
There is no clock line. Everything is done with precisely timed pulses called time slots. The master always starts each slot by pulling the line low.
| Operation | What happens |
|---|---|
| Reset + presence | Master holds the line low for at least 480 Β΅s, releases it, and any device present answers with a presence pulse (pulls low for roughly 60 to 240 Β΅s) |
| Write 1 | Master pulls low briefly (about 1 to 15 Β΅s), then releases for the rest of the slot |
| Write 0 | Master holds the line low for the whole slot (at least about 60 Β΅s) |
| Read | Master pulls low briefly (about 1 to 15 Β΅s) and releases. The slave then either holds the line low (0) or lets it go high (1). Master samples shortly after, within about 15 Β΅s from the start of the slot |
Each bit slot is roughly 60 to 120 Β΅s. Timing is critical, so MCUs use a hardware timer, a UART trick, or tight delay loops (with interrupts disabled) to generate it.
ROM commands
After every reset and presence pulse, the master sends a ROM command:
| Command | Code | Meaning |
|---|---|---|
| Read ROM | 0x33 | Read the ID (only when exactly one device is on the bus) |
| Match ROM | 0x55 | Address one specific device by its 64-bit ID |
| Skip ROM | 0xCC | Address all devices at once (or the only device) |
| Search ROM | 0xF0 | Discover all IDs on the bus using a binary-tree search |
After selecting a device, the master sends function commands specific to that device.
Example transaction (temperature sensor)
1. Reset + presence pulse
2. Skip ROM (0xCC) or Match ROM (0x55 + 8 ID bytes)
3. Convert T (0x44) β sensor measures; wait for conversion time
4. Reset + presence pulse
5. Skip/Match ROM
6. Read Scratchpad (0xBE) β read data bytes (and CRC), then verify CRC
Practical points
- Parasite power: the device charges an internal capacitor from the data line. During power-hungry operations (such as a temperature conversion) the master must provide a strong pull-up.
- Bus length and number of devices are limited by capacitance and the pull-up value.
- Because of its tight timing, interrupts during a bit slot can break communication, so disable them briefly or use hardware support.
- Always check the CRC of received data.
Pros: minimal wiring, unique IDs, easy to add many sensors on one cable. Cons: slow, timing-sensitive, single master, limited to small data.
8. Modbus RTU and TCP
What is Modbus?
Modbus is a very widely used application-layer protocol for industrial devices (PLCs, sensors, meters, drives). It defines what the messages mean, not the wires. It works as a simple request-response system.
- Master / client: sends a request.
- Slave / server: answers (or does not, if the request is not for it).
The same message structure (called the PDU) travels over different transports. The two most common are RTU (serial) and TCP (Ethernet).
Modbus data model
All data lives in four tables inside the slave:
| Table | Access | Size | Example |
|---|---|---|---|
| Coils | Read / write | 1 bit | Relay output |
| Discrete inputs | Read only | 1 bit | Switch input |
| Input registers | Read only | 16 bit | Sensor reading |
| Holding registers | Read / write | 16 bit | Setpoint, configuration |
Registers are 16-bit and sent big-endian (high byte first).
Common function codes
| Code | Function |
|---|---|
0x01 | Read coils |
0x02 | Read discrete inputs |
0x03 | Read holding registers |
0x04 | Read input registers |
0x05 | Write single coil |
0x06 | Write single register |
0x0F (15) | Write multiple coils |
0x10 (16) | Write multiple registers |
Exception responses
If a request cannot be handled, the slave replies with the function code with the top bit set (code + 0x80) and an exception code:
| Exception code | Meaning |
|---|---|
0x01 | Illegal function |
0x02 | Illegal data address |
0x03 | Illegal data value |
0x04 | Slave device failure |
Modbus RTU (serial)
- Runs over RS-485 (most common) or RS-232.
- Compact binary format.
- Typical serial settings: 8 data bits, even parity (or none with 2 stop bits), 9600 to 115200 baud.
RTU frame
ββββββββββββββββ¬ββββββββββββββββ¬ββββββββββββββ¬ββββββββββββββββ
β Slave addressβ Function code β Data β CRC-16 β
β 1 byte β 1 byte β N bytes β 2 bytes β
ββββββββββββββββ΄ββββββββββββββββ΄ββββββββββββββ΄ββββββββββββββββ
- Slave address: 1 to 247 (0 is broadcast: all slaves act, none reply).
- CRC-16: sent low byte first.
- Frame delimiting is by silence: a gap of at least 3.5 character times marks the end of a frame. A gap of more than 1.5 character times inside a frame means the frame is broken.
Example: read 2 holding registers starting at address 0 from slave 1
Request: 01 03 00 00 00 02 C4 0B
β β ββββ¬βββ ββββ¬βββ ββ CRC (low, high)
β β start quantity (2)
β β function 0x03
β slave address 1
Response: 01 03 04 00 0A 00 14 xx xx
β ββββ¬βββ βββ¬ββ
β reg 0 =10 reg 1 = 20
β byte count (4)
Modbus TCP
- Runs over a TCP connection, typically port 502.
- The client connects to the server, then sends requests and receives responses over the socket.
- No slave address and no CRC in the same way: TCP/IP already provides addressing and error checking. Instead there is a 7-byte MBAP header.
βββββββββββββββββββββββββββ MBAP header βββββββββββββββββββββββββ¬βββββββββββ PDU ββββββββββββ
β Transaction ID β Protocol ID β Length β Unit ID β Function code β Data β
β 2 bytes β 2 bytes (=0)β 2 bytes β 1 byte β 1 byte β N bytes β
ββββββββββββββββββ΄ββββββββββββββ΄βββββββββββ΄ββββββββββ΄ββββββββββββββββ΄ββββββββββββββββββββββββββ
| Field | Purpose |
|---|---|
| Transaction ID | Lets the client match each response to its request |
| Protocol ID | Always 0 for Modbus |
| Length | Number of following bytes |
| Unit ID | Identifies a device behind a gateway (for example a serial slave) |
RTU vs TCP
| Aspect | Modbus RTU | Modbus TCP |
|---|---|---|
| Transport | Serial (RS-485 / RS-232) | Ethernet TCP/IP |
| Addressing | Slave address byte | IP address + unit ID |
| Error check | CRC-16 | Provided by TCP (no Modbus CRC) |
| Frame end | 3.5-character silence | Length field |
| Topology | Multi-drop bus | Network, many simultaneous clients |
| Speed | Slow to moderate | Fast |
| Typical use | Field devices, sensors | Plant networks, SCADA, gateways |
Practical notes
- Address numbering confusion: documents often list registers as
40001,40002, and so on (the "4xxxx" prefix indicates holding registers). In the actual message, the address field is zero-based (40001is sent as0x0000). - Multi-register values (32-bit integers, floats) span two registers, and the word order differs between vendors, so check the device manual.
- A master should use a timeout and retry if a slave does not reply.
- A common architecture: gateways connect a Modbus TCP network to Modbus RTU devices.
9. Bluetooth and BLE Fundamentals
Two flavours
Bluetooth is a short-range wireless technology in the 2.4 GHz ISM band. It comes in two incompatible modes:
| Aspect | Bluetooth Classic (BR/EDR) | Bluetooth Low Energy (BLE) |
|---|---|---|
| Purpose | Continuous streaming | Small, occasional data with very low power |
| Data rate | Up to a few Mbit/s | About 125 kbit/s to 2 Mbit/s (PHY dependent) |
| Power | Higher | Very low (coin-cell devices can run for months or years) |
| Connection time | Slower | Very fast |
| Typical uses | Audio headsets, serial cable replacement | Sensors, wearables, beacons, remote controls |
| Compatibility | Classic-only and BLE-only devices cannot talk directly. Dual-mode devices support both |
Embedded projects most often use BLE.
Typical hardware arrangement
Most designs use either an MCU with a built-in radio, or a separate Bluetooth module connected over UART (or SPI) that handles the radio and protocol stack. The module may accept simple text (AT) commands or a binary host interface.
BLE stack layers (simplified)
ββββββββββββββββββββββββββββββββββββ
β Application / Profile β your data and behaviour
ββββββββββββββββββββββββββββββββββββ€
β GATT (services, characteristics)β how data is organised
β ATT (attribute protocol) β
ββββββββββββββββββββββββββββββββββββ€
β GAP (discovery, connection) β who can see and connect to whom
β L2CAP, Security Manager β
ββββββββββββββββββββββββββββββββββββ€
β Link Layer + Radio (PHY) β packets over the air
ββββββββββββββββββββββββββββββββββββ
GAP: roles, advertising and connections
GAP (Generic Access Profile) controls how devices find and connect.
| Role | Meaning |
|---|---|
| Peripheral | Advertises, accepts connections (for example a sensor tag) |
| Central | Scans, starts connections (for example a phone) |
| Broadcaster | Advertises only, no connection (a beacon) |
| Observer | Scans only, no connection |
- Advertising: a peripheral sends small packets on three advertising channels (37, 38, 39) at a set interval, containing its name and basic information.
- Scanning: a central listens for those packets.
- Connection: once connected, both sides exchange data at agreed connection intervals (roughly 7.5 ms up to 4 s). Longer intervals save power but add latency. Peripheral latency and supervision timeout are related connection settings.
- A peripheral can only be connected to a limited number of centrals, and often stops advertising once connected.
GATT: how data is organised
GATT (Generic Attribute Profile) defines a client-server data model on top of ATT.
Server (peripheral)
βββ Service (for example "Heart Rate") β identified by a UUID
βββ Characteristic (for example "Heart Rate Measurement")
β βββ Value
β βββ Descriptor (for example CCCD: enable notifications)
βββ Characteristic ...
| Term | Meaning |
|---|---|
| Service | A group of related data (for example Battery, Heart Rate, or a custom service) |
| Characteristic | A single data item with a value and properties |
| Descriptor | Extra information about a characteristic (for example, the on/off switch for notifications) |
| UUID | 16-bit (standard, assigned by Bluetooth SIG) or 128-bit (custom) identifier |
Characteristic properties and operations
| Operation | Meaning |
|---|---|
| Read | Client asks for the current value |
| Write | Client changes the value (with or without response) |
| Notify | Server pushes updates to the client automatically, no acknowledgement |
| Indicate | Like notify, but the client confirms each one |
Notify is the usual way to stream sensor data efficiently, because the client does not need to poll.
Data size
The default ATT MTU is 23 bytes, which leaves about 20 bytes of payload per packet. The MTU can be negotiated larger by both sides.
Security
- Pairing: devices exchange keys to set up encryption.
- Bonding: the keys are stored so devices reconnect without pairing again.
- Security levels range from none to authenticated, encrypted connections.
Power-saving tips
Use long advertising and connection intervals, send data only when needed, use notifications instead of polling, and let the radio sleep between events.
Pros and cons
Pros: low power (BLE), widely supported by phones, no wiring, standard profiles. Cons: short range (roughly 10 to 100 m depending on power and environment), small throughput for BLE, shared 2.4 GHz band with Wi-Fi and others, stack complexity.
10. Wi-Fi Communication Basics
What is Wi-Fi?
Wi-Fi (IEEE 802.11) is wireless Ethernet-like networking. A Wi-Fi device joins a network and then uses standard IP, TCP and UDP just like a wired Ethernet device. It provides much more throughput and range than BLE, at the cost of higher power consumption.
Basics
| Item | Description |
|---|---|
| Bands | 2.4 GHz (longer range, more crowded) and 5 GHz (faster, shorter range). Newer standards also use 6 GHz |
| Standards | 802.11b/g/n (2.4 GHz era), 802.11ac, 802.11ax ("Wi-Fi 6") and newer. Many low-cost embedded chips support only 2.4 GHz b/g/n |
| Typical throughput | From a few Mbit/s up to hundreds of Mbit/s or more (real-world rates are lower than the headline numbers) |
| Range | Roughly tens of metres indoors |
Modes and terms
| Term | Meaning |
|---|---|
| Access Point (AP) | The base station (router) that forms the network |
| Station (STA) | A client device that joins an AP. Most embedded devices are stations |
| SSID | The network name |
| BSSID | The MAC address of the access point |
| Channel | The frequency slot in use (for example channels 1, 6, 11 on 2.4 GHz are the non-overlapping ones) |
| RSSI | Received signal strength indicator, used to judge link quality |
| SoftAP mode | The device itself acts as an access point (useful for set-up) |
Security
Networks are protected with WPA2 or WPA3 (passphrase-based encryption). Open networks and the old WEP method should be avoided.
How an embedded device connects
1. Initialise radio and Wi-Fi driver
2. Scan for networks (or use a known SSID)
3. Authenticate and associate with the AP (using the passphrase)
4. Obtain an IP address (DHCP, or a static address)
5. Open sockets: TCP / UDP (and use higher-level protocols such as HTTP or MQTT)
6. Handle disconnects: detect loss, reconnect, retry with backoff
// Typical flow with a vendor SDK (pseudo-code)
wifi_init();
wifi_connect("MySSID", "MyPassword");
wait_for_ip(); // DHCP finished?
int s = socket_connect("server.example.com", 80);
socket_send(s, request, len);
socket_receive(s, response, sizeof(response));
How the MCU talks to a Wi-Fi chip
- Integrated: the MCU has a built-in Wi-Fi radio and runs the stack (common with Wi-Fi SoCs).
- External module: the MCU talks to a Wi-Fi module over UART (AT commands), SPI or SDIO.
- Either way, the networking stack and SDK do most of the work. Your code mainly handles connection state, sockets and data.
Practical considerations
- Power: Wi-Fi uses much more current than BLE. Power-save modes (sleeping between access-point beacons) and deep sleep with quick reconnect are important for battery devices.
- Reliability: signal strength, interference (microwave ovens, Bluetooth, other networks) and distance affect performance. Always handle disconnection and reconnection in software.
- Provisioning: the device needs the network name and password. Common methods are SoftAP set-up pages, BLE-based provisioning, or stored credentials.
- Security: use encrypted connections, do not hard-code secrets carelessly, and keep firmware updated.
- Memory: TCP/IP and security stacks need considerably more RAM and flash than simple serial links.
Pros: high throughput, direct IP connectivity (cloud, web), uses existing infrastructure, long range compared with BLE. Cons: power hungry, complex, needs a network and credentials, more susceptible to interference and congestion.
11. Protocol Selection and Trade-offs
Choosing the right interface is a design decision that depends on speed, distance, number of devices, noise, power, cost and complexity. There is no single best protocol. Each one wins in a particular situation.
Master comparison table (including the earlier protocols)
| Protocol | Medium | Wires / type | Typical speed | Typical reach | Topology | Devices | Notes |
|---|---|---|---|---|---|---|---|
| UART | Wire | 2, single-ended | Up to ~1 Mbit/s | Short (board / metres) | Point-to-point | 2 | Simplest, async |
| SPI | Wire | 4+, single-ended | Up to tens of Mbit/s | Short (on board) | Master + slaves (CS each) | Several | Fast, simple |
| IΒ²C | Wire | 2, open-drain | 0.1 to 1 Mbit/s | Short (on board) | Multi-device bus | Many | Addressed, ACK |
| CAN / CAN FD | Wire | 2, differential | β€ 1 Mbit/s; FD to several Mbit/s | Tens to hundreds of metres | Multi-master bus | Many | Robust, priority-based |
| LIN | Wire | 1 | β€ 20 kbit/s | Few metres | Single master | β€ 16 | Cheap, vehicle sub-nets |
| USB | Wire | Differential + power | 12 Mbit/s to Gbit/s | ~5 m cable | Host + devices (tiered star) | Up to 127 | Plug-and-play, power |
| Ethernet | Wire | Twisted pair | 10 / 100 / 1000 Mbit/s | ~100 m per segment | Star (switch) | Many | Full IP networking |
| RS-232 | Wire | Single-ended | Up to ~115 kbit/s | ~15 m | Point-to-point | 2 | Legacy ports |
| RS-485 | Wire | Differential | Up to ~10 Mbit/s | Up to ~1200 m | Multi-drop bus | ~32+ | Noise-immune, long |
| IΒ²S | Wire | 3 (+MCLK) | Audio bit rates (MHz) | Very short | Point-to-point | 2 | Digital audio only |
| 1-Wire | Wire | 1 + GND | ~16 kbit/s | Tens of metres | Master + many slaves | Many | Unique IDs, parasitic power |
| Modbus RTU | Over RS-485/232 | (serial) | Serial rates | Up to ~1200 m | Master-slave | Up to 247 | Application protocol |
| Modbus TCP | Over Ethernet | (network) | Network rates | Network | Client-server | Many | Industrial over IP |
| BLE | Wireless 2.4 GHz | Radio | ~125 kbit/s to 2 Mbit/s | ~10 to 100 m | Central + peripherals | Few connections | Very low power |
| Wi-Fi | Wireless 2.4/5 GHz | Radio | Up to hundreds of Mbit/s | Tens of metres | AP + stations | Many | High power, IP |
Questions to ask when choosing
| Question | Points toward |
|---|---|
| Is it between chips on the same board? | SPI, IΒ²C, UART, IΒ²S (audio) |
| Do you need high speed? | SPI, USB, Ethernet, Wi-Fi |
| Long cable run or electrically noisy place? | RS-485, CAN (differential) |
| Many devices on few wires? | IΒ²C, CAN, RS-485, 1-Wire |
| Hard real-time, priority and reliability (vehicle / machine)? | CAN / CAN FD |
| Very cheap, slow, simple vehicle devices? | LIN |
| Connecting to a PC, with plug-and-play? | USB (CDC, HID, MSC) |
| Needs to be on a network / internet / cloud? | Ethernet or Wi-Fi |
| Existing industrial equipment? | Modbus RTU / TCP, RS-485 |
| Digital audio streams? | IΒ²S |
| Many identical sensors, minimum wires? | 1-Wire (or IΒ²C) |
| No wires and battery-powered? | BLE (low rate) or Wi-Fi (high rate) |
| Phone connectivity? | BLE (or Bluetooth Classic, or Wi-Fi) |
Key trade-offs
| Trade-off | Explanation |
|---|---|
| Speed vs distance | Faster signalling shortens the usable cable length (RS-485, CAN, Ethernet) |
| Speed vs power | High-speed links and radios (Ethernet, Wi-Fi) use more energy than slow ones (BLE, LIN) |
| Simplicity vs capability | UART and SPI are easy. USB, Ethernet, Wi-Fi and BLE need large stacks |
| Wires vs pins | Fewer wires (IΒ²C, 1-Wire) usually means lower speed or more protocol overhead |
| Noise immunity | Differential buses (CAN, RS-485, USB, Ethernet) beat single-ended ones (UART, RS-232, SPI, 1-Wire) |
| Determinism | CAN priorities and LIN schedules give predictable timing, while Wi-Fi and Ethernet with TCP are less predictable |
| Cost | LIN, UART, IΒ²C are cheap. Ethernet, USB and wireless need extra chips, connectors and certification |
| Software effort | Using an existing stack or module saves time but uses memory, and may limit control |
| Standards and ecosystem | Widely supported protocols (USB, Ethernet, Wi-Fi, BLE, Modbus) give easy interoperability |
Typical combinations in real products
- Car: CAN / CAN FD backbone + LIN sub-networks.
- Factory sensor node: MCU + RS-485 running Modbus RTU, or Ethernet with Modbus TCP.
- Wearable: BLE to a phone, IΒ²C/SPI to its sensors.
- Smart speaker: IΒ²S to codecs, IΒ²C for codec control, Wi-Fi for cloud.
- IoT gateway: Ethernet or Wi-Fi to the cloud, RS-485 and BLE to local devices.
- Dev board: USB for programming and debug (virtual serial port).
12. Quick Revision Cheat Sheet
| Topic | One-line summary |
|---|---|
| CAN | Robust differential multi-master bus. Messages are identified by ID, lowest ID wins arbitration. 8 data bytes, up to 1 Mbit/s. |
| CAN FD | Same idea with up to 64 data bytes and a faster data phase. |
| LIN | Single-wire, single-master, slow (β€ 20 kbit/s), cheap bus for simple vehicle devices. UART-based with a schedule table. |
| USB | Host-controlled bus with enumeration, descriptors, endpoints and four transfer types (control, bulk, interrupt, isochronous). |
| Ethernet | Wired LAN. MCU has a MAC plus an external PHY. 48-bit MAC address, frames up to 1500 bytes payload, runs IP stack. |
| RS-232 | Single-ended Β±V levels, point-to-point, short distance. |
| RS-485 | Differential, multi-drop, long distance, noise-immune. Needs termination and direction control. |
| IΒ²S | Three-wire digital audio link: BCLK, WS (left/right), SD. MSB first, one-clock delay. |
| 1-Wire | One data line, unique 64-bit IDs, timed pulses, parasitic power possible. |
| Modbus RTU | Serial request-response protocol: address, function, data, CRC-16, frames split by silence. |
| Modbus TCP | Same functions over TCP port 502 with an MBAP header and no CRC. |
| Bluetooth / BLE | 2.4 GHz wireless. BLE is low power, organised by GAP (connect) and GATT (services, characteristics). |
| Wi-Fi | 802.11 wireless networking with full IP. Fast but power hungry. Station joins an access point, gets an IP, uses sockets. |
| Selection | Choose by speed, distance, number of devices, noise, power, cost and software complexity. |
Ten things to remember
- CAN is for noisy, multi-node, real-time systems. Lower identifier = higher priority, and arbitration never destroys the winning message.
- CAN FD adds payloads up to 64 bytes and a faster data phase while keeping CAN's robustness.
- LIN is the cheap, slow, master-controlled partner to CAN for simple devices.
- USB is host-driven: the device is enumerated, described by descriptors, and communicates through endpoints. Use standard classes (CDC, HID, MSC) when possible.
- Ethernet needs a MAC and a PHY plus a TCP/IP stack. It gives high speed and full network access.
- RS-232 is single-ended and point-to-point. RS-485 is differential and multi-drop. Always terminate RS-485 and control its direction pin carefully.
- IΒ²S carries audio with BCLK, WS and SD, and needs steady, DMA-driven buffering to avoid clicks.
- 1-Wire uses one wire, unique ROM IDs and strict microsecond timing.
- Modbus is an application protocol: RTU over serial with CRC and silence-based framing, TCP over Ethernet with the MBAP header.
- BLE is for low power, Wi-Fi is for high throughput. Always weigh speed, distance, power, cost and complexity when selecting any interface.
π§ Continue Learning
Related technical blueprints and practical guides in Firmware & Embedded Systems.

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

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

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

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