How-To-Tutorials · October 9, 2025

How to Tune a Digital PID Controller for Buck Converter with STM32F4

how to tune a digital pid controller for buck converter with stm32f4

Digital PID Control for a Buck Converter on STM32F4

Your buck converter's output voltage is only as good as your control loop. Open-loop PWM is fine for an LED dimmer, but if you need a stable regulated voltage under varying loads, you need a closed-loop controller. The PID algorithm is the workhorse here — it's been around for over a century because it works. On an STM32F4, you've got hardware timers for precise PWM generation, a fast 12-bit ADC for voltage feedback, and a Cortex-M4 FPU for floating-point math without penalty. That's everything you need for a tight digital control loop.

This walkthrough focuses on getting the PID running and then actually tuning it so the transient response is usable — because a PID with bad gains is worse than no PID at all.

Prerequisites

  • Understand what P, I, and D terms do individually (if "integral windup" doesn't ring a bell, brush up first)
  • Comfortable writing C for STM32 using the HAL library
  • STM32CubeIDE v1.16+ installed
  • Know how a buck converter works at the circuit level
  • Have an oscilloscope — you cannot tune a control loop by guessing

Parts/Tools

  • STM32F4 development board (Nucleo-F446RE, Discovery, etc.)
  • Buck converter circuit with accessible gate driver input and output voltage sense point
  • Bench power supply for the converter input
  • Oscilloscope (at least 2 channels — one for output voltage, one for PWM)
  • Connecting wires
  • Load resistor or electronic load for testing transient response

Steps

  1. Wire Up the Buck Converter

    Connect the buck converter's gate driver input to the STM32F4's timer output pin. Run the converter's output through a resistor divider to bring it into the 0–3.3V range for the ADC. Double-check your divider math — feeding more than 3.3V into an STM32 ADC pin will damage it.

    Connect your load (resistor or electronic load) to the converter output. Hook up the oscilloscope: Channel 1 on the output voltage, Channel 2 on the PWM signal.

  2. Configure TIM and ADC in CubeMX

    Open STM32CubeIDE, create a new project for your F4 board, and configure the peripherals in the CubeMX view:

    • Timer: Enable a timer (e.g., TIM1 for advanced-control timers with complementary outputs, or TIM2 for general purpose) in PWM Generation mode. Calculate your prescaler and ARR values for the desired switching frequency. For a 100kHz switching frequency on a 168MHz timer clock: prescaler = 0, ARR = 1679.
    • ADC: Enable an ADC channel connected to your voltage feedback pin. Set the resolution to 12-bit. For best results, trigger the ADC conversion from the timer's update event — this synchronizes your sampling with the PWM cycle and avoids aliasing noise from the switching transients.

    Generate the initialization code.

  3. Write the PID Control Algorithm

    Here's a PID implementation with proper time scaling and anti-windup. The STM32F4's FPU handles the float math in single cycles, so there's no performance concern:

    
    typedef struct {
        float Kp;
        float Ki;
        float Kd;
        float dt;           // Sample period in seconds
        float integral;
        float prev_error;
        float out_min;
        float out_max;
    } PID_State;
    
    void PID_Init(PID_State *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->integral = 0.0f;
        pid->prev_error = 0.0f;
        pid->out_min = out_min;
        pid->out_max = out_max;
    }
    
    float PID_Update(PID_State *pid, float setpoint, float measured) {
        float error = setpoint - measured;
    
        pid->integral += error * pid->dt;
        // Anti-windup: clamp integral term
        float i_term = pid->Ki * pid->integral;
        if (i_term > pid->out_max) {
            i_term = pid->out_max;
            pid->integral = pid->out_max / pid->Ki;
        } else if (i_term < pid->out_min) {
            i_term = pid->out_min;
            pid->integral = pid->out_min / pid->Ki;
        }
    
        float d_term = pid->Kd * (error - pid->prev_error) / pid->dt;
        pid->prev_error = error;
    
        float output = (pid->Kp * error) + i_term + d_term;
    
        // Clamp final output
        if (output > pid->out_max) output = pid->out_max;
        if (output < pid->out_min) output = pid->out_min;
    
        return output;
    }
    

    Using a struct makes it easy to have multiple PID instances if you ever need to control more than one converter, and it keeps state out of global variables.

  4. Implement the Main Loop

    Read the ADC, run the PID, update the PWM compare register. For a first pass, polling is fine. Once it works, move the PID update into a timer interrupt for deterministic timing:

    
    PID_State voltage_pid;
    
    int main(void) {
        HAL_Init();
        SystemClock_Config();
        MX_TIM2_Init();
        MX_ADC1_Init();
    
        // 100kHz = 10us period, output range 0 to ARR
        PID_Init(&voltage_pid, 1.0f, 0.1f, 0.01f, 0.00001f, 0.0f, 1679.0f);
        HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
    
        float setpoint = 5.0f; // Desired output voltage
    
        while (1) {
            HAL_ADC_Start(&hadc1);
            HAL_ADC_PollForConversion(&hadc1, HAL_MAX_DELAY);
            uint32_t raw = HAL_ADC_GetValue(&hadc1);
            // Convert ADC reading to voltage (adjust for your divider)
            float measured = (raw / 4096.0f) * 3.3f * 2.0f;
    
            float duty = PID_Update(&voltage_pid, setpoint, measured);
            __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, (uint32_t)duty);
        }
    }
    

    That * 2.0f factor is for a 2:1 resistor divider. Change it to match your actual circuit.

  5. Tune the PID Parameters

    This is where the oscilloscope earns its keep. I typically use manual tuning for power converters since they're well-behaved enough that Ziegler-Nichols tends to be overly aggressive:

    • Start P-only: Set Ki = 0, Kd = 0. Increase Kp from something small (0.1) until the output responds quickly to setpoint changes. If it starts oscillating, you've gone too far — note that value and back off to about 50-60% of it.
    • Add I: Slowly increase Ki to eliminate the steady-state error (the gap between setpoint and actual output). Watch the step response on the scope. Too much Ki causes overshoot and a slow "ringing" oscillation. You want zero steady-state error with minimal overshoot.
    • Add D: Kd reduces overshoot and speeds up settling. Start very small. The derivative term amplifies high-frequency noise from the ADC, so there's a practical limit. If the PWM output starts looking noisy/jittery, you've added too much Kd.

    Test at multiple load points. A converter that's tuned at 50% load might oscillate at 10% load or have sluggish response at 100%. You need gains that work across your entire operating range. If you can't find a single set of gains that works everywhere, you might need gain scheduling or a different control topology.

Troubleshooting

  • Output voltage not stable / oscillating:

    Almost always a tuning issue. Reduce Kp first. If that doesn't help, check that your ADC conversion factor matches reality — measure the actual output with a multimeter and compare to what the code thinks the voltage is. A scaling error means the PID is chasing a phantom setpoint.

  • ADC readings are garbage:

    Verify the ADC pin configuration in CubeMX (analog mode, correct channel). Check your physical wiring. If readings are noisy, add a small capacitor (100nF) at the ADC input and consider averaging multiple samples. Also make sure nothing else is using that ADC pin as GPIO.

  • PWM output isn't doing anything:

    Did you call HAL_TIM_PWM_Start()? This is the most common oversight. Also verify the timer output pin is correctly mapped in CubeMX — some pins have alternate function remapping, and the wrong AF selection means no output even though the timer is running internally.

  • Massive overshoot on startup:

    Your integral term is winding up during the ramp from 0V to setpoint. Make sure anti-windup is implemented and the clamp values are reasonable. You can also add soft-start logic that gradually increases the setpoint from 0 to the target over a few hundred milliseconds.

Going Further

Once the basic loop works, the biggest improvement is moving the PID calculation into a timer interrupt so it executes at a fixed, deterministic rate. Polling in the main loop means your control bandwidth depends on whatever else the CPU is doing. For a production design, you'd also want input voltage feedforward (to reject supply variations before they hit the output), overcurrent protection, and possibly a switch to fixed-point math if you're targeting an M0/M3 core without an FPU. On the STM32F4's Cortex-M4F, float performance is excellent, so stick with floats unless you have a specific reason not to.