반응형

지금까지 I2C, SPI, UART, ADC, PWM 글을 하나씩 정리했다. 그런데 PWM을 파다 보니 결국 그 뒤에 있던 건 타이머였더라. 그래서 이번엔 미뤄뒀던 타이머 / 인터럽트를 제대로 판다. 사실 이거 두 개는 마이컴의 진짜 기본기라서, 여기까지 오면 기초 시리즈가 한 바퀴 도는 느낌이다.

왜 타이머 + 인터럽트인가

LED를 1초마다 깜빡이는 코드를 처음 짤 땐 다들 이렇게 짠다.

while(1)
{
    LED = 1;
    delay_ms(1000);   // 1초 기다림
    LED = 0;
    delay_ms(1000);   // 또 1초 기다림
}

동작은 한다. 그런데 문제는 저 delay_ms(1000) 안에서 CPU가 진짜 아무것도 안 하고 멍때린다는 거다. 1초 동안 그냥 숫자만 세면서 시간을 죽이고 있는 거다. 버튼도 못 읽고, 센서도 못 보고, 통신도 못 한다. LED 하나 깜빡이겠다고 CPU를 통째로 붙잡아두는 셈이다.

비싼 CPU를 스톱워치로 쓰는 격

여기서 타이머 + 인터럽트가 등장한다. 이 둘을 쓰면 CPU는 딴 일을 하면서도 정확한 타이밍에 LED를 깜빡일 수 있다. 시간 재는 건 타이머(하드웨어)한테 맡기고, 시간 다 되면 인터럽트가 "야, 됐어!" 하고 CPU를 잠깐 불러주는 방식이다.

타이머부터 — 그냥 숫자 세는 기계

타이머(Timer)는 CPU와 별개로 도는 하드웨어 카운터다. 하는 일은 단순하다. 클럭 펄스가 하나 들어올 때마다 카운터 값을 1씩 올린다. 그게 전부다. CPU가 while문 돌리든 말든 상관없이 얘는 자기 페이스로 계속 숫자를 센다.

그 카운터 값이 담기는 레지스터가 TCNT(Timer/Counter)다. 예를 들어 8비트 타이머라면 TCNT는 0부터 시작해서 1, 2, 3… 이렇게 올라가다가 255(8비트 최댓값)까지 간다. 그 다음 한 번 더 세면? 담을 자리가 없으니 0으로 뚝 떨어진다. 이걸 오버플로(overflow)라고 한다.

  • TCNT : 지금 몇까지 셌는지 들어있는 레지스터. 0 → 255 → 0 → 255 … 반복.
  • 오버플로 : 255에서 한 번 더 세서 0으로 넘칠 때. 이 순간 "이벤트"가 발생한다.
  • 컴페어 매치(compare match) : 미리 정해둔 비교값과 TCNT가 같아지는 순간. 이것도 이벤트다. (끝까지 안 가고 중간 특정 값에서 알림받고 싶을 때 쓴다.)

TCNT는 0에서 TOP(255)까지 올라갔다가 오버플로로 0으로 뚝 떨어진다. 이 톱니를 무한 반복한다.

여기서 자연스럽게 드는 의문. "그럼 8비트 타이머는 255밖에 못 세는데, 1초 같은 긴 시간은 어떻게 재?" 좋은 질문이다. 그래서 두 가지 장치가 있다.

① 프리스케일러로 세는 속도를 늦춘다

프리스케일러(prescaler)는 클럭을 분주(分周)한다. 쉽게 말해 클럭을 느리게 만들어서 타이머한테 넣어주는 거다. CPU 클럭이 그대로 들어가면 타이머가 너무 빨리 넘쳐서 시간 재기가 힘드니까, 중간에서 "8번에 1번만", "1024번에 1번만" 이런 식으로 걸러서 천천히 세게 한다.

한 틱(카운터 1 올라가는) 시간은 이렇게 정해진다.

한 틱 시간 = 프리스케일러 ÷ F_CPU

예를 들어 F_CPU가 8MHz이고 프리스케일러가 1024라면, 한 틱 = 1024 ÷ 8,000,000 ≈ 0.128ms. 이 속도면 8비트 타이머가 0→255 한 바퀴(256틱) 도는 데 대략 32.8ms 걸린다. 프리스케일러를 키우면 더 느리게, 줄이면 더 빠르게 센다. 세는 속도를 다이얼처럼 조절하는 놈이라고 보면 된다.

② 오버플로 횟수를 소프트웨어로 센다

그래도 한 바퀴가 32.8ms면 1초엔 한참 모자란다. 그래서 오버플로가 몇 번 났는지를 우리가 변수로 센다. 예를 들어 오버플로가 약 30번 쌓이면 대략 1초다. "타이머가 넘칠 때마다 카운트 하나 올리고, 그게 N번 되면 1초 지난 걸로 친다" — 이 발상이 뒤에 나올 코드의 핵심이다.

인터럽트 — 사건이 나면 하던 일 멈추고 달려가기

그럼 "타이머가 오버플로 났다"는 걸 CPU가 어떻게 아느냐? 여기서 인터럽트(Interrupt)가 나온다. 인터럽트는 말 그대로 끼어들기다. 어떤 사건이 나면,

  1. CPU가 지금 하던 코드를 그 자리에서 잠깐 멈추고,
  2. 미리 정해둔 함수(ISR, 인터럽트 서비스 루틴)로 점프해서 그 일을 처리하고,
  3. 다 끝나면 원래 멈췄던 자리로 복귀해서 하던 걸 이어서 한다.

포인트는 CPU가 "언제 사건이 나나?" 하고 지켜보고 있을 필요가 없다는 거다. 사건이 나면 하드웨어가 알아서 CPU를 불러준다. 이걸 폴링(polling)이랑 비교하면 확 와닿는다.

폴링은 "다 됐어?"를 계속 물어보느라 CPU를 낭비하고, 인터럽트는 사건이 날 때만 잠깐 처리한다.

 

  • 폴링 : while 루프 돌면서 계속 "다 됐어? 아니. 다 됐어? 아니…" 하고 직접 물어본다. 물어보는 동안 CPU가 계속 붙잡혀 있으니 낭비가 크다.
  • 인터럽트 : 평소엔 딴 일 하다가, 사건이 나면 알아서 불러준다. CPU 낭비가 없다.

인터럽트를 쓰려면 두 개의 스위치를 알아야 한다. sei()전역 인터럽트 허용(켜기), cli()전역 인터럽트 금지(끄기)다. sei()를 안 해주면 아무리 설정해도 인터럽트가 안 걸리니까 이거 빼먹으면 안 된다. (나도 여기서 한참 헤맸다 ㅡㅡ;;;)

그리고 실제 처리 함수는 ISR(벡터명){ … } 형태로 쓴다. 예를 들어 Timer0 오버플로용 핸들러는 이렇게 생겼다.

ISR(TIMER0_OVF_vect)
{
    // 타이머0이 오버플로 날 때마다 자동으로 여기로 점프한다
}

코드로 보자 — delay 없이 LED 깜빡이기

이제 진짜다. 목표는 delay 없이 Timer0 오버플로 인터럽트로 LED를 일정 주기로 깜빡이는 거다. 아이디어는 위에서 말한 그대로다. 오버플로가 N번 쌓일 때마다 LED를 토글한다.

먼저 타이머와 인터럽트를 설정하는 부분이다.

volatile unsigned char count = 0;   // 오버플로 횟수 세는 변수

void Timer0_Init(void)
{
    TCCR0  = (1<<CS02)|(1<<CS00);   // 프리스케일러 1024로 클럭 분주
    TIMSK  = (1<<TOIE0);            // Timer0 오버플로 인터럽트 허용
    sei();                         // 전역 인터럽트 ON
}
  • TCCR0 : Timer0의 동작을 정하는 레지스터. CS02, CS00 비트를 세우면 프리스케일러가 1024로 설정된다. (즉 클럭을 1024로 나눠서 천천히 센다.)
  • TIMSK : 어떤 타이머 인터럽트를 쓸지 켜는 레지스터. TOIE0Timer0 Overflow Interrupt Enable, 즉 "Timer0 오버플로 인터럽트를 켠다"는 뜻이다.
  • sei() : 이걸 해줘야 전역 인터럽트가 실제로 동작한다. 아까 말한 그거다.

다음은 오버플로가 날 때마다 자동으로 불려가는 ISR이다.

ISR(TIMER0_OVF_vect)
{
    count++;                    // 오버플로 한 번 더 났다

    if (count >= N)             // N번 쌓이면
    {
        count = 0;             // 카운트 초기화
        LED ^= 1;              // LED 토글 (^= 1 은 0↔1 뒤집기)
    }
}

타이머가 넘칠 때마다 CPU가 잠깐 여기로 들어와서 count를 하나 올리고 바로 나간다. 그러다 count가 N에 도달하면 그때 LED를 뒤집고 카운트를 0으로 되돌린다. N값을 조절하면 깜빡이는 주기가 바뀐다. (한 틱 시간 × 256 × N 이 대략 반주기가 된다.)

그리고 여기가 진짜 하고 싶었던 얘기다. main은 이렇게 생겼다.

void main(void)
{
    DDRB = 0xFF;        // LED 핀 출력 설정 (예시)
    Timer0_Init();      // 타이머 + 인터럽트 세팅

    while(1)
    {
        // 여기선 LED 깜빡임에 대해 아무것도 안 한다!
        // 버튼 읽기, 센서 처리, 통신 등 '딴 일'을 마음껏 한다
    }
}

보이는가. while(1) 안에 LED 관련 코드가 한 줄도 없다. delay_ms도 없다. LED 깜빡임은 전부 인터럽트가 뒤에서 알아서 처리하고, main은 그동안 다른 일에 집중할 수 있다. 이게 아까 delay 코드랑 결정적으로 다른 점이다. 시간 재기를 하드웨어에 떠넘기고 CPU는 자유로워진 것. 처음 이 구조를 이해했을 때 좀 감동했다 ^^

ISR 짤 때 딱 하나 기억할 것

ISR은 최대한 짧게 짜라. 안에서 오래 걸리는 일(긴 delay, 복잡한 계산, 통신 대기 등)을 하면 그동안 다른 인터럽트도 밀리고 시스템 전체가 버벅인다. ISR 안에서는 플래그 하나 세우거나 변수 하나 올리는 정도로 짧게 처리하고 얼른 빠져나오는 게 정석이다. 무거운 일은 main의 while 루프에서 그 플래그 보고 처리하면 된다.

정리하면

  • 타이머 = CPU와 별개로 도는 하드웨어 카운터. TCNT가 0→TOP→0 반복하고, 넘칠 때 오버플로 이벤트가 난다.
  • 프리스케일러로 세는 속도를 조절한다. 한 틱 시간 = 프리스케일러 ÷ F_CPU.
  • 인터럽트 = 사건이 나면 하던 일 멈추고 ISR로 점프 → 처리 → 원래 자리 복귀.
  • 폴링은 계속 물어봐서 CPU 낭비, 인터럽트는 사건 날 때만 불러줘서 낭비 없음.
  • sei()로 전역 인터럽트 켜고, ISR(TIMER0_OVF_vect){…}로 핸들러 작성. ISR은 짧게.

이렇게 해서 I2C, SPI, UART, ADC, PWM에 이어 타이머/인터럽트까지 정리했다. 돌아보니 통신도, 변환도, PWM도 결국 이 타이머랑 인터럽트 위에 얹혀 있던 거더라. 마이컴 기초 시리즈가 얼추 한 바퀴 돈 느낌이다. 다음엔 이걸 다 엮어서 뭔가 하나 제대로 만들어보든가 해야겠다.

'프로그래밍 > 마이컴' 카테고리의 다른 글

PWM (펄스폭 변조)  (0) 2026.07.19
ADC (아날로그 읽기)  (0) 2026.07.19
UART 통신  (0) 2026.07.19
SPI 통신  (0) 2026.07.19
ATMEGA32L External Interrupt  (0) 2013.08.14
반응형

그동안 통신 쪽(I2C, SPI, UART)이랑 ADC까지 정리했으니, 이번엔 방향을 좀 틀어서 PWM을 정리해본다. ADC가 바깥 세상의 아날로그 값을 마이컴 안으로 읽어들이는 거였다면, PWM은 반대로 마이컴이 아날로그처럼 바깥으로 내보내는 느낌이라 짝처럼 묶어서 보기 좋더라.

일단 이 궁금증부터

LED 밝기를 절반만 켜고 싶다. 모터를 천천히만 돌리고 싶다. 그런데 마이컴 핀이 내보낼 수 있는 건 0V(LOW) 아니면 5V(HIGH), 딱 두 가지뿐이다. 디지털이니까. 그럼 그 중간인 "반쯤"은 대체 어떻게 만들지? 나는 처음에 무슨 볼륨 노브처럼 전압을 슬슬 낮춰주는 뭔가가 따로 있는 줄 알았다 ㅋㅋ

답은 좀 얍삽하다(?). 아주 빠르게 껐다 켰다를 반복하면서, "켜져 있는 시간의 비율"로 평균 전압을 만드는 거다. 절반만 켜고 싶으면 절반의 시간만 HIGH로 두면 된다. 이렇게 켜짐/꺼짐의 폭을 조절해서 원하는 세기를 만드는 게 바로 PWM이다.

용어부터 풀어보자

PWM은 Pulse Width Modulation, 우리말로 펄스폭 변조다. 이름 그대로 "펄스(HIGH로 튀어나온 신호)의 폭(넓이)을 변조(조절)한다"는 뜻이다. "피-더블유-엠"이라고 그냥 읽으면 된다.

핵심 규칙이 하나 있다. 주기(한 번 껐다 켜지는 데 걸리는 시간)는 고정해두고, 그 한 주기 안에서 HIGH로 켜져 있는 시간만 늘렸다 줄였다 한다는 거다. 켜짐과 꺼짐이 세트로 한 번 도는 게 한 주기고, 그 주기가 1초에 몇 번 도느냐가 주파수다.

한 주기 = ON시간 + OFF시간. 주기는 그대로 두고 ON시간(펄스 폭)만 조절한다.

 

듀티비(duty cycle)가 진짜 주인공

여기서 제일 중요한 놈이 듀티비(duty cycle)다. 한 주기 중에 HIGH로 켜져 있는 시간의 비율(%)을 말한다. 앞 그림에 있던 그 공식이다.

듀티비 = (ON 시간 / 전체 주기) × 100%

이 듀티비가 결국 평균 전압을 결정한다. 5V 기준으로 계산하면 이렇게 된다.

듀티비 평균 전압 (5V 기준) 체감
25% ≈ 1.25V LED 어둑, 모터 느릿
50% ≈ 2.5V 딱 절반
75% ≈ 3.75V LED 밝음, 모터 빠릿
100% 5V 계속 켜짐 (그냥 HIGH)

계산도 간단하다. 평균 전압 = 전원 전압 × 듀티비. 듀티 25%면 5V × 0.25 = 1.25V다. 이 평균값이 LED한테는 밝기로, 모터한테는 속도로 느껴진다.

세 파형 모두 주기(가로 폭)는 똑같다. 파란 칸(HIGH 시간)이 넓어질수록 평균 전압(주황 점선)이 올라간다.

 

근데 이게 왜 되는 거지?

솔직히 처음엔 "껐다 켰다 하면 깜빡거리기만 하지 어떻게 중간 밝기가 되냐"가 이해가 안 됐다. 포인트는 속도다. 스위칭을 눈이나 모터가 도저히 못 따라올 만큼 빠르게(보통 수백 Hz에서 수십 kHz) 반복하면, 사람 눈이나 모터의 관성이 그 깜빡임을 하나하나 못 쫓아가고 그냥 평균값으로 뭉뚱그려 느끼게 된다.

형광등이 사실은 미친 듯이 깜빡이는데 우리 눈엔 그냥 계속 켜진 걸로 보이는 거랑 비슷하다. 모터도 마찬가지로, 전기가 워낙 빨리 끊겼다 이어지니까 회전 관성이 그걸 평균으로 받아서 "적당한 속도"로 돈다. 그래서 PWM에서 주파수를 충분히 높게 잡는 게 중요하다. 너무 낮으면 LED가 눈에 띄게 깜빡거리거나 모터가 덜덜거린다.

AVR에서는 어떻게 만드나 (타이머)

이걸 소프트웨어로 PORT 켰다 껐다 delay 주면서 직접 만들 수도 있긴 한데, 그러면 CPU가 그 짓만 하고 있어야 해서 낭비다. 그래서 AVR은 타이머(Timer)가 하드웨어로 알아서 PWM을 뽑아준다. 우리는 레지스터 몇 개만 세팅하면 끝이고, 그 뒤론 CPU가 다른 일 해도 핀에서 파형이 계속 나온다. 이게 진짜 편하다.

PWM에 관여하는 레지스터를 정리하면 이렇다. (Timer0 기준으로 설명한다.)

  • WGM 비트 (TCCR0 안) : 타이머 동작 모드를 고른다. PWM을 쓰려면 Fast PWM이나 Phase-correct PWM 모드로 맞춘다.
  • COM 비트 (TCCR0 안) : 출력핀(OCn)을 어떻게 토글할지. 보통 "비교값 만나면 LOW로" 하는 non-inverting 모드를 쓴다.
  • CS 비트 (TCCR0 안) : 프리스케일러. 타이머 클럭을 몇으로 나눌지 정해서 PWM 주파수를 조절한다.
  • OCR0 레지스터 : 비교값. 이게 바로 듀티비를 정하는 값이다. 타이머가 0부터 세다가 이 값에 도달하면 핀을 내린다.

PWM 주파수는 이 공식으로 정해진다.

PWM 주파수 = F_CPU / (프리스케일러 × (TOP + 1))

8비트 타이머면 TOP이 255라서 (TOP+1)은 256이 된다. 여기까지가 세팅이고, 실제로 밝기를 바꾸는 건 오로지 OCR0 값 하나다.

코드로 보자 (Timer0, 8비트 Fast PWM)

말로 하면 복잡하니 코드가 마음 편하다. Timer0을 Fast PWM으로 세팅하는 예시다.

void PWM_Init(void)
{
    // OC0 핀(PWM 출력)을 출력으로. (ATmega128 기준 PB4)
    DDRB |= (1 << PB4);

    // Fast PWM 모드 : WGM01, WGM00 둘 다 1
    // non-inverting 출력 : COM01 = 1  (비교값 만나면 LOW)
    // 프리스케일러 8       : CS01 = 1
    TCCR0 = (1 << WGM00) | (1 << WGM01) | (1 << COM01) | (1 << CS01);

    OCR0 = 64;   // 듀티 ≈ 64/255 ≈ 25%  → LED 어둑하게
}

여기서 WGM00+WGM01로 Fast PWM 모드가 되고, COM01로 "OCR0에 도달하면 출력핀을 LOW로 내리는" 동작이 켜진다. 즉 타이머가 0→255를 계속 반복하는데, 0부터 OCR0까지는 HIGH, OCR0 넘으면 255까지는 LOW가 된다. 그래서 OCR0가 클수록 HIGH 구간이 길어지고 = 듀티가 커지고 = 밝아진다.

핵심은 세팅은 한 번만 하고, 그 다음부턴 OCR0 값만 바꾸면 밝기/속도가 실시간으로 바뀐다는 거다.

void main(void)
{
    PWM_Init();

    while (1)
    {
        OCR0 = 64;    // 듀티 25%  (5V × 0.25 ≈ 1.25V) → 어둑
        // OCR0 = 128; // 듀티 50%  (≈ 2.5V)  → 절반
        // OCR0 = 191; // 듀티 75%  (≈ 3.75V) → 밝음
    }
}

OCR0 한 줄만 0부터 255 사이로 바꿔주면 밝기가 쭉 조절된다. 만약 가변저항 값을 ADC로 읽어서 그 값을 OCR0에 그대로 넣어주면? 노브를 돌리는 대로 LED 밝기가 따라오는 디머(dimmer)가 된다. ADC랑 PWM이 이렇게 만난다 ^^

어디에 쓰나

  • LED 디밍 : OCR 값만 바꿔서 밝기 조절. 부드럽게 밝아졌다 어두워지는 숨쉬기 효과도 이걸로 만든다.
  • DC 모터 속도 제어 : 듀티비로 평균 전압 = 속도 조절. (실제론 모터 전류 때문에 트랜지스터/MOSFET를 하나 끼워서 구동한다.)
  • 서보모터 : 얘는 좀 특이한데, 주기 20ms(50Hz)에 펄스폭 1~2ms를 실어서 그 폭으로 각도를 정한다. (밝기가 아니라 위치를 PWM으로 지정하는 케이스 — 이건 나중에 따로 다뤄야겠다.)

정리하면

  • 마이컴 핀은 0V/5V만 내보내지만, 빠르게 껐다 켰다 하면 그 평균으로 중간값을 만들 수 있다. 그게 PWM.
  • 주기는 고정, 한 주기 안의 HIGH 시간(펄스 폭)만 조절한다.
  • 듀티비 = ON시간 / 주기. 평균 전압 = 전원 전압 × 듀티비. → LED 밝기, 모터 속도가 된다.
  • AVR에선 타이머가 하드웨어로 만들어준다. OCR 값만 바꾸면 밝기/속도가 바뀐다.

정리하다 보니 결국 PWM은 타이머가 다 해주는 거였다. 주파수도 프리스케일러+TOP으로 정하고, 듀티도 비교값으로 정하고… 죄다 타이머 이야기다. 그래서 다음엔 타이머와 인터럽트를 제대로 파봐야겠다. PWM도 결국 타이머로 만드는 거니까, 순서상 그게 맞는 것 같다.

'프로그래밍 > 마이컴' 카테고리의 다른 글

타이머/인터럽트  (0) 2026.07.19
ADC (아날로그 읽기)  (0) 2026.07.19
UART 통신  (0) 2026.07.19
SPI 통신  (0) 2026.07.19
ATMEGA32L External Interrupt  (0) 2013.08.14

+ Recent posts