Operating Systems Development Series | |
User land
はじめにようこそ!前章では、VFSについて調べ、テキストファイルをロードして表示しました。このVFSを使って、実行可能なプログラムファイルをロードすることもできます。これには、ドライバ、プログラムソフト、共有、ランタイムライブラリなどが含まれます。 この章では、ユーザーランドソフトウェアのサポートに踏み切ります。また、システムAPIとその動作についても見ていきます。 さっそく始めましょう。 保護レベルアセンブリ言語の輪カーネルランド第5章では、アセンブリ言語で使用されるリングの概念について簡単に見てきました。このリングは、異なる保護レベルを表しています。これらの保護レベルはハードウェアの詳細であり、ハードウェアによって実装されます。 リング0で動作するソフトウエアは、最も制御性が高いです。ハードウェアPIO、MMIO、プロセッサのハードウェア制御とテーブル(CPUキャッシュ制御やMMRなど)など、ソフトウェアがより多くのアクションを実行できるようにする特権命令を実行することができる。 特権命令の一覧は第7章に示したが、念のためここにも示しておく。 保護レベル0より大きいソフトウェアが上記の命令を実行しようとすると、プロセッサは保護フォールト(#PF)例外を生成する。
カーネルランドや カーネルモードは、リング0で動作するソフトウェアのことを指します。 これまでこの連載で書いてきたソフトウェアはすべてカーネルモードのソフトウェアで、カーネルとミニドライバでした。マイクロカーネルとハイブリッドは一般的に、このシリーズで使用しているものより高度なドライバインタフェーススキームを採用しており、ドライバを適切にインストールし、カーネルから完全に分離したユーザモードでドライバを実行できるようになっています。カーネルの一部をユーザーモードにすることも可能で、全ては設計次第です。 システムを最初に起動するときは、BIOSとOSが起動できるようにスーパーバイザーモードで動作しています。 ユーザーランドリング1〜リング3で動作するソフトウェアは、リング0で動作するソフトウェアよりもマシンを制御しにくい。これはマシンを保護するためで、リング1〜3で動作するソフトウェアが原因でエラーが発生した場合、プロセッサは一般保護(#GP)例外を使ってシステムエグゼクティブまたはカーネルに問題を通知する。 多くのOSでは、カーネルモードとユーザーモードの2モード制を採用している。x86ファミリーは4つの保護モードをサポートしていますが、これらのオペレーティングシステムでは、アーキテクチャ間での移植性を高めるために2つの保護モードのみを使用しています。 これらのOSでは、カーネルモードのソフトウェアはリング0、ユーザーランドのソフトウェアはリング3で実行されるように設計されています。リング1とリング2は使用されません。ドライバソフトウェアは、リング0で動作してハードウェアデバイスにアクセスするか、リング3で提供されるドライバAPIやシステムAPIを使ってハードウェアデバイスと通信することができる。 ユーザーモードソフトウェアはハードウェアデバイスに直接アクセスできないため、システムタスクを完了するためにオペレーティングシステムに通知する必要があります。これには、テキストの表示、ユーザーからの入力の取得、ドキュメントの印刷などが含まれる。これらの機能は、ライブラリやAPIという形でユーザーモードソフトウェアに提供される。これらのライブラリやAPIは、システムAPIと通信する。 システムAPI......この言葉は以前にも目にしたことがありますね。システムAPIについてはもう少し詳しく見ていきますが、ここではユーザーモードについて詳しく見ていきましょう。リング-1最近のプロセッサには、ハイパーバイザーのリング0アクセスを可能にする特別な保護レベルを持つものがある。 これは「リング-1」と呼ばれることもある。 ユーザーランドへようこそユーザーモードに入るには、いくつかのステップが必要です。(さあ、簡単だとは思わなかったでしょう:) しかし、それほど悪いことではありません。 ステップ1:グローバルディスクリプターテーブルまず、Global Descriptor Table (GDT)に戻る必要があります。GDTは、初めてプロテクトモードを設定する際に必要となった、あの大きな醜い構造体です。GDTは、プロセッサの情報を含む8バイトのエントリのリストを含んでいることを思い出してください。GDTのエントリーのビットフォーマットをもう一度見てみましょう(重要な部分は太字にしてあります)。
やばい、大丈夫.上のDPL(Descriptor Privilege Level)ビットは、そのディスクリプタに使用される特権レベルを表しています。つまり、これらのビットを3に設定することで、実質的にそのディスクリプタをユーザーモードのディスクリプタにすることができるのです。 そこで、最初のステップとして、GDT内に2つの新しいディスクリプタを作成します。1つはユーザーモードデータ用、もう1つはユーザーモードコード用です。これは、i86_gdt_initializeを修正して、ユーザーモード・コードとデータ用に2つの新しいGDTエントリーを追加することで行われます。では、それをやってみましょう。
上記のコードは、他のGDTエントリーを作成するときに行ったものと同じですが、1つだけ変更があります。I86_GDT_DESC_DPL フラグに注目してください。 これにより、両方の DPL ビットが 2 に設定され、ユーザー モード (リング 3) 用のビットが作成されます。上記のフラグはすべて、以前の章でプロテクトモードについて説明したときに書いたものです。 必要なのはこれだけです。ユーザモードコードディスクリプタはGDTのインデックス3に、ユーザモードデータディスクリプタはインデックス4にインストールされていることに注意してください。セグメントレジスタは、使用するセレクタのオフセットを含んでいることを忘れないでください。GDTの各エントリは8バイトのサイズなので、コードセレクタ0x18(8*3)、データセレクタ0x20(8*4)ということになる。 したがって、これらのセレクタを使用するには、上記のセグメントセレクタのいずれかを、使用するセグメントレジスタにコピーすればよいことになります。 DPLDPL(Descriptor Protection Level)は、セグメント記述子の保護レベルです。 例えば、私たちのカーネルのコードとデータセグメントのDPLは、リング0アクセスで0です。RPL要求保護レベル(RPL)は、ソフトウェアがCPLをオーバーライドして新しい保護レベルを選択することを可能にします。これは、ソフトウェアがリング0からリング3など他の保護レベルへの変更を要求できるようにするものである。RPLは、記述子セレクタのビット0と1に格納される。 待って、何?セグメントセレクタは、GDTへのオフセットに過ぎないことを思い出してください。たとえば、0x8バイトはリング0のコードディスクリプタのオフセットでした。0x8 と 0x10 はセグメントセレクタです。GDTのエントリーはすべて8バイトなので、セグメントセレクタの値は常に8の倍数、つまり8、16、24、32などです。8 は 2 進法では 1000 です。つまり、セグメントセレクタの値がどのようなものであっても、下位3ビットは0であることを意味します。 RPLはセグメントセレクタの下位2ビットに格納されます。したがって、セグメントセレクタが0x8の場合、RPLは0です。 0xbの場合(0x8ですが、最初の2ビットが設定されており、1000ではなく1011)、RPLは3です。 これは、ソフトウェアがユーザーモードに切り替わるために必要なことです。 CPLCPL(Current Protection Level)は、現在実行中のプログラムの保護レベルである。 CPLは、SSとCSのビット0と1に格納されている。GDTエントリーは8バイトのサイズであることを忘れないでください。プロテクトモードのセグメントレジスタにはセグメントセレクタ(GDTエントリオフセット)が含まれるため、下位3ビットはゼロであることが保証されます。 CSとSSの下位2ビットは、ソフトウェアのCPLを格納するために使用されます。 保護レベルソフトウェアが新しいセグメントをセグメントレジスタにロードしようとすると、プロセッサはソフトウェアのCPLとロードしようとするセグメントのRPLに対してチェックを実行します。RPLがCPLよりも高い場合、そのソフトウェアはセグメントをロードできます。そうでない場合、プロセッサはGeneral Protection Fault(#GP)を発生させます。 RPLの仕組みを理解することは重要で、ユーザーモードに切り替える際に使用する必要な情報です。 ステップ2:切り替えこれで、ユーザーモードに切り替えることができます。 ジャンプを実行する方法は2つあります。どちらもメリットとデメリットがあるので、詳しく見ていきましょう。なお、本連載では移植性の観点からIRETを使用します。 SYSEXIT命令このセクションは、今後拡張していく予定です。IRET / IRETD命令多くのOSでは、SYSEXIT命令よりも移植性が高いため、この方法を採用している場合があります。大きなOSでは、SYSEXITが使用できない場合のバックアップ方法としてサポートされている場合もあります。 さて、それではIRETはどのようにスイッチの実行に役立つのでしょうか。第3章で、モード切り替えの際に使用されるさまざまな方法を思い出してください。IRETはトラップリターン命令です。IRETを実行すると、ユーザーモードのコードに戻るようにスタックフレームを調整することができます。 IRETDが実行されると、スタックに次のようなものがあることを期待します。
IRETD により、プロセッサはスタックから取得した CS:EIP にジャンプします。また、EFLAGS レジスタにスタックから取得した値を設定します。SS:ESPには、スタックから取得したSSとESPの値を指すように設定されます。 これらは、INT命令実行時に自動的にスタックにプッシュされます。このため、通常の場合、これらの値は変更されないままです。しかし、これらの値を変更することで、IRETにモード切替を行わせることができます。 さて、まずはセグメントセレクタの設定です。下位2ビットが必要なRPLを表していることを思い出してください。この場合、ユーザーモードには3が必要です。では、それを実行しましょう。 これで、ユーザーモードへの切り替えを実行することができます。これは、IRET用のスタックフレームを構築し、IRETを発行することで行われます。
スタックフレームが上記のリストにあったものと一致していることに注意してください。IRETD命令により、上記のコードではリング3の内部で0x1B:aが呼び出されることになります。 しかし、少し問題があります。上記のルーチンを使ったり、カーネルで別の方法でユーザーモードに切り替えたりしようとすると、ページフォルト(PF)例外が発生するのです。これは、カーネル用のページがカーネルモードアクセス専用にマッピングされているためです。これを解決するには、別の方法でユーザーモードに入るか、ユーザーモードのソフトウェアがアクセスできるようにカーネルをマップする必要があります。 今のところ、ユーザーモードソフトウェアがアクセスできるように、カーネルをマップすることだけを考えています。これには、vmmngr_initialize()ルーチンを更新し、PTEとPDEにUSERビットを設定することが含まれます。 より複雑なオペレーティングシステムでは、この方法は使用されないでしょう。この方法は、ユーザーモードソフトウェアからアクセスできるようにカーネルページをマッピングした場合にのみ機能しますが、これは悪いことです。より推奨されるアプローチは、カーネルページをカーネルのみのアクセスのためにマッピングしておき、ユーザプログラムをロードするときに、カーネルのローダコンポーネントにユーザモードページをマッピングさせることです。そして、スタックとヒープのアロケータが、プログラムのスタックとヒープの領域をユーザモードにマップすることになります。この現在の方法は、カーネルスタックをユーザーランドと共有するもので、大規模なシステムではこれを行うべきではありません。 v8086モードへの移行v8086モードに入るには、ユーザーモードのタスクが必要です。したがって、上記のようにすれば、v86モードにも入ることができます。ただし、1点だけ少し工夫が必要です。 EFLAGSレジスタの形式を思い出してください。ビット17(VM)は、v8086モード制御フラグです。IRETを行う際にEFLAGSの値をスタックにプッシュするので、v86モードにするためには、EFLAGSのビット17をセットしてからスタックにプッシュすればよいのです。これにより、IRETは戻り時にEFLAGSレジスタのVMビットをセットするようになります。 以上で、v8086モードに移行することができます。 設計上の注意上記の方法は、ユーザモードに入る簡単な方法ですが、コストがかかります:上記の方法が動作するためには、カーネル領域は、リング3ソフトウェアがカーネルメモリにアクセスできるようにマッピングされなければなりません。 このため、リング3で実行している間、ソフトウェアは、保護モードによるいくつかの制限がありますが、カーネルルーチンを直接呼び出すことができ、カーネル空間を破壊することさえできます。 上記の問題を解決する可能性のある方法は、カーネルメモリをリング0ソフトウェアのために予約しておくことです。カーネルのローダーコンポーネントは、プログラムをロードしている間、プロセスのために必要なメモリのリング3領域をマップすることができます。 これについては、次章でOSのローダを開発する際に、さらに検討する予定です。 カーネルの世界へ戻るステップ1:TSSのセットアップx86アーキテクチャは、ハードウェア支援によるタスクスイッチングをサポートしています。つまり、このアーキテクチャには、プロセッサが異なるタスクを選択できるようにするためのハードウェア定義構造が含まれています。 最近のほとんどのオペレーティングシステムは、移植性の観点からハードウェアタスク切り替えサポートを利用していません。 これらのオペレーティングシステムは、通常、ソフトウェアタスク切り替え方法を採用しています。 タスクステートセグメント(TSS)TSSの構造は非常に大きいです。
TSSは、ハードウェアタスクスイッチの前のマシンの状態に関する情報を格納するために使用されます。多くのメンバーを持っているので、見てみましょう。
これらのフィールドのほとんどは、非常に単純です。このため、この構造体のいくつかのフィールド、特にリング 0 スタックとセレクタのフィールドを設定する必要があります。 ステップ2:TSSのインストールディスクリプタ・セグメントTSSは、その名前からも分かるように、セグメントです。すべてのセグメントと同様に、TSSはGDTのエントリを必要とします。これにより、タスクがビジー状態か非アクティブ状態かを設定したり、どのソフトウェアがアクセスできるか(DPL)、ディスクリプタで設定できる他のフラグなど、TSSを制御することができます。Base Addressフィールドは、設定したTSS構造体のベースアドレスでなければなりません。 LTR命令LTR(LoadTask Register)命令は、TSSをTSRレジスタにロードするために使用します。 例えば、以下のようなものです。
axはTSSのセグメントセレクタです。このアーキテクチャはハードウェアタスクの切り替えをサポートしているため、TSRには現在のタスクを定義するTSSのアドレスが格納されます。 タスクステートレジスタ(TSR)は、TSSセレクタ、TSSベースアドレス、TSSリミットを格納するレジスタです。ただし、ソフトウェアで変更できるのは、TSSセレクタのみです。 TSSのインストールTSS構造をインストールするには、まずTSSのGDTエントリーをインストールします。次に、上記のflush_tssを呼び出して、TSS をカレントタスクとして選択します。
上記のコードでは、TSSは、我々のtss_entry構造体のグローバル構造体の定義です。 前のタスク(ユーザーモードセレクタ)とリング0スタック(カーネルスタック、kernelSS:kernelESPに位置)に合わせてTSSのセレクタエントリを設定しています。flush_tssはTSSをTSRにインストールします。 追加命令他にも便利な命令がいくつかあります。これらの命令はすべて、ユーザーモードのソフトウェアで実行することができます。VERR命令VERR(Verify Segment is Readable) は、セグメントが読み取り可能かどうかをチェックするために使用できます。読み出し可能であれば、プロセッサはゼロフラグ(ZF)を1にセットします。この命令は、どのプロビレッジレベルでも実行することができます。
VERW命令VERW(Verify Segment is Writable) は、セグメントが書き込み可能かどうかをチェックするために使用できます。書き込み可能であれば、プロセッサはゼロフラグ(ZF)を1にセットします。この命令は、どのプロビレッジレベルでも実行することができます。
LSL命令セレクタのセグメントリミットをレジスタにロードする命令です。
ARPL 命令セレクタのRPLを調整する命令です。arpl dest,srcの形式で、destはメモリ位置またはレジスタ、srcはレジスタです。destのRPLがsrcより小さい場合、destのRPLビットはsrcのRPLビットに設定されます。たとえば、次のようになります。
システムAPI概要システムAPI は、ソフトウェアがオペレーティング システムと相互作用できるようにするツール、文書、およびインターフェイスを提供します。オペレーティングシステムによって使用される用語は異なりますが、基本的な考え方は同じです。例えば、WindowsはこのAPIを「Native API」と呼んでいます。 システムAPIは、ソフトウェアがオペレーティングシステムやデバイスドライバと対話するのを容易にする。 システムAPIは、ユーザーモードソフトウェアとカーネルモードソフトウェアの間のインターフェースである。ソフトウェアがシステム情報を必要とするとき、またはファイルの作成などのシステムタスクを実行するときはいつでも、ソフトウェアはシステムコールを呼び出します。 システムコールは、システムサービスとも呼ばれ、オペレーティングシステムによって提供されるサービスです。 このサービスは、通常、関数またはルーチンです。ソフトウェアは、システムタスクを実行するためにシステムコールを呼び出すことができます。 設計sysenter / sysexitこのセクションは、今後拡張していく予定です。ソフトウェア割り込みほとんどのシステムAPIは、ソフトウェア割り込みを使用して実装されています。ソフトウェアは、int 0x21のような命令を使用して、オペレーティングシステムのサービスを呼び出すことができます。例えば、DOSのTerminate関数を呼び出すには、次のようにします。
上記のコードでは、AHに関数番号が含まれています。int 0x21は0x21割り込みベクタを呼び出してDOSを呼び出しています。 上記を動作させるためには、OSが割り込みベクタ0x21のISRをインストールする必要があります。ISRはAHを比較し、正しいカーネルモード関数に制御を渡すFSM(Finity State Machine)になるでしょう。そして、親愛なる読者の皆さん、これが設計です。 ソフトウェア割り込みは、SYSENTERやSYSEXITよりも移植性が高いです。このため、ほとんどのOSはこの方法を(おそらく他の方法と一緒に)サポートしています。このシリーズでは、この方法を使用します。 例システムAPIは通常、数百のシステムコールで構成されています。 ここでは、いくつかのオペレーティングシステムと、それらがサポートしているメソッドの一覧を示します。INT番号は、上記の方法によるソフトウェア割り込みベクタ番号です。
基本システムAPIステップ1:システムコールテーブルほとんどのシステムAPIは、すべてのサービスを含むシステムコール・テーブルを実装しています。このテーブルは、静的、動的、自動生成、またはその3つの組み合わせのいずれかにすることができます。大規模なオペレーティングシステムでは、通常、システムコールの自動生成された動的サイズのテーブルが採用されています。これは、このテーブルに含まれる可能性のあるシステムサービスの数が多いためで、手作業で作成すると非常に面倒な作業となります。 この目的のためには、カーネルにシステムサービステーブルを定義すればよいのです。このテーブルには、カーネルにある呼び出し可能なさまざまな関数のアドレスが含まれています。
うーん、この表はかなり小さいですね。次の章でこのリストにさらに追加していきますが、あまり複雑にはならないでしょう。 DebugPrintf はユーザモードからアクセス可能であり(カーネルページがマッピングされているため)、DebugPrintf は特権命令を使用していないため、ユーザモードのソフトウェアは技術的に何の問題もなくこのルーチンを直接呼び出すことができます。オペレーティングシステムやエグゼクティブソフトウェアの設計によっては、セキュリティや安定性の問題を引き起こす可能性があります。 このため、カーネルページはカーネルモードからしかアクセスできないようにしておくことが一般的に推奨されています。 ソフトウェアは複雑になりますが、最終的には努力に見合った結果を得ることができるかもしれません。 ステップ2:サービスディスパッチャ次のステップは、サービスディスパッチャのISRを作成することです。その前に、どのISRを使うか決めなければなりません。ここではLinuxに倣って0x80を使うことにします。しかし、多くのOSは異なるベクタを使用しています。では、ISRをインストールしましょう。 ISRはHAL層が管理するIDTに格納されていることを思い出してください。また、IDTディスクリプタはそれぞれDPLの設定を持っていることを15章で思い出してください。IDTエントリのDPLがCPLより小さいと、GPFが発生します。つまり、ユーザモードに入ると、DPL 3のIDTディスクリプタを持つISRしか呼び出せなくなるのです。リング3のソフトウェアからシステム割り込みを呼び出したいので、このISRを正しいフラグでインストールしなければなりません。 しかし、現在のHALサブシステムの設計では、setvect()を呼び出すだけでは、特定のフラグを設定することができないため、これを実現することはできません。この問題を回避するために、setvect() は、オプションのフラグを設定できるように、 2番目のパラメータを持つように修正されました。これは、C++のデフォルトのパラメータ機能を使用して実現されているため、他のコードを更新する必要はありません。
これで全部です :) syscall_dispatcherは、システムコール用の ISR です。このISRは、_syscallsで関数を検索することによって、どのシステムサービスを呼び出すかを決定する必要があります。通常、システムAPIは関数番号を識別するためにEAXを使用します。ここでも同じことをするつもりです。上で定義したシステム・サービス・テーブルのおかげで、EAXをインデックスとして使用することができます。したがって、呼び出す関数は_syscalls [eax]となります。
さて、これで呼び出す関数へのポインタができました。しかし、ここでちょっとした問題が発生します。上記は、EAXで与えられた値に基づいて、必要なサービス関数へのポインタを効果的に取得します。しかし、その関数が何であるかはわかりません。また、その関数に何を渡せばいいのか、パラメータはどれくらいあるのかもわかりません。 解決策の1つは、関数呼び出しのためにすべてのレジスタをスタックにプッシュすることである。サービスはすべてCルーチンなので、C関数が期待するような方法でパラメータを渡さなければなりません。
これで終わりです :)add esp, 20は、プッシュした 20 バイトをスタックからポップし、IRETD命令で ISR からリターンしていることに注意してください。 システムソフトウェアやエグゼクティブがそれぞれの割り込みベクタにISRをインストー ルした後、ソフトウェアはソフトウェア割り込みを発行してそれを呼び出すことができま す。例えば、syscall_initを呼んでISRをインストールした場合、次のようにシステムサービスを呼び出すことができます。
設計上の注意点多くのOSは割り込みベクタ番号やレジスタの詳細をC言語のインタフェースで抽象化しています。 大きなOSではシステムサービスを直接呼び出すことも可能ですが、システムがユーザランドソフトウェアに提供するシステムサービスに関する標準Cインタフェースを開発することが推奨されます。 大型のOSでは、ディスプレイにメッセージを表示するようなシステムサービスは一般にありません。むしろ、ユーザがAPIを使ってカーネルモードのサービスやサーバ、デバイスドライバと対話できるような、ユーザランドのソフトウェアから呼び出せるようなサービスが含まれるでしょう。このため、大規模なOSでは、数百の関数呼び出しからなるシステムAPIを含むのが一般的です。 まとめユーザランドへようこそ! これで、ユーザランドとカーネルランドの切り替えに必要なものは全て揃いました。これで、ユーザモードのページをマッピングし、ロードし、ユーザモードでプログラムを実行することができるようになりました。しかし、システムがタスクを管理しないため、OSのカーネルにうまく戻ることはできません。これについては、次の章で見ていきます。 |