How Does a Servo Work? — A Deep Dive with the MG90S
Contents
Chapter 1: Producing the PWM Signal — Arduino and ESP32
1.1 Does It Eat All Your CPU Time?
1.2 Hardware Timers on the Arduino Uno
1.3 What Happens When You Call write(90)?
1.4 Driving 12 Servos from One Timer in Arduino
1.5 ESP32 and LEDC Timers
1.6 How LEDC(ESP32) Works Under the Hood
1.7 Comparison with Arduino Uno
Side Note: The Precision Problem — Timer Bit Width Matters
Chapter 2: Inside the Servo Motor
2.1 Servo Motor (MG90S)
2.2 Teardown — What’s Inside
2.3 The MG90S — Specs First
2.4 Top View — How It All Connects
2.5 Deep Down — The Control Loop
2.6 The Gear Train — Why It’s There
2.7 The RC Filter — Turning PWM into a Voltage
Chapter 3: Deadband — The Servo’s Blind Spot
Chapter 4: Putting It All Together
Introduction
We were trying to build a small robot that we saw on the internet(https://github.com/dorianborian/sesame-robot). It is a cool robot. As were assembling the parts of it, we were seeing some problems with the servo. It was a bit strange. Something as simple as making a servo motor move couple of degrees seem very trivial. Some of servos seem to be malfunctioning. We decided to open them and check what was wrong. To understand what was we had to understand how does a servo motor work. And that took us into a very interesting rabbit hole. Here we go ->
We were using MG90S servo, so we try to dive deep into them. But I believe the principle is similar across other servos too. We try to trace the complete process from sending the command from arduino/esp32S3 to the servo turning and stopping at the exact spot.
There are majorly two parts to the story ->
- How does a microcontroller(arduino uno/esp32s3) provide a PWM signal
- How does the servo work given an PWM signal(pulse width mouldation)
Chapter 1: Producing the PWM Signal — Arduino and ESP32
We essentially want to produce a PWM(Pulse width modulating signal). Something like the signal mentioned in the datasheet.
We need to generate that signal on one of the GPIOs to make sure that the servo moves. However, in the code, we have written servo.write(90). What actually happens in the hardware?
1.1 Does It Eat All Your CPU Time?
The naive approach — toggling a pin high for 1.5 ms and then low for 18.5 ms using delayMicroseconds() — does block the CPU for the entire 20 ms period. This is useless in any real program.
Here’s what that looks like written out:
// Naive blocking servo pulse — DO NOT USE in real code
const int SERVO_PIN = 9;
const int PULSE_US = 1500; // 90° — center position
const int PERIOD_US = 20000; // 50 Hzvoid setup() {
pinMode(SERVO_PIN, OUTPUT);
}void loop() {
digitalWrite(SERVO_PIN, HIGH);
delayMicroseconds(PULSE_US); // CPU stalls here for 1.5 ms digitalWrite(SERVO_PIN, LOW);
delayMicroseconds(PERIOD_US - PULSE_US); // CPU stalls here for 18.5 ms // Nothing else can happen — no sensor reads, no communication,
// no second servo. The CPU is burning 100% of its time waiting.
}
For a single servo doing nothing else, this technically works. The moment you add a second servo, a sensor read, or any time-sensitive logic, the accumulated delay calls desynchronize everything. This is why the real library uses hardware timers.
The right approach uses hardware timers.
1.2 Hardware Timers on the Arduino Uno
The ATmega328P in the Uno has three hardware timers:
TimerBitsPrimary useTimer08-bitmillis(), delay()Timer116-bitServo libraryTimer28-bittone()
The Arduino Servo library seizes Timer1 and configures it with a prescaler of 8:
// ../Servo/src/avr/Servo.cpp — initISR()
TCCR1A = 0; // normal counting mode
TCCR1B = _BV(CS11); // set prescaler of 8
TCNT1 = 0; // clear the timer count
TIMSK1 |= _BV(OCIE1A); // enable output compare interruptWith a 16 MHz clock and a prescaler of 8, each timer tick is:
tick duration = 8 / 16,000,000 Hz = 0.5 µsThe library macro encodes this directly:
// ../Servo/src/avr/Servo.cpp
#define usToTicks(_us) ((clockCyclesPerMicrosecond() * _us) / 8)
// At 16 MHz: (16 * us) / 8 = 2 * us ticks per microsecondSo usToTicks(1500) = 3000 ticks = 1500 µs. Clean and exact.
Here is what one servo at 1500 µs (90°) looks like in terms of interrupts:
T=0 T=1.5ms T=20ms
| | |
├──── pin HIGH ─────────┤──────────────── idle ────────────────────┤─ (repeats)
└─ pin LOW ↑ IRQ #1 ↑ IRQ #2 ↑ IRQ #3
TCNT reset to 0 pin goes LOW TCNT reset to 0
pin goes HIGH no more servos pin goes HIGH
OCR = 3000 ticks OCR = 40000 ticks (= 20ms mark) OCR = 3000 ticks
(= 1500 µs from now) Channel = -1 (frame done)Two interrupts per frame. IRQ #1 starts the pulse; IRQ #2 ends it and schedules the next interrupt at exactly the 20 ms frame boundary. The CPU is completely free for the 18.5 ms gap between them — only woken twice per cycle, for a few microseconds each time.
1.3 What Happens When You Call write(90)?
The library defines:
// ../Servo/src/Servo.h
#define MIN_PULSE_WIDTH 544 // µs — shortest pulse the library will send
#define MAX_PULSE_WIDTH 2400 // µs — longest pulse
#define DEFAULT_PULSE_WIDTH 1500write(value) checks: if value < MIN_PULSE_WIDTH (544) it's treated as degrees and mapped to the µs range; otherwise it's used as µs directly. This is the boundary between the two calling conventions:
// ../Servo/src/avr/Servo.cpp
void Servo::write(int value) {
if (value < MIN_PULSE_WIDTH) { // treat as degrees
value = map(value, 0, 180, SERVO_MIN(), SERVO_MAX());
}
this->writeMicroseconds(value);
}writeMicroseconds() then does two things — applies a TRIM_DURATION = 2 tick correction to compensate for the latency of the digitalWrite call inside the ISR, then atomically updates the tick count:
// ../Servo/src/avr/Servo.cpp
void Servo::writeMicroseconds(int value) {
value = value - TRIM_DURATION; // compensate for ISR overhead
value = usToTicks(value); uint8_t oldSREG = SREG;
cli(); // disable interrupts — atomic write
servos[channel].ticks = value;
SREG = oldSREG; // restore interrupt state
}The cli()/SREG restore pattern prevents a race condition: without it, the ISR could fire mid-write and read a half-updated tick value.
1.4 Driving 12 Servos from One Timer in Arduino
The library header states it plainly:
// ../Servo/src/Servo.h
#define SERVOS_PER_TIMER 12 // max servos per 16-bit timer
#define MAX_SERVOS (_Nbr_16timers * SERVOS_PER_TIMER)
// On Uno (_Nbr_16timers = 1): MAX_SERVOS = 12
// On Mega (_Nbr_16timers = 4): MAX_SERVOS = 48If there’s only one 16-bit timer on the Uno, how does it pulse 12 independent channels? The trick is interrupt chaining — a single ISR cycles through all servos sequentially within the 20 ms window.
The entire mechanism lives in handle_interrupts():
// ../Servo/src/avr/Servo.cpp
static inline void handle_interrupts(timer16_Sequence_t timer,
volatile uint16_t *TCNTn,
volatile uint16_t *OCRnA)
{
if (Channel[timer] < 0)
*TCNTn = 0; // Channel=-1 means we just finished the refresh
// interval, reset timer to start a new frame
else {
// End the pulse for the previous channel
if (SERVO(timer, Channel[timer]).Pin.isActive)
digitalWrite(SERVO(timer, Channel[timer]).Pin.nbr, LOW);
}Channel[timer]++; // advance to the next servo if (/* more servos to pulse */) {
// Schedule the next interrupt at: now + this servo's pulse width
*OCRnA = *TCNTn + SERVO(timer, Channel[timer]).ticks;
if (SERVO(timer, Channel[timer]).Pin.isActive)
digitalWrite(SERVO(timer, Channel[timer]).Pin.nbr, HIGH);
} else {
// All servos pulsed — wait out the rest of the 20 ms frame
*OCRnA = (unsigned int)usToTicks(REFRESH_INTERVAL);
Channel[timer] = -1; // will be incremented to 0 at frame start
}
}
The timeline for 3 attached servos looks like this:
T=0 T=1.2ms T=1.9ms T=2.5ms T=20ms
| | | | |
[S1 HIGH] [S1 LOW] [S2 LOW] [S3 LOW] [frame restarts]
[S2 HIGH] [S3 HIGH] [idle — no interrupts]
IRQ IRQ IRQ IRQEach ISR call takes ~microseconds. The CPU is free to run user code during the long idle gaps. All pulses are serialized (never simultaneous), and they all start from the same T=0 reference, so servo motion stays synchronized across channels.
One subtle consequence: if you attach all 12 servos at maximum position (2400 µs each), the total pulse train is 12 × 2400 = 28,800 µs, which exceeds the 20 ms REFRESH_INTERVAL. The library handles this gracefully — it still waits for at least REFRESH_INTERVAL before restarting even if the pulses ran long — but update rate will drop below 50 Hz when many servos are near maximum.
1.5 ESP32 and LEDC Timers
The ESP32 replaces the ATmega’s interrupt-chained approach with the LEDC (LED Controller) peripheral — originally designed for LED brightness control, but its hardware PWM output is exactly what servos need.
1.5.1 Hierarchy: Group → Timer → Channel
LEDC is organized in three levels:
Group (speed mode)
└── Timer (sets frequency + resolution — the clock source)
└── Channel (sets duty cycle — drives one GPIO pin)Group — ESP32 has two groups: high-speed (Group 0) and low-speed (Group 1). Each group has 8 channels and 4 timers. The ESP32S3 dropped the high-speed group entirely, leaving only Group 0 with 8 channels.
Timer — sets the PWM frequency and bit resolution for all channels attached to it. It doesn’t touch any pin. There are 4 timers per group.
Channel — holds the duty cycle value and drives one GPIO pin. It borrows its clock from whichever timer it’s assigned to. Two channels share each timer:
Group 0
├── Timer 0 ──► Channel 0 ──► GPIO pin
│ └─► Channel 1 ──► GPIO pin
├── Timer 1 ──► Channel 2 ──► GPIO pin
│ └─► Channel 3 ──► GPIO pin
├── Timer 2 ──► Channel 4 ──► GPIO pin
│ └─► Channel 5 ──► GPIO pin
└── Timer 3 ──► Channel 6 ──► GPIO pin
└─► Channel 7 ──► GPIO pinGroup 1 (standard ESP32 only — removed on ESP32S3)
├── Timer 0 ──► Channel 0 ──► GPIO pin
│ └─► Channel 1 ──► GPIO pin
... (same pattern, 8 more channels)From the library source:
ledc: 0 => Group: 0, Channel: 0, Timer: 0
ledc: 1 => Group: 0, Channel: 1, Timer: 0 ← shares Timer 0 with channel 0
ledc: 2 => Group: 0, Channel: 2, Timer: 1
ledc: 3 => Group: 0, Channel: 3, Timer: 1 ← shares Timer 1 with channel 2
...
ledc: 8 => Group: 1, Channel: 0, Timer: 0 ← second group (standard ESP32 only)
ledc: 9 => Group: 1, Channel: 1, Timer: 0
...1.5.2 How many servos can LEDC drive?
The answer depends on whether all servos share the same frequency, because channels sharing a timer must run at the same frequency — the timer is the frequency.
All servos at 50 Hz (typical servo use): Both channels on every timer can be used. Full channel count available.
All servos at different frequencies: Each frequency needs its own timer. You can only use one channel per timer — the second channel is stranded.
ChipGroupsTimersChannelsSame frequencyAll differentStandard ESP3228 total16 total16 servos8 servosESP32S314 total8 total8 servos4 servos
The library enforces the shared-frequency constraint and warns if you violate it:
// ../ESP32Servo/src/ESP32PWM.cpp — checkFrequencyForSideEffects()
if (ChannelUsed[pwm]->getTimer() == getTimer()) {
double diff = abs(ChannelUsed[pwm]->myFreq - freq);
if (abs(diff) > 0.1) {
ESP_LOGW(TAG, "WARNING PWM channel %d shares a timer with channel %d\n"
"changing frequency to %.3f Hz will ALSO change channel %d\n"
"from its previous frequency of %.3f Hz",
pwmChannel, pwm, freq, pwm, ChannelUsed[pwm]->myFreq);
}
}For servos all running at 50 Hz this is never a problem. It only bites you if you mix servos with other PWM outputs (like motor controllers or LEDs at different frequencies) on the same timer group.
1.6 How LEDC(ESP32) Works Under the Hood
This is where LEDC stops being intuitive if you’re used to microcontrollers that rely on ISRs for PWM. The whole peripheral is a pipeline of hardware blocks driven by a continuous clock — no CPU involvement after setup.
1.6.1 The Timer — a free-running counter
Each LEDC timer is a counter that increments on every tick of a divided-down clock and wraps at 2^N:
APB clock (80 MHz)
│
[Prescaler ÷ D] ← 18-bit divider, fractional
│
[Counter: 0 → 2^N → 0 → ...] ← N = bit resolution, 10–20 bitsThe output frequency falls directly out of these two values:
f = APB_CLK / (prescaler × 2^N)Example — 50 Hz at 16-bit:
prescaler = 80,000,000 / (50 × 65,536) ≈ 24.4 (fractional part handled in hardware)The timer has no awareness of pins or duty cycles. It just counts. That’s all it does.
The key timer registers:
LEDC_HSTIMERx_CONF_REG
├── TICK_DIV [18 bits] — prescaler value (integer + fractional)
├── HSTIMER_LIM [ 5 bits] — bit resolution N
├── HSTIMER_RST — reset counter to 0
└── HSTIMER_PAUSE — freeze the counter without losing configLEDC_HSTIMERx_VALUE_REG — current counter value (read-only)1.6.2 The Channel — two comparators and a latch
Each channel watches its assigned timer’s counter through two thresholds:
Counter: 0 ──────[HPOINT]──────────────[HPOINT + DUTY]──── 2^N
Output: ↑ goes HIGH ↓ goes LOW- HPOINT — the counter value at which the output pin goes HIGH. For servo PWM this is always 0: every pulse starts at the beginning of the cycle.
- DUTY — how many ticks to stay HIGH. This encodes the pulse width directly.
For a 1500 µs pulse at 16-bit 50 Hz:
DUTY ticks = 1500 / (20,000 / 65,536) = 4,915 ticks
Output HIGH: counter 0 → 4,915
Output LOW: counter 4,915 → 65,535The channel registers:
LEDC_HS_CHx_CONF0_REG
├── TIMER_SEL — which of the 4 timers drives this channel
└── SIG_OUT_EN — enable the outputLEDC_HS_CHx_HPOINT_REG — HIGH threshold (almost always 0 for servos)
LEDC_HS_CHx_DUTY_REG — duty ticks, left-shifted 4 bits (sub-tick fractional bits)LEDC_HS_CHx_CONF1_REG
└── DUTY_START — write 1 to latch the shadow register into the active comparator
1.6.3 Shadow registers — glitch-free updates
Writing a new duty value mid-cycle would create a corrupted pulse — you might get a 700 µs pulse if the update landed halfway through. LEDC prevents this with a shadow register:
ledcWrite(channel, duty)
→ new value lands in shadow register (takes no effect yet)
→ at the next counter overflow (cycle boundary): shadow → active comparator
→ next pulse uses the new duty, cleanly from the startThis means ledcWrite() is always safe to call at any time. The hardware guarantees the update never corrupts the current pulse.
1.6.4 The full pipeline
APB 80 MHz
│
▼
[Prescaler]
│
▼
[Counter 0 → 2^N] ──────────────────────────────────────────┐
│ │
▼ ▼
[Comparator: count == HPOINT?] [Comparator: count == HPOINT+DUTY?]
│ HIGH │ LOW
└──────────► SR latch ◄───────────────────┘
│
▼
[GPIO matrix] ← routes this channel's wire to any physical pin
│
▼
GPIO output pinThe GPIO matrix is a separate hardware crossbar. ledcAttachPin(pin, channel) writes to the matrix registers to route the channel's output wire to your chosen pin — this is why any LEDC channel can drive any GPIO-capable pin. More can be read about GPIO matrix here(It is a very cool idea).
1.6.5 What ledcWrite actually does
ledcWrite(channel, 4915)
→ write 4915 to LEDC_HS_CHx_DUTY_REG (shadow)
→ set DUTY_START = 1 in LEDC_HS_CHx_CONF1_REG
→ done — CPU walks awayThat’s a register write and a single bit flip. Everything after that is hardware. The counter keeps running, the comparators keep firing, the GPIO keeps toggling. None of it needs the CPU.
No interrupt chaining — pure hardware. The Arduino Servo library fires an ISR at every pulse boundary and manually toggles GPIO pins from software (the handle_interrupts() loop). LEDC has no equivalent. The CPU only touches LEDC when the code sends a new position — and even then it's two register writes. This is the fundamental architectural difference: Arduino uses a timer as a clock source and does all output work in software; LEDC channels are complete self-contained PWM outputs in silicon.
1.7 Comparison with Arduino Uno
FeatureArduino Uno (Timer1)ESP32 LEDCTimer instances18 (standard ESP32) / 4 LEDC + 6 MCPWM (S3)Channels per timer12 (software mux)2 (hardware, independent)Total channels1216 (standard ESP32) / 20 (ESP32S3)PulsesSerializedTruly simultaneousClock source16 MHz / prescaler 8APB 80 MHzPulse timing jitter~0.5 µs (one tick)Hardware-clocked
On ESP32, every servo gets its own hardware-generated pulse. There is no interrupt chaining, no serialization. Twelve servos update truly simultaneously — critically important for coordinated motion like walking robots.
1.7 Timer Width and Resolution
Here’s where the ESP32 library has a non-obvious default. The ESP32Servo library defaults to a 10-bit timer (DEFAULT_TIMER_WIDTH = 10):
// ../ESP32Servo/src/ESP32Servo.h
#define DEFAULT_TIMER_WIDTH 10
#define DEFAULT_TIMER_WIDTH_TICKS 1024 // 2^10With a 10-bit timer at 50 Hz, the tick length is:
tick = 20,000 µs / 1024 steps = 19.53 µs per stepThe usToTicks conversion the library uses:
// ../ESP32Servo/src/ESP32Servo.cpp
int Servo::usToTicks(int usec) {
return (int)((double)usec /
((double)REFRESH_USEC / (double)timer_width_ticks));
// = usec / (20000 / 1024)
// = usec / 19.53
}For writeMicroseconds(1500):
ticks = 1500 / 19.53 ≈ 76 ticksWith only 102 distinct positions across the full 500–2500 µs range, that maps to roughly 1.76° per step — a jump large enough to see with the naked eye on a slow sweep. Any animated motion (a robot arm easing into position, a camera gimbal panning smoothly) will look visibly stepped rather than smooth. setTimerWidth(16) costs nothing and gives 6,500+ positions; there is no reason to leave the default at 10-bit for servo work.
Get Niranjan Reddy’s stories in your inbox
Join Medium for free to get updates from this writer.
That’s a very coarse count. To get finer control, call setTimerWidth() before attach():
Servo myServo;void setup() {
myServo.setTimerWidth(16); // must be called before attach()
myServo.attach(SERVO_PIN, 500, 2500);
myServo.write(90);
}The setTimerWidth is an ESP32-only extension:
// ../ESP32Servo/src/ESP32Servo.cpp — allowed range: 10 to 20 bits
void Servo::setTimerWidth(int value) {
if (value < MINIMUM_TIMER_WIDTH) value = MINIMUM_TIMER_WIDTH; // 10
else if (value > MAXIMUM_TIMER_WIDTH) value = MAXIMUM_TIMER_WIDTH; // 20this->timer_width = value;
this->timer_width_ticks = pow(2, this->timer_width); if (this->attached()) { // re-init the LEDC channel with new width
pwm.detachPin(this->pinNumber);
pwm.attachPin(this->pinNumber, REFRESH_CPS, this->timer_width);
}
}
Side Note: The Precision Problem — Timer Bit Width Matters
The number of distinct positions you can command is determined by how fine the timer’s tick is relative to the servo’s pulse range (~2000 µs):
Positions = servo_range_µs / timer_tick_µsAVR (Arduino Uno) — Timer1, prescaler 8 Tick = 0.5 µs → ~3,700 distinct positions across 180°. The mechanical system is the bottleneck here, not the timer.
ESP32 — LEDC, default 10-bit timer Tick = 20,000 µs / 1024 steps = 19.53 µs → only ~102 distinct positions. That’s ~1.76° per step — you can see the jumps with the naked eye. Always call
setTimerWidth()for servo work.ESP32 — LEDC, 16-bit timer (recommended)
myServo.setTimerWidth(16); // call before attach()Tick = 0.305 µs → ~6,500 positions. Now the servo hardware is the bottleneck. The MG90S deadband of ~10 µs means the servo won’t respond to changes smaller than ~0.9° regardless, so 16-bit is the practical sweet spot.
ESP32 — LEDC, 20-bit timer (maximum) Tick ≈ 0.019 µs → ~105,000 positions. Way beyond what any hobby servo can act on, but useful for high-precision industrial servos or smooth animation interpolation.
Chapter 2: Inside the Servo Motor
Now, lets see how the PWM signal we sent is used to turn the servo motor.
2.1 Servo Motor (MG90S)
A servo is nothing but a control loop. Opening it apart showed us roughly what the control loop looked like.
2.2 Teardown — What’s Inside
We cracked open the four screws on the bottom and the MG90S reveals three distinct subsystems stacked on top of each other:
- The DC motor — a small coreless brushed motor at the bottom
- The gear train — a stack of metal reduction gears
- The control PCB — a tiny board containing the comparator IC, motor H bridge, transistors, and potentiometer
Before we delve into the control loop, a small side note on the MG90S servos. When we looked at the datasheet, it claims the full range of servo could be achieved by a span of 1000–2000 µs. However, when it was tested, the actual range was found to be around 500–2500 µs. We ended up using 500 to 2900 µs to be safe.
2.3 The MG90S — Specs First
The MG90S is a 9g metal-gear micro servo, one of the most common hobbyist servos in existence. Before tearing it down, here are the key numbers we found from the datasheet.
ParameterValueOperating voltage4.8 V — 6 VStall torque1.8 kg·cm (4.8 V) / 2.2 kg·cm (6 V)No-load speed0.1 s/60° (4.8 V) / 0.08 s/60° (6 V)Pulse range500 µs — 2500 µs (datasheet: 1000–2000 µs)PWM period20,000 µs (20 ms)PWM frequency50 HzAngular range~180°Deadband~±5–10 µs (~0.5°–0.9°)Weight~13.4 g
The most important numbers for control are the pulse range and frequency. A pulse of 500 µs commands the servo to one extreme (~0°). A pulse of 2500 µs commands the other extreme (~180°). A pulse of 1500 µs sits in the dead center. This pulse repeats every 20 ms, giving a frequency of:
f = 1 / T = 1 / 0.020 s = 50 Hz2.4 Top View — How It All Connects
Looking down at the PCB, three external wires come in:
- Brown / Black — Ground
- Red — VCC (4.8 V — 6 V)
- Orange / Yellow — Signal (PWM input)
The signal wire feeds into the control IC(We could not decipher exact IC number). The motor connects to the IC’s output stage. The potentiometer shaft is mechanically coupled to the final output gear, so it tracks the horn’s actual position. This is the feedback path that makes a servo a closed-loop system, not just a motor.
Here is the servo as a control loop:
┌─────────────────────────────────────────────────┐
│ SERVO INTERNALS │
│ │
PWM pulse ──► RC │ V_ref ──► Comparator ──► H-Bridge ──► Motor │
(from MCU) Filter │ ▲ │ │
│ │ Gear train│
│ V_fb (error) │ │
│ │ ▼ │
│ Potentiometer ◄──────── Output shaft │
│ │
└─────────────────────────────────────────────────┘The potentiometer is the key: it continuously converts shaft angle back into a voltage, closing the loop. The servo doesn’t know or care what angle you want in degrees — it only knows how to drive the error (V_ref − V_fb) to zero.
2.5 Deep Down — The Control Loop
This is where it gets interesting. The servo is a closed-loop position controller built from three components: a comparator, an H-bridge, and a potentiometer.
2.5.1 The Potentiometer as a Position Sensor
The potentiometer sits at the top of the gear train, coupled directly to the output shaft. As the horn rotates, the wiper voltage changes linearly. This voltage represents the current position.
The incoming PWM signal is passed through an RC low-pass filter (more on this later) to produce a reference voltage representing the commanded position.
2.5.2 The Comparator
A comparator (often a dedicated servo control IC) takes two inputs:
- V_ref — the filtered command voltage (where you want the servo)
- V_fb — the potentiometer voltage (where the servo is)
It computes the error: V_ref − V_fb. The sign of the error tells the motor which direction to spin; the magnitude tells it to spin at full power or stop.
2.5.3 The H-Bridge
To spin a DC motor in both directions, you need an H-bridge — four transistors arranged so that current can flow through the motor in either direction.
VCC
|
[Q1] [Q2]
| |
──+── Motor ──+──
| |
[Q3] [Q4]
|
GND- Forward (Q1 + Q4 closed, Q2 + Q3 open): current flows left-to-right through the motor
- Reverse (Q2 + Q3 closed, Q1 + Q4 open): current flows right-to-left
- Brake (Q3 + Q4 closed): motor terminals shorted — rapid braking
The comparator output drives the transistor gates. When the error is zero — when the horn reaches the commanded angle — the comparator shuts off the H-bridge and the motor stops.
This is why servos hold position under load: any external torque displacing the horn creates a non-zero error, and the motor corrects it.
2.5.4 Transistor Types in the H-Bridge
In hobby servo H-bridges, the four switches are typically a mix of NPN and PNP BJTs (or N-channel / P-channel MOSFETs in newer designs):
VCC
|
[Q1: PNP] [Q2: PNP] ← high-side switches, activated by pulling base LOW
| |
──+──── Motor ───+──
| |
[Q3: NPN] [Q4: NPN] ← low-side switches, activated by pulling base HIGH
|
GND- PNP transistors (Q1, Q2): connected between VCC and the motor. They switch ON when their base is pulled LOW relative to the emitter. The comparator output drives them through level-shifting resistors.
- NPN transistors (Q3, Q4): connected between the motor and GND. They switch ON when their base is driven HIGH. These are easier to drive directly from logic-level signals.
For forward rotation: Q1 (PNP) + Q4 (NPN) turn ON simultaneously. For reverse: Q2 (PNP) + Q3 (NPN). The comparator ensures Q1+Q2 or Q3+Q4 are never both ON at the same time (a “shoot-through” short from VCC to GND).
2.5.5 Flyback Diodes
A DC motor is an inductive load — it has a coil of wire. When the H-bridge switches off, the collapsing magnetic field in the coil tries to keep current flowing. With nowhere to go, it generates a voltage spike (back-EMF) that can be many times VCC — enough to destroy the transistors.
A flyback diode (also called a freewheeling or snubber diode) is placed in anti-parallel across each transistor. When the spike appears, the diode conducts and clamps the voltage to a safe level, dissipating the energy as heat rather than killing the transistor.
[Q1: PNP]
│ ↑D1 (flyback diode — conducts when motor back-EMF spikes)
│──┘In a full H-bridge that means four diodes, one per transistor. In integrated H-bridge ICs (like the DRV8833 or L9110) these are built in.
More detail on the theory: Flyback diode — Wikipedia
2.6 The Gear Train — Why It’s There
A small coreless motor spins fast (~10,000 RPM) but has almost no torque. Directly coupling it to an output shaft that needs to hold position against load is useless. The gear train solves both problems.
The MG90S uses a reduction ratio of approximately 1:14 (varies slightly by production batch). This means:
- Output speed ≈ motor speed / 14 → slow, controlled rotation
- Output torque ≈ motor torque × 14 (minus gear losses) → enough to hold load
The MG90S uses metal gears rather than plastic — hence the “M” in MG90S. Under repeated stress, plastic gears strip; metal gears deform much less, which is why the MG90S is preferred in applications with impact or sustained load.
2.6.1 Compound Gear Ratio — The Full Formula
The MG90S gear train has four reduction stages. Each stage meshes a small driving gear (pinion) with a larger driven gear. The ratio of each stage is simply the tooth count of the driven gear divided by the tooth count of the driving gear:
Stage ratio = T_driven / T_drivingThe total (compound) ratio is the product of all stages:
R_total = (T2/T1) × (T4/T3) × (T6/T5) × (T8/T7)For example, if the four stages have individual ratios of 4.4 × 3.7 × 2.8 × … the product gives the overall ~1:14 reduction. The actual tooth counts vary between MG90S production batches and are not published, which is why the “~1:14” figure is approximate.
I did not count but I just wanted to mention the basic idea here. See this link for better understanding of gears -> https://www.youtube.com/watch?v=aGHrhm-Rcp0
2.7 The RC Filter — Turning PWM into a Voltage
The incoming signal is a PWM waveform — it’s either 3.3 V or 5 V, and it’s high for between 500 µs and 2500 µs out of every 20 ms. The control IC needs a DC voltage proportional to pulse width, not a pulsing digital signal.
An RC low-pass filter does this conversion:
Signal ──[R]──┬── to comparator input
|
[C]
|
GNDThe filter averages the PWM signal over time. The output DC voltage is:
V_out = VCC × (pulse_width / period)
= VCC × (t_high / 20 ms)For a 1500 µs pulse (center position):
V_out = 5 V × (1.5 ms / 20 ms) = 0.375 VThis voltage is the reference that the comparator tracks against the potentiometer wiper. The RC time constant is chosen to smooth the 50 Hz ripple while still responding quickly to new pulse-width commands — typically R ~ 1–10 kΩ and C ~ 1–10 µF.
Chapter 3: Deadband — The Servo’s Blind Spot
One last wrinkle: servos don’t respond to arbitrarily small changes in pulse width. There’s a deadband — a minimum error threshold below which the comparator doesn’t activate the H-bridge at all.
This exists by design. Without it, the motor would hunt forever — the feedback loop overcorrects, the error reverses sign, it overcorrects again, oscillating around the target. The deadband provides hysteresis: once the error is small enough, the motor shuts off and the servo settles.
On the MG90S, the deadband is roughly ±5–10 µs around the target position, corresponding to roughly ±0.05°.
Practical consequences:
- Jitter from noisy signal lines — if your PWM signal has ±5 µs of noise (common if the signal wire runs alongside motor power wires), the servo will buzz constantly. Use a separate power supply for the servo, keep signal wires short, and add a small capacitor (100 µF) across the servo power rail.
- Writing below the deadband — sending 1500 µs followed by 1502 µs will produce no movement. You need to exceed the deadband to see a response. The AVR library’s
TRIM_DURATION = 2tick (1 µs) correction exists partly for this reason — it offsets systematic ISR latency so the actual pulse lands where you commanded it. - Mechanical deadband stacks — gear lash (backlash in the gear train) adds another ~0.5°–1° of mechanical deadzone on top of the electrical deadband. For precision applications, this is the dominant error source, and why the 3,700-step AVR resolution or 6,500-step 16-bit ESP32 resolution both vastly exceeds what you can actually observe at the output shaft.
Chapter 4: Putting It All Together
When you call servo.write(90) on an Arduino Uno:
write(90)sees 90 < 544 (MIN_PULSE_WIDTH), so it maps degrees to µs:map(90, 0, 180, 544, 2400)→ ~1472 µs.writeMicroseconds(1472)subtractsTRIM_DURATION = 2ticks (1 µs), converts to ticks:usToTicks(1471)→ 2942 ticks.- With interrupts disabled,
servos[channel].ticks = 2942is written atomically. - The next
TIMER1_COMPAinterrupt fires.handle_interrupts()pulls up this servo's pin HIGH, loads OCR1A = TCNT1 + 2942. - 1471 µs later the interrupt fires again. The pin goes LOW. The next servo’s pin goes HIGH.
- The servo’s RC filter integrates the 1472 µs pulse into a DC reference voltage.
- The comparator subtracts the potentiometer voltage from the reference voltage.
- The non-zero error drives the H-bridge transistors, spinning the motor through the gear train.
- The potentiometer tracks the shaft, feeding back a new voltage.
- When the error drops below the ~10 µs deadband, the H-bridge shuts off.
- The servo holds position, correcting any external disturbance that pushes it off-target.
It was really fun to go deep and explore how the servos actually worked. I have used it a lot of times, but never realized the amount of complexity behind it. Next, I am gonna do a deep dive on working of cameras(OV5640) with esp32S3 board.








