STM32 Servo Motor Control with Timer PWM: SG90 Angle from a Potentiometer
Hobby servos such as the TowerPro SG90 and MG996R are used in robot arms, pan-tilt camera mounts, RC models and automatic door locks. Inside the plastic case there is a small DC motor, a gearbox, a potentiometer on the output shaft and a control circuit. You do not drive the motor directly; instead you send a pulse whose width tells the servo which angle to hold, and its own circuit does the rest.
This makes servos a perfect fit for STM32 hardware timers. In this tutorial you will configure TIM3 to produce the standard 50 Hz servo signal, write a function that converts an angle into a pulse width, calibrate the end points of your particular servo, and then control the angle with a potentiometer read by the ADC.
The Servo Control Signal
A standard analog servo expects one pulse every 20 ms, which is 50 Hz. The width of the pulse sets the position. The commonly quoted values are about 1 ms for one end, 1.5 ms for the centre and 2 ms for the other end. In practice many SG90 units need a wider range, roughly 0.5 ms to 2.4 ms, to cover the full 180 degrees, and the exact limits vary between units and makers. That is why the code below keeps the minimum and maximum pulse as adjustable constants.
|<-- pulse 0.5 to 2.4 ms -->|
+---------------------------+ +----
| | |
-----+ +-------------------------+
|<------------------- period 20 ms ------------------>|The best timer setting is one that makes each count equal to 1 microsecond. Then the compare value is simply the pulse width in microseconds, and you get 1 us resolution, which is far finer than a hobby servo can resolve.
Nucleo-F401RE, TIM3 on APB1, timer clock 84 MHz PSC = 83 -> 84 MHz / 84 = 1 MHz, 1 count = 1 us ARR = 19999 -> period = 20000 us = 20 ms (50 Hz) CCR = 1500 -> 1.5 ms pulse (centre) Blue Pill, timer clock 72 MHz: PSC = 71, ARR = 19999
Wiring
| Servo wire | Colour on SG90 | Connect to |
|---|---|---|
| Signal | Orange | PA6 (TIM3_CH1), Arduino D12 on the Nucleo |
| Power | Red | Separate 5 V supply, positive |
| Ground | Brown | Supply ground AND board GND (common ground) |
Connect a 10 k potentiometer as in the ADC tutorial: outer pins to 3V3 and GND, wiper to PA0 (A0). The 3.3 V pulse from the STM32 is normally accepted by 5 V hobby servos, because their input threshold is low, so no level shifter is needed for the SG90.
CubeMX Configuration
- Set PA6 to TIM3_CH1. In Timers, TIM3, choose Clock Source Internal Clock and Channel1 PWM Generation CH1.
- Set Prescaler 83, Counter Period 19999, auto-reload preload Enable, PWM mode 1, Pulse 1500, output compare preload Enable.
- Set PA0 to ADC1_IN0, single conversion, sampling time 84 cycles, as in the ADC tutorial.
- Keep USART2 enabled if you want to print the angle. Generate the code.
Servo Driver and Potentiometer Control
/* USER CODE BEGIN PV */
/* Calibrate these for your servo. Start with 1000 and 2000,
then widen slowly while watching for the servo to buzz at the ends. */
#define SERVO_MIN_US 500U /* pulse width at 0 degrees */
#define SERVO_MAX_US 2400U /* pulse width at 180 degrees */
#define SERVO_MAX_DEG 180U
/* USER CODE END PV */
/* USER CODE BEGIN 0 */
/* Convert an angle in degrees to a pulse width and load it into CCR1.
With a 1 MHz timer tick, CCR units are microseconds. */
static void servo_set_angle(uint32_t angle)
{
if (angle > SERVO_MAX_DEG) angle = SERVO_MAX_DEG;
uint32_t pulse = SERVO_MIN_US +
(angle * (SERVO_MAX_US - SERVO_MIN_US)) / SERVO_MAX_DEG;
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, pulse);
}
static uint16_t read_pot(void)
{
uint16_t v = 0;
HAL_ADC_Start(&hadc1);
if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK)
{
v = (uint16_t)HAL_ADC_GetValue(&hadc1);
}
HAL_ADC_Stop(&hadc1);
return v;
}
/* USER CODE END 0 */
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_ADC1_Init();
MX_TIM3_Init(); /* PSC 83, ARR 19999 -> 50 Hz, 1 us per count */
/* USER CODE BEGIN 2 */
servo_set_angle(90); /* start at centre */
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
HAL_Delay(500); /* let the servo get there */
uint32_t filtered = read_pot(); /* start the filter at the real value */
uint32_t last_angle = 90;
/* USER CODE END 2 */
while (1)
{
/* USER CODE BEGIN 3 */
/* Simple low-pass filter: new = old + (input - old) / 8.
It removes ADC jitter that would make the servo twitch. */
uint32_t raw = read_pot();
filtered = filtered + ((int32_t)raw - (int32_t)filtered) / 8;
uint32_t angle = (filtered * SERVO_MAX_DEG) / 4095U;
/* Only update when the angle really changes by 2 degrees or more */
if (angle > last_angle + 1U || angle + 1U < last_angle)
{
servo_set_angle(angle);
last_angle = angle;
}
HAL_Delay(20); /* one update per servo frame is enough */
/* USER CODE END 3 */
}
}
How the Code Works
servo_set_anglemaps 0 to 180 degrees linearly onto the calibrated pulse range. For 90 degrees with the default constants, pulse = 500 + 90 x 1900 / 180 = 1450 us.- Setting the compare value before
HAL_TIM_PWM_Startmeans the very first pulse is already at the centre position, so the servo does not jump to one end at power-up. - The low-pass filter and the 2-degree dead band stop the servo from hunting back and forth because of small ADC noise, which would otherwise cause buzzing and extra current.
HAL_Delay(20)matches the 20 ms frame. Updating CCR faster does nothing useful because the servo only reads one pulse per frame.
Calibrating the End Points
Temporarily replace the loop with calls to servo_set_angle(0) and servo_set_angle(180). If the servo buzzes or feels warm at an end, it is pressing against its internal mechanical stop; reduce SERVO_MAX_US or raise SERVO_MIN_US by 50 us at a time until it is quiet. Running a servo against its stop for long periods can strip the plastic gears.
Troubleshooting
- Servo does not move at all: no common ground between the servo supply and the STM32 board.
- Board resets when the servo moves: the servo is powered from the board's 5 V rail. Use a separate supply.
- Servo only moves through about 90 degrees: the pulse range is too narrow. Widen it during calibration.
- Servo jitters at rest: increase the filter strength, add a 100 nF capacitor at the ADC pin, or add a 470 uF capacitor across the servo supply.
- Continuous-rotation servos (such as the FS90R) treat the pulse as speed and direction instead of angle, with 1.5 ms meaning stop.
Summary
A servo is controlled by a 50 Hz signal whose pulse width encodes the angle. By setting TIM3 to a 1 MHz tick with ARR = 19999, the compare value becomes the pulse width in microseconds, which makes the angle function simple. You also calibrated the end points, filtered the potentiometer input, and powered the servo safely from its own supply. Next, you will use the I2C bus to drive an SSD1306 OLED display.