简介

bounce

按键和开关无处不在。请观察你周边的世界,手机、相机、电风扇、还有你电脑的键盘……,这些简单的机械开关可以让我们和电子设备交互,比如设置它们的参数和状态,等等。按键从分类上讲,属于人机交互装置。目前有很多很酷的人机交互装置,比如 wii 遥控器, Kinect,语音识别,等等。但它们的成本和复杂度都很高。对于简单的单片机应用,我们只需要几分钱一只的按键即可。

好,让我们来研究怎样把按键接入单片机。

拆开按键

我们今天的任务是研究按键,首先要做的是看看这个黑色的小东西里面是什么样子的。

button_internal

输入输出

我们使用的单片机有很多的针脚,其中大部分都可以当成 GPIO 来用(即 General Purpose Input/Output 的缩写,也就是说这些脚是通用的,你可以用它们来干各种事情),GPIO 作为输出(Output) 模式我们已经见识过了。比如在控制 LED,控制数码管的时候它们都是工作在输出模式。

如果要识别按键,我们就需要使用输入(Input)模式,因为这时的情况是,单片机外部的按键产生的数据进入到单片机内部。

欲使用输入功能,我们要做一个小小的作准备工作,即做一个简单的实验来了解一个新的寄存器,也就是 I/O 输入寄存器 PINx, x 在我们的单片机里可以被替换成 B, C, D,如 PINB, PINC, PIND,它是 Port x INput 的意思。

pinx

在上面的原理图里,PORTC 上接 4 个 LED,PORTB 上接 4 个 按键。

下载源程序

#include <avr/io.h>

void init(void)
{
    DDRC = 0xFF;  // 将 PORTC 设置成输出模式
    PORTC = 0x00;

    DDRB = 0x00;  // 将 PORTB 设置成输入模式
    PORTB = 0xFF; // 打开内部上拉
}

int main(void)
{
    init();

    while (1) PORTC = PINB; // 读 PORTB 的输入,然后输出给 PORTC
}

试试这个程序,你会发现按下按键 S1,LED1 会熄灭;按下按键 S2,LED2 会熄灭;依次类推。

由于打开了 PORTB 的内部上拉功能,在没有键被按下时,用万用表测量 PORTB 的各个引脚,我们能测到高电压,因此 PINB 会读到 1,这时 PORTC 上对应的 LED 点亮。

如果某个键被按下,则引脚被拉低到地,因此 PINB 读到 0,PORTC 上对应的 LED 熄灭。

回顾一下我们前面所了解到的知识,一个 PORT(端口) 由 3 个寄存器控制它们的行为,分别是:

烦人的抖动

通过实验我们了解了 PINx 寄存器的功能,下面就可以进入主题了。这里的想法很简单:当一个键按下时,让一个值加一,然后显示出来。

实验所使用的电路和第一个实验里的电路一样。

下载源程序

#include <avr/io.h>

void init(void)
{
    DDRC = 0xFF;  // 将 PORTC 设置成输出模式
    PORTC = 0x00;

    DDRB = 0x00;  // 将 PORTB 设置成输出模式
    PORTB = 0xFF;
}

int main(void)
{
    uint8_t count = 0; // 按键计数器

    init();
    PORTC = count;  // 显示 count 的初始值(0)

    while (1)
    {
        while (PINB & 0b00000001) ; // 如果键没有被按下,则程序一直停在此处,处于等待状态,不往下走

        count++;                    // 有键按下了, count 自增
        if (count > 9) count = 0;   // 超过 9 了回到 0

        while ( !(PINB & 0b00000001) ) ; // 如果键被按下但未松开,则程序一直停在此处,不往下走
        PORTC = count;                   // 松开按键后更新显示
    }
}

注意,我们使用 4 个 LED 做显示,因此显示的值是二进制的。如果你觉得这样不够直观,也可以用数码管来做实验,原理是一样的。

button-led

PD0 (pin 2) 通过一个按键接地,PD0 通过一个 10k 的电阻上拉,因此,在按键没有按下的情况下,PD0 上的电压是 5V,如果按下按键,则 PD0 上的电压会就变成 0V,因为这时 PD0 通过按键接到 GND 上了。

这个电压的变化会发映在单片机里的寄存器,我们在程序里读寄存器的值就可以读出 GPIO 上的电压状态,就知道按键是否被按下,原理就是这样。

7 段数码管用来显示数字,当按一下键它显示的值就加一,加到 9 又回到 0.

下载源程序

#include <avr/io.h>
#include <avr/pgmspace.h> // 访问程序空间,后面的 pgm_read_byte 函数就由这个库所提供

// 7-seg LED 段码表
// 位: 7  6 5 4 3 2 1 0
// 段: DP G F E D C B A
// 加额外的 PROGMEM (PROGram MEMory 的缩写)修饰是为了把表格
// 存储在 Flash 存储器(程序代码就是存储在这里的)里而非 RAM 里以节省内存
const uint8_t led_7[16] PROGMEM = {
0x3F, // 0
0x06, // 1
0x5B, // 2
0x4F, // 3
0x66, // 4
0x6D, // 5
0x7D, // 6
0x07, // 7
0x7F, // 8
0x6F, // 9
0x77, // A
0x7C, // b
0x39, // C
0x5E, // d
0x79, // E
0x71  // F
};

void init(void)
{
    DDRC = 0xFF;  // 将 PORTC 设置成输出模式
    PORTC = 0x00;

    DDRB = 0xFF;  // 将 PORTB 设置成输出模式
    PORTB = 0x00;

    DDRD = 0x00;
    PORTD = 0xFF;
}

void display(uint8_t num)
{
    uint8_t temp;
    
    temp = pgm_read_byte(led_7 + num); // 查表并赋给一个临时变量,如果是共阳LED,要把值取反(~是取反操作符)
    PORTC = temp;                    // 把值赋给 IO
    PORTB = (temp >> 6);             // 把数据向右移 6 位才能对应到 PB0 上
}

int main(void)
{
    uint8_t count = 0; // 按键计数器

    init();
    display(count); // 显示 count 的初始值(0)

    while (1)
    {
        while (PIND & 0b00000001) ; // 如果键没有被按下,则程序一直停在此处,处于等待状态,不往下走

        count++;                    // 有键按下了, count 自增
        if (count > 9) count = 0;   // 超过 9 了回到 0

        while ( !(PIND & 0b00000001) ) ; // 如果键被按下但未松开,则程序一直停在此处,不往下走
        display(count);                  // 松开按键后更新显示
    }
}

按下面包板上的按键,并观察数码管或 LED 的显示,你应该已经发现了点什么东西。

我们“希望”(注意,只是我们希望 :)按键以下面的方式工作:当我们按下它时,它瞬间就接通了;当我们松开它时,它瞬间就断开了!因此,每按一下键,变量 count 就会加一。

button_ideal

但是单片机的 I/O 口看到的是下面这个情况,在键被按下时和松开时会有多次弹跳,因此单片机认为是多次按键,而不是一次!这不是我们想要的效果。

button_bounce

如果我们用示波器把按键的电压变化过程捕捉下来,真实的情况是下面这样子的(我们的真实世界是模拟的,连续的):

button_analog

debounce

从上面的实验里我们已经看到,本来我们只按了一次按键,但是单片机却认为我们按了多次按键,完全不是我们想要的效果,这个问题该如何解决?

抖动产生的原因

普遍认为抖动是由于机械按键的本身特性所产生的,也就是所在按下按键时,按键内部的金属片会弹跳一会儿,它要花一小点时间才会“稳定”下来。因此,按键的抖动在英语里叫 bounce,去除抖动则叫 debounce。目前大部分的教科书和大学里都是这样讲的。

也有一些资料说按键的抖动是由于信号的突然变化产生的,这时按键内部的金属片相当于一个小电感。到底哪种说法是正确的,我还没有认真考证。总之,我们的实验表明,抖动是存在的。

消除抖动

按键有抖动,当然不能满足我们的要求,下面来看看如何将抖动消除。

硬件去抖

这个方法是在按键和单片机之间加入一些硬件电路,用一些硬件原理将抖动滤除掉。这样做的好处是:在编写软件时会比较简单,但会增加硬件的复杂度。

与之对应的方法是软件去抖,也就是在程序里消除抖动,下面我们来看看这种方法。

延时去抖

状态机方法

上面的延时去抖法可以工作,但有一个缺点,它调用了软件延时函数 _delay_ms()。在 _delay_ms() 执行的时候,CPU 无法做别的事情,这样降低了单片机的实时性,影响了效率。实际上下面是一个更好的方法,即利用有限状态机来消除抖动1

下载源程序

#include <avr/io.h>
#define F_CPU 12000000UL
#include <util/delay.h>
#include <avr/interrupt.h> // sei(), cli() 和 ISR() 等中断相关的定义都在这个头文件里

// key-related constant definition
#define KEY_INPUT   PINB /* connect port     */
#define KEY_MASK    0x3F /* mask 0b0011 1111 */

#define KEY_NO    0   /* no key pressed */
#define KEY_S1    1   /* S1 */
#define KEY_S2    2   /* S2 */
#define KEY_S3    3   /* S3 */

#define KEY_STATE_0 0 /* key state 0 */
#define KEY_STATE_1 1 /* key state 1 */
#define KEY_STATE_2 2 /* key state 2 */

// function prototypes
// uint8_t read_key(void);


// 全局变量
volatile uint8_t key_scan_time_ok;      // key scan ok
volatile uint8_t key_scan_time_counter; // read keys every 10ms


// Timer/Counter 0 溢出中断服务例程
ISR (TIMER0_OVF_vect) // 查 avr-libc manual
{
    // 5.5ms 中断一次

    if (++key_scan_time_counter >= 2) // check for 11ms, increament counter
    {
        key_scan_time_counter = 0;
        key_scan_time_ok = 1;        // 11ms ready
    }
}

uint8_t read_button(void)
{
    static uint8_t key_state = 0, key_press;
    uint8_t key_return = KEY_NO; // KEY_NO = 0

    key_press = KEY_INPUT & KEY_MASK; // read the port

    switch (key_state)
    {
        case KEY_STATE_0:  // intial state
            if (key_press != KEY_MASK) key_state = KEY_STATE_1; 
            break;           // key pressed, jump to state 1
        case KEY_STATE_1:    // confirm state
            if (key_press == (KEY_INPUT & KEY_MASK)) // if the key is same
            {
                if (key_press == 0x3E)      // 0b0011 1110
                    key_return = KEY_S1;    // return the key code
                else if (key_press == 0x3D) // 0b0011 1101
                    key_return = KEY_S2;
                else if (key_press == 0x3B) // 0b0011 1011
                    key_return = KEY_S3;
                key_state = KEY_STATE_2;    // switch to release state
            }
            else
                key_state = KEY_STATE_0; // key has been release, swith to initial state
            break;
        case KEY_STATE_2:
            if (key_press == KEY_MASK) key_state = KEY_STATE_0; // key has been release, switch to initial state
            break;
    }

    return key_return;
}

void init(void)
{
    DDRC = 0xFF;  // 将 PORTC 设置成输出模式
    PORTC = 0x00;

    DDRB = 0x00;  // 将 PORTB 设置成输入模式
    PORTB = 0xFF; // 打开内部上拉

    // 设置 Timer 0
    TCCR0B = (1 << CS02);   // Prescaler 256
    TIMSK0 |= (1 << TOIE0); // 打开溢出中断功能
}

void blink(void)
{
    uint8_t i;
    
    for (i=0; i<3; i++)
    {
        PORTC = 0xFF;
        _delay_ms(200);
        PORTC = 0x00;
        _delay_ms(200);
    }
}

int main(void)
{
    uint8_t buttoncode; // 按键码
    uint8_t count = 0;  // 按键计数器

    init(); // 初始化
    sei();  // 开启全局中断

    PORTC = count; // 显示初始值 0

    while (1)
    {
        if (key_scan_time_ok)
        {
            key_scan_time_ok = 0;
            buttoncode = read_button(); // 读键
            if (buttoncode)             // 如果键值不是 0 表明有键被按下,则做下面的处理
            {
                if (buttoncode == KEY_S1) // key S1
                {
                    count++;                    // count 自增
                    if (count > 15) count = 0;   // 超过 15 了回到 0
                }
                if (buttoncode == KEY_S2) { // key S2
                    count--;
                    if (count <= 0) count = 0;
                }
                if (buttoncode == KEY_S3) { // key S3
                    blink();  // 闪烁
                }
            }
        }

        PORTC = count;
    }
}

编译上面的程序并写进芯片,按动按键并观察效果。这些代码可以在项目中重用。

游乐场

矩阵键盘

keypad4x4

PC 键盘

除了上面的例子,电脑用的 PS2 键盘也可以接入单片机,但软件处理上比较复杂,超出了本教程的范围。


  1. 在马潮所著的《AVR单片机嵌入式系统原理与应用实践》对这种方法有详细的讲解。