C 函数参数传递不足对多种架构的影响分析Looking at consequences of passing too few register parameters to a C function on various architectures
微软工程师 Raymond Chen 深入分析了 C 语言函数调用时寄存器参数传递不足的问题在不同处理器架构下的后果。研究显示,在 x86、ARM 和 Itanium 等主流架构上,未正确处理寄存器参数会导致栈空间浪费、性能下降甚至程序崩溃。特别值得注意的是,Itanium 架构因复杂的 ABI 设计使得此类问题更为严重,错误传播范围更广。该分析为底层程序员提供了重要的跨平台兼容性警示。
Raymond Chen
在我们对 Windows 上各种处理器调用约定的探索中,了解到在许多情况下,部分参数是通过寄存器传递的。
假设有一个函数需要两个参数,但你已知当第一个参数为正数时,该函数会忽略第二个参数。那么如果你只传入一个参数(比如传入了零),会发生什么?既然函数会忽略第二个参数,那为什么少传一个参数会有影响呢?
即使函数不使用某个参数,它仍可能将该参数的存储空间当作方便使用的临时工作区。例如:
int blah(int a, int b)
{
if (a <= 0) {
int c = f1();
f2(a);
return c;
} else {
return f3(a, b);
}把零作为唯一参数传给 blah 函数是否安全?你没有传入 b,而函数也不使用 b,那这又有什么关系呢?
从形式上看,C 和 C++ 语言规定,如果以错误的参数数量调用函数,其行为是未定义的,因此严格来说你已经违反了规则,任何事情都可能发生。
但让我们来看看可能会发生哪些具体问题。
如果在栈上传递的参数过少,且采用被调者清理(callee-clean)的调用约定,则被调函数会从栈上清理过多的字节,导致栈不平衡,很可能引发内存损坏。
即使不是被调者清理的调用约定,被调函数也会认为参数对应的内存存在,并可能将其用作临时存储区,从而破坏调用函数的栈帧中的内存。
在我们上面的例子中,编译器可能会意识到:“嘿,我不需要为变量 c 分配新的内存,可以直接重用原来存放已失效变量 b 的内存。”换句话说,它会重写函数如下:
int blah(int a, int b)
{
if (a <= 0) {
b = f1();
f2(a);
return c;
} else {
return f3(a, b);
}即使你没有为变量 b 保留内存空间,编译器仍会假定你保留了,并覆盖原本应分配给它的位置上的内容。
但如果参数是通过寄存器传递的,而你传入的数量不足呢?
在大多数处理器上,被调函数会尝试使用该寄存器,并读取其中任意未初始化的值。
但在 Itanium 架构上例外。
Itanium 的一个特殊特性是存在“非事物”(NaT)位——每个通用寄存器都附带一位,用于指示该寄存器是否包含有效值。寄存器进入 NaT 状态的常见原因包括:它是某次失败的推测性加载的结果,或者它是至少一个输入本身为 NaT 的数学运算结果。因此,如果你的未初始化输出寄存器恰好是一个由先前失败推测留下的 NaT,被调函数在使用该寄存器之前可能会决定将其值溢出到栈上以确保安全。
extern bool is_valid(int);
int blah2(int a, int b)
{
if (is_valid(a)) {
return f3(a, &b);
} else {
return 0;
}
}编译器意识到若 a 无效则需要取 b 的地址,因此必须先将值溢出到内存(以便获得地址)。然而,将 NaT 写入内存会触发“NaT 消耗”异常,所以即使在从未真正使用变量 b 的情况下,该函数也会崩溃。
但等等,还有更多情况。
在 Itanium 架构上,函数调用机制是体系结构层面的,而不仅仅是传统意义上的实现。调用方会声明输出寄存器的数量(即传递给被调用方的寄存器),这些寄存器在被调用方入口处会被重新编号,以便从寄存器 r32 开始可见。如果调用方说“我传递 2 个寄存器”,那么被调用方看到的就是 r32 和 r33。我之前已经详细讨论过这一点,但叶子函数尤其值得注意。
叶子函数是指不创建自定义栈帧,而是直接使用处理器为其默认生成的体系结构栈帧的函数。而这个默认栈帧仅包含传入的参数寄存器。当传递给函数的参数不足时,意味着默认栈帧中的寄存器数量少于函数所期望的数量。
从体系结构的角度看,规则是:如果读取位于当前帧之外的已压栈寄存器,其结果是“未定义”的。我在 Itanium 文档中找不到“未定义”的正式定义(尽管很可能是我漏掉了),但我认为这意味着“可以产生任何结果,包括异常,且该结果不依赖于当前处理器执行模式之外的信息”。¹ 特别地,它可能引发处理器异常,例如,因为那个已压栈寄存器的值恰好包含一个残留的 NaT。
Itanium 架构对写入位于当前帧之外的栈寄存器采取了更严格的立场:它要求必须触发非法操作故障。
我可以想象,从一个寄存器到另一个寄存器的移动指令竟然抛出异常,这看起来会很奇怪。
所以,这就是另一个例子,Itanium 架构更加严格地强制执行编程规则,在这种情况下,就是确保向函数传递正确数量的参数。
¹ 这意味着,例如,用户态代码中的“未定义”结果不能依赖于仅内核态可用的信息。
作者
Raymond 参与 Windows 的发展已有 30 多年。2003 年,他创办了一个名为 The Old New Thing 的网站,其受欢迎程度远远超出了他的想象,这一发展至今仍让他感到毛骨悚然。该网站催生了一本同名书籍《The Old New Thing》(Addison Wesley, 2007)。他偶尔会在 Windows Dev Docs Twitter 账号上出现,讲述一些毫无用处的趣闻轶事。
需要完整排版与评论请前往来源站点阅读。