MCU 레지스터 메모리 맵과 GPIO 직접 제어
라이브러리 함수 아래 숨겨진 메모리 맵 주소와 GPIO 레지스터 직접 제어의 핵심 원리를 알아봅니다.
무료 공개 · 최근 수정
HAL 함수 한 줄은 결국 주소 하나에 값을 쓴다
아두이노나 HAL 라이브러리를 쓰면 함수 한 줄로 핀에 불을 켭니다. digitalWrite나 HAL_GPIO_WritePin 같은 편리한 도구는 초보자가 감각을 익히기에 좋습니다.
하지만 예제 보드에서 잘 돌던 코드를 다른 칩이나 다른 핀에 옮기면 반응이 멈추곤 합니다. 남이 짠 실무 펌웨어를 열어 보면 함수 대신 *(volatile uint32_t *)0x40020018 = (1U << 5); 같은 낯선 줄이 가득합니다. 포인터와 16진수 주소의 정체를 모르면 코드가 왜 그렇게 움직이는지 이해하기 어렵습니다.
칩 설명서에서 레지스터 주소를 찾아 물리 핀 하나를 직접 제어하는 원리를 알아봅니다. 추상화 라이브러리가 뒤편에서 실제로 실행하는 하드웨어 조작을 한 단계씩 짚어 봅니다.
// 라이브러리를 사용한 LED 켜기 호출
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
// 실제 마이크로컨트롤러 메모리 주소에 직접 쓰는 연산
*(volatile uint32_t *)(0x40020018) = (1U << 5);상단의 함수 호출은 매개변수를 검사한 뒤 결국 하단의 단일 메모리 쓰기 명령으로 바뀝니다. 0x40020018은 RAM이나 플래시 메모리가 아닙니다. 입출력 핀의 전기 신호를 바꾸는 하드웨어 스위치 상자의 고유 주소입니다.
주변장치는 정해진 메모리 주소에 있다
아파트 단지에 동과 호수가 있듯이 마이크로컨트롤러(MCU) 내부의 모든 부품에도 고유한 방 번호가 붙어 있습니다. 부품마다 메모리 주소를 배정해 관리하는 방식을 메모리 맵 I/O(Memory-Mapped I/O)라고 부릅니다.
CPU는 전압을 바꿀 때 별도의 명령어를 쓰지 않습니다. 일반 변수에 숫자를 대입하듯 해당 주소에 비트 값을 써넣습니다.
핀의 전기적 특성과 입출력 방향을 제어하는 32비트 메모리 상자를 레지스터(Register)라고 부릅니다. 주변장치를 조작하려면 장치가 연결된 시작 번지인 기본 주소에 레지스터 고유 번호인 오프셋을 더합니다.
| 분류 | 메모리 주소 및 오프셋 | 담당 기능 |
|---|---|---|
| 주변장치 영역 시작점 | 0x40000000 | 내부 부품들의 통합 기본 주소 |
| GPIOA 기본 주소 | 0x40020000 | A 포트 핀 0~15번의 시작 주소 |
| MODER 레지스터 | + 0x00 (0x40020000) | 핀의 입출력 모드 설정 (32비트) |
| ODR 레지스터 | + 0x14 (0x40020014) | 핀 출력 전압 상태 보관 (32비트) |
| BSRR 레지스터 | + 0x18 (0x40020018) | 핀 개별 켜기 및 끄기 지정 (32비트) |
주소 0x40020018은 GPIOA 기본 주소 0x40020000에 BSRR의 오프셋 0x18을 더한 결과입니다. C 언어에서는 매번 16진수 숫자를 더하지 않고 구조체 포인터로 변환하여 읽기 쉽게 표현합니다.
메모리 주소와 레지스터의 비트 칸이 어떻게 연결되는지 도면으로 확인합니다.
그림: 메모리 주소 체계에서 GPIOA 포트와 MODER 핀 5 설정 비트로 이어지는 연결
왼쪽 메모리 맵의 GPIOA 주소 0x40020000에서 오른쪽 MODER 레지스터의 핀 5 칸으로 화살표가 이어집니다. 핀 5에 해당하는 10번과 11번 비트 자리에 01을 쓰면 물리 핀이 범용 출력 모드로 확정됩니다.
RCC로 클럭을 켜야 GPIO가 동작한다
집안의 메인 전원 차단기가 내려가 있으면 방 안의 스위치를 아무리 켜도 전등에 불이 들어오지 않습니다.
마이크로컨트롤러도 전원을 켠 직후에는 배터리를 아끼려고 거의 모든 주변장치의 전원선을 끊어 둡니다. 이 절전 기법을 클럭 게이팅(Clock Gating)이라고 부릅니다.
클럭 신호가 공급되지 않는 레지스터에 값을 쓰면 장치가 반응하지 않습니다. 쓰기 명령이 무시되거나 하드웨어 오류 예외가 일어납니다.
따라서 핀을 제어하기 전에 클럭 공급 장치인 RCC 레지스터를 열어야 합니다. STM32F4 칩에서는 RCC_AHB1ENR 레지스터(0x40023830)의 0번 비트를 1로 설정하여 GPIOA로 가는 클럭을 공급합니다.
// GPIOA로 들어가는 클럭 공급선 활성화 (기본 주소 0x40023800 + 오프셋 0x30)
*(volatile uint32_t *)(0x40023830) |= (1U << 0);이 코드를 실행하고 나야 비로소 GPIOA의 모든 레지스터가 정상 동작을 시작합니다.
MODER에서 핀 5의 두 비트만 바꿔 출력 모드로 만든다
32비트 MODER 레지스터는 0번부터 15번까지 16개 핀의 모드를 2비트씩 나누어 담고 있습니다.
5번 핀을 출력으로 바꾸려면 10번과 11번 비트만 골라서 수정해야 합니다. 섣불리 레지스터 전체에 값을 덮어쓰면 다른 핀의 입력이나 통신 설정이 모두 초기화됩니다.
원하는 비트만 안전하게 바꾸려면 비트 마스킹(Bit Masking)이라는 두 단계 과정을 거칩니다.
- 클리어 단계: 10번과 11번 비트만
00으로 지우고 다른 비트는 그대로 유지합니다. - 셋 단계: 출력 모드를 뜻하는
01값을 10번 비트 자리로 밀어 넣습니다.
// 1단계: 10번과 11번 비트를 0으로 비움 (3을 10칸 밀고 반전하여 AND 연산)
GPIOA->MODER &= ~(3U << (5 * 2));
// 2단계: 출력 모드 값 1을 10번 비트 자리에 쓰기 (OR 연산)
GPIOA->MODER |= (1U << (5 * 2));이 연산을 마치면 다른 핀의 기능에는 영향을 주지 않고 5번 핀만 범용 출력 모드로 변경됩니다.
BSRR로 다른 핀을 건드리지 않고 한 핀만 켜고 끈다
핀의 전압 상태를 출력 데이터 레지스터인 ODR에 직접 쓰면 예기치 못한 문제가 생길 수 있습니다. 읽고 수정하고 다시 쓰는 사이에 인터럽트가 끼어들면 다른 핀의 상태가 함께 덮어써지기 때문입니다.
하드웨어 제조사는 이 문제를 해결하기 위해 BSRR(Bit Set/Reset Register)이라는 전용 레지스터를 마련했습니다.
BSRR은 다른 핀을 건드리지 않고 한 핀만 원자적으로 켜거나 끌 수 있습니다. 32비트 중 하위 16비트에 1을 쓰면 해당 핀이 3.3V로 켜집니다. 상위 16비트에 1을 쓰면 핀이 0V로 꺼집니다.
// 5번 핀 켜기: 하위 5번 비트에 1 쓰기
GPIOA->BSRR = (1U << 5);
// 5번 핀 끄기: 상위 5번 비트(5 + 16 = 21번 비트)에 1 쓰기
GPIOA->BSRR = (1U << (5 + 16));0을 쓴 자리는 이전 상태를 그대로 유지합니다. 따라서 이전 값을 읽어와 마스킹할 필요 없이 단 한 번의 쓰기로 핀을 안전하게 제어합니다.
[참고] volatile 키워드가 필요한 이유
하드웨어 레지스터를 가리키는 C 포인터에는 반드시
volatile을 붙입니다. 컴파일러가 반복문 최적화 과정에서 메모리 읽기나 쓰기를 임의로 생략하지 않고 매번 실제 물리 주소에 직접 접근하도록 강제하기 위함입니다.
PC에서 비트 마스크 계산을 직접 확인한다
다음은 32비트 가상 레지스터에서 5번 핀의 비트를 0으로 비운 뒤 출력 모드로 설정하는 과정을 보여주는 독립 C 프로그램입니다.
#include <stdio.h>
#include <stdint.h>
// 32비트 정수를 2진수 문자열로 출력하는 보조 함수
void print_binary32(const char *label, uint32_t val) {
printf("%-16s: 0x%08X | ", label, val);
for (int i = 31; i >= 0; i--) {
printf("%d", (val >> i) & 1);
if (i % 4 == 0 && i != 0) {
printf(" ");
}
}
printf("\n");
}
int main(void) {
uint32_t moder = 0x00000000; // 초기 레지스터 상태 (모든 핀 입력 모드)
const uint32_t pin = 5;
const uint32_t mode_output = 1; // 01: 범용 출력 모드
print_binary32("초기 MODER 값", moder);
// 1단계: 5번 핀 자리(10, 11번 비트) 클리어
uint32_t clear_mask = ~(3U << (pin * 2));
print_binary32("클리어 마스크", clear_mask);
moder &= clear_mask;
print_binary32("클리어 후 상태", moder);
// 2단계: 출력 모드 비트 셋 (1U << 10)
uint32_t set_mask = (mode_output << (pin * 2));
print_binary32("설정 마스크", set_mask);
moder |= set_mask;
print_binary32("최종 MODER 결과", moder);
return 0;
}프로그램을 컴파일하여 실행했을 때 터미널에 나타나는 예시 출력은 다음과 같습니다.
초기 MODER 값 : 0x00000000 | 0000 0000 0000 0000 0000 0000 0000 0000
클리어 마스크 : 0xFFFFF3FF | 1111 1111 1111 1111 1111 0011 1111 1111
클리어 후 상태 : 0x00000000 | 0000 0000 0000 0000 0000 0000 0000 0000
설정 마스크 : 0x00000400 | 0000 0000 0000 0000 0000 0100 0000 0000
최종 MODER 결과 : 0x00000400 | 0000 0000 0000 0000 0000 0100 0000 0000출력 결과에서 10번과 11번 비트 자리가 01로 변경되어 최종 값이 0x00000400이 된 것을 볼 수 있습니다. 32비트 임베디드 레지스터를 다룰 때는 이와 같은 비트 시프트와 마스킹 공식을 활용합니다.
루프 안에서 핀 상태를 계속 읽는 대신 외부 신호가 들어왔을 때 즉시 감지하려면 Cortex-M NVIC 인터럽트 우선순위 처리로 실행 흐름을 제어합니다.
참고 문서
설명이 어렵거나 잘못된 부분을 발견하셨나요?
문서 수정 의견 보내기