Skip to main content

Welcome to a Grasp lesson

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

Lesson 44

DMA Interrupts for Data Buffer Processing

Recommended resources

Hello! Welcome back to our module on Data Acquisition with DMA.

In our last session, we created a very efficient, self-sustaining system to continuously read multiple ADC channels into a buffer using circular DMA. We saw in the debugger that the adc_values array was being updated in real-time with fresh sensor data, all without any CPU intervention in the main loop.

However, this left us with a critical question: how does our application know when it's safe to read that data? If the CPU tries to read the buffer at the exact moment the DMA is writing to it, we could get a corrupted or "torn" read—a mix of old and new data. Today, we will solve this fundamental problem.

Your learning outcome is to use DMA interrupts (transfer complete and half-transfer) to process data buffers. This is the standard professional technique for safely and efficiently handling continuous data streams from peripherals like ADCs, communication interfaces, and more.

1. The Challenge of Continuous Data and the "Ping-Pong" Solution

The setup from our last lesson has a classic race condition: the DMA controller and the CPU are two independent units competing for access to the same memory buffer.

To solve this, we use a technique called double buffering, or more colloquially, ping-pong buffering. Instead of one buffer, we use a single, larger buffer that we treat as two halves: a "ping" half and a "pong" half.

The process works like this:

  1. The DMA starts filling the first half of the buffer (the "ping" buffer).
  2. Once the first half is full, the DMA triggers a Half-Transfer Complete (HT) interrupt. This is a signal to the CPU: "The first half is ready for you. It's all yours."
  3. The CPU can now safely process the data in the first half.
  4. Simultaneously, the DMA automatically starts filling the second half of the buffer (the "pong" buffer).
  5. When the second half is full, the DMA triggers a Transfer Complete (TC) interrupt. The signal now is: "The second half is ready. You can process it."
  6. The CPU processes the second half, while the DMA (being in circular mode) wraps around and starts filling the first half again.

This cycle repeats indefinitely, ensuring the CPU is always working on a stable chunk of data that the DMA is not currently writing to.

DMA Ping-Pong Buffer Process for Audio Data
This diagram illustrates the ping-pong buffering concept. An external source (in our case, the ADC) provides data that the DMA writes into one buffer (e.g., dmaPingSrcBuf) while the CPU is free to process data from the other buffer (dmaPongSrcBuf). Once the roles are swapped, the flow continues, creating a seamless and safe data pipeline.

In the STM32 HAL, these interrupts are conveniently handled by two dedicated callback functions, which we will implement today:

  • HAL_ADC_ConvHalfCpltCallback()
  • HAL_ADC_ConvCpltCallback()

2. Implementing DMA Interrupts for ADC

Let's modify our previous project to implement this robust buffering scheme. The goal will be to continuously acquire data from two ADC channels, and in the callbacks, we will process the data by calculating the average of each channel's readings over the captured block.

This first video from DigiKey provides an excellent walkthrough of the entire process, from CubeMX configuration to the code implementation and debugging.

Getting Started With STM32 & Nucleo Part 4: Working with ADC and DMA - Maker.io

To see this concept in action, watch the video 'Getting Started With STM32 & Nucleo Part 4: Working with ADC and DMA'. It perfectly demonstrates how to enable DMA interrupts and use the half-complete and complete callbacks for processing.

Watch the section from 10:05 to 14:24. Pay close attention to how the DMA is set to circular mode, how a large buffer is defined, and how the HAL_ADC_ConvHalfCpltCallback and HAL_ADC_ConvCpltCallback functions are implemented to process different halves of the buffer.

Now, let's apply these steps to our own project.

Step 1: Enabling DMA Interrupts in STM32CubeIDE

This is the critical step that connects the DMA hardware events to our software.

  1. Open the .ioc file of your project from the last lesson.
  2. Navigate to System Core > NVIC.
  3. In the list of interrupts, find the one corresponding to the DMA channel used by your ADC. On the STM32F103, this is typically DMA1 channel 1 interrupt.
  4. Check the box in the Enabled column for this interrupt. The priority can be left at its default for now.
  5. Save the .ioc file to regenerate the code. The HAL will now automatically generate the necessary interrupt handler boilerplate for you.

Step 2: Writing the Application Code

We'll now write the code to define our double buffer and implement the callbacks.

  1. In Core/Src/main.c, head to the private variables section (/* USER CODE BEGIN PV */).

    • Let's define a buffer large enough for our ping-pong scheme. We'll capture 50 samples for each of our 2 channels in each half.
      • Buffer size = (samples per channel) * (number of channels) * (number of halves)
      • Buffer size = 50 * 2 * 2 = 200 uint16_t elements.
    • We also need variables to hold the final processed data.
    /* USER CODE BEGIN PV */
    
    
    
    #define ADC_SAMPLES_PER_CHANNEL 50
    #define ADC_CHANNELS 2
    #define ADC_BUF_LEN (ADC_SAMPLES_PER_CHANNEL * ADC_CHANNELS * 2) // Total buffer length for ping-pong
    
    volatile uint16_t adc_buf[ADC_BUF_LEN];
    volatile uint32_t avg_ch1 = 0;
    volatile uint32_t avg_ch2 = 0;
    /* USER CODE END PV */
    
  2. In the main() function, update the DMA start call to use our new buffer and size. This is still a one-time call.

    /* USER CODE BEGIN 2 */
    // Start the ADC in DMA mode with our ping-pong buffer
    HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, ADC_BUF_LEN);
    /* USER CODE END 2 */
    

    The while(1) loop can remain empty. All our processing will now happen in response to interrupts.

  3. At the bottom of main.c, inside the /* USER CODE BEGIN 4 */ section, implement the two callback functions.

    /* USER CODE BEGIN 4 */
    
    /**
      * @brief  Conversion DMA half-transfer complete callback.
      * @param  hadc: ADC handle
      * @retval None
      */
    void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc)
    {
      // This callback is fired when the first half of the buffer is filled.
      // Process the first half of the buffer (the "ping" buffer).
      uint32_t sum_ch1 = 0;
      uint32_t sum_ch2 = 0;
    
      for (int i = 0; i < ADC_BUF_LEN / 2; i += 2)
      {
        sum_ch1 += adc_buf[i];   // Even indices are CH1
        sum_ch2 += adc_buf[i+1]; // Odd indices are CH2
      }
    
      avg_ch1 = sum_ch1 / ADC_SAMPLES_PER_CHANNEL;
      avg_ch2 = sum_ch2 / ADC_SAMPLES_PER_CHANNEL;
    }
    
    /**
      * @brief  Conversion complete callback.
      * @param  hadc: ADC handle
      * @retval None
      */
    void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc)
    {
      // This callback is fired when the entire buffer is filled.
      // Process the second half of the buffer (the "pong" buffer).
      uint32_t sum_ch1 = 0;
      uint32_t sum_ch2 = 0;
    
      // Start processing from the midpoint of the buffer
      for (int i = ADC_BUF_LEN / 2; i < ADC_BUF_LEN; i += 2)
      {
        sum_ch1 += adc_buf[i];   // Even indices are CH1
        sum_ch2 += adc_buf[i+1]; // Odd indices are CH2
      }
    
      avg_ch1 = sum_ch1 / ADC_SAMPLES_PER_CHANNEL;
      avg_ch2 = sum_ch2 / ADC_SAMPLES_PER_CHANNEL;
    }
    
    /* USER CODE END 4 */
    

Step 3: Verification with the Debugger

  1. Build the project and start a Debug Session.
  2. Set a breakpoint inside HAL_ADC_ConvHalfCpltCallback and another inside HAL_ADC_ConvCpltCallback.
  3. Resume (F8) execution. The program should halt at the first breakpoint after the DMA fills the first 100 spots in adc_buf.
  4. Remove the breakpoints and open the Expressions view.
  5. Add avg_ch1, avg_ch2, and adc_buf to the watch list and enable Continuous Refresh.
  6. Resume execution. You should now see the avg_ch1 and avg_ch2 variables updating periodically as the callbacks are fired. Twisting your potentiometers should change the average values. You have successfully created a safe and efficient data processing pipeline!

3. Deeper Dive: Register Level vs. HAL

The HAL callbacks provide a clean abstraction. For a deeper understanding of what's happening at the hardware level, it's useful to see how this would be implemented with direct register access.

The ControllersTech video below, while focused on UART, shows the raw interrupt handling logic. In the DMA interrupt handler, the code manually checks the interrupt status register (DMA_ISR) to see if the Half-Transfer Flag (HTIF) or Transfer-Complete Flag (TCIF) is set, and then clears it in the Interrupt Flag Clear Register (DMA_IFCR). This is exactly what the HAL_ADC_IRQHandler and the subsequent callbacks are doing for you behind the scenes.

#10. How to configure DMA using Registers || HT and TC Interrupts

This video, '#10. How to configure DMA using Registers || HT and TC Interrupts', explains the low-level mechanics of DMA interrupts.

Watch the section from 10:18 to 13:01. Focus on how the DMA1_Channel6_IRQHandler checks the status register bits to determine if a half-transfer or full-transfer interrupt occurred, and then explicitly clears the corresponding flag. This gives you a clear picture of the hardware events that trigger the HAL callbacks.

For further reading and another perspective on implementing the HAL callbacks, these two articles provide excellent code examples and explanations.

A detailed tutorial on STM32 ADC - VisualGDB

The article 'A detailed tutorial on STM32 ADC' by VisualGDB provides another excellent example of implementing the ping-pong buffer for ADC.

Read section 27 of the article (starting with 'This can be solved by defining the HAL_ADC_ConvHalfCpltCallback() handler...'). It directly addresses the problem of data being overwritten during processing and shows the exact solution using the half and full complete callbacks.

STM32 UART DMA Receive Example | Normal & Circular Mode

Finally, this article from ControllersTech, 'STM32 UART DMA Receive Example', though about UART, has a fantastic explanation of the circular mode logic.

Read the section titled 'UART DMA in Circular Mode'. Pay close attention to the code snippets for HAL_UART_RxHalfCpltCallback and HAL_UART_RxCpltCallback. The logic of processing the first half vs. the second half of the RxData buffer is identical to what we did for our ADC.


Conclusion

You have now mastered one of the most important techniques in professional embedded firmware development. By using DMA interrupts with a ping-pong buffer, you can process high-throughput, continuous data streams without risking data corruption and without bogging down the CPU with polling loops.

Key Takeaways:

  • DMA interrupts signal the CPU when a block of data is ready for processing.
  • The Half-Transfer (HT) and Transfer-Complete (TC) interrupts are the foundation of the ping-pong buffering technique.
  • This method decouples the data acquisition (handled by ADC+DMA) from data processing (handled by the CPU in response to interrupts).
  • In the HAL, this is implemented by enabling the DMA interrupt in the NVIC and providing code for the HAL_ADC_ConvHalfCpltCallback() and HAL_ADC_ConvCpltCallback() functions.

Preview of the Next Lesson:
In our final lesson of this module, we will create a small capstone project that brings together several concepts you've learned. You will build a project to read the MCU's internal temperature sensor via ADC and DMA, and display the result on the I2C LCD. This will solidify your skills in peripheral configuration, DMA, and driver integration.