内核代码中的编译时检查

之前在工作中涉及对 2022 年 Linux Security Summit North America(LSS-NA)的调研,并向同事们介绍了其中一个演讲:Meaningful Bounds Checking in the Linux Kernel演讲材料 )。在分享过程中,有这样一段内容:

 1__FORTIFY_INLINE void *memcpy(void *dst, const void *src, size_t size)
 2{
 3        size_t dst_size = __builtin_object_size(dst, 1);
 4        size_t src_size = __builtin_object_size(src, 1);
 5 
 6        if (__builtin_constant_p(size)) {       /* Compile-time */
 7                if (dst_size < size)
 8                        __write_overflow();
 9                if (src_size < size)
10                        __read_overflow2();
11        }
12        if (dst_size < size || src_size < size)
13                fortify_panic(__func__);        /* Run-time */
14        return __underlying_memcpy(dst, src, size);
15}

这里讲的是开了 CONFIG_FORTIFY_SOURCE 后的 memcpy() 的实现,会对参数增加一些编译时(Compile-time)和运行时(Run-time)的检查。有同事就问到:这里的编译时检查是怎么实现的?就这个问题作了一番探究后,写成本文记录一下。

总结

内核代码中的编译时检查基于以下两点实现:

  1. error ("message") 函数属性(Function Attributes)1,参考 GCC 手册 6.33.1 节

    假如(在消除死代码后)代码中(仍)存在对带有该属性的函数的调用,那么编译就会报错。

  2. __OPTIMIZE__ 预置宏(Predefined Macro),参考 CPP 手册 3.7.2 节

    该宏在编译器开启了任意级别的优化时都会被定义。

error ("message") 函数属性

先来看看前文提到的编译时检查的代码:

1if (__builtin_constant_p(size)) {       /* Compile-time */
2        if (dst_size < size)
3                __write_overflow();
4        if (src_size < size)
5                __read_overflow2();
6}

__write_overflow()__read_overflow2() 的定义如下:

1/* include/linux/fortify-string.h */
2void __read_overflow2(void) __compiletime_error("detected read beyond size of object (2nd parameter)");
3void __write_overflow(void) __compiletime_error("detected write beyond size of object (1st parameter)");
1/* include/linux/compiler_attributes.h */
2#if __has_attribute(__error__)
3# define __compiletime_error(msg)       __attribute__((__error__(msg)))
4#else
5# define __compiletime_error(msg)
6#endif

可见,__write_overflow()__read_overflow2() 就是两个带有 error(msg) 属性的函数。

__OPTIMIZE__ 预置宏

对带 error(msg) 属性的函数无条件的调用,会让编译器无条件地报错,这不是我们想要的。若可借助编译器优化,把对这类函数的调用作为死代码的一部分消除掉,这样编译器就不会报错了。当前 Linux 内核默认开启 -O2 的编译优化:

 1# init/Kconfig
 2choice
 3	prompt "Compiler optimization level"
 4	default CC_OPTIMIZE_FOR_PERFORMANCE
 5
 6config CC_OPTIMIZE_FOR_PERFORMANCE
 7	bool "Optimize for performance (-O2)"
 8	...
 9
10config CC_OPTIMIZE_FOR_PERFORMANCE_O3
11	bool "Optimize more for performance (-O3)"
12	...
13
14config CC_OPTIMIZE_FOR_SIZE
15	bool "Optimize for size (-Os)"
16	...
17
18endchoice
1# Makefile
2ifdef CONFIG_CC_OPTIMIZE_FOR_PERFORMANCE
3KBUILD_CFLAGS += -O2
4else ifdef CONFIG_CC_OPTIMIZE_FOR_PERFORMANCE_O3
5KBUILD_CFLAGS += -O3
6else ifdef CONFIG_CC_OPTIMIZE_FOR_SIZE
7KBUILD_CFLAGS += -Os
8endif

可见内核代码具备这样的条件。回顾前文提到的编译时检查代码:

1size_t dst_size = __builtin_object_size(dst, 1);
2size_t src_size = __builtin_object_size(src, 1);
3
4if (__builtin_constant_p(size)) {
5        if (dst_size < size)
6                __write_overflow();
7        if (src_size < size)
8                __read_overflow2();
9}

通过 __builtin_constant_p() 确定 size常量,即编译时已知的量后,才有可能进行编译时检查。若 size 不是常量,则

1if (__builtin_constant_p(size)) {
2        ...
3}

就等同于

1if (false) {
2        ...
3}

在开启优化后,整个编译时检查的代码块都会作为死代码被优化掉。同理,在确定 size 是常量后,__builtin_object_size()2 返回的 {dst,src}_size 也是常量,那么 if (dst_size < size)if (src_size < size) 也可被优化为 if (true)if (false),从而令 __write_overflow()__read_overflow2() 被优化或遗留。若被遗留则最终触发编译器报错。

等等,到目前为止好像没 __OPTIMIZE__ 什么事儿啊?实际上,上述的一切都依赖编译器优化,而该宏的存在就指示了编译器优化的开启:

1/* include/linux/string.h */
2#if !defined(__NO_FORTIFY) && defined(__OPTIMIZE__) && defined(CONFIG_FORTIFY_SOURCE)
3#include <linux/fortify-string.h>
4#endif

可见,整个 include/linux/fortify-string.h 都是以开启优化为前提的。

结语

这里列一些内核中与编译时检查相关的代码,可供搜寻相关上下文:

  • BUILD_BUG_ON_MSG
  • __write_overflow
  • 但不包括 check_*_overflow()(这些属于运行时检查)

后续:偶然看到了个简单粗暴的编译时检查设计 。挺有趣的😄


  1. 用法 __attribute__((__error__(msg)))。这里 __error__() 等同于 error(),详见 GCC 手册 6.39 节 。 ↩︎

  2. 参考 GCC 手册 6.58 节 。 ↩︎


Things That Happen Around Function Calls
ARMv8.3 的 PAC 特性