[Rust] Embedded Rust: Qemu로 실행해보기
본 포스트에서는 qemu 에뮬레이션을 활용해서 arm-cortex MCU 환경에서 실행 가능한 파일을 만들고 돌려보는 것을 목표로 한다.
여기서는 LM3S6965라는 Arm-cortext M3 MCU 보드를 사용한다고 가정한다.
실제로는 이렇게 생겨먹었다.
디스크는 256k짜리 플래시메모리, 램은 64k, CPU는 50MHz짜리 32비트 싱글코어 ARM Cortex-M3 프로세서다. 그 외에는 몇가지 입출력 포트, 주변장치가 존재한다.
우리에겐 QEMU가 있기 때문에 장치가 없어도 최소한의 테스트는 가능하다. 오픈소스의 힘이다.
내 테스트 환경은 우분투다.
cargo-binutil
cargo binutil은 Rust Embedded 그룹에서 제공하는 유용한 보조도구다.
cargo install cargo-binutils
rustup component add llvm-tools-preview
바이너리 사이즈나 형태 등을 조회하거나 변경할 수 있는 기능을 제공한다.
임베디드 환경 자체가 실행파일을 컴팩트하게 줄이는게 중요하기 때문에 또 이런게 필요하다.
타겟 추가
Rust는 target이라는 단위를 통해서 컴파일 가능한 타겟 플랫폼을 다양하게 지원한다.
여기엔 ARM 계열 마이크로 프로세서들도 포함된다.
저 thumb들이 arm 인스트럭션 세트를 부르는 말이다.
저기서 우린 thumbv7m-none-eabi라는 것을 쓸 것이다.
우리가 에뮬레이션하고자 하는 플랫폼의 아키텍쳐다.
rustup target add thumbv7m-none-eabi

이렇게 깔리면 문제 없이 잘 된 것이다.
QEMU 설치
하드웨어 없이 테스트를 하려면 에뮬레이터가 필요하다. 이 방면에서 qemu 외의 대안은 거의 없다.
sudo apt install gdb-multiarch qemu-system-arm -y

프로젝트 기본구성
러스트 임베디드 그룹에서 관리하고 있는 입문용 템플릿이 있다. 직접 세팅하려면 arm용 crate도 주렁주렁 달고 할게 많은데, 이거면 다 된다.
일단 받아준다.
git clone https://github.com/rust-embedded/cortex-m-quickstart
그럼 이런 형태로 프로젝트가 구성되어있을 것이다. 형태가 사뭇 다른 것을 볼 수 있다.
일단 기본적인 빌드 설정부터 좀 깔아준다.
중괄호로 구멍뚫린걸 적당히 메워주면 된다.


이름은 testapp으로 해줬다.
그리고 .cargo/config.toml을 보면 뭐가 많은데

빌드 타겟을 바꾸려면 이거 갈아끼우면 된다.
우리는 그냥 기본값으로 주면 된다.
기본 코드 분석
기본 형태나 한번 가볍게 훑어보고 가자.
이전 포스트에서 대부분의 임베디드 환경은 std 사용 등이 불가능하다고 했었다.
그래서 no_std로만 컴파일을 해야 하고, main 함수도 명시적으로 제공되지 않는다.
위 코드에서는 entry 매크로를 통해서 main 함수의 형식을 맞췄을 뿐이다. 실제로 매크로를 확장해보면 이런 식으로 펼쳐진다.
C 호환 함수로 치환하고 export만 한 것이다. 그러면 저 환경에서는 저게 main처럼 실행된다.
panic_halt는 패닉이 발생했을 경우의 패닉 핸들러를 정의한다. 저건 패닉이 발생했을때 프로그램을 중단시키는 동작을 의미한다. 저런 핸들러가 없으면 no_std 환경은 애초에 컴파일부터가 안된다.

저게 실제로 하는 역할은 간단하다.
그냥 이런 패닉 핸들러를 컨텍스트에 풀어놓을 뿐이다.
main에 달려있는 저 반환타입도 생소해보일 수 있다.
저건 함수가 절대 반환하지 않을 것임을 명시하는 never 타입이다.
일반 환경에서는 그렇게 잘 사용되진 않지만, 임베디드 환경에서는 기본으로 사용된다. 메인 프로세스가 죽으면 안되기 때문이다.
빌드해보기
이제 컴파일을 한번 돌려보자. 빌드 설정은 거의 다 되어있기 때문에, build만 때려주면 된다.
cargo build --release

그러면 해당 플랫폼으로 실행파일이 뽑혀있을 것이다.
다시 한번 Hello World성 프로세스를 작성해보자. 이번에는 Qemu에 올려보겠다.
#![no_main]
#![no_std]
use panic_halt as _;
use cortex_m_rt::entry;
use cortex_m_semihosting::{debug, hprintln};
#[entry]
fn main() -> ! {
hprintln!("Hello, world!").unwrap();
// exit QEMU
// NOTE do not run this on hardware; it can corrupt OpenOCD state
debug::exit(debug::EXIT_SUCCESS);
loop {}
}
Hello World를 호스트 콘솔에 출력하고 죽도록 했다.
이 코드에서는 간결한 프로세스 중단을 위해 exit를 직접 때렸지만, 일반적으로 저렇게 짜지는 않을 것이다.
그리고 컴파일해서 qemu에 올려보면 된다.
qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel target/thumbv7m-none-eabi/release/testapp
실행하고 죽을 것이다.
저기서 exit를 뺀다면 죽지 않고 뱅글뱅글 돌 것이다. 그게 일반적인 프로세스다.
binutil 써보기
각박한 임베디드 환경에서는 바이너리 사이즈를 줄이는 것도 꽤 중요한 문제가 된다. 디스크가 1MB도 안되는게 보통이기 때문이다.
binutil을 사용하면 그런 분석이나 재처리 등을 꽤나 간편하게 할 수 있다.
cargo readobj --bin testapp -- --file-headers
이건 파일 헤더 정보를 읽는다.
cargo size --bin testapp --release -- -A

이건 바이너리의 각 영역별 크기를 알려준다.
어디가 사이즈 병목인지 추적하기 쉽다. 릴리즈 모드로 빌드했기 때문에 디버그 정보는 거의 없는게 보일 것이다.
cargo objdump --bin testapp --release -- --disassemble --no-show-raw-insn --print-imm-hex
objdump는 직접 어셈블리 코드를 뽑을때 쓴다. 인스트럭션 보고 줄이면 된다.
참조
https://docs.rust-embedded.org/book/intro/hardware.html
https://doc.rust-lang.org/nomicon/panic-handler.html