自前 printf — 数を文字に変える
今日のゴール
libc の printf を使わずに、自分で print_str / print_int / printf1 を書く。
自作コンパイラでコンパイルした自前の出力ライブラリで、libc なしのプログラムを動かす。
この回の位置づけ
| 項目 | 内容 |
|---|---|
| 必須の前提 | R1(sys_write が使える状態)とコマ15。check.py が完成した final/mycc.py で myprintf.c と tests/*.c をコンパイルするので、コマ15 が無いと1件も実行できない |
| 推奨の前提 | — |
| 改変しない | mycc.py、scaffold/、syscall.s(R1 のもの。配布済み・完成品) |
| 編集する | myprintf.c |
| 完了条件 | check.py の全 Step が PASS になる(このトピックに golden.py は無い) |
| コマ数 | 1 |
| 備考 | ランタイム発展シリーズの第2回。書くのはすべて Core プロファイルの C で、自分のコンパイラでコンパイルする |
コマ10 で「printf 自体は実装しない」と言った、その中身を作る。
可変長引数は定義できない — どう設計するか
本物の printf は可変長引数関数である。
int printf(char *fmt, ...);しかし Core プロファイルでは、... は外部関数の宣言だけに許され、
自分で定義することはできない(language_spec.md の「関数」節)。
可変長引数の実装には、スタック上の引数を型情報なしに読み出す仕組み(va_arg 相当)が必要で、
コンパイラ側の対応も要るからである。
そこで、引数が固定の関数を組み合わせる設計にする。
| 関数 | 役割 |
|---|---|
print_char(int c) |
1文字書く |
print_str(char *s) |
NUL 終端文字列を書く |
print_int(int n) |
整数を10進で書く |
printf1(char *fmt, int iarg, char *sarg) |
書式つき(引数1個ぶん) |
printf1 は「整数の引数」と「文字列の引数」を両方受け取り、
書式指定子に応じてどちらかを使う。使わない方には 0 を渡す。
printf1("n=%d\n", 42, 0);
printf1("s=%s\n", 0, "hello");不格好に見えるが、言語の制約の中で目的を達するのは実装の常である。
数を文字に変えるということ
print_int がこの回の中心である。
整数 1234 を画面に出すには、文字コードの列 '1' '2' '3' '4' を作る必要がある。
n % 10 で取れるのは最後に表示したい桁(一の位)である。
配列が使えないこの言語では、桁をバッファに詰めて後から並べ直す方式は取れない。
そこで、上位の桁を先に、再帰で出す方式にする。
print_int(1234):
1234 >= 10 なので、まず上位の桁を再帰で出す
print_int(123):
123 >= 10 なので、まず上位の桁を再帰で出す
print_int(12):
12 >= 10 なので、まず上位の桁を再帰で出す
print_int(1):
1 < 10 なので、そのまま '1' を出す
最後に 12 % 10 = 2 → '2' を出す
最後に 123 % 10 = 3 → '3' を出す
最後に 1234 % 10 = 4 → '4' を出す
出力順: '1' '2' '3' '4'
「まず上位を再帰で出してから、自分の一の位を出す」という順序が、 そのまま上の桁から下の桁への出力順になる。
数字を文字にするのは '0' + 桁 である('0' は 48)。
コマ10 で文字が小さな整数であることを見たが、ここではその逆向きの変換をしている。
int print_int(int n) {
if (n < 0) {
print_char(45); // '-'
n = -n;
}
if (n >= 10) {
print_int(n / 10); // 上位の桁を先に出す
}
print_char(48 + n % 10); // 自分の一の位
return 0;
}n == 0 は n >= 10 が成り立たないのでそのまま print_char(48 + 0) に落ちる。
「末尾から詰める」方式で必要だった n == 0 の特別扱いが不要になっている
(while (n > 0) のループが1度も回らない、という事故が原理的に起きない)。
負数を忘れない。 先頭で '-'(45)を出してから n = -n する。
INT_MIN は UB。 n = -2147483648 のとき、-n は int の表現範囲を超え、
language_spec.md が明記する符号付きオーバーフロー = 未定義動作に該当する
(発展課題5 で扱う)。
書式の解釈
printf1 は fmt を1文字ずつ見て、'%'(37)が来たら次の1文字で分岐する。
| 指定子 | 文字コード | 動作 |
|---|---|---|
%d |
100 | print_int(iarg) |
%s |
115 | print_str(sarg) |
%c |
99 | print_char(iarg) |
%% |
37 | '%' を1文字出す |
Core プロファイルには文字リテラルがあるので fmt[i] == '%' とも書けるが、
参照実装では数値で書いてある(どちらでもよい)。
これが、コマ10 で printf("x=%d\n", 42) を呼んだときに
libc の中で起きていたことである。書式文字列は、実行時に解釈されている。
実装
| Step | 実装対象 | 内容 |
|---|---|---|
| 1 | my_strlen / print_char / print_str |
文字列の基本 |
| 2 | print_int |
10進変換(この回の山場) |
| 3 | printf1 |
書式の走査と分岐 |
sys_write と _start は R1 の syscall.s(配布済み)から来る。
# 手で動かす場合
python3 ../../final/mycc.py myprintf.c > impl.s
python3 ../../final/mycc.py tests/fmt.c > main.s
riscv64-linux-gnu-gcc -nostdlib -static main.s impl.s syscall.s -o prog
qemu-riscv64 ./progテスト
python3 check.pytests/*.c を自作コンパイラでコンパイルし、myprintf.c と syscall.s と
リンクして実行し、標準出力と終了コードを確認する。libc は一切使わない。
| テスト | 確認内容 |
|---|---|
fmt.c |
printf1 の書式指定(%d %s %c %% と書式なし)が一通り通る |
numbers.c |
print_int が境界値(0、1桁の正数、負数、桁の大きい数)を正しく出せる |
この回の編集対象は C なので、未実装は NotImplementedError ではなく
myprintf.c の TODO(...) コメントで表してある。check.py は TODO が残っている間、
テストを回さず [SKIP] 未実装: … と報告する。SKIP は未達なので、完了条件は満たしていない。
発展課題
%x(16進)を足す: 10 以上の桁を'a'〜'f'にする分岐が必要になる- 桁揃え:
%5dのように幅を指定できるようにする(先に桁数を数えて空白を出す) - 引数を増やす:
printf2(fmt, i1, i2)を作る。 なぜ本物のprintfが可変長引数でなければ困るのかを実感する - 再帰方式のコスト: いまは1文字ごとに
sys_writeを呼んでいる(システムコールは遅い)。print_intが呼ぶsys_writeの回数は桁数(と符号の有無)で決まる。print_int(1234)とprint_int(-1234)でそれぞれ何回sys_writeが呼ばれるか数え、 桁数dを使った式で表す(-nostdlibでは配列もmallocも使えないため、 出力をため込んでまとめて書く方式は取れない — この制約下でのコスト見積もりとして考える) print_intの限界:-2147483648(int の最小値)を渡すとどうなるか。n = -nが正しく動かない理由を考える
コラム: libc も C で書かれている。
いま書いた print_int は、glibc の printf の中にある10進変換とほとんど同じことをしている。
違いは、バッファリング、ロケール、浮動小数点、スレッド安全性…といった
「実用のための膨大な付加物」である。
核心の「数を文字に変える」部分は、いま自分で書いたものと変わらない。
次回予告
出力ができるようになった。R3 ではメモリの割り当てを自分で行う。
コマ12 で「実装しない」とした malloc の中身を作る。