How-To-Tutorials · September 22, 2025

How to Implement a PID Controller for DC Motor Speed Control with STM32

PID-Controlled DC Motor Speed Regulation on STM32

PID control is one of those things that sounds intimidating until you actually implement it — then it's just three multiplications and some bookkeeping. The hard part isn't the math; it's tuning the gains and getting your feedback signal right.

This project uses an STM32 microcontroller to drive a DC motor via PWM, with a PID loop adjusting the duty cycle to maintain a target speed. For speed feedback, we'll measure the frequency of pulses from an encoder (or, in a simpler setup, an optical sensor counting rotations). The LED blink rate in this project serves as a visual indicator of the measured speed so you can eyeball whether the controller is doing its job.

Prerequisites

  • Basic understanding of PID control theory (know what proportional, integral, and derivative terms do)
  • Familiarity with STM32 peripheral configuration (timers, GPIO, PWM)
  • STM32 development board — any F4 or F1 series works (Nucleo boards are great for this)
  • STM32CubeIDE v1.16+ installed and working

Parts and Tools

  • STM32 development board (e.g., Nucleo-F446RE or STM32F407 Discovery)
  • Small DC motor (3-12V, depending on your power supply)
  • Motor driver — L298N H-bridge module or a simple MOSFET driver circuit
  • Rotary encoder or optical sensor for speed feedback
  • LED and appropriate current-limiting resistor (220-470 ohm) for visual feedback
  • External power supply for the motor (don't power motors from the dev board's regulator)
  • Jumper wires and breadboard
  • Computer with STM32CubeIDE

Steps

  1. Wire Up the Hardware

    Connect the motor to the motor driver, and the motor driver's input pin to a PWM-capable GPIO on the STM32 (e.g., PA8, which maps to TIM1_CH1 on most F4 boards).

    Connect the encoder or optical sensor output to another GPIO configured as an input with interrupt capability. We'll count pulses to measure speed.

    Hook up an LED to a separate GPIO (e.g., PB0) through a resistor. This LED will blink at a rate proportional to the measured motor speed — a quick sanity check that your feedback loop is reading real data.

    Watch out: always use a separate power supply for the motor, connected through the motor driver. DC motors create voltage spikes and noise that will reset your STM32 or corrupt ADC readings if you share power rails. Connect the grounds together, but keep the power separate.

  2. Set Up Your STM32CubeIDE Project

    Create a new STM32 project targeting your board. In the CubeMX pin configurator:

    • Configure TIM1 (or whichever timer maps to your chosen PWM pin) in PWM Generation mode on Channel 1
    • Set the timer prescaler and period to get a PWM frequency around 20kHz — high enough to be inaudible but within the motor driver's switching capability
    • Configure TIM2 as a basic timer with an interrupt for periodic PID computation (1ms period is a good starting point)
    • Set up the encoder input GPIO with EXTI (external interrupt) on the rising edge
    • Configure the LED GPIO as push-pull output

    Generate the code and open the project.

  3. Implement the PID Controller

    Here's the core PID logic. I like to keep it in its own function so it's easy to test and tune:

    #define PWM_MAX 999   // Matches your timer ARR value
    #define PWM_MIN 0
    
    typedef struct {
        float Kp;
        float Ki;
        float Kd;
        float setpoint;
        float integral;
        float prev_error;
    } PID_Controller;
    
    void PID_Init(PID_Controller *pid, float kp, float ki, float kd, float setpoint) {
        pid->Kp = kp;
        pid->Ki = ki;
        pid->Kd = kd;
        pid->setpoint = setpoint;
        pid->integral = 0.0f;
        pid->prev_error = 0.0f;
    }
    
    int PID_Compute(PID_Controller *pid, float measured) {
        float error = pid->setpoint - measured;
    
        // Accumulate integral with anti-windup clamping
        pid->integral += error;
        if (pid->integral > 1000.0f) pid->integral = 1000.0f;
        if (pid->integral < -1000.0f) pid->integral = -1000.0f;
    
        float derivative = error - pid->prev_error;
        pid->prev_error = error;
    
        float output = (pid->Kp * error)
                     + (pid->Ki * pid->integral)
                     + (pid->Kd * derivative);
    
        // Clamp output to valid PWM range
        if (output > PWM_MAX) output = PWM_MAX;
        if (output < PWM_MIN) output = PWM_MIN;
    
        return (int)output;
    }

    The anti-windup clamping on the integral term is easy to forget, but without it your motor will overshoot wildly after any sustained error (like startup). Always clamp it.

  4. Implement Speed Measurement and the Control Loop

    Use an interrupt to count encoder pulses, and a timer callback to periodically compute speed and run the PID:

    volatile uint32_t pulse_count = 0;
    PID_Controller motor_pid;
    
    // Called on each encoder pulse (EXTI interrupt)
    void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
        if (GPIO_Pin == GPIO_PIN_4) { // Encoder input on PA4
            pulse_count++;
        }
    }
    
    // Called every 100ms by TIM2 interrupt
    void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
        if (htim->Instance == TIM2) {
            // Calculate speed as pulses per 100ms
            float measured_speed = (float)pulse_count;
            pulse_count = 0;
    
            // Run PID
            int pwm_value = PID_Compute(&motor_pid, measured_speed);
            __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, pwm_value);
    
            // Blink LED proportional to speed
            static uint32_t led_counter = 0;
            led_counter++;
            if (led_counter >= (uint32_t)(500.0f / (measured_speed + 1.0f))) {
                HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0);
                led_counter = 0;
            }
        }
    }

    The +1.0f in the LED divider prevents a divide-by-zero when the motor is stopped.

  5. Initialize Everything in main()

    In your main() function, after the auto-generated HAL init code:

    PID_Init(&motor_pid, 2.0f, 0.5f, 0.1f, 50.0f); // Target: 50 pulses per 100ms
    
    HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);
    HAL_TIM_Base_Start_IT(&htim2);
    
    while (1) {
        // PID runs in the timer interrupt
        // Main loop is free for other tasks
    }

    The Kp, Ki, Kd values here are starting points — you will need to tune them for your specific motor and load. Start with Ki and Kd at zero, increase Kp until the motor responds but oscillates, then add Ki to eliminate steady-state error, and finally add a small Kd to dampen the oscillations.

  6. Build, Flash, and Tune

    Build the project in STM32CubeIDE and flash it to your board. The motor should start spinning and the LED should blink.

    Tuning tips:

    • If the motor oscillates back and forth, reduce Kp
    • If the motor never reaches the target speed, increase Ki (but not too much, or it will overshoot)
    • If the motor overshoots then slowly settles, increase Kd slightly
    • Use the SWO/ITM trace or UART to print the measured speed and PWM output — tuning blind is painful

Troubleshooting

  • Motor doesn't spin: Check power supply connections first. Then verify the PWM output with an oscilloscope or logic analyzer. A common gotcha is forgetting to call HAL_TIM_PWM_Start().
  • LED doesn't blink: Confirm the GPIO pin and port are correct. Check that TIM2 interrupt is enabled in CubeMX and that HAL_TIM_Base_Start_IT() is called.
  • PWM output looks wrong: Recheck your timer prescaler and ARR (auto-reload register) values. A quick formula: PWM frequency = timer clock / ((prescaler + 1) * (ARR + 1)).
  • PID is unstable (motor surges or oscillates): Zero out Ki and Kd, start with just Kp. If it's still unstable with a very small Kp, your feedback signal might be noisy — add a simple moving average filter on the pulse count. Also check that your PID sample rate (timer interrupt period) is consistent.
  • Integral windup: If the motor takes a long time to recover from large setpoint changes, your integral clamp values might be too high. Tighten them.

Taking It Further

Once you've got basic PID working, there's a lot of room to improve. Replace the simple pulse counting with a proper quadrature encoder for direction-aware speed sensing. Add a serial interface so you can change the setpoint and PID gains on the fly without reflashing. Implement a proper Ziegler-Nichols tuning procedure. Or port the whole thing to FreeRTOS (v11.x is the current kernel) with separate tasks for the control loop, the UI, and data logging. PID control is fundamental — once you've nailed it on a motor, the same principles apply to temperature control, position servos, balancing robots, and just about any closed-loop system you'll encounter in embedded work.