The most reliable setup for a hobbyist closed-loop arduino pid controller targeting DC motor speed is an Arduino Uno R3 paired with the PID_v1 library, a VNH5019 dual motor driver, and a 64 CPR quadrature encoder, running at a strict 10ms sample time. Unlike open-loop PWM control, which sags under mechanical load, a PID (Proportional-Integral-Derivative) loop constantly measures the actual shaft speed via the encoder and adjusts the PWM duty cycle to maintain your target RPM.

This guide gives you the exact hardware, the pin mapping, the compilable C++ code with atomic interrupt handling, and the debugging decision tree to fix the oscillation and compilation errors that plague 90% of first-time PID builds.

The Decision Path: Choosing Your PID Architecture

Not all PID loops are created equal. The sample time and sensor type dictate which library configuration you need. Use this decision table to lock in your architecture before buying parts.

Application Sensor Type Sample Time Library / Mode Verdict
Sous-Vide / Thermal Chamber Thermocouple (MAX31855) 1000ms - 5000ms PID_v1 (DIRECT) Choose for high-inertia thermal systems.
3D Printer Heated Bed Thermistor (100k) 500ms PID_v1 (DIRECT) Choose for standard Marlin-style heating.
DC Motor Speed Control Quadrature Encoder 10ms - 50ms PID_v1 (DIRECT) DEFAULT PICK: Fast response, handles load spikes.
Stepper Motor Position Limit Switches / Encoders 1ms PID_v1 (REVERSE) Choose for CNC/robot arm joint positioning.
Concrete Pick: For general hobbyist motor speed control, we are building the DC Motor Speed Control architecture. We will use a 10ms sample time. Anything faster (e.g., 1ms) on an Arduino Uno R3 causes the derivative term to amplify encoder noise, resulting in violent motor jitter.

Hardware Spec Sheet & Pin Mapping

You need components that can handle the electrical noise of a brushed DC motor while providing clean logic-level signals back to the ATmega328P microcontroller. Expect to spend roughly $75-$90 on these specific bench-tested parts.

Component Exact Variant / Model Approx. Price Why This Part?
Microcontroller Arduino Uno R3 (ATmega328P DIP) $27.00 Hardware interrupts on D2/D3 are stable for encoder reading.
Motor Driver Pololu VNH5019 Dual Motor Driver $32.00 Handles up to 12A continuous, 5V logic compatible, built-in flyback diodes.
Encoder SparkFun Quadrature Encoder (64 CPR) $15.00 64 Counts Per Rev (256 with 4x decoding) provides enough resolution at low RPM.
Motor 12V DC Planetary Gearmotor (100 RPM) $18.00 Low base RPM makes PID tuning manageable for beginners.

Pin Mapping Table

Wire the system exactly as shown. Do not move the encoder pins; D2 and D3 are the only pins on the Uno R3 that support hardware interrupts required for reliable quadrature decoding.

Arduino Uno R3 Pin Destination Module Module Pin Notes
D2 (INT0) Encoder OUT A Must use hardware interrupt.
D3 (INT1) Encoder OUT B Read in ISR for direction.
D9 (OC1A) VNH5019 Driver PWM 1A Hardware PWM pin.
D7 VNH5019 Driver DIR 1A Digital HIGH/LOW for direction.
5V Encoder / Driver VCC / VDD Logic power only.
GND All Modules GND Ensure common ground with 12V supply.

Complete PID Motor Control Code (Arduino Uno R3)

This code targets the Arduino Uno R3 (ATmega328P) and requires the Arduino PID Library (PID_v1) installed via the Library Manager. It includes atomic interrupt reading to prevent race conditions and a safety shutoff if the encoder stops reading.

#include <PID_v1.h>

// --- PIN DEFINITIONS ---
const int ENCODER_PIN_A = 2;  // Hardware interrupt 0
const int ENCODER_PIN_B = 3;  // Hardware interrupt 1
const int MOTOR_PWM_PIN = 9;  // Hardware PWM
const int MOTOR_DIR_PIN = 7;  // Direction control

// --- PID VARIABLES ---
double Setpoint, Input, Output;
// Initial tuning parameters (conservative start)
double Kp = 2.5, Ki = 4.0, Kd = 0.2;

// PID Object - MUST pass variables by reference (pointers)
PID myPID(&Input, &Output, &Setpoint, Kp, Ki, Kd, DIRECT);

// --- ENCODER & TIMING VARIABLES ---
volatile long encoderCount = 0;
unsigned long lastSampleTime = 0;
const int SAMPLE_TIME_MS = 10; // 10ms loop = 100Hz update rate
const int ENCODER_CPR = 256;   // 64 physical CPR * 4x quadrature decoding
const double MAX_SAFE_RPM = 150.0;

void setup() {
  Serial.begin(115200);
  
  // Enable internal pull-ups to prevent floating encoder noise
  pinMode(ENCODER_PIN_A, INPUT_PULLUP);
  pinMode(ENCODER_PIN_B, INPUT_PULLUP);
  pinMode(MOTOR_PWM_PIN, OUTPUT);
  pinMode(MOTOR_DIR_PIN, OUTPUT);

  // Attach interrupt to Pin A, trigger on RISING edge
  attachInterrupt(digitalPinToInterrupt(ENCODER_PIN_A), readEncoder, RISING);

  // Target speed in RPM
  Setpoint = 60.0; 
  
  myPID.SetMode(AUTOMATIC);
  myPID.SetSampleTime(SAMPLE_TIME_MS);
  myPID.SetOutputLimits(0, 255); // 8-bit PWM limit
  
  digitalWrite(MOTOR_DIR_PIN, HIGH); // Set forward direction
}

void loop() {
  unsigned long now = millis();
  
  if (now - lastSampleTime >= SAMPLE_TIME_MS) {
    lastSampleTime = now;
    
    // 1. Atomic read of encoder count to prevent ISR race conditions
    noInterrupts();
    long count = encoderCount;
    encoderCount = 0;
    interrupts();
    
    // 2. Calculate RPM
    // RPM = (counts / counts_per_rev) * (60_sec / sample_time_sec)
    Input = (count * 60000.0) / (ENCODER_CPR * SAMPLE_TIME_MS);
    
    // 3. Safety Error Handling: Runaway motor or disconnected encoder
    if (Input > MAX_SAFE_RPM && Output > 200) {
      myPID.SetMode(MANUAL);
      analogWrite(MOTOR_PWM_PIN, 0);
      Serial.println("ERROR: RPM exceeded safe limit or encoder disconnected. System halted.");
      while(1); // Halt execution
    }
    
    // 4. Compute PID and write to motor
    myPID.Compute();
    analogWrite(MOTOR_PWM_PIN, Output);
    
    // Debug output (throttle to every 100ms to avoid serial buffer flooding)
    if (count % 10 == 0) {
      Serial.print("RPM: "); Serial.print(Input);
      Serial.print(" | PWM: "); Serial.println(Output);
    }
  }
}

// --- INTERRUPT SERVICE ROUTINE ---
void readEncoder() {
  // Read Pin B to determine direction
  if (digitalRead(ENCODER_PIN_B) == HIGH) {
    encoderCount++;
  } else {
    encoderCount--;
  }
}

Debugging: Fixing Compilation Errors and Motor Oscillation

The PID_v1 library is notorious for throwing specific compiler errors if the constructor is misconfigured, and the hardware will oscillate violently if the timing is off. Here is how to fix the most common bench failures.

Exact Compilation Errors

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

  • Cause A (Most Likely): You forgot #include <PID_v1.h> at the very top of the sketch.
  • Cause B: The library is not installed. Go to Sketch > Include Library > Manage Libraries, search for "PID by Brett Beauregard", and install version 1.2.1.

Error 2: error: no matching function for call to 'PID::PID(double, double, double, double, double, double, int)'

  • Cause A (Most Likely): You passed the variables by value instead of by reference. The PID library requires pointers so it can read and write to the variables in the background. You must use the address-of operator: &Input, &Output, &Setpoint. See the code block above for the correct syntax.

The First 3 Things to Check When the Motor Oscillates or Fails

  1. Verify Sample Time Consistency: The myPID.Compute() function relies on being called at exact, regular intervals. If you have delay() statements or long Serial.print() blocking your loop(), the PID math will break down and cause overshoot. Always use the millis() non-blocking pattern shown in the code.
  2. Check Encoder Pull-ups and Noise: Brushed DC motors generate massive EMI (Electromagnetic Interference). If your encoder wires are unshielded and run parallel to the motor power wires, the ATmega328P will register phantom encoder ticks. Ensure INPUT_PULLUP is set, and physically separate signal wires from the 12V motor lines.
  3. Eliminate Derivative Kick: If you change the Setpoint variable on the fly (e.g., via a potentiometer or serial command), the Derivative term will see a massive instantaneous spike and slam the motor. To fix this, use myPID.SetControllerDirection(REVERSE) logic, or better yet, leave the Setpoint alone and tune the system for a fixed RPM.

Tuning Kp, Ki, Kd and Extending the Build

Out of the box, the Kp=2.5, Ki=4.0, Kd=0.2 values will get the motor moving, but it will likely be sluggish or slightly unstable. You must tune the constants for your specific mechanical load. Refer to the SparkFun Encoder Tutorial for deeper quadrature theory if your counts seem erratic.

The Tuning Decision Tree

Symptom on the Bench What is Happening Action to Take
Motor never reaches target RPM, stalls short. Proportional term is too weak to overcome static friction. Increase Kp by 50% until it reaches the target.
Motor reaches RPM but constantly hunts (oscillates +/- 10 RPM). Integral windup or Proportional term is too aggressive. Decrease Kp slightly, Increase Kd to add damping.
Motor takes 5+ seconds to settle at the target RPM. System is overdamped; Integral term isn't accumulating fast enough. Increase Ki to eliminate steady-state error faster.
Motor violently jerks or screeches when starting. Derivative kick or sample time is too fast for the mechanical inertia. Set Kd = 0. Increase SAMPLE_TIME_MS to 50ms.

How to Extend the Build

Once the baseline PID loop is stable, you can improve performance by adding a software low-pass filter to the encoder input. Mechanical vibration causes micro-fluctuations in the RPM reading, which the Derivative term amplifies. Add this single line right before myPID.Compute():

Input = (0.8 * Input) + (0.2 * lastInput);
lastInput = Input; // Declare lastInput globally

This exponential moving average smooths out high-frequency noise without introducing the severe phase lag of a hardware capacitor filter.

How to Simplify the Build

If you do not have a quadrature encoder and just need a motor to spin at a roughly consistent rate based on a potentiometer dial, drop the PID library entirely. Use a simplified open-loop mapping approach: read the potentiometer on A0 (0-1023), map it to 0-255, and write it to the PWM pin. It will not compensate for load changes (e.g., the motor slowing down when you press on the shaft), but it eliminates the need for interrupts, atomic reads, and PID tuning, reducing the code to under 15 lines.

Safety Note: When testing PID loops with high-torque DC motors, always keep your fingers clear of the shaft and secure the motor to a bench vise. A poorly tuned Integral term (Ki) can cause the motor to suddenly reverse direction or spin to maximum RPM to clear an accumulated error state.