傻孩子(GorgonMeducer) · 2021年01月11日

【嵌入式秘术】相约榨干SysTick的每一滴汁水

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

image.png

【说在前面的话】


相信很多人都遇到过这样的情况:在一个Cortex-M嵌入式应用中要实现一个精确的毫秒级延时并不困难——如果你有RTOS,在任务中使用诸如 os\_sleep(<休眠时间>) 之类的函数就可以轻松实现;如果你是裸机,也可以使用每个Cortex-M芯片都默认携带的_SysTick_来实现一个,甚至Arm官方的CMSIS都提供了现成的API,即_SysTick\_Config(<中断间隔的时钟周期数>)_:


static volatile uint32_t s_wMSCounter = 0;

extern uint32_t SystemCoreClock;
/*! \brief initialise platform before main()
 */
__attribute__((constructor(101)))
void platform_init(void)
{
    SystemCoreClockUpdate();
    /* Generate interrupt each 1 ms  */
    SysTick_Config(SystemCoreClock / 1000);    
}

__attribute__((weak))
void systimer_1ms_handler(void)
{
    /* default systimer 1ms hander 
     * you can override it by implement a non-weak version
     */
}

void SysTick_Handler (void) 
{
    if (s_wMSCounter) {
        s_wMSCounter--;
    }

    systimer_1ms_handler();
}

void delay_ms(uint32_t wMillisecond)
{
    s_wMSCounter = wMillisecond;
    while( s_wMSCounter > 0 );
}

上述代码非常典型,唯一需要强调的是_SystemCoreClock_是一个定义在启动文件_system\_<芯片型号>.c_ 里的全局变量,负责保存当前处理器的工作频率——上面的平台初始化函数 platform\_init()_ 就是借助这一变量把 _SysTick 初始化为以“1ms为间隔产生中断”的。

如果要实现一个微秒级延时却并不那么一帆风顺。首先,不同人关于实现方案就有不同的想法,比如:

  • 有的人习惯于直接用软件方法堆积_NOP()_来实现——这种方法所产生的延时效果“可能”容易受到编译器优化等级的影响——据说这也是很多人惧怕开启编译器的原因之一,因为一开优化,很多对时间敏感的硬件时序就因为延时函数的不稳定而一起变得不可捉摸;

extern uint32_t SystemCoreClock;

#ifndef DELAY_US_CALIBRATION
/*! \brief 不要问我为啥是 8, 我也不知道,但在当前这个工程下,8貌似最准
 *!        你如果不服,就自己测一个,然后定义这个宏……
 *!        如果你头铁改了工程的优化等级,请也无比亲自测一下……具体怎么
 *!        测,我也不知道。如果你也怕麻烦,就不要改优化等级。
 */
#    define DELAY_US_CALIBRATION    8
#endif

void delay_us(uint32_t wUS)
{
    //! calcluate how many cycles required for 1us
    uint32_t wCyclesPerUS = SystemCoreClock / 1000000ul;
    
    /*! subtract some cycles from wCyclesPerUS based on the 
     *! experience or actual measurement in current optimisation
     */
    wCyclesPerUS -= DELAY_US_CALIBRATION;

    for (int i = 0; i < wUS; i++) {
        for (j = 0; j < wCyclesPerUS; j++) {
            __NOP();
        }
    }
}
  • 有的人提倡使用定时器来实现精确延时,这一方案显然不太惧怕编译器优化的“血腥巨斧”。想法是没错的,但如果要保证这样写出来的延时库有一定的可移植性,就需要保证 delay\_us() 函数实现所依赖的硬件定时器是“通用的”和“普遍存在”的——符合这一要求的第一选择是SysTick——然而既然SysTick已经被 delay\_ms() 占用了,又如何能抽的开身呢?

既然 SysTick 被占用了,那有没有别的符合要求的硬件呢?如果不算Cortex-M0/M0+的话,从某种程度上说还真有——_DWT_。这是一个系统外设,专门用来为Cortex-M3及其以上芯片提供调试和追踪的硬件辅助功能。在【裸机思维】往期转载的文章中,就有使用DWT实现延时的内容。这个方法好是好,但缺点也是非常突出的:

  • DWT 根本就不是设计给用户用的,它是Cortex-M处理器预留给上位机调试软件(例如MDK)进行调试和追踪的。换句话说,上位机调试软件觉得这是自己的私人财产,从来没想过用户会去使用它——这就导致调试过程中,IDE会按照自己的意思随意修改它的配置——啥时候会改呢?这要看IDE的心情。如果你的程序依赖了_DWT_进行延时,那么调试的时候,IDE的一个无心之举可能就会毁了你的时序——这一知识点非常容易忽略掉,从而导致很多人遇到调试的时候,系统随机性的功能不正常的坑,从而浪费大把的时间,往往还想不到是DWT导致的——说这一方法是天坑可能一点也不为过
  • DWT 不是所有 Cortex-M 芯片都有……(Cortex-M0/M0+就没有)

既然 SysTick 被占用、_DWT _又是天坑,是不是意味着我们就只能使用芯片的普通定时器了?——这每个厂家都不一样……每个应用对定时器的使用情况也都不同,那我还怎么做通用的延时库啊?

别急,今天我们就来介绍一种在完全不影响 SysTick 已有功能的前提下,继续把它榨干——提供更多功能的方法。为了避免误解,我把这种方法的目标需求列举如下:

  • 提供一个精确的 delay\_us() 函数;
  • 提供一个精确测量任意代码块所实际占用系统周期数的方法;
  • 实现一个记录从进入 main() 函数以来总共经历了多少个时钟周期(且在合理的时间范围内不会溢出)的计数器(时间戳);
  • 用户已有的 SysTick 功能不能受到干扰
  • 比如用户使用 SysTick 作为RTOS的基准时钟(非Tickless模式);
  • 比如用户使用 SysTick 作为普通的毫秒级延时(就像前面例子代码所展示的那样);
  • 用户不需要修改自己任何已有的 SysTick 代码

【部署 perf\_counter 库】


要实现上述功能,可以直接借助一个叫做 perf\_counter 的库,这是我基于这几年在代码性能分析中总结出来的,我已经把它放在 github 上进行开源,其地址为:https://github.com/GorgonMedu...\_counter

这个库目前支持 Arm Compiler 5(armcc) 和 Arm Compiler 6(armclang)。它不仅提供了源代码,还提供了编译好的 library (.lib)可供全系列Cortex-M处理器使用。

第一步,下载最新的release:

image.png

第二步,将库加入到已有的 MDK 工程中:

image.png

**第三步:编译并调整一些工程选项
**
image.png

好,下面让我们来谈谈你可能遇到的问题,以及对应的解决方案:

问题一:提示找不到 $Super$$SysTick\_Handler

image.png

.\Out\example.axf: Error: L6218E: Undefined symbol $Super$$SysTick_Handler (referred from systick_wrapper_ual.o).

image.png

perf\_counter 库是一个“附加型”库——它假设你自己已经实现了一个SysTick的中断处理程序,并开启了中断模式——如果你没有,直接加一个空的就好了:

void SysTick_Handler (void) 
{
}

好,问题解决。什么?你的工程也根本没有用_SysTick_?好办,请在进入main后调用函数_init\_cycle\_counter() _并传递false,例如:


int main(void)
{
    ...
    init_cycle_counter(false);
    ...
}

这样做的目的是告诉 perf\_counter:“请自己玩的开心”。

问题二:wchar和enum的尺寸不兼容:


需要强调的是,_perf\_counter.lib_ 库在编译的时候,开启了 _Short enums/wchar_(分别对应命令行的 _-fshort-enums -fshort-wchar_)。这么做其实没什么特别的原因,但如果你的工程使用了不同的配置,例如:

image.png

你一定会看到这样的编译错误:

image.png

.\Out\example.axf: Error: L6242E: Cannot link object perf_counter.o as its attributes are incompatible with the image attributes.

既然知道了原因,解决方法就很简单,要么在工程配置中勾选上这一选项;要么使用源代码编译(不使用lib):

image.png

也就是图中所示的:_perf\_counter.c_ 和 _systick\_wrapper\_ual.s_。


    perf\_counter.c 依赖了 _CMSIS_,所以确保你的工程中加入了对_CMSIS_的支持——推荐的是使用MDK自带的 _CMSIS_,在RTE配置界面中勾选:

image.png

如果你使用的是工程自带的_CMSIS_(很多STM32工程就是这样),请确保你的_CMSIS _是较新的版本(判断标准就是是否带有 _cmsis\_compiler.h_)。

此外,这里的 systick\_wrapper\_ual.s 是一个汇编源程序,使用的是Arm的老语法(Unified Assembly Language),如果你的工程使用的是 Arm Compiler 5(armcc),这里就没什么需要特别注意的了;如果你的工程使用的是 Arm Compiler 6(armclang),则你需要检查工程配置,以确保MDK能正确的选择对应的Assembler

image.png

注意这里的 Assembler Option,根据你MDK版本的不同,它可能有以下几个有效选项:

  • armclang(Auto Select)——我吐血推荐选这个
  • armclang(GNU Syntax)—— 这个意思就是使用 GNU的汇编语法,显然不能选它;
  • armclang(Arm Syntax)——这是最新MDK(从5.32开始)才有的选项,选了也行;
  • armasm(Arm Syntax)——这就是 Arm Compiler 5里一直使用的老汇编器,选他当然兼容性最好。

做好了以上两个准备工作,编译应该就很顺利了。是不是觉得有点头大?头大就用 .lib 啊……完全不用经历这些痛苦


至此,我们完成了 perf\_counter 库在工程中的部署。那么它带给我们哪些功能呢?

【SysTick第一吃:微秒级精确延时】



#include "perf_counter.h"

...
delay_us(30);    //!< delay 30 us
...

**再也不用担心编译器优化导致延时不准啦!!!
**

再也不担心库不通用啦!!!

再也不用担心芯片不支持DWT啦!!!!!!

再也不用担心调试/追踪会干扰DWT啦!!!!

【SysTick第二吃:精确测量代码的时钟周期】


perf\_counter.h 提供了两个函数,用于精确测量任意代码片段所消耗的CPU时钟周期数(不是us数哦):


extern void start_cycle_counter(void);
extern int32_t stop_cycle_counter(void);

它们的使用非常简单直接,例如:


start_cycle_counter();
//! 测量 打印  "Hello World\r\n" 究竟用了多少个时钟周期
printf("Hello World! \r\n");
int32_t iCycleUsed = stop_cycle_counter(void);
printf("Cycle Used: %d", iCycleUsed);

当然,如果你的工程环境允许你用printf的话,还可以用 perf\_counter.h 自带的宏将上述代码简化一下:


//! the demo of __cycleof__()
__cycleof__() {
    printf("Hello World\r\n");
}

其运行结果为:
image.png

(以上结果为FVP仿真结果,CPU周期数值不可以做参考)

我们甚至还可以添加一点注释性的字符串,帮助我们区分测试的范围:

//! the demo of __cycleof__()
__cycleof__("Print string") {
    printf("Hello World\r\n");
}

image.png

我们看到,传递给\_\_cycleof\_\_的提示字符串"Print string"被添加到了"total cycle count:..." 的前面,一目了然。

实际上,_start\_cycle\_counter()_ 和 stop\_cycle\_counter() 的组合还可以用来测量中断处理程序实际使用的系统周期数——读过我【实时性迷思】系列文章的小伙伴,一定知道测量“事件处理函数所需时间”的意义:

volatile int32_t g_nMaxHandlingCycles = 0;

void USART0_RX_Handler(void)
{
    start_cycle_counter();
    
    //! 你的USART0 接收中断处理程序实际内容
    ...
    
    int32_t nCycles = stop_cycle_counter();
    g_nMaxHandlingCycles = MAX(nCycles, g_nMaxHandlingCycles);
}

从此一举告别“拍脑袋凭感觉”说中断处理时间要多长的旧世界。
image.png

此外,_start\_cycle\_counter()_ 和 stop\_cycle\_counter() 还支持类似体育老师所使用的秒表的功能,即,起跑后、可以分别记录每一个学生所用的时间。具体表现为:

int32_t nCycles = 0;
start_cycle_counter();           //!< 开始总计时
...
nCycles = stop_cycle_counter();  //!< 第一次获取从开始以来的时间
...
nCycles = stop_cycle_counter();  //!< 第二次获取从开始以来的时间
...
nCycles = stop_cycle_counter();  //!< 第三次获取从开始以来的时间
...

具体什么情况下要用到这样的方式就见仁见智了,这里就不再继续展开。

最后,需要强调一下,虽然 start\_cycle\_counter()stop\_cycle\_counter()startstop 的字样,但这只是逻辑上的,并不会真正的干扰 SysTick 的功能(也就是不会开启或者关闭 _SysTick_)。这也是这个库敢于声称自己不会影响用户已有的 SysTick 功能的原因。

【SysTick第三吃:系统时间戳】


阅读到这里,聪明的你一定已经发现了:无论是 perf\_counter_(performance counter)库名的明示,还是 _start\_cycle\_counter()stop\_cycle\_counter() 的强大功能,都暗示其实这个库应该不是专门用来提供微秒延时函数 delay\_us() 的,实际上,只要你稍微看一眼源代码就会发现上述猜想完全没错—— delay\_us() 其实才是附赠的:


void delay_us(int32_t iUs)
{
    iUs *= SystemCoreClock / 1000000ul;
    
    start_cycle_counter();
    while(stop_cycle_counter() < iUs);
}

看到真相的你,有没有意识到,在 start\_cycle\_counter()stop\_cycle\_counter() 之间不能调用 delay\_us() 呢?


既然 delay\_us() 都是“cycle counter”送的,还有啥别的功能是附赠的么?——还真有:系统时间戳。想象一下,既然 start\_cycle\_counter()stop\_cycle\_counter() 的组合可以获得从开始以来的时间,那么如果我在进入_main()_之前就执行 start\_cycle\_counter() ,然后在需要的时候调用 stop\_cycle\_counter() 是不是就可以获取“从_main()_开始已经执行了多少个周期”的系统时间戳呢?

Bingo!答对了,原理上就是这样,只不过实际上,为了保留 start\_cycle\_counter()stop\_cycle\_counter() 给用户使用,_per\_counter_库就自己独立实现了对应的逻辑——用户可以通过调用函数 _clock()_ 来获取这一信息:

#ifdef __PERF_CNT_USE_LONG_CLOCK__

熟悉系统库 <time.h>朋友可能会立即产生疑问,真正的定义不应该是这样的么:


#ifdef __PERF_CNT_USE_LONG_CLOCK__
__attribute__((nothrow)) 
extern int64_t clock(void);
#endif

clock\_t 在 Cortex-M环境下定义如下:

typedef unsigned int clock_t;    /* cpu time type */

为什么_perf\_counter.h_ 要采用不一样的定义呢?

说起来也简单:clock() 函数返回的是系统周期数,而不是什么以 us 或者 ms 为单位的时间——考虑到现在处理器频率动辄几百兆赫兹,有的甚至达到了1GHz(比如 NXP的RT系列),如果用 int32\_t (哪怕用 uint32\_t)也撑不了几秒钟。


假设系统频率为1GHz,使用 uint32\_t 来计数,由于32bit整数取值范围是0~4G,因此,最多4秒就撑不住了……


那究竟多长才够呢?

当我们使用 int64\_t 的时候,哪怕系统频率是 4GHz,2G 秒 ≈ 24855 天 ≈ 68年。虽然没有一万年那么久,不过多半一个嵌入式设备也没法用这么久(千年虫警告),但考虑到大部分Cortex-M嵌入式系统估计没有4GHz这么夸张,轻松跑个1000多年不溢出应该是没有问题的

既然我们铁了心要用 int64\_t 来取代 clock\_t 原本的 _int32\_t_,怎么解决这里的冲突呢?——显然去修改系统头文件 <time.h>是不允许的

翻开Arm的隐藏宝典:_AAPCS_,我们发现以下的规则:

32位系统下,

  • 如果函数的返回值其大小不超过_32bit_,则保存在寄存器 _r0_中;
  • 如果函数的返回值其大小为_64bit_,则其低 32bit 保存在 _r0_中、高_32bit_保存在 _r1_中。

显然,当我们实现_clock()_函数时返回 int64\_t_的值与 返回 _int32\_t_其实是兼容的——因为低_32bit_的内容实际上都是保存在 _r0 里的,此时如果用户调用_clock()_ 的时候:

  • 使用的是_<time.h>_里定义的函数原型,即  _clock\_t clock(void)_,则,当函数返回时,_r1_里保存的值会被无视,只有_r0_里的值被视作返回值;
  • 使用的是我们自己定义的函数原型,即 int64\_t clock(void)_,则你可以获得完整的 _int64\_t 时间戳。

既然原理清楚了,再看 perf\_counter.h 里面的定义,我们会发现_clock()_的函数原型被一个宏_\_\_PERF\_CNT\_USE\_LONG\_CLOCK\_\__ 保护着:


#ifdef __PERF_CNT_USE_LONG_CLOCK__
__attribute__((nothrow)) 
extern int64_t clock(void);
#endif

这实际上是告诉我们,如果我们想获得 int64\_t 时间戳时,只要在工程中定义宏 _ \_\_PERF\_CNT\_USE\_LONG\_CLOCK\_\_ _就可以了。
image.png

忙活了半天,有的小伙伴可能会疑惑了:饶了这么一大圈,_clock()_ 究竟有啥用处呢?这玩法就多了,快一键三联~ 下次我们好好来说说。

【后记】


perf\_counter_(https://github.com/GorgonMedu...\_counter)是我在工作中总结和整理出的一个库,它的特点是在不干扰已有 _SysTick 功能的前提下额外为我们提供系统周期测量的功能——并在这基础上衍生出了 delay\_us() 和 系统时间戳的功能——正可谓一鸭三吃,把SysTick榨干到了极致。

perf\_counter 库的原理其实很简单,但其中要处理的 corner case 确实很恼人,我也是历经一年多才真正想明白这里面的弯弯绕。后面如果阅读量不错的话,我会考虑专门出一篇介绍 perf\_counter 原理的文章。其中,关于如何“不影响现有_SysTick_中断处理程序”的功能,已经在之前的文章《【嵌入式秘术】手把手教你如何劫持RTOS》中进行了详细介绍,有兴趣的小伙伴可以再回味回味。

在开源的过程中,为了简化用户的使用,我做了如下的优化:

  • 在 Arm Compiler 5(armcc)和 Arm Compiler 6中,不需要用户手工对库进行初始化——库会在进入_main()_之前“自己做”;
  • _Lib_中的_perf\_counter.lib_适用于包含_Cortex-M0_在内的全系列Cortex-M处理器,做到全覆盖;
  • perf\_counter.h 几乎不依赖 _<stdint.h>_和_<stdbool.h>_之外的库。使用.lib进行部署,非常简洁方便。
    • *

如果你要用 safe\_atom\_code(),则需要 \_\_disable\_irq() 和 \_\_set\_PRIMASK() 的定义,一般Cortex-M工程都有。这些定义是由 CMSIS提供的。一般来说,普通的 perf\_counter 功能并不需要涉及任何这些内容。


_perf\_counter_库的使用当然也存在限制,重要的事情在最后说:

  • 如果你原本的 RTOS 使用了 _SysTick_并开启了_Tickless_模式,_perf\_counter_虽然不会干扰原有的 _SysTick_功能,但自己的计时功能却会受到 _Tickless_模式的干扰;
  • _perf\_counter_库假设你原本的_SysTick_应用会保持一个固定的定时周期——也就是 _LOAD_寄存器的内容是固定的、不会随着程序的执行而经常变化。其实_RTOS_的_tickless_模式会干扰_perf\_counter_的计数可靠性也是这个原因。

一般来说,大部分RTOS和普通的周期性定时功能都不会经常动态的去改变SysTick的计数周期,所以不必太担心。


专栏推荐文章

如果你喜欢我的思维,欢迎订阅裸机思维
版权归裸机思维(傻孩子图书工作室旗下公众号)所有,
所有内容原创,严禁任何形式的转载。
推荐阅读
关注数
1466
内容数
108
探讨嵌入式系统开发的相关思维、方法、技巧。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息