3. C语言在DSP中的优化技巧:循环展开、查表法、内联函数、寄存器变量

做DSP开发的朋友都知道,C语言写起来爽,但跑起来不一定爽。尤其是雷达信号处理这种实时性要求极高的场景,代码效率差一点,整个系统就崩给你看。我这些年踩过的坑不少,今天就把几个最实用的优化技巧拿出来聊聊。

3.1 循环展开:用空间换时间的老手艺

循环展开,说白了就是把循环体复制几份,减少循环控制的开销。DSP的循环指令虽然快,但每次迭代都要做一次跳转和条件判断,累积起来就是不小的开销。

核心思想:减少循环次数,增加每次循环的工作量。

举个例子,一个简单的FIR滤波器实现:

// 原始循环
for(i = 0; i < N; i++) {
    sum += x[i] * h[i];
}

// 展开2次
for(i = 0; i < N; i += 2) {
    sum += x[i]   * h[i];
    sum += x[i+1] * h[i+1];
}

我在项目中遇到过一个问题:一个256点的复数乘法,用原始循环跑了200多个时钟周期。展开4次后,直接降到60多个周期。为什么?因为循环控制指令从256次减少到了64次,而且DSP的并行指令能同时处理多个乘加操作。

我的经验:展开因子一般选2、4、8。展开太多会导致代码膨胀,反而影响指令缓存命中率。我习惯先看反汇编,确认DSP的VLIW(超长指令字)能并行执行几条指令,再决定展开倍数。

3.2 查表法:三角函数、开方、反正切,统统查表

雷达信号处理里,三角函数、开方、反正切这些运算太常见了。但DSP做这些运算,一个函数调用可能就是几十甚至上百个周期。查表法,就是提前算好结果存起来,运行时直接查。

你想想看,一个1024点的FFT,每个点都要算一次旋转因子。如果每次都用sin/cos函数算,光这个就够喝一壶的。我建议的做法是:

// 预计算旋转因子表
float cos_table[1024];
float sin_table[1024];

void init_twiddle_table(int N) {
    for(i = 0; i < N; i++) {
        cos_table[i] = cos(2 * PI * i / N);
        sin_table[i] = sin(2 * PI * i / N);
    }
}

// 运行时直接查表
float twiddle_real = cos_table[k];
float twiddle_imag = sin_table[k];

注意:查表法虽然快,但精度受限于表的大小。我曾经在某个项目中,为了省内存把正弦表只做了256点,结果频谱分析时出现了明显的谐波失真。后来改成1024点,问题就解决了。嗯,这里要记住:精度和内存,你得做个权衡。

除了三角函数,开方运算也可以用查表法。比如计算复数模值 sqrt(a² + b²),可以用平方根表或者近似公式。我个人习惯用查表加线性插值,精度够用,速度也快。

3.3 内联函数:省掉函数调用的开销

函数调用在DSP上是有代价的——压栈、跳转、返回,这些操作在实时处理中都是浪费。内联函数,就是把函数体直接嵌入调用处,省掉这些开销。

C语言里用 inline 关键字,但要注意:inline 只是建议,编译器不一定听你的。我一般这样写:

// 内联函数声明
static inline int sat_add(int a, int b) {
    int sum = a + b;
    // 饱和处理
    if((a > 0) && (b > 0) && (sum < 0)) sum = 0x7FFFFFFF;
    if((a < 0) && (b < 0) && (sum > 0)) sum = 0x80000000;
    return sum;
}

// 使用
int result = sat_add(sample1, sample2);

关键点:内联函数适合小函数,比如简单的数学运算、饱和处理、位操作。大函数内联会导致代码膨胀,反而降低性能。

我记得有一次优化一个脉冲压缩模块,里面有个复数乘法函数被调用了上万次。改成内联后,整个模块的执行时间减少了15%。为什么?因为DSP的流水线不用因为函数调用而频繁冲刷了。

3.4 寄存器变量:让数据离CPU更近

寄存器变量,就是用 register 关键字告诉编译器:这个变量请放在寄存器里,别放内存。DSP访问寄存器只需要1个周期,访问内存可能要几个甚至十几个周期。

// 寄存器变量示例
void fir_filter(short *x, short *h, short *y, int N) {
    register int i;
    register long sum;
    register short *px = x;
    register short *ph = h;
    
    for(i = 0; i < N; i++) {
        sum = 0;
        // 循环体
    }
}

我的习惯:循环计数器、累加器、频繁访问的指针,这些我都会声明为 register。但要注意,DSP的寄存器数量有限,一般就32个左右。你声明太多,编译器反而会忽略你的建议。

我曾经在调试一个MTI(动目标显示)滤波器时,发现性能始终上不去。后来看反汇编,发现循环计数器被放到了栈上。加上 register 后,性能提升了8%。嗯,有时候就是这些小细节决定了成败。

3.5 综合运用:一个实际案例

说了这么多,咱们看一个综合案例。假设我们要实现一个256点的复数点乘:

// 优化前
void complex_dot(complex *a, complex *b, complex *out, int N) {
    int i;
    for(i = 0; i < N; i++) {
        out[i].real = a[i].real * b[i].real - a[i].imag * b[i].imag;
        out[i].imag = a[i].real * b[i].imag + a[i].imag * b[i].real;
    }
}

// 优化后
static inline void cmul(complex *a, complex *b, complex *out) {
    register float ar = a->real, ai = a->imag;
    register float br = b->real, bi = b->imag;
    out->real = ar * br - ai * bi;
    out->imag = ar * bi + ai * br;
}

void complex_dot_opt(complex *a, complex *b, complex *out, int N) {
    register int i;
    register complex *pa = a, *pb = b, *pout = out;
    
    // 展开4次
    for(i = 0; i < N; i += 4) {
        cmul(pa++, pb++, pout++);
        cmul(pa++, pb++, pout++);
        cmul(pa++, pb++, pout++);
        cmul(pa++, pb++, pout++);
    }
}

优化效果:在我的项目中,这个优化让复数点乘的执行时间从原来的320个周期降到了112个周期,性能提升了近3倍。

3.6 避坑指南

最后,分享几个我踩过的坑:

  • 循环展开别过头:我曾经把循环展开到16倍,结果代码体积暴增,指令缓存命中率下降,性能反而变差了。一般4倍就够了。
  • 查表法注意对齐:DSP访问对齐数据最快。查表时确保表格地址是4字节或8字节对齐的,否则会有额外的访问开销。
  • 内联函数别滥用:内联会增加代码体积。我一般只内联那些被频繁调用且函数体小于10行的小函数。
  • 寄存器变量别贪多:DSP的寄存器是稀缺资源。你声明太多 register,编译器会忽略多余的,反而可能打乱它的优化策略。

这些技巧,说白了就是让DSP少做无用功。你想想看,雷达信号处理里,一个脉冲重复周期可能只有几毫秒,你要在这段时间内完成匹配滤波、MTI、CFAR等一系列操作。每一行代码的效率,都直接决定了系统能不能跑起来。

好了,今天就聊到这里。下一章我们讲讲内存优化——怎么让数据在DSP的各级存储间高效流转。那个坑更多,到时候我慢慢说。