Programming Field - プログラミング Tips

Windows で Unix の fork をしたい

スポンサーリンク

fork とは、Unix や Linux などで使える C 言語の関数です。(詳しくは「fork - Wikipedia」など。)

Windows でこれを実装しようとすると、かなりのクセがあります。何が問題になるかというと、「fork で作成される子プロセスは親プロセスのコピーで、メモリなどの状態がほぼすべて同じでなければならない」という点と、「fork が完了した時点で、親プロセスも子プロセスも同じ位置から実行を再開しなければならない(しかも戻り値は異なる)」という点です。(Cygwin ではこれを上手く実装しています。)

メモリブロックのコピー

メモリブロックについては、親プロセスと子プロセスが持つ同じ内容のメモリブロックは、同じアドレスでないとコピーとはいえません。これは、VirtualAlloc で割り当てたメモリブロックだけでなく malloc で割り当てたメモリブロックにも当てはまります。そこで親プロセスが子プロセスを作成する際、いかにメモリブロックをコピーするかが焦点の1つです。子プロセス側で親プロセスと同じアドレスにメモリブロックを割り当て、そこにデータをコピーすれば問題ありません。VirtualAlloc では、割り当てたいアドレスの位置を指定できるため、MEM_RESERVE フラグと MEM_COMMIT を組み合わせることで、使用されていない限りそのアドレスを確保できます。

ところが malloc では、単に同じサイズのメモリブロックを割り当てても同じアドレスになるとは限りません。HeapAlloc も然りです。アドレスを指定できる VirtualAlloc を使いたいですが、小さいメモリブロックに対して VirtualAlloc を使うのはもったいないです。ヒープが同じアドレスにコピーできれば楽なのですが・・・

そこで、VirtualAlloc を使って独自のヒープを作ることでこの問題を解決してしまいます。実際の実装については今度書きたいと思いますが、実装すべき関数は malloc、realloc、_expand、_msize、free の各関数、あるいは IMalloc インターフェイスの各メソッド程度があれば十分と思われます。実装の概要としては、大きなメモリブロックを VirtualAlloc で割り当て、そこから虫食い的に小さいメモリブロックを「作って」malloc の戻り値にする、といった感じです。このヒープを子プロセスにコピーするのは、前述の VirtualAlloc で割り当てたメモリブロックのコピー法をこのヒープの大きなメモリブロックに適用するだけでOKです。

※ このヒープを作ったら、コピーしたいメモリブロックに対しては C ランタイムの malloc や、それを使う関数を呼び出して割り当てないように注意してください。

最後に肝心のコピー方法ですが、WriteProcessMemory 関数を使うことでコピーすることができます。同じアドレスを子プロセスに割り当ててもらうときも、やはりこの関数を使い、グローバル変数にアドレスをコピーさせることで可能です。(グローバル変数は同じアドレスになる(親プロセスと子プロセスが同じアドレスで読み込まれていれば、の話)ので・・・)

実行位置とレジスタ

続いて、「fork が完了した時点で、親プロセスも子プロセスも同じ位置から実行を再開しなければならない(しかも戻り値は異なる)」という条件を満たすようなコードを紹介します。

※ 以降、32 ビット Intel 系(互換)のプロセッサを想定しています。

現在プログラムが実行している位置(アドレス)は eip レジスタに格納されており、実行が進むたびにこの値は変化していきます。

このアドレスは、当然メモリアドレスと全く同じものなので、メモリブロック等をコピーして実行する準備が整ったら、このレジスタの値を親子で揃えれば実行位置が同じになります。

eax, ebx, ecx, edx, esi, ebp の各レジスタの値をコピーする方法については、「まとめ」のコードを参照してください。このうち eax レジスタは、戻り値などに使われちょくちょく書き換わるので、コピーする必要はありません。

さらに eip レジスタについては、直接値を取得・書き換えすることが出来ないので、スタックと call / ret を使って処理します。

スタック

親プロセス子プロセス
実行位置スタック位置実行位置スタック位置
0x004805010x0013FC400x004010010x0015FFF0
0x004805010x0013FC400x004805010x0015FFF0

ところが、ただ単に実行位置を揃えただけではすぐエラーとなってしまいます。この原因はスタックの内容が親子で異なるためで、子プロセス側で実行位置だけを変更しても、その位置から実行するコードが親プロセスと同じ位置のスタックを読み込もうとしたとき、その値は未定義であり、エラーが起きるのも目に見えています。(スタックについての説明は別ページに設けました。)

さらに、同じプログラムを最初から起動しても、同じ位置で止めたときのスタック位置は異なる場合があります。すなわち、プログラムを実行するときはいつも同じスタックアドレスで開始するとは限らない、ということです。

そこで、少々強引ですがスタックもメモリブロックであるため、自分でスタックを用意してしまいます。

スタック メモリブロックは、サイズが大きくなるのとアドレスを揃えるのを楽にするため、VirtualAlloc 関数で割り当てます。メモリブロックのサイズは基本的に大きく取ります。0x100000 ぐらいで十分かもしれません。そして割り当てたメモリブロックをスタックにするには、メインコードが始まる直前辺りで書き換えを行います。ただし、esp に書き込む際はメモリブロックの一番後ろ部分のアドレスをセットするようにしてください。

以下はそのコード例です。

[C/C++]

DWORD s_dwOldStack;

int __stdcall call_main(LPVOID lpStack, int (* pfnMain)());
int my_main();

int __stdcall start_routine()
{
    LPVOID lpAddress;
    int nRet;

    // スタックのメモリブロックを割り当てる
    lpAddress = VirtualAlloc(NULL, 0x100000, MEM_COMMIT, PAGE_READWRITE);
    nRet = call_main(((LPBYTE) lpAddress) + 0x100000, my_main);
    VirtualFree(lpAddress, 0, MEM_RELEASE);
    // 場合によっては exit や ExitProcess をここで呼び出して終了する
    return nRet;
}

// メインコードを実行する直前にスタックを変更する関数
__declspec(naked) int __stdcall call_main(LPVOID, int (*)())
{
    __asm
    {
        push  ebp
        mov   ebp, esp
        push  ecx
        ; 引数 lpStack を取得
        mov   ecx, dword ptr[ebp + 8]
        mov   esp, ecx
        ; 古いスタックは ebp に残っているので取得する
        mov   s_dwOldStack, ebp
        ; 引数 pfnMain を取得
        mov   ecx, dword ptr[ebp + 0Ch]
        call  ecx

        ; スタックを元に戻す
        mov   esp, s_dwOldStack
        pop   ebp
        ret   8
    }
}

// メインコード内で exit が呼ばれ、アプリケーションを終了するとき
__declspec(naked) int __stdcall exit_called(int)
{
    __asm
    {
        ; 引数を取得する(スタックを戻した後では取得できない)
        mov   eax, dword ptr[esp + 4]
        ; スタックを元に戻す
        mov   esp, s_dwOldStack
        ; ここでスタックのメモリを解放しても良い

        pop   ebp
        ; ここに来る時点では、スタックは call_main のときのままなので
        ; ret では call_main の引数にあわせて 8 を書く
        ret   8
    }
}

これにより、スタックの位置、そしてスタックのデータまで子プロセスにコピーすることができるようになります。(VirtualAlloc で割り当てているのでメモリブロックを渡すのと同じ。)

まとめ

以上の内容を実装してみると、以下のようになります。

※ このコードは、自分が書いたコードの一部を変更したもので、動作確認をしていません。

// 上のコードで定義済み
//DWORD s_dwOldStack;

// レジスタなどをキープする構造体・変数
struct CRegisterData
{
    DWORD Edi;
    DWORD Esi;
    DWORD Ebx;
    DWORD Edx;
    DWORD Ecx;
    DWORD Ebp;
    DWORD Eip;
    DWORD Esp;
    // KeepRegisterData から戻るアドレスをキープする変数
    // (スタックが書き換わる可能性がある)
    DWORD RetAddr;
} g_regData;

// eip を戻り値として取得する
__declspec(naked) DWORD __stdcall GetEIP()
{
    __asm
    {
        mov   eax, dword ptr[esp]
        ret
    }
}

// eip を eax の内容に変更するために使用する
__declspec(naked) int __stdcall SetEIP()
{
    __asm
    {
        mov   dword ptr[esp], eax
        xor   eax, eax
        ret
    }
}

// レジスタをキープしたときは false、
// レジスタが戻ったときは true が返る
__declspec(naked) bool __stdcall KeepRegisterData()
{
    __asm
    {
        mov   g_regData.Edi, edi
        mov   g_regData.Esi, esi
        mov   g_regData.Ebx, ebx
        mov   g_regData.Edx, edx
        mov   g_regData.Ecx, ecx
        mov   g_regData.Ebp, ebp
        mov   g_regData.Esp, esp
        ; KeepRegisterData から戻るアドレスをキープする
        mov   eax, dword ptr[esp]
        mov   g_regData.RetAddr, eax
        call  GetEIP
        ; eax に 0 が入っているのは SetEIP から戻ってきたとき
        test  eax, eax
        je    OnRegisterRestored
        mov   g_regData.Eip, eax
        xor   eax, eax
        ret
OnRegisterRestored:
        inc   eax
        ret
    }
}

// 引数は call_main と揃えているが使用しない
__declspec(naked) int __stdcall RestoreRegisterData(LPVOID, int (*)())
{
    __asm
    {
        push  ebp
        mov   ebp, esp
        ; 古いスタックは ebp に残っているので取得する
        mov   s_dwOldStack, ebp

        ; レジスタを全て書き換える
        mov   edi, g_regData.Edi
        mov   esi, g_regData.Esi
        mov   ebx, g_regData.Ebx
        mov   edx, g_regData.Edx
        mov   ecx, g_regData.Ecx
        mov   ebp, g_regData.Ebp
        mov   esp, g_regData.Esp
        ; KeepRegisterData から戻るアドレスをスタックに戻す
        mov   eax, g_regData.RetAddr
        mov   dword ptr[esp], eax
        ; eip は直接書き換えられないので関数を利用する
        mov   eax, g_regData.Eip
        call  SetEIP
        ; ここには絶対に来ない(Win32 ではエラーとなる命令を置く)
        hlt
    }
}

// fork されたプロセスかどうかを示す変数
// WriteProcessMemory で書き込むために使う
bool g_bForked = false;

// CopyProcessMemory: 子プロセス (hProcess) にメモリをコピーする
// RestoreProcessMemory: 親プロセスからメモリを取得する
// ※ CopyProcessMemory の内部で子プロセスに親プロセスのハンドルを
//   渡すコードを書く(DuplicateHandle と WriteProcessMemory を使う)
void CopyProcessMemory(HANDLE hProcess);
void RestoreProcessMemory();

// fork の簡易実装版
// (ファイルハンドルなどは継承されない)
int myfork()
{
    STARTUPINFO si;
    PROCESS_INFORMATION pi;
    bool bVal;
    SIZE_T size;

    // レジスタをキープする
    if (!KeepRegisterData())
    {
        memset(&si, 0, sizeof(si));
        si.cb = sizeof(si);
        // 同じコマンド引数でプロセスを作成する
        // メモリの内容をコピーするため、休止状態にする
        if (!CreateProcess(NULL, GetCommandLine(), NULL, NULL,
          FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi))
            return -1;
        // 子プロセスの g_bForked を true に設定する
        bVal = true;
        WriteProcessMemory(pi.hProcess, &g_bForked,
            &bVal, sizeof(bVal), &size);
        // 子プロセスにメモリ内容をコピーする
        CopyProcessMemory(pi.hProcess);
        // プロセスを再開する
        ResumeThread(pi.hThread);
        CloseHandle(pi.hThread);
        CloseHandle(pi.hProcess);
        // 親プロセスは子プロセスの ID を返す
        return (int) pi.dwProcessId;
    }
    else
    {
        // RestoreRegisterData が eip をセットすると、
        // KeepRegisterData は true を返すので
        // この位置に処理が移る

        // 子プロセスは 0 を返す
        return 0;
    }
}

// fork された子プロセスの場合、メモリを読み込んで
// 実行位置を調整する
// pnRet には my_main の戻り値が入る
bool CheckForked(int* pnRet)
{
    if (!g_bForked)
        return false;
    // メモリを読み込む
    RestoreProcessMemory();
    // レジスタを読み込んで実行位置を変える
    *pnRet = RestoreRegisterData(NULL, NULL);
    return true;
}

// 通常のメイン関数
int main(int argc, char* argv[])
{
    int nRet;
    if (CheckForked(&nRet))
        return nRet;
    return start_routine();
}

// myfork が使える状態にあるメイン関数
int my_main()
{
    int nVal;
    nVal = 4;
    if (myfork())
    {
        printf("in parent process: nVal = %d\n", nVal);
    }
    else
    {
        printf("in child process: nVal = %d\n", nVal);
    }
}

その他

実際の fork に近づけるには、必要に応じてファイルハンドルなどを子プロセスに渡す(継承する)処理が必要になります。C ランタイムのファイル ディスクリプタについては _spawn 系関数を用いて子プロセスを作成すると継承してくれますが、上のコードでは使用していません。(もし使いたい場合は、CreateProcess(A/W)をフックして上のコードのように CREATE_SUSPENDED が設定されるようにしなければなりません。)

最終更新日: 2008/03/04