19. 性能优化:任务执行时间测量、代码优化技巧、缓存预取
做运动控制插补,说白了就是跟时间赛跑。你算得再准,跑得慢,电机那边就抖给你看。我这些年调过的插补任务,十有八九的问题不是算法不对,而是性能没榨干。今天咱们就聊聊怎么把RTOS里的插补任务优化到极致。
19.1 任务执行时间测量——你得先知道慢在哪
优化之前,先得测量。我见过不少工程师上来就改代码,改完发现更慢了。为什么?因为根本不知道瓶颈在哪。
19.1.1 硬件定时器测量法
我个人习惯用硬件定时器。精度高,不干扰任务执行。在Cortex-M系列上,我常用DWT(Data Watchpoint and Trace)计数器。
// 初始化DWT
void DWT_Init(void)
{
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
DWT->CYCCNT = 0;
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
}
// 测量函数执行时间(单位:CPU周期)
uint32_t measure_func_time(void (*func)(void))
{
uint32_t start = DWT->CYCCNT;
func();
uint32_t end = DWT->CYCCNT;
return end - start;
}
19.1.2 RTOS内置时间戳API
如果你用FreeRTOS,可以用xTaskGetTickCount()。但注意,这个精度只有1个tick,通常1ms。对于插补任务来说,太粗了。
我建议用vTaskGetRunTimeStats()。它能告诉你每个任务占了多少CPU时间。嗯,这个在调试阶段特别好用。
// 在任务中插入时间戳
void interpolation_task(void *pvParameters)
{
TickType_t start, end;
uint32_t exec_time_us;
for(;;)
{
start = xTaskGetTickCount();
// 插补计算核心
interpolate_step();
end = xTaskGetTickCount();
exec_time_us = (end - start) * portTICK_PERIOD_MS * 1000;
// 记录到环形缓冲区
log_exec_time(exec_time_us);
vTaskDelayUntil(&start, pdMS_TO_TICKS(1));
}
}
19.2 代码优化技巧——把每一周期都榨干
测量完了,发现插补任务占了80%的CPU。怎么办?别慌,咱们一步步来。
19.2.1 减少函数调用开销
插补算法里,小函数特别多。比如get_axis_position()、calc_velocity()这种。每次调用都有压栈出栈的开销。
我建议用inline关键字。但别滥用,只对调用频繁且代码量小的函数用。
// 不好的写法
int32_t get_axis_pos(uint8_t axis) {
return axis_pos[axis];
}
// 优化后
static inline int32_t get_axis_pos(uint8_t axis) {
return axis_pos[axis];
}
__attribute__((always_inline))强制内联。但代码体积会变大,注意权衡。
19.2.2 循环展开与软件流水
插补算法里经常有循环处理多轴数据。比如三轴联动,循环3次。这种小循环,展开后性能提升明显。
// 原始循环
for(int i = 0; i < 3; i++) {
axis_step[i] += axis_delta[i];
axis_pos[i] += axis_step[i];
}
// 循环展开
axis_step[0] += axis_delta[0];
axis_pos[0] += axis_step[0];
axis_step[1] += axis_delta[1];
axis_pos[1] += axis_step[1];
axis_step[2] += axis_delta[2];
axis_pos[2] += axis_step[2];
展开后少了循环变量的判断和跳转。对于实时性要求高的插补任务,这点提升很关键。
19.2.3 查表法代替实时计算
三角函数、开方运算,这些在插补里经常出现。但CPU算这些很慢。我习惯提前算好,存成表格。
// 预计算sin表,精度0.1度
#define SIN_TABLE_SIZE 3600
static int16_t sin_table[SIN_TABLE_SIZE];
void sin_table_init(void) {
for(int i = 0; i < SIN_TABLE_SIZE; i++) {
float angle = i * 0.1f * 3.14159f / 180.0f;
sin_table[i] = (int16_t)(sinf(angle) * 32767.0f);
}
}
// 查表代替计算
int16_t fast_sin(int16_t angle_deg_x10) {
return sin_table[angle_deg_x10 % SIN_TABLE_SIZE];
}
19.3 缓存预取——让CPU不等数据
你想想看,CPU跑200MHz,但内存访问要等几十个周期。这中间CPU就在空转。缓存预取就是提前把数据搬到Cache里。
19.3.1 数据对齐与Cache Line
Cache Line通常是32字节或64字节。如果你的数据跨了两个Cache Line,一次访问要加载两次。浪费啊。
// 对齐到Cache Line(假设64字节)
__attribute__((aligned(64)))
int32_t axis_buffer[3][64]; // 每个轴64个点的缓冲区
我在项目里遇到过,把结构体对齐后,插补任务的执行时间直接降了15%。
19.3.2 预取指令
ARM有PLD(Preload Data)指令。可以在计算当前数据时,提前把下一批数据加载到Cache。
void interpolate_with_prefetch(int32_t *buffer, int len) {
for(int i = 0; i < len; i++) {
// 预取后面第4个数据
__builtin_prefetch(&buffer[i + 4], 0, 3);
// 处理当前数据
process_point(buffer[i]);
}
}
19.3.3 避免Cache Thrashing
两个频繁访问的数据如果映射到同一个Cache Set,就会互相踢来踢去。这叫Cache Thrashing。
解决办法:把数据分散到不同内存区域,或者用__attribute__((section(".sram")))把关键数据放到SRAM。
// 把插补参数放到SRAM,避免和任务栈冲突
__attribute__((section(".sram")))
InterpParam_t interp_params;
// 把插补结果放到DTCM(如果MCU支持)
__attribute__((section(".dtcm")))
int32_t interp_result[3];
19.4 综合优化策略——一个实际案例
我记得有个项目,四轴插补任务,原本执行时间120μs。客户要求降到50μs以内。怎么做的?
| 优化步骤 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 1. 函数内联+循环展开 | 120μs | 85μs | 29% |
| 2. 查表代替三角函数 | 85μs | 62μs | 27% |
| 3. 数据对齐+缓存预取 | 62μs | 48μs | 23% |
| 4. 调整任务优先级+关中断 | 48μs | 45μs | 6% |
最终45μs,达标。你看,每一步都不大,但加起来效果惊人。
19.5 知识体系总览
下面这张图,是我对本章内容的总结。你可以把它当作优化时的检查清单。
嗯,优化这件事,说白了就是不断逼近硬件的极限。但别走火入魔,代码可维护性也很重要。我见过有人为了省10个周期,把代码写得跟天书一样,后来自己都看不懂了。
记住:先让功能跑起来,再谈优化。优化时,每次只改一处,测一次。这样出了问题,你才知道是哪一步导致的。