デバッグ用にテキストの表示などが行えると便利です。ここではシリアル通信*1のドライバを作成し、シリアル通信により文字の送受信を行うことで、QEMUのシリアル通信用の画面で文字の表示と入力された文字の取得を行ってみます*2。
[*1] 送信(Tx)と受信(Rx)をそれぞれ1本の信号線でやり取りする通信方式です。Windowsでは「COMポート」と呼ばれていたり、Linuxでは「シリアルポート(ttySx)」等と呼ばれているやつです。昔のPCには「D-sub9ピン」と呼ばれるコネクタが搭載されていて、「RS-232C」規格のケーブルを使ってシリアル通信を行うことができました。
[*2] ここはあくまでもシリアル通信による文字の送受信なので、キーボードドライバ自体は後の章で作成します。
それでは早速、シリアル通信による文字の送信を試してみましょう。この節のサンプルディレクトリは「031_ser_tx」です。
シリアル通信には専用のコントロールIC(コントローラ)があります。コントローラにもCPU同様にレジスタがあり、そのレジスタへ値を書き込んだり、逆に値を読み出したりすることで、シリアル通信でデータを送信したり受信したりできます。
外部のICが持つレジスタは、基本的に「IOアドレス空間」というアドレス空間からアクセスします。これは、ポインタ等でアクセスするような通常のメモリのアドレス空間とは別で、「in
」命令と「out
」命令という専用の命令を使用して、そのアドレス空間へアクセスします。
シリアルでのデータ送信は「THR(Transmitter Holding Register)
」というレジスタで行います。IOアドレスなどについては表3.1の通りです*3。
[*3] マシンには複数のシリアルポートが搭載されていたりするのですが、これは1つ目のシリアルポートのアドレスです。複数のシリアルポートを使う予定も無いので本書では常に1つ目のシリアルポートを使います。
IOアドレス | 0x03f8 |
---|---|
レジスタサイズ | 1バイト |
レジスタサイズが1バイトなので、1文字ずつ書き込み、1文字ずつ送信します。
それでは、out命令を使って、文字をシリアル通信で送ってみます。
1バイトのデータ送信を行う際のout命令の構文は以下の通りです。
アセンブラ | out %al, %dx |
---|---|
機械語 | ee |
使用できるレジスタは固定です。今回の場合、「AL」に送信したい文字のASCIIコードを格納し、「DX」へTHRのIOアドレス(0x03f8)を格納した状態でout命令を実施することで、データ送信を行えます。
8ビットレジスタと16ビットレジスタへそれぞれのビット幅の即値を格納する構文は2章で紹介しました。
再掲すると以下の通りです。
アセンブラ | mov 即値(8bit), レジスタ(8bit) |
---|---|
機械語 | b0+reg_ofs 即値(8bit) |
アセンブラ | mov 即値(16bit), レジスタ(16bit) |
---|---|
機械語 | 66 b9+reg_ofs 即値(16bit) |
ALレジスタのreg_ofsは「0」で、DXレジスタのreg_ofsは「2」なので、ALレジスタとDXレジスタへの値格納までをsample.txtへ記述するとリスト3.1の通りです。
ここでは文字A(0x41)を送信してみることにしました。
そして、out命令を使い、ALに格納した文字をDXが指すTHRへ書き込んでみます(リスト3.2)。
out命令の後にhltを使った無限ループを設置しました。
kernel.binを生成し、run_qemuスクリプトで実行してみてください。
シリアル送信した文字は、QEMUをGUIで起動した場合「Ctrl-Alt-3」キーでシリアル通信用の画面へ切り替えることで確認できます。CUIで起動した場合、標準でシリアル通信画面になっているのでそのまま表示されます。
文字「A」が表示されれば成功です。
$ run_qemu fs -nographic A
前節では、コントローラの状態確認無しでTHRへ書き込みを行っていました。THRはコントローラが持つ送信バッファです。コントローラの側で送信バッファにデータを受け入れる準備ができていない時にこのバッファへ書き込みを行ってしまうと、最悪の場合、未送信のデータを上書きして消してしまう恐れがあります。
シリアル通信のコントローラには「送信バッファが空になった」事を示すフラグがあるため、ここではこのフラグを確認してからTHRへ書き込むように変更してみます。
この節のサンプルディレクトリは「032_ser_tx_chk_flg」です。
コントローラのステータスは「Line Status Register(LSR)」というレジスタで確認できます。
IOアドレス | 0x03fd |
---|---|
レジスタサイズ | 1バイト |
LSRは各ビットがコントローラのステータスを示しており、各ビットの意味は以下の通りです。
ビット | 意味 |
---|---|
7 | 受信バッファに何らかのエラーが発生 |
6 | 送信バッファが空で内部の送信処理も全て完了 |
5 | 送信バッファが空(データ受け入れ可能) |
4 | ブレークシグナルを受信 |
3 | フレームエラー |
2 | パリティエラー |
1 | オーバーランエラー |
0 | 受信バッファに1つ以上のデータがある |
使うのはビット5です。このビットがセットされていれば送信バッファへデータ書き込みを行って問題ありません。
それでは、LSRの値を読み出す処理を追加してみます。
IOアドレス空間上のレジスタの値を取得する命令は「in
」命令で、構文は表3.7の通りです。
アセンブラ | in %dx, %al |
---|---|
機械語 | ec |
試しに、単体のソースコード「read_lsr.txt」を作り、LSRの値を読み出してみます(リスト3.3)。
リスト3.3を実行し、QEMUモニターでレジスタ値を確認すると以下の通りでした。
(qemu) info registers RAX=0000000000110060 RBX=00000000bef67f98 RCX=0000000000000000 RDX=00000000000003fd RSI=0000000000407180 RDI=00000000bfe9b018 RBP=00000000bff0eb10 RSP=0000000000210000 R8 =00000000bff0e9dc R9 =0000000000000078 R10=00000000bfe6e080 R11=00000000e7dc4657
RAXレジスタの最下位1バイト(ALレジスタ)が0x60になっています。ビット6とビット5がセットされているので、このステータス値なら、送信して問題無さそうです。
新たに2つの命令を使ってビットがセットされるまで待つ処理を実装します。流れとしては以下の2ステップです。
新たに登場した「je
」命令は、直前の演算結果に応じてジャンプするか否かが決まる「条件付きジャンプ命令」の1つです。CPUでは、数値の演算を行うと、その結果に応じてCPU内の「フラグレジスタ」と呼ばれるレジスタの各ビットが変化します。条件付きジャンプ命令は、このフラグレジスタの状態に応じてジャンプするか否かが決まる命令です。
jeは「Jump if Equal」の略です。関係するのはフラグレジスタの中の「ゼロフラグ」のビットで、je命令の実行時、このビットがセットされていればオペランドで示した相対アドレスへジャンプし、ビットがセットされていなければ、ジャンプはせずに次の命令へ進みます。
上記の2ステップの場合、ステップ1のANDによって、LSRのビット5がセットされていた場合のみステップ2で次の命令へ進むようになり、LSRのビット5がセットされていなければLSR値を取得するin命令まで戻ることになります。
je命令の構文はjmp命令と同じで以下の通りです。
アセンブラ | je <ラベルあるいは"."> |
---|---|
機械語 | 74 fe+rel_addr |
ここで、フラグレジスタの動きを見てみます。
例えば、alに0を格納し、0x20とandを取るだけのコードを作ってみます(リスト3.4)。
実行し、QEMUモニターでレジスタ値を表示させてみます。
(qemu) info registers RAX=0000000000110000 RBX=00000000bef67f98 RCX=0000000000000000 RDX=0000000000000000 RSI=0000000000407180 RDI=00000000bfe9b018 RBP=00000000bff0eb10 RSP=0000000000210000 R8 =00000000bff0e9dc R9 =0000000000000078 R10=00000000bfe6e080 R11=00000000e7dc4657 R12=00000000bef69540 R13=00000000bef69548 R14=00000000bff24b60 R15=00000000bef6a018 RIP=0000000000110005 RFL=00000046 [---Z-P-] CPL=0 II=0 A20=1 SMM=0 HLT=1
「RFL=」に書かれているのがフラグレジスタの値です。すぐ右側の"[]"の中にどんなフラグが設定されているかが現れていて、"Z"が「ゼロフラグ」の設定を示しています。ゼロフラグは演算結果がゼロだった場合に設定されるフラグです。今回の場合、0x00と0x20のANDは0x00になるため、このフラグが設定されました。
この状態で「je」命令を実行するとオペランドで指定した相対アドレスへジャンプします。
フラグレジスタの算術演算に関わるビット(下位8ビット)の一覧を紹介しておきます(表3.9)。
ビット | 意味 |
---|---|
7 | 符号フラグ。演算結果がマイナスの場合にセットされる |
6 | ゼロフラグ。演算結果がゼロの場合セットされる |
5 | 予約 |
4 | 調整フラグ。2進化10進(BCD)でのキャリー・ボローでセットされる |
3 | 予約 |
2 | パリティフラグ。演算結果最下位8ビットの1の数が偶数の場合セット |
1 | 予約 |
0 | キャリーフラグ。キャリー・ボローでセットされる |
フラグレジスタ自体は64ビットCPUの場合、64ビットの大きさがあります。ここでは算術演算に関わるもののみ紹介しました。今後使用するビットは登場した際に改めて紹介します*4。
[*4] フラグレジスタについて詳しくはSDMのVolume 1か、Wikibooksの記事が分かりやすいです。共に本書末尾の「参考情報」で紹介しています。
以上をまとめると、前節の機械語コードにフラグ確認の処理を追加するとリスト3.5のようになります。
実行結果は前節と変わりませんが、これでポーリングによるシリアル送信をちゃんと実装できました。
この章の最後に、シリアルポートからの受信データの取得を行ってみます。そして、受信した文字をそのままシリアルポートへ送信することで、エコーバック処理*5を実装してみます。
[*5] 入力したキーに対応する文字がそのまま画面へ表示される処理。シリアルドライバの送受信の動作確認としてよく使われます。
この節のサンプルディレクトリは「033_echoback」です。
まずは、受信バッファにデータが来るのを待ちます。
シリアルポートの受信バッファにデータがあるか否かもステータスレジスタLSRで確認できます。受信バッファに1つ以上のデータがあれば、LSRのビット0がセットされます(表3.10)。
ビット番号 | 意味 |
---|---|
ビット7 | 受信バッファに何らかのエラーが発生 |
ビット6 | 送信バッファが空で内部の送信処理も全て完了 |
ビット5 | 送信バッファが空(データ受け入れ可能) |
ビット4 | ブレークシグナルを受信 |
ビット3 | フレームエラー |
ビット2 | パリティエラー |
ビット1 | オーバーランエラー |
ビット0 | 受信バッファに1つ以上のデータがある |
sample.txtに受信バッファに1つ以上のデータが来るまで待つ処理を追加するとリスト3.6の通りです。
「シリアルポートのステータス取得」のコードブロックはシリアル送信の際と同じものです。
「受信バッファが空であればpauseしステータス取得まで戻る」のコードブロックは、これまでと少し異なります。
まず、新たに登場した「jne
」命令は、「Jump if Not Equal」の略です。je命令の逆で、「ゼロフラグがセットされていなければジャンプ」という挙動になります。
構文もje命令等と同様で表3.11の通りです。
アセンブラ | jne <ラベルあるいは"."> |
---|---|
機械語 | 75 fe+rel_addr |
そして、ALへ格納したステータス値と0x01でANDの結果に対して、以下の振る舞いを実装しています。
ここでは、「ビジーループで待つの際はpause命令を挟む」という事をするためにこのようなロジックにしています。
「pause
」命令は、「ビジーループしている」事をCPUへヒントとして伝える命令で、ビジーループにこの命令を挟んでおくことでCPUの実行効率を改善する効果があります。(詳しくは後述のコラムにまとめています。)
pause命令はオペランドが不要で、構文としては表3.12の通りです。
アセンブラ | pause |
---|---|
機械語 | f3 90 |
併せて、送信バッファが空になるのを待つ処理にも、pauseを挟んでおくことにします(リスト3.7)。
フラグ設定待ちのようなビジーループにpause命令を入れておくとCPUの実行効率低下を防ぐことができるのは、「メモリ順序違反(memory order violation)」を防ぐことができるためです。
インテル等のCPUには「投機的実行」と呼ばれる機能があり、現在実行している命令よりも先の命令をCPU内のバッファへ予め読み込み、場合によっては先に実行しておくことで、CPUの性能最適化を図っています。
ただ、条件付きジャンプ命令等、命令を評価し終えるまでどこへ分岐するか分からない命令があると、その先をどのように先読みするかが問題になります。
この時、インテル等のCPUでは「過去にどちらのコードパスを多く通ったか」等の統計値を元に、分岐先を推測して先読みを続行します。
しかし、今回のようにCPUとしてはほとんどの時間を「フラグ設定待ち」で過ごしていると、命令先読みのバッファは「フラグ設定待ちの処理」の先読みで埋め尽くされ、それらの処理が予め実行される形で最適化が行われます。これは、「次もきっとフラグ待ちだろう」という推測で最適化されている状況です。
そのため、いざフラグが設定された時に、推測が外れ、先読みしたバッファの内容や予め実行した処理もムダになり、実行効率が低下します。これを「メモリ順序違反」と呼びます。
pause命令があるとCPUがこのような誤った先読みをしなくなり、メモリ順序違反を防ぐことができます。
詳しくは、インテルのCPUマニュアルである「Intel 64 and IA-32 Architectures Software Developer's Manual(SDM)」の「Volume 2」の「4.3 INSTRUCTIONS (M-U)」にあるpause命令の解説をご覧ください。(本書末尾の「参考情報」にPDFが公開されているページのURLを載せています。)
次に受信バッファのデータを取得します。受信バッファのレジスタは「RBR(Receiver Buffer Register)」です。
IOアドレス | 0x03f8 |
---|---|
レジスタサイズ | 1バイト |
RBRのIOアドレスはTHRと同じです。同一のアドレスに対して、in命令による読み出す時は「RBR」へ、out命令による書き込み時は「THR」へ、となるように内部の回路が組まれています。
RBRからデータを読み出す処理を追加するとリスト3.8の通りです。
in命令の箇所では、受信バッファからALレジスタへ受信データを格納しています。ALレジスタはその他のin/out命令の箇所でも使用するため、ALへ格納したデータはmov命令でBLレジスタへコピーしています。
ここで、8ビットレジスタから8ビットレジスタへ値をコピーするmov命令の構文を紹介します。
アセンブラ | mov レジスタ(8bit,src), レジスタ(8bit,dst) |
---|---|
機械語 | 88 c0+reg_src_dst |
「reg_src_dst
」については表3.15の通りです。
src/dst | AL | CL | DL | BL | AH | CH | DH | BH |
---|---|---|---|---|---|---|---|---|
AL | 0x00 | 0x01 | 0x02 | 0x03 | 0x04 | 0x05 | 0x06 | 0x07 |
CL | 0x08 | 0x09 | 0x0a | 0x0b | 0x0c | 0x0d | 0x0e | 0x0f |
DL | 0x10 | 0x11 | 0x12 | 0x13 | 0x14 | 0x15 | 0x16 | 0x17 |
BL | 0x18 | 0x19 | 0x1a | 0x1b | 0x1c | 0x1d | 0x1e | 0x1f |
AH | 0x20 | 0x21 | 0x22 | 0x23 | 0x24 | 0x25 | 0x26 | 0x27 |
CH | 0x28 | 0x29 | 0x2a | 0x2b | 0x2c | 0x2d | 0x2e | 0x2f |
DH | 0x30 | 0x31 | 0x32 | 0x33 | 0x34 | 0x35 | 0x36 | 0x37 |
BH | 0x38 | 0x39 | 0x3a | 0x3b | 0x3c | 0x3d | 0x3e | 0x3f |
2章のimul命令で紹介した表とは異なり、各行が「第1オペランド(src)」で、各列が「第2オペランド(dst)」となっている点に注意してください。
受信バッファからデータを取得できたので、今度はそれを送信してみます(リスト3.9)。
特に新たな要素はありません。受信したデータはBLレジスタにあるため、それをALへコピーし、DXレジスタへ送信バッファアドレスを設定の上、out命令でシリアルポートへ送信しています。
これまで通り、ビルドし、動作確認してみてください。
入力したキーに対応する文字がシリアルの画面へ表示されます。
ただ、改行を入力すると、次の行へ移らずに行頭へ戻ってしまいます。
$ ../tools/run_qemu ../fs -nographic ... hogeo world ↑"hello world"と入力後、改行の後、 "hoge"と入力したが、次の行へ移らない。
これは、シリアルポートで受信する改行文字がCR(Carriage Return、復帰コード)のみであるためです。次の行への改行を行うLF(Line Feed)を送信していないため、改行キーを入力した際に、行頭に移動するのみで次の行へ移動しなかったのでした。
受信バッファから取得したデータを送信後、受信したデータがCR(0x0d)であった場合、LF(0x0a)を追加で送信するようにします(リスト3.10)。
新たに「cmp」命令が登場しました。これはオペランドで指定した数値の比較を行う命令です。挙動としては「第2オペランド(dst)を書き換えないsub命令」です。sub命令は第2オペランドから第1オペランド減算した結果を第2オペランドへ格納しますが、cmp命令は減算まで行ってフラグレジスタのみ変化させた後、減算結果は捨てます。
構文としては、cmp命令もsub命令同様、レジスタとしてALが対象の場合は1バイト少ない構文があります(表3.16)。
アセンブラ | cmp 即値(8bit), %al |
---|---|
機械語 | 3c 即値(8bit) |
ALを含むその他のレジスタについては表3.17の通りです。
アセンブラ | cmp 即値(8bit), レジスタ(8bit) |
---|---|
機械語 | 80 f8+reg_ofs 即値(8bit) |
追加した処理でやっていることとしては、送信処理後、cmp命令を使用して、ALレジスタに残っている送信済みの文字と0x0d(CR)を比較し、等しくなかった場合はjne命令で先頭まで戻り、等しかった場合は、以降のLF送信処理へ進みます。
LF送信処理は、BLへ0x0a(LF)を格納し、送信のためのシリアルポートステータス確認処理(*1)までジャンプするだけです。
この状態で動作確認してみると、改行まで反映されているはずです。
$ ../tools/run_qemu ../fs -nographic ... hello world hoge