Computer Science
탄탄한 기반 실력을 위한
전공과 이론 지식 모음
Today I Learned!
배웠으면 기록을 해야지
TIL 사진
Flutter 사진
Flutter로 모바일까지
거꾸로캠퍼스 코딩랩 Flutter 앱개발 강사
스파르타코딩클럽 즉문즉답 튜터
카카오테크캠퍼스 3기 학습코치
프로필 사진
박성민
임베디드 세계에
발을 들인 박치기 공룡
임베디드 사진
EMBEDDED SYSTEM
임베디드 SW와 HW, 이론부터 실전까지
ALGORITHM
알고리즘 해결 전략 기록
🎓
CAU 소프트웨어학부
텔레칩스 차량용 임베디드 스쿨 3기
애플 개발자 아카데미 1기
깃허브 사진
GitHub
프로젝트 모아보기
Instagram
인스타그램 사진

Embedded System/MCU

[MCU] General Purpose Timer

sm_amoled 2025. 11. 9. 18:27

드디어 General Purpose 타이머까지 왔다. 사실 정리는 금방 해서 한 3-4일 전에 완료해뒀는데, 실습을 하면서 실제로 타이머를 사용해보느라 시간이 좀 걸린 것 같아. 실습도 내가 생각했던 시나리오를 모두 해보지는 못했지만 나중에 필요한 시점에 타이머를 생각해내서 굴려볼 수 있을 정도로는 진행한 것 같다. 후후.

 

그럼 출발!

General Purpose Timer의 목적

General Purpose Timer로 분류되는 TIM9와 TIM14는 다음의 목적을 가진다.

  • 여러가지 목적으로 사용될 수 있다. (과연 General Purpose Timer 답다)
  • Basic Timer가 가지고 있던 출력 기능과 더불어, 외부 입력도 받을 수 있다.
    • 출력 : Waveform 출력을 만들기 위해 사용된다. (Output Compare 와 PWM에 사용)
    • 입력 : 입력 신호의 Pulse Length를 측정하기 위해 사용된다.

주요 기능들을 살펴보면, 앞서 다뤘던 Basic Timer와 다르게 몇 가지 항목이 더 붙어있다.

  • Basic Timer와는 다르게 하나의 타이머 내에서 여러 개의 채널을 가지며, 이는 Input Capture/Output Compare를 위해 사용할 수 있다.
  • Synchronization 회로가 있어서 외부 신호로 타이머를 제어한다거나 여러 타이머를 함께 연결한다거나 할 수 있다. (Slave-Mater 키워드를 말하는 듯)
  • 더 다양한 인터럽트 조건 설정이 가능하다.
    • 카운터 값의 변경, 입력 신호 캡쳐, 출력 신호 비교 등

General Purpose Timer의 HW 분석

우리의 STM32F429ZIT6 보드에서는 총 10개의 General-Purpose Timer가 있다.

  • TIM2, TIM5
    • 풀옵션
    • 32bit Counter와 Up/Downcounter 모드
    • 4개의 독립적인 채널
  • TIM3, TIM4
    • 16bit Counter와 Up/Downcounter 모드
    • 4개의 독립적인 채널
  • TIM9, TIM12
    • 16bit Counter와 Up/Downcounter 모드
    • 2개의 독립적인 채널
    • TIM2~TIM5와 동기화되어 실행될 수 있다.
  • TIM10, TIM11, TIM13, TIM14
    • 16bit Counter와 Up/Downcounter 모드
    • 1개의 채널
    • TIM2~TIM5와 동기화되어 실행될 수 있다.

GP Timer는 모두 APB 버스와 연결되어, APB의 CLK 주파수를 사용한다.

  • APB1과 연결 : TIM2, TIM3, TIM4, TIM5, TIM12, TIM13, TIM14
  • APB2와 연결 : TIM9, TIM10, TIM11

General Purpose Timer의 구조와 동작 이해

기본적인 형태는 앞서 다뤘던 Basic Timer와 유사하지만, 조금 더 상세한 옵션들이 추가되었다.

  • Internal Trigger의 입력신호를 Trigger Controller에서 추가로 입력받음
  • CNT counter의 값을 Capture/Compare 레지스터로 옮겨 확인함
  • GPIO Pin을 통해 외부로부터 입력 신호를 Input으로 받을 수 있다
  • GPIO Pin을 통해 외부로 출력을 내보낼 수 있다

출력

(풀옵션) GP Timer에는 4개의 CCR이 있고, 여기에서 연결된 각각의 GPIO 핀을 통해 외부로 출력을 할 수 있다.

GP Timer는 다양한 출력 모드가 있다. 그러나 모두 CNT 카운터 레지스터와 CCR Capture/Compare 레지스터의 값 비교를 통해 출력값을 결정한다.

  • Frozen: 출력 신호 동작 없음
  • Active on Match: CNT == CCR일 때 출력을 HIGH로 설정
  • Inactive on Match: CNT == CCR일 때 출력을 LOW로 설정
  • Toggle on Match: CNT == CCR일 때 출력 반전
  • Force Low / Force High: 강제로 LOW/HIGH 설정
  • PWM Mode 1: CNT < CCR일 때 Active, CNT >= CCR일 때 Inactive
  • PWM Mode 2: PWM Mode 1의 반대

GP 타이머는 PWM(Pulse-Width Modulation) 출력 신호를 만들어내는데에 유용하다!

출력을 위해서 값 비교를 하기 때문에 Compare 라는 이름이 붙었다.

입력

(풀옵션) GP Timer에는 4개의 CCR이 있고, 여기에서 연결된 각각의 GPIO 핀을 통해 외부에서 신호를 입력받아올 수 있다.

포트를 통해 들어온 신호는 Input Filter를 거쳐 노이즈를 제거하고 디지털 신호로 만든다. 그리고 Edge Detection을 통해 입력 신호에서 Rise 또는 Fall 이 발생한 타이밍의 CNT 값을 CCR에 저장하여 신호의 타이밍 정보를 얻을 수 있다.

GP Timer는 다양한 입력 모드가 있다. 입력으로 들어온 디지털 신호에 대해서 상승, 하강 중 어떤 경우에 CNT 레지스터의 값을 캡쳐할 지 선택할 수 있다.

  • Rising Edge Only: 상승 엣지만 감지
  • Falling Edge Only: 하강 엣지만 감지
  • Both Edges: 상승/하강 엣지 모두 감지

GP 타이머를 통한 입력은 다음의 경우에 유용하게 활용할 수 있다.

  • 펄스폭 측정: 같은 채널에서 상승/하강 엣지 캡처
  • 주파수 측정: 엣지 간 시간 간격으로 계산
  • 신호 타이밍 동기화: 외부 이벤트 감지 및 반응

신호가 들어오면 CNT의 값을 캡쳐하기 때문에 Capture 라는 이름이 붙었다.

General Purpose Timer는 어떻게 제어하는가

카운터 설정

ARR (Auto Reload): 타이머의 최대값 설정 (주기 결정)

PSC(Prescaler): 외부에서 들어오는 타이머 CLK을 분주하여 원하는 CLK 주파수로 낮춤

CLK_IN 으로부터 K배 느린 주파수를 원한다면 PSC 레지스터에 (K- 1) 의 값을 넣어주면 된다.

CNT(Counter): 내부적으로 Prescaled 된 CLK 마다 증감되는 값을 담고있는 레지스터

출력 모드

CCR1~4: 각 채널의 비교값 (PWM Duty 결정)

이 레지스터는 Preload 레지스터이고, 실제 CCR 값이 담기는 shadow 레지스터는 별도로 있다.

각 CCR이 출력모드로 설정되어 있을 때는 CCR 레지스터의 값과 CNT 레지스터의 값을 “비교”하여 신호 출력을 결정한다.

ㅤㅤ

CCER: 채널 활성화/극성 설정

Output 모드일 때 각각의 채널을 Output 활성화 여부와 Polarity (극성) 을 결정

CCMR1/CCMR2: 출력 모드 선택

Output 모드일 때 각 채널이 어떤 모드로 출력 값을 선택할 지 결정

입력 모드

CCMR1/CCMR2: 입력 캡처 모드 설정 (채널 선택, 필터 길이)

Input 모드일 때 필터 Prescale 정도에 대해 결정

CCER: 채널 활성화, 엣지 선택 (상승/하강/양쪽)

Input 모드일 때 어떤 파형을 감지할 지 결정

DIER: 인터럽트 활성화 (CC1IE, CC2IE 등)

파형 수신 시 인터럽트 활성화 여부 결정

SR (Status Register): 캡처 플래그 (CC1IF, CC2IF 등)

파형 수신 여부(Flag Set) 확인 가능

레지스터 정리

실습

1. LED 점멸하기 (Polling)

우선, 첫번째로 LED 점멸 코드를 작성해줬다. 1초 간격으로 0.5초간 LED OFF / 0.5초간 LED ON 이 되는 코드를 Polling 방식으로 작성해보았다.

// APB1 -> TIM3 CLK 공급 활성화
RCC->APB1ENR |= RCC_APB1ENR_TIM3EN;

TIM3->PSC = 8400 - 1;   // 10kHz
TIM3->ARR = 10000 - 1;  // 1초
TIM3->CCR1 = 5000;      // 0.5초 시점에서 비교 이벤트
TIM3->CNT = 0;

// 단방향 증감
TIM3->CR1 |= (0 << 5);
// Upcounter로 사용
TIM3->CR1 |= (0 << 4);

// CH1을 output 모드로 사용하겠다 (CMP)
TIM3->CCMR1 |= (0 << 0);
// OC1M[6:4] 초기화
TIM3->CCMR1 &= ~(7 << 4);

// 업데이트 반영
TIM3->EGR |= 0x1;

// 타이머 시작
TIM3->CR1 |= 1;
/* USER CODE END 2 */

/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
  if((TIM3->SR >> 0) & 0x1) {
      TIM3->SR &= ~(0x1);
      HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_RESET);
  }
  if((TIM3->SR >> 1) & 0x1) {
      TIM3->SR &= ~(0x1 << 1);
      HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET);
  }
/* USER CODE END WHILE */

/* USER CODE BEGIN 3 */
}

2. LED 점멸하기 (Interrupt)

그리고 이걸 인터럽트를 활용하는 방식으로 코드를 작성하면 이렇게 추가해줄 수 있다.

// 위 설정들 사이에 Interrupt 관련 코드 추가하기

    // CC1 Interrupt 활성화
    TIM3->DIER |= (0x1 << 1);
    // Update Interrupt 활성화
    TIM3->DIER |= (0x1 << 0);
    // NVIC에서 TIM3의 인터럽트를 처리하도록 활성화
    NVIC_EnableIRQ(TIM3_IRQn);

// IRQ Handler 작성
void TIM3_IRQHandler(void)
{
    // Update 인터럽트 플래그 확인
    if((TIM3->SR >> 0) & 0x1) {
          TIM3->SR &= ~(0x1);
          HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_RESET);
    }
    if((TIM3->SR >> 1) & 0x1) {
          TIM3->SR &= ~(0x1 << 1);
          HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET);
    }
}

3. PWM 파형 출력해보기

사실상 GP Timer의 가장 큰 쓸모라고 생각하는 PWM 파형을 한 번 출력해보자. 주기는 1초, Duty Rate는 30%, 50%, 70% 정도로 잡고 실행 결과를 체크해보겠다.

    // TIM3 CH1의 GPIO와 연결된 PA6을 살리기 위해서 Alternate Function 2 로 지정
    RCC->AHB1ENR|= RCC_AHB1ENR_GPIOAEN;

    GPIOA->MODER |= ~(0x3 << 6 * 2);
  GPIOA->MODER |= (0x2) << 6 * 2;
  GPIOA->AFR[0] &= ~(0xF << 6 * 4);
  GPIOA->AFR[0] |= 0x2 << 6 * 4;

    // APB1 -> TIM3 CLK 공급 활성화
    RCC->APB1ENR |= RCC_APB1ENR_TIM3EN;

    TIM3->PSC = 8400 - 1;   // 10kHz
    TIM3->ARR = 10000 - 1;  // 1초
    TIM3->CCR1 = 5000;      // 0.5초 시점에서 비교 이벤트
    TIM3->CNT = 0;

    // 단방향 증감
    TIM3->CR1 |= (0 << 5);
    // Upcounter로 사용
    TIM3->CR1 |= (0 << 4);

    // CH1을 output 모드로 사용하겠다 (CMP)
    TIM3->CCMR1 |= (0 << 0);
  // OC1M[6:4] 초기화
    TIM3->CCMR1 &= ~(0x7 << 4);
    TIM3->CCMR1 |= (0x6 << 4);

    // CC 1채널을 output으로 사용
    TIM3->CCER |= (0x1 << 0);

    // 업데이트 반영
    TIM3->EGR |= 0x1;

    // 타이머 시작
    TIM3->CR1 |= 1;

위 처럼 작성해주고 PA6 핀으로 나오는 출력을 확인해보면, 아래처럼 0.5초마다 반복되는 일정한 파형을 얻을 수 있다.

여기에서 CCR1의 값만 잘 조절해주면, 원하는 Duty Rate 을 가진 PWM 파형을 쉽게 만들어낼 수 있다.

    TIM3->CCR1 = 10000 * 0.3;      
    TIM3->CCR1 = 10000 * 0.7;      

테스트를 해보니 PWM 의 CCR 레지스터에는 원하는 설정값 - 1 같이 기존 ARR 레지스터에 값을 1 보정을 해주던 작업을 해주지 않아도 된다. (조정을 해주면 원하는 주기보다 0.01초만큼 차이가 난다. 즉, 보정을 해주면 안된다고 판단하였음)
요것은 CCR 레지스터는 값이 같아지면 (Compare의 결과가 Equal 부터) Output을 다르게 사용하기 때문이다. CCR 값은 보정하지 않는다. 유의하자!

4. Timer3의 트리거로 다른 Timer를 사용

이번에는 TIM3의 트리거로 TIM2를 사용하는 방법을 찾아보고 적용해봤다.

아마도 타이머의 트리거 신호로 다른 타이머 신호를 받을 수 있고, 각 타이머마다 받을 수 있는 타이머가 HW 적으로 이미 연결이 되어있다. 이 중에서 Select Bit으로 Trigger 신호를 받을지(Slave) + 어떤 신호를 받을지 (Master 선택) 선택해줄 수 있다.

나는 TIM2를 마스터로 사용해보기로 했고, 상황은 다음과 같이 설정했다.

  • TIM2가 1초마다 Update Event를 주기적으로 발생시킨다
  • TIM3는 TIM2의 신호에 Trigger되어 0.1초 주기의 PWM 신호를 One-Pulse로 만든다. 이때 Duty Rate는 70%로 한다. (LOW가 0.03초, HIGH가 0.07초)

문서를 참조하면 TIM3에서 Trigger 번호 1번에 TIM2의 TRG0 이 연결되어있으므로, 이 신호를 TIM3의 Trigger 신호로 설정해주면 된다.

    // APB1 -> TIM3 CLK 공급 활성화
    RCC->APB1ENR |= RCC_APB1ENR_TIM3EN;

    TIM3->PSC = 8400 - 1;   // 10kHz
    TIM3->ARR = 1000 - 1;  // 0.1초
    TIM3->CCR1 = 1000 * 0.7; // 0.07초 시점에서 비교 이벤트
    TIM3->CNT = 0;

    // One-Pulse Mode로 지정
    TIM3->CR1 |= 0x1 << 3;

    // 단방향 증감
    TIM3->CR1 |= (0 << 5);
    // Upcounter로 사용
    TIM3->CR1 |= (0 << 4);

// ******** TIM3의 Slave 설정
    **// TIM3의 Trigger로 TIM2를 사용**
    // Slave 모드로 설정 (CLK이 아니라 다른 신호에 반응하겠다)
    TIM3->SMCR |= 0x1 << 7;
    // ITR1을 트리거로 사용 
    TIM3->SMCR |= 0x1 << 4;
    // Trigger 신호에 따라 TIM이 Enable 되도록 Trigger 모드로 설정
    TIM3->SMCR |= 0x6 << 0;

    // CH1을 output 모드로 사용하겠다 (CMP)
    TIM3->CCMR1 |= (0 << 0);
  // OC1M[6:4] 초기화
    TIM3->CCMR1 &= ~(0x7 << 4);
    TIM3->CCMR1 |= (0x6 << 4);

    // CC 1채널을 output으로 사용
    TIM3->CCER |= (0x1 << 0);

    // 업데이트 반영
    TIM3->EGR |= 0x1;

    // Trigger에 의해 타이머를 동작시킬 거니깐 TIM3는 시작 X
  // TIM3->CR1 |= 1;

// ******** TIM2에 대한 설정
    // APB1 -> TIM2 CLK 공급 활성화
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;

    TIM2->PSC = 8400 - 1;   // 10kHz
    TIM2->ARR = 10000 - 1;  // 1초
    TIM2->CNT = 0;

    // 단방향 증감
    TIM2->CR1 |= (0 << 5);
    // Upcounter로 사용
    TIM2->CR1 |= (0 << 4);

    **// Update 이벤트 시점에 TRGO로 신호를 내보내기**
    TIM2->CR2 |= (0x2 << 4);

    // 업데이트 반영
    TIM2->EGR |= 0x1;

    // TIM2 시작
    TIM2->CR1 |= 0x1;

실행 결과, 아래처럼 1초 간격으로 / 0.03초간 LOW - 0.07초간 HIGH 인 PWM 파형의 출력을 확인할 수 있다.

5. Input 신호 캡쳐로 주기 파악하기

마지막으로, TIM3에 있는 Input 모드를 한 번 사용해보고 싶었다. 이게 신호의 상승과 하강에 대한 시점을 Counter 값을 통해서 파악하고 or 이번 상승부터 다음 상승까지의 시점을 파악하고, 파악한 값을 기반으로 두 신호 사이의 시간을 측정해 활용하는 용도로 쓸 수 있다.

이 신호간의 간격을 측정해서 도데채 어디에 활용 😦😦?? 이라고 생각했는데, PWM 신호나 회전 속도 파형을 읽어들여서 현재 어느정도 주기로 동작하고 있는지를 동적으로 측정하고 처리한다거나, 초음파, 적외선 센서나 신호가 들어오면 신호의 파형(길이)를 측정해서 어떤 수치를 가지는지 파악하는 등등에 활용된다. 오호라. (초음파 센서가 키트에 있는 이유가 이 기능을 한 번 써보라고 있는거구나 싶다)

TIM3 에서 신호를 Output Pin으로 내보내고, 이걸 다시 TIM2에서 입력으로 받아들여서 TIM2에서 TIM3의 주기를 측정하는 형태로 시나리오를 잡았다.

  • TIM3에서는 1초 주기로 신호를 뱉는다. 실습 3번, PWM 파형 만들기의 코드를 그대로 사용
  • TIM2에서는 CH1으로 TIM3의 CH1 Output 신호를 Input으로 받는다. (점퍼선으로 PA6 → PA5를 연결)
  • Rise마다 신호의 Rising Edge를 측정하고, Rising Edge 간의 간격이 몇 CLK 인지 계산 + TIM2의 CLK으로 이 신호 Rise 차이 시간을 계산한다.
  • 신호의 주기를 UART로 PC로 전송하고, PC의 터미널에서 확인한다.

TIM2 쪽의 활성화 코드를 아래처럼 작성해주었다. 주요한 내용은 CH1으로 Input 신호를 받아들이고, 이 Input 신호가 Rise가 될 때마다 Counter의 현재 값을 Caputre 하여 CCR 레지스터에 담고 인터럽트를 걸도록 설정해준다.

    // TIM2 클럭 활성화
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;

    TIM2->PSC = 8400 - 1;   // 10kHz
    // ARR: 32-bit 최대값
    TIM2->ARR = 0xFFFFFFFF;
    TIM2->CNT = 0;

    // CH1에서 Rise 신호마다 인터럽트를 걸도록 설정하기

    // CC1S = 01: CH1을 Input으로, TI1에 매핑
    TIM2->CCMR1 &= ~(0x3 << 0);
    TIM2->CCMR1 |= (0x1 << 0);

    // Input Filter 설정으로 노이즈 제거
    TIM2->CCMR1 &= ~(0xF << 4);
    // 8 샘플의 평균을 사용
    TIM2->CCMR1 |= (0x8 << 4);

    // Prescaler는 없이 설정
    TIM2->CCMR1 &= ~(0x3 << 2);

    // CC1P, CC1NP: Rising Edge (00)
    TIM2->CCER &= ~(0x1 << 1);  // CC1P = 0
    TIM2->CCER &= ~(0x1 << 3);  // CC1NP = 0

    // CH1 활성화
    TIM2->CCER |= (0x1 << 0);

    // CC1 인터럽트 활성화
    TIM2->DIER |= (0x1 << 1);   // CC1IE = 1
    NVIC_EnableIRQ(TIM2_IRQn);
    NVIC_SetPriority(TIM2_IRQn, 3);

    // 타이머 시작
    TIM2->CR1 |= 0x1;

인터럽트 쪽에서 이렇게 이전 Capture 값과 이번 Capture 값을 비교하고 그 차이가 몇 CLK 인지 계산하도록 작성해주었다.

volatile uint32_t capture_current = 0;
volatile uint32_t capture_previous = 0;
volatile uint32_t period = 0;          // 주기 (카운트)
volatile uint8_t is_first_capture = 1;
volatile uint8_t measurement_ready = 0;

void TIM2_IRQHandler(void)
{
    // TIM2의 CH1에서 인터럽트가 발생했다면
    if (TIM2->SR & (0x1 << 1))
    {
        TIM2->SR &= ~(0x1 << 1);  // 플래그 클리어

        // 현재 Rising Edge 시점을 가져오기
        capture_current = TIM2->CCR1;

        // 첫 번째 캡처는 지나감
        if (is_first_capture) {
            is_first_capture = 0;
        }
        // 두 번째 캡쳐부터는
        else
        {
            // 두 번째 캡처 - 주기 계산
            if (capture_current >= capture_previous) {
                period = capture_current - capture_previous;
            }
            // 오버플로우 발생한 경우에는 보정해주기
            else {
                // 오버플로우 (TIM2는 32-bit라 거의 없음)
                period = (0xFFFFFFFF - capture_previous) + capture_current + 1;
            }

            measurement_ready = 1;  // 측정 완료
        }

        // 다음 측정을 위해 저장
        capture_previous = capture_current;
    }
}

마지막으로 측정한 각 Rise 신호 시점의 차이가 몇 CLK이고 이게 시간으로는 몇 초인지를 계산해 UART로 PC로 보내도록 작성해줬다. 이건 main 의 while 문 안에서 1초마다 주기적으로 실행하도록 설정했따.

  while (1)
  {
      if (measurement_ready)
          {
              measurement_ready = 0;

              // 주기를 시간으로 변환
              // TIM2의 CLK을 10kHz으로 지정했어서
              // 10000으로 나눈 값을 시간으로 사용했음!
              float period_sec = (float)period / 10000.0f;

              // 주파수 계산
              float frequency = 10000.0f / (float)period;

              // UART로 출력
              sprintf(uart_buffer,
                      "Period: %.3f sec | Freq: %.3f Hz\r\n",
                      period_sec, frequency);

              HAL_UART_Transmit(&huart3, (uint8_t*)uart_buffer,
                               strlen(uart_buffer), HAL_MAX_DELAY);
          }

          HAL_Delay(1000);  // 1초마다 출력

    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
  }

입력으로 넘어오는 신호를 로직 애널라이저로 찍어보면 이렇게 1초 주기로 LOW, HIGH를 0.5초씩 번갈아 출력하는 신호이고, UART 출력을 확인해보면 이렇게 10,000CLK 이 1.000초마다 입력으로 들어온다는 것을 정확하게 확인할 수 있다!

만약 요놈을 점퍼선 여러개를 활용해서 LOW/HIGH에 대해 각각 측정하고 Duty Rate를 측정하는 실습을 진행했어도 재밌었을 것 같은데, 빵판이 집에 없어서 아직 해보지 못했다. 혹시 해보게 된다면 아래에 추가되어 있을것이다.

320x100