🦜

Rust 製簡単シリアルロギングライブラリ

2022/12/24に公開

こちらは モダン言語によるベアメタル組込み開発 24日目の記事です。

PC でベアメタルプログラミングをやろうとすると、おそらく多くの人が最初の方に検討するデバッグ手法の一つにシリアル通信でのロギングがあるかなと思います。ベアメタルプログラミングではいかに早い段階でデバッグしやすくするかが鍵になると思っています。そこで、この記事では、Rust で簡単にシリアル通信でデバッグできるようにするライブラリを作成したので、シリアル通信の説明を絡めつつ、その紹介したいと思います。

シリアル通信って

ビットを一個一個送りつける非常に原始的な通信方法です。

シリアル通信は UART (Universal Asynchronous Receiver-Transmitter) というハードウェア経由でよく実装されており、16550 UART という IC が多くの PC に搭載されてきました。往々にして RS-232C と呼ばれる接続規格が採用されており、こんな形をした 9 ピンのコネクタが PC についてるのを見たことがある人は多いと思います。

最近の PC では UART が搭載されてないことも多くなってきましたが、Bluetooth や USB アダプターによってもエミュレートされていることがありますし、なによりベアメタルプログラミングを行う時のとっかかりになる PC エミュレータの上には基本的に搭載されており、UART にログを吐くことでデバッグがやりやすくなります。

これらエミュレートされたものを全てひっくるめて、シリアル通信するための口のことを COM ポートと呼んだりします。

COM ポートロギングライブラリ

この COM ポートから Rust で簡単にログを吐くことができるライブラリを作成しました。ベアメタルで Rust が起動して main() に入ってから最小の手順で COM ポートからログを吐けるようにできます。

https://github.com/YushiOMOTE/com_logger

このライブラリを使うと、 log というロギング用 facade を利用して簡単にシリアル通信でログが吐けるようになります。

use log::*;

fn main() {
    com_logger::init();

    info!("Hello world");
}

1行で初期化が完了します。そのあとは普通に info!error! とか呼ぶとシリアル通信でその通りログが吐かれるようになります。

仕組み

ライブラリの実装自体は非常にシンプルで uart_16550 というライブラリが UART の I/O 通信の実装をラップしてくれているので、そのライブラリと log facade を繋いでいるだけになります。

利便性を高めるために色々やっていますが、基本的に中核のロジックはたったこれだけです。

use uart_16550::SerialPort;

pub struct Serial(SerialPort);

impl Serial {
   // ...
}

impl fmt::Write for Serial {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        for b in s.bytes() {
            self.0.send(b);
        }
        Ok(())
    }
}

struct Logger(AtomicU16);

impl log::Log for Logger {
    fn log(&self, record: &Record) {
        let mut serial = Serial::new(self.0.load(Ordering::Relaxed));

        let _ = write(
            &mut serial,
            format_args!(
                "{:>8}: {} ({}, {}:{})\n",
                record.level(),
                record.args(),
                record.target(),
                record.file().unwrap_or("<unknown>"),
                record.line().unwrap_or(0),
            ),
        );
    }

    // ...
}

SerialPort というのが uart_16550 というライブラリが提供してくれている構造体で、こいつの send メソッドに文字のバイト列を流し込むことで文字がシリアルに送信されます。

なので、SerialPort をラップして、任意の文字列を流し込めるようにするために fmt::Write trait を実装しています。こうすることで、core::fmt::write メソッドを用いて文字列が流し込めるようになりました。ドキュメントの例にあるように、さらに format_args と組み合わせて、簡単にログのフォーマットでシリアルポートに出力できるようになります。

AtomicU16 は何かというと、シリアル通信を行う COM ポートの番地を指しています。詳細は自説で解説します。

uart_16550 がやってること

上記だけだと単純すぎるので、uart_16650 が COM ポートをどう使っているかについても、もう少し踏み込んだ解説を行いたいと思っています。

COM ポートは PC に複数実装されてることがあり、 COM1, COM2, ... と番号づけで呼ばれたりします。これらの COM ポートへアクセスするには、I/O 命令という特殊な CPU 命令を用います。I/O 命令はざっくりいうとこの2つの命令があります。

  • IN 命令
  • OUT 命令

名前の通りそれぞれ、デバイスから CPU (プログラム) へ何か値(バイト列)を読み込む命令(IN)、CPU(プログラム)から CPU へ何か値(バイト列)を出力する命令(OUT)です。どのデバイスに読み書きするかは、メモリと同じように、番地で指定することができます。例えば、

IN 1234

1234 番地(に対応するデバイス)から値を読み込みます。

OUT 1234, 10

1234 番地(に対応するデバイス)に 10 という値を書き込みます。

といったイメージです。そこで、uart_16550 が行なっていることは、COM ポートの番地に対して、文字列を送信するための制御や文字列そのもののデータを読み書きするために、IN / OUT 命令をデバイスの仕様に従って発行してるということになります。具体的には以下の番地が COM ポートに割り当てられると決まっています。

  • COM1 ポートの番地:0x3F8 ~ 0x3FF
  • COM2 ポートの番地:0x2F8 ~ 0x2FF
  • COM3 ポートの番地:0x3E8 ~ 0x3EF
  • COM4 ポートの番地:0x2E8 ~ 0x2EF

例えば、1文字をシリアル送信するには、

  1. 番地 0x3FD の 5ビット目が 1 になるのを待つ
  2. 番地 0x3F8 に1バイト文字を書く

という手順を行います。他にも通信レートの設定などの初期化の手順を uart_16550 が行なってくれています。

Google に採用

COM ポートロギングライブラリは 2022年12月現在 Google が開発する crosvm という KVM ベースの仮想マシンモニタの example に採用されています。

Discussion