28、指针与性能优化:缓存友好访问、指针别名与restrict、循环展开与指针

性能优化这事儿,说白了就是跟硬件打交道。你写的那几行C代码,最终都要变成CPU指令,在内存和缓存之间来回倒腾。我做了十几年嵌入式,见过太多“看起来漂亮”的代码,跑起来却慢得像蜗牛。问题出在哪?很多时候就是指针用得不够聪明。

今天咱们聊三个硬核话题:缓存友好访问指针别名与restrict循环展开与指针。这些不是花架子,是真正能让你代码“飞起来”的技巧。

指针与性能优化核心知识体系 缓存友好访问 空间局部性 · 行优先 指针别名与restrict 别名冲突 · 编译器优化 循环展开与指针 减少分支 · 指针递增 连续内存访问 → 缓存命中率高 避免跳跃式指针遍历 restrict 消除别名歧义 让编译器大胆优化 循环体展开 + 指针步进 减少循环开销 三者结合:写出“缓存感知”的高效代码 嵌入式系统 · C语言底层优化

28.1 缓存友好访问:别让CPU等你

现代CPU比内存快好几个数量级。你想想看,CPU执行一条指令可能只要0.3纳秒,但从内存读数据却要几十纳秒。这中间的差距,全靠缓存来填补。

缓存的工作原理很简单:它假设你刚访问过的地址,附近的数据你很快也会用到。这叫“空间局部性”。

我举个例子。假设你有一个二维数组,要遍历所有元素:

// 缓存友好版本:按行遍历
int sum_row_major(int (*arr)[1024], int rows) {
    int total = 0;
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < 1024; j++) {
            total += arr[i][j];  // 连续内存访问
        }
    }
    return total;
}

// 缓存不友好版本:按列遍历
int sum_col_major(int (*arr)[1024], int rows) {
    int total = 0;
    for (int j = 0; j < 1024; j++) {
        for (int i = 0; i < rows; i++) {
            total += arr[i][j];  // 跳跃式访问
        }
    }
    return total;
}

我在项目中遇到过类似的情况。有一次做视频处理,一帧图像数据是1920x1080的数组。同事用列优先方式遍历像素,结果处理一帧要35毫秒。改成行优先后,直接降到8毫秒。差距就是这么大。

核心原则:用指针遍历数组时,尽量让访问模式跟内存布局一致。C语言是行优先存储,所以内层循环应该遍历列。

用指针写缓存友好代码,可以这样:

void process_buffer(int *buf, size_t len) {
    int *end = buf + len;
    for (int *p = buf; p < end; p++) {
        *p = (*p >> 1);  // 连续递增,缓存友好
    }
}

这种写法,每次p++都跳到下一个int,地址连续。缓存预取机制会提前把后面的数据拉进缓存,CPU几乎不用等。

小技巧:如果数据结构比较复杂,比如链表,可以考虑用“内存池”+“数组索引”代替指针链。这样能大幅提升缓存命中率。

28.2 指针别名与restrict:给编译器松绑

指针别名,说白了就是两个指针指向同一块内存。编译器看到这种情况,会变得非常保守。

为什么?因为它不敢乱优化。比如:

void vec_add(int *a, int *b, int *c, int n) {
    for (int i = 0; i < n; i++) {
        a[i] = b[i] + c[i];
    }
}

编译器心里会想:“万一a和b指向同一个数组呢?那每次写a[i]都可能改变b[i+1]的值,我还怎么优化?”于是它只能老老实实每次从内存读,写完再存回去。

这时候restrict就派上用场了。它是C99引入的关键字,告诉编译器:“我保证这些指针不会重叠,你放心优化吧。”

void vec_add_restrict(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指令,一次处理4个甚至8个int。性能提升非常明显。

警告:如果你加了restrict,但实际传入了重叠的指针,结果是未定义行为。编译器不会帮你检查,程序可能崩溃或算出错误结果。

我曾经在一个音频处理库中吃过这个亏。两个音频缓冲区有重叠,我加了restrict,结果出来的声音全是噪声。排查了两天才发现是别名问题。从那以后,我每次用restrict都会再三确认指针不会重叠。

用指针写的时候,restrict尤其重要:

void copy_data(int *restrict dst, const int *restrict src, size_t n) {
    int *d = dst;
    const int *s = src;
    const int *end = src + n;
    while (s < end) {
        *d++ = *s++;  // 编译器可以大胆用批量拷贝指令
    }
}

记住:restrict不是语法糖,它是你跟编译器之间的契约。用好了,性能翻倍;用错了,bug难查。

28.3 循环展开与指针:减少开销,榨干性能

循环是有开销的。每次迭代都要判断条件、更新计数器、跳转。如果循环体本身很小,那循环开销占比就很大。

循环展开,就是手动减少循环次数,把多个迭代合并到一起。配合指针操作,效果更好。

看个例子:

// 原始版本
void scale_vector(float *data, float factor, size_t n) {
    for (size_t i = 0; i < n; i++) {
        data[i] *= factor;
    }
}

// 循环展开 + 指针版本
void scale_vector_unrolled(float *data, float factor, size_t n) {
    float *p = data;
    float *end = data + n;
    
    // 处理4的倍数部分
    while (p + 4 <= end) {
        p[0] *= factor;
        p[1] *= factor;
        p[2] *= factor;
        p[3] *= factor;
        p += 4;
    }
    
    // 处理剩余部分
    while (p < end) {
        *p++ *= factor;
    }
}

展开4次之后,循环判断次数减少了75%。而且编译器看到连续的4次乘法,可能会生成SIMD指令,一次搞定。

我个人习惯展开4次或8次。展开太多反而不好,会让代码体积变大,指令缓存可能装不下。

经验值:对于嵌入式平台,展开2~4次通常收益最大。在ARM Cortex-M上,我试过展开8次反而变慢,因为代码膨胀导致指令缓存频繁miss。

指针在这里的优势很明显:p += 4i += 4然后data[i]少一次索引计算。别小看这一丁点,在循环里累积起来就很可观。

再复杂一点,处理结构体数组:

typedef struct {
    int x, y, z;
} Point3D;

void translate_points(Point3D *pts, int dx, int dy, int dz, size_t n) {
    Point3D *p = pts;
    Point3D *end = pts + n;
    
    while (p + 2 <= end) {
        p[0].x += dx; p[0].y += dy; p[0].z += dz;
        p[1].x += dx; p[1].y += dy; p[1].z += dz;
        p += 2;
    }
    
    while (p < end) {
        p->x += dx; p->y += dy; p->z += dz;
        p++;
    }
}

这里展开2次,因为结构体比较大,展开太多会让寄存器压力增大。嗯,这个度需要根据实际情况调整。

总结一下:循环展开配合指针递增,能同时减少循环开销和索引计算。但要注意平衡代码大小和性能收益。

28.4 三者结合:一个实战案例

最后,我把三个技巧揉在一起,给你看一个完整的例子。假设我们要做图像灰度化:

// RGB888 转灰度,优化版本
void rgb_to_gray_optimized(
    uint8_t *restrict gray,
    const uint8_t *restrict rgb,
    size_t pixel_count
) {
    // 指针方式,缓存友好
    uint8_t *g = gray;
    const uint8_t *s = rgb;
    const uint8_t *end = rgb + pixel_count * 3;
    
    // 每次处理2个像素(6字节),循环展开
    while (s + 6 <= end) {
        // 像素1
        *g++ = (uint8_t)((s[0]*77 + s[1]*150 + s[2]*29) >> 8);
        // 像素2
        *g++ = (uint8_t)((s[3]*77 + s[4]*150 + s[5]*29) >> 8);
        s += 6;
    }
    
    // 处理剩余像素
    while (s < end) {
        *g++ = (uint8_t)((s[0]*77 + s[1]*150 + s[2]*29) >> 8);
        s += 3;
    }
}

这个函数里:

  • restrict 告诉编译器gray和rgb不重叠
  • 指针递增 保证连续访问,缓存友好
  • 循环展开 减少分支开销

我在一个摄像头项目中用过类似的代码。原始版本用下标访问,处理640x480的图像要12毫秒。优化后降到4毫秒,刚好满足30fps的要求。

嗯,性能优化就是这样,有时候就差那么几毫秒。而指针,就是你手里最锋利的工具。


公众号:蓝海资料掘金营,微信deep3321