Steady state error ($e_{ss}$) is the persistent, non-zero difference between your target setpoint and the actual measured output after a control system has settled. If you command a 12V DC motor to move exactly 1000 encoder ticks and it permanently stalls at 985 ticks, your steady state error is 15 ticks. In control theory, it is the limit of the error signal as time approaches infinity.
On the bench, this usually happens because your controller's output isn't strong enough to overcome static friction (stiction) or a constant external load (like gravity) when the error gets very small. Below, we break down the physics of why this happens, build a closed-loop ESP32 motor controller to demonstrate it, and debug the exact firmware crashes that occur when you try to fix it poorly.
The Physics of Steady State Error (Why P-Control Fails)
To understand steady state error, you have to look at Proportional (P) control. In a purely proportional system, the output is calculated as:
Output = Kp × Error
Imagine you are using P-control to hold a robotic arm at a 45-degree angle against gravity. As the arm gets closer to 45 degrees, the error shrinks. If the error drops to 2 degrees, and your $K_p$ is 10, your motor receives a PWM command of 20. But if a PWM value of 20 isn't enough to overcome the gearbox's static friction and the weight of the arm, the motor simply won't move. The arm stays at 43 degrees. The error remains 2. The output remains 20. The system is stalled in a steady state error.
This is why we introduce the Integral (I) term. The integral accumulates the error over time. Even a tiny error of 2 degrees, multiplied by $K_i$ and summed over 500 milliseconds, will eventually generate a large enough output to break static friction and push the system to the exact setpoint, driving the steady state error to zero.
Project Build: ESP32 Closed-Loop Motor Controller
We will build a position-controlled DC motor system to observe and eliminate steady state error. We are using the TB6612FNG motor driver instead of the classic L298N. The L298N uses BJT transistors that drop ~2V at 1A, starving your motor and artificially increasing steady state error. The TB6612FNG uses MOSFETs, dropping only ~0.5V, giving you much finer low-speed control.
Parts List
- Microcontroller: ESP32-DevKitC V4 (ESP32-WROOM-32 module)
- Motor Driver: TB6612FNG Breakout Board (SparkFun or Pololu variant)
- Motor: Pololu 12V 100:1 Metal Gearmotor 25Dx52L mm with 48 CPR Encoder (Part #4754)
- Power: 12V 5A Switching Power Supply
- Wiring: 22 AWG silicone wire, 10kΩ pull-up resistors for encoder lines
Pin Mapping Table
| ESP32 GPIO | TB6612FNG / Motor Pin | Function |
|---|---|---|
| GPIO 18 | PWMA | Motor Speed (LEDC PWM) |
| GPIO 19 | AIN1 | Direction Control 1 |
| GPIO 21 | AIN2 | Direction Control 2 |
| GPIO 32 | Encoder A (AO) | Quadrature Signal A (Interrupt) |
| GPIO 33 | Encoder B (BO) | Quadrature Signal B (Read in ISR) |
| GND | GND / STBY | Common Ground (STBY tied to 3.3V) |
Complete Compilable Code with PID & Error Handling
This code targets the ESP32 Dev Module (ESP32-WROOM-32) board variant in the Arduino IDE. It uses the standard PID_v1 library (install via Library Manager). It includes hardware PWM setup via the ESP32 LEDC API and integral anti-windup protection.
#include
// --- Pin Definitions ---
#define PWM_PIN 18
#define DIR_A1 19
#define DIR_A2 21
#define ENC_A 32
#define ENC_B 33
// --- PWM Configuration (ESP32 LEDC) ---
const int pwmFreq = 1000;
const int pwmResolution = 10; // 10-bit = 0-1023
const int pwmChannel = 0;
// --- PID Variables ---
double Setpoint, Input, Output;
double Kp = 8.0, Ki = 2.5, Kd = 0.5; // Tuned for 100:1 gearbox
PID myPID(&Input, &Output, &Setpoint, Kp, Ki, Kd, DIRECT);
// --- Encoder Variables ---
volatile long encoderTicks = 0;
// --- Interrupt Service Routine ---
void IRAM_ATTR encoderISR() {
// Direct port read for speed; digitalRead is too slow for high CPR at high RPM
uint8_t bState = digitalRead(ENC_B);
if (bState == HIGH) {
encoderTicks++;
} else {
encoderTicks--;
}
}
void setup() {
Serial.begin(115200);
// Motor Driver Pins
pinMode(DIR_A1, OUTPUT);
pinMode(DIR_A2, OUTPUT);
digitalWrite(DIR_A1, LOW);
digitalWrite(DIR_A2, LOW);
// ESP32 LEDC PWM Setup
ledcSetup(pwmChannel, pwmFreq, pwmResolution);
ledcAttachPin(PWM_PIN, pwmChannel);
// Encoder Interrupts (Pull-ups required on breakout or external 10k)
pinMode(ENC_A, INPUT_PULLUP);
pinMode(ENC_B, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(ENC_A), encoderISR, RISING);
// PID Setup
Setpoint = 5000; // Target 5000 ticks
myPID.SetMode(AUTOMATIC);
myPID.SetOutputLimits(-1023, 1023); // Match 10-bit resolution
myPID.SetSampleTime(20); // 50Hz control loop
}
void loop() {
Input = encoderTicks;
if (myPID.Compute()) {
// Error Handling: Check for NaN from Integral Windup
if (isnan(Output)) {
Serial.println("ERR: PID yielded NaN. Resetting Integral.");
myPID.SetMode(MANUAL);
Output = 0;
myPID.SetMode(AUTOMATIC);
}
// Apply Direction and PWM
if (Output > 0) {
digitalWrite(DIR_A1, HIGH);
digitalWrite(DIR_A2, LOW);
ledcWrite(pwmChannel, Output);
} else {
digitalWrite(DIR_A1, LOW);
digitalWrite(DIR_A2, HIGH);
ledcWrite(pwmChannel, abs(Output));
}
}
// Prevent Task Watchdog Timeout in tight loops
yield();
delay(10);
}
Debugging: Task Watchdog Timeouts and Tuning Failures
When you aggressively increase the Integral gain ($K_i$) to force the steady state error to zero quickly, you will likely crash the ESP32. The exact error string you will see in the Serial Monitor is:
E (12345) task_wdt: Task watchdog got triggered.
E (12345) task_wdt: CPU 1: loopTask
This happens because the ESP32's FreeRTOS background tasks (like WiFi and hardware timers) are starved of CPU time. According to the Espressif Watchdog Documentation, the IDLE task must run at least once every 5 seconds.
Ranked Causes for Watchdog and PID Failures
- Integral Windup causing NaN: If the motor is physically blocked, the error accumulates infinitely. The floating-point math overflows to
NaN, breaking theledcWrite()logic and causing an infinite hang in the loop. - Blocking Encoder ISR: Using
digitalRead()inside an ISR at high motor speeds takes too many clock cycles, delaying the main loop and triggering the watchdog. - Missing yield(): Running a
while(1)PID loop withoutdelay()oryield()starves the RTOS IDLE task.
The First Three Things to Check When It Fails
- Verify Output Limits: Ensure
myPID.SetOutputLimits()exactly matches your PWM resolution. If you limit it to -255 to 255 but write to a 10-bit LEDC channel, your motor will never reach full speed, creating an artificial steady state error. - Check Encoder Pull-ups: Magnetic encoders output open-drain signals. If you forgot the 10kΩ pull-up resistors (or didn't use
INPUT_PULLUP), electrical noise from the motor brushes will cause the encoder count to drift wildly, making the PID output erratic. - Implement Anti-Windup: Clamp the integral term in your code, or use the library's built-in limits. If the system saturates (hits max PWM), stop accumulating the integral error.
Extending and Simplifying the Build
How to Extend: If you are controlling a system with a known, predictable load (like a conveyor belt moving boxes of a specific weight), add Feed-Forward Control. Instead of waiting for the PID integral to wind up to overcome the weight of the box, you calculate the baseline PWM required for that weight and add it directly to the PID output. This drastically reduces the time it takes to reach the setpoint and minimizes transient steady state error.
How to Simplify: If you realize you don't actually need to learn control theory and just need a motor to hit a position reliably, abandon the TB6612FNG and DC motor setup. Switch to a closed-loop smart servo like the DSS-M15S or a NEMA 17 stepper with a TMC2209 driver running in closed-loop mode. The manufacturer has already tuned the internal PID and handled the stiction mapping at the factory.
Frequently Asked Questions
What is steady state error in a temperature control system?
In a heater system, steady state error occurs when the heat lost to the ambient environment exactly matches the heat being generated by the heater, before the target temperature is reached. For example, if you set a 3D printer hotend to 200°C, but a P-only controller outputs only 40% PWM at 195°C, and 40% PWM perfectly balances the heat escaping into the room, the system will sit at 195°C forever. The 5°C gap is the steady state error.
How does the integral term eliminate steady state error?
The integral term acts as a historical memory of the error. Mathematically, it calculates the area under the error curve over time. As long as the error is non-zero (even if it's just 1 unit), the integral sum continues to grow. This growing sum forces the controller's output higher and higher until the physical system finally moves, eventually driving the error to exactly zero. Once the error is zero, the integral stops growing, but it retains its accumulated value to maintain the output needed to hold the system at the setpoint.
Can steady state error be zero in a real-world physical system?
Mathematically, yes. Physically, it is effectively zero, but it manifests as a 'deadband' or micro-oscillation. Because real sensors have noise (e.g., an encoder jittering between 4999 and 5001 ticks), the integral term will constantly wind and unwind slightly. The system will dither around the setpoint. In practice, engineers define steady state error as being 'eliminated' once it falls within the noise floor of the sensor.
Why does my system oscillate when I increase the integral gain to fix steady state error?
This is called integral windup or phase lag. The integral term relies on past data. By the time the accumulated integral force is strong enough to push the motor to the setpoint, the system has momentum. It overshoots the target. Now the error is negative, and the integral must 'unwind' all that accumulated positive history before it can pull the system back. This creates a slow, rolling oscillation around the setpoint. To fix this, lower $K_i$ and increase $K_d$ (Derivative), which acts as a damper to predict and prevent the overshoot.






