Lecture 04: Software Architecture
Obuda University -- Embedded Systems
Week 7 | Labs completed: Line Following, IMU Turns
Your Learning Arc (6 Lectures)
| Lecture | Week | Topic |
|---|---|---|
| Lecture 1 | Week 1 | What is Embedded? |
| Lecture 2 | Week 3 | Sensors, Signals & Actuators |
| Lecture 3 | Week 5 | Peripherals, Buses & Motor Control |
| Lecture 4 | Week 7 | Software Architecture ← you are here |
| Lecture 5 | Week 9 | Engineering Practice |
| Lecture 6 | Week 11 | Course Synthesis & Demo Prep |
Your Learning Arc (Detailed)
- Foundation (Weeks 1–2): What is Embedded?, GPIO, Sensors ✓
- Sensing & Signals (Weeks 3–4): ADC, I2C, PWM basics, Ultrasonic ✓
- Peripherals & Control (Weeks 5–6): Bus protocols, motor control, P-control, IMU ✓
- Software (Weeks 7–8): State Machines, Abstraction ← you are here
- Engineering (Weeks 9–10): Software Engineering, Integration
- Synthesis (Weeks 11–12): Course Synthesis, Demo
Narrative
Your code is getting complex. Flags, nested ifs, line-following AND obstacle avoidance AND battery checking -- all tangled together.
Part 1: From spaghetti code to structured design -- organizing behavior with state machines.
Part 2: What happens INSIDE robot.forward(80)? -- understanding the layers between your code and the silicon, and where your robot sits in the embedded architecture landscape.
The story: Your code is getting complex → flags explode → state machines solve this → but what happens INSIDE
robot.forward()? → layers of abstraction → Python vs C vs registers → your robot is one point on a spectrum from MicroPython to AUTOSAR.
What You Already Know (From Other Courses)
| From... | You already know... | Today we use it for... |
|---|---|---|
| Programming | Functions, classes, if/else, loops | State machines as code, abstraction layers |
| Programming | Data structures, arrays, dictionaries | State tables, transition maps, callback registries |
| Electronics | Digital logic, registers, bus protocols | Understanding the layers from Python down to silicon |
| Control Theory | Block diagrams, input → process → output | State machine as a controller, feedback loops |
| Lectures 2--3 | P-control, sensors, motor physics, I2C | The building blocks your state machine orchestrates |
Today's Map
- Lab Recap (Labs 5--6)
- Architecture Evolution
- Why Flags Explode
- State Machine Formalism
- Design Patterns & Implementation
- The Layer Cake: Python to Silicon
- Python vs C vs Registers
- Embedded Architecture Landscape
- Exercises & Quick Checks
Part 1
Lab Recap: Labs 5--6
Lab 5: Line Following
| What You Did | What You Discovered |
|---|---|
| Implemented P-control on line sensors | Proportional correction = smooth tracking |
| Tuned Kp with measurement-based starting point | Too low = sluggish, too high = oscillation |
Used ticks_ms() for non-blocking loop timing |
Loop rate affects how Kp "feels" |
| Compared bang-bang vs proportional control | Continuous correction eliminates overshoot |
You discovered that the formula matters: \(\text{correction} = K_p \times \text{error}\)
Lab 6: IMU Turns & Data Logging
| What You Did | What You Discovered |
|---|---|
| Used gyro heading for precise turn angles | Replaces unreliable time-based turns |
| Applied P-control to heading error | Same formula, different sensor |
| Collected data from multiple runs | Evidence beats intuition |
| A/B tested Kp values | Data shows trade-offs (speed vs consistency) |
Data-driven tuning: you used evidence, not guesswork.
The Growing Problem
But something else happened in those labs...
Your code is getting tangled. Line-following, obstacle detection, battery monitoring -- all mixed together with flags and nested conditions.
How do you handle "follow line" AND "avoid obstacle" AND "check battery" without spaghetti code?
Today we learn a formal technique to organize complex behavior: The State Machine.
And then: what happens inside the library calls your state machine uses.
Architecture Evolution
How Software Architecture Evolves
Every embedded system goes through this progression. A toy with one function sits at stage 1. Your car's engine ECU is at stage 5. The question is not "which is best" -- it's "where does my system's complexity actually sit?" Over-engineering is as bad as under-engineering.
As complexity grows, your code architecture must evolve:
| Stage | Architecture | When It Works | When It Breaks |
|---|---|---|---|
| 1 | Sequential (while True + sleep) |
One simple task | Multiple tasks with different timing |
| 2 | Round-Robin (check each task in order) | Few independent tasks | Tasks with dependencies or priorities |
| 3 | Round-Robin + ISR (interrupts for urgent events) | Time-critical events + background work | Complex state-dependent behavior |
| 4 | State Machine (explicit states + transitions) | Multiple modes, clear behavior per state | Very many states, concurrent subsystems |
| 5 | RTOS (real-time OS with threads) | True concurrency, complex scheduling | Overkill for simple systems |
Your robot is at stage 4: multiple behaviors demand explicit state management.
The Architecture Progression
┌───────────────┐ ┌───────────────────┐ ┌─────────────────┐
│ Round-Robin │ ──→ │ Round-Robin + ISR │ ──→ │ State Machine │
│ (Super Loop) │ │ (Fg/Bg Model) │ │ (Event-Driven) │
│ │ │ │ │ │
│ Simplest │ │ More responsive │ │ Most organized │
│ Polling-based│ │ HW-triggered │ │ Explicit states│
└───────────────┘ └───────────────────┘ └─────────────────┘
────────── complexity grows ──────────→
The simple polling loop from Lab 01 cannot handle the decisions your robot needs to make now.
State machines are the natural next step when behavior becomes complex.
Why Flags Explode
The Spaghetti Problem
Look at this code. You've probably written something like this already. It works -- until it doesn't. The moment you add a fifth behavior, you spend an hour tracing through nested conditions to find out why the robot randomly stops. I've reviewed hundreds of student projects. The number one cause of "it works sometimes" is flag spaghetti -- three booleans that accidentally end up in a combination nobody planned for.
As your robot gains features, code like this appears:
# The spaghetti problem
if moving and not obstacle and line_visible:
if turning:
if turn_complete:
moving = True
turning = False
else:
continue_turn()
else:
if obstacle_ahead:
stop()
obstacle = True
# ... more nested conditions ...
This code works -- until you try to change it.
Why It Fails
| Problem | Explanation |
|---|---|
| Hard to understand | What does moving and not obstacle and turning mean? |
| Hard to debug | The robot did something wrong -- but what state was it in? |
| Hard to extend | Adding "search for lost line" requires touching every branch |
| Hard to test | Which combinations of flags have you actually tested? |
The Combinatorial Explosion
Each boolean flag doubles the number of possible states:
| Flags | Example | Possible Combinations |
|---|---|---|
| 1 | moving |
2 |
| 2 | + obstacle |
4 |
| 3 | + turning |
8 |
| 4 | + line_lost |
16 |
| 5 | + low_battery |
32 |
Most of these 32 combinations are meaningless or should never occur -- but your code does not prevent them.
The Explosion Visualized

5 boolean flags = 32 possible combinations. You intended maybe 6 valid states. The other 26 are bugs waiting to happen.
Impossible Combinations
Any bug can put the robot into a state like:
There is no guard. Nothing prevents this. The code just does... whatever happens.
A Real Robot Scenario
Imagine your robot needs line-following, obstacle avoidance, and search:
Flags:
is_following is_avoiding is_searching
line_visible obstacle_near turning_left
emergency_stop
7 flags = 2^7 = 128 possible combinations
Meaningful states? Only about 5.
The other 123 combinations? BUGS waiting to happen.
A state machine eliminates 123 impossible combinations by design.
The Root Cause
Boolean flags describe properties of the system. What you actually need is to describe which behavior the system is performing right now.
A single, unambiguous answer to:
"What state is the robot in?"
State Machine Formalism
The Idea
Instead of scattered booleans, explicitly define:
- States -- the distinct modes your system can be in
- Transitions -- what causes the system to move between states
- Actions -- what the system does in each state (and during transitions)
This is called a Finite State Machine (FSM).
State Diagram

A line-following robot with obstacle detection:
Three states (IDLE, RUNNING, STOPPED). Four transitions (start, stop, obstacle detected, obstacle cleared). No ambiguity.
Reading the Diagram
| Element | Meaning |
|---|---|
| Box | A state -- one distinct behavior mode |
| Arrow | A transition -- the system changes state |
| Label on arrow | The event that triggers the transition |
| Starting state | Usually marked or obvious (IDLE) |
The robot is in exactly one box at any time. No exceptions.
Transition Table
The diagram as a table -- useful for checking completeness:
| Current State | Event | Next State | Action |
|---|---|---|---|
| IDLE | Start button | RUNNING | Start motors |
| RUNNING | Stop button | IDLE | Stop motors |
| RUNNING | Obstacle detected | STOPPED | Stop motors |
| STOPPED | Obstacle cleared | RUNNING | Resume motors |
| STOPPED | Stop button | IDLE | Stay stopped |
Have you defined what happens for every event in every state?
Completeness Check
Look at the table on the previous slide:
- What happens if the start button is pressed while already RUNNING?
- What about an obstacle event while IDLE?
Events that are not listed are implicitly ignored -- which is fine, as long as you made that choice deliberately.
An unhandled event should be a design decision, not an oversight.
Implementation Pattern
STATE_IDLE = 0
STATE_RUNNING = 1
STATE_STOPPED = 2
state = STATE_IDLE
def update(events):
global state
if state == STATE_IDLE:
if events.start_pressed:
state = STATE_RUNNING
return (0, 0) # Motors off
elif state == STATE_RUNNING:
if events.stop_pressed:
state = STATE_IDLE
return (0, 0)
if events.obstacle_detected:
state = STATE_STOPPED
return (0, 0)
return calculate_motor_speeds()
Implementation Pattern (continued)
elif state == STATE_STOPPED:
if events.stop_pressed:
state = STATE_IDLE
elif events.obstacle_cleared:
state = STATE_RUNNING
return (0, 0)
Notice:
- Current state is one variable, not five booleans
- Each state is a clearly defined block
- Transitions are explicit: you can read them directly
- The robot can only be in one state at a time
What State Machines Buy You
| Aspect | Without State Machine | With State Machine |
|---|---|---|
| Behavior | Scattered across nested if/else | Explicit in diagram and table |
| Debugging | "Why did it do that?" | Check current state + last transition |
| Adding features | Modify multiple if/else chains | Add a new state + transitions |
| Testing | Hard to cover all flag combos | Test each state independently |
| Documentation | "Read the code" | Draw the state diagram |
| Team work | Everyone edits the same logic | Each person owns a state |
The payoff increases with complexity. For two behaviors, either approach works. For five or more, the state machine pays for itself immediately.
State Machines in Engineering

State machines are not just a coding pattern -- they're a formal engineering tool. Every automotive ECU runs a state machine (AUTOSAR defines the state model). Every communication protocol is defined as a state machine (TCP, USB, CAN). Every safety-critical system uses verifiable state models -- because you can formally prove that no dangerous transition is possible.
UML statecharts are the industry-standard notation. What you're drawing on paper this week, automotive engineers draw in tools like Simulink or Enterprise Architect -- and the tools generate verified C code from the diagram.
- Traffic lights -- RED, GREEN, YELLOW with timed transitions
- Network protocols -- TCP, USB, CAN -- all defined as state machines
- Automotive ECUs -- AUTOSAR state management in every car
- Your washing machine -- FILL, WASH, RINSE, SPIN, DONE
Design Patterns
Pattern 1: Entry and Exit Actions
Some code should run once when entering or leaving a state, not every loop:
def transition_to(new_state):
global state
# Exit action for current state
if state == STATE_RUNNING:
stop_motors()
# Entry action for new state
if new_state == STATE_RUNNING:
start_motors()
elif new_state == STATE_STOPPED:
obstacle_timer = time.ticks_ms()
state = new_state
Why a function? It guarantees entry/exit actions always run.
Why Use transition_to()?
If you write state = STATE_RUNNING directly in three different places, you might forget the entry action in one of them.
# BAD -- easy to forget an action
state = STATE_RUNNING # Did I start motors?
# ... somewhere else ...
state = STATE_RUNNING # Forgot to start motors!
# GOOD -- always consistent
transition_to(STATE_RUNNING) # Entry action always runs
One function. One place. Always correct.
Pattern 2: Timed Transitions
"Stay in STOPPED for 1 second, then resume."
The timer starts on entry (inside transition_to):
elif state == STATE_STOPPED:
if events.stop_pressed:
transition_to(STATE_IDLE)
elif time.ticks_diff(time.ticks_ms(), obstacle_timer) > 1000:
if not events.obstacle_detected:
transition_to(STATE_RUNNING)
return (0, 0)
Non-blocking -- the timer is set once on entry, checked each iteration. Same pattern from Theory 05.
Pattern 3: Guard Conditions
Sometimes a transition should only happen if an extra condition is met:
# Only resume if battery is above threshold
elif state == STATE_STOPPED:
if events.obstacle_cleared and battery_voltage() > 3.3:
transition_to(STATE_RUNNING)
elif battery_voltage() <= 3.3:
transition_to(STATE_IDLE) # Too low to continue
Guard conditions prevent transitions that would be unsafe or meaningless.
Common Mistakes to Avoid
1. Modifying state from multiple places
Always use transition_to(). Never write state = X directly.
2. Forgetting to handle all events in all states Draw the transition table. Check every cell. Unhandled events should be a deliberate choice.
3. Not separating entry actions from per-loop actions Starting motors = entry action (once). Reading sensors = per-loop (every iteration).
4. Too many states If you have 15 states, split into two smaller state machines.
Implementation Patterns
Pattern A: The If-Elif Chain
The simplest approach -- what we have seen so far:
def update(events):
global state
if state == STATE_IDLE:
# handle idle
...
elif state == STATE_RUNNING:
# handle running
...
elif state == STATE_STOPPED:
# handle stopped
...
Pros: Easy to read, easy to understand. Cons: One giant function that grows with every new state.
Good for: 3-5 states. Beyond that, it gets unwieldy.
Pattern B: Dispatch Table
Use a dictionary to map states to handler functions:
def handle_idle(events):
if events.start_pressed:
transition_to(STATE_RUNNING)
return (0, 0)
def handle_running(events):
if events.stop_pressed:
transition_to(STATE_IDLE)
return (0, 0)
return calculate_motor_speeds()
handlers = {
STATE_IDLE: handle_idle,
STATE_RUNNING: handle_running,
STATE_STOPPED: handle_stopped,
}
# Main loop -- one line replaces the if-elif chain
def update(events):
return handlers[state](events)
Comparing the Two Patterns
| Aspect | If-Elif Chain | Dispatch Table |
|---|---|---|
| Readability | All logic visible in one place | Each state isolated in its own function |
| Adding states | Edit one large function | Add a function and one dict entry |
| Performance | Checks states sequentially | Direct lookup -- O(1) |
| Best for | Small (3-5 states) | Larger (5+ states) |
For Lab 07, the if-elif chain is fine. Know that the dispatch table exists for when you need it.
Hierarchical State Machines (Preview)
When behavior gets complex, nest state machines:
The top-level machine handles coarse modes (running, stopped, emergency). The sub-machine handles fine-grained behavior -- but only while the top level is in RUNNING.
Why Hierarchical?
Without hierarchy, adding emergency stop to 6 sub-states means 6 new transitions:
FOLLOW --> E_STOP AVOID --> E_STOP SEARCH --> E_STOP
TURN --> E_STOP BACKUP --> E_STOP SCAN --> E_STOP
With hierarchy, you add one transition at the top level:
All sub-states are automatically exited. Much simpler.
We will not implement this in Lab 07, but keep it in mind for your final project.
Preview: When the Main Loop Isn't Enough -- RTOS
A state machine handles one thing at a time within the main loop. But what about truly concurrent tasks?
| Problem | Why Single-Loop Fails |
|---|---|
| Multiple deadlines (motor 1kHz + display 10Hz + logging 1Hz) | Hard to guarantee all rates when tasks have variable execution time |
| Priority inversion (low-priority task blocks high-priority) | No preemption in single loop -- long task blocks everything |
| Team development (multiple engineers adding features) | Single loop = everyone modifies same code, conflicts |
A Real-Time Operating System (RTOS) gives you multiple threads with OS-managed scheduling and priorities.
For this course, state machines are sufficient. But know that RTOS exists as the next step when your system outgrows a single main loop.
Part 1 Summary
Five Things to Remember (State Machines)
-
Flags multiply -- 5 booleans = 32 possible combinations, most of them bugs
-
State machines make behavior explicit -- one variable, clear transitions, no ambiguity
-
Draw first, code second -- the state diagram IS the design
-
Use a transition function -- entry/exit actions run exactly once, every time
-
Check completeness -- every state must handle every possible event (even if the answer is "ignore it")
Part 2
The Layer Cake and Abstraction Levels
From robot.forward(80) to Silicon
The Open Question
Your state machine calls robot.forward(80) and the wheels turn. One line of Python, instant result.
What actually happens when you call
robot.forward(80)?
You write one line of Python. But between that line and the wheels turning, there are six layers of abstraction at work.
Last week you learned HOW devices communicate (I2C, SPI, UART). Today we look at the software layers that make it all work — and where your robot sits in the broader embedded world.
When Abstraction Breaks
Abstraction works beautifully -- until it does not.
- OLED froze -- is the problem in your code, the display library, the I2C bus, or the wiring?
- Motor speed is off -- is it your control logic, the picobot library, the PWM configuration, or the H-bridge?
- Sensor stopped responding -- loose wire, wrong I2C address, or bus contention?
When you only know the top layer, every failure is a mystery. When you understand ALL the layers, failures become diagnosable.
Understanding layers does not make you a better coder. It makes you a better debugger.
The Layer Cake
What Happens When You Call robot.forward(80)
┌─────────────────────────────────────────────┐ ← You write this
│ Your Code: robot.forward(80) │
├─────────────────────────────────────────────┤
│ picobot Library: motors.set_speeds(80,80) │
├─────────────────────────────────────────────┤
│ Driver Layer: PWM(Pin(10)).duty_u16(…) │
├─────────────────────────────────────────────┤
│ MicroPython: C function mp_hal_pin_write()│
├─────────────────────────────────────────────┤
│ Hardware Regs: Write to PWM_CH5_CC reg │
├─────────────────────────────────────────────┤ ← Silicon does this
│ Silicon: PWM counter compares, toggles pin│
└─────────────────────────────────────────────┘
You write one line. Six layers execute.
Why Layers?
Each layer in the stack has a single job:
- Your Code -- express intent: "go forward at 80%"
- picobot Library -- translate intent into motor commands
- Driver Layer -- configure MicroPython hardware objects (PWM, Pin)
- MicroPython Runtime -- call C functions that manipulate registers
- Hardware Registers -- memory-mapped addresses controlling peripherals
- Silicon -- transistors toggling pins at the requested frequency
Each layer only knows about the layer directly below it.
The Car Analogy
Think of driving a car:
┌──────────────────────┬────────────────┐
│ You: "Turn left" │ Intent │
├──────────────────────┼────────────────┤
│ Steering wheel │ Interface │
├──────────────────────┼────────────────┤
│ Power steering pump │ Amplification │
├──────────────────────┼────────────────┤
│ Rack and pinion │ Mechanism │
├──────────────────────┼────────────────┤
│ Wheels turn │ Physics │
└──────────────────────┴────────────────┘
You do not think about hydraulic fluid pressure when making a left turn.
But if the power steering fails, you need to understand the layers below.
Key Principle
Abstraction lets you USE a system without understanding every layer. But to DEBUG or DESIGN a system, you must see through the layers.
This is why we are looking under the hood today.
Benefits of Layered Design
| Problem | Without Layers | With Layers |
|---|---|---|
| Change motor pins | Edit 20 files that reference pin numbers | Edit 1 driver file |
| Add a different motor type | Rewrite application logic | Write a new driver, same API |
| Test line-following | Need a physical robot every time | Mock the driver, test on PC |
| Debug a motor issue | "Where is PWM configured?" -- search everywhere | Look in the motor driver module |
The Full Layer Stack
The complete abstraction stack for your robot, from intent down to silicon:
┌─────────────────────┬───────────────────────────────┐
│ Application Logic │ Follow the line, avoid walls │
├─────────────────────┼───────────────────────────────┤
│ Robot API │ robot.forward(80) │
├─────────────────────┼───────────────────────────────┤
│ Driver Layer │ Motor driver, sensor driver │
├─────────────────────┼───────────────────────────────┤
│ Hardware Abstraction│ Pin, PWM, ADC, I2C classes │
├─────────────────────┼───────────────────────────────┤
│ Hardware (MCU) │ Physical registers, silicon │
└─────────────────────┴───────────────────────────────┘
Each layer only talks to its direct neighbor ↕
Key principle: Each layer only talks to the layer directly below it.
Your application code should never touch GPIO pins directly -- it talks to the Robot API, which talks to drivers, which talk to hardware abstraction.
Good vs Bad Abstraction
BAD -- Application code touches hardware directly:
# Scattered throughout main.py
pwm_left = PWM(Pin(10))
pwm_right = PWM(Pin(11))
dir_left = Pin(12, Pin.OUT)
dir_right = Pin(13, Pin.OUT)
dir_left.value(1)
pwm_left.duty_u16(52428) # what does 52428 mean?!
GOOD -- Application code uses a driver:
class MotorDriver:
def __init__(self, pwm_pin, dir_pin):
self.pwm = PWM(Pin(pwm_pin))
self.dir = Pin(dir_pin, Pin.OUT)
def set_speed(self, percent):
self.dir.value(0 if percent >= 0 else 1)
self.pwm.duty_u16(int(abs(percent) / 100 * 65535))
left_motor = MotorDriver(10, 12)
left_motor.set_speed(80) # 80% forward -- clear!
NeoPixel Case Study: Three Levels of Understanding
Level 1 -- Library user:
Level 2 -- Understanding the protocol:
WS2812 requires precise timing: each bit is a HIGH pulse (short = 0, long = 1). 24 bits per LED at 800 kHz -- each bit takes 1.25 us.
Bit "1": ┌──────┐ Bit "0": ┌───┐
│ 700ns│ │350ns│
──────────┘ └────── ─────────┘ └─────────
600ns 800ns
total: 1.3 µs total: 1.15 µs
The LED distinguishes 1 from 0 by the width of the HIGH pulse — wider = 1, narrower = 0. Digital waveforms: "1" bit = 700 ns HIGH + 600 ns LOW (long pulse). "0" bit = 350 ns HIGH + 800 ns LOW (short pulse). Total ~1.25 µs per bit at 800 kHz.
NeoPixel Case Study: The Abstraction Trade-off
How can we generate these precise signals?
| Approach | Result | Why |
|---|---|---|
| Bit-bang in Python? | Fails | Each GPIO toggle takes ~5 us, need ~0.4 us |
| Bit-bang in C? | Works but wastes CPU | Consumes 100% CPU during transmission |
| Use PIO hardware? | Perfect | Exact timing, zero CPU load |
The driver author made this decision FOR you. That is the power of abstraction -- you call set_leds() without caring how the bits are sent.
But when something goes wrong (wrong colors, flickering, timing glitches), understanding the layers below helps you diagnose the problem.
The Cost of Abstraction
Abstraction is not free. Each layer adds:
- Execution time -- function calls, type checks, memory allocation
- Memory usage -- each layer has its own data structures
- Complexity -- more code to maintain and understand
Abstraction hides what happens, not how long it takes. Calling
robot.forward(80)feels instant, but dozens of function calls and register writes happen underneath.
Python vs C vs Registers
The Same Operation at Three Levels
GPIO -- Turn On an LED: Python
Highest abstraction:
Three concepts compressed into two lines: pin selection, direction, and output value.
GPIO -- Turn On an LED: C SDK
Middle abstraction:
#include "hardware/gpio.h"
gpio_init(13); // Configure pin
gpio_set_dir(13, GPIO_OUT); // Set direction
gpio_put(13, 1); // Set HIGH
Three explicit steps. Nothing is hidden.
GPIO -- Turn On an LED: Registers
No abstraction -- this is what both Python and C eventually do:
// 1. Select GPIO function (SIO) for pin 13
*(volatile uint32_t *)(IO_BANK0_BASE + 0x06C) = 5;
// 2. Set pin 13 as output
*(volatile uint32_t *)(SIO_BASE + 0x024) = (1 << 13);
// 3. Set pin 13 HIGH
*(volatile uint32_t *)(SIO_BASE + 0x014) = (1 << 13);
These "magic numbers" are memory-mapped register addresses from the RP2350 datasheet. The datasheet is the ground truth. Everything else is convenience.
PWM -- Motor at 50%: Python
from machine import Pin, PWM
motor = PWM(Pin(10))
motor.freq(1000)
motor.duty_u16(32768) # 50% duty cycle
Three lines: create PWM, set frequency, set duty cycle. Done.
PWM -- Motor at 50%: C SDK
#include "hardware/pwm.h"
uint slice = pwm_gpio_to_slice_num(10); // Which PWM slice?
gpio_set_function(10, GPIO_FUNC_PWM); // Route pin to PWM
pwm_set_wrap(slice, 65535); // Set period (top)
pwm_set_chan_level(slice, PWM_CHAN_A, 32768); // 50% compare
pwm_set_enabled(slice, true); // Start PWM
Five explicit steps. You see the slice, channel, wrap value, and explicit enable -- all hidden in Python.
What the PWM Hardware Does
PWM counter: 0 -> 1 -> 2 -> ... -> 32767 -> 32768 -> ... -> 65535 -> 0
|
Compare match!
Pin goes LOW
Output pin: ████████████████████░░░░░░░░░░░░░░░░░░░░
|<--- ON (50%) --->|<--- OFF (50%) --->|
The PWM hardware is a counter that runs continuously. Below the compare value = HIGH. Above = LOW. This square wave drives your motor.
Interpreter vs Compiler
Where does MicroPython fit?
Compiled (C):
source.c --> [Compiler] --> machine code --> runs on CPU
Interpreted (MicroPython):
main.py --> [Interpreter reads line-by-line at runtime] --> CPU
- Compiled (C): source code is translated to machine code before execution. Fast, efficient, catches errors at compile time.
- Interpreted (MicroPython): the interpreter reads your Python at runtime, line by line. Slower, more memory, but much faster development.
Key insight: The MicroPython interpreter IS a compiled C program running on the MCU. It reads your .py files and executes them.
Timer Interrupts: Python vs C
The difference matters most for deterministic timing:
Python -- polling with ticks_diff():
while True:
now = time.ticks_ms()
if time.ticks_diff(now, last_time) >= 10:
last_time = now
control_loop() # Timing depends on loop speed
C -- hardware timer interrupt:
volatile bool timer_fired = false;
bool timer_callback(repeating_timer_t *rt) {
timer_fired = true; // Hardware fires every 10 ms -- guaranteed
return true;
}
| Aspect | Python (ticks_diff) |
C (hardware timer) |
|---|---|---|
| Jitter | Depends on loop time | Microseconds |
| Determinism | Best-effort | Guaranteed |
| Use case | Prototyping, learning | Production, safety-critical |
volatiletells the C compiler: "this variable changes from an interrupt -- do not optimize away reads."
Layers Are Real: Timing Comparison
| Level | What You Write | Approx. Time |
|---|---|---|
| Python | Pin(13).on() |
1--2 us |
| C SDK | gpio_put(13, 1) |
~30 ns |
| Register | Direct memory write | ~7 ns |
Each layer adds convenience but costs time.
The ratio is roughly 200:1 between Python and C for simple hardware operations.
Choose the Right Level
- Python -- fast to write, easy to debug, good enough for most tasks in this course
- C SDK -- when you need speed or deterministic timing (production, safety-critical)
- Registers -- rarely written by hand; understanding them helps you read datasheets and debug hardware
You do not need to write register-level code. But knowing that every
Pin(13).on()eventually becomes a register write makes you a better embedded engineer.
Embedded Architecture Landscape
Beyond MicroPython — How the Industry Builds Systems
The RP2350 Under the Hood
Your machine.PWM and machine.I2C calls configure these peripherals through the bus. Once configured, the hardware runs autonomously — the CPU is free to do other work.
Your Robot's Software Stack
Every line you write passes through these layers. When the OLED freezes or a motor behaves wrong, the bug lives in one of these layers — knowing them tells you where to look.
What If One Loop Isn't Enough? — RTOS
Your robot runs a single while True loop. What happens when you need:
- Motor control at 1 kHz (1 ms)
- Sensor reading at 100 Hz (10 ms)
- Display update at 10 Hz (100 ms)
A Real-Time Operating System gives each task its own thread with guaranteed timing:
// FreeRTOS: three tasks, three priorities
void motor_task(void *p) { while(1) { pid_step(); vTaskDelay(1); } }
void sensor_task(void *p) { while(1) { read_imu(); vTaskDelay(10); } }
void display_task(void *p) { while(1) { oled_update(); vTaskDelay(100); } }
RTOS Task Scheduling
The scheduler preempts lower-priority tasks when a higher-priority task is ready. Your cooperative ticks_ms loop can't do this — if one task takes too long, everything waits.
When Do You Need an RTOS?
| Your Robot (Cooperative) | RTOS (Preemptive) |
|---|---|
| If motor task takes too long → everything waits | Scheduler interrupts it |
| Timing guarantee → best effort | Timing → provable worst-case |
| Task isolation → shared globals | Tasks → separate stacks, queues |
| Works for → learning, prototyping | Works for → drones, robots, industrial |
For this course, your single-loop state machine is sufficient. But when a system outgrows one loop, RTOS is the next step — not "rewrite everything," just add a scheduler.
Five Architectures, One Pattern
The Same Pattern at Every Level
Despite massive differences in complexity, ALL embedded architectures share this:
Application Logic ← "What the system does"
|
Middleware / OS ← "How tasks are scheduled"
|
HAL / Drivers ← "How to talk to hardware"
|
Hardware Regs ← "What the silicon provides"
Your P-control formula, your state machine, your I2C protocol — all the same across architectures. Only the API changes.
Same GPIO, Five Levels
| Level | Code |
|---|---|
| MicroPython | Pin(25, Pin.OUT).value(1) |
| C SDK | gpio_put(25, 1) |
| Register | *(uint32_t*)(0xd0000014) = (1<<25) |
| Linux sysfs | echo 1 > /sys/class/gpio/gpio25/value |
| AUTOSAR | Dio_WriteChannel(LED, STD_HIGH) |
Same pin goes HIGH. Same transistor switches. Five different ways to express it.
Where Does Each Architecture Live?
| Architecture | Typical Use | Timing | Example |
|---|---|---|---|
| MicroPython | Education, IoT prototypes | Best effort | Your PicoBot |
| Bare Metal C | Simple products, sensors | Deterministic | Thermostat, remote |
| RTOS | Drones, robots, industrial | Hard real-time | Flight controller |
| Linux | Cameras, UI, networking | Soft real-time | Raspberry Pi |
| AUTOSAR | Automotive, medical, aero | Certified | Engine ECU, ABS |
Increasing complexity → more layers → stricter timing guarantees.
You started at MicroPython. The Linux in Embedded Systems course takes you to the Linux column. The concepts you learned here — state machines, layers, bus protocols — apply at every level.
Exercises
Exercise 1: Design a State Machine (4 Behaviors)
Your robot must handle four distinct behaviors:
| State | Behavior |
|---|---|
| IDLE | Motors off, waiting for start button press |
| LINE_FOLLOW | Following the line using proportional control |
| OBSTACLE_AVOID | Obstacle detected -- stop, turn, resume |
| SEARCH | Line lost -- execute a search pattern to find it |
On paper, draw:
- 4 boxes for the states
- Arrows between them for every valid transition
- Labels on each arrow: what event triggers that transition?
- Entry actions noted inside or beside each box
Take 3 minutes. Draw it now.
Exercise 1: Transition Table
Complete every cell. Empty cells are where bugs hide.
| Current State | Event | Next State | Action |
|---|---|---|---|
| IDLE | Start button | _____ | _____ |
| LINE_FOLLOW | Obstacle detected | _____ | _____ |
| LINE_FOLLOW | Line lost | _____ | _____ |
| LINE_FOLLOW | Stop button | _____ | _____ |
| OBSTACLE_AVOID | Obstacle cleared | _____ | _____ |
| OBSTACLE_AVOID | Stop button | _____ | _____ |
| SEARCH | Line found | _____ | _____ |
| SEARCH | Timeout (5s) | _____ | _____ |
| any state | Emergency stop | _____ | _____ |
Exercise 1: Check Your Table
Questions to verify your design:
- Can the robot get stuck in any state with no way out?
- Is there a path from every state back to IDLE?
- Does emergency stop work from every state?
- What happens if an obstacle is detected during SEARCH?
- What happens if the line is lost during OBSTACLE_AVOID?
If you find a gap, add a transition.
Exercise 1: Sample Solution

Four states: IDLE → LINE_FOLLOW (on start), LINE_FOLLOW → AVOID (obstacle detected), LINE_FOLLOW → SEARCH (line lost), SEARCH → IDLE (timeout), AVOID → LINE_FOLLOW (cleared). Emergency stop returns to IDLE from any state.
Exercise 1: Sample Transition Table
| Current State | Event | Next State | Action |
|---|---|---|---|
| IDLE | Start button | LINE_FOLLOW | Start motors, LED on |
| LINE_FOLLOW | Obstacle detected | OBSTACLE_AVOID | Stop, begin turn |
| LINE_FOLLOW | Line lost | SEARCH | Start search pattern |
| LINE_FOLLOW | Stop button | IDLE | Stop motors |
| OBSTACLE_AVOID | Obstacle cleared | LINE_FOLLOW | Resume forward |
| OBSTACLE_AVOID | Stop button | IDLE | Stop motors |
| SEARCH | Line found | LINE_FOLLOW | Resume tracking |
| SEARCH | Timeout (5s) | IDLE | Stop motors, LED blink |
| any state | Emergency stop | IDLE | Kill motors immediately |
Exercise 2: Trace robot.forward(80) Through All Layers
Fill in what happens at each layer:
Layer 1 - Your Code: robot.forward(80)
|
Layer 2 - picobot Library: ???
|
Layer 3 - Driver Layer: ???
|
Layer 4 - MicroPython: ???
|
Layer 5 - Hardware Regs: ???
|
Layer 6 - Silicon: ???
Work in pairs. You have 3 minutes.
Exercise 2: Solution
Layer 1 - Your Code: robot.forward(80)
|
Layer 2 - picobot Library: motors.set_speeds(80, 80)
|
Layer 3 - Driver Layer: PWM(Pin(10)).duty_u16(52428)
| Pin(12).value(1) # direction
|
Layer 4 - MicroPython: C function mp_hal_pin_write()
|
Layer 5 - Hardware Regs: Write 52428 to PWM_CH5_CC register
|
Layer 6 - Silicon: PWM counter toggles pin at duty cycle
80% of 65535 = 52428. The library converts your percentage to a 16-bit duty value.
Exercise 3: Choose the Architecture
For each product, decide which architecture level is appropriate and why:
| Product | Architecture? | Why? |
|---|---|---|
| Smart thermostat (reads temp, controls relay) | _ | _ |
| Quadcopter drone (4 motors, IMU, GPS, camera) | _ | _ |
| Digital alarm clock | _ | _ |
| Car anti-lock braking system (ABS) | _ | _ |
| Home security camera with face detection | _ | _ |
Work in pairs. 2 minutes.
Exercise 3: Solution
| Product | Architecture | Why |
|---|---|---|
| Smart thermostat | Bare Metal C | Simple, deterministic, one task |
| Quadcopter drone | RTOS | Multiple deadlines (motor 1kHz, IMU 100Hz, GPS 1Hz) |
| Digital alarm clock | Bare Metal C | Simple, low power, no concurrency needed |
| Car ABS | AUTOSAR | Safety-certified, ISO 26262, provable timing |
| Security camera + face detection | Linux | Needs camera drivers, ML framework, networking |
The question is never "which is best" — it's "what does my system's complexity require?"
Exercise 4: Cooperative vs Preemptive
Your robot runs motor control (needs 1 ms), sensor reading (needs 3 ms), and OLED update (needs 15 ms) in a single loop.
Questions:
- What is the total loop time? Can you guarantee 1 ms motor updates?
- If the OLED update takes 25 ms instead of 15, what happens to motor control?
- How would an RTOS solve this? What would the task priorities be?
Work in pairs. 3 minutes.
Exercise 4: Solution
- Total loop = 1 + 3 + 15 = 19 ms. Motor runs at ~52 Hz, not 1000 Hz. Cannot guarantee 1 ms.
- Loop stretches to 29 ms. Motor control is starved — wheels jerk or drift.
- RTOS: Motor = HIGH priority (1 ms), Sensor = MEDIUM (10 ms), Display = LOW (100 ms). The scheduler preempts display to run motor — motor always meets its deadline.
Summary & Quick Checks
Ten Things to Remember
State Machines (Part 1):
- Flags multiply -- 5 booleans = 32 possible combinations, most of them bugs
- State machines make behavior explicit -- one variable, clear transitions, no ambiguity
- Draw first, code second -- the state diagram IS the design
- Use a transition function -- entry/exit actions run exactly once, every time
- Check completeness -- every state must handle every possible event
Architecture & Abstraction (Part 2):
- Abstraction layers hide complexity but add overhead -- each layer does one job
- Python → C → registers: same operation, different levels -- trading convenience for speed at ~200:1
- Understanding layers makes you a better debugger -- when something breaks, you know where to look
- Five architecture levels -- MicroPython → Bare Metal C → RTOS → Linux → AUTOSAR, same pattern at every level
- Choose architecture by complexity -- over-engineering is as bad as under-engineering
Quick Check 1
Why is a single state variable better than multiple boolean flags?
A single variable can only hold one value at a time, so the system is always in exactly one state. Boolean flags can be combined into impossible or meaningless configurations. 5 booleans = 32 combinations, but only ~5 are meaningful.
Quick Check 2
What is the purpose of entry and exit actions in a state machine?
Entry actions run once when entering a state (e.g., start motors, reset timer). Exit actions run once when leaving (e.g., stop motors). They guarantee setup and cleanup happen exactly once, not every loop iteration.
Quick Check 3
How many possible combinations do 6 boolean flags create?
2^6 = 64 combinations. Most of them are meaningless or buggy. A state machine with 6 named states has exactly 6 possible states -- no impossible combinations.
Quick Check 4
What is the approximate speed difference between Python and C for a GPIO toggle?
| Level | Time | Ratio |
|---|---|---|
Python (Pin(13).on()) |
~1-2 us | 1x |
C SDK (gpio_put(13, 1)) |
~30 ns | ~50x faster |
| Register (direct write) | ~7 ns | ~200x faster |
The MicroPython interpreter adds overhead for type checking, object lookup, and bytecode execution before reaching the same register write that C does directly.
Quick Check 5
Your robot needs motor control at 1 kHz and display at 10 Hz. Why can't a single
while Trueloop guarantee both?
If the display update takes 15 ms, the motor misses 15 deadlines. A cooperative loop runs tasks sequentially — one slow task starves everything. An RTOS solves this with preemptive scheduling: the motor task interrupts the display task every 1 ms.
Quick Check 6
What architecture would you choose for a drone flight controller, and why?
RTOS (e.g., FreeRTOS, Zephyr). Drones need hard real-time guarantees: motor control at 1 kHz, IMU at 500 Hz, GPS at 1 Hz — all with provable worst-case timing. MicroPython is too slow, bare metal can't manage multiple deadlines, Linux has no hard RT guarantees, AUTOSAR is overkill.
Bridge to Labs & Next Lecture
This Week: Lab 7 -- State Machines
Implement a state machine for your robot:
Your workflow:
- Draw the state diagram first -- on paper, before any code
- Fill in the transition table -- every state, every event, every action
- Implement using the
transition_to()pattern - Test each transition -- trigger every event in every state
10 minutes of design saves 30 minutes of debugging.
Tutorial: State Machines
Next Week: Lab 8 -- HW Abstraction + Architecture
| Step | What You Do | What You Learn |
|---|---|---|
| 1 | Read the picobot source code |
How the library is layered |
| 2 | Control an LED using Pin directly |
The driver layer, without the library |
| 3 | Control a motor using PWM directly |
PWM configuration that picobot hides |
| 4 | Scan the I2C bus and read raw IMU data | Communication protocols in practice |
| 5 | Compare direct code with picobot calls |
The value (and cost) of abstraction |
Understanding what the library does makes you a better user of it.
Tutorial: Hardware Abstraction
Bridge to Lecture 5 (Week 9)
Your state machine organizes behavior. Your abstraction layers organize code. Each piece works in isolation.
But your main.py has been growing for 8 weeks -- motor control, sensor reading, control logic, display updates, configuration values, all in one file.
What if changing one thing breaks three others?
Next lecture: Software engineering principles that make code survive change.
Separation of concerns, modules, clean interfaces. The same ideas that make the picobot library well-organized -- applied to YOUR code.
"Each piece works alone. But put them together..."
End
Lab 7 (this week): State Machines -- design and implement a 4-state robot behavior. Lab 8 (next week): HW Abstraction + Architecture -- peel apart the layers.
Next lecture is in Week 9: Engineering Practice.
"You're designing a state machine for 4 behaviors. The same formalism governs every traffic light and every nuclear plant."
"You're reading an IMU over I2C. The same protocol connects thousands of sensors in a modern factory."