Rustの「Hello, world!」は何をしているのか?
Rust の「Hello, world!」はprintln!を使うだけのシンプルなコードですが、その裏ではどのような処理が行われているのでしょうか?
結論から言うと、writeシステムコールを呼び出すために、C言語のwrite関数を呼び出すことになるのですが、そこに至るまでにはRust標準ライブラリの多層的な I/O 抽象化が存在しています。
そこで今回は、println!マクロがどのように展開され、どのような関数が呼び出されていくのか、その実装を追いかけてみました。
Hello, world!
今回取り上げるのは、以下の Hello, world! プログラムです。
fn main() {
println!("Hello, world!");
}$ cargo run
Hello, world!環境と調査方法
環境は以下の通りです。
- Rust 1.91.1
- OS: Ubuntu 22.04 LTS
- アーキテクチャ: x86_64
Rustのターゲットはx86_64-unknown-linux-gnuです。
Rust言語のGitHubリポジトリをクローンし、1.91.1タグをチェックアウトしてソースコードを調査しました。
Rust Analyzerが動くように、./x.py setupコマンドを実行します。
git clone https://github.com/rust-lang/rust.git
cd rust
git checkout 1.91.1
git submodule update --init --recursive
./x.py setupこのようにすると、VSCodeでRust Analyzerが動作するようになり、ソースコードの定義ジャンプやシンボル検索ができるようになります。
深掘り
println! マクロ
まずはこのマクロから始まります。
println! マクロは、標準出力に文字列を出力するためのマクロです。このマクロはRust言語の標準ライブラリstdに含まれています。そのため、ここからは標準ライブラリを辿っていきます。
GitHubでRust言語のリポジトリを開き、標準ライブラリの実装を確認します。
- これ以降に取り上げるソースコードは、Rust言語の標準ライブラリや
libcクレートのソースコードからの抜粋です。一部省略している箇所があります。 - 解説は、ソースコードのコメントも参考にしつつ、筆者が理解した内容をまとめたものです。
- コードブロックの上部にファイルパスと行番号を記載しています。
このマクロの定義はmacro.rsファイルにあります。
macro_rules! println {
() => {
$crate::print!("\n")
};
($($arg:tt)*) => {{
$crate::io::_print($crate::format_args_nl!($($arg)*));
}};
}引数ありの場合は2つめのパターンにマッチし、format_args_nl マクロでフォーマットされた引数を _print 関数に渡しています。
format_args_nlマクロはstdではなく、coreで定義されているようです。
pub use core::{
assert, assert_matches, cfg, column, compile_error, concat, const_format_args, env, file,
format_args, format_args_nl, // ...
};nlは「new line」の略で、改行を意味します。改行なしのformat_args!マクロもあります。
coreライブラリの実装を見に行きましょう。
macro_rules! format_args_nl {
($fmt:expr) => {{ /* compiler built-in */ }};
($fmt:expr, $($args:tt)*) => {{ /* compiler built-in */ }};
}これはコンパイラに組み込まれているマクロのため、coreでの実装は特にありません。Rustコンパイラ(rustc)が最終的にフォーマット処理を行います。
詳細を知りたい場合は、Rustコンパイラのソースコードを調べる必要がありますが、ここでは割愛します。
標準出力への書き込み
フォーマットが完了したら、_print関数に渡されます。次にこの関数を見てみましょう。
pub fn _print(args: fmt::Arguments<'_>) {
print_to(args, stdout, "stdout");
}引数のfmt::Argumentsは、フォーマット済みの文字列を作るための中間データです。
println! / format! / write! などは、すべてまず format_args! / format_args_nl を内部で呼んで、この Arguments を作るところから始まります。
pub struct Arguments<'a> {
// 出力するフォーマット文字列の分割された部分(リテラル部分)
pieces: &'a [&'static str],
// {} の中の指定(幅・整形など)。全部デフォルトなら None。
fmt: Option<&'a [rt::Placeholder]>,
// {} に入れる値たち。pieces のあいだに順番に差し込まれる。
args: &'a [rt::Argument<'a>],
}このArgumentsをprint_to関数に渡しています。次にこの関数を見てみましょう。
fn print_to<T>(args: fmt::Arguments<'_>, global_s: fn() -> T, label: &str)
where
T: Write,
{
// キャプチャしていたらバッファに書き込む
if print_to_buffer_if_capture_used(args) {
return;
}
if let Err(e) = global_s().write_fmt(args) {
panic!("failed printing to {label}: {e}");
}
}print_to_buffer_if_capture_usedは「出力をキャプチャしているかを確認し、キャプチャしている場合はバッファに書き込む」関数です。
例えば、cargo testでは、デフォルトでprintln!の出力はキャプチャされ、テストが失敗した場合にのみ表示されます。今回は標準出力に書き込む場合を確認したいため、falseが返ると仮定して次に進みます。
引数のglobal_s関数を呼んで、write_fmtメソッドで書き込んでいるようです。この場合では、global_sにはstdout関数が渡されています。
その実装はこちら。
pub fn stdout() -> Stdout {
Stdout {
inner: STDOUT
.get_or_init(|| ReentrantLock::new(RefCell::new(LineWriter::new(stdout_raw())))),
}
}標準出力を表すデータをグローバル変数(static変数)に保持し、これを参照しているようですね。
大文字のSTDOUTはグローバル変数で、以下のようになっています。
static STDOUT: OnceLock<ReentrantLock<RefCell<LineWriter<StdoutRaw>>>> = OnceLock::new();OnceLockは、一度だけ初期化されることを保証するための型です。get_or_initメソッドは、まだ初期化されていない場合に初期化を行い、その後は初期化済みの値を返します。これにより、STDOUTはプログラムの実行中に一度だけ初期化され、その後は同じインスタンスが使用されます。
このSTDOUTを使って初期化を行い、Stdout構造体を返しています。
pub struct Stdout {
inner: &'static ReentrantLock<RefCell<LineWriter<StdoutRaw>>>,
}
impl Write for Stdout {
fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> io::Result<()> {
// &*self は可変参照(排他参照)を不変参照(共有参照)に変換する
// つまり `&Stdout` の実装が呼ばれる
(&*self).write_fmt(args)
}
}
impl Write for &Stdout {
fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> io::Result<()> {
self.lock().write_fmt(args)
}
}ReentrantLockは同じスレッドだけが何回ロックしても OK な Mutexです。通常の Mutex では、同じスレッドが二回ロックしようとするとデッドロックになりますが、ReentrantLockはそのスレッドがすでにロックしていれば lock_count を増やして何度でも再入できます。
今ここまで辿ってきたのは、print_to関数の中で呼ばれているglobal_s().write_fmt(args)が最終的にどの実装へ到達するのか、その呼び出し先を明らかにするためでした。stdout関数が代入されたglobal_sはこのStdout構造体を返し、そのWriteトレイトのwrite_fmtメソッドが呼び出されます。
ReentrantLockでロックを取得してから書き込んでいるようですね。
書き込みにはLineWriter構造体が使われています。
pub struct LineWriter<W: ?Sized + Write> {
inner: BufWriter<W>,
}
impl<W: ?Sized + Write> Write for LineWriter<W> {
fn write_fmt(&mut self, fmt: fmt::Arguments<'_>) -> io::Result<()> {
LineWriterShim::new(&mut self.inner).write_fmt(fmt)
}
}LineWriterはBufWriterをベースに行単位でバッファリングを行うための構造体です。改行文字が現れた時点でバッファをフラッシュ(書き込み)します。これにより、print! と println! の混在でも表示が崩れにくく、出力が自然なタイミングで流れます。
write_fmtメソッドでは、LineWriterの補助的な役割を持つLineWriterShim構造体を使って書き込んでいるようです。
私なりのコメントをコード内に追加してあります。
pub struct LineWriterShim<'a, W: ?Sized + Write> {
buffer: &'a mut BufWriter<W>,
}
// 実装を読む上での前提知識
// flush: バッファの内容を内部ライターWに書き込み、さらにWをflushすること
// flush_buf: バッファの内容を内部ライターWに書き込むこと(Wはflushしない)
impl<'a, W: ?Sized + Write> Write for LineWriterShim<'a, W> {
/// 今回は関係ないが、面白いので載せておく
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
// 今回のbufに新しい改行が含まれているかを調べる
// 後ろから探す
let newline_idx = match memchr::memrchr(b'\n', buf) {
None => {
// ないなら、既存バッファの末尾が改行ならフラッシュする
self.flush_if_completed_line()?;
// 今回のbufはバッファに追加して終わり
return self.buffer.write(buf);
}
// あるなら、位置を取得する
Some(newline_idx) => newline_idx + 1,
};
// フラッシュする
// write 関数には、新しいデータをインナーに書き込むのは1回だけ、という契約がある
// フラッシュを後回しにすると、書き込み時にエラーが起きたときに、
// 何バイト消費したかを整合的に出すのが不可能になる
// ただし、このメソッドはBufWriterのみフラッシュし、内部ライターWはフラッシュしない
// なぜならここが改行かわからないため
//
// ちなみに`write_all` は「必要なら複数回書くことが許される」ため、よりシンプル
self.buffer.flush_buf()?;
// 改行までを書き込む
let lines = &buf[..newline_idx];
// 内部ライターWに書き込めたバイト数
let flushed = self.inner_mut().write(lines)?;
if flushed == 0 {
return Ok(0);
}
// 残りの部分
let tail = if flushed >= newline_idx {
// 改行まで書き込めた場合
let tail = &buf[flushed..];
// 残りが大きすぎれば次回書く
if tail.len() >= self.buffer.capacity() {
return Ok(flushed);
}
// 残りをバッファに入れる
tail
} else if newline_idx - flushed <= self.buffer.capacity() {
// 残りのバッファに改行が収まる場合
// つまり、さっき改行までを全部書けなかった場合
// 改行までをバッファに入れる
&buf[flushed..newline_idx]
} else {
// 改行までの残りがバッファに収まらない場合
let scan_area = &buf[flushed..];
// バッファに入りきる範囲で改行を探す
let scan_area = &scan_area[..self.buffer.capacity()];
match memchr::memrchr(b'\n', scan_area) {
Some(newline_idx) => &scan_area[..newline_idx + 1],
None => scan_area,
}
};
// 残りをバッファへ
let buffered = self.buffer.write_to_buf(tail);
Ok(flushed + buffered)
}
fn flush(&mut self) -> io::Result<()> {
self.buffer.flush()
}
/// 関係あるのはこのメソッド
/// buf を全て書き込む
/// 必要なら複数回に分けて書き込むことが許される
fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
// 新しい改行があるか?
match memchr::memrchr(b'\n', buf) {
None => {
// ないならバッファに追加するだけ
// 既存バッファが改行で終わるならフラッシュする
self.flush_if_completed_line()?;
self.buffer.write_all(buf)
}
Some(newline_idx) => {
// 改行がある場合
let (lines, tail) = buf.split_at(newline_idx + 1);
if self.buffered().is_empty() {
// バッファが空なら内部ライターWに直接書き込む
self.inner_mut().write_all(lines)?;
} else {
// 空でないなら、バッファに書き込んでからフラッシュする
self.buffer.write_all(lines)?;
self.buffer.flush_buf()?;
}
// 残りはバッファに追加する
self.buffer.write_all(tail)
}
}
}
}write_fmtの実装はありません。この場合はio::Writeトレイトのデフォルト実装が使われます。このトレイトのwrite_fmtメソッドは、フォーマットがなく完全に決まっている文字列に対しては、write_allメソッドを呼び出すようになっています。よって、上に示すwrite_allが呼び出されます。
pub trait Write {
fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> Result<()> {
if let Some(s) = args.as_statically_known_str() {
self.write_all(s.as_bytes())
} else {
// フォーマットありなら、フォーマットの処理を行う
default_write_fmt(self, args)
}
}
}さらに、実際にLineWriterの内部に渡されている構造体を見てみましょう。
stdout関数内で呼び出されているstdout_raw関数を確認します。
const fn stdout_raw() -> StdoutRaw {
StdoutRaw(stdio::Stdout::new())
}この構造体を返しています。
struct StdoutRaw(stdio::Stdout);
impl Write for StdoutRaw {
fn write_fmt(&mut self, fmt: fmt::Arguments<'_>) -> io::Result<()> {
handle_ebadf(self.0.write_fmt(fmt), || Ok(()))
}
}この内部には、sys::stdioモジールのStdout構造体が保持されています。
ここまで階層を遡ってきたのは、print_to が呼んでいる write_fmt が最終的にどの実装へ到達するのか、その呼び出し先を明らかにするためでした。
print_to関数の中で、global_s().write_fmt(args)が呼ばれていましたね。global_sにはstdout関数が代入されおり、この関数はStdout構造体を返します。そのStdoutの内部にある構造体を知りたくてここに来たわけです。
つまり、この構造体のwrite_fmtメソッドが呼び出されます。
このメソッドは、さらに内部にある構造体のwrite_fmtメソッドを呼び出し、handle_ebadf関数でエラーハンドリングを行っています。
handle_ebadf関数は、EBADF(不正なファイルディスクリプタ)エラーが発生した場合に特別な処理を行うための関数です。標準出力が存在しない・閉じられた場合に、エラーにならないよう、Ok(())を返すようにしています。
ちなみにこのsys::stdioモジュールは、OSごとに異なる実装が提供されています。今回はLinuxを使っているため、UNIX系OS向けの実装が使われますが、WindowsやUEFIの場合は別の実装が使われます。
cfg_select! {
any(target_family = "unix", target_os = "hermit") => {
mod unix;
pub use unix::*;
}
target_os = "windows" => {
mod windows;
pub use windows::*;
}
// 続く...
}さて、内側の構造体を見てみましょう。
pub struct Stdout;
impl io::Write for Stdout {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
unsafe { ManuallyDrop::new(FileDesc::from_raw_fd(STDOUT_FILENO)).write(buf) }
}write_fmtメソッドの定義はなく、この場合はio::Writeトレイトのデフォルト実装が使われます。このトレイトのwrite_fmtメソッドは、いずれwriteメソッドを呼び出すようになっています。よってwriteの実装を確認します。
このwriteメソッドは標準出力のファイルディスクリプタを取得して書き込んでいます。
ファイルディスクリプタは、UNIX系OSがファイル・ディレクトリ・ソケット・端末・デバイスなどの、「ファイルっぽいもの」を一元的に扱うための仕組みです。
ファイルディスクリプタの番号を表すSTDOUT_FILENOの定義は、libcクレートにあります。このクレートはC言語をRustから利用するための橋渡しの役割を果たします。
pub const STDOUT_FILENO: c_int = 1;標準出力はファイルディスクリプタの番号1に対応しています。0は標準入力、2は標準エラー出力です。
次に、FileDesc構造体がどうやって書き込みを行うのかを見てみます。
pub struct FileDesc(OwnedFd);
impl FileDesc {
pub fn write(&self, buf: &[u8]) -> io::Result<usize> {
let ret = cvt(unsafe {
libc::write(
self.as_raw_fd(),
buf.as_ptr() as *const libc::c_void,
cmp::min(buf.len(), READ_LIMIT),
)
})?;
Ok(ret as usize)
}
}ついに発見しました!
libc::write関数が呼ばれています。
UNIX系OSは全てをファイルとして扱うため、標準出力もファイルディスクリプタを通じて書き込みを行います。そして、ファイルディスクリプタに書き込むには、writeシステムコールを使います。
システムコールはOSが提供する機能を呼び出すためのインターフェースです。writeシステムコールは、指定されたファイルディスクリプタにデータを書き込みます。
このようなシステムコールは、C言語の標準ライブラリ(libc)を通じて利用されることが一般的で、今回はRustのlibcクレートを通じて呼び出されています。
writeは、ファイルディスクリプタ、書き込むデータのポインタ、書き込むデータの長さを引数に取ります。
libcクレートの中にある宣言を見てみましょう。
extern "C" {
pub fn write(fd: c_int, buf: *const c_void, count: size_t) -> ssize_t;
}この関数はC言語のwrite関数を呼び出しており、最終的にはOSのwriteシステムコールを通じで、標準出力にデータを書き込んでいます。
この関数の実装がC言語側にあるため、Rust言語側ではこれ以上掘り下げられません。
というわけで、この記事で掘り下げるのはここまでです。たくさんの抽象化レイヤを越えて、C言語のwrite関数に到達しました。

以降の処理は、この記事の環境なら、GNU C ライブラリ(glibc)が担当します。
まとめ
Rustの「Hello, world!」プログラムでは、println!マクロが呼び出され、フォーマットされた文字列が標準出力に書き込まれます。この過程で、標準ライブラリのI/O層を経由し、最終的にはlibc::write関数を通じて、C言語の処理が呼び出されます。
今回はRust側の実装まででしたが、この先にはglibcの内部処理やLinuxカーネルのsys_writeも続いています。また、端末(ターミナル)への表示処理も関わってきます。
こちらも別の記事で追ってみたいですね。
