Lecture 03: Peripherals, Buses & Control
Obuda University -- Embedded Systems
Week 5 | Labs completed: Ultrasonic & Timing, Motor Control
Your Learning Arc
- 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, IMU ← you are here
- Software (Weeks 7--8): State Machines, Abstraction
- Engineering (Weeks 9--10): Software Engineering, Integration
- Synthesis (Weeks 11--12): Course Synthesis, Demo
What You Discovered: Labs 3--4
| What You Did | What You Discovered |
|---|---|
| Built PWM from scratch (LED, buzzer, motor) | Duty cycle controls power; frequency controls smoothness/noise |
| Found the dead zone and tested PWM frequency | Motors need minimum PWM to start; low freq = audible whine |
| Drove open-loop on a paper line | Equal PWM does not mean straight line -- motors are mismatched |
| Added bang-bang line correction | Sensor feedback fixes drift -- even a simple controller works |
| Logged data to CSV and plotted on PC | Balance changes with speed -- calibration cannot fix it |
What You Already Know (From Other Courses)
| From... | You already know... | Today we use it for... |
|---|---|---|
| Electronics | V = IR, H-bridge circuits, bus protocols | AM1016A motor driver, I2C deep dive |
| Control Theory | Feedback, error signal, proportional gain | P-control for line following |
| Lecture 2 | PWM, duty cycle, ADC, I2C basics, OLED | Motor drive, I2C protocol detail |
| Labs 3--4 | Non-blocking timing, bang-bang control | Concurrent tasks, first controller |
Today's Map
- Lab Recap
- System Architecture -- From Python to Electrons
- Bus Communication -- I2C Deep Dive
- Peripheral Architecture (RP2350 internals)
- Timing & Scheduling
- Motor Control Essentials
- Feedback Control Concepts
- IMU Introduction
- Quick Checks + Bridge to Lab
The Key Question from Lab
You called set_motors(80, 80) in your motor control lab.
What ACTUALLY happened?
How did a Python function call become a spinning wheel? How many hardware layers did that command pass through? What ran without the CPU?
Today we trace the full path -- from Python to electrons -- and understand the hardware peripherals that make it all work.
Section 2
System Architecture — From Python to Electrons
What Happens When You Call set_motors(80, 80)?

Layer 1: The picobot Library
# What you write:
robot.set_motors(80, 80)
# robot.py: set_motors() calls motors.set_speed()
def set_motors(self, left, right):
self.motors.set_speed(left, right) # Motors class: two Motor objects
# motors.py Motor: the actual hardware control
def set_speed(self, speed):
speed = max(-255, min(255, int(speed))) # Clamp to range
if speed > 0:
target, other = self._pin_a, self._pin_b # Forward: PWM on A
else:
target, other = self._pin_b, self._pin_a # Backward: PWM on B
duty = int(abs(speed) / 255 * 65535) # 0-255 → 0-65535 (16-bit)
Pin(other, Pin.OUT, value=0) # Other pin held LOW
self._pwm = PWM(Pin(target)) # Create PWM on drive pin
self._pwm.freq(self.freq) # 1000 Hz default
self._pwm.duty_u16(duty) # Set duty cycle
The library is your code -- read src/picobot/lib/picobot/motors.py and modify it.
Layer 2: The RP2350 PWM Hardware
Once duty_u16() is called, the hardware takes over:
System +------------------------------+
Clock --> | PWM Slice (1 of 12) |
150 MHz | |
| Counter: 0 -> wrap -> 0 |--> GPIO Pin
| if counter < duty: HIGH | (to H-bridge)
| if counter >= duty: LOW |
| |
| wrap = freq, duty = duty_u16|
+------------------------------+
- No CPU involvement -- runs independently after configuration
- Exact timing -- hardware counter, not software loop
- Two channels per slice -- each motor uses one channel
- Python can do other work (read sensors, log data) while PWM runs
Layer 3: H-Bridge to Motor
GPIO Pin (3.3V, ~1mA) Motor (6V, ~500mA)
| |
v v
+----------+ +----------+ +----------+
| PWM |--->| AM1016A |-->| DC Motor |--> Gearbox --> Wheel
| (signal) | | H-bridge | | (power) |
+----------+ | (switch) | +----------+
+----------+
|
Battery
(power)
| Domain | Voltage | Current | What controls it |
|---|---|---|---|
| Signal | 3.3V | ~1 mA | RP2350 GPIO |
| Power | 3.7-4.2V | 50-1000 mA | AM1016A switches |
| Mechanical | N/A | N/A | Motor + gearbox |
The fundamental pattern: a tiny signal controls a large power flow.
The Sensor Side: Same Layers in Reverse
We traced set_motors() downward — from Python to the wheel. But the robot also needs to sense the world. The data flows upward — from physics to Python, through the same kind of layers:
Physical World Your Python Code
| ^
+--------+ +--------+
| IR LED | Optocoupler: LED shines, | picobot|
| +photo | photodetector reads | library|
| detect | reflection (black/white) +---+----+
+---+----+ |
| analog voltage +----+----+
+---v------+ | Digital |
|Comparator| Converts analog to digital | GPIO |
| circuit | threshold -> 0 or 1 | read |
+---+------+ +----+----+
| digital (0/1) |
+------------- GPIO Pin -----------------+
Sensors convert physical quantities → electrical signals → digital values → Python data.
The Complete Robot: Both Paths
ACTUATOR (down) SENSOR (up)
+---------------------------+
| Your Python Code |
| set_motors() get_error() |
+-----+--------------^------+
| |
+-----v-----+ +----+----------+
| PWM Hw | | GPIO/ADC/I2C |
+-----+-----+ +----^----------+
| |
+-----v-----+ +----+----------+
| H-Bridge | | Optocoupler |
| AM1016A | | Ultrasonic |
+-----+-----+ | IMU |
| +----^---------+
+-----v-----+ +----+----------+
| Motors | | Physical World|
+-----+-----+ +----^----------+
| |
+--> wheels -->+ (the control loop)
Embedded = software + hardware layers + physical world in a loop.
Section 3
Bus Communication
How chips talk to each other — and to the outside world
Why Chips Need to Talk
A microcontroller can't do everything alone. It needs sensors, displays, memory, and other processors. These are separate chips that must exchange data.
Two levels of communication:
| Intra-system (on the board) | Inter-system (to the outside) | |
|---|---|---|
| What | MCU ↔ sensors, displays, memory | Robot ↔ PC, other robots, cloud |
| Distance | cm (same PCB) | m to km |
| Protocols | I2C, SPI | UART, USB, WiFi, CAN, Ethernet |
| Your robot | Pico ↔ OLED (I2C), Pico ↔ IMU (I2C) | Pico ↔ your PC (UART over USB) |
Today we focus on intra-system — how the Pico talks to the OLED and IMU on the same board.
Where Communication Interfaces Are Used
| Interface | Application examples |
|---|---|
| I2C | Sensors (IMU, temperature, pressure), OLED displays, EEPROM, RTC clocks |
| SPI | SD cards, TFT displays, flash memory, fast ADCs, radio modules (nRF24L01) |
| UART | GPS modules, Bluetooth (HC-05), debug console, RS-232/RS-485 industrial |
| CAN | Automotive (every car since ~2008), industrial automation, robotics |
| Ethernet | Factory PLCs, IP cameras, IoT gateways |
| USB | Programming/debug, HID devices, storage |
| WiFi / BLE | IoT cloud connectivity, phone apps, mesh networks |
| 1-Wire | Temperature sensors (DS18B20), iButton authentication |
Your robot uses I2C (OLED + IMU), UART (debug/mpremote), and PWM (motors). A car uses all of the above simultaneously — 70+ ECUs connected via CAN, with Ethernet for cameras and BLE for your phone.
Serial vs Parallel Communication

| Serial | Parallel | |
|---|---|---|
| Wires | 1 data line | 8+ data lines |
| How | Bits sent one after another | All bits sent at once |
| Pins | Few (1–4) | Many (8+ data + clock) |
Why Parallel Lost (Outside the Chip)

| Parallel | Serial | |
|---|---|---|
| Speed | Fast (all bits at once) | Slower per clock |
| Pin count | data lines (\(2^N\)) + clock + control | 1–4 wires |
| Problem | Crosstalk, skew at distance | None |
| Cost | Expensive routing | Cheap |
At high speeds, parallel wires interfere with each other (crosstalk). Bits arrive at slightly different times (skew). This limits cable length and speed.
Why Parallel Lost (Outside the Chip)
In embedded: pins are precious. Serial wins for everything outside the chip.
High-speed serial uses differential pairs — two wires carrying opposite signals (D+ and D−). Noise hits both wires equally, the receiver subtracts them → noise cancels out. This is how USB, Ethernet, CAN, and LVDS achieve Gbps speeds over long cables with just 2 wires. I2C and SPI are single-ended (simpler, lower speed).
Inside the chip: the CPU-to-peripheral bus (AHB/APB) is still 32-bit parallel — distances are µm, not cm.
Communication Directions

| Mode | Direction | On your robot |
|---|---|---|
| Simplex | One way only | IR line sensor → Pico (read only) |
| Half-duplex | Both ways, takes turns | I2C — Pico sends commands OR reads data, not both |
| Full-duplex | Both ways simultaneously | UART — print() sends while mpremote receives |
The Three Serial Protocols You'll Use
| UART | SPI | I2C | |
|---|---|---|---|
| Wires | 2 (TX, RX) | 4 + 1/device | 2 (SDA, SCL) shared |
| Speed | 115.2 kbit/s | 1--50 Mbit/s | 400 kbit/s |
| Relative | 1× | 100--400× | 3.5× |
| Devices | 1 (point-to-point) | Few (CS per device) | Up to 127 (addressable) |
| Clock | No (async, baud rate) | Master provides | Master provides |
| Duplex | Full | Full | Half |
| Use case | GPS, BT, debug | SD card, TFT, fast ADC | OLED, IMU, sensors |
| Your robot | USB serial, mpremote | — (not used) | OLED (0x3C), IMU (0x69) |
Speed in context: oled.show() sends 512 bytes at I2C 400 kHz ≈ 10 ms. Over SPI at 10 MHz it would take 0.4 ms — 25× faster. That's why high-refresh displays use SPI.
Why Analog Wires Are Not Enough
You've seen three serial protocols. Your robot uses I2C because it needs only 2 shared wires for multiple devices. Let's see exactly how it works.
One analog wire carries one voltage. Your OLED needs 512 bytes per frame. The IMU sends 6 axes of data. A GPS sends full text sentences. One voltage cannot carry structured data.
Digital communication protocols solve this -- structured, addressable data over shared wires. Now let's dive deep into I2C, the protocol your robot uses most.
I2C — Two Wires, Many Devices
SDA (data) + SCL (clock) -- just 2 wires shared by all devices.
Think of it like a classroom: - The teacher (master = Pico) calls a student by name (address) - Only that student responds -- everyone else stays quiet - They take turns talking on the same pair of wires
Each device has a unique 7-bit address (up to 127 devices on one bus):
- OLED display: 0x3C
- BMI160 IMU: 0x69
I2C
The master (Pico) initiates all communication. Devices only respond when addressed.
Pull-up resistors keep both lines HIGH when idle -- devices pull lines LOW to signal.

Your robot: MCU Master (Pico) → OLED (0x3C) + IMU (0x69) on the same 2 wires.
I2C Protocol Basics

START → Address frame (7 bits + R/W + ACK) → Data frames (8 bits + ACK each) → STOP
| Clock Speed | Name | Your Robot Uses |
|---|---|---|
| 100 kHz | Standard | — |
| 400 kHz | Fast | OLED and IMU |
| 1 MHz | Fast+ | — |
Each bit takes one clock cycle. At 400 kHz, one byte (8 data bits + 1 ACK) = 22.5 µs.
I2C: START and STOP Conditions

- START (S): SDA goes LOW while SCL is HIGH — "attention, transaction begins!"
- STOP (P): SDA goes HIGH while SCL is HIGH — "transaction complete, bus released"
During data transfer, SDA only changes while SCL is LOW. This rule is what makes START and STOP unique — they are the ONLY times SDA changes while SCL is HIGH.
Logic Analyzer Capture
A logic analyzer shows the actual electrical signals on SDA and SCL. Each transaction is visible: START, address, ACK, data, STOP.
This is the difference between "I2C sends data" and seeing the bytes on the wire. When debugging, the logic analyzer is the ground truth -- it shows what the hardware actually did, not what your code intended.
What the Bytes Mean — SSD1306 Register Map
When oled.show() executes, MicroPython sends this I2C transaction:
START → 0x3C (OLED address) + Write
→ 0x40 (control byte: "data follows")
→ byte[0] (first 8 vertical pixels, column 0)
→ byte[1] (next 8 vertical pixels, column 1)
→ ...
→ byte[511] (last column, last page)
→ STOP
512 bytes = 128 columns x 4 pages (128 x 32 pixels / 8 bits per byte).
The SSD1306 controller interprets each byte as 8 vertical pixels. Bit 0 = top pixel, bit 7 = bottom pixel of that 8-pixel column.
Every I2C device has its own register map. The datasheet tells you what each byte means.
I2C Transaction Anatomy
i2c.writeto_mem(0x3C, 0x40, buffer) decomposes to these bytes on the wire:

| Field | Bits | Meaning |
|---|---|---|
| START | 1 | SDA goes LOW while SCL is HIGH — "transaction begins" |
| Address | 7 | 0x3C = 0111100 — which device to talk to |
| R/W | 1 | 0 = Write, 1 = Read |
| ACK | 1 | Slave pulls SDA LOW = "I'm here, got it." If no device at this address, SDA stays HIGH = NACK (no acknowledgement) |
| Register | 8 | 0x40 = data mode (tells SSD1306 "pixels follow") |
| ACK | 1 | Slave confirms each byte received |
| Data | 512 × 8 | Framebuffer contents (each byte followed by ACK) |
| STOP | 1 | SDA goes HIGH while SCL is HIGH — "transaction done" |
ACK is how the slave says "I received that byte." After every 8 data bits, the master releases SDA and the slave pulls it LOW for one clock cycle.
I2C: ACK/NACK and Clock Stretching
ACK/NACK — presence detection after every byte:
| SDA during ACK bit | Meaning | Master's action |
|---|---|---|
| LOW (slave pulls down) | ACK — "I'm here, ready" | Continue sending |
| HIGH (nobody pulls) | NACK — no response | Abort or retry |
ACK does NOT confirm the data is correct — it only confirms the addressed slave is present and able to respond. Data integrity requires higher-level checks (checksums, CRC).
NACK means: wrong address, device busy, not connected. This is how i2c.scan() works — send every address, check who ACKs.
Clock stretching — slave says "wait, I'm busy":
The slave can hold SCL LOW to pause the master. The master cannot send the next clock pulse until SCL is released. This lets slow devices (e.g. sensor doing a conversion) delay the transfer without losing data.
Embedded gotcha: clock stretching is why I2C transfers sometimes take longer than expected. If a slave stretches the clock for 1 ms, your
oled.show()takes 11 ms instead of 10 ms. The I2C hardware handles this automatically — but your timing budget must account for the worst case.
I2C in MicroPython
from machine import I2C, Pin
i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400_000)
devices = i2c.scan() # Returns list of addresses
print(devices)
# [60, 105] → 0x3C (OLED), 0x69 (IMU)
i2c.scan() sends the address of every possible device (0--127) and listens for an ACK. Only connected devices respond.
This is the first thing to try when debugging I2C: if scan() finds the device, the wiring is correct.
OLED Framebuffer and the Cost of show()
Framebuffer concept:
1. Draw in memory (fast -- just writing to RAM): oled.text("Hello", 0, 0)
2. Call oled.show() -- sends the entire 512-byte buffer over I2C
3. Display updates all at once (no flickering)
The cost at 400 kHz:
Each byte = 9 bits on the wire (8 data + 1 ACK). 512 bytes + overhead = ~4700 bits.
At 400 kHz: ~12 ms -- your entire budget in a 100 Hz control loop.
Without OLED: [sensors|compute|motors|idle........] 10 ms
With OLED: [sensors|compute|motors|oled.show()!] 10 ms ← no time left!
Solutions: Update every 5th or 10th loop iteration, not every cycle.
UART — The Simplest Protocol
Point-to-point, no clock wire — both sides agree on baud rate (bits per second).

TX crosses to RX — each side transmits on its own wire. Full duplex.
UART Frame Format

Each byte is framed: START bit (low) → 8 data bits → optional parity → STOP bit (high).
No clock wire — the receiver samples at the bit center using the agreed baud rate. If the baud rates don't match, data is garbage.
You already use UART: print() → UART → USB → your terminal. At 115200 baud, each byte takes ~87 µs.
SPI — The Fast One

4 wires: MOSI (master out), MISO (master in), SCK (clock), CS (chip select).
- Full duplex — send and receive simultaneously
- CS pin per device — no addressing needed, just pull CS low to select
- 10–100× faster than I2C — if
oled.show()used SPI: ~0.1 ms instead of ~10 ms
SPI: How Data Moves

Data shifts bit-by-bit through connected shift registers on each clock edge. Master and slave exchange one byte simultaneously — that's why SPI is full duplex.

Multiple devices: one CS wire per slave. Only the selected device responds.
Communication Protocols Comparison
| Protocol | Wires | Speed | Devices | Used For |
|---|---|---|---|---|
| Analog | 1 per sensor | N/A | 1 per pin | Simple sensors (temp, light) |
| I2C | 2 (shared) | 100--1000 kHz | Up to 127 | OLED, IMU, complex sensors |
| SPI | 4 + 1/device | 1--50 MHz | Few | Fast sensors, SD cards, TFT |
| UART | 2 (point-to-point) | 9600--115200 baud | 1 | GPS, Bluetooth modules |
I2C is the "universal compromise": only 2 wires, many devices, moderate speed. When you need more speed (camera, SD card), you move to SPI. When you need simplicity with one device (GPS), you use UART.
Section 4
Peripheral Architecture
How hardware does the work so Python does not have to
RP2350 Block Diagram
Your Code +----------------------------------+
(MicroPython) | RP2350 |
| | |
v | ARM Cortex-M33 (dual core) |
machine.PWM() | | |
machine.I2C() | AHB / APB Bus |
| | | |
+------------>| GPIO Mux (30 pins) |
| | | | | | |
| PWM I2C SPI UART PIO |
| x12 x2 x2 x2 x3 |
| slices ctrl ctrl ctrl SM |
| | | |
| DMA SRAM |
| x16ch 520KB |
+----------------------------------+
Every peripheral operates independently once configured. The CPU writes a few registers, then moves on.
PWM Peripheral
How hardware generates waveforms without the CPU
How PWM Works (Any MCU)
Every PWM peripheral has the same basic structure:
- A counter counts from 0 up to some maximum value, then resets
- A comparator watches the counter and sets the output pin HIGH or LOW
- The frequency is set by how fast the counter wraps (clock speed ÷ max value)
- The duty cycle is set by the compare threshold (where the output switches)
This is hardware — once configured, it runs without the CPU. The CPU just writes the compare value to change the duty cycle.
Different MCUs organize their PWM hardware differently, but the counter + comparator principle is universal (STM32, ESP32, AVR, RP2350 — all the same idea).
RP2350 PWM: Slices and Channels
The RP2350 has 8 PWM slices. Each slice has one counter and two output channels (A and B):
Slice 0: [Counter] --+--> Channel A (GPIO 0 or 16)
+--> Channel B (GPIO 1 or 17)
Slice 1: [Counter] --+--> Channel A (GPIO 2 or 18)
+--> Channel B (GPIO 3 or 19)
...
Slice 7: [Counter] --+--> Channel A (GPIO 14)
+--> Channel B (GPIO 15)
Key rules:
- Channels A and B in the same slice share the same frequency (one counter, one clock divider)
- Each channel has its own duty cycle (separate compare register)
- The GPIO pin number determines which slice and channel it belongs to: slice = pin // 2, channel = pin % 2
Your robot: Left motor uses GPIO 12-13 (slice 6), right motor uses GPIO 10-11 (slice 5). Each motor's forward/reverse pins are on the same slice — same frequency, independent duty. Each motor has its own AM1016A H-bridge driver.
PWM Hardware: Counter + Comparator
150 MHz system clock
|
+----v--------------------------------------+
| Clock Divider (integer + fraction) |
| 150 MHz / 150 = 1 MHz tick rate |
+----+--------------------------------------+
|
+----v--------------------------------------+
| 16-bit Counter: 0 -> TOP -> 0 -> TOP |
| TOP = 999 -> wraps every 1000 ticks |
| -> 1 MHz / 1000 = 1 kHz PWM frequency |
+----+--------------------------------------+
|
+----v--------------------------------------+
| Comparator: counter < CC -> HIGH |
| counter >= CC -> LOW |
| CC = 500 -> 50% duty cycle |
+----+--------------------------------------+
|
GPIO Pin -> H-bridge -> Motor
CPU writes TOP and CC once. Hardware runs forever. The CPU is completely free.
PWM in C: What the Registers Look Like
MicroPython's pwm.freq(1000) + pwm.duty_u16(32768) is a wrapper. In C:
pwm_set_clkdiv(slice, 150.0f); // 150 MHz / 150 = 1 MHz tick
pwm_set_wrap(slice, 999); // 1 MHz / 1000 = 1 kHz frequency
pwm_set_chan_level(slice, chan, 500); // 500/1000 = 50% duty
gpio_set_function(10, GPIO_FUNC_PWM); // Assign pin to PWM
pwm_set_enabled(slice, true); // Start — runs autonomously
Each function writes to a memory-mapped register:
// What pwm_set_wrap() actually does — one memory write:
*(volatile uint32_t *)(0x40050000 + 0x10 * slice + 0x04) = 999;
// PWM base slice offset TOP register
One write at 0x4005_0004 → hardware generates a 1 kHz waveform forever.
Python or C — same registers, same hardware, same result. Python takes ~100 µs to configure. C takes ~100 ns. But you only configure once, so it doesn't matter.
I2C Peripheral
Autonomous hardware — but the CPU must keep feeding it
I2C Hardware Controller
CPU writes to FIFO I2C Hardware Bus
+--------------+ +--------------------+ +-----------+
| address |--->| Shift register |--->| SDA wire |
| data bytes | | Clock generator |--->| SCL wire |
| | | ACK/NACK detect | | |
| |<---| State machine |<---| (pullups) |
| status | | (START/STOP/STRCH) | | |
+--------------+ +--------------------+ +-----------+
The I2C controller has its own shift register, clock generator, and protocol state machine. It generates START/STOP conditions, clocks out each bit, and detects ACK/NACK — all in hardware.
But unlike PWM, the CPU can't "fire and forget":
The CPU must write each data byte to the FIFO and wait for space. For 512 bytes (oled.show()), the CPU is stuck in a loop feeding the FIFO for ~10 ms.
I2C in C: What the Datasheet Tells You
The SSD1306 datasheet says: "send control byte 0x40 followed by data bytes."
// C SDK: send framebuffer to OLED
i2c_write_blocking(i2c0, 0x3C, &cmd, 1, true); // Address + 0x40
i2c_write_blocking(i2c0, 0x3C, framebuffer, 512, false); // 512 bytes
At the register level:
i2c0->ic_tar = 0x3C; // Set target address
i2c0->ic_data_cmd = 0x40; // Control byte
for (int i = 0; i < 512; i++) {
while (!(i2c0->ic_status & 0x2)); // Wait for FIFO space ← CPU blocked!
i2c0->ic_data_cmd = framebuffer[i]; // Feed one byte
}
MicroPython's i2c.writeto_mem(0x3C, 0x40, buf) does exactly this. The while loop is where the CPU time goes — waiting for the FIFO between each byte.
Reading datasheets is the core embedded skill. The datasheet tells you what bytes to send, in what order, to what address. The library wraps that. When debugging, the datasheet is ground truth.
The Real Cost: Display Refresh Rate
At 400 kHz I2C, 512 bytes + overhead ≈ 10 ms per frame.
But the CPU is blocked for the entire 10 ms:
Without OLED: [sense|compute|motors|........idle........] 20 ms
With OLED: [sense|compute|motors|oled.show()........!] 20 ms → half your budget gone!
Solutions: update display every 5th loop, use DMA, or use SPI OLED (faster bus).
PWM vs I2C: The Key Difference
Two Peripherals, Two Patterns
| PWM | I2C | |
|---|---|---|
| Hardware has | Counter + comparator | Shift register + clock gen + state machine |
| CPU configures | 2 registers (TOP, CC) | Address + clock speed |
| Then CPU... | Does nothing — fire and forget | Feeds every byte to FIFO |
| Runs for | Forever (until reconfigured) | Duration of one transaction |
| CPU time cost | ~100 µs once | ~10 ms per oled.show() |
| Blocking? | No | Yes (in MicroPython) |
This is why set_motors() is instant but oled.show() kills your control loop.
Both are hardware peripherals. The difference is whether the CPU needs to stay involved during the operation.
Why MicroPython Works
Python is 50–100× slower than C for computation. But you never bit-bang timing-critical I/O:
| Task | Who does the work? | Python's role |
|---|---|---|
| PWM generation | PWM counter + comparator | Write 2 registers once |
| I2C transfer | I2C shift register + clock | Feed FIFO (blocking) |
| Sensor read | GPIO hardware | Read 1 register |
| Control loop | Python (Kp × error) | ~10 µs — fits in 10 ms budget |
The rule: hardware handles timing-critical I/O. Python configures peripherals and makes decisions. That's why a "slow" language works for real-time control.
DMA: The Missing Piece
DMA = Direct Memory Access — hardware copies data without the CPU:
Normal (CPU copies): RAM --> CPU --> I2C FIFO (CPU busy)
DMA (hardware copies): RAM ----------> I2C FIFO (CPU free!)
Without DMA: oled.show() blocks for ~12 ms while the CPU feeds bytes to I2C.
With DMA: hardware reads the 512-byte framebuffer from RAM and streams it to the I2C controller. The CPU starts the transfer and immediately returns to the control loop.
MicroPython does not use DMA for I2C by default. But this is how
oled.show()could be non-blocking -- and how production embedded systems handle it. PIO + DMA on the RP2350 makes this possible.
Section 5
Blocking & Timing Recap
Where Blocking Hides
You solved the blocking problem in the timing lab. Quick recap — these steal time from your control loop:
| Operation | Typical Block Time | Why it blocks |
|---|---|---|
time.sleep(0.1) |
100 ms | Intentional wait |
robot.read_distance() |
0–30 ms | Waits for ultrasonic echo |
print() |
1–30 ms | Serial UART transfer |
oled.show() |
10 ms | CPU feeds I2C FIFO (see Section 4) |
Now you understand WHY oled.show() blocks — the I2C peripheral needs the CPU to feed each byte. PWM doesn't block because it's fire-and-forget.
The ticks_ms() Pattern
# NON-BLOCKING — loop keeps running
last_time = time.ticks_ms()
while True:
now = time.ticks_ms()
if time.ticks_diff(now, last_time) >= 10:
last_time = now
read_sensors()
# Other tasks run freely here
Key difference: CHECK if it is time, do not WAIT for time.
Interrupts (Brief Preview)
Polling checks sensors in a loop. Interrupts let hardware notify the CPU instantly:
from machine import Pin
button_pressed = False
def button_isr(pin):
global button_pressed
button_pressed = True # Just set a flag — handle in main loop
button = Pin(2, Pin.IN, Pin.PULL_UP)
button.irq(trigger=Pin.IRQ_FALLING, handler=button_isr)
ISR rules: fast and simple. No print(), no memory allocation, no I2C. Set a flag, store a timestamp, get out. You'll use interrupts hands-on in the State Machines lab.
Section 6
Motor Control Essentials
Motor in 60 Seconds
Everything you need to know about DC motors for this course:
| Concept | One-line explanation |
|---|---|
| Current → torque | More current through the coil = stronger magnetic force = more torque |
| Back-EMF | Spinning motor generates voltage that opposes the supply -- natural speed limit |
| Gearbox | Trades speed for torque -- your motor: 300 RPM no-load, 1:20 gear ratio |
| Deadband | Below ~20-30% PWM, static friction wins -- motor does not move |
| Stall current | Motor held still = no back-EMF = maximum current draw (1.2 A for your motor) |
That is the physics. The rest of this section is about the hardware interface.
NEVER Connect a Motor Directly to GPIO
| GPIO Pin | DC Motor | |
|---|---|---|
| Current capability | ~12 mA max | 100--500+ mA needed |
| What happens | Pin damaged | Motor barely twitches |
Think of it like a water valve: you turn the tap with your fingers (tiny force) → the valve controls high-pressure flow (big force). GPIO = your fingers. H-bridge = valve. Battery = water supply.
You need a power stage between the MCU and the motor.
The H-Bridge: Schematic

Four MOSFETs (Q1–Q4) arranged in an "H". The motor sits in the crossbar.
D1–D4 are flyback diodes — when a switch turns OFF, the motor's inductance tries to keep current flowing. The diodes provide a safe path for this decaying current. Without them, the voltage spike would destroy the MOSFETs.
MOSFETs switch in nanoseconds, handle amps, and waste very little power (low ON resistance). That's why they replaced relays for motor control.
H-Bridge: Forward

Q1 + Q4 closed: current flows from V+ through Q1, through the motor (left → right), through Q4 to GND.
Motor spins forward.
H-Bridge: Reverse

Q2 + Q3 closed: current flows the opposite way through the motor (right → left).
Motor spins backward. Same circuit, just different switches.
H-Bridge: All States
| Switches | Current | Motor | Use |
|---|---|---|---|
| Q1 + Q4 | Left → right | Forward | Normal driving |
| Q2 + Q3 | Right → left | Reverse | Backing up |
| Q1 + Q3 | Both to GND | Brake | Active stop (motor shorted) |
| All OFF | None | Coast | Free spin (no braking) |
DANGER: Q1+Q2 or Q3+Q4 = shoot-through — direct short from V+ to GND. Destroys the transistors in milliseconds.

Dead Time: Preventing Shoot-Through

When switching from forward (Q1+Q4) to reverse (Q2+Q3), the driver must turn OFF the old pair before turning ON the new pair. If both are ON simultaneously — even for nanoseconds — shoot-through occurs.
Dead time = a brief period (typically 100–500 ns) where ALL switches are OFF during every transition.
The AM1016A inserts dead time automatically — you don't need to worry about it in software. But this is why you can't just toggle GPIO pins to control a motor directly — you'd need to implement dead-time protection yourself.
In high-power applications (industrial drives, EVs), dead time management is critical. Too short = shoot-through risk. Too long = voltage distortion and torque ripple.
H-Bridge with PWM: Voltage and Current

Left: switches light up for each state. Right: voltage (blue) and current (orange) waveforms.
During PWM ON: current ramps up through Q1+Q4. During PWM OFF: current decays through the flyback diodes (D1–D4) — the inductance keeps it flowing. Each ON pulse starts from where the previous decay left off — this is how inductance smooths the PWM into steady motor current.
Your Robot's Motor Driver: AM1016A
Your robot has two AM1016A H-bridge ICs — one per motor.
| Feature | Value |
|---|---|
| Operating voltage | 2.5--7.5V |
| Continuous current | 1 A per channel |
| Control | 2 pins per motor (PWM + direction) |
| Protection | Built-in overcurrent, flyback diodes |
GPIO 13 (PWM) --> +--------+ --> Left Motor (+)
GPIO 12 (DIR) --> | AM1016A| --> Left Motor (-)
+--------+
GPIO 10 (PWM) --> +--------+ --> Right Motor (+)
GPIO 11 (DIR) --> | AM1016A| --> Right Motor (-)
+--------+
Battery (3.7V LiPo)
Signal vs Power: The Universal Pattern
- MCU provides SIGNAL — direction, duty cycle (milliamps, 3.3V)
- H-Bridge provides POWER — battery current at motor voltage (hundreds of mA)
| Actuator | Signal | Power Stage | Power Source |
|---|---|---|---|
| DC Motor | GPIO + PWM | H-bridge (AM1016A) | Battery |
| Relay | GPIO | Relay coil driver | Supply rail |
| Solenoid | GPIO | MOSFET switch | Supply rail |
| Servo | PWM signal | Built-in driver | Servo power rail |
| Stepper | GPIO × 4 | Stepper driver IC | 12-48V supply |
This pattern appears in every embedded system with actuators. The specific driver IC changes; the architecture does not.
Motor Deadband
Below a certain PWM duty cycle, the motor does not move. You found this in Task 4.
| PWM Duty | What Happens | Why |
|---|---|---|
| 0–20% | Nothing | Average torque < static friction |
| ~25% | Starts to twitch | Peak torque barely exceeds friction |
| 30%+ | Runs | Average torque > kinetic friction |
At stall (not moving): no back-EMF → current is HIGH per pulse. But at low duty the ON time is too short → not enough average torque.
What PWM Actually Controls
PWM controls torque, not speed. Think of it like a car's gas pedal:
| Gas Pedal (car) | PWM Duty (motor) | |
|---|---|---|
| Controls | Engine torque | Motor torque (current) |
| Achieves | Speed (indirectly) | Speed (indirectly, via torque > friction) |
| Startup | Press harder to get moving | Higher duty to overcome static friction |
| Cruising | Ease off once at speed | Could reduce duty — back-EMF helps maintain speed |
| Feedback | Speedometer → driver adjusts | None (no encoder) → open-loop guess |
To start a motor reliably: pulse high PWM briefly (overcome friction), then reduce to cruising duty. Without feedback (encoder/tachometer) you can't know the actual speed — you're controlling torque and hoping speed follows.
With encoders (future upgrade), you could measure actual RPM and close the loop: "maintain 200 RPM" instead of "apply 80 PWM and hope." Same as cruise control — the car measures speed and adjusts throttle automatically.
The deadband is different for each motor, surface, and battery level. That's why you measured it in Task 4 — and why it shifts with PWM frequency (Task 5).
Why PWM Works for Motors
The motor coil has inductance -- it resists changes in current:

- During HIGH phase: current flows through the H-bridge into the motor, torque is applied
- During LOW phase: inductance keeps current flowing through the flyback diode
- The coil "averages" the pulses into nearly steady current
Higher duty cycle = more average voltage = faster steady-state speed.
PWM Averaging in Action

The motor coil's inductance acts as a low-pass filter: it smooths the choppy PWM pulses into a nearly steady current. This is real electrical averaging -- unlike LED "brightness" which is just your eye being fooled.
Open-Loop Failure: Why Equal PWM Does Not Mean Straight
Same code, 5 runs. Each run produces a different "square":

Without measurement, you are guessing. Manufacturing tolerance, battery voltage, surface friction, temperature -- all shift the motor response. You proved this in lab: Task 8 data showed the motor balance shifting at different speeds.
The Differential Drive
Your robot has two independently driven wheels. Motion depends on speed difference:
| Left Motor | Right Motor | Result |
|---|---|---|
| Same speed | Same speed | Straight (in theory) |
| Faster | Slower | Curves RIGHT |
| Forward | Backward | Spins in place |

The only reliable solution: measure actual performance and correct in real-time.
Power System Awareness
Everything on the robot shares the same battery. When motors draw current, the voltage drops for everything else.
| State | Motor Current | Voltage | Effect |
|---|---|---|---|
| Idle | ~50 mA | 3.95V | Everything works fine |
| Moving | ~300 mA | 3.7V | Still OK |
| Pushing hard | ~500 mA | 3.4V | Sensors may glitch |
| Stalled | ~1 A | 2.8V | MCU may reset! |
Debugging hint: If your robot misbehaves when motors run, suspect power before software.
Section 7
Feedback Control Concepts
Bang-Bang Recap
You built this in the motor lab. Three possible outputs for a continuous input:
if line_position < 0:
turn_right() # FULL correction
elif line_position > 0:
turn_left() # FULL correction
else:
go_straight()
Same maximum correction for every error size = constant overshoot = oscillation.

P-Control: The Key Formula
- error = desired position minus measured position
- Kp = proportional gain (a constant you choose)
- correction = how much to adjust left/right motor speeds
Small error --> small correction --> gentle nudge
Large error --> large correction --> aggressive turn
Continuous. Proportional. No jumps.
P-Control Comparison

Same S-curve, three controllers at once: - Bang-bang (red): constant overshoot, jerky at curves - P-control Kp=15 (green): smooth but lags behind on sharp curves - P-control Kp=40 (yellow): tracks tightly, but too high → oscillation risk
The feedback loop: sensor reads position → compute error → multiply by Kp → adjust motors → repeat. The faster this cycle runs, the smoother the control.

Line Following with P-Control
The complete control loop in 5 key lines:
position = get_line_position(read_sensors())
error = 0 - position
correction = Kp * error
left_speed = base_speed + correction
right_speed = base_speed - correction
Wrap this in a non-blocking ticks_ms() loop (you already know how from the timing lab) and call set_motors(left_speed, right_speed). That is a complete P-controller.
Kp Tuning Visualized

| Kp | Response | Problem |
|---|---|---|
| Too low | Sluggish, never reaches target | Undershoot |
| Just right | Smooth tracking, small overshoot | Sweet spot |
| Too high | Oscillates wildly | Instability |
There is no universally "correct" Kp. It depends on motor speed, sensor spacing, and control loop frequency.
Section 8
IMU Introduction
The Problem Line Sensors Cannot Solve
Line sensors tell you where the line is directly underneath the robot.
They cannot tell you:
- What heading the robot is pointing
- What angle it has turned through
- How to execute a precise 90-degree turn
For line following, line sensors are enough. For navigation -- turning, mapping, returning -- you need to know orientation.
What Is an IMU?
IMU = Inertial Measurement Unit
Your robot has a BMI160 -- a 6-axis IMU connected via I2C at address 0x69:
| Axes | Sensor | Measures |
|---|---|---|
| 3 | Accelerometer | Linear acceleration (m/s²) |
| 3 | Gyroscope | Angular velocity (deg/s) |
For turning the robot, the gyroscope is the primary sensor.
Note the I2C connection -- the IMU shares the same SDA/SCL bus as the OLED. Two devices, same two wires, different addresses. This is I2C in action.
Rate, Not Angle — The Key Insight
The gyroscope tells you rotation RATE, not absolute angle.
This means the robot is currently turning at 45 degrees per second.
It does NOT mean the robot is at 45 degrees.
| Concept | Linear Motion | Rotation |
|---|---|---|
| Rate | Speedometer (km/h) | Gyroscope (deg/s) |
| Accumulated | Odometer (km) | Angle (degrees) |
| Relationship | distance = speed x time | angle = rate x time |
A gyroscope is a speedometer for rotation, not an odometer.
Euler Integration
The simplest way to convert angular velocity into angle:
angle = 0.0
last_time = time.ticks_ms()
while True:
now = time.ticks_ms()
dt = time.ticks_diff(now, last_time) / 1000.0 # seconds
last_time = now
gyro_z = read_gyro_z() # deg/s
angle += gyro_z * dt # degrees
Each iteration: read the rate, multiply by elapsed time, add to running total.
Gyro Bias + Calibration
Even when the robot is perfectly still, the gyroscope reads a small non-zero value (bias). Over time, this accumulates into drift:
| Time | Accumulated Error (0.3 deg/s bias) |
|---|---|
| 1 second | 0.3 degrees |
| 10 seconds | 3 degrees |
| 1 minute | 18 degrees |
Fix: Calibrate at startup while stationary:
def calibrate_gyro(samples=200):
total = 0.0
for _ in range(samples):
total += read_gyro_z()
time.sleep_ms(5)
return total / samples
gyro_bias = calibrate_gyro() # At startup (robot must be still!)
corrected_rate = read_gyro_z() - gyro_bias # During operation
For a 2-second turn, calibrated drift is negligible. For 5-minute navigation, it is catastrophic.
IMU Turn: Putting It Together
A precise 90-degree turn using gyroscope integration + P-control:
target_angle = 90.0
angle = 0.0
gyro_bias = calibrate_gyro()
while abs(angle) < target_angle:
dt = get_dt()
rate = read_gyro_z() - gyro_bias
angle += rate * dt
error = target_angle - abs(angle)
turn_speed = Kp * error
set_motors(turn_speed, -turn_speed) # Spin in place
robot.stop()
Same P-control formula from line following -- now applied to heading instead of line position. See src/picobot/lib/picobot/imu.py for the library implementation.
Section 9
Quick Checks + Bridge
Quick Check: I2C
How many bytes does oled.show() send over I2C?
--
512 bytes (128 x 32 pixels / 8 bits per byte) plus protocol overhead (address, register, ACK bits). At 400 kHz, this takes approximately 12 ms -- enough to consume your entire control loop budget if called every iteration.
Quick Check: PWM Hardware
What does the CPU do while PWM is running?
--
Nothing -- the PWM hardware runs independently. A counter + comparator in the RP2350 toggles the GPIO pin at exact timing. The CPU configured it once (freq() and duty_u16()) and moved on. This is why MicroPython can control motors -- the timing-critical work is in hardware, not software.
Quick Check: P-Control
Write the P-control formula. What happens when Kp is too high?
--
When Kp is too high, the correction overshoots the target. The robot swings past center, generates an error in the opposite direction, over-corrects again, and oscillates. This is the same instability as bang-bang control -- just from over-amplifying the error.
Quick Check: Blocking
Your control loop contains oled.show() and print(). What is the worst-case effect?
--
oled.show() blocks for ~12 ms (I2C transfer). print() blocks for 1-30 ms (serial). Combined worst case: 42+ ms of blocking -- your "100 Hz" loop drops to ~24 Hz. Your Kp tuning is invalid because the loop rate changed.
Fix: Update the display every 10th iteration. Remove print() from the control loop entirely.
Hands-On Next
This week: Lab 5 -- Line Following
What you will do: 1. Replace your motor lab's bang-bang with P-control -- smoother, faster, measurably better 2. Discover Kp experimentally -- try different values, observe, measure 3. Log P-control data and compare to your Task 7 bang-bang plot 4. Find the speed limit of P-control (higher than bang-bang!)
Tutorial: Line Following
Looking Ahead
Next week: Lab 6 -- IMU Turns
What you will do: 1. Calibrate gyroscope bias at startup 2. Use gyroscope integration for precise turn angles 3. Apply P-control to heading error (same formula, different sensor) 4. Combine line following with controlled turns
Lecture 4 (Week 7): State Machines & Abstraction
"Your code is getting complex -- flags, nested ifs, multiple behaviors. There is a better way."