RustでOSを作る
環境 MacOS Monterey
環境構築は以下参考。
MacOSだとできなそうなのでDockerでUbunts環境を用意し、ここでビルドすることにする。
nightlyで試したが、ちょうどバグを踏んだらしい。
nightlyの過去のversion(nightly-2022-06-18)に固定することにした。
参考
FROM ubuntu:latest
RUN apt update && apt upgrade -y && apt install -y curl build-essential
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
RUN apt install lld file -y
ENV PATH /root/.cargo/bin:$PATH
WORKDIR /rust
RUN rustup toolchain install nightly-2022-06-18 && rustup default nightly-2022-06-18
RUN rustup component add rust-src --toolchain nightly-2022-06-18-x86_64-unknown-linux-gnu
# bootloader
$ docker run -v ${pwd}:/rust/ <container_name> /bin/bash -c "cd /rust/bootloader && cargo +nightly-2022-06-18 rustc --target x86_64-unknown-uefi -Z build-std"
ターゲットは何向けにコンパイルするか。今回は独自OSになるのでx86_64-unknown-uefi
。
なお、jsonファイルを指定することでカスタム設定のターゲットとすることができる。
毎回オプションを指定するのは面倒。→ .cargo/config.toml
に書くことで省略可能。
$ cargo rustc --target x86_64-unknown-uefi
$ brew install qemu
$ qemu-system-x86_64 -version
QEMU emulator version 7.0.0
Copyright (c) 2003-2022 Fabrice Bellard and the QEMU Project developers
まずはQEMUでHello World。みかんOS本に従う。
$ qemu-img create -f raw disk.img 200M
200MiBの空のRawディスクイメージフォーマットのイメージを作成
この段階でバイナリエディタで開くと0で埋められているのがわかる。$ mkfs.fat -n 'MIKAN OS' -s 2 -f 2 -R 32 -F 32 disk.img
mkfs.<ファイルシステムの種類>
でファイルシステムを構築できる。
オプションは以下参照
ファイルシステム構築後はバイナリエディタで開くと先頭にいくつか情報が書き込まれていることが確認できる。
$ mkdir -p mnt
マウントする場所を作る
マウントはファイルシステムを認識しディレクトリツリーをつなぐことなのでつなぐための場所を用意している
$ hdiutil attach -mountpoint mnt disk.img
$ mkdir -p mnt/EFI/BOOT
$ cp hello.efi mnt/EFI/BOOT/BOOTX64.EFI
マウントしたことでファイルシステムにアクセスできるようになったのでパスを切り、ファイルをおく。
$ hdiutil detach mnt
$ curl -O https://raw.githubusercontent.com/uchan-nos/mikanos-build/master/devenv/OVMF_CODE.fd
$ curl -O https://raw.githubusercontent.com/uchan-nos/mikanos-build/master/devenv/OVMF_VARS.fd
$ qemu-system-x86_64 -drive if=pflash,file=OVMF_CODE.fd -drive if=pflash,file=OVMF_VARS.fd -hda disk.img
OVMF
ブートローダを作る
これに習っていく
まずは動くものを。UEFIアプリケーション
本家ではEDK2を使用しているが、rust-rsクレートを利用することでより楽になるみたい。
UEFIアプリケーションの起動まではかなり楽になる。
#![no_std]
#![no_main]
#![feature(abi_efiapi)]
#[macro_use]
extern crate alloc;
use core::arch::asm;
use core::fmt::Write;
use uefi::prelude::*;
use uefi::table::runtime::ResetType;
#[entry]
fn efi_main(_image: Handle, mut system_table: SystemTable<Boot>) -> Status {
// UEFIライブラリの初期化。
// https://docs.rs/uefi-services/latest/uefi_services/fn.init.html
uefi_services::init(&mut system_table).unwrap();
system_table.stdout().reset(false).unwrap();
writeln!(system_table.stdout(), "Hello, World!").unwrap();
{
let revision = system_table.uefi_revision();
let (major, minor) = (revision.major(), revision.minor());
log::info!("UEFI {}.{}", major, minor);
}
system_table.boot_services().stall(5_000_000);
system_table.stdout().reset(false).unwrap();
system_table.runtime_services()
.reset(ResetType::Shutdown, Status::SUCCESS, None);
}
UEFIライブラリの初期化時に引数に指定したSystemTable<Boot>
はUEFIアプリケーションを操作する窓口になる。
ただし常に全ての操作が可能ではない。
- boot services -> 起動後のブートストラップステージでのみ
- runtime services -> それ以降でのみ
stdout()
で標準出力用のインターフェースとなるオブジェクトを取得。write!()
ないしはwriteln!()
で書き込める。
reset(false)
でフラッシュする感じ。
unwrap_success()
は使えなかった。
stall()
で一定時間待つ。指定するのはマイクロ秒単位。アンダースコアは可読性向上のため。
メモリマップ取得
カーネルファイルを読み出してメモリに書き出すためにどの領域が空いているかを確認する必要がある。そのためにメモリマップを読む。
以下関数が用意されている。リファレンスに沿ってメモリマップのサイズを取得し、適切なバッファを用意してメモリマップを取得する。
memory_map_size
memory_mapメモリディスクリプタを取るとこまでできた。
ファイル出力は良くわからん、非本質なので一旦パス。
fn get_memory_map(boot_services: &BootServices) {
let map_size = boot_services.memory_map_size().map_size;
let mut memmap_buf = vec![0; map_size * 8];
log::info!("buffer size: {}", map_size);
let (_map_key, desc_itr) = boot_services.memory_map(&mut memmap_buf).unwrap();
let descriptors = desc_itr.copied().collect::<Vec<_>>();
descriptors.iter().for_each(|descriptor| {
log::info!("{:?}, {}, {}, {}", descriptor.ty, descriptor.phys_start, descriptor.virt_start, descriptor.page_count);
})
}
map_sizeの大きさそのままでbuffer
用意したがTOO_SMALL
的なエラーになる。
この辺は後でまた考える。
サンプルのカーネル作り
メモリマップを取得し空いてるメモリの場所を特定したら、そこにカーネルファイルを置きたいのでそのためのサンプルとなるカーネル(elfバイナリ)をdisk.img`に同梱する。ここでは中身はhaltするだけのもの。
#![no_std]
#![no_main]
// #![feature(start)]
use core::arch::asm;
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
unsafe {
loop {asm!("hlt")}
}
}
#[no_mangle]
pub extern "sysv64" fn kernel_main() -> ! {
unsafe {
loop {asm!("hlt")}
}
}
カーネルのビルド時にはターゲットをカスタム設定する必要がある。
jsonファイルの名称は任意で、ビルドオプションのtarger
にその名前を指定できれば良い。
ブートローダの方と同じく.cargo/config.toml
に記述することで省略可能。
"pre-link-args": {
"ld": [
"--entry=kernel_main",
"--image-base=0x100000",
"--static"
]
},
その中でコードのエントリポイントの関数名kernel_main
をjsonのリンカの設定に書く。
同じく、ここでリンク時にカーネルファイルを0x100000に配置するよう設定している。
参考)
#![feature(start)]
は古い?かも。
ELFヘッダのエントリポイントに書かれるアドレスに配置された関数が実行時に読み出され、実行される。→ kernel/src/main.rs
ファイルのメイン関数名をjsonのリンカの設定に指定するのはこのため
ファイルを読み出す
参考
Directoryを取得する
let loaded_image = boot_services.handle_protocol::<LoadedImage>(_image).unwrap().get();
let device = unsafe{(*loaded_image).device()};
let file_system = boot_services.handle_protocol::<SimpleFileSystem>(device).unwrap().get();
let mut root_dir = unsafe{(*file_system).open_volume().unwrap()};
handle_protocolでboot_servicesからプロトコルを読み出す(?)
LoadedImageプロトコルを読み込み、*で生のポインタとしてdevice()関数を呼ぶ。
取得したSimpleFileSystemにおいてopen_volume()
を用いるとルートディレクトリを取得できる。
Directory、FileHandle、RegularFileのインターフェースは以下らしい。
ドキュメントを見てもDirectory
にはread_entry()
しかなさそうに思うが実装を見るとopen()
とかがあるらしい。試しにルートディレクトリから確認できる範囲のファイルを確認してみる。バッファサイズは適当に決め打ちしとく。
let mut buf = vec![0; 4096];
let res = root_dir.read_entry(&mut buf).unwrap();
log::info!("{:?}", res);
let res = root_dir.read_entry(&mut buf).unwrap();
log::info!("{:?}", res);
let res = root_dir.read_entry(&mut buf).unwrap();
log::info!("{:?}", res);
$ hdiutil attach -mountpoint $(MOUNT_POINT) $(DISK_IMG_PATH)
を実行しmnt/
にマウントした状態でmnt/
以下のフォルダ構成を確認すると、ここでの出力結果と一致することがわかる。
このことからroot_dir
はルートディレクトリ(mnt/
)の情報を持った何らかのオブジェクトだとわかる。
open()
を見ると以下の定義とわかる。
fn open(
&mut self,
filename: &CStr16,
open_mode: FileMode,
attributes: FileAttribute,
) -> Result<FileHandle>
CStr16とかいう新しいやつが出てきた。rustは標準がutf-8なのでutf-16に変換する必要があるということ?サンプルコードがあるのでそれに従う。
use uefi::CStr16;
let mut buf = [0; 4];
CStr16::from_str_with_buf("ABC", &mut buf).unwrap();
みたいな感じで&str
型から変換する。
FileModeは3種類ある。
FileAttributeはよくわからん
RegularFileを取得する。
Use FileHandle::into_kind or RegularFile::new to create a RegularFile. In addition to supporting the normal File operations, RegularFile supports direct reading and writing.
RegularHandle
では直接ファイルを読み書きできるみたい。
なのでRegularFile::new()
に食わせてFileHandle
からRegularHandle
へ変換する。
ここで受け取るRegularHandleのread
関数でファイルを読み出し、バッファに格納する。
pub fn read(&mut self, buffer: &mut [u8]) -> Result<usize, Option<usize>> { ... }
この状態で読み取ったbufferを覗くと何かが読めてることが確認できる(バイナリなので読めはしない)
// RegularFile取得
let mut cstr_buf = [0u16; 32];
let cstr_file_name = CStr16::from_str_with_buf("kernel.elf", &mut cstr_buf).unwrap();
let file_handle = root_dir.open(cstr_file_name, FileMode::Read, FileAttribute::empty()).unwrap();
let mut file = unsafe {RegularFile::new(file_handle)};
// サイズ取得
let file_size = file.get_boxed_info::<FileInfo>().unwrap().file_size() as usize;
// バッファへの読み込み
let mut buf = vec![0; file_size];
file.read(&mut buf);
log::info!("{:?}", buf);
カーネルをメモリ上に配置する。
今bufferには読み出してきたカーネルelfが乗っている。
参考
goblinクレートを使うことでバイナリを解析できる。
program_headers()
を用いることでヘッダ情報をProgramHeadersのイテレータとして取得できる。
ここでelfバイナリのヘッダについて調べる。
PT_LOAD
p_filesz と p_memsz により記述される読み込み可能セグメントを指定します。ファイルのバイト列は、メモリーセグメントの先頭に対応付けられます。セグメントのメモリーサイズ (p_memsz) がファイルサイズ (p_filesz) より大きい場合、不足するバイトは、値 0 を保持しセグメントの初期化領域に続くように定義されます。ファイルサイズがメモリーサイズより大きくなることは許可されません。プログラムヘッダーテーブルの読み込み可能セグメントエントリは、p_vaddr 構成要素で整列され、昇順に現れます。
PT_LOADをかき集めたら良い。
この辺もう少し理解したい。
for ph in elf.program_headers.iter() {
log::info!("Program header: {} {} {} {}",elf::program_header::pt_to_str(ph.p_type),ph.p_offset,ph.p_vaddr,ph.p_memsz);
}
elfヘッダについて詳しい
readelf
でelfヘッダの内容を表示できる。
$ readelf -l kernel/target/x86_64-unknown-none-shimejios/debug/kernel.elf
Elf file type is EXEC (Executable file)
Entry point 0x101130
There are 4 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000100040 0x0000000000100040
0x00000000000000e0 0x00000000000000e0 R 0x8
LOAD 0x0000000000000000 0x0000000000100000 0x0000000000100000
0x0000000000000120 0x0000000000000120 R 0x1000
LOAD 0x0000000000000120 0x0000000000101120 0x0000000000101120
0x0000000000000016 0x0000000000000016 R E 0x1000
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x0
:
:
Program Headerは以下で取得できる。
for ph in elf.program_headers.iter() {
// 処理
}
以下参考になりそう。
ELF
仮想アドレスと物理アドレス
OSがプログラムをメモリ上にロードする時にはその時に空いているアドレスが割り当てられる。そのため、実行するプログラムのコード内では常に0番地から始まるものとして書かれていても実際には0番には置かれていないことが発生する。そのため、OSは実行時にベースアドレスレジスタに先頭のアドレスを格納し、プログラム内のアドレスを相対的なものとし、都度ベースアドレスからの相対的なアドレスを求めることで実際のアドレスを示すように調整している。
この実際のアドレスを物理アドレス。プログラム内の0番から始まるものとした相対的なアドレスを仮想アドレスとしている。
この相対的なアドレス(仮想アドレス)で表現されるものを再配置可能なプログラムといい、メモリ上のどこに展開されても正しく動かせるものとなる。
ELFには以下4種類あるらしい (e_type)
- ELF_REL : 再配置可能ファイル
- ELF_EXEC : 実行可能ファイル
- ELF_DYN : 共有オブジェクトファイル
- ELF_CORE : コアファイル
https://docs.oracle.com/cd/E19620-01/805-5821/6j5ga47bq/index.html
https://www.hazymoon.jp/OpenBSD/annex/elf.html
共有オブジェクトファイルはPIC(position-independent code)としてビルドされるが、通常のアプリケーションは絶対アドレスを用いて記述されている(最近では"いた"が正しいのかも)。セキュリティ強化目的にも有用。
PIE(position-independent executable)は実行ファイルについても相対アドレスを用いたもの。
→kernelからすると相対アドレスで書かれたPIEは共有オブジェクト(ELF_DYN)の認識になるらしい。
PIC(位置独立コード)
-
.so
ファイルなど。末尾にversion番号がつくことあり。 - 共有ファイルは複数のプログラムで共有される。
- プログラム実行時、そのプロセスの仮想メモリ空間に共有ファイルをロードする。共有ファイルが必要になると、PLT(Procedure Linkage Table)内のエントリを確認し、GOT(Global Offsets Table)で対応する関数へジャンプする(なお、初回はいろいろやってる。リンク詳細)。
カーネルのエントリポイントのアドレスをmem::transmute
で無理くり関数ポインタに変え、生成した関数を実行することでカーネルを起動する。
ここまででブートローダは完成。
カーネルを作り込んでいく。
ピクセルを描く
みかん本P82
まずはGraphicsOutput
の中身を見てみる。
use uefi::proto::console::gop::{GraphicsOutput, Mode, ModeInfo, PixelFormat, PixelBitmask};
fn efi_main(_image: Handle, mut system_table: SystemTable<Boot>) -> Status {
// (中略)
let gop = boot_services.locate_protocol::<GraphicsOutput>().unwrap().get();
let mode = unsafe{(*gop).query_mode(0_u32)}.unwrap(); // ★ この2行を1行にまとめるとエラーする。
let mode_info = mode.info(); // ★
let res = mode_info.resolution();
let stride = mode_info.stride();
let format = mode_info.pixel_format();
// let mask = mode_info.pixel_bitmask().unwrap();
log::info!("{:?}", res); // 640 480 なんか見たことある縦横の長さ
log::info!("{}", stride); // 640
log::info!("{:?}", format); // Bgr
unsafeのスコープを出るから?
temporary value is freed at the end of this statement
参考
1ピクセルあたり4バイトなのでその分係数を掛けてindexとする。strideは1行の横の長さなので上から何行目かを掛ける。
一応は白く塗りつぶせた。(QEMUのサイズの問題か縦は750くらいにしないと完全に真っ白にはならなかった。)
const BYTES_PER_PIXEL: usize = 4;
let gop = boot_services.locate_protocol::<GraphicsOutput>().unwrap().get();
let gop = unsafe{&mut (*gop)};
let mode = unsafe{gop.query_mode(0_u32)}.unwrap();
let mode_info = mode.info();
let (horizontal, vertical) = mode_info.resolution();
let stride = mode_info.stride();
let format = mode_info.pixel_format();
let mut fb = unsafe{gop.frame_buffer()};
for x in 0..horizontal {
for y in 0..vertical {
unsafe {
fb.write_value((stride * y + x) * BYTES_PER_PIXEL, [255_u8, 255_u8, 255_u8]);
}
}
}
本に習いBufferFrameConfig構造体を作り、baseとしてgop.frame_buffer()
を置くべきなのかも。
kernelのエントリポイント(関数ポインタ化したやつ)を呼ぶ時の引数にFrameBufferを渡す際に縦横の長さとかが含まれているようにしたい。
みかん本P98。Config構造体を作る。
struct FrameBufferConfig<'a> {
frame_buffer_base: &'a mut FrameBuffer<'a>,
horizontal_resolution: usize,
vertical_resolution: usize,
stride: usize,
format: PixelFormat
}
let bfconfig = FrameBufferConfig {
frame_buffer_base: &mut gop.frame_buffer(),
horizontal_resolution: mode.info().resolution().0,
vertical_resolution: mode.info().resolution().1,
stride: mode.info().stride(),
format: mode.info().pixel_format()
};
ライフタイム