1
Help us understand the problem. What are the problem?

posted at

updated at

makeとgccだけでOSを作ってみる

こんにちは.だいみょーじんです.
この記事は,自作OS Advent Calendar20日目の記事です.

対象読者:OS自作をやっていたり,興味がある人.特に30日でできる! OS自作入門(以下「30日本」)を読んだことがある人.

30日本をやってみて感じたもやもや

これまで多くの人々をOS自作の沼に陥れた30日本.
私もこの本の影響でOS自作にとりつかれた者のひとりです.
OS自作界の住人は誰しも心のどこかにハードウェアを完全に支配したいという欲求があるのではないでしょうか?
いや絶対にあると思います.
みなさんは30日本でOSを書いているときにこう思ったことはないですか?

  • なぜ著者が開発したツールを使わなければならないんだ!
  • なぜCの標準ライブラリを使わなければならないんだ!
  • なぜバッチファイルがあるんだ!(WindowsよりLinuxの方が好きなんで)

これではハードウェアを完全に支配しているとは言えない!
ということで,以下の条件でOSを作りたくなったわけですよ.

  • ビルドの際にはGNUツール群以外使用禁止
  • 自作OS内の命令列部分は全部自分で書く.要するにライブラリの使用禁止

やったことリスト

上の条件を満たしつつOSを作るためにやったことは以下の通り

  • マスターブートレコードを作成
  • フロッピーディスクのイメージを出力するツールを自作
  • Cのプログラムに飛ぶまでの部分をアセンブリで作成
  • Cのプログラムを書く
  • sprintfとかを自作
  • フォントをOSに埋め込むためのツールを自作
  • gdbでリアルモードデバッグ(おまけ)

ざっとこんな感じです.
順番に説明していきます.

マスターブートレコードを作成

マスターブートレコードは30日本でも解説されている,最初に0x7c00番地に読み込まれるあれです.
こんな感じにアセンブリで書きました.
30日本との大きな違いは,GNUアセンブラはデフォルトでAT&T記法になっており,30日本で使われるIntel記法と異なる点です.
GNUアセンブラも.intel_syntaxと書けばIntel記法で書けるそうですが,勉強のためにAT&T記法にチャレンジしてみました.
Intel記法とAT&T記法の一番の違いはオペランドの語順の違いです.
例えば「EAXにEDXを足す」はそれぞれ以下のようになります.

Intel記法
ADD EAX, EDX
AT&T記法
add %edx, %eax

今までIntel記法でしか書いたことがなかったので最初は面食らいましたが,Intel記法はEAX += EDXのように式として読む,AT&T記法はadd %edx to %eaxのように英語として読むように意識すれば簡単です.

あともうひとつ重要なこととして,リアルモードの機械語を出力するように指示する必要があります.
コードの中で.code16と書くと,それ以降の命令列はリアルモードの機械語で出力してくれます.

フロッピーディスクのイメージを出力するツールを自作

imagepackerは30日本のedimg.exeに相当するやつです.
こいつにマスターブートレコードと,フロッピーディスクに入れたいファイルたちを食わせると,FATやルートディレクトリ内のディレクトリエントリ構造体の配列を書き出しつつファイルをクラスタに分解・配置していって,フロッピーディスクのイメージを吐き出します.
こんな感じにCで書きました.
え?ライブラリ使ってるじゃんって?
いやこれは外部ツールであってOSに組み込まれるやつじゃないからいいの!(言い訳)
FATの仕様については30日本のほか,これが参考になりました.
あと,Cでマスターブートレコードの構造体を扱う際に,構造体内の各要素のオフセットが仕様で決まっている関係で,構造体内にパディングを入れないよう構造体の末尾に__attribute__((packed))をつける必要があります.
この方法はosdev-jpの初心者質問相談でuchan-nosさんに教えていただきました.(感謝)
osdev-jpに入ればOS自作ガチ勢に気軽に質問できるのでみんな入信しましょう!

マスターブートレコード構造体
typedef struct
{
    unsigned char jump_instructions[3];

    // The string doesn't end with '\0'.
    // Margin is filled with spaces.
    char product_name[8];

    unsigned short num_of_bytes_per_sector;
    unsigned char num_of_sectors_per_cluster;

    // Number of reserved sectors.
    // The boot record sectors are included in this value.
    // After the sectors, FAT sectors begin.
    unsigned short num_of_reserved_sectors;

    unsigned char num_of_FATs;
    unsigned short num_of_root_directory_entries;
    unsigned short num_of_sectors_in_disk;
    unsigned char media_type;
    unsigned short num_of_sectors_per_FAT;
    unsigned short num_of_sectors_per_track;
    unsigned short num_of_heads;
    unsigned int num_of_hidden_sectors;
    unsigned int large_num_of_sectors_in_disk;
    unsigned char drive_number;
    unsigned char reserved;

    // If it's 0x29, it's bootable;
    unsigned char boot_signature;

    unsigned int volume_serial_number;

    // The strings doesn't end with '\0'.
    // Margin is filled with spaces.
    char volume_label[11];
    char file_system_name[8];
} __attribute__((packed)) BootSector;

Cのプログラムに飛ぶまでの部分をアセンブリで作成

Cのプログラムに飛ぶまでにアセンブリで作成した以下のバイナリを順番に実行します.

  • マスターブートレコードの続きを主記憶にロードするloaddisk.bin
  • BIOSでハードウェアのメモリマップを取得するgetmemmp.bin
  • 画面モードを設定するinitscrn.bin
  • リアルモードからプロテクトモードに移行するmv2prtmd.bin
  • 0x7c00番地を起点にロードされたディスクイメージを0x00100000番地から始まる領域に移してカーネルのメインプログラムに飛ぶdplydisk.bin

ここはもう兎に角アセンブリでゴリゴリ書いてった感じです.
あと,リンカスクリプト(拡張子.ld)なるものを初めて書きました.
これはコンパイル済みのオブジェクトファイルをリンクする際の各オブジェクトの配置を記述するものです.
以下のようにメモリマップをglobal.ldに記述して,

global.ld
LOADDEST = 0x00007c00;     /* この番地を始点にディスクを読み込む */
LOADDISKADDR = 0x00004200; /* ディスク内のloaddisk.binの位置 */
LOADDISKSIZE = 0x00000600; /* ディスク内のloaddisk.binのサイズ */
GETMEMMPSIZE = 0x00000600; /* ディスク内のgetmemmp.binのサイズ */
INITSCRNSIZE = 0x00000a00; /* ディスク内のinitscrn.binのサイズ */
MV2PRTMDSIZE = 0x00000400; /* ディスク内のmv2prtmd.binのサイズ */
DPLYDISKSIZE = 0x00000400; /* ディスク内のdplydisk.binのサイズ */

さらにloaddisk.bin内のオブジェクトの配置をloaddisk.ldに記述します.

loaddisk.ld
OUTPUT_FORMAT("binary"); /* ELFヘッダを消す */

INCLUDE global.ld /* メモリマップの定義 */

BASE = LOADDEST + LOADDISKADDR; /* loaddisk.binの先頭番地 */

SECTIONS
{
    . = BASE;
    .text :
    {
        loaddisk.o(.text) /* 命令列 */
        loaddisk.o(.data) /* データ */
    }
    /DISCARD/:{*(.eh_frame)} /* 先頭に謎の領域が生成されないようにする*/
}

1行目でELFヘッダを消して先頭から.text領域が始まるようにするためにOUTPUT_FORMAT("binary");と書いてあります.
起動時に0x7c00番地を起点にフロッピーディスクの内容を主記憶にロードしているので,「0x7c00+フロッピーディスク内における実行ファイルの先頭位置」がその実行ファイルの先頭の番地になるようにベースアドレスを設定します.
実行ファイル先頭に命令列.textセクションを配置し,そのあとにデータ.dataセクションを配置します.
最後に,/DISCARD/:{*(.eh_frame)}と書いて.eh_frameという謎の領域が生成されないようにします.
あとは実行ファイルをimagepackerに食わせてフロッピーディスク上に配置するだけです.

下の図のように,フロッピーディスクにはマスターブートレコード,2つのFAT,ルートディレクトリ,loaddisk.binの命令列とデータ,その他のファイルが並んでいます.
起動すると0x7c00番地にマスターブートレコードを読み込んで実行し,ディスクの続きの領域を読み込んでloaddisk.binの命令列部分にジャンプします.
loaddisk.binではディスクのさらに続きの部分を読み込み,次のバイナリにジャンプし,カーネルのメインプログラムに到達するまでの各バイナリを実行していきます.

boot.png

Cのプログラムを書く

ここからカーネルのmain.cを作り始めます.
ポイントは,一切ライブラリを使っていないことと,ELFヘッダを消したことです.

gccは何もオプションをつけていないと勝手に標準ライブラリをリンクしやがります.
それを抑制するためにMakefileでgccにオプション-nostdlib-fno-builtin-fno-pieを渡すようにしています.
また,64ビットな環境でも32ビットの機械語を出力させるために,オプション-m32を渡しています.

ELFヘッダを消すために,リンカスクリプトkernel.ldを書いています.

kernel.ld
ENTRY(main); /* エントリポイントとなる関数を指定 */
OUTPUT_FORMAT("binary"); /* ELFヘッダを消去 */

BASE = 0x00106000; /* カーネルの先頭番地 */

SECTIONS
{
    . = BASE;
    .text :
    {
        main.o(.text)
                ... /* 以下各オブジェクトの.text領域,.rodata領域,.data領域,.bss領域を配置 */
    }
    /DISCARD/:{*(.eh_frame)}          /* 謎の領域が生成されないようにする */
    /DISCARD/:{*(.note.gnu.property)} /* 謎の領域が生成されないようにする */
}

先頭でエントリポイントの設定ENTRY(main);とELFヘッダの消去OUTPUT_FORMAT("binary");を指示し,各オブジェクトの.text領域,.rodata領域,.data領域,.bss領域を並べて,最後に謎の領域が生成されないように/DISCARD/:{*(.eh_frame)}/DISCARD/:{*(.note.gnu.property)}と書いてあります.

sprintfとかを自作

自作sprintf関数はここで実装しています.
sprintfの全ての機能を完全に実装しているわけではなく,必要な分だけ実装しています.
ポイントは,ライブラリの手を借りずに可変長引数を実現したことです.
呼び出し規約がcdeclであることを考慮して,アセンブリでスタックからn番目の引数を取得する関数を作りました.
sprintf関数の内部でこのget_variadic_argを呼び出すことで,sprintfに渡されたn番目の引数を取得できます.

n番目の引数を取得する関数
                # // get nth arg in variadic arg function
                # // the first arg is 0th
get_variadic_arg:       # unsigned int get_variadic_arg(unsigned int n);
0:
    pushl   %ebp
    movl    %esp,   %ebp
    pushl   %esi
    movl    (%ebp), %esi
    movl    0x08(%ebp),%edx
    movl    %ss:0x08(%esi,%edx,0x04),%eax
    popl    %esi
    leave
    ret

フォントをOSに埋め込むためのツールを自作

最後にmakefont.exeに相当するやつを作りました.
フォントのソースhankaku.txtを少しいじってbitmap.txtを作りました.
さてここからどうやってフォントをOSに組み込むかについてですが,1文字が縦16ピクセル横8ピクセルで,それがchar型で表現できる256種類あるわけで,それを1ピクセル1ビット,文字を16層にスライスしたときの1層が1バイト,1文字16バイト,256種類の文字で計4キビバイトのバイナリにまとめてカーネルにくっつければいいわけです.
ただ,このフォントは文字を表示するときにCのプログラムから参照するわけで,それならばバイナリにせずにC言語で扱えるような形にすれば使いやすくなると思いました.
そこで,bitmap.txtをC言語に変換するようなプログラムtranslator.cを作ったわけです.
人生で初めてC言語を出力するC言語を書きました.
変換結果はこんな感じ

fontdata.c
#include "font.h"

CharFont const font[0x100] =
{
    {{0000, 0x3c, 0x24, 0x24, 0x24, 0x24, 0x24, 0x3c, 0000, 0x3c, 0x24, 0x24, 0x24, 0x24, 0x24, 0x3c}},
    {{0000, 0x3c, 0x24, 0x24, 0x24, 0x24, 0x24, 0x3c, 0000, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20}},
    {{0000, 0x3c, 0x24, 0x24, 0x24, 0x24, 0x24, 0x3c, 0000, 0x3c, 0x20, 0x20, 0x3c, 0x04, 0x04, 0x3c}},
...

1ビットが1ピクセル,1バイトが文字の1層,1行が1文字のデザインを表していて,それが256行並んでいます.(長いので3行だけ載せてます.)

で,font.hからこのフォントバイナリにアクセスできるようにすれば,Cでフォントを扱えるようになるという仕組みです.

font.h
#ifndef _FONT_H_
#define _FONT_H_

#define CHAR_WIDTH 0x08
#define CHAR_HEIGHT 0x10

#define TAB_LENGTH 4

// width 8 pixels
// height 16 pixels
typedef struct
{
    unsigned char row[0x10];
} CharFont;

extern CharFont const font[0x100];

// return value
// 0 means background color should be put at the pixel(x, y) of the character
// 1 means foreground color should be put at the pixel(x, y) of the character
unsigned char get_font_pixel(unsigned char character, unsigned char x, unsigned char y);

#endif

gdbでリアルモードデバッグ(おまけ)

さてこうしてGNUツール群のみを使ってOSを作れるわけですが,オワコンレガシーBIOSを使ったOSはリアルモードで起動するため,開発の初期段階でリアルモードにおける動作をデバッグする必要があります.
QEMUをオプション-gdb tcp::<gdbと通信するポート番号>を付けてデバッガ待機状態で起動させ,続いてgdbを起動してgdbコマンドtarget remote localhost:<QEMUと通信するポート番号>を実行することでgdbからQEMU上で動くOSをデバッグできます.
リアルモード部分もこの方法で問題なくデバッグできますが,gdbのx/iコマンドでリアルモードの命令列を逆アセンブルする際に注意が必要です.
なんと,リアルモードの命令列であってもプロテクトモードの命令列として逆アセンブルしてしまい,正しいアセンブリを表示できないのです.
解決方法はここに記載されています.
具体的には,.gdbinittarget.xmli386-32bit.xmlを用意し,これらのファイルを置いた場所でgdbを起動するようにします.

.gdbinit
# tcp port
target remote localhost:<QEMUと通信するポート番号>

# real mode
set tdesc filename target.xml
target.xml
<?xml version="1.0"?>
<!DOCTYPE target SYSTEM "gdb-target.dtd">
<target>
 <architecture>i8086</architecture>
 <xi:include href="i386-32bit.xml"/>
</target>

i386-32bit.xmlここから入手できます.

これら3つのファイルを同じディレクトリに配置し,そのディレクトリからgdbを起動することで,gdbでx/iコマンドを実行した際に命令列をリアルモードの命令列として逆アセンブルできるようになります.
ただし,プロテクトモードの命令列もリアルモードの命令列として逆アセンブルしてしまうので,プロテクトモード移行後のデバッグでは.gdbinitの中でtarget.xmlを読み込んでいる部分をコメントアウトする必要があります.

まとめ

今回の記事は作成中の自作OSであるhariboslinuxを題材にしたものです.
linux上でビルドできるharibote OSという意味で,linuxではありません.(笑)
30日本でいえばまだ2週目で,これからどんどん実装していこうと思います.
使用するツールやライブラリに関して制限を設けることで,時間はかかっていますがとても勉強になっています.
また,最近はuchan-nosさんのみかん本を参考にレガシーフリーなOSとしてTHEOS(テオス)の開発にも挑戦してます.
なぜかFAT32のイメージを生成するプログラムから作ろうとしているのですが,こちらもゆっくり進めていく予定です.

参考文献

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
1
Help us understand the problem. What are the problem?