GH.

my profile picture

Rust로 임베디드 맛보기: Blinking an LED

2025. 8. 27.

🤖 AI 요약: micro:bit의 LED를 제어하며 임베디드 Rust의 4가지 추상화 계층을 학습한다. unsafe 블록으로 GPIO 레지스터를 직접 조작하는 방법부터 시작해서, PAC(주변장치 접근 크레이트)로 레지스터 추상화, HAL(하드웨어 추상화 레이어)로 타입 안전성 확보, 마지막으로 BSP(보드 지원 패키지)로 보드별 특성을 캡슐화하는 과정을 다룬다. 최종적으로는 "I LOVE RUST" LED 매트릭스 애니메이션을 구현하며 BSP의 직관적인 API 활용법을 보여준다.



임베디드 Rust 관련 글을 올린지 벌써 1년이 지났다. 이전 글에서 소개했던 The Rusty Bits 채널은 1년이 지난 지금도 꾸준히 새 영상을 업로드하고 있다. 여전히 영상의 퀄리티는 훌륭하다.

사실 이전 글의 실습에서는 Setup만 하고 끝났고, 원래 계획했던 후속 내용을 작성하지 못한 게 마음에 걸렸다. 시간이 좀 지났지만, 그 후속편을 작성해보고자 한다.

이전 글과 마찬가지로 영상의 모든 내용을 다루지는 않으며, 주요 개념과 함께 영상에서 생략된 부가 설명을 더하는 방식으로 진행할 예정이다. 참고로 필자는 고수준의 애플리케이션 계층에서만 일해온 웹 프론트엔드 개발자이며, 임베디드를 따로 배워본 적은 없다. 그러므로 이전 영상부터 차근차근 따라왔다면 크게 어렵지 않을 것이다.

잠깐 다른 이야기를 하자면, Claude Code 같은 AI 프로그래밍이 보편화된 지금은 물론이고, 그 이전에도 저수준 계층을 경험하는 것이 의미가 있는가에 대한 논의는 항상 있어왔다. 필자는 여러 이유로 여전히 애플리케이션 계층의 개발자도 저수준 지식을 갖춘 사람이 더 가치 있고, 오랫동안 업계에서 살아남을 수 있다고 생각한다. 그래서 평소에는 AI와 함께 코딩을 하면서도 짬을 내어 꾸준히 저수준 영역을 공부하고 있다.

불필요한 이야기는 그만하고, 후속 내용을 작성해 보겠다.

준비물

이전 글에서 구매했던 micro:bit 보드가 여전히 필요하다.

Image

필자의 micro:bit 보드는 1년이 지났지만, 여전히 잘 있다!

주변장치(Peripheral) 컨트롤

이번 목표는 micro:bit의 뒷면에 있는 LED를 깜빡여보는 것이다. LED를 사용하려면 먼저 회로를 분석해야 하는데, 관련 정보는 micro:bit 제조사의 GitHub에 공개되어 있다.

Image

PDF 파일로 제공되며, 실제로는 이미지 세 장 정도 분량의 회로도가 담겨 있다.

Micro:bit nRF52820 회로도 링크

회로도에서 LED matrix의 D2에 해당하는 LED를 깜빡여볼 것이다. 이를 위해서는 ROW1과 COL1, 즉 1행과 1열의 PIN을 제어해야 하는데, Output 모드로 각각 High와 Low로 상태를 변경해야 한다.

왜 High와 Low 설정을 해야 하는지 영상에서 자세한 설명은 없는데 첨언하자면, 전류는 전위차가 있어야 흐른다. 즉, ROW1과 COL1이 모두 High면 전류가 흐르지 않는다. COL1을 Low로 변경하면 ROW1과 COL1 사이에 전위차가 생기므로 전류가 흐르고 D2 LED에 전원을 공급할 수 있다. 참고로 다른 행과 열은 기본 방향(DIR) 모드인 Input 모드이고, 이때는 전류가 떠있다(Floating)고 표현하며 전류가 제대로 흐르지 않아 전원 공급이 어려운 상태로 이해하면 된다.

이번에는 LED와 마이크로컨트롤러가 서로 어떻게 연결되어 있는지 찾아봐야 한다. 이는 Target MCU 섹션을 보면 찾을 수 있는데, ROW1과 COL1이 연결되는 지점이 각각 P0.21, P0.28, 즉 PORT0의 PIN21과 PIN28임을 알 수 있다.

이 핀들을 디지털 출력으로 설정하려면 GPIO 주변장치가 필요하다. GPIO는 General Purpose Input/Output의 약자로, 마이크로컨트롤러의 가장 기본적인 인터페이스다. 이는 마이크로컨트롤러 내부에 있는 실리콘 웨이퍼 형태의 디지털 회로이므로, 이전 글에서도 살펴봤던 마이크로컨트롤러 데이터시트를 찾아봐야 한다.

프로그래머가 PIN을 제어하고 상호작용하려면, GPIO 섹션에 나오는 특정 주소에서 메모리를 읽거나 쓰는 작업이 필요하다. 각 핀(PIN)은 HIGH(1) 또는 LOW(0) 상태를 갖도록 제어할 수 있다.

Micro:bit nRF52820 데이터시트 링크

6.8 GPIO 섹션에서 GPIO 주변장치에 대한 정보를 볼 수 있다. 우선 PORT0의 시작 주소를 알아야 하며, 이는 6.8.2 Registers 섹션에서 찾을 수 있다.

Image

표에 따르면, GPIO 주변장치의 P0에 해당하는 시작 주소(Base address)는 0x5000_0000 이다.

Image

PIN의 방향(DIR)을 설정하는 방법도 알아야 하는데, PIN 설정(Configuration, CNF) 섹션인 **6.8.2.10 PIN_CNF[n]**에 따르면 Output 모드로 변경하기 위해 1 값을 할당하면 된다. DIR의 ID는 A 에 해당하고 이는 Bit number 기준으로 0번째 비트다.

Address offset이 0x700 이므로 n0 이라면, 방금 보았던 시작 주소에 이를 더해서 0x5000_0700 주소의 비트를 0 또는 1로 할당하면 0번째 PIN의 DIR을 설정할 수 있다. 하나의 PIN마다 0x4 씩 주소가 증가한다.

참고로 _ 는 단위를 구분하기 위한 용도이다.

Image

DRIVE도 설정할 수 있는데, 이는 전류 출력 세기에 대한 설정으로, LED를 하나 켜는 정도는 표준 드라이브(S0S1)면 충분하다.

Image

이전에 Output 모드로 변경한 PIN21과 PIN28을 각각 High와 Low 상태로 변경해야 전류가 흐른다고 했었다. 6.8.2.1 OUT 표에 따르면 Low는 0 으로, High 1 로 설정하면 된다. Address Offset도 추후 참고할 예정이다.

unsafe 블록을 사용하는 Rust

지금까지 찾아본 각 PIN 주소와 DIR, OUT 설정 방법을 활용해서 Rust 코드를 작성해야 한다.

Rust에서 특정 주소에 값을 직접 할당하기 위해서는 Rust 컴파일러의 Borrow Checker가 동작하지 않는 unsafe 블록을 활용해야 한다. 당연하게도 타입과 소유권에 엄격한 Rust에서 이는 치트(Cheat)와 다름없으니, 주석으로 사용 의도를 작성하는 것이 베스트 프랙티스(Best Practice)다.

TypeScript로 비유하면, 단언문 !처럼 컴파일러의 Checker를 무시하는 것과 유사한 행위로 볼 수 있다. 다른 관점으로는 unsafe를 발견한 동료 개발자에게 이 코드는 사실 문제가 없고, 외부 요인(하드웨어 정보 등)이 프로그래머의 제어하에 있다고 알리는 것과 같다.

전체 코드를 보기에 앞서 몇 가지 핵심 상수를 먼저 살펴보겠다.

  • GPIO0_PINCNF21_ROW1_ADDR : GPIO의 PORT0에서 PIN21에 해당하는 CNF 주소다. 앞서 PORT0의 시작 주소가 0x5000_0000, CNF 오프셋 주소는 0x700, PIN21은 0x421 곱하면 되므로 최종 주소 계산은 0x5000_0754 이다.
  • GPIO0_PINCNF28_COL1_ADDR : PIN28(COL1)의 CNF에 해당하는 주소로 동일한 방법으로 계산하여 0x5000_0770 이 된다.
  • DIR_OUTPUT_POS : DIR에 해당하는 0번째 비트 위치를 나타낸다.
  • PINCNF_OUTPUT_MODE : 각각의 PIN CNF에 할당할 설정 값이다. Output 모드로 변경한다.
  • GPIO0_OUT_ADDR : OUT 레지스터의 시작 주소이다.
  • GPIO0_OUT_ROW1_POS : PIN21(ROW1)에 해당하는 주소 오프셋으로 21 이다.

이제 LED 하나를 깜빡이기 위한 전체 코드를 보자.

Rust
#![no_std]
#![no_main]
 
use core::ptr::write_volatile;
 
use cortex_m::asm::nop;
use cortex_m_rt::entry;
use panic_halt as _;
 
#[entry]
fn main() -> ! {
		// PIN21 주소
    const GPIO0_PINCNF21_ROW1_ADDR: *mut u32 = 0x5000_0754 as *mut u32;
    // PIN28 주소
    const GPIO0_PINCNF28_COL1_ADDR: *mut u32 = 0x5000_0770 as *mut u32;
    // DIR에 해당하는 0번째 비트
    const DIR_OUTPUT_POS: u32 = 0;
    // 0번째 비트에 1을 설정하여 출력 모드로 설정. DRIVE는 기본값(0) 사용.
    const PINCNF_OUTPUT_MODE: u32 = 1 << DIR_OUTPUT_POS;
 
    unsafe {
        write_volatile(GPIO0_PINCNF21_ROW1_ADDR, PINCNF_OUTPUT_MODE);
        write_volatile(GPIO0_PINCNF28_COL1_ADDR, PINCNF_OUTPUT_MODE);
    }
 
    // OUT 레지스터 시작 주소
    const GPIO0_OUT_ADDR: *mut u32 = 0x5000_0504 as *mut u32;
 
    // OUT 레지스터에서 ROW1에 해당하는 21번째 비트 위치값
    const GPIO0_OUT_ROW1_POS: u32 = 21;
 
    let mut is_on = false;
 
    loop {
        unsafe {
		        // 21번째 ROW1은 1을 줌으로써 활성화하고
		        // 28번째 COL1은 출력 모드만 설정하고 다른 작업은 없었으니 기본값인 0이다.
		        // 이로인해 1x1에 해당하는 LED만 점등한다.
            write_volatile(GPIO0_OUT_ADDR, (is_on as u32) << GPIO0_OUT_ROW1_POS);
        }
        for _ in 0..400_000 {
            nop();
        }
        is_on = !is_on;
    }
}

우선, 앞서 설명했듯 ROW1과 COL1에 해당하는 PIN21, PIN28을 Output 모드로 변경한다. 이는 unsafe 블록에서 이루어지며, core 라이브러리의 write_volatile 함수를 활용하여 각 주소에 PINCNF_OUTPUT_MODE 비트를 할당한다.

다음 단계로 ROW1의 Low, High 상태를 번갈아 변경해야 깜빡이는 동작을 할 것이다. 이를 위해 OUT 레지스터의 ROW1(PIN21)에 해당하는 주소의 값을 01로 반복적으로 할당할 것이다. 이는 loopunsafe 블록을 활용한다.

is_on 은 Bool 값으로 01 을 명시적으로 표현하기 위해 만든 변수이며, write_volatile 함수에서 사용할 때는 u32 로 캐스팅한다.

전체 코드를 적용하면 불완전하지만 1×1에 해당하는 LED가 깜빡일 것이다.

주변장치 접근 크레이트 (Peripheral Access Crate, PAC)

주변장치 접근 크레이트, 줄여서 PAC은 우리가 이전에 했던 작업을 추상화할 수 있도록 한다. PAC 크레이트는 마이크로컨트롤러의 모든 주변 장치 레지스터에 접근할 수 있는 추상화된 인터페이스를 제공한다. PAC은 레지스터의 이름, 주소, 비트 필드 등을 알고 있다. 우리가 직접 주소를 찾아 값을 변경했던 작업을 할 필요가 없다.

PAC은 대체로 제조사에서 크레이트를 제공한다. 우리가 사용하고 있는 micro:bit의 NRF52833의 경우 nrf52833-pac 라는 이름으로 크레이트를 제공하고 있다.

https://crates.io/crates/nrf52833-pac

직접 이 크레이트를 만들어야 한다면, 마찬가지로 제조사에서 제공하는 레지스터 정보가 표준 형식으로 담긴 파일을 이용해서 만들면 되는데, ARM의 경우 CMSIS 시스템 뷰 설명 표준이라는 것이 있으며 SVD 확장자 파일 형태로 제공한다.

이 파일과 함께 svd2rust 크레이트를 사용하면 PAC으로 변환할 수 있다. nrf52833-pac 도 동일한 방법으로 만들어졌으며, 제조사에서도 이를 명시하고 있다.

https://crates.io/crates/svd2rust

svd2rust 로 만든 PAC은 Peripherals 이라는 싱글턴을 제공하며, 이에 대한 소유권을 take() 로 가져와 사용하면 된다.

아래는 기존 LED 깜빡이는 예제를 nrf52833_pac 을 활용하여 리팩토링한 코드이다.

Rust
#![no_std]
#![no_main]
 
use nrf52833_pac::Peripherals;
use cortex_m::asm::nop;
use cortex_m_rt::entry;
use panic_halt as _;
 
#[entry]
fn main() -> ! {
    let p = Peripherals::take().unwrap();
    p.P0.pin_cnf[21].write(|w| w.dir().output());
    p.P0.pin_cnf[28].write(|w| w.dir().output());
 
    let mut is_on = false;
    loop {
        p.P0.out.write(|w| w.pin21().bit(is_on));
        for _ in 0..200_000 {
            nop();
        }
        is_on = !is_on;
    }
}

이전에 unsafe 블록을 활용한 예제와 비교하면, 프로그래머가 훨씬 이해하기 쉽게 추상화되었다. 이전에는 P0부터 PIN21, PIN28, DIR 등 각각 해당하는 주소값과 오프셋을 알아야했는데, 이러한 것이 추상화되어 코드가 읽기 좋고 간결해졌다.

하드웨어 추상화 레이어 (Hardware Abstraction Layer , HAL)

PAC을 사용하더라도 특정 레지스터와 비트 필드는 알고 있어야 했다. 이를 한단계 더 쉽게 작성할 수 있는 크레이트가 있다. 이를 하드웨어 추상화 레이어, HAL이라 부른다. HAL은 PAC보다 높은 수준의 추상화 레이어이며, 몇 가지 작업을 명시적으로 할 수 있다.

https://crates.io/crates/nrf52833-hal

Rust
#![no_std]
#![no_main]
 
use cortex_m::asm::nop;
use cortex_m_rt::entry;
 
use embedded_hal::digital::{OutputPin, PinState};
use nrf52833_hal as hal;
use hal::gpio::Level;
use panic_halt as _;
 
#[entry]
fn main() -> ! {
    let p = hal::pac::Peripherals::take().unwrap();
    let port0 = hal::gpio::p0::Parts::new(p.P0);
    let _col1 = port0.p0_28.into_push_pull_output(Level::Low);
    let mut row1 = port0.p0_21.into_push_pull_output(Level::Low);
 
    let mut is_on = false;
    loop {
        let _ = row1.set_state(PinState::from(is_on));
        for _ in 0..200_000 {
            nop();
        }
        is_on = !is_on;
    }
}
 

_col1 변수명 앞에 붙은 언더바는 Rust 컴파일러에게 "이 변수는 사용하지 않는다."라고 알리는 일종의 관례다.

PORT와 PIN을 알아야하는 것은 동일하다. 다만, DIR와 DRIVE 변경, PIN 상태 설정 등 우리가 하는 작업이 무엇인지 이전보다 명확하게 알 수 있다. col1row1 을 Output 모드로 변경과 동시에 전류 출력 세기를 표준 드라이브로 명시적으로 설정한다. 그리고 mut row1 을 통해서 Pin의 상태 값을 Enum을 활용하여 반복적으로 변경한다.

영상에서는 Rust의 Borrow Checker와 Some() 에 대해서 설명하는데, 이 부분은 직접 시청하는 것을 권장한다. 요약하면, Rust의 언어 특징으로 인해 Port를 찾고 DIR을 바꾼 뒤 PIN 상태를 변경하는 일련의 순서를 잘 지킬 수 있게 해준다는 내용이다.

개인적으로 HAL 크레이트를 사용하면서 느낀 점은, 추상화 수준이 적절하다는 인상을 받았다. 임베디드 기준으로 너무 고수준도, 너무 저수준도 아닌 딱 적당한 지점에서 하드웨어의 세부사항은 숨기면서도 프로그래머가 여전히 하드웨어를 제어하고 있다는 느낌을 준다.

보드 지원 패키지 (Board Support Package, BSP)

BSP는 HAL에서 한번 더 추상화된 최상위 레이어다. 마이크로컨트롤러가 보드의 다른 부품들과 어떻게 연결되어 있는지 알고 있으며, 보드별 특성을 캡슐화한다.

micro:bit BSP의 경우, 5x5 LED 매트릭스, 버튼, 스피커, 센서 등 보드에 있는 모든 구성 요소에 대한 고수준 인터페이스를 제공한다. 이는 단순히 PIN 번호를 외우거나 회로도를 분석할 필요 없이, 추상화된 이름으로 하드웨어 리소스에 접근할 수 있게 해준다.

https://crates.io/crates/microbit

Rust
#![no_std]
#![no_main]
 
use panic_halt as _;
use cortex_m_rt::entry;
use embedded_hal::{delay::DelayNs, digital::OutputPin};
use microbit::{board::Board, hal::Timer};
 
#[entry]
fn main() -> ! {
    let mut board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);
 
    let _ = board.display_pins.col1.set_low();
    let mut row1 = board.display_pins.row1;
 
    loop {
        let _ = row1.set_low();
        timer.delay_ms(1_000);
        let _ = row1.set_high();
        timer.delay_ms(1_000);
    }
}

PORT와 DIR, PIN 등을 직접 다루던 복잡한 작업이 모두 사라졌다. board.display_pins.row1처럼 보드의 물리적 구조를 그대로 코드로 옮긴 직관적인 인터페이스를 제공한다.

BSP는 하드웨어 리소스의 올바른 초기화 순서를 보장하고, 잘못된 사용 패턴을 컴파일 타임에 방지한다. 예를 들어, 이미 사용 중인 PIN을 다시 가져오려고 하면 컴파일 에러가 발생한다.

이제 우리는 P0.21이나 P0.28 같은 하드웨어 구체적인 식별자 대신 row1, col1 같은 의미 있는 이름으로 작업할 수 있다. 이는 코드의 가독성을 높일 뿐만 아니라, 다른 보드로 포팅할 때도 유리하다. 만약 다른 micro:bit 버전이나 유사한 보드를 사용한다면, BSP만 교체하면 되기 때문이다.

BSP는 하드웨어 관련 지식이 충분하지 않아도 임베디드 개발을 시작할 수 있게 해주고, 프로그래머가 하드웨어보다는 애플리케이션 로직에 집중할 수 있게 도와준다.

마지막 코드 예제는 이전 글 마무리에서 보여준 "I LOVE RUST"를 LED 매트릭스로 표현하는 예제다.

Rust
#![no_std]
#![no_main]
 
use panic_halt as _;
use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use microbit::{board::Board, display::blocking::Display, hal::Timer};
 
#[entry]
fn main() -> ! {
    if let Some(board) = Board::take() {
        let mut timer = Timer::new(board.TIMER0);
        let mut display = Display::new(board.display_pins);
 
        #[allow(non_snake_case)]
        let letter_I = [
            [0, 1, 1, 1, 0],
            [0, 0, 1, 0, 0],
            [0, 0, 1, 0, 0],
            [0, 0, 1, 0, 0],
            [0, 1, 1, 1, 0],
        ];
 
        let heart = [
            [0, 1, 0, 1, 0],
            [1, 0, 1, 0, 1],
            [1, 0, 0, 0, 1],
            [0, 1, 0, 1, 0],
            [0, 0, 1, 0, 0],
        ];
 
        #[allow(non_snake_case)]
        let letter_R = [
            [0, 1, 1, 0, 0],
            [0, 1, 0, 1, 0],
            [0, 1, 1, 0, 0],
            [0, 1, 0, 1, 0],
            [0, 1, 0, 1, 0],
        ];
 
        #[allow(non_snake_case)]
        let letter_u = [
            [0, 0, 0, 0, 0],
            [0, 0, 0, 0, 0],
            [0, 1, 0, 1, 0],
            [0, 1, 0, 1, 0],
            [0, 1, 1, 1, 0],
        ];
 
        #[allow(non_snake_case)]
        let letter_s = [
            [0, 0, 0, 0, 0],
            [0, 0, 1, 1, 0],
            [0, 1, 0, 0, 0],
            [0, 0, 1, 0, 0],
            [0, 1, 1, 1, 0],
        ];
 
        #[allow(non_snake_case)]
        let letter_t = [
            [0, 0, 1, 0, 0],
            [0, 1, 1, 1, 0],
            [0, 0, 1, 0, 0],
            [0, 0, 1, 0, 0],
            [0, 0, 1, 0, 0],
        ];
        loop {
            display.show(&mut timer, letter_I, 1000);
            display.show(&mut timer, heart, 1000);
            display.show(&mut timer, letter_R, 1000);
            display.show(&mut timer, letter_u, 1000);
            display.show(&mut timer, letter_s, 1000);
            display.show(&mut timer, letter_t, 1000);
            display.clear();
            timer.delay_ms(250_u32);
        }
    }
 
    panic!("End");
}

이전 코드 예제보다도 더 직관적으로 바뀌었다. 한눈에 봐도 LED 보드를 이중 배열로 표현하여 어떤 모양을 보여주려고 하는지 바로 알 수 있다. Display 구조체를 사용하면서 PIN을 켜고 끄는 것이 명확해졌다.

Image

"I LOVE RUST!". 사실 그렇게까지 사랑하진 않는다 😬

마치며

이제까지 살펴본 추상화 단계들은 임베디드 프로그래밍에서 사용하는 일반적인 개념이다. 직접 레지스터 조작 → PAC → HAL → BSP로 이어지는 이 계층 구조는 단순히 Rust에만 국한된 것이 아니라, 현대 임베디드 개발 전반에 걸쳐 적용되는 패러다임이다.

각 단계를 경험하면서 느낀 점은, unsafe 블록으로 직접 메모리 주소를 조작할 때는 모든 것을 세밀하게 제어할 수 있지만, 코드 작성과 디버깅이 어려웠다. 반면 BSP를 사용하면 빠르게 프로토타입을 만들 수 있지만, 특수한 하드웨어 기능을 활용하기는 어려울 수 있고, 하드웨어 제조사의 크레이트에 의존성이 커진다. 또한 실무에서 사용할 때는 결국 데이터 시트 등 문서를 봐야하는 상황이 오지 않을까 싶다.

이전 글의 후속이 늦었지만, 이 글이 임베디드 Rust에 관심 있는 분들에게 도움이 되었기를 바란다.

관련 링크