타이머/인터럽트
지금까지 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가 같아지는 순간. 이것도 이벤트다. (끝까지 안 가고 중간 특정 값에서 알림받고 싶을 때 쓴다.)
여기서 자연스럽게 드는 의문. "그럼 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)가 나온다. 인터럽트는 말 그대로 끼어들기다. 어떤 사건이 나면,
- CPU가 지금 하던 코드를 그 자리에서 잠깐 멈추고,
- 미리 정해둔 함수(ISR, 인터럽트 서비스 루틴)로 점프해서 그 일을 처리하고,
- 다 끝나면 원래 멈췄던 자리로 복귀해서 하던 걸 이어서 한다.
포인트는 CPU가 "언제 사건이 나나?" 하고 지켜보고 있을 필요가 없다는 거다. 사건이 나면 하드웨어가 알아서 CPU를 불러준다. 이걸 폴링(polling)이랑 비교하면 확 와닿는다.
- 폴링 : 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: 어떤 타이머 인터럽트를 쓸지 켜는 레지스터.TOIE0는 Timer0 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도 결국 이 타이머랑 인터럽트 위에 얹혀 있던 거더라. 마이컴 기초 시리즈가 얼추 한 바퀴 돈 느낌이다. 다음엔 이걸 다 엮어서 뭔가 하나 제대로 만들어보든가 해야겠다.