Back to Sensors & Actuators Series

Part 10: System Design & Architecture

July 14, 2025 Wasil Zafar 48 min read

PCB design for sensor systems, embedded software architecture patterns, comprehensive testing methodologies, and professional debugging tools and techniques.

Table of Contents

  1. PCB Design
  2. Software Architecture
  3. Testing Strategies
  4. Debugging Tools
  5. Conclusion & Next Steps

PCB Design for Sensor Systems

Printed Circuit Board (PCB) design is where your schematic becomes a physical reality. For sensor-actuator systems, the layout directly impacts measurement accuracy, noise immunity, and electromagnetic compatibility (EMC).

Schematic Best Practices

Schematic Organization:
  • Hierarchical sheets: Power, MCU, sensors, actuator drivers, communication interfaces, connectors
  • Decoupling capacitors: 100 nF ceramic per VCC pin + 10 µF bulk per power rail
  • ESD protection: TVS diodes on all external-facing connectors
  • Test points: Add TP pads on key signals (SPI CLK, I2C SDA/SCL, ADC input, PWM output)
  • Net labels: Use descriptive names (SENSOR_SPI_MOSI, MOTOR_PWM_CH1) not generic (GPIO_12)

Layout & Routing

PCB Layer Stack-Up (4-Layer Recommended)

LayerPurposeNotes
Top (L1)Signal routing + componentsShort traces for high-speed signals
Inner 1 (L2)Ground planeContinuous, unbroken. Reference for all signals
Inner 2 (L3)Power planeSplit planes for 3.3V, 5V, motor power
Bottom (L4)Signal routingSecondary signals, connectors

EMC & Signal Integrity

Critical EMC Rules for Sensor PCBs:
  • Star grounding: Separate analog, digital, and power grounds — connect at a single point near the ADC
  • Guard rings: Surround high-impedance analog inputs with driven guard traces
  • Decoupling placement: Caps as close as physically possible to VCC pins (within 3 mm)
  • Trace impedance: 50 Ω for single-ended, 90 Ω differential for high-speed signals
  • Motor isolation: Keep motor driver section physically separated from analog sensor section with ground moat
  • Ferrite beads: Place on power lines entering sensitive analog sections

Software Architecture

Hardware Abstraction Layer (HAL)

A well-designed HAL isolates hardware-specific code from application logic, enabling portability across MCU families and testability on host machines.

// hal_gpio.h — Platform-independent GPIO interface
#ifndef HAL_GPIO_H
#define HAL_GPIO_H

#include <stdint.h>
#include <stdbool.h>

typedef enum {
    GPIO_MODE_INPUT,
    GPIO_MODE_OUTPUT,
    GPIO_MODE_AF,
    GPIO_MODE_ANALOG
} gpio_mode_t;

typedef struct {
    uint8_t port;    // Port identifier (0=A, 1=B, etc.)
    uint8_t pin;     // Pin number (0-15)
} gpio_pin_t;

// Platform-implemented functions
void hal_gpio_init(gpio_pin_t pin, gpio_mode_t mode);
void hal_gpio_write(gpio_pin_t pin, bool state);
bool hal_gpio_read(gpio_pin_t pin);
void hal_gpio_toggle(gpio_pin_t pin);

#endif // HAL_GPIO_H
// hal_gpio_stm32.c — STM32-specific HAL implementation
#include "hal_gpio.h"
#include "stm32f4xx.h"

static GPIO_TypeDef* get_port(uint8_t port) {
    GPIO_TypeDef* ports[] = {GPIOA, GPIOB, GPIOC, GPIOD, GPIOE};
    return ports[port];
}

void hal_gpio_init(gpio_pin_t pin, gpio_mode_t mode) {
    GPIO_TypeDef *gpio = get_port(pin.port);

    // Enable clock — RCC_AHB1ENR bit corresponds to port index
    RCC->AHB1ENR |= (1U << pin.port);

    // Set mode
    gpio->MODER &= ~(3U << (pin.pin * 2));
    gpio->MODER |= ((uint32_t)mode << (pin.pin * 2));
}

void hal_gpio_write(gpio_pin_t pin, bool state) {
    GPIO_TypeDef *gpio = get_port(pin.port);
    if (state)
        gpio->BSRR = (1U << pin.pin);
    else
        gpio->BSRR = (1U << (pin.pin + 16));
}

bool hal_gpio_read(gpio_pin_t pin) {
    GPIO_TypeDef *gpio = get_port(pin.port);
    return (gpio->IDR & (1U << pin.pin)) != 0;
}

Driver Model

// sensor_driver.h — Generic sensor driver interface
#ifndef SENSOR_DRIVER_H
#define SENSOR_DRIVER_H

#include <stdint.h>

typedef enum {
    SENSOR_OK,
    SENSOR_ERROR_COMM,
    SENSOR_ERROR_TIMEOUT,
    SENSOR_ERROR_RANGE,
    SENSOR_NOT_READY
} sensor_status_t;

typedef struct sensor_driver {
    const char *name;
    sensor_status_t (*init)(void *config);
    sensor_status_t (*read)(float *value);
    sensor_status_t (*self_test)(void);
    void (*power_down)(void);
} sensor_driver_t;

#endif // SENSOR_DRIVER_H
// bme280_driver.c — Concrete sensor driver implementation
#include "sensor_driver.h"
#include "hal_i2c.h"

#define BME280_ADDR      0x76
#define BME280_CHIP_ID   0x60
#define BME280_REG_ID    0xD0
#define BME280_REG_CTRL  0xF4
#define BME280_REG_DATA  0xF7

static sensor_status_t bme280_init(void *config) {
    uint8_t id;
    if (hal_i2c_read_reg(BME280_ADDR, BME280_REG_ID, &id, 1) != 0)
        return SENSOR_ERROR_COMM;
    if (id != BME280_CHIP_ID)
        return SENSOR_ERROR_COMM;

    // Forced mode, oversampling x1
    uint8_t ctrl = 0x25;
    hal_i2c_write_reg(BME280_ADDR, BME280_REG_CTRL, &ctrl, 1);
    return SENSOR_OK;
}

static sensor_status_t bme280_read(float *value) {
    uint8_t data[3];
    if (hal_i2c_read_reg(BME280_ADDR, BME280_REG_DATA, data, 3) != 0)
        return SENSOR_ERROR_COMM;

    int32_t raw = ((int32_t)data[0] << 12) | ((int32_t)data[1] << 4) | (data[2] >> 4);
    *value = compensate_temperature(raw);  // Apply calibration
    return SENSOR_OK;
}

// Register driver instance
const sensor_driver_t bme280_driver = {
    .name       = "BME280",
    .init       = bme280_init,
    .read       = bme280_read,
    .self_test  = NULL,
    .power_down = NULL
};

Event-Driven Architecture

// Simple event queue for embedded systems
#include <stdint.h>
#include <stdbool.h>

typedef enum {
    EVT_NONE = 0,
    EVT_SENSOR_READY,
    EVT_BUTTON_PRESS,
    EVT_TIMER_EXPIRED,
    EVT_MOTOR_FAULT,
    EVT_DATA_RECEIVED,
    EVT_MAX
} event_type_t;

typedef struct {
    event_type_t type;
    uint32_t     data;
} event_t;

#define EVT_QUEUE_SIZE 16
static event_t evt_queue[EVT_QUEUE_SIZE];
static volatile uint8_t evt_head = 0;
static volatile uint8_t evt_tail = 0;

bool event_post(event_type_t type, uint32_t data) {
    uint8_t next = (evt_head + 1) % EVT_QUEUE_SIZE;
    if (next == evt_tail) return false;  // Queue full
    evt_queue[evt_head] = (event_t){type, data};
    evt_head = next;
    return true;
}

bool event_get(event_t *evt) {
    if (evt_tail == evt_head) return false;  // Queue empty
    *evt = evt_queue[evt_tail];
    evt_tail = (evt_tail + 1) % EVT_QUEUE_SIZE;
    return true;
}

// Event handler dispatch table
typedef void (*event_handler_t)(uint32_t data);
static event_handler_t handlers[EVT_MAX] = {0};

void event_register(event_type_t type, event_handler_t handler) {
    handlers[type] = handler;
}

// Main event loop
void event_loop(void) {
    event_t evt;
    while (1) {
        if (event_get(&evt) && handlers[evt.type]) {
            handlers[evt.type](evt.data);
        }
        __WFI();  // Sleep until next interrupt
    }
}

Testing Strategies

Unit Testing Embedded Code

Embedded Testing Frameworks:
  • Unity: Lightweight C test framework. Single header. Ideal for embedded
  • CppUTest: C/C++ framework with memory leak detection. xUnit style
  • CMock: Auto-generates mocks from header files. Pairs with Unity
  • Ceedling: Build system wrapping Unity + CMock. Manages test discovery
// test_kalman.c — Unit test for Kalman filter (runs on host)
#include "unity.h"
#include "kalman.h"

void setUp(void) {}
void tearDown(void) {}

void test_kalman_converges_to_constant(void) {
    Kalman1D kf;
    kalman_init(&kf, 0.0f, 1.0f, 0.001f, 0.1f);

    // Feed constant measurement of 25.0
    float result = 0;
    for (int i = 0; i < 100; i++) {
        result = kalman_update(&kf, 25.0f);
    }

    // Should converge close to 25.0
    TEST_ASSERT_FLOAT_WITHIN(0.1f, 25.0f, result);
}

void test_kalman_rejects_spike(void) {
    Kalman1D kf;
    kalman_init(&kf, 25.0f, 0.01f, 0.001f, 0.1f);

    // One large spike should be mostly rejected
    float before = kf.x;
    float result = kalman_update(&kf, 100.0f);

    // Should not jump to 100 — spike is attenuated
    TEST_ASSERT_TRUE(result < 50.0f);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_kalman_converges_to_constant);
    RUN_TEST(test_kalman_rejects_spike);
    return UNITY_END();
}

Integration & Hardware-in-the-Loop Testing

Testing Pyramid for Embedded

LevelRuns OnSpeedCoverage
Unit testsHost PC (x86)MillisecondsIndividual functions, algorithms
Integration testsTarget MCUSecondsPeripheral drivers, communication
HIL testsMCU + simulated I/OMinutesFull system with emulated sensors
System testsReal hardwareVariableEnd-to-end with real sensors/actuators

Debugging Tools

Oscilloscope & Logic Analyzer

Essential Debugging Instruments:
  • Digital Oscilloscope: Measure analog signals, timing, PWM duty cycle, noise levels. Look for ringing, overshoot, ground bounce
  • Logic Analyzer: Decode digital protocols (SPI, I2C, UART, CAN). Saleae Logic 8 is the industry standard for embedded
  • Multimeter: Measure voltage levels, current draw, resistance. Use µA mode for power profiling
  • Current Probe: Measure dynamic current without breaking the circuit. Essential for low-power profiling
  • Protocol Analyzer: Dedicated USB, CAN, or Ethernet analyzers for complex protocol debugging

JTAG / SWD Debugging

# OpenOCD + GDB debugging session for STM32
# Terminal 1: Start OpenOCD server
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg

# Terminal 2: Connect GDB
arm-none-eabi-gdb build/firmware.elf
(gdb) target remote :3333
(gdb) monitor reset halt
(gdb) load
(gdb) break main
(gdb) continue

# Useful GDB commands for embedded debugging
(gdb) info registers           # View all CPU registers
(gdb) x/16xw 0x40020000       # Examine GPIO port A registers
(gdb) monitor mdw 0x40020000  # Read memory via OpenOCD
(gdb) watch *(uint32_t*)0x20000100  # Hardware watchpoint on variable

Conclusion & Next Steps

System design transforms a prototype into a production-ready embedded product. Good PCB design minimizes noise and interference, proper software architecture enables maintainability and testability, unit tests catch bugs before they reach hardware, and professional debugging tools accelerate the development cycle.

Key Takeaways:
  • 4-layer PCBs with dedicated ground planes are essential for mixed analog/digital sensor systems
  • HAL + driver model pattern enables portable, testable embedded firmware
  • Unit tests on the host PC catch 80% of bugs without touching hardware
  • Logic analyzers are indispensable for debugging SPI/I2C/UART communications
  • SWD + OpenOCD + GDB provides free, powerful on-chip debugging

In Part 11, we explore IoT & Connected Systems — connecting your sensor networks to the cloud with MQTT, AWS IoT, security best practices, and OTA firmware updates.