这是未定义的行为。它可能会起作用,也可能不会,这取决于编译器在为某些特定目标编译时碰巧选择了什么。它实际上是un定义的,而不是“保证会破坏”;这就是重点。编译器在生成代码时可以完全忽略 UB 的可能性,而不是使用额外的指令来确保 UB 中断。 (如果需要,请使用 -fsanitize=undefined 编译)。
了解究竟发生了什么需要查看 asm,而不仅仅是尝试运行它。
warning: address of stack memory associated with local variable 'buf' returned [-Wreturn-stack-address]
return buf;
^~~
即使没有 -Wall 启用,Clang 也会打印此警告。 正是因为它不是合法的 C,无论您的目标是什么 asm 调用约定。
Clang 使用为其编译的目标的 C 调用约定1。同一个 ISA 上的不同操作系统可以有不同的约定,尽管在 x86 之外大多数 ISA 只有一个主要的调用约定。 x86 已经存在了很长时间,以至于最初的调用约定(没有寄存器参数的堆栈参数)效率低下,因此各种 32 位约定不断发展。微软选择了与其他所有人不同的 64 位约定。于是就有了 x86-64 System V、Windows x64、i386 System V for 32-bit x86、AArch64 的标准约定、PowerPC 的标准约定等等。
我用 clang 测试了几次,每次显示字符串时
它是否“有效”的“决定”/“运气”是在编译时做出的,而不是在运行时。用同一个编译器多次编译/运行同一个源代码不会告诉你什么。
查看生成的 asm 以找出 char buf[4] 的结束位置。
我的猜测:也许您使用的是 Windows x64。与大多数调用约定相比,在那里发生工作更合理,您希望buf[4] 最终位于main 中的堆栈指针下方,因此call 到puts 和puts 本身将是很可能会覆盖它。
如果您在禁用优化的情况下在 Windows x64 上编译,retx() 的本地 char buf[4] 可能会放置在它拥有的影子空间中。然后调用者以相同的堆栈对齐方式调用puts(),因此retx 的影子空间变为puts 的影子空间。
如果puts 碰巧没有写入它的影子空间,那么retx 存储的内存中的数据仍然存在。例如也许puts 是一个包装函数,它依次调用另一个函数,而无需先为自己初始化一堆本地函数。但不是尾调用,所以它分配了新的影子空间。
(但这不是clang8.0在禁用优化的实践中所做的。看起来buf[4]将被放置在RSP下方并被踩到那里,使用__attribute__((ms_abi))从Linux clang获取Windows x64代码生成: https://godbolt.org/z/2VszYg)
但也有可能在堆栈参数约定中,在调用之前保留填充以将堆栈指针对齐 16。 (例如,用于 32 位 x86 的 Linux 上的现代 i386 System V)。 puts() 有一个 arg 但 retx() 没有,所以可能 buf[4] 最终在内存中,调用者在为 puts 推送指针 arg 之前“分配”为填充。
当然这是不安全的,因为在没有红区的调用约定中,数据会暂时位于堆栈指针下方。 (只有少数 ABI / 调用约定有红色区域:堆栈指针下方的内存,保证不会被目标进程中的信号处理程序、异常处理程序或调试器调用函数异步破坏。)
我想知道启用优化是否会使其内联并碰巧起作用。但是没有,我在 Windows x64 上测试过:https://godbolt.org/z/k3xGe4。 clang 和 MSVC 都优化了 "buf\0" 的任何存储到内存中。相反,他们只是将 puts 传递给一些未初始化的堆栈内存的指针。
由于启用优化而中断的代码几乎总是 UB。
脚注 1:x86-64 System V 除外,其中 clang 使用了一个额外的未记录的调用约定“功能”:窄整数类型作为寄存器中的函数参数被假定为符号扩展为 32 位。 gcc 和 clang 在调用时都会这样做,但 ICC 不会,因此从 ICC 编译的代码调用 clang 函数会导致损坏。见Is a sign or zero extension required when adding a 32bit offset to a pointer for the x86-64 ABI?