本文隶属于AVR单片机教程系列。
开发板上有4个按键,我们可以把每一个按键连接到一个单片机引脚上,来实现按键状态的检测。但是常见的键盘有104键,是每一个键分别连接到一个引脚上的吗?我没有考证过,但我们确实有节省引脚的方法。
矩阵键盘
这是一个4*4的矩阵键盘,共有16个按键只需要8个引脚就可以驱动。我们先来看看它的原理。
每个按键有两个引脚,当按键按下时接通。每一行的一个引脚接在一起,分别连接到左边4个端口,称为“行引脚”;每一列的另一个引脚接在一起,分别连接到右边的4个端口,称为“列引脚”。这就是矩阵键盘内部的电路连接方式。
那么如何驱动它呢?首先我们简化一下,只考虑第一排:
这样就很简单了吧,只要让行引脚保持低电平,4个列引脚设置为输入并开启上拉电阻,读到低电平则意味着按键被按下。其余3行同理。
但是下面3行毕竟没有凭空消失,怎样让它不影响第一行按键的检测呢?保持那3个行引脚悬空,不接就可以了。这样,第一行的行引脚接地,4个列引脚接到单片机上,就可以使用了。所以,要读取一行按键的状态,需要把对应行引脚置为低电平,其余保持悬空,在列引脚上设置上拉电阻并分别读取其电平。
于是读取16个按键的方法就呼之欲出了——先按以上方法读第一行,再把第二行的行引脚接地,第一行的悬空,而列引脚不用动,读取第二行……
这样一行一行地读,只要读的速度够快,人就反应不过来,觉得16个按键是同时读的。上回遇到“只要速度够快,人就追不上我”,是在学习数码管的时候,那时我们了解到了动态扫描的技术。同样地,一行一行地读取按键也是一种动态扫描。
#include
#include
#include
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
#include
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
#include
#include
#include
#include
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与按键驱动芯片,有市售模块可用:
如果你的面包板级设计需要数码管和按键等资源的话,使用这个模块无疑是很方便的。请你在互联网上搜索资料,学习使用这个模块。