本文隸屬於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的邏輯圖:
看暈了?我們一點一點來分析。
首先看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 <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共用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 <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,這一部分就放到下一篇去吧。
作業
-
有時候程序會無緣無故判定出一次按鍵按下,特別是松開按鍵的時候,原因是單片機讀取到的電平存在抖動。請你解決這個問題。
-
根據圖示習慣,我判斷74HC165邏輯圖中的D觸發器的
S
和R
引腳是異步的、電平觸發的。請你寫程序來驗證這個事實。 -
* 減少引腳數量的方法還有很多。有一種可以用一個ADC端口檢測多個按鍵的方法:
通過選擇合適的阻值,當按鍵的狀態組合(包括多個按鍵同時按下)不同時,ADC能讀到不同的電壓,從而實現按鍵狀態的檢測。請你實現這種方案。
-
* TM1638是一款LED與按鍵驅動芯片,有市售模塊可用:
如果你的面包板級設計需要數碼管和按鍵等資源的話,使用這個模塊無疑是很方便的。請你在互聯網上搜索資料,學習使用這個模塊。