The Hardware Reality of Arduino Pulse Width Modulation

Arduino pulse width modulation (PWM) is not true analog output; it is a digital square wave where the ratio of ON time to OFF time (duty cycle) simulates an average voltage. When you call the standard analogWrite() function on an ATmega328P-based board (like the Uno or Nano), the microcontroller toggles the pin HIGH and LOW at a fixed frequency. For most pins, this default frequency is roughly 490Hz. Pins 5 and 6 run at 980Hz.

While 490Hz is fine for dimming an LED, it falls squarely in the audible frequency range. When you drive a brushed DC motor or a high-power inductor at 490Hz, the magnetic fields cause physical vibration in the coils and stator—a phenomenon known as magnetostriction. The result is an annoying, high-pitched mechanical whine. To eliminate this, we push the PWM frequency above the human hearing threshold (typically 20kHz) by directly manipulating the hardware timers.

Callout Tip: Never use analogWrite() on Pin 9 or 10 if you are also using the Servo.h library. The Servo library hijacks Timer1 to generate its 50Hz pulses, which instantly disables hardware PWM on pins 9 and 10, leaving them stuck HIGH or LOW.

ATmega328P Hardware Timer & PWM Architecture

Understanding which timer controls which pin is the most common stumbling block in embedded PWM design. The table below maps the physical pins to their underlying hardware timers on the ATmega328P (Uno/Nano).

Digital Pin Hardware Timer Default Freq Max Resolution Notes & Conflicts
3 Timer 2 490 Hz 8-bit (0-255) Phase Correct PWM by default
5 Timer 0 980 Hz 8-bit (0-255) Warning: Alters millis() and delay() if freq is changed
6 Timer 0 980 Hz 8-bit (0-255) Warning: Alters millis() and delay() if freq is changed
9 Timer 1 490 Hz 16-bit (0-65535) Used by Servo.h; ideal for custom high-freq PWM
10 Timer 1 490 Hz 16-bit (0-65535) Used by Servo.h; shares Timer1 with Pin 9
11 Timer 2 490 Hz 8-bit (0-255) Shared with Timer 2 (Pin 3)

Parts List & Pin Mapping

This build targets the Arduino Nano v3 (ATmega328P, 16MHz, 5V logic). We are using a dedicated H-bridge motor driver rather than a single MOSFET to allow for bidirectional control and built-in dead-time protection, which prevents shoot-through current that can fry your silicon.

Required Components

  • Microcontroller: Arduino Nano v3 (ATmega328P variant, not the newer Nano Every or Nano 33 IoT for this specific timer code).
  • Motor Driver: TB6612FNG Dual Motor Driver Carrier (Handles 1.2A continuous, 3.2A peak per channel. Far superior to the ancient L298N which drops ~2V across its bipolar transistors).
  • Motor: 12V Brushed DC Motor (e.g., RS-550 or similar 1-3A draw).
  • Input: 10kΩ Linear Taper (B10K) Potentiometer.
  • Power Supply: 12V DC bench supply or battery pack (capable of ≥3A).

Pin Mapping Table

Arduino Nano Pin TB6612FNG Pin Function
D9 (PWM)PWMA20kHz PWM Signal (Channel A)
D7AIN1Direction Logic 1
D8AIN2Direction Logic 2
5VVCCLogic Power (5V)
GNDGNDCommon Ground (Crucial)
A0N/APotentiometer Wiper (Analog Read)
N/AVM12V Motor Power Supply
N/ASTBYTie to VCC (5V) to keep active

Wiring & Compilable 20kHz Code

Before uploading, wire the potentiometer wiper to A0, with the outer legs to 5V and GND. Connect the TB6612FNG VM to your 12V supply, and ensure the 12V supply GND is tied to the Arduino GND. Without a common ground, the PWM logic signals will float, causing erratic motor behavior.

  1. Connect Nano D9 to TB6612FNG PWMA.
  2. Connect Nano D7 to AIN1, and D8 to AIN2.
  3. Tie TB6612FNG STBY to 5V (or a digital pin if you want software standby).
  4. Connect Motor leads to A01 and A02.

The code below uses the TimerOne library to reconfigure Timer1 for a 50-microsecond period (20kHz). Note that TimerOne uses a 10-bit resolution (0-1023) for duty cycle, unlike the 8-bit (0-255) resolution of standard analogWrite().

#include <TimerOne.h>

// --- PIN DEFINITIONS ---
const int PIN_PWM = 9;      // Must be Timer1 pin (9 or 10 on Uno/Nano)
const int PIN_AIN1 = 7;     // Direction logic 1
const int PIN_AIN2 = 8;     // Direction logic 2
const int PIN_POT = A0;     // Potentiometer analog input

// --- CONSTANTS ---
const int PWM_PERIOD_US = 50; // 50us = 20,000Hz (20kHz)
const int DEADZONE_LOW = 40;  // Ignore pot noise at the bottom
const int DEADZONE_HIGH = 980; // Max out before physical end of pot travel

void setup() {
  pinMode(PIN_AIN1, OUTPUT);
  pinMode(PIN_AIN2, OUTPUT);
  pinMode(PIN_POT, INPUT);
  
  // Set initial direction (Forward)
  digitalWrite(PIN_AIN1, HIGH);
  digitalWrite(PIN_AIN2, LOW);
  
  // Initialize Timer1 for 20kHz PWM
  Timer1.initialize(PWM_PERIOD_US); 
  Timer1.pwm(PIN_PWM, 0); // Start at 0% duty cycle
  
  Serial.begin(115200);
  Serial.println("20kHz Silent PWM Motor Controller Initialized.");
}

void loop() {
  int rawPot = analogRead(PIN_POT);
  
  // Error handling / Noise filtering: Implement a deadzone
  int mappedPWM = 0;
  
  if (rawPot < DEADZONE_LOW) {
    mappedPWM = 0; // Hard stop to prevent low-voltage twitching
  } else if (rawPot > DEADZONE_HIGH) {
    mappedPWM = 1023; // Full speed
  } else {
    // Map the usable range to TimerOne's 10-bit resolution (0-1023)
    mappedPWM = map(rawPot, DEADZONE_LOW, DEADZONE_HIGH, 0, 1023);
  }
  
  // Apply PWM via TimerOne
  Timer1.pwm(PIN_PWM, mappedPWM);
  
  // Debug output (throttled to avoid serial buffer flooding)
  static unsigned long lastPrint = 0;
  if (millis() - lastPrint > 250) {
    Serial.print("Pot Raw: ");
    Serial.print(rawPot);
    Serial.print(" | PWM Duty (10-bit): ");
    Serial.println(mappedPWM);
    lastPrint = millis();
  }
  
  delay(10); // Small delay for ADC settling
}

Debugging PWM Failures & Exact Error Strings

When working with Arduino pulse width modulation across different ecosystems, you will inevitably hit compiler errors or silent runtime failures. Below are the most common exact error strings and their ranked causes.

The ESP32 Migration Error

If you copy standard AVR Arduino PWM code to an ESP32 running Arduino Core v3.x, compilation will fail. In 2024, Espressif officially deprecated and removed analogWrite() from the ESP32 Arduino Core to enforce the use of the LED Control (LEDC) peripheral API.

error: 'analogWrite' was not declared in this scope

Ranked Causes & Fixes:

  1. Using ESP32 Core 3.x: The function no longer exists. Fix: Replace analogWrite(pin, val) with ledcAttach(pin, freq, resolution) in setup, and ledcWrite(pin, val) in the loop.
  2. Missing Include: If using a wrapper library that attempts to alias it, ensure you have the correct board manager URL and core version installed.

The TimerOne Architecture Error

If you attempt to compile the 20kHz code above on an Arduino Mega 2560 or an ARM-based board (like the Zero or Nano 33 IoT), the compiler will halt.

#error "TimerOne library only supports ATmega168/328"

Ranked Causes & Fixes:

  1. Wrong Board Selected: You are using a non-AVR board. Fix: Use the PWM.h library for ARM boards, or the ESP32_AnalogWrite wrapper for ESP32.
  2. Arduino Mega Pin Mapping: The Mega uses different pins for Timer1 (Pins 11 and 12). If you must use a Mega, switch to the TimerThree or TimerFour libraries and update your physical wiring to match the Mega's timer pinout.

The First Three Things to Check When PWM Fails

If your code compiles but the motor stutters, whines, or refuses to spin, run through this diagnostic sequence before rewriting your code.

1. Is the pin actually a hardware PWM pin?
Check the silkscreen on your Arduino clone. Hardware PWM pins are marked with a tilde (~). If you call analogWrite() or attach a Timer library to a non-PWM pin (like D4 or D12 on a Nano), the microcontroller will simply output a static HIGH (5V) or LOW (0V) depending on whether the duty cycle is > 127.
2. Is a library hijacking the timer?
If your motor on Pin 9 is stuck at 100% speed, check your includes. The Servo.h library requires Timer1 to generate its 50Hz (20ms period) pulses. It silently disables Phase Correct PWM on Pins 9 and 10. Move your motor to Pin 3 (Timer2) or use the ESP32Servo library if on an ESP32, which uses the LEDC peripheral and avoids timer conflicts.
3. Are you falling into the IRF520 MOSFET trap?
Many beginners use the cheap KY-023 IRF520 MOSFET breakout module for PWM motor control. The IRF520 is not a logic-level MOSFET. Its Gate-Source Threshold Voltage ($V_{GS(th)}$) is up to 4.0V, meaning at 5V logic, it is barely turning on. The $R_{DS(on)}$ remains high, causing massive voltage drop and heat. Fix: Use a true logic-level MOSFET like the IRLZ44N, or stick to dedicated H-bridge ICs like the TB6612FNG.

How to Extend or Simplify the Build

To Simplify: If you are driving a small 5V fan or an LED strip where audible whine is not an issue, strip out the TimerOne library entirely. Replace Timer1.pwm(PIN_PWM, mappedPWM) with analogWrite(PIN_PWM, mappedPWM / 4). (Dividing by 4 scales the 10-bit 0-1023 value down to the 8-bit 0-255 range expected by analogWrite). This removes the external dependency and frees up Timer1 for other uses.

To Extend: Open-loop PWM only guesses the motor speed based on voltage. Under load, the motor will slow down. To build a professional-grade controller, add a quadrature rotary encoder to the motor shaft and wire it to the Arduino's hardware interrupt pins (D2 and D3). Implement the PID_v1 library to create a closed-loop feedback system that dynamically adjusts the PWM duty cycle to maintain a constant RPM regardless of mechanical load.

References & Further Reading