writeシステムコールによる文字列の出力
これまで終了コードを介して出力を行うプログラムを作ってきたが,非常に不便であった(1つの数しか出力できない,0〜255の範囲の数しか出力できない,実行終了後に終了コードを表示させる手間が掛かる,など)。
今回の目標は,以下のようなプログラムを作ることだ。
- EAXレジスタの値を10進数または16進数で端末画面に出力するプログラム
つまり,printfと同様のものを自作しよう,ということだ。 そうすることで,数値を10進数で出力するという「普通の出力」はどういう仕組みで実現されているのか? がわかるようになるだろう。
プログラムからの出力
C言語のprintfのように,普通,高級言語には文字列や数を出力する関数や命令が備わっているが,さて「プログラムからの出力」とは一体なんだろうか? どのようにして行われるのだろうか?
プログラムの外部への情報の出力には,端末画面への文字列の出力,ファイルへの書き込み,ネットワークを介した情報送信,など様々あるが,UNIX系OSではこれらはすべてwriteシステムコールを通じて行われる。
下記は,writeシステムコールを呼び出すC言語プログラムの例だ。
#include <unistd.h>
char str[] = "I'm fine.\n";
int main()
{
/* 出力先1番にstrの中身を10バイト出力 */
write(1, str, 10);
return 0;
}
実行すると,I'm fine. と端末画面に出力する
(printf("I'm fine.\n"); と書くのとほぼ同じ)。
write関数もprintf関数等と同様のもののように見えるが,write関数は単にwriteシステムコールを呼び出すためだけの関数だ(system call wrapperと呼ばれる)。
writeシステムコールの引数は,出力先番号(ファイルディスクリプタと言う),バイト列を格納した配列(の先頭番地),出力バイト数,である。 ファイルディスクリプタは,オープン中のファイルや通信相手との接続に対してOSが付ける識別番号だ。プログラム実行中,ファイルをオープンしたり通信相手との接続を確立したりするたび,新しい番号が割り当てられる。ただし,0, 1, 2番はそれぞれ標準入力,標準出力,標準エラー出力を表す番号であり,オープン済みの状態でプログラムの実行が開始される(特段指定しなければ,これらは端末キーボードと端末画面に繋がれている)。
printf等のよく使う出力関数は,システムコールではなく標準ライブラリ関数である。つまり,予め用意されているプログラム断片だが,OSから見れば,一般プログラマが自作した関数と違いはない(ソースコードを書いた人が違うだけ)。
そして,実際に出力を行うのは必ずwriteシステムコールだ(それ以外の出力手段はない)。標準ライブラリ関数である出力関数は,writeシステムコールに渡すためのバイト列を配列上に作った上で,writeシステムコールを呼び出すのである。
つまり,まとめるとこういうことだ。
- UNIX系OSでは,プログラム外部への出力は,writeシステムコールを介して行われる。
- writeシステムコールは,出力先番号,出力するバイト列,その長さ,を引数とする。
- writeシステムコールには,バイト列(文字コードの列)をそのまま出力する機能しかない。例えばレジスタ中の数値を「10進数の数字の列」として出力したければ,そのような数字(の文字コード)の列への変換を自作する必要がある(
printf等,高級言語の出力関数は,その自作の手間を省くためにある。最終的にwriteシステムコールを呼ぶことで出力が行われる,という点は同じ)。
補足:文字列とバイト列
上記のプログラムや printf("I'm fine.\n"); を実行するプログラムは普通,「文字列 I'm fine.(+ 改行)を出力するプログラム」と説明されるが,コンピュータにとって文字列とは文字コードの列にほかならない。
従って,「文字列 I'm fine. + 改行 を出力する」とは,正確に言えば「0x49 (I), 0x27 ('), 0x6d (m), 0x20 (空白), 0x66 (f), 0x69 (i), 0x6e (n), 0x65 (e), 0x2e (.), 0x0a (改行) の10バイトの列を出力する」ことだ。
この10バイトの列が I'm... という文字コードの列なのか,73, 39, 109, ... という8ビットの数の列なのか,32ビットの数 544024393 (= 0x206d2749), 1701734758 (= 0x0x656e6966) と16ビットの数 2606 (= 0x0a2e) からなる列なのかは,それを仲介しているOSは関知せず,そのままファイルに書き込む,あるいはそのまま相手ソフトウェアや相手コンピュータに渡すだけだ。
このバイト列を「文字コードの列」と解釈し,対応する文字を並べて表示しているのは「端末」である。
端末は,受け取ったバイト列を文字コード列と解釈し,対応する文字の字形データを読み出して,画面に表示する。
writeシステムコール
下記は I'm fine. と端末画面に出力するアセンブリ言語プログラムだ。
EAXに4を代入して int 0x80 を実行すると,writeシステムコールが呼び出される。
writeシステムコールの引数として,EBXにファイルディスクリプタ(出力先番号),ECXに出力文字列の先頭番地,EDXに文字列の長さを格納しておく。
ファイルディスクリプタ1は標準出力を表す。
writeシステムコールの実行が終わると,int 0x80の次の命令に復帰して実行を続ける。
section .text
global _start
_start:
mov eax, 4 ; writeのシステムコール番号
mov ebx, 1 ; 出力先番号 (1=標準出力)
mov ecx, msg ; 文字列の開始番地
mov edx, msglen ; 文字列のバイト数
int 0x80 ; システムコール
mov eax, 1 ; exitのシステムコール番号
mov ebx, 0 ; 終了コード
int 0x80 ; システムコール
section .data
msg: db "I'm fine.", 0x0a
msglen: equ $ - msg ; 文字列のバイト数 (= この行の番地とmsgの差)
"I'm fine." のように二重引用符 "(または単引用符 ')で囲んだ記述は,各文字の文字コードからなるデータ列に翻訳される。
つまり,ラベル msg の付いた行は,下記の記述と同じだ(ただしASCII文字コード表を使うコンピュータの場合)。
末尾の 0x0a (= 10) は「改行」の文字コードだ。
msg: db 73,39,109,32,102, 105,110,101,46,10
出力文字列の生成
writeシステムコールは,第2引数として文字コード列の先頭番地を取ることに決まっているので,(仮に1文字しかなくても)出力したい文字列は主記憶の中に置かなければならない。
何かの計算結果をwriteシステムコールで出力する場合は,出力文字列を格納するための空き領域を予め用意しておき,そこに文字コード列を書き込んでから,writeシステムコールを呼び出す。
下記は,「9876543210 (改行)」という文字列を出力するプログラムだ。
この(改行を入れて11文字の)文字列をプログラム実行中に主記憶領域に書き込んでからwriteシステムコールを呼び出している。
; "9876543210" を出力
ndigit: equ 10 ; 出力する桁数
section .text
global _start
_start:
mov ecx, buf + ndigit ; 作業領域の末尾の次の番地
mov edx, ndigit ; ループ回数
mov al, '0' ; 文字 0
; 領域 buf に "9876543210" を書き込む
loop: dec ecx ; 次の書き込み先
mov [ecx], al ; 作業領域に1文字書き込む
inc al ; 次の文字
dec edx ; 残り文字数
jnz loop ; 残り文字数 > 0 ならループ
; writeシステムコールを呼び出す。そのあと終了
mov eax, 4 ; writeのシステムコール番号
mov ebx, 1 ; 出力先番号 (1=標準出力)
mov edx, ndigit + 1 ; 改行を含めた長さ
int 0x80
mov eax, 1 ; exitのシステムコール番号
mov ebx, 0 ; 終了コード
int 0x80
section .data
buf: times ndigit db 0 ; ndigit文字分の領域
db 0x0a ; 改行
(ラベル buf は buffer(緩衝装置・緩衝地帯)の意味。コンピュータ関連用語として,処理待ちのためにデータを一時的に入れておく記憶領域のことをbufferと言う。)
ラベル buf から始まる10文字分の領域に,出力したい文字列を書き込み,writeシステムコールを呼び出している。改行文字は予め buf + 10 番地の値として記述してある。
この例では領域の後ろから順に(文字 0 から順に),主記憶内領域に文字コードを書き込んでいる(下図は,ループ開始前,及び,ループから抜けた後のバッファ領域の中身)。
前から順に(文字 9 から順に)書き込んでも構わない。
writeシステムコールを呼び出す時点で出力すべき文字列が完成していればよい。
このプログラム例では,ループを抜けたとき,ECXが出力文字列の先頭を指しているので,これをそのままwriteシステムコールの第2引数として使っている。
1バイトの値を書き込み・読み出ししたい場合は,mov [ecx], al のように8ビットレジスタ(AL, AH, ... DL, DH)を使う。(mov [ecx], eax のように32ビットレジスタを使うと4バイト書き込まれてしまうことに注意。)これらの8ビットレジスタは32ビットレジスタの一部分につけられた別名なので,例えば EAX を使って計算した後 AL の値を主記憶に書き込む(= 計算結果の最下位8ビットだけ書き込む)といったこともできる。
'0' のように文字を引用符で囲んで記述すると,その文字の文字コードに翻訳される。
文字コード表を調べてその数値をプログラム中に書くよりも,'0' のように書く方がよい。
楽だし,誤りを防止できるし,その数値の意味(0の文字コードであること)がわかりやすい。
上記プログラムでは,0, 1, 2, ... の文字コードが「連続」していることを利用している(AL = '0' のときにALに1加えると AL = '1' になる。AL = '1' のときにALに1加えると AL = '2' になる)。
また,レジスタに0以上9以下の数が格納されているとき,それに '0' を加えれば,数字(文字)に変換できる。
(A〜Zやa〜zの文字コードも連続している。例えば,'b'は'a' + 1 に等しい。'c'は'a' + 2 に等しい。'f'は'a' + 5 に等しい。'z'は'a' + 25 に等しい。ただし,'9'と'A'や'9'と'a'は連続していないので,16進文字列を扱う場合はその点に注意。)