24、属性与扩展:GCC __attribute__的使用、__builtin_expect、__restrict关键字
说到GCC的扩展功能,我最早接触是在一个嵌入式项目里。那时候代码跑得好好的,就是性能差那么一口气。后来一位老同事指着我的代码说:「你这里加个__attribute__试试。」嗯,从那以后,我就再也没小看过这些「非标准」的东西。
说白了,GCC的这些扩展就是给编译器递小纸条。你告诉它更多信息,它就能生成更聪明的机器码。今天咱们就聊聊三个最实用的:__attribute__、__builtin_expect和__restrict。
24.1 __attribute__:给编译器递小纸条
__attribute__是GCC提供的一种语法,用来给变量、函数、类型附加额外信息。写法是双括号包起来:__attribute__((...))。
我个人习惯把它放在声明末尾,这样可读性最好。比如:
void fatal_error(void) __attribute__((noreturn));
这告诉编译器:这个函数不会返回。编译器知道了,就不会在调用后生成多余的返回处理代码,还能帮你检查逻辑漏洞。
常用的__attribute__类型
| 属性名 | 作用 | 适用场景 |
|---|---|---|
| packed | 取消结构体对齐填充 | 网络协议、硬件寄存器映射 |
| aligned(n) | 指定对齐字节数 | DMA缓冲区、SIMD数据 |
| noreturn | 函数不会返回 | 错误处理、无限循环 |
| unused | 抑制未使用警告 | 回调函数、接口占位 |
| deprecated | 标记为废弃 | API迁移过渡期 |
packed 的典型用法:我在解析网络包时经常用。如果不加packed,结构体里会塞进填充字节,直接memcpy比对就会出错。
struct ip_header {
uint8_t ver_ihl;
uint8_t tos;
uint16_t total_length;
// ... 其他字段
} __attribute__((packed));
注意:packed虽然省空间,但访问未对齐的成员会有性能损失。ARM平台上甚至可能触发异常。所以,只在必要时用。
函数属性:优化调用约定
除了变量和类型,函数也能加属性。我常用的几个:
// 内联建议,但由编译器决定
static inline __attribute__((always_inline)) int add(int a, int b) {
return a + b;
}
// 纯函数:结果只依赖参数,无副作用
int compute(int x) __attribute__((pure));
// 构造函数:main之前自动执行
__attribute__((constructor)) void init_hardware(void);
pure属性我特别喜欢。它告诉编译器:这个函数没有副作用,同样的参数调用多次结果一样。编译器就能放心地做公共子表达式消除,甚至直接省略重复调用。
小心误用:如果你给一个会修改全局状态的函数加了pure,编译器可能会优化掉关键操作,导致逻辑错误。我曾经见过有人给日志函数加pure,结果日志全被优化没了——调试时一脸懵。
24.2 __builtin_expect:给分支预测递情报
分支预测是现代CPU的看家本领。但编译器有时候猜不准,这时候就需要你手动告诉它:哪条路更可能走。
__builtin_expect(expr, expected)的用法很简单:
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
if (unlikely(ptr == NULL)) {
return -1;
}
// 正常处理逻辑...
为什么加两个感叹号?这是为了确保x被转换成布尔值。你想想看,如果x是个指针,直接传进去可能会有类型不匹配的警告。
我个人习惯在嵌入式底层代码里大量使用unlikely。比如检查错误返回值:
int ret = send_packet(data, len);
if (unlikely(ret < 0)) {
// 错误处理,很少执行
log_error("send failed: %d", ret);
return ret;
}
// 正常路径,编译器会优化成更紧凑的代码
编译器收到这个提示后,会把「likely」的分支放在紧挨着条件判断的位置,减少跳转指令。对于循环体内的分支,效果尤其明显。
性能收益:在ARM Cortex-M上,正确使用__builtin_expect能让关键循环提速10%-20%。当然,前提是你真的知道哪条路更常见。瞎猜的话,反而会拖慢速度。
24.3 __restrict:消除指针别名歧义
__restrict是C99标准里restrict的GCC版本。它告诉编译器:这个指针是访问某块内存的唯一入口。
为什么要这个?因为C语言的指针太自由了。两个指针可能指向同一块内存,编译器不敢乱优化。你想想看:
void add_arrays(int *a, int *b, int *c, int n) {
for (int i = 0; i < n; i++) {
a[i] = b[i] + c[i];
}
}
编译器会怀疑:a会不会和b重叠?b会不会和c重叠?如果重叠,就不能用SIMD指令一次处理多个元素。加上__restrict就不同了:
void add_arrays(int * __restrict a,
int * __restrict b,
int * __restrict c,
int n) {
for (int i = 0; i < n; i++) {
a[i] = b[i] + c[i];
}
}
编译器看到__restrict,就知道这三个指针指向的区域互不重叠。于是它可以放心地生成SIMD指令,循环展开,甚至做软件流水线优化。
实测数据:我在一个音频处理项目里,给核心循环加了__restrict,配合-O3优化,性能提升了将近3倍。编译器直接生成了NEON向量化指令,而之前它一直在做保守的标量运算。
使用__restrict的注意事项
- 别撒谎:如果你承诺了不重叠,实际上却传入了重叠的指针,结果是未定义行为。程序可能崩溃,也可能静默算出错误结果。
- 只用于指针:数组名本身不能加
__restrict,但指向数组的指针可以。 - 函数参数最常用:局部变量加
__restrict意义不大,因为编译器通常能自己分析出别名关系。
我曾经踩过的坑:有一次我给memcpy类似的函数加了__restrict,但调用方传入了重叠的源和目标地址。结果数据复制到一半就被覆盖了,排查了半天才发现是restrict的锅。从那以后,我养成了一个习惯:在文档里明确标注「此函数要求参数指向不重叠的内存区域」。
24.4 知识体系总览
这三个扩展虽然各自独立,但目标一致:让编译器更懂你的意图。我画了张图,帮你理清它们的关系:
24.5 避坑指南
这些扩展虽然好用,但用错了就是坑。我总结了几条经验:
- 不要滥用packed:结构体成员访问会变慢,ARM上还可能触发对齐异常。只在网络协议、硬件寄存器映射等场景用。
- __builtin_expect别瞎猜:如果你不确定哪条路更常见,就别用。错误的提示比没有提示更糟糕。
- __restrict是承诺,不是建议:传入了重叠指针,后果自负。编译器不会帮你检查。
- 可移植性:这些是GCC扩展,MSVC和Clang虽然也支持大部分,但语法可能略有不同。跨平台代码建议用宏封装。
我的封装习惯:
#ifdef __GNUC__
#define LIKELY(x) __builtin_expect(!!(x), 1)
#define UNLIKELY(x) __builtin_expect(!!(x), 0)
#define RESTRICT __restrict__
#else
#define LIKELY(x) (x)
#define UNLIKELY(x) (x)
#define RESTRICT
#endif
这样在非GCC环境下也能编译,只是没有优化效果而已。
好了,关于GCC的这三个扩展就聊到这儿。它们不是什么高深的技术,但用好了能让你的嵌入式代码跑得更快、更稳。下次写代码时,不妨想想:我能不能给编译器递张小纸条?
公众号:蓝海资料掘金营,微信deep3321