碼管協(xié)同實戰(zhàn):消抖、動態(tài)掃描與時間片輪詢)
1. 從“按一下沒反應”說起這個練手項目到底在練什么如果你手里有一塊51單片機開發(fā)板上面通常會有那么幾個按鍵和幾位數(shù)碼管。很多人第一次把按鍵和數(shù)碼管連起來的時候心里想的很簡單按一下加一按一下減一這有什么難的結果代碼燒進去按下按鍵數(shù)碼管要么紋絲不動要么數(shù)字亂跳要么加一加得飛快根本停不下來。這個場景太經(jīng)典了幾乎每個從51單片機入門的人都在這里卡過。這個項目的核心其實不是“加一減一”這個動作本身而是兩個基礎外設的協(xié)同工作按鍵負責輸入數(shù)碼管負責輸出單片機在中間做邏輯處理。聽起來簡單但里面藏著三個關鍵問題——按鍵怎么被可靠地識別、數(shù)碼管怎么被穩(wěn)定地驅(qū)動、兩個任務怎么在一個主循環(huán)里互不打架。把這三個問題搞明白了你后面做更復雜的項目比如按鍵設置參數(shù)、數(shù)碼管顯示多位數(shù)、按鍵長短按區(qū)分功能都會順很多。這篇文章適合誰看如果你剛學完51的GPIO輸出能讓LED閃爍但還沒真正把輸入和輸出串起來做過一個完整的小系統(tǒng)那這個項目就是為你準備的。如果你已經(jīng)做過但效果不理想按鍵響應時好時壞數(shù)碼管有重影或者亮度不均那這篇文章里關于消抖、動態(tài)掃描和狀態(tài)機的部分應該能幫你把問題定位清楚。我會從硬件連接講到代碼結構從按鍵消抖的時機講到數(shù)碼管掃描的頻率盡量把每個“為什么這么做”都說明白。2. 硬件連接按鍵和數(shù)碼管的兩種典型接法2.1 獨立按鍵的電路邏輯與上拉電阻的作用先看按鍵。51開發(fā)板上最常見的按鍵接法是獨立按鍵每個按鍵占用一個IO口。按鍵的一端接單片機引腳另一端接GND。但這里有個關鍵細節(jié)51單片機的P1、P2、P3口內(nèi)部有弱上拉P0口沒有。所以如果你把按鍵接在P1口引腳在按鍵未按下時會被內(nèi)部上拉拉到高電平按下時被拉到GND邏輯是“按下為低電平”。這種接法不需要外部上拉電阻電路簡單。但如果你用的是P0口就必須外接上拉電阻通常選4.7kΩ到10kΩ之間。為什么因為P0口是開漏輸出結構內(nèi)部沒有上拉能力如果不加上拉按鍵未按下時引腳處于浮空狀態(tài)讀到的電平是不確定的可能一會兒是1一會兒是0程序就會誤判。我見過有人把按鍵接在P0口沒加上拉結果數(shù)碼管上的數(shù)字自己亂跳查了半天以為是程序問題其實是硬件浮空導致的。還有一種接法是按鍵一端接VCC另一端接引腳引腳同時接下拉電阻到GND。這種邏輯是“按下為高電平”。兩種接法在代碼里判斷條件相反寫程序之前一定要先確認自己板子上的實際電路。2.2 數(shù)碼管的共陰共陽區(qū)別與限流電阻的選型數(shù)碼管分共陰極和共陽極兩種。共陰極的意思是所有LED的陰極連在一起接GND段選引腳給高電平點亮共陽極則是所有陽極連在一起接VCC段選引腳給低電平點亮。這兩種在代碼里的段碼表是完全相反的用錯了要么全亮要么全滅要么顯示亂碼。限流電阻的選型經(jīng)常被忽略。假設你用5V供電LED正向壓降約1.8V想要每段電流在5mA到10mA之間那么電阻值大約是(5-1.8)/0.008 ≈ 400Ω。實際開發(fā)板上常用330Ω或470Ω。電阻太小LED電流過大長期運行會縮短壽命而且數(shù)碼管整體功耗上升電阻太大亮度不夠在動態(tài)掃描時尤其明顯因為每個數(shù)碼管只亮一小段時間亮度本來就打了折扣。注意如果你用的是動態(tài)掃描方式驅(qū)動多位數(shù)碼管限流電阻是接在段選線上的而不是位選線上。位選線通常直接由三極管或者單片機引腳驅(qū)動用來控制哪一位數(shù)碼管導通。2.3 動態(tài)掃描的位選驅(qū)動為什么不能直接用IO口灌電流多位數(shù)碼管要顯示不同的數(shù)字必須用動態(tài)掃描。同一時刻只讓一位數(shù)碼管導通快速輪流切換利用人眼視覺暫留形成“同時顯示”的效果。位選信號控制哪一位導通段選信號控制這一位顯示什么數(shù)字。問題來了如果數(shù)碼管每一位的公共端直接接單片機IO口當這一位導通時所有點亮的段的電流都會流過這個IO口。假設一位數(shù)碼管顯示數(shù)字“8”8個段全亮每段5mA總電流就是40mA。51單片機單個IO口的灌電流能力通常在20mA左右40mA已經(jīng)超標了。所以實際電路中位選通常用三極管如8550或8050來驅(qū)動單片機IO口只提供基極電流真正的負載電流由三極管承擔。這個細節(jié)在做單個數(shù)碼管的時候不會遇到但一旦做多位數(shù)碼管動態(tài)掃描就必須考慮。我見過有人直接用P2口驅(qū)動四位數(shù)碼管的位選結果數(shù)碼管亮度很暗而且單片機發(fā)熱明顯就是因為IO口被強行拉出了超過額定值的電流。3. 按鍵消抖為什么你按一下數(shù)字會跳好幾下3.1 機械按鍵的抖動本質(zhì)與波形特征機械按鍵在按下和松開的瞬間金屬觸點不會立刻穩(wěn)定接觸而是會彈跳幾次產(chǎn)生一系列快速的高低電平變化。這個抖動時間通常在5ms到20ms之間取決于按鍵的機械結構和使用年限。對于單片機來說它的運行速度是微秒級的在這5到20ms內(nèi)它可以采樣到很多次電平變化如果程序里直接判斷“低電平就加一”那按一次按鍵可能會觸發(fā)好幾次加一操作。你可以用示波器看按鍵引腳的波形按下瞬間會看到一串密集的毛刺然后才穩(wěn)定在低電平。松開時同樣有一串毛刺。這就是為什么你的數(shù)碼管數(shù)字會一次跳好幾個數(shù)。3.2 軟件消抖的兩種實現(xiàn)延時采樣與狀態(tài)機最常用的消抖方法是延時采樣檢測到按鍵引腳為低電平后延時10ms到20ms再次檢測如果還是低電平就確認按鍵按下。這個方法簡單直接適合初學者。但它的缺點是延時期間CPU什么也不做如果你后面要同時做數(shù)碼管掃描這個延時會導致數(shù)碼管閃爍。更好的方法是狀態(tài)機消抖不需要阻塞延時。思路是給每個按鍵維護一個狀態(tài)變量在主循環(huán)的每次迭代中檢測按鍵電平如果連續(xù)多次檢測到相同電平比如連續(xù)3次每次間隔5ms就確認狀態(tài)變化。這樣消抖和數(shù)碼管掃描可以并行進行互不影響。// 狀態(tài)機消抖的簡化示例 #define KEY_STABLE_COUNT 3 unsigned char key_state[2] {1, 1}; // 當前穩(wěn)定狀態(tài) unsigned char key_count[2] {0, 0}; // 計數(shù)器 void key_scan(void) { unsigned char i; for (i 0; i 2; i) { unsigned char current KEY_PIN(i); // 讀取當前電平 if (current ! key_state[i]) { key_count[i]; if (key_count[i] KEY_STABLE_COUNT) { key_state[i] current; key_count[i] 0; if (current 0) { // 確認按下 key_pressed(i); } } } else { key_count[i] 0; } } }這段代碼的核心思想是只有連續(xù)多次讀到同一個新電平才認為狀態(tài)真的變了。這樣抖動期間的電平跳變會被計數(shù)器過濾掉。3.3 松手檢測為什么加一減一要在按鍵釋放時觸發(fā)還有一個容易被忽略的細節(jié)加一減一的操作應該在按鍵按下時觸發(fā)還是釋放時觸發(fā)如果按下時觸發(fā)你按住不放程序會一直檢測到低電平數(shù)字就會連續(xù)加。如果你想要“按一下加一按住不加”就需要在按鍵釋放時觸發(fā)或者檢測“按下沿”。按下沿檢測的邏輯是記錄上一次的按鍵狀態(tài)如果上一次是高電平這一次是低電平說明剛剛按下觸發(fā)一次操作。這樣按住不放也只觸發(fā)一次。實現(xiàn)起來很簡單unsigned char last_key 1; if (last_key 1 current_key 0) { // 檢測到下降沿觸發(fā)加一 } last_key current_key;實際項目中我通常會把消抖和邊沿檢測結合起來消抖確認按下后再判斷是不是從高到低的跳變是的話才執(zhí)行操作。這樣既不會因為抖動誤觸發(fā)也不會因為按住不放連續(xù)觸發(fā)。4. 數(shù)碼管動態(tài)掃描亮度、重影與刷新率的平衡4.1 動態(tài)掃描的時序一位顯示多久才不閃動態(tài)掃描的核心參數(shù)是刷新率。假設你有4位數(shù)碼管每一位顯示2ms一輪下來是8ms刷新率就是125Hz。人眼對75Hz以上的刷新基本感覺不到閃爍所以2ms每位是安全的。如果你把每位時間設成5ms一輪20ms刷新率50Hz有些人就能看出輕微的閃爍尤其是快速移動視線的時候。但每位時間也不能太短。如果每位只給0.5ms雖然刷新率很高但每位數(shù)碼管點亮的時間太短亮度會明顯下降。因為LED的亮度不僅取決于電流還取決于導通時間的占空比。占空比是1/4的話亮度大約只有靜態(tài)顯示的四分之一。所以實際調(diào)試時需要在亮度和刷新率之間找平衡。我的經(jīng)驗是每位1ms到3ms之間比較合適具體看數(shù)碼管的型號和限流電阻的大小。4.2 段碼表的生成共陰共陽的編碼差異段碼表是數(shù)碼管顯示的基礎。以共陰極為例假設段選引腳連接順序是a、b、c、d、e、f、g、dp對應P0口的bit0到bit7。數(shù)字0需要點亮a、b、c、d、e、f不點亮g和dp所以二進制是00111111即0x3F。數(shù)字1點亮b、c即0x06。完整的0到9段碼表如下數(shù)字共陰段碼十六進制共陽段碼十六進制00x3F0xC010x060xF920x5B0xA430x4F0xB040x660x9950x6D0x9260x7D0x8270x070xF880x7F0x8090x6F0x90共陽段碼其實就是共陰段碼的按位取反。如果你不確定自己的數(shù)碼管是共陰還是共陽可以寫個測試程序讓P0口輸出0x00和0xFF看數(shù)碼管是全亮還是全滅就能判斷出來。4.3 消隱處理消除數(shù)碼管切換時的“鬼影”動態(tài)掃描時如果段選和位選的切換順序不對會出現(xiàn)“鬼影”——上一位的數(shù)字會短暫地出現(xiàn)在下一位上。原因是當你切換位選時段選數(shù)據(jù)還是上一位的新選中的數(shù)碼管會瞬間顯示上一位的段碼然后你才更新段選數(shù)據(jù)。解決辦法是先消隱再切換。具體操作是在切換位選之前先把段選口輸出0x00共陰或0xFF共陽讓所有段熄滅然后再切換位選最后輸出新的段碼。這樣雖然每位會有一小段全滅的時間但人眼幾乎察覺不到卻能徹底消除鬼影。void display_scan(void) { static unsigned char digit 0; // 消隱 SEG_PORT 0x00; // 共陰全部熄滅 // 切換位選 DIGIT_PORT digit_select[digit]; // 輸出段碼 SEG_PORT seg_code[display_buf[digit]]; // 切換到下一位 digit (digit 1) % 4; }這個順序不能亂。如果你先輸出段碼再切換位選鬼影就會出現(xiàn)。很多教程里沒強調(diào)這一點導致初學者做出來的數(shù)碼管總是有淡淡的殘影。5. 主循環(huán)架構按鍵掃描和數(shù)碼管刷新怎么共存5.1 時間片輪詢不用定時器也能跑得穩(wěn)初學者最容易犯的錯誤是在主循環(huán)里用delay_ms(10)做按鍵消抖結果數(shù)碼管掃描被阻塞顯示閃爍。正確的做法是把主循環(huán)設計成時間片輪詢結構按鍵掃描和數(shù)碼管刷新各自按照自己的時間間隔執(zhí)行互不阻塞。具體實現(xiàn)可以用定時器產(chǎn)生一個1ms的基準節(jié)拍在定時器中斷里設置標志位主循環(huán)檢測標志位來執(zhí)行對應任務。比如volatile unsigned char tick_1ms 0; volatile unsigned char tick_10ms 0; void timer0_isr(void) interrupt 1 { static unsigned char count 0; TH0 0xFC; // 1ms定時 TL0 0x18; tick_1ms 1; count; if (count 10) { count 0; tick_10ms 1; } } void main(void) { timer0_init(); while (1) { if (tick_1ms) { tick_1ms 0; display_scan(); // 每1ms刷新一位 } if (tick_10ms) { tick_10ms 0; key_scan(); // 每10ms掃描一次按鍵 } } }這樣數(shù)碼管每1ms切換一位4位就是4ms一輪刷新率250Hz非常穩(wěn)定。按鍵每10ms掃描一次配合狀態(tài)機消抖響應也很靈敏。5.2 顯示緩沖區(qū)的設計數(shù)字變化時如何避免閃爍顯示緩沖區(qū)是一個數(shù)組存放當前要顯示的數(shù)字。按鍵加一減一的時候只修改緩沖區(qū)里的值數(shù)碼管掃描函數(shù)從緩沖區(qū)讀取并顯示。這樣數(shù)字的變化和顯示刷新是解耦的不會因為修改數(shù)字導致顯示異常。unsigned char display_buf[4] {0, 0, 0, 0}; // 四位顯示緩沖區(qū) unsigned int counter 0; // 實際計數(shù)值 void update_display(void) { display_buf[0] counter / 1000 % 10; display_buf[1] counter / 100 % 10; display_buf[2] counter / 10 % 10; display_buf[3] counter % 10; }按鍵觸發(fā)時修改counter的值然后調(diào)用update_display()更新緩沖區(qū)。數(shù)碼管掃描函數(shù)只管從緩沖區(qū)取數(shù)顯示不關心數(shù)字是怎么變的。這種分層設計讓代碼邏輯清晰后續(xù)擴展也方便。5.3 邊界處理0減一和9999加一怎么處理如果計數(shù)器是0到9999的范圍減到0再減怎么辦加到9999再加怎么辦有兩種策略一種是循環(huán)回繞0減一變99999999加一變0另一種是飽和處理0減一保持09999加一保持9999。循環(huán)回繞適合做計數(shù)器、時鐘設置這類場景飽和處理適合做參數(shù)調(diào)節(jié)防止超出有效范圍。選擇哪種取決于你的應用需求。代碼實現(xiàn)上循環(huán)回繞用取模運算飽和處理用條件判斷// 循環(huán)回繞 if (key_add) counter (counter 1) % 10000; if (key_sub) counter (counter 9999) % 10000; // 飽和處理 if (key_add counter 9999) counter; if (key_sub counter 0) counter--;我個人的習慣是如果這個數(shù)值代表某種物理量比如溫度設定值用飽和處理更安全如果只是計數(shù)循環(huán)回繞更自然。6. 實測中遇到的幾個坑與排查思路6.1 按鍵反應遲鈍消抖時間設太長的后果有一次我?guī)蛣e人調(diào)代碼他說按鍵要按得很重很久才有反應。我看了一下他的消抖代碼延時設了50ms而且是在中斷里用delay_ms(50)。50ms的消抖時間太長了人正常按鍵的動作大概100ms左右如果消抖占了50ms再加上松手檢測的等待整體響應就變得很遲鈍。消抖時間不是越長越好。10ms到20ms足夠覆蓋絕大多數(shù)按鍵的抖動超過20ms就屬于過度消抖會影響操作手感。而且消抖時間應該根據(jù)按鍵的實際質(zhì)量來調(diào)新按鍵抖動小舊按鍵抖動大但一般不超過20ms。6.2 數(shù)碼管某一位特別暗位選驅(qū)動能力不足有人反映四位數(shù)碼管里有一位特別暗其他三位正常。排查下來發(fā)現(xiàn)那一位的位選直接接了單片機IO口沒有用三極管驅(qū)動。因為那一位顯示的數(shù)字恰好是“8”8個段全亮電流最大IO口拉低時電壓被抬高導致實際加在LED上的電壓降低亮度就暗了。解決辦法很簡單給每一位位選都加上三極管驅(qū)動或者用專門的位選驅(qū)動芯片。如果不想改硬件也可以降低那一位的顯示占空比但這樣整體亮度會下降不是好辦法。6.3 數(shù)字跳變不連續(xù)按鍵掃描和顯示刷新共用變量沖突還有一個隱蔽的坑按鍵掃描和顯示刷新如果共用同一個全局變量而且沒有做保護可能會出現(xiàn)數(shù)據(jù)競爭。比如按鍵中斷里修改了counter主循環(huán)里正在用counter計算顯示緩沖區(qū)讀到的可能是修改到一半的值。在51這種8位機上unsigned int的讀寫不是原子的需要兩條指令。如果中斷在第一條指令后發(fā)生并修改了變量主循環(huán)讀到的就是錯誤的值。解決辦法是在主循環(huán)讀取counter之前先關中斷讀完再開中斷或者用臨時變量緩存。unsigned int get_counter(void) { unsigned int temp; EA 0; temp counter; EA 1; return temp; }這個細節(jié)在簡單的加一減一項目里可能不明顯但一旦數(shù)值變大或者操作變頻繁就可能出現(xiàn)偶發(fā)的顯示錯誤。養(yǎng)成好習慣對共享變量的訪問加保護后面做復雜項目會省很多事。7. 從加一減一延伸出去這個框架還能做什么把按鍵消抖、數(shù)碼管動態(tài)掃描、時間片輪詢這三個模塊搭好之后你會發(fā)現(xiàn)這個框架可以直接復用到很多場景。比如把加一減一改成多級菜單一個按鍵短按切換菜單項另一個按鍵長按進入設置數(shù)碼管顯示不同的參數(shù)。按鍵狀態(tài)機稍微擴展一下加上長按和雙擊檢測就能實現(xiàn)更復雜的交互。再比如把數(shù)碼管換成LCD1602或者OLED顯示的內(nèi)容從數(shù)字變成字符串但主循環(huán)的時間片架構不用變只需要把顯示刷新函數(shù)替換掉。按鍵掃描和消抖的邏輯完全復用。這就是分層設計的好處輸入層、處理層、顯示層各自獨立換掉任何一層都不影響其他層。如果你正在學51單片機我建議你不要急著做那些看起來很炫的項目先把按鍵加數(shù)碼管這個小系統(tǒng)吃透。把消抖的時機、掃描的頻率、消隱的順序、緩沖區(qū)的設計這些細節(jié)都親手調(diào)一遍遇到問題自己排查一遍。這些經(jīng)驗在后面做更復雜的嵌入式項目時會反復用到。我自己在調(diào)試更復雜的系統(tǒng)時遇到顯示異?;蛘甙存I不靈第一反應還是回到這幾個基本點去查消抖夠不夠、掃描時序?qū)Σ粚?、共享變量有沒有保護?;A打牢了上層的東西都是水到渠成的事。