This website requires JavaScript.
쿠폰
배송지
블로그

STM32 ADC 사용법: 전압 측정, 인터럽트·DMA와 DAC 피드백

최초 게시일 Aug 28, 2026, 업데이트 되였습니다. Aug 28, 2026

15 분

표목(TOC)
  • 1. STM32 ADC의 기본 원리
  • 2. 하드웨어 셋업
  • 3. STM32CubeMX 공통 설정
  • 4. Polling 방식의 STM32 ADC 예제
  • 5. 인터럽트 방식의 STM32 ADC 예제
  • 6. DMA 방식의 STM32 ADC 예제
  • 7. DAC·ADC 피드백 제어
  • 8. 참고 자료

핵심 요약

  • STM32 ADC의 해상도, 샘플링과 디지털 값을 전압으로 환산하는 원리를 설명합니다.
  • NUCLEO-F446RE에서 Polling, Interrupt, DMA 방식으로 ADC 데이터를 수집합니다.
  • TEMT6000 조도 센서와 다중 채널 ADC 설정 및 Circular DMA 처리를 구현합니다.
  • DAC 출력과 LED 조명 피드백을 연결해 간단한 실시간 제어를 확인합니다.

이 글에서는 STM32 ADC의 기본 원리와 전압 계산 방법을 살펴보고, NUCLEO-F446RE에서 TEMT6000의 밝기 값을 Polling·인터럽트·DMA로 읽어 DAC 기반 LED 조명 피드백까지 구현합니다.

브레드보드에서 센서와 LED 드라이버의 동작을 확인한 뒤 커넥터와 테스트 포인트를 작은 인터페이스 PCB로 정리하면 배선 오류를 줄이고 같은 실험 환경을 반복하기 쉽습니다. PCB 제작시에Gerber 파일은 JLCPCB PCB 서비스에 업로드하고 온라인 견적에서 제작 가능 여부와 비용을 확인할 수 있습니다.

ADC(Analog-Digital 변환)에 대한 기본적인 내용을 배웁니다.

STM32에 내장되어 있는 ADC에 대한 설명과 함께 기초적인 세팅을 알아봅니다.

ADC값을.Polling 으로 처리하는 방법을 배웁니다.

ADC값을 인터럽트를 사용해서 처리하는 방법을 배웁니다.

ADC값을 DMA(Direct Memory Access)를 사용해서 처리하는 방법을 배웁니다.

1. STM32 ADC의 기본 원리

우리가 살고 있는 세상은 거의 모든 것이 연속적 입니다.

하늘을 한번 보면 하늘의 색은 일반적으로 파란색을 띄고 있고 이 색이 자연스럽게 더 진하거나 연하거나 부드럽게 변하는 것을 볼수 있습니다. 우리가 모니터에서 하늘사진을 찍은것을 보면 멀리서 볼때는 부드럽게 변하는것 처럼 보이지만 이것을 확대 해 보면 픽셀의 단위가 보이고 연속적이 아닌 불연속적인 픽셀의 테두리를 볼수 있습니다. (양자역학에서 보면 불연속적 이라고 볼수 있지만 여기서는 양자역학에 대해서 다루지 않습니다. 그냥 일반적인 고전역학을 생각을 해 봅시다)

이미지 포맷을 보면 가로의 픽셀 갯수와 세로의 픽셀 갯수가 정해져 있습니다. 그리고 이미지 픽셀의 크기까지 정해져 있죠. 하늘 그 자체는 아날로그지만 사진을 찍어서 저장을 하게 되면 디지털이 됩니다.

이것은 이미지(2D)에 대한 설명이고 여기서 차원을 줄여서 1차원으로 생각을 해 보면 소리, 빛의 밝기, 온도, 기울어짐 같은 시간에 따라 변하는 값들이 있을것입니다.

여기서 우리가 측정하고자 하는 빛의 밝기는 시간에 따라서 어두워지거나 밝아지거나 합니다. 이 밝기는 연속적이죠. 이 값을 컴퓨터에서 처리 할수 있도록 그 값을 여러단계로 나누는 작업을 ADC 라고 합니다.

그림 1. Analog to Digital 변환 과정

그림 1. Analog to Digital 변환 과정

그림 1에서 처럼 변환에는 몇개의 과정을 거친다. 시간축으로 데이터를 샘플링을 하고 샘플링 시점의 값을 단계값으로 변환하여 값을 가져온다. 단계값으로 변환하는 과정을 양자화라고 한다. 단계를 나눌때 2의 거듭제곱으로 나누는데 이 값이 8이면 2^8=256 단계로 나누어지고 만일 12라면 2^12=4096 단계로 나눌수 있다. ADC(아날로그 디지털 변환기)의 스펙의 하나로 이 샘플링 레이트(sampling rate, 얼마나 자주 샘플링을 할수 있는지)와 해상도(resolution, 몇비트로 나눌수 있는지) 가 포함이 되어 있다.

참고로 STM32F446의 ADC는 단일 ADC 기준 최대 2.4 MSPS, 12비트 해상도를 지원하며 10·8·6비트 해상도도 선택할 수 있습니다. 실제 변환률은 ADC 클럭과 Sampling Time 설정에 따라 달라집니다.

이 프로젝트의 TEMT6000 모듈은 3.3 V로 구동하고, 모듈의 아날로그 출력이 STM32 ADC 입력 허용 범위인 0~3.3 V 안에 머물도록 연결합니다. 원소자 TEMT6000은 광트랜지스터이므로 출력 범위는 모듈의 저항 구성과 조도에 따라 달라집니다.

12비트 ADC에는 4096개의 코드가 있으며 3.3 V 기준 1 LSB의 크기는 약 3.3 V/4096=0.806 mV입니다. 전압 환산에는 실제 기준 전압과 ADC 코드 범위(0~4095)를 사용해야 하며, 이 값은 정밀도(precision)가 아니라 양자화 해상도(resolution)를 뜻합니다.

STM32F446의 SAR ADC는 입력을 Sample & Hold한 뒤 변환합니다. 12비트 변환 시간은 선택한 Sampling Time에 변환 사이클을 더해 계산하며, 이 문서의 480-cycle 설정에서는 표 1처럼 (480+12)/21 MHz≈23.43 µs입니다.

2. 하드웨어 셋업

그림 2. LED, TEMT6000 셋업

그림 2. LED, TEMT6000 셋업

그림 3. LED, LED 드라이버 회로, TEMT6000 셋업

그림 3. LED, LED 드라이버 회로, TEMT6000 셋업

3. STM32CubeMX 공통 설정

ADC는 Pin Configuration 뿐만 아니라 Clock Configuration 설정을 잘 살펴봐야 한다.

기본으로 세팅되는값을 사용해도 되지만 만일 클럭 스피드를 수정할 경우에 ADC의 샘플링 레이트가 변할수 있으므로 동일한 레이트를 유지하기 위해서는 설정값을 바꿀 필요가 있다.

아래의 표 1 에서는 Nucleo-F446RE 보드의 기본 설정을 사용했을때의 결과값 이다.

실제로 이 세팅값을 사용해서 프로젝트를 진행한다. 참고로 이 테이블에서 사용하는 값 중 하나인 480 (cycle)은 ADC 세팅값의 일부이다.

실제 최종 clock 조건계산결과
SYSCLK/PCLK284 MHz / 84 MHzAPB2 prescaler=1
ADC clock84 MHz ÷ 421 MHz
1회 변환(480+12) ÷ 21 MHz약 23.43 µs
연속 최대 변환률21 MHz ÷ 492약 42.7 ksample/s
100-sample 블록100 ÷ 42.7 ksample/s약 2.34 ms

표 1. 샘플링 레이트 계산의 예

이 프로젝트에서 배워야 할 내용인 Polling, Interrupt, DMA(Direct Memory Access)는 ADC후에 데이터를 처리하는 방법에 대한 내용이다. 사용하는 방법에 따라서 같은 MCU로 더 나은 성능을 구현할 수 있다.

모든 실습에서 공통적으로 사용될 기능은 VCP(Virtual Comm Port) 이다.

VCP에 printf() 함수를 사용해서 내부의 값들을 시리얼 포트를 통해 터미널로 출력할수 있다.

기본적인 세팅은 다음과 같다.

3.1 VCP 핀 설정

PA2, PA3를 VCP로 사용한다. USART2를 선택한 후에 설정을 하면 된다.

그림 4. VCP 핀 설정

그림 4. VCP 핀 설정

(2) 설정값

3.2 VCP 파라미터 설정

그림 5. VCP 설정(1)

USART2 를 선택한 후에

그림 6. VCP 파라미터 설정

그림 6. VCP 파라미터 설정

Mode: Asynchronous

Basic Parameters – Baud Rate: 115200

이 두개의 필드만 확인하면 된다.

3.3 printf() 리디렉션 코드 (코드 일부)

/* USER CODE BEGIN Includes */
#include <stdio.h>
/* USER CODE END Includes */
/* USER CODE BEGIN Includes */
#include <stdio.h>
/* USER CODE END Includes */

printf() 함수를 redirect 하기 위한 함수 추가 (코드 일부)

/* USER CODE BEGIN 0 */
int __io_putchar(int ch)
{
    HAL_UART_Transmit(&huart2, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
    return ch;
}
/* USER CODE END 0 */
/* USER CODE BEGIN 0 */
int __io_putchar(int ch)
{
    HAL_UART_Transmit(&huart2, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
    return ch;
}
/* USER CODE END 0 */

이때 HAL_UART_Transmit() 함수의 첫번째 인자인 &huart2 변수는 코드가 생성한 변수를 사용하면 된다. 변수는 Private variables 영역에서 생성 된다. (코드 일부)

/* Private variables ---------------------------------------------------------*/
UART_HandleTypeDef huart2;
/* Private variables ---------------------------------------------------------*/
UART_HandleTypeDef huart2;

3.4 ADC 설정

그림 7. ADC 핀 설정

그림 7. ADC 핀 설정

PA0를 ADC핀 으로 설정 합니다. 핀을 클릭한 후에 ADC1_IN0를 선택합니다.

Pinout & Configuration에서 Analog를 선택한 후 ADC1을 선택합니다.

그림 8. ADC 파라미터 설정

그림 8. ADC 파라미터 설정

Mode 에서 IN0를 선택한 후

Rank – Sampling Time 을 480 Cycles 로 세팅합니다.

나머지 설정은 그대로 둡니다. (Polling일때)

코드를 생성하면 ADC에 해당하는 handle이 자동으로 생성됩니다.

ADC_HandleTypeDef hadc1;

3.5 DAC 설정

그림 9. DAC 핀 설정

그림 9. DAC 핀 설정

Pin Out 에서 PA4를 선택한 후에 DAC_OUT1을 선택합니다.

이 DAC_OUT1값을 사용해서 LED의 밝기를 설정합니다. OPAMP-MOSFET을 사용한 LED 드라이버에 입력으로 들어갑니다.

Analog – DAC 선택한 후에

그림 10. DAC 파라미터 설정

그림 10. DAC 파라미터 설정

Mode에서 OUT1 Configuration을 선택합니다.

이 설정은 Polling, Interrupt, DMA 모두 동일합니다.

역시 코드 생성시에 handle이 만들어집니다.

DAC_HandleTypeDef hdac;

4. Polling 방식의 STM32 ADC 예제

Polling 방식은 ADC 변환을 시작한 뒤 완료 여부를 함수가 직접 확인하는 방법입니다. 이 예제는 약 23.43 µs의 변환이 끝난 뒤 1 ms를 대기하므로, 실질적인 측정 간격은 변환 시간과 코드 실행 시간을 포함해 1 ms보다 조금 깁니다.

Polling 방식은 가장 간단한 방법이지만MCU를 계속해서 써야 하기 때문에 루프에 MCU를 많이 사용하는 기능이 추가 될 경우 그 시간간격을 보장할수 없기 때문에 기능 구현에 제한이 생길 수 있습니다.

int main(void)
{
  HAL_Init();
  SystemClock_Config();
  MX_GPIO_Init();
  MX_ADC1_Init();
  MX_TIM3_Init();
  MX_USART2_UART_Init();
  MX_DAC_Init();

  HAL_DAC_Start(&hdac, DAC_CHANNEL_1);
  printf("\r\nDAC - TEMT6000 Test Start\r\n");
  while (1)
  {
    for (uint32_t percent = 0; percent <= 100; percent += 10)
    {
      uint32_t dacValue;
      uint32_t dac_mV;
      dacValue = (4095 * percent) / 100;
      dac_mV = (3300 * dacValue) / 4095;
      HAL_DAC_SetValue(&hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, 
 dacValue); 
      printf("\r\n====================================\r\n");
      printf("DAC = %lu %%\r\n", percent);
      printf("DAC Raw = %lu\r\n", dacValue);
      printf("DAC Voltage = %lu mV\r\n", dac_mV);
      printf("====================================\r\n");

      HAL_Delay(500);
      for (uint32_t repeat = 0; repeat < 5; repeat++) {
	   uint32_t adcSum = 0;
         for (uint32_t i = 0; i < 100; i++) {
           HAL_ADC_Start(&hadc1);
           if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) {
             adcSum += HAL_ADC_GetValue(&hadc1);
           }
           HAL_ADC_Stop(&hadc1);
           HAL_Delay(1);
         }
         uint32_t adcAverage = adcSum / 100;
         uint32_t adc_mV = (adcAverage * 3300) / 4095;
         printf("Sample %lu : ADC Average = %lu, Voltage = %lu mV\r\n",
                 repeat + 1, adcAverage, adc_mV);
      }
	 HAL_Delay(500);
    }
    printf("\r\nTest Completed\r\n");
    HAL_Delay(3000);
  }
}
int main(void)
{
  HAL_Init();
  SystemClock_Config();
  MX_GPIO_Init();
  MX_ADC1_Init();
  MX_TIM3_Init();
  MX_USART2_UART_Init();
  MX_DAC_Init();

  HAL_DAC_Start(&hdac, DAC_CHANNEL_1);
  printf("\r\nDAC - TEMT6000 Test Start\r\n");
  while (1)
  {
    for (uint32_t percent = 0; percent <= 100; percent += 10)
    {
      uint32_t dacValue;
      uint32_t dac_mV;
      dacValue = (4095 * percent) / 100;
      dac_mV = (3300 * dacValue) / 4095;
      HAL_DAC_SetValue(&hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, 
 dacValue); 
      printf("\r\n====================================\r\n");
      printf("DAC = %lu %%\r\n", percent);
      printf("DAC Raw = %lu\r\n", dacValue);
      printf("DAC Voltage = %lu mV\r\n", dac_mV);
      printf("====================================\r\n");

      HAL_Delay(500);
      for (uint32_t repeat = 0; repeat < 5; repeat++) {
	   uint32_t adcSum = 0;
         for (uint32_t i = 0; i < 100; i++) {
           HAL_ADC_Start(&hadc1);
           if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) {
             adcSum += HAL_ADC_GetValue(&hadc1);
           }
           HAL_ADC_Stop(&hadc1);
           HAL_Delay(1);
         }
         uint32_t adcAverage = adcSum / 100;
         uint32_t adc_mV = (adcAverage * 3300) / 4095;
         printf("Sample %lu : ADC Average = %lu, Voltage = %lu mV\r\n",
                 repeat + 1, adcAverage, adc_mV);
      }
	 HAL_Delay(500);
    }
    printf("\r\nTest Completed\r\n");
    HAL_Delay(3000);
  }
}

HAL_Init();

SystemClock_Config();

MX_GPIO_Init();

MX_ADC1_Init();

MX_TIM3_Init();

MX_USART2_UART_Init();

MX_DAC_Init();

여기 까지 cubeMX에서 생성된 코드 입니다.

HAL_DAC_Start(&hdac, DAC_CHANNEL_1);

DAC를 시작하는 함수 입니다. HAL(Hardware Abstraction Layer) 함수인데 입력으로 DAC의 handle과 채널값을 받습니다. Handle은 main.c에 자동생성 되고 채널값은 Drivers - STM32F4xx_HAL_Driver – Inc - stm32f4xx_hal_dac.h 에 정의되어 있습니다.

아래 코드는 DAC 출력을 0~100%까지 10%씩 바꾸고, 각 단계에서 ADC 값을 100회 측정해 평균을 구한 뒤 VCP로 출력합니다. HAL_ADC_PollForConversion()의 두 번째 인자는 샘플링 간격이 아니라 최대 대기 시간(timeout)입니다. (코드 일부)

DAC 시작

HAL_DAC_Start(&hdac, DAC_CHANNEL_1);

printf("\r\nDAC - TEMT6000 Test Start\r\n");

DAC 0% 설정

for (uint32_t percent = 0; percent <= 100; percent += 10)

500ms 안정화

HAL_Delay(500);

ADC 100회 측정 및 평균 × 5회 (각 변환 완료 후 1 ms 대기)

for (uint32_t repeat = 0; repeat < 5; repeat++)

uint32_t adcSum = 0;

for (uint32_t i = 0; i < 100; i++)

HAL_ADC_Start(&hadc1);

if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) {

adcSum += HAL_ADC_GetValue(&hadc1);

}

HAL_ADC_Stop(&hadc1);

uint32_t adcAverage = adcSum / 100;

Polling코드의 핵심은 아래의 HAL_ADC_PollForConversion 함수 입니다.

HAL_ADC_PollForConversion(&hadc1, 10)

두번째 인자인 10은 최대 10ms 를 기다린다는 뜻입니다. 이 시간내에 변환이 끝나게 되면 10ms 를 기다리지 않고 즉시 다음 코드로 넘어 갑니다. 그러니 10ms 간격으로 설정을 해도 실제로는 그보다 빨리 동작을 하게 됩니다. 만일 변환시간이 1ms 밖에 걸리지 않는다면 100번을 읽는 작업이 1초가 아닌 0.1초가 될수도 있습니다.

DAC 10% 설정

같은 측정 반복

...

DAC 100% 측정

3초 대기

DAC 0%부터 다시 반복

이 코드를 실행 시켜서 나온 결과는 다음과 같습니다.

그림 11. LED출력과 TEMT6000의 입력

그림 11. LED출력과 TEMT6000의 입력

DAC 출력의 변화 (0% ~ 100%)에 따른 TEMT6000 값의 변화를 보면 마지막 구간 90%~100%를 제외하고 거의 선형임을 알 수 있습니다. 일반적으로 LED 출력에 센서를 이용한 피드백을 하는 시스템에서 출력범위의 제한이나 캘리브레이션을 하지 않는 경우 최상위 구간과 최하위 구간에서 비선형을 나타내는 경우가 일반적입니다.

5. 인터럽트 방식의 STM32 ADC 예제

이미 지난번 내용에서 언급한바 있지만 인터럽트는 말 그대로 끼어든다 라는 뜻입니다. 메인 함수가 루프 안에서 동작하고 있을때 인터럽트의 동작 조건에 맞는 상태가 되면 메인 함수에 끼어들어서 인터럽트에서 하고싶은 작업이 있는 함수(ISR, Interrupt Service Routine)로 점프를 해서 미리 설정해 놓은 작업을 하고 다시 메인함수가 실행하던 위치로 돌아와서 메인함수를 계속해서 동작하게 합니다. STM32에서는 다음과 같은 과정을 거칩니다.

그림 12. STM32의 인터럽트 루틴

그림 12. STM32의 인터럽트 루틴

CubeMX에서 ADC 인터럽트 설정은 간단합니다. Pinout & Configuration – ADC1 - NVIC Setting에서 Interrupt를 추가(add)하면 되고 나머지는 코드에서 설정을 합니다.

그림 13. ADC의 인터럽트 설정

그림 13. ADC의 인터럽트 설정

실제 코드에서 실행되는 순서는 다음과 같습니다.

NVIC → IRQ Handler → HAL Handler → Callback → IRQ Handler 종료 → main 복귀

이 순서항목에서 우리가 설정해야 하는 부분은 Callback 함수 입니다. 나머지는 cubeMX에서 생성이 될때 다 설정이 됩니다.

ADC에서 인터럽트를 사용하는 두가지 방법이 있습니다.

1. ADC 인터럽트만 사용한다. 샘플링 간격은 일반적인 Tick을 사용한다.

2. ADC 인터럽트에 타이머 인터럽트를 추가로 사용한다. 샘플링 간격을 타이머 인터럽트로 조절한다

ADC 인터럽트는 아날로그 디지털 변환이 끝날때 발생합니다. 하지만 일정시간 간격으로 값을 읽어서 변환을 하려면 일정시간 간격을 만들어내는 방법이 필요 합니다. 앞에서 설명했던 Polling 방식에서 처럼 Tick을 사용해서 간격을 만들거나 타이머 인터럽트를 이용해서 그 시간간격을 만들어 낼수 있습니다. 타이머 인터럽트를 사용해서 시간간격을 만들면 인터럽트 콜백 함수안에 ADC를 시작하는 함수를 넣으면 되고 AD변환이 끝나게 되면 ADC 인터럽트를 발생시켜서 값을 더하거나 평균을 내거나 VCP로 출력하는 코드를 콜백 함수에 넣으면 됩니다. 만일 타이머 인터럽트의 간격이 아주 짧을경우 (10ms이하)에 VCP출력 기능을 넣게 되면 다음 인터럽트가 밀릴수 있습니다. VCP 출력은 상대적으로 시간이 많이 필요합니다.

NVIC → 타이머 인터럽트 → 타이머 인터럽트 콜백 → ADC 시작 → AD변환 끝 → ADC 인터럽트 → ADC 인터럽트 콜백 → ADC 값 더하거나/평균 → main 복귀

이번 프로젝트에는 두번째 방법을 사용해서 일정간격 (1ms)으로 AD변환을 해 보겠습니다.

그림 14. 타이머3 핀 설정

그림 14. 타이머3 핀 설정

그림 15. 타이머 3 파라미터 설정

그림 15. 타이머 3 파라미터 설정

타이머 3의 기준클럭은 APB1 Timer clocks 입니다.

그림 16. 타이머 3의 기준 클럭

그림 16. 타이머 3의 기준 클럭

이 기준 클럭으로 부터 1ms = 1KHz를 만들어야 합니다. 다음과 같은 계산식이 필요합니다.

STM32 시리즈 2 STM32 ADC 사용법 전압 측정 인터럽트·DMA와 DAC 피드백 이미지 16

Prescaler = 83

Counter Period: 999

콜백 함수에서 함께 사용하는 값은 인터럽트와 메인 루프 양쪽에서 바뀔 수 있으므로 전역 변수로 선언하고 volatile을 지정합니다. (코드 일부)

/* USER CODE BEGIN PV */

volatile uint32_t adcSum = 0;
volatile uint32_t adcAverage = 0;
volatile uint16_t adcCount = 0;
volatile uint8_t adcAverageReady = 0;
volatile uint8_t adcBusy = 0;

/* USER CODE END PV */
/* USER CODE BEGIN PV */

volatile uint32_t adcSum = 0;
volatile uint32_t adcAverage = 0;
volatile uint16_t adcCount = 0;
volatile uint8_t adcAverageReady = 0;
volatile uint8_t adcBusy = 0;

/* USER CODE END PV */

cubeMX로 부터 생성된 handle (추가하지 않아도 됩니다) (코드 일부)

/* Private variables --------------------*/

ADC_HandleTypeDef hadc1;
DAC_HandleTypeDef hdac;
TIM_HandleTypeDef htim3;
UART_HandleTypeDef huart2;
/* Private variables --------------------*/

ADC_HandleTypeDef hadc1;
DAC_HandleTypeDef hdac;
TIM_HandleTypeDef htim3;
UART_HandleTypeDef huart2;

타이머 3와 ADC의 콜백 함수를 추가 합니다. (코드 일부)

/* USER CODE BEGIN 4 */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if ((htim->Instance == TIM3) && (adcBusy == 0U))
    {
        adcBusy = 1U;
        if (HAL_ADC_Start_IT(&hadc1) != HAL_OK)
        {
            adcBusy = 0U;
        }
    }
}
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1)
    {
        adcSum += HAL_ADC_GetValue(hadc);
        adcCount++;
        adcBusy = 0U;
        if (adcCount >= 100U)
        {
            adcAverage = adcSum / 100U;
            adcSum = 0U;
            adcCount = 0U;
            adcAverageReady = 1U;
            HAL_TIM_Base_Stop_IT(&htim3);
        }
    }
}
/* USER CODE END 4 */
/* USER CODE BEGIN 4 */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if ((htim->Instance == TIM3) && (adcBusy == 0U))
    {
        adcBusy = 1U;
        if (HAL_ADC_Start_IT(&hadc1) != HAL_OK)
        {
            adcBusy = 0U;
        }
    }
}
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1)
    {
        adcSum += HAL_ADC_GetValue(hadc);
        adcCount++;
        adcBusy = 0U;
        if (adcCount >= 100U)
        {
            adcAverage = adcSum / 100U;
            adcSum = 0U;
            adcCount = 0U;
            adcAverageReady = 1U;
            HAL_TIM_Base_Stop_IT(&htim3);
        }
    }
}
/* USER CODE END 4 */

TIM3 콜백은 ADC가 사용 중이 아닐 때 HAL_ADC_Start_IT()로 변환을 시작합니다. 시작 함수가 실패하면 adcBusy를 다시 0으로 돌려 다음 주기에 재시도할 수 있게 합니다.

ADC 콜백함수(변환완료)에서는 adcSum에 값을 계속해서 더하고 100번 값이 더해지면 그 값을 100으로 나눈 평균값을 adcAverage에 넣습니다.

그리고 100번 더한후에는 타이머 인터럽트를 중단 시키는데 앞에서 설명한것 처럼 1ms 인터럽트에 VCP출력 함수를 넣게 되면 타이머가 밀릴수 있어서 타이머 인터럽트를 멈추고 VCP 출력을 메인함수에서 한 후에 타이머를 다시 시작합니다.

int main(void)
{
  HAL_Init();
  SystemClock_Config();
  MX_GPIO_Init();
  MX_ADC1_Init();
  MX_TIM3_Init();
  MX_USART2_UART_Init();
  MX_DAC_Init();
  HAL_DAC_Start(&hdac, DAC_CHANNEL_1);
  printf("\r\nDAC - TEMT6000 Test Start\r\n");
  while (1)
  {
      for (uint32_t percent = 0; percent <= 100; percent += 10)
      {
          uint32_t dacValue;
          uint32_t dac_mV;
          dacValue = (4095 * percent) / 100;
          dac_mV = (3300 * dacValue) / 4095;
          HAL_DAC_SetValue(&hdac, DAC_CHANNEL_1, 
                           DAC_ALIGN_12B_R, dacValue);
          printf("DAC = %lu %%\r\n", percent);
          printf("DAC Raw = %lu\r\n", dacValue);
          printf("DAC Voltage = %lu mV\r\n", dac_mV);
          HAL_Delay(500);
          for (uint32_t repeat = 0; repeat < 5; repeat++)
          {
              adcSum = 0;
              adcCount = 0;
              adcAverage = 0;
              adcAverageReady = 0;
              adcBusy = 0;
              HAL_TIM_Base_Start_IT(&htim3);
              while (adcAverageReady == 0)
              {
              }
              uint32_t adc_mV = (adcAverage * 3300) / 4095;
              printf("Sample %lu: ADC=%lu, %lu mV\r\n", 
                     (unsigned long)(repeat + 1U), (unsigned long)adcAverage, 
                     (unsigned long)adc_mV);
          }
          HAL_Delay(500);
      }
      printf("\r\nTest Completed\r\n");
      HAL_Delay(3000);
  }
}
int main(void)
{
  HAL_Init();
  SystemClock_Config();
  MX_GPIO_Init();
  MX_ADC1_Init();
  MX_TIM3_Init();
  MX_USART2_UART_Init();
  MX_DAC_Init();
  HAL_DAC_Start(&hdac, DAC_CHANNEL_1);
  printf("\r\nDAC - TEMT6000 Test Start\r\n");
  while (1)
  {
      for (uint32_t percent = 0; percent <= 100; percent += 10)
      {
          uint32_t dacValue;
          uint32_t dac_mV;
          dacValue = (4095 * percent) / 100;
          dac_mV = (3300 * dacValue) / 4095;
          HAL_DAC_SetValue(&hdac, DAC_CHANNEL_1, 
                           DAC_ALIGN_12B_R, dacValue);
          printf("DAC = %lu %%\r\n", percent);
          printf("DAC Raw = %lu\r\n", dacValue);
          printf("DAC Voltage = %lu mV\r\n", dac_mV);
          HAL_Delay(500);
          for (uint32_t repeat = 0; repeat < 5; repeat++)
          {
              adcSum = 0;
              adcCount = 0;
              adcAverage = 0;
              adcAverageReady = 0;
              adcBusy = 0;
              HAL_TIM_Base_Start_IT(&htim3);
              while (adcAverageReady == 0)
              {
              }
              uint32_t adc_mV = (adcAverage * 3300) / 4095;
              printf("Sample %lu: ADC=%lu, %lu mV\r\n", 
                     (unsigned long)(repeat + 1U), (unsigned long)adcAverage, 
                     (unsigned long)adc_mV);
          }
          HAL_Delay(500);
      }
      printf("\r\nTest Completed\r\n");
      HAL_Delay(3000);
  }
}

동작방법은 Polling과 동일합니다. (대부분의 코드가 비슷합니다) (코드 일부)

앞에서 말한것 처럼 100번 읽고 변환하고 평균낸 뒤에 타이머 인터럽트를 멈추고 VCP출력 (printf함수)한 후에 다시 타이머3인터럽트 HAL_TIM_Base_Start_IT(&htim3)를 시작 합니다.

6. DMA 방식의 STM32 ADC 예제

앞에서 처럼 100개의 데이터를 받는 작업은 1ms 마다 인터럽트를 발생시킵니다. Polling과 마찬가지로 인터럽트 역시 MCU를 많이 사용하게 됩니다. 이렇게 많은 데이터를 변환하거나 복사하는 작업에 DMA를 사용하게 되면 MCU의 부담을 덜어줄 수 있습니다.

이 DMA 작업에는 MCU의 코어가 필요없습니다. 대신에 특정작업이 완료 되었을때, 예를 들어 버퍼의 절반이 채워졌거나 버퍼의 전부가 채워졌거나등의 이벤트가 발생할때 인터럽트가 동작하게 됩니다. 이 내용은 UART에서 이미 설명했습니다.

그림 17. Circular DMA 파라미터 설정

그림 17. Circular DMA 파라미터 설정

DMA역시 인터럽트를 사용하기 때문에 이전 인터럽트 설정을 그대로 두고 DMA 파라미터를 설정합니다. ADC1을 선택하고 Add 버튼을 누르면 상세 설정이 나옵니다.

Circular 모드를 선택합니다.

Peripheral Data Width와 Memory Data Width는 모두 Half Word(16비트)로 설정합니다.

Peripheral은 주소가 정해져 있기때문에 주소값을 증가시킬 필요가 없습니다.

메모리(버퍼)의 주소값은 증가시켜 줍니다. 그래야 다음 주소값에 데이터를 복사할수 있습니다.

값을 계속해서 변환하는 실습을 위해서 다음 설정을 수정합니다.

그림 18. DMA를 위한 ADC 설정

그림 18. DMA를 위한 ADC 설정

DMA로 데이터를 받을 버퍼를 설정합니다. (코드 일부)

인터럽트를 사용하기 때문에 전역변수로 설정합니다.

#define ADC_BUFFER_SIZE 1000U
#define ADC_HALF_SIZE   (ADC_BUFFER_SIZE / 2U)
uint16_t adcBuffer[ADC_BUFFER_SIZE];
volatile uint8_t adcHalfReady = 0U;
volatile uint8_t adcFullReady = 0U;
#define ADC_BUFFER_SIZE 1000U
#define ADC_HALF_SIZE   (ADC_BUFFER_SIZE / 2U)
uint16_t adcBuffer[ADC_BUFFER_SIZE];
volatile uint8_t adcHalfReady = 0U;
volatile uint8_t adcFullReady = 0U;

DMA 완료를 위한 콜백 함수를 만듭니다. (코드 일부)

void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1)
    {
        adcHalfReady = 1U;
    }
}
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1)
    {
        adcFullReady = 1U;
    }
}
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1)
    {
        adcHalfReady = 1U;
    }
}
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1)
    {
        adcFullReady = 1U;
    }
}

메인 함수 입니다. (코드 일부)

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_DMA_Init();
    MX_USART2_UART_Init();
    MX_ADC1_Init();
    printf("\r\nADC Circular DMA Test Start\r\n");
    if (HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adcBuffer,
                          ADC_BUFFER_SIZE) != HAL_OK)
    {
        Error_Handler();
    }
    while (1)
    {
        uint16_t *block = 0;
        if (adcHalfReady != 0U)
        {
            adcHalfReady = 0U;
            block = &adcBuffer[0];
        }
        else if (adcFullReady != 0U)
        {
            adcFullReady = 0U;
            block = &adcBuffer[ADC_HALF_SIZE];
        }
        if (block != 0)
        {
            uint32_t sum = 0U;
            for (uint32_t i = 0U; i < ADC_HALF_SIZE; i++)
            {
                sum += block[i];
            }
            printf("ADC Half Average = %lu\r\n",
                   (unsigned long)(sum / ADC_HALF_SIZE));
        }
    }
}
int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_DMA_Init();
    MX_USART2_UART_Init();
    MX_ADC1_Init();
    printf("\r\nADC Circular DMA Test Start\r\n");
    if (HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adcBuffer,
                          ADC_BUFFER_SIZE) != HAL_OK)
    {
        Error_Handler();
    }
    while (1)
    {
        uint16_t *block = 0;
        if (adcHalfReady != 0U)
        {
            adcHalfReady = 0U;
            block = &adcBuffer[0];
        }
        else if (adcFullReady != 0U)
        {
            adcFullReady = 0U;
            block = &adcBuffer[ADC_HALF_SIZE];
        }
        if (block != 0)
        {
            uint32_t sum = 0U;
            for (uint32_t i = 0U; i < ADC_HALF_SIZE; i++)
            {
                sum += block[i];
            }
            printf("ADC Half Average = %lu\r\n",
                   (unsigned long)(sum / ADC_HALF_SIZE));
        }
    }
}

루프를 시작하기 전에 DMA를 시작합니다.

DMA가 시작되면 1000개의 변환값이 원형 버퍼에 저장됩니다. 버퍼의 앞 절반과 뒤 절반이 채워질 때 각각 콜백이 실행됩니다.

메인 루프는 adcHalfReady 또는 adcFullReady 플래그를 확인해 DMA가 현재 쓰지 않는 절반만 평균 내고 VCP로 출력합니다.

Circular DMA에서는 처리 시간이 다음 반쪽 버퍼가 채워지는 시간보다 짧아야 합니다. 출력이 길거나 연산이 무거우면 샘플링 속도를 낮추거나 별도의 처리 버퍼를 사용해야 합니다.

두 개의 독립 버퍼를 번갈아 사용하는 더블 버퍼링도 같은 덮어쓰기 문제를 줄이는 방법입니다.

7. DAC·ADC 피드백 제어

위의 어떤 실습도 DMA를 잘 사용하는 예제는 아닙니다.

DMA는 값을 그냥 저장하는 용도가 아닌 리얼타임 제어에서 유용하게 쓸 수 있습니다.

이번 예제는 1500이라는 값을 설정하고 센서에 외란을 가했을 때 다시말해 외부 환경이 변했을 때 어떻게 1500을 계속해서 유지하도록 제어를 할수 있는지를 볼것입니다.

이 예제를 위해서 P(Proportional) 제어를 구현합니다. DMA를 사용하는 것을 중점적으로 보는 것이 목적이라서 PID 제어까지는 하지 않습니다.

그림 19. 피드백 시스템

그림 19. 피드백 시스템

타겟을 1500으로 설정한 후에 입력으로 넣습니다. P 제어를 통해서 계산된 값이 DAC를 통해서 LED에 전달되고 LED의 밝기가 변합니다. 변화된 밝기를 TEMT6000 센서로 읽어서 이를 ADC를 통해 디지털 값으로 변환하고 이 값을 다시 기존의 값과 비교해서 P 제어를 합니다. 설정된 값과 피드백 된 값의 차이를 에러로 설정하고 그 값에 P게인과 연산한 후에 새로운 출력값을 만들어 냅니다.

모든 CubeMX 설정은 앞의 Circular DMA 설정과 동일합니다. 아래 코드는 P 제어에 필요한 정의, 콜백과 메인 루프만 정리한 코드 일부입니다.

/* USER CODE BEGIN PD */
#define ADC_BLOCK_SIZE   100
#define ADC_DMA_SIZE     200
#define ADC_TARGET       1500
#define ADC_DEADBAND     5
#define KP_DIV           10      // adjustment = error / 10
/* USER CODE END PD */

/* USER CODE BEGIN PD */
#define ADC_BLOCK_SIZE   100
#define ADC_DMA_SIZE     200
#define ADC_TARGET       1500
#define ADC_DEADBAND     5
#define KP_DIV           10      // adjustment = error / 10
/* USER CODE END PD */
/* USER CODE BEGIN PV */
uint16_t adcBuffer[ADC_DMA_SIZE];
volatile uint8_t adcHalfReady = 0;
volatile uint8_t adcFullReady = 0;
uint32_t dacValue = 1400;
/* USER CODE END PV */

/* USER CODE BEGIN PV */
uint16_t adcBuffer[ADC_DMA_SIZE];
volatile uint8_t adcHalfReady = 0;
volatile uint8_t adcFullReady = 0;
uint32_t dacValue = 1400;
/* USER CODE END PV */
/* USER CODE BEGIN 0 */
int __io_putchar(int ch) {
	HAL_UART_Transmit(&huart2, (uint8_t*) &ch, 1, HAL_MAX_DELAY);
	return ch;
}
void ProcessADCBlock(uint16_t *buffer)
{
    uint32_t adcSum = 0;
    for (uint32_t i = 0; i < ADC_BLOCK_SIZE; i++)
    {
        adcSum += buffer[i];
    }
    uint32_t adcAverage = adcSum / ADC_BLOCK_SIZE;
    int32_t error = ADC_TARGET - (int32_t)adcAverage;
    if ((error > ADC_DEADBAND) || (error < -ADC_DEADBAND))
    {
        int32_t adjustment = error / KP_DIV;
        if (adjustment == 0)
        {
            if (error > 0) adjustment = 1;
            else adjustment = -1;
        }

        int32_t newDac = (int32_t)dacValue + adjustment;

        if (newDac > 4095) newDac = 4095;
        if (newDac < 0) newDac = 0;
        dacValue = (uint32_t)newDac;

        HAL_DAC_SetValue(&hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, dacValue);
    }
    printf("ADC=%lu Error=%ld DAC=%lu\r\n", adcAverage, error, dacValue);
}
/* USER CODE END 0 */

/* USER CODE BEGIN 0 */
int __io_putchar(int ch) {
	HAL_UART_Transmit(&huart2, (uint8_t*) &ch, 1, HAL_MAX_DELAY);
	return ch;
}
void ProcessADCBlock(uint16_t *buffer)
{
    uint32_t adcSum = 0;
    for (uint32_t i = 0; i < ADC_BLOCK_SIZE; i++)
    {
        adcSum += buffer[i];
    }
    uint32_t adcAverage = adcSum / ADC_BLOCK_SIZE;
    int32_t error = ADC_TARGET - (int32_t)adcAverage;
    if ((error > ADC_DEADBAND) || (error < -ADC_DEADBAND))
    {
        int32_t adjustment = error / KP_DIV;
        if (adjustment == 0)
        {
            if (error > 0) adjustment = 1;
            else adjustment = -1;
        }

        int32_t newDac = (int32_t)dacValue + adjustment;

        if (newDac > 4095) newDac = 4095;
        if (newDac < 0) newDac = 0;
        dacValue = (uint32_t)newDac;

        HAL_DAC_SetValue(&hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, dacValue);
    }
    printf("ADC=%lu Error=%ld DAC=%lu\r\n", adcAverage, error, dacValue);
}
/* USER CODE END 0 */
/* USER CODE BEGIN 4 */

void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1)
    {
        adcHalfReady = 1;
    }
}

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1)
    {
        adcFullReady = 1;
    }
}
/* USER CODE END 4 */

/* USER CODE BEGIN 4 */

void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1)
    {
        adcHalfReady = 1;
    }
}

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1)
    {
        adcFullReady = 1;
    }
}
/* USER CODE END 4 */
int main(void)
{
  HAL_Init();
  SystemClock_Config();
  MX_GPIO_Init();
  MX_DMA_Init();
  MX_ADC1_Init();
  MX_TIM3_Init();
  MX_USART2_UART_Init();
  MX_DAC_Init();

  HAL_DAC_Start(&hdac, DAC_CHANNEL_1);
  dacValue = 1400;
  HAL_DAC_SetValue(&hdac, DAC_CHANNEL_1,
                   DAC_ALIGN_12B_R, dacValue);

  printf("\r\nTEMT6000 Feedback Control Start\r\n");
  printf("Target = %d\r\n", ADC_TARGET);
  printf("Initial DAC = %lu\r\n", dacValue);

  if (HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adcBuffer,
                        ADC_DMA_SIZE) != HAL_OK)
  {
      Error_Handler();
  }

  while (1) {
	if (adcHalfReady)
	{
	    adcHalfReady = 0;
	    ProcessADCBlock(&adcBuffer[0]);
	}
	if (adcFullReady)
	{
	    adcFullReady = 0;
	    ProcessADCBlock(&adcBuffer[ADC_BLOCK_SIZE]);
	}
  }
}
int main(void)
{
  HAL_Init();
  SystemClock_Config();
  MX_GPIO_Init();
  MX_DMA_Init();
  MX_ADC1_Init();
  MX_TIM3_Init();
  MX_USART2_UART_Init();
  MX_DAC_Init();

  HAL_DAC_Start(&hdac, DAC_CHANNEL_1);
  dacValue = 1400;
  HAL_DAC_SetValue(&hdac, DAC_CHANNEL_1,
                   DAC_ALIGN_12B_R, dacValue);

  printf("\r\nTEMT6000 Feedback Control Start\r\n");
  printf("Target = %d\r\n", ADC_TARGET);
  printf("Initial DAC = %lu\r\n", dacValue);

  if (HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adcBuffer,
                        ADC_DMA_SIZE) != HAL_OK)
  {
      Error_Handler();
  }

  while (1) {
	if (adcHalfReady)
	{
	    adcHalfReady = 0;
	    ProcessADCBlock(&adcBuffer[0]);
	}
	if (adcFullReady)
	{
	    adcFullReady = 0;
	    ProcessADCBlock(&adcBuffer[ADC_BLOCK_SIZE]);
	}
  }
}

Circular 버퍼를 사용하는데 절반씩 나누어서 사용합니다. 버퍼의 크기는 200 이고 half, 100개를 받은 후에는 0에서 99까지의 데이터를 사용해서 평균을 낸 후에 그 값을 1500과 비교해서 에러값을 계산합니다. Full이 되면 100부터 199까지의 데이터를 사용해서 평균을 내고 에러값을 계산합니다. Circular 버퍼를 사용하게 되면 버퍼가 절반이 차면 이벤트를 발생시키고 가득차면 다시 이벤트를 발생시킵니다.

콜백 함수에서 각각의 이벤트가 발생했을 때 flag를 합니다. adcHalfReady와 adcFullReady 변수가 그 flag 입니다.

메인함수에서 이 flag에 따라서 버퍼의 앞부분(0~99)를 입력으로 넣을것인지 뒷부분(100~199)를 입력으로 넣을것인지를 결정 합니다.

ProcessADCBlock함수는 어느 버퍼에서 온 것인지를 확인하지 않습니다. 이미 메인함수에서 정해졌기 때문이죠. 그냥 계산해서 P제어를 실행 합니다.

이렇게 실시간으로 제어를 하는 프로젝트에 DMA는 아주 유용합니다.

그림 20. 제어 결과

그림 20. 제어 결과

이렇게 해서 STM32에서 ADC를 사용하는 방법에 대해서 알아봤습니다.

STM32 실습 회로를 PCB로 제작해 보세요

Gerber 파일을 업로드하고 제조 가능 여부, 비용과 DFM 결과를 확인하십시오.

지금 견적 받기

8. 참고 자료

STMicroelectronics STM32F446 제품 정보: 공식 문서 열기

STMicroelectronics RM0390 Reference Manual: 공식 문서 열기

Vishay TEMT6000 데이터시트: 공식 문서 열기

지속적인 성장