STM32 PWM 제어 가이드: 주파수·듀티비 설정과 모터 제어 예제
13 분
- 1. STM32 PWM·타이머·카운터 기본 원리
- 2. 외부 입력 카운터와 RPM 측정
- 3. STM32 PWM으로 서보모터 제어
- 4. STM32 PWM DC 모터 제어와 A/B상 인코더
- 5. 참고 자료
핵심 요약
- 타이머 Prescaler와 ARR을 이용한 PWM 주파수·듀티비 계산 방법을 정리합니다.
- NUCLEO-G491RE에서 서보모터와 DC 모터를 PWM으로 제어합니다.
- 단일 채널 및 A/B상 인코더를 읽어 회전 방향과 RPM을 계산합니다.
- CubeMX 설정, HAL 코드와 VCP 출력 결과를 실제 실습 이미지로 확인합니다.
Nucleo-G491RE 보드와 DC 모터/인코더를 이용한 실습
이 글에서는 STM32 PWM의 주파수·듀티비 계산 원리를 익히고, NUCLEO-G491RE의 타이머와 카운터를 이용해 서보모터·DC 모터를 구동하며 단일 채널 및 A/B상 인코더를 읽습니다.
브레드보드에서 모터 드라이버와 인코더 동작을 확인한 뒤 전원·신호 커넥터와 테스트 포인트를 작은 인터페이스 PCB로 정리하면 배선 오류를 줄이고 재현성을 높일 수 있습니다. PCB 제작시Gerber 파일은 JLCPCB PCB 서비스에 업로드하고 온라인 견적에서 제작 가능 여부와 비용을 확인할 수 있습니다.
- 타이머/카운터에 대한 기본적인 내용을 배웁니다.
- PWM으로 서보모터를 제어합니다.
- PWM으로 DC 모터를 제어합니다.
- 카운터를 이용해서 1 채널 인코더 신호를 처리합니다.
- 타이머를 이용해서 A/B상 인코더 신호를 처리합니다.
1. STM32 PWM·타이머·카운터 기본 원리
STM32의 타이머는 내부 클럭을 세거나 외부 펄스를 카운트해 주기 이벤트, 입력 캡처, 출력 비교와 PWM을 만드는 주변장치입니다. STM32G491RE 제품군에는 watchdog과 SysTick을 포함해 총 15개의 타이머가 있습니다.
범용 타이머 가운데 TIM2는 32비트이고 TIM3·TIM4는 16비트입니다. TIM6·TIM7은 PWM/입력 캡처 채널이 없는 기본 타이머이며, TIM1·TIM8·TIM20 같은 고급 제어 타이머와 저전력 타이머도 용도에 맞게 사용할 수 있습니다.
이전 설명에서도 사용했던 타이머 인터럽트 기능은 내부의 클럭을 사용한 타이머의 기능중에 하나입니다. 흔히들 타이머 라고 하면 생각하는 기능이 인터럽트 타이머 기능입니다.
카운터는 무엇인가를 세는 기능인데 주로 외부에서 들어오는 신호중에 펄스를 세는 기능을 말합니다. 이 카운터 기능역시 타이머 기능중에 일 부분입니다.
PWM(Pulse Width Modulation)은 타이머 카운터를 이용해 일정한 주기 안에서 High 구간의 폭, 즉 듀티비를 조절하는 방식입니다.
결국 이 타이머/카운터/PWM은 모두 타이머를 이용해서 만드는 기능들인데 이 이름을 조금 수정해 보자면 타이머 인터럽트/외부 입력 카운터/타이머-카운터를 이용한 PWM 이라고 할수 있겠습니다.
여기서 흔히들 타이머를 내부 클럭을 이용한 기능, 카운터는 외부의 클럭을 이용한 기능 이라고 말하는데 간단하게 말하면 맞는말 이지만 전체를 설명하지는 못합니다.
타이머를 이해하기 위해 알아야할 중요한 레지스터 4개가 있습니다. PSC, CNT, ARR, CCR
1.1 PSC(Prescaler)
- 타이머의 입력 클럭을 분주(나누는) 합니다.
- 실제 분주 값은 PSC + 1 이고 카운터의 클럭 주파수가 됩니다.
- 만일 입력 주파수가 84MHz 라고 할때 PSC=83이 되면 카운터 클럭 주파수는 1MHz가 됩니다.
1.2 CNT(Counter)
- 현재 카운트 되는 값 입니다. (읽고 쓰기가 가능)
- Up-counting 모드에서는 CNT가 0에서 ARR까지 증가한 뒤 업데이트 이벤트와 함께 0으로 다시 로드됩니다.
- 0에서 시작해서 ARR 까지 증가 후 감소 (Center aligned 모드)
- Up/Down 모드에서 0, ARR/ARR, 0 사이클 왕복
- CNT 방향 비트 (DIR)로 Up/Down 모드 설정
1.3 ARR(Auto-Reload Register)
- 자동으로 값이 로드 되고 이 값이 주기를 결정합니다.
- Up 카운트 시 최대값은 ARR이 되고 Down 카운트시 최소값은 0 입니다.
- CNT가 0 또는 ARR에 도달하면 업데이트 이벤트(UEV)가 발생합니다.
- 센터 정렬모드에서 주기는 두배가 됩니다.
1.4 CCR(Compare Register)
- 비교값의 범위는 0에서 ARR까지 입니다.
- CNT와 CCR 비교가 일치할때 이벤트가 발생합니다.
- PWM 모드에서 출력(High/Low) 결정의 기준이 됩니다.
- 채널마다 CCR1, CCR2, 같은 레지스터가 존재합니다.
Up-counting 모드

그림 1. Up-counting 모드 (STM, RM0440 매뉴얼)
타이머와 카운터의 조합으로 이벤트를 만들어 낼수 있습니다. (이후에도 같은 방법으로 설명합니다)
Up-counting 모드에서 ARR값을 설정해 놓고 (그림 1에서는 0x36으로 세팅) 카운터값이 증가하다가 ARR값이 되면 Counter Overflow 이벤트(Update event)가 발생하게되고 이벤트 콜백 함수(유저가 만들어 놓은)가 실행이 됩니다.

그림 2. Down-counting 모드
Down-counting 모드에서는 ARR 설정값 부터 시작해서 CNT(그림에서의 Counter Register)가 하나씩 감소하게 되고 0 이 되면 Underflow 가 발생하고 역시 Update event가 발생하게 됩니다. 유저는 콜백함수를 만들수 있고 이 콜백함수가 실행이 됩니다.
여기까지는 Edge align 모드 입니다.

그림 3. Center align 모드 (ARR은 6으로 세팅)
Center-aligned 모드에서는 CNT가 0과 ARR 사이를 왕복합니다. 업데이트 이벤트가 발생하는 지점은 CMS 등 설정에 따라 달라질 수 있으므로 CubeMX 설정과 RM0440의 해당 타이머 절을 함께 확인합니다.

그림 4. PWM 모드
PWM 모드에서는 ARR과 CCR 값에 따라서 결과가 다르게 나타납니다.
PWM mode 1의 Up-counting 예에서는 CNT가 CCR보다 작은 동안 출력이 활성 상태가 됩니다. 따라서 듀티비는 대략 CCR/(ARR+1)로 계산하며, CCR=0이면 0%, CCR=ARR+1이면 100%가 됩니다. 출력 극성과 PWM mode 1/2 설정에 따라 High와 Low의 의미는 바뀔 수 있습니다.
CCR=ARR이면 마지막 한 카운트 구간이 비활성 상태이므로 정확한 100%가 아닙니다.
정확한 100% 출력을 만들려면 CCR을 ARR+1로 설정하거나 출력 강제 활성 모드를 사용합니다.
CCR=0이면 PWM mode 1의 활성 High 구간은 0%가 됩니다.
이렇게 타이머의 기본적인 기능들에 대해 알아봤습니다. 이 외에도 추가적인 기능이 있습니다. STM에서 만든 레퍼런스 매뉴얼중 RM0440을 참조 하면 더 많은 추가적인 기능을 포함한 더 많은 내용을 확인할수 있습니다.
2. 외부 입력 카운터와 RPM 측정
이 실습을 위해 TT 모터와 20개의 슬릿이 있는 소형 인코더 원반 그리고 IR LED와 센서가 일체형으로 내장되어 있는 인코더 모듈을 사용했습니다.

그림 5. 카운터 실습을 위한 모터, 인코더, 센서
이미지에서 보이는 노란색이 TT 모터 이고 그 축에 연결된 슬릿이 있는 원형 디스크가 인코더 입니다. 그리고 인코더의 슬릿을 관통하는 IR LED/센서 모듈이 있습니다.
모터는 먼저 낮은 전압에서 무부하로 시험했지만, 정격 전압과 허용 전류는 사용한 모터의 데이터시트를 따라야 합니다. 모터를 MCU 전원 핀에 직접 연결하지 말고 별도 전원과 드라이버를 사용합니다.

그림 6. 인코더 출력 파형
센서의 빛이 인코더의 슬릿을 통과하면 High 값을 출력하고 슬릿을 통과하지 못하면 Low 값을 출력하게 됩니다.
오실로스코프의 주파수 측정값은 36.4237 Hz입니다. 그림 6의 한 주기가 인코더 펄스 한 개에 해당합니다.
초당 회전수는

이고 RPM 으로 바꾸면

RPM = 109.27 이 됩니다.
이제 이 인코더 펄스(열)을 읽어서 STM32로 RPM값을 측정해 보겠습니다.
사용하는 타이머는 32비트 범용 타이머인 TIM2 입니다.
2.1 STM32CubeMX 설정
RPM을 구한 뒤 printf()로 VCP에 출력하려면 UART 설정이 필요합니다. NUCLEO-G491RE의 기본 ST-LINK VCP는 PA2(TX)·PA3(RX)의 LPUART1에 연결됩니다.
NUCLEO-F446RE에서는 PA2·PA3의 USART2를 VCP로 사용하므로 생성되는 핸들은 각각 hlpuart1과 huart2로 구분해야 합니다.


그림 7. Pin 맵/이름
설정은 두개만 고치면 됩니다.
Mode: Asynchronous
Baud Rate: 115200 Bits/s

그림 8. LPUART1(VCP) 파라미터 설정
외부에서 들어오는 인코더 신호(펄스)를 받아야 할 포트가 필요합니다.
여기서는 타이머2(TIM2) 포트를 사용해서 그 인코더 신호를 카운트 할수 있습니다.

그림 9. 타이머2 핀맵(Pinmap)
타이머2의 채널 1은 PA0을 사용합니다. 핀 설정에서 TIM_CH1을 선택하면 됩니다.

그림 10. 타이머2(TIM2) 설정
타이머2의 설정중에서 중요한 설정은
Slave Mode: External Clock Mode 1
Trigger Source: TI1FP1 (Timer Input 1 Filtered input 1)
- 이TIM2_CH1핀 (PA0)으로 들어온 신호가 필터를 거쳐서 카운팅을 합니다.
- Trigger Filter에서 필터를 선택할수 있습니다.
Counter Settings – Prescaler: 0 (내부 클럭이 아니라서 필요가 없습니다)
Counter Period(ARR): 19 (슬릿 20개를 한 회전으로 볼 때 0~19를 센 뒤 업데이트 이벤트가 발생합니다.)
Slave Mode Controller는 External Clock Mode 1, Trigger Source는 TI1FP1로 설정해 PA0의 필터링된 입력 에지를 카운트합니다.
Trigger Filter: 4 (숫자가 커지면 노이즈를 더 잘 제거 하지만 속도가 느려져 고속펄스를 놓칠수 있음. 일반적인 인코더 노이즈 제거에 2~4 값을 사용함)
이 설정에서 보듯이 카운터 이벤트는 20개의 펄스를 읽은 시점에서 발생을 하게 됩니다. 아래의 코드에서 이벤트는 모터가 한바퀴를 돌때마다 발생합니다. (아래의 코드에서 설명)
이 값을 수정해서 반바퀴 돌때마다 혹은 1펄스 읽을때 마다 이벤트를 발생시켜도 됩니다. RPM계산을 할때 값이 달라지게 될뿐 다른 차이는 없습니다.
2.2 카운터로 RPM을 구하는 코드
VCP사용을 위한 코드는 다음과 같습니다.
이전 VCP 코드와 동일하지만 NUCLEO-G491RE에서는 huart2가 아니라 hlpuart1을 사용합니다. 이 핸들은 CubeMX가 생성합니다. (코드 일부)
/* USER CODE BEGIN Includes */
#include <stdio.h>
/* USER CODE END Includes */
/* USER CODE BEGIN 0 */
int __io_putchar(int ch)
{
HAL_UART_Transmit(&hlpuart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}
/* USER CODE END 0 */
/* USER CODE BEGIN Includes */
#include <stdio.h>
/* USER CODE END Includes */
/* USER CODE BEGIN 0 */
int __io_putchar(int ch)
{
HAL_UART_Transmit(&hlpuart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}
/* USER CODE END 0 */
RPM 계산을 위한 변수를 추가 합니다. (코드 일부)
/* USER CODE BEGIN PD */
#define ENCODER_PPR 20U
#define SPEED_PERIOD_MS 1000U
/* USER CODE END PD */
/* USER CODE BEGIN PV */
volatile uint32_t encoder_revolution_count = 0;
volatile uint32_t encoder_period_ms = 0;
volatile uint32_t encoder_last_tick = 0;
volatile uint8_t encoder_data_ready = 0;
/* USER CODE END PV */
/* USER CODE BEGIN PD */
#define ENCODER_PPR 20U
#define SPEED_PERIOD_MS 1000U
/* USER CODE END PD */
/* USER CODE BEGIN PV */
volatile uint32_t encoder_revolution_count = 0;
volatile uint32_t encoder_period_ms = 0;
volatile uint32_t encoder_last_tick = 0;
volatile uint8_t encoder_data_ready = 0;
/* USER CODE END PV */
주석은 추가된 코드의 위치를 쉽게 찾기 위해 넣었습니다.
타이머2에서 발생하는 (20펄스 카운트 할때마다) 이벤트의 콜백 함수 입니다. (코드 일부)
20펄스 카운트 할 때마다 발생을 하고 이는 모터가 한바퀴 회전했다는것을 의미합니다.
이 코드에서는 HAL_GetTick() 함수를 이용해서 모터가 한바퀴 회전할때 걸리는 시간을 측정하고 모터의 회전수를 카운트 합니다.
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM2)
{
uint32_t current_tick = HAL_GetTick();
encoder_period_ms = current_tick - encoder_last_tick;
encoder_last_tick = current_tick;
encoder_revolution_count++;
encoder_data_ready = 1;
}
}
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM2)
{
uint32_t current_tick = HAL_GetTick();
encoder_period_ms = current_tick - encoder_last_tick;
encoder_last_tick = current_tick;
encoder_revolution_count++;
encoder_data_ready = 1;
}
}
마지막으로 main() 함수 입니다. (코드 일부)
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_LPUART1_UART_Init();
MX_TIM2_Init();
__HAL_TIM_SET_COUNTER(&htim2, 0U);
__HAL_TIM_CLEAR_FLAG(&htim2, TIM_FLAG_UPDATE);
encoder_last_tick = HAL_GetTick();
if (HAL_TIM_Base_Start_IT(&htim2) != HAL_OK)
{
Error_Handler();
}
printf("TIM2 Counter Interrupt Start\r\n");
uint32_t previous_count = 0;
uint32_t previous_tick = HAL_GetTick();
while (1)
{
if (encoder_data_ready)
{
uint32_t revolution_count;
uint32_t period_ms;
uint32_t rpm_x10;
__disable_irq();
revolution_count = encoder_revolution_count;
period_ms = encoder_period_ms;
encoder_data_ready = 0;
__enable_irq();
if (period_ms > 0U)
{
rpm_x10 = 600000U / period_ms;
printf("Revolution=%lu, Period=%lu ms, RPM=%lu.%lu\r\n",
(unsigned long)revolution_count,
(unsigned long)period_ms,
(unsigned long)(rpm_x10 / 10U),
(unsigned long)(rpm_x10 % 10U));
}
}
}
}
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_LPUART1_UART_Init();
MX_TIM2_Init();
__HAL_TIM_SET_COUNTER(&htim2, 0U);
__HAL_TIM_CLEAR_FLAG(&htim2, TIM_FLAG_UPDATE);
encoder_last_tick = HAL_GetTick();
if (HAL_TIM_Base_Start_IT(&htim2) != HAL_OK)
{
Error_Handler();
}
printf("TIM2 Counter Interrupt Start\r\n");
uint32_t previous_count = 0;
uint32_t previous_tick = HAL_GetTick();
while (1)
{
if (encoder_data_ready)
{
uint32_t revolution_count;
uint32_t period_ms;
uint32_t rpm_x10;
__disable_irq();
revolution_count = encoder_revolution_count;
period_ms = encoder_period_ms;
encoder_data_ready = 0;
__enable_irq();
if (period_ms > 0U)
{
rpm_x10 = 600000U / period_ms;
printf("Revolution=%lu, Period=%lu ms, RPM=%lu.%lu\r\n",
(unsigned long)revolution_count,
(unsigned long)period_ms,
(unsigned long)(rpm_x10 / 10U),
(unsigned long)(rpm_x10 % 10U));
}
}
}
}
이 메인함수에서는 encoder_data가 준비되었는지 확인후에 콜백 함수에서 저장한 값들을 연산을 위해 내부의 변수로 복사를 합니다. 이 변수 복사를 하는동안에도 콜백함수가 실행되어 값이 변할수 있기 때문에 IRQ(인터럽트)를 disable 해서 복사하는 동안에 외부에서 그 값이 변하지 않도록 합니다. 이는 RTOS에서 흔히 볼수 있는 critical section과 같은 역할을 합니다.
이 값을 이용해서 RPM을 계산한 후에 printf() 함수를 사용해서 VCP로 출력을 합니다.
이 코드를 실행하면 다음과 같은 화면을 터미털로 확인할 수 있습니다.

그림 11. 터미털로 출력된 RPM값
3. STM32 PWM으로 서보모터 제어
이 실습에서는 SG90급 소형 서보모터를 사용합니다. 전원은 모터 사양에 맞게 별도로 공급하고 STM32와 GND를 공통으로 연결합니다.
이 실습에서는 SG90 소형 서보모터를 사용했습니다.
동작 전압: 5V
신호 전압: 3.3V ~ 5V
PWM 주파수: 50Hz (주기: 20ms)
예제 매핑: 0도 부근 1.0 ms, 180도 부근 2.0 ms. 실제 끝점은 제품 편차가 있으므로 무리한 기계적 충돌이 없도록 안전 범위에서 보정합니다.
서보마다 허용 펄스 폭과 각도 범위가 다를 수 있으므로 사용한 제품의 데이터시트나 실측값을 우선합니다.
이번 실습에서는 ADC의 입력을 받아 0도에서 180도 회전하는 기능을 만듭니다.
앞에서 설명한 것처럼 PWM을 구현하면 됩니다. 다만 얼마나 높은 해상도로 구현을 할것인지가 중요한 내용입니다.
50 Hz PWM의 주기는 20 ms입니다. 카운터 틱을 1 kHz(1 ms)로 만들면 ARR=19, CCR=1~2로 1~2 ms 펄스를 만들 수 있지만 각도 해상도는 매우 낮습니다.
더 부드러운 제어를 위해 이 예제는 카운터 틱을 1 MHz(1 µs)로 설정합니다.
1 MHz 틱에서 ARR=19999이면 20,000카운트가 20 ms가 되고, CCR=1000~2000이면 1.0~2.0 ms 펄스가 됩니다.
따라서 펄스 폭을 1 µs 단위로 조절할 수 있으며, 실제 각도 분해능은 서보의 기계적 특성과 보정 결과에 따라 달라집니다.
3.1 STM32CubeMX 설정
PSC, ARR, CCR값 세팅을 동작 스펙에 맞게 설정해야 합니다.
NUCLEO-G491RE 보드의 이 예제는 170 MHz 타이머 입력 클럭을 사용합니다. NUCLEO-F446RE의 84 MHz 예제와 값이 다르므로 Clock Configuration을 먼저 확인합니다.
F446RE에서 사용하던 클럭이 84MHz 였다면 G491RE의 클럭은 170MHz 을 사용합니다.

그림 12. 타이머3(TIM3) 기준 클럭

그림 13. 타이머3 핀설정
타이머에서 나오는 PWM 신호를 사용해야 하기 때문에 출력 핀설정이 필요합니다. 여기서는 타이머3(TIM3) 채널2(CH2)를 사용합니다. PC7핀에 할당합니다.

그림 14. 타이머3(TIM3) 파라미터 설정
중요한 설정은 다음과 같습니다.
Clock Source: Internal Clock
Channel 2: PWM Generation CH2 (직관적 입니다)
Prescaler (PSC): 169
- 이전에 설명했듯이 169+1(시작이 0이라서 169가 되면 170개가 됩니다)의 값이 사용됩니다. 입력 클럭이 170MHz 이라서 170으로 나누면 1MHz가 됩니다.
Counter Mode: Up
Counter Period (ARR): 19999
- 카운터 클럭은 1MHz 이고 1카운트는 1us 입니다.
- ARR이 19999가 되면 0에서 19999까지 20000개의 카운트가 되고 PWM 주기는 20000us 즉 20ms가 됩니다. PWM 주파수는 50Hz가 됩니다.
PWM Generation Channel 2 – Mode: PWM mode 1
Pulse (16 bits value): 1500
- 이 값이 CCR 입니다. 채널 2번이라 CCR2가 되겠습니다.
- 1MHz에 1500 카운트면 1500us = 1.5ms가 됩니다. 서보모터 설정으로 약 90도 회전합니다.
초기 Pulse는 1500(1.5 ms)으로 두고, 이후 CCR2 값을 코드에서 바꾸어 원하는 각도로 이동합니다.

그림 15. ADC 핀 설정

그림 16. ADC 파라미터 설정
중요설정은 다음과 같습니다.
ADC_Regular_ConversionMode – Rank – Sampling Time: 247.5 Cycles
- 서보모터의 움직임이 ADC의 변환 사이클 보다 매우 느리기 때문에 큰 값을 설정해도 됩니다.
마지막으로 VCP 설정은 “카운터”의 설정과 동일합니다.
3.2 서보 제어 코드
VCP 코드 역시 이전 “카운터”의 코드와 동일합니다. (코드 일부)
자동 생성된 UART 핸들이 NUCLEO-G491RE에서는 hlpuart1이라는 점에 주의합니다. NUCLEO-F446RE의 VCP 핸들은 huart2입니다.
/* Private variables -----------------------------------------------*/
ADC_HandleTypeDef hadc1;
UART_HandleTypeDef hlpuart1;
TIM_HandleTypeDef htim3;
/* Private variables -----------------------------------------------*/
ADC_HandleTypeDef hadc1;
UART_HandleTypeDef hlpuart1;
TIM_HandleTypeDef htim3;
ADC에서 읽어오는 값과 펄스값을 저장할 변수를 정의 합니다. (코드 일부)
/* USER CODE BEGIN PV */
uint32_t adc_value = 0;
uint32_t servo_pulse_us = 1500;
/* USER CODE END PV */
/* USER CODE BEGIN PV */
uint32_t adc_value = 0;
uint32_t servo_pulse_us = 1500;
/* USER CODE END PV */
메인 함수 입니다. (코드 일부)
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_ADC1_Init();
MX_LPUART1_UART_Init();
MX_TIM3_Init();
if (HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED) != HAL_OK)
{
Error_Handler();
}
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 1500);
if (HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2) != HAL_OK)
{
Error_Handler();
}
while (1)
{
HAL_ADC_Start(&hadc1);
if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK)
{
adc_value = HAL_ADC_GetValue(&hadc1);
servo_pulse_us = 1000U + ((adc_value * 1000U) / 4095U);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, servo_pulse_us);
}
HAL_ADC_Stop(&hadc1);
HAL_Delay(200);
}
}
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_ADC1_Init();
MX_LPUART1_UART_Init();
MX_TIM3_Init();
if (HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED) != HAL_OK)
{
Error_Handler();
}
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 1500);
if (HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2) != HAL_OK)
{
Error_Handler();
}
while (1)
{
HAL_ADC_Start(&hadc1);
if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK)
{
adc_value = HAL_ADC_GetValue(&hadc1);
servo_pulse_us = 1000U + ((adc_value * 1000U) / 4095U);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, servo_pulse_us);
}
HAL_ADC_Stop(&hadc1);
HAL_Delay(200);
}
}
HAL_ADCEx_Calibration_Start()로 ADC 오프셋을 보정합니다. ADC 변환을 시작하기 전에 전원 인가 후 한 번 실행합니다.
__HAL_TIM_SET_COMPARE()로 서보의 초기 펄스 폭을 설정합니다. CubeMX의 초기 Pulse 값과 같다면 생략할 수 있습니다.
HAL_TIM_PWM_Start()로 TIM3 채널 2의 PWM 출력을 시작합니다.
While 내부에서는 폴링 방식으로 ADC의 값을 읽습니다. HAL_ADC_PollForConversion함수로 세팅을 하는데 여기서는 최대 10ms를 기다립니다.
폴링 함수 내부에서 ADC값을 읽은 후에 서보회전값을 계산하고 그 값을 CCR에 넣어주면 PWM 출력이 만들어 집니다.
그리고 0.2초를 기다리게 되는데 이 값은 모터의 회전시간에 따라서 조절하면 됩니다.

그림 17. 서보모터를 위한 1ms PWM (0도)

그림 18. 서보모터를 위한 2ms PWM (180도)
4. STM32 PWM DC 모터 제어와 A/B상 인코더
STM32의 타이머 입력에는 A/B 상 인코더를 처리하는 기능이 내장되어 있습니다.
A/B 상 인코더는 다음과 같이 이해하면 됩니다.

그림 19. A/B상 인코더 출력
그림 19처럼 A상이 먼저 변하면 한 방향, B상이 먼저 변하면 반대 방향으로 판단할 수 있습니다. 실제 Forward/Reverse 부호는 배선과 타이머 설정에 따라 반대가 될 수 있습니다.
이번 실습에서 구현할 기능은 다음과 같습니다.
- ADC 입력으로 모터 속도 조절
- 모터 구동을 위해 TB6612FNG 모듈을 사용
- 모터에 연결되어 있는 A/B 상 인코더를 입력으로 받아 모터의 회전 방향과 RPM 출력
STM32에서 사용하는 핀들은 다음과 같습니다.
| STM32 핀 | 연결대상 | 기능 | 설정방식 |
|---|---|---|---|
| PB0 | 가변저항 출력 | ADC1_IN15 | Analog |
| PC7 | TB6612 PWMA | TIM3_CH2_PWM | Alternate Function |
| PA8 | TB6612 AIN1 | 모터 방향 1 | GPIO OUTPUT |
| PA9 | TB6612 AIN2 | 모터 방향 2 | GPIO OUTPUT |
| PB5 | TB6612 STBY | Enable | GPIO OUTPUT |
| PA0 | 인코더 A상 | TIM2_CH1 | Alternate Function |
| PA1 | 인코더 B상 | TIM2_CH2 | Alternate Function |
전체 연결은 아래 그림 20과 같습니다.
상단 중앙의 모듈은 TB6612FNG 모터 드라이버입니다. VM 전원 근처에는 모터의 과도 전류를 완화할 벌크 커패시터와 데이터시트 권장 바이패스 커패시터를 배치합니다.
아래쪽에 모터 속도 조절을 위한 가변저항과 제어/인코더처리를 위한 Nucelo-G491RE 보드가 있습니다.

그림 20. 실습용 셋업

그림 21. 실습에 사용된 모터와 인코더 (일체형)
케이블에 대한 정보는 다음과 같습니다.
Red: 모터 전원 +
Black: 인코더 전원 GND
Green: 인코더 B상
Yellow: 인코더 A상
Blue: 인코더 전원 +
White: 모터전원 -
4.1 핀과 주변장치 설정
(1) ADC

그림 22. ADC 핀맵

그림 23. ADC 파라미터 설정
중요설정
Rank – Sampling Time: 247.5 Cycles
- 이 사이클 보다 더 높은 사이클도 괜찮습니다.
(2) 타이머2 (TIM2)

그림 24. 타이머2 핀 맵 (CH1, CH2)

그림 25. 타이머2 파라미터 설정
중요설정
Combined Channels: Encoder Mode
- A/B 상을 처리하기 위해 두개의 채널을 사용합니다.
Counter Mode: Up
Counter Period: 4294967295
- 32비트 TIM2의 최대 ARR 값입니다. 카운터는 이 범위 안에서 증가 또는 감소합니다.
- 이 예제는 업데이트 이벤트를 사용하지 않고 일정 시간마다 CNT 차이를 읽습니다.
- ARR은 측정 구간에서 래핑이 자주 일어나지 않도록 충분히 큰 값으로 설정합니다.
Encoder – Encoder Mode: Encoder Mode TI1 and TI2
- TI1은 A상 엣지를 카운트 하고 B상으로 방향 판단을 합니다.
- TI2는 B 상 엣지를 카운트 하고 A상으로 방향 판단을 합니다.
- TI1 and TI2 는 A/B상 엣지를 모두 이용합니다.
Polarity: Rising Edge
- 두개의 입력이 동일해야 합니다
Input Filter: 4
- 역시 두개의 입력이 동일해야 합니다.
- 노이즈 처리를 위한 필터입니다. 값이 커지면 노이즈를 더 잘 제거하지만 속도가 느려집니다.
(3) 타이머3 (TIM3) – PWM 출력

그림 26. 타이머3 핀맵

그림 27. 타이머3 파라미터 설정
중요설정
Prescaler (PSC) : 169
- 1MHz 기준 클럭을 만듭니다. (0부터 169까지 = 170)
- 170MHz/(1+169) = 1MHz
Counter Mode: Up
Counter Period (ARR): 99
- 실제 카운트 값은 99+1=100
- PWM 주파수: 1 MHz / 100 = 10 kHz
PWM Generation Channel 2 – Mode : PWM mode 1
(4) GPIO

그림 28. GPIO 설정
중요설정
PA8 GPIO output level: High
PB5 GPIO output level: High
4.2 DC 모터·인코더 코드
VCP 코드는 생략합니다. (코드 일부)
네개의 handle 변수가 생성 되었습니다. (코드 일부)
/* Private variables -----------------------------------------------*/
ADC_HandleTypeDef hadc1;
UART_HandleTypeDef hlpuart1;
TIM_HandleTypeDef htim2;
TIM_HandleTypeDef htim3;
/* Private variables -----------------------------------------------*/
ADC_HandleTypeDef hadc1;
UART_HandleTypeDef hlpuart1;
TIM_HandleTypeDef htim2;
TIM_HandleTypeDef htim3;
인코더 카운트, ADC 입력과 PWM 비교값을 저장할 변수를 정의합니다. COUNTS_PER_OUTPUT_REV는 사용한 모터·감속기·x4 디코딩을 반영한 출력축 1회전당 실제 카운트로 바꿔야 합니다. 확인 전 기본값 0에서는 코드가 안내 메시지만 출력합니다. (코드 일부)
/* USER CODE BEGIN PD */
#define RPM_SAMPLE_TIME_MS 1000U
#define COUNTS_PER_OUTPUT_REV 0U /* 확인 필요: 실제 출력축 1회전당 카운트 */
/* USER CODE END PD */
/* USER CODE BEGIN PV */
int32_t encoder_count = 0;
uint32_t adc_value = 0U;
uint32_t pwm_duty = 50U;
uint32_t previous_count = 0U;
uint32_t previous_time = 0U;
/* USER CODE END PV */
/* USER CODE BEGIN PD */
#define RPM_SAMPLE_TIME_MS 1000U
#define COUNTS_PER_OUTPUT_REV 0U /* 확인 필요: 실제 출력축 1회전당 카운트 */
/* USER CODE END PD */
/* USER CODE BEGIN PV */
int32_t encoder_count = 0;
uint32_t adc_value = 0U;
uint32_t pwm_duty = 50U;
uint32_t previous_count = 0U;
uint32_t previous_time = 0U;
/* USER CODE END PV */
메인함수 입니다. (코드 일부)
int main(void) {
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_LPUART1_UART_Init();
MX_ADC1_Init();
MX_TIM3_Init();
MX_TIM2_Init();
if (HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED) != HAL_OK)
{
Error_Handler();
}
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_5, GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_9, GPIO_PIN_RESET);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 0);
if (HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2) != HAL_OK)
{
Error_Handler();
}
if (HAL_TIM_Encoder_Start(&htim2, TIM_CHANNEL_ALL) != HAL_OK)
{
Error_Handler();
}
__HAL_TIM_SET_COUNTER(&htim2, 0);
previous_count = __HAL_TIM_GET_COUNTER(&htim2);
previous_time = HAL_GetTick();
while (1)
{
HAL_ADC_Start(&hadc1);
if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK)
{
adc_value = HAL_ADC_GetValue(&hadc1);
pwm_duty = 50U + (adc_value * 50U) / 4095U;
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, pwm_duty);
}
HAL_ADC_Stop(&hadc1);
int main(void) {
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_LPUART1_UART_Init();
MX_ADC1_Init();
MX_TIM3_Init();
MX_TIM2_Init();
if (HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED) != HAL_OK)
{
Error_Handler();
}
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_5, GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_9, GPIO_PIN_RESET);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 0);
if (HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2) != HAL_OK)
{
Error_Handler();
}
if (HAL_TIM_Encoder_Start(&htim2, TIM_CHANNEL_ALL) != HAL_OK)
{
Error_Handler();
}
__HAL_TIM_SET_COUNTER(&htim2, 0);
previous_count = __HAL_TIM_GET_COUNTER(&htim2);
previous_time = HAL_GetTick();
while (1)
{
HAL_ADC_Start(&hadc1);
if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK)
{
adc_value = HAL_ADC_GetValue(&hadc1);
pwm_duty = 50U + (adc_value * 50U) / 4095U;
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, pwm_duty);
}
HAL_ADC_Stop(&hadc1);
uint32_t current_time = HAL_GetTick();
uint32_t elapsed_time = current_time - previous_time;
if (elapsed_time >= RPM_SAMPLE_TIME_MS)
{
uint32_t current_count = __HAL_TIM_GET_COUNTER(&htim2);
int32_t delta = (int32_t)(current_count - previous_count);
uint32_t absolute_delta = (delta < 0) ?
(uint32_t)(-delta) : (uint32_t)delta;
uint32_t counts_per_output_rev = COUNTS_PER_OUTPUT_REV;
if (counts_per_output_rev == 0U)
{
printf("COUNTS_PER_OUTPUT_REV 설정 필요\r\n");
}
else
{
uint32_t rpm_x10 = (uint32_t)
(((uint64_t)absolute_delta * 600000ULL) /
((uint64_t)counts_per_output_rev * elapsed_time));
if (delta > 0)
{
printf("Direction: Forward, RPM: %lu.%lu\r\n",
(unsigned long)(rpm_x10 / 10U),
(unsigned long)(rpm_x10 % 10U));
}
else if (delta < 0)
{
printf("Direction: Reverse, RPM: %lu.%lu\r\n",
(unsigned long)(rpm_x10 / 10U),
(unsigned long)(rpm_x10 % 10U));
}
else
{
printf("Direction: Stop, RPM: 0.0\r\n");
}
}
previous_count = current_count;
previous_time = current_time;
}
HAL_Delay(5);
}
}
uint32_t current_time = HAL_GetTick();
uint32_t elapsed_time = current_time - previous_time;
if (elapsed_time >= RPM_SAMPLE_TIME_MS)
{
uint32_t current_count = __HAL_TIM_GET_COUNTER(&htim2);
int32_t delta = (int32_t)(current_count - previous_count);
uint32_t absolute_delta = (delta < 0) ?
(uint32_t)(-delta) : (uint32_t)delta;
uint32_t counts_per_output_rev = COUNTS_PER_OUTPUT_REV;
if (counts_per_output_rev == 0U)
{
printf("COUNTS_PER_OUTPUT_REV 설정 필요\r\n");
}
else
{
uint32_t rpm_x10 = (uint32_t)
(((uint64_t)absolute_delta * 600000ULL) /
((uint64_t)counts_per_output_rev * elapsed_time));
if (delta > 0)
{
printf("Direction: Forward, RPM: %lu.%lu\r\n",
(unsigned long)(rpm_x10 / 10U),
(unsigned long)(rpm_x10 % 10U));
}
else if (delta < 0)
{
printf("Direction: Reverse, RPM: %lu.%lu\r\n",
(unsigned long)(rpm_x10 / 10U),
(unsigned long)(rpm_x10 % 10U));
}
else
{
printf("Direction: Stop, RPM: 0.0\r\n");
}
}
previous_count = current_count;
previous_time = current_time;
}
HAL_Delay(5);
}
}
- ADC 캘리브레이션 - HAL_ADCEx_Calibration_Start
- TB6612 출력 세팅 – HAL_GPIO_WritePin
- 초기 PWM 0% duty - __HAL_TIM_SET_COMPARE
- PWM 출력 시작 - HAL_TIM_PWM_Start
- 인코더 시작 - HAL_TIM_Encoder_Start
- 폴링 방식 시간 시작 (초기값) - previous_time = HAL_GetTick();
- While 내부 반복
- 폴링 방식 ADC값 읽기 - HAL_ADC_PollForConversion
- ADC값으로부터 모터 PWM duty 계산
- 인코더 값을 읽어서 PWM과 방향 계산
- 값이 커지면 순방향 작아지면 역방향
- 방향 측정은 타이머 내부에서 자동으로
- VCP로 출력

그림 29. 실제 A/B상 인코더 출력

그림 30. VCP 터미널 출력
가변저항 값을 변화시키면 RPM값 역시 변하는 것을 확인할 수 있습니다.
이상 STM32에서의 타이머/카운터/PWM 에 대해서 알아봤습니다.
5. 참고 자료
STMicroelectronics STM32G491RE 제품 정보: 공식 문서 열기
STMicroelectronics RM0440 Reference Manual: 공식 문서 열기
STMicroelectronics UM2505 NUCLEO-G491RE 보드 매뉴얼: 공식 문서 열기
지속적인 성장
STM32 CAN 통신 예제: CubeMX와 HAL로 송수신 구현하기
핵심 요약 Classic CAN과 CAN FD의 프레임 및 비트 타이밍 차이를 설명합니다. NUCLEO-G491RE와 Jetson TX2를 CAN 트랜시버로 연결합니다. CubeMX 필터·인터럽트 설정과 HAL 기반 송수신 코드를 구현합니다. 종단저항, CANH·CANL, candump와 오류 응답을 이용한 점검 방법을 다룹니다. Nucleo-G491RE 보드와 Jetson 보드를 이용한 CAN 버스 통신 이 글에서는 STM32 CAN 통신 예제로 NUCLEO-G491RE와 Jetson TX2를 연결하고, Classic CAN과 CAN FD의 비트 타이밍·필터·수신 인터럽트·응답 송신을 단계별로 구현합니다. 브레드보드에서 트랜시버와 종단저항, CANH·CANL 연결을 검증한 뒤 커넥터와 테스트 포인트를 작은 CAN 인터페이스 PCB로 정리하면 반복 시험과 배선 점검이 쉬워집니다. PCB 제작시Gerber 파일은 JLCPCB PCB 서비스에 업로드하고 온라인 견적에서 제작 가능 여부와 비용을 확......
STM32 PWM 제어 가이드: 주파수·듀티비 설정과 모터 제어 예제
핵심 요약 타이머 Prescaler와 ARR을 이용한 PWM 주파수·듀티비 계산 방법을 정리합니다. NUCLEO-G491RE에서 서보모터와 DC 모터를 PWM으로 제어합니다. 단일 채널 및 A/B상 인코더를 읽어 회전 방향과 RPM을 계산합니다. CubeMX 설정, HAL 코드와 VCP 출력 결과를 실제 실습 이미지로 확인합니다. Nucleo-G491RE 보드와 DC 모터/인코더를 이용한 실습 이 글에서는 STM32 PWM의 주파수·듀티비 계산 원리를 익히고, NUCLEO-G491RE의 타이머와 카운터를 이용해 서보모터·DC 모터를 구동하며 단일 채널 및 A/B상 인코더를 읽습니다. 브레드보드에서 모터 드라이버와 인코더 동작을 확인한 뒤 전원·신호 커넥터와 테스트 포인트를 작은 인터페이스 PCB로 정리하면 배선 오류를 줄이고 재현성을 높일 수 있습니다. PCB 제작시Gerber 파일은 JLCPCB PCB 서비스에 업로드하고 온라인 견적에서 제작 가능 여부와 비용을 확인할 수 있습니다. 타이머/......
STM32 ADC 사용법: 전압 측정, 인터럽트·DMA와 DAC 피드백
핵심 요약 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 서비스에 업로드하고 온라인 견적에서 제작 가능 여부와 비용을 확인할 수 있습니다......
STM32 UART 통신 예제: 송수신, 인터럽트 및 DMA 구현 가이드
핵심 요약 Polling, Interrupt, DMA 수신 방식의 차이와 CPU·버퍼의 역할을 비교합니다. NUCLEO-G491RE, NUCLEO-F446RE와 BH1750을 연결해 실제 센서 데이터를 전송합니다. 115200 bps, 8-N-1, TX/RX 교차 연결과 공통 GND 설정을 단계별로 확인합니다. DMA Normal 및 Circular 모드와 Receive-to-Idle 처리 방법을 예제로 구현합니다. Virtual COM, 인터럽트 및 DMA로 센서 데이터 송수신 하기 NUCLEO-G491RE, NUCLEO-F446RE, BH1750 1. 이번 글에서 만들 시스템 STM32 UART 통신 예제를 처음 배울 때는 문자열 하나를 보내는 예제부터 시작하는 경우가 많습니다. 문자열이 터미널에 출력되면 통신이 된 것처럼 보입니다. 하지만 실제 임베디드 시스템에서는 센서가 계속 데이터를 만들고, 다른 MCU가 그 데이터를 놓치지 않고 받아 처리해야 합니다. 이번 글에서는 Nucleo-G4......
EasyEDA에서 커스텀 PCB 풋프린트 만드는 방법
필요한 PCB 풋프린트가 EasyEDA 부품 라이브러리에 없다면 직접 만들 수 있습니다. 풋프린트 라이브러리 편집기는 PCB 편집기와 유사한 도구를 사용하지만 일부 불필요한 기능은 표시되지 않을 수 있습니다. 풋프린트는 부품 본체의 크기, 패키지 형식, 패드 크기, 핀 길이와 폭, 핀 간격 등 실제 실장에 필요한 정보를 도형으로 표현한 PCB 설계 데이터입니다. 기호 라이브러리 작성과 기본 흐름이 비슷하며, 이 글에서는 EasyEDA에서 커스텀 풋프린트를 만드는 과정을 단계별로 설명합니다. 회로도에 관한 내용은 전기 회로도 설계 가이드를 참고하십시오. PCB 패드와 풋프린트 설계 PCB 설계에서는 부품을 선택하고 보드 위에 배치한 뒤 각 부품을 전기적으로 연결합니다. 부품마다 핀 수, 핀 배열과 패키지 형식이 다릅니다. PCB 패드는 부품 리드나 단자를 납땜하는 노출 금속 영역이며, 패드를 정확한 위치에 배열하고 외곽선과 조립 정보를 더한 것이 풋프린트입니다. 부품 라이브러리 작성은 크게 두 ......
PCB 레이어 수 선택 방법: 단면·양면·다층 PCB 설계 가이드
PCB 설계를 시작할 때 가장 중요한 결정 중 하나는 동박 레이어 수를 정하는 것입니다. 레이어 수는 배선 공간, 신호 무결성, 전원 분배, 방열, 기판 크기와 제조 비용에 영향을 줍니다. 이 글에서는 프로젝트에 적합한 PCB 레이어 수를 판단하는 기준과 단면·양면·다층 PCB의 설계 지침을 설명합니다. PCB 레이어 수를 결정하는 기준 회로 복잡도 분석 부품 수가 많고 연결 관계가 복잡할수록 더 많은 배선 공간이 필요합니다. 부품 밀도가 높거나 BGA와 같이 핀 수가 많은 부품을 사용하면 추가 신호 레이어가 필요할 수 있습니다. 여러 고속 신호나 민감한 아날로그 신호가 포함된 설계에서는 전원 및 그라운드 플레인을 별도로 배치할 수 있는 추가 레이어가 신호 무결성 향상에 도움이 됩니다. 연속적인 리턴 경로를 확보하면 노이즈, 신호 간섭과 크로스토크를 줄일 수 있습니다. 기판 크기와 공간 제약 고려 PCB가 소형 전자기기 내부에 들어가야 한다면 사용할 수 있는 기판 면적이 제한됩니다. 이 경우 ......