【C/C学习】inline最近在学内核相关的书籍其中一个关键字对于它的理解不够深入只知道它可以用于消除函数调用和返回带来的开销但是发现它的存在不止于此文章目录【C/C学习】inline整个总结inline是什么inline不一定保证函数会展开关于gcc -O为什么inline能带来进一步优化不内联时函数像一个“黑盒子”内联之后黑盒子被打开了,如果为什么不能所有函数都inline代码变大为什么会影响性能为什么内核特别喜欢短小的 inline 函数为什么通常写 static inline为什么内核更喜欢 inline 而不是复杂宏整个总结1️⃣ inline函数可以减少函数调用和返回开销inline 不只是“把代码复制过去”更重要的是它让调用者和被调用者可以作为一个整体参与优化。2️⃣是否内联由编译器结合函数大小、调用频率和优化策略决定3️⃣如果一个较大的函数被大量调用全部展开会导致代码体积膨胀增加指令缓存压力反而可能降低性能4️⃣内联函数通常放在头文件中这样调用它的源文件在编译时可以看到完整的函数定义因此 Linux 内核通常把短小、调用频繁、对性能比较敏感的函数定义为 static inline。static 用于限制函数的作用域inline 用于表达内联意图。相比复杂宏inline 函数具有更好的类型安全性和可读性因此对于能够用函数表达的逻辑内核通常更倾向于使用 inline 函数而不是复杂的宏。inline是什么例如普通函数int add(int a, int b) { return a b; } int x add(1, 2);普通情况下程序执行到add(1, 2);会发生一次函数调用调用者 ↓ 准备参数 ↓ 保存必要的寄存器/建立调用环境 ↓ 跳转到 add() ↓ 执行 ab ↓ 返回 ↓ 恢复调用环境 ↓ 继续执行如果这个函数特别短比如static inline int add(int a, int b) { return a b; }编译器有机会把调用int x add(1, 2);直接变成类似int x 1 2;也就是把函数的代码展开到调用位置附近。这就是所谓函数内联Function Inlining而对于非常短调用非常频繁的函数这种优化可能很有价值inline不一定保证函数会展开inline是给编译器的内联建议并不保证函数一定会被展开。现代 GCC/Clang 会结合函数大小调用频率优化级别是否递归编译器自己的代价模型来决定是否真正 inline。也就是根据编译优化最后函数行为可能不同例如gcc -O2和gcc -O0行为可能明显不同。关于gcc -O-O0 不做任何主动优化变量全部存储在栈内存中不进行函数内联保留完整调试信息。-O2 在 -O1 基础上额外开启函数内联消除函数调用开销压栈、跳转、返回循环优化循环展开、强度削弱乘法→加法死代码消除移除永远不会执行的代码常量传播与折叠编译时直接算出常量表达式结果指令调度重排指令充分利用 CPU 流水线维度-O0(无优化)-O2(标准优化)优化程度完全不优化代码与源码逐行对应开启死代码消除、常量折叠、函数内联、指令调度、循环优化等执行速度最慢比-O0快 50%~100%代码体积最大变量全存栈无压缩较小编译速度最快中等调试体验完美断点精准对应源码较差变量可能被优化掉用途开发调试阶段生产环境首选为什么inline能带来进一步优化让编译器获得更大的优化视野。“让编译器获得更大的优化视野” 原来编译器把函数调用看成一个“黑盒子”内联之后它能直接看到函数里面的具体代码于是有更多机会把前后代码一起优化。不内联时函数像一个“黑盒子”intadd(inta,intb){returnab;}intfoo(){intx10;inty20;returnadd(x,y);}如果add()没有被内联编译器在foo()这里可以把它想象成foo()│ │ 调用 ↓ ┌─────────────┐ │add()│ ← 黑盒 │ │ │returnab │ └─────────────┘编译器知道add(10, 20)会返回一个值。但是如果由于编译条件、编译单元、优化策略等原因它没有把add()的实现纳入当前优化过程那么调用点和函数内部就存在一个边界。内联之后黑盒子被打开了,如果static inline int add(int a, int b) { return a b; }调用int foo() { int x 10; int y 20; return add(x, y); }内联以后编译器可以把它看成int foo() { int x 10; int y 20; return x y; }于是编译器现在可以同时看到foo()里的代码 add()里的代码内联以后int foo() { return 10 20; }编译器继续优化return 30;甚至最终可能变成非常简单的机器指令。这里发生了add(10,20) ↓ 10 20 ↓ 30为什么编译器能做到因为它看到了add()内部return a b;如果函数是一个真正的黑盒foo() ↓ call add()那么在调用点它就不一定能直接利用add()内部的信息。内联之后编译器可以把调用者和函数内部代码放在一起分析从而有机会进行常量传播死代码消除条件优化寄存器分配优化inline 不只是“把代码复制过去”更重要的是它让调用者和被调用者可以作为一个整体参与优化。但是现在的 GCC/Clang即使没有你写inline也可能自动内联。为什么不能所有函数都inline代价代码膨胀例如static inline int foo() { // 很多很多代码 }假设foo()被调用了 1000 次。如果真的全部展开foo代码 foo代码 foo代码 foo代码 ... 重复1000次那么程序体积就可能明显增加。代码变大为什么会影响性能这里涉及Instruction Cache指令缓存I-Cache。CPU 执行程序的时候并不是每条指令都直接从内存读取而是会利用缓存CPU ↓ I-Cache ↓ 内存如果代码特别大代码体积变大 ↓ I-Cache 能装下的有效代码比例下降 ↓ Instruction Cache Miss 增多 ↓ 需要从更低层级缓存/内存取指令 ↓ 性能可能下降所以 inline 并不是越多越快。而是一个 trade-offinline │ ├── 优点 │ ├── 减少函数调用开销 │ └── 给编译器更多优化机会 │ └── 缺点 ├── 代码体积变大 ├── I-Cache 压力增大 └── 可能反而影响性能为什么内核特别喜欢短小的 inline 函数因为内核里有大量调用非常频繁、函数非常短、对性能比较敏感的操作。例如链表、位操作、引用计数、一些简单的数据结构操作等。假设static inline int foo(int x) { return x 1; }如果它每秒调用几百万次甚至更多那么函数调用本身的额外成本就可能值得关注。所以内核中经常能看到static inline ...不要一看到inline就认为“这个函数一定被展开了。”而应该理解为开发者认为这个函数适合内联并通过inline给编译器提供这个意图。为什么通常写static inlineinline表示这个函数适合进行内联。static表示这个函数的链接范围限制在当前编译单元。编译器只有函数声明并不知道函数具体实现。所以 inline 函数经常直接写在.h为什么内核更喜欢 inline 而不是复杂宏宏的问题是宏本质上是预处理器进行的文本替换没有真正的函数类型检查。而 inline 函数staticinlineintmax(inta,intb){returnab?a:b;}属于真正的 C 函数有参数类型检查返回值类型作用域调试信息更清晰但是 inline 不能完全替代宏,宏有一些 inline 做不到的事情。例如条件编译#ifdef CONFIG_DEBUG #define DEBUG_PRINT(...) ... #endif或者需要操作符、类型等预处理阶段能力的场景。对于本来可以用函数表达的简单逻辑内核通常优先考虑类型安全更好的 inline 函数而不是复杂宏但宏在条件编译、代码生成等场景仍然有作用。
