Devin.KR

마이크로컨트롤러란 - CPU·메모리·주변장치

개발자KR 조회 7

이 장에서 배우는 것

이 책은 스마트 화분 펌웨어를 예제로 삼아 온습도 센서를 읽고 펌프를 돌리고 LED를 켜는 장치를 장마다 조금씩 완성해 간다. 그 코드를 쓰기 전에 먼저 짚어야 할 것은 코드가 올라가는 무대, 곧 마이크로컨트롤러(microcontroller, MCU) 그 자체다. PC에서 짜던 C 프로그램과 MCU에서 도는 펌웨어는 같은 언어를 쓰지만 실행되는 방식과 주어진 자원이 다르다. 이 장에서는 그 차이를 정리하고, 다음 장부터 레지스터를 직접 건드리기 전에 필요한 메모리 그림을 미리 그려 둔다.

  • MCU가 PC와 무엇이 다른지, CPU·메모리·주변장치가 어떻게 한 칩에 묶여 있는지 설명할 수 있다
  • 플래시와 RAM의 역할 차이와 휘발성 여부를 구분할 수 있다
  • 메모리 맵이라는 개념으로 코드·데이터·레지스터가 하나의 주소 공간 위에 놓인다는 것을 이해한다
  • 크로스 컴파일과 플래싱이라는 개발 흐름을 알고, 이 책이 hal_sim이라는 PC 시뮬레이션 계층을 쓰는 이유를 이해한다

문제 상황

김 주임은 지금까지 리눅스 서버용 C 프로그램만 작성해 왔다. 이번에 맡은 일은 스마트 화분 펌웨어 개발이다. 팀 문서를 읽다 보니 낯선 문장이 여럿 나온다. "이 칩은 플래시 256KB, RAM 32KB다", "펌웨어는 크로스 컴파일러로 빌드해서 플래싱한다", "main이 return하면 안 된다". 지금까지 쓰던 gcc main.c -o app 한 줄, 그리고 int main(void) { ... return 0; } 습관이 왜 여기서는 통하지 않는지 감이 잡히지 않는다. RAM이 32KB밖에 없다는 말도 이상하다. 방금 쓰던 서버는 RAM이 수십 GB 단위였다.

이 장은 이런 질문에 답하기 위한 장이다. 실제 보드가 없어도 개념을 손에 쥘 수 있도록, 이 책은 레지스터·핀·시간을 흉내 내는 hal_sim이라는 PC 시뮬레이션 계층을 두고 모든 예제를 그 위에서 컴파일하고 실행한다.

MCU와 PC, 실행 방식이 다르다

PC 프로그램은 운영체제 위에서 돈다. 여러 프로세스가 동시에 떠 있고, 메모리는 가상 주소로 관리되며, 프로그램이 끝나면 운영체제가 자원을 회수한다. MCU 펌웨어는 이런 뒷받침이 없다. 전원이 들어오면 정해진 주소(리셋 벡터)부터 명령을 실행하기 시작하고, 보통 그 프로그램 하나만 영원히 돈다. 운영체제가 없으니 "프로그램 종료"라는 개념도 없다. 펌웨어의 main이 return하면 그 다음에 실행될 코드가 정의되어 있지 않다.

자원의 크기도 다르다. 서버는 메모리를 기가바이트 단위로 쓰지만, 화분에 들어갈 저가형 MCU는 RAM이 수십 킬로바이트에 그치는 경우가 흔하다. 이 제약은 나중에 배열 하나를 잘못 선언하는 순간 바로 링크 오류로 돌아온다.

PC 프로그램과 MCU 펌웨어의 실행 방식 비교
구분PC 프로그램MCU 펌웨어
실행 환경운영체제 위의 프로세스운영체제 없이 하드웨어 위에서 직접 실행
시작 지점로더가 main을 호출전원 인가 시 리셋 벡터부터 실행
종료main이 return하면 프로세스 종료보통 종료 없이 무한 루프
메모리 규모기가바이트 단위, 가상 메모리수십 킬로바이트~메가바이트, 고정 물리 주소
CPU·메모리·주변장치가 한 칩 안에서 버스로 묶여 있다

MCU는 CPU, 플래시, RAM, 그리고 GPIO·타이머 같은 주변장치가 한 칩 안에 함께 들어 있고, 이들은 칩 내부의 버스로 연결되어 서로 값을 주고받는다. PC에서는 CPU·메모리·디스크·네트워크 카드가 각각 다른 칩으로 나뉘어 있지만, MCU에서는 이 모든 것이 하나의 칩 안에 압축되어 있다.

플래시와 RAM, 코드와 데이터가 사는 자리

MCU 안에는 성격이 다른 두 종류의 메모리가 있다. 플래시(flash)는 전원이 꺼져도 내용이 남는 비휘발성 메모리로, 실행할 코드와 바뀌지 않는 상수(문자열 리터럴, const 데이터)가 여기 저장된다. RAM은 전원이 꺼지면 내용이 사라지는 휘발성 메모리로, 프로그램이 실행되는 동안 값이 바뀌는 전역 변수와 지역 변수(스택)가 여기서 산다. 펌웨어를 "플래싱한다"는 말은 컴파일된 바이너리를 이 플래시 영역에 써 넣는 작업을 뜻한다.

두 메모리는 용량 차이도 크다. 플래시는 보통 수십~수백 킬로바이트, RAM은 그보다 훨씬 작은 수 킬로바이트~수십 킬로바이트인 경우가 많다. 그래서 전역 배열 하나를 크게 잡으면 RAM이 먼저 바닥난다. 읽기 전용 데이터를 const로 선언해 플래시 쪽에 남겨 두는 습관이 중요한 이유가 여기 있다.

플래시와 RAM의 성격 비교
구분플래시RAM
휘발성비휘발성(전원 꺼져도 유지)휘발성(전원 꺼지면 사라짐)
주로 담는 것실행 코드, const 상수, 문자열 리터럴전역·정적 변수, 스택, 힙
쓰기 방식플래싱 도구로 통째로 기록실행 중 CPU가 자유롭게 읽고 씀
전형적 용량수십KB~수MB수KB~수백KB

메모리 맵과 개발 흐름

주소 하나로 보는 세상

MCU에서는 플래시, RAM, 그리고 GPIO·타이머 같은 주변장치의 레지스터가 모두 하나의 주소 공간 위에 나란히 배치된다. 이것을 메모리 맵(memory map)이라 부른다. 예를 들어 0x08000000번지부터는 플래시, 0x20000000번지부터는 RAM, 0x40000000번지부터는 주변장치 레지스터가 놓이는 식이다(정확한 번지는 칩마다 다르다). 중요한 것은, 주변장치의 상태를 읽거나 바꾸는 일이 결국 "특정 주소의 값을 읽거나 쓰는 일"과 같다는 점이다. 이 개념은 레지스터를 직접 포인터로 다루는 다음 장의 바탕이 된다. 이 장에서는 그 개념만 손에 쥐고, 아래 완성 코드에서는 hal_sim이 흉내 낸 "주소로 값을 읽는" 함수를 통해 가볍게 맛만 본다.

플래시·RAM·주변장치 레지스터가 하나의 주소 공간에 순서대로 놓이고 그 사이엔 빈 영역이 있다

크로스 컴파일과 플래싱

PC 프로그램은 짜고 있는 그 컴퓨터에서 바로 실행할 목적으로 컴파일한다. MCU 펌웨어는 다르다. 개발자는 x86이나 arm64 노트북에서 코드를 짜지만, 실행될 곳은 전혀 다른 명령어 집합을 쓰는 작은 칩이다. 그래서 호스트에서 도는 컴파일러가 아니라, 타겟 칩용 바이너리를 만드는 크로스 컴파일러(cross compiler)를 쓴다. 이렇게 만든 바이너리를 보드에 옮겨 플래시에 기록하는 과정을 플래싱(flashing)이라 부르며, 보통 디버거나 전용 프로그래머 장비를 거친다.

이 책은 실제 보드 없이도 개념과 코드 흐름을 익힐 수 있도록, 레지스터·핀·시간의 동작을 흉내 내는 hal_sim.h/hal_sim.c라는 PC 시뮬레이션 계층을 둔다. 모든 완성 코드는 호스트의 cc로 그대로 컴파일·실행된다. 실제 보드로 넘어갈 때는 hal_sim의 함수 호출 부분을 그 보드의 HAL(하드웨어 추상화 계층)이나 레지스터 접근 코드로 바꾸는 정도의 작업이 남는다.

이 책의 hal_sim과 실제 보드의 개발 흐름 비교
단계hal_sim(이 책)STM32 계열ESP32·아두이노
컴파일러호스트 cc(clang/gcc), -std=c11arm-none-eabi-gcc 같은 크로스 컴파일러xtensa-esp32-elf-gcc를 아두이노 IDE가 내부에서 호출
실행 위치터미널에서 바로 도는 PC 프로세스보드에 플래싱 후 리셋 벡터부터 실행보드에 업로드 후 전원 인가 시 바로 실행
수정 후 확인재컴파일 후 그 자리에서 재실행재컴파일 → 디버거/프로그래머로 재플래싱재컴파일 → 시리얼 부트로더로 재업로드
디버깅 도구printf, lldb/gdb 같은 일반 디버거SWD 디버거, 오실로스코프시리얼 모니터, 일부 보드는 JTAG

완성 코드

가상 MCU가 부팅하면서 자신의 이름·클럭·메모리 맵을 알리고, 리셋 원인 레지스터를 주소로 읽어 오고, 몇 차례 틱을 도는 아주 작은 펌웨어다. 세 파일을 같은 디렉터리에 두고 컴파일한다.

hal_sim.h

#ifndef HAL_SIM_H
#define HAL_SIM_H

#include <stdint.h>

/* 메모리 맵: 실제 MCU 데이터시트의 주소 배치를 흉내낸 상수다 */
#define HAL_SIM_FLASH_BASE 0x08000000u
#define HAL_SIM_FLASH_SIZE (256u * 1024u)
#define HAL_SIM_RAM_BASE   0x20000000u
#define HAL_SIM_RAM_SIZE   (32u * 1024u)
#define HAL_SIM_PERIPH_RESET_CAUSE 0x40000000u

typedef struct {
    const char *chip_name;
    uint32_t clock_hz;
} hal_sim_mcu_t;

void hal_sim_boot(const hal_sim_mcu_t *mcu);
uint32_t hal_sim_read32(uint32_t address);
void hal_sim_tick(unsigned cycle);

#endif

hal_sim.c

#include "hal_sim.h"
#include <stdio.h>

#define RESET_CAUSE_POWER_ON 0x00000001u

void hal_sim_boot(const hal_sim_mcu_t *mcu)
{
    printf("[BOOT] chip=%s clock=%u Hz\n", mcu->chip_name, (unsigned)mcu->clock_hz);
    printf("[MAP ] flash 0x%08X..0x%08X (%u KB)\n",
           (unsigned)HAL_SIM_FLASH_BASE,
           (unsigned)(HAL_SIM_FLASH_BASE + HAL_SIM_FLASH_SIZE - 1u),
           (unsigned)(HAL_SIM_FLASH_SIZE / 1024u));
    printf("[MAP ] ram   0x%08X..0x%08X (%u KB)\n",
           (unsigned)HAL_SIM_RAM_BASE,
           (unsigned)(HAL_SIM_RAM_BASE + HAL_SIM_RAM_SIZE - 1u),
           (unsigned)(HAL_SIM_RAM_SIZE / 1024u));
}

uint32_t hal_sim_read32(uint32_t address)
{
    if (address == HAL_SIM_PERIPH_RESET_CAUSE) {
        return RESET_CAUSE_POWER_ON;
    }
    return 0u;
}

void hal_sim_tick(unsigned cycle)
{
    printf("[TICK] %u\n", cycle);
}

main.c

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

static const hal_sim_mcu_t g_mcu = {
    .chip_name = "SIM-M0",
    .clock_hz = 48000000u,
};

static uint32_t g_poll_count;

int main(void)
{
    hal_sim_boot(&g_mcu);

    uint32_t reset_cause = hal_sim_read32(HAL_SIM_PERIPH_RESET_CAUSE);
    printf("[APP ] reset_cause register = 0x%08X\n", (unsigned)reset_cause);

    for (unsigned cycle = 1; cycle <= 3; ++cycle) {
        g_poll_count++;
        hal_sim_tick(cycle);
    }

    printf("[APP ] g_poll_count = %u\n", (unsigned)g_poll_count);
    printf("[APP ] boot sequence done\n");
    return 0;
}

줄별 해설

hal_sim.h

  • HAL_SIM_FLASH_BASE/SIZE, HAL_SIM_RAM_BASE/SIZE는 이 가상 칩의 메모리 맵을 상수로 못박은 것이다. 실제 칩에서는 이 값이 데이터시트에 나와 있다.
  • HAL_SIM_PERIPH_RESET_CAUSE는 "리셋 원인을 담고 있는 레지스터의 주소"를 흉내 낸 값이다. 값 자체가 아니라 값을 찾아갈 주소라는 점이 핵심이다.
  • hal_sim_mcu_t는 칩 이름과 클럭 속도만 담는 아주 작은 구조체로, main.c에서 어떤 가상 칩을 부팅할지 정하는 데 쓰인다.

hal_sim.c

  • hal_sim_boot는 부팅 시 칩 정보와 메모리 맵을 콘솔에 찍는다. 실제 보드였다면 UART로 나갈 로그를, 여기서는 printf로 대신한다.
  • hal_sim_read32는 "주소를 주면 값을 돌려준다"는 메모리 맵 접근의 형태만 흉내 낸 함수다. 실제로는 그 주소를 volatile 포인터로 역참조하는데, 그 방식은 다음 장에서 다룬다.
  • hal_sim_tick은 시간이 흐르는 것을 흉내 내는 최소한의 장치로, 이후 장에서 타이머 개념이 나오기 전까지는 이런 식의 자리표시자로만 쓴다.

main.c

  • g_mcu는 static const로 선언되어 있다. 값이 바뀌지 않는 데이터이므로 실제 칩에서는 플래시 쪽 읽기 전용 영역에 놓인다.
  • g_poll_count는 static이지만 const가 아니다. 실행 중 값이 바뀌므로 RAM에 놓이는 대표적인 전역 변수다. 두 변수의 대비가 앞서 본 플래시/RAM의 차이를 그대로 보여준다.
  • main이 return 0으로 끝나는 것은 이 프로그램이 PC 시뮬레이션이기 때문이다. 실제 보드용 펌웨어라면 이 자리는 보통 무한 루프여야 한다. 이 차이는 "실무에서 자주 틀리는 것"에서 다시 짚는다.

실행 결과

cc -std=c11 -Wall -Wextra -o smartpot hal_sim.c main.c
./smartpot
[BOOT] chip=SIM-M0 clock=48000000 Hz
[MAP ] flash 0x08000000..0x0803FFFF (256 KB)
[MAP ] ram   0x20000000..0x20007FFF (32 KB)
[APP ] reset_cause register = 0x00000001
[TICK] 1
[TICK] 2
[TICK] 3
[APP ] g_poll_count = 3
[APP ] boot sequence done

실무에서 자주 틀리는 것

RAM 용량을 무시한 전역 배열

RAM이 32KB인 칩에서 아래처럼 큰 버퍼를 전역으로 잡으면 링커가 RAM 초과로 빌드를 거부한다.

/* RAM이 32KB인 칩에서 */
static char log_buffer[65536];  /* 64KB, RAM보다 크다 */
static char log_buffer[4096];   /* RAM 여유를 보고 정한 크기 */

main이 PC 프로그램처럼 return하는 펌웨어

PC에서 하던 습관대로 초기화만 하고 return하면, 실제 보드에서는 그 다음 무엇이 실행될지 정의되어 있지 않다.

int main(void)
{
    system_init();
    return 0;   /* PC 프로그램처럼 종료 */
}
int main(void)
{
    system_init();
    for (;;) {
        run_once();
    }
}

호스트 컴파일러로 만든 바이너리를 보드에 그대로 올리기

호스트용 cc는 노트북의 CPU 명령어 집합으로 바이너리를 만든다. 그것을 보드에 그대로 복사하면 애초에 그 칩이 이해할 수 없는 명령어가 실행된다.

cc main.c -o firmware.bin
# 이 firmware.bin을 그대로 보드에 복사
arm-none-eabi-gcc -mcpu=cortex-m0 -mthumb -o firmware.elf main.c
# 이후 플래싱 도구로 firmware.elf를 보드에 기록

문자열 리터럴을 RAM 변수처럼 수정하기

문자열 리터럴은 읽기 전용 영역(플래시류)에 놓인다. 포인터로만 가리키고 그 내용을 고치려 하면 실제 보드에서는 하드폴트로 이어질 수 있다.

char *msg = "ready";
msg[0] = 'R';   /* 읽기전용 영역을 수정 시도 */
char msg[] = "ready";  /* 배열로 선언해 RAM에 복사본을 둔다 */
msg[0] = 'R';

한눈에 보기

이 장에서 나온 용어와 의미
개념의미
플래시비휘발성 메모리. 코드와 const 상수가 저장된다
RAM휘발성 메모리. 실행 중 바뀌는 변수와 스택이 저장된다
메모리 맵플래시·RAM·주변장치 레지스터가 하나의 주소 공간에 배치된 구조
레지스터메모리 맵 위의 특정 주소에 놓인, 주변장치를 제어·확인하는 값
크로스 컴파일호스트가 아닌 다른 아키텍처용 바이너리를 만드는 컴파일
플래싱컴파일된 바이너리를 보드의 플래시에 기록하는 작업
hal_sim이 책이 쓰는 PC용 레지스터/핀/시간 시뮬레이션 계층

연습 문제

  1. MCU 펌웨어의 main 함수가 보통 return하지 않는 이유를 한 문단으로 설명하라.
  2. 완성 코드가 출력하는 플래시 범위와 RAM 범위를 보고, 전원이 꺼지면 내용이 사라지는 쪽은 어느 영역인지, 그리고 g_mcu와 g_poll_count 중 어느 변수가 그 영역에 해당하는지 설명하라.
  3. 이 책이 실제 보드 대신 hal_sim.h/.c를 쓰는 이유를 크로스 컴파일·플래싱 흐름과 연결해 설명하라.
  4. hal_sim_read32가 주소를 받아 값을 돌려주는 지금의 방식은, 다음 장에서 배울 "레지스터를 포인터로 직접 읽고 쓰는 방식"과 어떻게 다를지 예상해서 서술하라.

정답과 해설

  1. PC의 main은 운영체제에게 종료 코드를 돌려주고 프로세스가 끝나는 것을 전제로 한다. MCU에는 그 프로그램을 이어받을 운영체제가 없으므로 return 이후 실행될 코드가 정해져 있지 않다. 그래서 펌웨어는 보통 초기화를 마친 뒤 무한 루프로 들어가 전원이 꺼질 때까지 돈다.
  2. RAM이 휘발성 영역이다. g_poll_count는 static이지만 const가 아니어서 실행 중 값이 바뀌므로 RAM에 놓인다. 반대로 g_mcu는 static const로 선언되어 값이 바뀌지 않으므로 실제 칩에서는 플래시 쪽 읽기 전용 영역에 놓인다.
  3. 실제 보드를 쓰려면 매번 크로스 컴파일러로 빌드하고 디버거나 프로그래머로 플래싱한 뒤에야 결과를 볼 수 있어 반복 주기가 길다. hal_sim은 레지스터·핀·시간의 동작만 호스트 코드로 흉내 내므로, 보드 없이 호스트의 cc로 바로 컴파일하고 그 자리에서 실행해 개념과 코드 흐름을 빠르게 반복해 볼 수 있다.
  4. 이 장의 hal_sim_read32는 주소를 정수 인자로 받아 내부에서 비교한 뒤 값을 돌려주는 평범한 함수 호출이다. 다음 장에서 배울 방식은 그 주소를 실제로 volatile 포인터로 캐스팅해 역참조함으로써, 함수 호출 없이 그 메모리 위치를 직접 읽고 쓴다. 두 방식 모두 "주소로 값에 접근한다"는 개념은 같지만, 실제 레지스터 접근은 컴파일러가 그 읽기/쓰기를 최적화로 없애 버리지 않도록 volatile을 반드시 함께 써야 한다는 차이가 있다.

댓글 0

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

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