Skip to content

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

  1. Lab Recap
  2. System Architecture -- From Python to Electrons
  3. Bus Communication -- I2C Deep Dive
  4. Peripheral Architecture (RP2350 internals)
  5. Timing & Scheduling
  6. Motor Control Essentials
  7. Feedback Control Concepts
  8. IMU Introduction
  9. 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)?

center width:500


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

center width:600 bg right:35% w:90%

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)

bg right:25% w:90%

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

center width:550

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 UARTprint() 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 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.

center width:700

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


I2C Protocol Basics

center width:700

STARTAddress 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

center width:550

  • 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:

center width:700

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).

center width:450

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


UART Frame Format

center width:550

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

bg right:40% w:90%

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

center width:650

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.

center width:350

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:

  1. A counter counts from 0 up to some maximum value, then resets
  2. A comparator watches the counter and sets the output pin HIGH or LOW
  3. The frequency is set by how fast the counter wraps (clock speed ÷ max value)
  4. 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.

\[\text{Max FPS} = \frac{1000}{10} = 100 \text{ FPS (theoretical)}\]

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

# BLOCKING — everything waits
while True:
    read_sensors()
    time.sleep(0.01)  # Stuck here for 10 ms!
# 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

bg right:35% w:90%

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

bg right:45%  w:90%

Q1 + Q4 closed: current flows from V+ through Q1, through the motor (left → right), through Q4 to GND.

Motor spins forward.


H-Bridge: Reverse

bg right:45%  w:90%

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.

bg right:30% w:90%


Dead Time: Preventing Shoot-Through

bg right:35% w:90%

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

center width:750

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:

center width:600

  1. During HIGH phase: current flows through the H-bridge into the motor, torque is applied
  2. During LOW phase: inductance keeps current flowing through the flyback diode
  3. The coil "averages" the pulses into nearly steady current

Higher duty cycle = more average voltage = faster steady-state speed.


PWM Averaging in Action

center width:600

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":

center width:600

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

center width:700

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.

center width:600


P-Control: The Key Formula

\[\text{correction} = K_p \times \text{error}\]
  • 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

center width:800

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.

center width:500


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

center width:700

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.

Reading: +45.0 deg/s

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:

\[\theta_{new} = \theta_{old} + \omega \times \Delta t\]
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?

--

\[\text{correction} = K_p \times \text{error}\]

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."