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引数として使っている。

the contents of the buffer

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' を加えれば,数字(文字)に変換できる。

AZazの文字コードも連続している。例えば,'b''a' + 1 に等しい。'c''a' + 2 に等しい。'f''a' + 5 に等しい。'z''a' + 25 に等しい。ただし,'9''A''9''a'は連続していないので,16進文字列を扱う場合はその点に注意。)

results matching ""

    No results matching ""