top of page

MicroZed Chronicles: Proportional Integral Derivative (PID) Controller

When I started my degree back in 1995, I remember sitting in one of the lecture rooms, listening to one of the lecturers outlining the possible specialisations we electronic engineers would get to select from at the midpoint of the second year. There were three paths available: pure electronic engineering, which is the path I selected; Electronics and Information Systems Engineering, which the majority took; and Electronics and Control Systems Engineering, which, as we all had to take a controls module in the first semester of the second year (which was rather challenging, the standard text was written by the Ken and Bill the leaders of the Control element), had only a handful of people selecting it.



However, despite not selecting control engineering as a specialisation, over the years as an FPGA engineer I have designed a number of control systems. Some of these have been used to control nuclear reactors, others to maintain thermal stability for space imaging.


There has been a range of algorithms implemented in these applications; however, one of the most commonly used is the Proportional Integral Derivative (PID) Controller, also sometimes called a Three-Term Controller.


Conceptually, the PID algorithm is straightforward: it will update its output to lower the difference between a desired setpoint and the measured value. This means we can use the PID algorithm for applications where we want to control something; from position, flight stabilisation, and industrial processes to temperature control and a wide range of other applications. This also means it will react to deviations (e.g. sudden actuator position change, etc.) to bring the output back to the setpoint.


How quickly it can react and how accurate its control is (e.g. overshoot, undershoot) depends upon the tuning of the PID. Tuning the PID can be an entire PhD in itself; there are several methods available (e.g. Ziegler-Nichols).


The PID algorithm achieves the desired setpoint by looking at the present error, this is the proportional measurement. The controller also looks at the accumulated past errors by summing all of the past errors, this is the integral measurement.


The controller also attempts to predict future errors based on the rate of change, this is the derivative measurement. The role of the derivative is to prevent large, rapid changes which would lead to overshoot and potentially result in instability.


The architecture of the PID can be seen below. Its architecture makes it pretty simple for implementation in programmable logic.

As the algorithm is mathematical, I used the VHDL IEEE Fixed package, as this allows us to easily perform the equations required.


The integration for the integral term is just an accumulation, while the differentiation required for the derivative term is a simple subtraction between the present and previous error.


We do need to add in a little protection to the PID, ensuring that it has maximum and minimum outputs. I also implemented a simple check for saturation on the integral term when the output is saturated.


All of which can be seen below:

library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use ieee.fixed_pkg.all;

entity pid_controller is
    generic (
        -- Fixed point format: (integer_bits-1 downto -fractional_bits)
        -- Example: sfixed(15 downto -16) gives 16.16 format
        g_INTEGER_BITS : integer := 16;
        g_FRAC_BITS    : integer := 16
    );
    port (
        i_clk         : in  std_logic;
        i_rst         : in  std_logic;
        i_enable      : in  std_logic;
        
        -- Input signals
        i_setpoint    : in  sfixed(g_INTEGER_BITS-1 downto -g_FRAC_BITS);
        i_feedback    : in  sfixed(g_INTEGER_BITS-1 downto -g_FRAC_BITS);
        
        -- PID gains
        i_kp          : in  sfixed(g_INTEGER_BITS-1 downto -g_FRAC_BITS);
        i_ki          : in  sfixed(g_INTEGER_BITS-1 downto -g_FRAC_BITS);
        i_kd          : in  sfixed(g_INTEGER_BITS-1 downto -g_FRAC_BITS);
        
        -- Output
        o_pid_output  : out sfixed(g_INTEGER_BITS-1 downto -g_FRAC_BITS);
        
        -- Optional saturation limits
        i_output_max  : in  sfixed(g_INTEGER_BITS-1 downto -g_FRAC_BITS);
        i_output_min  : in  sfixed(g_INTEGER_BITS-1 downto -g_FRAC_BITS);
        
        -- Status/debug outputs
        o_error_out   : out sfixed(g_INTEGER_BITS-1 downto -g_FRAC_BITS);
        o_p_term_out  : out sfixed(g_INTEGER_BITS-1 downto -g_FRAC_BITS);
        o_i_term_out  : out sfixed(g_INTEGER_BITS-1 downto -g_FRAC_BITS);
        o_d_term_out  : out sfixed(g_INTEGER_BITS-1 downto -g_FRAC_BITS)
    );
end entity pid_controller;

architecture behavioral of pid_controller is
    
    -- Internal signals
    signal s_error_current    : sfixed(g_INTEGER_BITS downto -
								g_FRAC_BITS);
    signal s_error_previous   : sfixed(g_INTEGER_BITS downto -				
								g_FRAC_BITS);
    signal s_error_derivative : sfixed(g_INTEGER_BITS downto -
								g_FRAC_BITS);
    
    -- PID terms
    signal s_p_term : sfixed((g_INTEGER_BITS*2) downto -g_FRAC_BITS*2);
    signal s_i_term : sfixed(g_INTEGER_BITS*3 downto -g_FRAC_BITS*3);
    signal s_d_term : sfixed((g_INTEGER_BITS*2) downto -g_FRAC_BITS*2);
    
    -- Integral accumulator (wider to prevent overflow)
    signal s_integral_sum : sfixed(g_INTEGER_BITS*2 downto -
								g_FRAC_BITS*2);
    
    -- Final PID output before saturation
    signal s_pid_sum : sfixed(g_INTEGER_BITS*3+2 downto -g_FRAC_BITS*3);
    signal s_pid_saturated : sfixed(g_INTEGER_BITS-1 downto -g_FRAC_BITS);
    
    -- Integral windup protection
    signal s_integral_enable : std_logic;
    -- Sequence operations
    signal s_enb_delay : std_logic_vector(4 downto 0);

begin
    
    process(i_clk, i_rst)
    begin
        
        if rising_edge(i_clk) then
            if i_rst = '1' then
                s_error_current <= (others => '0');
                s_error_previous <= (others => '0');
                s_integral_sum <= (others => '0');
                s_pid_saturated <= (others => '0');
                s_enb_delay <= (others => '0');
            else 
        
                --use simple shift register to time calculations correctly
                s_enb_delay <= s_enb_delay(s_enb_delay'high-1 downto 
						    s_enb_delay'low) & i_enable;
                
                if i_enable = '1' then
                    -- Store previous error for derivative calculation
                    s_error_previous <= s_error_current;
                    -- Calculate current error
                    s_error_current <= i_setpoint - i_feedback;
    
                end if;
                
                case s_enb_delay is
                 
                    when "00001" => 
                        -- Calculate derivative 
                        s_error_derivative <= resize( 
                                     s_error_current - s_error_previous,  
                                     s_error_derivative);
                
                    when "00010" => 
                        -- Update integral sum (with windup protection)
                        if s_integral_enable = '1' then
                            s_integral_sum <= resize(s_integral_sum +                   
                                         s_error_current, s_integral_sum);
                        end if;
                                            
                    when "00100"=>
                        -- Calculate PID terms
                        s_p_term <= i_kp * s_error_current;
                        s_i_term <= i_ki * s_integral_sum;
                        s_d_term <= i_kd * s_error_derivative;
                        
                    when "01000" => 
                        s_pid_sum <= (s_p_term) + (s_i_term) + (s_d_term);
                    
                    when "10000" =>  
                        -- Apply saturation limits
                    if s_pid_sum > resize(i_output_max, s_pid_sum) then
                            s_pid_saturated <= i_output_max;
                    elsif s_pid_sum < resize(i_output_min, s_pid_sum) then
                            s_pid_saturated <= i_output_min;
                        else
                            s_pid_saturated <= resize(s_pid_sum, 
											s_pid_saturated);
                        end if;
            
                    when others => null;
                end case;
           end if;
        end if;
    end process;
    
    -- Integral windup protection logic
    -- Disable integral accumulation when output is saturated and error would increase saturation
    s_integral_enable <= '1' when (
        (s_pid_sum <= resize(i_output_max, s_pid_sum) and s_pid_sum >= 			
				    resize(i_output_min, s_pid_sum)) or
        (s_pid_sum > resize(i_output_max, s_pid_sum) and
				   s_error_current < 0) or
        (s_pid_sum < resize(i_output_min, s_pid_sum) and 
				   s_error_current > 0)
    ) else '0';
    
    -- Output assignments
    o_pid_output <= s_pid_saturated;
    o_error_out <=  resize(s_error_current, o_error_out);
    o_p_term_out <= resize(s_p_term, o_p_term_out);
    o_i_term_out <= resize(s_i_term, o_i_term_out);
    o_d_term_out <= resize(s_d_term, o_d_term_out);
    
end architecture behavioral;

To ensure the values were calculated correctly, I used a simple shift register to phase the calculations. It provides a simpler approach than using an FSM to phase the calculation.


I also wrote a simple test bench and created a project in Vivado to simulate the design. This design targeted the Digilent Arty S7-50.


library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use ieee.fixed_pkg.all;

entity pid_testbench is
end entity pid_testbench;

architecture testbench of pid_testbench is
    
    constant g_CLK_PERIOD : time := 10 ns;
    
    -- Testbench signals
    signal s_clk : std_logic := '0';
    signal s_rst : std_logic := '1';
    signal s_enable : std_logic := '0';
    
    -- Fixed point signals (16.16 format)
    signal s_setpoint : sfixed(15 downto -16);
    signal s_feedback : sfixed(15 downto -16);
    signal s_kp, s_ki, s_kd : sfixed(15 downto -16);
    signal s_output_max, s_output_min : sfixed(15 downto -16);
    signal s_pid_output : sfixed(15 downto -16);
    signal s_error_out, s_p_term_out, s_i_term_out, s_d_term_out : sfixed(15 downto -16);
    
begin
    
    -- Clock generation
    s_clk <= not s_clk after g_CLK_PERIOD/2;
    
    -- DUT instantiation
    dut: entity work.pid_controller
        generic map (
            g_INTEGER_BITS => 16,
            g_FRAC_BITS => 16
        )
        port map (
            i_clk => s_clk,
            i_rst => s_rst,
            i_enable => s_enable,
            i_setpoint => s_setpoint,
            i_feedback => s_feedback,
            i_kp => s_kp,
            i_ki => s_ki,
            i_kd => s_kd,
            o_pid_output => s_pid_output,
            i_output_max => s_output_max,
            i_output_min => s_output_min,
            o_error_out => s_error_out,
            o_p_term_out => s_p_term_out,
            o_i_term_out => s_i_term_out,
            o_d_term_out => s_d_term_out
        );
    
    -- Test stimulus
    stimulus: process
    begin
        -- Initialize
        s_setpoint <= to_sfixed(100.0, s_setpoint);  -- Target value
        s_feedback <= to_sfixed(0.0, s_feedback);    -- Initial feedback
        s_kp <= to_sfixed(0.5, s_kp);                -- Proportional gain
        s_ki <= to_sfixed(0.1, s_ki);                -- Integral gain  
        s_kd <= to_sfixed(0.05, s_kd);               -- Derivative gain
        s_output_max <= to_sfixed(255.0, s_output_max);
        s_output_min <= to_sfixed(-255.0, s_output_min);
        
        
        wait for 100 ns;
        s_rst <= '0';
        s_enable <= '1';
        wait until rising_edge(s_clk); 
        s_enable <= '0';
        
       
        wait for 1000 ns;
        s_feedback <= to_sfixed(50.0, s_feedback);   
        s_enable <= '1';
        wait until rising_edge(s_clk); 
        s_enable <= '0';
        
        wait for 1000 ns;
        s_feedback <= to_sfixed(80.0, s_feedback);
                s_enable <= '1';
        wait until rising_edge(s_clk); 
        s_enable <= '0';
        
        wait for 1000 ns;
        s_feedback <= to_sfixed(95.0, s_feedback);
                s_enable <= '1';
        wait until rising_edge(s_clk); 
        s_enable <= '0';
        
        wait for 1000 ns;
        s_feedback <= to_sfixed(95.0, s_feedback);  -- Reached setpoint
                s_enable <= '1';
        wait until rising_edge(s_clk); 
        s_enable <= '0';
        
        -- Disturbance test
        wait for 1000 ns;
        s_feedback <= to_sfixed(100.0, s_feedback);  -- New setpoint
                s_enable <= '1';
        wait until rising_edge(s_clk); 
        s_enable <= '0';
        
       
        wait for 1000 ns;
        s_feedback <= to_sfixed(120.0, s_feedback);  -- New setpoint
                s_enable <= '1';
        wait until rising_edge(s_clk); 
        s_enable <= '0';
        
        wait for 1000 ns;
        s_feedback <= to_sfixed(110.0, s_feedback);  -- New setpoint
                s_enable <= '1';
        wait until rising_edge(s_clk); 
        s_enable <= '0'; 
        
        wait for 1000 ns;
        s_feedback <= to_sfixed(100.0, s_feedback);  -- New setpoint
                s_enable <= '1';
        wait until rising_edge(s_clk); 
        s_enable <= '0';

        wait for 2000 ns;
        report "simulation complete" severity failure;
        wait;
    end process;
    
end architecture testbench;

I also created a simple test bench to test the PID algorithm, along with creating a simple Excel model of how the module should perform for the given inputs and configurations.

The simulation provides the same results as the simple Excel model, which is always encouraging.



The code is available on my GitHub.


The PID is a useful algorithm to understand and to be able to implement in our FPGA, as it enables a much faster loop than what can often be achieved in software implementation.


UK FPGA Conference


FPGA Horizons - October 7th 2025 - THE FPGA Conference, find out more here.


Workshops and Webinars:


If you enjoyed the blog why not take a look at the free webinars, workshops and training courses we have created over the years. Highlights include:



Boards


Get an Adiuvo development board:


  • Adiuvo Embedded System Development board - Embedded System Development Board

  • Adiuvo Embedded System Tile - Low Risk way to add a FPGA to your design.


Embedded System Book   


Do you want to know more about designing embedded systems from scratch? Check out our book on creating embedded systems. This book will walk you through all the stages of requirements, architecture, component selection, schematics, layout, and FPGA / software design. We designed and manufactured the board at the heart of the book! The schematics and layout are available in Altium here   Learn more about the board (see previous blogs on Bring up, DDR validation, USB, Sensors) and view the schematics here.

bottom of page