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的各级存储间高效流转。那个坑更多,到时候我慢慢说。