Rust에서 하드웨어에 접근할 때 좋은 API는 어떤 모습이어야 하는지 살펴보고, I/O 포트, 시스템 레지스터, 메모리 매핑 I/O 같은 접근 방식과 svd2rust, tock-registers, safe-mmio, derive-mmio 등 주요 크레이트의 문서화·사용성을 비교합니다.
TrainingRust ExpertsFerroceneOpen SourceBlogGet in touchGithubImprintPrivacy policy
Menu
Jonathan
March 2026
Article
Published on March 18, 2026 29 min read
코딩 도움이 필요하신가요?
Ferrous Systems에서는 베어메탈에서 실행되는 Rust 코드를 많이 작성합니다. 이는 프로세서가 리셋에서 빠져나오자마자 가장 먼저 실행되는 코드이며, 도움을 청할 “더 높은 권한”의 존재가 없는 코드입니다. 이 중 일부는 우리 교육에서 토론할 예제 코드로 만들고, 일부는 고객을 위해 작성하며, 일부는 오픈소스로 공개합니다. 오픈소스는 Rust Embedded Devices Working Group을 통해서이기도 하고, 우리 자체의 Knurling Project를 통해서이기도 합니다.
우리가 관찰한 것 중 하나는, 하부 하드웨어와 상호작용하기 위한 API에 있어 일관성이 그리 크지 않다는 점입니다. 반드시 문제가 되는 것은 아닙니다. 아키텍처가 다르고 주변장치가 다르면 서로 다른 접근이 필요할 수 있으니까요. 다만 특정 플랫폼에서 사람들이 Rust를 접할 때 무엇이 좋은 API를 만드는지, 그리고 반대로 어떤 선택이 학습/탐색 과정에서 마찰을 늘리는지에 대해 제가 가진 생각과 관찰을 몇 가지 적어두고자 합니다.
Rust는 메모리 안의 값들과 쉽게 상호작용할 수 있게 해줍니다. 즉, 기본 타입(정수, 부동소수점, bool 등)으로부터 값을 만들 수 있고, 그런 것들을 조합하는 우리만의 타입(struct, enum 등)도 설계할 수 있습니다. 하지만 이 중 어떤 것도 실제로 기계가 무언가를 하게 만들지는 못합니다. 예를 들어 let led_on = true; 같은 변수를 만든다고 해서(안타깝게도) LED가 켜지지는 않습니다. RAM에 값을 저장/로드하는 것 이상의 일을 기계가 하게 하려면, _unsafe Rust_로 들어가야 합니다. 이것은 Rust 컴파일러가 우리 프로그램을 모델링한 범위 밖에 있는 데이터에 작용하는 연산을 수행하게 해주며, 하드웨어(또는 운영체제 커널)에 동작을 명령할 수 있게 합니다.
불행히도 하드웨어는 프로세서에 여러 방식으로 “보일” 수 있고, 올바른 unsafe 연산의 종류는 여러분이 상호작용하려는 하드웨어에 전적으로 달려 있습니다. 다음으로 흔한 예시 3가지를 살펴보겠습니다.
IBM PC의 초기 시절, 그리고 그 이전의 8080 기반 CP/M 머신 시절에는 프로세서가 두 개의 주소 공간을 갖고 있었습니다. 하나는 데이터용, 하나는 I/O용입니다. 거의 모든 프로그래밍은 데이터 주소 공간에서 이뤄졌지만, 하드웨어와 대화하고 싶을 때는 I/O 주소 공간에 대해 읽거나 쓸 수 있는 특수 I/O 명령을 사용할 수 있었습니다. 저처럼 오래 해오신 분이라면 MS-DOS 시절의 마법 같은 숫자를 기억할지도 모르겠습니다. 예를 들어 0x220(Creative Labs SoundBlaster 카드의 기본 I/O 주소)이나 0x3F8(시리얼 포트 COM1의 기본 I/O 주소) 같은 것 말이죠. 이들은 I/O 공간의 주소이며, _포트_라고도 부릅니다.
Phil Opperman의 훌륭한 블로그 Writing an OS in Rust는 I/O 읽기/쓰기를 사용하는 x86-64 베어메탈 코드의 좋은 예시입니다. Phil은 x86-64 크레이트와 그 안의 Port 추상화를 사용하며, 코드는 다음과 같습니다:
// in src/main.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum QemuExitCode {
Success = 0x10,
Failed = 0x11,
}
pub fn exit_qemu(exit_code: QemuExitCode) {
use x86_64::instructions::port::Port;
unsafe {
let mut port = Port::new(0xf4);
port.write(exit_code as u32);
}
}
그 Port 타입 내부를 보면, 사용자가 I/O 포트에 쓰기를 원할 때 적절한 OUT 명령을 만들어내기 위해 _인라인 어셈블리_를 사용한다는 것을 알 수 있습니다:
impl PortWrite for u8 {
#[inline]
unsafe fn write_to_port(port: u16, value: u8) {
unsafe {
core::arch::asm!("out dx, al", in("dx") port, in("al") value, options(nomem, nostack, preserves_flags));
}
}
}
IBM PC 아키텍처는 메모리 매핑 I/O도 지원합니다(예: 원래 MDA 비디오 카드의 비디오 RAM은 설정을 위한 I/O 레지스터와 별도로 0xB0000 주소에 매핑되어 있었습니다). 하지만 시간이 지나며, 특히 PCI 버스의 등장과 함께 PC 하드웨어는 설정과 데이터를 모두 메모리 매핑 I/O로 전환했습니다.
Arm과 RISC-V 같은 최신 RISC 아키텍처는 I/O 명령이라는 개념 자체를 아예 건너뛰기도 했습니다. 이들은 주로 _메모리 매핑 I/O_를 사용하지만, 프로세서와 밀접하게 연결된 기능을 위해 _시스템 레지스터_도 갖고 있습니다.
시스템 레지스터는 I/O 포트와 비슷하지만, 주소를 갖는 대신 다른 형태의 고유 식별자를 갖습니다. 또한 별도의 주변장치로서 프로세서 밖에 있는 것이 아니라, 기능적으로 프로세서의 일부입니다.
좋은 예시는 실시간 시스템용 32비트 Arm 아키텍처 버전 7(친구들 사이에서는 Armv7-R)입니다. 이 아키텍처에는 번호가 매겨진 _레지스터_를 가진 _코프로세서_라는 개념이 있고, MCR 명령을 사용해 코프로세서에서 일반 프로세서 _레지스터_로 값을 _이동_할 수 있습니다:
let value: usize;
unsafe {
// Read the MPIDR (*Multiprocessor Affinity Register*)
core::arch::asm!("mcr p15, 0, {r}, c0, c0, 5", r = out(reg) value, options(nomem, nostack));
}
println!("MPIDR contains: {value:08x}");
여기서의 코프로세서는 전통적인 의미의 “코프로세서”(메인보드에서 메인 프로세서 옆에 놓인 두 번째 칩)라기보다는, Arm이 프로세서가 정상적인 Arm 아키텍처의 범위를 벗어난 일을 하게 하기 위해 그 메커니즘을 활용하는 것입니다. 예컨대 위의 예처럼 “멀티프로세서 시스템에서 나는 어느 프로세서인가?”라는 질문에 답한다든지 말이죠.
이런 매직 넘버(이 경우 OP1 = 0, CRn = c0, CRm = c0, OP2 = 5)는 딱히 기억하기 쉽지 않고, 아키텍처 레퍼런스 매뉴얼의 거대한 표에 설명돼 있습니다. 하지만 역시 추상화를 만들어 더 다루기 쉽게 만들 수 있습니다. aarch32-cpu 크레이트는 다음과 같은 일을 합니다:
pub struct Mpidr(pub u32);
impl SysReg for Mpidr {
const CP: u32 = 15;
const CRN: u32 = 0;
const OP1: u32 = 0;
const CRM: u32 = 0;
const OP2: u32 = 5;
}
impl SysRegRead for Mpidr {}
impl Mpidr {
#[inline]
/// Reads MPIDR (*Multiprocessor Affinity Register*)
pub fn read() -> Mpidr {
// Safety: reading this co-processor register is always allowed, and
// has no side-effects
let value = unsafe { <Self as SysRegRead>::read_raw() };
Self(value)
}
}
// We expose a nice, safe, API
let id: Mpidr = Mpidr::read();
AArch64 시스템 역시 시스템 레지스터 개념에 의존하지만, 이 아키텍처에서 Arm은 각 시스템 레지스터에 고유한 이름을 부여하는 쪽을 선택했습니다:
let value: usize;
unsafe {
// Read the MPIDR (*Multiprocessor Affinity Register*) for Exception Level 1
core::arch::asm!("mrs {r:w}, MPIDR_EL1", r = out(reg) value, options(nomem, nostack));
}
println!("MPIDR contains: {value:08x}");
이게 위의 32비트 버전보다 훨씬 읽기 쉽다는 데에는 동의할 수 있을 겁니다.
RISC-V도 _시스템 레지스터_를 사용하며, 각각 이름이 있긴 하지만 어셈블리 코드에서는 고유한 숫자 ID를 사용해야 합니다:
let value: usize;
unsafe {
// Read the Hart ID with a *CSR Atomic Read and Set Bits* operation
core::arch::asm!("csrrs {r}, 0xF14, x0", r = out(reg) value, options(nomem, nostack));
}
println!("HartID is: {value:08x}");
일반적으로 시스템 레지스터를 다룬다는 것은 하드웨어로부터/하드웨어로 usize 크기의 정수를 넣고 빼는 것을 의미합니다. 그 정수를 더 작은 구성요소로 어떻게 나눌지 선택하는 것은 중요한 주제인데, 이것은 뒤에서 다시 다루겠습니다.
앞서 언급했듯이, I/O 연산과 시스템 레지스터 읽기/쓰기는 한 번에 단 하나의 데이터 단위만 처리합니다. 보통 머신 워드 크기(즉 Rust로는 usize)의 정수입니다. 하지만 고속 I/O 인터페이스나 대용량 비디오 메모리를 다뤄야 한다면, 이는 금방 병목이 됩니다. 대신 대부분의 현대 컴퓨터 시스템은 I/O 장치를 메모리와 같은 주소 공간 안에 그대로 제시합니다. 이른바 _메모리 매핑 I/O_입니다.
문제는 Rust가 자신이 알고 있고 위치를 아는 변수만 사용하길 좋아한다는 점입니다. 그럼 시리얼 포트 송신 FIFO가 메모리 주소 0xE020_5000에 있다면, 여기에 쓰는 것이 괜찮다고 Rust에게 어떻게 알려줄까요? unsafe 포인터 연산을 쓸 수는 있지만, 조심해야 합니다. 다음 코드는 무엇을 할까요?
pub fn write_string_to_uart(s: &str) {
const UART0_TRANSMIT_FIFO: *mut u32 = 0xE020_5000 as *mut u32;
for byte in s.bytes() {
// Safety: This is where the UART FIFO lives
unsafe { UART0_TRANSMIT_FIFO.write(byte as u32) };
}
}
UART로 문자열을 쓰는 거죠? Godbolt의 Compiler Explorer에게 물어봅시다(주석은 제가 달았습니다):
write_string_to_uart:
cmp r1, #0 // is string length zero?
addne r0, r0, r1 // if not, r0 = string start + string length
movwne r1, #20480 // r1 = 0x5000
movtne r1, #57376 // r1 |= 0xE020_0000
ldrbne r0, [r0, #-1] // load one byte from r0 - 1
strne r0, [r1] // write byte to UART FIFO
bx lr // exit the function
좋습니다. 먼저, Arm의 조건 코드 덕분에 분기 명령에 공간을 낭비하지 않고 각 명령에 “마지막 compare가 Not Equal일 때만 실행”을 붙일 수 있다는 점은 멋지네요. 그런데 제 루프는 어디 갔죠? 이 함수는 UART에 _단 한 바이트_만 씁니다. 저는 분명 문자열의 모든 바이트를 쓰라고 했는데요.
문제는 *mut u32의 write 메서드가 여러분이 RAM에 쓰는 것이라고 믿는다는 것입니다. RAM의 같은 위치에 값을 10번 쓰면 어떻게 되나요? 처음 9개는 덮어써지고 마지막 값만 남습니다. 그러니 옵티마이저가 “도와준” 것입니다! 루프에서 RAM에 쓰고 있다는 것을 보고 루프를 버리고 마지막 쓰기만 남겼습니다. 이것은 메모리에 쓰는 경우엔 훌륭한 성능 최적화지만, 우리는 메모리에 쓰는 게 아닙니다. 우리는 부작용 때문에 쓰기가 실제로 일어나길 원합니다. 이것은 단순히 “메모리에 값을 놓는 것”이 아니라 “이 주소에 쓰면 UART의 transmit 핀에 데이터 바이트가 나타난다”는 의미입니다.
이를 위한 메서드가 있습니다. 어느 정도는요.
pub fn write_string_to_uart(s: &str) {
const UART0_TRANSMIT_FIFO: *mut u32 = 0xE020_5000 as *mut u32;
for byte in s.bytes() {
// Safety: This is where the UART FIFO lives
unsafe { UART0_TRANSMIT_FIFO.write_volatile(byte as u32) };
// ^^^^^^^^^^^^^^ - this is now a volatile operation
}
}
이것은 다음과 같이 컴파일됩니다:
write_string_to_uart:
cmp r1, #0 // is string length zero?
bxeq lr // if it is, return from function
movw r2, #20480 // r2 = 0x5000
movt r2, #57376 // r2 |= 0xE020_0000
.LBB1_2:
ldrb r3, [r0], #1 // load byte into r3 from address in r0, and increment r0 by 1
subs r1, r1, #1 // decrement remaining string length by 1
str r3, [r2] // write byte to UART FIFO
bne .LBB1_2 // if remaining string length is not zero, loop back to label .LBB1_2
bx lr // exit from function
write_volatile을 사용함으로써 “이 쓰기들은 중요하니 최적화로 제거하지 말라”고 말하는 셈입니다. 이는 괜찮게 동작하지만 중요한 주의점이 있습니다. MMIO 주소에 대해서는 포인터만 만들어야 하고, 레퍼런스는 절대 만들면 안 됩니다. 즉, 아래 Rust 코드는 sound하지 않습니다:
#[repr(C)]
struct Uart {
// write here to write to TX FIFO, read here to read from RX FIFO
fifo: u32,
// control the UART here
control: u32,
// get the status here
status: u32,
}
let uart_ref: &Uart = unsafe { &*(0xE020_5000 as *mut Uart) };
이는 Rust의 레퍼런스가 LLVM에 의해 _dereferenceable_로 알려져 있기 때문입니다. LLVM이 dereferenceable하다고 판단하는 것은 LLVM이 원할 때면 언제든 역참조될 수 있으며, 여러분이 명시적으로 요청할 때만 역참조되는 것이 아닙니다. 이게 문제가 되는 이유는, 해당 메모리 주소에서 읽기(역참조가 의미하는 바)가 부작용을 갖고 있고, 우리는 LLVM이 마음대로 이를 수행하길 원치 않기 때문입니다. 그렇게 되면 UART FIFO에서 문자를 랜덤하게 버리게 됩니다. 실제로는 큰 문제가 드물게 관찰되지만, 일반적으로 MMIO 주소 공간에 대한 레퍼런스는 Unsound하며 피해야 한다는 데에 합의가 되어 있습니다.
“그럼 포인터만 쓰면 되지 않나요?”라고 생각할 수도 있습니다. 문제는 Rust에 “구조체 시작에 대한 포인터가 있을 때, 그 구조체 필드에 대한 포인터를 만들어 달라”는 것을 좋은 문법으로 표현하는 방법이 없다는 점입니다. 다음처럼 써야 합니다:
#[repr(C)]
struct Uart {
fifo: u32,
control: u32,
status: u32,
}
let uart_ptr: *mut Uart = unsafe { 0xE020_5000 as *mut Uart };
// this does not create a temporary reference, but the syntax is awful
let fifo_ptr = unsafe { &raw mut (*uart_ptr).fifo };
unsafe { fifo_ptr.write_volatile(0x00) };
뒤에서 특정 크레이트들을 이야기할 때 MMIO에 대한 멋진 추상화를 보게 될 텐데, 이 문제를 해결하기 위해 고를 수 있는 해법이 여럿 있습니다.
위의 세 접근(I/O 읽기/쓰기, 시스템 레지스터, 메모리 매핑 I/O)는 모두 단일 usize 크기의 데이터 단위를 주고받게 해줍니다. 하지만 하드웨어 디자이너들은 이런 레지스터를 귀중한 자원으로 봅니다. 예를 들어 “이 주변장치가 지금 On인가 Off인가”를 기록하기 위해 32비트 전체를 쓰는 것은 꽤 낭비입니다. 그런 정보는 비트 하나면 충분하고, 정수에는 32(또는 64)비트가 있으니까요. 그래서 설계자들은 가능한 많은 작은 값들을 하나의 정수에 빽빽하게 담는 것을 좋아합니다.
예시로 Arm PL011 UART의 Interrupt FIFO Level Select Register, UARTIFLS를 보겠습니다:
| Description | Name | Bits |
|---|---|---|
| Reserved, do not modify, read as zero. | - | 31:6 |
| Receive interrupt FIFO level select. | RXIFLSEL | 5:3 |
| Transmit interrupt FIFO level select. | TXIFLSEL | 2:0 |
RXIFLSEL의 가능한 값은:
0b000 = Receive FIFO becomes ≥ 1/8 full0b001 = Receive FIFO becomes ≥ 1/4 full0b010 = Receive FIFO becomes ≥ 1/2 full0b011 = Receive FIFO becomes ≥ 3/4 full0b100 = Receive FIFO becomes ≥ 7/8 full0b101-0b111 = reserved.TXIFLSEL의 가능한 값은:
0b000 = Transmit FIFO becomes ≤ 1/8 full0b001 = Transmit FIFO becomes ≤ 1/4 full0b010 = Transmit FIFO becomes ≤ 1/2 full0b011 = Transmit FIFO becomes ≤ 3/4 full0b100 = Transmit FIFO becomes ≤ 7/8 full0b101-0b111 = reserved.두 경우 모두 32비트 레지스터에 3비트 값 두 개와 여러 ‘reserved 공간’이 들어있음을 볼 수 있습니다(보통 여기는 0을 쓰고, 읽을 때는 무시하지만, 하드웨어 기술 문서가 무엇을 해야 하는지 알려줄 것입니다). 3비트 값의 8가지 옵션 중 5개는 의미가 정해져 있고 3개는 그렇지 않습니다.
C 프로그래밍 언어에는 ‘비트필드’라는 개념이 있으며, 그 언어에서는 이런 레지스터를 다음처럼 표현할 수 있습니다:
struct {
unsigned long ifls_reserved: 26;
unsigned long ifls_rxifsel: 3;
unsigned long ifls_txifsel: 3;
}
불행히도 Rust에는 언어 차원의 이런 기능이 없습니다. 대신 일반적으로 시프트와 마스크를 사용해 주어진 레지스터의 필드에 접근하는 메서드를 제공합니다:
pub struct Uartifls(u32);
impl Uartifls {
pub fn get_rxifsel(&self) -> Option<Rxifsel> {
match (self.0 >> 3) & 0b111 {
0b000 => Some(Rxifsel::_1_8_full),
0b001 => Some(Rxifsel::_1_4_full),
0b010 => Some(Rxifsel::_1_2_full),
0b011 => Some(Rxifsel::_3_4_full),
0b100 => Some(Rxifsel::_7_8_full),
_ => None,
}
}
}
set_rxifsel 메서드는 독자의 연습문제로 남기겠습니다. 다만 ‘modify’ 메서드가 어떻게 생겼을지 논의할 가치는 있습니다. 레지스터를 수정할 때 우리는 세 가지를 해야 합니다:
저는 이를 위해 클로저 기반 API를 좋아합니다:
impl Uart {
pub fn modify_ifls<F>(&mut self, f: F) where F: FnOnce(&mut Uartifls) {
let mut value = self.read_ifls();
f(&mut value);
self.write_ifls(value);
}
}
fn set_fifo_levels(uart: &mut Uart) {
uart.modify_ifls(|r| {
r.set_rxifsel(Rxifsel::_7_8_full);
r.set_txifsel(Txifsel::_1_2_full);
});
}
여기에는 두 타입이 있습니다. 하나(Uart)는 주변장치(및 그 모든 레지스터)를 나타내고, 다른 하나(Uartifls)는 특정 레지스터 하나(UARTIFLS 레지스터)의 내용을 나타냅니다.
클로저는 또 다른 장점이 있습니다. modify의 read 부분은 했는데 write 부분을 잊어버리는 일이 _불가능_해집니다(예: 둘 사이에 early return을 추가하는 경우).
fn set_fifo_levels(uart: &mut Uart) {
let mut ifls = uart.read_ifls();
ifls.set_rxifsel(Rxifsel::_7_8_full);
// someone adds this later on - note the early return
do_other_operation()?;
// so now this might not happen
ifls.set_txifsel(Txifsel::_1_2_full);
uart.write_ifls(ifls);
}
Rust 옵티마이저는 컴파일 시점에 클로저를 “사라지게” 만드는 데 매우 능숙하며, 매우 최적화된 머신 코드만 남깁니다. Rust 사람들이 말하는 ‘제로 코스트 추상화’입니다. 아래는 MMIO 기반 접근을 하는 더 큰 프로그램에서 가져온, 위 클로저 기반 set_fifo_levels 함수의 어셈블리 출력입니다. 제가 수동으로 주석을 달았습니다.
set_fifo_levels:
push {fp, lr} ; Save state
mov fp, sp ; Adjust frame pointer
ldr r0, [r0] ; Get the register pointer from the Uart object
mov r2, #34 ; Our two fifo values packed as a single integer
ldr r1, [r0] ; Read the register
bfi r1, r2, #0, #6 ; Modify the bottom 6 bits of the value
str r1, [r0] ; Write to the register
pop {fp, pc} ; Pop state to return from function
클로저는 보이지 않죠! 거의 어셈블리로 손수 짰을 법한 코드와 비슷하지만, Rust로 작성하는 편이 어셈블리를 직접 쓰는 것보다 훨씬 실수가 적었습니다. 추상화는 वास्तव(정말로) 제로 코스트였습니다.
API에서 내보내는 메서드 측면에서는, 올바른 비트를 얻기 위한 시프트/마스크와 상수들이 많이 필요합니다. 따라서 이러한 메서드들은 미리 생성하거나 proc-macro로 생성하는 식으로 자동 생성되는 경우가 흔합니다. 시프트와 마스크를 정확히 맞추는 일은 성가신 작업이기 때문입니다.
이 모든 준비를 끝내고 제가 정말 말하고 싶었던 것은 이것입니다. 사용자는 이 API를 어떻게 발견할까요? 즉, 어떤 하드웨어에 대한 추상 인터페이스를 제공하는 크레이트의 문서를(로컬에서 cargo doc로 빌드했든, https://docs.rs에 호스팅되어 있든) 보았을 때, 다음 질문들에 얼마나 빨리 답할 수 있을까요?
IFLS 레지스터)는 어떻게 읽고/쓰고/수정하나?TXIFLS 비트필드)는 어떻게 읽거나 쓰나?이런 라이브러리를 개발할 때 저는 두 가지를 하곤 합니다:
#![deny(missing_docs)]를 켜서 모든 타입과 함수에 문서를 달도록 스스로를 상기시키기cargo doc --open을 실행해서 사용자가 보게 될 문서를 그대로 확인하기왜냐하면 우리는 에디터에서 소스 코드를 보며 크레이트를 _개발_하지만, 대부분의 사용자는 의존성의 소스 코드를 실제로 열어보지 않습니다. 그들은 문서에 의존하며, 종종 https://docs.rs에 호스팅된 문서를 봅니다. 따라서 개발자인 제가 생성된 문서를 검사하는 것은 코드가 어떤 머신 코드로 컴파일되는지를 테스트하는 것만큼이나 중요하다고 생각합니다.
이 영역에서 존재하는 하드웨어 추상화 툴킷 몇 가지를 살펴보고, _문서_라는 렌즈로 바라보겠습니다. 이를 돕기 위해, 저는 레지스터 3개만 가진 아주 단순한 가상의 하드웨어(UART)를 만들었습니다. 그리고 이 드라이버를 다음으로 구현했습니다:
각 패키지의 문서는 https://registry.ferrocene.dev/에 호스팅되어 있습니다.
svd2rustsvd2rust 도구는 Arm의 System View Description 포맷(XML)로 작성된 하드웨어 설명에서 MMIO 기반 Rust 소스 코드를 생성하는 프로그램입니다. 이 XML 파일은 시스템의 모든 주변장치(Peripherals)(그리고 그들이 존재하는 MMIO 주소), 그 주변장치 내부의 레지스터(Registers), 그리고 그 레지스터 내부의 _비트필드(Bitfields)_를 설명합니다. 비트필드가 잘 정의된 값의 집합을 갖는 경우, 그 집합을 포괄하는 enum 타입을 만들고, 각 레지스터에 대해 read, write, modify 함수를 제공합니다.
svd2rust는 주변장치 하나만을 위한 코드를 생성하기보다는, SVD 파일에 опис(기술)된 모든 주변장치를 커버하는 전체 크레이트를 생성하며, 인터럽트 벡터와 기타 세부사항도 함께 생성합니다. svd2rust로 생성된 크레이트는 일반적으로 Peripheral Access Crate 또는 ‘PAC’이라고 부르며, Cortex-M 기반 MCU를 Rust로 다룰 때 이 도구(또는 유사 도구)는 사실상 표준입니다.
소스 코드는 github.com/ferrous-systems/handling-system-registers/tree/main/svd2rust-example에 있고, 문서는 registry.ferrocene.dev/docs/svd2rust-example에 있습니다.
첫 페이지에서는 generic 모듈과, Uart라는 이름의 단일 주변장치 모듈을 봅니다. Peripherals라는 구조체 하나가 있고, 이는 시스템의 각 주변장치마다 하나의 인스턴스를 포함합니다. 각 주변장치의 메모리 주소는 SVD 파일에서 가져와 크레이트에 하드코딩됩니다. 아래쪽에는 UART 드라이버로, Uart라는 타입 별칭(type alias)이 보입니다.
Uart를 클릭해 들어가면 associated const 1개와 메서드 2개가 보이는데, 평균적인 사용자에게는 그다지 유용하지 않습니다. UART의 레지스터에 어떻게 접근하는지 즉시 드러나지 않지만, 알고 보면 상단의 타입 별칭에서 링크된 Uart::RegisterBlock을 클릭해야 합니다.
이제 레지스터마다 하나씩, 총 3개의 메서드가 보입니다. pub const fn status(&self) -> &Status 함수의 Uart::Status 반환 타입을 따라가면, 문서 상단에 read()를 호출해 Uart::status::R 타입의 값을 얻을 수 있다고 나옵니다. 이 R 타입을 찾기 어려웠기 때문에 저는 예전에 이 문서를 svd2rust에 추가한 적이 있습니다.
또 클릭해 들어가면 R 타입에 tx_ready()와 rx_ready() 메서드가 있고, 그 반환 타입에는 is_yes()와 is_no()가 있습니다. 이는 제가 작성한 SVD 파일 내용 그대로입니다.
실제로 이 API를 쓰려면 다음 같은 코드를 작성합니다:
/// Represents a UART
pub struct Uart {
regs: svd2rust_example::Uart,
}
impl Uart {
/// Create a UART driver, from the given low-level object
pub const fn new(regs: svd2rust_example::Uart) -> Uart {
Uart { regs }
}
/// Enable the UART
pub fn enable(&mut self) {
self.regs.control().modify(|_r, w| {
w.en().set_bit();
w
});
}
/// Transmit a byte
///
/// Blocks until space available
pub fn transmit(&mut self, byte: u8) {
while self.regs.status().read().tx_ready().is_no() {
core::hint::spin_loop();
}
self.regs.data().write(|w| unsafe { w.byte().bits(byte) });
}
}
/// Example program
pub fn main() {
let p = unsafe { svd2rust_example::Peripherals::steal() };
let mut uart = Uart::new(p.Uart);
uart.enable();
uart.transmit(b'X');
}
여기서 레지스터의 write() 메서드가 앞에서 논의한 클로저 기반 API를 갖는 것을 볼 수 있습니다. 이 예에서는 data 레지스터 안에 정의된 필드가 없으므로, 레지스터의 raw bits에 _unsafe_하게 써야 합니다. 레지스터에 정의된 필드가 있다면 control 레지스터처럼 설정을 위한 좋은 메서드가 생깁니다.
요약하자면 svd2rust는 SVD 파일을 쓰기 때문에, SVD 파일이 존재하는 MMIO 기반 주변장치에만 적합합니다. svd2rust가 생성하는 코드는(입력 SVD가 정확하고 완전하다는 가정 하에) 꽤 포괄적이며, API를 사용하는 코드는 꽤 읽기 쉽지만, 문서에서 필드와 레지스터의 이름을 찾아 이동하기가 꽤 어렵다는 것을 확인했습니다.
tock-registerstock-registers 크레이트는 Rust로 작성된 실시간 운영체제인 TockOS에서 사용하도록 설계되었습니다. TockOS는 상호 불신하는 여러 애플리케이션을 안전하고 신뢰성 있게 실행하는 데 초점을 맞춥니다. SVD 파일을 툴에 넣어 Rust 코드를 생성하는 대신, tock-registers는 Rust 소스 코드 안에서 주변장치를 정의할 수 있게 해주는 proc-macro들의 모음입니다.
소스 코드는 github.com/ferrous-systems/handling-system-registers/tree/main/tock-registers-example에 있고, 문서는 registry.ferrocene.dev/docs/tock-registers-example에 있습니다.
문서를 보면 UartRegisters와 Uart라는 구조체가 보입니다. 이는 svd2rust API의 RegisterBlock과 Uart 타입과 비슷합니다. 크레이트를 손으로 작성했기 때문에, 저는 new와 transmit 같은 고수준 함수를 Uart 타입에 직접 추가했습니다. UartRegisters를 보면 3개의 필드가 충분히 명확하게 보이고, 그 필드에서 사용하는 ReadWrite 타입을 클릭해 들어가면 read, write, set 등의 메서드를 볼 수 있습니다.
이 API의 한 가지 차이는, read와 write가 “어떤” 필드를 읽거나 쓸지를 나타내는 값을 인수로 받는다는 점입니다(예: status.read(Status::tx_ready)). 반면 svd2rust는 항상 레지스터 전체를 읽은 다음 그 안에서 특정 필드에 접근하게 합니다(예: status.read().tx_ready()). tock-registers에도 한 번 읽고 여러 필드에 접근하는 방식이(extract 메서드로) 가능하지만, 제가 본 대부분의 예제는 비트필드를 한 번에 하나씩 접근합니다. 어느 스타일이든 괜찮지만, read 메서드가 인수를 원하느냐 아니냐를 미리 알고 있어야 합니다.
실제로 이 API를 쓰면 다음과 같습니다:
/// Represents a UART
pub struct Uart {
// UartRegisters is the type tock-registers has generated
regs: &'static mut UartRegisters,
}
impl Uart {
/// Create a UART driver, with the UART at the given address
///
/// # Safety
///
/// The pointer `addr` must point to a valid UART structure, with
/// appropriate alignment.
pub const unsafe fn new(addr: *mut UartRegisters) -> Uart {
Uart {
regs: unsafe { &mut *addr },
}
}
/// Configure the UART
pub fn configure(&mut self, enabled: bool, baud: u32, stop_bits: Control::stop_bits::Value) {
use tock_registers::interfaces::{ReadWriteable};
self.regs.control.modify(
Control::stop_bits.val(stop_bits as u32)
+ Control::enable.val(enabled as u32)
+ Control::baud_rate.val(baud),
);
}
/// Transmit a byte
///
/// Blocks until space available
pub fn transmit(&mut self, byte: u8) {
use tock_registers::interfaces::{Readable, Writeable};
while self.regs.status.read(Status::tx_ready) == 0 {
core::hint::spin_loop();
}
self.regs.fifo.set(byte as u32);
}
}
tock-registers의 modify 함수는 단일 FieldValue를 받는데, 여러 FieldValue를 더해서(+) 만들 수 있습니다. 저는 이 문법을 올바르게 맞추기가 꽤 어렵다고 느꼈고, 자동완성도 크게 도움이 되지 않았습니다. 특히 각 필드는 Field 타입의 const와 동시에 _모듈_도 만들기 때문에, 에디터의 자동완성 팝업에서 잘못된 것을 고르면 원하던 메서드가 보이지 않게 됩니다.
tock-registers의 마지막 문제는 MMIO 주소에 대한 레퍼런스를 만들도록 의존한다는 점입니다. 앞서 말했듯 이는 정의되지 않은 동작이며, 실무에서 대체로 동작하긴 하지만, 해결책이 나오길 바랍니다.
요약하면, 전체 크레이트를 자동 생성하는 대신 주변장치 단위로 만들 수 있다는 것은 좋습니다. 정의 코드는 꽤 읽기 쉽지만, 레지스터와 비트필드를 읽고 쓰고 수정하는 문법을 찾아내기가 까다로울 수 있습니다. 문서가 여기서 더 많은 안내를 제공해도 좋겠습니다.
safe-mmio다음 후보는 Google의 safe-mmio입니다. 이 크레이트는 “MMIO 주소 공간에 대한 레퍼런스를 만들지 말라”는 문제를 해결하기 위해 특별히 설계되었고, *mut MyPeripheral 포인터를 담는 struct와, 함수형 매크로(field! 같은)를 사용해 “주변장치 포인터 → 주변장치 레지스터 포인터” 변환을 중간 레퍼런스 없이 수행합니다.
tock-registers와 달리 비트필드는 처리하지 않고 레지스터 수준 접근만 다룹니다. 제가 본 safe-mmio 사용 크레이트들은 보통 개별 비트필드 지원을 위해 bitflags와 조합하므로, 여기서도 그렇게 했습니다.
소스 코드는 github.com/ferrous-systems/handling-system-registers/tree/main/safe-mmio-example에 있고, 문서는 registry.ferrocene.dev/docs/safe-mmio-example에 있습니다.
문서부터 보면, 이전처럼 사용 가능한 레지스터를 매우 명확하게 보여주는 repr(C) 구조체 UartRegisters가 있습니다. 이는 tock-registers와 매우 비슷해 보입니다. Control 타입을 클릭하면 레지스터 안 비트필드에 대한 상수가 있고, 합집합/교집합에 대한 메서드가 보이지만, 비트필드를 어떻게 수정하는지가 명확하지 않습니다. 알고 보면 레지스터 안 각 비트필드에 대해 Control 값을 만들고, 그것들을 |로 OR 한 뒤 결합된 값을 레지스터에 쓰는 방식입니다. 예제 코드는 다음과 같습니다:
/// UART parity
pub enum Parity {
/// No Parity
None,
/// Odd Parity
Odd,
/// Even Parity
Even,
}
/// Represents a UART
pub struct Uart<'a> {
regs: UniqueMmioPointer<'a, UartRegisters>,
}
impl<'a> Uart<'a> {
/// Create a UART driver, with the UART at the given address
pub const fn new(regs: UniqueMmioPointer<'a, UartRegisters>) -> Uart<'a> {
Uart { regs }
}
/// Configure the UART
pub fn configure(&mut self, enabled: bool, baud: u32, parity: Parity) {
let p = match parity {
Parity::None => Control::empty(),
Parity::Odd => Control::PARITY_ENABLE,
Parity::Even => Control::PARITY_ENABLE | Control::PARITY_EVEN,
};
let en = if enabled {
Control::ENABLE
} else {
Control::empty()
};
let baud = Control::from_bits((baud << 1) & Control::BAUD_RATE.bits()).unwrap();
field!(self.regs, control).write(p | en | baud);
}
/// Transmit a byte
///
/// Blocks until space available
pub fn transmit(&mut self, byte: u8) {
while !field!(self.regs, status).read().contains(Status::TX_READY) {
core::hint::spin_loop();
}
field!(self.regs, fifo).write(byte as u32);
}
}
역시 이것도 저는 작성하기가 좀 성가롭다고 느꼈습니다. 예를 들어 보드레이트 필드를 설정하는 방법이 명확하지 않았습니다. 또 bitflags 매크로가 enum을 생성해주지 않아서 Parity 같은 커스텀 enum을 만들어야 했습니다. 레지스터 접근은 field!(self.regs, fifo).write(byte as u32) 같은 코드를 통해 이뤄지는데, 읽기엔 다소 어렵지만 문법만 알면 쓰기는 크게 어렵지 않습니다.
제가 보기에 큰 문제 하나는 주변장치를 가리키는 UniqueMmioPointer 핸들을 만드는 부분입니다(기본적으로 *mut UartRegisters지만 소유권 의미론이 추가된 타입입니다). UniqueMmioPointer::new는 core::ptr::NonNull을 요구하는데 합리적이긴 하지만, _그것_을 만들기 위해선 꽤 번거로운 단계를 거쳐야 합니다…
// With tock-registers, I can write:
let mut uart = unsafe { Uart::new(0x4000_0000 as *mut UartRegisters) };
// With safe-mmio, I have to write:
let mut uart = Uart::new(unsafe {
UniqueMmioPointer::new(NonNull::new_unchecked(0x4000_0000 as *mut UartRegisters))
});
safe-mmio의 추가적인 장점은 ptr::write_volatile과 ptr::read_volatile API 사용을 피한다는 것입니다. 이 API들은 동작하긴 하지만, Arm 아키텍처에서 LLVM은 때때로 _레지스터 write-back_을 수행하는 명령 인코딩을 선택합니다. 즉, “레지스터 안 주소로 로드/스토어하고, 동시에 그 레지스터의 주소에 4를 더하는” 어셈블리 명령입니다.
// store "w9" to "(address in x0) + 4", *and then* set x0 = (x0 + 4)
str w9, [x0], #4
이런 명령은 유용합니다. 한 명령이 두 개보다 더 작고 빠르니까요. 하지만 이슈가 있습니다. AArch64에서 하이퍼바이저 위에서 코드가 실행되고, 메모리 영역이 하이퍼바이저로 트랩되도록 설정되어 있다면(예: 게스트 OS를 대신해 하이퍼바이저가 에뮬레이션하는 가상 UART), 하이퍼바이저는 로드/스토어와 주소에 대한 정보는 받지만 writeback에 대한 정보는 받지 못합니다. 그 결과 레지스터가 업데이트되어야 하는데 업데이트되지 않아 프로그램이 잘못 실행됩니다. safe-mmio가 쓰는 우회책은 자신들만의 volatile read/write 함수를 두는 것인데, 이는 writeback을 수행하지 않는 명령을 사용하도록 인라인 어셈블리로 구현됩니다.
전반적으로 safe-mmio가 하려는 일은 마음에 듭니다. MMIO 핸들 타입의 생성자가 다소 부담스럽긴 해도요. 다만 bitflags는 그리 인상적이지 않았고, 레지스터에 1비트보다 넓은 비트필드가 있다면 다른 대안을 찾고 싶습니다.
derive-mmio재미있는 이야기입니다. 저는 Google이 safe-mmio를 작성한 것과 거의 같은 시기에 derive-mmio를 작성했습니다. 그들의 크레이트를 더 일찍 봤다면 제가 이것을 쓰지 않았을 수도 있고, 반대로도 마찬가지일 수 있습니다. 하지만 우리가 같은 결정을 한 지점과, 다른 길을 택한 지점을 비교해보는 것은 흥미롭다고 생각합니다.
tock-registers와 safe-mmio처럼 주변장치를 설명하기 위해 repr(C) struct를 만듭니다. 하지만 각 레지스터를 읽기-쓰기/읽기 전용으로 표시하기 위한 특수 타입을 쓰는 대신, derive-mmio 매크로가 이해하는 애트리뷰트를 사용합니다.
소스 코드는 github.com/ferrous-systems/handling-system-registers/tree/main/derive-mmio-example에 있고, 문서는 registry.ferrocene.dev/docs/derive-mmio-example에 있습니다.
문서를 열면 두 타입이 보입니다. MMIO 핸들(예: safe-mmio의 UniqueMmioPointer 같은)인 MmioUartRegisters와, repr(C) struct인 UartRegisters입니다. struct를 클릭하면 필드가 명확히 나열되고 문서화되어 있습니다. 각 레지스터의 타입에 대한 세부사항도 클릭해서 볼 수 있습니다. 여기서는 저는 bitbybit 크레이트를 사용하기로 했습니다. Control 타입에는 fn baud_rate(&self) -> u23 및 fn set_enable(&mut self, field_value: bool) 같은 비트필드별 메서드가 있습니다. u23은 오타가 아닙니다. bitbybit는 arbitrary-int를 사용해 “비트 폭이 임의의” 정수 타입을 제공합니다.
MmioUartRegisters 타입을 보면, 구조체 정의에 주어진 애트리뷰트에 따라 각 레지스터를 읽고/쓰고/수정하는 메서드가 생성되어 있습니다. 즉 safe-mmio처럼 field! 매크로를 강제하는 대신, 자동 생성된 메서드(그리고 문서)를 제공합니다.
API 사용 예시는 다음과 같습니다:
/// Represents a UART
pub struct Uart<'a> {
regs: MmioUartRegisters<'a>,
}
impl<'a> Uart<'a> {
/// Create a UART driver, with the UART at the given address
pub const fn new(regs: MmioUartRegisters<'a>) -> Uart<'a> {
Uart { regs }
}
/// Configure the UART
pub fn configure(&mut self, enabled: bool, baud: u32, parity: Parity) {
self.regs.modify_control(|w| {
w.with_baud_rate(u23::from_u32(baud));
w.with_enable(enabled);
w.with_parity(parity);
w
});
}
/// Transmit a byte
///
/// Blocks until space available
pub fn transmit(&mut self, byte: u8) {
while !self.regs.read_status().tx_ready() {
core::hint::spin_loop();
}
self.regs.write_fifo(byte as u32);
}
}
이 클로저 문법은 svd2rust의 문법에서 영감을 받았고, 자동완성과도 궁합이 꽤 좋습니다. 또한 read API는 tock-registers처럼 특정 필드만 읽는 방식이 아니라, 기본적으로 레지스터 전체를 반환합니다.
MmioUartRegisters 타입에는 usize를 받는 깔끔한 생성자도 있습니다:
let regs = unsafe { UartRegisters::new_mmio_at(0x4000_0000) };
하지만 현재 derive-mmio는 volatile read/write에 의존하므로, LLVM이 AArch64 하이퍼바이저가 트랩된 I/O를 정확히 에뮬레이션하지 못하는 MMIO load/store 명령을 선택할 위험이 있습니다. 이는 향후 추가하고 싶은 부분입니다. Cortex-R82 기반 장치들이 시장에 나오면서 AArch64 지원은 임베디드 시스템에서 점점 더 중요해질 것입니다.
만약 이 API 스타일을 코프로세서 레지스터에 적용하고 싶다면, bitbybit 타입을 사용하고, 적절한 인라인 어셈블리 연산을 수행하는 read/write 메서드를 가진 struct를 작성하면 됩니다.
요약하면, 저는 이 접근을 문서 친화적이면서도 읽기 쉬운 방식으로 설계했습니다. 생성된 코드는(구현 수준의 문서가 잘 나오지 않는) 트레이트 구현에 기대기보다는, (문서를 포함한) 자동 생성 메서드에 기대도록 했습니다. svd2rust의 아이디어를 차용하면서도, tock-registers처럼 주변장치 단위로 드라이버를 구성할 수 있게 했습니다. 한 가지 바꿀 수 있는 점이 있다면, UART 레지스터 타입들을 registers 서브모듈로 옮겨서, 주변장치 전체를 나타내는 타입들과 더 명확히 구분할 수도 있겠다는 생각입니다.
우리는 하드웨어에 접근하는 세 가지 방법, 즉 I/O 주소 지정, 코프로세서(또는 시스템) 레지스터, 메모리 매핑 I/O를 이야기했습니다. 그리고 MMIO API를 구성하는 네 가지 주요 접근인 svd2rust, tock-registers, safe-mmio + bitflags, derive-mmio + bitbybit을 살펴봤습니다.
완벽한 해법은 없고, 각각 장점이 있습니다. 전체 MCU에 대한 저수준 드라이버를 한 번에 생성해주기 때문에 svd2rust(또는 chiptool 같은 파생 도구)를 좋아할 수도 있습니다. 하지만 10,000페이지짜리 데이터시트가 있는 자동차용 SoC처럼 SVD 파일이 없다면 그 접근은 통하지 않습니다. 또한 svd2rust가 마침내 MMIO 레퍼런스 타입에서 벗어나는 것도 보고 싶습니다. 이는 tock-registers에도 해당합니다.
tock-registers가 주변장치 단위로 드라이버를 정의하게 해주는 점은 좋지만, 저는 특정 필드나 레지스터에 대해 필요한 문서를 찾기가 어렵다고 느낍니다. safe-mmio는 MMIO 레퍼런스 문제를 해결하면서 동시에 AArch64 하이퍼바이저 문제도 해결한다는 점이 좋지만, 필드가 단순 불리언보다 넓은 값이거나 열거형인 경우에는 bitflags보다 bitbybit을 조합하는 편이 더 낫다고 생각합니다. bitbybit API가 그런 경우에 더 잘 동작하니까요.
마지막으로, 제가 쓴 것이니 당연히 저는 derive-mmio를 살펴보라고 권하고 싶습니다. 하지만 동시에, 여러분 모두가 자신의 소프트웨어에 대해 cargo doc를 조금 더 자주 실행해보고, “사용자가 이 문서를 통해 자신의 질문을 어떻게 해결할 수 있을까?”를 자문해보길 권합니다.
Ana
February 2026
Jessie
December 2025
Julia
March 2026