MMUを使用しないFork()の実装

読者の皆さん、こんにちは! 数年前、 vfork()について記事で、MMUを使用しないシステムでのfork()の実装について話すことを約束しましたが、今では私の手に届きました:)



この記事では、このような奇妙なfork()



実装方法を説明します。 サードパーティのプログラム-ダッシュfork()



を使用してアプリケーションを実行するインタープリターでの操作性を確認します。



猫の下で誰が気にしてください。



正当な質問が発生する可能性がありますvfork()



がこの目的で存在し、MMUを使用せずにプロセスを作成できる場合、なぜfork()



ある種の切り捨てバージョンを作成するのですか? 実際、アドレス空間を「最大限に」コピーすることを使用しないアプリケーションがいくつかありますが、 vfork()



それらに十分でvfork()



ませんvfork()



子孫でvfork()



を使用する場合、スタックに触れることさえできないことを思い出してください、つまり、関数から戻るか、ローカル変数を変更します)。



dash-POSIX互換の軽量シェル-このようなプログラムの例にすぎません。 簡単にするために、サードパーティのプログラムを呼び出すとき、ダッシュはfork()



使用し、その後両方のプロセス(親と子)でいくつかの静的データを変更し、いくつかのヒープ操作を実行し、子プロセスがexecv()



を使用して目的のプログラムを呼び出し、親がwaitpid()



呼び出しますwaitpid()



そして、子孫が完了するのを待っています。



これらすべての言葉の意味を理解していない人のための小さな教育プログラムですが、何らかの理由で記事を読み続けました:UNIXシステムでは、プロセスはfork()



システムコールを使用して作成されます。 POSIXからの定義により、 fork()



はいくつかの変数を除いてプロセスの正確なコピーを作成する必要があります。 関数が成功すると、関数はゼロの値を子プロセスに返し、子プロセスの数を親に返します(その後、プロセスは「自分の生活」を開始します)。 システムでは、変数の同じアドレス、同じスタックアドレスなどを使用して、2つのプロセスが動作し始めることがわかります。 ただし、仮想メモリの使用により、プロセス間でデータが「混同」されることはありません。親データのコピーが作成され、子プロセスが他の物理アドレスにアクセスします。 仮想メモリの詳細については、 こちらをご覧ください



最新のMMUでは、メモリページのアクセス許可を設定できるため、プロセスを作成するには、変換テーブルを複製し、コピーオンライトフラグでページをマークするだけで十分です。このページに書き込もうとすると、実際にデータを複製します。 MMUの欠如は、この最適化を使用することを不可能にするだけでなく、原則としてfork()



呼び出しを実装する可能性にも疑問を投げかけます。 仮想メモリのハードウェアサポートがない場合、プログラムは仮想アドレスではなく物理アドレスを使用します。つまり、 fork()



取得したプロセスは、親プロセスと同じデータに必然的にアクセスします。



広義には、プロセスのアドレス空間は、特定のプロセスにマップされたすべてのメモリを意味すると理解されています。 これには、プログラムコード、バンチ、スレッドのスタック、部分的にカーネルメモリ、周辺メモリを含むセグメントなどが含まれます。 この記事では、アドレス空間は、ヒープ、静的データ(以下、静的データは.bssおよび.dataセクションのデータを意味します)、およびプロセススタック、つまり両方のプロセスで変更できるデータとして理解されます。 これらのデータを使用して、「フォーク」の作業間の混乱を避けるためにデータを処理する必要があります。



これはマルチタスクOSのfork()



実装であるため、プロセスコンテキストの切り替えが重要な役割を果たします。 プロセッサー時間を共有するオブジェクトのコンテキストの切り替えについては、記事「OSのカーネルでのマルチタスクの編成」に記載されています 。 この場合、アドレス空間も切り替える必要があります。 MMUを備えたシステムで、これが大まかに言えば、変換テーブルへのポインターの変更である場合、MMUがないと、プロセスの正しい動作を保証するのがより困難になります。



1つの方法は、プロセスを切り替えるときに、スタック、ヒープ、および静的メモリの値を交換することです。 もちろん、これは非常に遅いですが、十分に簡単です。 実際、これはMMUなしでfork()



を実装するためのアイデアです。 分岐したプロセスに必要なデータを記憶し、切り替え時に値を作業アドレス空間にコピーします。 スタック、ヒープ、および静的データの場合、これは異なる方法で行われます。



スタック



OSでは、ストリームスタックのメモリは動的に割り当てられませんが、それぞれ静的プールから、.bssの一部にスタックの場所が含まれています。 対応するフローが分岐しているスタックにのみ関心があります。 POSIX定義によると、コピープロセスには1つのスレッド(対応するシステムコールが発生したスレッドのコピー)のみが必要ですが、 fork()



は異なるスレッドから異なるタイミングで呼び出すことができるため、アドレススペース、スタックごとにスレッドのリストを維持する必要があります維持する必要があります。





ヒープデータは、静的ページプールから割り当てられたバッファーに格納されます。



.bssおよび.data



Emboxは(すべてのアプリケーションとともに)単一の静的イメージにコンパイルされるため、.bssセクションと.dataセクションに関する情報はどのELFファイルからも取得されず、一意の名前(たとえば__module_embox__cmd__shell_data_vma



)で共通セクションに保存されます。 どのアプリケーションに関連するデータに関する情報は、コンパイル時に設定される特別な構造に格納されます。



 struct mod_app { char *data; size_t data_sz; char *bss; size_t bss_sz; };
      
      





現在のプロセスの実行中に、そのデータが保存されている場所を見つけることができます。



プログラムを直接起動する場合、対応するデータをメモリ領域にコピーする必要があります(そのため、次回プログラムを起動するときに変数の初期値が正しくなります)。



これらは、システムメモリの割り当てられた部分にコピーするメモリの一部です。



フォーク



上記はfork()



呼び出しの動作を正確に理解するのに十分です。

fork



関数自体はアーキテクチャに依存しています。 アセンブリ言語では、レジスタの転送はfork_body()



呼び出しfork_body()



システムコールのロジックを実装する関数fork_body()



への引数として実装されます。



ほとんどの読者はx86命令セットに精通していますが、はるかに短く理解しやすいため、ARMの実装を紹介します。



レジスタはpt_regs構造に保存されます。



 typedef struct pt_regs { int r[13]; /*    */ int lr; /*   */ int sp; /*   */ int psr; /*  , ARM-  */ } pt_regs_t;
      
      





fork()関数のソースコード
 /*     ,    68  */ sub sp, sp, #68 /*  13       */ stmia sp, {r0 - r12, lr} /*  SP */ str sp, [sp, #56] /*   CPSR    ,   CPSR  r0*/ mrs r0, cpsr; /*  CPSR   */ str r0, [sp, #60]; /*      r0    */ mov r0, sp /*   -   */ b fork_body
      
      







ご覧のとおり、実際、ptregsでは、正しいSP値は保存されず、68バイトシフトされます(この中にpt_regs_t



構造体を配置しpt_regs_t



)。 レジスタを復元するとき、これを考慮します。



fork()呼び出しのアーキテクチャに依存しない部分
 void _NORETURN fork_body(struct pt_regs *ptregs) { struct addr_space *adrspc; struct addr_space *child_adrspc; struct task *parent; pid_t child_pid; struct task *child; assert(ptregs); parent = task_self(); assert(parent); child_pid = task_prepare(""); if (0 > child_pid) { ptregs_retcode_err_jmp(ptregs, -1, child_pid); panic("%s returning", __func__); } adrspc = fork_addr_space_get(parent); if (!adrspc) { adrspc = fork_addr_space_create(NULL); fork_addr_space_set(parent, adrspc); } /* Save the stack of the current thread */ fork_stack_store(adrspc, thread_self()); child = task_table_get(child_pid); child_adrspc = fork_addr_space_create(adrspc); /* Can't use fork_addr_space_store() as we use * different task as data source */ fork_stack_store(child_adrspc, child->tsk_main); fork_heap_store(&child_adrspc->heap_space, task_self()); fork_static_store(&child_adrspc->static_space); memcpy(&child_adrspc->pt_entry, ptregs, sizeof(*ptregs)); sched_lock(); { child = task_table_get(child_pid); task_start(child, fork_child_trampoline, NULL); fork_addr_space_set(child, child_adrspc); thread_stack_set(child->tsk_main, thread_stack_get(thread_self())); thread_stack_set_size(child->tsk_main, thread_stack_get_size(thread_self())); } ptregs_retcode_jmp(ptregs, child_pid); sched_unlock(); panic("%s returning", __func__); }
      
      







ptregs_retcode_jmp()



関数を呼び出すと、親プロセスに戻ります。 次に、子プロセスはプロセスの開始時に同じ呼び出しを使用します。



 static void *fork_child_trampoline(void *arg) { struct addr_space *adrspc; adrspc = fork_addr_space_get(task_self()); fork_stack_restore(adrspc, stack_ptr()); ptregs_retcode_jmp(&adrspc->pt_entry, 0); panic("%s returning", __func__); }
      
      





子孫がexecv()を呼び出した後、重複するアドレススペースをサポートする必要はなくなりました。したがって、コンテキストの切り替え時に何もコピーする必要はありません。



ヘルスチェック



実際、ダッシュでは、この機能で十分でした:)



Emboxをチェックインするには、テンプレートx86 / qemuを実行できます



 git clone https://github.com/embox/embox.git cd embox make confload-x86/qemu make ./scripts/qemu/auto_qemu
      
      





次に、ダッシュを呼び出し、その内部で他のコマンド、たとえばpingを呼び出すことができます。







ほとんどの場合、ダッシュを「突く」ことにより、いくつかの例外を達成することが可能になり、リポジトリに問題を作成することをheしないでください :)



All Articles