Advanced 8: Abstraction Layers
Prerequisites: State Machines | Paired tutorials: HW Abstraction + Architecture
Learning Objectives
By the end of this session you will be able to:
- Explain the concept of abstraction layers and why they exist in embedded systems
- Trace a function call from high-level Python through the library to hardware registers
- Compare the same operation at different abstraction levels (Python, C SDK, registers)
- Identify the trade-offs of each layer (convenience vs control, speed vs portability)
- Read a hardware datasheet to understand what a library function does internally
Overview
Every time you call robot.forward(80), six layers of abstraction execute between your Python line and the wheels turning. This module peels them apart.
You'll learn:
- How software layers translate intent ("go forward") into hardware actions (register writes)
- Why layered design makes code maintainable and portable
- How communication protocols (I2C, SPI) solve the pin-count problem
- The real cost of abstraction: Python vs C vs registers
1. The Layer Cake: From Python to Silicon (~10 min)
What Happens When You Call robot.forward(80)
Every time you write robot.forward(80), a chain of abstraction layers executes:
┌─────────────────────────────────────────────────────┐
│ 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 register │
├─────────────────────────────────────────────────────┤
│ Silicon PWM counter compares, toggles pin│
└─────────────────────────────────────────────────────┘
You write one line. Six layers execute. Each layer translates your intent into something closer to what the hardware understands.
Why Layers?
Each layer in the stack has a single job:
- Your Code — Express intent: "go forward at 80% speed"
- 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 that control peripheral blocks
- Silicon — Transistors toggling pins at the frequency you requested
Each layer only knows about the layer directly below it.
Your code does not know about PWM registers. The PWM register does not know about robots. This is the power of abstraction: each layer can change independently as long as its interface stays the same.
The Car Analogy
Think of driving a car:
┌──────────────────┐
│ You: "Turn left" │ ← Intent
├──────────────────┤
│ Steering wheel │ ← Interface
├──────────────────┤
│ Power steering │ ← Amplification
├──────────────────┤
│ Rack and pinion │ ← Mechanism
├──────────────────┤
│ Wheels turn │ ← Physical result
└──────────────────┘
You do not think about hydraulic fluid pressure when making a left turn. The layers handle it. But if the power steering fails, you need to understand the layers below to diagnose the problem.
Key Principle
Abstraction lets you use a system without understanding every layer. But to debug or design a system, you must be able to see through the layers when needed.
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 the application logic | Write a new driver, keep the same API |
| Test line-following algorithm | Need a physical robot every time | Mock the driver layer, test on your PC |
| Debug a motor issue | "Where is PWM configured?" — search everywhere | Look in the motor driver module |
Real Example from Your Robot
In picobot, the motor driver knows that Pin 10 is the left motor PWM and Pin 11 is the right motor PWM. Your state machine code never mentions pin numbers — it says robot.forward(80).
If we rewired the robot to use different pins, only the driver file changes. Your state machine code stays exactly the same.
That is abstraction working for you.
The Full Layer Stack
Here is the complete abstraction stack for your robot, from high-level intent down to silicon:
┌─────────────────────────────┐
│ Application Logic │ ← "Follow the line"
├─────────────────────────────┤
│ Robot API │ ← robot.forward(80)
├─────────────────────────────┤
│ Driver Layer │ ← Motor driver, sensor driver
├─────────────────────────────┤
│ Hardware Abstraction │ ← Pin, PWM, ADC, I2C classes
├─────────────────────────────┤
│ Hardware (MCU) │ ← Physical registers, silicon
└─────────────────────────────┘
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.
Why? If you change the motor driver chip, only the driver layer changes. The application logic does not know or care.
Good vs Bad Abstraction — Motor Driver Example
The difference between layered and non-layered code becomes obvious when you look at motor control:
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)
# Somewhere later...
dir_left.value(1)
pwm_left.duty_u16(52428) # what does 52428 mean?!
GOOD — Application code uses a driver:
# In motor_driver.py
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):
"""Set speed from -100 to +100"""
self.dir.value(0 if percent >= 0 else 1)
self.pwm.duty_u16(int(abs(percent) / 100 * 65535))
# In main.py — clean and readable
left_motor = MotorDriver(10, 12)
right_motor = MotorDriver(11, 13)
left_motor.set_speed(80) # 80% forward — clear!
In the "bad" version, the magic number 52428 means nothing to a reader. Pin numbers are scattered across the file. If you change the motor wiring, you must find and update every place that references those pins. In the "good" version, the driver encapsulates all hardware details, and the application code reads like plain English.
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
In embedded systems, these costs matter. A MicroPython function call takes microseconds; a direct register write takes nanoseconds. We will quantify this in the next section.
Warning
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. When timing matters, you must understand the layers.
Case Study: NeoPixel LEDs — Three Levels of Understanding
The NeoPixel LEDs on your robot illustrate abstraction levels perfectly. Here is the same operation seen from three different perspectives:
Level 1 — Library user:
You call one function. The LEDs turn red. You do not know or care how.
Level 2 — Understanding the protocol:
The WS2812 LED requires precise timing: each bit is a HIGH pulse (short = 0, long = 1). 24 bits per LED (8 green, 8 red, 8 blue), transmitted at 800 kHz. This means each bit takes 1.25 us.
To send a "1" bit: To send a "0" bit:
┌────────┐ ┌───┐
│ │ │ │
───┘ └─────── ────┘ └────────────
│← 700ns→│←600ns→│ │350│←── 800ns ─→│
Level 3 — The abstraction trade-off:
| Approach | Result | Why |
|---|---|---|
| Bit-bang in Python? | Too slow | 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.
From Library User to Driver Author
This progression — use it, understand it, build it — is the journey from beginner to embedded engineer:
- Use the library — get things working quickly
- Read the source — understand what the library does
- Write your own — when the library does not fit your needs
You are at step 2 in this course. Step 3 comes with experience.
2. Communication Protocols — How Devices Talk (~10 min)
The Pin Problem
Your robot has many devices: 4 line sensors, an OLED display, an IMU (accelerometer + gyroscope), motors, LEDs, a buzzer, an ultrasonic sensor.
If every device needed its own dedicated GPIO pins, you would run out of pins fast:
4 line sensors × 1 pin each = 4 pins (ADC)
2 motors × 2 pins each = 4 pins (PWM + direction)
1 ultrasonic × 2 pins = 2 pins (trigger + echo)
1 buzzer × 1 pin = 1 pin (PWM)
NeoPixel LEDs × 1 pin = 1 pin (PIO)
OLED display × ??? pins = 8+ pins if parallel!
IMU sensor × ??? pins = many pins if parallel!
─────────────────────────────────
Already 20+ pins, and that's a simple robot.
The solution: shared communication buses. Instead of dedicated wires for each device, multiple devices share the same 2-4 wires and take turns communicating.
Protocol Comparison
| Protocol | Wires | Speed | Best For | On Your Robot |
|---|---|---|---|---|
| GPIO | 1 per signal | — | Buttons, simple LEDs | Motor direction, buzzer |
| UART | 2 (TX/RX) | Medium | Debug console, PC comms | USB serial |
| I2C | 2 (SDA/SCL) | Medium | Multiple sensors, displays | OLED + IMU |
| SPI | 4+ (MOSI/MISO/SCK/CS) | High | Fast sensors, SD cards | (not used on this robot) |
Why So Many Protocols?
Each protocol makes a different trade-off between pin count, speed, and complexity:
- GPIO is simplest: one wire, one signal. But it does not scale.
- UART connects two devices point-to-point. Good for debug output.
- I2C lets many devices share two wires. Slower, but pin-efficient.
- SPI is fast but needs an extra chip-select wire per device.
There is no "best" protocol — only the best fit for your constraints.
I2C: Your Robot's Shared Bus
Your robot uses I2C for both the OLED display and the IMU sensor. Two devices, two wires:
SDA (data) ────────┬──────────┐
SCL (clock) ───────┼────────┐ │
│ │ │
┌────────┴───┐ ┌─┴─┴────────┐
│ OLED │ │ IMU │
│ addr: 0x3C │ │ addr: 0x68 │
└────────────┘ └─────────────┘
How it works:
- The Pico (master) sends the address of the device it wants to talk to
- Only the device with that address responds
- Data flows on the shared SDA line, clocked by SCL
- When the conversation ends, the bus is free for the next device
Each device has a unique address:
- OLED display:
0x3C - IMU (gyro/accelerometer):
0x68
You can discover connected devices by scanning the bus:
from machine import I2C, Pin
i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400000)
devices = i2c.scan()
print("Found devices at:", [hex(addr) for addr in devices])
# Output: Found devices at: ['0x3c', '0x68']
Debugging I2C
If i2c.scan() returns an empty list, check:
- Are SDA and SCL connected to the right pins?
- Are pull-up resistors present? (I2C requires them)
- Is the device powered?
This is often the first thing to check when a sensor or display "does not work."
I2C Is Also Layered
Notice how I2C fits into the abstraction model:
Your code: oled.text("Hello", 0, 0)
Library: Send framebuffer bytes via I2C
MicroPython: i2c.writeto(0x3C, data)
Hardware: SDA/SCL signals on the wire
Device: OLED controller updates pixels
The same layered pattern appears everywhere.
Python vs C: GPIO & PWM from Registers (10 min)
Now let's make the abstraction layers concrete. We will look at the same operation — turning on an LED and running a PWM motor — at three different levels: Python, C SDK, and raw register writes.
GPIO — Turn On an LED
Python (highest abstraction)
Three concepts compressed into two lines: pin selection, direction, and output value.
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
The C SDK makes each step visible. You initialize the pin, set its direction, then set its value. Nothing is hidden.
Register Level (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; // GPIO_FUNC_SIO
// 2. Set pin 13 as output (write to GPIO_OE_SET register)
*(volatile uint32_t *)(SIO_BASE + 0x024) = (1 << 13);
// 3. Set pin 13 HIGH (write to GPIO_OUT_SET register)
*(volatile uint32_t *)(SIO_BASE + 0x014) = (1 << 13);
What Are These Magic Numbers?
IO_BANK0_BASE, SIO_BASE, 0x06C, 0x024, 0x014 — these are memory-mapped register addresses defined in the RP2350 datasheet.
Every peripheral on the chip is controlled by writing specific values to specific memory addresses. The C SDK wraps these addresses in readable function names. MicroPython wraps the C SDK in Python objects.
The datasheet is the ground truth. Everything else is convenience.
PWM — Motor at 50% Duty Cycle
Python
from machine import Pin, PWM
motor = PWM(Pin(10))
motor.freq(1000)
motor.duty_u16(32768) # 50% duty cycle
Three lines: create PWM on a pin, set frequency, set duty cycle. Done.
C SDK
#include "hardware/pwm.h"
uint slice = pwm_gpio_to_slice_num(10); // Find which PWM slice owns pin 10
gpio_set_function(10, GPIO_FUNC_PWM); // Route pin 10 to PWM hardware
pwm_set_wrap(slice, 65535); // Set period (top value)
pwm_set_chan_level(slice, PWM_CHAN_A, 32768); // Set compare value — 50%
pwm_set_enabled(slice, true); // Start the PWM
Five explicit steps. You see concepts that Python hides: the PWM slice, the channel within that slice, the wrap value that defines the period, and the explicit enable.
What the 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. When the counter is below the compare value (32768), the pin is HIGH. When above, the pin is LOW. This produces a square wave — and that square wave drives your motor.
The Point: Layers Are Real
| Level | What You Write | What Happens | Approximate Time |
|---|---|---|---|
| Python | Pin(13).on() |
~100 instructions executed | 1--2 us |
| C SDK | gpio_put(13, 1) |
~5 instructions | ~30 ns |
| Register | Direct memory write | 1 instruction | ~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 systems, safety-critical code)
- Registers — rarely written by hand; understanding them helps you read datasheets and debug hardware issues
You do not need to write register-level code. But knowing that it exists — and that every Pin(13).on() eventually becomes a register write — makes you a better embedded engineer.
Think About This
Your line-following control loop reads 4 ADC channels, computes a position, calculates a correction, and updates 2 PWM outputs — all in Python.
If the loop runs at 100 Hz (10 ms period), and each Python hardware call takes ~2 us, how much of your 10 ms budget is spent on hardware access?
(Answer: roughly 20 us for ~10 calls = 0.2% of the budget. Python overhead is manageable at 100 Hz. At 10 kHz, it would not be.)
Hands-On: Inside the Box
Try these exercises to explore abstraction layers on your robot:
| Step | What You Do | What You Learn |
|---|---|---|
| 1 | Read the picobot source code |
How the library is structured in layers |
| 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 your direct code with picobot calls |
The value (and cost) of abstraction |
See also: HW Abstraction Tutorial and Software Architecture Tutorial for guided lab exercises.
Going Deeper
These reference pages expand on today's topics. They are not required for Lab 08, but will deepen your understanding.
Reference Material
- GPIO Basics — Pin modes, pull-ups, and electrical characteristics
- PWM Reference — Frequency, duty cycle, and PWM slices in detail
- Robot Electronics — Pin mapping and hardware schematic
- Software Architectures — Layered design, drivers, and HAL patterns
Key Takeaways
Remember These
- Abstraction = hiding complexity — each layer does one job and exposes a simple interface to the layer above
- Layers are real —
robot.forward(80)triggers six layers of execution down to silicon - Each layer only talks to its neighbor — your code talks to the library, the library talks to drivers, drivers talk to hardware
- Communication protocols share wires — I2C lets the OLED and IMU share just 2 pins
- Python vs C vs registers — same operation at different abstraction levels, trading convenience for speed
Quick Check
Understanding Check
1. Your teammate changes the motor pin wiring on the robot. In a well-layered design, how many files need to change?
Answer
One file — the motor driver module. The pin numbers are defined in the driver layer. Your application code (state machines, line following, obstacle avoidance) never references pin numbers directly, so it remains untouched.
2. The OLED display and IMU both use I2C. How does the Pico know which device to talk to?
Answer
Each I2C device has a unique address. The OLED is at 0x3C and the IMU is at 0x68. When the Pico sends data, it first transmits the target address on the bus. Only the device with that address responds.
3. Why does Pin(13).on() in Python take ~1-2 us while the equivalent register write takes ~7 ns?
Answer
Python is interpreted. Each line goes through the MicroPython bytecode interpreter, which performs type checking, object lookup, and multiple function calls before eventually executing the same register write that C does directly. The overhead is roughly 200x for simple hardware operations.
4. Name one benefit and one cost of abstraction layers.
Answer
Benefit: changes are localized — swapping a motor driver only affects one module, not the entire codebase. Cost: each layer adds execution time and memory usage. In embedded systems where microseconds and kilobytes matter, unnecessary layers can cause real problems.
Related Tutorials
- Hardware Abstraction — Exploring abstraction layers and direct hardware access
- Software Architecture — Code organization and design patterns