이번 문서의 목표: 이 파일을 다 읽으면 GPIO 레지스터를 C 코드로 직접 조작할 수 있고, 폴링과 인터럽트 방식의 장단점을 응답성·CPU 부담 관점에서 비교할 수 있으며, NVIC의 벡터·우선순위 개념과 타이머의 분주비(prescaler)로부터 실제 주기를 계산하는 전 과정을 설명할 수 있다.
GPIO: 마이크로컨트롤러가 세상과 맞닿는 가장 기본적인 통로
쉽게 말하면: GPIO는 소프트웨어에서 켜고 끄거나 읽을 수 있는 핀(pin) 하나하나를 뜻하며, 모든 핀은 몇 개의 레지스터로 제어된다.
GPIO(General Purpose Input/Output, 범용 입출력)는 특정 기능이 고정되어 있지 않아 소프트웨어가 입력 또는 출력 용도로 자유롭게 설정할 수 있는 MCU의 핀을 말한다. LED를 켜고 끄거나, 버튼이 눌렸는지 읽거나, 다른 칩에 간단한 신호를 보내는 가장 기초적인 방법이 GPIO다. 07편에서 다룬 메모리 맵드 I/O 방식대로, GPIO는 보통 다음과 같은 레지스터 조합으로 제어된다.
| 레지스터 | 역할 |
|---|---|
| 모드 레지스터(MODER 등) | 각 핀을 입력·출력·대체기능·아날로그 중 무엇으로 쓸지 설정 |
| 출력 데이터 레지스터(ODR) | 출력으로 설정된 핀에 내보낼 값(0 또는 1)을 저장 |
| 입력 데이터 레지스터(IDR) | 입력으로 설정된 핀의 현재 전압 상태(0 또는 1)를 읽음 |
| 설정 레지스터(OTYPER, PUPDR 등) | 오픈드레인/푸시풀 출력 방식, 풀업·풀다운 저항 사용 여부 설정 |
#define GPIOA_MODER (*(volatile unsigned int *)0x40020000) /* 모드 설정 레지스터 */
#define GPIOA_ODR (*(volatile unsigned int *)0x40020014) /* 출력 데이터 레지스터 */
#define GPIOA_IDR (*(volatile unsigned int *)0x40020010) /* 입력 데이터 레지스터 */
void gpio_init(void) {
GPIOA_MODER |= (1 << 10); /* 5번 핀(비트 10~11)을 출력 모드(01)로 설정 */
GPIOA_MODER &= ~(1 << 11);
}
void led_on(void) {
GPIOA_ODR |= (1 << 5); /* 5번 핀 출력을 1로 세팅 (LED 점등) */
}
int button_pressed(void) {
return (GPIOA_IDR & (1 << 0)) != 0; /* 0번 핀 입력값 읽기 (버튼 상태 확인) */
}이 코드에서 비트 조작(bit manipulation) 연산자 |=(특정 비트를 1로 세팅), &= ~(특정 비트를 0으로 클리어), &(특정 비트만 골라 확인)가 반복해서 쓰인다. GPIO 레지스터는 한 레지스터 안에 여러 핀의 설정이 비트 단위로 촘촘히 몰려 있으므로, 원하는 핀 하나만 건드리고 나머지 비트는 그대로 두는 이 세 연산이 임베디드 C 코드의 기본기다(12편에서 volatile과 함께 더 깊이 다룬다).
폴링과 인터럽트: 이벤트를 알아채는 두 가지 방법
쉽게 말하면: 폴링은 CPU가 “됐나? 됐나?”하고 계속 직접 확인하러 가는 방식이고, 인터럽트는 장치가 준비되면 CPU에게 먼저 알려주는 방식이다.
버튼이 눌렸는지, 데이터 수신이 끝났는지 같은 이벤트(event)를 CPU가 알아채는 방법은 크게 두 가지다.
폴링(polling)은 CPU가 반복문을 돌며 특정 레지스터의 상태 비트를 계속 검사하는 방식이다.
while (1) {
if (button_pressed()) { /* 계속 확인만 반복한다 */
led_on();
break;
}
}인터럽트(interrupt)는 이벤트가 실제로 발생했을 때 하드웨어가 CPU의 현재 흐름을 강제로 멈추고 05편에서 다룬 예외 처리 절차에 따라 정해진 처리 루틴(ISR)으로 이동시키는 방식이다. CPU는 이벤트가 일어날 때까지 다른 작업을 계속할 수 있고, 이벤트가 발생하는 순간에만 그 처리에 잠깐 집중한다.
| 구분 | 폴링 | 인터럽트 |
|---|---|---|
| CPU 점유 | 이벤트를 기다리는 동안에도 CPU를 계속 점유(바쁜 대기, busy-waiting) | 이벤트가 없을 때는 다른 작업이나 저전력 대기 가능 |
| 응답 속도 | 검사 주기에 따라 늦어질 수 있음 | 하드웨어가 즉시 감지해 06편에서 계산한 지연 시간 안에 반응 |
| 구현 복잡도 | 단순(반복문 하나로 충분) | 벡터 테이블, ISR 등록, 우선순위 설정 등 더 복잡 |
| 여러 이벤트 동시 대응 | 하나씩 순서대로 검사해야 함 | NVIC가 우선순위에 따라 자동으로 조율 |
| 적합한 상황 | 이벤트가 매우 빈번하거나 다른 할 일이 전혀 없을 때 | 이벤트가 드물게 발생하고 그사이 CPU가 다른 일을 해야 할 때 |
시험 함정: “인터럽트가 폴링보다 항상 CPU 효율이 좋다”는 명제는 절대적으로 옳지 않다. 이벤트가 극도로 빈번하게 발생하는 상황(예: 초고속 데이터 스트림)에서는 매번 인터럽트가 걸려 예외 진입·복귀 오버헤드(05·06편에서 계산한 저장·복원 비용)가 누적되어 오히려 폴링보다 비효율적일 수 있다. 상황에 따라 유리한 방식이 달라진다는 것이 시험이 자주 묻는 포인트다.
NVIC: 여러 인터럽트를 벡터와 우선순위로 관리한다
쉽게 말하면: NVIC는 Cortex-M 안에서 “어떤 인터럽트가 들어왔는지, 그중 무엇을 먼저 처리할지”를 결정해 주는 전담 하드웨어다.
Cortex-M 계열은 NVIC(Nested Vectored Interrupt Controller, 중첩 벡터 인터럽트 컨트롤러)라는 전용 하드웨어로 인터럽트를 관리한다. 05편에서 클래식 ARM의 IRQ/FIQ 2단계 구조를 봤는데, NVIC는 이를 훨씬 세분화해 수십~수백 개의 인터럽트 각각에 고유 번호(벡터 번호)와 우선순위 값을 부여한다.
- 벡터(vector): 각 인터럽트 번호에 대응하는, 그 인터럽트가 발생했을 때 실행할 ISR의 시작 주소. 05편의 클래식 ARM 벡터 테이블이 7개 고정 오프셋에 점프 명령을 담았다면, NVIC의 벡터 테이블은 인터럽트 번호별로 ISR의 실제 주소를 직접 담은 배열이다.
- 우선순위(priority): 여러 인터럽트가 동시에 대기 중일 때 어느 것을 먼저 처리할지 정하는 값. 보통 숫자가 작을수록 우선순위가 높다.
- 중첩(nested): 낮은 우선순위의 ISR을 처리하는 도중에 더 높은 우선순위의 인터럽트가 발생하면, 현재 ISR을 잠시 멈추고 더 급한 인터럽트를 먼저 처리한 뒤 원래 ISR로 돌아온다. 06편에서 다룬 레이트 어라이벌(late-arrival)이 이 중첩 처리를 빠르게 만드는 기법 중 하나였다.
NVIC_EnableIRQ(TIM2_IRQn); /* TIM2 타이머 인터럽트를 활성화 */
NVIC_SetPriority(TIM2_IRQn, 1); /* 우선순위 값 1 부여 (숫자가 작을수록 우선) */
void TIM2_IRQHandler(void) { /* NVIC 벡터 테이블에 등록된 ISR */
if (TIM2_SR & 0x1) { /* 타이머 갱신(업데이트) 플래그 확인 */
TIM2_SR &= ~0x1; /* 플래그를 지워야 같은 인터럽트가 반복 발생하지 않는다 */
/* 실제 처리 내용 */
}
}시험 함정: ISR 안에서 인터럽트 발생 원인이 된 플래그(status flag)를 지우지 않으면, ISR을 빠져나가자마자 같은 인터럽트가 다시 걸려 무한 반복에 빠진다. 이 플래그 클리어 절차를 빠뜨리는 실수는 실제 개발에서도, 시험 문제에서도 자주 등장하는 함정이다.
타이머와 카운터: 시간을 세는 하드웨어
쉽게 말하면: 타이머는 정해진 속도로 숫자를 계속 세어 올라가다가(또는 내려가다가) 목표 값에 도달하면 이벤트(인터럽트 등)를 일으키는 주변장치다.
타이머(timer)와 카운터(counter)는 하드웨어적으로 거의 같은 구조를 쓴다. 클록 신호가 들어올 때마다 내부의 카운트 레지스터 값을 1씩 증감시키다가, 미리 정해 둔 값에 도달하면 특정 동작(플래그 세팅, 인터럽트 발생 등)을 수행한다. 클록 신호의 출처가 MCU 내부 클록이면 흔히 “타이머”라 부르고, 외부에서 들어오는 펄스(예: 회전수 감지 센서 신호)를 세면 “카운터”라 부르는 경우가 많지만, 하드웨어 구조 자체는 동일하다.
분주(prescaler)와 주기 계산: 전 과정을 펼치기
쉽게 말하면: 타이머에 들어오는 클록이 너무 빠르면, 분주기로 클록을 몇 분의 일로 나눠 속도를 늦춘 뒤에 세도록 만든다.
MCU의 코어 클록은 보통 수십~수백 메가헤르츠(MHz)로 매우 빠르다. 이 속도를 그대로 타이머 카운터에 넣으면 카운터가 순식간에 최댓값을 넘어가 버려 원하는 긴 주기(예: 1초마다 한 번)를 만들기 어렵다. 그래서 타이머 앞단에는 분주기(prescaler, 프리스케일러)가 있어, 입력 클록을 정수 배로 나눠 카운터에 더 느린 클록을 공급한다.
전형적인 타이머의 주기 계산은 다음 두 단계로 이뤄진다.
1단계: 분주기를 거친 뒤의 타이머 클록 주파수를 구한다.
- : 분주기를 거쳐 실제로 카운터에 공급되는 클록 주파수
- : MCU에 공급되는 원래 코어(또는 버스) 클록 주파수
- : 분주 레지스터(Prescaler register)에 설정하는 값. 레지스터 값이 일 때 실제 분주비는 이다(레지스터 값 0이 “분주 없음”, 즉 1분주를 뜻하도록 설계되어 있기 때문)
2단계: 카운터가 0부터 목표 값(자동 재적재 값)까지 세는 데 걸리는 시간을 구한다.
- : 카운터가 한 번 오버플로(또는 목표 값 도달)해 인터럽트나 이벤트를 일으키기까지 걸리는 시간, 즉 타이머의 주기
- : 자동 재적재 레지스터(Auto-Reload Register)에 설정하는 목표 카운트 값. 카운터가 0부터 까지 세려면 번의 클록이 필요하다
숫자로 검산: 1밀리초 주기 타이머 설정하기
MCU의 타이머 입력 클록이 이고, 1밀리초(1kHz)마다 한 번씩 인터럽트가 발생하도록 타이머를 설정한다고 하자.
먼저 분주기로 클록을 다루기 쉬운 속도(여기서는 1MHz, 즉 1마이크로초 단위로 세는 클록)로 낮춘다.
이 식을 에 대해 정리하면 다음과 같다.
즉 분주 레지스터에 15를 넣으면 16MHz 클록이 16분주되어 1MHz(1마이크로초에 한 번 증가하는) 클록이 카운터에 공급된다.
다음으로, 1MHz(1마이크로초 단위)로 세는 카운터가 1000마이크로초(=1밀리초)마다 오버플로하도록 을 구한다.
따라서 분주 레지스터에 15, 자동 재적재 레지스터에 999를 설정하면, 이 타이머는 정확히 1밀리초(1000분의 1초)마다 한 번씩 오버플로 이벤트(그리고 이를 인터럽트로 연결해 두었다면 인터럽트 요청)를 발생시킨다.
TIM2_PSC = 15; /* 16MHz -> 1MHz로 분주 */
TIM2_ARR = 999; /* 1MHz 클록으로 1000번 세면 1ms */
TIM2_CR1 |= 0x1; /* 타이머 카운터 시작 */
NVIC_EnableIRQ(TIM2_IRQn);시험 함정: 분주비와 자동 재적재 값 계산에서 “+1”을 빠뜨리는 실수가 매우 흔하다. 레지스터에 그대로 넣는 값(, )과 실제 분주 배수·카운트 횟수(, ) 사이에 항상 1의 차이가 있다는 점을 놓치면, 목표 주기의 정확히 두 배 가까이 어긋난 값을 계산하게 된다.
역방향 계산: 목표 주파수에서 필요한 설정값 찾기
시험 문제는 반대 방향으로도 나올 수 있다. “코어 클록이 72MHz인 MCU에서 100Hz(즉 10밀리초 주기)의 인터럽트를 만들고 싶고, 로 고정되어 있다면 은 얼마인가?”라는 문제를 풀어 보자.
분주 결과 타이머 클록은 1MHz(1마이크로초 단위)가 된다. 목표 주기는 10밀리초, 즉 10,000마이크로초이므로
따라서 에 9999를 설정해야 100Hz(10밀리초 주기)의 타이머 이벤트를 얻을 수 있다. 이처럼 분주(prescaler)와 자동 재적재(ARR) 두 레지스터를 어떻게 조합하느냐에 따라, 같은 코어 클록에서도 매우 다양한 주기의 타이머를 자유롭게 만들 수 있다.
자주 틀리는 점
- 폴링을 “구식이라 항상 나쁜 방법”으로 단정하는 오류. 상황에 따라 폴링이 인터럽트보다 유리할 수 있다.
- ISR 안에서 상태 플래그를 지우는 절차를 생략해도 된다고 착각하는 오류. 플래그를 지우지 않으면 같은 인터럽트가 반복해서 발생한다.
- 분주비 계산에서 레지스터 값과 실제 분주 배수를 그대로 같다고 놓는(+1을 빠뜨리는) 오류. 예를 들어 이면 실제 분주비는 15가 아니라 16이다.
핵심 정리
- GPIO는 모드·출력·입력 레지스터의 비트 조작으로 제어하며, 메모리 맵드 I/O 방식을 그대로 따른다.
- 폴링은 CPU가 직접 반복 검사하는 방식, 인터럽트는 이벤트 발생 시 하드웨어가 CPU를 강제로 처리 루틴으로 이동시키는 방식이며, 상황에 따라 유불리가 달라진다.
- NVIC는 인터럽트마다 벡터(ISR 주소)와 우선순위를 부여하고, 낮은 숫자가 높은 우선순위를 뜻하며 중첩 처리를 지원한다.
- 타이머 주기는 로 분주 클록을 구한 뒤, 로 최종 주기를 계산하는 두 단계로 구한다.
- 레지스터 값과 실제 배수 사이의 “+1” 차이를 놓치지 않는 것이 계산 문제의 핵심이다.