cathy 在 提交
![cathy的头像 cathy的头像](https://mcu.eetrend.com/files/styles/picture200/public/letter-avatars/u-593.png?itok=XTs2kpZ8)
相信很多道友都有对输入IO 口进行滤波的需求,比如按键输入、红外对管输入等。这里鱼鹰就以按键为例介绍如何进行较为高效的滤波。
我们以为接入单片机引脚的按键按下后(并弹起)电平变化应该是这样的:
![“”](http://mcu.eetrend.com/files/2021-05/博客/100112508-205249-1.png)
实际上却是这样的:
![“”](http://mcu.eetrend.com/files/2021-05/博客/100112508-205250-2.png)
首先思考一个问题,如果没有进行滤波,会有什么问题?
一次按下过程可能被认为多次按下,因为按下后有抖动过程,这个过程电平并不稳定,导致单片机在很短的时间内多次检测到低电平状态。这样一来,本来只按下了一次,程序却认为按下了多次,这对按键功能会产生影响。
如果将按键引脚设置为外部中断触发,那么在极短的时间内CPU将多次进入中断,影响中断的性能(所以对于非数字接口,即没有稳定的高低电平的接口,如果不需要非常高的实时性,那么鱼鹰不建议设置为外部中断触发方式)。
那么我们该如何进行处理呢?
很自然的,因为按下过程中有抖动期,我们就会想办法跳过抖动时间,然后再检测电平变化,所以,V0.1 版本就应运而生,这也是郭天祥老师告诉我们初学者最简单易懂的方式:
V0.1
typedef enum { KEY_LEVEL_DOWN, // 假设低电平为按下 KEY_LEVEL_UP, }KeyLevelTypedef; KeyLevelTypedef get_key_level() { return (KeyLevelTypedef)HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0); } // V0.1 void key_scan() { if(get_key_level() == KEY_LEVEL_DOWN) { HAL_Delay(20); // 假设抖动时间 20 ms if(get_key_level() == KEY_LEVEL_DOWN) { key_flag = 1;// 按键按下标志位 } } }
对于初学者而言,这段代码简单易懂,但是对于工作多年的人来说,这种方式效率极其低下。
有可能你会说,如果使用操作系统,当延时函数使用系统延时,那么这段时间就可以切换到其他任务进行处理,而不会浪费CPU使其空转了。但是如果这个任务本身功能比较复杂,那么这种处理会严重影响其他功能的执行,所以这种代码应该不会出现在工作多年的工程师手中。
那么是否有更高效的方式呢?
有,就是记录前后两次电平的变化,通过比较两次电平是否相等来确定电平是否稳定(这个方式在《延时功能进化论(合集)》有做简单介绍)。
V1.0
typedef enum { KEY_STATE_IDLE, // 按键空闲 KEY_STATE_DOWN, // 按键按下 KEY_STATE_FINISH, // 按键处理完成(由应用程序设置) }KeyStateTypedef; KeyStateTypedef key_state; KeyLevelTypedef key_last_level; // 上次电平状态 // V1.0 // 函数调用周期 20 ms(如何实现应该不需要再说明了吧) void key_scan() { KeyLevelTypedef temp; // 可不可以不使用这个中间变量? temp = get_key_level(); if(temp != key_last_level){ key_last_level = temp; return; } // 当运行到这里,说明电平已经稳定下来了 if(temp == KEY_LEVEL_DOWN){ if(key_state == KEY_STATE_IDLE){ // 确保曾经释放过按键,这样可以保证在按下时不会不停设置该标志位 key_state = KEY_STATE_DOWN;// 按键按下标志位 } } else{ if(key_state == KEY_STATE_FINISH){ // 防止多线程情况下同时修改 key_state = KEY_STATE_IDLE; // 释放按键 } } }
在这里,使用了两个全局变量,一个是 key_state,一个是 key_last_level。
前者共三种状态,这是为了防止按键扫描和按键处理程序不是顺序执行而设定的。当你按下按键后,保证按键处理程序必然可以得到按下状态,同时只有释放了按键之后才可以更改状态位,然后才能再次触发。
这样可以保证按键扫描和按键处理得以顺序执行(这里面的关系需要考虑清楚,否则的就会写出有 BUG 的程序)。
而后者只在本函数使用,所以不存在使用风险(前提是没有多个任务同时调用该函数,否则照样有风险)。
可以看到该代码没有任何延时函数,简单、高效,当然这里有一个前提,那就是该函数的调用周期必须大于抖动时间,但是也别太长,否则实时性不好。
假设抖动期时间为 20 ms,实现 20 ms 的调用周期有很多种方法:
1、中断定时器定时调用
2、软件定时器调用(需操作系统)
3、线程内周期执行该函数(需操作系统)
4、使用鱼鹰介绍的方式(《延时功能进化论之V2.5~V2.7(鱼鹰强烈建议)》)
我们再次看这个图:
![“”](http://mcu.eetrend.com/files/2021-05/博客/100112508-205251-3.png)
如果我们使用 V1.0 的方式,我们就会发现,当程序运行在抖动期,因为函数调用的时间大于抖动时间,那么程序总是可以得到稳定后的状态。
比如空闲状态下(key_last_level为高电平),突然按下按键,假设在抖动中期程序检测到高电平,那么20 ms 后检测的是低电平,显然这是不相等的(key_last_level更新为低电平),那么程序就会执行下一次,下一次即40 ms 后检测肯定是低电平(如果不是,说明电平不稳定),此时电平相等,即可认为电平稳定了。
而如果在抖动中期程序检测到低电平,那么20 ms 后检测的应该还是低电平,那么程序认为此时电平已经稳定了,那也没有问题,因为它已经跳过了抖动期。
V2.0
如果说,滤波只有按键这种抖动的话,那么上述方式应该算很不错了,但有时对IO滤波的需求比较复杂,那么上述方式只可参考,不可直接拿来对任何 IO 进行滤波。
而且很多时候,程序需要定时检测多个 IO 的电平状态,当电平发生变化时,我们能及时通知应用层,而且只在电平发生变化时才进行通知。但与此同时我们需要在电平稳定之后才通知,而不是变化后马上进行通知,否则可能在电平抖动时多次通知。
所以针对这种需求,我们需要设计一个更加通用一些的滤波函数,能应对所有数字 IO 的滤波(包括按键)。
其实按键滤波已经包含了滤波思想,只是不够通用,需要进一步改进。
typedef enum { LEVEL_LOW, // LEVEL_HIGH, }LevelTypedef; typedef struct { uint32_t last_time; // 上次时间 LevelTypedef last_level; // 上次电平状态 }FilterParaTypedef; // V2.0 // para 滤波变量,level 当前检测电平状态, time 当前时间戳,单位 1 ms, stable_time希望电平稳定的时间 uint8_t filter(FilterParaTypedef *para, LevelTypedef level, uint32_t time, uint32_t stable_time) { if(level != para->last_level){ para->last_level = level; // 更新当前电平状态 para->last_time = time; // 更新电平变化的时刻 return 0; // 电平未稳定 } if(time - para->last_time > stable_time){ // 这两个条件可以放在一起进行 && 判断吗? return 1; // 需要上报 } return 0; // 电平稳定时间不够长 }
这个代码的思想就是,当电平不稳定时,更新当前时间戳,一旦电平不再变化,并且持续的时间够长(这个时间由用户决定),那么返回 1 表示电平已经稳定了(这个函数没有调用周期限制,调用周期不同,会产生一些影响,这个和滤波时间精度有关)。
这个代码看起来挺简单的,也好像没啥问题,但实际上是存在问题的。
看到那个稳定时间判断条件了吗?如果下次继续执行这个函数,那么程序依然返回 1,所以它总是会在稳定后不停的返回 1(判断条件总是成立),这样一来,这个函数并不能实现电平变化后才进行通知,也就是说调用者无法通过返回值直接决定下一步动作。
可能你会说,如果在返回 1 之前先更新一下时间戳呢?看过鱼鹰之前笔记的应该知道,这种方式会周期性返回 1,即如果希望电平稳定时间为 10 ms,那么在电平稳定后,每隔 10 ms 返回 1,这是我们不希望看到的。
那么有没有什么解决办法呢?当然。
因为我们只希望在变化之后再稳定时才返回1,即我们既希望短暂电平变化并不返回1,而那些长时间稳定的电平能在稳定时间阈值之后返回1,又希望在稳定之后只返回一次1,之后电平变化后如果再次稳定才返回1。
有点绕口,看图好了:
![“”](http://mcu.eetrend.com/files/2021-05/博客/100112508-205253-4.png)
因为目前判断条件总是返回1,所以我们需要增加限制条件,让它不总是返回1。
简单的办法是,增加一个变量,用于记录上次的稳定后的电平,比如这样:
typedef enum { LEVEL_LOW, // LEVEL_HIGH, }LevelTypedef; typedef struct { uint32_t last_time; // 上次时间 LevelTypedef last_level; // 上次电平状态 LevelTypedef last_stable_level; // 上次稳定的电平状态 }FilterParaTypedef; // V2.0 // para 滤波变量,level 当前检测电平状态, time 当前时间戳,单位 1 ms, stable_time希望电平稳定的时间 uint8_t filter(FilterParaTypedef *para, LevelTypedef level, uint32_t time, uint32_t stable_time) { if(level != para->last_level){ para->last_level = level; // 更新当前电平状态 para->last_time = time; // 更新电平变化的时刻 return 0; // 电平未稳定 } if(time - para->last_time > stable_time){ // 这两个条件可以放在一起进行 && 判断吗? if(level != para->last_stable_level) { // 电平稳定时间够长且电平发生了变化 para->last_stable_level = level; return 1; // 需要上报 } } return 0; // 电平稳定时间不够长 }
这样一来,下一次继续执行时,就不会再次返回1了。
但是以上代码其实是有一个隐含问题的,那就是如果两次长时间电平之间有一个短时间的不同电平存在,那么也只会上报一次,即返回一次1,即如下情况:
![“”](http://mcu.eetrend.com/files/2021-05/博客/100112508-205254-5.png)
如果说这是你想要的效果,那么恭喜你,你不用更改代码;但如果这不是你想要的结果,那这个代码就存在BUG,毕竟变化的时间虽然短,但还是变化了的嘛。
还有一个问题,看过鱼鹰以前笔记的人都知道,这种计时方式是存在问题的,因为如果你的电平稳定时间很长,长到四字节计时器溢出了,那么就可能出问题。
不过在这里,即使出现溢出,也没关系,结果是一样的,因为如果电平稳定时间很长了,那么肯定已经上报过一次了,后面肯定也不需要再次上报了。
V2.5
V2.0方式确实很高效,但是为了只在变化时上报一次,就要增加一个变量还是很不爽的,如果说鱼鹰没有找到好的方式,那么鱼鹰会采用的,但凑巧的是,鱼鹰想到了更好的方式,不需要增加这个变量也能达到效果。
一个用于计时,一个用于记录上次电平,这两个变量肯定是不可或缺的。但是如果你仔细思考一下,就会发现,所谓的记录上次电平,其实是在变化时就被快速更改了的,它记录的是实时电平变化,而计时是在变化后更新时间戳,稳定时判断稳定时间,如果我们把计时顺序换一下,会如何呢?
即,稳定时更新时间戳,变化时判断稳定时间,而记录电平的变量只记录已稳定的电平,会怎么样?
typedef struct { uint32_t last_time; // 上次时间 LevelTypedef last_stable_level; // 上次稳定的电平状态 }FilterParaTypedef; // V2.5 // para 滤波变量,level 当前检测电平状态, time 当前时间戳,单位 1 ms, stable_time希望电平稳定的时间 uint8_t filter(FilterParaTypedef *para, LevelTypedef level, uint32_t time, uint32_t stable_time) { if(level != para->last_stable_level){ if(time - para->last_time > stable_time) { para->last_stable_level = level; // 如果这次电平稳定时间足够长,那么记录这次稳定的电平 return 1; // 上报 } return 0; // 不上报,同时不更新时间戳(稳定时间不够) } para->last_time = time; // 不断更新电平稳定时间,保存电平稳定时的时间戳 return 0; // 不上报 }
上面的代码比V2.0简单了许多,但也稍微难理解,但如果你仿真测试一番,其实也容易理解。
测试代码(rt_tick_get() 函数用于获取当前时间,单位 ms):
FilterParaTypedef FilterPara; void task(void *parameter) { while(1) { LevelTypedef temp = (LevelTypedef)HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0); if(filter(&FilterPara, temp, rt_tick_get(), 100)) { rt_kprintf("stable level is %u\n",temp); } rt_thread_delay(5); } }
当你修改PB0电平时,可得到如下测试结果:
![“”](http://mcu.eetrend.com/files/2021-05/博客/100112508-205255-6.png)
在这个例子中,要求电平稳定时间20 ms,而线程的执行周期为 5 ms,即电平采样率为5 ms,当你的手速点击足够快时(如果不够快,可以加长 20 ms),那么应该不会有任何打印信息输出。
需要注意的是,采样率比较关键,如果电平变化快,而采样率设置的不合适,那么不能完全反应外界引脚电平的变化,这个和“香农定理”有关,超出鱼鹰的范围,就不多说了。
V3.0
有的时候需求可能要求只需要稳定一个高电平或者低电平才上报,其他时候不上报,那么该如何修改V2.5的代码呢?
上报时加入限制条件即可,如下所示:
// V3.0 // para 滤波变量,level 当前检测电平状态, time 当前时间戳,单位 1 ms, stable_time希望电平稳定的时间 uint8_t filter(FilterParaTypedef *para, LevelTypedef level, uint32_t time, uint32_t stable_time) { if(level != para->last_stable_level){ if(time - para->last_time > stable_time) { para->last_stable_level = level; // 如果这次电平稳定时间足够长,那么记录这次稳定的电平 if(level == LEVEL_HIGH) // LEVEL_HIGH 可以作为 para 的成员变量参数传入,方便适应其他电平 { return 1; // 上报 } } return 0; // 不上报,同时不更新时间戳(稳定时间不够) } para->last_time = time; // 不断更新电平稳定时间,保存电平稳定时的时间戳 return 0; // 不上报 }
这样一来,只会在高电平稳定时才会进行上报,而低电平却不会上报。但是这种方式同样有一个隐藏限制,那就是低电平必须能稳定一段时间,否则下次高电平无法上报,照样有 V2.0 的限制,如何打破这种限制呢?
V3.1
如果我们的需求是,变化后高电平稳定时上报一次,如果之后存在低电平,然后又变为高电平,并且稳定了,那么希望也能上报,那该如何处理呢?
![“”](http://mcu.eetrend.com/files/2021-05/博客/100112508-205256-7.png)
代码如下:
// V3.1 // para 滤波变量,level 当前检测电平状态, time 当前时间戳,单位 1 ms, stable_time希望电平稳定的时间 uint8_t filter(FilterParaTypedef *para, LevelTypedef level, uint32_t time, uint32_t stable_time) { if(level != para->last_stable_level){ if(level != LEVEL_HIGH) // LEVEL_HIGH 可以作为 para 的成员变量参数传入,方便适应其他电平 { para->last_stable_level = level; // 快速切换状态 // para->last_time = time; // 是否有必要同时更新时间戳呢? } else if(time - para->last_time > stable_time) { para->last_stable_level = level; // 如果这次电平稳定时间足够长,那么记录这次稳定的电平 if(level == LEVEL_HIGH) // LEVEL_HIGH 可以作为 para 的成员变量参数传入,方便适应其他电平 { return 1; // 上报 } } return 0; // 不上报,同时不更新时间戳(稳定时间不够) } para->last_time = time; // 不断更新电平稳定时间,保存电平稳定时的时间戳 return 0; // 不上报 }
V3.2
为了让这个滤波代码(事实上已经不仅仅承担滤波功能,同时承担了变化并稳定后上报功能)更加通用,可以这样设计:
typedef enum { LEVEL_LOW, // LEVEL_HIGH, }LevelTypedef; typedef struct { uint32_t last_time; // 上次时间 LevelTypedef last_stable_level; // 上次稳定的电平状态 LevelTypedef filter_level; // 希望滤波的电平 }FilterParaTypedef; // V3.2 // para 滤波变量,level 当前检测电平状态, time 当前时间戳,单位 1 ms, stable_time希望电平稳定的时间 uint8_t filter(FilterParaTypedef *para, LevelTypedef level, uint32_t time, uint32_t stable_time) { if(level != para->last_stable_level){ if(level != para->filter_level) // LEVEL_HIGH 可以作为 para 的成员变量参数传入,方便适应其他电平 { para->last_stable_level = level; // 快速切换状态 // para->last_time = time; // 是否有必要同时更新时间戳呢? } else if(time - para->last_time > stable_time) { para->last_stable_level = level; // 如果这次电平稳定时间足够长,那么记录这次稳定的电平 if(level == para->filter_level) // LEVEL_HIGH 可以作为 para 的成员变量参数传入,方便适应其他电平 { return 1; // 上报 } } return 0; // 不上报,同时不更新时间戳(稳定时间不够) } para->last_time = time; // 不断更新电平稳定时间,保存电平稳定时的时间戳 return 0; // 不上报 }
因为函数没有全局变量,所以可以认为它是一个可重入函数(前提是传入的参数指针地址不同),可放心使用。
本文转载自:鱼鹰谈单片机(作者:鱼鹰Osprey)
免责声明:本文为转载文章,转载此文目的在于传递更多信息,版权归原作者所有。本文所用视频、图片、文字如涉及作品版权问题,请联系小编进行处理(联系邮箱:cathy@eetrend.com)。