本文隶属于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的逻辑图:

看晕了?我们一点一点来分析。

首先看CLKCLK 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高电平时为1R高电平时为0,两者都低电平时为之前锁存的电平。那么165的D触发器中的SR信号是否也是这样的功能呢?

不完全相同,它们的作用不需要时钟信号,是异步的,并且它们不是上升沿触发而是电平触发的,即只要高电平保持,它们将一直起作用,使D触发器忽略1D信号的输入。我判断这两个信号是异步的,是因为C1标了1,对应1D1,而S没有标1,因此SC1无关;是电平触发的,因为S左边没有像C1左边那样的三角形,它表示边沿触发。

SH/LD引脚用于选择移位寄存器的工作模式。当SH/LD为高时,非门输出低,两个与非门一定输出高,D触发器的SR前有个圆圈,表示低电平有效,SR不起作用,移位寄存器在时钟上升沿移位;当SH/LD为低时,非门输出高,两个与非门的输出是另一个输入取非,当A为高和低时分别有SR为低,并行端口上的数据被锁存进移位寄存器中。

通过以上分析,我们可以总结出使用165读取8个输入的方法:先把SH/LD置低然后置高,再读取QH的电平,读到的就是H信号,然后在CLK引脚上产生一个上升再下降的时钟信号,并从QH读到G,如此循环,直到8个输入都读完。

那我们来实践一下吧。从开发板的原理图中可以看到,AH连接到开发板左上方Ext In处,0对应H7对应AQH连接PD2CLK连接PD4SH/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共用CLKSH/LD。这样就可以把8位并行转串行扩展为16位甚至更多。

74HC595

讲到并行输入转串行输出的165,就不得不讲串行输入转并行输出的74HC595。事实上,595有这样的地位:玩单片机的人接触的第一块芯片是那块单片机,第二块就应该是595。

595和165是兄弟芯片,结构与165对称。SER为串行输入,8位移位寄存器由时钟信号SRCLK的上升沿控制;RCLK上升沿控制一组RS锁存器,将移位寄存器中的数据反映到QAQH引脚的电平上来;SRCLR低电平有效,异步地将移位寄存器中的数据全部清零;略有不同的是输出级,595支持三态输出,当OE为高电平时高阻输出。

那为什么之前说595做不到三态输出呢?因为只有一个OE信号,大家得一起高阻,没法一个输出低电平其余高阻输出。

开发板上有一块595,SER连接PD3SRCLK连接PD4RCLK与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的用法,终于可以自己点亮数码管了。

把数码管的负极连接到端口45上。

#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共用SRCLKRCLK,一片的QH'连接下一片的SER。但是当级联的595数量很多时,刷新一次输出是比较耗时的,可以考虑换一种组织方式,把一串595换成多组级联,每一组第一个595的SER连接单片机,所有595共用SRCLKRCLK,可以有效减少级联长度。这是用引脚数量换取速度,具体还是应该根据需求来权衡。

尽管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 Out03号引脚接开发板右上方BGESGNDCD接矩阵键盘行引脚,Ext In03号引脚接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,这一部分就放到下一篇去吧。

 

作业

  1. 有时候程序会无缘无故判定出一次按键按下,特别是松开按键的时候,原因是单片机读取到的电平存在抖动。请你解决这个问题。

  2. 根据图示习惯,我判断74HC165逻辑图中的D触发器的SR引脚是异步的、电平触发的。请你写程序来验证这个事实。

  3. * 减少引脚数量的方法还有很多。有一种可以用一个ADC端口检测多个按键的方法:

    通过选择合适的阻值,当按键的状态组合(包括多个按键同时按下)不同时,ADC能读到不同的电压,从而实现按键状态的检测。请你实现这种方案。

  4. * TM1638是一款LED与按键驱动芯片,有市售模块可用:

    如果你的面包板级设计需要数码管和按键等资源的话,使用这个模块无疑是很方便的。请你在互联网上搜索资料,学习使用这个模块。

AVR单片机教程——矩阵键盘的更多相关文章

  1. AVR单片机教程——小结

    本文隶属于AVR单片机教程系列.   第一期挺让我失望的,是我太菜,没有把想讲的都讲出来.经常写了很多,然后一点一点删掉,最后就没多少了. 而且感觉难度不合适,处于很尴尬的位置.讲得简单,难的丢给库, ...

  2. AVR单片机教程——旋转编码器

    好久没写这个系列了.今天讲讲旋转编码器. 旋转编码器好像不是单片机玩家很常用的器件,但是我们的开发板上有,原因如下: 旋转编码器挺好用的.电位器能旋转的角度有限,旋转编码器可以无限圈旋转:旋转时不连续 ...

  3. AVR单片机教程——数码管

    先解答之前一个思考题:如果不把引脚配置为输出而写高电平,连接LED会怎样? 实验结果是,LED会亮,但相比于输出高电平的情况,亮度很低.这是为什么呢? 通过上一篇教程我们知道,引脚输入输出模式是由寄存 ...

  4. AVR单片机教程——数字输出

    从上一篇教程中我们了解到,按键与开关的输入本质上就是数字信号的读取.这一篇教程要讲的是,控制LED的原理是数字信号的输出.数字IO是单片机编程之有别于桌面编程的各项内容中最简单.最基础的. 在讲数字信 ...

  5. AVR单片机教程——数字输入

    我们已经学习了如何使用按键和拨动开关,不知你有没有好奇 button_down 和 switch_status 等函数是如何实现的.本篇教程带你一探究竟,让我们从按键的原理开始. 在原理图中,按键的符 ...

  6. AVR单片机教程——拨动开关

    在按键的上方有4个拨动开关.开关与按键,在原理和使用方法上都是很类似的,但有不同的用途——按键按下后松开就会弹起,而开关可以保存其状态. <switch.h> 定义了与开关相关的函数.sw ...

  7. AVR单片机教程——按键动作

    上一篇教程中我们学习了如何读取按键状态.而按键的动作,比如单击,至少需要两个状态才能判定,长按.双击的判定更加复杂.今天我们来学习如何使用库函数判断按键单击,以及其实现原理. 我们要实现的是:当一个按 ...

  8. AVR单片机教程——按键状态

    好久没更新了,今天开始继续,争取日更. 今天我们来讲按键.开发板的右下角有4个按键,按下会有明显的“咔嗒”声.如何检测按键是否被按下呢?首先要把按键或直接或间接地连接到单片机上.与之前使用的4个LED ...

  9. AVR单片机教程——随机点亮LED

    之前我们做的闪烁LED和流水灯,灯效都是循环的.这次我们来尝试一些不一样的——每一次随机选择一个LED并点亮. 要实现随机的效果,我们要用C语言标准库中的相关设施: #define RAND_MAX ...

随机推荐

  1. torch or numpy

    黄色:重点 粉色:不懂 Torch 自称为神经网络界的 Numpy, 因为他能将 torch 产生的 tensor 放在 GPU 中加速运算 (前提是你有合适的 GPU), 就像 Numpy 会把 a ...

  2. 关于在vuejs中动态加载不确定数量和内容的组件的解决方案

    在做一个门户项目的时候,客户要求需要进行私人化定制,每个人进入首页的时候可以自定义首页显示的版块 要在4.50个组件中显示随机N个组件按照每个人选定的顺序排列.需求说完了,接下来说说解决方案: htm ...

  3. Centos 7.5安装 Redis 5.0.0

    1 我的环境  1.1 linux(腾讯云) CentOS Linux release 7.5.1804 (Core)  1.2 Redis Redis 5.0.0 2 下载 官网 官网下载地址 3 ...

  4. DOCKER学习_005:Flannel网络配置

    一 简介 Flannel是一种基于overlay网络的跨主机容器网络解决方案,也就是将TCP数据包封装在另一种网络包里面进行路由转发和通信, Flannel是CoreOS开发,专门用于docker多机 ...

  5. Spring Security入门(基于SSM环境配置)

    一.前期准备 配置SSM环境 二.不使用数据库进行权限控制 配置好SSM环境以后,配置SpringSecurity环境 添加security依赖   <dependency> <gr ...

  6. 【一起学源码-微服务】Nexflix Eureka 源码四:EurekaServer启动之完成上下文构建及EurekaServer总结

    前言 上篇文章已经介绍了 Eureka Server上下文创建相关的Eureka Client逻辑,这一部分还是比较复杂的.接下来就讲解下Eureka Server上下文初始化最后的部分,然后加上整个 ...

  7. 二、webdriver API

    目录 1. webdriver中常用属性 2. 浏览器页面操作 3. 鼠标操作 4. 键盘操作 5. 下拉框操作 1. webdriver中常用属性 import time from selenium ...

  8. 洛谷P1220 关路灯 题解 区间DP

    题目链接:https://www.luogu.com.cn/problem/P1220 本题涉及算法:区间DP. 我们一开始要做一些初始化操作,令: \(p[i]\) 表示第i个路灯的位置: \(w[ ...

  9. Don’t Repeat Yourself,Repeat Yourself

    Don't Repeat Yourself,Repeat Yourself Don't repeat yourself (DRY, or sometimes do not repeat yoursel ...

  10. Spark学习笔记(四)—— Yarn模式

    1.Yarn运行模式介绍 Yarn运行模式就是说Spark客户端直接连接Yarn,不需要额外构建Spark集群.如果Yarn是分布式部署的,那么Spark就跟随它形成了分布式部署的效果.有yarn-c ...