Devin.KR

레지스터와 메모리 맵 입출력

개발자KR 조회 6

이 장에서 배우는 것

앞 장에서는 마이크로컨트롤러가 CPU, 메모리, 주변장치로 이루어져 있고 이들이 하나의 주소 공간을 공유한다는 것을 다뤘다. 이번 장은 그 공유된 주소 공간을 C 코드에서 실제로 어떻게 다루는지, 즉 레지스터를 포인터로 읽고 쓰는 방법을 다룬다. 보드 없이도 이 내용을 연습할 수 있도록 hal_sim.h/hal_sim.c라는 작은 시뮬레이션 계층을 만들어, 스마트 화분 제어 보드의 레지스터가 있다고 가정하고 값을 설정·해제·확인하는 코드를 끝까지 작성한다.

  • 메모리 맵 입출력(memory-mapped I/O)이 무엇이고 왜 포인터로 레지스터에 접근하는지 설명할 수 있다
  • volatile이 없을 때 컴파일러 최적화가 어떤 오동작을 일으키는지 이해한다
  • 비트 마스크로 레지스터의 특정 비트만 설정·해제·확인하는 코드를 작성할 수 있다
  • 여러 레지스터를 구조체 하나로 묶어 다루는 방법과 그 함정을 안다
  • PC 시뮬레이션 코드와 실제 보드 코드의 차이를 구분해서 설명할 수 있다

문제 상황

스마트 화분 보드를 개발하는 팀에서 토양 수분 센서를 켜는 코드를 작성했다고 하자. 센서 컨트롤러에는 전원을 켜는 ENABLE 비트와 측정을 시작시키는 START 비트가 같은 레지스터에 들어 있다. 담당자는 먼저 ENABLE 비트를 켜 두고, 잠시 후 START 비트를 켜려고 그 레지스터에 새 값을 통째로 대입했다. 그런데 측정이 시작되지 않는다. 로그를 찍어 보니 ENABLE 비트가 어느새 꺼져 있다 — 두 번째 대입이 레지스터 전체를 덮어써서 앞서 켜 둔 비트를 지운 것이다.

비슷한 시기에 다른 담당자는 "측정 완료" 상태 비트가 켜질 때까지 기다리는 폴링 루프를 짰다. 디버그 빌드에서는 잘 동작했는데, 최적화 옵션을 켜서 빌드하자 프로그램이 그 루프에서 멈춰 버렸다. 두 문제 모두 레지스터를 다루는 방식에 원인이 있다. 이 장에서 그 원인과 해결책을 코드로 확인한다.

메모리 맵 입출력과 volatile

메모리 맵 입출력은 CPU가 주변장치의 제어·상태 값을 별도의 명령어 없이 일반 메모리 주소처럼 읽고 쓰는 방식이다. 즉 *p = 1;이라는 문장 하나로 RAM의 변수를 바꿀 수도 있고, 센서를 켜는 레지스터를 바꿀 수도 있다. CPU 입장에서 둘은 "어떤 주소에 값을 쓴다"는 점에서 똑같다. 차이는 그 주소 뒤에 실제 메모리 셀이 있느냐, 하드웨어 회로가 있느냐일 뿐이다.

문제는 컴파일러가 이 차이를 모른다는 데 있다. 컴파일러는 "이 변수는 내가 마지막에 쓴 값 그대로일 것"이라 가정하고 반복되는 읽기를 생략하거나 캐시된 값을 재사용하는 최적화를 한다. 일반 변수라면 맞는 가정이지만, 레지스터는 하드웨어가 CPU 코드와 무관하게 값을 바꿀 수 있으므로 이 가정이 깨진다. volatile 키워드는 "이 값은 내가 모르는 사이에 바뀔 수 있으니 매번 실제 메모리에서 다시 읽고, 쓸 때도 반드시 실제로 써라"라고 컴파일러에 지시하는 수단이다.

CPU는 포인터가 가리키는 주소가 RAM인지 레지스터인지 구분하지 않고 똑같이 읽고 쓴다

이 장의 시뮬레이션에서는 실제 물리 주소 대신 정적 변수로 만든 구조체를 "레지스터"로 취급한다. 실제 보드에서는 이 구조체가 칩 제조사가 정해 둔 고정 주소에 겹쳐 놓이지만, C 언어 문법과 volatile의 역할은 동일하다.

비트 마스크로 설정·해제·확인하기

레지스터 하나에는 보통 여러 비트가 서로 다른 의미를 갖고 모여 있다. 이 장의 예제에서는 CTRL 레지스터에 ENABLE 비트와 START 비트가 함께 들어 있다. 특정 비트만 건드리고 나머지는 그대로 두려면 대입(=) 대신 OR·AND·XOR 연산을 쓴다.

비트 연산으로 레지스터 다루기
연산목적예시
설정특정 비트를 1로 켜고 나머지는 그대로 둔다regs->CTRL |= HAL_CTRL_ENABLE;
해제특정 비트를 0으로 끄고 나머지는 그대로 둔다regs->CTRL &= ~(uint32_t)HAL_CTRL_START;
확인특정 비트가 켜져 있는지 검사한다if (regs->STATUS & HAL_STATUS_READY)
토글특정 비트를 반전시킨다regs->CTRL ^= HAL_CTRL_START;

마스크 값은 1u << n 형태로 정의해 두면 어느 비트를 가리키는지 이름만 보고 알 수 있다. 매직 넘버로 0x02라고 쓰는 것보다 HAL_CTRL_START라고 쓰는 쪽이 나중에 레지스터 배치가 바뀌어도 코드를 고치기 쉽다.

레지스터 구조체로 묶기

레지스터가 여러 개면 포인터를 하나씩 따로 만드는 대신 구조체로 묶어서 하나의 포인터로 전체를 다루는 편이 코드를 훨씬 읽기 쉽게 만든다. 이때 구조체의 각 필드는 실제 레지스터 폭과 같은 정수 타입으로 맞추고, 모두 volatile로 선언한다. 필드 폭을 다르게 섞으면 컴파일러가 정렬을 맞추려고 패딩 바이트를 끼워 넣어 구조체의 오프셋이 실제 하드웨어 배치와 어긋날 수 있다.

완성 코드

hal_sim.h

#ifndef HAL_SIM_H
#define HAL_SIM_H

#include <stdint.h>

typedef struct {
    volatile uint32_t CTRL;
    volatile uint32_t STATUS;
    volatile uint32_t DATA;
} hal_regs_t;

#define HAL_CTRL_ENABLE   (1u << 0)
#define HAL_CTRL_START    (1u << 1)

#define HAL_STATUS_READY  (1u << 0)
#define HAL_STATUS_ERROR  (1u << 1)

hal_regs_t *hal_sim_open(void);
void hal_sim_step(hal_regs_t *regs);

#endif

hal_sim.c

#include "hal_sim.h"

static hal_regs_t g_regs;
static int g_busy_count;

hal_regs_t *hal_sim_open(void)
{
    g_regs.CTRL = 0;
    g_regs.STATUS = 0;
    g_regs.DATA = 0;
    g_busy_count = 0;
    return &g_regs;
}

void hal_sim_step(hal_regs_t *regs)
{
    if (!(regs->CTRL & HAL_CTRL_ENABLE)) {
        return;
    }
    if (regs->CTRL & HAL_CTRL_START) {
        g_busy_count++;
        if (g_busy_count >= 3) {
            regs->DATA = 462;
            regs->STATUS |= HAL_STATUS_READY;
            regs->CTRL &= ~(uint32_t)HAL_CTRL_START;
            g_busy_count = 0;
        }
    }
}

main.c

#include <stdint.h>
#include <stdio.h>

#include "hal_sim.h"

int main(void)
{
    hal_regs_t *regs = hal_sim_open();

    regs->CTRL |= HAL_CTRL_ENABLE;
    regs->CTRL |= HAL_CTRL_START;
    printf("측정 시작: CTRL=0x%02x\n", (unsigned)regs->CTRL);

    int ticks = 0;
    while (!(regs->STATUS & HAL_STATUS_READY)) {
        hal_sim_step(regs);
        ticks++;
    }
    printf("READY 도달까지 %d틱\n", ticks);

    uint32_t raw = regs->DATA;
    printf("원시값: %u\n", (unsigned)raw);

    regs->STATUS &= ~(uint32_t)HAL_STATUS_READY;
    printf("STATUS 초기화 후: 0x%02x\n", (unsigned)regs->STATUS);

    return 0;
}

줄별 해설

hal_sim.h의 hal_regs_t는 실제 보드의 레지스터 블록을 흉내 낸 구조체다. 세 필드 모두 uint32_t로 폭을 맞추고 volatile을 붙여, 뒤에 오는 main.c가 이 값들을 하드웨어가 바꿀 수 있는 값으로 다루게 만든다. HAL_CTRL_ENABLE부터 HAL_STATUS_ERROR까지는 1u << n 형태의 비트 마스크로, 이름만 보고 어느 비트인지 알 수 있게 한다.

hal_sim.c는 진짜 하드웨어 대신 동작하는 부분이다. hal_sim_open은 레지스터를 0으로 초기화하고 포인터를 돌려준다. hal_sim_step은 ENABLE이 켜져 있고 START가 켜진 상태에서 세 번 호출될 때마다 측정이 끝난 것으로 치고 DATA에 값을 채운 뒤 READY 비트를 켜고 START 비트는 스스로 끈다. 실제 센서라면 이 과정이 인터럽트나 DMA로 비동기적으로 일어나지만, 여기서는 반복 호출로 그 지연을 흉내 낸다.

main.c는 먼저 |=로 ENABLE과 START 비트를 각각 켠다. 두 줄을 따로 쓴 이유는 앞서 켠 비트를 뒤에서 덮어쓰지 않기 위해서다. 이어지는 while 루프는 STATUS의 READY 비트가 켜질 때까지 hal_sim_step을 반복 호출하며 ticks를 센다. 값이 volatile로 선언되어 있으므로 컴파일러는 매 반복마다 STATUS를 실제로 다시 읽는다. 루프를 빠져나온 뒤 DATA를 읽고, 마지막으로 &= ~(uint32_t)HAL_STATUS_READY로 READY 비트만 끄고 나머지 상태 비트는 건드리지 않는다.

실행 결과

$ cc -std=c11 -Wall -Wextra -o smartpot main.c hal_sim.c
$ ./smartpot
측정 시작: CTRL=0x03
READY 도달까지 3틱
원시값: 462
STATUS 초기화 후: 0x00

실무에서 자주 틀리는 것

volatile을 빠뜨린다

레지스터 필드에 volatile을 붙이지 않으면 최적화 빌드에서 폴링 루프가 값을 한 번만 읽고 다시 읽지 않을 수 있다.

/* 틀린 코드 */
typedef struct {
    uint32_t CTRL;
    uint32_t STATUS;
} hal_regs_t;

while (!(regs->STATUS & HAL_STATUS_READY)) {
    hal_sim_step(regs);
}
/* 고친 코드 */
typedef struct {
    volatile uint32_t CTRL;
    volatile uint32_t STATUS;
} hal_regs_t;

레지스터 전체를 새 값으로 덮어쓴다

대입(=)은 지정하지 않은 비트까지 0으로 만들어 버린다. 문제 상황에서 본 ENABLE 비트가 꺼지는 사고가 바로 이 패턴이다.

/* 틀린 코드: ENABLE 비트가 꺼진다 */
regs->CTRL = HAL_CTRL_START;
/* 고친 코드 */
regs->CTRL |= HAL_CTRL_START;

부호 있는 정수로 비트를 뒤집는다

마스크나 레지스터 타입의 폭을 명시하지 않고 ~을 쓰면, 타입이 다른 플랫폼으로 옮겼을 때 상위 비트가 의도와 다르게 채워질 수 있다.

/* 틀린 코드 */
regs->CTRL &= ~HAL_CTRL_START;
/* 고친 코드: 레지스터 폭으로 캐스팅 */
regs->CTRL &= ~(uint32_t)HAL_CTRL_START;

필드 폭을 섞어서 구조체를 만든다

레지스터 폭보다 좁은 타입을 끼워 넣으면 컴파일러가 정렬을 맞추려고 패딩을 넣어, 구조체 오프셋이 실제 하드웨어 레지스터 배치와 어긋난다.

/* 틀린 코드: CTRL 뒤에 패딩 3바이트가 끼어들 수 있다 */
typedef struct {
    volatile uint8_t  CTRL;
    volatile uint32_t STATUS;
} hal_regs_t;
/* 고친 코드: 폭을 레지스터와 동일하게 맞춘다 */
typedef struct {
    volatile uint32_t CTRL;
    volatile uint32_t STATUS;
} hal_regs_t;

한눈에 보기

이 장에서 다룬 개념 정리
개념의미예제에서 쓴 코드
volatile값이 코드 밖에서 바뀔 수 있으니 매번 실제로 읽고 쓰라는 지시volatile uint32_t STATUS;
비트 설정다른 비트는 두고 특정 비트만 켠다regs->CTRL |= HAL_CTRL_START;
비트 해제다른 비트는 두고 특정 비트만 끈다regs->STATUS &= ~(uint32_t)HAL_STATUS_READY;
레지스터 구조체여러 레지스터를 하나의 포인터로 묶어서 다룬다hal_regs_t *regs = hal_sim_open();
PC 시뮬레이션과 실제 보드의 차이
항목이 장의 PC 시뮬레이션실제 보드(STM32·ESP32·아두이노)
레지스터의 실체정적 변수로 만든 구조체(g_regs)칩 내부 버스에 매핑된 실제 하드웨어 회로
주소를 얻는 방법hal_sim_open이 반환하는 포인터#define GPIOA ((GPIO_TypeDef *)0x40020000)처럼 고정 주소를 캐스팅
값이 바뀌는 시점hal_sim_step을 호출할 때 우리가 직접 바꾼다센서·인터럽트·DMA 등이 CPU 코드와 무관하게 바꾼다
volatile 필요성습관을 들이는 차원에서 필요하다반드시 필요하다 — 없으면 최적화 빌드에서 오동작한다
volatile이 없으면 컴파일러가 값을 캐시해 무한 루프에 빠질 수 있고, volatile이 있으면 매번 메모리를 다시 읽어 상태 변화를 감지한다

연습 문제

  1. hal_regs_t의 CTRL 레지스터에서 ENABLE 비트는 그대로 둔 채 START 비트만 끄는 코드 한 줄을 작성하라.
  2. hal_regs_t에 새 레지스터 THRESHOLD(4바이트, 수분 임계값 저장용)를 추가하려고 한다. 구조체에 필드를 추가할 때 폭과 순서에 대해 어떤 점을 확인해야 하는지 서술하라.
  3. hal_regs_t의 세 필드에서 volatile을 모두 지우고 -O2로 컴파일했다고 가정하자. 이 장의 main.c에 있는 while 루프가 어떤 문제를 일으킬 수 있는지 설명하라.
  4. STATUS 레지스터의 ERROR 비트(HAL_STATUS_ERROR)가 켜져 있는지 확인하고, 켜져 있다면 CTRL의 ENABLE 비트를 끄는 코드를 작성하라.

정답과 해설

  1. regs->CTRL &= ~(uint32_t)HAL_CTRL_START;
    대입이 아니라 AND-NOT 연산을 써야 ENABLE 비트가 그대로 남는다.
  2. 새 필드도 다른 필드와 같은 폭(uint32_t)으로 선언하고 volatile을 붙여야 한다. 필드 순서는 실제 하드웨어에서 그 레지스터가 놓인 주소 순서와 같아야 하며, 폭이 다른 타입을 섞으면 컴파일러가 끼워 넣는 패딩 때문에 구조체의 오프셋이 하드웨어 배치와 어긋날 수 있다.
  3. volatile이 없으면 컴파일러는 STATUS 값이 루프 안에서 바뀌지 않는다고 가정해 첫 번째 읽은 값을 CPU 레지스터에 캐시해 두고 재사용할 수 있다. 이 경우 hal_sim_step이 실제로 STATUS를 바꾸더라도 루프 조건은 처음 읽은 값 그대로 평가되어 조건이 참으로 남을 수 있고, 그러면 루프를 빠져나오지 못한다.
  4. if (regs->STATUS & HAL_STATUS_ERROR) {
        regs->CTRL &= ~(uint32_t)HAL_CTRL_ENABLE;
    }
    비트 확인에는 &만 쓰고, 비트를 끌 때는 대입이 아니라 AND-NOT을 사용해 다른 비트를 건드리지 않는다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.