如何对IO口进行高效滤波?

cathy的头像

相信很多道友都有对输入IO 口进行滤波的需求,比如按键输入、红外对管输入等。这里鱼鹰就以按键为例介绍如何进行较为高效的滤波。

我们以为接入单片机引脚的按键按下后(并弹起)电平变化应该是这样的:

“”

实际上却是这样的:

“”

首先思考一个问题,如果没有进行滤波,会有什么问题?

一次按下过程可能被认为多次按下,因为按下后有抖动过程,这个过程电平并不稳定,导致单片机在很短的时间内多次检测到低电平状态。这样一来,本来只按下了一次,程序却认为按下了多次,这对按键功能会产生影响。

如果将按键引脚设置为外部中断触发,那么在极短的时间内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(鱼鹰强烈建议)》)

我们再次看这个图:

“”

如果我们使用 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。

有点绕口,看图好了:

“”

因为目前判断条件总是返回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,即如下情况:

“”

如果说这是你想要的效果,那么恭喜你,你不用更改代码;但如果这不是你想要的结果,那这个代码就存在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电平时,可得到如下测试结果:

“”

在这个例子中,要求电平稳定时间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

如果我们的需求是,变化后高电平稳定时上报一次,如果之后存在低电平,然后又变为高电平,并且稳定了,那么希望也能上报,那该如何处理呢?

“”

代码如下:

// 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)。