编译器对零初始化结构体生成 memset 调用,GCC 在 9K 阈值处触发
来源标题:Zero-Initialisation Shenanigans
GCC 在结构体零初始化时于 9K 字节阈值处生成 memset() 调用,而非 rep 指令或向量操作。作者指出,手动改写的等价函数 b() 经编译后会对 memset 调用做尾调用优化(TCO),而原始函数 a() 未能触发同样的优化。
以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
struct S { char buf[9u * 1024]; }; struct S a(void) return (struct S){0}; a() 编译后变成: a: sub rsp, 8 mov edx, 9216 xor esi, esi call memset add rsp, 8 ret 编译器会生成一个 memset() 调用来做零初始化。9K 似乎是 GCC 发出 memset() 的阈值,超过这个大小就不再使用 rep 系列指令。原因不明——毕竟后者(我记得)是微码实现的,而向量操作在任何大小下可能都更优。 无论如何,上面这段汇编仍然很有意思。在 SysV ABI 下,a() 被变换为: void b(struct S *p) memset(p, 0, sizeof(*p)); 确实,如果你编译这个版本,会得到完全相同的汇编。调用方为返回值分配内存,并把指针传给被调用方。 顺便说一句,这和 C++ 的 RVO 完全无关。 如果你擅长汇编(我并不擅长),你会注意到编译器生成的结果并非最优。编译手动“变换”后的 b(),你会得到: b: mov edx, 9216 xor esi, esi jmp memset 这大概就是最优的了。哈,编译器对 memset() 调用做了 TCO。为什么对 a() 就做不到呢?看看 LLVM 的 IR(-O3 下): define dso_local void @a(ptr dead_on_unwind noalias nofree writable writeonly sret(%struct.S) align 1 captures(none) initializes((0, 9216)) %agg.result) local_unnamed_addr { entry: tail call void @llvm.memset.p0.i64(ptr noundef nonnull align 1 dereferenceable(9216) %agg.result, i8 0, i64 9216, i1 false) ret void define dso_local void @b(ptr nofree noundef writeonly captures(none) initializes((0, 9216)) %s) local_unnamed_addr { entry: tail call void @llvm.memset.p0.i64(ptr noundef nonnull align 1 dereferenceable(9216) %s, i8 0, i64 9216, i1 false) ret void 中端确实在 a() 中成功对 memset() 做了尾调用!除了一些属性差异外,两个函数降级后的结果非常相似。所以问题确实出在后端,它不知为何搞砸了,没有生成尾调用。 GCC 也失败了,原因不明(ABI 设计缺陷?)。不过这不重要,因为没人会那样写代码,而且调用本来也会被内联。 在折腾这些的时候,我发现了一个有趣的 GCC bug。我是个老古董,用 Debian 13,自带 GCC 14。如果你用这个版本编译一个 c() 变体,会得到: struct S c(void) struct S r; memset(&r, 0, sizeof(r)); return r; c: push rbx mov edx, 9216 mov rbx, rdi xor esi, esi sub rsp, 9216 mov rdi, rsp call memset mov rdi, rbx mov edx, 9216 xor esi, esi call memset add rsp, 9216 pop rbx ret GCC 可真贴心啊?它决定格外谨慎,调用了两次 memset(),以防万一。而且它还在被调用方的栈帧里分配了一个 struct S。GCC 15 及以后版本就没这么谨慎了,只生成一次 memset() 调用,于是生成的汇编和 a() 完全一样。 但即便是 GCC 15,似乎也漏掉了一些边界情况,你仍然能得到疯狂的输出。比如这个变体: struct S d(void) struct S r = {0}; return r; .LC0: .zero 2 .zero 9214 d: push rbx mov edx, 9216 mov rbx, rdi mov esi, OFFSET FLAT:.LC0 sub rsp, 9216 mov rdi, rsp call memcpy mov rsi, rsp mov rdi, rbx mov edx, 9216 call memcpy add rsp, 9216 pop rbx ret 它没有调用 memset(),而是创建了一个填满 0 的 9K 对象,然后做 memcpy()。两次。它在被调用方分配了一个 struct S r,把 9K 对象拷贝进去,再把 r 拷贝到调用方分配的 struct S 里。什么鬼? 双重 memcpy() 在 GCC 16 中修复了,但仍然会拷贝一个 9K 对象而不是调用 memset(): .LC0: .zero 2 .zero 9214 d: sub rsp, 8 mov edx, 9216 mov esi, OFFSET FLAT:.LC0 call memcpy add rsp, 8 ret 我记得这个问题在 trunk 上已经修了?这肯定是个优化 bug,不是正确性问题。再看一个: struct S e(void) struct S r = (struct S){0}; return r; 上面这个和 d() 类似,但用复合字面量来初始化 r(像 a() 那样)。GCC 16 对此生成 memset(),和 a() 的汇编完全一样。更早的版本也是如此。什么鬼? 如果你用 0 以外的东西,仍然会出问题: struct S f(void) return (struct S){{-1}}; f: sub rsp, 8 mov edx, 9216 xor esi, esi call memset mov BYTE PTR [rax], -1 add rsp, 8 ret struct S g(void) struct S r = {{-1}}; return r; .LC0: .string "\377" .zero 9214 g: sub rsp, 8 mov edx, 9216 mov esi, OFFSET FLAT:.LC0 call memcpy add rsp, 8 ret struct S h(void) struct S r = (struct S){{-1}}; return r; h: push rbx mov edx, 9216 mov rbx, rdi xor esi, esi sub rsp, 9216 mov rdi, rsp call memset mov rsi, rsp mov rdi, rbx mov edx, 9216 mov BYTE PTR [rsp], -1 call memcpy add rsp, 9216 pop rbx ret lol。
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。