第 27 章:C 语言高级主题

第 27 章:C 语言高级主题

🎉 恭喜你来到 C 语言的高阶世界!如果你是一路从第 1 章打怪升级到这里的老玩家,那今天我们要聊的内容,绝对是"隐藏关卡"级别的 —— 普通人听都没听过,用得好的人都是 C 语言界的老司机。

这一章我们要探索的,是 C 语言那些"藏在深闺人未识"的高级特性。别怕,我会像带你逛菜市场一样,把每一个知识点都掰开了、揉碎了讲给你听。准备好了吗?Let’s go! 🚀


27.1 可变参数宏(C99):… 与 VA_ARGS

27.1.1 什么是可变参数宏?

话说某天,你想写一个调试宏,能够像 printf 一样打印任意数量的参数:

1
2
3
4
5
// 普通宏只能接受固定参数
#define PRINT_INT(x) printf("Value: %d\n", x)

// 但你想支持 PRINT_INT(1), PRINT_INT(1, 2, 3) 各种花式调用?
// 可变参数宏就是来解决这个问题的!

C99 引入了一个神器:...(三个点),它表示"任意多个参数"。配合一个特殊的内置宏 __VA_ARGS__,你就能写出像 printf 一样灵活的宏。

27.1.2 第一个可变参数宏

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include <stdio.h>

// 可变参数宏:args 是传递给 printf 的可变部分
#define DEBUG_PRINT(fmt, ...) printf(fmt, __VA_ARGS__)

int main(void) {
    int x = 42;
    double y = 3.14;

    DEBUG_PRINT("x = %d\n", x);          // x = 42
    DEBUG_PRINT("x = %d, y = %.2f\n", x, y);  // x = 42, y = 3.14

    return 0;
}

__VA_ARGS__ 是一个神奇的宏,它会把 ... 接收到的所有参数原封不动地吞进去,传给 printf。

想象一下:... 就像一个"百宝箱",你往里面扔什么它都接着,而 __VA_ARGS__ 就是打开这个宝箱的钥匙,把里面的东西一次性全倒出来。

27.1.3 处理"可变参数为空":##VA_ARGS 与 VA_OPT

但是!这里有个坑:

1
2
3
4
5
6
7
#include <stdio.h>

#define DEBUG_PRINT(fmt, ...) printf(fmt, __VA_ARGS__)

// 如果你只传一个参数:
DEBUG_PRINT("Hello");  // 展开后变成:printf("Hello", );  ← 多了一个逗号!
// 编译错误!谁见了都想打人!

标准的 C99/C11/C17 都没有办法在预处理阶段判断"可变参数是不是空的"。真正解决问题的是 C23 引入的 __VA_OPT__:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

// ① C23 标准写法:__VA_OPT__(,) 表示"可变参数非空时,在这里插一个逗号"
#define DEBUG_PRINT(fmt, ...) printf(fmt __VA_OPT__(,) __VA_ARGS__)

// ② GNU 扩展写法:## 前缀让预处理器在可变参数为空时吞掉前面的逗号
//    (GCC / Clang 支持;-pedantic 下会提示这是 GNU 扩展)
#define DEBUG_PRINT_GNU(fmt, ...) printf(fmt, ##__VA_ARGS__)

int main(void) {
    DEBUG_PRINT("Hello\n");             // ✅ 展开为 printf("Hello\n");
    DEBUG_PRINT("x = %d\n", 42);        // ✅ 展开为 printf("x = %d\n", 42);

    DEBUG_PRINT_GNU("Hello\n");         // ✅ 同上(GCC / Clang)
    DEBUG_PRINT_GNU("x = %d\n", 42);    // ✅ 同上(GCC / Clang)

    return 0;
}

两个容易踩的坑:

  1. DEBUG_PRINT("Hello\n") 这种"完全不写可变参数"的调用,从 C23 起才算合法。在 C17 及以前,... 至少要接一个实参(否则是一个约束违反,-pedantic 会提示 -Wvariadic-macro-arguments-omitted)。
  2. ##__VA_ARGS__ 是 GNU 扩展而非 ISO C。它的标准替代品正是 C23 的 __VA_OPT__。两者选一个用即可,不要混用。

27.1.4 实战:写一个自己的日志宏

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <stdio.h>
#include <stdarg.h>

// 日志级别:0=静默, 1=ERROR, 2=WARN, 3=INFO, 4=DEBUG
#define LOG_LEVEL 3

// 真正干活的函数:先打印级别/文件/行号,再把可变参数交给 vprintf
void log_msg(const char *level, const char *file, int line, const char *fmt, ...) {
    va_list args;
    printf("[%s] %s:%d: ", level, file, line);
    va_start(args, fmt);
    vprintf(fmt, args);  // vprintf 接收 va_list
    va_end(args);
    printf("\n");
}

// 关键技巧:用预处理指令决定"某个级别的日志到底编不编进程序"。
// 级别不够时宏展开成空语句,既没有运行时开销,也不会产生"参数未使用"警告。
#if LOG_LEVEL >= 1
#  define LOG_ERROR(...) log_msg("ERROR", __FILE__, __LINE__, __VA_ARGS__)
#else
#  define LOG_ERROR(...) ((void)0)
#endif
#if LOG_LEVEL >= 2
#  define LOG_WARN(...)  log_msg("WARN",  __FILE__, __LINE__, __VA_ARGS__)
#else
#  define LOG_WARN(...)  ((void)0)
#endif
#if LOG_LEVEL >= 3
#  define LOG_INFO(...)  log_msg("INFO",  __FILE__, __LINE__, __VA_ARGS__)
#else
#  define LOG_INFO(...)  ((void)0)
#endif
#if LOG_LEVEL >= 4
#  define LOG_DEBUG(...) log_msg("DEBUG", __FILE__, __LINE__, __VA_ARGS__)
#else
#  define LOG_DEBUG(...) ((void)0)
#endif

int main(void) {
    LOG_ERROR("这是一个错误: code=%d", 500);   // [ERROR] main.c:47: 这是一个错误: code=500
    LOG_WARN("内存使用率: %.1f%%", 85.5);      // [WARN] main.c:48: 内存使用率: 85.5%
    LOG_INFO("服务启动成功");                  // [INFO] main.c:49: 服务启动成功
    LOG_DEBUG("调试信息: x=%d, y=%d", 1, 2);   // 级别不够,这行被编译掉了

    return 0;
}

va_list、va_start、vprintf、va_end 都来自 <stdarg.h>,是处理可变参数的"四件套"。请注意:把 ... 原样转发给另一个可变参数函数时,不能直接把 __VA_ARGS__ 塞进 printf,而要改用 vprintf + va_list —— 因为在 log_msg 内部,实参已经被"收拢"成 va_list 了。详见第 7 章"可变参数函数"。

27.1.5 小结

语法含义
...可变参数占位符
__VA_ARGS__将可变参数展开传递给其他函数
##__VA_ARGS__GNU 扩展:可变参数为空时删除前面的逗号
__VA_OPT__(x)C23 标准:可变参数非空时才展开为 x

27.2 复合字面量(C99):(int[]){1, 2, 3}

27.2.1 什么是复合字面量?

你有没有遇到过这种情况:需要一个临时数组,只用一次,然后就想扔掉?

1
2
3
4
5
6
// 传统的痛苦写法
int arr[3] = {1, 2, 3};
int sum = 0;
for (int i = 0; i < 3; i++) {
    sum += arr[i];
}

C99 给你一个优雅的解决方案:复合字面量(Compound Literal)。它允许你直接在表达式中创建一个匿名数组或结构体,不需要单独定义变量!

27.2.2 匿名数组:复合字面量的基本操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>

int main(void) {
    // 传统写法:先定义变量,再使用
    int arr[3] = {1, 2, 3};

    // C99 复合字面量:直接在表达式中创建数组
    // (type){ initializers }
    int sum = 0;
    for (int i = 0; i < 3; i++) {
        sum += ((int[]){1, 2, 3})[i];  // 匿名数组,即用即弃
    }
    printf("sum = %d\n", sum);  // sum = 6

    // 更骚的操作:直接传给函数
    int max = 0;
    // 复合字面量是"匿名对象",只能通过指针去访问它的地址
    int *numbers = (int[]){5, 2, 8, 1, 9};
    for (int i = 0; i < 5; i++) {
        if (numbers[i] > max) max = numbers[i];
    }
    printf("max = %d\n", max);  // max = 9

    return 0;
}

复合字面量就像外卖:你不需要在自己家厨房(变量)里做饭(初始化数组),直接点个现成的(复合字面量),用完餐盒直接扔掉(表达式结束就回收)。

27.2.3 复合字面量用于结构体

结构体才是复合字面量的主场!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h>

struct Point {
    int x;
    int y;
};

struct Rectangle {
    struct Point top_left;
    struct Point bottom_right;
};

int main(void) {
    // 传统写法:先定义,再赋值
    struct Point p1;
    p1.x = 0;
    p1.y = 1;

    // C99 复合字面量:一条语句搞定!
    struct Point p2 = (struct Point){10, 20};  // x=10, y=20
    printf("p2: x=%d, y=%d\n", p2.x, p2.y);     // p2: x=10, y=20

    // 嵌套复合字面量
    struct Rectangle rect = (struct Rectangle){
        (struct Point){0, 100},    // top_left
        (struct Point){50, 0}      // bottom_right
    };
    printf("Rect: top_left=(%d,%d), bottom_right=(%d,%d)\n",
           rect.top_left.x, rect.top_left.y,
           rect.bottom_right.x, rect.bottom_right.y);
    // Rect: top_left=(0,100), bottom_right=(50,0)

    // 指针也可以用复合字面量!
    struct Point *ptr = &(struct Point){30, 40};  // 匿名结构体的地址
    printf("ptr: x=%d, y=%d\n", ptr->x, ptr->y);   // ptr: x=30, y=40

    return 0;
}

27.2.4 指定初始化器 + 复合字面量

把指定初始化器(Designated Initializers)和复合字面量混搭使用,效果更佳:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>

struct Config {
    int port;
    char *host;
    int debug_level;
};

int main(void) {
    // 使用指定初始化器,只初始化需要的字段
    struct Config *cfg = &(struct Config){
        .host = "localhost",
        .port = 8080,
        .debug_level = 2
    };

    printf("连接 %s:%d (debug=%d)\n",
           cfg->host, cfg->port, cfg->debug_level);
    // 连接 localhost:8080 (debug=2)

    return 0;
}

27.2.5 小结

1
2
3
4
5
6
7
// 数组复合字面量
(int[]){1, 2, 3}
(int[3]){1, 2, 3}

// 结构体复合字面量
(struct Point){10, 20}
 &(struct Point){10, 20}  // 取地址

复合字面量是 C99 的"语法糖",让你的代码更简洁、更表达意图。


27.3 语句表达式(GNU C 扩展):({ int x = 1; x + 2; })

27.3.1 括号里的代码块 = 表达式?

这是 GNU C 的一个扩展(GCC 和 Clang 支持,但不是标准 C),它允许你在括号里写语句!这听起来就很疯狂:

1
2
3
4
5
({  // 左圆括号 + 左花括号
    int x = 1;
    int y = 2;
    x + y;  // 这个表达式的值是最后一个语句的值
})  // 右花括号 + 右圆括号

普通括号 () 里只能放表达式。但语句表达式 ({}) 允许你放一整个代码块!这就像是把一个函数塞进了括号里。

27.3.2 语句表达式的基础用法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>

int main(void) {
    // 语句表达式的值 = 最后一个表达式的值
    int result = ({
        int a = 10;
        int b = 20;
        a + b;  // 30 — 这个值被外层接收
    });
    printf("result = %d\n", result);  // result = 30

    // 甚至可以写循环!
    int sum = ({
        int total = 0;
        for (int i = 1; i <= 5; i++) {
            total += i;
        }
        total;  // 循环结束后,total = 15
    });
    printf("sum 1..5 = %d\n", sum);  // sum 1..5 = 15

    return 0;
}

27.3.3 实战:用语句表达式模拟 let 绑定

很多函数式语言有 let 绑定:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 伪代码(函数式风格)
// let x = 10 in x * 2

// 在 C 里用语句表达式模拟:
#include <stdio.h>

int main(void) {
    // 模拟 let x = 10 in x * 2
    int result = ({
        int x = 10;
        x * 2;
    });
    printf("%d\n", result);  // 20

    // 模拟 let x = 5, y = 3 in (x + y) * (x - y)
    int poly = ({
        int x = 5;
        int y = 3;
        (x + y) * (x - y);
    });
    printf("%d\n", poly);  // (5+3)*(5-3) = 8*2 = 16

    return 0;
}

27.3.4 实战:安全的宏参数求值

语句表达式的一个经典应用是避免宏的副作用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <stdio.h>

// 危险!如果传入 x++ 会被求值两次
#define SQUARE_BAD(x) ((x) * (x))

// 安全版本:语句表达式确保求值一次
#define SQUARE_SAFE(x) ({ \
    typeof(x) _x = (x);   \
    _x * _x;              \
})

int main(void) {
    int num = 3;
    printf("SQUARE_BAD(%d) = %d\n", num, SQUARE_BAD(num));   // 9 ✅

    num = 3;
    int bad = SQUARE_BAD(num++);   // 展开成 ((num++) * (num++))
    // ⚠️ 未定义行为:同一个对象在两次未定序的修改之间被改了两次。
    // 结果是 9、12 还是别的什么,取决于编译器与优化级别。
    printf("SQUARE_BAD(num++) = %d, num = %d\n", bad, num);

    num = 3;
    printf("SQUARE_SAFE(%d) = %d\n", num, SQUARE_SAFE(num));  // 9 ✅

    num = 3;
    int good = SQUARE_SAFE(num++);  // 先把 num++ 的结果存进 _x,再算 _x * _x
    printf("SQUARE_SAFE(num++) = %d, num = %d\n", good, num); // 9, 4 ✅

    return 0;
}

把两次副作用分开写成独立语句,是为了让示例本身不引入额外的未定义行为 —— 原版把 num 和 SQUARE_BAD(num++) 放在同一个 printf 调用里,调用实参之间也是未定序的,等于用一个 UB 去演示另一个 UB。

typeof(x) 曾是 GNU 扩展,C23 起已标准化(见 27.4)。现在你只需要知道它是"获取 x 的类型"的魔法。

27.3.5 语句表达式的限制

1
2
3
4
5
// ❌ 不能这样用:语句表达式是 GNU 扩展,非 GNU 编译器会报错的
// #include <stdio.h>
// int main(void) { ... }  // 需要加 -std=gnu99 或 -std=gnu11

// ✅ 使用时应该加上 -std=gnu99 以上标准

语句表达式虽好,但它是 GCC/Clang 的"方言",不是 ISO C 标准。如果你的代码要跨编译器(MSVC),慎用!


27.4 typeof / typeof_unqual(C23,标准化 GCC/Clang 扩展)

27.4.1 typeof 是什么?

typeof 用来在编译时获取一个表达式的类型。你可以把它理解为"类型的类型"——普通 typeof 精确地告诉你这是什么类型。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

int main(void) {
    int x = 10;
    double y = 3.14;

    // 获取 x 的类型,然后用它声明新变量
    typeof(x) a = 20;      // int a = 20;
    typeof(y) b = 2.71;    // double b = 2.71;

    printf("a = %d, b = %.2f\n", a, b);  // a = 20, b = 2.71

    // 获取表达式的类型
    typeof(x + y) result = x + y;  // double result = 10 + 3.14 = 13.14
    printf("result = %.2f\n", result);  // result = 13.14

    return 0;
}

typeof 就像是 C 语言的"镜子",你拿一个变量照一照,它告诉你这个变量的类型是什么。

27.4.2 typeof 在宏中的应用

typeof 最常用的场景是写类型无关的宏:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <stdio.h>

// 安全的 max 宏:自动推断类型
#define MAX(a, b) ({ \
    typeof(a) _a = (a); \
    typeof(b) _b = (b); \
    _a > _b ? _a : _b; \
})

// 安全的 swap 宏
#define SWAP(x, y) do { \
    typeof(x) _temp = (x); \
    (x) = (y); \
    (y) = _temp; \
} while(0)

int main(void) {
    int i1 = 5, i2 = 10;
    printf("MAX(%d, %d) = %d\n", i1, i2, MAX(i1, i2));  // 10

    double d1 = 3.14, d2 = 2.71;
    printf("MAX(%.2f, %.2f) = %.2f\n", d1, d2, MAX(d1, d2));  // 3.14

    SWAP(i1, i2);
    printf("After swap: i1=%d, i2=%d\n", i1, i2);  // i1=10, i2=5

    return 0;
}

27.4.3 C23 的 typeof_unqual

C23 引入了一个新变体:typeof_unqual。这个"去掉 const/volatile/restrict 限定符"版本,解决了 typeof 的一个问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>

int main(void) {
    const int ci = 100;
    volatile int vi = 200;

    // typeof(ci) 是 const int,不适合做变量类型
    typeof(ci) x = 10;     // const int x = 10;
    // x = 20;  // ❌ 错误!x 是 const

    // typeof_unqual 去掉限定符
    typeof_unqual(ci) y = 10;  // int y = 10;
    y = 30;  // ✅ OK!
    printf("y = %d\n", y);  // y = 30

    typeof_unqual(vi) z = 200;  // int z = 200;
    printf("z = %d\n", z);  // z = 200

    return 0;
}

简单记忆:typeof 是"照妖镜",看到什么类型就是什么类型;typeof_unqual 是"去美化滤镜",把 const/volatile 这些修饰词都去掉,给你最纯粹的基础类型。


27.5 __builtin 系列(GCC/Clang)

27.5.0 __builtin 是什么?

__builtin_* 是一系列由 GCC 和 Clang 提供的内置函数。它们不是标准 C 的一部分,但几乎所有现代 C 编译器都支持(MSVC 除外)。这些函数直接映射到底层 CPU 指令,执行效率极高,是"捷径中的捷径"。


27.5.1 __builtin_popcount / __builtin_popcountll

用途:计算一个整数的二进制表示中有多少个 1(汉明重量,Hamming Weight)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>

int main(void) {
    // 5 的二进制是 101,有 2 个 1
    printf("popcount(5)   = %d\n", __builtin_popcount(5));      // 2
    // 7 的二进制是 111,有 3 个 1
    printf("popcount(7)   = %d\n", __builtin_popcount(7));      // 3
    // 255 的二进制是 11111111,有 8 个 1
    printf("popcount(255) = %d\n", __builtin_popcount(255));    // 8

    // __builtin_popcountll 用于 64 位
    printf("popcountll(0xFFFFFFFFFFFFFFFF) = %d\n",
           __builtin_popcountll(0xFFFFFFFFFFFFFFFFULL));  // 64

    // 应用场景:判断一个数是否是 2 的幂
    int n = 16;
    if (__builtin_popcount(n) == 1) {
        printf("%d 是 2 的幂!\n", n);  // 16 是 2 的幂!
    } else {
        printf("%d 不是 2 的幂\n", n);
    }

    return 0;
}

形象理解:把一个数想象成一副扑克牌,popcount 就是数一数里面有多少张黑桃 A(也就是有多少个 1)。


27.5.2 __builtin_expect(likely/unlikely)

用途:告诉编译器某个条件更可能为真还是假,帮助 CPU 做分支预测优化。

这个函数名听起来很陌生,但它的应用你一定见过:Linux 内核中的 likely() 和 unlikely() 宏就是用它实现的!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>

// Linux 内核风格的 likely/unlikely 宏
#define likely(x)   __builtin_expect(!!(x), 1)   // 很可能为真
#define unlikely(x) __builtin_expect(!!(x), 0)   // 很可能为假

int main(void) {
    int error = 0;

    // 告诉编译器:这个条件很不可能发生
    if (unlikely(error)) {
        printf("出错了!\n");
    } else {
        printf("一切正常\n");  // 编译器会把这个分支优化为"主要执行路径"
    }

    // 另一个例子
    int value = 100;
    if (likely(value > 0)) {
        printf("value 是正数: %d\n", value);  // 编译器优先优化这个分支
    }

    return 0;
}

分支预测优化原理:现代 CPU 有流水线(pipeline),它会"猜"接下来要执行哪条分支。如果猜对了,程序飞起;如果猜错了,要flush流水线,浪费十几个时钟周期。__builtin_expect 就是告诉 CPU:“你猜这个方向!”

Linux 内核源码中的典型用法:

1
2
3
4
// 摘自 Linux 内核(简化)
if (unlikely(ptr == NULL)) {
    return -ENOMEM;  // 很少发生的错误情况
}

27.5.3 __builtin_offsetof

用途:计算结构体中某个成员相对于结构体起始地址的字节偏移量。它和标准库的 offsetof 宏功能完全相同 —— 事实上 glibc 里 offsetof 就是用 __builtin_offsetof 实现的。它唯一的"优势"是绕过了 offsetof 的某些使用限制(例如成员名里带逗号、或者用于模板式的宏展开)。

⚠️ 一个流传很广的误解:__builtin_offsetof 不能用于位域(bit-field)。无论 offsetof 还是 __builtin_offsetof,用在位域上都是编译错误(Clang 会说 cannot compute offset of bit-field)。因为位域没有独立的地址,“偏移量"这个概念对它不适用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdio.h>
#include <stddef.h>  // 标准 offsetof

struct Point {
    int x;       // 偏移 0
    int y;       // 偏移 4
    double z;    // 偏移 8(double 需要 8 字节对齐)
};

// 位域结构体:成员共用同一个存储单元,"偏移量"没有意义
struct Packet {
    unsigned int header  : 4;   // 前 4 位:头部
    unsigned int type    : 4;   // 中 4 位:类型
    unsigned int payload : 24;  // 后 24 位:数据
};

int main(void) {
    // offsetof 和 __builtin_offsetof 的结果完全一致
    printf("offsetof(Point, x) = %zu\n", offsetof(struct Point, x));             // 0
    printf("offsetof(Point, y) = %zu\n", offsetof(struct Point, y));             // 4
    printf("offsetof(Point, z) = %zu\n", offsetof(struct Point, z));             // 8
    printf("__builtin_offsetof(Point, z) = %zu\n",
           __builtin_offsetof(struct Point, z));                                 // 8

    printf("sizeof(struct Packet) = %zu\n", sizeof(struct Packet));              // 4

    // ❌ 下面这行会编译失败:Cannot compute offset of bit-field 'header'
    // printf("%zu\n", offsetof(struct Packet, header));

    return 0;
}

__builtin_offsetof 就像是给你一个"透视眼”,能看到结构体在内存中的布局——每个成员住在哪一层楼(偏移量)。但"位域"成员住的是同一个房间里的上下铺,没有独立的门牌号,所以既不能用 offsetof 也不能用 __builtin_offsetof 去问它"住几楼"。

顺带一提:标准 C 明确禁止把 offsetof 用在位域上。如果确实需要"位域在整个存储单元里的位置",只能靠手工分析 + _Static_assert(sizeof(struct Packet) == 4, "...") 这样的静态检查来约束。


27.5.4 __builtin_trap / __builtin_debugtrap

用途:触发调试器断点或程序崩溃。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>

// 简单的断言宏
#define ASSERT(cond) do { \
    if (!(cond)) { \
        fprintf(stderr, "Assertion failed: %s\n", #cond); \
        __builtin_trap();  /* 触发 SIGTRAP,让调试器停下来 */ \
    } \
} while(0)

int divide(int a, int b) {
    ASSERT(b != 0);  // 运行时检查:除数不能为 0
    return a / b;
}

int main(void) {
    printf("正常情况: 10/2 = %d\n", divide(10, 2));  // 5

    // 在调试器中运行时会停在这里!
    // divide(10, 0);

    return 0;
}

__builtin_trap 就像是程序里的"烟雾报警器"——平时安静无声,一旦触发就直接把整个系统叫停(并叫来调试器这个"消防员")。

__builtin_debugtrap 是 Clang 的变体,用于更精细的调试控制。


27.5.5 __builtin_prefetch:数据预取

用途:提前把数据从内存加载到 CPU 缓存,减少 CPU 等待内存的时间。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <stdio.h>
#include <time.h>
#include <stdlib.h>   // malloc / free / clock

#define N 10000000

// 不使用 prefetch 的版本
long long sum_without_prefetch(int *arr, int n) {
    long long sum = 0;
    for (int i = 0; i < n; i++) {
        sum += arr[i];
    }
    return sum;
}

// 使用 prefetch 的版本
long long sum_with_prefetch(int *arr, int n) {
    long long sum = 0;
    for (int i = 0; i < n; i++) {
        // 预取 arr[i+16],即提前加载未来的数据
        // 0 = 预读到所有缓存层级
        // 1 = 预读指令缓存(这里不合适,仅做示例)
        // arr+16 是在访问 arr[i] 时,提前告诉 CPU "一会儿要用 arr[i+16]"
        if (i + 16 < n) {
            __builtin_prefetch(&arr[i + 16], 0, 3);  // 3 = 临时性读
        }
        sum += arr[i];
    }
    return sum;
}

int main(void) {
    int *arr = malloc(N * sizeof(int));
    for (int i = 0; i < N; i++) arr[i] = i + 1;

    clock_t start = clock();
    long long s1 = sum_without_prefetch(arr, N);
    clock_t t1 = clock() - start;

    start = clock();
    long long s2 = sum_with_prefetch(arr, N);
    clock_t t2 = clock() - start;

    printf("Without prefetch: sum=%lld, time=%.3fms\n", s1, t1 * 1000.0 / CLOCKS_PER_SEC);
    printf("With prefetch:    sum=%lld, time=%.3fms\n", s2, t2 * 1000.0 / CLOCKS_PER_SEC);

    free(arr);
    return 0;
}

__builtin_prefetch 就像是提前把外卖预订单发出去——等你吃完眼前的菜,下一道菜已经快做好了,不用等。

预取参数说明:

  • 第1个参数:预取数据的地址
  • 第2个参数:0=读,1=写(写回)
  • 第3个参数:0=临时性(很快被覆盖),3=会使用较久

27.6 泛型选择:C11 _Generic

27.6.1 _Generic 是什么?

C11 引入了一个革命性的特性:_Generic 选择表达式。你可以把它理解为 C 语言的"类型 switch"——根据表达式的类型,选择不同的值或操作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <stdio.h>

int main(void) {
    // _Generic 的语法:
    // _Generic(expr, type1: value1, type2: value2, ..., default: valueN)
    //
    // 它会检查 expr 的类型,然后返回对应类型的值

    int i = 42;
    double d = 3.14;
    char *s = "hello";

    // 根据 i 的类型(int)选择输出
    printf("%s\n", _Generic(i, int: "i 是 int 类型",
                                 double: "i 是 double 类型",
                                 default: "i 是其他类型"));  // i 是 int 类型

    printf("%s\n", _Generic(d, int: "d 是 int 类型",
                                 double: "d 是 double 类型",
                                 default: "d 是其他类型"));  // d 是 double 类型

    printf("%s\n", _Generic(s, int: "s 是 int 类型",
                                 double: "s 是 double 类型",
                                 char *: "s 是 char* 类型",
                                 default: "s 是其他类型"));  // s 是 char* 类型

    return 0;
}

_Generic 就像是酒店的自动行李寄存系统:你把行李(表达式)交给它,它根据行李的标签(类型)自动分配到对应的柜子(值)。

27.6.2 实现类型分发

一个经典应用:根据输入类型执行不同的代码路径。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <stdio.h>

// 泛型打印:让 _Generic 只负责"挑格式串",真正的打印还是交给 printf。
// 这一点很重要 —— _Generic 的每个分支都必须是一个类型正确的表达式,
// 直接把 printf(...) 塞进分支里会因为"类型对不上"而编译失败。
#define PRINT_VALUE(x) printf(_Generic((x), \
    int:    "int: %d\n", \
    double: "double: %.2f\n", \
    char:   "char: '%c'\n", \
    char *: "char*: \"%s\"\n", \
    default: "未知类型\n"), (x))

// 泛型加法:根据左操作数的类型选择计算分支
#define ADD(a, b) _Generic((a), \
    int:   ((a) + (b)), \
    double: ((a) + (b)), \
    default: 0 \
)

int main(void) {
    char c = 'A';

    PRINT_VALUE(42);       // int: 42
    PRINT_VALUE(3.14);     // double: 3.14
    PRINT_VALUE(c);        // char: 'A'
    PRINT_VALUE("hello");  // char*: "hello"

    printf("ADD(1, 2) = %d\n", ADD(1, 2));            // 3
    printf("ADD(1.5, 2.5) = %.1f\n", ADD(1.5, 2.5));  // 4.0

    return 0;
}

⚠️ 两个常见坑:

  1. _Generic 的所有分支都会被完整地做语义检查(即使没被选中)。所以像 char *: printf("%d", x) 这种"类型对不上"的分支照样报错。
  2. 字符常量 'A' 在 C 里的类型是 int 而不是 char(这一点和 C++ 不同),所以要演示 char 分支必须写成 char c = 'A'; PRINT_VALUE(c);。上面旧版本直接写 PRINT_VALUE('A') 会走 int 分支。

27.6.3 实现泛型 min/max

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <stdio.h>

// typeof 曾是 GCC/Clang 扩展,C23 已把它标准化(另有 typeof_unqual 变体)
// 这里的 MIN/MAX 不用 _Generic,而是靠 typeof 声明"同类型的临时变量",
// 从而保证每个参数只求值一次(避免 MIN(i++, j++) 这种副作用被放大)
#define MIN(a, b) ({ \
    __typeof__(a) _a = (a); \
    __typeof__(b) _b = (b); \
    (void)(&_a != &(_b)); /* 类型不同时产生警告 */ \
    _a < _b ? _a : _b; \
})

#define MAX(a, b) ({ \
    __typeof__(a) _a = (a); \
    __typeof__(b) _b = (b); \
    (void)(&_a != &(_b)); \
    _a > _b ? _a : _b; \
})

int main(void) {
    int i1 = 5, i2 = 10;
    printf("MIN(%d, %d) = %d\n", i1, i2, MIN(i1, i2));    // 5
    printf("MAX(%d, %d) = %d\n", i1, i2, MAX(i1, i2));    // 10

    double d1 = 3.14, d2 = 2.71;
    printf("MIN(%.2f, %.2f) = %.2f\n", d1, d2, MIN(d1, d2));  // 2.71
    printf("MAX(%.2f, %.2f) = %.2f\n", d1, d2, MAX(d1, d2));  // 3.14

    return 0;
}

27.7 C23 constexpr:受限编译期求值

27.7.1 constexpr 是什么?

C23 引入了 constexpr 关键字。先说结论,因为这一点非常容易被写错:

C23 的 constexpr 是一个"存储类说明符",而且只能修饰对象(变量),不能修饰函数。 constexpr int square(int x) 在 C 里是编译错误(Clang 的原文:'constexpr' can only be used with variables)。这一点和 C++ 完全不同 —— C++ 的 constexpr 可以修饰函数。

那 constexpr 到底做什么?它声明一个编译期常量对象:编译器必须能在编译时算出它的初始值,同时这个对象本身也是 const 的。它最重要的用途是"可以出现在常量表达式里",比如数组长度、case 标签、static_assert 的比较对象等。

想象一下:你去餐厅点菜,constexpr 就像是"预制菜"——在你下单之前,菜已经做好了(编译时算好了),上菜飞快(程序运行飞快)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <limits.h>

// constexpr 对象:值必须在编译期确定,且本身就是 const
constexpr int SQUARE_SIDE = 4;
constexpr int AREA = SQUARE_SIDE * SQUARE_SIDE;   // 16,可用在常量表达式里
constexpr double PI = 3.14159265358979323846;

// 编译期断言:对常量表达式做静态检查
static_assert(AREA == 16, "AREA 应该是 16");

// ❌ 这是 C++ 的写法,在 C 里直接编译报错:
// constexpr int square(int x) { return x * x; }

int main(void) {
    int grid[AREA];                     // AREA 是编译期常量,这是"定长数组"而非 VLA
    printf("grid 有 %zu 个元素\n", sizeof(grid) / sizeof(grid[0]));  // 16
    printf("SQUARE_SIDE = %d, PI = %.5f\n", SQUARE_SIDE, PI);
    printf("BITINT_MAXWIDTH = %llu\n", (unsigned long long)BITINT_MAXWIDTH);

    return 0;
}

27.7.2 那"编译期求值的函数"怎么办?

C 里没有 constexpr 函数,能用的工具是这几样:

需求C 里的做法
编译期算一个整数常量宏、enum 常量,或 constexpr 对象
编译期检查条件C11 起有 _Static_assert(C23 起也可以写 static_assert)
编译期做位运算、判断类型#if / __has_builtin 等预处理指令
希望某个函数的调用被"折叠"成常量没有标准保证;现代编译器在 -O2 下会自行做常量传播和内联
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

// ① 宏:最老牌的"编译期求值"
#define SQUARE(x) ((x) * (x))

// ② constexpr 对象:结果可以在常量表达式里使用
constexpr int SIDE = 3;
constexpr int AREA = SQUARE(SIDE);       // 9

// ③ 编译期断言,把错误提前到编译阶段
static_assert(SQUARE(5) == 25, "宏展开的常量检查");

int main(void) {
    int board[SIDE * SIDE];              // 定长数组:9 个 int
    printf("SIDE=%d, AREA=%d, 棋盘格数=%zu\n",
           SIDE, AREA, sizeof(board) / sizeof(board[0]));

    return 0;
}

顺便澄清一个常见误解:“C23 的 constexpr 不允许循环、递归、volatile”。这句话描述的其实是 C++ 的 constexpr 函数遵循的规则(而且 C++14 之后连循环也放开了)。C23 根本没有"constexpr 函数"这回事,所以也谈不上"限制它不能做什么"。

想深入了解 constexpr 对象的准确规则,可以看标准草案 N3096 的 6.7.1(存储类说明符)与 6.6(常量表达式)两节。


27.8 C23 nullptr:nullptr_t 类型

27.8.1 nullptr 是什么?

在 C 语言的历史上,NULL 可能是"整型 0",也可能是"空指针",具体是哪个由标准库实现决定:

1
2
3
4
5
// 某实现可能这样定义
#define NULL 0
// 也可能这样定义
#define NULL ((void*)0)
// C23 起,还可以定义成 nullptr(见下)

一个 NULL 两种"身份",会带来一些麻烦,比如:

1
2
3
4
5
6
void foo(char *p);
foo(NULL);   // 没问题,两种定义都能隐式转换成指针

// 但如果想在宏里区分"整数 0"和"空指针",就没辙了:
//   _Generic(NULL, int: ..., void *: ...)   ← 到底选哪个分支,取决于实现
// 在 C++ 里重载函数时也是同理(C 没有重载,但 _Generic 会遇到同样的问题)。

C23 引入了一个新的关键字:nullptr。它是一个类型安全的空指针常量,类型是 nullptr_t(定义在 <stddef.h> 里),可以隐式转换成任意指针类型,也可以和指针比较。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <stdio.h>
#include <stddef.h>   // nullptr_t 定义在这里

void foo(char *p) {
    if (p == NULL) {
        printf("p 是空指针!\n");
    } else {
        printf("p 指向: %s\n", p);
    }
}

int main(void) {
    char *p1 = nullptr;  // 类型是 nullptr_t
    char *p2 = NULL;

    printf("p1 == p2: %s\n", p1 == p2 ? "相等" : "不相等");  // 相等

    foo(nullptr);  // p 是空指针!
    foo("hello"); // p 指向: hello

    // nullptr_t 类型
    _Static_assert(sizeof(nullptr_t) == sizeof(void*),
                   "nullptr_t 应该和指针大小一样");

    return 0;
}

nullptr 就像是"null 的豪华升级版":之前 null 有两个面孔(0 和 (void*)0),现在 nullptr 只有一张面孔,而且这张面孔是专门给指针用的。


27.9 C23 标准属性全解

C23 正式标准化了属性语法:[[xxx]](GCC/Clang 早就有的 __attribute__((xxx)) 是它的前身)。这就像是给代码贴标签,告诉编译器"这段代码有特殊含义"。

C23 一共只标准化了 7 个属性,全部列在这里,免得记错:

属性作用可用于
[[deprecated]] / [[deprecated("原因")]]标记已废弃函数、类型、变量、成员…
[[fallthrough]]声明 switch 穿透是故意的语句
[[maybe_unused]]抑制"未使用"警告变量、函数、参数、类型…
[[nodiscard]] / [[nodiscard("原因")]]忽略返回值时告警函数、结构体/枚举类型
[[noreturn]]函数不会正常返回函数
[[reproducible]]函数是"幂等且无副作用"的函数类型
[[unsequenced]]函数是"无状态、无副作用"的函数类型

⚠️ 两个常见误解(很多 AI 生成的教程都会写错):

  • [[likely]] / [[unlikely]] 是 C++20 的属性,C23 并没有采纳。在 C 里写 [[likely]] 只会得到 warning: unknown attribute 'likely' ignored。C 里的对应手段仍然是 __builtin_expect(见 27.5.2)。
  • [[no_unique_address]] 也没有被 C23 采纳,同样属于 C++20。

27.9.1 [[noreturn]]

告诉编译器:这个函数不会返回给调用者(比如 exit()、abort()、longjmp())。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>

// C23 标准化。之前的标准写法是 _Noreturn(C11 引入的函数说明符,
// 至今仍然可用,但 C23 起更推荐 [[noreturn]])
[[noreturn]] void fatal_error(const char *msg) {
    printf("严重错误: %s\n", msg);
    exit(1);  // 永远不会返回
}

// 注意:不要试图自己写一个叫 __builtin_trap 的函数去"覆盖"编译器内置函数,
// 那样会报 "definition of builtin function"。编译器内置的 __builtin_trap()
// 本身就等价于一条 trap 指令,见 27.5.4。

int main(void) {
    printf("程序开始\n");
    fatal_error("测试错误");  // 永远不会执行到这里
    printf("永远不会打印\n");  // 编译器可能会警告
}

27.9.2 [[nodiscard]]

告诉编译器:如果调用者忽略这个函数的返回值,就报警告。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <stdio.h>
#include <stdlib.h>
#include <string.h>   // strlen / strcpy

// 基本形式
[[nodiscard]] int * allocate_array(int size) {
    int *arr = malloc(size * sizeof(int));
    return arr;
}

// 带 message 的形式(C23)
[[nodiscard("请检查内存是否释放")]] char *strdup_safe(const char *s) {
    char *copy = malloc(strlen(s) + 1);
    if (copy) strcpy(copy, s);
    return copy;
}

int main(void) {
    // ✅ 正确用法:接收返回值
    int *arr = allocate_array(10);
    printf("arr 指针本身占 %zu 字节(注意:不是数组大小)\n", sizeof(arr));
    printf("分配了 %d 个 int,共 %zu 字节\n", 10, 10 * sizeof(int));

    // ❌ 错误用法:忽略返回值,编译器会报警告!
    allocate_array(100);  // 警告:忽略 nodiscard 函数的返回值!

    // 带 message 的形式:警告里会带上"请检查内存是否释放"
    // strdup_safe("hello");  // (取消注释即可看到警告)

    free(arr);
    return 0;
}

[[nodiscard]] 就像是高铁的禁止吸烟标志:你如果无视它(忽略返回值),就会触发"烟雾报警"(编译器警告)。


27.9.3 [[maybe_unused]]

告诉编译器:这个变量/函数可能没用到,别报警告。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

// 在调试时有用,但发布时可能没用到
[[maybe_unused]] int debug_counter = 0;

// 函数参数可能没用到
int process_data(int used_param, [[maybe_unused]] int unused_param) {
    [[maybe_unused]] int temp = 42;  // 临时变量,用 or 不用都行
    return used_param * 2;
}

int main(void) {
    debug_counter++;  // 可能用,可能不用
    printf("result = %d\n", process_data(10, 999));  // result = 20
    // 编译器不会抱怨 debug_counter 和 unused_param 没被使用

    return 0;
}

27.9.4 [[deprecated]]

标记某个符号已被废弃,使用时会产生警告。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>

// 废弃旧 API,建议使用新 API
[[deprecated("请使用 new_calculate 代替")]]
int old_calculate(int x) {
    return x * 2;
}

// 不带 message 的基本形式
[[deprecated]]
char *old_function(void) {
    return "旧函数";
}

int new_calculate(int x) {
    return x * x;
}

int main(void) {
    int r1 = new_calculate(5);   // ✅ 正常
    int r2 = old_calculate(5);   // ⚠️ 警告:使用了废弃的 old_calculate
    printf("r1=%d, r2=%d\n", r1, r2);

    return 0;
}

27.9.5 [[fallthrough]]

在 switch 语句中,明确表示想要"fall through"到下一个 case。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <stdio.h>

const char *get_day_type(int day) {
    switch (day) {
        case 1:
        case 2:
        case 3:
        case 4:
        case 5:
            return "工作日";
        case 6:
            return "周六";
        case 7:
            return "周日";
        default:
            return "无效日期";
    }
}

// 使用 fallthrough 标记"有意为之"的穿透
const char *get_day_name(int day) {
    switch (day) {
        case 1:
            return "星期一";
        case 2:
            return "星期二";
        case 3:
            return "星期三";
        case 4:
            return "星期四";
        case 5:
            return "星期五";
        case 6:
            [[fallthrough]];  // 故意穿透:周六和周日共用一套逻辑
        case 7:
            return "周末";
        default:
            return "无效";
    }
}

int main(void) {
    printf("%s\n", get_day_type(3));   // 工作日
    printf("%s\n", get_day_name(6));   // 周末(穿透到 case 7)
    printf("%s\n", get_day_name(7));   // 周末

    return 0;
}

没有 [[fallthrough]],编译器会以为你是"忘了写 break";加上它,编译器就知道:“哦,这是故意的!”

27.9.6 必须澄清的误区:C23 没有 [[likely]] / [[unlikely]]

很多资料会把 [[likely]]、[[unlikely]] 说成"C23 给 __builtin_expect 的语法糖"。这是错的:这两个属性属于 C++20,C23 的属性列表里并没有它们。在 C 里写 [[likely]] 只会得到一条 warning: unknown attribute 'likely' ignored,不会带来任何优化。

C 里做分支提示,标准手段只有编译器内置函数(__builtin_expect,见 27.5.2):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

int divide(int a, int b) {
    // __builtin_expect(b == 0, 0) 表示"这个条件基本不会成立"
    if (__builtin_expect(b == 0, 0)) {
        printf("除数不能为 0!\n");
        return 0;
    }
    return a / b;
}

int main(void) {
    printf("10 / 2 = %d\n", divide(10, 2));   // 5
    printf("10 / 3 = %d\n", divide(10, 3));   // 3
    divide(10, 0);                            // 除数不能为 0!

    return 0;
}

想确认某个属性到底有没有被支持,可以用 C23 的 __has_c_attribute:

  • __has_c_attribute(likely) → 在 GCC 15 / Clang 21 上会展开为 0(不支持);
  • __has_c_attribute(nodiscard) → 展开为非 0(支持)。返回值是"标准版本号 × 100 + 月份",例如 202311L 表示 C23。

27.9.7 另一个误区:[[no_unique_address]] 也不在 C23 里

[[no_unique_address]] 同样是 C++20 的属性,C23 没有采纳它。在 C 里写它,编译器的反应依然是"不认识这个属性,忽略":

1
warning: unknown attribute 'no_unique_address' ignored [-Wunknown-attributes]

另外要提醒一句:上面旧版本示例里的 struct Empty { };(没有任何成员的结构体)在 C 里其实也是非标准写法。C 要求结构体至少有一个具名成员,只有 GCC/Clang 才把它当作扩展接受(并且 sizeof 为 0)。在 C 里想表达"占位"的语义,稳妥做法是显式给一个成员,例如:

1
struct tag { unsigned char dummy; };

想在 C 里节省成员的存储,目前没有标准手段;能靠的只有手工重排成员顺序(把对齐要求高的成员放前面)和 _Static_assert 检查布局。


27.10 澄清:C23 没有模块系统

⚠️ 先给结论:C 语言至今(C23 及之后已发布的版本)都没有模块系统,import / module / export 这些关键字在 C 里的编译结果只有一条 —— error: unknown type name 'import'。它们是 C++20 的特性(C++ 的模块确实用 import / export)。本章早先的一些代码把它们当成"C23 新特性",那是错误的,已经改写。

27.10.1 为什么会有"C23 有模块"的说法?

这多半是两件事被混在了一起:

  1. C++20 的模块(import std;、export module math;)确实存在,而且演示代码很常见;
  2. C 委员会讨论过给 C 加模块(各种提案),但从未进入 C23 标准文本。在 N3096(C23 草案)里搜索 module 一词,命中数是 0。

27.10.2 C 里想要"减少头文件重复包含",用什么?

答案是继续用预处理器的老办法,它们都很成熟:

手段说明标准
头文件保护宏#ifndef X_H / #define X_H / #endifC89 起
#pragma once更简洁,但属于扩展,不是标准编译器扩展(GCC/Clang/MSVC 都支持)
前置声明减少不必要的 #includeC89 起
只包含接口、把实现放 .c从工程结构上减少依赖—
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/* point.h —— 一个规范的头文件 */
#ifndef POINT_H
#define POINT_H

struct Point {
    int x;
    int y;
};

int point_distance_squared(const struct Point *a, const struct Point *b);

#endif /* POINT_H */
1
2
3
4
5
6
7
8
/* point.c —— 实现放在源文件里 */
#include "point.h"

int point_distance_squared(const struct Point *a, const struct Point *b) {
    int dx = a->x - b->x;
    int dy = a->y - b->y;
    return dx * dx + dy * dy;
}

如果将来 C 真的采纳了模块,编译器一定会通过 __STDC_VERSION__ 升级和 __has_include 之外的特性探测宏来区分。在此之前,看到任何"用 import 写 C 代码"的教程,直接跳过即可。

27.11 内联汇编:__asm__ volatile(GCC 扩展)

⚠️ 警告:内联汇编是 GCC/Clang 的扩展,非标准 C!它可以让你的代码直接和 CPU 指令打交道,但代价是可移植性为零。除非你真的需要优化到极致,或者在写操作系统内核,否则不要用!

还有一层"可移植性为零"是很多人没意识到的:本节示例里的指令(addl、rdtsc)和约束(=a、=b、=A)都是 x86/x86-64 专属的。把同样的代码拿到 ARM64(Apple Silicon、绝大多数手机、越来越多的服务器)上编译,会直接得到 error: invalid output constraint '=a' in asm 之类的错误。想跟随本节实验,请在 x86-64 环境(或 x86_64 交叉编译目标)下进行。

27.11.1 什么是内联汇编?

内联汇编允许你在 C 代码中直接写汇编指令。编译器会把这些汇编代码"粘合"进生成的机器码里。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <stdio.h>

int add(int a, int b) {
    int result;
    // __asm__ 是 GCC 的内联汇编关键字
    __asm__ volatile (
        "addl %%ebx, %%eax"  // 汇编指令:eax = eax + ebx
        : "=a"(result)       // 输出操作数:result 放在 eax
        : "a"(a), "b"(b)     // 输入操作数:a 放在 eax,b 放在 ebx
    );
    return result;
}

int main(void) {
    printf("add(3, 4) = %d\n", add(3, 4));  // 7
    return 0;
}

上面这段只适用于 x86/x86-64:%eax、%ebx 是 x86 的寄存器名,"=a"、"a"、"b" 是 x86 的约束字母。用 GCC/Clang 编译时要加 -std=gnu11(或更高的 gnu* 标准),因为 __asm__ 不是 ISO C 的一部分。

内联汇编就像是你在厨房里做菜,突然说"让我来用分子料理的手法处理这块肉"——直接用最高级、最底层的工具,但风险也最高(可能搞砸整道菜)。

27.11.2 内联汇编的语法

1
2
3
4
5
6
__asm__ volatile (
    "汇编指令"
    : 输出操作数 (约束)  // "=r" = 输出到寄存器,"=a" = eax, "=b" = ebx
    : 输入操作数 (约束)  // "r" = 任意寄存器,"i" = 立即数
    : 被修改的寄存器     // 告诉编译器哪些寄存器会被用到(clobber)
);

27.11.3 常用约束

下表中的具体字母(a、b、A…)都绑定到 x86 的寄存器,换个架构就完全不同:

约束含义可移植性
r任意通用寄存器(输入)通用
=r任意通用寄存器(输出)通用
m内存操作数通用
i立即数(编译期常量)通用
0~9与第 N 个操作数使用同一个位置通用
=&r输出专用寄存器(early-clobber)通用
"a" / "=a"x86 的 eax/raxx86 专属
"b" / "=b"x86 的 ebx/rbxx86 专属
"=A"仅 32 位 x86:eax/edx 拼成一个 64 位值x86-32 专属
cc汇编会修改条件码标志通用
memory汇编会读写内存,编译器需重新加载通用

27.11.4 实战:读取 CPU 时钟周期

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <stdio.h>

// 读取 CPU 时间戳计数器(x86 / x86-64 专属)
// rdtsc 把 64 位计数拆成 edx:eax 两半,所以要分别接收再拼起来。
// 注意:在 64 位下不能用 "=A" 约束 —— 那只是 32 位 x86 的写法。
static inline unsigned long long get_cycles(void) {
    unsigned int lo, hi;
    __asm__ volatile (
        "rdtsc"
        : "=a"(lo), "=d"(hi)     // eax → lo,edx → hi
    );
    return ((unsigned long long)hi << 32) | lo;
}

int main(void) {
    unsigned long long start = get_cycles();

    // 干点啥
    long long sum = 0;
    for (int i = 0; i < 1000000; i++) sum += i;

    unsigned long long end = get_cycles();
    printf("执行消耗了 %llu 个时钟周期\n", end - start);
    printf("sum = %lld\n", sum);

    return 0;
}

27.12 C23 #embed(二进制文件内容嵌入)

27.12.1 #embed 是什么?

这是一个超级实用的新特性!在 C23 之前,如果你想把一个二进制文件(比如图片、字体、配置)嵌入到程序里,你需要用 xxd、base64 或者外部工具把它转成 C 数组。

现在,C23 的 #embed 可以直接帮你把二进制文件嵌入到编译后的程序里!

可用性提示:#embed 从 GCC 15 和 Clang 19 才开始支持(本示例在 Apple Clang 21 上可以直接编译)。更早的版本会报 expected expression 之类看起来毫不相关的错误。MSVC 目前不支持。

下面这个例子里,favicon.bin 是你自己准备的真实文件(随便找个图片或文本文件改名即可,注意它得和源文件在同一个目录,或者在包含路径上):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <stdio.h>

// C23 #embed:把二进制文件内容原样嵌入
// 这会生成一个 unsigned char 数组,大小正好等于文件字节数
const unsigned char favicon_data[] = {
    #embed "favicon.bin"
};

// limit(N):最多嵌入 N 字节(相当于"截断")
const unsigned char logo_head[] = {
    #embed "favicon.bin" limit(16)
};

// prefix / suffix:在"整段展开结果"的首/尾各追加一段内容(各只生效一次)
// 标准里的经典用法:suffix(,) 后紧跟一个 0,就给数组补上了结束符
const unsigned char logo_z[] = {
    #embed "favicon.bin" limit(4) suffix(,)
    0
};

int main(void) {
    printf("favicon 大小: %zu 字节\n", sizeof(favicon_data));
    printf("只取前 16 字节: %zu 字节\n", sizeof(logo_head));
    printf("前 4 字节 + 结束符: %zu 字节\n", sizeof(logo_z));

    return 0;
}

#embed 就像是把外卖直接装进肚子里——以前你需要用工具把图片转成数组,现在编译器直接帮你做了。

27.12.2 #embed 的参数

C23 一共只定义 4 个标准参数(写成别的名字都会报 unknown embed preprocessor parameter,比如常见的 terminator、if_empty_then 都是不存在的):

参数含义
limit(N)最多嵌入 N 个元素(相当于截断)
prefix(tokens…)在整个展开结果之前插入一段预处理记号
suffix(tokens…)在整个展开结果之后插入一段预处理记号
if_empty(tokens…)如果资源为空,用这些记号替换掉整个 #embed 指令
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>

// 情形一:文件存在且非空 —— 正常嵌入前 4 个字节,再加一个 0 结尾
const unsigned char data1[] = {
    #embed "favicon.bin" limit(4) suffix(,)
    0
};

// 情形二:用 limit(0) 强制"资源被视为空",
// 于是整个 #embed 被 if_empty 里的内容替换,数组只剩一个 0
const unsigned char data2[] = {
    #embed "favicon.bin" limit(0) if_empty(0)
};

static_assert(sizeof(data2) == 1, "limit(0) 会把资源视为空,从而走 if_empty 分支");

int main(void) {
    printf("data1 大小: %zu 字节\n", sizeof(data1));  // 5
    printf("data2 大小: %zu 字节\n", sizeof(data2));  // 1
    return 0;
}

还有两个细节值得记住:

  1. #embed 的结果天然就是"逗号分隔的元素序列",所以可以直接放进 { } 初始化列表,也可以配合 suffix(,) + 后续元素使用。
  2. if_empty 不能帮你处理"文件找不到"。文件不存在时是编译错误(fatal error: 'xxx.bin' file not found),if_empty 只处理"文件存在但内容为空(或被 limit(0) 变成空)“的情况。

27.13 _BitInt(N)(C23):任意精度整数

27.13.1 _BitInt 是什么?

C23 引入了一个激动人心的特性:_BitInt(N) —— 可以指定精确位数的整数类型!以前 int 固定是 32 位,现在你可以要一个 7 位、96 位、128 位的整数。

不过"任意"要打个引号:N 的取值范围是 1 ~ BITINT_MAXWIDTH(定义在 <limits.h>,标准要求它至少等于 ULLONG_WIDTH 也就是 64,具体上限由实现决定)。另外各实现还有自己的额外限制,比如 Clang 只支持到 128 位(而且有符号和无符号是分别限制的),所以别真的去写 _BitInt(1000000)。

配合新类型,C23 还给整数字面量加了 wb / uwb 后缀:123wb 表示"宽度刚好放得下 123 的 _BitInt",3uwb 表示无符号版本。这样就不用自己算位宽了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <limits.h>

int main(void) {
    // _BitInt(N):正好 N 位的整数(含符号位)
    _BitInt(7) small = 63;                    // 7 位有符号:-64 ~ 63
    unsigned _BitInt(7) usmall = 127;         // 7 位无符号:0 ~ 127

    // 用 wb 后缀让字面量自己挑一个放得下的位宽
    _BitInt(128) big = 12345678901234567890wb;

    printf("small=%d, usmall=%u\n", (int)small, (unsigned)usmall);
    printf("sizeof(_BitInt(7))   = %zu 字节\n", sizeof(_BitInt(7)));
    printf("sizeof(_BitInt(128)) = %zu 字节\n", sizeof(big));
    printf("BITINT_MAXWIDTH      = %llu\n", (unsigned long long)BITINT_MAXWIDTH);

    // 只要结果不超过 128 位,就能安全地做大数运算
    _BitInt(128) a = (_BitInt(128))1 << 100;   // 2^100
    _BitInt(128) b = (_BitInt(128))1 << 20;    // 2^20
    _BitInt(128) c = a * b;                    // 2^120,仍在 128 位之内
    _BitInt(128) expected = (_BitInt(128))1 << 120;
    printf("2^100 * 2^20 == 2^120 ? %s\n", (c == expected) ? "是" : "否");

    return 0;
}

注意上一版代码里的两个错误:_BitInt(7) small = 100; 会溢出(7 位有符号装不下 100,实际值会变成 -28,编译器给出 -Wconstant-conversion 警告);而 (_BitInt(128))1 << 100 再乘以 1 << 50 得到 2^150,超出 128 位,属于有符号溢出(未定义行为)。写位精确整数时,“结果会不会溢出"必须自己盯着。

_BitInt 就像是一叠可裁剪的草稿纸:int 是 A4 纸,long long 是 A3 纸,_BitInt(N) 则是"你要多大就裁多大”,但最大也只能裁到实现允许的那个尺寸。

27.13.2 _BitInt 的用法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <stdio.h>

int main(void) {
    // 基本声明:和普通整数一样可以赋初值、参与运算
    _BitInt(8)   byte_val  = -1;      // 8 位有符号:-128 ~ 127
    _BitInt(16)  word_val  = 1000;    // 16 位有符号
    _BitInt(32)  dword_val = 100000;  // 32 位有符号
    unsigned _BitInt(8) ubyte = 255;  // 8 位无符号:0 ~ 255

    printf("%d %d %d %u\n",
           (int)byte_val, (int)word_val, (int)dword_val, (unsigned)ubyte);

    // F(100) ≈ 3.54e20,需要约 69 位,96 位无符号绰绰有余
    unsigned _BitInt(96) fib1 = 1, fib2 = 1, fibn = 0;
    for (int i = 3; i <= 100; i++) {
        fibn = fib1 + fib2;
        fib1 = fib2;
        fib2 = fibn;
    }
    printf("F(100) > 2^64 ? %s\n",
           (fibn > ((unsigned _BitInt(96))1 << 64)) ? "是" : "否");

    // printf 家族没有任何转换说明符能直接打印 _BitInt。
    // 值能放进标准类型时可以转换后打印(超宽会按 2^N 取模截断);
    // 想完整打印大数,只能自己按位/按十进制逐位提取(见 27.13.4)。
    printf("F(100) 的低 64 位 = %llu\n", (unsigned long long)fibn);

    return 0;
}

27.13.3 _BitInt 的限制

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <limits.h>

int main(void) {
    // ✅ N 的取值范围是 1 ~ BITINT_MAXWIDTH,并且可以是常量表达式
    enum { WIDTH = 128 };          // 或者用 #define / constexpr
    _BitInt(WIDTH) configurable = 0;

    printf("BITINT_MAXWIDTH = %llu\n", (unsigned long long)BITINT_MAXWIDTH);
    printf("sizeof(_BitInt(%d)) = %zu 字节\n", (int)WIDTH, sizeof(configurable));

    // ✅ 与标准整数类型之间可以互相转换
    long long  from_std = 42;
    _BitInt(64) to_bit   = from_std;
    long long  back      = (long long)to_bit;
    printf("from_std=%lld, to_bit=%lld, back=%lld\n",
           from_std, (long long)to_bit, back);

    return 0;
}

// ❌ 以下写法都会编译失败(取消注释即可自行验证):
//   _BitInt(0) x;            // N 必须 ≥ 1
//   _BitInt(8192) z;         // 超过本实现的 BITINT_MAXWIDTH
//   signed _BitInt(256) w;   // Clang:有符号 _BitInt 最多 128 位

关于上限,记住三句话就够了:

  • 标准层面:N 必须落在 1 ~ BITINT_MAXWIDTH 内(BITINT_MAXWIDTH 见 <limits.h>)。
  • 实现层面:各编译器可以更保守。Clang 目前把有符号、无符号都限制在 128 位;GCC 的额度要大得多(具体数值取决于目标平台,可用 BITINT_MAXWIDTH 打印)。
  • 格式化输出:printf 的转换说明符里没有给 _BitInt 准备的位置,必须转换或手工提取。

27.13.4 实战:计算大整数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <stdio.h>
#include <string.h>

// 简单的字符串数字加法(演示 _BitInt 的用途)
void add_big_integers(const char *a, const char *b, char *result) {
    int len_a = strlen(a);
    int len_b = strlen(b);
    int max_len = (len_a > len_b ? len_a : len_b) + 1;

    int carry = 0;
    int i = 0;
    for (i = 0; i < max_len; i++) {
        int da = (i < len_a) ? a[len_a - 1 - i] - '0' : 0;
        int db = (i < len_b) ? b[len_b - 1 - i] - '0' : 0;
        int sum = da + db + carry;
        result[max_len - 1 - i] = '0' + (sum % 10);
        carry = sum / 10;
    }
    result[max_len] = '\0';
}

int main(void) {
    // 传统字符串方法:不依赖任何扩展,任何编译器都能跑
    char a[] = "123456789012345678901234567890";
    char b[] = "987654321098765432109876543210";
    char result[100];

    add_big_integers(a, b, result);
    printf("%s + %s = %s\n", a, b, result);
    // 123456789012345678901234567890 + 987654321098765432109876543210 = 1111111110111111111011111111100

    // 如果编译器的 _BitInt 位宽够大,同样的加法可以直接算;
    // 但要打印出来仍需自己逐位提取(printf 不支持 _BitInt)。
    // 这两个数各约 100 位,所以要给到 128 位才放得下。
    unsigned _BitInt(128) x = 123456789012345678901234567890uwb;
    unsigned _BitInt(128) y = 987654321098765432109876543210uwb;
    unsigned _BitInt(128) sum = x + y;
    printf("用 _BitInt(96) 算出的和,低 64 位 = %llu\n", (unsigned long long)sum);

    return 0;
}

本章小结

这一章我们一起探索了 C 语言的"高级武器库”!让我们来一个快速回顾:

知识点标准用途
... + __VA_ARGS__C99可变参数宏,让宏也能接受任意多参数
__VA_OPT__(x)C23可变参数非空时才展开,解决"多余逗号"问题
##__VA_ARGS__GNU 扩展同上,但只能在 GCC/Clang 上用
复合字面量 (int[]){1,2,3}C99临时数组/结构体,即用即弃
语句表达式 ({})GNU在括号里写代码块,返回最后表达式的值
typeof / typeof_unqualC23获取变量类型,写类型无关的宏
__builtin_popcountGCC/Clang快速计算二进制中 1 的个数
__builtin_expectGCC/Clang分支预测优化,likely/unlikely 宏的实现原理
__builtin_offsetofGCC/Clang计算结构体成员偏移量(不能用于位域)
__builtin_trapGCC/Clang触发调试器断点
__builtin_prefetchGCC/Clang提前预取数据到缓存
_GenericC11类型分发,实现泛型选择
constexprC23声明编译期常量对象(不能修饰函数)
nullptrC23类型安全的空指针
[[noreturn]]C23标记不会返回的函数
[[nodiscard]]C23标记不能忽略返回值的函数
[[maybe_unused]]C23抑制"未使用"警告
[[deprecated]]C23标记废弃的符号
[[fallthrough]]C23标记 switch 的有意穿透
[[reproducible]] / [[unsequenced]]C23描述函数的优化性质
[[likely]] / [[unlikely]]❌ 仅 C++20C23 没有采纳,C 里用 __builtin_expect
[[no_unique_address]]❌ 仅 C++20C23 没有采纳
import / module / export❌ 仅 C++20C 没有模块系统,继续用 #include
__asm__ volatileGNU 扩展内联汇编(非标准,x86 专属,慎用)
#embedC23二进制文件内容嵌入(GCC 15+ / Clang 19+)
_BitInt(N) / wb 后缀C23位精确整数(Clang 上限 128 位)

🎓 毕业感言:恭喜你完成 C 语言高阶课程!你现在掌握了 C 语言界的大部分"隐藏技能"。但记住:能力越大,责任越大。那些 GNU 扩展虽然强大,但会锁定你的代码到特定编译器。选择工具时,永远要问自己:“我真的需要这个吗?”

继续加油,未来的 C 语言大师! 🚀