作为一个在 1.44 MB 软盘和 56 kbit 调制解调器时代长大的人,我一直都很喜欢小型程序,还在随身携带的软盘上下载了很多小程序。如果遇到有个程序无法放入我的软盘,我就会开始思考:这程序有很多图形吗?有音乐吗?能做很多复杂的事情吗?还是只是过于臃肿了?
不同于过去,如今磁盘空间变得很便宜,巨大的闪存驱动器也十分普遍,导致人们逐渐放弃了对程序大小的优化。
但在传输过程中,程序大小仍然很重要:通过网络传输程序时,MB 相当于秒数。在最佳情况下,100 MBit/S 的速度每秒也只能传输 12 MB。对于一个等待程序下载完成的人来说,五秒和一秒之间的差异可能对其体验产生重大影响:人们通常认为,小于 0.1 秒的都是即时的,3 秒是保持用户流量不中断的极限,超过 10 秒则很难再保证用户的参与。
也就是说,虽然程序“小”不再是必要条件,但“小”依然是更好的。
为了证明这个观点,这篇文章就是一个实验,目的是找出一个有用的自包含 C# 可执行文件到底能有多小。C# 应用程序能否达到用户认为“即时”下载的大小?这是否能让 C# 在目前尚未使用的地方得以应用?
1.
什么是“自包含(self-contained)”?
所谓“自包含”应用程序,是指包含了在操作系统原始安装中运行所需的所有组件的应用程序。
C# 编译器属于一组针对虚拟机的编译器:C# 编译器的输出是一个可执行文件,需要某种虚拟机(VM)才能执行。所以我们不能只安装一个裸机操作系统,就指望在上面运行 C# 编译器生成的程序。
在 Windows 系统上,曾经可通过安装 .NET Framework 来运行 C# 编译器的输出结果。但现在,许多 Windows SKU 不再携带该框架(如 IoT、Nano Server、ARM64 等),.NET Framework 也不支持 C# 语言的最新增强功能,正在被逐渐淘汰。
要使 C# 应用程序能够“自包含”,它需要包含运行时和使用到的所有类库——对于我们预算的 2 KB 来说,要容纳的东西实在太多了!
2.
2 KB 游戏来了!
我们要制作一个图形迷宫游戏,以下是最终成品:
3.
游戏结构
我们需要先构建一些框架,这样才能将像素推送到屏幕上。按理说我们可以从 WinForms 之类的东西开始,但摆脱对 WinForms 的依赖是让程序变小的第一步,所以我打算跳过这一步。为此,我将使用一些众所周知的 Sizecoding 技术(一种构建极小程序的艺术)进行构建。虽然这种受 Sizecoding 启发的框架在一开始并不会给我们带来太多帮助,但在最后一步将变得至关重要。要注意:本文并非 GUI 编程入门,过程中我可能会滥用一些东西。
我将使用 Win32 API 进行构建,让程序具有可移植性,以便在 Linux 上也能运行(Win32 是 Linux 上唯一稳定的 ABI)。
我们首先创建一个窗口。通常情况下,要使用 Win32 创建顶层窗口,人们会首先注册一个具有窗口过程的类来处理消息。我们跳过这一步,直接使用 EDIT 类,它是系统定义的,通常用于文本框小部件。
// This could also be "edit"u8, but banging it out as a little-endian numeric constant is smaller
long className = 'e' | 'd' << 8 | 'i' << 16 | 't' << 24;
IntPtr hwnd = CreateWindowExA(WS_EX_APPWINDOW | WS_EX_WINDOWEDGE, (byte*)&className, null,
WS_VISIBLE | WS_CAPTION | WS_CLIPSIBLINGS | WS_CLIPCHILDREN,
0, 0, Width, Height, 0, 0, 0, 0);
现在我们只需要主循环。每个拥有窗口的线程都需要运行一个消息泵,以便窗口可以自行绘制或对被拖动等情况做出反应。
bool done = false;
while (!done)
{
MSG msg;
while (PeekMessageA(&msg, 0, 0, 0, PM_REMOVE) != BOOL.FALSE)
{
done |= GetAsyncKeyState(VK_ESCAPE) != 0;
DispatchMessageA(&msg);
}
}
现在程序可以运行了,我们将看到一个光标闪烁的白色窗口:
按下 ESC 键即可关闭窗口。
现在让我们在其中绘制一些内容。在 CreateWindowExA 调用后,添加一行以获取窗口的设备上下文:
IntPtr hdc = GetDC(hwnd);
接下来,声明一个变量来保存帧缓冲区-像素的宽度 * 高度。我们这样做有点不合常规,但这样方便在以后的优化中,在可执行文件的数据段内分配此区域。我们将像素的数量乘以 4 来保存每个分量:红色、绿色、蓝色和一个保留字节。
class Screen
{
internal static ScreenBuffer s_buffer;
}
struct ScreenBuffer
{
fixed byte _pixel[Width * Height * 4];
}
需要注意的地方是 fixed byte _pixel[Width * Height * 4] 字段:这是 C# 声明固定数组的语法。固定数组是结构的一部分数组,你可以将其视为一组字段 byte _pixel_0,_pixel_1,_pixel_2,_pixel_3,... _pixel_N,可作为数组访问。此数组的大小需要在编译时是一个常量,以便整个结构的大小是固定的。
我们还需要准备一个结构:BITMAPINFO 结构,用于向 Win32 介绍屏幕缓冲区的属性。我把它放入一个静态变量中,以便稍后将其作为初始化/字面数据块放入可执行文件的数据段中(省去了初始化单个字段的代码)。
class BitmapInfo
{
internal static BITMAPINFO bmi = new BITMAPINFO
{
bmiHeader = new BITMAPINFOHEADER
{
biSize = (uint)sizeof(BITMAPINFOHEADER),
biWidth = Width,
biHeight = -Height,
biPlanes = 1,
biBitCount = 32,
biCompression = BI.RGB,
biSizeImage = 0,
biXPelsPerMeter = 0,
biYPelsPerMeter = 0,
biClrUsed = 0,
biClrImportant = 0,
},
bmiColors = default
};
}
现在我们可以将缓冲区的内容绘制到屏幕上。在 PeekMessage 循环下添加以下内容:
fixed (BITMAPINFO* pBmi = &BitmapInfo.bmi)
fixed (ScreenBuffer* pBuffer = &Screen.s_buffer)
{
StretchDIBits(hdc, 0, 0, Width, Height, 0, 0, Width, Height, pBuffer, pBmi, DIB_RGB_COLORS, SRCCOPY);
}
如果现在运行程序,你会看到一个黑色的窗口,因为因为所有像素都被初始化为零。
如果你想了解迷宫绘制逻辑本身,可以参考“Lode 的计算机图形教程(https://lodev.org/cgtutor/raycasting.html#Textured_Raycaster)”,我就是把 Lode 的 C++ 代码翻译成了 C#,因此没什么好说的。
其中,我唯一做的改变是,注意到向前移动与向后移动是相反的(向左和向右移动也一样),Lode 的原始代码对所有 4 个方向都有额外的处理,但我将其简化为 2 个方向,然后通过乘以-1 得到相反的方向。
差不多就是这样,接下来让我们看看程序大小。
4.
.NET 8 迷宫的默认大小
要用 CoreCLR 生成默认(单文件)配置,请运行:
$ dotnet publish -p:PublishSingleFile=true
这将生成一个 64 MB 大小的单个 EXE 文件,里面包括游戏、.NET 运行时和作为 .NET 标准组成部分的基础类库。你可能会说“比 Electron 好”,认为它还不错,但让我们看看是否能做得更好。
5.
压缩单个文件
.NET 单个可执行文件可以选择压缩,压缩之后程序仍完全一样。让我们打开压缩功能:
$ dotnet publish -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true
现在,我们的游戏大小降到了 35.2 MB,只有开始时的一半,但仍比 2 KB 要大得多。
6.
IL(Intermediate Language)精简程序
通过扫描整个程序并删除未被引用的代码,删除应用程序中未使用的代码。精简过程中可能会破坏某些使用运行时反射查看程序结构的 .NET 程序,但我们没有使用运行时反射,所以精简不会有问题。要在项目中使用精简,需添加一个 PublishTrimmed 属性,如下所示:
$ dotnet publish -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true -p:PublishTrimmed=true
在此设置下,游戏缩小至 10 MB。不错,但还差得远。
7.
本地 AOT 编译
我们还有另一种选择,即使用本地 AOT 部署(Ahead-of-Time Compilation,提前编译)。本地 AOT 部署可生成完全本地化的可执行文件,并根据应用程序的需要定制运行时,不过我们对运行时的需求不多。本地 AOT 部署意味着精简和单文件都是可以省略的,因此我们可以从命令行中删除这些内容。本地 AOT 也没有内置压缩功能。命令行很简单:
$ dotnet publish -p:PublishAot=true
现在游戏大小是 1.13 MB,事情开始变得有趣了。
8.
删除未使用的框架功能
经过精简的本地 AOT 编译部署,提供了一个删除不必要的框架功能或优化输出大小的选项。
因此,我们将:优化大小、关闭对漂亮堆栈跟踪字符串的支持、启用不变全局化、删除框架异常消息字符串。
$ dotnet publish -p:PublishAot=true -p:OptimizationPreference=Size -p:StackTraceSupport=false -p:InvariantGlobalization=true -p:UseSystemResourceKeys=true
变成了 923 KB。此时,我们已经用完了.NET 上官方支持的选项,但游戏大小仍超出了 921 KB。
9.
bflat
bflat 是一个用于 C# 的编译器,构建于官方 .NET SDK 的部分组件之上,其核心是对 dotnet/runtime 代码库进行了一些改动。它内置于 bflat CLI 中,该 CLI 提供了一个可同时针对 IL 和本地代码的 C# 编译器。
你可以用 winget install bflat 进行安装以便跟进。由于 bflat 是基于真正的 .NET 构建的,让我们从上一步开始,删除堆栈跟踪字符串,关闭全局化,并删除框架异常消息:
$ bflat build -Os --no-stacktrace-data --no-globalization --no-exception-messages
882 KB。由于 bflat 进行的主观更改,程序略微变小了一点。
10.
使用 zerolib 的 bflat
当涉及到运行库时,bflat 编译器提供了三个选项:你可以用 .NET 自带的完整运行时库,也可以使用 bflat 自带的名为 zerolib 的最小实现,或者根本不使用标准库。
我们精心设计了游戏,使其能兼容 zerolib 的限制,让我们切换到 zerolib。
$ bflat build -Os --stdlib:zero
9 KB,我们快成功了!
11.
直接调用
如果用十六进制编辑器打开生成的可执行文件,你会发现其中有对 LoadLibrary 和 GetProcAddress 的调用,而这些调用在原始程序中是没有的。这是因为默认情况下,bflat 会自动解析对 gdi32.dll 和 user32.dll 的 p/invoke 调用。让我们指示 bflat 静态解析这些调用:
$ bflat build -Os --stdlib:zero -i gdi32 -i user32
嗯……看起来不太管用:
lld: error: undefined symbol: StretchDIBits
>>> referenced by D:\git\minimaze\Program.cs:262
>>> D:\git\minimaze\minimaze.obj:(minimaze_Program__Main)
这是因为 bflat 并不提供 Windows 系统的所有导入库,只提供了所需的子集。我们可以通过将 bflat 的链接器指向 Windows SDK 中的导入库来解决这个问题:
$ bflat build -Os --stdlib:zero -i gdi32 -i user32 --ldflags C:\Progra~2\WI3CF2~1\10\Lib\10.0.22621.0\um\x64\gdi32.lib
成功!我们降到了 8 KB。
12.
调试支持和重定位
再仔细查看输出可执行文件时,我们会发现另外两样东西:
(1).reloc 部分。如果可执行文件没有加载到首选基地址(例如由于地址空间布局随机化导致),这个部分就包含了修复可执行文件所需的信息。
(2)PDB 文件的路径,一般调试器会使用该路径查找文件。
我们不需要这两个参数,将它们删掉:
$ bflat build -Os --stdlib:zero -i gdi32 -i user32 --ldflags C:\Progra~2\WI3CF2~1\10\Lib\10.0.22621.0\um\x64\gdi32.lib --no-pie --no-debug-info
好了,现在是 7 KB。
13.
为 x86 构建
到目前为止,我们一直在为 x86-64 架构构建程序,这种架构是 x86 架构的兼容二进制扩展。作为一种扩展,指令编码更大,指针也更大……所以,让我们切换到 x86(注意,我还交换了 gdi32.lib 文件的路径,使其指向 x86 版本)。
$ bflat build -Os --stdlib:zero -i gdi32 -i user32 --ldflags C:\Progra~2\WI3CF2~1\10\Lib\10.0.22621.0\um\x86\gdi32.lib --no-pie --no-debug-info -arch x86
6.5 KB,至此我们也用完了 bflat 编译器里的所有优化。
14.
Crinkler 链接器
构建本地可执行文件通常包括两个步骤:生成一个包含机器代码的目标文件,但实际上还不能运行;运行链接器,从目标文件生成可执行文件。
到目前为止,我们一直在使用 bflat 的链接器(实际上只是一个打包好的 LLVM 链接器,LLD),不过也有一种专门的链接器可用于 Sizecoding:Crinkler。Crinkler 是 Windows 下的一个压缩链接器,专门针对大小仅为几千字节的可执行文件。那么,让我们试试使用 Crinkler。
首先,我们需要找到调用链接器的命令行开关,要查看 bflat 如何运行 LLVM 链接器,请在 bflat build 后添加 -x 命令行开关:
$ bflat build -Os --stdlib:zero -i gdi32 -i user32 --ldflags C:\Progra~2\WI3CF2~1\10\Lib\10.0.22621.0\um\x64\gdi32.lib --no-pie --no-debug-info -arch x86 -x
接下来,我们需要目标文件。bflat 通常会在创建 EXE 文件后删除目标文件,我们可以通过在 bflat build 中添加 -c,命令它在生成 obj 文件后停止:
$ bflat build -Os --stdlib:zero -i gdi32 -i user32 --ldflags C:\Progra~2\WI3CF2~1\10\Lib\10.0.22621.0\um\x64\gdi32.lib --no-pie --no-debug-info -arch x86 -c
现在我们有了 minimaze.obj,让我们运行 Crinkler。我们将传递一些参数,而这些参数在上面的步骤中都能找到:输入目标文件的名称,输出可执行文件的名称,入口点符号的名称(__managed__Main),kernel32.lib、user32.lib、gdi32.lib 的路径,zerolibnative.obj 的路径(bflat zerolib 的实现细节)。
$ crinkler minimaze.obj /out:minimaze-crinkled.exe /entry:__managed__Main C:\Progra~2\WI3CF2~1\10\Lib\10.0.22621.0\um\x86\user32.lib C:\Progra~2\WI3CF2~1\10\Lib\10.0.22621.0\um\x86\kernel32.lib C:\Progra~2\WI3CF2~1\10\Lib\10.0.22621.0\um\x86\gdi32.lib C:\Users\michals\AppData\Local\Microsoft\WinGet\Packages\MichalStrehovsky.bflat_Microsoft.Winget.Source_8wekyb3d8bbwe\lib\windows\x86\zerolibnative.obj /subsystem:windows
(以上是一行)
1936 B,不到 2 KB,成功了!
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课