AVR单片机教程——矩阵键盘
本文隶属于AVR单片机教程系列。
开发板上有4个按键,我们可以把每一个按键连接到一个单片机引脚上,来实现按键状态的检测。但是常见的键盘有104键,是每一个键分别连接到一个引脚上的吗?我没有考证过,但我们确实有节省引脚的方法。
矩阵键盘

这是一个4*4的矩阵键盘,共有16个按键只需要8个引脚就可以驱动。我们先来看看它的原理。

每个按键有两个引脚,当按键按下时接通。每一行的一个引脚接在一起,分别连接到左边4个端口,称为“行引脚”;每一列的另一个引脚接在一起,分别连接到右边的4个端口,称为“列引脚”。这就是矩阵键盘内部的电路连接方式。
那么如何驱动它呢?首先我们简化一下,只考虑第一排:

这样就很简单了吧,只要让行引脚保持低电平,4个列引脚设置为输入并开启上拉电阻,读到低电平则意味着按键被按下。其余3行同理。
但是下面3行毕竟没有凭空消失,怎样让它不影响第一行按键的检测呢?保持那3个行引脚悬空,不接就可以了。这样,第一行的行引脚接地,4个列引脚接到单片机上,就可以使用了。所以,要读取一行按键的状态,需要把对应行引脚置为低电平,其余保持悬空,在列引脚上设置上拉电阻并分别读取其电平。
于是读取16个按键的方法就呼之欲出了——先按以上方法读第一行,再把第二行的行引脚接地,第一行的悬空,而列引脚不用动,读取第二行……
这样一行一行地读,只要读的速度够快,人就反应不过来,觉得16个按键是同时读的。上回遇到“只要速度够快,人就追不上我”,是在学习数码管的时候,那时我们了解到了动态扫描的技术。同样地,一行一行地读取按键也是一种动态扫描。
#include <ee2/pin.h>
#include <ee2/delay.h>
#include <ee2/uart.h>
int main(void)
{
    const pin_t row[4] = {PIN_0, PIN_1, PIN_2, PIN_3};
    const pin_t col[4] = {PIN_4, PIN_5, PIN_6, PIN_7};
    const char name[16] = {
        '1', '2', '3', 'A',
        '4', '5', '6', 'B',
        '7', '8', '9', 'C',
        '*', '0', '#', 'D',
    };
    bool status[16] = {false};
    uart_init(UART_TX_64, 384);
    for (uint8_t j = 0; j != 4; ++j)
        pin_write(col[j], PULLUP);
    while (1)
    {
        for (uint8_t i = 0; i != 4; ++i)
        {
            pin_write(row[i], LOW);
            pin_mode(row[i], OUTPUT);
            for (uint8_t j = 0; j != 4; ++j)
            {
                uint8_t index = i * 4 + j;
                bool cur = pin_read(col[j]);
                if (status[index] && !cur)
                {
                    uart_print_char(name[index]);
                    uart_print_line();
                }
                status[index] = cur;
            }
            pin_mode(row[i], INPUT);
        }
        delay(1);
    }
}
在这个程序中,单片机每一毫秒把16个按键各读一遍,然后跟上一次读取比对,判定按键是否按下,然后在串口上输出。
输入的动态扫描没有输出的动态扫描要求那么严格。在数码管的动态扫描中,需要显示第1位→延时一段时间→显示第2位→延时一段时间,而且延时必须相同,否则不同位的亮度就有差异。而矩阵键盘的动态扫描就不需要那么严格的时序,读完一行以后完全可以不延时,就像上面的程序中做的那样,直接读下一行。
最后提一句,上面的分析和程序都把行引脚作为输出,列引脚作为输入,事实上由于行与列是对称的,把行列互换也是可以的。但如果是一个4行8列的矩阵键盘,还是应该把行引脚作输出,因为这个“输出”的实际上要求三态输出,包含了低电平与高阻态。我们接下来将看到,74HC595芯片做不到这一点。
以及,“矩阵键盘”的“矩阵”之处在于其电路连接,而不一定是外观。把16个按键排成一行,一样可以用矩阵键盘的连接方式。
74HC165
另一种扩展输入的方式是使用以74HC165为代表的并行转串行IC。165有8个并行输入、一个串行输入、一对互补串行输出引脚,以及时钟和锁存信号等。这是165的逻辑图:

看晕了?我们一点一点来分析。
首先看CLK和CLK INH这一部分,两个信号通过或门连接,提供后续电路的时钟信号。CLK INH称为时钟屏蔽信号。当CLK INH为高时,或门总是输出高电平,不再有时钟;当CLK INH为低时,或门输出电平与CLK相同。所以,只有当CLK INH为低时,后续电路才能工作。
时钟信号提供给一组移位寄存器,移位寄存器的基本单元是D触发器。一个D触发器可以以高低电平的形式锁存一位数据,在其右方的端口输出。在信号C1(即CLK,当CLK INH为低时)的上升沿,D触发器把1D信号的电平保存起来,同时反映到输出信号上。上升沿是一个瞬间的信号,8个D触发器同时收到这一信号,把前一个输出保存起来,供后一个D触发器在下一次时钟上升沿读取。这样,在每个上升沿,SER的数据进入最左边的D触发器,所有数据右移了一位,最右边的一位反映在QH引脚上,在上升沿丢失。
下一节中74HC595的逻辑图中有一组类似的移位寄存器,不过除了第一个以外用的都是SR锁存器,它同样在时钟上升沿锁存数据,这个数据在S高电平时为1,R高电平时为0,两者都低电平时为之前锁存的电平。那么165的D触发器中的S和R信号是否也是这样的功能呢?
不完全相同,它们的作用不需要时钟信号,是异步的,并且它们不是上升沿触发而是电平触发的,即只要高电平保持,它们将一直起作用,使D触发器忽略1D信号的输入。我判断这两个信号是异步的,是因为C1标了1,对应1D的1,而S没有标1,因此S与C1无关;是电平触发的,因为S左边没有像C1左边那样的三角形,它表示边沿触发。
SH/LD引脚用于选择移位寄存器的工作模式。当SH/LD为高时,非门输出低,两个与非门一定输出高,D触发器的S和R前有个圆圈,表示低电平有效,S和R不起作用,移位寄存器在时钟上升沿移位;当SH/LD为低时,非门输出高,两个与非门的输出是另一个输入取非,当A为高和低时分别有S和R为低,并行端口上的数据被锁存进移位寄存器中。
通过以上分析,我们可以总结出使用165读取8个输入的方法:先把SH/LD置低然后置高,再读取QH的电平,读到的就是H信号,然后在CLK引脚上产生一个上升再下降的时钟信号,并从QH读到G,如此循环,直到8个输入都读完。
那我们来实践一下吧。从开发板的原理图中可以看到,A到H连接到开发板左上方Ext In处,0对应H,7对应A;QH连接PD2,CLK连接PD4;SH/LD有些复杂,需要让(PC3, PC2) = (0, 1)使SH/LD为高电平,(PC3, PC2) = (1, 1)使SH/LD为低电平。
uint8_t read_165()
{
    DDRC  |=   1 << DDC2;    // PC2 output
    DDRC  |=   1 << DDC3;    // PC3 output
    DDRD  &= ~(1 << DDD2);   // QH  input
    DDRD  |=   1 << DDD4;    // CLK output
    PORTC |=   1 << PORTC2;  // PC2 high
    PORTC |=   1 << PORTC3;  // PC3 high, SH/LD low
    PORTC &= ~(1 << PORTC3); // PC3 low,  SH/LD high
    PORTD &= ~(1 << PORTD4); // CLK low
    uint8_t result = 0;
    for (uint8_t i = 0; i != 8; ++i)
    {
        result >>= 1;            // the bit read first is LSB
        if (PIND & (1 << PIND2)) // QH high
            result |= 1 << 7;    // set result's MSB
        PORTD |=   1 << PORTD4;  // CLK high
        PORTD &= ~(1 << PORTD4); // CLK low
    }
    return result;
}
需要注意的一点是,进入循环之前的初始化除了要配置输入输出以外,CLK必须为低电平,因为CLK是上升沿触发,如果进入函数之前此引脚输出高电平而函数中没有把它置低,循环第一次中移位寄存器就不会移位,H的电平就会被读两次,而A会被忽略。
等等,关于165芯片,我们还有SER串行输入没有讲。注意到SER是第一个D触发器的输入,QH是最后一个D触发器的输出,而中间都是前一个D触发器的输出是后一个D触发器的输入,你有没有受到什么启发?
你想把SER连接到QH上?那没什么用。正确的做法是把一片165的QH连接到另一片165的SER上,还可以连接更多,这种连接方式成为级联;最后一片的QH连接单片机,第一片的SER不需要使用,一般会接一个确定的电平;所有165共用CLK和SH/LD。这样就可以把8位并行转串行扩展为16位甚至更多。
74HC595
讲到并行输入转串行输出的165,就不得不讲串行输入转并行输出的74HC595。事实上,595有这样的地位:玩单片机的人接触的第一块芯片是那块单片机,第二块就应该是595。

595和165是兄弟芯片,结构与165对称。SER为串行输入,8位移位寄存器由时钟信号SRCLK的上升沿控制;RCLK上升沿控制一组RS锁存器,将移位寄存器中的数据反映到QA到QH引脚的电平上来;SRCLR低电平有效,异步地将移位寄存器中的数据全部清零;略有不同的是输出级,595支持三态输出,当OE为高电平时高阻输出。
那为什么之前说595做不到三态输出呢?因为只有一个OE信号,大家得一起高阻,没法一个输出低电平其余高阻输出。
开发板上有一块595,SER连接PD3,SRCLK连接PD4,RCLK与165的SH/LD类似,当(PC3, PC2) = (0, 1)为高电平,(PC3, PC2) = (1, 0)时为低电平。
话不多说,我们直接看代码:
void write_595(uint8_t _data)
{
    DDRD  |=    1 << DDD3;    // SER   output
    DDRD  |=    1 << DDD4;    // SRCLK output
    DDRC  |= 0b11 << DDC2;    // PC3:2 output
    PORTD &=  ~(1 << PORTD4); // SRCLK low
    for (uint8_t i = 0; i != 8; ++i)
    {
        if (_data & 1 << 0)          // LSB first
            PORTD |=   1 << PORTD3;  // SER high
        else
            PORTD &= ~(1 << PORTD3); // SER low
        _data >>= 1;
        PORTD |=   1 << PORTD4;      // SRCLK high
        PORTD &= ~(1 << PORTD4);     // SRCLK low
    }
#define PC32(x) (PORTC = (PORTC & ~(0b11 << PORTC2)) | (x) << PORTC2)
    PC32(0b10); // RCLK low
    PC32(0b01); // RCLK high
#undef PC32
}
595最经典的功能就是驱动LED了。事实上,开发板上的数码管和LCD接口都是挂在595的输出上的。现在我们学习了595的用法,终于可以自己点亮数码管了。
把数码管的负极连接到端口4和5上。
#include <ee2/pin.h>
#include <ee2/delay.h>
void write_595(uint8_t _data);
int main()
{
    pin_t digit[2] = {PIN_4, PIN_5};
    for (uint8_t i = 0; i != 2; ++i)
    {
        pin_write(digit[i], HIGH);
        pin_mode(digit[i], OUTPUT);
    }
    uint8_t which[8] = {
        1, 1, 1, 1, 0, 0, 0, 0
    };
    uint8_t pattern[8] = {
        0b00000001, 0b00000010, 0b00000100, 0b00001000,
        0b00001000, 0b00010000, 0b00100000, 0b00000001
    };
    while (1)
        for (uint8_t i = 0; i != 8; ++i)
        {
            pin_write(digit[which[i]], LOW);
            write_595(pattern[i]);
            delay(200);
            pin_write(digit[which[i]], HIGH);
        }
}
595也是支持级联的,方法是多片595共用SRCLK和RCLK,一片的QH'连接下一片的SER。但是当级联的595数量很多时,刷新一次输出是比较耗时的,可以考虑换一种组织方式,把一串595换成多组级联,每一组第一个595的SER连接单片机,所有595共用SRCLK和RCLK,可以有效减少级联长度。这是用引脚数量换取速度,具体还是应该根据需求来权衡。
尽管595是单片机学习中必不可少的部分,但是我非常不建议你在面包板上搭建595电路,不是因为单片机与595的连接麻烦,而在于驱动LED需要串联电阻,并且每一个LED都需要独立的电阻。而我非常贴心地在板载595的输出和Ext Out引脚之间接了470Ω的电阻,可以简化你的电路设计。
综合实践
那么,有没有办法把动态扫描和595、165扩展组合起来使用呢?
我想你应该已经有大致思路了:595写一个,165读一组,这样循环4次,就可以把16个按键都读一遍。但是我们还有一个问题没有解决:如何改造595,让它能输出低电平和高阻态?
首先我们得有个感觉,这是可以实现的,因为595输出有两个状态——高电平和低电平,而我们现在需要的也是两个状态——低电平和高阻态,而不需要高电平输出,所以应该想想办法,加点东西把高电平改成高阻。
想出来了吗?反正我不会。但是我知道两种电路,能把高电平变成低电平,低电平变成高阻态:

- Q1是一个NPN型的三极管,左边的基极(- B)串联了电阻后作为输入,下方的发射极(- E)接地,上方的集电极(- C)作为输出。当输入高电平时,有电流从基极流向发射极,三极管就允许有电流从集电极流向发射极,可以认为输出低电平;当输入低电平时,基极与发射极之间没有电流,集电极与发射极之间也不能有电流,可以认为输出高阻态。
- Q2是一个N沟道的MOS管,左边的栅极(- G)作为输入,下方的源极(- S)接地,上方的漏极(- D)作为输出。当输入高电平时,漏极和源极之间出现导电沟道,并且电阻很小,输出为低电平;当输入低电平时,没有导电沟道,输出为高阻态。
关于三极管和MOS管这两种有源器件,你最好参考一些其他资料,比如相关教科书。
这两种输出称为开集输出和开漏输出,效果是差不多的。由于现在绝大部分IC都使用CMOS工艺,一般用的都是“开漏输出”这个名字。如果单片机要读取一个开漏输出的电平,必须接上拉电阻,就像矩阵键盘中的那样,高阻态的输出在有了上拉电阻之后会被读成高电平。
其实为了讲原理,我在NPN和NMOS中选一个讲就可以了,但是不巧的是这两种我们都要用——开发板上有两个NPN三极管和两个N沟道MOS管,刚好够矩阵键盘的4行用。电路连接是:Ext Out的0到3号引脚接开发板右上方B和G,E和S接GND,C和D接矩阵键盘行引脚,Ext In的0到3号引脚接4个列引脚。开发板已经给165的输入连接了上拉电阻。

#include <ee2/bit.h>
#include <ee2/exout.h>
#include <ee2/exin.h>
#include <ee2/uart.h>
#include <ee2/timer.h>
void timer()
{
    static const char name[16] = {
        '1', '2', '3', 'A',
        '4', '5', '6', 'B',
        '7', '8', '9', 'C',
        '*', '0', '#', 'D',
    };
    static bool status[16] = {false};
    static uint8_t phase = 0;
    if (phase & 1)
    {
        uint8_t row = exin_read();
        for (uint8_t i = 0; i != 4; ++i)
        {
            uint8_t index = (phase >> 1) * 4 + i;
            bool cur = read_bit(row, i);
            if (status[index] && !cur)
            {
                uart_print_char(name[index]);
                uart_print_line();
            }
            status[index] = cur;
        }
    }
    else
    {
        exout_write(1 << (phase >> 1));
    }
    if (++phase == 8)
        phase = 0;
}
int main()
{
    exout_init();
    exin_init();
    uart_init(UART_TX_64, 384);
    timer_init();
    timer_register(timer);
    while (1)
        ;
}
这个程序把按键扫描放到了中断中进行。扫描分为8个阶段,从0开始编号,偶数阶段写595,分别给4个行引脚对应的位中的一个写1,其余写0,奇数阶段读165,根据列引脚对应位的值判断按键是否按下。这样做的好处是可以分散工作量,有效防止定时器中断ISR执行时间超过中断间隔,轻则定时不准确,重则栈溢出,程序跑飞。根据我的测试,一个看似微不足道的4*4矩阵键盘扫描,需要100us的时间,是定时器中断间隔的10%。不难想象,对于更复杂的设备,这个值可能超过100%,不把任务分散一下是不行的。
别忘了595和165都只用了4个端口哦!在这种扩展方式下,一片595和一片165可以连接64个按键,级联的话可以还可以翻几倍。一共需要占用了多少单片机引脚呢?595的SER和165的QH可以借助一个电阻共用一个,595的SRCLK和165的CLK共用一个,595的RCLK和165的SH/LD也可以共用一个——总共3个,相当优秀。
本来我还想讲用SPI总线驱动595和165,鉴于这一篇教程已经很长了,下一篇DAC也涉及SPI,这一部分就放到下一篇去吧。
作业
- 有时候程序会无缘无故判定出一次按键按下,特别是松开按键的时候,原因是单片机读取到的电平存在抖动。请你解决这个问题。 
- 根据图示习惯,我判断74HC165逻辑图中的D触发器的 - S和- R引脚是异步的、电平触发的。请你写程序来验证这个事实。
- * 减少引脚数量的方法还有很多。有一种可以用一个ADC端口检测多个按键的方法:  - 通过选择合适的阻值,当按键的状态组合(包括多个按键同时按下)不同时,ADC能读到不同的电压,从而实现按键状态的检测。请你实现这种方案。 
- * TM1638是一款LED与按键驱动芯片,有市售模块可用:  - 如果你的面包板级设计需要数码管和按键等资源的话,使用这个模块无疑是很方便的。请你在互联网上搜索资料,学习使用这个模块。 
AVR单片机教程——矩阵键盘的更多相关文章
- AVR单片机教程——小结
		本文隶属于AVR单片机教程系列. 第一期挺让我失望的,是我太菜,没有把想讲的都讲出来.经常写了很多,然后一点一点删掉,最后就没多少了. 而且感觉难度不合适,处于很尴尬的位置.讲得简单,难的丢给库, ... 
- AVR单片机教程——旋转编码器
		好久没写这个系列了.今天讲讲旋转编码器. 旋转编码器好像不是单片机玩家很常用的器件,但是我们的开发板上有,原因如下: 旋转编码器挺好用的.电位器能旋转的角度有限,旋转编码器可以无限圈旋转:旋转时不连续 ... 
- AVR单片机教程——数码管
		先解答之前一个思考题:如果不把引脚配置为输出而写高电平,连接LED会怎样? 实验结果是,LED会亮,但相比于输出高电平的情况,亮度很低.这是为什么呢? 通过上一篇教程我们知道,引脚输入输出模式是由寄存 ... 
- AVR单片机教程——数字输出
		从上一篇教程中我们了解到,按键与开关的输入本质上就是数字信号的读取.这一篇教程要讲的是,控制LED的原理是数字信号的输出.数字IO是单片机编程之有别于桌面编程的各项内容中最简单.最基础的. 在讲数字信 ... 
- AVR单片机教程——数字输入
		我们已经学习了如何使用按键和拨动开关,不知你有没有好奇 button_down 和 switch_status 等函数是如何实现的.本篇教程带你一探究竟,让我们从按键的原理开始. 在原理图中,按键的符 ... 
- AVR单片机教程——拨动开关
		在按键的上方有4个拨动开关.开关与按键,在原理和使用方法上都是很类似的,但有不同的用途——按键按下后松开就会弹起,而开关可以保存其状态. <switch.h> 定义了与开关相关的函数.sw ... 
- AVR单片机教程——按键动作
		上一篇教程中我们学习了如何读取按键状态.而按键的动作,比如单击,至少需要两个状态才能判定,长按.双击的判定更加复杂.今天我们来学习如何使用库函数判断按键单击,以及其实现原理. 我们要实现的是:当一个按 ... 
- AVR单片机教程——按键状态
		好久没更新了,今天开始继续,争取日更. 今天我们来讲按键.开发板的右下角有4个按键,按下会有明显的“咔嗒”声.如何检测按键是否被按下呢?首先要把按键或直接或间接地连接到单片机上.与之前使用的4个LED ... 
- AVR单片机教程——随机点亮LED
		之前我们做的闪烁LED和流水灯,灯效都是循环的.这次我们来尝试一些不一样的——每一次随机选择一个LED并点亮. 要实现随机的效果,我们要用C语言标准库中的相关设施: #define RAND_MAX ... 
随机推荐
- 让Word Add-in For MediaWiki支持Word 2013
			1.下载并安装Word Add-in For MediaWiki https://www.microsoft.com/en-us/download/details.aspx?id=12298 2.在“ ... 
- 从头学pytorch(六):权重衰减
			深度学习中常常会存在过拟合现象,比如当训练数据过少时,训练得到的模型很可能在训练集上表现非常好,但是在测试集上表现不好. 应对过拟合,可以通过数据增强,增大训练集数量.我们这里先不介绍数据增强,先从模 ... 
- Django 中配置MySQL数据库
			在Django的项目中会默认使用sqlite的数据库 配置MySQL需要在setting.py 里加入以下设置: 配置数据库 DATABASES = { 'default': { 'ENGINE': ... 
- 超详细!如何利用Huginn制作专属RSS
			前言 本文首发于个人网站,欢迎订阅.本篇博文接上利用Feed43为网站自制RSS源,上一篇讲解了RSS的简介以及利用Feed43自制专属RSS,Feed43有其优势,缺陷也很明显,不能高度自定义.有的 ... 
- HTML和JavaScript代码分离、平稳退化(1)
			使用的编辑器是Hbuilder,浏览器是Chrome. HTML和JavaScript代码分离,会使得修改网页功能和代码的阅读与维护会轻松的许多,不用在DOM中阅读大量的JavaScript代码. 文 ... 
- 洛谷$P5366\ [SNOI2017]$遗失的答案 数论+$dp$
			正解:数论$dp$ 解题报告: 传送门$QwQ$ 考虑先质因数分解.所以$G$就相当于所有系数取$min$,$L$就相当于所有系数取$max$ 这时候考虑,因为数据范围是$1e8$,$1e8$内最多有 ... 
- 在.NET Core中批量注入Grpc服务
			GRPC 是谷歌发布的一个开源.高性能.通用RPC服务,尽管大部分 RPC 框架都使用 TCP 协议,但其实 UDP 也可以,而 gRPC 干脆就用了 HTTP2.还有就是它具有跨平台.跨语言 等特性 ... 
- 为什么大家都说Java中只有值传递?
			最近跟Java中的值传递和引用传递杠上了,一度怀疑人生.查了很多资料,加上自己的理解,终于搞清楚了,什么是值传递和引用传递.也搞明白了,为什么大家都说Java只有值传递,没有引用传递.原来,我一直以来 ... 
- 别再埋头刷LeetCode之:北美算法面试的题目分类,按类型和规律刷题,事半功倍
			算法面试过程中,题目类型多,数量大.大家都不可避免的会在LeetCode上进行训练.但问题是,题目杂,而且已经超过1300道题. 全部刷完且掌握,不是一件容易的事情.那我们应该怎么办呢?找规律,总结才 ... 
- matplotlib绘制符合论文要求的图片
			最近需要将实验数据画图出来,由于使用python进行实验,自然使用到了matplotlib来作图. 下面的代码可以作为画图的模板代码,代码中有详细注释,可根据需要进行更改. # -*- coding: ... 
