Devin.KR

임베디드 · 기본

MCU·GPIO·통신 기본

타이머와 PWM - 밝기와 모터 속도

프리스케일러·주기, PWM 듀티, 시뮬레이션으로 파형 출력

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서는 버튼 입력을 폴링과 디바운싱으로 읽어 GPIO 출력을 켜고 껐다. 이번 장은 같은 GPIO 핀을 타이머(timer) 주변장치로 제어해, CPU가 반복문에 묶이지 않고도 일정한 주기로 자동 반복되는 신호를 만드는 방법을 다룬다. 이 신호에서 켜져 있는 시간의 비율을 듀티(duty)라 부르고, 듀티를 바꾸면 LED 밝기와 펌프 모터 속도를 단계적으로 조절할 수 있다. 실제 보드 대신 hal_sim.h와 hal_sim.c로 타이머 레지스터와 시간의 흐름을 흉내 내는 시뮬레이션 계층을 두고, 카운터 값이 비교값을 지나는 순간 파형이 어떻게 바뀌는지 PC에서 직접 확인한다.

  • 프리스케일러(prescaler)와 자동 재적재 값으로 타이머 주파수를 계산할 수 있다
  • 비교값(CCR)으로 듀티를 정하고, 듀티와 평균 출력 레벨의 관계를 설명할 수 있다
  • PC 시뮬레이션으로 PWM 파형을 생성하고 결과를 눈으로 검증할 수 있다
  • LED와 모터에 서로 다른 PWM 주파수를 골라야 하는 이유를 안다

문제 상황

스마트 화분 펌웨어는 지금까지 버튼을 누르면 LED를 켜고 끄는 정도의 기능만 가지고 있었다. 이제 요구사항이 바뀌어, 조도가 낮을수록 보조 LED를 서서히 밝게 하고 토양이 마를수록 펌프 모터도 세기를 조절해야 한다고 하자. 토양 상태를 어떻게 숫자로 읽어 판단할지는 다음 장에서 다루고, 이 장에서는 "밝기와 속도를 조절하는 방법" 자체에 집중한다.

GPIO 출력을 배울 때처럼 핀을 그냥 켜고 끄면 밝기는 0% 또는 100%뿐이다. 소프트웨어 지연으로 빠르게 껐다 켰다 하면 밝기를 흉내 낼 수는 있지만, 그 반복문이 CPU를 붙잡고 있어 버튼 읽기나 센서 통신 같은 다른 일을 못 하게 된다. 지연 시간이 조금만 흔들려도 LED가 깜박이거나 모터가 고르지 않게 떨린다. 이 문제를 해결하는 하드웨어가 타이머다. 타이머는 CPU 개입 없이 정해진 주기로 카운터를 증가시키고, 비교값과 일치하는 순간 자동으로 핀 레벨을 바꾼다.

타이머 레지스터: 프리스케일러와 주기

마이크로컨트롤러의 타이머는 내부에서 자동으로 증가하는 카운터(CNT)를 가진 하드웨어다. 이 카운터가 1 증가하는 속도는 시스템 클럭을 얼마나 나누는지에 달려 있는데, 이 나눗값을 정하는 레지스터(register)가 프리스케일러(PSC, prescaler)다. 시스템 클럭이 16MHz이고 PSC가 1599라면, 타이머 클럭은 16,000,000을 1600으로 나눈 10,000Hz가 되어 초당 만 번 카운터가 증가한다.

카운터는 무한히 증가하지 않고, 자동 재적재 레지스터(ARR, auto-reload register)에 설정한 값에 도달하면 다시 0으로 돌아간다. 이 한 바퀴가 PWM의 한 주기다. 예를 들어 ARR이 19면 카운터는 0부터 19까지 20단계를 거쳐 한 바퀴를 돌고, PWM 주파수는 타이머 클럭을 (ARR+1)로 나눈 값, 즉 10,000을 20으로 나눈 500Hz가 된다. ARR이 커질수록 한 주기 안의 단계 수가 늘어나 듀티를 더 촘촘하게 나눌 수 있지만, 같은 타이머 클럭에서는 그만큼 주파수가 낮아진다.

카운터가 CCR값을 지나는 순간 PWM 출력 핀의 레벨이 High에서 Low로 바뀐다
타이머 레지스터 네 가지가 하는 일
레지스터의미계산식 또는 역할이 장 예제(LED 타이머)
PSC입력 클럭을 나누는 값타이머 클럭 = 시스템 클럭 / (PSC+1)1599 (16MHz → 10kHz)
ARR카운터가 되돌아가는 최댓값주파수 = 타이머 클럭 / (ARR+1)19 (20단계, 5% 분해능)
CNT현재 카운터 값매 타이머 클럭마다 1 증가, ARR을 넘으면 0으로0부터 19까지 반복
CCR핀 레벨이 바뀌는 기준값CNT가 CCR보다 작으면 High, 아니면 Low듀티(%) × 20 / 100

PWM 듀티와 평균 출력

카운터가 한 바퀴 도는 동안 출력 핀의 레벨을 정하는 값이 비교 레지스터(CCR, capture/compare register)다. 카운터 값이 CCR보다 작은 동안은 핀을 High로, CCR 이상이 되면 Low로 유지하는 방식을 가장자리 정렬(edge-aligned) PWM이라 부른다. 이때 듀티는 CCR을 (ARR+1)로 나눈 비율이다. 듀티가 25%면 한 주기 중 4분의 1만 켜져 있고 나머지는 꺼져 있다.

LED나 모터는 전기 신호를 평균으로 받아들인다. 사람 눈은 초당 수백 번 이상 깜박이는 빛을 개별 켜짐·꺼짐으로 인식하지 못하고 평균 밝기로 받아들이며, 모터 코일에 흐르는 전류도 관성 때문에 평균값에 가깝게 반응한다. 그래서 듀티를 25%, 50%, 75%로 올리면 평균 전압도 그만큼 높아지고, LED는 더 밝게, 모터는 더 빠르게 도는 것처럼 보인다.

다만 PWM 주파수는 아무 값이나 골라도 되는 것이 아니다. 너무 낮으면 눈이 개별 깜박임을 감지해 LED가 떨리는 것처럼 보이고, 모터는 회전이 매끄럽지 않다. 반대로 모터에서는 PWM 주파수가 사람이 들을 수 있는 가청 주파수 대역에 걸리면 코일이 그 주파수로 미세하게 떨리며 소음을 낸다. 그래서 LED는 수백 Hz면 충분하지만, 모터는 가청 대역을 벗어나도록 20kHz 이상으로 올리는 경우가 많다.

PWM 듀티가 커질수록 한 주기 동안의 평균 출력 레벨이 높아져 LED는 더 밝고 모터는 더 빠르게 돈다

시뮬레이션과 실제 보드의 차이

이 장의 hal_sim.h와 hal_sim.c는 실제 타이머 레지스터 접근을 함수 호출로 흉내 낸 것이다. hal_timer_tick()을 한 번 부르는 것이 타이머 클럭 한 틱이 지난 것과 같다고 약속했을 뿐, 실제로 마이크로초 단위 시간이 흐르지는 않는다. 실제 칩에서는 타이머가 클럭에 맞춰 하드웨어적으로 카운터를 증가시키고, 인터럽트나 DMA로 CPU 개입 없이 핀 레벨을 바꾼다.

이 장의 시뮬레이션과 실제 보드의 차이
항목STM32ESP32아두이노(AVR)
타이머 레지스터 접근TIMx->PSC, TIMx->ARR, TIMx->CCR1 등에 직접 대입ledc_timer_config(), ledc_channel_config() API 호출analogWrite() 내부에서 Timer0/1/2 레지스터를 대신 설정
PWM 해상도레지스터 폭이 16~32비트라 세밀한 조절 가능LEDC 듀티 해상도가 최대 20비트대부분 8비트 타이머라 기본 256단계
핀과 타이머 연결대체 기능 레지스터로 GPIO를 특정 타이머 채널에 배정LEDC 채널을 거의 모든 GPIO에 자유롭게 배정PWM 가능 핀이 보드에 물결표(~)로 고정 표시됨

실무 코드를 이 장의 시뮬레이션에서 실제 보드로 옮길 때 바뀌는 것은 레지스터 이름과 초기화 API뿐이다. 프리스케일러와 ARR, CCR로 주파수와 듀티를 계산하는 방식 자체는 그대로 적용된다.

완성 코드

다음 세 파일을 같은 디렉터리에 두고 컴파일한다. hal_sim.h와 hal_sim.c는 타이머 레지스터를 흉내 내고, main.c는 LED 밝기와 펌프 모터 속도를 듀티로 조절하는 예제를 담는다.

hal_sim.h

#ifndef HAL_SIM_H
#define HAL_SIM_H

#include <stdint.h>

#define HAL_SYS_CLOCK_HZ 16000000u

typedef struct {
    uint32_t psc;
    uint32_t arr;
    uint32_t ccr;
    uint32_t cnt;
} hal_timer_t;

void hal_timer_init(hal_timer_t *tm, uint32_t psc, uint32_t arr);
void hal_timer_set_duty_percent(hal_timer_t *tm, uint32_t percent);
uint32_t hal_timer_freq_hz(const hal_timer_t *tm);
uint8_t hal_timer_tick(hal_timer_t *tm);

#endif

hal_sim.c

#include "hal_sim.h"

void hal_timer_init(hal_timer_t *tm, uint32_t psc, uint32_t arr)
{
    tm->psc = psc;
    tm->arr = arr;
    tm->ccr = 0u;
    tm->cnt = 0u;
}

void hal_timer_set_duty_percent(hal_timer_t *tm, uint32_t percent)
{
    if (percent > 100u) {
        percent = 100u;
    }
    tm->ccr = (tm->arr + 1u) * percent / 100u;
}

uint32_t hal_timer_freq_hz(const hal_timer_t *tm)
{
    uint32_t timer_clock = HAL_SYS_CLOCK_HZ / (tm->psc + 1u);
    return timer_clock / (tm->arr + 1u);
}

uint8_t hal_timer_tick(hal_timer_t *tm)
{
    uint8_t level = (tm->cnt < tm->ccr) ? 1u : 0u;

    tm->cnt++;
    if (tm->cnt > tm->arr) {
        tm->cnt = 0u;
    }
    return level;
}

main.c

#include <stdio.h>
#include "hal_sim.h"

static void print_pwm_period(hal_timer_t *tm, uint32_t requested_percent)
{
    char wave[64];
    uint32_t ticks = tm->arr + 1u;
    uint32_t high_count = 0u;
    uint32_t i;

    hal_timer_set_duty_percent(tm, requested_percent);
    tm->cnt = 0u;

    for (i = 0u; i < ticks; i++) {
        uint8_t level = hal_timer_tick(tm);
        wave[i] = level ? '#' : '_';
        high_count += level;
    }
    wave[ticks] = '\0';

    printf("duty=%3u%% ccr=%2u 파형=%s 측정duty=%3u%%\n",
           requested_percent, tm->ccr, wave, high_count * 100u / ticks);
}

static void print_motor_speed(uint32_t percent)
{
    const uint32_t dead_zone = 20u;
    const uint32_t max_rpm = 6000u;
    uint32_t rpm;

    if (percent <= dead_zone) {
        printf("duty=%3u%% -> 정지 (기동 최소 듀티 %u%% 미만)\n", percent, dead_zone);
        return;
    }

    rpm = (percent - dead_zone) * max_rpm / (100u - dead_zone);
    printf("duty=%3u%% -> %4u RPM\n", percent, rpm);
}

int main(void)
{
    hal_timer_t tim_led;
    hal_timer_t tim_motor;
    uint32_t duties_led[5] = {0u, 25u, 50u, 75u, 100u};
    uint32_t duties_motor[7] = {0u, 10u, 20u, 25u, 50u, 75u, 100u};
    uint32_t i;

    hal_timer_init(&tim_led, 1599u, 19u);
    hal_timer_init(&tim_motor, 39u, 19u);

    printf("LED PWM 주파수 = %u Hz\n", hal_timer_freq_hz(&tim_led));
    for (i = 0u; i < 5u; i++) {
        print_pwm_period(&tim_led, duties_led[i]);
    }

    printf("\n펌프 모터 PWM 주파수 = %u Hz\n", hal_timer_freq_hz(&tim_motor));
    for (i = 0u; i < 7u; i++) {
        print_motor_speed(duties_motor[i]);
    }

    return 0;
}

줄별 해설

  • hal_timer_t 구조체는 psc, arr, ccr, cnt 네 값만으로 타이머 한 채널을 표현한다. 실제 칩의 TIM 레지스터 묶음을 최소한으로 줄인 형태다.
  • hal_timer_set_duty_percent()는 요청한 퍼센트를 100으로 제한한 뒤 (arr+1)에 퍼센트를 곱하고 100으로 나눠 CCR을 정한다. 이 제한을 빼면 100%를 넘는 값이 그대로 CCR에 들어가 버그가 생긴다.
  • hal_timer_tick()은 호출될 때마다 카운터를 1 증가시키고, 반환값으로 그 순간의 핀 레벨을 돌려준다. cnt가 arr을 넘으면 0으로 되돌려 한 주기를 마친다.
  • print_pwm_period()는 한 주기(arr+1번)만큼 hal_timer_tick()을 반복 호출해 파형을 문자열로 만들고, 실제로 켜진 횟수를 세어 측정 듀티를 계산한다. 시뮬레이션은 지터가 없어 측정값과 설정값이 항상 일치한다.
  • print_motor_speed()는 듀티를 RPM으로 바꾸기 전에 dead_zone보다 작은지 먼저 확인한다. 실제 모터는 낮은 듀티에서 정지 마찰을 이기지 못해 돌지 않는데, 이 데드존을 흉내 낸 것이다.
  • main()은 LED용 타이머(500Hz)와 모터용 타이머(20kHz)를 서로 다른 프리스케일러로 초기화해, 같은 하드웨어 구조를 다른 용도로 재사용하는 방법을 보여준다.

실행 결과

$ cc -std=c11 -Wall -Wextra -o pwm_demo main.c hal_sim.c && ./pwm_demo
LED PWM 주파수 = 500 Hz
duty=  0% ccr= 0 파형=____________________ 측정duty=  0%
duty= 25% ccr= 5 파형=#####_______________ 측정duty= 25%
duty= 50% ccr=10 파형=##########__________ 측정duty= 50%
duty= 75% ccr=15 파형=###############_____ 측정duty= 75%
duty=100% ccr=20 파형=#################### 측정duty=100%

펌프 모터 PWM 주파수 = 20000 Hz
duty=  0% -> 정지 (기동 최소 듀티 20% 미만)
duty= 10% -> 정지 (기동 최소 듀티 20% 미만)
duty= 20% -> 정지 (기동 최소 듀티 20% 미만)
duty= 25% ->  375 RPM
duty= 50% -> 2250 RPM
duty= 75% -> 4125 RPM
duty=100% -> 6000 RPM

실무에서 자주 틀리는 것

ARR을 너무 작게 잡아 듀티 분해능을 잃는다

ARR이 작으면 한 주기의 단계 수가 줄어 듀티를 세밀하게 나눌 수 없다. 정수 나눗셈 때문에 의도한 값과 다른 단계로 잘려나가기도 한다.

hal_timer_init(&tm, 159u, 9u); /* ARR=9: 10단계뿐, 10% 단위로만 조절된다 */
hal_timer_set_duty_percent(&tm, 45u); /* ccr=4가 되어 실제로는 40%가 적용된다 */
hal_timer_init(&tm, 159u, 99u); /* ARR=99: 100단계, 1% 단위로 조절된다 */
hal_timer_set_duty_percent(&tm, 45u); /* ccr=45, 요청한 45%가 그대로 적용된다 */

듀티 값 범위를 검증하지 않아 항상 켜진 상태가 된다

퍼센트를 100 이하로 제한하지 않고 그대로 계산에 쓰면, 100을 넘는 값이 들어왔을 때 CCR이 ARR+1보다 커져 카운터가 항상 CCR보다 작은 상태, 즉 항상 High가 되어 버린다.

void hal_timer_set_duty_percent(hal_timer_t *tm, uint32_t percent)
{
    tm->ccr = (tm->arr + 1u) * percent / 100u; /* percent=150이면 항상 High로 고정된다 */
}
void hal_timer_set_duty_percent(hal_timer_t *tm, uint32_t percent)
{
    if (percent > 100u) {
        percent = 100u;
    }
    tm->ccr = (tm->arr + 1u) * percent / 100u;
}

LED와 모터에 같은 PWM 주파수를 그대로 재사용한다

LED에서 잘 동작하던 설정을 그대로 모터에 옮기면, 그 주파수가 가청 대역에 걸려 모터 코일이 떨리며 소리를 낸다.

hal_timer_init(&tim_motor, 1599u, 19u); /* LED와 같은 500Hz를 모터에도 그대로 사용 */
hal_timer_init(&tim_motor, 39u, 19u); /* 20kHz로 올려 가청 대역을 벗어난다 */

듀티와 속도가 항상 선형이라고 가정해 데드존을 무시한다

듀티를 그대로 속도에 비례시키면, 낮은 듀티에서도 모터가 돈다고 계산하지만 실제 모터는 정지 마찰 때문에 최소 듀티 이하에서는 움직이지 않는다.

static void print_motor_speed(uint32_t percent)
{
    uint32_t rpm = percent * 6000u / 100u; /* 10%에서도 600RPM으로 돈다고 가정한다 */
    printf("duty=%3u%% -> %4u RPM\n", percent, rpm);
}
static void print_motor_speed(uint32_t percent)
{
    const uint32_t dead_zone = 20u;
    const uint32_t max_rpm = 6000u;

    if (percent <= dead_zone) {
        printf("duty=%3u%% -> 정지\n", percent);
        return;
    }
    printf("duty=%3u%% -> %4u RPM\n", percent,
           (percent - dead_zone) * max_rpm / (100u - dead_zone));
}

한눈에 보기

이 장에서 쓴 공식과 값 정리
개념공식LED 예제 값
타이머 주파수시스템 클럭 / ((PSC+1) × (ARR+1))500Hz
듀티(%)CCR / (ARR+1) × 1000~100% 정수
분해능ARR+1 단계ARR=19 → 5% 단위
데드존(모터 한정)최소 기동 듀티 미만에서는 회전하지 않음20%

연습 문제

  1. 시스템 클럭이 16MHz인 타이머에서 PSC=15999, ARR=49로 설정했다. 이 타이머의 PWM 주파수(Hz)는 얼마인가?
  2. 위 타이머에서 듀티를 30%로 설정하면 CCR 값은 얼마인가?
  3. 본문 예제의 모터 데드존을 20%에서 15%로 낮추고 최대 RPM은 6000으로 그대로 두었다. 듀티 25%일 때 계산되는 RPM은 얼마인가? 정수 나눗셈 기준으로 계산하라.
  4. 본문에서 LED PWM 주파수로 500Hz를 골랐다. 이 값을 100Hz로 낮추면 어떤 문제가 생길 수 있는지 설명하라.

정답과 해설

  1. 타이머 클럭 = 16,000,000 / (15999+1) = 1,000Hz. 주파수 = 1,000 / (49+1) = 20Hz다.
  2. CCR = (49+1) × 30 / 100 = 50 × 30 / 100 = 15다.
  3. RPM = (25-15) × 6000 / (100-15) = 10 × 6000 / 85 = 60000 / 85다. 정수 나눗셈이므로 소수점 이하가 버려져 705가 된다.
  4. 100Hz는 사람이 눈을 빠르게 움직이거나 스마트폰 카메라로 촬영할 때 개별 깜박임을 감지할 수 있는 경계에 가깝다. 화면에 줄무늬가 찍히거나 눈이 피로해질 수 있어, 실무에서는 여유를 두고 수백 Hz 이상을 사용한다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.