在嵌入式系统中,用的最多的输入设备就是按键,用户的应用需求可通过相应按键传递到系统软件中,软件转而完成用户请求,实现简单的人机交互。笔者此处就矩阵按键的实现作一个简单的介绍。

1. 按键输入概述

按键是一种常开型按钮开关,平时键的二个触点处于断开状态,按下键时它们才闭合。按键控制电路就是用来实时监视按键,当有键接下时,电路监控中的输入引脚电平发生变化,检测到这种变化后,控制电路进行按键扫描,定位按键的位置,并把相关的按键信息反馈回上一层应用中。常见的按键输入设计有独立式按键,矩阵式按键。独立式按键每个键占用一个IO口,电路配置灵活,软件简单,但按键较多时,IO口浪费大。矩阵式按键适用于按键数量较多的场合,由行线和列线组成,按键位于行列的交叉点上。节省IO口。通常按键控制电路通过查询方式或中断方式去检测按键的输入,查询方式需占用一定的cpu资源,查询频率太低可能造成按键输入丢失,太高浪费cpu资源,通常按键查询频率约50HZ较合适。中断方式需占用cpu一路外部中断,但不会占用cpu资源,只要有按键按下时,cpu即可马上检测到输入,进行扫描并得到按键值。

2. 硬件设计

笔者此处采用4x4的矩阵按键设计,当然,矩阵键盘可通过四个肖特基二极管构成四输入的与门(可参考笔者这篇文章<浅谈小信号肖特基二极管在数字电路中的应用>),连接到单片机的外部中断引脚,从而实现中断方式检测按键输入。为兼容目前开发板常见的矩阵按键设计,笔者把4x4的矩阵按键接口接在P1口,通过查询方式检测按键输入。

05_矩阵按键扫描 - 图1

图2-1 4x4矩阵按键

3. 驱动实现

由于我们采用的是查询方式按键设计,因此单片机需一定的频率去扫描P1口的按键,通常这个频率约50HZ较合适,为保证这个扫描频率,通常是通过定时器产生时标周期性进行执行扫描。P1.4P1.7列线通过上拉电阻接到VCC上,P1.0P1.3行线产生相应的扫描信号,无按键,列线处于高电平状态,有键按下,列线电平状态将由与此列线相连的行线电平决定。行线电平为低,则列线电平为低,行线电平为高,则列线电平为高。

按键扫描函数如下,该函数需周期执行,以扫描按键的状态。以51单片机为例,P1.0~P1.3逐行输出扫描信号,在Key.h模块头文件实现接口宏KeyOutputSelect()

#define KeyOutputSelect(Select) {P1 = ~(1<<(Select));}

输出扫描线后,需要读取对应扫描线的按键状态(P1.4~P1.7),同样在Key.h模块头文件实现引脚状态读取接口宏KeyGetPinState()

#define KeyGetPinState()        (P1>> 4)

读取了对应扫描线下的按键引脚状态,就需判断哪些引脚电平为0(按下),对读到的引脚状态进行取反转换成对引脚状态变量进行搜1算法,得到键值的速度能达到最快,并且多个按键同时按下时也能够正确得到优先级最高的按键。按键有效按下会得到0~15的键值,无按键按下时得到键值16。

voidKeyScan()
{
    unsigned char i;

    unsigned char KeyValue;

    unsigned char PinState;

    if (KeyState.State == STATE_DISABLE) {
        return; // 按键禁用时,不对键盘进行扫描

    }

// 键值为0~15,未按键键值为16,任意多的键按下均能

// 正确返回优先级最高的键值

    KeyValue = 0;

    for (i=0; i<4; i++) {
        KeyOutputSelect(i); // 输出扫描线

        // 得到对应扫描线时的按键状态

        PinState = KeyGetPinState();

        // 有键按下时,PinState中有0的位置即为键值位置

        PinState = ~PinState;

// 搜索Pinstate第一个为1的位                   

        if (!(PinState & 0xf)) {           

            KeyValue += 4;             

            continue; // 该扫描线没有按键按下,进入下一扫描线                       

        }

        // 该扫描线有键按下,对半进行检索1的位置                            

        if (!(PinState & 0x3)) {           

            KeyValue += 2; // 低2位(P1.4~P1.5)没有按下             

            PinState >>= 2; // 移位检索(P1.6~P1.7)             

        }                              

        if (!(PinState & 0x1)){           

            KeyValue += 1;             

        }

        break; // 有鍵按下,退出继续扫描  

    }



    KeyStore(KeyValue); // 保存按键状态  

}

得到了按键值后,我们需要对按键值进行处理并根据按键状态把可能产生的按键消息保存进缓冲区中,以便用户程序读取处理。按键通常有按下、松手、长按这几个状态,需要支持按下检测、松手检测、长按、连击的功能,并且需要对按键进行去抖滤波。按键的状态往往会在这几种情况进行切换,因此,对按键进行状态机编程是相当清晰的思路。我们在KeyStore()函数中实现对按键状态的转移判断,在模块中我们通过按键状态结构变量KeyState来跟踪记录按键的状态

typedef struct {
    unsigned char State; // 按键的各个状态转移

    unsigned int TimeCount;  // 用来跟踪各个状态的计时

} KEY_STATE;

static KEY_STATE KeyState; // 按键状态机状态转移

检测到相应的按键事件后(KEY_UP、KEY_DOWN、KEY_LONG),需产生相应的按键消息保存进按键缓存区,通常可以开辟一个按键队列缓存,以便保存多个产生的按键消息,不会因用户代码未能及时处理按键而造成按键丢失,笔者此处为避免复杂,以一个按键缓冲为例,按键事件结构变量KeyBuffer用来保存按键消息

typedef struct {
    unsigned char Value;

    unsigned char State;

} KEY_EVENT;

// 按键扫描得到的键值存放在KeyBuffer中,包含键值及键状态

static volatile KEY_EVENT KeyBuffer;

按键消抖以及长按均是需要以时间为判断标准,我们在模块中定义消抖时间以及长按时间判决以及相应的状态宏

// 按键的扫描周期为20ms

#define WOBBLE_COUNT    1  // 按键消抖计数,1个按键扫描周期(20ms)

#define LONG_COUNT      100 // 长按100个扫描周期判断为长按(2S)



#define STATE_INIT         0x0 // 按键初始化状态

#define STATE_WOBBLE       0x1 // 按键消抖状态

#define STATE_LONG          0x2 // 按键长按检测状态

#define STATE_RELEASE      0x3 // 按键释放状态

#define STATE_DISABLE      0x4 // 按键禁用状态

完整的KeyStore()函数实现如下

static voidKeyStore(unsigned char Value)

{
    static unsigned char LastValue;

    switch (KeyState.State) {
    case STATE_INIT: // 初始状等待按键

        if (Value < KEY_NULL) {
        // 记录下按下的键并进入消抖状态

            LastValue = Value;

            KeyState.TimeCount = WOBBLE_COUNT -1;

            KeyState.State = STATE_WOBBLE;

        }

        break;

    case STATE_WOBBLE:

        if (KeyState.TimeCount) {
            KeyState.TimeCount--; // 消抖计时未到

            break;

        }

        // 消抖后再次判断为同一键值则认为键按下保存键值

        // 并进入到长按检测态中,否则认为干扰,回到初始态

        if (Value ==LastValue) {
            KeyBuffer.Value = LastValue;

            KeyBuffer.State = KEY_DOWN;

            KeyState.TimeCount = LONG_COUNT - 1;

            KeyState.State = STATE_LONG;   

        } else {
            KeyState.State = STATE_INIT;

        }

        break;

    case STATE_LONG:

        if (Value == LastValue) {
            if(KeyState.TimeCount) {
                KeyState.TimeCount--; // 长按计时未到

                break;

            }

            // 长按确定后,保存长按的键值,并循环长按计时

            KeyBuffer.Value = LastValue;

            KeyBuffer.State = KEY_LONG;

            KeyState.TimeCount = LONG_COUNT - 1;           

        } else {
            // 长按时按键改变则认为长按的键释放,

            // 进入释放按键态

            KeyState.State =STATE_RELEASE;

        }

        break;

    case STATE_RELEASE:

        // 保存按键弹起的键值,并返回到初始化状态

        KeyBuffer.Value= LastValue;

        KeyBuffer.State = KEY_UP;

        KeyState.State = STATE_INIT;

        break;

    default:

        KeyState.State = STATE_INIT;

        break;

    }

}

按键扫描可以作为一个任务,需大约20ms进行执行扫描一次,笔者此处例程用数码管显示相应的按键值,数码管扫描函数需2ms进行一位的扫描,因此数码管扫描也可当作一个任务,另外还需一个按键处理任务用来处理按键扫描任务得到按键消息。安排一定量的任务以及保证实时是需要一定的编程模式进行程序设计。笔者此处介绍一种分时处理的思想,这种思想就是用定时器重复进行一定间隔的计时,产生时间片,任务根据这个时标来确定运行、挂起、超时等情况。对于抢占式操作系统如ucos、Linux等操作系统来说,这个定时器对于操作系统就如同人的心脏,操作系统在每个SystemTick会进行各种状态的处理,如任务的延时时间到、任务超时、有高优先级的任务需运行进行上下文切换抢占等。8位单片机运行像ucos这样的很小型操作系统也几乎没有实际意义,因为单操作系统就占用了8位单片机的大部分资源。但我们仍然可以用时间分片的思想来设计我们的8位单片机系统。对于一般任务,我们可以在定时器中断服务程序中判断任务是否需运行,实际的代码执行在完成定时器中断服务后进行调度,这可称为合作型任务,即一旦这个任何运行,只有退出了,其它合作型任务才能得到运行,因为合作型任务是不可抢占的,如果一个任务设计不好,如用了软件延时Delay_ms()让cpu空等浪费,将造成整个合作型任务均没有实时性。确实耗时长的任务应分状态,细分成小任务,保证任务的实时。对于一些需强实时的任务,比如必须精确地在某一时间点执行的任务,可以在定时器中断中执行,此时的任务是抢占型任务,即可打断合作型任务,执行完自身后再中断返回继续执行合作型任务。抢占型任务可能出现的问题就是会同时访问共用的资源,而这一资源往往只能被一个任务独占,造成访问冲突,例如合作型任务正在往串口发送数据”1234”,但发送到”1”时,这时定时器中断来了,执行抢占式任务,抢占式任务也要访问串口发送数据”abcd”,这时在PC机上接收到的串口数据就为”1abcd234”,造成了错误。因此对于抢占型操作系统,访问独占资源,都是需要关调度器或加锁的方法进行访问。

笔者为了让读者有一个认识,把数码管扫描任务及按键扫描任务当作抢占式任务,按键处理任务当作合作型任务。当然,设计一个调度器根据需求是需要实现不同的功能函数的,如调度器数据结构、初始化函数,定时器中断服务程序,调度器增加任务函数,调度器执行任务函数,调度器删除任务函数等。为避免复杂,笔者实现定时器中断服务函数,每个任务的数据结构只有两个任务变量TaskPeriod以及TaskRun,其中TaskPeriod变量用来时标计数,是必须的,TaskRun为任务执行标志,不为0时表明任务需调度执行,因此实际每个任务只需时标计数变量即可。

// 按键处理任务执行周期及执行标记

staticvolatile unsigned char DoKeyRun = 0;

staticvolatile unsigned char DoKeyPeriod = 0;



// 定时器2ms中断处理作为时标

voidT0_Interrupt() interrupt 1

{
    static unsigned char KeyScanPeriod = 0;

    TH0 = (65536-2000) / 256;

    TL0 = (65536-2000) % 256;

    DigitalTube_Scan(); // 刷新数码管

    KeyScanPeriod++; // 按键扫描时标计数

    if (KeyScanPeriod >= 10) { // 每隔20ms进行按键扫描

        KeyScanPeriod = 0;

        KeyScan();

    }

    DoKeyPeriod++; // 按键处理时标计数

    if (DoKeyPeriod >= 11) { // 每隔22ms进行按键处理

        DoKeyPeriod = 0;

        DoKeyRun++;

    }

}

每隔2ms执行实时任务DigitalTube_Scan()以及每隔20ms执行KeyScan(),而对于按键处理任务DoKey()需在按键扫描可能得到键值后才需调度处理,设为22ms执行处理一次即可,在定时器中按键处理任务的时标计数到了22ms后,才设置相关的执行计数标志DoKeyRun,告知调度器需执行按键任务。

    while(1) {
        if (DoKeyRun > 0) {
            DoKeyRun = 0;

            DoKey();

        }

        // 加入睡眠函数,节省功耗

    }

调度器简单的判断按键处理任务的执行标志,若需执行,则调用DoKey()执行任务。所有任务执行完后,此时调试器已经无事可做,一般让cpu进入休眠,可以极大的节省功耗,如这个例程,单片机任务并不重,估计90%的时间都在休眠。下一个时间片到来后会唤醒cpu执行定时器中断程序并如此重复任务的调度。

其它任务通过KeyGetValue()函数获得按键扫描的值及按键状态(KEY_UP、KEY_DOWN、KEY_LONG),抢占式任务KeyScan()产生按键消息到缓存区,在任务中调用KeyGetValue()访问按键缓存区时,应先关中断禁止调度,防止访问临界区时发生中断,执行抢占式任务KeyScan()可能同时修改按键缓冲区,KeyGetValue()访问完临界区后即可再开中断。

unsigned charKeyGetValue(unsigned char *pState)

{
    unsigned char KeyValue;

    if (KeyState.State == STATE_DISABLE) {
        *pState = KEY_NULL;

        return KEY_NULL;

    }

// 按键扫描任务放在中断中,在访问以下中断任务临界变量时,

// 应关中断,保证不在访问这些变量时产生中断,中断扫描任务

// 同时访问这些变量,造成冲突

    IntDisable();

    *pState = KeyBuffer.State;

    KeyValue = KeyBuffer.Value;

    KeyBuffer.State = KEY_NULL; // 按键得到后,清空缓冲区

    KeyBuffer.Value = KEY_NULL;

    IntEnable(); // 访问完临界变量后再开中断



    return KeyValue;

}

在例程中,DoKey()任务为简单的得到键值并在数码管进行显示,按键长按2s会进行长按计数,完整代码见文章给出的链接,包括完整的Keil源码及Proteus仿真工程。

void DoKey()

{
    unsigned char KeyState;

    unsigned char KeyValue;

    unsigned char *pBuffer;

    // 获得数码管显存,以作更新数据显示

    pBuffer = DigitalTube_GetBuffer();

    KeyValue = KeyGetValue(&KeyState);

    switch (KeyState) {
    case KEY_UP:

        // 按键15按下后禁用键盘

        if (KeyValue == 15) {
            pBuffer[0] = 11;

            pBuffer[1] = 11;

            pBuffer[2] = 11;

            pBuffer[3] = 11;               

            KeyDisable();

        } else {                       

            pBuffer[2] = KeyValue / 10;

            pBuffer[3] = KeyValue % 10;

            pBuffer[0] = 10;

        }

        break;

    case KEY_LONG: // 每隔2s长按处理

        if (pBuffer[0] < 9) {
            pBuffer[0]++;

        } else {
            pBuffer[0] = 0;

        }

        break;

    default:

        break;

    }                  

}

4. 附录

本章节Keil工程源码及Proteus仿真,可直接验证效果。Keys.rar包含矩阵按键模块源码Keys.c/Keys.h、数码管模块源码DigitalTube.c/DigitalTube.h以及示例源码main.c,可供下载学习。

http://pan.baidu.com/s/1pJNQf15