大家可能都喜欢大=好,但在程序发布这件事上,没人希望自己的程序大。偏偏 .NET 总给人一个印象:要么让用户先装运行时,要么把运行时一起打包带走。一个 Hello World,发布出来可能就是几十上百兆。
我先试了一下。同一个 Hello World,用自包含 + ReadyToRun 发布,打开输出目录是这样的:
一个 Hello World 的发布目录
右键看一下属性:
192 个文件,86.1 MB
192 个文件,86.1MB。就为了打印一行 Hello, World!。
吓死宝宝啦
那么在 2026 年,.NET 到底能做到多小?
我心里有个参照:同样的 Hello World 用 C 写,MinGW 链接,我估计 4KB 左右能搞定。.NET 肯定做不到这个程度,所以我给自己定了一个目标:能小于 100KB,我就觉得非常惊人了。
规则也定一下:目标机是一台没装 .NET 的 Windows 10,exe 可以依赖 Windows 自带的 DLL(kernel32、UCRT 这些),但不能要求用户再装任何东西。需要用户另装运行时的方案也会测,但单独标出来。(我手上是 Windows 11,所有 exe 都实际运行过;Win10 能不能跑,是根据它们依赖的 DLL 推断的。)
先看结论
我一共试了 16 种发布方式,从大到小排成一列:
16 种发布方式的体积
最大的 90,370,778 字节,最小的 657 字节,差了 13.7 万倍。
16 种全写一遍太啰嗦,下面只挑几个关键的台阶:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
第一站:官方正路
先说正常的路子。代码就是最经典的那个:
class Program
{
static void Main()
{
System.Console.WriteLine("Hello, World!");
}
}
SelfContained 自包含发布,整个运行时和全部基础类库都会复制到输出目录,191 个文件,76.6MB。打开 PublishSingleFile 变成单个文件,还是 70MB:
单文件 70MB
再打开裁剪(PublishTrimmed),用不到的程序集和方法都会被删掉,降到 12.3MB;加上压缩和几个特性开关,10.2MB。
到这里就降不动了。回头看第一张截图,coreclr.dll 4.5MB,clrjit.dll 2MB,这两个是运行时和 JIT 编译器本体,裁剪对它们无能为力。
想再往下,只能换路线:Native AOT。它在发布时就把 C# 编译成机器码,不需要 JIT,只编译真正用到的代码。默认设置 1.1MB,把官方提供的体积开关全部打开(节选):
<PublishAot>true</PublishAot>
<OptimizationPreference>Size</OptimizationPreference>
<InvariantGlobalization>true</InvariantGlobalization>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
<StackTraceSupport>false</StackTraceSupport>
<EventSourceSupport>false</EventSourceSupport>
<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
结果是 748KB:
Native AOT + 体积开关:748 KB
顺便说一下框架依赖发布,也就是 dotnet build 默认的那种产物:一个 159KB 的 hello.exe 启动器,加上几 KB 的 hello.dll 和两个 json 配置文件,合计 163KB。看着小,但要用户自己先装好 .NET 运行时,不符合今天的规则。
所以官方正路的终点就是 748KB,离 100KB 还差 7 倍多。
就这?
748KB 里装了什么
一个 Hello World 怎么就要 748KB?我写了个脚本拆了一下:链接时加上 /MAP 生成映射文件,再结合 ILC(Native AOT 编译器)输出的 map 文件,把 exe 里的每个字节都归到某一类:
748KB 的构成
一半是托管代码。Console.WriteLine 背后有编码转换、流、格式化,光数字格式化相关的 System.Number 就有 25KB 机器码。不过把 Console 换成直接调用 Win32 的 WriteFile,我试了一下,也只少了 46KB。
另一个大头是 GC,占了 235KB,其中 GC 的主体源文件 gcwks.cpp 一个就编译出了 187KB。剩下的是启动、栈遍历、异常处理这些原生运行时,还有 x64 的异常展开表(.pdata)。
经常有人问的两个问题,正好在这里回答一下。
要不要 vcruntime140.dll? 不要。C/C++ 运行时是静态链接进来的,就是图里那 29KB。用 dumpbin 看一下依赖:
748KB 版本的依赖
全是 Windows 自带的:kernel32、advapi32、bcrypt、ole32,以及 UCRT 的 api-ms-win-crt-*,Win10 里都有。
GC 能不能拿掉? 官方没有这条路。官方提供的开关只能调整 GC 的行为,比如工作站/服务器模式、并发、各种阈值,没有“不要 GC”这个选项。有个 IlcStandaloneGCSupport 属性,作用是让程序可以加载外部的 GC,内置的那份照样会链接进来。网上见到的“去掉 GC”的实验,要么是自己改了运行时重新编译,要么就是下面要做的事:把整个运行时都扔掉。
插曲:正常情况下的下限
到这里可以先给一个阶段性的答案:正常写 C#,用正常的标准库,不让用户装任何东西,最小大约 750KB。
再往下有两个现成的工具:
-
bflat:第三方的 C# 原生编译器,加上它自己的一组裁剪开关,697KB。 -
UPX:把 748KB 的 exe 压成 320KB。
UPX 本质上就是压缩:exe 被压缩后存起来,前面加一段解压代码,运行时先在内存里解压再执行。省的是硬盘和下载体积,内存一点没省,启动还要多花一点解压时间。而且加壳程序特别容易被杀毒软件误报(后面就有一个现成的例子)。
还有一个对照组:用 Windows 自带的 .NET Framework 4.x 里的 csc.exe 编译,exe 只有 3.5KB,比后面去掉标准库的 5KB 版本还小。但道理和框架依赖发布一样:exe 里只有 IL,真正干活的 .NET Framework 4.8 是 Windows 10 预装的。小,是因为运行时别人已经帮你装好了。
第二站:格局打开,标准库不要了
格局打开
既然 748KB 里大部分是运行时和标准库,那就都不要了。
具体做法是:仍然用官方的 dotnet publish 和 Native AOT 编译器,但不引用 .NET 的 System.Private.CoreLib,换成我自己写的一个只有 200 行左右的 MiniCoreLib.cs,最后只链接 kernel32.lib。
没有标准库就没有 Console,Hello World 只能自己调 Win32 API:
static unsafe class Program
{
[DllImport("kernel32"), SuppressGCTransition]
static extern nint GetStdHandle(int nStdHandle);
[DllImport("kernel32"), SuppressGCTransition]
static extern int WriteFile(nint hFile, byte* lpBuffer, int nBytes, int* lpWritten, nint lpOverlapped);
static void Main()
{
int written;
ReadOnlySpan<byte> s = "Hello, World!\r\n"u8;
fixed (byte* p = &s[0])
WriteFile(GetStdHandle(-11), p, 15, &written, 0);
}
}
MiniCoreLib.cs 里只放编译器和 ILC 必须要的东西:Object、各个基元类型、String、ReadOnlySpan<T>,以及 ILC 指名要的几个辅助类。没有 GC,P/Invoke 前后用来配合 GC 的过渡函数也就全是空的:
namespace System
{
public class Object
{
// 每个对象的第一个字段是 MethodTable 指针,这是和 ILC 的约定
private IntPtr m_pMethodTable;
}
public struct Int32 { }
public sealed class String { public readonly int Length; }
// ……其他基元类型、Array、ReadOnlySpan<T> 等
}
namespace Internal.Runtime.CompilerHelpers
{
internal static class StartupCodeHelpers
{
[RuntimeExport("RhpPInvoke")] static void RhpPInvoke(IntPtr frame) { }
[RuntimeExport("RhpPInvokeReturn")] static void RhpPInvokeReturn(IntPtr frame) { }
// ……
}
}
项目文件里关键的几项:
<NoStdLib>true</NoStdLib>
<!-- 让 ILC 把我们自己的程序集当作系统库 -->
<IlcSystemModule>$(AssemblyName)</IlcSystemModule>
<!-- 直接从托管的 Main 进入,不经过 C 运行时的启动代码 -->
<EntryPointSymbol>__managed__Main</EntryPointSymbol>
再写几个 MSBuild Target,把 SDK 默认加上的框架引用、GC 库、C 运行时全部去掉,链接命令只剩 kernel32.lib /NODEFAULTLIB /ENTRY:__managed__Main。
思路不复杂,坑倒是不少,挑三个印象最深的:
-
.NET 10 的 ILC 有一个全程序 IL 扫描器,会无条件地要求 SpanHelpers.Memmove、类型转换等运行时辅助函数存在,而我的“标准库”里根本没有。解决办法是给 ILC 加一个--noscan参数,跳过扫描。 -
普通的 P/Invoke 每次调用前后都要建立一个过渡帧,带着它的 Main有 204 字节。加上[SuppressGCTransition]之后变成了直接call,Main缩到 100 字节左右,但 JIT 又会在调用后插入一段 GC 轮询,读取一个叫RhpTrapThreads的数据符号,C# 没法导出数据符号。最后在 MiniCoreLib 里放了一个静态字段,再用链接器的/ALTERNATENAME把这个符号指向它。 -
SDK 默认会给链接器传一个空的导出定义文件,结果 exe 里多出一张空导出表,白白多了 512 字节。
结果是 5,120 字节,依赖只剩一个 KERNEL32.dll:
5KB,只依赖 kernel32
bflat 也有一个类似的 --stdlib:zero 模式,思路相同,出来是 4KB。
第三站:链接器骚操作,1,904 字节
5KB 已经远远低于我定的 100KB 了,但上面的截图里还有个细节:这个 exe 有 6 个节(section)。PE 文件里每个节默认都要按 512 字节对齐,代码本身只有几百字节,大部分都是对齐填充。
所以接下来交给 MSVC 链接器:
|
|
|
|---|---|
/FIXED /DYNAMICBASE:NO |
.reloc
|
/MERGE:.rdata=.text /MERGE:.data=.text …
/SECTION:.text,ERW
|
.text(导入表 .idata 不让合并,只能单独留着)
|
/ALIGN:16 /FILEALIGN:16 |
|
/STUB:dos-stub.bin |
|
/EMITTOOLVERSIONINFO:NO
|
|
结果是 1,904 字节。剩下的大头是 ILC 为运行时生成的 ReadyToRun 头和几张元数据表,大约 700 多字节,它们不在可以被链接器单独删除的节里,只能留着。
令人窒息的操作
终点:Crinkler,657 字节
最后一步借用 demoscene 的工具。做 4KB intro(整个音画作品只有 4KB 的程序)的人有一个专门的链接器,叫 Crinkler。它不是普通的压缩壳:它直接读取编译器生成的 .obj,自己完成链接,用上下文建模算法压缩,再在 exe 里嵌一个很小的解压器。它还会在运行时自己去找 kernel32 里的函数,所以 PE 导入表都是空的。
Crinkler 只能输出 32 位程序,所以这一档让 ILC 生成 x86 代码(RuntimeIdentifier=win-x86)。它也不支持 /ALTERNATENAME,前面 GC 轮询那个坑只好换个绕法:让 ILC 认不出 SuppressGCTransition,P/Invoke 改走那几个空的过渡函数。
结果是 657 字节。整个 exe 用 xxd 打出来,一屏就放得下:
657 字节的完整内容
再看一下属性:
大小 657 字节,占用空间 0 字节
注意“占用空间”那一行:0 字节。NTFS 上特别小的文件,内容会直接存在 MFT(主文件表)的文件记录里,不用另外分配磁盘簇。这个 exe 小到连一个簇都不用占。
比开头我估计的 C 语言 + MinGW 的 4KB 还小。
你这个逼装的好,而且多了一些细节在里面
被 Defender 当成了木马
还有个小插曲。我把这些 exe 发到 GitHub Release 上,下载下来一运行,657 字节这个直接被 Windows Defender 拦了,报的是 Trojan:Win32/Crysant.RPS!MTB:
Defender:Trojan:Win32/Crysant.RPS!MTB
当场逮捕
一个只会打印 Hello World 的程序成了木马。我猜是因为压缩过、体积极小、PE 结构又很不常规,这些特征和恶意软件太像了。不过后来又下载运行了几次,Defender 都放行了。不管怎样,这也说明了加壳程序在实际分发中的风险,前面说 UPX 容易误报就是这个意思。
这还算 .NET 吗?
最后得说实话。
后面这几档(5KB、1,904 字节、657 字节)没有 GC,没有异常处理,没有 Console,连 new 一个对象都不行:需要分配内存的代码根本链接不过去。它们用的是 C# 语法、Roslyn 编译器和 .NET 官方的 Native AOT 编译器,但已经不是平时写的那个 .NET 了,更像是“拿 C# 当 C 用”。
所以回到标题的问题:
- 正常写代码,不让用户装运行时
:Native AOT 加上官方体积开关,750KB 左右。这是官方支持范围内的下限,实际项目我会停在这里。 - 很在乎下载体积
:再套一层 UPX,320KB 左右,但要做好被杀毒软件误报的准备。 - 纯粹想挑战极限
:去掉标准库再配合链接器和 Crinkler,可以做到 657 字节。
我给自己定的目标是 100KB,正常路子没做到,掀了桌子之后倒是远远超额了。
源码和下载
16 种方式的全部源码、构建脚本都在 GitHub 上,MIT 协议:
-
源码与下载均可访问 GitHub 仓库。
Release 里的全部 exe
想自己跑一遍的话,装好 .NET 10 SDK 和 Visual Studio 的 C++ 桌面开发工作负载,在仓库根目录运行 .\build-all.ps1。它会下载需要的第三方工具,逐个构建、统计大小、运行并检查输出是不是精确等于 Hello, World!:
build-all.ps1 的输出
仓库里还有 scripts\analyze-aot-size.ps1,就是前面拆解 748KB 用的那个脚本。
如果你有办法做得更小,或者用官方支持的方式压到了 748KB 以下,欢迎在评论区或者 GitHub 上告诉我。
那么好用的框架,还不赶紧来Star
交流
感谢阅读到这里,如果觉得本文有意思,欢迎去 GitHub 点个 Star,也欢迎评论和点赞,这是我持续创作的动力!

