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;
}
小技巧:测量时记得关中断。否则ISR插进来,测出来的时间就不准了。我曾经因为这个查了一整天bug,最后发现是定时器中断在捣乱。

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];
}
注意:inline只是建议,编译器可能不理会。你可以用__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];
}
核心思想:空间换时间。嵌入式里Flash通常够用,把计算量转移到查表上,插补任务能快3-5倍。

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]);
    }
}
经验之谈:预取距离要合适。太近了,数据还没加载完;太远了,可能把有用的数据挤出去。我一般取4-8个元素的距离。

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,达标。你看,每一步都不大,但加起来效果惊人。

避坑指南:我曾经为了追求极致性能,把所有函数都内联了。结果代码体积暴涨,I-Cache命中率下降,反而更慢了。优化要适度,先测量,再动手。

19.5 知识体系总览

下面这张图,是我对本章内容的总结。你可以把它当作优化时的检查清单。

插补任务性能优化知识体系 任务执行时间测量 硬件定时器(DWT) RTOS时间戳API 运行时间统计 代码优化技巧 函数内联 循环展开 查表法 软件流水线 缓存预取优化 数据对齐 预取指令(PLD) 避免Cache Thrashing 内存区域划分 优化原则:先测量 → 再优化 → 再测量 避免过度优化,每次改动都要有数据支撑

嗯,优化这件事,说白了就是不断逼近硬件的极限。但别走火入魔,代码可维护性也很重要。我见过有人为了省10个周期,把代码写得跟天书一样,后来自己都看不懂了。

记住:先让功能跑起来,再谈优化。优化时,每次只改一处,测一次。这样出了问题,你才知道是哪一步导致的。