임베디드 Rust: 마이크로컨트롤러, no_std, Embassy
임베디드 Rust란 물건 안에 들어 있는 작은 컴퓨터용으로 Rust를 쓰는 걸 말해요. 온도 조절기, 키보드, 드론, 화분에 물 주는 센서 같은 것들이요. 이런 컴퓨터를 마이크로컨트롤러(microcontroller)라고 불러요. 칩 하나에 프로세서, 몇백 킬로바이트의 메모리, 그리고 LED, 버튼, 센서와 이야기하는 핀이 다 들어 있어요. 보통은 운영체제가 아예 없어요. 전원을 켤 때부터 끌 때까지 여러분의 프로그램 하나만 돌아가요.
무섭게 들리지만, 그만큼 정말 재미있기도 해요. 몇 줄만 쓰면 책상 위에서 진짜 불빛이 깜빡이거든요. 이 페이지에서는 무엇을 할 수 있는지, 어떤 보드와 크레이트를 고르면 좋은지, 그리고 첫 프로그램을 칩에 올리는 방법을 보여 드릴게요. 그러면서 브라우저에서 바로 실행할 수 있는 예제로 “임베디드식 사고방식”도 연습해 볼 거예요.
Rust가 임베디드와 잘 맞는 이유
섹션 제목: “Rust가 임베디드와 잘 맞는 이유”수십 년 동안 임베디드 코드는 주로 C로 작성됐어요. Rust는 작은 칩에서 아주 중요한 몇 가지를 가져와요.
- 가비지 컬렉터가 없어요. 메모리를 정리하려고 프로그램을 멈추는 일이 없으니, 타이밍을 예측할 수 있어요. 신호에 마이크로초 단위로 반응해야 할 때 중요해요.
- 런타임 없이 메모리 안전해요. 소유권과 대여 규칙은 컴파일할 때 검사되니까, 칩 위에서는 비용이 전혀 없어요. 댕글링 포인터나 인터럽트와 메인 루프 사이의 데이터 경합 같은 온갖 종류의 크래시를, 플래시에 올리기도 전에 잡아내요.
- 비용 없는 추상화(zero-cost abstraction). HAL 덕분에 메모리 주소를 직접 건드리는 대신
led.set_high()라고 쓸 수 있어요. 그래도 컴파일하면 손으로 쓴 것과 똑같은 레지스터 쓰기 한 번이 돼요. - 소유권으로 하드웨어를 표현해요. 핀은 하나의 값이에요. 핀이
Output으로 이동(move)되고 나면, 다른 누구도 그 핀을 가져가서 실수로 입력으로 쓸 수 없어요. 예전에는 C 파일의 주석에 적어 두던 규칙을 컴파일러가 강제해 줘요. - 도구가 훌륭해요.
cargo,rustup타깃,probe-rs가 제조사 IDE와 makefile의 상당 부분을 대신해요.
아쉬운 점도 솔직하게 말할게요. 칩 제조사들은 아직 SDK와 예제를 C로 먼저 내놓아서, 어떤 칩은 Rust 지원이 부족하거나 커뮤니티에서만 지원해요. 데이터시트도 여전히 읽어야 해요. 그리고 일부 크레이트는 1.0 전이라 릴리스마다 API가 바뀌니, 항상 내가 쓰는 버전의 예제를 따라 하세요.
무엇을 만들 수 있을까요
섹션 제목: “무엇을 만들 수 있을까요”- 센서와 작은 기기: 기상 관측기, 흙 습도 알림이, 만보기
- USB 기기: 커스텀 키보드, 게임 컨트롤러, MIDI 컨트롤러
- 인터넷에 연결된(IoT) 기기: ESP32로 측정값을 서버에 보내는 Wi-Fi나 블루투스 센서
- 로봇과 모터: 라인 트레이서, 스테퍼 모터와 서보 제어, LED 스트립과 디스플레이
- 본격적인 시스템: Oxide Computer는 마이크로컨트롤러용 작은 운영체제인 Hubris(영어)를 전부 Rust로 만들었어요. ESP32를 만든 회사인 Espressif는 자체적으로 Rust 지원을 관리하고 The Rust on ESP Book(영어)을 펴내요.
시작하기 쉬운 보드
섹션 제목: “시작하기 쉬운 보드”다음 중 어떤 것이든 첫 보드로 좋아요. 고르기 어렵다면 Raspberry Pi Pico를 고르세요. 싸고, 자료가 아주 많고, 아래의 Embassy 예제도 Pico용으로 쓰여 있어요.
| 보드 | 칩 | Rust 타깃 | 좋은 점 | 시작할 크레이트 |
|---|---|---|---|---|
| Raspberry Pi Pico / Pico W | RP2040 (Arm Cortex-M0+) | thumbv6m-none-eabi |
싸고 인기가 많고, Embassy 지원이 훌륭해요 | embassy-rp, 또는 rp2040-hal + rp-pico |
| Raspberry Pi Pico 2 | RP2350 (Arm Cortex-M33 또는 RISC-V) | thumbv8m.main-none-eabihf |
더 새롭고 빠르며, 부동소수점 장치가 있어요 | embassy-rp(피처 rp235xa), rp235x-hal |
| ESP32-C3 / ESP32-C6 보드 | Espressif RISC-V 칩 | riscv32imc-unknown-none-elf / riscv32imac-unknown-none-elf |
Wi-Fi와 블루투스가 내장되어 있고, 공식 Rust 지원이 있어요 | esp-hal과 esp-generate 도구 |
| STM32 Nucleo, “Black Pill” | STM32 (예: STM32F4) | thumbv7em-none-eabihf |
산업 현장에서 쓰는 거대한 칩 제품군이에요. Nucleo 보드에는 디버그 프로브가 내장되어 있어요 | embassy-stm32, stm32f4xx-hal |
| BBC micro:bit v2 | nRF52833 (Arm Cortex-M4F) | thumbv7em-none-eabihf |
디버그 프로브, LED, 버튼, 센서가 보드에 다 있어요 | microbit-v2, 그리고 Discovery 책 |
micro:bit은 공식 Embedded Rust Discovery 책(영어)에서 쓰는 보드예요. 초보자를 위한 무료 단계별 강좌예요. 원조 ESP32와 ESP32-S3는 RISC-V 대신 Xtensa 코어를 써요. 이 칩들에는 Espressif의 espup 도구가 특별한 Rust 툴체인을 설치해 줘요. Pico W의 LED는 일반 핀이 아니라 Wi-Fi 칩에 연결되어 있어서, 다른 블링키가 필요해요(Embassy에 이를 위한 wifi_blinky 예제가 있어요).
no_std와 no_main 알아보기
섹션 제목: “no_std와 no_main 알아보기”평범한 Rust 프로그램은 표준 라이브러리(std)를 써요. std는 운영체제가 있다고 가정해요. 파일, 스레드, 힙, println!을 위한 터미널 같은 것들이요. 마이크로컨트롤러에는 그런 게 하나도 없어요. 그래서 임베디드 프로그램은 다음 두 줄로 시작해요.
#![no_std] // don't link the standard library, only `core`#![no_main] // don't use the usual `fn main()` entry point#를 붙이면core(영어)만 쓸 수 있어요. 정수,Option,Result, 반복자, 슬라이스, 트레이트, 포매팅 같은 것들이에요.Vec,String,Box, 파일, 스레드는 없어요. (할당자가 있으면alloc크레이트로Vec등을 다시 쓸 수 있지만, 그게 필요 없는 프로젝트도 많아요.)#진입점을 쓰지 마라”)이 필요한 이유는, 칩 위에는main을 호출해 줄 운영체제가 없기 때문이에요.cortex-m-rt같은 런타임 크레이트가 리셋 후에 메모리를 준비하고 여러분의 함수로 점프해요. 그 함수에는#[cortex_m_rt::entry]나, Embassy라면#[embassy_executor::main]같은 속성을 붙여요.- 패닉이 나면 어떻게 할지도 정해 줘야 해요. 출력할 터미널이 없거든요.
panic-probe나panic-halt같은 크레이트가 이걸 제공해요.use panic_probe as _;처럼 써서 추가해요.
Vec이 없으면 목록은 어떻게 저장할까요? heapless(영어) 크레이트가 컴파일할 때 정해지는 고정 용량을 가진 Vec, String, 큐를 제공해요. 이것들은 스택이나 static 안에 있어서 힙이 필요 없고, 메모리가 부족해서 깜짝 놀랄 일도 없어요. 버퍼가 가득 차면 넣으려던 값을 그냥 돌려줘요.
use core::fmt::Write;use heapless::{String, Vec};
fn main() { // 측정값이 정확히 4개 들어갈 자리. 메모리는 변수 자체의 일부예요. // 힙도 할당자도 없고, 크기는 컴파일할 때 정해져요. let mut readings: Vec<i32, 4> = Vec::new();
for value in [215, 218, 221, 224, 227] { match readings.push(value) { Ok(()) => println!("stored {value}"), Err(rejected) => println!("buffer full, dropped {rejected}"), } }
// 용량이 16바이트로 고정된 String let mut line: String<16> = String::new(); write!(line, "avg={}", readings.iter().sum::<i32>() / readings.len() as i32).unwrap(); println!("{line} (capacity {})", line.capacity());}heapless는 PC에서도 동작하니까, cargo add [email protected]와 cargo run으로 직접 해 볼 수 있어요(Playground에는 없어요).
stored 215stored 218stored 221stored 224buffer full, dropped 227avg=219 (capacity 16)층층이 쌓인 생태계
섹션 제목: “층층이 쌓인 생태계”임베디드 Rust 크레이트는 서로 위에 차곡차곡 쌓여 있어요. 주로 HAL이나 프레임워크 수준에서 작업하게 되겠지만, 그 아래에 무엇이 있는지 알아 두면 도움이 돼요.
| 층 | 무엇인가요 | 예 |
|---|---|---|
| PAC (Peripheral Access Crate) | 칩의 레지스터 설명 파일에서 자동으로 만들어져요. 레지스터와 필드마다 어느 정도 안전한 함수가 하나씩 있어요. 아주 저수준이에요. | rp2040-pac, stm32f4, esp32c3 |
| HAL (Hardware Abstraction Layer) | 한 칩 제품군을 위한 쓰기 편한 타입들이에요. Output, Uart, I2c, 타이머 등. PAC 위에 만들어져요. |
rp2040-hal, esp-hal, stm32f4xx-hal, embassy-rp, embassy-stm32 |
embedded-hal 트레이트 |
OutputPin, I2c, SpiDevice, DelayNs 같은 공용 트레이트예요. HAL이 구현하고, 드라이버가 사용해요. 1.0 버전은 안정적이에요. |
embedded-hal, embedded-hal-async |
| BSP (Board Support Package) | HAL에 특정 보드 하나의 핀과 부품 이름(“LED”, “버튼 A”)을 더한 거예요. | rp-pico, microbit-v2 |
| RTIC | 인터럽트 중심 앱을 위한 프레임워크예요. 작업이 인터럽트와 우선순위에 연결되고, 공유 데이터는 컴파일러가 빌드할 때 검사해요. | rtic |
| Embassy | 비동기 프레임워크예요. 실행기(executor), 타이머, 네트워킹, USB와 자체 HAL을 제공해요. async fn 작업을 쓰고, 하드웨어 이벤트를 .await해요. |
embassy-executor, embassy-time, embassy-rp |
마법은 가운데 줄에 있어요. embedded-hal의 I2c 트레이트에 맞춰 작성한 온도 센서 드라이버는 Pico, ESP32, STM32에서 고치지 않고 그대로 동작해요. 각 HAL이 같은 트레이트를 구현하기 때문이에요. 아래에서 이런 드라이버를 직접 만들어 볼 거예요.
타깃과 probe-rs 설치하기
섹션 제목: “타깃과 probe-rs 설치하기”여러분의 PC에는 x86이나 Arm 데스크톱 프로세서가 있고, Pico에는 Cortex-M0+가 있어요. 그래서 크로스 컴파일(cross-compile)을 해요. 한 기계에서, 다른 기계를 위해 빌드하는 거예요. rustup이 그 칩용으로 미리 컴파일된 core 라이브러리를 내려받아 줘요.
rustup target add thumbv6m-none-eabi # RP2040 (Pico)rustup target add thumbv8m.main-none-eabihf # RP2350 (Pico 2)rustup target add thumbv7em-none-eabihf # micro:bit v2, STM32F4프로그램을 칩에 복사하고 로그를 보려면 probe-rs(영어)와 디버그 프로브가 필요해요. 디버그 프로브는 칩의 디버그 핀에 연결하는 작은 USB 장치예요. micro:bit과 STM32 Nucleo 보드에는 내장되어 있어요. Pico라면 Raspberry Pi Debug Probe를 쓰거나, 무료 debugprobe 펌웨어를 올린 Pico를 하나 더 쓰면 돼요. probe-rs는 설치 스크립트로 설치하거나(Windows는 설치 페이지(영어) 참고) cargo로 설치해요. 아래에서 # or, slower, from source:는 “또는, 더 느리지만 소스에서 빌드”라는 뜻이고, probe-rs list는 연결된 프로브 목록을 보여 줘요.
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh# or, slower, from source:cargo install probe-rs-tools --locked
probe-rs list # shows the probes it can see아직 프로브가 없나요? Pico의 BOOTSEL 버튼을 누른 채로 꽂으면 USB 드라이브로 나타나요. picotool이나 elf2uf2-rs 같은 도구로 그렇게 프로그램을 복사할 수 있어요. 다만 프로브 없이는 defmt 로그를 볼 수 없어요.
ESP32-C3나 C6라면 보통 프로브가 전혀 필요 없어요. 칩에 USB가 내장되어 있어서, espflash 도구가 평범한 USB 케이블로 플래시해 줘요. cargo install esp-generate espflash --locked를 실행한 다음 esp-generate로 칩과 옵션을 고르면, cargo run --release가 빌드하고, 플래시하고, 로그까지 보여 줘요.
Raspberry Pi Pico에서 Embassy로 LED 깜빡이기
섹션 제목: “Raspberry Pi Pico에서 Embassy로 LED 깜빡이기”“블링키”(blinky)는 임베디드의 “Hello, world!“예요. LED를 켜고, 기다리고, 끄고, 기다리기를 영원히 반복해요. 다음은 공식 Embassy RP2040 블링키 예제(영어)에 주석을 덧붙인 거예요. 진짜 보드가 필요하고 Playground에서는 실행되지 않아요.
#![no_std]#![no_main]
use defmt::*;use defmt_rtt as _; // defmt 로그를 프로브로 보내요use embassy_executor::Spawner;use embassy_rp::gpio;use embassy_time::Timer;use gpio::{Level, Output};use panic_probe as _; // 패닉이 나면: 프로브로 메시지를 출력하고 멈춰요
#[embassy_executor::main(executor = "embassy_rp::executor::Executor", entry = "cortex_m_rt::entry")]async fn main(_spawner: Spawner) { // 칩의 모든 주변 장치(peripheral)의 소유권을 딱 한 번 가져와요 let p = embassy_rp::init(Default::default()); // Pico의 LED는 25번 핀에 있어요. PIN_25가 `led`로 이동해요. let mut led = Output::new(p.PIN_25, Level::Low);
loop { info!("led on!"); led.set_high(); Timer::after_millis(500).await;
info!("led off!"); led.set_low(); Timer::after_millis(500).await; }}눈여겨볼 점이 몇 가지 있어요.
main은async fn함수예요.Timer::after_millis(500).await는 바쁜 반복문으로 시간을 때우지 않아요. 타이머가 울릴 때까지 실행기가 칩을 재워요. 작업이 더 있다면(Spawner로 띄운 작업들), 이 작업이 기다리는 동안 다른 작업들이 실행돼요. async/await가 처음이라면, PC에서 Tokio를 쓸 때와 같은 개념이에요. 운영체제가 없다는 것만 달라요.p.PIN_25는Output::new로 이동해요.p.PIN_25를 한 번 더 쓰려고 하면 컴파일러가 막아요. 하드웨어에 적용한 소유권이에요.use defmt_rtt as _;와use panic_probe as _;는 이름을 쓰려는 게 아니라, 그 크레이트가 준비해 주는 것(로그 채널과 패닉 핸들러)만을 위해 가져오는 거예요.
깜빡이는 걸 가장 빨리 보는 방법은 공식 예제 폴더를 그대로 쓰는 거예요. Embassy 저장소를 클론한 다음, examples/rp에서 cargo run --bin blinky --release를 실행하세요. 내 프로젝트를 만든다면, 의존성은 그 예제의 Cargo.toml에서 가져와요(마지막 줄의 주석은 “probe-rs를 위해 디버그 정보를 남긴다. 칩에 플래시되지는 않는다”는 뜻이에요).
[package]name = "blinky"version = "0.1.0"edition = "2024"
[dependencies]embassy-executor = { version = "0.10.0", features = ["defmt"] }embassy-time = { version = "0.5.1", features = ["defmt", "defmt-timestamp-uptime"] }embassy-rp = { version = "0.10.0", features = ["defmt", "unstable-pac", "time-driver", "critical-section-impl", "rp2040", "executor-thread"] }defmt = "1.0.1"defmt-rtt = "1.0.0"panic-probe = { version = "1.0.0", features = ["print-defmt"] }cortex-m = { version = "0.7.6", features = ["inline-asm"] }cortex-m-rt = "0.7.0"
[profile.release]debug = true # keep debug info for probe-rs; it isn't flashed to the chip파일 두 개가 더 있어요. 무엇을 빌드하고 어떻게 실행할지 cargo에게 알려 주는 파일이에요. .cargo/config.toml은 타깃을 정하고, cargo run이 probe-rs로 플래시하게 만들어요.
[target.'cfg(all(target_arch = "arm", target_os = "none"))']runner = "probe-rs run --chip RP2040"
[build]target = "thumbv6m-none-eabi"
[env]DEFMT_LOG = "debug"마지막으로 같은 examples/rp 폴더에서 memory.x(칩에서 플래시와 RAM이 어디에 있는지)와 build.rs(링커 스크립트를 넘겨줘요)를 복사하세요. 그런 다음 프로브를 꽂고 실행해요.
cargo run --releasecargo가 thumbv6m-none-eabi용으로 빌드하고, probe-rs가 칩에 플래시하고, LED가 깜빡이기 시작하면서 led on! / led off! 메시지가 타임스탬프와 함께 터미널에 흘러나와요.
defmt로 로그 남기기
섹션 제목: “defmt로 로그 남기기”마이크로컨트롤러에는 화면이 없고, core::fmt로 글자를 포매팅하면 플래시 공간을 많이 차지해요. defmt(영어, “deferred formatting”, 즉 미뤄 둔 포매팅)는 둘 다 해결해요. 포맷 문자열은 칩이 아니라 PC에 저장돼요. 칩은 작은 ID와 날것의 값만 프로브로 보내고, probe-rs가 그걸 다시 읽을 수 있는 글자로 바꿔 줘요.
use defmt::{info, warn, Format};
#[derive(Format)] // #[derive(Debug)]와 비슷하지만, defmt용이에요pub struct Reading { pub sensor_id: u8, pub milli_celsius: i32,}
pub fn report(r: &Reading) { info!("new reading: {}", r); if r.milli_celsius > 60_000 { // `=u8`과 `=i32`로 defmt에게 정확한 타입을 알려 주면, 공간을 더 아낄 수 있어요 warn!("sensor {=u8} is too hot: {=i32} mC", r.sensor_id, r.milli_celsius); }}로그 레벨은 log 크레이트와 똑같이 trace!, debug!, info!, warn!, error!예요. .cargo/config.toml의 DEFMT_LOG 설정이 어떤 레벨을 아예 컴파일에 포함할지 골라요. 그보다 낮은 레벨은 0바이트예요.
레지스터로 생각하기: 비트와 마스크
섹션 제목: “레지스터로 생각하기: 비트와 마스크”모든 HAL 아래에서, 하드웨어를 제어한다는 건 레지스터(register)에 숫자를 쓰는 거예요. 레지스터는 각 비트가 칩의 무언가를 켜고 끄는 특별한 메모리 주소예요. 흔한 칩에서는 32비트 GPIO 출력 레지스터에 핀마다 비트가 하나씩 있어요. 여기서는 평범한 u32가 그 레지스터 역할을 해요. 그러니 모든 임베디드 개발자가 쓰는 네 가지 동작을 연습할 수 있어요.
const LED: u32 = 25; // Pico 보드의 LED는 25번 핀에 있어요
fn main() { // 이 u32가 칩의 GPIO 출력 레지스터라고 생각해요. // 비트 0은 핀 0을, 비트 1은 핀 1을 제어하는 식이에요. let mut gpio_out: u32 = 0b1000; // 핀 3은 이미 켜져 있어요
// 비트 하나 켜기: 마스크와 OR gpio_out |= 1 << LED; println!("LED on: {gpio_out:#034b}");
// 비트 하나 읽기: 마스크와 AND 한 다음 0과 비교 let led_is_on = (gpio_out & (1 << LED)) != 0; let pin2_is_on = (gpio_out & (1 << 2)) != 0; println!("LED on? {led_is_on}, pin 2 on? {pin2_is_on}");
// 비트 하나 뒤집기: 마스크와 XOR gpio_out ^= 1 << LED; println!("toggled: {gpio_out:#034b}");
// 비트 하나 끄기: 뒤집은 마스크와 AND gpio_out |= 1 << LED; gpio_out &= !(1 << 3); println!("pin 3 off: {gpio_out:#034b}"); println!("as hex: {gpio_out:#010x}");}실행 결과:
LED on: 0b00000010000000000000000000001000LED on? true, pin 2 on? falsetoggled: 0b00000000000000000000000000001000pin 3 off: 0b00000010000000000000000000000000as hex: 0x02000000Rust에서 정수에 !를 쓰면 모든 비트가 뒤집혀요(C에서는 ~를 써요). {:#034b}는 값을 0b 접두사와 함께 32자리로 채워서 2진수로 출력하니까, 비트 25가 켜졌다 꺼지는 걸 볼 수 있어요. 출력은 칸을 맞춰야 해서 영어로 두었어요. 비트 연산자 전체 목록은 연산자에서 볼 수 있어요.
많은 레지스터는 숫자 하나에 여러 필드를 담아요. 속도 설정에 몇 비트, “켜짐”에 한 비트 하는 식이에요. 필드 하나만 바꾸려면 읽기-수정-쓰기(read-modify-write)를 해요. 그 필드의 비트를 지우고, 새 값을 OR로 넣어서, 다른 비트는 전혀 건드리지 않는 거예요.
// 지어낸 UART 제어 레지스터:// 비트 0 = 켜기// 비트 4..=6 = 속도 설정(3비트 숫자, 0~7)const ENABLE: u32 = 1 << 0;const SPEED_SHIFT: u32 = 4;const SPEED_MASK: u32 = 0b111 << SPEED_SHIFT;
fn read_speed(reg: u32) -> u32 { (reg & SPEED_MASK) >> SPEED_SHIFT}
fn write_speed(reg: u32, speed: u32) -> u32 { // 예전 필드를 지우고, 새 값을 넣어요. // 다른 비트는 모두 원래 그대로 남아요. (reg & !SPEED_MASK) | ((speed << SPEED_SHIFT) & SPEED_MASK)}
fn main() { let mut ctrl: u32 = ENABLE | (2 << SPEED_SHIFT); println!("start: {ctrl:#010b}, speed = {}", read_speed(ctrl));
ctrl = write_speed(ctrl, 5); println!("speed 5: {ctrl:#010b}, speed = {}", read_speed(ctrl));
// 9는 3비트에 들어가지 않아요. 마스크가 값을 잘라 내서 // 이웃한 비트로 넘쳐 들어가지 않게 막아요. ctrl = write_speed(ctrl, 9); println!("speed 9?: {ctrl:#010b}, speed = {}", read_speed(ctrl));
let still_enabled = (ctrl & ENABLE) != 0; println!("still enabled: {still_enabled}");}실행 결과:
start: 0b00100001, speed = 2speed 5: 0b01010001, speed = 5speed 9?: 0b00010001, speed = 1still enabled: true9가 아무 말 없이 1이 된 걸 보세요. 바로 이런 버그로부터 PAC와 HAL이 지켜 줘요. 필드마다 이름이 붙은 메서드를 따로 주기 때문에(값에도 이름이 붙어 있는 경우가 많아요), 마스크를 손으로 쓸 일이 거의 없어요. 진짜 하드웨어에서 레지스터는 고정된 메모리 주소이고, unsafe 코드 안에서 core::ptr::write_volatile로 써요. 그 부분은 PAC가 대신 해 줘요.
PC에서 테스트할 수 있는 드라이버 만들기
섹션 제목: “PC에서 테스트할 수 있는 드라이버 만들기”배울 수 있는 가장 좋은 임베디드 습관은 이거예요. 로직을 칩 하나가 아니라 트레이트에 맞춰 작성하세요. 그러면 같은 코드가 보드에서는 진짜 HAL과 함께, PC에서는 가짜(“목”, mock) 부품과 함께 평범한 테스트로 돌아가요. 여기서 StatusLed에 필요한 건 “켜고 끌 수 있는 무언가”와 “기다릴 수 있는 무언가”뿐이에요.
use std::cell::RefCell;use std::rc::Rc;
// LED 깜빡이 드라이버에 필요한 두 가지 능력. embedded-hal 1.0의// OutputPin과 DelayNs 트레이트를 Playground에서 돌아가도록 단순하게 흉내 냈어요.trait OutputPin { fn set_high(&mut self); fn set_low(&mut self);}
trait Delay { fn delay_ms(&mut self, ms: u32);}
// 드라이버는 어떤 칩도 몰라요. 트레이트만 써요.struct StatusLed<P: OutputPin, D: Delay> { pin: P, delay: D,}
impl<P: OutputPin, D: Delay> StatusLed<P, D> { // `times`번 깜빡여요: `on_ms` 동안 켜고, `off_ms` 동안 꺼요. fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) { for _ in 0..times { self.pin.set_high(); self.delay.delay_ms(on_ms); self.pin.set_low(); self.delay.delay_ms(off_ms); } }}
// --- PC에서 테스트하기 위한 가짜 부품 ---// 두 가짜 부품이 같은 공유 로그에 기록해서, 순서를 확인할 수 있어요.type Log = Rc<RefCell<Vec<String>>>;
struct MockPin { log: Log,}
impl OutputPin for MockPin { fn set_high(&mut self) { self.log.borrow_mut().push("on".to_string()); } fn set_low(&mut self) { self.log.borrow_mut().push("off".to_string()); }}
struct MockDelay { log: Log,}
impl Delay for MockDelay { fn delay_ms(&mut self, ms: u32) { self.log.borrow_mut().push(format!("wait {ms}")); }}
fn main() { let log: Log = Rc::new(RefCell::new(Vec::new())); let pin = MockPin { log: Rc::clone(&log) }; let delay = MockDelay { log: Rc::clone(&log) };
let mut led = StatusLed { pin, delay }; led.blink(2, 100, 400);
let events = log.borrow(); println!("{}", events.join(", "));
assert_eq!(events.len(), 8); assert_eq!(events[0], "on"); assert_eq!(events[1], "wait 100"); println!("all checks passed");}실행 결과:
on, wait 100, off, wait 400, on, wait 100, off, wait 400all checks passed두 목은 Rc<RefCell<...>>로 로그 하나를 공유해서(스마트 포인터 참고), 이벤트의 정확한 순서를 확인할 수 있어요. 실제 프로젝트에서는 assert_eq! 줄들을 #[test] 함수에 넣고, 보드 없이 PC에서 cargo test로 실행해요(Rust 테스트와 테스트 더 알아보기 참고). assert_eq!가 "on" 같은 문자열과 비교하기 때문에, 로그 문자열은 영어로 두었어요.
보드에서는 같은 드라이버를 진짜 embedded-hal 1.0 트레이트에 맞춰 작성해요. 바뀌는 건 두 가지뿐이에요. 핀 메서드가 Result를 반환하니까(I/O 확장 칩에 달린 핀은 실패할 수 있거든요) ?로 에러를 넘겨주고, 코드에 std가 전혀 필요 없어요.
#![no_std]
use embedded_hal::delay::DelayNs;use embedded_hal::digital::OutputPin;
pub struct StatusLed<P, D> { pin: P, delay: D,}
impl<P: OutputPin, D: DelayNs> StatusLed<P, D> { pub fn new(pin: P, delay: D) -> Self { StatusLed { pin, delay } }
pub fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) -> Result<(), P::Error> { for _ in 0..times { self.pin.set_high()?; self.delay.delay_ms(on_ms); self.pin.set_low()?; self.delay.delay_ms(off_ms); } Ok(()) }}Embassy의 Output은 OutputPin을 구현하고 embassy_time::Delay는 DelayNs를 구현하니까, Pico에서 StatusLed::new(led, embassy_time::Delay)가 그대로 동작해요. 이 진짜 트레이트에 맞춰 PC에서 테스트하고 싶다면, embedded-hal-mock(영어) 크레이트가 미리 만들어진 가짜 핀, I2C, SPI 버스를 제공해요.
부동소수점 없이 하는 센서 계산
섹션 제목: “부동소수점 없이 하는 센서 계산”RP2040의 Cortex-M0+처럼 작은 칩 중에는 부동소수점 장치(FPU)가 없는 게 많아요. 부동소수점도 동작은 하지만 소프트웨어로 처리해서 느리고, 프로그램도 커져요. 그래서 임베디드 코드는 고정 소수점(fixed-point) 숫자를 자주 써요. 더 작은 단위의 정수를 쓰는 거예요. 25.25도 대신 25250밀리도로 저장해요.
TMP102 같은 온도 센서는 2바이트를 보내요. 위쪽 12비트가 1/16 °C 단위의 부호 있는 숫자예요.
// 위쪽 12비트가 1/16 °C(0.0625 °C) 단위의 온도예요.// 부호 있는(2의 보수) 숫자예요.fn to_millicelsius(bytes: [u8; 2]) -> i32 { // i16은 부호를 유지해요. 부호 있는 숫자에 `>> 4`를 해도 부호가 유지돼요. let raw = i16::from_be_bytes(bytes) >> 4; // 1/16 °C = 62.5 m°C이니까, 625를 곱하고 10으로 나눠요. raw as i32 * 625 / 10}
// 부동소수점을 전혀 쓰지 않고 25250을 "25.250"으로 출력해요.fn format_milli(m: i32) -> String { let sign = if m < 0 { "-" } else { "" }; let m = m.abs(); format!("{sign}{}.{:03}", m / 1000, m % 1000)}
fn main() { let samples = [ [0x19, 0x40], // 따뜻한 방 [0x00, 0x10], // 0보다 큰 가장 작은 한 단계 [0xFF, 0x80], // 어는점 바로 아래 [0xE7, 0x00], // 냉동실 ];
for bytes in samples { let m = to_millicelsius(bytes); println!("{bytes:02X?} -> {m:>6} m°C = {} °C", format_milli(m)); }}실행 결과:
[19, 40] -> 25250 m°C = 25.250 °C[00, 10] -> 62 m°C = 0.062 °C[FF, 80] -> -500 m°C = -0.500 °C[E7, 00] -> -25000 m°C = -25.000 °C세 가지 덕분에 이게 제대로 동작해요. i16::from_be_bytes는 두 바이트를 상위 바이트가 먼저 오는 순서(“빅 엔디언”, big-endian)로 읽어서 부호 있는 숫자 하나로 만들어요. 대부분의 센서가 이 순서로 데이터를 보내요. 부호 있는 정수에 >> 4를 하면 부호가 유지되니까, 영하 온도도 제대로 나와요. 그리고 format_milli는 부호를 따로 처리해요. 정수 계산에서 -500 / 1000은 0이라서, 이 단계가 없으면 -0.5 °C가 0.500으로 출력될 거예요.
정수 나눗셈은 소수점 아래를 버리기도 해요. 62.5 m°C가 62가 됐죠. 방 온도 센서에서 0.5밀리도는 상관없지만, 내가 고른 단위가 정밀도를 얼마나 주는지는 항상 생각해 보세요. 범위도 조심하세요. 큰 측정값에 큰 수를 곱하면 i32가 오버플로할 수 있어요(정수 오버플로 참고). PC에서는 String을 써도 괜찮지만, 칩에서는 defmt로 출력하거나 heapless::String에 써요.
자주 하는 실수
섹션 제목: “자주 하는 실수”no_std 프로그램에서 std의 것을 쓰기. println!, Vec, String은 std에서 오기 때문에 그냥 없어요.
error: cannot find macro `println` in this scope --> src/main.rs:4:5 |4 | println!("hello"); | ^^^^^^^“이 스코프에서 println 매크로를 찾을 수 없다”는 뜻이에요. 로그에는 defmt::info!를, 컬렉션에는 heapless(또는 할당자와 함께 alloc 크레이트)를 쓰세요.
패닉 핸들러를 잊어버리기. no_std 바이너리는 패닉이 났을 때 무엇을 할지 정해야 해요. panic-probe 같은 크레이트가 없으면 이런 에러가 나요(“#[panic_handler] 함수가 필요한데 찾을 수 없다”).
error: `#[panic_handler]` function required, but not foundCargo.toml에 panic-probe(또는 panic-halt)를 추가하고, main.rs에 use panic_probe as _;를 넣으세요.
레지스터 전체를 덮어쓰기. reg = 1 << 3은 핀 3을 켜지만, 다른 핀은 모두 꺼 버려요. 비트 하나만 바꾸려면 reg |= 1 << 3을 쓰세요. 필드는 위에서 본 읽기-수정-쓰기로 바꾸세요.
오래된 튜토리얼 따라 하기. 임베디드 크레이트는 빠르게 바뀌어서, 몇 년 전 블로그 글은 embedded-hal 0.2나 예전 Embassy API를 쓰는 경우가 많아요. 실제로 의존하는 크레이트 버전의 examples 폴더에서 시작하세요.
연습 문제
섹션 제목: “연습 문제”1. 어떤 핀이 켜져 있을까요?
섹션 제목: “1. 어떤 핀이 켜져 있을까요?”비트 하나를 뒤집는 toggle(reg: u32, pin: u32) -> u32와, 비트가 1인 핀을 모두 나열하는 pins_on(reg: u32) -> Vec<u32>를 작성하세요. 핀 0, 5, 25가 켜진 상태에서 시작해서, 목록과 켜진 핀 개수를 출력하고, 핀 5와 7을 뒤집은 다음 다시 출력하세요. 힌트: 0부터 31까지 각 핀을 마스크로 확인하세요. u32에는 count_ones() 메서드가 있어요.
정답 보기
fn toggle(reg: u32, pin: u32) -> u32 { reg ^ (1 << pin)}
fn pins_on(reg: u32) -> Vec<u32> { (0..32).filter(|pin| reg & (1 << pin) != 0).collect()}
fn main() { let mut reg: u32 = (1 << 0) | (1 << 5) | (1 << 25); println!("on: {:?} ({} pins)", pins_on(reg), reg.count_ones());
reg = toggle(reg, 5); reg = toggle(reg, 7); println!("on: {:?} ({} pins)", pins_on(reg), reg.count_ones());}실행 결과:
on: [0, 5, 25] (3 pins)on: [0, 7, 25] (3 pins)XOR은 예전 값이 무엇이든 비트를 뒤집어요. 그래서 핀 5를 뒤집으니 꺼졌고, 핀 7을 뒤집으니 켜졌어요. count_ones()가 1인 비트를 세어 주니까, 그 부분에는 반복문이 필요 없어요.
2. 부동소수점 없이 배터리 잔량 구하기
섹션 제목: “2. 부동소수점 없이 배터리 잔량 구하기”12비트 ADC는 03300 mV를 04095로 읽어요. 배터리는 전압을 절반으로 줄이는 전압 분배기를 거쳐 연결되어 있어서, 실제 전압은 측정값의 두 배예요. battery_mv(raw: u16) -> i32와 battery_percent(mv: i32) -> i32를 작성하세요. 3000 mV가 0%, 4200 mV가 100%이고, 0보다 작거나 100보다 크면 안 돼요. 측정값 2606, 2482, 2234, 1861로 확인해 보세요. 힌트: 정밀도를 잃지 않도록 나누기 전에 곱하세요. 그리고 clamp를 살펴보세요.
정답 보기
const EMPTY_MV: i32 = 3000;const FULL_MV: i32 = 4200;
fn battery_mv(raw: u16) -> i32 { // 0..=3300 mV를 재는 12비트 ADC(0..=4095), 전압을 절반으로 줄이는 분배기 뒤에 있어요 raw as i32 * 3300 * 2 / 4095}
fn battery_percent(mv: i32) -> i32 { let percent = (mv - EMPTY_MV) * 100 / (FULL_MV - EMPTY_MV); percent.clamp(0, 100)}
fn main() { for raw in [2606, 2482, 2234, 1861] { let mv = battery_mv(raw); println!("raw {raw} -> {mv} mV -> {}%", battery_percent(mv)); }}실행 결과:
raw 2606 -> 4200 mV -> 100%raw 2482 -> 4000 mV -> 83%raw 2234 -> 3600 mV -> 50%raw 1861 -> 2999 mV -> 0%4095로 나누기를 마지막에 하면 정밀도가 유지돼요. raw / 4095 * 6600이라고 쓰면 4095보다 작은 모든 측정값이 0이 될 거예요. 가장 큰 곱셈인 4095 * 6600은 약 2700만이라서 i32에 넉넉히 들어가요.
- 임베디드 Rust는 운영체제가 없는 마이크로컨트롤러에서 돌아가요. 가비지 컬렉터가 없고, 컴파일할 때 안전성을 검사하고, 비용 없는 추상화를 제공해서 Rust가 아주 잘 맞아요.
#![no_std]를 쓰면core만 남아요.#![no_main]을 쓰면 런타임 크레이트가 여러분의 진입 함수를 호출해요. 패닉 핸들러는 직접 골라야 해요.heapless는 고정 용량 컬렉션을 줘요.- 층 구조: PAC(레지스터), HAL(한 칩 제품군),
embedded-hal트레이트(공용), BSP(보드 하나), 그리고 그 위의 RTIC와 Embassy 같은 프레임워크. rustup target add로 칩의 타깃을 추가한 다음, probe-rs와 디버그 프로브로 플래시하고 로그를 봐요(ESP32라면espflash).defmt는 로그를 아주 작게 유지해 줘요.- PC에서 연습하세요. 레지스터를 위한 비트 마스크, 목 부품과 함께 트레이트에 맞춰 작성한 드라이버, 부동소수점 대신 쓰는 고정 소수점 정수.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. #![no_std]는 무슨 일을 하나요?
2. 센서 드라이버 하나가 Pico, ESP32, STM32에서 고치지 않고 동작하게 해 주는 층은 무엇일까요?
3. 다른 비트는 바꾸지 않고 reg의 비트 3만 켜려면 어떻게 할까요?
4. 임베디드 코드에서 25.25 °C를 25250밀리도로 저장하는 경우가 많은 이유는 무엇일까요?
점수: 0 / 4
더 알아보기
섹션 제목: “더 알아보기”- The Embedded Rust Book (영어):
no_std, 주변 장치, 레지스터, 임베디드 생태계를 소개하는 공식 입문서예요. - Discovery (micro:bit v2) (영어): BBC micro:bit으로 LED 깜빡이기부터 센서까지 한 단계씩 안내하는 무료 초보자 강좌예요.
- The Embassy Book (영어): Embassy 시작하기, 실행기와 HAL의 동작 방식, 공식 예제를 실행하는 방법을 다뤄요.
- The Rust on ESP Book (영어):
esp-hal,esp-generate,espflash, ESP32 툴체인에 대한 Espressif의 공식 가이드예요. - probe-rs installation (영어): 모든 운영체제에서 probe-rs를 설치하고 디버그 프로브를 준비하는 방법을 설명해요.