To control a DC motor smoothly without audible whine using PWM for Arduino, use an Arduino Uno R3 (ATmega328P), the TimerOne library to push the hardware PWM frequency to 20kHz, and a TB6612FNG MOSFET motor driver. Default 490Hz PWM causes magnetostriction whine in motor windings, and older BJT-based drivers like the L298N waste torque at low duty cycles due to high voltage drops.
The Quick Decision: Which PWM Method and Board to Pick?
Not all PWM implementations are equal. The right choice depends entirely on your load type and channel count. Use this decision tree to lock in your hardware and software approach.
| If Your Project Needs... | Then Pick This Board | Use This PWM Method |
|---|---|---|
| 1-6 channels, LEDs or heaters (frequency insensitive) | Arduino Uno R3 / Nano v3 | Native analogWrite() (490Hz / 980Hz) |
| 1-6 channels, DC motors or audio (needs >16kHz to avoid whine) | Arduino Uno R3 / Nano v3 | TimerOne library (Hardware Timer1, up to 31kHz) |
| 7-15 channels, mixed loads | Arduino Mega 2560 | Native analogWrite() on pins 2-13, 44-46 |
| 16+ channels, high-res 16-bit, or Wi-Fi integration | ESP32 DevKit V1 (WROOM-32) | ledc API (Software-mapped PWM, up to 80MHz base) |
Hardware Spec Sheet: TB6612FNG vs. L298N for PWM Motor Control
When applying PWM to a motor, the driver's internal architecture dictates your low-speed performance. The ancient L298N uses Bipolar Junction Transistors (BJTs), while the modern TB6612FNG uses MOSFETs. This difference is critical for PWM tuning.
| Specification | TB6612FNG (MOSFET) | L298N (BJT H-Bridge) |
|---|---|---|
| Continuous Current | 1.2A per channel | 2.0A per channel |
| Voltage Drop at 1A | ~0.5V (MOSFET Rds(on)) | ~2.5V to 3.0V (BJT Vce(sat)) |
| Low PWM Duty Cycle Torque | Excellent (voltage reaches motor) | Poor (voltage drop eats the pulse) |
| Switching Speed | Fast (Handles 20kHz+ easily) | Slower (Can overheat at high freq) |
| Typical Price (2026) | ~$12 (SparkFun/Pololu carrier) | ~$4 (Generic clone modules) |
Because the L298N drops up to 3V across its BJT junctions, a 10% duty cycle PWM pulse on a 12V supply might only deliver 6V to the motor for a fraction of a millisecond—resulting in stalling. The TB6612FNG's low MOSFET resistance passes the full 12V pulse, maintaining magnetic field strength even at 5% duty cycles. For deep-dive specs, refer to the Pololu TB6612FNG datasheet and carrier board documentation.
Parts List and Pin Mapping for Arduino Uno R3
This build targets the Arduino Uno R3 (Rev3) with the ATmega328P DIP chip. Do not use an Arduino Uno R4 Minima for this specific code, as the R4 uses a Renesas RA4M1 ARM Cortex-M4 and does not support the AVR-specific TimerOne library.
Bill of Materials
- Microcontroller: Arduino Uno R3 (Rev3) - ~$25
- Motor Driver: TB6612FNG Dual Motor Driver Carrier (Pololu #713 or SparkFun #14451) - ~$12
- Actuator: 12V DC Gearmotor (60 RPM, 1A stall current max) - ~$15
- Input: 10kΩ Linear Trimpot (Potentiometer) - ~$1
- Power: 12V 2A DC Barrel Jack Power Supply
Pin Mapping Table
| Arduino Uno R3 Pin | TB6612FNG Pin | Function / Notes |
|---|---|---|
| D9 (Hardware PWM) | PWMA | Timer1 controlled 20kHz PWM signal |
| D7 (Digital Out) | AIN1 | Motor A Direction Logic 1 |
| D8 (Digital Out) | AIN2 | Motor A Direction Logic 2 |
| 5V | VCC | Logic level power (Do NOT confuse with VMOT) |
| GND | GND | Common ground (Must share with 12V supply GND) |
| N/A | VMOT | Connected directly to 12V Power Supply + |
| N/A | STBY | Jumper to VCC (5V) to keep driver enabled |
| A0 (Analog In) | Trimpot Wiper | Manual speed/direction input (0-5V) |
Complete Compilable Code: Smooth Acceleration with Error Handling
This sketch uses the TimerOne library to override the default Arduino analogWrite 490Hz frequency. It includes an exponential moving average filter to eliminate potentiometer ADC jitter, a deadzone to prevent creep, and a 'kickstart' routine to overcome static friction at low PWM duty cycles.
#include <TimerOne.h>
// ==========================================
// PIN DEFINITIONS (Arduino Uno R3 ATmega328P)
// ==========================================
#define PIN_POT A0
#define PIN_PWMA 9 // Must be Pin 9 or 10 for TimerOne
#define PIN_AIN1 7
#define PIN_AIN2 8
// ==========================================
// SYSTEM CONSTANTS
// ==========================================
const int DEADZONE = 50; // Prevents motor creep at center
const int KICKSTART_DUTY = 1023; // 100% duty cycle (10-bit resolution)
const int KICKSTART_MS = 50; // 50ms pulse to break static friction
// ==========================================
// STATE VARIABLES
// ==========================================
float smoothedPot = 512.0;
bool isStopped = true;
void setup() {
Serial.begin(115200);
pinMode(PIN_AIN1, OUTPUT);
pinMode(PIN_AIN2, OUTPUT);
pinMode(PIN_POT, INPUT);
// Initialize Timer1 for 20kHz PWM (50 microsecond period)
// Default analogWrite is 490Hz, which causes audible motor whine
Timer1.initialize(50);
Timer1.pwm(PIN_PWMA, 0);
// Verify initialization
if (Timer1.period != 50) {
Serial.println("ERROR: Timer1 failed to initialize. Check board variant.");
while(1); // Halt execution
}
Serial.println("PWM Motor Control Initialized at 20kHz.");
}
void loop() {
int rawPot = analogRead(PIN_POT);
// Error Handling / Noise Filtering: Exponential Moving Average
// Smooths out 50/60Hz mains noise and wiper contact bounce
smoothedPot = (0.85 * smoothedPot) + (0.15 * rawPot);
// Map 0-1023 to -1023 to 1023 for bidirectional control
int targetSpeed = map((int)smoothedPot, 0, 1023, -1023, 1023);
// Apply Deadzone
if (abs(targetSpeed) < DEADZONE) {
targetSpeed = 0;
}
// Set Direction Logic
if (targetSpeed > 0) {
digitalWrite(PIN_AIN1, HIGH);
digitalWrite(PIN_AIN2, LOW);
} else if (targetSpeed < 0) {
digitalWrite(PIN_AIN1, LOW);
digitalWrite(PIN_AIN2, HIGH);
} else {
digitalWrite(PIN_AIN1, LOW);
digitalWrite(PIN_AIN2, LOW);
}
// Kickstart Routine: Overcomes static friction at low duty cycles
int absSpeed = abs(targetSpeed);
if (absSpeed > 0 && isStopped) {
Timer1.setPwmDuty(PIN_PWMA, KICKSTART_DUTY);
delay(KICKSTART_MS);
isStopped = false;
} else if (absSpeed == 0) {
isStopped = true;
}
// Apply final PWM duty cycle (TimerOne uses 0-1023 for 10-bit resolution)
Timer1.setPwmDuty(PIN_PWMA, absSpeed);
// Small delay to stabilize ADC readings and prevent watchdog timeouts
delay(20);
}
Debugging PWM Failures: The First Three Things to Check
When your motor misbehaves, do not immediately rewrite the code. Hardware and physics dictate PWM behavior. Follow this ranked troubleshooting path.
1. Symptom: Motor emits a high-pitched whine or hum
- Cause: You are using the default
analogWrite()function, which runs at 490Hz (or 980Hz on pins 5/6). This frequency falls squarely in the human hearing range, and the rapid magnetic expansion/contraction (magnetostriction) in the motor windings acts as a speaker. - Fix: Verify you are using the
TimerOnelibrary as shown in the code above. If you see the compiler errorfatal error: TimerOne.h: No such file or directory, open the Arduino IDE Library Manager (Ctrl+Shift+I) and install 'TimerOne' by Paul Stoffregen.
2. Symptom: Motor stalls or hums without spinning at low speeds (10-20% duty cycle)
- Cause: Static friction in the gearbox requires significantly more torque to start moving than to keep moving. A 15% PWM pulse doesn't deliver enough average current to break static friction.
- Fix: Implement the 'Kickstart Routine' included in the provided code. This applies 100% duty cycle for 50 milliseconds the moment the motor transitions from stopped to moving, breaking friction before dropping to the target low speed.
3. Symptom: Motor jitters or creeps when the potentiometer is centered
- Cause: Analog-to-Digital Converter (ADC) noise. The Uno's 10-bit ADC will naturally fluctuate by ±2 to ±4 bits even with a stable voltage, causing the motor to rapidly switch directions or pulse at the deadzone edge.
- Fix: Check the Exponential Moving Average (EMA) filter in the code. If jitter persists, increase the
DEADZONEconstant from 50 to 100, or add a 0.1µF ceramic capacitor directly between the trimpot wiper pin and GND on your breadboard to filter high-frequency noise.
Extending and Simplifying the Build
Once the baseline 20kHz PWM control is stable, you will likely need to adapt the system for real-world constraints.
How to Simplify (Cost & Space Reduction)
If you only need unidirectional speed control (e.g., a fan or a conveyor belt) and do not need to reverse the motor, drop the TB6612FNG entirely. Replace it with a single IRLB8721 N-Channel MOSFET (approx. $1.50). Connect the Arduino PWM pin to the gate via a 100Ω resistor, the source to GND, and the drain to the motor's negative terminal. This cuts component count and eliminates the H-bridge voltage drop completely.
How to Extend (Closed-Loop Control)
Open-loop PWM assumes the motor always spins at the exact speed dictated by the duty cycle. In reality, mechanical load variations will cause speed droop. To extend this build into a closed-loop system:
- Add a quadrature rotary encoder (e.g., 600 PPR) to the motor shaft.
- Wire the encoder A/B channels to Arduino Uno pins 2 and 3 (hardware interrupt pins).
- Replace the direct
Timer1.setPwmDuty()call with a PID controller (using the Arduino PID library). The PID loop will dynamically adjust the PWM duty cycle 100 times a second to maintain a target RPM regardless of the physical load applied to the motor.
By selecting the correct MOSFET driver, pushing the PWM frequency out of the audible spectrum, and handling static friction in software, you eliminate the most common failure modes of Arduino motor control. Stick to the Uno R3 and TimerOne for single-motor benchmarks, and migrate to ESP32 ledc APIs only when your project scales beyond 6 channels.






