QUOTE
Clang 编译内核的难点,从来不只是语法,而是 GCC 多年沉淀下来的事实语义。
先澄清一个重要现状:今天再说“Linux 内核很难用 Clang 编译”,已经不准确了。
这句话更适合描述过去,尤其是 Linux 5.9 之前的历史阶段。得益于 Google、LLVM 社区和内核社区多年的投入,Clang/LLVM 现在已经是 Linux 内核官方支持的编译器路径之一。对很多架构和配置来说,编译内核已经可以直接写成:
make LLVM=1
问题不在于 Clang 现在不能编译内核,而在于 Linux 内核长期不是“ISO C 的简单子集”。它更像是一套工程化的 GNU C 方言:C 语言本体、GNU C 扩展、架构内联汇编、链接脚本、段布局、编译器内置函数、内核自己的宏系统,全部缠在一起。
Clang 要支持内核,难点不是“把语法解析过去”这么简单。真正困难的是把 GCC 多年形成的边界语义、优化时机、汇编约束、符号保留、诊断行为和 ABI 细节对齐到足够接近。Linux 内核越接近硬件、越靠近启动和异常路径,这些差异就越容易变成编译失败、链接失败,甚至运行时崩溃。
这篇文章按开发者影响来拆:内核到底用了哪些 GNU C 扩展,为什么它们有必要,Clang 当年卡在哪里,以及今天看这些历史问题应该得到什么工程结论。
01
OVERVIEW

一个更准确的判断是:
「Linux 内核能被 Clang 编译,不是因为内核放弃了 GNU C,而是因为 Clang 学会了足够多 GCC 语义;同时,内核也清理了一批只在 GCC 下“碰巧工作”的代码。」
02
WHY GNU C
普通应用可以把 C 写得很接近标准 C。内核不行。
内核没有标准库运行时,没有用户态异常处理,没有普通进程模型,也不能把性能关键路径都交给编译器自由发挥。它需要直接表达几类能力:
精确控制某段代码和数据放进哪个 ELF section。
在 C 代码中嵌入架构汇编,并告诉编译器寄存器、内存和控制流会发生什么。
用宏生成类型安全、零运行时成本的抽象。
在编译期根据常量性、类型、结构体布局选择不同实现。
把启动后不再需要的初始化代码释放掉,把初始化后只读的数据改成写保护。
在不同架构之间保留同一套 C 接口,同时允许架构覆盖底层实现。
标准 C 本身没有给这些需求提供足够表达力。GNU C 扩展于是成了内核代码的一部分。
这并不等于内核随意依赖“黑魔法”。很多扩展是为了把本来只能写成汇编、链接脚本或手写 ABI glue 的东西,尽量收进 C 层,并交给编译器参与类型检查和优化。
03
INLINE ASM
Linux 内核里最难兼容的部分,一直是内联汇编。
普通内联汇编已经不简单。内核还会同时使用输入操作数、输出操作数、clobber、早期 clobber、立即数修饰符、地址修饰符、架构寄存器约束、volatile 语义和 memory barrier。对编译器来说,这些信息直接影响寄存器分配、指令选择、调度和优化边界。
更麻烦的是 asm goto。
asm goto 是 GCC 扩展,允许汇编代码直接跳转到 C 语言标签:
asm goto(
"test %0, %0\n\t"
"jz %l[zero]"
:
: "r"(value)
: "cc"
: zero
);
/* value is not zero. */
return 1;
zero:
return 0;
这类能力对内核很有价值,因为内核经常需要在极短路径上插入可被运行时修补的跳转点。例如 jump label 和 static key 会把一个分支在默认情况下变成接近零成本的 nop,需要开启时再把机器码 patch 成跳转。
早期 Clang 支持 asm goto 的过程很痛苦,尤其是“带输出操作数的 asm goto”。这种写法不仅告诉编译器“汇编可能跳到某个标签”,还告诉它“汇编会产生一个 C 变量值”。这相当于同时改变控制流图和数据流图:
asm goto(
"/* arch-specific sequence */"
: "=r"(out)
: "r"(in)
: "memory"
: failed
);
编译器必须回答几个问题:
如果汇编跳到了 failed,输出值是否有效。
输出寄存器能否和输入寄存器重叠。
哪些寄存器在不同控制流边上仍然活跃。
优化器能否移动这段汇编前后的内存访问。
后端在生成机器码时如何表达这种多出口、多结果的指令。
这不是前端加一个语法开关就能解决的问题。它会一路影响 LLVM IR、SelectionDAG 或 GlobalISel、寄存器分配和机器码验证。直到 LLVM 16 前后,带输出的 asm goto 才算进入比较可用的状态。
内核里的汇编模板还大量使用 GCC 风格修饰符,例如 %c0 打印立即数、%a0 打印地址,以及各种架构私有约束。Clang 早期经常不是“不支持 inline asm”,而是支持了 99%,最后 1% 刚好落在内核最热、最底层、最不能绕开的路径上。
04
MACROS
Linux 内核宏大量依赖两个 GNU C 扩展:
({ ... })
__typeof__(x)
语句表达式允许一个代码块产生值。typeof 允许宏拿到表达式的类型。二者结合后,宏可以做到“像函数一样只求值一次,又保留参数类型”。
典型例子是简化版 min:
#define min(x, y) ({ \
typeof(x) _x = (x); \
typeof(y) _y = (y); \
_x < _y ? _x : _y; \
})
如果没有语句表达式,宏很容易重复求值:
#define bad_min(x, y) ((x) < (y) ? (x) : (y))
int v = bad_min(i++, j);
这里 i++ 可能执行两次。内核不能接受这种宏副作用。
container_of 也是同一类思路。它通过成员指针反推出外层结构体指针,是链表、引用计数、驱动模型等内核基础设施的常用工具:
#define container_of(ptr, type, member) ({ \
const typeof(((type *)0)->member) *__mptr = (ptr); \
(type *)((char *)__mptr - offsetof(type, member)); \
})
Clang 很早就支持语句表达式和 typeof,但内核的问题在于组合复杂度。一个宏可能同时使用 typeof、__builtin_types_compatible_p、__builtin_constant_p、条件表达式、位操作和编译期断言。GCC 和 Clang 在什么时候认定一个表达式是常量、什么时候发出诊断、错误信息绑定到哪一层宏展开上,都可能不同。
这些差异单看语言标准很难判断对错,因为它们本来就不属于 ISO C。内核只能在实践中把 GCC 的事实语义当成兼容目标,同时把一些过度依赖边界行为的宏改得更明确。
05
CONTROL FLOW
GNU C 允许取一个局部标签的地址:
static void run(unsigned int op)
{
static void *targets[] = {
&&op_load,
&&op_add,
&&op_store,
};
goto *targets[op];
op_load:
/* ... */
return;
op_add:
/* ... */
return;
op_store:
/* ... */
return;
}
这叫 labels as values,也常被称为 computed goto。它可以把解释器式分发、状态机或某些低层 fast path 写成直接跳转,减少 switch 生成代码的不确定性。
内核并不是到处都用 computed goto,但这类扩展一旦出现在架构相关路径里,编译器就必须和链接器、重定位模型、控制流完整性检查、异常表生成配合好。
Clang 的语法支持不是最大问题。真正容易出问题的是后端:标签地址在不同架构上如何重定位,优化器能不能合并或重排基本块,CFI 或 LTO 是否会误判间接跳转目标。用户态程序里这种差异可能只是性能变化;内核里可能就是启动失败或某个异常路径无法返回。
06
SECTIONS
Linux 内核大量使用 section 属性:
#define __init __attribute__((section(".init.text")))
#define __ro_after_init __attribute__((section(".data..ro_after_init")))
这类标记不是装饰。它们决定代码和数据的生命周期。
启动阶段用完的初始化函数会进入 .init.text,启动完成后对应内存可以释放。初始化后不应再修改的数据会进入特殊段,等系统完成初始化后再变成只读。异常表、跳转标签、tracepoint、静态调用、设备表、模块元数据,也都依赖编译器把对象放进约定 section。
问题在于,优化器天生想做几件事:
没被直接调用的函数可以删除。
很短的函数可以内联。
等价常量可以合并。
局部符号可以改名或隐藏。
跨模块优化可以重新安排调用边界。
这些在普通程序里是好事,在内核里可能破坏链接脚本和运行时 patch 机制。尤其是 LTO 打开后,编译器看到的是更大的全局视野,也更容易把内核“通过 section 被使用”的对象误判为无用。
弱符号和 alias 也有类似问题:
void generic_memcpy(void *dst, const void *src, unsigned long n);
void memcpy(void *dst, const void *src, unsigned long n)
__attribute__((weak, alias("generic_memcpy")));
内核常用通用实现提供默认符号,再让架构代码提供更优实现覆盖它。Clang/LLVM 必须在优化、LTO 和链接阶段都尊重这些符号关系。否则编译器可能觉得自己只是做了一个合理优化,内核看到的却是启动路径、异常路径或架构覆盖实现被破坏。
07
ARRAY LAYOUT
老 C 代码里常见一种写法:
struct packet {
unsigned int len;
unsigned char data[0];
};
data[0] 是 GNU C 扩展,常用来表达“结构体后面紧跟一段可变长度数据”。标准 C99 后有了 flexible array member:
struct packet {
unsigned int len;
unsigned char data[];
};
Linux 内核历史悠久,零长度数组、单元素数组和 flexible array member 都曾在不同代码里出现。它们牵涉结构体大小、尾部 padding、越界检查、对象大小推导和 sanitizer 诊断。
Clang 早期在一些边界上和 GCC 不完全一致。例如 sizeof 推导、__builtin_object_size、UBSan bounds 检查,对内核里“按协议布局有意访问尾部数据”的模式可能给出不同判断。结果不是代码逻辑错了,而是编译器看不懂内核的布局约定。
范围初始化也是 GNU C 常用扩展:
static int table[16] = {
[0 ... 7] = 1,
[8 ... 15] = 2,
};
这类写法在表驱动代码里很自然,尤其适合 syscall table、字符分类表、状态表、权限位图等场景。Clang 对语法本身支持较早,但仍需要在诊断、常量表达式和目标文件生成上保持和 GCC 兼容。
近年来内核社区持续推动更明确的数组尾部写法,例如迁移到标准 flexible array member,并引入 __counted_by 这类属性来帮助编译器和 sanitizer 理解“长度字段”和“尾部数组”的关系。这不是为了迁就 Clang,而是为了让代码对所有编译器和检查工具都更清楚。
08
BUILTINS
内核大量使用编译器内置函数。常见的包括:
__builtin_constant_p(x)
__builtin_expect(x, 1)
__builtin_choose_expr(cond, a, b)
__builtin_types_compatible_p(type1, type2)
__builtin_object_size(ptr, type)
__builtin_add_overflow(a, b, &out)
其中最容易出兼容差异的是 __builtin_constant_p。它表面上只是判断一个表达式是否为编译期常量,但“什么时候算常量”并不是一句话能说清楚。
例如内核可能写出这种模式:
#define fast_or_slow(x) \
(__builtin_constant_p(x) ? fast_const_path(x) : slow_path(x))
GCC 可能在某个优化阶段判断 x 是常量,于是选择 fast_const_path。Clang 的常量传播时机不同,可能认为它不是常量,于是走到 slow_path。如果两个分支的类型、约束或可用上下文不完全一样,就会出现 GCC 能过、Clang 不能过的情况。
__builtin_choose_expr 又进一步放大了这个问题。它可以在编译期选择表达式,而且未选中的分支理论上不参与运行时计算。内核会利用这个特性写出非常激进的类型检查宏。Clang 如果在未选中分支上诊断得更早,或者类型检查顺序和 GCC 不一样,就会暴露差异。
这类问题通常不是通过“禁用某个 warning”解决,而是需要重新设计宏,让常量性判断、类型检查和回退路径都更显式。
09
DIAGNOSTICS
内核对 warning 很敏感。很多配置会把 warning 当成错误,尤其是新架构、新安全选项或 CI 配置。
Clang 和 GCC 的诊断风格不同。Clang 通常更愿意给出细粒度 warning,例如数组越界、未初始化变量、隐式类型转换、枚举转换、不可达代码、address space 不匹配等。对普通项目来说,这常常是好事;对内核这种宏非常重、路径非常多、还要支持大量架构的项目来说,新 warning 可能变成真实的构建阻塞。
sanitizer 也是同样逻辑。KASAN、UBSan、CFI、Shadow Call Stack、Kernel Control Flow Integrity 等能力让内核更安全,但它们要求编译器、链接器和运行时插桩都严格理解内核语义。
例如内核有些看似越界的访问,其实是在访问协议头后面的 flexible array。编译器如果只按普通对象边界理解,就会插入错误检查或报告误报。最终解决方式通常是两边一起改:
内核把数据结构写得更明确。
编译器改进对象大小和边界推导。
对确实无法静态表达的路径使用专门属性关闭局部检查。
10
EVOLUTION
Linux 没有靠一句“Clang 应该兼容 GCC”解决问题。真正的过程更像长期接口对齐。
第一步是给内核加清晰的编译入口。今天常见写法是:
make LLVM=1
它会把 CC、LD、AR、NM、OBJCOPY、OBJDUMP、READELF、STRIP 等工具切到 LLVM 工具链对应实现。只把 CC=clang 改掉是不够的,因为内核构建不是单一编译器调用,而是一整套编译、汇编、链接、符号处理和镜像生成流程。
第二步是持续清理内核代码。典型例子是移除 VLA:
void f(unsigned int n)
{
char buf[n];
}
变长数组会让栈使用变得不透明,还会妨碍编译器做静态分析。Linus Torvalds 多次强调过内核不应该继续依赖这类风险很高的写法。内核后来通过 -Wvla 和代码迁移,基本清掉了 VLA。
第三步是推动 LLVM 补齐 GNU C 语义和内核所需后端能力。ClangBuiltLinux 项目、Google Android 内核团队、发行版维护者和 LLVM 开发者都在这个过程中做了大量工作。很多修复不是“让 Clang 接受某个奇怪语法”,而是让 LLVM 在真实内核配置下生成正确目标文件。
第四步是让内核抽象出编译器差异。内核里有大量 compiler_types.h、compiler_attributes.h、compiler-gcc.h、compiler-clang.h 这类封装,目的就是不要让每个子系统直接散落编译器判断。
11
GUIDANCE
如果你只是使用 Linux,结论很简单:现代内核用 Clang 编译已经是成熟路径,尤其在 Android、ChromeOS、部分发行版和安全特性组合里非常常见。
如果你在写内核代码,结论要更谨慎:
不要把“GCC 能编译”当成语言语义证明。
新增 GNU C 扩展用法前,先看内核已有封装是否已经覆盖。
涉及 inline asm 时,要同时考虑 GCC 和 Clang 的约束解释。
涉及 section、alias、weak、used、retain 时,要考虑 LTO 和链接脚本。
涉及 flexible array、对象大小和 sanitizer 时,要让结构体布局尽量显式。
公开宏不要依赖过于微妙的 __builtin_constant_p 时机。
新代码尽量让 compiler-specific 细节集中在 include/linux/compiler*.h 这类边界里。
如果你在写普通 C 项目,Linux 内核的经验也有参考价值:GNU C 扩展不是不能用,但它们一旦进入公共 API、宏系统或构建模型,就会变成你和编译器之间的长期契约。换编译器时真正贵的,往往不是语法,而是语义细节。
12
CHECKLIST
如果一个历史 C 项目正在从 GCC 迁到 Clang,可以按这个顺序排查:
先用 Clang 编译但保留 GNU dialect,例如 -std=gnu11,不要一开始就切到严格 ISO C。
清点 inline asm,重点看 asm goto、输出操作数、特殊约束和架构修饰符。
搜索语句表达式、typeof、__builtin_constant_p、__builtin_choose_expr,确认宏是否依赖 GCC 的求值时机。
检查 section、alias、weak、used、retain,尤其是开启 LTO 后是否仍被保留。
把零长度数组和单元素尾数组迁移到 flexible array member,并补充长度字段约束。
区分真实 bug 和诊断差异,不要只靠关闭 warning 掩盖问题。
对启动路径、异常路径、原子操作、内存屏障和符号表做运行时验证。
把编译器差异封装到少数头文件里,不要在业务代码里散落 #ifdef __clang__。
这套顺序对内核有效,对数据库、虚拟机、语言运行时、嵌入式系统也基本适用。越接近硬件和 ABI 的 C 项目,越不能只用“能编过”判断迁移完成。
∞
THE END
Linux 内核确实是 GNU C 扩展的集大成者。它依赖 asm goto、语句表达式、typeof、labels as values、section 属性、弱符号、范围初始化和大量 __builtin_*,不是因为开发者喜欢写怪代码,而是因为内核需要表达标准 C 没有覆盖的底层约束。
Clang 之所以今天能顺利编译内核,不是因为这些复杂性消失了,而是因为 LLVM 学会了足够多 GCC 的事实语义,内核也把一部分历史包袱清理成更明确、更可检查的代码。
这件事最值得记住的工程含义是:编译器兼容从来不只是“支持同一种语言”。对 Linux 这样的系统软件来说,真正要兼容的是一整套编译器扩展、目标文件语义、链接约定、优化边界和运行时假设。Clang 编译内核的成熟,正是这套边界被多年打磨后的结果。