Embedded Software Architecture — From MicroPython to AUTOSAR
How software is organized in embedded systems — from your MicroPython robot to professional automotive and Linux systems. Understanding these patterns helps you work with ANY embedded platform.
The Universal Pattern
Despite massive differences in complexity, ALL embedded architectures share this structure:
Application Logic ← "What the system does"
|
Middleware/OS ← "How tasks are scheduled"
|
HAL / Drivers ← "How to talk to hardware"
|
Hardware Regs ← "What the silicon provides"
|
Physical World ← "What we're controlling/sensing"
When you move from MicroPython to C to RTOS to Linux, the layers change but the pattern doesn't. Your P-control formula is the same. Your state machine is the same. Your I2C protocol is the same. Only the API for accessing them changes.
RP2350 Peripheral Bus
All architectures on the RP2350 talk to the same hardware through this bus structure:
The CPU cores access peripherals (PWM, I2C, SPI, etc.) through a shared bus. Each peripheral operates autonomously once configured — the CPU sets up registers, then the hardware runs independently.
Architecture 1: Your Robot (MicroPython + picobot)
| Layer | What | Example |
|---|---|---|
| Application | Your robot code | robot.line_follow(speed=80, kp=30) |
| Library | picobot package | motors.py, sensors.py, imu.py |
| Runtime | MicroPython interpreter | machine.PWM, machine.I2C, GC |
| Hardware | RP2350 peripherals | PWM slices, I2C controllers, GPIO mux |
Characteristics: - Fastest development cycle (REPL, no compilation) - ~50–100× slower than C for computation - Non-deterministic timing (garbage collector pauses) - Perfect for learning and prototyping
Architecture 2: Bare Metal C (RP2350 C SDK)
| Layer | What | Example |
|---|---|---|
| Application | Your C code | main.c: control loop, state machine |
| SDK / HAL | Pico SDK functions | pwm_set_wrap(), i2c_write_blocking() |
| Startup | CMSIS / vector table | Clock config, stack setup, NVIC |
| Registers | Memory-mapped I/O | *(uint32_t*)(0x40050004) = 999 |
Same PWM at three levels:
// SDK function (portable, safe):
pwm_set_wrap(slice, 999);
// SDK inline (slightly faster):
pwm_hw->slice[slice].top = 999;
// Direct register (fastest, chip-specific):
*(volatile uint32_t *)(0x40050000 + 0x10 * slice + 0x04) = 999;
Architecture 3: RTOS (FreeRTOS / Zephyr)
| Layer | What | Example |
|---|---|---|
| Tasks | Application threads | motor_task (1ms), sensor_task (10ms) |
| Kernel | Scheduler + synchronization | Preemptive scheduler, semaphores, queues |
| BSP | Board support package | Pin mappings, clock config |
| HAL | Hardware abstraction | Same as bare metal C SDK |
Key difference from your robot:
| MicroPython (cooperative) | RTOS (preemptive) | |
|---|---|---|
| If motor task takes too long | Everything waits | Scheduler interrupts it |
| Timing guarantee | Best effort | Provable worst-case |
| Task isolation | Shared globals | Separate stacks, queues |
// FreeRTOS: three tasks at different priorities
void motor_task(void *p) { while(1) { pid_step(); vTaskDelay(1); } } // 1ms, HIGH
void sensor_task(void *p) { while(1) { read_imu(); vTaskDelay(10); } } // 10ms, MEDIUM
void display_task(void *p) { while(1) { oled_update(); vTaskDelay(100);} }// 100ms, LOW
Architecture 4: Embedded Linux
| Layer | What | Example |
|---|---|---|
| Userspace | Applications, scripts | Python, C++, Qt GUI |
| System calls | Kernel interface | open(), read(), ioctl() |
| Kernel | Scheduler, memory, VFS | Linux kernel + device tree |
| Drivers | Hardware abstraction | i2c-bcm2835, spidev, pinctrl |
| Hardware | SoC peripherals | I2C, SPI, GPIO, DMA, MMU |
Same I2C read — Linux vs MicroPython:
# MicroPython (direct hardware access):
data = i2c.readfrom_mem(0x69, 0x0C, 6)
# Linux (through kernel driver):
fd = open("/dev/i2c-1", "rb+")
# ... ioctl to set address, write register, read data
Same bytes on the wire. Different software layers.
Linux in Embedded Systems Course
In the Linux in Embedded Systems course, you'll work with Raspberry Pi running Linux — writing device drivers, using sysfs, and understanding the kernel architecture.
Architecture 5: Automotive (AUTOSAR)
| Layer | What | Example |
|---|---|---|
| Application (SWC) | Software Components | Engine control, ABS, dashboard |
| RTE | Runtime Environment | Port-based communication |
| BSW Services | OS, Communication, Memory | OSEK OS, CAN stack, NvM |
| BSW ECU Abstraction | ECU-specific drivers | CAN driver, ADC driver |
| MCAL | Microcontroller Abstraction | Register-level drivers |
| Hardware | ECU + sensors + actuators | CAN controller, injectors |
Characteristics: - Standardized across ALL car manufacturers - Formally verified, safety-certified (ISO 26262) - Configuration-driven (XML tools generate C code) - WCET analysis required for every ISR
Comparison Table
| MicroPython | Bare Metal C | RTOS | Linux | AUTOSAR | |
|---|---|---|---|---|---|
| Layers | 4 | 3 | 4 | 5+ | 6+ |
| Language | Python | C | C/C++ | C/Python | C (generated) |
| Timing | Best effort | Deterministic | Hard real-time | Soft RT | Certified |
| Memory | GC managed | Manual | Pool/stack | Virtual memory | Static |
| Debugging | REPL, print | GDB, JTAG | RTOS-aware GDB | gdb, ftrace | Trace tools |
| Use case | Education, IoT | Sensors, simple | Drones, robots | Camera, UI | Cars, medical |
Useful Data Structures
Circular Buffer (Ring Buffer)
The most common embedded data structure — UART RX, sensor logs, DMA buffers:
class RingBuffer:
def __init__(self, size):
self.buf = [0] * size
self.size = size
self.head = 0 # Write position
self.tail = 0 # Read position
self.count = 0
def put(self, value):
if self.count < self.size:
self.buf[self.head] = value
self.head = (self.head + 1) % self.size
self.count += 1
def get(self):
if self.count > 0:
value = self.buf[self.tail]
self.tail = (self.tail + 1) % self.size
self.count -= 1
return value
Why circular? Fixed memory (safe in ISR), O(1) read/write, wraps automatically.
Lookup Table (LUT)
Pre-computed values for fast math — avoids expensive calculations in control loops:
import math
SIN_TABLE = [int(math.sin(math.radians(d)) * 1000) for d in range(360)]
# In control loop — instant lookup instead of 100µs sin() call:
sin_value = SIN_TABLE[angle_degrees] # ~1 µs
Fixed-Point Arithmetic
Fast integer math when you need fractions but can't afford floats:
# Fixed-point with 8 fractional bits (multiply by 256):
PI_FP = 804 # 3.14159 * 256 ≈ 804
radius_fp = 10 << 8 # 10.0 in fixed-point = 2560
result_fp = (PI_FP * radius_fp) >> 8 # = 31.4 (as integer 31)
Integer math is 10–100× faster than float on small MCUs.
Useful Algorithms
PID Controller
Your P-control extended with Integral (removes steady-state error) and Derivative (dampens overshoot):
class PID:
def __init__(self, kp, ki, kd, out_min, out_max):
self.kp, self.ki, self.kd = kp, ki, kd
self.out_min, self.out_max = out_min, out_max
self.integral = 0
self.prev_error = 0
def compute(self, error, dt):
self.integral += error * dt
derivative = (error - self.prev_error) / dt if dt > 0 else 0
self.prev_error = error
output = self.kp * error + self.ki * self.integral + self.kd * derivative
return max(self.out_min, min(self.out_max, output))
Debounce
Filters electrical noise on button/sensor inputs:
class Debounce:
def __init__(self, pin, stable_ms=50):
self.pin = pin
self.stable_ms = stable_ms
self.last_raw = pin.value()
self.stable = self.last_raw
self.last_change = time.ticks_ms()
def read(self):
raw = self.pin.value()
now = time.ticks_ms()
if raw != self.last_raw:
self.last_change = now
self.last_raw = raw
if time.ticks_diff(now, self.last_change) > self.stable_ms:
self.stable = raw
return self.stable
Moving Average Filter
Smooths noisy sensor readings:
class MovingAverage:
def __init__(self, size):
self.buf = [0] * size
self.idx = 0
self.total = 0
self.n = 0
def update(self, value):
self.total -= self.buf[self.idx]
self.buf[self.idx] = value
self.total += value
self.idx = (self.idx + 1) % len(self.buf)
self.n = min(self.n + 1, len(self.buf))
return self.total / self.n
Peripheral Access at Each Level
GPIO: Turn on an LED
| Level | Code |
|---|---|
| MicroPython | Pin(25, Pin.OUT).value(1) |
| C SDK | gpio_put(25, 1) |
| Register | *(uint32_t*)(0xd0000014) = (1<<25) |
| Linux sysfs | echo 1 > /sys/class/gpio/gpio25/value |
| AUTOSAR | Dio_WriteChannel(LED, STD_HIGH) |
I2C: Read IMU data
| Level | Code |
|---|---|
| MicroPython | i2c.readfrom_mem(0x69, 0x0C, 6) |
| C SDK | i2c_write_blocking(); i2c_read_blocking() |
| Linux | ioctl(fd, I2C_SLAVE, 0x69); read(fd, buf, 6) |
Same bytes on the wire. Same I2C protocol. Different wrappers.
Further Reading
- Interrupts Reference — ISR patterns, timing, professional context
- State Machine Reference — Implementation patterns
- Execution Models — Polling, interrupts, RTOS comparison
- RP2350 C SDK — Bare metal programming
- FreeRTOS Book — RTOS concepts
- AUTOSAR Standards — Automotive architecture
- Barr Group Coding Standard — Professional C practices