1. 程式人生 > >AVR微控制器教程——矩陣鍵盤

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

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