Netwide Assembler(NASM)は80x86およびx86-64用のアセンブラで、MicrosoftやLinux, UNIX上の各種オブジェクトファイル・実行ファイルフォーマットに対応しています。Linux/UNIX/Windows各プラットフォームで動作し、アセンブラの入門レベルからOSの開発まで幅広く使われていています。
なおNASMアセンブラの記述はIntel方式(destination, source)です。
本記事ではNASMを使って16bitDOSプログラミング(但し.COMファイルフォーマットのみ)を体験してみます。
2010年9月時点での最新版 nasm-2.09.01 を使用し、 Windows XP SP3 (Pen4) 上で動作確認しています。
NASMのインストール方法の詳細については省略します。Web上で適宜検索・調査してインストールして下さい。
早速 Assembler/ForFun(x86_32)/01, 16bit DOS with debug.exe でdebugコマンドを使って作成した "Hello, World!" を作ってみましょう。
ファイル名は"hello_world01.asm"とします。開発環境やツール自体が16bitの制限から離れているので、ファイル名も8.3形式に囚われずに済みます。
hello_world01.asm:
org 100H bits 16 section .text start: MOV AH, 9H MOV DX, hello INT 21H MOV AH,4CH MOV AL,1H INT 21H section .data hello: db "Hello, World!$" section .bss
ポイント:
より厳密にCPUモードを指定したい場合は、"CPU"ディレクティブを使って命令セットを制限しても良いでしょう。
ex: CPU 286 ; 80286の命令セットに制限する。
COMファイル生成におけるセグメントの扱いなど、詳細はNASMのドキュメントを参照して下さい。
コンパイル:
> nasm -fbin -o hello_world01.com hello_world01.asm
実行:
> hello_world01.com Microsoft (R) KKCFUNC バージョン 1.10 Copyright (C) Microsoft Corp. 1991,1993. All rights reserved. KKCFUNC が組み込まれました. マイクロソフトかな漢字変換 バージョン 2.51 (C)Copyright Microsoft Corp. 1992-1993 Hello, World! >
NASMには逆アセンブラも付属していますので、早速hello_world01.comを逆アセンブルしてみましょう。
"-b"でプロセッサモード、"-o"でオフセットを指定します。
> ndisasm -b 16 -o 100H hello_world01.com 00000100 B409 mov ah,0x9 00000102 BA1001 mov dx,0x110 00000105 CD21 int 0x21 00000107 B44C mov ah,0x4c 00000109 B001 mov al,0x1 0000010B CD21 int 0x21 0000010D 0000 add [bx+si],al 0000010F 004865 add [bx+si+0x65],cl 00000112 6C insb 00000113 6C insb 00000114 6F outsw 00000115 2C20 sub al,0x20 00000117 57 push di 00000118 6F outsw 00000119 726C jc 0x187 0000011B 642124 and [fs:si],sp
"Hello, World!"のデータ部分まで逆アセンブルされてしまいましたが、実行コード部分はソースと同じコードに逆アセンブルされていることが確認出来ました。
NASMの場合、バッククォートで囲むと改行などのエスケープシーケンスがASCIIコードに展開されます。
これを使って、改行付でHelloWorldを出力してみましょう。また、AH=09hの文字列出力だと"$"で終端させる必要がありましたが、1文字ずつ出力するAH=06hファンクションコールを使ってC言語と同様の0終端文字列に対応させてみます。
hello_world02.asm:
org 100H bits 16 section .text start: MOV AH, 6H MOV BX, hello .print1: MOV DL, [DS:BX] CMP DL, 0H JZ .print1_end INT 21H INC BX JMP .print1 .print1_end: MOV AH,4CH MOV AL,1H INT 21H section .data hello: db `Hello, \r\nWorld!\r\n`, 0 section .bss
コンパイル+実行:
> nasm -fbin -o hello_world02.com hello_world02.asm > hello_world02.com Hello, World!
文字入力を扱うDOSファンクションコールはいくつかのバリエーションが存在します。
AH = 01h | DOS 1+ - READ CHARACTER FROM STANDARD INPUT, WITH ECHO |
AH = 06h | DOS 1+ - DIRECT CONSOLE INPUT |
AH = 07h | DOS 1+ - DIRECT CHARACTER INPUT, WITHOUT ECHO |
AH = 08h | DOS 1+ - CHARACTER INPUT WITHOUT ECHO |
AH = 0Ah | DOS 1+ - BUFFERED INPUT |
AH = 0Ch | DOS 1+ - FLUSH BUFFER AND READ STANDARD INPUT |
今回は文字「列」としてバッファに保存してくれる AH=0Ah を使って名前を入力してもらい、「Hello, (入力された名前), welcome!」と表示してみます。
「Hello, (入力された名前), welcome!」を表示するとなると、少なくとも「文字列の出力」を2回行うことになります。
ソースの機能としても、「文字列の出力」「文字列の入力」「プログラム終了」の3種類のDOSファンクションコールを呼ぶことになりますので、一旦それぞれのファンクションコールをサブルーチンとして切り出しておきましょう。
とりあえずhello_world02.asmを書き直して、C言語のstdcall呼び出し規約を意識してサブルーチン化してみました。
hello_world03.asm:
org 100H bits 16 section .text start: PUSH hello CALL print1 PUSH 1H CALL exit ; arg1(1byte) : exit code ; return : non exit: PUSH BP MOV BP, SP MOV AH, 4CH MOV AL, [BP+4] INT 21H ; arg1(2byte) : address of null-terminated string ; return : non print1: PUSH BP MOV BP, SP MOV AH, 6H MOV BX, [BP+4] .print1_loop: MOV DL, [DS:BX] CMP DL, 0H JZ .print1_end INT 21H INC BX JMP .print1_loop .print1_end: POP BP RET 2 section .data hello: db `Hello, \r\nWorld!\r\n`, 0 section .bss
メインルーチンがぐっと見やすくなりました。
ではこれに AH=0Ah ファンクションコールを導入してみましょう。
このファンクションコールは、DS:DXに専用の構造体アドレスを格納する必要があります。
offset size description 00h BYTE 読み込む文字数(改行含む) 01h BYTE 戻り値で読み込まれた文字数(改行含まず) 02h N*BYTE 読み込まれた文字データ+改行
サブルーチンのIFとしては、文字列を受け取るバッファのアドレスとバッファサイズの2つにしたほうが分かりやすいので、上述の構造体はサブルーチン内部でスタック上に構築してしまいましょう。
PUSH バッファアドレス PUSH WORD バッファサイズ CALL gets ; arg1(2byte) : address of buffer ; arg2(2byte) : buffer length (including null terminator) ; return : non gets: PUSH BP MOV BP, SP XOR AX, AX MOV AX, [BP+4] SUB SP, AX PUSH AX
構造体のoffset:01hについてですが、初回呼び出し時はとりあえず0でOKです。バッファサイズをPUSHするときにWORDサイズでPUSHしてもらうと、ちょうどAXで受け取りスタックに積み直すことで、リトルエンディアンなので offset 00h, 01h がバッファサイズ, 0の並びになります。
あとはINT21hを呼びます。
XOR AX, AX MOV AH, 0AH MOV DX, SP INT 21H
続けて、末尾が改行になっていますので"0"に上書きします。
; retrieve "number of read chars" MOV BX, SP INC BX XOR AX, AX MOV AL, BYTE [BX] ; overwrite CR to 0 MOV BX, SP ADD BX, 2 ADD BX, AX MOV BYTE [BX], 0H
仕上げに、スタック上のバッファ内容を、引数で受け取ったバッファアドレスにコピーします。
MOV SI, SP ADD SI, 2 MOV DI, [BP+6] XOR CX, CX MOV CL, AL INC CX ; for terminate character REP MOVSB
最後にスタックフレームを復元して戻ります。
POP AX MOV AX, [BP+4] ADD SP, AX POP BP RET
「Hello, (入力された名前), welcome!」を表示する最終的なアセンブラコードは次のようになりました。
hello_world03.asm:
org 100H bits 16 section .text name_buf_len equ 10 start: PUSH BP MOV BP, SP PUSH prompt CALL print1 PUSH name_buf PUSH WORD name_buf_len CALL gets PUSH hello_1 CALL print1 PUSH name_buf CALL print1 PUSH hello_2 CALL print1 PUSH 1H CALL exit ; arg1(2byte) : address of buffer ; arg2(2byte) : buffer length (including null terminator) ; return : non gets: PUSH BP MOV BP, SP XOR AX, AX MOV AX, [BP+4] SUB SP, AX PUSH AX XOR AX, AX MOV AH, 0AH MOV DX, SP INT 21H ; retrieve "number of read chars" MOV BX, SP INC BX XOR AX, AX MOV AL, BYTE [BX] ; overwrite CR to 0 MOV BX, SP ADD BX, 2 ADD BX, AX MOV BYTE [BX], 0H MOV SI, SP ADD SI, 2 MOV DI, [BP+6] XOR CX, CX MOV CL, AL INC CX ; for terminate character REP MOVSB POP AX MOV AX, [BP+4] ADD SP, AX POP BP RET ; arg1(1byte) : exit code ; return : non exit: PUSH BP MOV BP, SP MOV AH, 4CH MOV AL, [BP+4] INT 21H ; arg1(2byte) : address of null-terminated string ; return : non print1: PUSH BP MOV BP, SP MOV AH, 6H MOV BX, [BP+4] .print1_loop: MOV DL, [DS:BX] CMP DL, 0H JZ .print1_end INT 21H INC BX JMP .print1_loop .print1_end: POP BP RET 2 section .data prompt: db "What's your name ? : ", 0 hello_1: db `\r\nHello, `, 0 hello_2: db `, welcome!\r\n`, 0 section .bss name_buf resb name_buf_len
コンパイル+実行:
> nasm -fbin -o hello_world03.com hello_world03.asm > hello_world03.com What's your name ? : 123456789 Hello, 123456789, welcome! >hello_world03.com What's your name ? : FooBar Hello, FooBar, welcome! >
Assembler/ForFun(x86_32)/01, 16bit DOS with debug.exe の記事では取りあげませんでしたが、コマンドラインや環境変数を取得することも出来ます。
MS-DOSの場合、COM or EXEがロード後、"Program Segment Prefix"(PSP)という領域を参照するとコマンドラインや環境変数を取り出せるようになっています。
"Program Segment Prefix" 参考資料:
PSPの0x2C, 2Dで示されたセグメントの先頭から環境変数を取得出来ます。一つのエントリは'0'終端で、最後のエントリの後には'0'が二つ続きます。
ざっくりロジックを作ってみると、こうなりました。ロード直後は、PSPのセグメントはCOM/EXEともにDSに保存されています。
start: PUSH BP MOV BP, SP MOV BX, [DS:002Ch] MOV DS, BX MOV BX, 0000H .loop2: PUSH BX CALL print1 .loop1: ; 1エントリの'0'終端までBXポインタを進めます。 INC BX CMP BYTE [DS:BX], 0 JNZ .loop1 ; 1文字前進させて'0'なら、'0'が2つ続いたので全エントリを表示し終わったことを意味します。 INC BX CMP BYTE [DS:BX], 0 JNZ .loop2 PUSH 1H CALL exit
しかしこれだと改行が無いので見づらいです。そこで".data"セクションにCRLFだけの文字列をおいて、1エントリ分表示した後にCRLFを表示させてみます。
ここで注意が必要です。エントリをチェックするメインのループでは、DSは環境変数のセグメントを指しています。しかしCRLFの文字列は実行コードと同じCSセグメントに存在します。
そのため、CRLFを表示するのであれば一時的にセグメントを切替え、表示を終えたらまた環境変数のセグメントに戻す必要があります。なお起動直後はCSとDSは同じ値になっていますので、ここではDSを保存し、切り替えに使います。
この2点を盛り込み、改行コード付で表示出来るようにしたのが次のソースコードです。
env.asm:
org 100H bits 16 section .text start: PUSH BP MOV BP, SP PUSH DS ; スタック上にプログラム起動時点でのDSを保存 MOV BX, [DS:002Ch] MOV [segenv], BX ; 環境変数のセグメントを保存 MOV DS, BX ; 環境変数のセグメントに切替 MOV BX, 0000H .loop2: PUSH BX CALL print1 POP DS ; プログラム起動時点のDSを復元 PUSH crlf CALL print1 PUSH DS MOV DS, [segenv] ; 環境変数のセグメントに切替 .loop1: INC BX CMP BYTE [DS:BX], 0 JNZ .loop1 INC BX CMP BYTE [DS:BX], 0 JNZ .loop2 POP DS PUSH 1H CALL exit ; arg1(1byte) : exit code ; return : non exit: PUSH BP MOV BP, SP MOV AH, 4CH MOV AL, [BP+4] INT 21H ; arg1(2byte) : address of null-terminated string ; return : non print1: PUSH BP MOV BP, SP PUSH BX PUSH DX MOV AH, 6H MOV BX, [BP+4] .print1_loop: MOV DL, [DS:BX] CMP DL, 0H JZ .print1_end INT 21H INC BX JMP .print1_loop .print1_end: POP DX POP BX POP BP RET 2 section .data crlf: db `\r\n`, 0 section .bss segenv: resw 1
コンパイル:
> nasm -fbin -o env.com env.asm
実行結果は自明なので省略します。
続いてコマンドラインを表示してみます。
PSPのドキュメントを調べていくと、80hにコマンドラインの長さが、81hからコマンドラインに渡された文字列が格納されていることが分かります。
終端文字列は不定のようなので、80hで取得された文字数分だけ、1文字ずつ表示してみます。
cmdline1.asm:
org 100H bits 16 section .text start: PUSH BP MOV BP, SP XOR CX, CX MOV CL, [DS:0080h] CMP CX, 0 JZ .loop_end MOV AH, 02H XOR DX, DX MOV BX, 81H .loop: MOV DL, [BX] INT 21H INC BX LOOP .loop .loop_end: PUSH 1H CALL exit ; arg1(1byte) : exit code ; return : non exit: PUSH BP MOV BP, SP MOV AH, 4CH MOV AL, [BP+4] INT 21H section .data section .bss
コンパイル+実行:
> nasm -fbin -o cmdline1.com cmdline1.asm > cmdline1.com > cmdline1.com a b c d a b c d >
コマンドライン文字列を表示出来ましたが、自分自身のプログラム名が表示されていません。
MicrosoftのKB自分自身のプログラム名については、KB123729を見ると環境変数のブロックに続けて格納されているようです。
実際にdebugコマンドで見てみると、環境変数のブロック終端である二つの'0'の後ろに、"01 00"が続き、その後にプログラム名が格納されています。
> debug cmdline1.com -D 2C 2D 2D57:0020 FE 2C ., -D 2CFE:0500 L 100 ... 2CFE:0560 33 30 20 54 33 00 00 01-00 43 4D 44 4C 49 4E 45 30 T3....CMDLINE ^^^^^ 2CFE:0570 31 2E 43 4F 4D 00 00 00-48 57 00 00 00 00 00 00 1.COM...HW......
終端文字列はとりあえず'0'と仮定して良さそうです。
環境変数を表示した時のenv.asmを改造した cmdline2.asm は以下のようになりました。
org 100H bits 16 section .text start: PUSH BP MOV BP, SP PUSH DS MOV BX, [DS:002Ch] MOV [segenv], BX MOV DS, BX MOV BX, 0000H .loop1: INC BX CMP BYTE [DS:BX], 0 JNZ .loop1 INC BX CMP BYTE [DS:BX], 0 JNZ .loop1 ADD BX, 3 ; "01 00"をスキップ PUSH BX CALL print1 POP DS PUSH 1H CALL exit ; arg1(1byte) : exit code ; return : non exit: (以下 env.asm と同じ)
コンパイル+実行:
> nasm -fbin -o cmdline2.com cmdline2.asm > cmdline2.com C:\(...)\CMDLINE2.COM >
上手く動いてくれたようです。
なおdebug経由で起動した時は"CMDLINE2.COM"しか格納されていませんが、単体で起動するとフルパスで格納されるようです。
NASMを使って16bitDOSプログラム(COMフォーマット)を組み立てる手順を見ていきました。
またDOS環境における環境変数やコマンドライン文字列の取得方法についても見ていきました。
32bitから64bitへの移行が進んでいる2010年現在、16bitDOSプログラムをアセンブラで開発する機会は滅多にないと思いますが、もし万が一、そのような機会に遭遇した時にこのページを思い出して頂ければ幸いです。