查看原文
其他

嫌应用太大,程序员极限「整活」:“在 2 KB 内,用 C# 制作了一款迷宫游戏!”

CSDN 2024-01-17

【CSDN 编者按】如今多数应用动辄几十上百兆,加上后续的缓存和数据,部分应用大小不知不觉间就突破了 1 GB,而其中并非所有功能都是用户常用的。对于这个问题,一直钟爱小型程序的本文作者进行了一个尝试:能否摒弃所有无用功能,在 2 KB 内开发一个独立小游戏?

作者 | Michal       译者 | 郑丽媛
出品 | CSDN(ID:CSDNnews)

作为一个在 1.44 MB 软盘和 56 kbit 调制解调器时代长大的人,我一直都很喜欢小型程序,还在随身携带的软盘上下载了很多小程序。如果遇到有个程序无法放入我的软盘,我就会开始思考:这程序有很多图形吗?有音乐吗?能做很多复杂的事情吗?还是只是过于臃肿了?

不同于过去,如今磁盘空间变得很便宜,巨大的闪存驱动器也十分普遍,导致人们逐渐放弃了对程序大小的优化。 

但在传输过程中,程序大小仍然很重要:通过网络传输程序时,MB 相当于秒数。在最佳情况下,100 MBit/S 的速度每秒也只能传输 12 MB。对于一个等待程序下载完成的人来说,五秒和一秒之间的差异可能对其体验产生重大影响:人们通常认为,小于 0.1 秒的都是即时的,3 秒是保持用户流量不中断的极限,超过 10 秒则很难再保证用户的参与。

也就是说,虽然程序“小”不再是必要条件,但“小”依然是更好的。

为了证明这个观点,这篇文章就是一个实验,目的是找出一个有用的自包含 C# 可执行文件到底能有多小。C# 应用程序能否达到用户认为“即时”下载的大小?这是否能让 C# 在目前尚未使用的地方得以应用?

什么是“自包含(self-contained)”?

所谓“自包含”应用程序,是指包含了在操作系统原始安装中运行所需的所有组件的应用程序。

C# 编译器属于一组针对虚拟机的编译器:C# 编译器的输出是一个可执行文件,需要某种虚拟机(VM)才能执行。所以我们不能只安装一个裸机操作系统,就指望在上面运行 C# 编译器生成的程序。

在 Windows 系统上,曾经可通过安装 .NET Framework 来运行 C# 编译器的输出结果。但现在,许多 Windows SKU 不再携带该框架(如 IoT、Nano Server、ARM64 等),.NET Framework 也不支持 C# 语言的最新增强功能,正在被逐渐淘汰。 

要使 C# 应用程序能够“自包含”,它需要包含运行时和使用到的所有类库——对于我们预算的  2 KB 来说,要容纳的东西实在太多了!

2 KB 游戏来了!

我们要制作一个图形迷宫游戏,以下是最终成品:

游戏结构

我们需要先构建一些框架,这样才能将像素推送到屏幕上。按理说我们可以从 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 smallerlong 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 得到相反的方向。 

差不多就是这样,接下来让我们看看程序大小。

.NET 8 迷宫的默认大小 

我已将这个游戏放在了 GitHub 存储库(https://github.com/MichalStrehovsky/minimaze)中,你也可以跟着做。要用 CoreCLR 生成默认(单文件)配置,请运行:

$ dotnet publish -p:PublishSingleFile=true

这将生成一个 64 MB 大小的单个 EXE 文件,里面包括游戏、.NET 运行时和作为 .NET 标准组成部分的基础类库。你可能会说“比 Electron 好”,认为它还不错,但让我们看看是否能做得更好。

压缩单个文件

.NET 单个可执行文件可以选择压缩,压缩之后程序仍完全一样。让我们打开压缩功能:

$ dotnet publish -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true

现在,我们的游戏大小降到了 35.2 MB,只有开始时的一半,但仍比 2 KB 要大得多。

IL(Intermediate Language)精简程序

通过扫描整个程序并删除未被引用的代码,删除应用程序中未使用的代码。精简过程中可能会破坏某些使用运行时反射查看程序结构的 .NET 程序,但我们没有使用运行时反射,所以精简不会有问题。要在项目中使用精简,需添加一个 PublishTrimmed 属性,如下所示:

$ dotnet publish -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true -p:PublishTrimmed=true

在此设置下,游戏缩小至 10 MB。不错,但还差得远。

本地 AOT 编译

我们还有另一种选择,即使用本地 AOT 部署(Ahead-of-Time Compilation,提前编译)。本地 AOT 部署可生成完全本地化的可执行文件,并根据应用程序的需要定制运行时,不过我们对运行时的需求不多。本地 AOT 部署意味着精简和单文件都是可以省略的,因此我们可以从命令行中删除这些内容。本地 AOT 也没有内置压缩功能。命令行很简单:

$ dotnet publish -p:PublishAot=true

现在游戏大小是 1.13 MB,事情开始变得有趣了。

删除未使用的框架功能

经过精简的本地 AOT 编译部署,提供了一个删除不必要的框架功能或优化输出大小的选项。

因此,我们将:优化大小、关闭对漂亮堆栈跟踪字符串的支持、启用不变全局化、删除框架异常消息字符串。

$ dotnet publish -p:PublishAot=true -p:OptimizationPreference=Size -p:StackTraceSupport=false -p:InvariantGlobalization=true -p:UseSystemResourceKeys=true

变成了 923 KB。此时,我们已经用完了.NET 上官方支持的选项,但游戏大小仍超出了 921 KB。

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 进行的主观更改,程序略微变小了一点。

使用 zerolib 的 bflat

当涉及到运行库时,bflat 编译器提供了三个选项:你可以用 .NET 自带的完整运行时库,也可以使用 bflat 自带的名为 zerolib 的最小实现,或者根本不使用标准库。

我们精心设计了游戏,使其能兼容 zerolib 的限制,让我们切换到 zerolib。

$ bflat build -Os --stdlib:zero

9 KB,我们快成功了!

直接调用

如果用十六进制编辑器打开生成的可执行文件,你会发现其中有对 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。

调试支持和重定位

再仔细查看输出可执行文件时,我们会发现另外两样东西: 

(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。

为 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 编译器里的所有优化。 

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

之后,将打印出如下一行:

C:\Users\michals\AppData\Local\Microsoft\WinGet\Packages\MichalStrehovsky.bflat_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\lld.exe -flavor link "D:\git\minimaze\minimaze.obj" /out:"D:\git\minimaze\minimaze.exe" /libpath:"C:\Users\michals\AppData\Local\Microsoft\WinGet\Packages\MichalStrehovsky.bflat_Microsoft.Winget.Source_8wekyb3d8bbwe\lib\windows\x86" /libpath:"C:\Users\michals\AppData\Local\Microsoft\WinGet\Packages\MichalStrehovsky.bflat_Microsoft.Winget.Source_8wekyb3d8bbwe\lib\windows" /libpath:"C:\Users\michals\AppData\Local\Microsoft\WinGet\Packages\MichalStrehovsky.bflat_Microsoft.Winget.Source_8wekyb3d8bbwe\lib" /subsystem:console /entry:__managed__Main /fixed /incremental:no /merge:.modules=.rdata /merge:.managedcode=.text advapi32.lib bcrypt.lib crypt32.lib iphlpapi.lib kernel32.lib mswsock.lib ncrypt.lib normaliz.lib ntdll.lib ole32.lib oleaut32.lib user32.lib version.lib ws2_32.lib shell32.lib Secur32.Lib /opt:ref,icf /nodefaultlib:libcpmt.lib C:\Progra~2\WI3CF2~1\10\Lib\10.0.22621.0\um\x86\gdi32.lib C:\Progra~2\WI3CF2~1\10\Lib\10.0.22621.0\um\x86\user32.lib C:\Users\michals\AppData\Local\Microsoft\WinGet\Packages\MichalStrehovsky.bflat_Microsoft.Winget.Source_8wekyb3d8bbwe\lib\windows\x86\zerolibnative.obj

我们要记住这一点,以备后用。

接下来,我们需要目标文件。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,我真的成功了!

原文链接:https://migeel.sk/blog/2024/01/02/building-a-self-contained-game-in-csharp-under-2-kilobytes/

推荐阅读:

M3 MacBook Pro 能提效?程序员、产品经理自证后,CTO:你赢了,新电脑在路上了

Pascal 之父、图灵奖得主 Niklaus Wirth 逝世!发明多款编程语言,首提「算法+数据结构=程序」

取代iOS,鸿蒙将成为中国第二大智能手机操作系统;Vision Pro或将本月底上架;VCoder:大语言模型的眼睛|极客头条

继续滑动看下一个

嫌应用太大,程序员极限「整活」:“在 2 KB 内,用 C# 制作了一款迷宫游戏!”

向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存