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
- 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)
| Layer | Purpose | Notes |
|---|---|---|
| Top (L1) | Signal routing + components | Short traces for high-speed signals |
| Inner 1 (L2) | Ground plane | Continuous, unbroken. Reference for all signals |
| Inner 2 (L3) | Power plane | Split planes for 3.3V, 5V, motor power |
| Bottom (L4) | Signal routing | Secondary signals, connectors |
EMC & Signal Integrity
- 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
- 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
| Level | Runs On | Speed | Coverage |
|---|---|---|---|
| Unit tests | Host PC (x86) | Milliseconds | Individual functions, algorithms |
| Integration tests | Target MCU | Seconds | Peripheral drivers, communication |
| HIL tests | MCU + simulated I/O | Minutes | Full system with emulated sensors |
| System tests | Real hardware | Variable | End-to-end with real sensors/actuators |
Debugging Tools
Oscilloscope & Logic Analyzer
- 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.
- 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.