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與按鍵驅動芯片,有市售模塊可用:

    如果你的面包板級設計需要數碼管和按鍵等資源的話,使用這個模塊無疑是很方便的。請你在互聯網上搜索資料,學習使用這個模塊。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM