3、代码性能度量:时间测量、CPU占用率、内存占用分析

做点钞机嵌入式开发,最怕什么?

怕钞票数着数着就卡了。怕一开机内存就爆了。怕CPU跑满100%,机器烫得能煎鸡蛋。

这些问题,光靠看代码是看不出来的。你得会量。拿数据说话。

这一章,我就跟你聊聊性能度量的三把尺子:时间、CPU、内存。这三样搞明白了,你的代码优化才有方向。

3.1 时间测量:别靠感觉,靠示波器和定时器

我见过不少工程师,代码写完了,拍着胸脯说“这个函数肯定很快”。结果一上示波器,好家伙,跑了200毫秒。

为什么会这样?因为人的感觉不靠谱。尤其是中断服务函数里,你以为就几条指令,实际上可能调用了printf,或者做了浮点运算。

3.1.1 用GPIO翻转法测时间

这是我最常用的方法。简单粗暴,但有效。

在函数开始前拉高一个GPIO,结束后拉低。然后用示波器看脉宽。

// 测量关键函数执行时间
GPIO_SetBits(GPIOB, GPIO_Pin_0);  // 开始测量
process_banknote_data();           // 被测函数
GPIO_ResetBits(GPIOB, GPIO_Pin_0); // 结束测量

我个人习惯用PB0做测量口。为什么?因为示波器探头夹上去方便,而且这个引脚一般没被复用。

小技巧: 如果被测函数执行时间太短,示波器抓不到,可以循环执行100次再取平均值。比如循环100次,脉宽是50ms,那单次就是0.5ms。

3.1.2 用系统定时器精确计时

有些场合不方便接示波器,比如机器已经在产线上了。这时候可以用芯片内部的定时器。

// 使用SysTick测量微秒级时间
uint32_t start_tick = SysTick->VAL;
process_banknote_data();
uint32_t end_tick = SysTick->VAL;
uint32_t elapsed = (start_tick - end_tick) / (SystemCoreClock / 1000000);

嗯,这里要注意:SysTick是递减计数的,所以要用start_tick减去end_tick。我刚开始用的时候搞反了,测出来的时间全是负数,查了半天才发现。

3.1.3 避坑指南:测量本身也会消耗时间

我曾经在项目里犯过一个低级错误。为了测量一个中断响应时间,我在中断入口和出口各加了一个GPIO翻转。结果测出来的时间比预期多了2微秒。

后来才意识到,GPIO翻转本身就要花时间。尤其是用库函数操作,那开销可不小。

注意: 测量代码本身也会消耗CPU周期。如果精度要求高,建议用寄存器直接操作,或者用DMA来辅助测量。

3.2 CPU占用率:别让主循环变成死循环

点钞机的主循环,说白了就是一个大while(1)。里面塞了各种任务:电机控制、传感器读取、通信处理、UI刷新。

如果CPU占用率太高,就会出现一个现象:按了停止键,机器要等半秒才反应。这在点钞机上是绝对不能接受的。

3.2.1 用空闲任务统计法

这个方法我用了很多年。原理很简单:在空闲任务里跑一个计数器,统计单位时间内空闲任务执行了多少次。

// 空闲任务中累加计数器
void idle_task(void)
{
    static uint32_t idle_count = 0;
    idle_count++;
    
    // 每秒计算一次CPU占用率
    if(get_system_tick() % 1000 == 0)
    {
        uint32_t total_ticks = 1000000; // 假设每秒有100万个空闲机会
        uint32_t cpu_usage = 100 - (idle_count * 100 / total_ticks);
        printf("CPU占用率: %d%%\n", cpu_usage);
        idle_count = 0;
    }
}

你想想看,如果空闲任务每秒跑了80万次,说明CPU有80%的时间是空闲的,那占用率就是20%。反过来,如果只跑了10万次,那CPU占用率就是90%,得赶紧优化了。

3.2.2 用RTOS自带工具

如果用了FreeRTOS或uCOS,事情就简单多了。这些系统自带CPU占用率统计功能。

// FreeRTOS中获取任务CPU占用率
TaskStatus_t task_status;
vTaskGetInfo(task_handle, &task_status, pdTRUE, eInvalid);
printf("任务%s占用率: %d%%\n", task_status.pcTaskName, 
       task_status.ulRunTimeCounter * 100 / total_run_time);
核心观点: CPU占用率超过70%就要警惕了。点钞机是实时系统,你得留出30%的余量应对突发情况,比如突然塞入一叠钞票。

3.3 内存占用分析:堆栈溢出是隐形杀手

做嵌入式最怕什么?不是功能实现不了,而是程序跑着跑着就挂了。而且挂得毫无规律。

我遇到过最头疼的一个bug:点钞机连续工作8小时后,突然死机。查了三天,最后发现是堆栈溢出。

3.3.1 静态内存分析:看map文件

编译完成后,编译器会生成一个.map文件。这里面记录了每个变量、每个函数的地址和大小。

// 在.map文件中查找关键信息
.bss     0x20000000  0x4A0   // 未初始化数据段大小
.data    0x200004A0  0x200   // 初始化数据段大小
.stack   0x200006A0  0x400   // 栈大小
.heap    0x20000AA0  0x200   // 堆大小

我个人习惯在编译脚本里加一个检查:如果RAM使用率超过80%,就报warning。这样在开发阶段就能发现问题,不用等到上机测试。

3.3.2 动态内存分析:监控malloc/free

点钞机里有些数据是动态分配的,比如钞票图像缓存。如果频繁malloc/free,很容易产生内存碎片。

// 自定义内存分配器,记录分配情况
void* my_malloc(size_t size)
{
    void* ptr = malloc(size);
    total_allocated += size;
    if(total_allocated > MAX_HEAP_SIZE * 0.8)
    {
        printf("警告:堆内存使用超过80%%\n");
    }
    return ptr;
}
警告: 在中断服务函数里不要用malloc。我曾经因为这个导致系统随机死机。中断里应该用静态分配或者内存池。

3.3.3 栈溢出检测:填充魔法数

这是我最推荐的检测方法。在系统初始化时,把栈空间全部填充成0xDEADBEEF。然后定期检查栈底的值有没有被改写。

// 栈溢出检测
#define STACK_FILL_PATTERN 0xDEADBEEF

void stack_monitor(void)
{
    uint32_t* stack_bottom = (uint32_t*)&_estack;
    if(*stack_bottom != STACK_FILL_PATTERN)
    {
        printf("栈溢出!栈底已被改写\n");
        // 触发错误处理
    }
}

嗯,这里要注意:这个检测函数要放在主循环里定期调用,或者放在低优先级的中断里。我一般每100ms检查一次,够用了。

3.4 实战案例:一次内存泄漏的排查过程

我记得有一次,客户反馈点钞机连续工作3小时后,速度越来越慢,最后卡死。

我远程连上去一看,内存占用率从开始的30%,慢慢涨到了95%。典型的泄漏症状。

排查过程是这样的:

  1. 先用内存监控工具,发现每次处理一张钞票,堆内存就增加128字节
  2. 定位到图像处理模块,每次调用都会malloc一个缓冲区
  3. 检查代码,发现有一个异常分支里忘了free
  4. 修复后,连续跑了48小时,内存占用率稳定在35%

说白了,内存泄漏就是“借了不还”。每次malloc都要对应一个free,这个原则一定要守住。

3.5 本章小结

性能度量这件事,说白了就是三个字:量化它。

  • 时间不够精确?用GPIO翻转法,示波器一看就知道
  • CPU跑满了?用空闲任务统计法,找出哪个任务在偷懒
  • 内存泄漏了?用填充魔法数法,让栈溢出无处遁形

下一章,我会讲代码优化的具体技巧。但在此之前,我建议你先把手头的项目跑一遍性能度量。你会发现,很多问题其实早就存在,只是你没去量而已。