Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 19

Dimming an LED with PWM Duty Cycle Control

Recommended resources

Hello! Welcome back to our module on Advanced Timers, PWM, and Signal Measurement.

In our last lesson, you successfully configured a timer to generate a PWM signal with a static 25% duty cycle. You did this by directly manipulating the timer's registers (PSC, ARR, and CCRx), giving you a solid, low-level understanding of how the hardware works.

Today, we'll bring that static signal to life. Our learning outcome is to programmatically control the duty cycle of a PWM signal to dim an LED. We will take the foundation you built and add the final, crucial step: changing the duty cycle in real-time within our C code. This simple but powerful technique is the basis for countless applications, from dimming dashboard lights in a car to controlling the speed of electric motors.

1. From Static to Dynamic: The Key to Control

The transition from a static PWM to a dynamic one is surprisingly simple. You already know that the value in the Capture/Compare Register (CCRx) determines the duty cycle. The key insight is that this register is memory-mapped, just like all the others. This means we can write a new value to it from our C code at any time.

The process to create a "fading" or "breathing" effect on an LED is:

  1. Start the PWM timer, which you already know how to do.
  2. Enter a loop in your main function.
  3. Inside the loop, incrementally change a variable that holds the desired duty cycle value.
  4. On each iteration, write this variable's value directly into the CCRx register.

The Role of Preload Enable

Remember in the last lesson we set the OCxPE (Output Compare Preload Enable) bit? Now you'll see why it's so important. When OCxPE is enabled, writing a new value to CCRx doesn't change the duty cycle instantaneously. Instead, the hardware buffers this new value and applies it only at the start of the next PWM cycle (the "update event"). This ensures a clean, glitch-free transition from one duty cycle to the next, which is essential for smooth control.


2. Implementing the Duty Cycle Change

Let's look at how to implement this in code. The core of the operation is a single line that writes to the timer's Capture/Compare Register.

STM32 PWM Output Example Code (PWM Generation Tutorial)

The article 'STM32 PWM Output Example Code' by DeepBlue Embedded provides a concise explanation and a perfect code example for this task. We'll focus on the section that shows how to change the duty cycle.

First, read the section 'STM32 PWM HAL Functions', focusing on the part that demonstrates changing the duty cycle via direct register access (TIM2->CCR1 = 16383;). Then, jump to the section 'STM32 PWM Example Code' and study the while(1) loop. Notice how it uses two nested loops to increment and then decrement a variable, writing the result to TIM2->CCR1 on each step.

As you saw in the article, the bare-metal C code to set the duty cycle for Timer 2, Channel 1 is simply:

TIM2->CCR1 = new_duty_cycle_value;

This direct register access is efficient and transparent. The HAL provides a macro for this, __HAL_TIM_SET_COMPARE(), which ultimately does the same thing. Understanding the direct register method is crucial for professional development, as it demystifies the library functions.


3. Visualizing the Effect

Reading about a fading LED is one thing; seeing it implemented and observing the resulting waveform is much more instructive. The following video demonstrates creating a fading effect and shows the output on an oscilloscope, which will help you visualize what's happening on the pin.

STM32 Beginners Guide Part3: PWM, TIMERS, Frequency and Duty Cycle. LED Dimming with PWM example.

This clip from CircuitGator HQ's tutorial shows the exact for loop logic we need to implement. It also provides an excellent visual of the changing duty cycle on an oscilloscope.

Watch from 16:00 to 18:36. Pay attention to the two for loops used to create the fade-in and fade-out effect. Notice how the variable i is passed to the function that sets the compare register, and observe the corresponding smooth change in the duty cycle on the oscilloscope.


4. Hands-On Project: Create a "Breathing" LED

Now it's time to put this into practice. You will modify the code from our previous lesson to make the LED connected to PB9 fade in and out continuously.

Your Goal:
Modify the main() function of your previous project to programmatically sweep the duty cycle of TIM4_CH4 from 0% to 100% and back again.

Recall Your Setup from Last Lesson:

  • Timer: TIM4
  • Channel: TIM4_CH4 (connected to pin PB9)
  • Frequency: 1 kHz
  • ARR: 999 (meaning the counter goes from 0 to 999)
  • PSC: 71
  • Clock: 72 MHz

This means a duty cycle of 0% corresponds to TIM4->CCR4 = 0, and a duty cycle of 100% corresponds to TIM4->CCR4 = 999.

Instructions:

  1. Start with the code from the previous lesson where you successfully generated a static PWM signal.
  2. Modify the while(1) loop in your main function.
  3. Implement a loop that increments a variable from 0 to 999. Inside this loop, write the variable's value to TIM4->CCR4 and add a small delay (e.g., HAL_Delay(1);) to control the speed of the fade.
  4. Implement a second loop that decrements the variable from 999 back down to 0, again updating TIM4->CCR4 and adding a delay.
  5. Connect an LED (with a series resistor, ~220-330Ω) between pin PB9 and GND. The longer leg of the LED (anode) goes to PB9.
  6. Compile, flash, and observe the LED. It should smoothly brighten and dim. If you have your logic analyzer, you can also probe the pin to see the duty cycle changing.

Here is the complete main.c file incorporating the solution. Use it as a reference after you attempt to write the while(1) loop yourself.




#include "stm32f10x.h"

// A simple software delay function
void delay_ms(volatile uint32_t ms) {
    // A more precise delay would use the SysTick timer, 
    // but for this visual example, a simple loop is sufficient.
    // Each outer loop iteration is very roughly 1ms on a 72MHz clock.
    for (; ms > 0; ms--) {
        for (volatile uint32_t i = 0; i < 8000; i++);
    }
}

void pwm_init(void) {
    // 1. Enable clock for GPIOB and TIM4
    RCC->APB2ENR |= RCC_APB2ENR_IOPBEN;
    RCC->APB1ENR |= RCC_APB1ENR_TIM4EN;

    // 2. Configure PB9 as Alternate Function Push-Pull
    GPIOB->CRH &= ~(GPIO_CRH_CNF9 | GPIO_CRH_MODE9); 
    GPIOB->CRH |= GPIO_CRH_MODE9;      // Output mode, max speed 50 MHz
    GPIOB->CRH |= GPIO_CRH_CNF9_1;     // Alternate function output Push-pull

    // 3. Configure TIM4 timer base
    TIM4->PSC = 71;   // 72,000,000 / (71 + 1) = 1,000,000 Hz (1 MHz) counter clock
    TIM4->ARR = 999;  // 1 MHz / (999 + 1) = 1 kHz PWM frequency

    // 4. Configure TIM4 Channel 4 for PWM Mode 1
    TIM4->CCMR2 |= (TIM_CCMR2_OC4M_2 | TIM_CCMR2_OC4M_1); // PWM Mode 1
    TIM4->CCMR2 |= TIM_CCMR2_OC4PE; // Enable Preload for CCR4

    // 5. Enable the Channel 4 Output and set polarity
    TIM4->CCER |= TIM_CCER_CC4E;    // Enable output for channel 4
    // Default polarity is Active High, which is what we want.

    // 6. Enable Auto-Reload Preload and start the timer
    TIM4->CR1 |= TIM_CR1_ARPE;      // Enable ARPE
    TIM4->CR1 |= TIM_CR1_CEN;       // Enable the counter (CEN)
}

int main(void) {
    pwm_init();

    uint16_t duty_cycle = 0;

    while(1) {
        // Fade In (brighten)
        for (duty_cycle = 0; duty_cycle < 1000; duty_cycle++) {
            TIM4->CCR4 = duty_cycle;
            delay_ms(1); // Control the speed of the fade
        }

        // Fade Out (dim)
        for (duty_cycle = 999; duty_cycle > 0; duty_cycle--) {
            TIM4->CCR4 = duty_cycle;
            delay_ms(1); // Control the speed of the fade
        }
    }
}

Conclusion

Excellent work! You have now mastered the art of dynamically controlling a PWM output. This is a fundamental skill in embedded systems that bridges the gap between software logic and physical actuation.

Key Takeaways:

  • You can change a PWM signal's duty cycle in real-time simply by writing a new value to the corresponding CCRx register.
  • The OCxPE (Output Compare Preload Enable) bit is vital for ensuring smooth, glitch-free transitions when changing the duty cycle.
  • A simple for loop combined with a software delay is sufficient to create a visually smooth "breathing" effect on an LED.

Preview of the Next Lesson:
So far, we've used timers to generate signals. But timers are equally powerful for measuring signals. In the next lesson, we will explore the other side of the coin: we will configure a timer channel in Input Capture mode. This will allow us to capture the precise time of an incoming signal's edge, a technique essential for measuring frequency, pulse width, and interfacing with many types of sensors.