ベアメタルでHello World

この章の内容はThe Embednomiconにちょっと詳しい解説をつけただけです。すでにこちらのドキュメントを読んでいるのであればこの章は飛ばして次の章に進んでください。

プロジェクトのセットアップ

rustupでRustコマンドをインストールするとcargoというコマンドが使えるようになります。 このコマンドはRust標準のビルドシステム兼パッケージマネージャーです。

まずはOS用の新規プロジェクトをつくりましょう。今回のプロジェクト名はbookosで行きます。もし自分の気に入ったプロジェクト名があれば、それでも構いません。

$ cargo new bookos
     Created binary (application) `bookos` package
$ cd bookos
$ ls
Cargo.toml  src
$ ls src
main.rs

src/main.rsをこれから書き換えてHello Worldするプログラムを書いていきます。 その前に、このプロジェクトで使うRustのバージョンを指定するためにrust-toolchainというファイルをこのプロジェクトにおいておきます。

$ echo "nightly-2019-09-19" > rust-toolchain

このファイルがプロジェクトのトップディレクトリにあることで、rustupにどのバージョンのRustを使えばいいかを自動的に教えて、バージョンを切り替えてくれるようになります。

プログラムの実行

OSのある環境下では、たとえばターミナルから実行コマンドを打つことでOSがプログラムをロードして実行してくれますが、 OSのないベアメタル環境ではどのように自分の実行したいプログラムを実行すればいいのでしょうか? 電源投入時のCPUの動作について、ARMv7-MのアーキテクチャリファレンスマニュアルのB1.5.5 Reset behaviorに書いてあります。 この疑似コードによると、最後にtmp0xFFFF_FFFEのandを取った値にブランチする、つまりtmpの値の最下位ビットを0にしたメモリアドレス上に置かれたプログラムが実行される、ということのようです。 このtmpvectortable+4の値になるようですが、このvectortableの説明はB1.5.3にあります。Vector tableは例外が発生したときにどのアドレスにジャンプするべきかを指す配列です。 先頭エントリーはリセット時のスタックポインタの値になるので、4バイト先のエントリがリセット時に飛ぶべきアドレスとなっています。 このvector tableですが、どこに置かれるべきかはチップの実装依存となっているため、今回はSTM32F42xxxのリファレンスマニュアルを見る必要があります。 2.4 Boot configurationの章を見てみましょう。本来であれば0番地がコードの最初のエリアになるのですが、設定によってこれを変更できることが書いてあります。 BOOT0BOOT1の値で制御されるのですが、Nucleoボードのユーザーマニュアルによるとどうやらデフォルト値はともに0のようなので、メインのフラッシュメモリから読み出されることになります。 このフラッシュメモリのアドレスはというとSTMのリファレンスマニュアル3.4章の表に0x800_0000番地から始まる、と書いてあるので、ここにvector tableを配置してあげれば自分のプログラムを実行できるということになりそうです。 このフラッシュメモリ領域がいわゆるROMの領域で、ここに自分のプログラムを書き込んであげることになります。

no_std環境下でのRust

自分のプログラムをボード上で実行させる方法がなんとなくわかったと思いますが、問題はどうやったらこのフォーマットに従ったプログラムをRustで書いてバイナリにすればいいか、です。 それを解説したのがThe Embednomiconだったわけです。

通常、Rustのプログラムを書くときstdクレートと呼ばれる言語の標準ライブラリを利用してプログラムをコンパイルすることになります。 しかし、このstdクレートは様々なOSの機能を利用することを前提として実装されているので、OSのないベアメタル環境下では当然動きません。 また、コンパイルしたときのフォーマットもOS上で実行するためのフォーマットになっているので、それも変更しなければなりません。 前者の問題を解決するための手段が#![no_std]アトリビュートです。これを使うとstdクレートの代わりに、OSの存在なしでも利用できる機能を提供するcoreクレートを使ってプログラムをコンパイルしてくれます。

では、no_stdでの最小プログラムをThe Embednomiconから引用します。

#![no_main]
#![no_std]

use core::panic::PanicInfo;

#[panic_handler]
fn panic(_panic: &PanicInfo<'_>) -> ! {
    loop {}
}

#![no_main]というアトリビュートがついていて、main関数がないこと、#[panic_handler]というアトリビュートがついたpanic関数が存在することが普通のプログラムとは異なっています。 main関数が通常であれば最初に実行されるプログラムとなるのですが、これもOSが存在して事前準備をしてくれることを前提としたものになっています。 main関数に相当するレイヤーは後々つくっていくことになりますが、今回は最小の、なので省略です。 代わりにパニック時の動作についてはきちんと指定する必要があります。例えばOptionNoneに対してunwrapを呼び出すと通常のプログラムならば異常終了するはずですが、OSのないベアメタル環境ではこの異常終了時の動作を定義してあげる必要があります。 これが#![panic_handler]アトリビュートがついているpanic関数、というわけです。今回は単に無限ループさせるだけですね。

このプログラムをビルドしてみましょう。 普通にビルドしてしまうと、実行しているPC向けのバイナリをつくってしまいますので、ターゲットを指定してあげる必要があります。

$ cargo build --target thumbv7em-none-eabihf
   Compiling bookos v0.1.0 (/home/garasubo/workspace/bookos)
    Finished dev [unoptimized + debuginfo] target(s) in 0.25s

thumbというのはArmの命令セットの名前で、命令長が16ビットになっているという特徴があります。ARM命令と呼ばれる命令セットも存在してそちらは命令長が32ビットなのですが、ARMv7-Mではこちらはサポートされていません。 後ろのhfというのはハードウェアの浮動小数点演算器が存在することを示しています。一部のARMv7-Mのチップでは浮動小数点演算器がないことがあり、コンパイラでそれらをソフトウェア演算にしなければならないことがあります。 もっとも、今回は浮動小数点の絡む処理を書く予定はないので、間違えてつけなくても(あるいは違うボードを使っていて浮動小数点演算器がないにもかかわらずhfをつけたとしても)問題にはならないでしょう。

Vector tableを定義する

stdが使えない問題はクリアしましたが、まだなにもプログラムが実行できない状態なのでこれを解決していきましょう。 このArmマイコンではVector tableに従って最初に実行されるプログラムが決定されることがマニュアルからわかっているので、このVector tableを組み込んだプログラムをコンパイルできるようにすればこの問題は解決できます。 プログラムをコンパイルしたとき、各関数や変数がどのようなフォーマットでバイナリとして配置されるかを定義するにはリンカースクリプトというものを使います。 これはC言語など他の言語でベアメタルプログラミングするときも使うものです。 The Embednomiconの2.Memory layoutの章で使われているリンカースクリプトを見てみましょう。このリンカスクリプトはlink.ldとしてプロジェクトのトップディレクトリにおいておきましょう。

* Memory layout of the LM3S6965 microcontroller */
/* 1K = 1 KiBi = 1024 bytes */
MEMORY
{
  FLASH : ORIGIN = 0x00000000, LENGTH = 256K
  RAM : ORIGIN = 0x20000000, LENGTH = 64K
}

/* The entry point is the reset handler */
ENTRY(Reset);

EXTERN(RESET_VECTOR);

SECTIONS
{
  .vector_table ORIGIN(FLASH) :
  {
    /* First entry: initial Stack Pointer value */
    LONG(ORIGIN(RAM) + LENGTH(RAM));

    /* Second entry: reset vector */
    KEEP(*(.vector_table.reset_vector));
  } > FLASH

  .text :
  {
    *(.text .text.*);
  } > FLASH

  /DISCARD/ :
  {
    *(.ARM.exidx .ARM.exidx.*);
  }
}

MEMORYというのがマイコンのメモリのレイアウトを記述するセクションになっていて、SECTIONSでプログラムをそのメモリにどう配置するかを定義しています。 MEMORYの中身はハードウェア依存で、本書で使っているマイコンのレイアウトとは異なるので、あとで修正する必要があります。 .vector_tableというのがVector tableを配置するところです。FLASHの先頭に置くようにしています。 一番最初のエントリーはスタックポインタのアドレスの初期値を格納する必要があります。 次のエントリーはリセット時に呼び出される関数へのアドレスになっています。これをRust側で定義してあげればいいというわけですね。 このスクリプトではRAM領域の末尾を指定してます。スタックポインタは末尾から先頭に向かって伸びていくので、通常は利用可能なRAM領域の末尾をしてしておけばよいでしょう。 .textセクションが実際のプログラムを置く場所です。 .ARM.exidxは標準ライブラリでのみ利用するセクションなので破棄するように指示しています。

では、reset_vectorを定義してあげるところを引用します。

// The reset vector, a pointer into the reset handler
#[link_section = ".vector_table.reset_vector"]
#[no_mangle]
pub static RESET_VECTOR: unsafe extern "C" fn() -> ! = Reset;

#[link_section = ".vector_table.reset_vector"]というアトリビュートで、メモリ上の配置をしてあげることができます。 RESET_VECTORという変数をそこにおいてあげるというわけですが、これがReset関数への関数ポインタになっています。 extern "C"というのがついていると思いますが、これはC言語の関数と同じ形式で呼び出せるようにするというものです。 関数の型ですが、() -> !となっています。この返り値の!というのはこの関数を実行したら決して終了することはない、ということを意味しています(発散する関数とも言います)。 C言語の関数とRustの関数はコンパイルしたときにフォーマットが異なり、C言語形式でないとマイコンはそこにジャンプしてそのまま実行ということができません。 #[no_mangle]というアトリビュートもついています。Rustでコンパイルしたとき、変数や関数の名前はリンカースクリプト内では別の名前(シンボル)に置き換えられるマングリングという処理が行われます。これを防ぐのがこのアトリビュートの役割です。 しかし、今回はこの変数を直接指定するということをしていないので、なくても動くでしょう。

あとはReset関数を定義すれば完成です。

#[no_mangle]
pub unsafe extern "C" fn Reset() -> ! {
    let _x = 42;

    // can't return so we go into an infinite loop here
    loop {}
}

さて、上述のリンカスクリプトを使うためには、コンパイル時にオプションとして渡してあげる必要があります。 RUSTFLAGSを環境変数としてセットすることで、rustcコマンドにオプションを追加することができます。

$ RUSTFLAGS="-C link-args=-Tlink.ld" cargo build --target thumbv7em-none-eabihf
   Compiling bookos v0.1.0 (/home/garasubo/workspace/bookos)
    Finished dev [unoptimized + debuginfo] target(s) in 0.15s

さて、毎回このオプションを渡すのは少々面倒くさいです。.cargo/configというファイルをつくっておくと、デフォルトのパラメータをセットできます。詳しくは公式のドキュメントを参照してください。

[target.thumbv7em-none-eabihf]
rustflags = [
    "-C", "link-arg=-Tlink.ld",
]

[build]
target = "thumbv7em-none-eabihf"

こうしておけば単にcargo buildとすれば前の様々なオプションをつけたコマンドと同じ結果が得られるはずです。

プログラムをボードで実行する

ここで一度ボードにプログラムを書き込んであげたいと思います。 まずは、link.ldの中のアドレスを修正するところから始めます。STMのリファレンスマニュアルの3.4章によるとFLASHの領域は0x800_0000から0x81f_ffffまでの2MB領域に広がっていることがわかります。 RAM領域はリファレンスマニュアルの2.3.1を見ると0x2000_0000から256KBの領域にあるとわかります。 しかし、STMのデータシートの方を3.6章を見ると、64KBはcore coupled memoryで使うことができず、5章のメモリマップを見ると0x2003_0000以降の領域は使えないことがわかります。 よって、以下のように書き換えましょう。

MEMORY
{
  FLASH : ORIGIN = 0x08000000, LENGTH = 2M
  RAM : ORIGIN = 0x20000000, LENGTH = 192K
}

書き換えたら再度cargo buildしましょう。

プログラムをフラッシュROMに書き込むにはST-Linkというこのボードに内蔵されたデバッグ用インターフェースを利用することで可能です。 ボードには2つmicro USBの端子がついていると思いますが、切れ込みで別れた小さいエリアについている方の端子(CN1)がST-Linkと通信するための端子です。 PC側はこのインターフェースを利用するためにOpen OCDを使います。Ubuntuであればaptコマンド経由でインストールできます。

IMAGE=target/thumbv7em-none-eabihf/debug/bookos openocd -f interface/stlink-v2-1.cfg -f target/stm32f4x.cfg -c "init; reset halt; flash write_image erase $IMAGE; verify_image $IMAGE; reset; shutdown"

-fオプションでデフォルトで用意された設定ファイルを利用することができます。-cオプションでコマンドを実行しています。このコマンドでボードを初期化した後、$IMAGEで指定されたファイルを書き込んだ後、ちゃんと書き込めたかの確認もしています。 cargo buildで生成されたバイナリはtarget/<アーキテクチャ名>/debug/<アプリケーション名>にあります。 しかし、もとのプログラムが無限ループするだけで何もしていないプログラムのため、これでは動いているかどうかすらよくわかりません。 そこで、デバッガを利用することでどのようにプログラムが動いているか覗いてみましょう。デバッガはGDBを利用します。 Ubuntu 18.04以降ではaptよりgdb-multiarchを、それ以前の場合はgdb-arm-none-eabiをインストールしましょう。 GDBには別の環境で動いているプログラムと通信してデバッグする機能があります。Open OCDはGDBと通信するためのサーバーとしての機能もあります。 ターミナルを2つ開いて、片方のターミナルでは

openocd -f interface/stlink-v2-1.cfg -f target/stm32f4x.cfg

を実行しておくとこれがGDBのサーバーとなります。 別のターミナルで

gdb-multiarch target/thumbv7em-none-eabihf/debug/bookos

を実行するとGDBが立ち上がりますが、この状態ではまだサーバーとは通信していません。target remoteコマンドでサーバーを指定したのち、プログラムを読み込んでみましょう。

(gdb) target remote :3333
Remote debugging using :3333
0x00000000 in ?? ()
(gdb) load
Loading section .vector_table, size 0x4 lma 0x8000000
Start address 0x0, load size 4
Transfer rate: 7 bytes/sec, 4 bytes/write.
(gdb) break Reset
Breakpoint 1 at 0x800000c: file src/main.rs, line 13.

これでReset関数にブレークポイントが仕掛けられました。 あとは普通にステップ実行できます。

(gdb) continue
Continuing.
Note: automatically using hardware breakpoints for read-only addresses.

Breakpoint 1, Reset () at src/main.rs:13
13	    let _x = 42;
(gdb) step
16	    loop {}
(gdb) 
^C
Program received signal SIGINT, Interrupt.
0xe7ff9000 in ?? ()

最後は無限ループになっているので、強制的に止めました。

いざ、Hello World

プログラムは書き込めたのでHello Worldを出力していきたいと思います。 しかし、今回のボードにはディスプレイはついていません。どこにこの文字を出力すればいいのでしょうか。 一般的に、このような組込みボードと通信するインターフェースとしてUARTというモジュールがあります。これを使えばPCとマイコン間で比較的簡単に通信ができます。 しかし、今回はこれを使わずもっと手軽なセミホスティングというデバッガを介した出力でこれをやろうと思います。 この方法はあくまでデバッグ用の機能なので、デバッガをつけられない実際の製品版で用いることはできず、UARTと比べるとかなり通信速度は遅いのですが、 今回の目的はあくまでプログラムがちゃんと動作しているかの確認のためのHello Worldなので、セミホスティングを使うことにしましょう。 このセミホスティングの機能を全部理解するのも大変なので、今回は既存のライブラリを使ってしまいましょう。 RustのEmbeddedワーキンググループの提供しているクレートであるcortex-m-semihostingを使います。 Cargo.tomldependencyとしてこのクレートを追加します。

[dependencies]
cortex-m-semihosting = "0.3.5"

その後、src/main.rs内のReset関数を以下のように書き換えます。

use cortex_m_semihosting::hprintln;

#[no_mangle]
pub unsafe extern "C" fn Reset() -> ! {
    hprintln!("Hello World").unwrap();

    loop {}
}

これでビルドをしてみると、リンクのところで失敗してしまいます。

$ cargo build
...
  = note: rust-lld: error: no memory region specified for section '.rodata'
          rust-lld: error: no memory region specified for section '.bss'
          

error: aborting due to previous error

error: Could not compile `bookos`.

To learn more, run the command again with --verbose.

.rodata.bssセクションがないと言われています。ざっくり説明すると.rodataは定数を保存しておく領域、.bssは初期値が0のグローバル変数を保存する領域です。 これ以外にも.dataという初期値が0でないグローバル変数のための領域も実は定義をサボってきました。 これらのセクションはリンカースクリプトで定義しないといけないのはもちろんなのですが、これをちゃんとプログラムで使うためにはいくつか処理をする必要があります。 .bssセクションは書き換えの必要があるため、RAM領域に配置されるのですが、RAM領域の初期値が0である保証はないのでこれを0にプログラム側で初期化してあげる必要があります。 .dataセクションは初期値がROM領域にあって実際の変数はRAM領域に配置されることになるので、ROM領域の初期値をRAM領域側にコピーする必要があります。 .rodataはリードオンリーなので何もしなくて大丈夫です。 Embedonomiconと同様にReset関数の先頭で以下のような処理をしておきましょう。

use core::ptr;

pub unsafe extern "C" fn Reset() -> ! {
    extern "C" {
        static mut _sbss: u8;
        static mut _ebss: u8;
        static mut _sidata: u8;
        static mut _sdata: u8;
        static mut _edata: u8;
    }

    let count = &_ebss as *const u8 as usize - &_sbss as *const u8 as usize;
    ptr::write_bytes(&mut _sbss as *mut u8, 0, count);

    let count = &_edata as *const u8 as usize - &_sdata as *const u8 as usize;
    ptr::copy_nonoverlapping(&_sidata as *const u8, &mut _sdata as *mut u8, count);
...

link.ldは以下のようなセクションを加えます。

SECTIONS
{
...

  .rodata :
  {
      *(.rodata .rodata.*);
  } > FLASH

  .bss (NOLOAD):
  {
    _sbss = .;
    *(.bss .bss.*);
    _ebss = .;
  } > RAM

  .data : AT(ADDR(.rodata) + SIZEOF(.rodata))
  {
    _sdata = .;
    *(.data .data.*);
    _edata = .;
  } > RAM

  _sidata = LOADADDR(.data);

  /DISCARD/ :
...

link.ldで定義した定数をReset関数でextern Cで読み込んで使っています。 まず_sbssから_ebssの領域をptr::write_bytesで0で初期化します。 次に.dataの初期値は_sidataの値から始まるROM領域に存在しますがROM領域にはプログラムでは書き換え不能なので、プログラム上の配置は_sdataから始まるRAM領域になっています。 これをptr::copy_nonoverlappingでコピーします。

さて、これで今度はcargo buildでのコンパイルが通るはずです。 さっきと同様に、Open OCDとGDBを立ち上げてプログラムを実行すると、Open OCD側にHello Worldが出現するのが期待される結果ですが、そのためにはOpen OCDのセミホスティング機能を有効化する必要があります。 GDBを立ち上げtarget remote :3333を実行したあとに、

(gdb) monitor arm semihosting enable

というコマンドを実行してから、continueを実行してプログラムを再開すると、以下のようにOpen OCD側にHello Worldが出力されるはずです。

...
xPSR: 0x01000000 pc: 0x08000008 msp: 0x2001c000
in procedure 'arm'
semihosting is enabled
Hello World