查看原文
其他

最强干货!延时功能进化论(适用于单片机和任何延时平台)

嵌入式ARM 2021-01-31

Editor's Note

来自鱼鹰的一篇进阶万字干货,值得收藏。

The following article is from 鱼鹰谈单片机 Author 鱼鹰Osprey

最强干货,不仅适用于单片机应用场合,其他任何需要延时的平台都可以借鉴参考!
在这篇长达万字的长文中,鱼鹰将通过延时这种刚需功能聊聊溢出、可重入、编程思想、共享变量保护等方面内容,以延时功能为载体,能更好的理解这些缥缈的知识点。
本篇长文将分成五篇陆续发布:概述、V1.0~V1.5、V1.7、V2.0~V2.3、V2.5~V2.7。版本V3.x留给对本公众号发展有帮助的道友,后台回复关键字延时即可获取。
在生活中,时间与我们的生活息息相关,日出而作,日落而息,说的就是利用太阳来大概判断时间,从而规划自己的作息。而在单片机领域,同样需要一个时间去控制你的代码运行情况。
玩单片机的应该都了解过晶振,很多初学者可能会从前辈那里得到这样一个比喻:单片机的心脏。从功能的角度来说确实如此,因为单片机代码确实是依靠晶振执行的,比如说晶振输出一个脉冲,CPU执行一条指令,就像心脏跳动一次,你做一个抬手动作。只不过你的心脏可能1秒只能跳动几十次,而晶振1秒输出脉冲几十兆赫兹(1 MHz = 1,000 KHz = 1000,000 Hz),而一般单片机还会将晶振输出的频率进行倍频,倍频后的频率才最终用于驱动CPU的运行。
如果说现实生活中,时间的最小单位是秒(应该没人在生活和工作中去精确到毫秒吧,更多可能是分钟),而在单片机领域,常用的时间单位应该是毫秒、微秒、纳秒,而决定时间精度的就是晶振(更准确的说是经过晶振分频、倍频后的系统时钟,比如说 STM32 的 8 M 晶振分频成 1 M,然后倍频成 72 M 作为系统时钟驱动 CPU,进而执行存储器中的代码)。
对于应用开发来说,可能他不会去了解晶振频率多少,系统频率多少,一条指令运行时间又是多少?他们更多的需求是,在多少毫秒、多少秒(微秒很少用)之后这段代码执行一遍,这个界面刷新一遍,然后以这个时间为周期,循环执行。这是利用延时功能去达到特定代码周期执行的目的。
延时可分为相对延时和绝对延时,相对延时与绝对延时的差别可看下图理解:
(在后面介绍的几种延时演进版本中,可自行思考采用何种方式延时)
本篇笔记将根据鱼鹰多年编程经验介绍自己如何实现延时功能。说是进化论,不如说是鱼鹰个人延时功能的使用演进过程。
说到延时函数,51单片机过来的道友脑中应该会想起郭天祥老师视频中的延时函数,而使用原子例程的道友会想起例程中使用systick定时器实现的延时功能,尽管他们的实现方式有所不同,但他们都是采用死等的方式达到延时功能(所谓死等,有个比喻鱼鹰觉得挺恰当的:驴拉磨,让CPU一直在一个地方打转,时间到了就离开这个地方)。
死等方式确实很容易理解,也很容易实现,但是它的弊病也很明显,不说它极大的浪费了CPU的资源(在延时过程中,除了能处理中断,后面的代码无法处理),更重要的是影响的代码的执行效果,比如说你有一个功能时需要20毫秒执行一次,而你的另一个功能却需要30毫秒执行,那你如何采用死等方式处理其中的矛盾呢?
既然是进化论,为了笔记的完整性,鱼鹰将根据自己使用过的延时函数进行一一说明。

01.延时实现V1.x:死等延时


延时实现V1.0:
鱼鹰首先使用的延时实现就是上面所说的死等方式:
类似上面这种CPU运行在一个循环中,直到达到条件后离开该函数,从而达到延时功能。
这种实现方式只要对C语言有所了解,很容易理解,但是在使用过程中,你会发现延时并不准确,或者说可能在这个单片机里面,延时很准,移植到另一个单片机可能一点就不准了,所以为了达到准确的延时功能,必须借助示波器等仪器调整参数以确定真正的延时时间。
并且还有一种情况就是,如果这个函数被中断后再执行,那么你的延时将不再准确!这个问题留在V1.5版本讨论。
当然如果你使用KEIL开发,鱼鹰还可以告诉你另一个确定时间的方法:
这个时间在参数设置正确的情况下还是非常准的(M3内核以上,在线或者模拟,右下角时间可清零用于重新计时),当然你还是得确定这个时间和现实时间的换算关系(当你把下面的参数根据实际情况设置正确后,它们的换算关系是1:1,这个可以通过示波器确定),当你了解了换算关系,那么你就可以脱离示波器,来知道某些代码的运行时间(精度可达每条指令运行时间,这个时间实际上用的是DWT,这个后面说)。

与上述方式类似的实现是采用nop指令(空指令,即除了浪费CPU没有任何功能的指令),但鱼鹰不建议使用nop指令实现微秒级别以上的延时,因为鱼鹰不觉得nop指令能比上述实现方式优势更大:
1、都要采用某种方式确定实际延时时间(比如示波器,当然还有鱼鹰上面的方式,通过对比示波器你会发现,准确到让你怀疑人生!);
2、当优化级别提高时,有可能出现延时不一致的情况;
有人会说可以通过nop指令的执行时间,进而确定延时时间,但是又引入以下问题:
1、去哪查找nop指令运行时间?上网,没错,但好像这种资料比较难找,就算你找到了你怎么就能确定这个资料是对的,你还是需要通过示波器(还有上述方式,再次强调!)之类的确定。
2、不同平台下的nop指令执行时间不一致,比如M3内核和M4内核nop指令执行周期不一致,即使相同平台下,如果哪天心血来潮,改变了系统时钟频率,那么参数你还是得重新确定。
3、nop汇编移植性不是很好,单片机中,大多数时候采用C语言编写,你要在C中嵌入汇编需要折腾一下。
以上就是鱼鹰不建议采用nop指令的原因,既然能用C语言解决的,干嘛需要汇编指令,这种方式并没有比上述代码有更多优势,反而缺点不少。
那么真的如上面所说,nop指令没有一点优势吗?
有,精确延时(这里的精确延时指的是微小延时情况下的精确)!当芯片手册告诉你某些功能需要延时多少个系统时钟周期时,采用nop指令无疑是最好的方式,因为这是能达到最小延时(指不会额外浪费CPU)的最佳方式,这样你也不用考虑进入、退出函数时额外消耗的时间了(当延时足够长时,进入、退出函数消耗的时间可以忽略不计,而延时很短情况下需要考虑)。
延时实现V1.5
前面介绍了版本1.0有一个很大的弊端,那就是在更改优化级别的情况下,可能影响延时效果。所以有必要找到更好的方式去实现延时效果。
事实上,鱼鹰在很长时间都是采用V1.0进行延时的,比如流水灯、按键消抖、数码管显示等。直到看到正点原子的延时函数:
注释很清晰,简单来讲就是设置一个初始值,然后由硬件负责递减这个值,当减到零后设置标志位,循环中只要不停地查询这个标志位是否置位即可,一旦置位,即代表延时时间达到了。
通过代码和注释可以知道,最大延时时间1864毫秒,1秒多点,对于单片机来说,时间很长了。
现在我们来分析这种实现方式的优势:
1、延时时间相对精确,也就是说,只要配置正确,精度可达systick时钟精度(当然如果延时在微秒级别时,误差较大,除了进出函数消耗外,还有循环体外语句和判断语句的执行时间,这些很难避免)。
2、即使代码采用最高级别进行优化编译,对于毫秒级别的延时影响也非常小。
3、即使在延时过程中被中断了(裸机环境下被硬件中断,系统环境下被硬件和其他任务中断),延时时间在绝大多数情况下是准确的,但是 V1.0版本采用纯软件的方式总是将被中断的时间包含在延时时间内
虽说软件版或硬件版都可能存在延时不准确的问题,但事实上在软件中多延时几个毫秒是没有多大问题的(可通过关中断确保延时准确),所以这种超过延时的情况不必太纠结,但是如果你的延时函数的延时时间可能比需要延时时间更短,那么就要引起注意了!
分析了好处,咱们说说缺点:
1、就像上面所说的,延时时间最大1秒多点,对于有些需求来说,延时有点短了(有些人可能会说这是鸡蛋里挑骨头,谁没事延时那么久,就算需要延时很久,多延时几次就行了,嗯,算你过关)。
2、占用systick时钟。用过嵌入式系统的都知道,大多数操作系统都会采用systick作为系统的心跳时钟,也就是说,如果将来你的裸机代码需要移植到系统中执行,必须重新实现延时功能(可能你会说,我就在裸机上开发,不上系统行不行,OK)。
3、函数非可重入!这一点很多人可能都没有意识到,在写这段话之前,鱼鹰也没有意识到(延时这么简单的功能,谁会想那么多,鱼鹰亦是如此。但鱼鹰在思考它的缺点时,也以为在裸机环境下不需要考虑可重入和不可重入问题,因为裸机就一个大循环,肯定顺序执行,也就不需要考虑这种问题(为什么顺序执行就不需要考虑可重入问题?),但是却突然想到了硬件中断可打断主循环的情况,而在中断中执行微秒级别的延时是有可能的)。
当你在主循环中延时秒级别时,突然中断来了,开始延时秒级别的代码,那么必然修改systick寄存器,导致返回主循环时快速退出延时,最终达不到预期的延时效果!这是很可怕的事情,比如模拟I2C通信时,出现了这种情况……
通过上述分析,你应该知道使用这种实现方式有多大风险了吧!
那么该如何改进呢?
延时实现V1.7
(事实上以下实现方式应该是鱼鹰在使用V2.7版本很久后才采用的方式,但因为内容的相关性,换个顺序介绍)
是不是很简单,简单到让你怀疑它的功能!
这里不再使用systick,而是使用DWT(关于这个模块,鱼鹰后期可能专门写一个小节介绍它,欢迎关注鱼鹰谈单片机),为什么使用它呢?
1、不占用操作系统的心跳时钟;
2、精度非常高,系统时钟的精度,也就是说,即使你多执行了一条指令,它也能发现!
3、延时足够长,168 M频率下可延时 25 秒多(0xFFFF FFFF / 168),这对于大多数需求都足够了,只要你的执行周期或者延时时间在此之内的都没有问题。
4、属于不用白不用的资源(cortex-m3、m4都有这个模块,像鱼鹰这么节约的人,肯定要用上的)。
采用上述实现方式有什么好处:
1、解决了可重入问题。
2、函数内尽可能的减少不必要的语句执行时间。
3、精度高,延时长(采用DWT的优势,而不是实现方式的优势)。
4、因为由硬件修改寄存器,所以可用于任何中断中,即使这个中断优先级非常高(有些时候,如果时间寄存器由软件修改,那么比它高的中断就不能正常运行)。但不建议在中断中延时毫秒级别以上的延时,微秒级别可以考虑。
现在我们来看看这段代码如何实现:
微秒和168相乘,是为了换算168M主频下的延时时钟数(1 MHz = 1 us)。
接着获取当前时钟,作为开始计时的时刻,最后当前时间(由硬件更改该值)与计时时刻比较,当发现时间增加到大于延时时钟数时,即可跳出循环,此时即达到了延时目的。
以上代码似乎不难理解,但是有经验的道友可能会问,你的变量大小是有限的(在这里是4字节),你不怕溢出吗?溢出了之后,计时时刻可能会比当前时刻更大,那么使用减法会不会有问题?
在大一的时候鱼鹰就在思考这个问题了,两个无符号的数相减,如果前者比后者小,会发生什么问题?这样是否就达不到准确延时的目的了?是否需要考虑溢出的情况?
有人会说这只需要一个判断语句就能轻松搞定了,当前者比后者小的时候特殊处理即可,比如4和5相减,特殊处理即可。
但鱼鹰一直觉得应该有一种比较好的方式去解决,直到大四实习看到 FIFO 的源码,鱼鹰才豁然开朗,终于找到了(对 FIFO 感兴趣的可以去看鱼鹰的另一篇笔记,很详细的介绍了一个非常有意思的公式,而鱼鹰也在那篇笔记中说到可以利用这种方式去做延时,只是里面写的有点bug,事实上不算bug,只是有种脱裤子放屁的感觉,各位道友可以去看看当时的实现方式,而对于 FIFO, 鱼鹰目前也有了更多的经验,发现那篇笔记的实现方式采用 % 取余运算效率较低,还有一种更高效的方式,而且建议能用判断语句,就别用 % 处理)!
那么目前这种看似没有处理溢出的方式是否真的能够适应溢出的情况,答案是肯定的,那么原理何在?
我们经常可以看到钟表(非数字手表),指针从1一直转到12,然后又从1开始,周而复始。当其从1转回到1时,即经历了12个小时,但是如果你在超过半天时间后来查看钟表时,虽然你之前看到的是1,现在看到的还是1,但实际上已经过去了24小时了!
同理,计算机的世界亦是如此,如果说一个字节的最大值是255,那么这里的255就是钟表里的12,字节溢出后变为0,而钟表溢出后就是1,这种特性是由计算机和钟表本身决定的,不随外界变化而变化,当我们能够利用这种特性是,你会发现能简化很多东西(这个函数需要靠自己去悟,别人很难说清)。
DWT计数器变量大小为4个字节,也就是说最大值为0xFFFF FFFF,那么我们来思考以下几个问题:
1、这个函数的最大延时是多少?
2、这个函数的使用是否真的没有一点隐患?
3、它凭什么是可重入函数?
4、是否适用于所有定时器?
第一个问题,最大延时,前面鱼鹰已经计算过了,25秒多,那么精确的时间是多少呢?
(0xFFFF FFFF / 168) us,那么为什么不是((0xFFFF FFFF + 1)/ 168) us?这个留给道友去思考。
第二个问题,使用隐患问题,这个问题其实在说明钟表例子时已经说明了,如果你在超过它最大延时的时候再回来查询这个值,你会发现最终延时远远超出了。事实上,你的延时函数不可能被打断25秒多(如果真的打断这么长时间,你就要好好考虑了),但是你不得不考虑这个问题,因为你在下次使用过这种方式时,你不能确定是否真的能使用DWT这种超级延时外设,有可能你的最大延时是1 毫秒(比如一个定时器被你设定1毫秒溢出),那么你的延时函数被打断 1 毫秒后再回来执行是很可能的,所以你除了要考虑它的最大延时时间,还要考虑它最大被打断时间(DWT不用考虑这么多,因为25秒对于单片机来说实在是太长了)。
事实上,除了这个隐患还有另一个,这个留在V2.7版本讨论。
第三、所谓可重入,简单来说,就是这个函数能否当成两个函数执行,而不影响他们的功能,更实际一点的话就是,当在主函数和中断函数同时调用(注意用词,执行不恰当)这个函数时是否会造成功能紊乱(在这里表现为延时不准)。在这个函数中,这里的共享资源是DWT,按理说共享资源都需要进行保护,这样才能可重入,但是因为这个函数只对DWT进行读取操作,而不进行写操作(写操作由硬件自动完成),所以不存在修改共享资源的情况,也就是它可重入的原因,那么为什么那些参数、局部变量也是可重入的?这个基础扎实的话应该能懂,如果不懂就在评论区留言好了。
第四个问题,思想可适用于所有定时器,但注意只是思想,当你的定时器是递减的,比如systick,那么需要做一点点修改,谨记(当然168这个数也得正确设置,如果不知道怎么修改,可留言)!
事实上这种实现方式鱼鹰最近(2020-01)在安富莱电子(强烈建议工作的道友参考安富莱例程和文档,因为这些代码应该都是由一位大佬写的,非常专业,而正点原子更适合初学者)和RT-Thread文档中都有看到相关描述,只是在此之前鱼鹰并没有看到过类似代码,而是由FIFO源码深入思考受到启发,进而实现了以上代码。而当看到安富莱延时实现时才发现,实现代码惊人相似(实际上在此之前鱼鹰一直采用非死等方式实现延时的,但后来发现死等方式也很有必要,比如模拟I2C总线,所以利用非死等V2.7的实现思想实现了死等)。


02延时实现V2.x:非死等延时


延时实现V2.0
前面V1.x版本的演进,很好的解决了延时问题,但有经验的你会发现,上述实现方式有一个硬伤,那就是都是采用死等方式实现,在等待延时内单片机除了能响应中断外,什么也干不了,如果说延时时间很短(微秒级别)或者必须采用死等方式处理外,其他情况我们应该尽可能让延时和其他任务同时处理。
假设我们有这样一个需求,4个按键检测、1个LCD屏幕显示。按键滤波10毫秒,LCD屏幕最低30毫秒刷新一次,执行时间1毫秒(为啥不说按键执行时间,因为执行时间太短了,这里忽略不计了)。
如果在裸机环境下开发,入门级的会这样处理(大一时的鱼鹰):
主循环不停执行按键扫描,因为要滤波,所以使用郭天祥老师那种方式滤波10毫秒后等待按键电平稳定,然后检测,并且在主循环不停刷新LCD,反正这里说刷新周期为30毫秒,而我CPU任务不多,有时间就刷新呗。
按键滤波10毫秒,LCD屏幕执行时间1毫秒,这样在按键按下情况,LCD屏幕刷新周期可达到11毫秒,可达到要求(如果按键没有按下,刷新周期1毫秒)。
后来随着需求增加,比如增加串口通信处理,增加大量浮点数据处理,执行时间20毫秒,要求这两个任务能在100毫秒内执行完毕,并且数据处理过程可被外部中断而不影响。
假如把这个串口和数据处理继续放在主函数,先前最差情况下(按键按下),执行周期为11毫秒,增加新任务后,最差情况下31毫秒可以完成串口和数据处理任务,小于100毫秒。
但是当你增加新任务后,你会发现LCD刷新要求达不到了(最少30毫秒),那该怎么办?
通过分析你可以很容易的发现,按下按键那一刻有10毫秒白白浪费了CPU,如果这段时间能用于数据处理就好了。
问题点找到了,但是该怎么处理呢?
我们很容易想到把按键检测功能放到中断中去处理,为什么呢?因为它的执行很短,只是滤波耗时,如果我们能把按键检测和滤波分开执行就好了。
于是我们想到了使用两个变量将按键和检测分开(关于中断下按键检测可参考蓝桥杯代码,看似很难理解,但理解后你会发现这是一种非常高效的实现方式):
一个变量用于记录按键电平状态,一个变量用于计时,定时器1ms中断一次,当按键按下后(定时中断中检测是否按下),记录电平状态,并且初始化计时变量为10,在定时中断中发现计时变量不为0,递减变量,直到为零后再次检测按键状态和当前状态是否一致即可(关于按键检测鱼鹰可能会在后期分享一篇笔记进行深入分析,实现单击、双击、长按等功能,关注鱼鹰即可,在这里描述的这种实现方式鱼鹰感觉还是复杂了点)。
因上述代码执行时间很短,所以在解决了按键问题后,就可以达到任务要求了。
由此我们可以得到V2.0版本基本实现思想:
使用一个变量,专门用于计时。
思想很简单,但是实现起来有点麻烦:
第一:你需要设置初始计时值
第二:你需要在特定的地方递增或递减这个计时值,比如定时中断函数。
第三:计时时间达到后进行相关处理。
以上第二点是一个很麻烦的事情,在特定的地方修改值不是很大麻烦(也算一个麻烦,因为一旦你的计时任务增多,你的定时中断任务复杂度必然会增加),最大问题在于修改计数值本身,修改变量会导致一些可重入问题,这个问题留待后面解决。
即使经过上述方式优化代码结构后,如果在后期再增加新任务后,你可能会发现LCD屏幕刷新周期可能又不符合要求,每次增加任务后都可能会导致刷新频率不符合要求(因为目前就是任务空闲即刷新,并没有固定刷新时间),反反复复修改代码谁也受不了,难道这就是所谓的码农,难道就没有更好的解决办法了吗?
延时实现V2.1
借用上述V2.0的思想,我们可以将其扩展成V2.1,使得任务中对时间要求较高的功能进行精确控制。
前面介绍的任务可以细分好几个小任务,按键检测任务、LCD刷新任务、计算任务、串口任务。
上述任务中,LCD刷新任务有30 ms的硬性要求(保证屏幕不闪烁)。所以借用V2.0思想,使用计时变量对它精确控制,但是又不能像按键处理那样将LCD刷新任务放到中断任务中处理,因为LCD执行时间长达1 ms。
很容易的,我们可以安排一个变量,在主循环中设置初始值,并执行刷新任务,而中断中检测这个值是否大于零,大于零即递减。
代码如下所示:
这样一来,只要单片机不是满负荷运行,应该就能达到刷新要求了(这里最终延时应该是31ms,可自行测试一下)。
但是有经验的你会发现,计时变量在中断和主函数都进行了修改操作,这就导致了一个问题:是否需要对这个变量保护?
原则上需要,但是在这里却不需要。为什么?
通过认真思考,你会发现两次修改都是有条件的:中断下修改需要变量大于0,而主函数下修改需要变量等于零。这样一来,你会发现变量修改是互斥的,也就是不存在同时修改的可能!!!
这也是为什么你没有从别人类似的代码中看到对共享资源保护的原因!
但是有些道友对共享变量不进行保护,在使用上存在顾虑,所以就会思考是否存在一种更好的方式去实现上述方法,由此V2.2现世。
延时实现V2.2
    V2.1存在的问题在于两个地方同时修改了变量,导致变量成为了伪共享变量(为什么是伪共享变量已经在前面解释了)。
    有没有一种方法,让变量只在中断被修改,而主函数可以根据变量当前的值判断延时时间是否达到呢?
    这里介绍一种常规的方法,就是让变量的初始化赋值直接在中断进行:
    
这样主函数就只需要做判断即可,而不需要修改变量。
但是这种处理有一个隐患,你必须在1ms内(假设中断1ms)查询到超时时间,一旦主函数有任务运行超过此时间,延时时间必然不准。
所以以上解决方式不建议采用。那么是否有更好一点的解决方式呢?
再说一个更好的方式之前,再介绍一种方式。
延时实现V2.3
计时变量递增,但不是30时清零,而是240时清零,即0~239循环(为什么不是增长到255后自然溢出?)。
这种实现方式好处就是减少了重新赋值计时变量的次数,但不可避免的是,仍然存在1ms查询频率的限制,一旦超出,延时不再准确!
是否存在一种没有查询频率限制的实现方法呢?换句话说,我们不是根据时刻判定延时时间的到达,而是通过时长判断呢?即1~2倍超时时间内都可以认为超时时间到,而不是刚好就是超时时间呢?
延时实现V2.5
    当然有,这里介绍一种有意思的公式(这个公式的妙处可看鱼鹰FIFO相关笔记):
(尾-头+表长) % 表长
    这个公式是用来计算队列中的元素个数,受它启发,我们可以利用这个公式计算时长:
    Duration= (CurrentTime – Time + 256) % 256(注意不是255)
    CurrentTime为随时间不断递增的变量,Time为记录的一个时刻点。
    因为CurrentTime递增到256后自动清零的特性,可简化公式如下:
    Duration= CurrentTime – Time
    由此我们得到了和V1.7类似的代码:
    
    这里特别注意需要强制转换成8位无符号整数,这是因为相减的时候默认采用有符号计算,我们需要让结果强制变成无符号8位整数(如果变量类型为无符号32位整数则不需要如此),还有判断条件是大于29,而不是30。
    可以看看延时结果:
我们关注的重点在于变量溢出后是否还能准确延时30 ms。
事实证明,即使溢出,延时仍然准确无误。

延时实现V2.7
上述延时版本的实现是一种飞跃,极大的简化延时代码的实现,但如果没有接下来这个版本的演进,你可能会如此实现各种功能的延时:
每一个延时功能,都使用两个变量按上述V2.5实现各种延时。
如果延时任务不多,确实没有问题,但是如果延时任务很多,会带来什么问题?
第一:中断函数处理的延时变量很多,增加中断负担;
第二:需要的延时变量很多。
那么是否有更好解决方式呢?
利用V2.5实现两种以上延时功能后,你会发现,中断中的延时变量完全可以由一个变量承担计时任务。
比如延时200ms任务和LCD刷新任务:
可以看到,通过上述代码可以实现两种延时任务,互不干扰。
而以上代码才是鱼鹰这篇长文的核心!也是鱼鹰愿意花大量笔墨去写这样一个通用功能的原因所在,也是鱼鹰特别希望各位道友掌握的一个延时技能(到此全文笔记已接近尾声,V3.x版本更多的是用于提高关于延时使用的认知)。
那么现在来分析这样的代码实现有什么好处:
1、可重入,也就是说你在任何函数中采用这种延时方式,原则上不会影响延时的准确性(最终延时时间大于等于需要延时时间)。
2、中断执行代码极少,各个延时功能几乎没有耦合关系。
3、延时精度高,在这里延时精度为1 ms。
4、可实现超长延时,如果把计时变量改成32位整数,延时时间超长(这里还是建议使用DWT,用于精确延时)。
5、变量少,一个延时任务只需要一个变量记录上一次延时到达的时刻即可。
6、延时变量类型可根据延时长度自行选择。比如说延时100 ms 使用8位整数,延时1 s 使用16位整数,延时 1天使用32位整数(当然判断代码需要根据情况修正才能准确延时,不懂的话可以留言讨论)。
7、非阻塞式延时,执行效率高。
讨论了这么多优点,难道就没有任何缺点吗?
一番思考下,鱼鹰想到了一个缺点,或者说另一个使用隐患。
我们想象这样一个场景,8位的计时变量,延时250 ms,上一次记录的超时时刻是0,原本应该在250这个时刻判断为超时,但有一个任务执行时间10 ms,导致在即将到达判断条件前执行了这个10 ms任务(假设在249这个时刻执行了这个任务,249 + 10 = 259,溢出变成3,查询时3 – 0 > 249不成立,但是250~255这些值是成立的),那么最终导致下一个250才是超时时刻,也就是说最终延时250 * 2 ms(如果不凑巧下一次又在249附近这里运行了这个任务,那么后果……),这肯定不是我们想看到的。
那么从这个例子中我们可以总结一个防止延时时间错误的方法:
延时时间 + 最大查询时间 < 最大可延时时间
在上面例子中,延时时间为250,查询时间暂且认为是10(不包含其他任务情况下),最大可延时时间255。代入公式发现不满足条件,这也就是为什么会出现延时错误(延时错误指的是超出延时时间两倍以上)的原因。
延时实现V2.8:单次延时
还好没有把版本号提的太高,不然就尴尬了。
因为鱼鹰的需求一直是周期延时,就没往单次延时方向考虑,后来将笔记发布到知乎之后,有网友由此受到启发,想改进他的延时功能(当时他使用的方法类似 V2.1)。
一开始鱼鹰很不明白,为什么明知这个版本的两大缺点,还选择这种方式呢?
1、查询频率限制
2、如果延时任务多,中断处理变得复杂
后来慢慢讨论,终于知道为什么了,这是当时的讨论:
第一,该网友使用的场景是单次延时,而V2.7是周期延时,如果没有好的策略处理是不能实现单次延时的;第二,虽然该网友的中断变量数会增加,但他巧妙的避免了查询频率这一关键缺陷,所以还是很有参考价值的(希望公众号的道友也能如这位网友一般,把自己的见解留言在文章下,这样的交流对技术的提高是很有帮助的)。
相信很多道友在读前面几篇笔记时,有看到鱼鹰重点强调了“查询频率”,但又有多少道友理解了这一词呢?查询频率,换句话说就是代码的执行周期,因为是在延时判断环境下,所以鱼鹰称之为“查询频率”。今天这篇笔记测试环境不再是裸机,而是 RT-Thread 操作系统,通过修改线程延时,能让各位道友更深刻理解“查询频率”一词。
延时实现V2.8.0
现在先来看看这位网友是如何解决查询频率限制的吧:
这是网友在鱼鹰前面的思路下回复的内容,当时鱼鹰那篇文章采用图片形式,根本无法复制粘贴,所以这位网友能够敲出这些代码进行回复也算是有心了。
当时初看代码时,还以为和鱼鹰写的版本一样,所以一直在和这位网友现在晋升为鱼鹰的道友了,后面就以道友相称了)强调查询频率限制,后面经过不断地讨论后,他理解了鱼鹰的查询频率的意思,鱼鹰也理解了这段代码和鱼鹰写的是不一样的。
我们可以看一下中断处理,发现它并不是直接将变量递减至零,而是留下了一个1,这个1就是用来做最后的延时超时时的处理工作,而一旦处理完成,完成清零。
也就是说,一个变量,被这位道友分成两部分用,前部分用来延时,后一个1用来做超时时间达到的标志位,还有一点就是最终延时时间不需要再减1了。
因为时间递减到1之后,中断不再对其递减,所以这个1一直保留,直到判断超时代码执行(查询)完成,才完成最终的清零操作,轻松实现单次延时功能,而且也不存在查询频率的限制。
确实是相当不错的策略,一个变量就解决一个延时问题,没啥副作用。
如果延时不多,这个策略确实很不错,但是一旦延时增多,中断的负担就会增加,远不如V2.7版本的一条代码高效(中断代码要尽可能的少,执行时间尽可能的短)。
由此我们可以思考,是否能对 V2.7 版本进行改进,达到单次延时的效果呢。
一番思考下,鱼鹰终于找到了一个很好的策略去实现它。
延时实现V2.8.5
为了实现单次延时,鱼鹰增加了一个延时变量,也就是说,在鱼鹰这种策略下,如果要实现一次单次延时,必须使用两个变量,这是这个方法的一点缺陷,所以对于内存不足的情况,可以使用那位道友的V2.8.0(这个版本号是随便编的,方便鱼鹰说明)。
现在贴上代码(RT-Thread):
由于鱼鹰懒得找systick处理函数在哪,也不想修改系统的代码,就使用了一个线程增加时间(实际上该系统有一个函数rt_tick_get() 函数可以使用,但怕有些读者不熟悉,所以自己弄了一个变量替代)。
关键性的东西已经注释好了,现在分析一下。
为了使时间变量准确增加,递增变量的线程task优先级必须比延时线程main更高(这里的main函数也是一个线程)。
首先分析一下为什么这个策略可以实现单次延时?关键点就在于延时时间delay被初始化为最大值,而每次延时完毕也会将其设置为最大值,而判断条件是大于号,也就是说只要delay设置为最大值,那么这个条件永远也满足不了,里面的代码也永远不会执行,这样一来就实现了单次延时的效果,而且这个延时时间也可以任意指定(也有限制条件,可回看V2.7)
现在进行第一次测试:
设置两个线程执行频率1 ms,在这里的超时代码查询频率也可以认为是1 ms,但由于打印函数比较耗时,所以执行时间较长,好在打印函数也是有条件限制的,影响不大。
这里用了一个flag来表示触发条件,这个flag通过一个系统的shell命令led设置,
现在输入命令,触发标志位:
可以看到,延时时间非常准确,说延时20 ms,就延时20 ms,绝不含糊。
现在将main线程执行频率设置为 6 ms,task线程执行频率还是 1 ms。
测试结果如下:
可以看到,鱼鹰只改变了main线程的执行频率,就导致延时时间超过了20毫秒,达到25毫秒,很不准确。这是因为虽然超时时间到了,但是因为代码还没有执行到,所以导致执行到代码时,已经超过延时时间很久了。
所以,对于精确延时,必须注意查询频率。
这里还有要注意的一点是,因为delay变量在多个地方调用,所以注意互斥保护,因为一旦上次延时没有达到,你再次修改延时时间,那么必然影响上次延时效果,这是V2.8两个版本都要考虑的问题,切记!


-THE END-



以上文章,来源于微信公众号「鱼鹰谈单片机」

作者:鱼鹰Osprey


长按后识别图中二维码关注

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存