Hobby servos handle their own position loop internally — you send 90° and the servo gets there. But the moment you move to DC motors with encoders, steppers with feedback, or want smooth velocity control on top of position, PID control becomes your responsibility. Here’s how to build it, tune it, and add gravity feedforward to get an arm that moves the way you intend.
When you need explicit PID
You can skip custom PID if:
- You use standard RC hobby servos (built-in PID, just write angles).
- Motion accuracy within ±2° is acceptable.
- You’re doing slow, pre-planned joint moves.
You need explicit PID when:
- You drive DC motors or stepper motors directly and need closed-loop position.
- You want velocity-controlled trajectories (smooth ramps, not step changes).
- You’re doing inverse kinematics and need precise, repeatable end-effector position.
- You want gravity compensation so a loaded arm doesn’t sag between moves.
The PID equation
error = target_angle − current_angle
integral += error × dt
derivative = (error − prev_error) / dt
output = Kp × error
+ Ki × integral
+ Kd × derivative
- Kp (proportional): the main driver — output proportional to current error. Too low: sluggish. Too high: oscillates.
- Ki (integral): eliminates steady-state error that P alone can’t correct. Too high: integral windup causes overshoot and instability.
- Kd (derivative): damping — reacts to how fast error is changing. Smooths the approach to the target; too high adds noise sensitivity.
Arduino implementation
struct PID {
float kp, ki, kd;
float integral = 0, prevError = 0;
float outMin = -255, outMax = 255;
float compute(float target, float current, float dt) {
float err = target - current;
integral += err * dt;
// Anti-windup: clamp integral
integral = constrain(integral, outMin / ki, outMax / ki);
float deriv = (err - prevError) / dt;
prevError = err;
return constrain(kp*err + ki*integral + kd*deriv, outMin, outMax);
}
};
PID shoulder = {4.0f, 0.05f, 0.8f};
void loop() {
float dt = 0.02f; // 20 ms loop
float current = readEncoderDegrees(); // your encoder function
float output = shoulder.compute(90.0f, current, dt);
analogWrite(MOTOR_PWM_PIN, abs(output));
digitalWrite(MOTOR_DIR_PIN, output > 0 ? HIGH : LOW);
delay(20);
}
Keep the loop timing consistent — PID assumes fixed dt. Use a hardware timer or
millis() delta rather than delay() in a real implementation.
Python implementation (for RPi or laptop control)
import time
class PID:
def __init__(self, kp, ki, kd, out_min=-1.0, out_max=1.0):
self.kp, self.ki, self.kd = kp, ki, kd
self.out_min, self.out_max = out_min, out_max
self.integral = 0.0
self.prev_error = 0.0
def compute(self, target: float, current: float, dt: float) -> float:
error = target - current
self.integral = max(self.out_min / (self.ki or 1),
min(error * dt + self.integral,
self.out_max / (self.ki or 1)))
derivative = (error - self.prev_error) / dt
self.prev_error = error
output = self.kp * error + self.ki * self.integral + self.kd * derivative
return max(self.out_min, min(output, self.out_max))
shoulder_pid = PID(kp=3.0, ki=0.02, kd=0.5)
dt = 0.02
while True:
current = read_encoder() # your encoder read function
out = shoulder_pid.compute(90.0, current, dt)
set_motor_effort(out) # your motor drive function
time.sleep(dt)
Tuning step-by-step
- Set Ki=0, Kd=0. Increase Kp slowly until the joint oscillates around the
target. Note that value — call it
Kp_osc. - Back off Kp to about 0.5–0.6 ×
Kp_osc. - Add Kd starting at 0.01 × Kp. Increase until overshoot damps without adding noise jitter. Typical range: 0.05–0.3 × Kp.
- Add Ki only if needed. If the joint stops a degree or two short of target, add Ki starting at 0.001 × Kp. Watch for windup (slow growing oscillation) — lower Ki or add anti-windup clamping if it appears.
Tune each joint separately. Gravity-loaded joints (shoulder, elbow carrying the forearm weight) need higher Kp and more Kd than lightweight wrist joints.
Feedforward for gravity compensation
For a joint that fights gravity, the PID I-term tends to wind up (accumulating a large correction to cancel gravity). Feedforward pre-cancels gravity before the PID runs:
import math
def gravity_feedforward(joint_angle_deg: float, arm_mass_kg: float,
arm_length_m: float) -> float:
"""Return the torque (N·m) needed to hold this joint against gravity."""
angle_rad = math.radians(joint_angle_deg)
return arm_mass_kg * 9.81 * arm_length_m * math.cos(angle_rad)
# In the control loop:
ff = gravity_feedforward(current_angle, 0.15, 0.12) # 150 g arm, 12 cm
out = pid.compute(target, current, dt) + ff_to_motor_units(ff)
The cosine term accounts for the changing lever arm as the joint rotates — the compensation is maximum at horizontal (0°) and zero at vertical (90°).
From PID to trajectory control
Single-joint PID gets each joint to its target. For a smooth multi-joint move, wrap the PID loops in a trajectory generator that ramps joint velocities rather than stepping directly to the target angle:
# Trapezoidal velocity profile: accelerate, cruise, decelerate
def trapezoid_positions(start, end, max_speed, accel, dt):
positions = []
v, pos = 0.0, start
while abs(pos - end) > 0.1:
remaining = abs(end - pos)
v_max_from_stop = math.sqrt(2 * accel * remaining)
v_target = min(max_speed, v_max_from_stop)
v = min(v + accel * dt, v_target)
pos += math.copysign(v * dt, end - pos)
positions.append(pos)
return positions
Feed each generated position into the PID target each loop tick and the joint follows
a smooth S-curve rather than snapping to the final angle.
For the hardware side, encoders from the sensors shop give position feedback; the wiring diagram covers adding encoder signals alongside the existing servo/motor wiring.
Frequently asked questions
What is PID control in a robotic arm?
PID stands for Proportional-Integral-Derivative. It's a feedback control algorithm that compares the current joint angle (measured by an encoder or pot) to the target angle, then computes a correction signal that drives the motor. The P term reacts to the current error, the I term corrects accumulated past error, and the D term damps oscillation by reacting to how fast the error is changing.
Do I need PID for a servo robotic arm?
Standard RC hobby servos have a PID controller built in — you send a PWM angle and the servo's internal circuitry handles position control. PID becomes your problem when you drive a motor directly (DC motor + encoder, or stepper with position feedback), or when you want to add velocity control, smooth trajectories, or gravity compensation on top of a standard servo's built-in loop.
How do I tune PID for a robot arm joint?
Start with Ki=0, Kd=0 and increase Kp until the joint oscillates, then back off to about 60% of that value. Add Kd until oscillation damps (typically 0.01–0.1× Kp). Add a small Ki only if steady-state error remains after P+D tuning. Gravity-loaded joints (shoulder) need higher Kp than free-swinging joints (wrist).
What is feedforward in robot arm control?
Feedforward adds a known compensation signal before the PID computes its correction. For a gravity-loaded joint, the feedforward term is a constant or cosine-scaled offset that pre-cancels the predictable gravity torque — so the PID only handles the remaining error rather than fighting gravity from scratch on every move. It reduces the integral windup problem and gives faster, smoother response.