Project Overview & Difficulty Rating

Building a reliable PID controller with Arduino requires moving beyond simple open-loop PWM and implementing closed-loop feedback. The most robust entry point is controlling a DC gearmotor's speed using a quadrature encoder and the industry-standard PID_v1.h library. This setup compensates for load changes, voltage sags, and friction in real-time.

Difficulty Rating: Intermediate (Requires hardware interrupt knowledge and basic control theory)
Target Board Variant: Arduino Uno R3 (ATmega328P) or Nano v3. The code relies on specific hardware interrupt pins native to the 328P architecture.
Estimated Cost: $35 - $45 USD (excluding power supply)
Time to Build: 90 minutes (wiring + baseline tuning)

Hardware Spec Sheet & Pin Mapping

Do not use an L298N motor driver for precision PID control. The L298N uses BJT transistors that drop 2V-3V and introduce non-linear deadzones at low PWM duty cycles. Instead, use a MOSFET-based driver like the TB6612FNG for linear, high-frequency PWM response.

ComponentExact Variant / Part NumberNotes
MicrocontrollerArduino Uno R3 (ATmega328P)Pins 2 & 3 required for hardware interrupts
Motor DriverTB6612FNG Dual Carrier (Pololu #713 or SparkFun ROB-14451)MOSFET-based, minimal voltage drop, handles up to 1.2A continuous per channel
Motor & EncoderPololu 30:1 Metal Gearmotor 37Dx57L mm with 64 CPR EncoderQuadrature encoder provides 1920 counts per output shaft revolution
Power Supply12V 5A Switching PSU (Mean Well LRS-60-12)Must handle motor stall current without browning out the Arduino

Pin Mapping Table

Arduino Uno PinTB6612FNG / Encoder PinFunction
D2 (Interrupt 0)Encoder OUTAHardware interrupt for pulse counting
D4Encoder OUTBDirection sensing (optional for unidirectional speed)
D5 (PWM)PWMAMotor speed control via analogWrite
D7AIN1Motor direction logic (Set HIGH for forward)
D8AIN2Motor direction logic (Set LOW for forward)
5VVCC / Encoder VCCLogic power for driver and encoder pull-ups
GNDGNDCommon ground (Crucial: tie PSU GND to Arduino GND)

Wiring Steps & Compilable Code

  1. Power Isolation: Connect the 12V PSU to the TB6612FNG VMOT pin. Connect the PSU ground to both the TB6612FNG GND and the Arduino GND. Never power the motor through the Arduino's 5V regulator.
  2. Logic Connections: Wire the TB6612FNG VCC to the Arduino 5V. Connect AIN1 to D7, AIN2 to D8, and PWMA to D5. Tie the STBY (Standby) pin directly to 5V to keep the driver enabled.
  3. Encoder Wiring: Connect Encoder VCC to 5V, GND to GND, OUTA to D2, and OUTB to D4. The internal pull-ups in the code will handle signal conditioning.
  4. Upload the Firmware: Install the PID_v1 library by Brett Beauregard via the Arduino Library Manager before compiling.
#include <PID_v1.h>

// --- Pin Definitions ---
#define ENCODER_PIN_A 2  // Hardware interrupt pin on Uno
#define ENCODER_PIN_B 4
#define MOTOR_PWM_PIN 5
#define MOTOR_IN1_PIN 7
#define MOTOR_IN2_PIN 8

// --- PID Variables ---
double Setpoint, Input, Output;
// Initial tuning: Aggressive P, moderate I, low D for speed control
double Kp = 2.5, Ki = 6.0, Kd = 0.1; 
PID myPID(&Input, &Output, &Setpoint, Kp, Ki, Kd, DIRECT);

volatile long encoderCount = 0;
unsigned long lastPrint = 0;

void setup() {
  Serial.begin(115200);
  
  pinMode(ENCODER_PIN_B, INPUT_PULLUP);
  pinMode(MOTOR_PWM_PIN, OUTPUT);
  pinMode(MOTOR_IN1_PIN, OUTPUT);
  pinMode(MOTOR_IN2_PIN, OUTPUT);

  // Set motor direction forward
  digitalWrite(MOTOR_IN1_PIN, HIGH);
  digitalWrite(MOTOR_IN2_PIN, LOW);

  // Attach hardware interrupt for encoder pulse counting
  attachInterrupt(digitalPinToInterrupt(ENCODER_PIN_A), countEncoder, RISING);

  Setpoint = 150; // Target speed (pulses per sample interval)
  myPID.SetMode(AUTOMATIC);
  myPID.SetSampleTime(50); // 50ms sample time (20Hz control loop)
  myPID.SetOutputLimits(40, 255); // 40 minimum to overcome static friction
}

void loop() {
  // Calculate speed: delta counts over the sample interval
  static long lastCount = 0;
  Input = encoderCount - lastCount;
  lastCount = encoderCount;

  myPID.Compute();
  analogWrite(MOTOR_PWM_PIN, Output);

  // --- Error Handling & Fault Detection ---
  // If PID is maxing out but motor isn't spinning, we have a stall or disconnected encoder
  if(Output > 240 && Input < 5 && millis() > 2000) {
    Serial.println("FAULT: Encoder disconnected or motor stalled! Halting.");
    analogWrite(MOTOR_PWM_PIN, 0);
    myPID.SetMode(MANUAL); // Disable PID to prevent integral windup damage
    while(1); // Halt execution
  }

  // Serial plotter output for tuning visualization
  if(millis() - lastPrint > 100) {
    Serial.print("SP:"); Serial.print(Setpoint);
    Serial.print("\tIN:"); Serial.print(Input);
    Serial.print("\tOUT:"); Serial.println(Output);
    lastPrint = millis();
  }
}

void countEncoder() {
  encoderCount++;
}
Tuning Tip: The SetOutputLimits(40, 255) is critical. DC motors have a static friction threshold. If the PID output is allowed to drop to 0, the motor stops entirely, and the Proportional term must fight static friction again to restart it, causing severe oscillation. Setting the lower limit just above the stall voltage keeps the motor in its dynamic friction range.

Debugging: First Three Things to Check When It Fails

When your PID controller with Arduino misbehaves, do not immediately start changing Kp, Ki, and Kd. Hardware and timing flaws mimic bad tuning. Check these three items first.

1. Compilation Error: error: 'PID' does not name a type

Ranked Causes:

  1. Wrong Library Installed: You searched "PID" in the Library Manager and installed a generic fork. You must install PID v1 by Brett Beauregard. The class name is case-sensitive and relies on this specific header.
  2. Missing Include: The #include <PID_v1.h> directive is missing or placed after variable declarations.

2. Runtime Issue: Motor Oscillates Wildly Around Setpoint

Ranked Causes:

  1. Sample Time Jitter: The PID_v1 library calculates the derivative and integral based on the exact time delta between Compute() calls. If you have delay() functions or long Serial.print() statements blocking the loop, the time delta spikes, causing massive derivative kicks. Fix: Use millis() for all timing, as shown in the code above.
  2. Derivative Noise: Encoders generate high-frequency quantization noise. The Derivative term amplifies this noise. Fix: Drop Kd to 0. Speed control rarely needs a derivative term; PI control is usually sufficient.

3. Runtime Issue: Motor Stalls and Never Reaches Setpoint

Ranked Causes:

  1. Interrupts Not Firing: The encoder wiring is loose, or the pull-up resistors are disabled. The Arduino sees 0 RPM, so the Integral term winds up to maximum. Fix: Spin the motor by hand with the power off and verify encoderCount increments via Serial.
  2. Output Limits Too Low: The SetOutputLimits maximum is set below the PWM value required to overcome the mechanical load.

For a deeper theoretical understanding of why integral windup causes these stalls, refer to National Instruments' PID Theory Explained guide, which details how the I-term accumulates error during saturated states.

Extending and Simplifying the Build

Depending on your application, you may need to strip this project down to its core or scale it up for industrial-style prototyping.

How to Simplify:
If you only need rough speed regulation and lack a quadrature encoder, you can replace the hardware interrupt with a back-EMF analog reading across a shunt resistor, or simply map a potentiometer to a fixed PWM curve (open-loop). However, true PID requires a measurement. The simplest valid sensor upgrade is using a slotted optical switch (like the LM393 speed sensor module, ~$2 USD) to count gear teeth instead of a precision magnetic encoder.

How to Extend:
To build a dual-axis system (like a differential drive robot), instantiate a second PID object: PID myPID2(&Input2, &Output2, &Setpoint2, Kp, Ki, Kd, DIRECT);. The PID_v1 library supports multiple instances natively. For advanced UI, wire an I2C OLED (SSD1306) and use a rotary encoder with a push-button to adjust the Setpoint and K-values on the fly without recompiling. Be sure to read the I2C bus only when the PID sample time triggers to avoid blocking the control loop.

Safety Caveat: If you adapt this PID code to control a heating element (like a 3D printer hotend or sous-vide cooker) via a Solid State Relay (SSR), you MUST implement a software watchdog and a hardware thermal fuse. If the Arduino locks up while the SSR is latched HIGH, the heater will remain on indefinitely, creating a severe fire hazard.

Frequently Asked Questions

How do I tune a PID controller with Arduino without guessing?

Stop guessing and use the Ziegler-Nichols method. First, set Ki and Kd to zero. Slowly increase Kp until the motor speed begins to oscillate at a constant amplitude. Note this value as the Ultimate Gain ($K_u$) and measure the time between oscillation peaks as the Ultimate Period ($P_u$). Then, apply the standard Z-N formulas: $K_p = 0.45 \times K_u$, $K_i = (1.2 \times K_p) / P_u$, and $K_d = (0.075 \times K_p) \times P_u$. This provides a mathematically sound baseline that you can then soften for your specific mechanical load. Brett Beauregard's original PID library documentation covers the math behind why these specific ratios work for most DC systems.

Why is my Arduino PID controller reacting so slowly to sudden load changes?

Slow reaction to load disturbances (like a conveyor belt hitting an obstruction) is almost always caused by a sample time that is too long, or an Integral term that is too conservative. The I-term is responsible for eliminating steady-state error over time. If your motor drops speed under load and takes 3 seconds to recover, your Ki is too low. Increase Ki in increments of 1.0 until the recovery time drops below 500ms, then watch for overshoot. Additionally, ensure your SetSampleTime() is no larger than 50ms for fast-moving mechanical systems.

Can I run a PID controller with Arduino on an ESP32 instead of an Uno?

Yes, but you must account for the ESP32's dual-core FreeRTOS architecture. The PID_v1 library relies on millis(), which works fine on the ESP32. However, if your encoder interrupts are firing on one core while the PID computation runs on the other, you can encounter race conditions with the volatile encoder count variable. To fix this, wrap your encoder variable reads in a mutex or portMUX_TYPE spinlock, or pin the entire PID loop task to Core 1 using xTaskCreatePinnedToCore(). Furthermore, the ESP32's PWM (LEDC) resolution is 13-bit by default, so you must scale the PID Output Limits from (0, 255) to (0, 8191) and use ledcWrite() instead of analogWrite().

What is the difference between DIRECT and REVERSE acting in the PID library?

This parameter tells the library how the output affects the input. In DIRECT acting (like a motor or heater), an increase in Output causes an increase in Input (speed or temperature). In REVERSE acting (like a cooling fan or a refrigeration compressor), an increase in Output causes a decrease in Input. If your motor immediately runs at 100% PWM and runs away from the setpoint the moment you turn it on, you have likely selected the wrong acting direction, causing positive feedback instead of negative feedback.