Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
#한 줄 요약
“Peripheral은 class와 세 가지 동작 모드의 결합입니다.” Blocking, interrupt-driven, DMA로 나뉩니다.
#어떤 문제를 푸는가
벤더 HAL은 C 함수입니다.
HAL_UART_Transmit(&huart2, data, len, HAL_MAX_DELAY);HAL_UART_Transmit_IT(&huart2, data, len);HAL_UART_Transmit_DMA(&huart2, data, len);타입이 없어 어떤 UART든 같은 함수로 호출합니다. C++ wrapping으로 다음을 얻습니다.
- Type-safe: UART2와 UART3가 섞이지 않습니다
- RAII: 자원 lifecycle을 관리합니다
- Concepts: interface를 통일합니다
- Mode polymorphism: blocking, interrupt, DMA 모드를 바꿔 끼웁니다
#기본 — UART class
template<uintptr_t Address>class Uart { struct Regs { volatile uint32_t SR, DR, BRR, CR1, CR2, CR3, GTPR; };
static Regs& regs() { return *reinterpret_cast<Regs*>(Address); }
public: static void init(uint32_t baud, uint32_t pclk) { regs().BRR = pclk / baud; regs().CR1 = (1 << 13) | (1 << 3) | (1 << 2); // UE | TE | RE }
// Blocking transmit static void send_byte(uint8_t b) { while (!(regs().SR & (1 << 7))); // TXE regs().DR = b; }
static void send(const uint8_t* data, size_t len) { for (size_t i = 0; i < len; ++i) send_byte(data[i]); while (!(regs().SR & (1 << 6))); // TC }
// Blocking receive static uint8_t recv_byte() { while (!(regs().SR & (1 << 5))); // RXNE return regs().DR; }};
using Uart2 = Uart<0x40004400>;
Uart2::init(115200, 84'000'000);Uart2::send(reinterpret_cast<const uint8_t*>("Hello\n"), 6);type별로 wrapper가 갈립니다. Uart2와 Uart3는 다른 type입니다.
#Interrupt-driven UART
template<uintptr_t Address>class IsrUart { // ... 기본 regs
static inline std::atomic<volatile uint8_t*> tx_buf_{nullptr}; static inline std::atomic<size_t> tx_remaining_{0};
public: static void init(uint32_t baud, uint32_t pclk) { regs().BRR = pclk / baud; regs().CR1 = (1 << 13) | (1 << 3) | (1 << 7); // UE | TE | TXEIE }
// Non-blocking send static bool send_async(const uint8_t* data, size_t len) { if (tx_remaining_.load() > 0) return false; // busy tx_buf_.store(const_cast<volatile uint8_t*>(data)); tx_remaining_.store(len); // Enable TX interrupt regs().CR1 |= (1 << 7); return true; }
static void irq_handler() { auto* buf = tx_buf_.load(); auto remaining = tx_remaining_.load();
if (remaining > 0) { regs().DR = *buf; tx_buf_.store(buf + 1); tx_remaining_.store(remaining - 1); } else { // Disable TX interrupt regs().CR1 &= ~(1 << 7); } }};
// ISRextern "C" void USART2_IRQHandler() { IsrUart<0x40004400>::irq_handler();}CPU가 묶이지 않습니다. 전송 중에 다른 일을 할 수 있습니다.
#DMA UART
template<uintptr_t Address, uint8_t DmaChannel>class DmaUart {public: static void init(uint32_t baud, uint32_t pclk) { // ... UART regs init // DMA setup // Enable DMA TX regs().CR3 |= (1 << 7); }
static bool send_dma(const uint8_t* data, size_t len) { // DMA channel busy? if (dma_busy(DmaChannel)) return false;
// Configure DMA dma_setup_mem_to_peripheral(DmaChannel, data, len, ®s().DR); dma_start(DmaChannel); return true; }
static void dma_complete_handler() { // Transfer 완료 — callback 호출 등 }};
using LogUart = DmaUart<0x40004400, 7>; // DMA1 Channel 7
LogUart::send_dma(big_buffer, 1024); // CPU 거의 안 씀CPU 사용이 거의 0입니다. 큰 buffer 전송에 적합합니다.
#Concept으로 UART interface 통일
template<typename T>concept UartInterface = requires(T t, const uint8_t* data, size_t len) { { T::init(uint32_t{}, uint32_t{}) } -> std::same_as<void>; { T::send(data, len) } -> std::same_as<void>; { T::recv_byte() } -> std::convertible_to<uint8_t>;};
template<UartInterface Uart>class Logger {public: static void log(const char* msg) { Uart::send(reinterpret_cast<const uint8_t*>(msg), strlen(msg)); }};
using LogChannel = Logger<Uart2>;LogChannel::log("hello");UART implementation을 blocking, interrupt, DMA 중 어느 것으로도 교체할 수 있습니다. Logger는 변경할 필요가 없습니다.
#SPI 추상화
template<uintptr_t Address>class Spi { struct Regs { volatile uint32_t CR1, CR2, SR, DR, CRCPR, RXCRCR, TXCRCR, I2SCFGR; };
static Regs& regs() { return *reinterpret_cast<Regs*>(Address); }
public: static void init(SpiMode mode, SpiSpeed speed) { regs().CR1 = static_cast<uint32_t>(mode) | static_cast<uint32_t>(speed) | (1 << 2) | // master (1 << 6); // SPE }
static uint8_t transfer(uint8_t out) { while (!(regs().SR & (1 << 1))); // TXE regs().DR = out; while (!(regs().SR & (1 << 0))); // RXNE return regs().DR; }
template<size_t N> static void transfer(const std::array<uint8_t, N>& tx, std::array<uint8_t, N>& rx) { for (size_t i = 0; i < N; ++i) { rx[i] = transfer(tx[i]); } }};SPI 특성상 전송과 수신이 함께 이뤄집니다. 한 함수에서 처리합니다.
#SPI device — Chip Select 통합
template<typename Spi, typename CsPin>class SpiDevice {public: static void init() { Spi::init(SpiMode::Mode0, SpiSpeed::Fast); CsPin::configure(GpioMode::Output); CsPin::set(); // CS inactive (high) }
static void select() { CsPin::clear(); } // CS active low static void deselect() { CsPin::set(); }
template<size_t N> static auto transfer(const std::array<uint8_t, N>& tx) { std::array<uint8_t, N> rx{}; select(); Spi::transfer(tx, rx); deselect(); return rx; }};
using AccelChip = SpiDevice<Spi1, GpioPin<GpioPortA, 4>>;
auto response = AccelChip::transfer<2>({0x80, 0x00});SPI와 CS pin이 하나의 device로 묶입니다. 매번 CS toggle이 자동으로 일어납니다.
#I2C 추상화
template<uintptr_t Address>class I2c {public: static void init(uint32_t speed_hz) { // ... I2C regs setup }
static bool write(uint8_t addr, const uint8_t* data, size_t len) { if (!start()) return false; if (!send_address(addr, /*write=*/true)) return false; for (size_t i = 0; i < len; ++i) { if (!send_byte(data[i])) return false; } stop(); return true; }
static bool read(uint8_t addr, uint8_t* data, size_t len) { if (!start()) return false; if (!send_address(addr, /*read=*/false)) return false; for (size_t i = 0; i < len; ++i) { data[i] = recv_byte(i == len - 1); // last → NACK } stop(); return true; }};I2C protocol은 복잡합니다. bus arbitration, ACK/NACK, restart 등을 다뤄야 합니다. 벤더 HAL을 wrapping하는 방식이 대부분 실용적입니다.
#ADC 추상화
template<uintptr_t Address, uint8_t Channel>class AdcChannel {public: static void init() { // ADC 초기화 }
static uint16_t read_blocking() { // 변환 시작 ADC_REG(Address)->CR2 |= (1 << 0); // SWSTART while (!(ADC_REG(Address)->SR & (1 << 1))); // EOC return ADC_REG(Address)->DR; }
static float read_voltage(float vref = 3.3f) { uint16_t raw = read_blocking(); return (raw * vref) / 4096.0f; }};
using Ch_Temp = AdcChannel<0x40012000, 5>;float v = Ch_Temp::read_voltage();Channel을 type parameter로 두면 runtime channel 선택 비용이 0입니다.
#RAII 통합 — Peripheral Guard
template<typename Peripheral>class PeripheralGuard {public: PeripheralGuard() { Peripheral::enable_clock(); Peripheral::init(); }
~PeripheralGuard() { Peripheral::shutdown(); Peripheral::disable_clock(); }
PeripheralGuard(const PeripheralGuard&) = delete;};
void burst_log(const char* msg) { PeripheralGuard<Uart2> uart; // turn on, init Uart2::send(...); // 자동 power down + clock off}power saving이 자연스럽게 됩니다. function scope를 벗어나면 clock이 꺼집니다.
#Peripheral Pool — 동적 할당 (드물게)
class UartPool { static inline std::array<Uart*, 5> pool_; static inline std::array<bool, 5> in_use_{false, false, false, false, false};
public: static Uart* acquire() { for (size_t i = 0; i < pool_.size(); ++i) { if (!in_use_[i]) { in_use_[i] = true; return pool_[i]; } } return nullptr; }
static void release(Uart* p) { for (size_t i = 0; i < pool_.size(); ++i) { if (pool_[i] == p) { in_use_[i] = false; p->shutdown(); return; } } }};runtime peripheral 선택이 필요한 경우에 씁니다. 임베디드 대부분은 static을 씁니다. 동적이 필요할 때 pool을 활용합니다.
#자주 보는 함정과 안티패턴
#1. peripheral 초기화 누락
clock enable이 누락되거나 alternate function이 미설정이면 동작하지 않습니다. RAII guard로 보장합니다.
#2. Blocking과 ISR 혼용
blocking send 중 ISR이 같은 peripheral을 접근하면 race가 발생합니다. 모드를 통일합니다.
#3. DMA buffer alignment
DMA는 특정 alignment를 요구합니다. alignas(4)로 맞춰 줍니다.
#4. Volatile 누락
peripheral register는 volatile pointer로 다룹니다. 자세한 내용은 Part 5-01에서 다룹니다.
#5. peripheral lifetime
static peripheral은 영원히 살아 있습니다. power down이 명시적으로 필요하면 destructor를 활용합니다.
#6. Multi-task에서 같은 peripheral 공유
mutex나 queue로 직렬화합니다.
#측정 — C++ peripheral vs HAL
같은 UART 100 byte 전송 비교입니다(STM32F4, 115200 baud).
HAL_UART_Transmit (blocking): 코드: 1.2 KB (HAL library) 실행: ~8.7 ms (115200 baud bound)
C++ Uart<...>::send (blocking): 코드: 80 B 실행: ~8.7 ms (동일)속도는 동일합니다. C++가 15배 작은 코드입니다. HAL은 generic 처리와 safety check로 코드가 큽니다.
#정리
- Peripheral은 address를 가진 template class로 표현하며 type-safe합니다.
- 세 가지 모드를 다룹니다 — Blocking(simple), Interrupt(non-blocking), DMA(CPU 0).
- Concept으로 interface를 통일하면 mode를 교체할 수 있습니다.
- SPI에 CS pin을 묶는 device wrapper로 더 높은 abstraction을 만듭니다.
- RAII guard로 clock과 power를 관리합니다.
- HAL을 wrap하는 방식이 대부분 더 빠르고 작습니다.
#관련 항목
#다음 글
Part 5-04: HAL 설계 패턴 — 범용 HAL 구조를 다루며, 벤더 종속성 격리와 다중 보드/MCU 지원을 살펴봅니다.
Embedded C++ for Real Systems · 40 of 41
- 1Embedded C++ for Real Systems — 임베디드 모던 C++ 시리즈 소개
- 2임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교
- 3임베디드 C++ 컴파일러 플래그 분석 — -fno-rtti·-fno-exceptions·-Os
- 4임베디드 C++ 런타임 요구사항 — libstdc++·newlib·crt0 분석
- 5C++ 코드 크기 분석 — 가상 함수·템플릿·예외 비용 추적
- 6C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃
- 7C++ 스타트업 코드 분석 — .init_array·전역 생성자 호출 순서
- 8임베디드 C++ 링커 스크립트 — vtable·정적 객체 배치 추적
- 9임베디드 C++ 표준 선택 가이드 — C++11/14/17/20/23 트레이드오프
- 10임베디드 RAII 기초 — 리소스 안전성과 결정적 소멸 보장
- 11임베디드 RAII 실전 패턴 — Lock·Pin·DMA·Power 관리
- 12constexpr 기초와 임베디드 적용 — 컴파일 타임 계산 활용
- 13constexpr 고급 활용 — 룩업 테이블·CRC·해시 컴파일 타임 생성
- 14consteval과 constinit 분석 — C++20 컴파일 타임 강제 메커니즘
- 15임베디드 Templates 기초 — 타입 안전과 코드 재사용 분석
- 16Template 비용 분석 — 코드 폭증·인스턴스화·디버그 정보 측정
- 17CRTP 패턴 분석 — vtable 없는 정적 다형성
- 18Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사
- 19C++20 Concepts 활용 — 템플릿 제약과 가독성 개선
- 20동적 할당 없는 임베디드 C++ — placement new·정적 객체·풀
- 21Custom Allocator 기초 — std::allocator 인터페이스 분석
- 22Pool Allocator 구현 — Fixed-Size Block과 O(1) 보장
- 23std::pmr 임베디드 활용 — Polymorphic Memory Resource 분석
- 24No-Exception C++ 설계 — 코드 크기·결정성 트레이드오프
- 25임베디드 에러 처리 패턴 — Result·errno·optional 비교
- 26std::expected 분석 — C++23 결과 타입과 에러 전파
- 27No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기
- 28임베디드 스마트 포인터 선택 — unique·shared·custom 비교
- 29임베디드 C++ 소유권 모델 — single·shared·borrow 패턴
- 30Intrusive Containers 분석 — 동적 할당 없는 컨테이너 설계
- 31ETL 라이브러리 분석 — Embedded Template Library의 STL 대체
- 32임베디드 Lock-free 기초 — atomic·memory ordering·CAS
- 33Lock-free Container 구현 — SPSC Queue·Ring Buffer
- 34Type-safe Flags 패턴 — Enum Class·Strong Typedef·Tag
- 35임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
- 36Compile-time FSM 구현 — 템플릿으로 상태 전이 검증
- 37Singleton 대안 패턴 — Service Locator·Static Init·Phantom
- 38MMIO Register 추상화 — 타입 안전한 비트 필드 접근
- 39GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
- 40Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
- 41임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교
관련 글
이 글을 참조하는 글 (5)
- 임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교— Embedded C++ for Real Systems
- GPIO 추상화 패턴 — Template·Concept으로 보드 독립성— Embedded C++ for Real Systems
- MMIO Register 추상화 — 타입 안전한 비트 필드 접근— Embedded C++ for Real Systems
- CRTP 패턴 분석 — vtable 없는 정적 다형성— Embedded C++ for Real Systems
- C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃— Embedded C++ for Real Systems