A PID (Proportional-Integral-Derivative) controller for Arduino continuously calculates an error value as the difference between a desired setpoint and a measured process variable, applying a correction based on three distinct mathematical terms. Whether you are stabilizing a 3D printer hotend, balancing a self-driving rover, or holding a DC gearmotor at an exact RPM under varying loads, closed-loop PID control is the industry standard. For 95% of embedded applications, you should skip writing your own math from scratch and use Brett Beauregard’s legendary PID_v1 library. It handles the edge cases—like derivative kick and integral windup—that typically cause homemade PID loops to destroy hardware.
This guide walks through building a closed-loop DC motor speed controller. We are targeting the Arduino Nano v3 (ATmega328P) paired with a TB6612FNG motor driver and an optical encoder. You will get the exact pinout, the complete compilable code, real-world baseline tuning parameters, and a debugging matrix for when the motor inevitably oscillates or stalls on your bench.
Hardware Spec Sheet & Pin Mapping
Before writing a single line of code, you must map your hardware correctly. The most common point of failure in embedded PID loops is assigning an interrupt-dependent sensor (like an encoder) to a pin that doesn't support hardware interrupts. On the Arduino Nano v3, only pins D2 and D3 support hardware interrupts.
| Component / Module | Exact Variant | Operating Voltage | Arduino Nano Pin | Function & Constraints |
|---|---|---|---|---|
| Microcontroller | Arduino Nano v3 (ATmega328P) | 5V Logic / 7-12V VIN | N/A | Brain. 16MHz clock, 32KB Flash. |
| Motor Driver | TB6612FNG Dual Channel | VM: 4.5-13.5V, VCC: 5V | D5 (PWM), D4, D7 | PWMA to D5. Pololu breakout recommended. |
| Feedback Sensor | LM393 Optical Encoder (20-slot) | 3.3V - 5V | D2 (INT0) | Must be on D2 or D3 for hardware ISR. |
| Actuator | 12V DC Gearmotor (100 RPM, 11 PPR) | 12V DC Nominal | N/A (via Driver) | 11 Pulses Per Rev * 20 slots = 220 ticks/rev. |
| User Input | 10K Ohm Potentiometer | 5V | A0 | Used to dynamically adjust the PID Setpoint. |
The Compilable PID Control Code
The code below targets the Arduino Nano v3. It uses a hardware interrupt service routine (ISR) to count encoder ticks, calculates the current RPM every 100ms, and feeds that into the PID library. It also includes vital error handling: if the encoder stops reporting ticks (e.g., a loose wire), the code catches the timeout, zeroes the PWM output to prevent integral windup, and flags the serial monitor.
Prerequisite: Install the PID library by Brett Beauregard via the Arduino IDE Library Manager before compiling.
#include <PID_v1.h>
// --- PIN DEFINITIONS (Nano v3) ---
#define ENC_PIN_A 2 // MUST be D2 or D3 for hardware interrupt
#define PWM_PIN 5 // PWMA on TB6612FNG
#define DIR_PIN_1 4 // AIN1
#define DIR_PIN_2 7 // AIN2
#define POT_PIN A0 // Setpoint dial
// --- SYSTEM CONSTANTS ---
#define TICKS_PER_REV 220 // 11 PPR motor * 20 slot encoder disk
#define SAMPLE_TIME_MS 100 // PID calculation interval
#define SENSOR_TIMEOUT 500 // ms before we declare sensor failure
// --- VOLATILE VARIABLES FOR ISR ---
volatile long encoderTicks = 0;
unsigned long lastTickTime = 0;
// --- PID VARIABLES ---
double Setpoint, Input, Output;
// Baseline tuning for 12V gearmotor (Adjust via Serial later)
double Kp = 2.50, Ki = 0.80, Kd = 0.10;
// Initialize PID object (Input, Output, Setpoint, Kp, Ki, Kd, Direction)
PID myPID(&Input, &Output, &Setpoint, Kp, Ki, Kd, DIRECT);
unsigned long lastCalcTime = 0;
unsigned long lastTickSeen = 0;
bool sensorFault = false;
void setup() {
Serial.begin(115200);
pinMode(ENC_PIN_A, INPUT_PULLUP);
pinMode(PWM_PIN, OUTPUT);
pinMode(DIR_PIN_1, OUTPUT);
pinMode(DIR_PIN_2, OUTPUT);
// Set motor direction (Forward)
digitalWrite(DIR_PIN_1, HIGH);
digitalWrite(DIR_PIN_2, LOW);
// Attach hardware interrupt (Falling edge for optical encoder)
attachInterrupt(digitalPinToInterrupt(ENC_PIN_A), countEncoder, FALLING);
// Initialize PID parameters
Setpoint = 50.0; // Default 50 RPM
myPID.SetMode(AUTOMATIC);
myPID.SetSampleTime(SAMPLE_TIME_MS);
myPID.SetOutputLimits(0, 255); // Constrain to 8-bit PWM
lastTickSeen = millis();
Serial.println("PID Motor Controller Initialized.");
}
void loop() {
unsigned long currentTime = millis();
// 1. Read User Setpoint from Potentiometer (Map 0-1023 to 0-100 RPM)
int potVal = analogRead(POT_PIN);
Setpoint = map(potVal, 0, 1023, 0, 100);
// 2. Calculate RPM every SAMPLE_TIME_MS
if (currentTime - lastCalcTime >= SAMPLE_TIME_MS) {
noInterrupts(); // Safely read volatile variable
long ticks = encoderTicks;
encoderTicks = 0; // Reset for next interval
interrupts();
// RPM = (ticks / ticks_per_rev) * (60000 / sample_time_ms)
Input = (double)ticks / TICKS_PER_REV * (60000.0 / SAMPLE_TIME_MS);
// 3. Error Handling: Sensor Timeout / Disconnect
if (ticks > 0) {
lastTickSeen = currentTime;
sensorFault = false;
} else if (currentTime - lastTickSeen > SENSOR_TIMEOUT && Setpoint > 5.0) {
sensorFault = true;
}
if (sensorFault) {
Output = 0; // Kill PWM to prevent integral windup
analogWrite(PWM_PIN, 0);
Serial.println("ERROR: Sensor Timeout - Check Wiring or Encoder Disk");
} else {
// 4. Compute PID and apply to motor
myPID.Compute();
analogWrite(PWM_PIN, Output);
// Serial Telemetry for Plotter
Serial.print("Set:"); Serial.print(Setpoint);
Serial.print(" In:"); Serial.print(Input);
Serial.print(" Out:"); Serial.println(Output);
}
lastCalcTime = currentTime;
}
}
// --- INTERRUPT SERVICE ROUTINE ---
void countEncoder() {
encoderTicks++;
}
Real-World Tuning Parameters (Kp, Ki, Kd)
The biggest lie in embedded engineering is that you can mathematically derive perfect PID constants for a physical system without empirical testing. Friction, gearbox backlash, and PWM dead-zones ruin theoretical models. When tuning a PID controller for Arduino, start with the baseline values below, then apply the Ziegler-Nichols manual tuning method: set Ki and Kd to 0, increase Kp until the motor oscillates evenly, then back it off by 50% and introduce Ki to eliminate steady-state error.
| Load Profile | Kp (Proportional) | Ki (Integral) | Kd (Derivative) | Notes & Edge Cases |
|---|---|---|---|---|
| 12V Gearmotor (No Load) | 2.50 | 0.80 | 0.10 | Baseline. Motor responds quickly, minimal overshoot. |
| 12V Gearmotor (High Inertia / Flywheel) | 4.20 | 0.15 | 1.50 | Higher Kd required to brake the flywheel before it overshoots the setpoint. |
| 3D Printer Hotend (Heater Cartridge) | 35.0 | 1.25 | 0.00 | Thermal systems have massive lag. Kd is zeroed out to avoid reacting to sensor noise. |
| Line-Following Rover Steering | 12.0 | 0.00 | 8.00 | Pure PD control. Integral is disabled because steering doesn't accumulate "error history". |
Debugging: First 3 Things to Check When It Fails
When your PID loop misbehaves, it rarely fails silently. It usually manifests as compiler errors, serial telemetry dropouts, or physical hardware violence. Here are the exact error strings and their ranked causes.
1. Compile Error: error: 'PID' does not name a type
Ranked Causes:
- Library Not Installed: You haven't installed Brett Beauregard's library. Go to Sketch > Include Library > Manage Libraries, search for "PID", and install the one authored by Brett Beauregard.
- Case Sensitivity (Linux/Mac): If you are on a case-sensitive file system,
#include <pid_v1.h>will fail. It must be exactly#include <PID_v1.h>. - Multiple Library Conflict: You have an older, conflicting PID library in your
Documents/Arduino/librariesfolder. Delete the duplicate.
2. Serial Output: ERROR: Sensor Timeout - Check Wiring or Encoder Disk
Ranked Causes:
- Wrong Interrupt Pin: You wired the encoder to D4, D6, or D8. The Arduino Nano v3 only supports hardware interrupts on D2 and D3. The ISR is never firing, so
encoderTicksremains 0. - Missing Pull-up Resistor: The LM393 optical encoder module has an open-collector output. If you didn't use
INPUT_PULLUPin yourpinMode()declaration, the signal line will float, and the interrupt won't trigger reliably. - Encoder Disk Misalignment: The slotted disk is rubbing against the sensor housing, or the IR LED isn't aligned with the phototransistor. Shine a phone flashlight through the slot; you should see the receiver on the other side.
3. Physical Symptom: Motor oscillates violently or "chatters" around setpoint
Ranked Causes:
- Integral Windup: The motor stalled physically, but the PID kept accumulating error (Integral), maxing out the PWM. When the stall clears, the motor violently overcorrects. Fix: Ensure
myPID.SetOutputLimits(0, 255)is in your setup, which the PID_v1 library uses to internally clamp the integral term. - Sample Time Mismatch: Your
SAMPLE_TIME_MSis too fast (e.g., 10ms) for the mechanical inertia of the gearbox. The PID is reacting to noise before the motor has time to physically respond to the last PWM change. Increase sample time to 100ms or 200ms. - Kp is too high: The proportional reaction is overpowering the system. Halve your Kp value.
Extending and Simplifying the Build
Not every project requires a full three-term PID loop, and some require vastly more complexity. Here is how to adapt this architecture to your specific constraints.
How to Simplify (Drop to P-Control):
If you are just building a simple fan thermostat or a basic line sensor, drop the I and D terms. Set Ki = 0 and Kd = 0. Proportional-only control will leave a "steady-state error" (e.g., you ask for 100 RPM, but it settles at 96 RPM because 100% PWM isn't enough to overcome friction without the Integral term pushing it over the edge). For non-precision tasks, this 4% error is perfectly acceptable and saves you hours of tuning.
How to Extend (Cascade PID & ESP32 Migration):
If you are building a balancing robot or a CNC spindle, a single PID loop isn't enough. You need Cascade PID. In a cascade setup, the output of the first PID loop (e.g., Position) becomes the Setpoint of the second PID loop (e.g., Velocity). To do this, instantiate two separate PID objects in your code and chain their variables.
If you outgrow the Arduino Nano's 16MHz clock and 2KB SRAM—especially if you need to run a web server alongside your PID loop—migrate to the ESP32-DevKitC V4. The PID_v1 library is fully compatible with the ESP32 Arduino core. However, be aware that the ESP32's ADC is notoriously non-linear; if you use a potentiometer for setpoint input on an ESP32, add a 100nF ceramic capacitor between the wiper pin and GND to filter out the ADC noise, or your setpoint will jitter.






