MicroZed Chronicles: Proportional Integral Derivative (PID) Controller
- Adam Taylor
- 1 day ago
- 7 min read
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:
Upcoming Webinars Timing, RTL Creation, FPGA Math and Mixed Signal
Professional PYNQ Learn how to use PYNQ in your developments
Introduction to Vivado learn how to use AMD Vivado
Ultra96, MiniZed & ZU1 three day course looking at HW, SW and PetaLinux
Arty Z7-20 Class looking at HW, SW and PetaLinux
Mastering MicroBlaze learn how to create MicroBlaze solutions
HLS Hero Workshop learn how to create High Level Synthesis based solutions
Perfecting Petalinux learn how to create and work with PetaLinux OS
Boards
Get an Adiuvo development board:
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.