Jerry @DOA&INPAC, SJTU

Working out everything from the first principles.

导航

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的逻辑图:

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

首先看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与按键驱动芯片,有市售模块可用:

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

posted on 2020-02-02 19:58  Jerry_SJTU  阅读(1808)  评论(0编辑  收藏  举报