.NET 发布最小能多小?从 86MB 压到 657 字节
.NET发布最小能多小?我从86MB凹到了657字节 - .NET骚操作
同一个 Hello World,.NET 自包含 + ReadyToRun 发布为 192 个文件、86.1MB,经 Native AOT 加全部官方体积开关降到 748KB,再叠加 UPX 为 320KB。
大家可能都喜欢大=好,但在程序发布这件事上,没人希望自己的程序大。偏偏 .NET 总给人一个印象:要么让用户先装运行时,要么把运行时一起打包带走。一个 Hello World,发布出来可能就是几十上百兆。
我先试了一下。同一个 Hello World,用自包含 + ReadyToRun 发布,打开输出目录是这样的:

右键看一下属性:

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 种发布方式,从大到小排成一列:

最大的 90,370,778 字节,最小的 657 字节,差了 13.7 万倍。
16 种全写一遍太啰嗦,下面只挑几个关键的台阶:
| 发布方式 | 大小 | 用户需要装 .NET 吗 |
|---|---|---|
| 自包含(默认,191 个文件) | 76.6MB | 不用 |
| 自包含单文件 + 裁剪 | 12.3MB | 不用 |
| Native AOT + 全部官方体积开关 | 748KB | 不用 |
| 再加 UPX 压缩壳 | 320KB | 不用 |
| 去掉标准库 | 5KB | 不用 |
| 再加链接器骚操作 | 1,904 字节 | 不用 |
| 换成 Crinkler 链接 | 657 字节 | 不用 |
第一站:官方正路
先说正常的路子。代码就是最经典的那个:
class Program
{
static void Main()
{
System.Console.WriteLine("Hello, World!");
}
}
SelfContained 自包含发布,整个运行时和全部基础类库都会复制到输出目录,191 个文件,76.6MB。打开 PublishSingleFile 变成单个文件,还是 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:

顺便说一下框架依赖发布,也就是 dotnet build 默认的那种产物:一个 159KB 的 hello.exe 启动器,加上几 KB 的 hello.dll 和两个 json 配置文件,合计 163KB。看着小,但要用户自己先装好 .NET 运行时,不符合今天的规则。
所以官方正路的终点就是 748KB,离 100KB 还差 7 倍多。

748KB 里装了什么
一个 Hello World 怎么就要 748KB?我写了个脚本拆了一下:链接时加上 /MAP 生成映射文件,再结合 ILC(Native AOT 编译器)输出的 map 文件,把 exe 里的每个字节都归到某一类:

一半是托管代码。Console.WriteLine 背后有编码转换、流、格式化,光数字格式化相关的 System.Number 就有 25KB 机器码。不过把 Console 换成直接调用 Win32 的 WriteFile,我试了一下,也只少了 46KB。
另一个大头是 GC,占了 235KB,其中 GC 的主体源文件 gcwks.cpp 一个就编译出了 187KB。剩下的是启动、栈遍历、异常处理这些原生运行时,还有 x64 的异常展开表(.pdata)。
经常有人问的两个问题,正好在这里回答一下。
要不要 vcruntime140.dll? 不要。C/C++ 运行时是静态链接进来的,就是图里那 29KB。用 dumpbin 看一下依赖:

全是 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:

bflat 也有一个类似的 --stdlib:zero 模式,思路相同,出来是 4KB。
第三站:链接器骚操作,1,904 字节
5KB 已经远远低于我定的 100KB 了,但上面的截图里还有个细节:这个 exe 有 6 个节(section)。PE 文件里每个节默认都要按 512 字节对齐,代码本身只有几百字节,大部分都是对齐填充。
所以接下来交给 MSVC 链接器:
| 开关 | 作用 |
|---|---|
/FIXED /DYNAMICBASE:NO |
放弃 ASLR,从而去掉重定位节 .reloc |
/MERGE:.rdata=.text /MERGE:.data=.text … 加 /SECTION:.text,ERW |
所有节合并成一个可读、可写、可执行的 .text(导入表 .idata 不让合并,只能单独留着) |
/ALIGN:16 /FILEALIGN:16 |
对齐从 4KB/512 字节降到 16 字节。链接器会警告,但 Windows 能正常加载 |
/STUB:dos-stub.bin |
把“This program cannot be run in DOS mode”那段 DOS 存根换成一个 64 字节的头 |
/EMITTOOLVERSIONINFO:NO 等 |
去掉 Rich 头、调试目录这些附加信息 |
结果是 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 打出来,一屏就放得下:

再看一下属性:

注意“占用空间”那一行:0 字节。NTFS 上特别小的文件,内容会直接存在 MFT(主文件表)的文件记录里,不用另外分配磁盘簇。这个 exe 小到连一个簇都不用占。
比开头我估计的 C 语言 + MinGW 的 4KB 还小。

被 Defender 当成了木马
还有个小插曲。我把这些 exe 发到 GitHub Release 上,下载下来一运行,657 字节这个直接被 Windows 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 协议:
- 源码:https://github.com/sdcb/min-dotnet-poc
- 下载:Release v1.0.0,所有小于 2MB 的单文件 exe 都可以单独下载,也可以下载
min-dotnet-poc-exes.zip一次拿全(里面附有说明)

想自己跑一遍的话,装好 .NET 10 SDK 和 Visual Studio 的 C++ 桌面开发工作负载,在仓库根目录运行 .\build-all.ps1。它会下载需要的第三方工具,逐个构建、统计大小、运行并检查输出是不是精确等于 Hello, World!:

仓库里还有 scripts\analyze-aot-size.ps1,就是前面拆解 748KB 用的那个脚本。
如果你有办法做得更小,或者用官方支持的方式压到了 748KB 以下,欢迎在评论区或者 GitHub 上告诉我。

交流
感谢阅读到这里,如果觉得本文有意思,欢迎去 GitHub 点个 Star,也欢迎评论和点赞,这是我持续创作的动力!
欢迎关注微信公众号 .NET骚操作,或者扫码加入 .NET骚操作2群 微信交流群:

(如果微信群二维码过期,请加入 .NET骚操作交流QQ群:495782587)
来源:博客园 · cnblogs.com