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 知识体系总览

这三个扩展虽然各自独立,但目标一致:让编译器更懂你的意图。我画了张图,帮你理清它们的关系:

GCC扩展三剑客:让编译器更懂你 __attribute__ 给声明附加元信息 • packed:取消对齐填充 • aligned:指定对齐 • noreturn:不返回 • pure:纯函数 • constructor:自动初始化 适用:结构体布局、 函数优化、初始化 __builtin_expect 给分支预测递情报 • likely(x):大概率真 • unlikely(x):大概率假 • 优化分支布局 • 减少跳转惩罚 适用:错误检查、 边界判断、热路径 __restrict 消除指针别名歧义 • 承诺指针不重叠 • 允许向量化优化 • 允许循环展开 • 减少内存访问 适用:数值计算、 音频/视频处理、 memcpy类函数 共同目标:通过向编译器提供额外信息,生成更优的机器码

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