😽

アセンブリ言語(NASM)で FizzBuzz を書く

2024/08/15に公開

はじめに

こんにちは!めもりーです。
最近 PHP で OS を作ったり,CPU エミュレータを作る機会が多く,何かとアセンブリに触れてきました。
FizzBuzz といえばプログラミング言語を学ぶにあたって,欠かせない一つのアルゴリズムです。

PHP で書くととても簡単に以下のように表現できます:

<?php

for ($i = 1; $i < 100; $i++) {
    if (($i % 15) === 0) {
        echo "FizzBuzz\n";
    } else if (($i % 5) === 0) {
        echo "Buzz\n";
    } else if (($i % 3) === 0) {
        echo "Fizz\n";
    } else {
        echo "{$i}\n";
    }
}

とても簡単ですね。しかし,アセンブリになるとどうなるでしょうか。相当に難易度が上がります。
アセンブリをやるとわかりますが,C 言語が高級言語(いまは低級言語と呼ぶ宗派もいるらしいですが)だと言われる所以がわかります。
本記事では,アセンブリでどのようにして FizzBuzz を表現するのか,PHP で表現しつつ学習の備忘録も含めて書いていきます。

実はアセンブリ書いたことがこの年までなく,完全に初心者で,しかもハードル高いと感じていたんですが ChatGPT に教わりながら学べているので ChatGPT 様々です。

前提

  • Intel x86 前提です。
  • NASM (Netwide Assembler) を前提に書いていきます。
  • エミュレータは QEMU を用います。
  • 16 ビットリアルモードで書きます。

事前に以下のような形で必要なものをインストールしておきます。

brew install nasm
brew install qemu

アセンブリと PHP

PHP には goto という指定したラベルに飛ぶ機能が備わっていますが,アセンブリではこの goto を駆使し,グローバル空間にある数が限られたあらかじめ定義されている変数をこねくり回すイメージです。

また,アセンブリには命令(Instruction)が数百個に並びあります。この命令が何かしらのグローバル空間にある,いずれかの変数を書き換えるので,書き換えられる変数の初期化を忘れると,値がバグることがしょっちゅうあるわけです。

PHP で 0 から 9 までの出力をアセンブリっぽく表すなら以下でしょうか:

<?php

function cmp($left, $right) {
    global $zeroFlag;
    $zeroFlag = $left === $right;
}

$zeroFlag = 0;
$cx = 10;
$bx = 0;
label:
    $cx--;
    echo "{$bx}\n";

    cmp($cx, 0);

    $bx++;
    if (!$zeroFlag) goto label;
finish:
    return;

これでもだいぶわかりやすい方ではありますが…。イメージは大体こんな感じです。cmp を別のところで使うと $zeroFlag の値が変わるので,気をつけなければなりません,というのがアセンブリを書いている上では頻出するものです。

文字列出力 - Hello World!

アセンブリには BIOS 割り込みを用いたテレタイプモードでの出力,もしくはビデオサービス上で,指定した位置にピクセルをレンダリングする手法の大まかに 2 種類があります。

テレタイプモードでは,ターミナルのカーソルの位置に指定した文字を書き込んでカーソルを移動させる割り込み機能です。
今回は簡易的にしたいのでテレタイプモードでの出力とします。
加えて,更に手軽にやるために BIOS のブートローダとして認識させて出力する手法を用います。

BIOS ブートローダは 512 バイトで,2 バイトが末尾にブートローダのシグネチャとして書き込まれているものです。
また,開始オフセットはデフォルトでは 0x7C00 となり,BIOS はこの位置から,ブートローダを起動します。
以下のようにすると正常にブートローダが起動できる事がわかります。

[bits 16]

; オフセットを 0x7C00 とする
[org 0x7C00]

; 510 バイト目までヌルバイトで埋める
times 510-($-$$) db 0

; シグネチャ 0x55, 0xAA (リトルエンディアンなので dw 0xAA55 と等価)を書き込む。
dw 0xAA55

上記の書かれたコードを boot.asm として保存し nasm を用いて以下のようにコンパイルします。

$ nasm boot.asm -o boot.bin

次に以下のコマンドを用いて QEMU を起動すると何らエラーが表示されない画面になります。

$ qemu-system-x86_64 -drive file=boot.bin,format=raw

アセンブリで文字列を出力するには以下のようなコードを用意します。

[bits 16]

; オフセットを 0x7C00 とする
[org 0x7C00]

; si レジスタの位置を hello_world のオフセットにする
mov si, hello_world

; print_string ルーチンを呼び出す
call print_string

; 終了
hlt

print_string:
  ; si レジスタの位置から al レジスタに文字をロード
  ; si を一つ進める
  lodsb

  ; al がヌルバイトかどうか。ヌルバイトである場合はゼロフラグが 1 になる
  cmp al, 0

  ; ゼロフラグが 1 の場合 .done のオフセットへ
  jz .done

  ; ゼロフラグが 0 の場合,.char ルーチンが呼ばれる
  call .char

  ; .done オフセットへ
  jmp .done

  .char:
    ; テレタイプモードの指定
    mov ah, 0x0E

    ; BIOS 割り込みのビデオサービスを呼び出す
    int 0x10
    jmp print_string
  .done:
    ret

; Hello World! の書き込み
hello_world:
  db 'Hello World!', 0

; 510 バイト目までヌルバイトで埋める
times 510-($-$$) db 0

; シグネチャ 0x55, 0xAA (リトルエンディアンなので dw 0xAA55 と等価)を書き込む。
dw 0xAA55

上記のアセンブリを HelloWorld.asm と命名し以下のように NASM を実行します。

$ nasm HelloWorld.asm -o HelloWorld.bin

次に同様に QEMU を起動します。

$ qemu-system-x86_64 -drive file=HelloWorld.bin,format=raw

Hello World! が出力できましたね。一つ一つ紐解いていきましょう。

mov si, hello_world

mov si, hello_world

mov 命令は si レジスタを hello_world というラベルが付けられたオフセットの情報を格納する命令です。

例外もありますが多くの命令は

ins dest, src

のようなフォーマットに則っており, ins (instruction, 命令) によって src が情報元として扱われて,dest を使ってゴニョゴニョするようになっています。

ちなみに mov 命令はいくつかの挙動がありますが,概ね dest に src の内容をコピーします。
ただし si や di のようなインデックスレジスタの場合は,オフセットをコピーするようになっています。実際にオペレーションコードも以下の mov と:

mov al, 0x00

以下の mov とでは:

mov si, label

異なります。 Intel x86 の命令では,レジスタごとにオペレーションコードが分かれていたり, 1 つのオペレーションコードでオペランドの情報をもとに細かく制御している場合があります。

ModR/M

少し話はそれますが,先ほどの例では, mov al, 0x000xB0mov si, label0xB8 など異なります。オペレーションコードが違えばオペランド(引数)も違います。

レジスタからレジスタへ値をコピーする際には:

mov al, bl

上記のようになるわけですが,これのオペレーションコードは 0x89 です。 mov ah, bh も同様のオペレーションコードにコンパイルされます。この命令のオペランドは 1 つで ModR/M と呼びます。

これはコピー先のレジスタ,コピー元のレジスタ,どういうモードのコピーの方法(レジスタ to レジスタ)なのかがビットで格納されています。

ビットマスク 名称 役割
0b11000000 mode モードの情報。 11 の場合はレジスタからレジスタへのコピー。 10 の場合は 16 ビット符号付きディスプレースメントあり,01 の場合は 8 ビット符号付きディスプレースメントあり,00 の場合はディスプレースメントなし,もしくは符号なしディスプレースメントありです。
0b00111000 dest コピー先のレジスタ番号。もしくはオペレーションコード最適化用のディジット,オペレーションコード拡張など。
0b00000111 src コピー元のレジスタ番号。もしくはメモリアドレスなど。

オペレーションコードによって微妙に方言が変わります。なお,16 ビットなのでこのサイズで済んでいますが,32 ビットになるともちろん足りてきません。この場合,SIB (Scaled Index Byte)が与えられて,値を拡張することがあります。が,まぁ FizzBuzz の出力程度ではまだ出てきません。

call print_string

これは ret 命令が実行された際に,呼び出し元に戻るオフセット先の命令実行です。簡潔にいえば,ルーチン(PHP でいうところの関数)の実行です。

みたとおりのままなので解説は割愛しますが,オフセット先の命令実行なだけであるので, ret を実行しないと EOF (End Of File) まで実行されてしまいます。

ラベルとオフセット

print_string や,後ほど出てくる .done はラベルといい,オフセットへのマーカーみたいなものです。命令をオペレーションコードにコンパイルする際にラベルもオフセット,すなわちディスク内の位置を指し示す数値に変換されます。

ただ命令が増えれば,各々のオフセットの位置ももちろん変わるため,わかりやすいようにオフセットにマーカーを引くのがラベルの役割です。

そういった役割から,ラベルは同じ名称をつけることはできませんが,.done のように先頭にドットを付ける場合は,親ラベル内の子ラベルとして扱われます。
つまり,親ラベルさえ異なれば子ラベルでは同じラベル名をつけることができます。例えば以下のようにです。

; ... 省略

label1:
  ; 何かしらの処理
  .done:

label2:
  ; 何かしらの処理
  .done:

hlt

PHP でいうところの __HALT_COMPILER(); です。 CPU の実行を停止させる,すなわち終了させます。

lodsb

lodsb は Load String Byte で,16 ビットリアルモードの場合,si が指しているオフセットから 1 文字取得して al に格納します。さらに si を一つインクリメントさせます。
つまり,もともと H のオフセットを指していたものが e のオフセットを指し,次に l を指し…となります。

cmp al, 0

cmp x, y は, xy を比較して,ゼロになる場合はゼロフラグが 1 になります。そのため cmp al, 0 はヌルバイトであれば 0 となるので,ゼロフラグが 1 になるわけです。

他にもいくつかのフラグがあるんですが,気になる方は調べていただいて,今回は割愛することとします。

jz .done

.done ラベルが付いたオフセットに,ゼロフラグが 1 の場合,移動させます。ゼロフラグが 0 ,すなわち,cmp などの結果がゼロではなかった場合は,次の命令を実行します。

call .char

call print_string と同様のため割愛

jmp .done

条件などなく指定されたオフセットに移動します。この場合 .done ラベルのついたオフセットに移動させます。

mov ah, 0x0E

後の BIOS 呼び出しのためにテレタイプモードを指定します。これによりターミナルに文字列を出力することができるようになります。

int 0x10

これは BIOS 割り込みの (BIOS Interruption) ビデオサービスを呼び出します。この割り込みでは ax の値が用いられます。このとき,ax の値,すなわち ah, al はどうなっているのでしょうか。

レジスタ
ax 0x480E
-> al 0x48 (H)
-> ah 0x0E (テレタイプモード)

al には lodsb から得た H (0x48) が格納されています。これを 0x0E と指定したテレタイプモードで出力するわけです。これを繰り返してヌルバイトが cmp されるまで,すなわち Hello World! が出力されるのです。

jmp print_string

もう一度 print_string を実行し直します。このとき si の値が一つインクリメントされているため,次の int 0x10 実行時は al は e (0x65) となります。

ret

呼び出しもと,すなわち call print_string したオフセットに戻ります。

レジスタの役割

アセンブリには以下のようなよく使うレジスタがあります。

レジスタ名 レジスタ番号 役割
ax 0b000 アキュムレータレジスタ。計算結果などを格納する役割がある。
cx 0b001 カウンタレジスタ。ループなどでよく用いられる。
dx 0b010 データレジスタ。こちらも計算結果などを格納する役割がある。
bx 0b011 ベースレジスタ。何かしらの基底になる情報を格納する役割。
si 0b110 ソースインデックスレジスタ。画像などの入力元の情報の "位置" などを格納する役割がある
di 0b111 デスティネーションインデックスレジスタ。画像などの出力先の情報の "位置" などを格納する役割がある

axdx に至っては,上位ニブル(high 4 bits),下位ニブル(low 4 bits)を表す,al, ah, bl, bh... などがあります。ただ,al や ah が指すレジスタ番号は ax と同じです。

各々のレジスタにはそれなりの役割があるわけですが,アセンブリの良い(?)ところは,挙げたレジスタの例はすべて汎用レジスタ,つまり自分が使いたい値をぶっこめるところです。
ただ,上記でも記述しているように, cmp や int のような命令ではどういったレジスタが使われるかは不明瞭なので,実際に,アセンブリをスラスラ書くためには使われるレジスタの種類は覚えておかねばなりません。
そうしなければ,命令を実行したときに ax や bx の値が期待した値ではなくなっていることがあります。

ゆえに以下のような初期化をよく行います。

xor ax, ax

こうすることで ax を 0 にすることができます。

ちなみに 32bit になると eax のように e がついたり, 64bit になると rax のように r がつきます。ただし,eah のような上位 16bit や下位 16bit を表すようなレジスタはありません。

ループ文

NASM のループ文には簡易的にループを実行できる loop 命令と,古典的な jz (Jump If Zero) ないしは jnz Jump if not zero を用いる手法があります。

まずは loop 文ですが,loop 文は与えられた cx レジスタの値をデクリメントしていき,ゼロになるまでループを続けます。例えば 10 回ループさせたい場合は以下のようにします:

mov cx, 10
loop_if_cx_is_not_zero:
  ; 何かしらの処理
  loop loop_if_cx_is_not_zero

jnz は演算結果がゼロになるまで繰り返したい場合などに有用です。

loop_if_cx_is_not_zero:
    ; 何かしら演算処理して ax の値が変わっていく
    cmp ax, 0
    jnz loop_if_cx_is_not_zero

アセンブリのループ文は案外簡単にできますね。

PHP であれば,ここで echo "{$cx}" とすれば余裕じゃないか,と思うかもしれませんが,アセンブリは手間がかかります。加えて,ASCII コードの知識が一定程度必要にもなってきます。

どういうことでしょうか。cx レジスタの値は 10 ,つまり 0x10 を指しているわけですが,10 を指す ASCII コードはありません。それに, 09 は 0x30〜0x39 であるので 0x10 をターミナル上に出力するには,工夫が必要です。

考え方としては:

  • 0x10 を 0x1 と 0x0 に分割する。
  • それぞれに 0x30 を足す

とすることで,0x10 の値を出力できます。つまり itoa (Integer to Ascii) を自前で実装する必要があるということです。

itoa の実装

さて,itoa はどのように実装すればよいでしょうか。まずは PHP で考えましょう。


<?php

$int = 10;
echo (string) $int;

違いますね。アセンブリに文字列キャストなんてものはありません。高価な関数や配列などももちろんありません。ループ文と chr,文字列オフセットアクセスだけを使って考えてみます。
以下のようにしましょう。

<?php

// 文字列化する値
$int = 91;

// 3 バイト分 + ヌルバイト
$str = "\x0\x0\x0\x0";

$length = 3;
$i = 0;

// 値を文字に変換する
do {
    // 10 進数であるため割ったあまりが,値となる
    $value = $int % 10;

    // 10 で割った数に変える
    $int = intdiv($int, 10);

    // '0' (0x30) にあまりの数 1 (0x01) を足す。
    // そうすることで '1' (0x31) とできる
    $str[$length - $i - 1] = chr($value + 0x30);
    $i++;

    // ゼロになるまで繰り返す
} while ($int !== 0);

// 先頭がヌルバイトではなくなるまでループする
$i = 0;
while ($str[$i] === chr(0x00)) {
    $i++;
}

// 先頭に値をコピーしていく
$j = 0;
do {
    // 値を先頭に移動させる
    $str[$j++] = $str[$i++];
} while ($i < $length);

// 最後の値をヌルバイトに置き換える
$str[$j] = chr(0);

// 文字列の出力
// PHP の場合ヌルバイトもろとも出力してしまうので,ヌルバイトまでで出力を止めるようにしたもの。
$i = 0;
do {
    echo $str[$i];
} while ($str[$i++] !== chr(0x00));

純粋に 10 で割っていくと,末尾の値から処理されるため以下のように値が入ることになります。

0 1 2 3
'1' (0x31) '9' (0x39) Null (0x00) Null (0x00)

本来ほしい値は 91 なので,期待と異なってしまいます。そもそも 10 で割り切るまで繰り返さないと,桁数も求められません。常用対数なんて高度な数式も使えるわけではないですし。

では,どうするか。いろんな解法がありますが,上記の PHP の例では末尾から詰めて,先頭に詰め直すという手法を取っています。

$str[$length - $i - 1] とすることで

1 回目の実行: 末尾 - 0 - 1 = 末尾-1
2 回目の実行: 末尾 - 1 - 1 = 末尾-2

と指定した文字列オフセットに入れると以下のようになります。

0 1 2 3
Null (0x00) '9' (0x39) '1' (0x31) Null (0x00)

次に,先頭のヌルバイトを詰めるため以下のように

$i = 0;
while ($str[$i] === chr(0x00)) {
    $i++;
}

$i を 0x00 ではない位置にします。

0 1 ($i の位置) 2 3
Null (0x00) '9' (0x39) '1' (0x31) Null (0x00)

次に $i の位置にある値を先頭 ($j = 0) にコピーして, $i, $j をインクリメント。
最大文字列長(3 バイト)までコピーします。そうすると以下のようになります。

0 1 2 ($i の位置) 3
'1' (0x31) '9' (0x39) '1' (0x31) Null (0x00)

最後に $i の位置をヌルバイトで埋めて完了です。

0 1 2 ($i の位置) 3
'1' (0x31) '9' (0x39) Null (0x00) Null (0x00)

これで itoa の仕組みの解説になります。PHP をアセンブリに翻訳してみましょう。次はアセンブリで実装してみましょう。

itoa:
  mov si, itoa_value

  ; cx の値を退避
  push cx

  ; 文字列の初期化 ($str = "\x0\x0\x0\x0" と等価)
  mov cx, length+1
  .fill_by_zero:
    mov bl, 0x00
    mov [si], bl
  loop .fill_by_zero

  ; cx の値を戻す
  pop cx

  ; 10 で商がゼロになるまで割る
  ; 以下と等価
  ; 
  ; do {
  ;     $value = $int % 10;
  ;     $int = intdiv($int, 10);
  ;
  ;     $str[$length - $i - 1] = chr($value + 0x30);
  ;     $i++;
  ; } while ($int !== 0);
  ;
  mov si, itoa_value
  mov bx, 10
  .loop:
    xor dx, dx
    div bx
    add dl, '0'
    mov [si+length-1], dl
    dec si
    cmp al, 0
    jnz .loop

  ; ヌルバイトじゃない値までループ
  ; 以下と等価
  ; 
  ; $i = 0;
  ; while ($str[$i] === chr(0x00)) {
  ;     $i++;
  ; }
  ;
  mov si, itoa_value
  .seek_to_nonnull_ahead:
    lodsb
    cmp al, 0
    jz .seek_to_nonnull_ahead
  dec si

  ; 値を先頭に移動させる処理
  ; 以下と等価
  ;
  ; $j = 0;
  ; do {
  ;     // 値を先頭に移動させる
  ;     $str[$j++] = $str[$i++];
  ; } while ($i < $length);
  ; 
  mov di, itoa_value
  .move_to_ahead:
    lodsb
    cmp al, 0
    jz .finish
    mov [di], al
    inc di
    jmp .move_to_ahead
    .finish:

  ; ヌルバイトで埋める
  ; 以下と等価
  ; 
  ; $str[$j] = chr(0);
  mov al, 0x00
  mov [di], al

  ; 呼び出し元へ戻る
  ret

; itoa の結果を格納する
itoa_value:
  times length+1 db 0

実装できましたね。次は割った数の余りに応じて分岐させる処理です。

割った数の余りに応じて分岐させる

Fizz, Buzz, FizzBuzz の出力において重要なロジックの一つとして余りに応じて処理を分けるということです。Fizz は 3, Buzz5,FizzBuzz は 15 でそれぞれ余りがゼロの場合出力するような処理が必要です。

itoa でもしれっと使っていますが,NASM ではモジュロ演算(%)はありません。16 ビットリアルモードでは通常,除算である div を実行することで ax レジスタに商,dx レジスタに余りが格納されるようになっています。

なお割られる数はオペランドで指定するわけではなく ax レジスタとなるので,ax に事前に値を入れておく必要があります。

つまり Fizz を出力したい場合は,以下のようになります。

; 割られる数の指定
mov ax, 12

; dx レジスタを初期化
; 初期化しておかないと,余りの数が不意に前の処理によって入ってしまっている場合があるため
xor dx, dx

; 割る数
mov bx, 3

; div のオペランドには割る数が入っているレジスタを指定する必要があります。
div bx

; 余りがゼロではない場合,fizz_finished のオフセットに飛んで次の処理へ
cmp dl, 0
jnz .fizz_finished

; 余りがゼロである場合,Fizz を出力
mov si, fizz
call print_string
jmp .finish
.fizz_finished:

.finish には以下のように改行を出力させるなどの後処理を入れます。

.finish:
  ; 改行の出力
  mov si, newline
  call print_string

これで Fizz の出力ができます。実際に以下のアセンブリで試してみましょう。

[bits 16]

; オフセットを 0x7C00 とする
[org 0x7C00]

; 割られる数の指定
mov ax, 12

; dx レジスタを初期化
; 初期化しておかないと,余りの数が不意に前の処理によって入ってしまっている場合があるため
xor dx, dx

; 割る数
mov bx, 3

; div のオペランドには割る数が入っているレジスタを指定する必要があります。
div bx

; 余りがゼロではない場合,fizz_finished のオフセットに飛んで次の処理へ
cmp dl, 0
jnz .fizz_finished

; 余りがゼロである場合,Fizz を出力
mov si, fizz
call print_string
jmp .finish
.fizz_finished:

.finish:
  ; 改行の出力
  mov si, newline
  call print_string

; 終了
hlt

print_string:
  ; si レジスタの位置から al レジスタに文字をロード
  ; si を一つ進める
  lodsb

  ; al がヌルバイトかどうか。ヌルバイトである場合はゼロフラグが 1 になる
  cmp al, 0

  ; ゼロフラグが 1 の場合 .done のオフセットへ
  jz .done

  ; ゼロフラグが 0 の場合,.char ルーチンが呼ばれる
  call .char

  ; .done オフセットへ
  jmp .done

  .char:
    ; テレタイプモードの指定
    mov ah, 0x0E

    ; BIOS 割り込みのビデオサービスを呼び出す
    int 0x10
    jmp print_string
  .done:
    ret

; Fizz
fizz:
  db 'Fizz', 0

; Fizz
newline:
  db 0x0D, 0x0A, 0

; 510 バイト目までヌルバイトで埋める
times 510-($-$$) db 0

; シグネチャ 0x55, 0xAA (リトルエンディアンなので dw 0xAA55 と等価)を書き込む。
dw 0xAA55

mov ax, 12 であれば 3 で割り切れるので,Fizz が出力されることを期待しますし,値を変えて 13 とすれば改行だけの表示になることがわかります。
これを応用して,残りの Buzz, FizzBuzz の出力,それ以外の場合は数字を出力させる(先ほど実装した itoa を用いる)ことで FizzBuzz が実装できます。

FizzBuzz

まずは,ループ文は特に考えず与えられた値を用いて FizzBuzz, 数字の出力ができるコードを想像しましょう。Fizz のコードを応用して Buzz, FizzBuzz を出力してみます。このとき 3 と 5 の 公倍数である 15 の値をもつ FizzBuzz の順序に注意をします。FizzBuzz の処理を先に書かねばなりません。

そうすると,以下のようになるでしょうか。

[bits 16]

; オフセットを 0x7C00 とする
[org 0x7C00]

; itoa の値のサイズ
%define length 3

; 割られる数の指定
mov ax, 13

; ax の値が変わってしまうため保持しておく
push ax

; FizzBuzz ------------------------------------------------------------------

; ax の値を戻す
pop ax

; ax の値を保持
push ax

; dx レジスタを初期化
xor dx, dx

; 割る数
mov bx, 15

; div のオペランドには割る数が入っているレジスタを指定する必要があります。
div bx

; 余りがゼロではない場合,fizzbuzz_finished のオフセットに飛んで次の処理へ
cmp dl, 0
jnz .fizzbuzz_finished

; 余りがゼロである場合,Fizz を出力
mov si, fizzbuzz
call print_string
jmp .finish
.fizzbuzz_finished:

; Buzz ----------------------------------------------------------------------

; ax の値を戻す
pop ax

; ax の値を保持
push ax

; dx レジスタを初期化
xor dx, dx

; 割る数
mov bx, 5

; div のオペランドには割る数が入っているレジスタを指定する必要があります。
div bx

; 余りがゼロではない場合,fizzbuzz_finished のオフセットに飛んで次の処理へ
cmp dl, 0
jnz .buzz_finished

; 余りがゼロである場合,Fizz を出力
mov si, buzz
call print_string
jmp .finish
.buzz_finished:

; Fizz ----------------------------------------------------------------------

; ax の値を戻す
pop ax

; ax の値を保持
push ax

; dx レジスタを初期化
xor dx, dx

; 割る数
mov bx, 3

; div のオペランドには割る数が入っているレジスタを指定する必要があります。
div bx

; 余りがゼロではない場合,fizzbuzz_finished のオフセットに飛んで次の処理へ
cmp dl, 0
jnz .fizz_finished

; 余りがゼロである場合,Fizz を出力
mov si, fizz
call print_string
jmp .finish
.fizz_finished:

; 数字 -----------------------------------------------------------------------

; ax の値を戻す
pop ax

; ax の値を保持
push ax

call itoa

mov si, itoa_value
call print_string


.finish:
  ; 改行の出力
  mov si, newline
  call print_string

; 終了
hlt

print_string:
  ; si レジスタの位置から al レジスタに文字をロード
  ; si を一つ進める
  lodsb

  ; al がヌルバイトかどうか。ヌルバイトである場合はゼロフラグが 1 になる
  cmp al, 0

  ; ゼロフラグが 1 の場合 .done のオフセットへ
  jz .done

  ; ゼロフラグが 0 の場合,.char ルーチンが呼ばれる
  call .char

  ; .done オフセットへ
  jmp .done

  .char:
    ; テレタイプモードの指定
    mov ah, 0x0E

    ; BIOS 割り込みのビデオサービスを呼び出す
    int 0x10
    jmp print_string
  .done:
    ret

itoa:
  mov si, itoa_value

  ; cx の値を退避
  push cx

  ; 文字列の初期化 ($str = "\x0\x0\x0\x0" と等価)
  mov cx, length+1
  .fill_by_zero:
    mov bl, 0x00
    mov [si], bl
  loop .fill_by_zero

  ; cx の値を戻す
  pop cx

  ; 10 で商がゼロになるまで割る
  ; 以下と等価
  ;
  ; do {
  ;     $value = $int % 10;
  ;     $int = intdiv($int, 10);
  ;
  ;     $str[$length - $i - 1] = chr($value + 0x30);
  ;     $i++;
  ; } while ($int !== 0);
  ;
  mov si, itoa_value
  mov bx, 10
  .loop:
    xor dx, dx
    div bx
    add dl, '0'
    mov [si+length-1], dl
    dec si
    cmp al, 0
    jnz .loop

  ; ヌルバイトじゃない値までループ
  ; 以下と等価
  ;
  ; $i = 0;
  ; while ($str[$i] === chr(0x00)) {
  ;     $i++;
  ; }
  ;
  mov si, itoa_value
  .seek_to_nonnull_ahead:
    lodsb
    cmp al, 0
    jz .seek_to_nonnull_ahead
  dec si

  ; 値を先頭に移動させる処理
  ; 以下と等価
  ;
  ; $j = 0;
  ; do {
  ;     // 値を先頭に移動させる
  ;     $str[$j++] = $str[$i++];
  ; } while ($i < $length);
  ;
  mov di, itoa_value
  .move_to_ahead:
    lodsb
    cmp al, 0
    jz .finish
    mov [di], al
    inc di
    jmp .move_to_ahead
    .finish:

  ; ヌルバイトで埋める
  ; 以下と等価
  ;
  ; $str[$j] = chr(0);
  mov al, 0x00
  mov [di], al

  ; 呼び出し元へ戻る
  ret

; itoa の結果を格納する
itoa_value:
  times length+1 db 0

fizzbuzz:
  db 'FizzBuzz', 0

buzz:
  db 'Buzz', 0

fizz:
  db 'Fizz', 0


; Fizz
newline:
  db 0x0D, 0x0A, 0

; 510 バイト目までヌルバイトで埋める
times 510-($-$$) db 0

; シグネチャ 0x55, 0xAA (リトルエンディアンなので dw 0xAA55 と等価)を書き込む。
dw 0xAA55

このようにすると mov ax, 12 の場合は Fizz が,mov ax, 13 の場合は 13 が出力されますね。

mov ax, 12 の場合
mov ax, 12 の場合


mov ax, 13 の場合

さて,ここで FizzBuzz, Fizz, Buzz の処理が類似していることから,ルーチン化できそうだと考えられます。以下のようにルーチン化しましょう。

fizzbuzz_output:
  ; ax の値が div によって変わってしまうので,元々の値を push しておく
  push ax
  
  ; dx レジスタを初期化
  xor dx, dx

  ; div のオペランドには割る数が入っているレジスタを指定する必要があります。
  div bx

  ; 余りがゼロではない場合,fizzbuzz_finished のオフセットに飛んで次の処理へ
  cmp dl, 0
  jnz .fizzbuzz_finished

  ; 余りがゼロである場合,si を出力
  call print_string

  .fizzbuzz_finished:
  
  ; ax の値を戻す
  pop ax
  ret

この fizzbuzz_output を呼び出すことでシンプルに出力できるようになります。
以下のように書き換えてみましょう。

[bits 16]

; オフセットを 0x7C00 とする
[org 0x7C00]

; itoa の値のサイズ
%define length 3

; 割られる数の指定
mov ax, 13

; FizzBuzz ------------------------------------------------------------------

; 割る数
mov bx, 15

; 出力する文字列は FizzBuzz
mov si, fizzbuzz

; fizzbuzz_output の呼び出し
call fizzbuzz_output

; ゼロフラグがセットされている場合,すなわち余りがない場合は .finish ラベルのオフセットへ
jz .finish


; Buzz ----------------------------------------------------------------------

; 割る数
mov bx, 5

; 出力する文字列は Buzz
mov si, buzz

; fizzbuzz_output の呼び出し
call fizzbuzz_output

; ゼロフラグがセットされている場合,すなわち余りがない場合は .finish ラベルのオフセットへ
jz .finish

; Fizz ----------------------------------------------------------------------

; 割る数
mov bx, 3

; 出力する文字列は Fizz
mov si, fizz

; fizzbuzz_output の呼び出し
call fizzbuzz_output

; ゼロフラグがセットされている場合,すなわち余りがない場合は .finish ラベルのオフセットへ
jz .finish

; 数字 -----------------------------------------------------------------------

call itoa

mov si, itoa_value
call print_string

.finish:
  ; 改行の出力
  mov si, newline
  call print_string

; 終了
hlt

fizzbuzz_output:
  ; ax の値が div によって変わってしまうので,元々の値を push しておく
  push ax

  ; dx レジスタを初期化
  xor dx, dx

  ; div のオペランドには割る数が入っているレジスタを指定する必要があります。
  div bx

  ; 余りがゼロではない場合,fizzbuzz_finished のオフセットに飛んで次の処理へ
  cmp dl, 0
  jnz .fizzbuzz_finished

  ; 余りがゼロである場合,si を出力
  call print_string

  .fizzbuzz_finished:

  ; ax の値を戻す
  pop ax
  ret


print_string:
  ; si レジスタの位置から al レジスタに文字をロード
  ; si を一つ進める
  lodsb

  ; al がヌルバイトかどうか。ヌルバイトである場合はゼロフラグが 1 になる
  cmp al, 0

  ; ゼロフラグが 1 の場合 .done のオフセットへ
  jz .done

  ; ゼロフラグが 0 の場合,.char ルーチンが呼ばれる
  call .char

  ; .done オフセットへ
  jmp .done

  .char:
    ; テレタイプモードの指定
    mov ah, 0x0E

    ; BIOS 割り込みのビデオサービスを呼び出す
    int 0x10
    jmp print_string
  .done:
    ret

itoa:
  mov si, itoa_value

  ; 文字列の初期化 ($str = "\x0\x0\x0\x0" と等価)
  mov cx, length+1
  .fill_by_zero:
    mov bl, 0x00
    mov [si], bl
  loop .fill_by_zero

  ; 10 で商がゼロになるまで割る
  ; 以下と等価
  ;
  ; do {
  ;     $value = $int % 10;
  ;     $int = intdiv($int, 10);
  ;
  ;     $str[$length - $i - 1] = chr($value + 0x30);
  ;     $i++;
  ; } while ($int !== 0);
  ;
  mov si, itoa_value
  mov bx, 10
  .loop:
    xor dx, dx
    div bx
    add dl, '0'
    mov [si+length-1], dl
    dec si
    cmp al, 0
    jnz .loop

  ; ヌルバイトじゃない値までループ
  ; 以下と等価
  ;
  ; $i = 0;
  ; while ($str[$i] === chr(0x00)) {
  ;     $i++;
  ; }
  ;
  mov si, itoa_value
  .seek_to_nonnull_ahead:
    lodsb
    cmp al, 0
    jz .seek_to_nonnull_ahead
  dec si

  ; 値を先頭に移動させる処理
  ; 以下と等価
  ;
  ; $j = 0;
  ; do {
  ;     // 値を先頭に移動させる
  ;     $str[$j++] = $str[$i++];
  ; } while ($i < $length);
  ;
  mov di, itoa_value
  .move_to_ahead:
    lodsb
    cmp al, 0
    jz .finish
    mov [di], al
    inc di
    jmp .move_to_ahead
    .finish:

  ; ヌルバイトで埋める
  ; 以下と等価
  ;
  ; $str[$j] = chr(0);
  mov al, 0x00
  mov [di], al

  ; 呼び出し元へ戻る
  ret

; itoa の結果を格納する
itoa_value:
  times length+1 db 0

fizzbuzz:
  db 'FizzBuzz', 0

buzz:
  db 'Buzz', 0

fizz:
  db 'Fizz', 0


; Fizz
newline:
  db 0x0D, 0x0A, 0

; 510 バイト目までヌルバイトで埋める
times 510-($-$$) db 0

; シグネチャ 0x55, 0xAA (リトルエンディアンなので dw 0xAA55 と等価)を書き込む。
dw 0xAA55

出力できましたね。さて最後は 100 回繰り返しで表示させてみましょう。
ループ文は loop を使うか cmpjz/jnz などを用いて繰り返します。今回は cmpjnz を使ってみます。以下のように書き換えてみましょう。

[bits 16]

; オフセットを 0x7C00 とする
[org 0x7C00]

; itoa の値のサイズ
%define length 3

; FizzBuzz (100 回分)
%define fizzbuzz_loops 100

mov cx, 0

loop_for_fizzbuzz:
  ; cx の値をインクリメント
  inc cx

  ; 割られる数の指定
  mov ax, cx

  ; FizzBuzz ----------------------------------------------------------------

  ; 割る数
  mov bx, 15
  mov si, fizzbuzz
  call fizzbuzz_output
  jz .finish


  ; Buzz --------------------------------------------------------------------

  mov bx, 5
  mov si, buzz
  call fizzbuzz_output
  jz .finish

  ; Fizz --------------------------------------------------------------------

  mov bx, 3
  mov si, fizz
  call fizzbuzz_output
  jz .finish

  ; 数字 ---------------------------------------------------------------------

  call itoa

  mov si, itoa_value
  call print_string


  .finish:
    ; 改行の出力
    mov si, newline
    call print_string

  ; cx が fizzbuzz_loops になるまで繰り返す
  cmp cx, fizzbuzz_loops
jnz loop_for_fizzbuzz

; 終了
hlt

fizzbuzz_output:
  ; ax の値が div によって変わってしまうので,元々の値を push しておく
  push ax

  ; dx レジスタを初期化
  xor dx, dx

  ; div のオペランドには割る数が入っているレジスタを指定する必要があります。
  div bx

  ; 余りがゼロではない場合,fizzbuzz_finished のオフセットに飛んで次の処理へ
  cmp dl, 0
  jnz .fizzbuzz_finished

  ; 余りがゼロである場合,si を出力
  call print_string

  .fizzbuzz_finished:

  ; ax の値を戻す
  pop ax
  ret


print_string:
  ; si レジスタの位置から al レジスタに文字をロード
  ; si を一つ進める
  lodsb

  ; al がヌルバイトかどうか。ヌルバイトである場合はゼロフラグが 1 になる
  cmp al, 0

  ; ゼロフラグが 1 の場合 .done のオフセットへ
  jz .done

  ; ゼロフラグが 0 の場合,.char ルーチンが呼ばれる
  call .char

  ; .done オフセットへ
  jmp .done

  .char:
    ; テレタイプモードの指定
    mov ah, 0x0E

    ; BIOS 割り込みのビデオサービスを呼び出す
    int 0x10
    jmp print_string
  .done:
    ret

itoa:
  mov si, itoa_value

  ; cx の値を退避
  push cx

  ; 文字列の初期化 ($str = "\x0\x0\x0\x0" と等価)
  mov cx, length+1
  .fill_by_zero:
    mov bl, 0x00
    mov [si], bl
  loop .fill_by_zero

  ; cx の値を戻す
  pop cx

  ; 10 で商がゼロになるまで割る
  ; 以下と等価
  ;
  ; do {
  ;     $value = $int % 10;
  ;     $int = intdiv($int, 10);
  ;
  ;     $str[$length - $i - 1] = chr($value + 0x30);
  ;     $i++;
  ; } while ($int !== 0);
  ;
  mov si, itoa_value
  mov bx, 10
  .loop:
    xor dx, dx
    div bx
    add dl, '0'
    mov [si+length-1], dl
    dec si
    cmp al, 0
    jnz .loop

  ; ヌルバイトじゃない値までループ
  ; 以下と等価
  ;
  ; $i = 0;
  ; while ($str[$i] === chr(0x00)) {
  ;     $i++;
  ; }
  ;
  mov si, itoa_value
  .seek_to_nonnull_ahead:
    lodsb
    cmp al, 0
    jz .seek_to_nonnull_ahead
  dec si

  ; 値を先頭に移動させる処理
  ; 以下と等価
  ;
  ; $j = 0;
  ; do {
  ;     // 値を先頭に移動させる
  ;     $str[$j++] = $str[$i++];
  ; } while ($i < $length);
  ;
  mov di, itoa_value
  .move_to_ahead:
    lodsb
    cmp al, 0
    jz .finish
    mov [di], al
    inc di
    jmp .move_to_ahead
    .finish:

  ; ヌルバイトで埋める
  ; 以下と等価
  ;
  ; $str[$j] = chr(0);
  mov al, 0x00
  mov [di], al

  ; 呼び出し元へ戻る
  ret

; itoa の結果を格納する
itoa_value:
  times length+1 db 0

fizzbuzz:
  db 'FizzBuzz', 0

buzz:
  db 'Buzz', 0

fizz:
  db 'Fizz', 0


; Fizz
newline:
  db 0x0D, 0x0A, 0

; 510 バイト目までヌルバイトで埋める
times 510-($-$$) db 0

; シグネチャ 0x55, 0xAA (リトルエンディアンなので dw 0xAA55 と等価)を書き込む。
dw 0xAA55

FizzBuzz.asm として保存して以下のようにコンパイルしましょう。

$ nasm FizzBuzz.asm -o FizzBuzz.bin

次に,以下のように QEMU を起動させます。

$ qemu-system-x86_64 -drive file=FizzBuzz.bin,format=raw  

FizzBuzz が正しく表示されましたね!もちろん,4 桁も可能です。

; itoa の値のサイズ
%define length 3

; FizzBuzz (100 回分)
%define fizzbuzz_loops 100

これらの値をそれぞれ以下のように変更してみましょう。

; itoa の値のサイズ
%define length 4

; FizzBuzz (1129 回分)
%define fizzbuzz_loops 1129

変更後,再度 nasm FizzBuzz.asm -o FizzBuzz.bin でコンパイルして実行してみましょう。

4 桁もできましたね!
本当はビデオメモリに書き込むなどの手法なども解説したいのですが,フォントをどう扱うかみたいな話になったときに,ブートローダ上の 512 バイトではどう考えても足りないので,別ディスクを読み込む,32 ビットプロテクションモードに移行する,など多彩な処理が必要になってくるなど事前知識を多く必要とするため割愛します。

Discussion