具有不同编译器的不同翻译单元中的内联函数标记未定义的行为?

inline function in different translation units with different compiler flags undefined behaviour?

在 visual studio 中,您可以为各个 cpp 文件设置不同的编译器选项。例如:在 "code generation" 下,我们可以在调试模式下启用基本的运行时检查。或者我们可以更改浮点模型 (precise/strict/fast)。这些只是例子。有很多不同的标志。

一个内联函数可以在程序中定义多次,只要定义相同即可。我们将此函数放入 header 并将其包含在多个翻译单元中。现在,如果不同 cpp 文件中的不同编译器选项导致函数的编译代码略有不同,会发生什么情况?那么他们确实不同,我们有未定义的行为?您可以将函数设为静态(或将其放入未命名的命名空间),但更进一步,直接在 class 中定义的每个成员函数都是隐式内联的。这意味着如果这些 cpp 文件共享相同的编译器标志,我们只能在不同的 cpp 文件中包含 classes。我无法想象这是真的,因为这基本上很容易出错。

我们在未定义行为领域真的那么快吗?还是编译器会处理这种情况?

就标准而言,命令行标志的每种组合都会将编译器转换为不同的实现。虽然实现能够使用其他实现生成的目标文件很有用,但标准没有强加要求它们这样做。

即使没有内联,也可以考虑在一个编译单元中包含以下函数:

char foo(void) { return 255; }

以及另一个:

char foo(void);
int arr[128];
void bar(void)
{
  int x=foo();
  if (x >= 0 && x < 128)
     arr[x]=1;
}

如果 char 在两个编译单元中都是有符号类型,则第二个单元中 x 的值将小于零(从而跳过数组赋值)。如果它在两个单元中都是无符号类型,它将大于 127(同样跳过赋值)。但是,如果一个编译单元使用有符号 char 而另一个使用无符号,并且如果实现期望 return 值在结果寄存器中进行符号扩展或零扩展,则结果可能是编译器可能会确定 x 不能大于 127,即使它包含 255,或者它不能小于 0,即使它包含 -1。因此,生成的代码可能会访问 arr[255]arr[-1],并可能导致灾难性的结果。

虽然在许多情况下使用不同的编译器标志组合代码应该是安全的,但标准没有努力区分这种混合是安全的还是不安全的。

an inline function can be defined multiple times in the program, as long as the definitions are identical

没有。 ("Identical" 在这里甚至不是一个定义明确的概念。)

形式上,定义必须在某种非常强烈的意义上是等价的,这甚至没有意义作为一个要求,也没有人关心:

// in some header (included in multiple TU):

const int limit_max = 200; // implicitly static

inline bool check_limit(int i) {
  return i<=limit_max; // OK
}

inline int impose_limit(int i) {
  return std::min(i, limit_max); // ODR violation
}

这样的代码是完全合理的,但形式上违反了一个定义规则:

in each definition of D, corresponding names, looked up according to 6.4 [basic.lookup], shall refer to an entity defined within the definition of D, or shall refer to the same entity, after overload resolution (16.3 [over.match]) and after matching of partial template specialization (17.9.3 [temp.over]), except that a name can refer to a const object with internal or no linkage if the object has the same literal type in all definitions of D, and the object is initialized with a constant expression (8.20 [expr.const]), and the value (but not the address) of the object is used, and the object has the same value in all definitions of D;

因为异常不允许使用具有内部链接的 const 对象(const int 是隐式静态的)以直接绑定 const 引用(然后仅将引用用于其值) .正确的版本是:

inline int impose_limit(int i) {
  return std::min(i, +limit_max); // OK
}

这里 limit_max 的值用在一元运算符 + 中,然后一个 const 引用绑定到一个用该值 初始化的临时值。谁真的这样做了?

但即使是委员会也不认为正式的 ODR 很重要,正如我们在 Core Issue 1511 中看到的那样:

1511. const volatile variables and the one-definition rule

Section: 6.2 [basic.def.odr] Status: CD3 Submitter: Richard Smith Date: 2012-06-18

[Moved to DR at the April, 2013 meeting.]

This wording is possibly not sufficiently clear for an example like:

  const volatile int n = 0;
  inline int get() { return n; }

我们看到,委员会认为,这种公然违反 ODR 的意图和目的的写法,是一种在每个 TU 中读取不同易失性对象的代码,即对 different 对象具有可见副作用的代码,因此 different 可见副作用 是可以的,因为我们这样做了关心哪个是哪个.

重要的是内联函数的效果是模糊等价的:做一个 volatile int read,这是一个非常弱的等价,但足以自然使用 ODR 实例无差异:使用内联函数的哪个特定实例并不重要,也不会产生影响

特别是 volatile 读取读取的值根据定义编译器是未知的,因此编译器分析的 post 条件和此函数的不变量是相同的。

当在不同的 TU 中使用不同的函数定义时,您需要确保从调用者的角度来看它们是严格等价的:永远不可能用一个替换另一个来让调用者感到惊讶。这意味着即使代码不同,可观察到的行为也必须完全相同

如果您使用不同的编译器选项,它们不得更改函数可能结果的范围(编译器认为可能)。

因为 "standard"(这不是真正的编程语言规范)允许浮点对象具有其官方声明类型所不允许的真实表示,以完全不受约束的方式,使用任何除非您激活“double 意味着 double”模式(这是唯一的理智模式),否则任何受 ODR 多重定义的非易失限定浮点类型似乎都有问题。

我最近写了一些GCC代码测试是否真的存在这个问题。

剧透:确实如此。

设置:

我正在使用 AVX512 指令编译我们的一些代码。由于大多数 cpu 不支持 AVX512,我们需要在没有 AVX512 的情况下编译大部分代码。问题是:用AVX512编译的cpp文件中使用的inline function是否可以"poison"整个库带有非法指令。

想象这样一种情况,非 AVX512 cpp 文件中的函数调用我们的函数,但它命中了来自 AVX512 编译单元的程序集。这将使我们在非 AVX512 机器上 illegal instruction

让我们试一试:

func.h

inline void __attribute__ ((noinline)) double_it(float* f) {
  for (int i = 0; i < 16; i++)
    f[i] = f[i] + f[i];
}

我们定义了一个内联函数(在 link 意义上)。使用硬编码 16 将使 GCC 优化器使用 AVX512 指令。我们必须让它 ((noinline)) 来防止编译器内联它(即将它的代码粘贴到调用者)。这是假装此函数太长不值得内联的廉价方法。

avx512.cpp

#include "func.h"
#include <iostream>

void run_avx512() {
  volatile float f = 1;
  float arr [16] = {f};
  double_it(arr);
  for (int i = 0; i < 16; i++)
    std::cout << arr[i] << " ";
  std::cout << std::endl;
}

这是AVX512使用我们的double_it功能。它将一些数组加倍并打印结果。我们将使用 AVX512 对其进行编译。

non512.cpp

#include "func.h"
#include <iostream>

void run_non_avx() {
  volatile float f = 1;
  float arr [16] = {f};
  double_it(arr);
  for (int i = 0; i < 16; i++)
    std::cout << arr[i] << " ";
  std::cout << std::endl;
}

和之前一样的逻辑。这个不会用AVX512编译。

lib_user.cpp

void run_non_avx();

int main() {
  run_non_avx();
}

一些用户代码。调用未使用 AVX512 编译的 `run_non_avx。它不知道它会膨胀 :)

现在我们可以编译这些文件并link将它们作为共享库(可能常规库也可以)

g++ -c avx512.cpp -o avx512.o -O3 -mavx512f -g3 -fPIC
g++ -c non512.cpp -o non512.o -O3 -g3 -fPIC
g++ -shared avx512.o non512.o -o libbad.so
g++ lib_user.cpp -L . -lbad -o lib_user.x
./lib_user.x

运行 我机器上的这个(没有 AVX512)给了我

$ ./lib_user.x
Illegal instruction (core dumped)

附带说明一下,如果我更改 avx512.o non512.o 的顺序,它就会开始工作。似乎 linker 忽略了相同功能的后续实现。