傻孩子(GorgonMeducer) · 2022年02月15日

实时性迷思(5)——实战RTOS多任务性能分析

image.png

【说在前面的话】

在本系列前面的文章《实时性迷思(2)——“时间片轮转”的沙子》中,我们详细介绍了实时性的概念、澄清了一些常见的误解并介绍了一种评估实时性任务对CPU资源占用的方法,即:

当前实时性任务所消耗的CPU资源百分比为:
image.png
这里的
image.png
就是“事件n”的CPU资源占用。

如果对细节还不清楚的小伙伴,可以单击这里。这里我们假设你已经对这个公式的基本原理了然于心。

在实践中,我们应该如何利用上述公式评估 RTOS 中一个任务的系统资源占用呢?这里我们就要借助 perf_counter 所提供的专门工具了。

熟悉我公众号文章的小伙伴都知道:perf_counter 是我维护的一个开源项目,致力于在不干扰用户原有SysTick功能的情况下,额外获得一个测量CPU时钟周期的能力,这就是大家很熟悉的运算符 __cycleof__()

/*! demo of __cycleof__() operation */
__cycleof__() {
    printf("Hello world\r\n");
}

该运算符会测量花括号中代码所使用的CPU周期数。当然,如果不习惯这种奇怪的语法,也可以使用 perf_counter 所提供的简单API函数。比如,同样的功能也可以使用下面的代码来实现:

start_cycle_counter(); {
    printf("Hello world\r\n");
}
int32_t cycles = stop_cycle_counter();

简单直观。
image.png

【如何测量一个任务的系统占用】

在RTOS中,假如我们有一个任务:

static void task_a (void *argument)
{
    ...
    while (1) {
        uint32_t wTick = osKernelGetTickCount();
        
        any_workload(); // 我们用这个函数来模拟任意的功能代码
        
        // 该任务尝试以 20 ms(50Hz) 为“稳定”间隔,周期性的执行
        osDelayUntil(wTick + 20);   
    }
}

可以看出,该函数具有以下特点:

  • 只要任务负载所消耗的时间小于20s,则该任务就能以给定的周期稳定的重复执行。这是借助了 osDelayUntil 的特性。
  • 如果该任务具有实时性,则实时性窗口就是任务的重复周期——在这个例子中就是 20ms。

这样的任务在我们的日常应用中非常典型,随手动能举出一堆例子,比如:

  • 以固定时间间隔进行LCD刷新
  • 以固定时间间隔扫描矩阵键盘
  • 以固定时间间隔闪烁LED
  • 以固定时间间隔读取传感器的值
  • ……

如何才能方便的测量它的系统占用率呢?这是个好问题。

熟悉perf_counter的小伙伴也许会立即给出如下的答案:

static void task_a (void *argument)
{
    ...
    while (1) {
        int32_t cycle_used;
        start_cycle_counter();
        
        //! 以下是原来的要测量的代码,我们打包起来不动它们
        {
            uint32_t wTick = osKernelGetTickCount();
        
            any_workload(); // 我们用这个函数来模拟任意的功能代码
        
            // 该任务尝试以 20 ms(50Hz)为“稳定”间隔,周期性的执行
            osDelayUntil(wTick + 20); 
        }
        
        // 读取结果
        cycle_used = stop_cycle_counter();
        
        //! 把 cycle_used转化成毫秒,并保存在 cpu_usage里
        float cpu_usage = (float)cycle_used / (float)(SystemCoreClock / 1000ul);
        //! 计算CPU占用的百分比
        cpu_usage = (cpu_usage / 20.0f) * 100.0f;
    }
}

这段代码的思路总的来说是正确的:

  1. 在一次循环开始的时候,利用 perf_counter 所提供的函数 start_cycle_counter() 来开启CPU性能计数器;
  2. 在循环结束后,通过 stop_cycle_counter() 来读取计数器结果;
  3. 将测量结果转化为毫秒后,与任务周期(实时性窗口)相除,计算出该任务的CPU占用率。

但这里有个小瑕疵:osDelayUntil() 执行期间,当前任务其实是处于挂起状态——RTOS会进行任务调度,在该任务休眠期间执行别的任务——因此,不应该把这一期间的CPU周期数记录到最终结果里。实际上,正是因为这样的原因,上述代码的测量结果将始终是 100%——简直测了个寂寞。

既然知道问题出在哪里,就可以对上述代码进行小小的修改:

static void task_a (void *argument)
{
    ...
    while (1) {
        int32_t cycle_used;
        start_cycle_counter();
        
        //! 以下是原来的要测量的代码,我们打包起来不动它们
        {
            uint32_t wTick = osKernelGetTickCount();
        
            any_workload(); // 我们用这个函数来模拟任意的功能代码
        
            // 读取结果
            cycle_used = stop_cycle_counter();
            
            // 该任务尝试以 20 ms(50Hz)为“稳定”间隔,周期性的执行
            osDelayUntil(wTick + 20); 
        }
        //! 把 cycle_used转化成毫秒,并保存在 cpu_usage里
        float cpu_usage = (float)cycle_used / (float)(SystemCoreClock / 1000ul);
        //! 计算CPU占用的百分比
        cpu_usage = (cpu_usage / 20.0f) * 100.0f;
    }
}

image.png
这还真是头疼医头脚痛医脚啊……看似在osDelayUntil()之前读取结果就能获取 any_workload() 所使用的CPU周期数……但

你不考虑高优先级任务、中断之类在执行循环体的时候发生抢占么?抢占后的CPU周期数也要从测量结果中扣除啊

是啊,RTOS 什么时候进行任务切换,我们也没法掌控啊。这可如何是好?
image.png
别慌,perf_counter 为此专门提供了一个RTOS特供版的API:

start_task_cycle_counter() stop_task_cycle_counter() 来取代原有的版本——它们原理也不复杂,就是在RTOS的调度器加个“摄像头”,只要任务切换走了,就暂停当前任务的计数器,等到任务切换回来以后再重新打开——这就做到了只测量当前任务实际使用的尽周期数

因此,上述代码可以简单的修改为:

static void task_a (void *argument)
{
    init_task_cycle_counter();
    ...
    while (1) {
        int64_t cycle_used;
        start_task_cycle_counter();
        
        //! 以下是原来的要测量的代码,我们打包起来不动它们
        {
            uint32_t wTick = osKernelGetTickCount();
        
            any_workload(); // 我们用这个函数来模拟任意的功能代码
        
            // 该任务尝试以 20 ms(50Hz)为“稳定”间隔,周期性的执行
            osDelayUntil(wTick + 20); 
        }
        
        // 读取结果
        cycle_used = stop_task_cycle_counter();
        
        //! 把 cycle_used转化成毫秒,并保存在 cpu_usage里
        float cpu_usage = (float)cycle_used / (float)(SystemCoreClock / 1000ul);
        //! 计算CPU占用的百分比
        cpu_usage = (cpu_usage / 20.0f) * 100.0f;
    }
}

特别提示:别忘记每个任务一开始的地方用 init_task_cycle_counter() 初始化下当前任务的CPU计数器——为什么要这么做?因为perf_counter为每个任务都独立提供了一个默认的CPU计数器啊。

你以为我要说“完美”了么?原本我也是这么想的,感谢开源社区的甲方爸爸们,为了提了如下的需求:

  • 能不能不需要用户自己提供任务的实时性窗口信息啊
  • 能不能少写点代码啊
  • 能不能自己计算结果啊
  • 能不能计算 n 次的平均结果啊
  • ……

image.png
这是最终完成稿:

static void task_a (void *argument)
{
    init_task_cycle_counter();
    ...
    __super_loop_monitor__(100) {
        uint32_t wTick = osKernelGetTickCount();
        
        any_workload(); // 我们用这个函数来模拟任意的功能代码
        
        // 该任务尝试以 20 ms(50Hz) 为“稳定”间隔,周期性的执行
        osDelayUntil(wTick + 20);   
    }
}

image.png
居然这么优雅?是的!上述代码只是:

  • 在任务开头处加了一个 init_task_cycle_counter() 对当前任务的CPU计数器进行了初始化;
  • 将 while(1) 换成了 __super_loop_monitor__(100)——这里的100表示将100次测量结果进行平均

那结果呢?结果输出在哪里?答案是:

  • 默认情况下,测量结果会通过 printf 打印出来,类似这样:
    image.png
  • 如果你不想使用printf进行打印,而是想读取测量的结果,则可以通过下面的代码来实现:

    static void task_a (void *argument)
    {
    init_task_cycle_counter();
    
    int64_t cycle_used;
    int64_t time_elapsed;
    __super_loop_monitor__(100, {
        cycle_used = __cpu_usage__.lTaskUsedCycles;
        time_elapsed = __cpu_usage__.lTimeElapsed;
    }) {
        uint32_t wTick = osKernelGetTickCount();
        
        any_workload(); // 我们用这个函数来模拟任意的功能代码
        
        // 该任务尝试以 20 ms(50Hz) 为“稳定”间隔,周期性的执行
        osDelayUntil(wTick + 20);   
    }
    }

    这里的语法结构是这样的:

__super_loop_monitor__(<平均多少次输出结果>, 
{
    //任意的代码片断,其中可以通过匿名结构体 __cpu_usage__ 
    // 来读取所需的结果
}
) {
    // 任务的超级循体
}

在前面的例子代码中,其核心是这样的:

    int64_t cycle_used;
    int64_t time_elapsed;
    __super_loop_monitor__(100, 
    {
        cycle_used = __cpu_usage__.lTaskUsedCycles;
        time_elapsed = __cpu_usage__.lTimeElapsed;
    }) {
       ... 
    }
  • 将100次测量所经过的总周期数保存在 time_elapsed 中;
  • 将任务在100次循环过程中实际使用的周期数保存在 cycle_used 中;
  • 借助这两个变量,我们就可以自己计算CPU系统占用啦。

【重要的使用注意事项】

  • 每个任务默认都拥有一个CPU性能计数器,它们彼此独立,互不干扰
  • 不要忘记在任务一开头用 init_task_cycle_counter() 对其进行初始化。
  • super_loop_monitor() 依赖 GNU 扩展,一定要在编译器选项中打开哦。

比如 Arm Compiler 6(armclang)中要把 Language C设置为 gnu99或者 gnu11
image.png
Arm Compiler 5(armcc)中要选择C99GNU extensions选项:
image.png
如果你的MDK中找不到上述两个选项,则说明你的MDK版本太低了,推荐升级。你可以在关注公众号【裸机思维】后发送关键字"MDK"或者通过菜单获取最新的MDK网盘链接。

  • 如果一个任务并非是以稳定的周期进行执行的,而是完全取决于信号量来进行挂起和唤醒的,也可以使用 __super_loop_monitor__() 配合一个较大的基数(比如1000或者10000)来获取一个较为可信的平均结果。
  • 使用 __super_loop_monitor__() 或者 xxxx_task_cycle_counter() 函数所测量的结果是任务的“净”周期数。因此,即便它的值恒定小于实时性窗口,也只能说明任务本身执行时间“有潜力”满足实时性要求,但“根本不足以”证明一个任务的实时性得到了满足——因为“任务净周期”加上抢占或者轮转到其它任务执行所消耗的时间可能就超过实时性窗口要求了。要想测量一个任务是否满足实时性要求,还是要用普通版本的 start_cycle_counter() 、stop_cycle_counter()__cycleof__()之类传统的手段进行测量。

【perf_counter和RTOS补丁的部署】

最后,我们来说说 perf_counter 和对应 RTOS补丁的部署。

首先,perf_counter在MDK中是以 cmsis-pack的形式进行部署的,您可以在关注【裸机思维】公众号后,发送消息“perf_counter” 来获取最新的网盘链接。具体部署细节,可以参考文章《【喂到嘴边了的模块】超级嵌入式系统“性能/时间”工具箱》。

当我们成功安装对应的 cmsis-pack后,打开 RTE 窗口,可以找到到 Utilities 下的 perf_counter模块。将其展开后,勾选Core,并根据你所使用的RTOS种类选择一个对应的补丁:
image.png
可以看到, perf_counter支持了目前市面上比较主流的一些RTOS。目前,对列表中的大部分RTOS来说,勾选对应的patch就完成了对应的支持。

【ThreadX的注意事项】

你需要打开工程配置,跳转到 Asm界面,在Misc Controls中追加如下的命令行选项:

-include "Pre_Include_Global.h"

image.png
此外,目前 perf_counterThreadX补丁仅在 Arm Compiler 6(armclang)下有效,对于Arm Compiler 5(armcc)的用户来说,我尽力了……但很抱歉。

【RTX的注意事项】

在RTX配置中,找到 RTX_Config.h 
image.png
Configuration Wizard界面下:

  • 去除stack overrun checking的勾选
  • 去除stack usage watermark的勾选
  • Processor mode for Thread execution 设置为 Privileged mode(否则会触发hardfault)
    image.png

【其它注意事项】

1、需要特别说明的是:如果你使用的 RTOS 是使用 library 的形式部署的,请切换回 Source形式。

这是由于 Patch 所附着的一些 RTOS调度器函数可能会在 Library中以 inline 的形式存在——无法被我们的Patch附着——只有以Source源代码形式进行编译,Compiler才能意识到不能在这些关键的函数身上耍小聪明。

2、由于 perf_counter 是在每个任务的栈底消耗 48个字节作为CPU计数器,因此请务必关闭RTOS的栈溢出检测功能——因为这 48个字节显然破坏了水印,会导致栈溢出的误判。此外,perf_counter提供了额外的水印,如果你发现48个字节的最后4字节不是 0xDEADBEEF或者0x8492A53C,则说明发生了栈溢出。

3、由于 perf_counter 的API要访问 SysTick寄存器,因此请务必在配置RTOS的任务时,让其工作在特权模式下——如果RTOS任务工作在非特权模式下,任何针对SysTick寄存器的访问都将触发Hardfault。

【说在后面的话】

其实很多RTOS自己就提供了类似的功能,比如FreeRTOSThreadX就是这样,但也有一些RTOS并非如此,比如大家熟悉的 RT-ThreadRTX5

一方面,perf_counter 为上述所有 RTOS 提供了测量功能和统一的API;另一方面,perf_counter 还对这些统计功能做了增强,比如:

  • 很多 RTOS 自带的计数器是32位的,无法在系统高频率的情况下,提供长时间的统计,而 perf_counter 的计数器是64位的;
  • perf_counter允许用户实现跨越多个任务的测量,这对测量一个涉及多个任务的数据流的系统性能消耗非常——限于篇幅,我们将在下一期文章中着重介绍。

perf_counter是一个寄放在Github上的开源项目,虽然小众但已经很多小伙伴的日常工作带来了便利——这让我非常自豪。
https://github.com/GorgonMeducer/perf_counter
image.png

首发:裸机思维
作者:GorgonMeducer 傻孩子

专栏推荐文章

如果你喜欢我的思维,欢迎订阅裸机思维
推荐阅读
关注数
1466
内容数
108
探讨嵌入式系统开发的相关思维、方法、技巧。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息