Embedded Firmware · 임베디드 펌웨어
삼상 인버터를 움직이는 펌웨어 — ARM Cortex-M MCU 8대 핵심 모듈
전동 킥보드를 밀어 보면 손바닥에 묵직한 가속이 전해진다. 그 힘을 만드는 모터는 사실 1초에 수천 번씩 켜졌다 꺼지는 전자 스위치들의 합주이고, 그 합주를 지휘하는 것은 손톱만 한 칩 하나다. 이 칩이 마이크로컨트롤러(MCU, Microcontroller Unit)다.
이 글은 32비트 ARM Cortex-M 계열 MCU로 삼상 인버터를 제어하기 위해 반드시 거쳐야 하는 펌웨어의 8가지 핵심 모듈을 한 편으로 정리한 것이다. 전공자가 아니어도 따라올 수 있도록 비유와 도해를 곁들였다.
다루는 내용
1MCU의 해부도 — 코어·버스·주변장치
하나의 칩 안에 무엇이 들어 있는가
MCU는 단순한 연산기가 아니라 작은 컴퓨터 한 대를 통째로 칩에 욱여넣은 부품이다. 그 안에는 명령을 처리하는 CPU 코어, 외부 세계와 신호를 주고받는 주변장치(peripheral), 그리고 둘을 잇는 통로인 버스(bus)가 함께 들어 있다.
여기서 혼동하기 쉬운 점이 있다. ARM은 코어 설계도만 만드는 회사다. 칩 제조사들은 이 ARM 코어를 사 와서 그 둘레에 GPIO·타이머·ADC·통신 모듈 같은 주변장치를 붙여 완성된 MCU로 판다. 그래서 제조사가 달라도 코어가 같으면(예: Cortex-M7) 코어 부분의 동작은 동일하고, 주변장치 구성만 제각각이다.
ARM 코어는 용도에 따라 세 갈래로 나뉜다. Cortex-A는 스마트폰·태블릿처럼 고성능이 필요한 곳에, Cortex-R은 의료·항공·자동차처럼 실시간성이 중요한 곳에, Cortex-M은 저전력 임베디드 제어에 쓰인다. 모터·인버터 제어에 흔히 쓰는 것이 바로 이 M 계열이며, 그중 상급인 M7은 최대 216 MHz로 동작하면서도 전력 소비가 적다.
코어는 책상에 앉아 일하는 직원, 주변장치들은 회계·영업·총무 같은 각 부서, 버스는 부서를 잇는 복도다. 그리고 벽시계의 똑딱임이 클럭이다. 모두가 같은 시계의 박자에 맞춰 움직이기에, 시계가 빠를수록 사무실 전체가 더 빨리 돌아간다.
다만 모든 부서가 같은 속도로 일하지는 않는다. 버스에는 위계가 있다. 코어에 가장 가까운 고속 버스(AHB, Advanced High-performance Bus)는 216 MHz까지 달리고, 여기서 브릿지(bridge)를 거쳐 저속 버스(APB, Advanced Peripheral Bus)인 APB2(최대 108 MHz)와 APB1(최대 54 MHz)로 갈라진다. 어떤 주변장치가 어느 버스에 매달려 있는지에 따라 동작 클럭이 달라지므로, 모듈을 설정할 때 이 연결 관계를 알고 있어야 한다.
2레지스터로 말하는 법 — 메모리 맵
MCU를 제어한다는 것의 진짜 의미
MCU에게 "이 핀을 켜라", "이 속도로 통신하라"고 지시하는 일은 결국 특정 주소에 0과 1을 쓰는 것으로 귀결된다. 그 주소에 놓인 작은 기억 장소를 레지스터(register)라 부른다. 칩 안의 모든 자원 — 메모리, GPIO, 타이머, ADC — 은 저마다 고유한 주소를 부여받고 한 장의 지도 위에 배치되는데, 이 지도가 메모리 맵(memory map)이다.
레지스터는 역할에 따라 셋으로 나누어 생각하면 편하다. 통신 속도·동작 모드·인터럽트 사용 여부처럼 무엇을 어떻게 할지 정하는 컨트롤 레지스터, 변환이 끝났는지·인터럽트가 발생했는지처럼 상태를 읽는 스테이터스 레지스터, 그리고 변환된 값이나 받은 데이터가 담기는 데이터 레지스터다.
대부분의 32비트 MCU에서 레지스터는 32비트 폭이며, 각 비트마다 제조사가 의미를 부여해 두었다. 따라서 원하는 기능만 켜려면 다른 비트는 건드리지 않고 특정 비트만 조작하는 비트 연산이 필수다.
// 비트 켜기 — OR 연산 (다른 비트는 건드리지 않음)
REG |= (1 << 3); // 3번 비트만 1로
// 비트 끄기 — AND + NOT
REG &= ~(1 << 3); // 3번 비트만 0으로
// 2비트 묶음 설정 — 먼저 지우고(클리어) 다시 쓰기
REG &= ~(0b11 << 6); // 7,6번 비트 클리어
REG |= (0b01 << 6); // 출력 모드(01)로 세팅
32비트 레지스터는 32개의 토글 스위치가 한 줄로 늘어선 제어판이다. 각 스위치는 "출력으로 쓸까", "풀업을 켤까" 같은 개별 기능을 담당한다. 전체 보드를 한꺼번에 갈아엎으면 멀쩡히 켜둔 다른 스위치까지 꺼지니, 원하는 스위치 하나만 골라 올리고 내리는 것이 비트 연산이다.
주소를 매번 직접 적는 방식은 정확하지만 지저분하고 실수하기 쉽다. 그래서 실무에서는 같은 구역의 레지스터들을 구조체(struct)로 묶고, 더 나아가 제조사가 제공하는 표준 헤더(CMSIS, Cortex Microcontroller Software Interface Standard)에 정의된 이름을 가져다 쓴다. 추상화 단계가 올라갈수록 코드가 읽기 쉬워진다.
// ① 주소를 직접 다루기 (가장 날것)
#define GPIOD_MODER (*(volatile unsigned int *)(0x40020C00))
GPIOD_MODER |= (1 << 6);
// ② 구조체로 묶기 (레지스터가 4바이트 간격으로 나열됨)
typedef struct {
volatile unsigned int MODER; // +0x00
volatile unsigned int OTYPER; // +0x04
volatile unsigned int OSPEEDR; // +0x08
// ...
} GPIO_t;
#define GPIOD ((GPIO_t *)0x40020C00)
GPIOD->MODER |= (1 << 6);
요점은 어느 방식을 쓰든 본질은 같다는 것이다. 주소를 찾아가 비트를 세운다. 이 한 문장이 임베디드 제어의 알파이자 오메가다.
3GPIO — 디지털 입출력의 기본
불을 켜는 일에서 시작한다
임베디드 학습의 첫걸음은 거의 예외 없이 LED 켜기다. 프로그래밍의 "Hello, World"처럼, 핀 하나를 켜고 끄며 칩과 처음 악수를 나누는 의식이다. 이때 쓰이는 것이 GPIO(General-Purpose Input/Output, 범용 입출력) 핀이다.
출력으로 설정한 핀은 내부적으로 푸시풀(push-pull) 구조를 갖는다. 위쪽 스위치가 켜지면 핀이 전원 전압(3.3 V)으로, 아래쪽 스위치가 켜지면 0 V로 끌린다. 출력 데이터 레지스터(ODR, Output Data Register)에 1을 쓰면 하이, 0을 쓰면 로우가 나간다.
여기서 직관과 어긋나는 대목이 하나 있다. LED의 음극(캐소드)이 핀에 연결된 회로에서는, 핀을 0V로 만들어야 전위차가 생겨 불이 켜진다. 핀을 3.3 V로 두면 전원과 같아져 전류가 흐르지 못해 꺼진다.
// 1) 포트 클럭 켜기 (주변장치는 클럭부터)
RCC_AHB1ENR |= (1 << 3); // GPIOD 클럭 enable
// 2) 핀을 출력 모드로
GPIOD_MODER &= ~(0b11 << 6);
GPIOD_MODER |= (0b01 << 6); // PD3 = 출력
// 3) 핀을 0V로 → LED 점등 (캐소드가 핀에 연결된 회로)
GPIOD_ODR &= ~(1 << 3);
반대로 핀을 입력으로 쓰면 외부 신호를 읽어들인다. 이때 핀은 풀업/풀다운 저항으로 평상시 전압을 고정하고, 슈미트 트리거(Schmitt trigger)로 노이즈에 둔감해지며, 보호 다이오드로 과전압에서 살아남는다. 입력 핀의 최대 허용 전압은 보통 3.3 V이지만, 일부 핀은 5 V까지 견디도록 설계(데이터시트에 FT, Five-volt Tolerant로 표기)되어 있어 5 V 센서를 직접 받을 수 있다.
스위치가 눌리지 않은 평소에 핀이 어떤 값일지 정해 두지 않으면, 핀은 바람에 흔들리는 문처럼 0과 1 사이를 떠돈다(플로팅). 풀업저항은 평소 문을 닫아두는 스프링이다. 누군가 손잡이를 당겨(스위치를 눌러) 0V로 끌어내리기 전까지는 안정적으로 3.3 V를 유지한다.
한 가지 더. 출력 핀의 전환 속도(슬루 레이트)도 조절할 수 있는데, 빠를수록 좋은 것은 아니다. 전환이 빠르다는 것은 곧 고주파 성분이 많다는 뜻이고, 이는 EMI(Electromagnetic Interference, 전자기 간섭)를 키운다. 고장 신호처럼 급한 것은 빠르게, 그렇지 않은 것은 적당한 속도로 — 요구 사항에 딱 맞게 설정하는 절제가 좋은 설계다.
4클럭 — MCU의 심장 박동
모든 동작은 박자 위에서 일어난다
MCU 내부의 거의 모든 일은 제멋대로가 아니라 클럭(clock)의 박자에 맞춰 일어난다. 클럭 신호의 상승/하강 모서리(엣지)마다 명령어가 한 단계씩 처리되고 데이터가 한 칸씩 이동한다. 따라서 클럭 주파수가 높을수록 더 빠르게 일한다.
클럭의 원천은 네 가지다. 외부 크리스탈로 만드는 HSE(High-Speed External), 칩 내부 RC 발진기로 만드는 HSI(High-Speed Internal, 16 MHz 고정), 그리고 저속용인 LSE·LSI(Low-Speed External/Internal, 실시간 시계나 워치독용)이다. HSI는 외부 부품이 필요 없어 저렴하지만 정밀도가 떨어지고, HSE는 부품값이 들지만 정확하다.
문제는 16 MHz로는 모터 제어에 한참 모자란다는 것이다. 그래서 PLL(Phase-Locked Loop, 위상 고정 루프)이라는 주파수 체배 회로로 입력 클럭을 끌어올린다.
PLL 내부의 발진기(VCO, Voltage-Controlled Oscillator)는 입력 주파수가 1~2 MHz일 때 가장 안정적이며, 지터(jitter)를 줄이려면 2 MHz가 권장된다. 그래서 16 MHz를 먼저 8로 나눠 2 MHz로 만든 뒤, 216을 곱해 432 MHz(VCO 출력은 192~432 MHz 범위)를 얻고, 다시 2로 나눠 최종 216 MHz를 만든다.
천천히 밟는 페달(저속 입력)을 기어비로 바꿔 바퀴를 빠르게 돌리는 것이 PLL이다. 반대로 프리스케일러(prescaler)는 빠른 클럭을 적당히 느리게 나눠 주는 변속기다. 굳이 모든 부서가 최고 속도로 달릴 필요는 없다 — 빠르면 전력만 더 먹기 때문이다.
클럭 설정에는 정해진 순서가 있다. 전원이 켜진 직후 MCU는 일단 HSI(16 MHz)로 깨어나고, 그 상태에서 외부 클럭과 PLL을 준비한 다음, 안정화가 확인되면 비로소 시스템 클럭을 PLL 출력으로 갈아탄다. 게다가 216 MHz는 그냥 도달되지 않는다. 기본 한계인 180 MHz를 넘으려면 내부 전압 레귤레이터의 출력을 한 단 높이는 오버드라이브(overdrive)를 켜야 한다.
216 MHz에서 한 사이클은 약 4.6 ns다. 그런데 프로그램이 저장된 플래시 메모리는 한 번 읽는 데 약 35 ns가 걸린다. 그래서 코어가 플래시를 기다려 주도록 웨이트 스테이트(wait state)를 7로 설정하고, 자주 쓰는 명령·데이터는 빠른 캐시(cache)에 담아 속도 차이를 메운다.
// (개념 흐름) 16MHz HSE → PLL → 216MHz
// 1) HSE/HSI 켜고 안정화 대기
// 2) 안정화 전까지는 HSI(16MHz)를 시스템 클럭으로 임시 사용
// 3) PLL 분주비 설정: 16MHz ÷8 = 2MHz → ×216 = 432MHz → ÷2 = 216MHz
// 4) 플래시 wait state = 7, 캐시 enable, 오버드라이브 on
// 5) PLL을 시스템 클럭으로 전환, APB1/APB2 프리스케일러로 54MHz 분배
5인터럽트 — 사건이 생기면 달려간다
기다리지 말고, 불릴 때 응답하라
외부 사건을 처리하는 방식에는 두 가지가 있다. 하나는 폴링(polling) — 사건이 올 때까지 다른 일은 제쳐 두고 계속 들여다보는 것. 다른 하나는 인터럽트(interrupt) — 평소엔 자기 일을 하다가, 사건이 발생한 그 순간에만 하던 일을 잠시 멈추고 처리하는 것이다.
폴링은 치킨이 올 때까지 현관문 앞에 붙어 서서 다른 아무 일도 못 하는 사람이다. 인터럽트는 거실에서 할 일을 하다가, 초인종이 울리면 그제야 문으로 가는 사람이다. 사건이 자주·즉각 온다면 폴링이 단순해서 낫고, 그렇지 않다면 인터럽트가 자원을 아낀다.
인터럽트를 총괄하는 것은 NVIC(Nested Vectored Interrupt Controller, 중첩 벡터 인터럽트 컨트롤러)로, Cortex-M 코어가 공통으로 갖춘 부품이다. 인터럽트가 발생하면 미리 정해진 벡터 테이블에서 해당 사건의 처리 함수, 즉 ISR(Interrupt Service Routine)의 주소를 찾아 그곳으로 점프한다. 여러 인터럽트가 동시에 몰리면 정해 둔 우선순위에 따라 차례를 정한다.
GPIO 핀으로 들어오는 외부 신호는 EXTI(External Interrupt/Event Controller)가 받는다. EXTI는 신호의 상승/하강 엣지를 검출해 NVIC로 넘기고, NVIC가 사용자 함수를 불러낸다.
// EXTI4 인터럽트 서비스 루틴 (사용자가 내용을 채움)
void EXTI4_IRQHandler(void) {
if (EXTI_PR & (1 << 4)) { // 정말 4번 라인에서 왔는가?
EXTI_PR = (1 << 4); // 1을 써서 플래그 클리어
GPIOD_ODR ^= (1 << 3); // LED 토글
// ※ 여기에 긴 delay를 넣지 말 것!
}
}
인터럽트 처리 함수 안에서 1초씩 멈춰 버리면, 그 사이 더 급한 다른 인터럽트가 와도 처리하지 못해 시스템 전체가 꼬인다. ISR은 짧고 빠르게 끝내고, 무거운 작업은 본문으로 넘기는 것이 원칙이다.
이 모든 것이 모터 제어에서 결정적이다. 회전자의 위치를 알려주는 홀센서(Hall sensor) 신호를 인터럽트로 받아, 그 순간 삼상 인버터의 전류 위상을 정확히 전환하는 것이 핵심 동작이기 때문이다.
6ADC — 아날로그를 숫자로
전압·전류·온도를 디지털 세계로 옮기는 다리
전압, 전류, 온도, 빛 같은 물리량은 끊김 없이 이어지는 아날로그(연속) 신호다. 그러나 디지털 칩은 숫자만 이해한다. 이 둘을 잇는 다리가 ADC(Analog-to-Digital Converter, 아날로그-디지털 변환기)다. 실제로 대부분의 센서는 물리량을 먼저 전압으로 바꾸고, 그 전압을 ADC로 읽는다.
주의할 한 가지는 입력 전압이 기준 전압(여기서는 3.3 V)을 넘으면 안 된다는 점이다. 예컨대 전기차의 800 V 배터리 전압을 그대로 물리면 칩이 즉시 망가진다. 그래서 저항 분압으로 800 V를 3.3 V 이내로 낮춰 입력한다.
변환은 세 단계를 거친다. 일정 간격으로 표본을 뜨는 샘플링, 그 표본을 가장 가까운 단계로 반올림하는 양자화, 그 단계에 숫자를 매기는 부호화다.
세로축을 얼마나 잘게 쪼개느냐가 분해능(resolution)이다. 12비트 ADC는 0~4095(2¹²−1)의 4,096단계로 나눈다. 기준 3.3 V를 4,096으로 나누면 한 단계가 약 0.81 mV다. 비트가 클수록 더 촘촘해진다 — 8비트면 한 단계가 약 12.9 mV, 16비트면 약 0.05 mV다.
// ADC 결과를 전압으로 환산 (12비트, 기준 3.3V)
// ADCout = (2^n - 1) × Vin / Vref
// 예) Vin=1V, Vref=3.3V, n=12 → 4095 × 1/3.3 ≈ 1240
float volt = (float)adc_raw * 3.3f / 4095.0f;
변환에는 오차가 따른다. 연속을 계단으로 근사하며 생기는 양자화 오차(비트가 높을수록 작아짐), 일정한 상수만큼 위/아래로 치우치는 오프셋 오차(상수를 더하거나 빼서 보정), 기울기가 달라지는 이득 오차(특정 값을 곱해 보정)다. 뒤의 둘은 소프트웨어로 비교적 쉽게 잡을 수 있다.
우리가 쓰는 칩의 ADC는 SAR(Successive Approximation Register, 축차 비교) 방식이다. 입력 전압을 커패시터에 잠시 가둔 뒤(샘플&홀드), 내부 DAC 값과 비교를 거듭하며 가장 가까운 디지털 값을 찾아낸다.
샘플링 시간은 짧으면 빠르지만 정밀도가 떨어질 수 있어, 실제로는 실험으로 적절한 값을 찾는다 — 속도와 정밀도의 맞교환이다. 인버터에서는 이 ADC로 상전류·DC링크 전압·소자 온도를 읽어 제어와 보호의 근거로 삼는다.
7타이머/카운터 — 시간과 PWM의 공장
정확한 주기를 만들고, 모터를 구동한다
같은 모듈이지만 부르는 이름이 둘이다. 내부 클럭으로 일정 주기를 재면 타이머(timer), 외부 신호의 개수를 세면 카운터(counter)다. 내부 클럭을 쓰느냐 아니냐가 갈림길이다.
동작 원리는 단순하다. 클럭 박자마다 숫자를 하나씩 올리다가, ARR(Auto-Reload Register, 자동 재적재 레지스터)에 적어 둔 최댓값에 닿으면 0으로 떨어지며 오버플로우(overflow) 사건을 일으킨다. 이 사건마다 인터럽트를 발생시키면 정확한 주기의 시계가 된다. 한편 CCR(Capture/Compare Register, 캡처/비교 레지스터)은 카운터 값과 비교되어, 일치하는 순간 출력 신호를 바꾼다 — 이것이 PWM(Pulse-Width Modulation, 펄스 폭 변조)의 원리다.
듀티(duty), 즉 한 주기 중 켜져 있는 비율을 바꾸려면 CCR 값만 조절하면 된다. 카운터는 0→ARR만 반복하는 업카운트, 0↔ARR을 오르내리는 업/다운(센터 얼라인) 모드로 동작한다. 16비트 타이머라면 0~65,535까지 셀 수 있어 분해능이 매우 높다. 주기는 클럭 주파수 ÷ (프리스케일러 × ARR)로 정해진다.
형광등은 켜짐과 꺼짐뿐이지만, 아주 빠르게 껐다 켜기를 반복하면 사람 눈에는 밝기가 중간으로 보인다. 켜진 시간의 비율(듀티)을 바꾸는 것이 곧 밝기 조절, 모터라면 속도 조절이다.
고급 타이머(TIM1·TIM8)는 모터 제어에 특화되어 있다. 한 채널과 그 반전 채널을 자동으로 상보(complementary) 출력하기 때문이다. 인버터의 한 상은 위·아래 두 스위치로 이루어지는데, 둘이 동시에 켜지면 전원이 단락되는 암 쇼트(arm short)가 일어나 큰 사고가 난다. 그래서 둘은 번갈아 켜져야 하고, 전환 순간에는 둘 다 잠시 꺼두는 데드타임(dead-time)이 반드시 필요하다.
야간 근무자가 자리를 비우기 전에 주간 근무자가 먼저 앉아 버리면 한순간 둘이 겹쳐 충돌한다. 그래서 한 사람이 완전히 떠난 뒤에야 다음 사람이 앉도록 짧은 빈 시간을 둔다. 실제 스위치는 켜지고 꺼지는 데 시간이 걸리므로, 이 공백이 없으면 위·아래가 순간적으로 함께 켜져 단락된다.
8UART — 가장 단순한 대화
선 두 가닥으로 주고받는 약속
통신은 데이터를 한 비트씩 줄지어 보내는 직렬(serial)과 여러 비트를 한꺼번에 보내는 병렬(parallel)로 나뉜다. 병렬은 빠르지만 선이 많고 거리가 길어지면 간섭에 약하다. 임베디드에서는 배선이 단순하고 장거리에 강한 직렬이 압도적으로 많이 쓰인다.
또 다른 분류 축도 있다. 동시에 보내고 받을 수 있으면 전이중(full-duplex), 한 번에 한 방향만 되면 반이중(half-duplex, 무전기처럼)이다. 그리고 별도의 클럭 선에 맞춰 주고받으면 동기(synchronous), 클럭 없이 양쪽이 속도만 약속하고 주고받으면 비동기(asynchronous)다.
UART(Universal Asynchronous Receiver/Transmitter, 범용 비동기 송수신기)는 이름 그대로 비동기 직렬 통신이다. 클럭 선이 없는 대신, 한 프레임을 시작 비트 → 데이터 비트 → 정지 비트라는 약속(프로토콜)으로 감싼다.
송신부(TX)와 수신부(RX)는 데이터 폭(보통 8비트)과 보레이트(baud rate, 초당 비트 수)를 똑같이 맞춰야 한다. 보레이트는 사용하는 버스 클럭을 원하는 속도로 나눠 레지스터에 적는 것으로 간단히 설정된다. 또 한 가지 — 배선할 때 TX는 상대의 RX에, RX는 상대의 TX에 엇갈려 연결해야 통신이 된다. 같은 이름끼리 잇는 실수가 가장 흔하다.
지휘자가 없는(클럭이 없는) 두 사람이 또박또박 대화하려면, 미리 "1초에 몇 글자 속도로 말하자"고 약속해야 알아듣는다. 약속한 속도가 어긋나면 같은 문장도 뒤죽박죽이 된다. UART의 보레이트가 바로 그 약속이다.
UART는 가장 단순한 만큼 가장 널리 쓰인다. MCU 내부 변수를 PC로 찍어 보며 디버깅하거나, 블루투스 모듈과 연결해 전동 킥보드의 속도·온도·전류를 스마트폰으로 받아보는 텔레메트리(원격 계측)에 흔히 동원된다.
9하나의 제어 루프로 수렴하다
여덟 조각이 만나 모터를 돌린다
지금까지의 여덟 모듈은 따로 노는 지식이 아니라, 하나의 제어 루프를 이루는 톱니바퀴들이다. 삼상 인버터로 BLDC·PMSM 모터를 돌리는 과정에 그대로 포개진다.
순서를 따라가 보면 이렇다. 클럭으로 칩 전체를 깨우고, 레지스터로 각 주변장치를 설정한다. 홀센서 신호가 GPIO 인터럽트로 들어와 회전자의 현재 위치를 알리면, MCU는 다음 전류 위상을 정한다. 그 위상을 타이머/PWM이 여섯 개 스위치의 상보 신호로 — 데드타임을 둬 단락을 막으며 — 인버터에 내보낸다. 모터가 돌면 그 결과인 상전류·DC전압·온도를 ADC가 되읽어 제어를 보정하고, 그 모든 상태를 UART로 바깥에 중계한다.
요컨대 작은 칩 하나가 1초에 수천 번씩 "지금 어디인가 → 어떻게 켤 것인가 → 정말 그렇게 되었는가"를 반복하며 모터를 빚어낸다. 펌웨어의 여덟 모듈은 그 질문을 던지고 답하기 위한 최소한의 어휘다. 이 어휘를 손에 익히는 순간, 비로소 칩과 같은 언어로 대화할 수 있게 된다.