機實戰(zhàn)解析)
簡介這是一份以51單片機為核心的綜合性項目程序文檔適合單片機初學(xué)者、電子愛好者以及正在做課程設(shè)計或入門開發(fā)的人群。項目將1602液晶秒表與簡易計算器功能整合在同一系統(tǒng)中使用4x4矩陣鍵盤輸入直接I/O口方式連接硬件包含數(shù)碼管、LED、蜂鳴器等外設(shè)覆蓋了鍵盤掃描、定時器延時、液晶顯示驅(qū)動、模式切換、四則運算及異常狀態(tài)提示等典型開發(fā)環(huán)節(jié)。文檔包共1個docx文件整體約15KB內(nèi)容以完整C語言源碼和硬件電路說明為主便于讀者按模塊理解并直接移植到實驗板上驗證。目前已有73人瀏覽學(xué)習(xí)。通過該文檔學(xué)習(xí)者可以獲得一個可運行的51單片機綜合示例掌握1602液晶字符顯示、矩陣鍵盤讀取、基于定時器的秒表計時以及計算器運算與錯誤指示功能的設(shè)計思路適合用于鞏固基礎(chǔ)知識并延伸拓展。1. 51單片機液晶秒表與簡易計算器一個項目把定時器、矩陣鍵盤和狀態(tài)機串起來“單片機液晶秒表以及簡易計算器”這個標(biāo)題幾乎是單片機課程設(shè)計里出場率最高的組合之一。它表面上是兩個獨立功能實際做起來卻是一套硬件復(fù)用的經(jīng)典模板同一個4×4矩陣鍵盤在秒表模式下負(fù)責(zé)啟動、暫停、清零在計算器模式下變成數(shù)字鍵和運算符同一塊LCD1602液晶兩種模式共用顯示通道只靠一個模式標(biāo)志位切換解釋。支撐這套系統(tǒng)的三個核心點也正是嵌入式入門必須跨過去的三道坎定時器中斷怎么產(chǎn)生穩(wěn)定時間基準(zhǔn)矩陣鍵盤掃描怎么寫才不會阻塞主循環(huán)1602液晶的初始化時序和刷新策略怎么和業(yè)務(wù)邏輯共存。做完這個項目你會發(fā)現(xiàn)單片機設(shè)計實例里常見的電子鐘、智能門禁、簡易電壓表底層都在復(fù)用同一套按鍵掃描加液晶驅(qū)動的框架。2. LCD1602液晶與矩陣鍵盤的接線和驅(qū)動先把外設(shè)點亮再說2.1 為什么選LCD1602和8線并口接法LCD1602是字符型液晶模塊一屏顯示2行、每行16個字符驅(qū)動方式比點陣屏簡單得多只要按時序往它的數(shù)據(jù)總線上寫命令和字符編碼就行。它支持8線和4線兩種并口模式8線模式一次傳輸一個完整字節(jié)4線模式要分兩次傳高4位和低4位。課程設(shè)計里絕大多數(shù)人直接用8線接法因為51單片機的P0口正好8個I/O接線直觀調(diào)試時用邏輯分析儀看數(shù)據(jù)也方便。雖然多占4個引腳但換來的是驅(qū)動代碼可讀性高排查“花屏”“白屏”這類初始化問題時可以更快定位是哪一位數(shù)據(jù)沒上來。選型上還要注意一點開發(fā)板上的1602引腳順序并不統(tǒng)一有的把RS接在P2.0有的接在P3.5。我一般建議先看原理圖確認(rèn)管腳再改宏定義不要直接抄網(wǎng)上的接線表。后面所有驅(qū)動代碼都圍繞一個假設(shè)展開RS在P2.0RW在P2.1E在P2.2數(shù)據(jù)線在P0。換成其他引腳只需改宏邏輯不變。2.2 管腳對照表怎么接才算接對LCD1602引腳功能接51單片機引腳VSS電源地GNDVDD電源正5VV0對比度調(diào)節(jié)10k電位器滑臂RS寄存器選擇0命令/1數(shù)據(jù)P2.0RW讀寫選擇0寫/1讀P2.1E使能脈沖下降沿鎖存P2.2D0~D7并行數(shù)據(jù)總線P0.0~P0.7A背光正極5VK背光負(fù)極GND這張表里有三個地方容易踩坑。第一P0口內(nèi)部沒有上拉電阻輸出高電平時實際是開漏狀態(tài)必須外接上拉排阻到VCC否則D0~D7的高電平拉不上去液晶顯示的就是亂碼或空白。第二V0不接電位器時對比度可能過高或過低屏上有字也看不清。第三背光A/K不接不會影響顯示但屏幕會暗很多人誤以為液晶壞了。2.3 LCD1602驅(qū)動代碼寫命令、寫數(shù)據(jù)、初始化三步走驅(qū)動1602的核心只有兩個函數(shù)寫命令和寫數(shù)據(jù)。區(qū)別只在RS這一位的電平RS0寫入的是命令比如清屏、設(shè)置顯示模式RS1寫入的是要顯示的字符ASCII碼。RW在整個項目中固定為0因為只寫不讀。E引腳產(chǎn)生一個下降沿液晶模塊在這個沿鎖存總線數(shù)據(jù)。#define LCD_RS P2_0 #define LCD_RW P2_1 #define LCD_E P2_2 #define LCD_DB P0 void lcd_write_cmd(unsigned char cmd) { LCD_RS 0; // 命令模式 LCD_RW 0; // 寫操作 LCD_DB cmd; // 命令字放上數(shù)據(jù)線 LCD_E 1; _nop_(); // 等待總線電平穩(wěn)定 LCD_E 0; // 下降沿鎖存 } void lcd_write_data(unsigned char dat) { LCD_RS 1; // 數(shù)據(jù)模式 LCD_RW 0; LCD_DB dat; LCD_E 1; _nop_(); LCD_E 0; }參數(shù)說明_nop_()是Keil C51提供的空指令執(zhí)行一個機器周期作用只是給總線電平一點穩(wěn)定時間。如果你的編譯器不識別這個函數(shù)可以換成幾條空語句替代。寫命令和寫數(shù)據(jù)的時序順序不能反必須先置RS再給E脈沖否則模塊鎖存到的是錯誤類型的數(shù)據(jù)。初始化函數(shù)是所有顯示問題的第一排查點。上電后1602內(nèi)部寄存器需要時間準(zhǔn)備所以第一步要延時15ms左右然后連續(xù)寫入0x38、0x08、0x01、0x06、0x0C這組命令。void lcd_init(void) { delay_ms(15); // 上電等待內(nèi)部復(fù)位 lcd_write_cmd(0x38); // 8位總線、2行、5x7點陣 delay_ms(5); lcd_write_cmd(0x38); // 再寫一次確保模式穩(wěn)定 delay_ms(1); lcd_write_cmd(0x08); // 關(guān)閉顯示初始化過程中不閃屏 lcd_write_cmd(0x01); // 清屏 delay_ms(2); lcd_write_cmd(0x06); // 寫入字符后地址自動加1 lcd_write_cmd(0x0C); // 開顯示不顯示光標(biāo) }這組命令是標(biāo)準(zhǔn)的1602初始化流程網(wǎng)上能找到的驅(qū)動基本都長這樣。0x38的含義是8位數(shù)據(jù)總線、雙行顯示0x0C可以拆開理解0x0A是顯示開加光標(biāo)0x0E是顯示開加光標(biāo)閃爍課程設(shè)計里一般用0x0C這種純凈的顯示模式。如果初始化完屏幕是白底黑塊先查0x38之前的延時夠不夠再查對比度電位器最后才懷疑代碼。2.4 4×4矩陣鍵盤行列掃描逐列拉低逐行讀入矩陣鍵盤用8個引腳組成4行4列按鍵在行列交叉處。識別按鍵的原理是先把某一列拉低其他列保持高電平然后讀取所有行引腳的電平。如果這一列上有鍵被按下對應(yīng)行就會被拉低這樣行列坐標(biāo)組合起來就唯一確定了一個按鍵。日常見到的51單片機硬件設(shè)計里這個方案占絕大多數(shù)原因很簡單16個獨立按鍵要占16個I/O矩陣鍵盤只占8個。unsigned char key_scan_4x4(void) { unsigned char row, key 0xff; P1 0xEF; // 1110 1111P1.4輸出低電平 if (P1 ! 0xEF) { // 有行被拉低說明有鍵按下 delay_ms(10); // 延時消抖避開按鍵抖動區(qū)間 if (P1 ! 0xEF) { // 二次確認(rèn)仍有鍵按下才處理 for (row 0; row 4; row) { if ((P1 (0x01 row)) 0) { key row * 4; // 第0列的第row行 break; } } while (P1 ! 0xEF); // 等待按鍵釋放 } } // 其余三列需要把P1改成0xDF、0xBF、0x7F邏輯完全一樣 return key; // 0xff表示無按鍵 }這段代碼只演示了第一列的掃描工程上是把它寫成一個for循環(huán)依次把P1賦值為0xEF、0xDF、0xBF、0x7F四輪掃描合成一個完整按鍵值。delay_ms(10)是關(guān)鍵按鍵按下時電平不是瞬間穩(wěn)定而是會跳變幾次這個抖動過程通常持續(xù)5到20ms延時10ms后再次判斷能過濾掉絕大部分誤觸發(fā)。while循環(huán)等待釋放是為了防止一次按下被重復(fù)識別。2.5 鍵值映射同一塊鍵盤怎么當(dāng)兩種設(shè)備用矩陣鍵盤掃描出的行列號只是坐標(biāo)它代表什么功能完全由上層決定。這種分離設(shè)計是這個項目能同時容納秒表和計算器的關(guān)鍵。下面是我常用的一組映射矩陣行列計算器模式秒表模式(0,0)數(shù)字7無功能(0,1)數(shù)字8無功能(1,0)數(shù)字4無功能(2,0)數(shù)字1無功能(3,0)C清零秒表清零(3,1)0無功能(3,2)暫停/繼續(xù)(3,3)運算符啟動映射表放哪里通常是放在一個switch語句里按鍵掃描函數(shù)返回行列號模式分發(fā)函數(shù)根據(jù)當(dāng)前mode決定這個行列號對應(yīng)什么操作。這樣寫的好處是鍵盤掃描邏輯和業(yè)務(wù)邏輯完全解耦后續(xù)要調(diào)整按鍵布局只改映射表一處即可。3. 定時器0產(chǎn)生10ms時間基準(zhǔn)秒表計時的核心邏輯3.1 為什么計時必須用定時器中斷秒表的第一需求是精準(zhǔn)計時。有人會想當(dāng)然地用delay_ms(1000)加變量計數(shù)這個方案在按鈕按下時立刻暴露問題主循環(huán)執(zhí)行到延時函數(shù)時整個CPU泡在空循環(huán)里按鍵掃描完全停擺。按下暫停鍵要等當(dāng)前延時跑完才響應(yīng)秒表就不叫秒表了。定時器中斷解決的是這個結(jié)構(gòu)性矛盾定時器在硬件層面獨立計數(shù)計數(shù)溢出后觸發(fā)中斷主循環(huán)該干嘛還干嘛按鍵掃描、液晶刷新、計算器邏輯各干各的互不阻塞。STC51單片機有兩個16位定時器這里用定時器0。關(guān)鍵是工作方式選擇方式1是16位不自動重裝重裝動作要寫在中斷服務(wù)程序里方式2是8位自動重裝但計數(shù)范圍只有0到255產(chǎn)生10ms時基需要多進(jìn)幾十次中斷。課程設(shè)計里常用方式1一次中斷處理10ms中斷頻率不高留給主循環(huán)的時間充足。3.2 初值計算12MHz晶振下為什么是0xD8F051單片機的機器周期是晶振周期的12分頻。12MHz晶振下一個機器周期恰好1微秒定時器每計數(shù)一次就是1微秒。要產(chǎn)生10ms時基需要計數(shù)10000次。16位定時器從0計數(shù)到65535溢出所以初值應(yīng)該是65536減10000等于55536十六進(jìn)制是0xD8F0。這個值拆成高8位和低8位高字節(jié)0xD8寫入TH0低字節(jié)0xF0寫入TL0。定時器初始化代碼void timer0_init(void) { TMOD 0xF0; // 只改低4位避免影響定時器1的配置 TMOD | 0x01; // 定時器0方式116位計數(shù) TH0 0xD8; // 初值高8位 TL0 0xF0; // 初值低8位 ET0 1; // 允許定時器0中斷 EA 1; // 打開總中斷開關(guān) TR0 1; // 啟動定時器0 }參數(shù)說明TMOD的低4位控制定時器0高4位控制定時器1所以先清低4位再或上新值是規(guī)范做法。ET0和EA缺一不可很多人只寫了ET0忘了EA中斷根本進(jìn)不去。TR0是運行控制位置1才開始計數(shù)。如果你用的是STC12系列等1T單片機機器周期是晶振周期本身同樣的晶振下初值要重新算不能照搬0xD8F0。3.3 中斷服務(wù)程序重裝初值、累加時間、喚醒主循環(huán)volatile unsigned char second_flag 0; volatile unsigned int sys_tick 0; unsigned char second_cnt 0; void timer0_isr(void) interrupt 1 { TH0 0xD8; // 手動重裝初值 TL0 0xF0; sys_tick; // 每10ms加一次 if (sys_tick 100) { // 100個10ms就是1秒 sys_tick 0; second_flag 1; // 置標(biāo)志告訴主循環(huán)可以刷秒了 if (second_cnt 99) // 秒表顯示上限99秒 second_cnt; } }中斷服務(wù)函數(shù)里不能直接調(diào)用液晶刷新函數(shù)。1602的寫時序包含多步IO操作累計耗時幾十微秒放在中斷里會延長中斷處理時間導(dǎo)致下一次定時器計數(shù)錯位時間基準(zhǔn)慢慢漂移。正確做法是中斷里只置second_flag主循環(huán)檢測到這個標(biāo)志后再去刷新液晶。volatile關(guān)鍵字不能省主循環(huán)和中斷共享這些變量編譯器可能會把變量優(yōu)化到寄存器里加volatile強制每次從內(nèi)存讀取。還有一個細(xì)節(jié)方式1不是自動重裝TH0和TL0的重裝指令本身也占用幾個機器周期這會讓每次中斷周期略大于10ms。所以實際項目里會把初值微調(diào)比如改成0xD8F4左右用示波器或邏輯分析儀量一下秒脈沖再校準(zhǔn)。課程設(shè)計階段只要誤差不明顯0xD8F0完全夠用。3.4 秒表狀態(tài)機啟動、暫停、清零三種狀態(tài)遷移秒表功能用狀態(tài)機組織比堆一坨if else清晰得多。定義三個狀態(tài)STOP停止、RUN計時中、PAUSE暫停。按下啟動/暫停鍵狀態(tài)按規(guī)則遷移按下清零鍵回到STOP并把計時變量歸零。typedef enum {STOP, RUN, PAUSE} stopwatch_state_t; stopwatch_state_t state STOP; void stopwatch_key_proc(unsigned char key) { if (key KEY_TOGGLE) { // 啟動/暫停鍵 if (state RUN) state PAUSE; else if (state PAUSE) state RUN; else state RUN; } else if (key KEY_RESET) { // 清零鍵 state STOP; second_cnt 0; sys_tick 0; } }為什么用狀態(tài)機而不是直接寫“按下就計時再按就停止”因為狀態(tài)遷移是有條件有順序的狀態(tài)機把每個狀態(tài)的合法遷移畫清楚代碼就不會出現(xiàn)“清零后還在計時”這類邏輯漏洞。你要是之后想加計次功能只需要在狀態(tài)機里加一條遷移邊不用動其他邏輯。4. 簡易計算器的邏輯與顯示從按鍵輸入到結(jié)果輸出4.1 按鍵分發(fā)一個process_key搞定兩種模式主循環(huán)里掃描到按鍵后不直接處理業(yè)務(wù)而是交給一個統(tǒng)一的分發(fā)函數(shù)根據(jù)當(dāng)前是秒表模式還是計算器模式調(diào)用對應(yīng)的處理函數(shù)。這是把兩個功能塞進(jìn)同一套硬件的最干凈做法。void process_key(unsigned char key) { if (mode MODE_STOPWATCH) { stopwatch_key_proc(key); } else if (mode MODE_CALC) { calc_key_proc(key); } }mode是一個全局標(biāo)志位由一個專門的模式切換鍵翻轉(zhuǎn)。比如按D鍵mode從秒表切到計算器同時調(diào)用lcd清屏防止兩套界面內(nèi)容互相殘留。這種設(shè)計的擴(kuò)展性很好以后加溫度計模式、電壓表模式只需要加一個else if分支。4.2 簡易計算器的能力邊界支持到什么程度算合理標(biāo)題里寫了“簡易計算器”這四個字定義了功能邊界。數(shù)字輸入限制為4位只支持加減乘除不涉及優(yōu)先級和括號結(jié)果限制在32位無符號整數(shù)范圍內(nèi)為什么不用int51單片機是16位編譯器int類型只有16位最大能表示6553512加34還能應(yīng)付9999乘9999直接溢出。所以操作數(shù)和結(jié)果都用unsigned long32位無符號最大約42億4位數(shù)乘4位數(shù)不會越界。除0要單獨判斷顯示ERR比顯示一個錯誤數(shù)值更符合實際預(yù)期。4.3 數(shù)字輸入與操作數(shù)累積num*10digit是怎么來的計算器每次按下數(shù)字鍵都要把新數(shù)字追加到當(dāng)前操作數(shù)末尾。比如當(dāng)前操作數(shù)是12按下3期望得到123。實現(xiàn)方式是num num * 10 digit。這個式子看起來簡單但它隱含了一個前提操作數(shù)必須足夠大不能因為累加溢出而提前截斷這也是上面堅持用unsigned long的原因。unsigned long num1 0, num2 0; unsigned long result 0; unsigned char op_flag 0; // 0輸入第一個數(shù)1輸入第二個數(shù) unsigned char digit_count 0; void calc_input_digit(unsigned char d) { if (digit_count 4) { if (op_flag 0) { num1 num1 * 10 d; } else { num2 num2 * 10 d; } digit_count; } }參數(shù)說明digit_count用來限制最大4位輸入防止用戶連按數(shù)字鍵把操作數(shù)推到溢出邊界。op_flag用來區(qū)分當(dāng)前輸入的是第一個還是第二個操作數(shù)。按運算符之前輸入的是num1按完運算符之后輸入的是num2。如果沒有這個標(biāo)志位兩個數(shù)字會混在一起這是初學(xué)者寫計算器最常見的錯誤。4.4 運算符與等號處理等號按下才計算運算符按鍵不直接計算結(jié)果只記錄操作符類型把op_flag置1并把digit_count清零讓后續(xù)數(shù)字鍵從個位重新累加。等號按下時才根據(jù)op_flag執(zhí)行對應(yīng)運算。void calc_execute(void) { if (op_flag ) { result num1 num2; } else if (op_flag -) { result (num1 num2) ? (num1 - num2) : 0; } else if (op_flag *) { result num1 * num2; } else if (op_flag /) { if (num2 0) { calc_show_error(); // 1602顯示 ERR return; } result num1 / num2; } num1 result; // 結(jié)果回填支持連續(xù)計算 num2 0; op_flag 0; digit_count 0; }這里有幾個設(shè)計決策值得一提。減法結(jié)果如果小于0unsigned long無法表示負(fù)數(shù)直接顯示0比顯示亂碼好這是簡易計算器的妥協(xié)。除法判斷num2為0后彈出錯誤標(biāo)志不會繼續(xù)執(zhí)行后面的結(jié)果回填。num1回填result這一步很關(guān)鍵算完123446后再按運算符和數(shù)字就是基于46繼續(xù)算符合日常計算器“連算”的操作習(xí)慣。4.5 1602兩行排版第一行表達(dá)式第二行結(jié)果1602只有兩行每行16字符顯示布局要精打細(xì)算。我的方案是第一行滾動顯示輸入過程第二行顯示結(jié)果。比如輸入“1234”第一行顯示“1234”第二行顯示“46”。void calc_display(void) { lcd_write_cmd(0x80); // 光標(biāo)定位到第一行首地址 lcd_put_str(1234 ); // 實際是格式化拼接 lcd_write_cmd(0xC0); // 光標(biāo)定位到第二行首地址 lcd_put_str(46 ); }字符型轉(zhuǎn)換不要用sprintfKeil C51的printf系列函數(shù)會拉進(jìn)整個stdio庫占2KB左右ROM。更常見的做法是手寫一個整形轉(zhuǎn)字符串函數(shù)把數(shù)字一位一位取出來轉(zhuǎn)成ASCII碼再送液晶。刷新時機上計算器的顯示只在按鍵按下時才需要更新不像秒表每秒自動刷所以直接放在按鍵處理函數(shù)末尾調(diào)用即可。5. STC單片機燒錄、Proteus仿真與調(diào)試技巧5.1 用STC-ISP把hex文件燒進(jìn)單片機程序編譯通過后會生成hex文件。STC單片機的下載工具是STC-ISP流程固定芯片型號選擇STC89C52RC或STC89C52串口選擇設(shè)備管理器里識別到的COM口打開hex文件點擊下載后立刻給單片機斷電再上電完成冷啟動。這個冷啟動動作是STC下載協(xié)議的要求漏掉它就會一直停在“正在檢測目標(biāo)單片機”的界面。燒錄完成后按一下復(fù)位鍵程序才開始運行。5.2 Proteus仿真里最隱蔽的坑晶振頻率和時序仿真Proteus仿真時雙擊單片機芯片可以把晶振頻率改成12MHz。如果你用的是STC89C52ISP軟件里的硬件選項寫著12T模式仿真模型默認(rèn)也是12T兩者一致才能跑出正確秒表。很多人從仿真轉(zhuǎn)實物后發(fā)現(xiàn)秒表快了一倍原因往往是實物開發(fā)板上用的是11.0592MHz晶振而代碼按12MHz算的初值。定時器初值必須按實際晶振算這是計時類項目的第一原則。5.3 三個必查的隱蔽問題第一P0口上拉。Proteus里不接上拉有時也能顯示實物必須接否則液晶白屏。第二1602初始化白屏。先跑一個最小驗證程序只清屏再顯示固定字符“TEST”排除驅(qū)動問題后再接業(yè)務(wù)邏輯。第三秒表跳秒。排查方向先看按鍵消抖的delay_ms(10)是不是把主循環(huán)拖久了只要中斷里的second_flag能在1秒內(nèi)被消費掉跳秒就不會發(fā)生。跑通基礎(chǔ)功能后可以試著把按鍵消抖改成狀態(tài)機版本不阻塞主循環(huán)把顯示刷新改成時間片輪詢1602的寫操作都放在固定節(jié)奏里執(zhí)行。這類改動不會改變標(biāo)題里的功能但會讓代碼離工程實踐更近一步。本文還有配套的精品資源點擊獲取