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進文字列を扱う場合はその点に注意。)