|
PID controller implementation without using integer arithmetic. Uses TI IQMath lib to replace floating point instructions.
|
Some of the content is created with AI support. I've marked the parts where AI was used.
All narrative is written by me.
Math and PID
Traditionally, the PID algorithm uses floating point math. For an MSPM0 controller, those are expensive runtime calculations. And they have to be done in each execution of the PID control loop.
TI MSPM0 SDK comes with the IQMath library. A library that lets you represent floating point numbers in an integer variable. And lets you calculate with them. I implemented the PID algorithm using IQMath variables and algorithms.
The lib is used to:
- declare floating point values to IQMath representation
- multiply IQMath variables
It's not used to add values. You can just the C traditional + operation on IQMath variables.
I also didn't use the lib to convert ADC (the PID feedback signal) and PWM (the PWM output signal) to IQMath. Because of their nature, there was an optimised way (shift left / right) to convert those.
The lib allows you to select the number of bits reserved for the fractional part. I used the default Q(24) format, where 24 bits of an int32_t are used for fractional.

source: Google's Gemini AI

image: real time capture, regulating to desired output of 70% of 3.3V. PID loop executes every 2 ms.
PID algorithm
I created a C++ class that implements the PID algorithm, and can hold the controller state..
#include <ti/iqmath/include/IQmathLib.h>
constexpr _iq out_max = _IQ(1.0); // Maximum allowable output (e.g., _IQ(1.0) for 100% PWM)
constexpr _iq out_min = _IQ(0.0); // Minimum allowable output (e.g., _IQ(0.0) for 0% PWM)
class pid {
public:
pid(_iq kp, _iq ki, _iq kd) : kp(kp), ki(ki), kd(kd) {}
void init(_iq kp, _iq ki, _iq kd, bool reset = true) {
kp = kp;
ki = ki;
kd = kd;
if (reset) {
reset_state();
}
}
_iq compute(_iq setpoint, _iq feedback) {
_iq error;
_iq p_term, i_term, d_term;
_iq output;
// 1. Calculate the current error (e = r - y)
error = setpoint - feedback;
// 2. Proportional Term: P = kp * error
p_term = _IQmpy(kp, error);
// 3. Integral Term with anti-windup: I = I_prev + (ki * error)
integral = integral + _IQmpy(ki, error);
// Anti-windup: Clamp the integral accumulator to prevent runway saturation
if (integral > out_max) {
integral = out_max;
} else if (integral < out_min) {
integral = out_min;
}
i_term = integral;
// 4. Derivative Term: D = kd * (error - prev_error)
d_term = _IQmpy(kd, error - previous_error);
// 5. Total Output: Control = P + I + D
output = p_term + i_term + d_term;
// 6. Save current error for the next execution cycle's derivative calculation
previous_error = error;
// 7. Final Output Saturation: Clamp the overall control signal
if (output > out_max) {
output = out_max;
} else if (output < out_min) {
output = out_min;
}
return output;
}
private:
_iq kp;
_iq ki;
_iq kd;
_iq integral = _IQ(0.0);
_iq previous_error = _IQ(0.0);
inline void reset_state() {
integral = _IQ(0.0);
previous_error = _IQ(0.0);
}
};
I used Google Gemini AI to suggest algorithm transition from floating point to IQMath Q(24). I reviewed and adapted where needed, and turned it into a class.

Highlights of the class:
- at initialisation, you set the PID Kp, Ki and Kd parameters. They are all in Q(24) format.
- they can be tweaked at runtime with the init() method
- the compute() method implements the algoritm using integer math only, no floating point.
- runtime state is kept in two Q(24) data members: integral and previous_error.
Performance
With the clock at 32 MHz, and GCC 15.2.1, optimisation set to max, the PID takes 140 µs. That includes:
- sample and convert value to IQMath
- run PID algorithm
- convert IQMath outcome to 16 bits unsigned and set PWM duty cycle
With the same toolchain, but optimisation off, for debugging, the loop takes 150 µs.
These values drive the minimum time for the hardware timer that drives the PID loop.
Other things to consider: there is no reason to loop faster than the output's PWM frequency, because setting duty cycle more than once per cycle has no effect.
With the settings of this project, where the PWM counter is 16 bits (65535), the maximum PWM is 488.2887006942855 Hz (when prescaler and divider are set to 1). So no use to set PID timer to less than 2.047 ms.
Google AI input:
Rule of thumb is to select a 10x to 20x lower PID loop frequency than the PWM.
Use Case
I reused my project PID temperature controller for the EasyL1105 MSPM0 board - Pt. 1: framework . And replaced the PID algorithm with this class.
The project tries to set an output voltage to a given set level.. It uses PWM to generate that voltage. ADC is used to measure that voltage.
At runtime, the PID algorithm constantly validates if the voltage matches the set level. If not, ith adjusts the PWM duty cycle to drive the voltage either up or down.

PID controller object creation:
pid controller(_IQ(0.1), _IQ(0.01), _IQ(0.05)); // Example PID gains for voltage control
Set the desired output (value between 0 and 1):
_iq target_command = _IQ(0.75);
The control loop:
while (1) {
if (perform) {
perform = false;
perform_adc();
perform_pid();
perform_pwm();
}
}
perform_adc() retrieves the current output, and translates it to a Q(24):
void perform_adc() {
DL_ADC12_startConversion(ADC12_0_INST);
while (false == checkADC) {
__WFE();
}
// Shift left by 12 bits (24 minus 12 equals 12)
feedback = ((int32_t)DL_ADC12_getMemResult(ADC12_0_INST, DL_ADC12_MEM_IDX_0)) << 12; // Direct to 0.0 - 1.0 per-unit in Q24!
checkADC = false;
DL_ADC12_enableConversions(ADC12_0_INST);
}
You can spot that I use an optimised shift to translate the 12-bits ADC to a Q(24).
I used Google Gemini AI to suggest the most performant way to translate the 12 bits to Q(24)
performe_pid() calls the algorithm:
void perform_pid() {
output_pid = controller.compute(target_command, feedback);
}
The PID takes the desired voltage, and the value the ADC reported. It then calculates the PMW duty cycle that 'll get us there.
perform_pwm() then alters the duty cycla, as suggested by the algorythm:
void perform_pwm() {
if (output_pid == pid::out_max) {
// fix case: (_iq(1.0) >> 8) == 65536, and that turns the duty cycle off
// Force a true 100% duty cycle by setting CC value to the LOAD value + 1
DL_TimerG_setCaptureCompareValue(PWM_0_INST, DL_TimerG_getLoadValue(PWM_0_INST) + 1, DL_TIMER_CC_0_INDEX);
} else {
// Convert Q24 per-unit (0.0 to 1.0 excluded) to raw timer counts (0 to 65535)
DL_TimerG_setCaptureCompareValue(PWM_0_INST, ((uint32_t)output_pid) >> 8, DL_TIMER_CC_0_INDEX); // update ccr0 value
}
}
Again, a shift was used to translate the Q(24) to the desired duty cycle value for PWM register.
I used Google Gemini AI to suggest the most performant way to translate the Q(24) to DL_TIMER_CC_0_INDEX
The complete main file:
#include "ti_msp_dl_config.h"
#include <ti/iqmath/include/IQmathLib.h>
volatile bool perform = false;
volatile bool checkADC;
_iq feedback;
_iq target_command = _IQ(0.80); // Example target command in Q24 per-unit (0.0 to 1.0)
_iq output_pid = _IQ(0.0);
import pid;
// const _iq k_adc = _IQ(0.000244140625); // ADC conversion factor for 12-bit resolution (1/4096)
pid::pid controller(_IQ(0.1), _IQ(0.01), _IQ(0.05)); // Example PID gains for voltage control
void perform_adc() {
DL_ADC12_startConversion(ADC12_0_INST);
while (false == checkADC) {
__WFE();
}
// Shift left by 12 bits (24 minus 12 equals 12)
feedback = _iq(DL_ADC12_getMemResult(ADC12_0_INST, DL_ADC12_MEM_IDX_0)) << 12; // Direct to 0.0 - 1.0 per-unit in Q24!
checkADC = false;
DL_ADC12_enableConversions(ADC12_0_INST);
}
void perform_pwm() {
if (output_pid == pid::out_max) {
// fix case: (_iq(1.0) >> 8) == 65536, and that turns the duty cycle off
// Force a true 100% duty cycle by setting CC value to the LOAD value + 1
DL_TimerG_setCaptureCompareValue(PWM_0_INST, DL_TimerG_getLoadValue(PWM_0_INST) + 1, DL_TIMER_CC_0_INDEX);
} else {
// Convert Q24 per-unit (0.0 to 1.0 excluded) to raw timer counts (0 to 65535)
DL_TimerG_setCaptureCompareValue(PWM_0_INST, output_pid >> 8, DL_TIMER_CC_0_INDEX); // update ccr0 value
}
}
void perform_pid() {
output_pid = controller.compute(target_command, feedback);
}
int main() {
SYSCFG_DL_init();
/* timer 5 interrupt ticks per second */
/* Enable Timer0 NVIC */
NVIC_EnableIRQ(TIMER_0_INST_INT_IRQN);
NVIC_EnableIRQ(ADC12_0_INST_INT_IRQN);
checkADC = false;
/* Start PWM */
DL_TimerG_startCounter(PWM_0_INST);
/* Start Timer counting */
DL_TimerG_startCounter(TIMER_0_INST);
while (1) {
if (perform) {
perform = false;
DL_GPIO_setPins(GPIO_GRP_LEDS_PORT,
GPIO_GRP_LEDS_PIN_LED_GREEN_PIN);
perform_adc();
perform_pid();
perform_pwm();
DL_GPIO_clearPins(GPIO_GRP_LEDS_PORT,
GPIO_GRP_LEDS_PIN_LED_GREEN_PIN);
}
}
}
extern "C" {
void TIMER_0_INST_IRQHandler(void) {
switch (DL_TimerG_getPendingInterrupt(TIMER_0_INST)) {
case DL_TIMER_IIDX_ZERO:
perform = true;
break;
default:
break;
}
}
void ADC12_0_INST_IRQHandler(void) {
switch (DL_ADC12_getPendingInterrupt(ADC12_0_INST)) {
case DL_ADC12_IIDX_MEM0_RESULT_LOADED:
checkADC = true;
break;
default:
break;
}
}
}
Result
This "measure - assess - correct" loop is driven by a common timer, that makes it run every 200 ms.

You can influence speed and stability by tuning the 3 parameters.
AI can support. You can ask it to suggest parameters to make it move faster to the set point, to react faster or slower on load changes, to improve stability, to avoid ringing or overshoot...
This is how Google Gemini AI explains the role of the parameters:
Convert ADC and PWM uint to IQMath format
In the perform_adc() function, you can spot that I used the bit shift operation to convert the unsigned integer ADC value to a Q(24) value.
The ADC returns a 12 bit value. You can safely turn that into a Q(24) by left-shifting it . It's typecast to an int32_t, because that's the underlying type of the Q(24) variable.
feedback = ((int32_t)DL_ADC12_getMemResult(ADC12_0_INST, DL_ADC12_MEM_IDX_0)) << 12;Similar for the perform_pwm() function. The duty cycle compare register expects an uint32_t with a value between 0 and 65535. You can do that by right-shifting:
DL_TimerG_setCaptureCompareValue(PWM_0_INST, ((uint32_t)output_pid) >> 8, DL_TIMER_CC_0_INDEX); // update ccr0 valueWant to see if this works? Create a small program that runs these instructions. Look at the underlying representation with a debugger:
// test conversion ADC unsigned 12 bit value to IQ24
uint16_t adc_result = 4095; // max
_iq iq_adc_result = ((int32_t)adc_result) << 12; // 1.0
[[maybe_unused]] float float_adc_result = ((float)iq_adc_result) * 0.000000059604644775390625f; // for your convenience, show as float. Should be 1.0
// test conversion IQ24 to 16 bit PWM duty cycle compare value
_iq iq_duty_cycle = _IQ(1.0);
[[maybe_unused]] uint32_t duty_cycle = ((uint32_t)iq_duty_cycle) >> 8; // should be 65536
Resources used:
- ADC0, with input pin PA25. Its interrupt flags when conversion done
- PWM timer TIMG0, with output pin PA5.
I selected this timer / pin combination, because you can set its output drive to high.
This helps when you want to apply some load to the DC output voltage (see last point in this list) and see how the PID algorithm corrects for it. - generic timer PWMG1, to make the control loop tick every 200 ms. It fires an Interrupt to signal the loop has to be executed.
- GPIO pin PA27 to make a LED light up when the loop runs.
The loop is so fast that you can't see it, but you can put an oscilloscope over it. - a simple RC low pass filter converts the PWM to a DC signal.

The attached project targets the MSPM0L1306 Launchpad. But you can adapt it to EasyL1105 with SysConfig.
references:
- TI IQMath library. It's part of the MSPM0 SDK.
- MSPM0 IQMath example.
- PID controller on Wikipedia
- EasyL1105 development board.
- Expressif also uses that IQMath library.
- comparable example that uses ARM's vendor-independent DPS++ fixed point math
Thank you for reading.
VS Code project: mspm0_pid_20261021.zip. In this project, both PWM and PID run at 488 Hz. Both can be adapted by modifying PWM_0 and TIMER_0 in SysConfig.
(check this post for the expected VS Code workspace settings: MSPM0 project with VS Code, CMake and GCC - part 6: empty "template" project )
part of the EasyL1105 series. all posts

Top Comments