はじめに
この記事はRust Advent Calendar 2020 9日目の記事です。
Linux上のRustをno_stdで動かしてみます。no_stdとは標準ライブラリを使わないということで、一般的なOSの上ではない環境などでRustを動作させたいときに使われます。今回はLinux上で動かすので別にまあ標準ライブラリを使えばいいんですが、こういったムダなことを通してRustの低レイヤ部分を学べたらいいなと思ってやってみました。
とりあえずno_stdを付けてみる
no_stdでRustを使うには、ソースコードに#![no_std]
で属性を付けてやります。cargo new
のデフォルトで書かれているprintln!
は標準ライブラリで定義されているため今回は削除しておきます。
#![no_std]
fn main() {
}
こんな感じで空っぽのmain
関数だけ定義してcargo build
しますが、いろいろと怒られます。
まずは#[panic_handler]
がないと言われます。Rustではパニック時の動作が定義されている必要があるようです。これはRustonomiconに参考実装があるので、一番簡単なものをほぼそのまま使いましょう。
#[panic_handler]
fn panic(_panic: &core::panic::PanicInfo<'_>) -> ! {
loop {}
}
また、eh_personality
がないとも言われます。これはパニック時にスタックを巻き戻すときに使われる関数のようですが、パニック時の動作をabortに変更することで必要なくなります。
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
これらを直すと次はstart
がないと言われます。これはUnstable Bookに参考実装があり、#[start]
を付けた関数を用意することで解決できます。ただし、これはまだ安定化されていないやり方なので、ツールチェインをnightlyとし、#![feature(start)]
を付けておく必要があります。
#![feature(start)]
#[start]
fn rust_main(_argc: isize, _argv: *const *const u8) -> isize {
0
}
ここまでの修正を合わせて、main.rsはこのようになっています。
#![feature(start)]
#![no_std]
#[panic_handler]
fn panic(_panic: &core::panic::PanicInfo<'_>) -> ! {
loop {}
}
#[start]
fn rust_main(_argc: isize, _argv: *const *const u8) -> isize {
0
}
標準Cライブラリと戦う
さて、上記のmain.rsをcargo build
してみましょう。すると、このようなエラーが出力されました。省略部分にはcc
に続くオプションが大量に並んでいますが、絶対パスなども含まれているためここでは載せていません。
error: linking with `cc` failed: exit code: 1
|
= note: "cc" ...省略...
= note: /usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/8/../../../x86_64-linux-gnu/Scrt1.o: in function `_start':
(.text+0x12): undefined reference to `__libc_csu_fini'
/usr/bin/ld: (.text+0x19): undefined reference to `__libc_csu_init'
/usr/bin/ld: (.text+0x26): undefined reference to `__libc_start_main'
collect2: error: ld returned 1 exit status
今回の環境(普通のLinuxシステム)では、cc
の実体はgcc
です。その実行中にリンカ(ld
)がエラーを出しているようです。Rustコンパイラはリンクの工程を外部のツールに頼っており、Linuxシステムではそれは大抵gcc
(ld
)でしょう。つまり、Rustとしてのコンパイルは完了しているが、実行形式を出力するためのリンクが失敗していると考えてよさそうです。
見つからないシンボルは、__libc_csu_fini
、__libc_csu_init
、__libc_start_main
です。これらは名前からも察せられるように、標準Cライブラリに含まれる関数です。ここで、上記では省略したcc
のオプションを確認すると-nodefaultlibs
が付いていることが分かります。これは、通常デフォルトでリンクされる標準CライブラリをリンクしないようにするGCCのオプションです。#![no_std]
が付いているときにはRustの標準ライブラリを使わないだけでなく、Cの標準ライブラリも使わないように設定されるというのは自然な流れでしょう。
ちなみに、見つからないシンボルを使おうとしている_start
はどこから来ているのでしょうか。実はこれも標準Cライブラリの一部ではあるのですが、スタートアップの処理(Scrt1.o)はメインのライブラリ(libc.so)とは分離されており、-nodefaultlibs
ではlibc.soしか除去されません。Scrt1.oも取り除くには-nostartfiles
などのオプションを付ける必要があります。ですが、今回はこのまま進めていくことにしましょう。
標準Cライブラリの関数が見つからないためにリンクが失敗していることは分かりました。これはもちろん、libc.soをリンクすることで修正することができます。それは例えば、build.rsにこのような処理を追加することで可能です。
fn main() {
println!("cargo:rustc-link-lib=c");
}
ですが、せっかくno_stdでやっているのにCの標準ライブラリを使いたくないですね。シンボルがないなら自分で作ればいいわけです。作る準備として、それぞれの関数について少し確認してみましょう。
0000000000001040 <_start>:
1040: 31 ed xor ebp,ebp
1042: 49 89 d1 mov r9,rdx
1045: 5e pop rsi
1046: 48 89 e2 mov rdx,rsp
1049: 48 83 e4 f0 and rsp,0xfffffffffffffff0
104d: 50 push rax
104e: 54 push rsp
104f: 4c 8d 05 8a 02 00 00 lea r8,[rip+0x28a] # 12e0 <__libc_csu_fini>
1056: 48 8d 0d 23 02 00 00 lea rcx,[rip+0x223] # 1280 <__libc_csu_init>
105d: 48 8d 3d 7c 01 00 00 lea rdi,[rip+0x17c] # 11e0 <main>
1064: ff 15 76 2f 00 00 call QWORD PTR [rip+0x2f76] # 3fe0 <__libc_start_main@GLIBC_2.2.5>
106a: f4 hlt
106b: 0f 1f 44 00 00 nop DWORD PTR [rax+rax*1+0x0]
まずこれは、libc.soをリンクした場合のバイナリをobjdump -d
で出力したときの_start
の処理です。call
命令で実行しているのは__libc_start_main
だけであることが分かります。また、glibc(2.28)の実装における__libc_start_main
のシグネチャはこのようになっています。
STATIC int
LIBC_START_MAIN (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL),
int argc, char **argv,
#ifdef LIBC_START_MAIN_AUXVEC_ARG
ElfW(auxv_t) *auxvec,
#endif
__typeof (main) init,
void (*fini) (void),
void (*rtld_fini) (void), void *stack_end)
つまり、__libc_csu_fini
と__libc_csu_init
は__libc_start_main
に引数として与えられ、__libc_start_main
の中で実行されています。__libc_csu_init
は初期化(initial)処理、__libc_csu_fini
は終了(finish)処理をおそらく意味しており、どうもGCC拡張でmain関数前後のコンストラクタとデストラクタを定義できたりするみたいです。今回は使わなくてもよさそうなので、この2つは空の関数としてシンボルだけ用意することにします。
#[no_mangle]
fn __libc_csu_fini() {}
#[no_mangle]
fn __libc_csu_init() {}
続いて__libc_start_main
ですが、まずは第一引数としてmain
が渡されていることに注目します。これが実質、私たちが実行したいmain
関数になります。実質と言っているのは、ここでのmain
は#[start]
を付けたrust_main
ではなく、rust_main
を呼びだすだけの(おそらくコンパイラにより定義された)main
関数となっているためです。
コマンドライン引数はとりあえず使わないので一旦考えないようにしましょう。用意されている引数を使わないことによる悪影響はないはずです。また、単純な形のシグネチャなのでABIの考慮も後回しにしましょう。
#[no_mangle]
fn __libc_start_main(main: fn() -> isize) {
main();
}
結果、main.rsはこのようになりました。とりあえずcargo build
は成功し、デバッガなどで確認すればrust_main
が呼ばれていることも確認できます。
#![feature(start)]
#![no_std]
#[no_mangle]
fn __libc_csu_fini() {}
#[no_mangle]
fn __libc_csu_init() {}
#[no_mangle]
fn __libc_start_main(main: fn() -> isize) {
main();
}
#[panic_handler]
fn panic(_panic: &core::panic::PanicInfo<'_>) -> ! {
loop {}
}
#[start]
fn rust_main(_argc: isize, _argv: *const *const u8) -> isize {
0
}
システムコールを使う
現状のmain.rsは正しく終了できていないため、最終的には_start
にあるhlt
命令でSegmentation faultになってしまいます。ですが、とりあえずそれは放置してrust_main
の処理を作ってしまいましょう。
「Hello, world!」という文字列を出力したいわけですが、入出力にはOSの協力が必要なのでLinuxのシステムコールを叩く必要があります。しかし今の環境にはRustの標準ライブラリはおろか、Cレベルのシステムコールラッパーを提供している標準Cライブラリもありません。つまり、アセンブリレベルでシステムコールを叩く必要があります。
Rustでは最近新しいインラインアセンブリのマクロasm!
がnightlyで提供されるようになりました。それを紹介するInside Rust Blogの記事で、まさに今やろうとしているアセンブリが参考実装として記載されています。ほぼそのまま持ってきてしまいましょう。
#![feature(start, asm)]
#[start]
fn rust_main(_argc: isize, _argv: *const *const u8) -> isize {
let buf = "Hello, world!\n";
let ret: isize;
unsafe {
asm!(
"syscall",
in("rax") 1, // write
in("rdi") 1, // stdout
in("rsi") buf.as_ptr(),
in("rdx") buf.len(),
out("rcx") _, // destroyed in kernel
out("r11") _, // destroyed in kernel
lateout("rax") ret,
);
}
ret
}
asm!
もまだnightlyでしか使えないため、#![feature(asm)]
が必要です。また、アセンブリの中身はRustで面倒を見てくれないため、当然unsafe
になります。
ではアセンブリの中身を見ていきましょう。syscall
はシステムコールを発行するための命令です。(システムコール専用のソフトウェア割り込みみたいな感じだと思います。)そしてrax
にはシステムコール番号と呼ばれる、システムコールを一意に識別するための番号が入ります。rdi
、rsi
、rdx
には引数が入ります。rcx
とr11
はカーネル内で破壊される可能性があるため使われないようにしておきます。最後に戻り値はrax
に格納されています。
ここでの引数はwrite(2)
の仕様に従います。システムコール仕様の確認はLinuxのMan pageなどを確認するのがいいでしょう。また、LinuxシステムコールにおけるABI(Application Binary Interface)仕様はglibcにまとまったコメントがあったので、こちらを参照するのも分かりやすいと思います。
/* The Linux/x86-64 kernel expects the system call parameters in
registers according to the following table:
syscall number rax
arg 1 rdi
arg 2 rsi
arg 3 rdx
arg 4 r10
arg 5 r8
arg 6 r9
The Linux kernel uses and destroys internally these registers:
return address from
syscall rcx
eflags from syscall r11
...
Syscalls of more than 6 arguments are not supported. */
要は、システムコール番号をrax
に、引数をそれぞれ適切なレジスタに設定してsyscall
命令を発行すればいいようです。
ここで、システムコールの仕様はMan pageなどで確認できますが、システムコール番号までは記載されていません。また、Linuxのシステムコール番号はCPUアーキテクチャごとに異なっていますので、自分の環境における適切な番号を確認する必要があります。私の環境では/usr/include/x86_64-linux-gnu/asm/unistd_64.hに記載されているようでした。おそらくLinuxカーネルがユーザーランドに提供しているヘッダファイルでしょう。
#define __NR_read 0
#define __NR_write 1
#define __NR_open 2
...
さて、これで「Hello, world!」はできていると思いますが、先ほど放置した終了処理についても考えていきましょう。glibcの__libc_start_main
ではexit(3)
を最後に呼んでいるようでした。プロセスを終了させるので最終的に_exit(2)
が呼ばれればよさそうです。先ほどのwrite(2)
と同じ要領で、__libc_start_main
の最後で_exit(2)
を呼ぶようにしましょう。その際、main
の戻り値を受け取ってその値を_exit(2)
に渡すことにしてみます。
#[no_mangle]
fn __libc_start_main(main: fn() -> isize) {
let ret = main();
unsafe {
asm!(
"syscall",
in("rax") 60, // _exit
in("rdi") ret,
out("rcx") _, // destroyed in kernel
out("r11") _, // destroyed in kernel
);
}
}
これでmain
の実行後に正しくプロセスを終了できるようになりました。
最後におまけで、__libc_start_main
でコマンドライン引数をちゃんと扱うようにして、念のためextern
でC言語のABIを指定しておきましょう。完成形はこのようになりました。
#![feature(start, asm)]
#![no_std]
#[no_mangle]
fn __libc_csu_fini() {}
#[no_mangle]
fn __libc_csu_init() {}
#[no_mangle]
extern "C" fn __libc_start_main(
main: extern "C" fn(isize, *const *const u8) -> isize,
argc: isize,
argv: *const *const u8
) {
let ret = main(argc, argv);
unsafe {
asm!(
"syscall",
in("rax") 60, // _exit
in("rdi") ret,
out("rcx") _, // destroyed in kernel
out("r11") _, // destroyed in kernel
);
}
}
#[panic_handler]
fn panic(_panic: &core::panic::PanicInfo<'_>) -> ! {
loop {}
}
#[start]
fn rust_main(_argc: isize, _argv: *const *const u8) -> isize {
let buf = "Hello, world!\n";
let ret: isize;
unsafe {
asm!(
"syscall",
in("rax") 1, // write
in("rdi") 1, // stdout
in("rsi") buf.as_ptr(),
in("rdx") buf.len(),
out("rcx") _, // destroyed in kernel
out("r11") _, // destroyed in kernel
lateout("rax") ret,
);
}
ret
}
実行すると「Hello, world!」を出力し、戻り値もシェルに渡せていることが分かります。
$ cargo run -q
Hello, world!
$ echo $?
14
おわりに
スタック周りの設定とかいろいろやらなければいけないかと思っていましたが、Linuxはカーネルがきれいに設定してくれているおかげか、動かすだけならそこまで複雑にならずに実装できました。main関数の起動に関しては、#![no_main]
を付けて_start
に全部書いてしまったほうが楽だったかもしれないですね。まあC言語レベルのスタートアップ処理に少し詳しくなれたのでよしとしましょう。