GetCurrentDirectoryとGetModuleFileName:作業フォルダと実行ファイルの場所

カレントディレクトリはプロセスに1つで、いつでも変わります。GetModuleFileName の MAX_PATH 切り詰めを防ぐループと、.NET の CurrentDirectory・BaseDirectory・ProcessPath の実測。

2026年11月改訂: この記事は2021年に韓国語で公開したものを書き直しました。C++ のサンプル2本は MinGW-w64 14.0.0(GCC 16.2.0)で Windows 向けにコンパイルしましたが、この記事を書いた Mac では実行していません。 2021年版の実行結果には個人のユーザーフォルダーのパスが含まれていたため削除し、以下の Windows パスはすべて C:\work\app\ 形式の例です。1本目のサンプルは閉じ括弧の後ろに見えない文字(U+200B)が付いていて、そのままコピーするとコンパイルできず、バッファ不足時の戻り値の説明も誤っていました。.NET の3つのパス API はクロスプラットフォームなので、Mac で実際に実行した結果を載せています。

この記事は「プログラムと同じフォルダーの設定ファイルを読みたいのに、起動の仕方によって見つかったり見つからなかったりする」という問題を抱えている方向けです。原因はほとんどの場合、カレントディレクトリ(作業フォルダー)と実行ファイルの場所の取り違えです。結論から言うと、実行ファイルの隣のファイルは GetModuleFileNameW(NULL, …) で得たフォルダー(.NET では AppContext.BaseDirectory)を基準に絶対パスを組み立て、GetCurrentDirectory はユーザーがコマンドラインで渡した相対パスを解決するときにだけ使います。

2つの関数のプロトタイプと、2021年版の説明の訂正

DWORD WINAPI GetCurrentDirectoryW(DWORD nBufferLength, LPWSTR lpBuffer);
DWORD WINAPI GetModuleFileNameW(HMODULE hModule, LPWSTR lpFilename, DWORD nSize);

2021年版のプロトタイプは IpBuffer、IpFileName と、long pointer を表す小文字の l が大文字の I になっていました。動作には影響しませんが、正しくは lpBuffer です。より重要なのは、バッファが足りないときの振る舞いが2つの関数で違うことです。Microsoft Learn に基づいて整理します。

状況 GetCurrentDirectoryW GetModuleFileNameW
成功 コピーした文字数(NUL を除く) コピーした文字数(NUL を除く)
バッファ不足 必要なサイズ(NUL を含む)を返す。バッファには書かない 切り詰めた文字列を書き、nSize を返す。ERROR_INSUFFICIENT_BUFFER
失敗 0 0

2021年版は GetCurrentDirectory について「バッファが足りない場合はバッファの値が返る」と書いていましたが、正確には NUL を含む必要サイズが返ります。だからサイズ 0 で一度呼んで必要な長さを聞くパターンが使えます。一方 GetModuleFileName は必要なサイズを教えてくれず、切り詰めた結果を 0 以外の値とともに返すため、戻り値だけ見ると成功に見えます。ドキュメントによると、Windows XP ではこのとき NUL で終端すらされていませんでした。

2021年のサンプルを再コンパイルした結果

2本とも char Path[MAX_PATH] を使っているので、マルチバイト(ANSI)設定でしか通りません。Unicode 設定では次のとおりです。

$ x86_64-w64-mingw32-g++ -Wall -Wextra -DUNICODE -D_UNICODE orig_cwd.cpp
orig_cwd.cpp:7:35: error: cannot convert 'char*' to 'LPWSTR' {aka 'wchar_t*'}
$ x86_64-w64-mingw32-g++ -Wall -Wextra -DUNICODE -D_UNICODE orig_mod.cpp
orig_mod.cpp:7:29: error: cannot convert 'char*' to 'LPWSTR' {aka 'wchar_t*'}

1本目にはもう1つ落とし穴がありました。ブログの原文では最後の } の後ろにゼロ幅スペース(U+200B)が付いていて、そこまでコピーすると ANSI 設定でもこう失敗します。

orig_cwd_zw.cpp:11:2: error: '\U0000200b' does not name a type

Web エディターを経由したコードには意外とよく紛れ込む文字なので、目に見えない箇所でコンパイルエラーが出たら疑ってみてください。そのほか、2本とも戻り値を一切確認しておらず、MAX_PATH を超えるパスでは切り詰められたパスを黙って表示します。

2021年版の実行結果は Visual Studio 2012 のプロジェクトフォルダーを示していました。カレントディレクトリはプロジェクトフォルダー、実行ファイルはソリューションの Debug フォルダーです。これは Visual Studio がデバッグ時に作業フォルダーをプロジェクトフォルダーに設定するためです。同じ exe をエクスプローラーからダブルクリックすれば、どちらも exe のフォルダーになります。つまり開発中は異なり、配布後はたまたま同じに見えるという状況がよく起きます。例のパスで書き直すと次の形です(実行結果ではなく、構造を示す例です)。

GetCurrentDirectory : C:\work\app\ConsoleApplication4\ConsoleApplication4
GetModuleFileName   : C:\work\app\ConsoleApplication4\Debug\ConsoleApplication4.exe

カレントディレクトリはプロセスに1つ、そして変わる

3段の図。上段:cmd やショートカットの作業フォルダーなどの起動元がカレントディレクトリを決める。これはプロセスに1つの値で、GetCurrentDirectory とすべての相対パスがこれを読む。中段:ファイルダイアログと、任意のスレッドからの SetCurrentDirectory も同じカレントディレクトリにつながる。下段は別系統:プロセス開始時に固定される実行ファイルで、GetModuleFileName(NULL) と Environment.ProcessPath が読む。
実行ファイルの場所はプロセス開始時に決まって変わらない。カレントディレクトリはプロセスに1つだけの値で、起動した側が決め、その後もファイルダイアログや SetCurrentDirectory を呼ぶどのコードからでも変わる。

カレントディレクトリはプロセス全体で1つだけの値です。GetCurrentDirectory のドキュメントも、SetCurrentDirectory が書き込む値はプロセスごとのグローバル変数に保存されると説明し、そのためマルチスレッドのアプリや共有ライブラリのコードは GetCurrentDirectory を使わず、相対パスを避けるよう勧めています。この値を決めたり変えたりするものは、思ったより多くあります。

  • 起動した側:cmd から起動すれば cmd のカレントフォルダー、ショートカットならプロパティの「作業フォルダー」、タスクスケジューラなら操作の「開始」フォルダーの設定に従います。
  • ファイルダイアログ:コモンファイルダイアログは、ユーザーがフォルダーを移動する間にカレントディレクトリを変えることがあります。OFN_NOCHANGEDIR の説明には、このフラグは GetOpenFileName では効果がないという注記まで付いています。「ファイルを1つ開いたら相対パスがおかしくなった」という不具合の定番の原因です。
  • ほかのコード:自分のスレッドでなくても、プラグインやライブラリが SetCurrentDirectory を呼べば、すべてのスレッドに影響します。

そのため fopen("settings.json", …) のように相対パスで開くコードは、起動方法によって結果が変わります。.NET で実際に確かめました。次の3つの API はクロスプラットフォームなので、Mac(Apple Silicon、.NET 10.0.14、Release)で実行しています。出力のパスは /work/app(ビルド出力)、/work/src(プロジェクトフォルダー)、/Users/me(ホームフォルダー)に置き換えました。

Print("start");
Directory.SetCurrentDirectory(Path.GetTempPath());   // プロセス内のどのコードでもできる
Print("after SetCurrentDirectory");

static void Print(string when)
{
    Console.WriteLine($"[{when}]");
    Console.WriteLine($"  Environment.CurrentDirectory : {Environment.CurrentDirectory}");
    Console.WriteLine($"  AppContext.BaseDirectory     : {AppContext.BaseDirectory}");
    Console.WriteLine($"  Environment.ProcessPath      : {Environment.ProcessPath}");
}

ホームフォルダーからアプリの実行ファイルを直接起動した結果です。

[start]
  Environment.CurrentDirectory : /Users/me
  AppContext.BaseDirectory     : /work/app/
  Environment.ProcessPath      : /work/app/Where
[after SetCurrentDirectory]
  Environment.CurrentDirectory : /private/var/folders/…/T
  AppContext.BaseDirectory     : /work/app/
  Environment.ProcessPath      : /work/app/Where

プロジェクトフォルダーから dotnet /work/app/Where.dll で起動するとこうなります。

[start]
  Environment.CurrentDirectory : /work/src
  AppContext.BaseDirectory     : /work/app/
  Environment.ProcessPath      : /opt/homebrew/Cellar/dotnet/10.0.403/libexec/dotnet

分かることは3つです。カレントディレクトリは起動した場所に従い、SetCurrentDirectory 1行ですぐ変わります。AppContext.BaseDirectory はどう起動してもアプリのフォルダーで、末尾に区切り文字が付きます。Environment.ProcessPath はプロセスの実行ファイルなので、dotnet xxx.dll で起動するとアプリではなく dotnet ホストを指します(dotnet run はアプリの実行ファイルを起動するので /work/app/Where でした)。アプリの隣のファイルを探すなら BaseDirectory が正解です。

もう1つ、単一ファイル発行(-p:PublishSingleFile=true)では typeof(Program).Assembly.Location が空文字列になります。発行時に実際に次の警告が出て、実行結果も '' でした。

warning IL3000: 'System.Reflection.Assembly.Location.get' always returns an empty string for assemblies embedded in a single-file app. If the path to the app directory is needed, consider calling 'System.AppContext.BaseDirectory'.

同じプログラムで1回あたりの呼び出しコストも測りました(ウォームアップ4ラウンドの後の5ラウンド目、3回実行)。Environment.ProcessPath は 1.1〜1.2ns、昔ながらの Process.GetCurrentProcess().MainModule.FileName は 2,014〜2,141ns で、約1,800倍の差でした。ProcessPath は最初に取得した値をキャッシュするためです。繰り返し呼ぶ場面はまれですが、新しいコードで MainModule を使う理由はありません。

GetModuleFileName:伸びるバッファと長いパス

MAX_PATH は 260 文字です。2021年版は「パスの長さは 260 に制限されている」としていましたが、Windows 10 バージョン 1607 以降は、レジストリの LongPathsEnabled とアプリマニフェストの longPathAware の両方がそろえば多くのファイル API でこの制限を外せます。また \\?\ で始まるパスは以前から約 32,767 文字まで使えました。深いフォルダーにインストールされたアプリでは実行ファイルのパスが 260 文字を超えうるので、固定長のバッファは使わずに次のように書きます。

// x86_64-w64-mingw32-g++ -municode -Wall -Wextra paths2026.cpp -o paths2026.exe
#include <windows.h>
#include <cstdio>
#include <string>

// GetModuleFileNameW は必要なサイズを教えない:収まるまで広げる
std::wstring ExePath(DWORD start = MAX_PATH)
{
    std::wstring buf(start, L'\0');
    for (;;) {
        DWORD n = GetModuleFileNameW(nullptr, buf.data(), (DWORD)buf.size());
        if (n == 0) return {};                                   // 本当の失敗
        if (n < buf.size()) { buf.resize(n); return buf; }       // 収まった
        // n == size:切り詰め(Vista 以降は ERROR_INSUFFICIENT_BUFFER)
        if (buf.size() >= 32768) return {};                      // UNICODE_STRING_MAX_CHARS
        buf.resize(buf.size() * 2);
    }
}

// GetCurrentDirectoryW は教えてくれる:サイズを聞いてから受け取る
std::wstring CurrentDir()
{
    DWORD need = GetCurrentDirectoryW(0, nullptr);               // NUL を含む
    if (need == 0) return {};
    std::wstring buf(need, L'\0');
    DWORD n = GetCurrentDirectoryW(need, buf.data());            // NUL を除く
    if (n == 0 || n >= need) return {};                          // その間に変わった
    buf.resize(n);
    return buf;
}

int wmain()
{
    std::wstring exe = ExePath(8);                               // わざと小さく始めてループを通す
    std::wstring dir = exe.substr(0, exe.find_last_of(L"\\/") + 1);
    std::wprintf(L"exe : %ls\n", exe.c_str());
    std::wprintf(L"dir : %ls\n", dir.c_str());
    std::wprintf(L"cwd : %ls\n", CurrentDir().c_str());

    std::wstring config = dir + L"settings.json";               // 実行ファイルの隣は絶対パスで
    std::wprintf(L"open: %ls\n", config.c_str());
    return 0;
}

-Wall -Wextra で警告なしにコンパイルできました。実行はしていないので出力は載せていません。 要点は2つです。GetModuleFileNameW の戻り値がバッファサイズと等しければ切り詰められているので、サイズを2倍にして呼び直します。GetCurrentDirectoryW はサイズ 0 で必要な長さを先に聞きます。2回目の結果が1回目のサイズ以上なら、その間に別スレッドがカレントディレクトリを変えたということなので、やり直すか失敗として扱います。

C# から GetModuleFileNameW を直接呼ぶ必要があるなら(たとえば別の DLL モジュールのパスが欲しいとき)、LibraryImport で同じループを書きます。net10.0-windows、-p:EnableWindowsTargeting=true -p:AnalysisMode=All で警告0件でした。実行ファイル自身のパスなら、前述の Environment.ProcessPath で十分です。

[LibraryImport("kernel32.dll", EntryPoint = "GetModuleFileNameW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
private static partial uint GetModuleFileName(nint hModule, Span<char> buffer, uint size);

internal static string ModulePath(nint module = 0)
{
    for (int size = 260; size <= 32768; size *= 2)
    {
        char[] buf = new char[size];
        uint n = GetModuleFileName(module, buf, (uint)size);
        if (n == 0) throw new System.ComponentModel.Win32Exception();
        if (n < size) return new string(buf, 0, (int)n);
        if (Marshal.GetLastPInvokeError() != ERROR_INSUFFICIENT_BUFFER)   // 122
            throw new System.ComponentModel.Win32Exception();
    }
    throw new PathTooLongException();
}

使い分けのまとめ

知りたいもの Win32 .NET
実行ファイルのパス GetModuleFileNameW(NULL, …) + 伸びるバッファ Environment.ProcessPath
アプリのファイルがあるフォルダー 上のパスからファイル名を除く AppContext.BaseDirectory
ユーザーが起動した場所(相対パスの解決) GetCurrentDirectoryW Environment.CurrentDirectory
  • カレントディレクトリはプロセスに1つで、起動方法・ファイルダイアログ・ほかのコードによって変わります。自分のファイルの基準にはしません。
  • GetModuleFileName は切り詰めても 0 を返しません。戻り値がバッファサイズと等しければ広げて呼び直します。
  • MAX_PATH 前提の固定バッファではなく、サイズを聞くか広げるパターンを使います。

ウィンドウのタイトルを扱う SetWindowText と GetWindowText、キーの状態を扱う GetKeyState と GetAsyncKeyState も、同じ方法で検証し直しています。

よくある質問

設定ファイルを GetCurrentDirectory 基準で読んでもよいですか?

いいえ。カレントディレクトリは起動した側(cmd、ショートカットの作業フォルダー、タスクスケジューラ)が決め、実行中もファイルダイアログや別スレッドの SetCurrentDirectory で変わります。実行ファイルの隣のファイルは GetModuleFileName(NULL) で得たフォルダー、.NET なら AppContext.BaseDirectory を基準に絶対パスを作ります。

GetModuleFileName のバッファを MAX_PATH にするとなぜ危ないのですか?

パスがバッファに収まらないと、切り詰めた文字列を書き込んで nSize をそのまま返し、ERROR_INSUFFICIENT_BUFFER を設定します。戻り値が 0 ではないので成功に見えます。戻り値がバッファサイズと等しければ、バッファを2倍にして呼び直すループが必要です。長いパスを有効にした環境では 260 文字を超えるパスが実際に現れます。

.NET の Environment.ProcessPath と AppContext.BaseDirectory は何が違いますか?

ProcessPath は実行中のプロセスの実行ファイル、BaseDirectory はアプリのファイルがあるフォルダーです。アプリの実行ファイルから起動すれば同じフォルダーを指しますが、dotnet Where.dll のように起動すると ProcessPath は dotnet ホストになります。アプリの隣のファイルを探すなら BaseDirectory を使います。

  • #Win32
  • #GetCurrentDirectory
  • #GetModuleFileName
  • #MAX_PATH
  • #.NET

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑