【问题标题】:inline function in different translation units with different compiler flags undefined behaviour?具有不同编译器标志的不同翻译单元中的内联函数未定义行为?
【发布时间】:2019-02-02 16:16:47
【问题描述】:

在 Visual Studio 中,您可以为各个 cpp 文件设置不同的编译器选项。例如:在“代码生成”下,我们可以在调试模式下启用基本运行时检查。或者我们可以改变浮点模型(精确/严格/快速)。这些只是例子。有很多不同的标志。

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

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

【问题讨论】:

  • 所以,sum(2,2) 在一个编译单元中给出 4,在另一个编译单元中给出 5。我在这里看不到未定义的行为,
  • @AlexF 问题是the one definition rule。不同translation units 中的多个(不同)定义是UB。模板的情况略有不同,但对于非模板化的内联函数,它可能绝对是个问题。
  • @AlexF 它说必须在使用 odr 的每个翻译单元中定义内联函数,并且定义必须满足项目符号列表中的条件(这实际上是说所有定义必须看起来相同且等价)。
  • 我认为这是一个灰色地带。 ODR 要求涉及多个定义的形式和语义,而不是生成的代码。
  • @Someprogrammerdude 我看不出如何用任何标准解释来解决这个问题,因为就语言而言,编译器标志甚至不存在。

标签: c++ language-lawyer one-definition-rule inline-functions translation-unit


【解决方案1】:

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

即使没有内联,也请考虑在一个编译单元中包含以下功能:

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 而另一个使用无符号,并且如果实现预期在结果寄存器中返回值以符号扩展或零扩展,则结果可能是编译器可能会确定 @987654326 @ 不能大于 127,即使它持有 255,或者它不能小于 0,即使它持有 -1。因此,生成的代码可能会访问arr[255] 或arr[-1],从而产生潜在的灾难性后果。

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

【讨论】:

  • 所以经验法则是不要更改单个翻译单元中的标志。如果这听起来很严格,为什么 MSVC 允许这样做?我理解它的警告级别和其他不那么重要的东西,但代码兼容性破坏标志正在敲响我的警钟。
  • @phön:编译的某些方面需要在翻译单元之间保持一致;其他部分没有。实现的文档通常应该明确哪些部分是哪些部分,但这是标准管辖范围之外的实现质量问题。我的意思不是说更改标志通常很危险,但它并不总是安全。
  • @phön:另外,关于 MSVC,我认为该编译器在 x86 和 x64 模式下使用的调用约定指定当函数返回 char 或 unsigned char 时,底部 8扩展 AX 寄存器(EAX 或 RAX)的位保存该值,其余位(其中 24 或 56 个)保存未指定的值。因此,在使用 MSVC 时,返回有符号字符但被期望为无符号字符的代码调用的函数(反之亦然)将表现得好像结果已转换为调用者期望的类型。然而,像 ARM 之类的其他平台就不会表现得这么好。
  • "就标准而言,命令行标志的每种组合都会将编译器转换为不同的实现"我不知道您在哪里阅读。任何改变实现定义方面的标志组合(字符符号),是的。
  • @curiousguy:就标准而言,C 或 C++ 实现需要一堆源文件作为输入,以及要运行的程序的任何输入,并自动执行所有必要的操作产生任何有问题的程序应该产生的输出。该标准没有“持久”目标文件的概念,也没有定义任何方法可以在程序的任何部分开始编译阶段 1 的时间和程序的时间之间更改任何源文件或编译器的配置完成执行。
【解决方案2】:

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

剧透:确实如此。

设置:

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

想象这样一种情况,来自非 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];
}

我们定义了一个内联(在链接器意义上)函数。使用硬编码的 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。它不知道它会起泡:)

现在我们可以编译这些文件并将它们链接为共享库(可能普通的库也可以)

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 的顺序,它就会开始工作。似乎链接器忽略了相同功能的后续实现。

【讨论】:

  • 奇怪的是问题已经在 GCC 中解决,用于 LTO 优化,但不适用于标头中的非内联内联函数。无论如何,诀窍是强制内联。
【解决方案3】:

一个内联函数可以在程序中定义多次,如 只要定义相同

没有。 (“相同”在这里甚至不是一个定义明确的概念。)

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

// 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
}

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

在D的每一个定义中,对应的名字,根据 6.4 [basic.lookup],应指在 D 的定义中定义的实体,或应指重载后的同一实体 分辨率(16.3 [over.match])和部分模板匹配后 专业化(17.9.3 [temp.over]),除了名称可以引用 const 对象,如果对象具有相同的内部链接或没有链接 D 的所有定义中的字面量类型,并且对象已初始化 带有常量表达式 (8.20 [expr.const])、 和值(但不是 使用对象的地址),并且对象具有相同的值 在 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 变量和单定义规则

部分:6.2 [basic.def.odr] 状态:CD3 提交者:Richard 史密斯日期:2012-06-18

[在 2013 年 4 月的会议上移至 DR。]

对于以下示例,此措辞可能不够清晰:

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

我们看到,委员会认为这种公然违反 ODR 的意图和目的的行为,在每个 TU 中读取不同 volatile 对象的代码,即具有不同对象的可见副作用,所以不同可见副作用,没关系,因为我们不关心哪个是哪个。

重要的是内联函数的效果是模糊等价的:执行 volatile int 读取,这是一个非常弱的等价性,但足以自然使用 ODR instance indifference:使用内联函数的哪个特定实例无关紧要,也不会产生影响。

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

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

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

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

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2021-12-10
    • 2016-01-28
    • 2018-06-15
    • 1970-01-01
    • 2012-03-11
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多