按键和开关无处不在。请观察你周边的世界,手机、相机、电风扇、还有你电脑的键盘……,这些简单的机械开关可以让我们和电子设备交互,比如设置它们的参数和状态,等等。按键从分类上讲,属于人机交互装置。目前有很多很酷的人机交互装置,比如 wii 遥控器, Kinect,语音识别,等等。但它们的成本和复杂度都很高。对于简单的单片机应用,我们只需要几分钱一只的按键即可。
好,让我们来研究怎样把按键接入单片机。
我们今天的任务是研究按键,首先要做的是看看这个黑色的小东西里面是什么样子的。
我们使用的单片机有很多的针脚,其中大部分都可以当成 GPIO 来用(即 General Purpose Input/Output 的缩写,也就是说这些脚是通用的,你可以用它们来干各种事情),GPIO 作为输出(Output) 模式我们已经见识过了。比如在控制 LED,控制数码管的时候它们都是工作在输出模式。
如果要识别按键,我们就需要使用输入(Input)模式,因为这时的情况是,单片机外部的按键产生的数据进入到单片机内部。
欲使用输入功能,我们要做一个小小的作准备工作,即做一个简单的实验来了解一个新的寄存器,也就是 I/O 输入寄存器 PINx, x 在我们的单片机里可以被替换成 B, C, D,如 PINB, PINC, PIND,它是 Port x INput 的意思。
在上面的原理图里,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 做显示,因此显示的值是二进制的。如果你觉得这样不够直观,也可以用数码管来做实验,原理是一样的。
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 就会加一。
但是单片机的 I/O 口看到的是下面这个情况,在键被按下时和松开时会有多次弹跳,因此单片机认为是多次按键,而不是一次!这不是我们想要的效果。
如果我们用示波器把按键的电压变化过程捕捉下来,真实的情况是下面这样子的(我们的真实世界是模拟的,连续的):
从上面的实验里我们已经看到,本来我们只按了一次按键,但是单片机却认为我们按了多次按键,完全不是我们想要的效果,这个问题该如何解决?
普遍认为抖动是由于机械按键的本身特性所产生的,也就是所在按下按键时,按键内部的金属片会弹跳一会儿,它要花一小点时间才会“稳定”下来。因此,按键的抖动在英语里叫 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;
}
}
编译上面的程序并写进芯片,按动按键并观察效果。这些代码可以在项目中重用。
除了上面的例子,电脑用的 PS2 键盘也可以接入单片机,但软件处理上比较复杂,超出了本教程的范围。
在马潮所著的《AVR单片机嵌入式系统原理与应用实践》对这种方法有详细的讲解。↩