跳到正文
博客园· .NET骚操作·· 3 小时前AI 评分36

.NET 发布最小能多小?从 86MB 压到 657 字节

.NET发布最小能多小?我从86MB凹到了657字节 - .NET骚操作

AI 导读

同一个 Hello World,.NET 自包含 + ReadyToRun 发布为 192 个文件、86.1MB,经 Native AOT 加全部官方体积开关降到 748KB,再叠加 UPX 为 320KB。

正文

大家可能都喜欢大=好,但在程序发布这件事上,没人希望自己的程序大。偏偏 .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 种全写一遍太啰嗦,下面只挑几个关键的台阶:

发布方式 大小 用户需要装 .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:

单文件 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 放弃 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 打出来,一屏就放得下:

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 协议:

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,也欢迎评论和点赞,这是我持续创作的动力!

欢迎关注微信公众号 .NET骚操作,或者扫码加入 .NET骚操作2群 微信交流群:

(如果微信群二维码过期,请加入 .NET骚操作交流QQ群:495782587)

来源:博客园 · cnblogs.com