STM32 PWM LED Dimming with Timers: Prescaler, ARR and CCR Explained

A GPIO pin can only be fully on or fully off. To make an LED glow at half brightness, or to run a DC motor at half speed, you switch the pin on and off very quickly and change the fraction of time it stays on. This is Pulse Width Modulation (PWM). Doing it in software wastes the CPU, but every STM32 has hardware timers that generate PWM with no CPU work once they are set up.

In this tutorial you will configure TIM2 on a Nucleo-F401RE to produce 1 kHz PWM on PA5, which is the pin of the onboard LD2 LED, and smoothly fade it in and out. You will learn the three numbers that control every STM32 timer: the prescaler, the auto-reload value and the compare value.

How Timer PWM Works

A general-purpose timer has a counter that counts up from 0 on each tick of its clock. When the counter reaches the auto-reload register (ARR), it returns to 0 and starts again; one full count is one PWM period. Each channel has a capture/compare register (CCR). In PWM mode 1, the output is high while the counter is less than CCR and low after that. So CCR sets the pulse width, and ARR sets the period.

Timer tick frequency  = Timer clock / (PSC + 1)
PWM frequency         = Timer clock / ((PSC + 1) x (ARR + 1))
Duty cycle (%)        = CCR / (ARR + 1) x 100

Nucleo-F401RE at 84 MHz, TIM2 on APB1:
APB1 = 42 MHz, timer clock = 2 x 42 = 84 MHz
PSC = 83   -> tick = 84 MHz / 84 = 1 MHz (1 us per count)
ARR = 999  -> PWM = 1 MHz / 1000 = 1 kHz
CCR = 250  -> duty = 250 / 1000 = 25 %

Notice the factor of two. When an APB bus prescaler is not 1, the timers on that bus receive twice the bus clock. CubeMX's Clock Configuration tab shows this as APB1 Timer clocks, and that is the number to use in the formula.

Pins and Timer Channels

PWM can only appear on pins that have a timer channel as an alternate function. The datasheet's alternate function table lists them. The pins used in this tutorial are:

BoardTimer channelPinLEDTimer clock
Nucleo-F401RETIM2_CH1PA5Onboard LD284 MHz
Blue PillTIM2_CH1PA0External LED with 330 ohm resistor72 MHz

On the Blue Pill, use PSC = 71 to get a 1 MHz tick from the 72 MHz timer clock, and keep ARR = 999. The rest of the code is identical.

CubeMX Configuration

  1. Click PA5 and change it from GPIO_Output to TIM2_CH1. The LD2 label is no longer a GPIO, which is expected.
  2. Open Timers, TIM2. Set Clock Source to Internal Clock and Channel1 to PWM Generation CH1.
  3. In Parameter Settings set Prescaler to 83, Counter Mode Up, Counter Period (ARR) to 999, auto-reload preload Enable.
  4. Under PWM Generation Channel 1 set Mode to PWM mode 1, Pulse to 0, Output compare preload Enable, CH Polarity High.
  5. Generate the code. CubeMX creates htim2 and MX_TIM2_Init().

Fading the LED

Human eyes respond to brightness roughly logarithmically, so a linear ramp of duty cycle looks like it brightens quickly and then stays nearly flat. The code applies a simple square-law correction, which gives a much smoother visible fade without any lookup table.

main.c (Nucleo-F401RE): PWM fade on PA5 using TIM2 channel 1
/* USER CODE BEGIN PV */
#define PWM_TOP    999U   /* must match ARR set in CubeMX */
#define FADE_STEPS 100U   /* brightness levels from 0 to 100 */
/* USER CODE END PV */

/* USER CODE BEGIN 0 */
/* Convert a brightness level 0..100 into a compare value 0..PWM_TOP.
   Squaring the level gives a perceptually smoother fade. */
static uint32_t level_to_ccr(uint32_t level)
{
  return (level * level * (PWM_TOP + 1U)) / (FADE_STEPS * FADE_STEPS);
}

static void led_set_level(uint32_t level)
{
  uint32_t ccr = level_to_ccr(level);
  if (ccr > PWM_TOP + 1U) ccr = PWM_TOP + 1U;   /* CCR > ARR means 100 % */
  __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, ccr);
}
/* USER CODE END 0 */

int main(void)
{
  HAL_Init();
  SystemClock_Config();
  MX_GPIO_Init();
  MX_TIM2_Init();          /* PSC = 83, ARR = 999, PWM mode 1 on CH1 (PA5) */

  /* USER CODE BEGIN 2 */
  /* Start the counter and enable the channel output */
  HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
  /* USER CODE END 2 */

  while (1)
  {
    /* USER CODE BEGIN 3 */
    /* Fade in over about 1 second */
    for (uint32_t level = 0; level <= FADE_STEPS; level++)
    {
      led_set_level(level);
      HAL_Delay(10);
    }

    /* Fade out over about 1 second */
    for (int32_t level = FADE_STEPS; level >= 0; level--)
    {
      led_set_level((uint32_t)level);
      HAL_Delay(10);
    }

    HAL_Delay(300);        /* short pause while the LED is off */
    /* USER CODE END 3 */
  }
}

How the Code Works

  • HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1) enables the channel output and starts the counter. Until this call, the pin stays low even though it is configured.
  • __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, ccr) is a macro that writes directly to the CCR1 register. It is very fast and safe to call often.
  • At level 50, the compare value is 50 x 50 x 1000 / 10000 = 250, a 25 % duty cycle, which looks roughly half as bright as full on.
  • Writing a CCR value greater than ARR keeps the output high for the whole period, giving 100 % duty. Writing 0 keeps it low.
  • You can read the current settings with __HAL_TIM_GET_AUTORELOAD(&htim2) and change the frequency at run time with __HAL_TIM_SET_AUTORELOAD.

Choosing the PWM Frequency

For LEDs, anything above a few hundred hertz looks steady, and 1 kHz is a safe choice. For DC motors driven through an L298N or MOSFET, 1 to 20 kHz is typical; higher values move the whine above hearing. Remember the trade-off: for a fixed timer clock, a higher frequency means a smaller ARR and therefore fewer duty-cycle steps. With an 84 MHz clock and PSC = 0, a 20 kHz PWM gives ARR = 4199, still over 4000 steps of resolution.

Troubleshooting

  • LED stays off: HAL_TIM_PWM_Start was not called, or PA5 is still configured as GPIO in another part of the code.
  • LED flickers visibly: the PWM frequency is too low because PSC or ARR is larger than intended.
  • Brightness changes in jumps near the bottom: expected with integer maths at low levels; increase ARR for finer steps.
  • Frequency is half or double what you calculated: you used the APB clock instead of the APB timer clock.
  • Using TIM1 and getting no output: advanced timers TIM1 and TIM8 need their main output enabled. HAL_TIM_PWM_Start handles this, but writing registers directly requires setting the MOE bit in BDTR.

Summary

Every STM32 PWM output is controlled by three values: PSC sets the tick rate, ARR sets the period, and CCR sets the pulse width. You configured TIM2 channel 1 on PA5 for 1 kHz, started it with HAL_TIM_PWM_Start, and changed brightness with __HAL_TIM_SET_COMPARE. The next tutorial uses the same timer knowledge to position a hobby servo, where the pulse width itself carries the information.