シンボルでのロギング
このセクションでは、極めて軽量なロギングを行うために、シンボルとELFフォーマットを利用する方法をお見せします。
任意のシンボル
クレート間で、安定したシンボルインタフェースが必要な場合、no_mangle
アトリビュートを主に使用し、
時には、export_name
アトリビュートを使用します。
export_name
アトリビュートは、シンボル名になる文字列を引数に取ります。
一方、#[no_mangle]
は、基本的に#[export_name = <item-name>]
のシンタックスシュガーです。
引数に取れる文字列は、1単語の名前だけに限定されないことがわかりました。
例えば、文のような任意の文字列をexport_name
アトリビュートの引数として使うことができます。
少なくても出力形式がELFの場合、nullバイトを含まないものならば何でも構いません。
そのことを確認してみましょう。
$ cargo new --lib foo
$ cat foo/src/lib.rs
#![allow(unused)] fn main() { #[export_name = "Hello, world!"] #[used] static A: u8 = 0; #[export_name = "こんにちは"] #[used] static B: u8 = 0; }
$ ( cd foo && cargo nm --lib )
foo-d26a39c34b4e80ce.3lnzqy0jbpxj4pld.rcgu.o:
0000000000000000 r Hello, world!
0000000000000000 V __rustc_debug_gdb_scripts_section__
0000000000000000 r こんにちは
これがどこに繋がるか、わかりますか?
エンコードする
やることは、次の通りです。ログメッセージごとにstatic
変数を1つ作りますが、
メッセージをその変数の中に格納せずに、変数のシンボル名にメッセージを格納します。
ログ出力するものは、static
変数の内容ではなく、そのアドレスです。
static
変数のサイズがゼロでない限り、各変数は異なるアドレスを持ちます。
ここで行うことは、各メッセージを一意の識別子(変数のアドレスになります)に効率的にエンコードすることです。
ログシステムの一部は、この識別子をメッセージにデコードしなければなりません。
このアイデアを実現するコードを書いていきましょう。
この例では、I/Oが必要なため、cortex-m-semihosting
クレートを使用します。
セミホスティングは、ターゲットデバイスがホストのI/O機能を借りられるようにするための技術です。
今回の場合、QEMUは細かい設定なしでセミホスティングが使えるため、デバッガは不要です。
実機の場合、シリアルポートのようなI/Oが必要になります。
QEMU上でI/Oを使う最も簡単な方法であるため、今回はセミホスティングを使います。
コードは次のとおりです。
#![no_main] #![no_std] use core::fmt::Write; use cortex_m_semihosting::{debug, hio}; use rt::entry; entry!(main); fn main() -> ! { let mut hstdout = hio::hstdout().unwrap(); #[export_name = "Hello, world!"] static A: u8 = 0; writeln!(hstdout, "{:#x}", &A as *const u8 as usize); #[export_name = "Goodbye"] static B: u8 = 0; writeln!(hstdout, "{:#x}", &B as *const u8 as usize); debug::exit(debug::EXIT_SUCCESS); loop {} }
プログラムがQEMUプロセスを終了できるようにするため、debug::exit
も使えるようにしてあります。
QEMUプロセスを手動で終了しなくて良いため、便利です。
そして、こちらはCargo.tomlのdependencies
セクションです。
[dependencies]
cortex-m-semihosting = "0.3.1"
rt = { path = "../rt" }
これでプログラムをビルドできます。
$ cargo build
実行するためには、QEMU起動時に、--semihosting-config
フラグを付け加えます。
$ qemu-system-arm \
-cpu cortex-m3 \
-machine lm3s6965evb \
-nographic \
-semihosting-config enable=on,target=native \
-kernel target/thumbv7m-none-eabi/debug/app
0x1fe0
0x1fe1
注記 これらのアドレスは、あなたが得たアドレスと異なるかもしれません。
static
変数のアドレスは、 ツールチェインが更新された時(例えば、最適化が改善されるかもしれません)に変わる可能性があるからです。
コンソールに2つのアドレスが表示されました。
デコードする
どのようにして、このアドレスを文字列に変換するのでしょうか? 答えはELFファイルのシンボルテーブルです。
$ cargo objdump --bin app -- -t | grep '\.rodata\s*0*1\b'
00001fe1 g .rodata 00000001 Goodbye
00001fe0 g .rodata 00000001 Hello, world!
$ # 1列目はシンボルのアドレス、最終列はシンボル名です。
objdump -t
はシンボルテーブルを表示します。このテーブルは全てのシンボルを含んでいますが、
.rodata
セクションの中にある1バイトの大きさ(変数の型はu8
です)のアドレスだけを詳しく見ていきます。
プログラムを最適化すると、シンボルのアドレスが変わる可能性があるため、注意して下さい。 確認してみましょう。
専門家によるアドバイス
target.thumbv7m-none-eabi.runner
を、 長いQEMUコマンド(qemu-system-arm -cpu (..) -kernel
)に設定することができます。 Cargo設定ファイル(.cargo/config
)にコマンドを書くことで、cargo run
がそのランナーを使ってバイナリを実行します。
$ head -n2 .cargo/config
[target.thumbv7m-none-eabi]
runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel"
$ cargo run --release
Running `qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel target/thumbv7m-none-eabi/release/app`
0xb9c
0xb9d
$ cargo objdump --bin app --release -- -t | grep '\.rodata\s*0*1\b'
00000b9d g O .rodata 00000001 Goodbye
00000b9c g O .rodata 00000001 Hello, world!
常に実行したELFファイル内の文字列を探すようにして下さい。
もちろん、ELFファイルに含まれるシンボルテーブル(.symtab
セクション)を解析するツールを使うことで、
ELFファイル内の文字列を探すプロセスを自動化することが可能です。
そのようなツールを実装することは、本書のスコープ外です。
そのため、読者の演習とします。
ゼロコストにする
より良いものにできるでしょうか?もちろんできます!
現在の実装は、static
変数を.rodata
に配置しています。これは、その変数の値を決して使わないにも関わらず、
Flashの容量を専有することを意味します。
リンカスクリプトの魔法を少し使うことで、Flashの使用量をゼロにできます。
$ cat log.x
SECTIONS
{
.log 0 (INFO) : {
*(.log);
}
}
static
変数を新しい.log
出力セクションに配置します。
このリンカスクリプトは、入力オブジェクトファイルの.log
セクションにある全てのシンボルを集め、
.log
出力セクションに置きます。
このパターンは、メモリレイアウトの章でやりました。
少し新しい部分は、(INFO)
の部分です。これは、リンカに、このセクションは割当不可セクションであることを教えます。
割当不可セクションは、ELFバイナリにメタデータとして残りますが、ターゲットデバイスにはロードされません。
また、この出力セクションの開始アドレスを.log 0 (INFO)
で0
に指定しています。
他に改善点は、フォーマットされたI/O(fmt::Write
)から、バイナリI/Oに切り替えることです。
つまり、文字列としてではなく、バイトとしてホストにアドレスを送ります。
バイナリシリアライゼーションは、複雑になる可能性がありますが、各アドレスを1バイトとしてシリアライズすることで、 極めて簡潔になります。この方法により、エンディアネスやフレーム化について悩まなくて済みます。 この形式の欠点は、1バイトは256のアドレスしか表現できないことです。
これらの変更を加えましょう。
#![no_main] #![no_std] use cortex_m_semihosting::{debug, hio}; use rt::entry; entry!(main); fn main() -> ! { let mut hstdout = hio::hstdout().unwrap(); #[export_name = "Hello, world!"] #[link_section = ".log"] // <- 追加! static A: u8 = 0; let address = &A as *const u8 as usize as u8; hstdout.write_all(&[address]).unwrap(); // <- 変更! #[export_name = "Goodbye"] #[link_section = ".log"] // <- 追加! static B: u8 = 0; let address = &B as *const u8 as usize as u8; hstdout.write_all(&[address]).unwrap(); // <- 変更! debug::exit(debug::EXIT_SUCCESS); loop {} }
実行する前に、リンカに渡す引数に-Tlog.x
を追加しなければなりません。
Cargo設定ファイルで、追加できます。
$ cat .cargo/config
[target.thumbv7m-none-eabi]
runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel"
rustflags = [
"-C", "link-arg=-Tlink.x",
"-C", "link-arg=-Tlog.x", # <- 追加!
]
[build]
target = "thumbv7m-none-eabi"
これで実行することができます!今回、出力はバイナリ形式であるため、
xxd
コマンドにパイプし、16進数の文字列に再変換します。
$ cargo run | xxd -p
0001
アドレスは、0x00
と0x01
です。では、シンボルテーブルを見てみましょう。
$ cargo objdump --bin app -- -t | grep '\.log'
00000001 g O .log 00000001 Goodbye
00000000 g O .log 00000001 Hello, world!
目的の文字列があります。今回は、アドレスがゼロから開始していることに気づくでしょう。
これは、.log
出力セクションに、開始アドレスを設定したためです。
u8
を型として使っているため、各変数は1バイトの大きさです。
もしu16
のような型を使った場合、全てのアドレスは偶数になり、全てのアドレス空間(0...255
)を、
効率的に利用することができないでしょう。
パッケージする
文字列をログ出力するステップは、常に一緒です。そこで、 クレート内でだけ利用可能なマクロにリファクタリングします。 また、I/O部分をトレイトで抽象化することで、ロギングライブラリをより再利用可能にできます。
$ cargo new --lib log
$ cat log/src/lib.rs
#![allow(unused)] #![no_std] fn main() { pub trait Log { type Error; fn log(&mut self, address: u8) -> Result<(), Self::Error>; } #[macro_export] macro_rules! log { ($logger:expr, $string:expr) => {{ #[export_name = $string] #[link_section = ".log"] static SYMBOL: u8 = 0; $crate::Log::log(&mut $logger, &SYMBOL as *const u8 as usize as u8) }}; } }
このライブラリが.log
セクションに依存することを考えると、このライブラリがlog.x
リンカスクリプトの提供に責任を持つべきです。
それでは、そうしましょう。
$ mv log.x ../log/
$ cat ../log/build.rs
use std::{env, error::Error, fs::File, io::Write, path::PathBuf}; fn main() -> Result<(), Box<Error>> { // リンカスクリプトをリンカが見つけられる場所に置きます let out = PathBuf::from(env::var("OUT_DIR")?); File::create(out.join("log.x"))?.write_all(include_bytes!("log.x"))?; println!("cargo:rustc-link-search={}", out.display()); Ok(()) }
それでは、log!
マクロを使って、アプリケーションをリファクタリングしましょう。
$ cat src/main.rs
#![no_main] #![no_std] use cortex_m_semihosting::{ debug, hio::{self, HStdout}, }; use log::{log, Log}; use rt::entry; struct Logger { hstdout: HStdout, } impl Log for Logger { type Error = (); fn log(&mut self, address: u8) -> Result<(), ()> { self.hstdout.write_all(&[address]) } } entry!(main); fn main() -> ! { let hstdout = hio::hstdout().unwrap(); let mut logger = Logger { hstdout }; log!(logger, "Hello, world!"); log!(logger, "Goodbye"); debug::exit(debug::EXIT_SUCCESS); loop {} }
新しいlog
クレートへの依存を、Cargo.toml
に追加するのを忘れないようにしましょう。
$ tail -n4 Cargo.toml
[dependencies]
cortex-m-semihosting = "0.3.1"
log = { path = "../log" }
rt = { path = "../rt" }
$ cargo run | xxd -p
0001
$ cargo objdump --bin app -- -t | grep '\.log'
00000001 g O .log 00000001 Goodbye
00000000 g O .log 00000001 Hello, world!
以前と同じ出力になりました!
おまけ:複数のログレベル
多くのログフレームワークは、異なるログレベルでメッセージをロギングする方法を提供しています。 これらのログレベルは、メッセージの重要度を告げています。「これはエラーです」、「これはただの警告です」、など。 これらのログレベルは、例えばエラーメッセージを検索する時に、重要でないメッセージを除去するために使用されます。
私達のログライブラリを、フットプリントの増加なしに、ログレベルをサポートするように拡張できます。 やることは、次の通りです。
メッセージ用に、0以上、255以下のフラットなアドレス空間があります。 簡単化のために、エラーメッセージと警告メッセージを区別したいだけ、としましょう。 全てのエラーメッセージをアドレス空間の最初に置き、警告メッセージをエラーメッセージの後に置きます。 デコーダが最初の警告メッセージのアドレスを知っていれば、メッセージを分類可能です。 このアイデアは、3つ以上のログレベルをサポートするときに拡張できます。
log
マクロを、error!
とwarn!
の2つの新しいマクロで置き換えて、このアイデアを試してみましょう。
$ cat ../log/src/lib.rs
#![allow(unused)] #![no_std] fn main() { pub trait Log { type Error; fn log(&mut self, address: u8) -> Result<(), Self::Error>; } /// エラーログレベルでメッセージをログ出力します #[macro_export] macro_rules! error { ($logger:expr, $string:expr) => {{ #[export_name = $string] #[link_section = ".log.error"] // <- 変更! static SYMBOL: u8 = 0; $crate::Log::log(&mut $logger, &SYMBOL as *const u8 as usize as u8) }}; } /// 警告ログレベルでメッセージをログ出力します #[macro_export] macro_rules! warn { ($logger:expr, $string:expr) => {{ #[export_name = $string] #[link_section = ".log.warning"] // <- 変更! static SYMBOL: u8 = 0; $crate::Log::log(&mut $logger, &SYMBOL as *const u8 as usize as u8) }}; } }
メッセージを異なるリンクセクションに配置することでエラーと警告を区別します。
次にやらなければならないことは、エラーメッセージを警告メッセージの前に配置するように、 リンカスクリプトを更新することです。
$ cat ../log/log.x
SECTIONS
{
.log 0 (INFO) : {
*(.log.error);
__log_warning_start__ = .;
*(.log.warning);
}
}
エラーと警告との境界に、__log_warning_start__
という名前をつけています。
このシンボルのアドレスは、最初の警告メッセージのアドレスになります。
次に、これらの新しいマクロを使うように、アプリケーションを更新します。
$ cat src/main.rs
#![no_main] #![no_std] use cortex_m_semihosting::{ debug, hio::{self, HStdout}, }; use log::{error, warn, Log}; use rt::entry; entry!(main); fn main() -> ! { let hstdout = hio::hstdout().unwrap(); let mut logger = Logger { hstdout }; warn!(logger, "Hello, world!"); // <- 変更! error!(logger, "Goodbye"); // <- 変更! debug::exit(debug::EXIT_SUCCESS); loop {} } struct Logger { hstdout: HStdout, } impl Log for Logger { type Error = (); fn log(&mut self, address: u8) -> Result<(), ()> { self.hstdout.write_all(&[address]) } }
出力は、それほど変わりません。
$ cargo run | xxd -p
0100
相変わらず2バイトの出力が得られています。 しかし、警告が最初にログ出力されているにも関わらず、エラーが0番地、警告が1番地になっています。
それでは、シンボルテーブルを見てみます。
$ cargo objdump --bin app -- -t | grep '\.log'
00000000 g O .log 00000001 Goodbye
00000001 g O .log 00000001 Hello, world!
00000001 .log 00000000 __log_warning_start__
.log
セクション内に__log_warning_start__
という追加のシンボルがあります。
このシンボルのアドレスは、最初の警告メッセージのアドレスです。
この値より小さいアドレスを持つシンボルは、エラーになります。
それ以外のシンボルは警告です。
適切なデコーダを使うと、これら全ての情報から、次の人間が読みやすい出力を得ることができます。
WARNING Hello, world!
ERROR Goodbye
このセクションを気に入った場合、stlog
ログフレームワークを確認して下さい。
このアイデアを完全に実装しています。