我的 LeetCode 题解仓库里有个小工具 gen-readme:扫一遍 problems/ 目录,读每个文件第一行的元数据,按 id 排序,生成 README.md 的题目表格。源码是 C++,编译出来的二进制 59736 字节,约 58KB。
需求很简单:把这个二进制压到极限,“60KB 不可接受”。
我第一反应是 KISS——58KB 其实没什么不可接受的,一个 build-time 工具而已。但需求方点破了:60KB 本身就在 KISS 的可接受范围内,所以这题的意义不是”够用就行”,而是炫技。那就往死里压。
记录一下整个过程。它最后没落地(中途被叫停),但沿途把 macOS arm64 上一个 Mach-O 可执行文件的体积构成摸了个透,这部分是有 reference 价值的。
第一反应:编译选项,然后撞墙
最省事的办法是加 size 优化标志。-Os(为体积优化)、-Oz(clang 更激进的体积优化)、strip 去符号、-Wl,-dead_strip 死代码消除,挨个试:
for f in "-O2" "-Os" "-Oz" "-Oz -fno-exceptions -fno-rtti" \ "-Oz -fno-exceptions -fno-rtti -ffunction-sections -fdata-sections -Wl,-dead_strip"; do c++ -std=c++17 $f -o t gen-readme.cpp raw=$(wc -c < t); strip -x t printf '%6d raw / %6d strip [%s]\n' "$raw" "$(wc -c < t)" "$f"done输出:
59728 raw / 56784 strip [-O2] 61152 raw / 56912 strip [-Os] 67648 raw / 58032 strip [-Oz] 65088 raw / 56752 strip [-Oz -fno-exceptions -fno-rtti] 65088 raw / 56752 strip [-Oz -fno-exceptions -fno-rtti -ffunction-sections -fdata-sections -Wl,-dead_strip]撞墙了。无论怎么折腾,strip 之后都卡在 ~56KB。-Oz 甚至比 -O2 更大(代码更小,但其它开销没动)。
为什么 dead_strip 删不掉东西?因为源码 #include <iostream> 和 #include <filesystem>。这两个头会注入静态初始化器(std::cout/std::cin 的构造、locale 机器代码)。死代码消除的前提是”没人引用”,而静态初始化器在程序启动时被无条件调用——它们被引用了,删不掉。56KB 里很大一块是这套你根本没用到的 iostream/locale 运行时。
第一性原理:这程序根本不需要 C++ 运行时
退一步想:这个工具本质上只做四件事——遍历目录、读文件首行、按整数排序、写一段文本。这里面没有一个环节需要 std::cout 的 locale,也不需要 std::filesystem 的符号链接解析语义。
先量一下地板。一个什么都不做的程序,C 和 C++ 各编出多大?
printf 'int main(){return 0;}\n' > min.cprintf '#include <cstdio>\nint main(){puts("hi");}\n' > min.cppcc -Oz -o min_c min.c && strip -x min_cc++ -Oz -o min_cpp min.cpp && strip -x min_cppwc -c min_c min_cpp 16840 min_c 33464 min_cpp纯 C 的地板是 16840,C++(哪怕只用 <cstdio> 的 puts)是 33464。差的这 16KB 后面会解释。但方向已经很清楚:把 <iostream> 换成 <cstdio>、<filesystem> 换成 POSIX 的 <dirent.h>,就能把那 56KB 的运行时甩掉一大半。
干脆重写成纯 C:opendir/readdir 遍历目录,fopen/fgets 读首行,qsort 排序,fprintf 写出。逻辑跟原来一一对应,extract(从 key="value" 里抠值)、lang_display、难度徽章这些函数照搬。
关键是输出必须逐字节一致。题解目录正被另一个进程同步(这中间还踩过一次”基准目录在我两次测量之间从 5 个文件变成 65 个”的移动靶坑),所以我冻结一份快照,让新旧两个二进制都跑这同一份目录,再 diff:
cp -R problems frozen/problemsc++ -std=c++17 -O2 -o cpp_ref gen-readme.cpp # 旧版做基准cc -std=c11 -O2 -o gc gen-readme.c # 新版( cd frozen && ../cpp_ref . ) ; cp frozen/README.md ref.md( cd frozen && ../gc . ) ; cp frozen/README.md new.mddiff ref.md new.md && echo "✅ 逐字节一致"✅ 逐字节一致 (141 题,README 21669 字节)wc -c gc → 3460834608 字节。从 59736 砍到 34608,-42%,输出一字不差。而且换任何优化级别都是 34608——它已经触底了。
但等等,纯 C 的地板不是 16840 吗?为什么我的程序是 34608,差了整整一倍?
段分析:一整个 16KB 页,只为了 208 字节
把二进制拆开看段(segment):
size -m gcSegment __TEXT: 16384 Section __text: 2608 # 代码 Section __stubs: 276 # 动态链接桩 Section __cstring: 4617 # 难度表 + 格式字符串 Section __unwind_info: 104Segment __DATA_CONST: 16384 Section __got: 208 # ← 一整个 16KB 页,只装了 208 字节Segment __LINKEDIT: 16384文件大小 34608 ≈ 16384(__TEXT)+ 16384(__DATA_CONST)+ 1840(__LINKEDIT 实际文件大小)。
两个事实在这里咬合:
第一,Apple Silicon 用 16KB 页(Intel Mac 是 4KB)。Mach-O 的段在虚拟地址空间里必须页对齐,而段在文件里的偏移也得跟虚拟地址同余——结果就是每个段在文件里都按 16KB 对齐。__TEXT 里实际只用了 7605 字节,照样占满一整页。
第二,__DATA_CONST 段里只有一个 __got(Global Offset Table,全局偏移表),208 字节,装的是动态链接到 libc 那些函数(opendir、fopen、qsort…)的指针。就为了这 208 字节,搭进去一整个 16KB 页。
验证一下这是不是 libc 的硬地板——一个只调用一次 libc 函数的 C 程序有多大:
printf '#include <stdio.h>\nint main(){printf("hi");}\n' > onecall.ccc -Oz -o onecall onecall.c && strip -x onecallwc -c onecall # → 3346433464,结构跟我的一样:__TEXT 一页 + __DATA_CONST 一页 + __LINKEDIT。只要你链接 libc,这第二个页就跑不掉。 这就是 min_cpp 也是 33464 的原因——它 puts 了一次。而 min_c(return 0,一个 libc 函数都不调)能到 16840,因为它没有 GOT,没有 __DATA_CONST 页。
所以缩代码、缩那张 4.5KB 的难度表,都是白费——它们全在 __TEXT 那一页里,那页已经分配了。我甚至算过把难度表 2-bit 打包(4 个状态 0/E/M/H,每题 2 bit,3973 题从 4530 字节压到 ~994 字节),结论是:表还在同一页内,文件体积一字节不变。不要去优化已经免费的东西。
要再往下,只有一条路:干掉 __DATA_CONST 那个 GOT 页。而 GOT 的存在,纯粹是因为动态链接 libc。
击穿地板:裸系统调用,链接 libSystem 但零导入
思路:不调用任何 libc 函数,改用 arm64 的 svc #0x80 指令直接发系统调用。没有 libc 符号引用,就没有 GOT,就没有 __DATA_CONST 页。
macOS arm64 的 syscall 约定:指令是 svc #0x80,系统调用号放在 x16,参数依次放 x0–x5。BSD(Unix 类)系统调用号要加上 0x2000000 的类别前缀——比如 write 是 0x2000004。一个通用包装:
typedef long i64;#define SC(n) (0x2000000 | (n)) // 加 Unix 类别前缀static i64 sc(i64 n, i64 a, i64 b, i64 c, i64 d){ register i64 x16 asm("x16")=n, x0 asm("x0")=a, x1 asm("x1")=b, x2 asm("x2") =c, x3 asm("x3")=d; i64 cf; asm volatile("svc #0x80\n\tcset %1, cs" // 出错时 carry 置位 : "+r"(x0), "=r"(cf) : "r"(x16),"r"(x1),"r"(x2),"r"(x3) : "memory","cc"); return x0;}然后 -nostdlib(不要 C 运行时启动代码),自定义入口。这条路上我连踩四个坑,每个都值得记一笔。
坑一:入口符号的下划线
cc -nostdlib -e _start -o t t.c# ld: Undefined symbols: "_start", referenced from: <initial-undefines>macOS 的 C ABI 给符号加一个前导下划线:C 函数 _start 编出来的汇编符号是 __start(下划线前缀 + 字面的 _start)。而 -e _start 找的是汇编层的 _start。错位了。把 C 函数命名为 mystart(→ 汇编符号 _mystart),用 -e _mystart,解决。
坑二:必须链接 libSystem
cc -nostdlib -e _mystart -o t t.c# ld: dynamic executables or dylibs must link with libSystem.dylibmacOS 的 ld 不允许一个动态可执行文件不链接 libSystem。但链接 ≠ 导入符号。如果我 -lSystem 但一个 libc 符号都不引用(全走裸 syscall),GOT 就是空的,__DATA_CONST 还是不会生成。试一下:
SDK=$(xcrun --show-sdk-path)cc -Oz -nostdlib -L"$SDK/usr/lib" -lSystem -e _mystart -o hello hello.cwc -c hello # → 16856size -m hello | grep SegmentSegment __PAGEZERO ...Segment __TEXT: 16384Segment __LINKEDIT: 1638416856 字节,能跑,exit 0。__DATA_CONST 没了,GOT 没了,stubs 没了。 只剩 __TEXT(代码)+ __LINKEDIT(dyld fixup 信息 + ad-hoc 代码签名)。16856 = 16384 + 472。
顺带说一句签名:Apple Silicon 强制所有 arm64 可执行文件必须带签名才能运行,ad-hoc 本地签名就够,而且 ld 会自动 ad-hoc 签——这就是上面这些 -nostdlib 二进制不用我手动 codesign 就能跑的原因。那段签名就躺在 __LINKEDIT 里。
坑三:把十进制系统调用号写成了十六进制
接下来要用 getdirentries64 替代 opendir/readdir 列目录。我直接写了 0x2000344,运行——毫无输出,进程静默死亡。
getdirentries64 的系统调用号是 344(十进制)。我把 344 当成十六进制塞进了 0x2000344,算出来是个根本不存在的调用号,内核直接 SIGSYS 把进程干掉,连一个字节都没来得及写。正确的是 0x2000000 | 344 = 0x2000158。从 SDK 头确认:
grep getdirentries64 "$SDK/usr/include/sys/syscall.h"# #define SYS_getdirentries64 344这就是我把 SC(n) 宏定义成接收十进制的原因——SC(344) 杜绝这类手抖。
坑四:argc/argv 不在栈上,在寄存器里
修好调用号后目录能列了,但 gen-readme <某目录> 传进去的路径不生效,永远退化成当前目录。我一开始按传统 Unix 约定,在入口用 mov x0, sp 然后从栈上读 argc/argv:
asm volatile("mov x0, sp\n\tb _startc"); // 错:把真正的 argc 冲掉了错。现代 ld 默认用 LC_MAIN 加载命令,dyld 是按 main(argc, argv, envp, apple) 的函数调用约定来跳进入口的——argc 在 x0、argv 在 x1,不是栈传参。我那句 mov x0, sp 反而把真正的 argc 覆盖了。直接写成普通函数即可:
__attribute__((noreturn))void mystart(int argc, char** argv){ // dyld 用寄存器把 argc/argv 喂进来 const char* dir = argc > 1 ? argv[1] : "."; ...}getdirentries64 填的是 struct direntry(u64 d_ino; u64 d_seekoff; u16 d_reclen; u16 d_namlen; u8 d_type; char d_name[]),按 d_reclen 步进遍历,d_type == 8(DT_REG)筛常规文件——刚好省掉对每个条目 stat 的开销。列出来跟 ls 对得上。
走到这里,方向已经验证可行:纯 syscall + nostdlib,可以把二进制从 34608 进一步压到 ~16856(约 17KB),击穿了 libc 的 33KB 地板。剩下的就是把 extract/排序/格式化输出全部用无 libc 的方式实现(自己写 strlen/strstr、bump 分配器、整数转字符串、插入排序),把 README 拼进一个 BSS 大缓冲一次 write 出去。
然后——叫停。
收尾:炫技归炫技,理性答案是 34608
需求方在裸 syscall 这一步喊了停,cancel + cleanup。所有实验都在 scratchpad 里,仓库零改动,干净退出。
复盘一下这条线的”性价比拐点”:
- 59736 → 34608(纯 C,去掉 iostream/filesystem):-42%,输出逐字节一致,换任何优化级别都触底,
update-cache.py还能照常重新生成。这是理性答案——干净、稳、可维护。 - 34608 → ~16856(裸 syscall + nostdlib):再砍一半,击穿 libc 地板。但代价是一整套手写 syscall、自己实现 libc 子集、入口 ABI 的脆弱性,而且工具链(
update-cache.py重新嵌入难度表并重编译)要跟着改。这是炫技,需求方为它埋单的意愿是有限的。
min_c 的 16840 提示了 macOS arm64 上动态可执行文件的真实地板:一个 16KB 的 __TEXT 页 + 一个小小的 __LINKEDIT(dyld fixup + ad-hoc 签名)。想再往下(sub-16KB),只能手搓非常规 Mach-O:重叠加载命令、手动页哈希签名——那是另一个量级的脆弱,对一个 README 生成器属于纯粹的自我证明。
这趟没落地,但把一个问题彻底摸清了:macOS arm64 上一个可执行文件的体积,主要不是被你的代码撑起来的,是被结构撑起来的——16KB 的页粒度、动态链接强加的 GOT 页、libSystem 强制链接、强制代码签名。看懂这四样,你就能一眼判断任何一个 Mach-O 还能不能再小、卡在哪个地板上。
至于那个工具,留在 34608 就挺好。
参考: Apple Silicon Macs will require signed code — The Eclectic Light Company · macOS on Apple Silicon use 16KiB pages — Hacker News · Kernel Syscalls — The Apple Wiki