1000行で作るOS - アプリケーション

  1. はじめに
  2. 開発環境
  3. RISC-V入門
  4. OSの全体像
  5. ブート
  6. Hello World!
  7. C標準ライブラリ
  8. カーネルパニック
  9. 例外処理
  10. メモリ割り当て
  11. プロセス
  12. ページテーブル
  13. アプリケーション
  14. ユーザーモード
  15. システムコール
  16. ディスク読み書き
  17. ファイルシステム
  18. おわりに

本章では、カーネルから一旦離れて、最初のユーザーランドのプログラムとそのビルド方法を見ていきます。

メモリレイアウト

前章ではページングという仕組みを使ってプロセスごとの独立した仮想アドレス空間を実現しました。本章では、アプリケーションを仮想アドレス空間上のどこに配置するかを考えます。

アプリケーションの実行ファイルをどこに配置するかを定義する、新しいリンカスクリプト (user.ld) を作成しましょう。

user.ld
ENTRY(start) SECTIONS { . = 0x1000000; .text :{ KEEP(*(.text.start)); *(.text .text.*); } .rodata : ALIGN(4) { *(.rodata .rodata.*); } .data : ALIGN(4) { *(.data .data.*); } .bss : ALIGN(4) { *(.bss .bss.* .sbss .sbss.*); . = ALIGN(16); /* https://github.com/nuta/operating-system-in-1000-lines/pull/23 */ . += 64 * 1024; /* 64KB */ __stack_top = .; ASSERT(. < 0x1800000, "too large executable"); } }

筆者が適当に決めた、カーネルアドレスと被らない領域 (0x1000000から 0x1800000の間) にアプリケーションの各データを配置することにします。大体カーネルのリンカスクリプトと同じではないでしょうか。

ここで登場しているASSERTは、第一引数の条件が満たされていなければリンク処理 (本書ではclangコマンド) を失敗させるものです。ここでは、.bssセクションの末尾、つまりアプリケーションの末尾が 0x1800000 を超えていないことを確認しています。実行ファイルが意図せず大きすぎることのないようにするためです。

ユーザーランド用ライブラリ

次にユーザーランド用ライブラリを作成しましょう。まずはアプリケーションの起動に必要な処理だけを書きます。

user.c
#include "user.h" extern char __stack_top[]; __attribute__((noreturn)) void exit(void) { for (;;); } void putchar(char c) { /* 後で実装する */ } __attribute__((section(".text.start"))) __attribute__((naked)) void start(void) { __asm__ __volatile__( "mv sp, %[stack_top]\n" "call main\n" "call exit\n" ::[stack_top] "r"(__stack_top)); }

アプリケーションの実行はstart関数から始まります。カーネルのブート処理と同じように、スタックポインタを設定し、アプリケーションのmain関数を呼び出します。

アプリケーションを終了するexit関数も用意しておきます。ただし、ここでは無限ループを行うだけにとどめておきます。

また、common.cprintf 関数が参照している putchar 関数も定義しておきます。のちほど実装します。

カーネルの初期化処理と異なる点として、.bssセクションをゼロで埋める処理 (ゼロクリア) をしていません。これは、カーネルがゼロで埋めていることを保証してくるからです (alloc_pages関数)。

ゼロクリアは実用的なOSでも行われている処理で、ゼロで埋めないと以前そのメモリ領域を使っていた他のプロセスの情報が残ってしまうためです。パスワードのような機密情報が残ってしまっていたら大変です。

加えて、ユーザランド用ライブラリのヘッダファイル (user.h) も用意しておきましょう。

user.h
#pragma once #include "common.h" __attribute__((noreturn)) void exit(void); void putchar(char ch);

最初のアプリケーション

最初のアプリケーション (shell.c) は次のものを用意します。カーネルの時と同じく、文字を表示するのにも一手間必要なので、無限ループを行うだけにとどめておきます。

shell.c
#include "user.h" void main(void) { for (;;); }

アプリケーションのビルド

最後にアプリケーションのビルド処理です。

run.sh
OBJCOPY=/opt/homebrew/opt/llvm/bin/llvm-objcopy # シェルをビルド $CC $CFLAGS -Wl,-Tuser.ld -Wl,-Map=shell.map -o shell.elf shell.c user.c common.c $OBJCOPY --set-section-flags .bss=alloc,contents -O binary shell.elf shell.bin $OBJCOPY -Ibinary -Oelf32-littleriscv shell.bin shell.bin.o # カーネルをビルド $CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf \ kernel.c common.c shell.bin.o

最初の$CCを呼び出している箇所はカーネルと同じで、clangがコンパイル・リンク処理を一括して行います。

1つ目の$OBJCOPYは、ビルドした実行ファイル (ELF形式) を生バイナリ形式 (raw binary) に変換する処理です。まず、生バイナリとは何かというと、ベースアドレス (ここでは0x1000000) から実際にメモリ上に展開される内容が入ったものです。OSは生バイナリの内容をそのままコピーするだけで、アプリケーションをメモリ上に展開できます。一般的なOSでは、ELFのような展開先の定義とメモリ上のデータが分かれた形式を使いますが、本書では簡単のために生バイナリを使います。

2つ目の$OBJCOPYは、生バイナリ形式の実行イメージを、C言語に埋め込める形式に変換する処理です。llvm-nmコマンドで何が入っているかを見てみましょう。

$ llvm-nm shell.bin.o
00010260 D _binary_shell_bin_end
00010260 A _binary_shell_bin_size
00000000 D _binary_shell_bin_start

_binary_という接頭辞に続いて、ファイル名、そしてstartendsizeが続いています。それぞれ、実行イメージの先頭、終端、サイズを示すシンボルです。実際には次のように利用します。

extern char _binary_shell_bin_start[];
extern char _binary_shell_bin_size[];

void main(void) {
    uint8_t *shell_bin = (uint8_t *) _binary_shell_bin_start;
    printf("shell_bin size = %d\n", (int) _binary_shell_bin_size);
    printf("shell_bin[0] = %x (%d bytes)\n", shell_bin[0]);
}

このプログラムは、shell.binのファイルサイズと、ファイル内容の1バイト目を出力します。つまり、次のように_binary_shell_bin_start変数にファイル内容が入っているかように扱えます。

char _binary_shell_bin_start[] = "shell.binのファイル内容";

また、_binary_shell_bin_size変数には、ファイルサイズが入っています。ただし少し変わった使い方をします。もう一度llvm-nmで確認してみましょう。

$ llvm-nm shell.bin.o | grep _binary_shell_bin_size
00010454 A _binary_shell_bin_size

$ ls -al shell.bin   ← shell.bin.oではなくshell.binであることに注意
-rwxr-xr-x 1 seiya staff 66644 Oct 24 13:35 shell.bin

$ python3 -c 'print(0x10454)'
66644

出力の1列目は、シンボルのアドレスです。この10260という値はファイルの大きさと一致しますが、これは偶然ではありません。一般的に、.oファイルの各アドレスの値はリンカによって決定されます。しかし、_binary_shell_bin_sizeは特別なのです。

2列目のAは、_binary_shell_bin_sizeのアドレスがリンカによって変更されない種類のシンボル (absolute) であることを示しています。 char _binary_shell_bin_size[]という適当な型の配列として定義することで、_binary_shell_bin_sizeはそのアドレスを格納したポインタとして扱われることになります。ただし、ここではファイルサイズをアドレスとして埋め込んでいるので、キャストするとファイルサイズになるのです。オブジェクトファイルの仕組みをうまく使った、ちょっとした小技が使われています。

最後に、カーネルのclangへの引数に、生成した shell.bin.o を追加しています。これで、最初に起動すべきアプリケーションの実行ファイルを、カーネルイメージに埋め込めるようになりました。

逆アセンブリを見てみる

逆アセンブリしてみると、リンカスクリプトに定義されている通り、.text.startセクションは実行ファイルの先頭に配置され、0x1000000start関数が配置されていることがわかります。

$ llvm-objdump -d shell.elf

shell.elf:	file format elf32-littleriscv

Disassembly of section .text:

01000000 <start>:
 1000000: 37 05 01 01  	lui	a0, 4112
 1000004: 13 05 05 26  	addi	a0, a0, 608
 1000008: 2a 81        	mv	sp, a0
 100000a: 19 20        	jal	0x1000010 <main>
 100000c: 29 20        	jal	0x1000016 <exit>
 100000e: 00 00        	unimp

01000010 <main>:
 1000010: 01 a0        	j	0x1000010 <main>
 1000012: 00 00        	unimp

01000016 <exit>:
 1000016: 01 a0        	j	0x1000016 <exit>