.NET Core 和 .NET Framework 启动可执行文件的差别
在 Windows 下,使用 .NET Framework 構建出來的應用,可以只有一個可執行文件,在可執行文件里面包含了 IL 代碼。使用 .NET Core 構建出來的應用,將會包含一個 Exe 可執行文件,和對應的 Dll 文件,而 IL 代碼將放在 Dll 文件里面。那么使用 .NET Framework 和使用 .NET Core 所輸出的 Exe 可執行文件有什么差別,本文將從文件格式以及啟動過程兩個方面給大家聊聊這兩個的不同
與封閉的 dotnet framework 不相同的是,咱可以從 GitHub 上獲取完全的 dotnet core 整個的源代碼。也因此咱能了解到的 dotnet core 的細節將會比 dotnet framework 多得多。在本文開始之前,必須要說明的是,本文非入門向,而且本文的內容是需要在很多的限制條件下才成立。在經過了這么多年的發展,無論是 dotnet framework 還是 dotnet core 都有著各自的不同的多個版本甚至的多個分支,每個之間都有著很多差別。而限于我的技術和考古能力,我僅僅能聊的只有最通用的部分
當前有很多書籍在講 dotnet core 的底層,我在這里將這些書籍推薦給期望了解更多底層細節的伙伴:農夫的 《.NET Core底層入門》 以及偉民哥翻譯的 《.NET內存管理寶典 - 提高代碼質量、性能和可擴展性》 這兩本書。本文的很多細節出處都來自于這兩本書
本文所指的 dotnet core 包括了 dotnet core 以及 dotnet 5 等多個版本,不討論加入 Mono 以及加入 .NET Native 和單文件發布等科技。本文的 dotnet framework 指的是 dotnet framework 4.0 到 4.8 的版本,其他版本不在本文范圍內,根據我的考古,更古老的 dotnet framework 有不同的行為,但我缺乏足夠的依據,因此也不在放在本文
先從文件的格式開始聊起。在咱寫一個默認的 .NET Framework 控制臺應用的時候,在 VisualStudio 上進行 Debug 調試構建輸出,此時將可以從輸出文件路徑上看到僅僅只有一個 EXE 可執行文件,而沒有 DLL 動態鏈接庫文件。這個輸出的 Exe 可執行文件是一個符合標準的 PE 格式的文件。而 PE (Portable Executable)格式文件是微軟 Win32 環境可執行文件的標準文件格式。也就是說使用 .NET Framework 輸出的可執行文件和其他 Win32 可執行文件的文件格式是相同的。盡管格式上是相同的,微軟在 Windows 下依然對 .NET Framework 應用做了特別的處理,因為在 .NET Framework 輸出的可執行文件里面,包含了從元數據和 MSIL 代碼,換句話說就是真正的邏輯是包含在 MSIL 代碼里面,而不是作為本機代碼的存在。這就需要 Windows 系統在用戶執行 .NET Framework 可執行文件的時候進行一些特殊的處理
那既然 .NET Framework 的可執行文件在執行時需要 Windows 做特殊的處理,那么 Windows 如何了解到這是一個需要處理的 .NET Framework 應用?根據 Managed Execution Process 官方文檔?可以了解到,在 .NET Framework 的輸出可執行文件里面,在 PE 文件的 COFF 頭內容添加了特殊的內容,用來標識這是一個 .NET Framework 應用
在運行 .NET Framework 的可執行文件的時候,首先進入的 operating system loader 將會判斷 PE 文件的 COFF 頭內容,通過 COFF 頭識別這個可執行文件是否 .NET Framework 可執行文件。對于 .NET Framework 可執行文件而言,將會加載 mscoree.dll 進行執行,通過?_CorValidateImage?和?_CorImageUnloading?分別用來通知 operating system loader 托管模塊的映像的加載和卸載。其中在?_CorValidateImage?中將執行確保該代碼是有效的托管代碼以及將映像中的入口點更改為運行時中的入口點。而在 x64 中,還會在?_CorValidateImage?中通過在內存中修改映像的 PE32 為 PE32+ 格式。也因為 .NET Framework 應用是依靠系統的特殊處理,因此 .NET Framework 又有一個原因耦合了系統環境,這和 .NET Core 的啟動有著本質的差別
回到 .NET Core 下,依然是通過 VisualStudio 在 Debug 下構建輸出一個控制臺的應用。此時可以看到構建輸出將會包含一個 Exe 和對應的 Dll 文件。通過 dotnet core 應用是如何跑起來的 通過AppHost理解運行過程?可以了解到,在 .NET Core 的默認輸出的 Exe 可執行文件是 AppHost 文件,這是一個純 Win32 可執行文件,里面不包含 IL 代碼。而業務邏輯的 IL 代碼是存放在 DLL 里面
在 .NET Core 下,輸出的 Exe 可執行文件其實是通過預先已經構建完畢的模板文件,進行二進制修改替換生成的文件。在構建的過程中,將會從?C:\Program Files\dotnet\sdk\5.0.100\AppHostTemplate\?文件夾里面將 apphost.exe 文件拷貝到項目的 obj 文件夾下。然后執行 HostWriter 構建過程命令,將 AppHost 中的特定二進制內容替換為具體項目的信息,接著拷貝到最終輸出文件路徑。當然,上文的 AppHostTemplate 文件夾路徑會根據你所安裝的 dotnet sdk 版本而變化。那么這個輸出的 AppHost 文件是誰構建的,作用又是什么?細節部分如下
通過開源的 .NET Core 倉庫,可以了解到在?dotnet runtime\src\installer\corehost\?文件夾里面,其實就是 AppHost 文件的核心邏輯。通過閱讀?dotnet runtime\src\installer\corehost\corehost.cpp?文件可以了解到在 AppHost 文件在執行過程中所執行的過程。在?dotnet runtime\src\installer\corehost\corehost.cpp?文件的?exe_start?大概就是整個可執行文件的入口方法了,在這里實現的功能將包含使用 hostfxr 和 hostpolicy 來托管執行整個 dotnet 進程,以及主函數的調起。而在使用托管之前,需要先尋找 dotnet_root 也就是 dotnet 框架用來承載整個 dotnet 進程。而不同的項目之間有著不同的項目信息,需要在執行過程中進行動態配置。這就是在構建過程中 HostWriter 構建過程所執行的邏輯,將預先構建完成的 AppHost 中的部分內容替換為具體項目的信息,同時給 AppHost 嵌入 Win32 清單,如圖標等內容
在 .NET Core 中,在 SDK 里面已經包含了將?dotnet runtime\src\installer\corehost\?文件夾里面的 AppHost 構建輸出的 AppHost.exe 文件。在此文件里面由以下代碼定義了部分模板替換內容
#define EMBED_HASH_HI_PART_UTF8 "c3ab8ff13720e8ad9047dd39466b3c89" // SHA-256 of "foobar" in UTF-8 #define EMBED_HASH_LO_PART_UTF8 "74e592c2fa383d4a3960714caef0c4f2" // 這兩句代碼就是 foobar 的 UTF-8 二進制的 SHA-256 字符串 #define EMBED_HASH_FULL_UTF8 (EMBED_HASH_HI_PART_UTF8 EMBED_HASH_LO_PART_UTF8) // NUL terminatedbool is_exe_enabled_for_execution(pal::string_t* app_dll) {constexpr int EMBED_SZ = sizeof(EMBED_HASH_FULL_UTF8) / sizeof(EMBED_HASH_FULL_UTF8[0]);// 這里給的是就是最長 1024 個 byte 的 dll 名,加上一個 \0 一共是 1025 個字符constexpr int EMBED_MAX = (EMBED_SZ > 1025 ? EMBED_SZ : 1025); // 1024 DLL name length, 1 NUL// 這就是定義在 AppHost.exe 二進制文件里面的一段空間了,長度就是 EMBED_MAX 長度,內容就是 c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2 這段字符串static char embed[EMBED_MAX] = EMBED_HASH_FULL_UTF8; // series of NULs followed by embed hash stringstatic const char hi_part[] = EMBED_HASH_HI_PART_UTF8;static const char lo_part[] = EMBED_HASH_LO_PART_UTF8;// 將 embed 的內容復制到 app_dll 變量里面pal::clr_palstring(embed, app_dll); }int exe_start(const int argc, const pal::char_t* argv[]) {// 讀取嵌入到二進制文件的 App 名,也就是 dotnet 的入口 dll 路徑,可以是相對也可以是絕對路徑pal::string_t embedded_app_name;if (!is_exe_enabled_for_execution(&embedded_app_name)){trace::error(_X("A fatal error was encountered. This executable was not bound to load a managed DLL."));return StatusCode::AppHostExeNotBoundFailure;}// 將 embedded_app_name 的內容賦值給 app_path 變量,這個變量的定義代碼我沒有寫append_path(&app_path, embedded_app_name.c_str());const pal::char_t* app_path_cstr = app_path.empty() ? nullptr : app_path.c_str();// 跑起來 dotnet 應用rc = hostfxr_main_bundle_startupinfo(argc, argv, host_path_cstr, dotnet_root_cstr, app_path_cstr, bundle_header_offset); }也如上面代碼所述,在 AppHost 文件里面將會通過 hostfxr_main_bundle_startupinfo 跑起來 dotnet 應用,加載 CLR 執行引擎,執行 DLL 里面的 IL 邏輯
在具體項目構建過程中,將通過 HostWriter 構建過程,執行如下邏輯,將如上面代碼的 foobar 的 UTF-8 二進制的 SHA-256 字符串替換為具體項目的路徑
/// <summary>/// Embeds the App Name into the AppHost.exe/// If an apphost is a single-file bundle, updates the location of the bundle headers./// </summary>public static class HostWriter{/// <summary>/// 這就是 AppHost 的 foobar 的 UTF-8 二進制的 SHA-256 字符串/// </summary>private const string AppBinaryPathPlaceholder = "c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2";private static readonly byte[] AppBinaryPathPlaceholderSearchValue = Encoding.UTF8.GetBytes(AppBinaryPathPlaceholder);/// <summary>/// Create an AppHost with embedded configuration of app binary location/// </summary>/// <param name="appHostSourceFilePath">The path of Apphost template, which has the place holder</param>/// <param name="appHostDestinationFilePath">The destination path for desired location to place, including the file name</param>/// <param name="appBinaryFilePath">Full path to app binary or relative path to the result apphost file</param>/// <param name="windowsGraphicalUserInterface">Specify whether to set the subsystem to GUI. Only valid for PE apphosts.</param>/// <param name="assemblyToCopyResorcesFrom">Path to the intermediate assembly, used for copying resources to PE apphosts.</param>public static void CreateAppHost(string appHostSourceFilePath,string appHostDestinationFilePath,string appBinaryFilePath,bool windowsGraphicalUserInterface = false,string assemblyToCopyResorcesFrom = null){var bytesToWrite = Encoding.UTF8.GetBytes(appBinaryFilePath);if (bytesToWrite.Length > 1024){throw new AppNameTooLongException(appBinaryFilePath);}void RewriteAppHost(){// Re-write the destination apphost with the proper contents.using (var memoryMappedFile = MemoryMappedFile.CreateFromFile(appHostDestinationFilePath)){using (MemoryMappedViewAccessor accessor = memoryMappedFile.CreateViewAccessor()){BinaryUtils.SearchAndReplace(accessor, AppBinaryPathPlaceholderSearchValue, bytesToWrite);appHostIsPEImage = PEUtils.IsPEImage(accessor);if (windowsGraphicalUserInterface){if (!appHostIsPEImage){throw new AppHostNotPEFileException();}PEUtils.SetWindowsGraphicalUserInterfaceBit(accessor);}}}}// 忽略代碼}}那為什么在 dotnet 里面選擇的是預先構建出來 AppHost 文件,將 AppHost 文件放在 SDK 里面。在構建代碼的時候通過構建過程將 AppHost 文件的部分二進制替換而輸出為最終可執行文件?原因就是 dotnet core 期望能做到讓構建過程盡可能簡單,同時又期望能支持更多的平臺。通過預先構建出來可執行二進制文件,可以在構建出來的二進制文件盡可能使用 Native 的邏輯,而一旦使用了 Native 的邏輯就意味著構建環境有足夠的要求。但是在開發者端,其實很沒有必要去安裝那么多的構建環境,因此就預先構建出來二進制文件,這樣開發者端就能專注于構建 dotnet 的邏輯,而不需要為了構建入口的可執行文件而安裝大量的構建環境
在 dotnet core 里面,所有的 IL 邏輯存放在獨立的 DLL 里面,在 Windows 下的可執行文件僅僅是 AppHost 文件,是一個沒有被系統特殊處理的 Win32 可執行文件。在運行過程中,將在一開始執行 AppHost 的本機邏輯,在 AppHost 的 Native 邏輯里面將跑起來 dotnet 引擎,加載 DLL 里面的 IL 邏輯,然后將舞臺交給咱的業務邏輯
回顧一下 dotnet core 和 dotnet framework 的可執行文件的差別
文件內容的差別是:
- .NET Core: 純 Win32 的 PE 格式文件,不包含 IL 邏輯。包含 IL 邏輯的放在額外的 Dll 文件 
- .NET Framework: 稍微特殊的 Win32 的 PE 格式文件,包含了特殊 COFF 頭內容用來標識這是 .NET Framework 文件。在 PE 格式文件里面包含了 IL 邏輯 
啟動的時候的差別是:
- .NET Core: 作為傳統的 Win32 應用啟動,在啟動過程中加載 CLR 引擎,然后通過 CLR 引擎執行 IL 邏輯 
- .NET Framework: 由系統根據 COFF 頭判斷這是 .NET Framework 應用,通過特殊手段啟動,使用系統的 mscoree.dll 進行初始化 
這就是 .NET Framework 和 .NET Core 啟動的可執行文件的差別,以及執行的差別
現在的 .NET Framework 的運行時大部分邏輯都沒有開源(我即使能通過MVP權限拿到我也不敢在這里吹)因此只能通過官方公開的文檔了解到細節,而 .NET Core 是完全開源的,因此我對 .NET Core 里面的邏輯相對來說更了解。好在后續將會統一使用為完全開源的 .NET 5 以及后續版本,所以即使對 .NET Framework 的執行細節不了解,問題也許不大
關于 .NET Core 底層接地氣的書籍,我推薦農夫的 《.NET Core底層入門》 這本書。而關于內存相關,我推薦偉民哥翻譯的 .NET內存管理寶典 - 提高代碼質量、性能和可擴展性 這本書。
參考
dotnet core 應用是如何跑起來的 通過AppHost理解運行過程
dotnet core 應用是如何跑起來的 通過自己寫一個 dotnet host 理解運行過程
Managed Execution Process
總結
以上是生活随笔為你收集整理的.NET Core 和 .NET Framework 启动可执行文件的差别的全部內容,希望文章能夠幫你解決所遇到的問題。
 
                            
                        - 上一篇: 巧用lock解决缓存击穿的解决方案
- 下一篇: 专业的软件安装包可以这样做!
