作者:鱼鹰Osprey
ID :emOsprey相信很多道友都有对输入IO 口进行滤波的需求,比如按键输入、红外对管输入等。这里鱼鹰就以按键为例介绍如何进行较为高效的滤波。我们以为接入单片机引脚的按键按下后(并弹起)电平变化应该是这样的:
![](/uploads/mp/20230516/a8/6463cb752307d.jpg)
![](/uploads/mp/20230516/a8/6463cb75595a0.jpg)
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.0typedef 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、使用鱼鹰介绍的方式(《》)我们再次看这个图:![](/uploads/mp/20230516/a8/6463cb757b065.jpg)
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。有点绕口,看图好了:![](/uploads/mp/20230516/a8/6463cb75aca2f.jpg)
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,即如下情况:![](/uploads/mp/20230516/a8/6463cb75e023b.jpg)
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电平时,可得到如下测试结果:![](/uploads/mp/20230516/a8/6463cb764de3c.jpg)
// 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如果我们的需求是,变化后高电平稳定时上报一次,如果之后存在低电平,然后又变为高电平,并且稳定了,那么希望也能上报,那该如何处理呢?![](/uploads/mp/20230516/a8/6463cb767e3f4.jpg)
// 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; // 不上报
}
因为函数没有全局变量,所以可以认为它是一个可重入函数(前提是传入的参数指针地址不同),可放心使用。以上就是鱼鹰能想到的比较高效的滤波方式,可以参考借鉴一番。当然,如果你有更好的方式,不如留言交流。