Back to Sensors & Actuators Series

Part 6: Actuator Control Systems

July 14, 2025 Wasil Zafar 50 min read

PWM generation, H-Bridge circuit design, PID control theory with tuning methods, and closed-loop feedback implementation on microcontrollers.

Table of Contents

  1. PWM Generation
  2. H-Bridge Circuits
  3. PID Control Theory
  4. Closed-Loop Feedback
  5. Conclusion & Next Steps

PWM Generation

PWM Fundamentals

Pulse Width Modulation (PWM) is the fundamental technique for controlling actuator power. By rapidly switching a signal on and off at a fixed frequency, the duty cycle (ratio of on-time to total period) controls the average voltage delivered to a load.

PWM Frequency Selection:
  • DC Motors: 20–25 kHz (above audible range to eliminate whine)
  • Servo Motors: 50 Hz (20 ms period, standard for hobby servos)
  • LED Dimming: 1–10 kHz (above flicker perception)
  • Stepper Drivers: 20–50 kHz (microstepping current control)
  • Power Supplies: 100 kHz–2 MHz (switching regulators)

STM32 Timer PWM

// STM32F4 Timer PWM — dual-channel motor control
#include "stm32f4xx.h"

// TIM1 CH1 (PA8) and CH2 (PA9) — 20kHz PWM for dual motor
void pwm_dual_init(void) {
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    RCC->APB2ENR |= RCC_APB2ENR_TIM1EN;

    // PA8, PA9 → AF1 (TIM1_CH1, TIM1_CH2)
    GPIOA->MODER  |= (2U << 16) | (2U << 18);
    GPIOA->AFR[1] |= (1U << 0)  | (1U << 4);

    // 168 MHz / (168 * 500) = 2 kHz? No — for 20 kHz:
    // 168 MHz / (8 * 1050) = 20 kHz
    TIM1->PSC  = 8 - 1;          // 21 MHz timer clock
    TIM1->ARR  = 1050 - 1;       // 20 kHz period
    TIM1->CCR1 = 0;              // CH1 duty = 0%
    TIM1->CCR2 = 0;              // CH2 duty = 0%

    // PWM Mode 1 on both channels
    TIM1->CCMR1 = (6U << 4) | TIM_CCMR1_OC1PE |
                  (6U << 12) | TIM_CCMR1_OC2PE;
    TIM1->CCER  = TIM_CCER_CC1E | TIM_CCER_CC2E;
    TIM1->BDTR  = TIM_BDTR_MOE;  // Main output enable (advanced timer)
    TIM1->CR1  |= TIM_CR1_CEN;
}

void pwm_set_duty(uint8_t channel, float duty) {
    if (duty < 0.0f) duty = 0.0f;
    if (duty > 100.0f) duty = 100.0f;
    uint32_t ccr = (uint32_t)(duty * (TIM1->ARR + 1) / 100.0f);

    if (channel == 1) TIM1->CCR1 = ccr;
    else if (channel == 2) TIM1->CCR2 = ccr;
}

Advanced PWM Modes

Advanced Timer PWM Modes

ModeDescriptionApplication
Center-AlignedCounter counts up then down; symmetric pulses reduce EMIMotor drives, inverters
ComplementaryCH1 and CH1N outputs, with dead-time insertionHalf/Full bridge, BLDC
One-PulseCounter stops after one periodStepper single-step
Encoder ModeTimer counts encoder pulses (CH1/CH2 as inputs)Position/speed feedback

H-Bridge Circuits

How H-Bridges Work

An H-Bridge is a circuit with four switches (transistors) arranged in an H pattern around a motor. By activating diagonal pairs, current flows through the motor in either direction, enabling bidirectional speed control.

H-Bridge Operating Modes:
  • Forward: Q1 + Q4 ON (high-side left + low-side right) — current flows left to right
  • Reverse: Q2 + Q3 ON (high-side right + low-side left) — current flows right to left
  • Brake (slow decay): Q3 + Q4 ON (both low-side) — motor terminals shorted, electromagnetic braking
  • Coast (fast decay): All OFF — motor freewheels to a stop
  • FORBIDDEN: Q1 + Q3 or Q2 + Q4 simultaneously — shoot-through short circuit!
H-Bridge Operating Modes
flowchart LR
    subgraph Forward
        F1["Q1 ON"] --- F2["Q4 ON"]
    end
    subgraph Reverse
        R1["Q2 ON"] --- R2["Q3 ON"]
    end
    subgraph Brake
        B1["Q3 ON"] --- B2["Q4 ON"]
    end
    subgraph Coast
        C1["All OFF"]
    end
    HB["H-Bridge
Controller"] --> Forward HB --> Reverse HB --> Brake HB --> Coast Forward -->|"Current →"| MF["Motor
Forward"] Reverse -->|"Current ←"| MR["Motor
Reverse"] Brake -->|"Shorted"| MS["Motor
Stops Quick"] Coast -->|"Freewheel"| MC["Motor
Coasts"] style HB fill:#3B9797,stroke:#3B9797,color:#fff style MF fill:#e8f4f4,stroke:#3B9797,color:#132440 style MR fill:#f0f4f8,stroke:#16476A,color:#132440 style MS fill:#fff5f5,stroke:#BF092F,color:#132440 style MC fill:#e8f4f4,stroke:#3B9797,color:#132440

Popular Driver ICs

Motor Driver IC Comparison

DriverChannelsCurrentVoltageFeatures
L298NDual H-Bridge2A / 3A peak5–46VClassic, high dropout (2V), needs heatsink
TB6612FNGDual H-Bridge1.2A / 3.2A peak2.5–13.5VMOSFET, low Rds(on), efficient
DRV8833Dual H-Bridge1.5A / 2A peak2.7–10.8VLow voltage, built-in current limit
DRV8871Single H-Bridge3.6A6.5–45VSimple 2-pin interface, current sense
BTS7960Single H-Bridge43A continuous5.5–27VHigh current, for large motors
// TB6612FNG dual motor driver control
#include "stm32f4xx.h"

typedef struct {
    GPIO_TypeDef *in1_port;
    uint16_t      in1_pin;
    GPIO_TypeDef *in2_port;
    uint16_t      in2_pin;
    volatile uint32_t *pwm_ccr;
} Motor_t;

Motor_t motorA = { GPIOB, 0, GPIOB, 1, &TIM3->CCR1 };
Motor_t motorB = { GPIOB, 2, GPIOB, 3, &TIM3->CCR2 };

void motor_drive(Motor_t *m, int16_t speed) {
    // speed: -100 to +100
    if (speed > 0) {
        m->in1_port->BSRR = (1U << m->in1_pin);
        m->in2_port->BSRR = (1U << (m->in2_pin + 16));
    } else if (speed < 0) {
        m->in1_port->BSRR = (1U << (m->in1_pin + 16));
        m->in2_port->BSRR = (1U << m->in2_pin);
        speed = -speed;
    } else {
        // Brake: both LOW
        m->in1_port->BSRR = (1U << (m->in1_pin + 16));
        m->in2_port->BSRR = (1U << (m->in2_pin + 16));
    }
    // Set PWM duty (0-100 → 0-ARR)
    *m->pwm_ccr = (uint32_t)speed * (TIM3->ARR + 1) / 100;
}

PID Control Theory

The PID Equation

The PID (Proportional-Integral-Derivative) controller is the most widely used feedback control algorithm. It continuously calculates the error between a desired setpoint and the measured process variable, then computes a corrective output:

$$u(t) = K_p \cdot e(t) + K_i \int_0^t e(\tau) \, d\tau + K_d \frac{de(t)}{dt}$$

PID Components:
  • Proportional (Kp): Output proportional to current error. Large Kp = fast response but may oscillate. Too small = sluggish response
  • Integral (Ki): Accumulates past errors to eliminate steady-state offset. Too large = overshoot and oscillation (integral windup)
  • Derivative (Kd): Predicts future error by measuring rate of change. Damps oscillation but amplifies noise. Often filtered or disabled (PI control)
PID Feedback Control Loop
flowchart LR
    SP["Setpoint
r(t)"] --> SUM(("+
−")) SUM --> PID["PID
Controller"] PID --> PLANT["Plant
(Motor/Heater)"] PLANT --> OUT["Output
y(t)"] OUT --> SENSOR["Sensor
Feedback"] SENSOR -->|"Measured value"| SUM PID -.->|"Kp · e(t)"| PID PID -.->|"Ki · ∫e(t)"| PID PID -.->|"Kd · de/dt"| PID style SP fill:#3B9797,stroke:#3B9797,color:#fff style OUT fill:#132440,stroke:#132440,color:#fff style PID fill:#e8f4f4,stroke:#3B9797,color:#132440 style SENSOR fill:#f0f4f8,stroke:#16476A,color:#132440

Tuning Methods

PID Tuning Approaches

MethodApproachPros / Cons
Manual TuningSet Ki, Kd = 0. Increase Kp until oscillation. Add Ki. Add Kd to dampSimple but time-consuming
Ziegler-NicholsFind ultimate gain (Ku) and oscillation period (Tu). Calculate gains from tableClassic, aggressive response
Cohen-CoonStep response: measure delay (L) and time constant (T)Better for processes with dead time
Software Auto-TuneRelay feedback, frequency analysisAutomated, good starting point
PID Tuning Decision Tree
flowchart TD
    START["Observe System
Response"] --> Q1{"Oscillating?"} Q1 -->|"Yes, large"| A1["Reduce Kp"] Q1 -->|"No"| Q2{"Steady-state
error?"} A1 --> START Q2 -->|"Yes"| A2["Increase Ki
(slowly)"] Q2 -->|"No"| Q3{"Overshoot?"} A2 --> START Q3 -->|"Yes"| A3["Increase Kd
or reduce Kp"] Q3 -->|"No"| Q4{"Response
too slow?"} A3 --> START Q4 -->|"Yes"| A4["Increase Kp"] Q4 -->|"No"| DONE["✅ Tuned!"] A4 --> START style START fill:#3B9797,stroke:#3B9797,color:#fff style DONE fill:#132440,stroke:#132440,color:#fff style Q1 fill:#fff5f5,stroke:#BF092F,color:#132440 style Q2 fill:#fff5f5,stroke:#BF092F,color:#132440 style Q3 fill:#fff5f5,stroke:#BF092F,color:#132440 style Q4 fill:#fff5f5,stroke:#BF092F,color:#132440

Code Implementation

// PID controller implementation for embedded systems
#include <stdint.h>

typedef struct {
    float kp, ki, kd;
    float integral;
    float prev_error;
    float output_min, output_max;
    float integral_max;   // Anti-windup limit
    float dt;             // Sample time in seconds
} PID_Controller;

void pid_init(PID_Controller *pid, float kp, float ki, float kd,
              float dt, float out_min, float out_max) {
    pid->kp = kp;
    pid->ki = ki;
    pid->kd = kd;
    pid->dt = dt;
    pid->output_min = out_min;
    pid->output_max = out_max;
    pid->integral_max = out_max * 0.5f;  // Limit integral to 50% of output
    pid->integral = 0.0f;
    pid->prev_error = 0.0f;
}

float pid_compute(PID_Controller *pid, float setpoint, float measurement) {
    float error = setpoint - measurement;

    // Proportional term
    float p_term = pid->kp * error;

    // Integral term with anti-windup
    pid->integral += error * pid->dt;
    if (pid->integral > pid->integral_max)
        pid->integral = pid->integral_max;
    else if (pid->integral < -pid->integral_max)
        pid->integral = -pid->integral_max;
    float i_term = pid->ki * pid->integral;

    // Derivative term (on measurement to avoid setpoint kick)
    float derivative = (error - pid->prev_error) / pid->dt;
    float d_term = pid->kd * derivative;
    pid->prev_error = error;

    // Compute and clamp output
    float output = p_term + i_term + d_term;
    if (output > pid->output_max) output = pid->output_max;
    if (output < pid->output_min) output = pid->output_min;

    return output;
}

void pid_reset(PID_Controller *pid) {
    pid->integral = 0.0f;
    pid->prev_error = 0.0f;
}
#!/usr/bin/env python3
"""PID controller simulation with matplotlib"""

import numpy as np
import matplotlib.pyplot as plt

class PIDController:
    def __init__(self, kp, ki, kd, dt, output_limits=(-100, 100)):
        self.kp, self.ki, self.kd = kp, ki, kd
        self.dt = dt
        self.integral = 0.0
        self.prev_error = 0.0
        self.out_min, self.out_max = output_limits

    def compute(self, setpoint, measurement):
        error = setpoint - measurement
        self.integral += error * self.dt
        self.integral = np.clip(self.integral, self.out_min / self.ki if self.ki else 0,
                                 self.out_max / self.ki if self.ki else 0)
        derivative = (error - self.prev_error) / self.dt
        self.prev_error = error
        output = self.kp * error + self.ki * self.integral + self.kd * derivative
        return np.clip(output, self.out_min, self.out_max)

# Simulate DC motor speed control
dt = 0.01  # 100 Hz control loop
time = np.arange(0, 5, dt)
setpoint = np.where(time < 1.0, 0, 1000)  # Step to 1000 RPM at t=1

pid = PIDController(kp=0.5, ki=2.0, kd=0.01, dt=dt)
speed = 0.0
speeds = []

for sp in setpoint:
    output = pid.compute(sp, speed)
    # Simple motor model: speed += (output - friction * speed) * dt
    speed += (output * 10 - 0.1 * speed) * dt
    speeds.append(speed)

plt.figure(figsize=(10, 5))
plt.plot(time, setpoint, 'r--', label='Setpoint')
plt.plot(time, speeds, 'b-', label='Actual Speed')
plt.xlabel('Time (s)')
plt.ylabel('Speed (RPM)')
plt.title('PID Motor Speed Control Simulation')
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()

Closed-Loop Feedback

Encoder Feedback

Quadrature encoders provide two square wave signals (A and B) offset by 90°, enabling both position and direction detection. STM32 timers have a dedicated encoder mode that counts pulses automatically in hardware.

// STM32 Timer Encoder Mode for position feedback
#include "stm32f4xx.h"

// TIM2 encoder on PA0 (CH1) and PA1 (CH2)
void encoder_init(void) {
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;

    // PA0, PA1 → AF1 (TIM2)
    GPIOA->MODER  |= (2U << 0) | (2U << 2);
    GPIOA->AFR[0] |= (1U << 0) | (1U << 4);
    GPIOA->PUPDR  |= (1U << 0) | (1U << 2);  // Pull-up

    // Encoder mode 3: count on both TI1 and TI2 edges
    TIM2->SMCR = 3;                           // SMS = 011
    TIM2->CCMR1 = (1U << 0) | (1U << 8);     // CC1S = TI1, CC2S = TI2
    TIM2->CCER  = 0;                          // Non-inverted
    TIM2->ARR   = 0xFFFFFFFF;                 // 32-bit counter
    TIM2->CNT   = 0;
    TIM2->CR1  |= TIM_CR1_CEN;
}

int32_t encoder_get_position(void) {
    return (int32_t)TIM2->CNT;
}

// Calculate speed (call at fixed interval, e.g., 10ms)
static int32_t last_count = 0;
float encoder_get_speed_rpm(uint16_t ppr, float dt_sec) {
    int32_t current = (int32_t)TIM2->CNT;
    int32_t delta = current - last_count;
    last_count = current;
    // Quadrature encoder: 4 counts per pulse
    return (float)delta / (4.0f * ppr) / dt_sec * 60.0f;
}

Current Sensing

Current sensing is essential for torque control, overcurrent protection, and sensorless motor control. Common approaches:

Current Sensing Methods:
  • Shunt Resistor: Low-value resistor (10–100 mΩ) in series. Voltage drop = I × R. Requires amplification (INA180, INA219)
  • Hall-Effect: Non-contact, ACS712/ACS758. Electrical isolation. ±5A to ±200A
  • Current Transformer: AC only, high-frequency. Toroidal core around wire
  • Driver IC Built-in: DRV8871, DRV8301 have integrated current sense outputs

Conclusion & Next Steps

Actuator control combines PWM power delivery with feedback-driven algorithms to achieve precise motion. The PID controller remains the workhorse of embedded control, and proper H-Bridge selection ensures efficient power delivery to motors.

Key Takeaways:
  • Choose PWM frequency based on actuator type (20+ kHz for DC motors, 50 Hz for servos)
  • Select H-Bridge driver ICs based on voltage, current, and efficiency requirements
  • PID anti-windup clamping prevents integral accumulation during saturation
  • Hardware encoder mode on STM32 timers provides zero-CPU-cost position counting
  • Current sensing enables torque control and overcurrent protection

In Part 7, we cover System Integration — combining sensors and actuators into complete systems with RTOS, state machines, and communication protocols.