C语言的嵌套函数(nested function)是GCC的一个老扩展,专门用来写回调。多年来,只要你把父函数里的局部变量塞进一个嵌套函数、再把这个函数的地址传出去,GCC背后就会悄悄在栈上生成一段可执行代码,运行时动态写入再跳转执行。这段代码叫trampoline,代价是你的程序栈必须允许执行——这恰好是现代内存安全防护(W^X、不可执行栈)最想禁止的事。这个矛盾存在了很久,GCC最近两个版本才算真正动手去解决。
GCC 16 先解决"最容易的那部分"
GCC 16正式保证:一个不访问父函数变量的嵌套函数,不管开不开优化,都不会生成trampoline——之前这个保证只在开优化时成立,现在连-O0也算数,而且写进了文档。这类函数依然能访问静态变量、常量和类型,足够写不少回调,但代价是要手动把要用的数据打包成结构体传进去,相当于自己模拟一个闭包。
真正难啃的是会访问父函数变量的嵌套函数——这才是嵌套函数原本最有用的地方,也是trampoline问题的重灾区。
GCC 17 补上"会捕获变量"的那一半
一个补丁在今年被批准合并进GCC 17的开发分支,新增两个内置函数__builtin_call_code_address和__builtin_call_static_chain,配合已有的__builtin_call_with_static_chain,可以把闭包拆成一对指针——代码地址和环境指针(static chain)——分别取出来,调用时手动传回去,整个过程不再需要trampoline。作者把这套东西包成一个"宽指针"结构体{code, chain},再配上宏,写出来的回调代码几乎和普通函数指针一样自然,编译器甚至能把它优化成一条lea指令。
这两个内置函数的命名本身经历过反复讨论,早期版本用的是__builtin_nested_code一类的名字,最终定稿才改成现在这样——说明这不是一次拍板的设计,是社区评审磨出来的结果。GCC 17目前还没有正式发布,这个特性理论上仍有被微调的空间。
但这不是一个"装了就好"的开关
问题在于,这套方案要求开发者主动改写调用方式。普通C代码里,回调函数指针长这样:int (*cb)(int),调用方直接cb(x)就行,qsort、pthread这些标准库和无数第三方API都是照这个假设写的。而宽指针方案要求把函数指针换成{code, chain}结构体,调用时必须知道要用__builtin_call_with_static_chain——如果调用方是一份不知道这套协议的第三方库,直接拿裸的code地址当普通函数指针去调,一旦这个嵌套函数其实捕获了变量,那就是未定义行为。
- 风险.这意味着新方案目前只能用在"调用双方代码你自己都写"的封闭场景,换不掉那些期待标准函数指针的通用回调接口。
再看GCC现有的两条退路,同样不算真退路。-fno-trampolines这个选项,文档里写得很清楚:对C这类语言,在大多数需要trampoline的目标平台上实际不起作用。把trampoline挪到堆上的-ftrampoline-impl=heap能避开可执行栈,但目前文档记载支持的目标平台只有三个,分配开销更高,longjmp还可能让它泄漏。GCC提供的-Wtrampolines警告至少能让你在编译期知道自己踩没踩这个坑,算是目前最现实的"体检工具"。
补丁堵的是后门,不是墙。
谁该盯着这件事
对系统编程、内核、安全关键软件这类对可执行栈零容忍的场景,这条进展是实打实的利好——至少给了一条不依赖trampoline的路。但对写日常业务代码的C程序员,这套东西目前更像是给"愿意深度定制调用协议"的人准备的选项,不是随手用一下就能白拿的安全收益。
作者在文章末尾预告,后续会讨论如何让这套写法同时兼容clang——这才是决定它能不能走出GCC自留地的关键。跨编译器的static chain寄存器约定目前是GCC和目标平台各自的惯例,不是通用ABI标准的一部分。真正的答案,大概要等语言标准本身接过"宽指针类型"这个概念,而不是靠内置函数手工拼出来。眼下能确定的是:GCC 17还没发布,这场堵漏工程,走到一半。
