内核的 C 代码在技术上是否不正确?

内核的 C 代码在技术上是否不正确?

人们可以在互联网上找到几个线程,例如:

http://www.gossamer-threads.com/lists/linux/kernel/972619

人们抱怨他们无法使用 -O0 构建 Linux,并被告知这不受支持; Linux 依靠 GCC 优化来自动内联函数、删除无效代码以及执行构建成功所需的操作。

我自己已经针对至少某些 3.x 内核验证了这一点。如果使用 -O0 编译,我尝试过的那些在构建时间几秒钟后退出。

这通常被认为是可接受的编码实践吗?编译器优化(例如自动内联)是否足够可预测且值得依赖?至少在只处理一个编译器时? GCC 的未来版本通过默认优化(即 -O2 或 -Os)破坏当前 Linux 内核的构建的可能性有多大?

更迂腐的一点是:由于 3.x 内核在没有优化的情况下无法编译,它们是否应该被视为技术上不正确的 C 代码?

答案1

您将几个不同(但相关)的问题组合在一起。其中一些并不是真正的主题(例如,编码标准),所以我将忽略它们。

我将从内核是否是“技术上不正确的 C 代码”开始。我从这里开始是因为答案解释了内核占据的特殊位置,这对于理解其余部分至关重要。

内核的 C 代码在技术上是否不正确?

答案肯定是“不正确”。

有几种方式可以认为 C 程序是不正确的。让我们先解决一些简单的问题:

  • 不遵循 C 语法(即有语法错误)的程序是不正确的。内核使用 C 语法的各种 GNU 扩展。就 C 标准而言,这些都是语法错误。 (当然,对于GCC来说,它们不是。尝试使用-std=c99 -pedantic或类似的编译...)
  • 不执行其设计目的的程序是不正确的。内核是一个巨大的程序,即使快速检查其更改日志也会证明,它肯定不是一个巨大的程序。或者,正如我们常说的,它有错误。

优化在 C 中意味着什么

[注意:本节包含对实际规则的非常错误的重述;有关详细信息,请参阅标准并搜索 Stack Overflow。]

现在需要更多解释。 C 标准规定某些代码必须产生某些行为。它还说某些在语法上有效的 C 事物具有“未定义的行为”;一个(不幸的是常见的!)示例是访问超出数组末尾的内容(例如,缓冲区溢出)。

未定义的行为是如此强大。如果一个程序包含它,即使是一点点,C 标准也不再关心程序表现出什么行为,或者编译器在面对它时会产生什么输出。

但即使程序只包含定义的行为,C 仍然允许编译器有很大的回旋余地。作为一个简单的示例(注意:对于我的示例,为了简洁起见,我省略了#include行等):

void f() {
    int *i = malloc(sizeof(int));
    *i = 3;
    *i += 2;
    printf("%i\n", *i);
    free(i);
}

当然,应该打印 5,后跟换行符。这就是 C 标准所要求的。

如果你编译该程序并反汇编输出,你会期望调用 malloc 来获取一些内存,返回的指针存储在某处(可能是寄存器),值 3 存储到该内存,然后 2 添加到该内存(可能是甚至需要加载、添加和存储),然后将内存复制到堆栈并将点字符串"%i\n"放入堆栈,然后printf调用函数。相当多的工作。但相反,您可能会看到就像您写的一样:

/* Note that isn't hypothetical; gcc 4.9 at -O1 or higher does this. */
void f() { printf("%i\n", 5) }

事情是这样的:C 标准允许这样做。 C标准只关心结果,而不是实现它们的方式。

这就是 C 语言优化的意义所在。编译器提出了一种更智能的方法(通常更小或更快,具体取决于标志)来实现 C 标准所需的结果。有一些例外,例如 GCC 的-ffast-math选项,但除此之外,优化级别不会改变技术上正确的程序的行为(即仅包含定义的行为的程序)。

您可以仅使用定义的行为编写内核吗?

让我们继续检查我们的示例程序。我们编写的版本,而不是编译器将其转换成的版本。我们做的第一件事是调用malloc来获取一些内存。 C 标准告诉我们malloc做什么,但没有告诉我们它是如何做的。

malloc如果我们看一下旨在清晰(而不是速度)的实现,我们会发现它会进行一些系统调用(例如mmapwith MAP_ANONYMOUS)来获取大块内存。它在内部保留一些数据结构,告诉它该块的哪些部分已使用,哪些部分空闲。它找到一个至少与您要求的大小一样大的空闲块,切出您要求的数量,并返回指向它的指针。它也完全用 C 编写,并且仅包含定义的行为。如果它是线程安全的,它可能包含一些 pthread 调用。

现在,最后,如果我们看看mmap发生了什么,我们会看到各种有趣的东西。首先,它会进行一些检查以查看系统是否有足够的可用 RAM 和/或交换空间用于映射。接下来,它找到一些空闲地址空间来放入块。然后它编辑一个称为页表的数据结构,并可能在此过程中进行一系列内联汇编调用。它实际上可能会找到一些物理内存的空闲页面(即实际 DRAM 模块中的实际位)——这个过程可能需要强制其他内存进行交换——也。如果它没有对整个请求的块执行此操作,则会进行设置,以便在首次访问所述内存时发生这种情况。其中大部分是通过内联汇编、写入各种魔术地址等来完成的。另请注意,它还使用了内核的大部分,特别是在需要交换的情况下。

内联汇编、写入魔术地址等都超出了 C 规范。这并不奇怪; C 可以运行在许多不同的机器架构上,其中包括许多在 20 世纪 70 年代初期 C 发明时几乎无法想象的机器架构。隐藏特定于机器的代码是内核(以及某种程度上的 C 库)的核心部分。

当然,如果您回到示例程序,就会清楚地看到printf一定是相似的。如何在标准 C 中进行所有格式化等操作非常清楚;但实际上将其显示在显示器上?或者通过管道传输到另一个程序?内核(可能还有 X11 或 Wayland)再次发挥了很多魔力。

如果您考虑内核所做的其他事情,其中​​很多都在 C 之外。例如,内核将数据从磁盘(C 不知道磁盘、PCIe 总线或 SATA)读取到物理内存(C 只知道 malloc、不是 DIMM、MMU 等),使其可执行(C 对处理器执行位一无所知),然后将其作为函数调用(不仅在 C 之外,非常不允许)。

内核与其编译器之间的关系

如果您还记得之前的情况,如果程序包含未定义的行为,则就 C 标准而言,所有的赌注都会失败。但内核确实必须包含未定义的行为。因此,内核与其编译器之间必须存在某种关系,至少足以让内核开发人员确信内核可以正常工作,尽管违反了 C 标准。至少就 Linux 而言,这包括内核对 GCC 内部工作原理有一定的了解。

坏掉的可能性有多大?

未来的 GCC 版本可能会破坏内核。我可以非常自信地说出这一点,因为这种情况以前发生过好几次。当然,GCC 中严格的别名优化之类的东西也破坏了除内核之外的很多东西。

另请注意,Linux 内核所依赖的内联不是自动内联,而是内核开发人员手动指定的内联。有很多人用 -O0 编译了内核,并报告在修复了一些小问题后它基本上可以工作。 (其中之一甚至在您链接的线程中)。大多数情况下,内核开发人员认为没有理由使用 进行编译-O0,并且需要优化作为副作用,使得一些技巧发挥作用,并且没有人使用 进行测试-O0,因此不支持它。

例如,它可以编译并链接到-O1或更高版本,但不能编译和链接-O0

void f();

int main() {
    int x = 0, *y;
    y = &x;

    if (*y)
        f();
    return 0;
}

通过优化,gcc 可以确定f()永远不会被调用,并忽略它。如果不进行优化,gcc 会保留调用,并且链接器会失败,因为没有f().内核开发人员依靠类似的行为来使内核代码更易于读/写。

答案2

来自Gentoo GCC 优化 Wiki

第 2.3 节:-O 标志

-O 接下来是 -O 变量。这控制了优化的总体水平。这使得代码编译需要更多的时间,并且可能占用更多的内存,特别是当您提高优化级别时。

有七个 -O 设置:-O0、-O1、-O2、-O3、-Os、-Og 和 -Ofast。您应该在 /etc/portage/make.conf 中只使用其中之一。

除了 -O0 之外,每个 -O 设置都会激活几个附加标志,因此请务必阅读 GCC 手册中有关优化选项的章节,以了解在每个 -O 级别激活哪些标志,以及它们的一些解释做。

让我们检查一下每个优化级别:

-O0:此级别(即字母“O”后跟零)完全关闭优化,如果在 CFLAGS 或 CXXFLAGS 中未指定 -O 级别,则该级别是默认值。这可以减少编译时间并可以改善调试信息,但如果不启用优化,某些应用程序将无法正常工作。除调试目的外,不建议使用此选项。
-O1:这是最基本的优化级别。编译器将尝试在不花费太多编译时间的情况下生成更快、更小的代码。这是非常基本的,但它应该能够始终完成工作。
-O2:比 -O1 更进一步。除非您有特殊需要,否则这是建议的优化级别。除了 -O1 激活的标志之外,-O2 还将激活更多标志。使用 -O2,编译器将尝试提高代码性能,而不会影响大小,也不会花费太多编译时间。
-O3:这是可能的最高优化级别。它可以实现在编译时间和内存使用方面代价高昂的优化。使用 -O3 进行编译并不能保证提高性能,事实上,在许多情况下,由于二进制文件较大和内存使用量增加,可能会降低系统速度。 -O3 还可以破坏多个软件包。因此,不建议使用-O3。
-Os:此选项将优化代码的大小。它激活所有不会增加生成代码大小的 -O2 选项。它对于磁盘存储空间极其有限和/或 CPU 缓存较小的计算机非常有用。
-Og:在 GCC 4.8 中,引入了新的通用优化级别 -Og。它满足了快速编译和卓越调试体验的需求,同时提供合理水平的运行时性能。总体开发体验应该优于默认优化级别-O0。请注意,-Og 并不意味着 -g,它只是禁用可能干扰调试的优化。
-Ofast:GCC 4.7 中的新增功能,由 -O3 加上 -ffast-math、-fno-protect-parens 和 -fstack-arrays 组成。此选项违反了严格的标准合规性,不建议使用。如前所述,-O2 是建议的优化级别。如果包编译失败并且您没有使用 -O2,请尝试使用该选项进行重建。作为后备选项,请尝试将 CFLAGS 和 CXXFLAGS 设置为较低的优化级别,例如 -O1 甚至 -O0 -g2 -ggdb(用于错误报告和检查可能的问题)。

您具体询问了有关 -O0 的问题,这不是优化。阅读上面的内容表明 O0 只能用于调试。如果您曾经使用过 menuconfig,您会注意到有一个选项可以启用或禁用内核调试。启用后,此选项输出调试信息,其方式与 O0 为您提供信息的方式大致相同。另外,我认为您可能忽略了一点,即整个系统是使用一个且仅有一个优化设置构建或编译的,即您不能在 O0 编译内核,而在 O2 编译系统的其余部分


关于 GCC 版本之间的向后兼容性,GCC 将始终保持版本之间的兼容性,因为在一个版本中启用 -O 标志与新版本中的 -O 设置相同。请参阅上面关于 GCC4.7 和 -Ofast 选项的注释,因为该选项仅在 4.7 及以后版本中可用,但 4.7 中的 -O2 = 每个版本中的 -O2

相关内容