首页
社区
课程
招聘
[原创]
发表于: 2024-1-13 17:17 4003

[原创]

2024-1-13 17:17
4003

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

 

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

 

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

 

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

 

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

 

1.

 

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

 

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

 

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

 

 Windows 系统上,曾经可通过安装 .NET Framework 来运行 C# 编译器的输出结果。但现在,许多 Windows SKU 不再携带该框架(如 IoTNano ServerARM64 等),.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);

接下来,声明一个变量来保存帧缓冲区-像素的宽度 高度。我们这样做有点不合常规,但这样方便在以后的优化中,在可执行文件的数据段内分配此区域。我们将像素的数量乘以 来保存每个分量:红色、绿色、蓝色和一个保留字节。

 

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 的原始代码对所有 个方向都有额外的处理,但我将其简化为 个方向,然后通过乘以-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.

 

ILIntermediate 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 部分。如果可执行文件没有加载到首选基地址(例如由于地址空间布局随机化导致),这个部分就包含了修复可执行文件所需的信息。

 

2PDB 文件的路径,一般调试器会使用该路径查找文件。

 

我们不需要这两个参数,将它们删掉:

 

$ 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),不过也有一种专门的链接器可用于 SizecodingCrinklerCrinkler 是 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.libuser32.libgdi32.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直播授课

收藏
免费 3
支持
分享
最新回复 (3)
雪    币: 1451
活跃值: (3886)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
2
哪里下载这个2k的程序
2024-1-13 23:33
0
雪    币: 295
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
等下周发给你链接
2024-1-14 22:46
1
雪    币: 200
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
4
正在 学习syscall  user32的调用号  这样库就不需要了 就只有基本3个dll 来apc回调 , 但是 user32的名称和调用号 出了用符号文件来解决简单点 还没找到好办法.能找到好办法的话 应该还能再小点
2024-4-27 18:27
0
游客
登录 | 注册 方可回帖
返回
//