本文隶属于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. Windows 服务安装与卸载 (通过 installutil.exe)

    1. 安装 安装 .NET Framework ; 新建文本文件,重命名为 ServiceInstall.bat,将 ServiceInstall.bat 的内容替换为: C:\\Windows\\M ...

  2. Android程序分析环境(搭建步骤略)

    1:安装JDK JDK(Java Development Kit) 是 Java 语言的软件开发工具包(SDK).没有JDK的话,无法编译Java程序. 2:安装Android  SDK Androi ...

  3. 0017 CSS 三大特性:层叠性、继承性、优先级

    目标: 理解 能说出css样式冲突采取的原则 能说出那些常见的样式会有继承 应用 能写出CSS优先级的算法 能会计算常见选择器的叠加值 5.1 CSS层叠性 概念: 所谓层叠性是指多种CSS样式的叠加 ...

  4. salesforce零基础学习(九十五)lightning out

    随着salesforce对lightning的推进,越来越多的项目基于lightning开发,导致很多小伙伴可能都并不了解classic或者认为不需要用到classic直接就开始了lightning的 ...

  5. JMM&Thread

    1.概述 高效并发通过JAVA线程之间提高并发协调实现,在实现过程中需考虑硬件的效率和一致性,但在运算的过程中需要考虑处理器与内存的交互,所以基于高速缓存的存储交互解决的处理器与内存的方案,在对多处理 ...

  6. Mongdb的基本操作及java中用法

    Mongdb中所有数据以Bson(类似JSON)的格式存在,可以存储集合,map,二进制文件等多种数据类型. 数据库的常用操作 use [数据库名称];//有就选中,没有就添加并选中show dbs; ...

  7. ArrayList数组扩容方式(基于jdk1.8)

    ArrayList无参构造函数为: public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } 而DEFA ...

  8. SpringBoot整合Thymeleaf-基于SpringBoot2.X版本

    1.为啥要用Thymeleaf模板引擎?现在不都前后端分离了么? 熊dei们,别着急,我们先来谈谈为啥开始用Thymeleaf模板引擎,先照顾照顾下我们这些可爱的小白童鞋.... 为啥开始用Thyme ...

  9. python+selenium+Chrome options参数

    python+selenium+Chrome options参数 Chrome Options常用的行为一般有以下几种: 禁止图片和视频的加载:提升网页加载速度. 添加代理:用于翻墙访问某些页面,或者 ...

  10. centos7搭建Fabric基础环境

    一.首先升级centos最新内核 参考https://www.cnblogs.com/sky-cheng/p/12146054.html 二.卸载旧版本docker [root@localhost ~ ...