自作OS、始まります! #8
キーボード入力の改善
バッファリング対応
さて、#7のハンドラはグローバル変数に格納しており、これはよくないという話をしました。というのも、CPUの処理によっては入力された値を表示する前に次の割り込みが発生し、全ての入力を処理しきれない可能性があるからです。これを解決するために今回はリングバッファを用います。
スタックとキューとリングバッファ
誰もが認めるであろう難しいけど便利なものとしてC++のテンプレートがあげられると思います。実際、自分はほとんど理解出来ていないですが、そんな人でも簡単なデータ構造程度であればテンプレートを用いて作成できます。そしてこれが非常に便利なのです。
折角C++でOSを作っているわけですし、これを使わない手はありません。しかも、テンプレートで任意の型のデータ構造に対応させつつ、要素数は固定させるといったことも可能です。そのため、メモリ管理を使えないOS開発の初期段階においても存分に力を発揮してくれます。
まず、この記事での(というよりは要素数が固定の?)キューとリングバッファはほとんど同じものです。ただし、キューは要素数がいっぱいになった場合は何もしないけど、リングバッファは上書きをするという差を持たせています。スタックは普通のFILOです。
template <typename T, uint32_t N> class RingBuffer { public: RingBuffer() { write_pos = 0; read_pos = 0; count = 0; } void push(T push_data) { data[write_pos] = push_data; ++write_pos; ++count; if (write_pos == N) write_pos = 0; } void pop(void) { if (count == 0) return; ++read_pos; --count; if (read_pos == N) read_pos = 0; } T front(void) const { return data[read_pos]; } uint32_t size(void) const { return count; } bool empty(void) const { return !count; } private: T data[N]; uint32_t write_pos, read_pos, count; };
とりあえず使用しているリングバッファのコードのみ記載しておきます。といってもキューはpush時に要素数と最大値の比較をし、同じ場合はreturnをするif文を記述するだけです。スタックは積むだけなのでさらに簡単だと思いますのでいらないかと・・・
注意する点として、テンプレートを使う場合ヘッダファイルとソースファイルに分割するとコンパイルエラーが出てしまいます。使用したい型を明示的に記述すると一応解決できるのですが、それではあまりテンプレートを使う意味がないように思えます。ヘッダファイルに実装まで書いてしまえばこの問題は解決するので素直に記述しましょう。
リングバッファを使うように変更する
使用したいデータの型(intやcharなど)をTとして、確保したい要素数をNとして以下のように宣言すればこのリングバッファを使えるようになります。
RingBuffer<char, 100> key_buf; // char型で100個の要素を持ったリングバッファ /* 値の追加 */ key_buf.push(keycode); /* 値の取り出し */ char keycode = key_buf.front(); key_buf.pop();
そして上記のkey_bufを使ってハンドラを書き換えてみましょう。
void do_kbc_handler() { io_write_8(PIC0_OCW2_ADDR, 0x61); if (!(io_read_8(KBC_STATUS_ADDR) & KBC_STATUS_OBF)) { return; } char keycode = io_read_8(KBC_DATA_ADDR); key_buf.push(keycode); return; }
前回から少し変えてkeycodeの値自体を追加するようにしています。これは後の大文字小文字対応のためです。
関数的にも結構処理が短くできているように思われます。また、これによって100個までは値を取りこぼす心配がありません。どんな連打でもさすがに100個あれば足りるでしょう、試してはいませんが・・・もし足りない場合は要素数を増やせばいいので簡単ですね。
割り込み禁止は短く
リングバッファを使うことになったため、値を取り出すのは割り込み処理外となります。しかし、値を取り出そうとした瞬間に次の割り込みが発生しては大変困ります。そこで、cliという割り込み禁止命令を使って値を取り出す瞬間だけ割り込みが発生しないようにします。sti命令と同様にこれもアセンブリの関数です。
io_stihlt(); io_cli(); if(key_buf.empty()) continue; char keycode = key_buf.front(); key_buf.pop(); io_sti(); /* 取り出したkeycodeの処理 */
io_stihlt関数(sti命令で割り込みを許可した後にhlt命令でCPUを休ませてあげる関数)から処理が戻る時は、すなわち割り込みが発生した時です。そのため、一旦cli命令で割り込みを禁止し、確実にkeycodeの取得と削除を行ってからsti命令で割り込みを許可し、keycodeの処理に移るようにします。
これでずいぶん割り込みが短く、それでいて連続で発生しても対処できるようになりました。あとは大文字小文字だけです。
シフトの押下判定を取る
本来であればコントロールキー等もすべて対処したいところではありますが、現状コントロールキー使わないので・・・
キーボードを離した時にKBCから送られてくるキーコードは押した時の値+0x80です。そこで、シフトキーが押されたときにまずシフトが押されているかどうか(is_Shift)をtrueにし、離されたときにfalseにすればうまいことシフトキーの押下判定を取ることができそうです。
/* 取り出したkeycodeの処理 */ if(keycode & KBC_DATA_PRESSED){ keycode ^= KBC_DATA_PRESSED; if(keycode == Key_Shift_L || keycode == Key_Shift_R) is_Shift = false; continue; } if(keycode == Key_Shift_L || keycode == Key_Shift_R){ is_Shift = true; continue; } char write_data = is_Shift ? keymap_shift[keycode] : keymap[keycode]; putc(frame_buffer, write_data);
KBC_DATA_PRESSEDは0x80の値を持っていて、keycodeと&を取ると離された時かどうかがわかります。(&を取ってtrue、つまり1の場合keycodeも0x80のビットが立っているため、離されたときです。)そして、^、つまりxorを用いて0x80のビットをなくしてからシフトかどうかを確認します。このあたりの処理は特に何でもいい気がします、0x80を足したシフトのキーコードとの比較とかでも。キーコードがシフトの場合はis_Shiftをfalseに、そうでない場合は何らかのキーが離されただけなので何もしません。
次に、キーが押された場合の処理ですが、これは単純に押されたキーがシフトかどうかを見るだけです。シフトの場合はis_Shiftをtrueにし、そうでない場合はkeycodeに対応した値を取得、表示させます。このkeymapは頑張って作りました。シフトが押されている時または押されていない時に出したい値を羅列しただけです。
これで大文字も小文字も!なんかも出せるようになったはずです。試してみましょう。
ちゃんとシフトを押している間だけ大文字になってくれています。しかも!なんかもふつうに入力できています。
ちなみに、押したキーによっては変な値が出たりしますが・・・とりあえず気にしないでおきましょう。
次回以降の目標
割とサクッとやりたかった部分が実装出来てウキウキで記事書いてました。次回以降、どうしましょうか・・・描画をsheet構造体に対応させたいですが、そのためにはメモリ管理が必須です。しかし、メモリ管理は現状OSの講義で知識として習っただけのページングが必須。まずは勉強からになりそうですね。
自作OS、始まります! #7
割り込みをしたい
前回、ついにローダーが完成していよいよOSの自作が始まったわけで、とりあえずまずはキーボードで入力するところからかなと。
幸いにも文字を描画する部分については#5でもう作っているので、今回やるべきことは割り込みを受け付けるためのGDT/IDT初期化、PIC初期化(今時はAPICが望ましいですがとりあえずしばらくはPICを使います。)、キーボード割り込み処理ハンドラの作成あたりでしょうか。
GDT初期化
とはいっても作っているのは64bitのOS、セグメントは使わないので申し訳程度の初期化です。
今回は「フルスクラッチで作る!x86_64自作OS」を参考にほとんど同じ内容を実装しただけなのでそちらを参照してください。
IDT初期化
IDT(Interrupt Descriptor Table)とはある割込みに対してどの関数を呼び出すか、を記述する表のようなものです。
まずは必要な構造体と初期化関数です。
typedef struct { uint16_t offset_low, selector; uint8_t ist, type_attr; uint16_t offset_middle; uint32_t offset_high, zero; } INTERRUPT_DESCRIPTOR; INTERRUPT_DESCRIPTOR descriptors[256]; void init_idt() { for (unsigned int i = 0; i < 256; ++i) { set_interrupt_handler(i, default_handler); } unsigned long long idtr[2]; idtr[0] = (reinterpret_cast<unsigned long long>(descriptors) << 16) | (sizeof(descriptors) - 1); idtr[1] = (reinterpret_cast<unsigned long long>(descriptors) >> 48); load_idt(idtr); } .global load_idt load_idt: lidt [rcx] ret
IDTの実装はINTERRUPT_DESCRIPTOR構造体が256エントリ存在する配列で、それぞれに対して呼び出したいハンドラ関数を設定しなければなりません。
とはいえ、まず使いたいのはキーボードの割り込みであるため、一旦全て同じ関数(default_handler:hltするだけ)を設定し、後から必要なハンドラを個別に設定していくことにします。
また、IDTの設定には通常の命令ではなくlidtという特殊な命令を使う必要があり、これはCやC++では書けないためアセンブリで書く必要があります。(load_idt関数)
次に、ハンドラを設定するためのset_interrupt_handler関数です。
void set_interrupt_handler(unsigned int idt_no, __attribute__((ms_abi)) void (*handler)()) { descriptors[idt_no].offset_low = reinterpret_cast<unsigned long long>(handler); descriptors[idt_no].selector = 0x0008; descriptors[idt_no].type_attr = 14; descriptors[idt_no].type_attr |= 0b10000000; descriptors[idt_no].offset_middle = reinterpret_cast<unsigned long long>(handler) >> 16; descriptors[idt_no].offset_high = reinterpret_cast<unsigned long long>(handler) >> 32; }
idt_noは割込み番号で、例えばキーボードの割り込みなら0x21を使用しています。先ほどの構造体を見ればわかりますが、設定方法が結構複雑です・・・
offset_○○には設定したいハンドラのアドレス64bitを上位32bit、真ん中16bit、下位16bitに分けて入れます。selectorはハンドラの所属しているセグメントの番号を格納します。(ただし下位3bitを0にする必要があるため、実際には番号を3bit左シフトしたものです。今回で言えば1 << 3で8となっています。)
type_attrはtypeとattributeを合わせて持っている変数で、14が割り込みまたは例外であることを示すディスクリプタタイプです。そして8bit目のpフラグと呼ばれる部分がディスクリプタが存在しているかどうか(Present)を表すフラグで、type_attr |= 0b10000000とすることでビットを1にしています。
書き忘れていましたが、引数のvoid (*handler)()は引数無しの関数ポインタです。ここに設定したいハンドラを記述した関数を渡すことで設定します。
PIC初期化
PICはProgrammable Interrupt Controllerの略でCPUに割り込み発生を通知してくれます。 今更PICについて書くことはないそうなので、自分も特にないです。やってることも30日OSと同様に初期化しているだけです。
void init_pic() { io_write_8(PIC0_ICW1_ADDR, 0x11); io_write_8(PIC0_ICW2_ADDR, 0x20); io_write_8(PIC0_ICW3_ADDR, 0x04); io_write_8(PIC0_ICW4_ADDR, 0x01); io_write_8(PIC0_OCW1_ADDR, 0xff); io_write_8(PIC1_ICW1_ADDR, 0x11); io_write_8(PIC1_ICW2_ADDR, 0x28); io_write_8(PIC1_ICW3_ADDR, 0x02); io_write_8(PIC1_ICW4_ADDR, 0x01); io_write_8(PIC1_OCW1_ADDR, 0xff); } void set_pic(unsigned int idt_no) { if (idt_no < 0x28) { unsigned char idt_set_bit = 1U << (idt_no - 0x20); if (!(io_read_8(PIC0_IMR_ADDR) & idt_set_bit)) return; io_write_8(PIC0_OCW1_ADDR, io_read_8(PIC0_IMR_ADDR) - idt_set_bit); } else { unsigned char idt_set_bit = 1 << (idt_no - 0x28); if(!(io_read_8(PIC1_IMR_ADDR) & idt_set_bit)) return; io_write_8(PIC1_OCW1_ADDR, io_read_8(PIC1_IMR_ADDR) - idt_set_bit); } }
set_picは指定したIRQのビットを0にする関数で、一応マスターとスレーブに対応できているはずです。(マウスの割り込みを試すまで正しいかはわからないのですが・・・)
io_write_8はout命令を用いて指定したアドレスに8ビットのデータを書き込む関数でアセンブリによる実装です。ついでにio_read_8は逆にin命令で8ビットのデータを読み込む関数です。
.global io_write_8 io_write_8: mov eax, edx mov edx, ecx out dx, al ret .global io_read_8 io_read_8: mov edx, ecx xor rax, rax in al, dx ret
KBC初期化
KBCはキーボードコントローラーのことで、KBCがPICに割り込みを通知、PICがCPUに割り込みの通知を通知することで割り込みが発生します。このKBCにレジスタ経由でアクセスすることで、キーボードが押されているかどうかや、どのキーが押されているかといった情報を取得することができます。
.global int21_handler int21_handler: push rdi mov rdi, 0x21 call handler_wrapper pop rdi iretq .global handler_wrapper handler_wrapper: push rax push rcx push rdx push rbx push rbp push rsi call do_default_handler pop rsi pop rbp pop rbx pop rdx pop rcx pop rax ret
int21_handlerがキーボードの割り込み時に呼び出したいハンドラです。handler_wrapperを見るとよくわかるのですが、割り込み発生時はレジスタをスタックにpushして値を保存しておく必要があります。int21_handler内に全部羅列してもいいのですが、それだと今後ハンドラが増えていったときに冗長なため、まず対応したハンドラを呼び出した時に割込み番号だけ引数に設定し、handler_wrapperを呼び出す形にしました。これなら今後ハンドラを増やすことになっても番号を変更するだけで済みますからね。また、割り込みから戻る際には通常のret命令ではなくiretq命令を使用する必要があります。handler_wrapperはハンドラとして設定することはないためret命令で終わっていますが、int21_handlerはiretq命令で終了します。
そして、上記のハンドラから呼び出すdo_default_handler関数は、割り込み番号を引数のレジスタ(rdi)にセットしているのでそれを使って対応した関数を呼び出すようにします。
extern "C" { void do_default_handler(unsigned int handler_no) { switch (handler_no) { case 0x21: do_kbc_handler(); break; default: break; } } }
現状割り込みを発生させるのはキーボードのみなので0x21の場合だけ書いておきましょう。extern "C"でC言語として実装していますが、これがないとアセンブリから関数を呼び出すのがはるかに難しくなります。というのもC++の関数の場合マングルを考えたうえで呼び出す関数のシンボル名を記述する必要があるからです。やってる内容は特にC++の機能が必要ではないのでCで書いてしまえばいいでしょう。もし必要ならC++の関数を呼び出すCの関数でもいいと思います。
最後はキーボードのハンドラです。
void do_kbc_handler() { if (!(io_read_8(KBC_STATUS_ADDR) & KBC_STATUS_OBF)) { io_write_8(PIC0_OCW2_ADDR, 0x61); return; } unsigned char keycode = io_read_8(KBC_DATA_ADDR); if (keycode & KBC_DATA_PRESSED) { io_write_8(PIC0_OCW2_ADDR, 0x61); return; } g_chara = keycode_map[keycode]; io_write_8(PIC0_OCW2_ADDR, 0x61); return; }
keycode_mapはキーボードを押した時に来るキーコード(例えば'A'なら0x1eなど)を実際の文字に変換するための配列です。keycode_map[0x1e]→'A'というように値を返してくれます。ただ、このキーコードはそれぞれのキーボードによって微妙に変わる可能性があるので、その場合は自分でキーコード見ながら配列を実装する必要があります・・・(私はしました・・・)
また、今回は簡単に実装したかったのでグローバル変数に格納してしまっていますが、これは非常によくないです。次回、バッファリングに対応させる予定です。それと割り込みを禁止させる部分も追加しないとですね・・・
それぞれの初期化をまとめる
上記4つの初期化をカーネルロード後に呼び出すためにinit_kernel関数に入れておきます。気をつけることとしては初期化の順番くらいでしょうか。
void init_kernel() { init_gdt(); init_idt(); init_pic(); init_kbc(); }
思いのほか実装しなければならない部分が多かったのですが、これで割り込みに対応できたはずです。
いざ、割り込み!
今回はグローバル変数に入力された値を格納しているだけなので、無限ループで割り込みが来たら変数の値を出力するという形で試してみます。
while (1) { io_stihlt(); putc(frame_buffer, g_chara); g_chara = 0; }
io_stihlt()はsti命令を実行した後hlt命令を行うアセンブリ関数です。sti命令は割り込み許可を行う命令で、逆に割り込みを禁止する命令はcli命令です。(今回は使っていませんが、次回使う予定です。)
sti命令で割り込みを許可した後であればhlt命令で停止してしまっても割り込みによってまた動いてくれるのでCPUの節約になります。
後はお祈りしながらmake runするだけです。
まだ大文字と小文字を区別できていないので全て大文字になってしまっていますが、キーボードの割り込み処理には対応できています!
次回以降の目標
次回は割り込み禁止部分の設定、大文字小文字の対応(シフトキーの対応)、バッファリングの対応などでしょうか。それが終わったら・・・はまたそのうち考えます。
自作OS、始まります! #6
ローダー自作
今になって思えば、ローダーを自作しようと思った時点で沼でした・・・
結局開始から3週間かかってなんとかローダーとして完成した、という感じです。ただ、とにかく知らない知識が多くて、逆に大量の知識を得ることができました。
メモリマップを見てみる
カーネルをロードするといっても適当にメモリに配置すればいいというわけではないので、まずはメモリマップを見て空いているメモリを探します。UEFIを使う場合、BIOS画面から選択できるBoot Manager内のUEFI Internal Shellでmemmapと入力することで確認できます。
TypeがAvailableとなっている部分が使用可能なメモリ空間で、StartアドレスからEndアドレスまでで一つの空間です。見た限りでは0x100000から0x7FFFFFまでが比較的大きく使用可能なのでここにカーネルを配置することにします。また、ELFファイルの中身はそのまま0x1100000から0xBBFFFFFFに一時的に配置し、必要なもののみ0x100000にコピーすることにします。
ELF形式について学ぶ
ローダーを書く前に、まずこのELF形式というファイル形式について知っておく必要があります。(ELFでないカーネルをロードする上ではおそらく必要ありませんが。)ちなみに自分は全く知らなかったので一からお勉強することになりました。
とはいえ色々な方々の解説記事が多くある今、情報を得るのにはそこまで苦労はしません。もし書籍で探す場合は「リンカ・ローダ実践開発テクニック」が非常に詳しく書いていると思います。(他の本は読んでないのでわかりません、ごめんなさい。)
※また、主要な構造体等は下記にも書きますが必要な値を全て書くわけではないので、適宜man elf
等で確認してください。
ELFとはExecutable and Linkable Formatの略で、LinuxなどのUNIX系OSで採用されている実行フォーマットです。実際の変数の値やコードのほかに、ヘッダというそれらの情報を格納したエリアがコンパイラによって挿入されます。
ヘッダにはELFヘッダ、プログラムヘッダ、セクションヘッダが存在し、それぞれ役割が違います。
ELFヘッダ
ELFヘッダはファイルの先頭に挿入され、そのプログラム全体の情報が格納されます。試しに、自分のカーネルのELFヘッダを以下に貼り付けます。これはLinux環境の場合端末でreadelf -h ○○.ELF
と打てば見ることができます。
ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: DYN (Shared object file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x0 Start of program headers: 64 (bytes into file) Start of section headers: 171776 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 5 Size of section headers: 64 (bytes) Number of section headers: 15 Section header string table index: 13
また、自分で書いたコード内で上記の内容を得たい場合、以下の構造体を使うことで解決します。
typedef struct { unsigned char e_ident[EI_NIDENT]; uint16_t e_type; uint16_t e_machine; uint32_t e_version; Elf64_Addr e_entry; Elf64_Off e_phoff; Elf64_Off e_shoff; uint32_t e_flags; uint16_t e_ehsize; uint16_t e_phentsize; uint16_t e_phnum; uint16_t e_shentsize; uint16_t e_shnum; uint16_t e_shstrndx; } Elf64_Ehdr;
0x1100000アドレスにELFの全てのデータをReadした場合、上記の構造体を用いて以下のようなコードで取得できます。
unsigned long long kernel_addr = 0x100000lu; unsigned long long kernel_tmp_addr = 0x1100000lu; /* ここでkernel_tmp_addrにカーネルデータを読み込み */ Elf64_Ehdr *elf_header = reinterpret_cast<Elf64_Ehdr *>(kernel_tmp_addr);
プログラムヘッダ
プログラムヘッダはELFヘッダの後ろに挿入され、各セグメントの属性や読み込み先が格納されています。readelf -l ○○.ELF
で見ることができます。
Elf file type is DYN (Shared object file) Entry point 0x0 There are 5 program headers, starting at offset 64 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align LOAD 0x0000000000001000 0x0000000000000000 0x0000000000000000 0x00000000000008e0 0x00000000000008e0 R E 0x1000 LOAD 0x00000000000018e0 0x00000000000108e0 0x00000000000108e0 0x0000000000028120 0x0000000000028120 RW 0x1000 DYNAMIC 0x0000000000029940 0x0000000000038940 0x0000000000038940 0x00000000000000c0 0x00000000000000c0 RW 0x8 GNU_RELRO 0x0000000000019918 0x0000000000028918 0x0000000000028918 0x00000000000100e8 0x0000000000011000 R 0x1 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RW 0x0 Section to Segment mapping: Segment Sections... 00 .text .dynsym .gnu.hash .hash .dynstr .rela.dyn .eh_frame 01 .data .got .dynamic 02 .dynamic 03 .got .dynamic 04
対応する構造体は次の構造体です。
typedef struct { uint32_t p_type; uint32_t p_flags; Elf64_Off p_offset; Elf64_Addr p_vaddr; Elf64_Addr p_paddr; uint64_t p_filesz; uint64_t p_memsz; uint64_t p_align; } Elf64_Phdr;
ELFヘッダを取得できると、その内部のe_phoffを用いてプログラムヘッダの位置がわかるため、プログラムヘッダ一覧を取得することができます。
Elf64_Phdr *elf_program_headers = reinterpret_cast<Elf64_Phdr *>(kernel_tmp_addr + elf_header->e_phoff);
この時、elf_program_headersは複数のプログラムヘッダの先頭を指している状態なので、それぞれのプログラムヘッダを見たい場合は以下のようにして取り出すことができます。
Elf64_Phdr program_header = elf_program_headers[i];
これで全てのプログラムヘッダを見る準備が整ったので、それぞれのセグメントを必要に応じてカーネル本体を置く予定のメモリ番地に移動させます。ここで配置するべきセグメントはp_typeがPT_LOADとなっているものです。
for (unsigned int i = 0; i < elf_header->e_phnum; ++i) { Elf64_Phdr program_header = elf_program_headers[i]; if (program_header.p_type != PT_LOAD) continue; memcpy(reinterpret_cast<void *>(kernel_addr + program_header.p_vaddr), reinterpret_cast<void *>(kernel_tmp_addr + program_header.p_offset), program_header.p_filesz); memset( reinterpret_cast<void *>(kernel_addr + program_header.p_vaddr + program_header.p_filesz), 0, program_header.p_memsz - program_header.p_filesz); }
ELFヘッダ内のe_phnumがプログラムヘッダの数を格納しているため、その値だけforループを回し、それぞれのプログラムヘッダを取得します。そして、各プログラムヘッダのtypeを確認し、必要に応じてメモリに配置します。
また、ELFではファイルサイズが必ずしもメモリサイズと同じ値になっているとは限らないため、ファイルサイズ分memcpyした後、メモリサイズ-ファイルサイズ分memsetで0を埋めてます。ちなみにmemcpyとmemsetの引数は以下です。
memset(void *dst, void *src, int size); memcpy(void *dst, int value, int size);
これでプログラムヘッダ周りは終了です。(多分)
セクションヘッダ
最後はセクションヘッダです。これはプログラムヘッダとは違い、セクションごとに存在し、ファイル内の末尾に挿入されます。readelf -S ○○.ELF
で確認できます。今回は主にグローバル変数を処理する際に必要となる再配置で使います。
There are 15 section headers, starting at offset 0x29f00: Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .text PROGBITS 0000000000000000 00001000 0000000000000683 0000000000000000 AX 0 0 16 [ 2] .dynsym DYNSYM 0000000000000688 00001688 0000000000000018 0000000000000018 A 5 1 8 [ 3] .gnu.hash GNU_HASH 00000000000006a0 000016a0 0000000000000018 0000000000000000 A 2 0 8 [ 4] .hash HASH 00000000000006b8 000016b8 0000000000000010 0000000000000004 A 2 0 4 [ 5] .dynstr STRTAB 00000000000006c8 000016c8 0000000000000001 0000000000000000 A 0 0 1 [ 6] .rela.dyn RELA 00000000000006d0 000016d0 0000000000000078 0000000000000018 A 2 0 8 [ 7] .eh_frame PROGBITS 0000000000000748 00001748 0000000000000198 0000000000000000 A 0 0 8 [ 8] .data PROGBITS 00000000000108e0 000018e0 0000000000008037 0000000000000000 WAMS 0 0 16 [ 9] .got PROGBITS 0000000000028918 00019918 0000000000000028 0000000000000000 WA 0 0 8 [10] .dynamic DYNAMIC 0000000000038940 00029940 00000000000000c0 0000000000000010 WA 5 0 8 [11] .comment PROGBITS 0000000000000000 00029a00 0000000000000049 0000000000000001 MS 0 0 1 [12] .symtab SYMTAB 0000000000000000 00029a50 0000000000000240 0000000000000018 14 2 8 [13] .shstrtab STRTAB 0000000000000000 00029c90 0000000000000072 0000000000000000 0 0 1 [14] .strtab STRTAB 0000000000000000 00029d02 00000000000001fc 0000000000000000 0 0 1 Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings), I (info), L (link order), O (extra OS processing required), G (group), T (TLS), C (compressed), x (unknown), o (OS specific), E (exclude), l (large), p (processor specific)
対応する構造体は次の構造体です。
typedef struct { uint32_t sh_name; uint32_t sh_type; uint64_t sh_flags; Elf64_Addr sh_addr; Elf64_Off sh_offset; uint64_t sh_size; uint32_t sh_link; uint32_t sh_info; uint64_t sh_addralign; uint64_t sh_entsize; } Elf64_Shdr;
今回は以下のようなコードで使用しています。
Elf64_Shdr *elf_section_headers = reinterpret_cast<Elf64_Shdr *>(kernel_tmp_addr + elf_header->e_shoff); Elf64_Shdr symtab_sections; for (unsigned int k = 0; k < elf_header->e_shnum; ++k) { Elf64_Shdr search_section = elf_section_headers[k]; if (search_section.sh_type != SHT_SYMTAB) continue; symtab_sections = search_section; break; } for (unsigned int i = 0; i < elf_header->e_shnum; ++i) { Elf64_Shdr section_header = elf_section_headers[i]; if (section_header.sh_type != SHT_RELA) continue; for (unsigned int j = 0; j < section_header.sh_size / section_header.sh_entsize; ++j) { Elf64_Rela *rela = (Elf64_Rela *)(kernel_tmp_addr + section_header.sh_offset + section_header.sh_entsize * j); Elf64_Sym *target_sym = nullptr; for (unsigned int k = 0; k < symtab_sections.sh_size / symtab_sections.sh_entsize; ++k) { Elf64_Sym *search_sym = (Elf64_Sym *)(kernel_tmp_addr + symtab_sections.sh_offset + symtab_sections.sh_entsize * k); if (search_sym->st_value != static_cast<unsigned long long>(rela->r_addend)) continue; target_sym = search_sym; break; } unsigned long long *memto = (unsigned long long *)(kernel_addr + rela->r_offset); *memto = kernel_addr + target_sym->st_value; } }
プログラムヘッダの時と同様にそれぞれのセクションヘッダをforループで取得し、sh_typeを確認します。先に、後で使うために.symtabセクションを取得しておきます。このセクションには再配置する際に使うシンボル名やそのアドレスが入っています。
次に、.rel.○○セクションまたは.rela.○○セクションが再配置する必要があるため、この部分だけを選びます。(なお、上記で再配置しているのはrelaだけです。)そして、そのセクションのサイズを各要素のサイズで割ることでRELAセクションの要素数を計算します。その後、RELAに対応するシンボルを先ほど取得しておいた.symtabセクションから探します。シンボル名が見つかったらあとは再配置してあげればOKです。
再配置で行うことは、.dataセクション内に存在するグローバル変数の実体に対するアドレスを適切な位置に格納することです。アドレスはkernel_addr + target_sym->st_value
で、格納先はkernel_addr + rela->r_offset
で取得できるので、後はポインタを使って値を格納すれば終了です。
ELF形式のカーネルを作成する
さて、今回ロードするカーネルとして、ELF形式でPIC(Position-Independent Code)というメモリ上の位置に依存しないで実行可能なバイナリを用います。
自分の環境(clang++)では--target=x86_64-unknown-none-elf
と-fPIC
と-Wl,pie
のオプションをつけると上記のバイナリとしてコンパイルできました。(色々試してたらできたので、これらすべてが必要かは定かではありません・・・)
また、以下のようなリンカスクリプトも用意しました。といっても各セクション間に適当なメモリ容量を開けておき、関数のエントリーを指定するだけのものですが。(実際bss領域は今のところ使っていませんけど、気にしないでください)
ENTRY(kernel_start) SECTIONS { .text : { *(.text) } . += 0x10000; .data : { *(.data) *(.rodata) } . += 0x10000; .got : { *(.got) } . += 0x10000; .bss : { *(.bss) } }
ロードする
ここまででローダーもELF形式のカーネルも用意できたので、前回のようにExitBootServices関数を呼び出してエントリポイントへジャンプすればカーネルが起動するはずです。
見た目は何も変わってないですが、内部では大きな進歩です。
ちなみにエントリポイントへのジャンプは以下のアセンブリ関数で行っています。
void kernel_start(BootStruct* boot_struct); // 呼び出したいカーネルのエントリー jump_entry(&boot_struct, entry_point, stack_pointer); // C++でのアセンブリ呼び出し .global jump_entry jump_entry: push rbp mov rbp, rsp mov rsp, r8 mov rdi, rcx call rdx mov rsp, rbp pop rbp ret
ローダーの時とカーネルの時でアーキテクチャが異なるので、引数を入れるレジスタが変わります。具体的にはローダー時にはrcx, rdx, r8, r9という順であったレジスタが、カーネル時にはrdi, rsi, rdx, rcxという順になります。
そこで、kernel_startに渡したいboot_structはmov命令でrcxからrdiレジスタへ移し、stack_pointerはそのままrspに移しています。そして、エントリポイントのアドレスは渡す必要が無いため、そのままcall命令で使うことでカーネルにジャンプしています。ちなみにstack_pointerの値は適当に決めました。
次回以降の目標
目標も何も、ようやく開発の開始ですね。
実はブート時にやるべきことはまだまだ残っていますが、開発していくうえで必要に応じてやっていきましょう・・・
とりあえず当面の目標ははりぼてOSの進み方に合わせようかなとは思っています。
30日でできる!OS自作入門(Day 13)
タイマの続き
今日もタイマをやるのですが、どちらかというとタイマそのものではなくその裏の割り込み処理とかを改善する、という感じでしたかね・・・
性能測定
ただコードを改善するだけでは本当にそれが速度も改善で来ているかはわかりません。そこで、実際に数値を比較することで改善を定量的に示すために、ベンチマークテストのようなものを作成します。といっても現状複雑なことをしているわけではないため、10秒間ひたすらcount変数をインクリメントし続け、タイムアウト時にこの値を見てみることでどれだけ値が増えているかで確認します。
ちなみにタイマの開始直後は値にぶれが大きいため、開始数秒後(本では3秒後)に一旦値を0にリセットして、そこから測定を開始するようです。(なので実際は7秒間ですね)
とりあえず上記で数回計測を行って、平均を取ればその時の実装における性能がわかるという流れですね。
FIFOバッファを見直す
現状、キーボード・マウス・タイマの割り込みはそれぞれ別のFIFOを使っています。そのため、割り込みがあるかどうかはとりあえず全てのFIFOを確認しなければならず、無駄が多くなります。3種類どれにでも対応できるような汎用性の高いFIFOを作成できればFIFOは1つだけで済むはずで、実際そのようなFIFOは作れます。
その値が何を表しているかを判定できればいいため、一定値オフセットを用意してあげるだけで解決できます。例えば、キーボードの値は常に256を足した状態で入れたり、マウスの値はキーボードの範囲よりも上になるように512を足すといった具合です。あとは処理するタイミングでそのオフセット分を引けば問題ありませんね。
ちなみに、この変更だけで実機でも1.3倍ほど性能が上がるようです。
続・割り込み処理は短く
FIFO自体の性能は上記のおかげで上がりましたが、そもそもFIFOはタイマ向きではありません。それは、タイマを新規に作成する際に正しい順番にいれた後、それ以外をずらす処理を行う必要があるからです。しかも、この新規作成は割り込みを禁止にした状態で行うので割り込み処理の速度にも影響がありそうです。
線形リスト
というわけでついに線形リストの登場です。(双方向ではないやつ)
線形リストではそれぞれの構造体(別に構造体でなくてもいいですが、簡単のため)の中に次の構造体へのポインタを持たせることで、ずらし処理が高速に行えるようになります。ずらし処理は次の変数へのポインタを付け替えてあげるだけでよくなるからです。
いつ見てもこれを思いついた人は天才に見えますよね。普通次へのポインタを持たせるなんて考えないと思うんですが・・・
番兵を置く
現状、タイマを登録する際に、
・タイマが1つだけ
・先頭に入れる
・途中に入れる
・末尾に入れる
と4通りそれぞれを考えるようになっていて、非常に無駄が多いです。これを解決してくれるのが番兵くんです。
番兵として一番末尾に常に絶対に到達しないであろう時間を設定したタイマ(到達しそうになってもリセットすればいいわけですが)を配置すると、上記の4つがなんと2つまで減らせるのです。(1つだけになる可能性がなく、末尾に入れる可能性もないため)
これでずいぶんとタイマの新規登録が早くなったはずです。
性能測定
ほんとは14日目の内容ですが、流れで見てしまいましょう。
実は、上記と同じ条件で測定してみると数値がほとんど変わりません。というのも、新規登録の特にずらし処理を高速化したわけで、実際にそういった環境で測定してみないと変化がわかりづらいのです。
まぁ、実際はずらし処理をたくさん行ってみてもそこまで大きな性能向上は得られないのですが(インクリメントが1000回くらい大きくなる程度)、しかし割り込み処理を素早く行えるかどうかは非常に大きな問題のため、こういった小さな改善でも大きな成果といえるのです。
Day 14へ
これでひとまずタイマは終了です。明日は少し毛色が変わって高解像度に対応させることとキー入力を文字で表示させるようです。それが終わったらいよいよマルチタスクなのでサラっと進みたいところですね。
自作OS、始まります! #5
始まります
OSの自作が
さて、いきなりですが今まで書いてきたものは自作OSではありませんでした。(今回も自作OS色はないですが。)言うなればUEFIアプリケーションですかね。
いよいよカーネル部分を書き始めたので、ついにタイトル通りの自作OS、始まります!というわけです。
ちなみにまだブートローダーは完成していません。。。
あ、前回で掲げた目標のファイル周りは次回以降(多分)でローダー部分を書く際に使うので、そこで書く予定です。
ブートローダーとしてのUEFI
BootServicesとRuntimeServices
これまではUEFIを疑似的なOSとして扱ってきました。そのため、実装してきたプロトコルを呼び出すだけで文字の描画やキーボード入力など、色々なことを行うことができました。それらは基本的にBootServicesと呼ばれる、カーネルを呼び出す前に使うことのできる関数群です。しかし、ブートローダーとしてUEFIを使う場合、カーネルをブートして処理を移すとそれらは使えなくなり、以降使えるのはRuntimeServicesにある関数群だけになります。
当然キーボードやマウスの入力のためには割り込み処理を自分で書く必要がありますし、メモリ管理や画面の描画に至るまでほぼ全てを自分で書く必要があります。逆に言えば、これらを自分で書いてこその自作OSという話ですね。楽しみです。
ExitBootServices
上記のBootServicesの環境からRuntimeServicesの環境に移すために呼び出す関数がExitBootServices関数です。この関数を呼び出した後(厳密には呼び出しに成功した後)はRuntimeServicesのみ使用できるようになります。
このExitBootServices関数ですが、適切に呼び出さないと失敗する場合があるそうです。参考↓
ExitBootServices関数を呼び出してRuntimeServicesの環境に移った後は自作したカーネルをメモリにロードして、カーネルのエントリーポイントへジャンプすればブートローダーとしてのUEFIの役割は終了です。
とりあえず文字を出す
まぁ、初歩として典型ですね。とりあえずUEFIにあったSIMPLE_TEXT_OUTPUT_PROTOCOL内の関数に頼らない文字描画から始めていきます。
画面に描画する
BootServices環境時に使ったGRAPHICS_OUTPUT_PROTOCOLですが、この中のMode->FrameBufferBaseに描画する画面の先頭アドレスが取得されています。そして、カーネルに対してこのアドレスさえ渡しておけばRuntimeServices環境でも同様に画面に描画することが可能です。
ある(x, y)という座標に対する描画は以前のdraw_pixel関数とほとんど同様の実装で行えます。
typedef struct { unsigned char Blue; unsigned char Green; unsigned char Red; unsigned char Reserved; } PixelFormat; typedef struct { unsigned long long *FrameBufferBase; unsigned long long FrameBufferSize; unsigned int ResolutionH; unsigned int ResolutionV; } FrameBuffer; void draw_pixel(unsigned int x, unsigned int y, PixelFormat color, FrameBuffer *fb) { PixelFormat *base = reinterpret_cast<PixelFormat *>(fb->FrameBufferBase); PixelFormat *point = base + (fb->ResolutionH * y) + x; point->Blue = color.Blue; point->Green = color.Green; point->Red = color.Red; point->Reserved = color.Reserved; }
上記におけるResolutionHやResolutionVも同様にBootServices環境におけるMode->Info内にあるので、BootService環境時に保存しておきます。
フォントの準備
はりぼてOSでも使われているフォントを使用させていただきました。今回はfont.hとしてソースコードに直接埋め込む形を取りました。こうしておくことで以下のようなコードで文字を描画できるようになります。
void puts_font(FrameBuffer *fb, char *str) { int basex = 1, basey = 1; for (; *str != '\0'; ++str) { if (*str == '\n') { basex = 1; basey += 19; continue; } for (int y = 0; y < 16; ++y) { for (int x = 0; x < 8; ++x) { if (font_map[*str][y][x]) { draw_pixel(basex + x, basey + y, Color_White, fb); } else { draw_pixel(basex + x, basey + y, Color_Black, fb); } } } basex += 9; } }
まだ簡易的なものなので、単純に表示したい文字のfont_mapを見て、0なら黒を1なら白を描画しているだけです。一応改行文字の場合は各位置を次の行の先頭にしています。
そんなこんなでとりあえず文字を描画してみました。
フォントが自分で描画した感がとても出ていていいですね!
次回以降の目標
現在進行形でやっているところですが、まずはカーネルのローダーの実装ですかね。とりあえずカーネルをELF形式で出力したので、次はメモリにコピーするコードを書こうというところです。
30日でできる!OS自作入門(Day 12)
タイマ
ついにきましたね、タイマ。
タイマって?
timer、時計ですね。一定時間ごとに割り込みを発生させてくれるというとても重要で便利なものです。
このタイマのおかげで非常にたくさんのメリットが生まれます。例えばHLT命令で停止している時でもタイマは割込みを発生してくれるので、どれだけの時間が経過したかをCPUを休ませながら把握できます。また、今後くるであろうマルチタスクにおけるコンテキストスイッチを発生させるタイミングもタイマによって実現できます。
PIT(Programmable Interval Timer)を使ってみる
このPITは設定した周期毎に割り込みを発生させてくれます。例えば、11932を設定すると100Hzの動作、つまり10msごとに割り込みを発生させてくれるようになります。あとはこの割込みに対応するための割り込みハンドラを作成し、IDTに割り込みハンドラを登録すればこのタイマを扱えるようになります。このあたりの流れはキーボードの割り込み処理何かとほとんど同じです。
割り込みハンドラでとりあえずカウンタを増やせばこのカウンタは1秒間に100回増えることになります。
タイムアウト機能
タイマをCPUに依らず実時間で扱えるということは、「○○秒経ったら教えてくれ」というような扱い方ができるということです。使い方は簡単で、割り込みのタイミングで設定した秒数を減らしていき、0になったら通知すればよいだけです。
といいたいところでしたが、これにはいくつか問題があります。
まず、複数のタイムアウトに対応できていない点です。まぁ、これは配列か何かを用意してそのすべてを割り込みごとに減らすようにすれば実現はできます。
もう一つは、時間がかかるという点です。いつしか、割り込みはできるだけ短くするべきという話がありました。しかし、現状のコードは複数のタイムアウトに対応させてもさせなくても、遅いです。
割込みは素早く
前の記事を見ても毎回何かしらを高速化してますね。(それこそが自作における本質な気がしますし、たくさんの知識を身に着けられるので好きです。)
さて、ここでもいくつかの高速化の方法が提案されるわけですが、例のごとく一気に最後の段階を見ます。
見たかったのですが、翌日の内容にさらなる高速化があって何とも言えない気持ちになってしまいました・・・とりあえず、現状の高速化を考えてみます。
・毎回複数のタイムアウトすべてをインクリメントするのではなく、一つカウンタ変数を用意して、インクリメントするのはその変数だけにする。
・下敷きと同じように順番を管理する構造体と中身を持った構造体の二つを用意し、 期限が短いものからタイムアウトしているかを確認していき、一度でもタイムアウトしていなかったら確認をやめる。
という高速化を取っています。
とはいえ、この方法にも欠点はあって、挿入する際に割り込みが発生しては困るので一時的に割り込みを禁止する部分が出てしまいます。こればっかりは事前の登録に時間をかけるか、処理中に時間をかけるかのトレードオフですね。
Day13へ
明日も引き続きタイマです。さらなる高速化、楽しみですね。(まぁ、もう読んでいるのですが。)ベンチマークテストのようなものもやってみるようです。
30日でできる!OS自作入門(Day 11)
ついにウィンドウ
らしいです、GUI(というよりはそういう感じの見た目)はやっぱり説得感が全然違いますよね。
の前にマウスの続きをちょっと
昨日まででほとんど完成したマウスでしたが、Windowsなんかではよくやる画面の端ギリギリまでマウスを持っていくやつができませんね。これは描画をマウスの右端が画面の端とぶつかるまでしか行わないようにしているからで、マウスの左端がぶつかるまで行えば解決しそうです。
が、それだけでは解決しません。これは、人の目には画面は縦横で構成された二次元のように見えますが、実際はi一次元配列と同じで、右端(例えば[0][319])のさらに右横は一段下の左端([1][0])になるからです。これでは右端まで持っていった場合画面外にあるマウスが左側に描画されることになってしまうわけです。
しかし、昨日の下敷きのおかげで解決は簡単で、描画範囲の計算時に画面買いになったらその部分を描画しなければいいだけです。下敷きがさっそく効いてきましたね。
コードを考えている間は画面外に出てはいけないからマウスの右端までで考えようとなりがちだと思いますが、実際にやってみると課題が見えてきたりするものですね。
ウィンドウを描画する
満を持してのという感じですね。とはいえ11日目にしてもうウィンドウが描画されているの、やばいですね。
とりあえずウィンドウのサイズを適当に決めて下敷きとして1枚もらい、それっぽくなるように色を塗り(ここが重要)、マウスの下に配置します。このウィンドウはまだ動かず、消せもしないものですけど、迫力は段違いですね。
今回ばかりは普段ソースコード部分を流し読みで満足し、デモ写真を見るだけの自分でも、PC上で動かしました。
いつの間にこんなカラフルでパソコンっぽい画面(バカっぽい発言)になっていたんだ・・・
動きのあるウィンドウ
さっきのは文字を表示したままだったので何も変化がなく、味気ないですよね。そこでカウンターでずっと数を足し続けながら描画するウィンドウを考えてみます。すると、すぐに現在の描画方法の欠点が出てきます。描画する度に背景がチラチラ見えてしまうのです。
これは先日までで必要な範囲だけの描画にはなったものの、その範囲は毎回背景から全て描画しているのが原因です。そこで、描画しなおすウィンドウと、その下敷きより上にある下敷きのみ再描画させれば解決できそうと考えられますが、それだけでは別のチラチラが発生します。
描画って難しいんですね、やっぱり。
さて、このチラチラの原因は再描画する必要が出た下敷きより上”全て”を再描画している点です。例えばマウスは動かしてないのにその下のウィンドウを再描画するからマウスも毎回再描画していたら、チラチラしそうですよね。これを回避するにはどうあがいてもマウスやほかのウィンドウを「よけて」描画しなければなりません。
本では描画用の配列と全く同じサイズの配列を用意して、一番上に来る下敷きの番号を格納させています。確かに、こうすればある画面の1点に対してそこで描画すべきウィンドウがどれかすぐわかりますね。そして、再描画する範囲で回したループ内でその部分の下敷きの番号が再描画する下敷きと同じ場合のみ再描画すればマウスを「よける」という動作が達成できそうですね。
確かにこの方法であれば実装は楽ですが、消費メモリが気になるところですね・・・int型だと1920*1080描画には8MB、short型を使っても4MBかかってしまいます。だからといっていい方法が思いついているわけではないのですが。
Day12へ
明日からはついにウィンドウを卒業してタイマです。
OSからしてもめちゃめちゃ重要な分野ですね。残念ながら使用しているタイマはずいぶん古いものになってしまいましたが・・・