ghlee.dev

my profile picture

Rust로 임베디드 맛보기

2024. 5. 4.

취미로 Rust를 공부하고 있던 차에 The Rusty Bits라는 유튜브 채널을 발견했다. Rust를 이용한 임베디드 프로그래밍에 대한 소개를 하는 채널로 처음 발견했을 때는 영상이 두 개뿐이었지만 퀄리티가 굉장해서 기대하고 있던 채널이었다.

마침 새로운 영상이 올라온 김에 영상을 따라서 실습해보며 그 과정에 대해 이야기하면서 영상에서는 생략된 자잘한 설명도 남겨보고자 한다.

Rust 튜토리얼을 해봤다면 실습하는데는 무리가 없으며 Cargo, 크레이트 등 Rust 개발 환경의 기초적인 지식은 있어야한다. 그리고 학부 수준의 어셈블리어 프로그래밍을 해봤다면 실습이 굉장히 수월하겠지만 안해봤어도 크게 문제는 없다.

구체적인 실습 과정은 유튜브 영상 컨텐츠를 직접 확인해보길 바란다. 참고한 영상의 링크는 글 최하단에 남겨두겠다.

Setup

임베디드 프로그래밍을 하려면 우선 하드웨어(마이크로 컨트롤러)부터 구입해야한다. 영상에서 쓰인 마이크로 컨트롤러는 Micro:bit에서 만든 nRF52833라는 모델이며 검색해보니 교육용으로 만들어진 모델로 한국에서도 쉽게 구매할 수 있었다.

Hardware sale page

Hardware

내가 실제 구매한 마이크로 컨트롤러이다. micro B to USB C 케이블도 별도로 쿠팡에서 3천 원 주고 구매했다.

마이크부터 스피커, 버튼 및 터치 센서까지 다양한 모듈이 있지만 실제 사용할 모듈은 노트북과 연결하여 통신하기 위한 마이크로 5핀 커넥터 정도이고 추후에 5X5 LED 매트릭스를 이용해 볼 예정이다.

다음으로 할 일은 해당 모델이 어떤 아키텍처와 명령어 셋을 사용하는지 알아야 하는데 구글링을 해보면 Micro:bit 홈페이지에서 모델에 대해 소개하는 페이지를 찾을 수 있다.

Hardware description

https://tech.microbit.org/hardware/#nrf52-application-processor

페이지를 보면 Arm의 Cortex-M4 프로세서를 사용하는 것을 찾을 수 있으며 친절하게도 링크가 있다. 또한 Flash ROM과 RAM의 크기에 대해서도 나와있는데 이는 나중에 쓰일 정보이니 일단 킵해두자.

여담으로 Arm이나 Cortex 같은 용어는 전자기기에 관심이 있다면 들어봤을 법도 한데, 삼성의 갤럭시 스마트폰에 탑재되는 퀄컴의 스냅드래곤과 삼성의 엑시노스 같은 AP는 Arm에서 설계한 아키텍처(Cortex-M, Cortex-X 등)를 가져와서 자기들 입맛에 맞게 조합하여 만든 거다. 마찬가지로 Micro:bit에서 만든 nRF52833 모델도 Arm의 Cortex-M4 설계를 가져와서 사용했다고 보면 된다.

Snapdragon 8 gen 3 spec

갤럭시 S24에 탑재된 스냅드래곤 8 GEN 3의 스펙 문서 중 일부이다. 우측의 CPU 설명을 보면 Arm Cortex-X4 technology라고 작게 적혀있다.

cortex-m4 processor spec

https://developer.arm.com/Processors/Cortex-M4

아까 발견한 링크를 타고 들어가면 Cortex-M4에 대해 자세히 나오는데 CPU 아키텍처는 ARMv7E-M을 사용하며 명령어셋(ISA)은 Thumb or Thumb-2을 사용하고 있는 것을 알 수 있다. 또한 IEEE 754 명세 기반의 부동소수점 연산을 위한 FPU가 포함되어 있으며 효율적인 벡터 연산을 위한 SIMD를 지원한다.

아키텍처나 명령어셋 같은 스펙은 하드웨어의 종류마다 다르기 때문에 이러한 방식으로 하드웨어 제조사가 공개해놓은 정보를 직접 찾아보아야 한다.

다음으로 할 일은 ARMv7E-M이라는 CPU 아키텍처를 Rust에서 지원하는지 찾아야 하는데 이는 The rustc book의 플랫폼 지원 현황에서 찾아볼 수 있다.

the rustc book

https://doc.rust-lang.org/nightly/rustc/platform-support.html

ARMv7E-M에 해당하는 타겟으로 두 가지가 나오는데 우리가 가진 하드웨어는 FPU가 존재하는 모델이라서 thumbv7em-none-eabihf를 rustup에 추가해 주면 된다. 이에 대한 내용은 후술할 cortex-m-rt 크레이트 문서에서 알려준다. 이제 아래 명령어를 실행해서 rustup에 호스트 도구를 추가해 보자.

Bash
$ rustup target add thumbv7em-none-eabihf

제대로 추가됐는지 rustup show 명령어로 확인할 수 있으며 아래와 같이 thumbv7em-none-eabihf이 추가된 것을 볼 수 있다.

Bash
$ rustup show

rustup show

rustup을 처음 설치하면 이미지에 있는 aarch64-apple-darwin처럼 하드웨어에 맞는 기본 호스트 도구가 추가된다. 나 같은 경우 M3 맥북에어를 사용하고 있으므로 애플 실리콘 아키텍처에 맞는 호스트 도구가 추가되어 있었다.

Setup.. 아직 Setup..

이제까지 한 일을 정리하면 실제 하드웨어를 구매하고 마이크로 컨트롤러 모델에 대한 정보를 찾았으며 거기서 CPU 아키텍처와 명령어 셋을 알아냈고 rustup에 필요한 호스트 도구를 추가해 주었다.

뭔가 많이 한거 같지만 여전히 세팅할 것이 잔뜩 남아있다..😢

드디어 Rust 프로젝트를 만들 때가 왔다. 원하는 경로에 아래 명령어를 실행해서 새 프로젝트를 만들어주자.

Bash
$ cargo new rustymicrobit

그리고 새로 만든 Rust 프로젝트에서 main.rs로 가서 아래처럼 코드를 추가해 준다.

Rust
#![no_std]
#![no_main]
 
fn main() {}

파일의 최상단에 특별한 코드를 추가해 주었는데 이 둘의 용도는 다음과 같다.

  • #![no_std]는 Rust가 제공하는 표준 라이브러리를 사용하지 않겠다는 의미이며 사실상 표준 라이브러리를 사용할 수 없는 임베디드 프로그래밍에서는 필수이다.
  • #![no_main]는 프로그램의 시작점으로 강제되던 main() 함수를 강제하지 않겠다는 의미이다.

이제 Cortex-M4 프로세서를 사용하기 위한 런타임 환경을 구성해 주어야 하는데 Rust에서는 cortex-m-rt라는 크레이트가 있다. Cortex-M 팀이 직접 관리하고 있는 크레이트로 초기 부팅 시 static 변수를 초기화하고 인터럽트(interrupt) 처리에 필요한 데이터를 벡터 테이블에 채우는 등 런타임 환경을 구성하는데 필요한 최소한의 코드로 구성되어 있는 크레이트이다.

Bash
$ cargo add cortex-m-rt

또한 아래 경로로 파일을 만들어서 cargo build 실행 시 사용될 설정을 미리 정의해둔다.

config.toml
# .cargo/config.toml
[build]
target = "thumbv7em-none-eabihf"
 
[target.thumbv7em-none-eabihf]
rustflags = ["-C", "link-arg=-Tlink.x"]

아래처럼 #[entry]속성을 main() 위에 선언해 주고 함수도 조금 수정하자.

Rust
#![no_std]
#![no_main]
 
use cortex_m_rt::entry;
 
#[entry]
fn main() -> ! {
  loop {
 
  }
}

main() 함수의 반환 값은 없으므로 !를 반환 타입으로 지정해준다.

이제 빌드 시 런타임 코드가 들어갈 주소를 지정해야 하는데 우리가 사용하는 하드웨어의 FLASHROM의 메모리 시작 주소와 용량을 알아야 한다. 아까 보았던 하드웨어 스펙을 기억하는가? 다시 페이지로 가서 보면 각각 FLASH는 512KB, ROM은 128KB 용량인 것을 확인할 수 있다.

메모리 시작 주소는 어떻게 알 수 있을까? 이는 메모리 맵이라는 것을 확인해야 하는데 일반적으로 하드웨어 제조사가 제공하는 스펙 문서에 나와있다. 하드웨어 스펙 페이지에서 nRF52833 모델 관련 링크를 여러번 타고 들어가면 PDF로 만들어진 스펙 문서를 확인할 수 있는데 이 곳에서 Memory map 키워드로 검색하면 찾을 수 있다.

memory map

https://infocenter.nordicsemi.com/pdf/nRF52833_PS_v1.6.pdf

여기서 Flash와 Data RAM(SRAM)의 시작 주소를 확인하면 되며 각각 0x00000000, 0x20000000인 것을 알 수 있다. 이제 알아낸 메모리 용량과 시작 주소를 가지고 루트 경로에 memory.x 파일을 만들어서 아래처럼 정의해 주자.

memory.x
MEMORY
{
    FLASH : ORIGIN = 0x00000000, LENGTH = 512K
    RAM   : ORIGIN = 0x20000000, LENGTH = 128K
}

참고로 홈페이지나 문서에서 SRAM과 ROM 용어를 혼용해서 사용되고 있는데, 둘 다 실행에 필요한 데이터를 미리 저장하는 의미로 큰 차이가 없어서 그런 게 아닐까 추측해 본다. 🤔

아직 빌드를 시도해 보기 전에 지금 쯤이면 main.rs 파일에서 컴파일러가 에러를 보여주고 있을 거라 생각되는데 이는 rust-analyzer의 타겟을 정해주지 않아서 발생한다. 아래처럼 설정 파일을 만들어서 타겟을 지정해 주고 VSCode를 재시작해 주자.

JSON
// .vscode/settings.json
{
  "rust-analyzer.check.allTargets": false,
  "rust-analyzer.cargo.target": "thumbv7em-none-eabihf"
}

드디어 cargo build 명령어로 빌드를 시도해 보면 다음과 같은 에러를 마주할 수 있다. 😬

error: `#[panic_handler]` function required, but not found

예기치 못한 에러가 발생했을 때 프로그램을 중지시키거나 할 수 있는 코드가 필요하다는 에러 메시지인데 panic_halt라는 크레이트를 추가해 주고 main.rs를 살짝 바꿔주자.

Bash
$ cargo add panic_halt
Rust
#![no_std]
#![no_main]
 
use cortex_m_rt::entry;
use panic_halt as _; // panic_halt를 추가해준다.
 
#[entry]
fn main() -> ! {
  loop {
 
  }
}

이제 다시 cargo build 명령어를 실행하면 정상적으로 빌드가 진행될 것이다.

Binary size and address

이번엔 우리가 작성한 코드들의 바이너리 크기와 시작 주소를 알고 싶을 때 사용할 도구를 설치해 보자.

Bash
$ rustup component add llvm-tools
Bash
$ cargo install cargo-binutils

Rust는 컴파일러 백엔드로 LLVM을 사용하고 있으므로 자세한 디버깅을 하려면 llvm-tools가 필요하다. 또한 Cargo에서 이를 편리하게 사용할 수 있게 해주는 cargo-binutils를 설치해 준다. 모두 설치 후 아래 명령어를 실행해 보자.

Bash
$ cargo size -- -Ax

binary sizes

자세히 보면 아까 memory.x 파일을 통해 정의해 주었던 FLASHData RAM의 시작 주소에 맞춰서 .vector_table.data의 시작 주소도 정해진 것을 알 수 있다. 주소가 바뀌는 것이 궁금하다면 memory.x에서 시작 주소를 변경한 후 기존에 빌드 되어있던 target 폴더를 지우고 다시 빌드 해보면 바뀌는 것을 확인할 수 있다.

디버거 설치와 코드 실행

디버깅을 위해 실습 코드를 변경해야 하는데 우선 필요한 크레이트를 추가해 주자.

Bash
$ cargo add rtt-target
Bash
$ cargo add cortex-m --features critical-section-single-core
  • rtt-target은 마이크로 컨트롤러에서 디버그 로깅을 가능하게 해준다. 다르게 표현하면 내 콘솔에 메시지를 띄울 수 있도록 실시간 통신 기능이 있다.
  • cortex-m은 Cortex-M 프로세서에 대한 조작을 가능하게 해주는 코드가 있다. critical-section-single-corenRF52833 모델이 단일 코어이기 때문에 해당 기능을 넣어준다.

다음으로 main.rs의 코드를 아래처럼 변경하자.

Rust
#![no_std]
#![no_main]
 
use cortex_m::asm::nop;
use cortex_m_rt::entry;
use panic_halt as _;
use rtt_target::{rprint, rtt_init_print};
 
#[entry]
fn main() -> ! {
    rtt_init_print!();
    loop {
        rprint!("Echo...\n");
        // 각 루프마다 딜레이를 주기 위해 임시로 넣어준다.
        for _ in 0..100_000 {
            // No operation
            nop();
        }
    }
}

이제 작성한 코드를 우리가 구입한 하드웨어에 넣어주고 실행해야 한다. 이를 위해 probe-rs 패키지를 설치한다.

Bash
$ cargo install probe-rs --features cli

이 부분은 유튜브의 실습 영상과는 다른 방법인데, 영상의 댓글을 보다 보니 해당 패키지를 추천하는 사람들이 있었고 나 또한 사용해 보니 괜찮아 보여서 이걸로 진행했다. 영상 제작자도 probe-rs에 대한 평가를 고정 댓글로 남겨두었으니 확인해 보면 도움이 될 것이다.

기존에 작성했던 config.toml 파일도 일부 수정하자.

config.toml
# .cargo/config.toml
[build]
target = "thumbv7em-none-eabihf"
 
[target.thumbv7em-none-eabihf]
rustflags = ["-C", "link-arg=-Tlink.x"]
# runner 설정을 추가한다.
# probe-rs 문서에 관련 내용이 설명되어있다.
runner = 'probe-rs run --chip nRF52833_xxAA'

드디어 처음으로 코드를 실행해 볼 순간이 왔다. 😁

우선 구입한 마이크로 컨트롤러를 컴퓨터(혹은 노트북)에 케이블을 이용하여 연결한다. 그리고 기존 빌드 된 파일을 없애기 위해 target 폴더를 지운 뒤 cargo run 명령어를 실행해 보자.

first execute code

우리가 입력한 Echo... 메시지가 터미널에 순차적으로 나온다면 성공이다. 🎉

probe-rs debugger extension

이제까지 우리가 구입한 하드웨어의 스펙에 맞게 코드를 작성하고 이를 빌드 하여 하드웨어에 옮겨준 후 실행까지 무사히 성공했다. 마지막으로 VSCode의 디버깅 도구를 이용할 수 있도록 세팅해 주고 마무리하겠다.

VSCode의 확장 프로그램 중에서 Debugger for probe-rs을 검색하여 설치하자.

https://marketplace.visualstudio.com/items?itemName=probe-rs.probe-rs-debugger

다음으로 .vscode 폴더에 launch.json 파일을 만들어서 아래처럼 작성해 준다.

JSON
// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "preLaunchTask": "${defaultBuildTask}",
      "type": "probe-rs-debug",
      "request": "launch",
      "name": "probe-rs Test",
      "cwd": "${workspaceFolder}",
      "chip": "nRF52833_xxAA",
      "flashingConfig": {
        "flashingEnabled": true
      },
      "coreConfigs": [
        {
          "programBinary": "./target/thumbv7em-none-eabihf/debug/${workspaceFolderBasename}"
        }
      ]
    }
  ]
}

참고로 해당 세팅에 대한 내용은 probe-rs 문서에 잘 나와있으니 필요하다면 참고하면 된다.

이제 디버거 도구를 이용해서 디버깅을 할 수 있는데, rprint!("Echo...\n"); 부분에 중단점을 찍고 디버깅을 해보았다.

disassembly

Call Stack에서 함수를 우측 클릭해서 디스어셈블을 하면 함수 위치로 바로 갈 수 있다. 물론 코드를 직접 우측 클릭해서 볼 수도 있다.

좌측 디버거 패널에서 정적 변수(Static), 레지스터(Registers), 변수(Variables) 등의 정보를 확인할 수 있다. 정적 변수에는 우리가 작성해서 마이크로 컨트롤러로 플러시 했던 코드가 있고 변수에는 RTT의 채널에 대한 데이터나 이터레이션 등의 데이터가 있다. 레지스터의 값이 바뀌는 것도 확인할 수 있으니 중단점을 바꿔가면서 디버깅해보길 바란다.

마치며

기나긴 세팅 끝에 마이크로 컨트롤러와 아주 간단한 통신을 해보았다. 비록 콘솔에 메시지를 남기는 정도였지만 임베디드 혹은 어셈블리어 프로그래밍을 처음 접한다면 실습을 이해하는 게 간단하지는 않았을 것으로 생각된다.

영상에서는 언급하지 않고 넘어간 몇몇 부분을 초심자도 알법한 쉬운 용어로 설명을 덧붙여 보았는 데 도움이 됐길 바란다.

다음 단계는 마이크로 컨트롤러가 가지고 있는 25개의 LED와 통신하는 실습인데 궁금한 사람은 직접 유튜브 채널로 가서 영상을 보며 실습해 보길 바란다. 이 글에서 디버깅까지 무사히 실습했다면 다음 단계도 그리 어렵지 않을 것이다. 또한 글에서 작성한 코드는 깃허브 링크로 남겨두겠으니 필요하다면 참고하길 바란다.

led

"I ♥️ RUST" 🤭

개인적으로는 취미로 공부하는 Rust를 사용할 만한 분야가 많지 않아서 (그동안은 Leetcode 문제를 풀거나 블록체인의 스마트 컨트랙트 작성하는 정도였다.) 이번 기회에 소형 하드웨어에서의 프로그래밍을 경험해 볼 수 있어서 재미있었다.

참고한 유튜브 실습 영상

코드 샘플