:矩陣鍵盤掃描與雙棧表達式求值)
簡介基于STM32F103RCT6微控制器設計實現(xiàn)的簡易計算器完整工程包面向嵌入式初學者和參加電子設計競賽的學生解決從零搭建STM32計算器項目時外設驅(qū)動、按鍵掃描、LCD顯示與運算邏輯整合的難題。工程基于Keil uVision開發(fā)曾獲校級電子設計競賽一等獎涵蓋Cortex-M3內(nèi)核配置、GPIO與中斷管理、HAL庫驅(qū)動編寫等關鍵知識點具備較高的參考價值。包內(nèi)共998個文件壓縮包約27.86MB。563個C源文件和247個H頭文件構(gòu)成完整源碼.s啟動文件與.uvprojx工程文件幫助快速打開項目.hex/.axf可執(zhí)行文件可直接燒錄驗證另有.lib數(shù)學庫、.map映射文件等便于分析程序結(jié)構(gòu)。目前已有882人學習下載適合希望深入STM32實戰(zhàn)、參考完整競賽方案或?qū)W習代碼組織方式的讀者。1. 一塊 256KB Flash 的單片機做計算器誤打誤撞踩中了嵌入式系統(tǒng)的門檻直接擺一個有點反常規(guī)的結(jié)論簡易計算器這個需求用 STM32F103RCTx 屬于典型的資源過剩但資源過剩恰恰是這個項目最大的價值。Cortex-M3 內(nèi)核跑到 72MHz256KB Flash、48KB SRAM、LQFP64 引出 51 個 GPIO做四則運算浪費得離譜可它正好卡在“點燈太簡單、做產(chǎn)品又不夠”的尷尬區(qū)間。真正讓新手和中級工程師拉開差距的是從矩陣鍵盤到 LCD 顯示再到表達式解析這一整條數(shù)據(jù)通路而不是某個外設的單點操作。這篇文章從選型開始講把硬件接線、時鐘配置、輸入狀態(tài)機、雙棧求值一路走完最后落到怎么用示波器和 GPIO 翻轉(zhuǎn)驗證時序。適合正在從裸機點燈往小型系統(tǒng)設計過渡的人也適合給課程設計找一個能寫清楚、能拍視頻演示的完整方案。2. 內(nèi)存、引腳與時鐘STM32F103RCTx 最小系統(tǒng)到底怎么搭2.1 LQFP64 的引腳賬為什么 RCTx 比 C8T6 更從容F103 系列里最常被拿來對比的是 C8T6 和 RCTx。C8T6 是 LQFP48 封裝64KB Flash、20KB SRAMGPIO 引出 37 個RCTx 則是 LQFP64256KB Flash、48KB SRAMGPIO 引出 51 個。計算器的外設需求并不復雜16 個矩陣按鍵占 8 根 IOLCD1602 用 4 線模式占 6 根 IO加上預留的 SWD 調(diào)試口和幾個指示燈C8T6 的 37 個引腳理論上也夠用。但真正讓 RCTx 顯得從容的是封裝和引腳分布PA0 到 PA15、PB0 到 PB15、PC0 到 PC15 排列整齊可以把矩陣鍵盤的行列分到兩個完整端口上掃描代碼不用做位運算拼接直接把一個端口的 ODR 拉低再讀另一個端口的 IDR 就行。這種“端口天然對齊”的好處在寫掃描程序的時候會明顯地感受到。選 RCTx 還有一個隱藏理由48KB SRAM 對表達式解析是一個很舒服的上限。字符串緩沖區(qū)開 64 字節(jié)、運算符棧開 32 字節(jié)、數(shù)字棧開 32 個 double每個 8 字節(jié)共 256 字節(jié)加起來不到 1KB剩下的 SRAM 想加歷史記錄、菜單界面甚至浮點格式化輸出都寬裕。如果換成 C8T6 的 20KB代碼密集起來之后堆棧碰撞的概率會高不少。做計算器這種教學性質(zhì)的項目沒必要在第一步就給自己設限。2.2 給計算器分配引腳矩陣鍵盤與 LCD1602 的接線表硬件設計遵循一個原則鍵盤的行和列各占一個完整端口LCD 的數(shù)據(jù)線按順序貼在另一個端口的低 4 位或高 4 位這樣固件里能直接讀寫寄存器不用逐個 bit 映射。常見的分配方式如下可以直接抄用。模塊信號引腳說明矩陣鍵盤 4x4行 R0-R3PC0-PC3開漏輸出平時輸出高掃描時逐行拉低矩陣鍵盤 4x4列 C0-C3PC4-PC7上拉輸入讀 IDR 高 4 位判斷按下位置LCD1602DB4-DB7PB0-PB34 線模式數(shù)據(jù)線引腳順序必須與代碼一致LCD1602RSPB40命令 1數(shù)據(jù)LCD1602EPB5下降沿鎖存數(shù)據(jù)預留SWDPA13/PA14調(diào)試下載不可挪作他用注意 PF0/PF1 和 PD0/PD1 在很多 LQFP64 封裝上也能引出但 PD0/PD1 是 OSC_IN/OSC_OUT 的復用位置接了外部 8MHz 晶振之后這兩個引腳就不可用了。表格里鍵盤和 LCD 不碰 PA 口目的就是把 PA 的調(diào)試口完整保留下來。2.3 時鐘樹配置把 8MHz HSE 拉到 72MHz 而不是 64MHz計算器這種低速交互設備對 CPU 頻率不敏感但系統(tǒng)時鐘會影響串口波特率誤差、定時器 tick 精度和 LCD 延時函數(shù)的計算方式。最穩(wěn)妥的組合是 8MHz 無源晶振 PLL 倍頻到 72MHzFlash 等待周期設 2。標準外設庫風格的配置如下。void RCC_Configuration(void) { RCC_DeInit(); RCC_HSEConfig(RCC_HSE_ON); /* 啟用外部高速晶振 */ while (RCC_GetFlagStatus(RCC_FLAG_HSERDY) RESET); RCC_HCLKConfig(RCC_SYSCLK_Div1); /* AHB SYSCLK 72MHz */ RCC_PCLK1Config(RCC_HCLK_Div2); /* APB1 36MHz注意不能超36MHz上限 */ RCC_PCLK2Config(RCC_HCLK_Div1); /* APB2 72MHz */ FLASH_PrefetchBufferCmd(FLASH_PrefetchBuffer_Enable); FLASH_SetLatency(FLASH_Latency_2); /* 72MHz必須配2個等待周期 */ RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); /* 8MHz * 9 72MHz */ RCC_PLLCmd(ENABLE); while (RCC_GetFlagStatus(RCC_FLAG_PLLRDY) RESET); RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); while (RCC_GetSYSCLKSource() ! 0x08); /* 0x08 表示 PLL 作為系統(tǒng)時鐘 */ }參數(shù)上有兩個常見坑。一個是 APB1 的頻率上限是 36MHz如果分頻配置寫錯USART2/3、I2C1/2 和定時器 2-7 的時鐘會直接越界表現(xiàn)為外設偶發(fā)不工作。另一個是 Flash 等待周期72MHz 必須設FLASH_Latency_2設成 1 會出現(xiàn)取指錯誤現(xiàn)象是代碼隨機跑飛或者某些函數(shù)調(diào)用時死機。后面接 GPIO 時RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | GPIOB | GPIOC, ENABLE)一步把三個端口時鐘全打開別一個一個寫邏輯一樣但代碼少三行。3. 矩陣鍵盤掃描、消抖與事件分發(fā)計算器的輸入層怎么組織3.1 從按下到入隊按鍵狀態(tài)機怎么定義很多人寫按鍵掃描習慣用一個GPIO_ReadInputDataBit加Delay_ms(20)的流程檢測到低電平先延時再確認。這個寫法在計算器上能用但有個隱患消抖期間整個系統(tǒng)被阻塞如果此時 LCD 正在輸出一幀數(shù)據(jù)會出現(xiàn)顯示閃爍。更接近工程實踐的做法是把按鍵邏輯拆成離散狀態(tài)每個狀態(tài)只在固定的掃描周期內(nèi)判斷一次條件是否滿足把消抖時間變成狀態(tài)遷移的時間門檻。typedef enum { KEY_IDLE, /* 無按鍵按下 */ KEY_DEBOUNCE, /* 檢測到按下等待消抖確認 */ KEY_PRESSED, /* 確認按下已經(jīng)派發(fā)事件 */ KEY_RELEASE_WAIT /* 等待釋放避免一次按壓重復觸發(fā) */ } KeyState_t; KeyState_t keyState KEY_IDLE; uint32_t keyTime 0; /* 記錄狀態(tài)進入時刻 */狀態(tài)遷移規(guī)則很簡單IDLE 掃描到有鍵按下記錄時間進 DEBOUNCEDEBOUNCE 中兩次間隔 10ms 再掃描若按鍵仍保持按下判定有效并派發(fā)事件進 PRESSEDPRESSED 里按鍵一旦釋放回 IDLE。這個狀態(tài)機對一次“按壓-釋放”只產(chǎn)生一個事件Double 按鍵和連發(fā)都由上層處理。3.2 非阻塞矩陣掃描用 HAL_GetTick 做消抖矩陣掃描的核心操作是逐行輸出低電平然后讀列。下面給出非阻塞寫法掃描頻率由外部主循環(huán)控制每次調(diào)用只做一次行掃描或狀態(tài)判斷不等待。uint8_t MatrixScan_GetKey(void) { static uint8_t activeRow 0; uint8_t col 0; GPIO_Write(GPIOC, ~(0x01u activeRow)); /* 當前行拉低其余行保持高 */ col (GPIO_ReadInputData(GPIOC) 4) 0x0F; /* 讀 PC4-PC7 */ if (col ! 0x0F) { for (uint8_t i 0; i 4; i) { if ((col (0x01u i)) 0) { return activeRow * 4 i; /* 鍵號 行 * 4 列 */ } } } activeRow (activeRow 1) % 4; /* 換下一行周而復始 */ return 0xFF; /* 無按鍵 */ }矩陣掃描有個經(jīng)典細節(jié)行引腳必須先寫成輸出模式并且初始輸出高電平再在執(zhí)行這句代碼時把目標位清零。上面GPIO_Write(~(0x01 activeRow))的原型是GPIO_Write(GPIO_TypeDef* GPIOx, uint16_t PortVal)它寫的是整個 ODR。參數(shù)0x01 activeRow對應 PC0、PC1、PC2、PC3取反后其余行輸出高當前行輸出低列引腳 PC4-PC7 保持輸入上拉。返回值約定0xFF表示沒有鍵按下使用時先判斷再處理。狀態(tài)機的消抖邏輯可以寫成獨立函數(shù)每 10ms 由 SysTick 或者定時器中斷觸發(fā)一次void KeyScan_Tick(void) /* 每10ms調(diào)用一次 */ { uint8_t key MatrixScan_GetKey(); switch (keyState) { case KEY_IDLE: if (key ! 0xFF) { keyTime HAL_GetTick(); keyState KEY_DEBOUNCE; } break; case KEY_DEBOUNCE: if (key 0xFF) { keyState KEY_IDLE; /* 抖動或誤觸直接復位 */ } else if ((HAL_GetTick() - keyTime) 10) { KeyEvent_Send(key); /* 觸發(fā)事件寫入隊列 */ keyState KEY_PRESSED; } break; case KEY_PRESSED: if (key 0xFF) { keyState KEY_RELEASE_WAIT; keyTime HAL_GetTick(); } break; case KEY_RELEASE_WAIT: if (key ! 0xFF) { keyState KEY_PRESSED; /* 中途又被按下忽略 */ } else if ((HAL_GetTick() - keyTime) 20) { keyState KEY_IDLE; /* 確認完全釋放 */ } break; } }這里的消抖時間固定在 10ms。機械按鍵的抖動通常在 5-15ms 之間10ms 是一個平衡值如果用的按鍵比較舊或者按壓力度不穩(wěn)可以把 10改成 20代價是長按響應變慢計算器場景完全可接受。KEY_RELEASE_WAIT這個狀態(tài)的目的是保證一次物理按壓最多發(fā)出一次事件否則手指輕微抖動會讓表達式里重復出現(xiàn)同一個數(shù)字。3.3 LCD1602 4 線模式最小驅(qū)動代碼與初始化序列LCD1602 的數(shù)據(jù)引腳只有 8 根4 線模式只接 DB4-DB7能省 4 個 IO。寫一個字節(jié)時先發(fā)高 4 位再發(fā)低 4 位每次發(fā)完都產(chǎn)生一個下降沿作為鎖存信號。最小驅(qū)動代碼如下。#define LCD_RS_PORT GPIOB #define LCD_RS_PIN GPIO_Pin_4 #define LCD_E_PORT GPIOB #define LCD_E_PIN GPIO_Pin_5 #define LCD_DATA_PORT GPIOB #define LCD_DATA_MASK 0x0F /* PB0-PB3 對應 DB4-DB7 */ void LCD_WriteNibble(uint8_t nibble) { GPIO_Write(LCD_DATA_PORT, (GPIO_ReadOutputData(LCD_DATA_PORT) ~LCD_DATA_MASK) | (nibble 0x0F)); GPIO_SetBits(LCD_E_PORT, LCD_E_PIN); /* E 拉高 */ GPIO_ResetBits(LCD_E_PORT, LCD_E_PIN); /* E 拉低下降沿鎖存數(shù)據(jù) */ } void LCD_WriteByte(uint8_t rs, uint8_t data) { GPIO_WriteBit(LCD_RS_PORT, LCD_RS_PIN, rs ? Bit_SET : Bit_RESET); LCD_WriteNibble(data 4); /* 先高四位 */ LCD_WriteNibble(data 0x0F); /* 再低四位 */ }初始化序列是 1602 芯片手冊里固定的流程不同廠商的模組略有差異但下面這組在絕大多數(shù)屏上都能穩(wěn)定工作。void LCD_Init(void) { HAL_Delay(50); LCD_WriteNibble(0x03); HAL_Delay(5); LCD_WriteNibble(0x03); HAL_Delay(5); LCD_WriteNibble(0x03); HAL_Delay(5); LCD_WriteNibble(0x02); HAL_Delay(5); /* 切換到 4 線模式 */ LCD_WriteByte(0, 0x28); /* 4 線、雙行、5x8 點陣 */ LCD_WriteByte(0, 0x0C); /* 顯示開光標關閃爍關 */ LCD_WriteByte(0, 0x01); /* 清屏 */ LCD_WriteByte(0, 0x06); /* 寫入后地址自動加一 */ HAL_Delay(2); }初始化序列里最容易被忽略的是開頭那三個0x03。上電后 1602 處于 8 線模式必須先連續(xù)發(fā)送三次0x03讓控制器對齊到 8 位指令邊界再發(fā)0x02切到 4 線。如果上來直接發(fā)0x28屏幕大概率顯示亂碼。實際排錯時用邏輯分析儀掛在 E 引腳上看每次LCD_WriteNibble后 E 上必須有一個完整的下降沿沒有下降沿就是 GPIO 模式配錯最常見的是把 E 引腳設成了開漏輸出且外部沒接上拉。3.4 一個簡單的事件派發(fā)按鍵值到表達式字符把按鍵值映射成表達式字符是一步很容易被低估的邏輯。計算器上“1”鍵按下后不僅要往顯示緩沖區(qū)寫字符還要在內(nèi)存中維護一個狀態(tài)當前輸入的是操作數(shù)還是運算符、是否已經(jīng)按過小數(shù)點、表達式長度是否超過 LCD 一行 16 字符的限制。我一般用一個 64 字節(jié)的環(huán)形字符數(shù)組做輸入緩沖配合一個光標位置索引每次按鍵事件到達時執(zhí)行簡單的 switch-case 映射。char exprBuf[64]; /* 表達式緩沖區(qū) */ uint8_t exprLen 0; void KeyEvent_Handle(uint8_t keyCode) { char ch KeyToChar(keyCode); /* 0-9 - 0-910-11--12-*13-/14-15-C */ if (ch C) { exprLen 0; exprBuf[0] \0; LCD_Clear(); return; } if (exprLen 63) return; /* 防溢出超出直接丟棄 */ exprBuf[exprLen] ch; exprBuf[exprLen] \0; LCD_ShowString(0, 0, exprBuf); /* 每次按鍵刷新一次顯示 */ }KeyToChar是一張查表函數(shù)不建議直接寫 16 個 case用const char keyMap[16] {...}更清晰。緩沖區(qū)溢出檢查放在寫入之前即使連續(xù)快速按鍵也不會破壞棧。顯示刷新策略是每次都把整行重繪計算器一屏最多 16 個字符清屏加寫字符串總耗時在毫秒級不會產(chǎn)生可見閃爍。4. 從按鍵流到運算結(jié)果簡易計算器的表達式解析與雙棧求值4.1 先選算法三種四則求值方案怎么取舍輸入層交出的是一個中綴表達式字符串比如12*345/9。把它變成結(jié)果有幾種常見路線各自的復雜度差異很大。方案核心思路代碼量適用場景直接求值掃描時遇到數(shù)字就累計遇到運算符立即按當前優(yōu)先級計算最少只支持兩個數(shù)字一次運算調(diào)度場算法 雙棧運算符進棧時先彈高優(yōu)先級運算符結(jié)合數(shù)字棧邊轉(zhuǎn)邊算中等完整四則運算、帶括號、連續(xù)運算遞歸下降按語法規(guī)則拆表達式、項、因子逐層遞歸較多需要錯誤定位、支持一元運算符和復雜語法簡易計算器只用 、-、*、/ 和括號調(diào)度場算法最合適。它在一次掃描中完成中綴到后綴的轉(zhuǎn)換和求值不需要把后綴表達式另外存一份內(nèi)存占用比遞歸下降小邏輯又比直接求值完整得多。STM32F103RCTx 的 48KB SRAM 跑這個算法綽綽有余但算法精簡的意義不在省內(nèi)存而是讓代碼更容易被逐行審查和測試。4.2 雙棧求值器的 C 實現(xiàn)雙棧的意思是準備一個數(shù)字棧存操作數(shù)、一個運算符棧存操作符和左括號。掃描表達式時數(shù)字直接進數(shù)字棧運算符則要先把棧頂所有優(yōu)先級不低于當前運算符的運算彈出來算完再把自己壓棧遇到右括號彈到最近的左括號為止。double Calc_Eval(const char *expr, int *ok) { double numStack[32]; char opStack[32]; int topN 0, topO 0; int i 0; *ok 1; while (expr[i] ! \0) { if (expr[i] 0 expr[i] 9) { double v 0; while (expr[i] 0 expr[i] 9) { v v * 10 (expr[i] - 0); i; } if (expr[i] .) { /* 處理小數(shù)部分一位位縮小 */ i; double frac 0.1; while (expr[i] 0 expr[i] 9) { v (expr[i] - 0) * frac; frac * 0.1; i; } } if (topN 32) { *ok 0; return 0; } numStack[topN] v; continue; } if (expr[i] () { opStack[topO] (; i; continue; } if (expr[i] )) { while (topO 0 opStack[topO-1] ! () { if (!Calc_Pop(numStack, topN, opStack, topO, ok)) return 0; } if (topO 0) topO--; /* 彈出左括號 */ i; continue; } if (expr[i] || expr[i] - || expr[i] * || expr[i] /) { while (topO 0 opStack[topO-1] ! ( Calc_Priority(opStack[topO-1]) Calc_Priority(expr[i])) { if (!Calc_Pop(numStack, topN, opStack, topO, ok)) return 0; } opStack[topO] expr[i]; i; continue; } i; /* 遇到空格等非法字符直接跳過 */ } while (topO 0) { if (!Calc_Pop(numStack, topN, opStack, topO, ok)) return 0; } if (topN ! 1) { *ok 0; return 0; } return numStack[0]; }運算符優(yōu)先級用Calc_Priority實現(xiàn)加減返回 1乘除返回 2左括號返回 0。彈棧運算函數(shù)Calc_Pop從數(shù)字棧取兩個操作數(shù)按運算符計算把結(jié)果壓回數(shù)字棧。這里最關鍵的一行是彈棧條件它保證相同優(yōu)先級的運算符也按從左到右的順序求值。數(shù)值棧用double53 位尾數(shù)在 16 位 LCD 顯示范圍內(nèi)誤差可以忽略如果只用int10/3會直接得到 3這和真實計算器行為不符。4.3 邊界與緩沖除零、連續(xù)運算、溢出怎么兜住雙棧算法暴露出來的第一類問題是除零。10/(5-5)在掃描到右括號時才執(zhí)行除法此時才報錯已經(jīng)太晚界面需要返回一個可辨識的錯誤碼。我在Calc_Pop里遇到除數(shù)為 0 時直接置*ok 0并返回上層拿到 0 后顯示Error而不是繼續(xù)打印一個無窮大。第二類問題是表達式末尾缺操作數(shù)比如12*。掃描結(jié)束后運算符棧還有乘號但數(shù)字棧里只剩 1Calc_Pop取數(shù)時會越界。棧數(shù)組大小 32 只是上限真正的防御是靠函數(shù)入口處的topN 2判斷凡是彈棧前數(shù)字棧不足兩個就判定表達式非法。這類錯誤在人工測試時很難把所有組合敲一遍建議寫一個簡單的參數(shù)化測試函數(shù)把12、12*、1/0、(12)*3、12*3-4/2這些用例一次性跑完。第三類風險是數(shù)值溢出。計算器用 double 類型1e308 * 1e308會得到 inf顯示層需要判斷isinf(result)或isnan(result)。另外 LCD1602 只有 16 列超過 16 位的數(shù)字字符串需要進行滾動顯示或改用科學計數(shù)法格式化常見的做法是sprintf(buf, %.6g, result)它能自動在小數(shù)位過多時切換指數(shù)格式。5. 用 GPIO 翻轉(zhuǎn)測出按鍵處理耗時再給“掃描-顯示-計算”解耦5.1 用 GPIO 翻轉(zhuǎn)驗證掃描周期和 LCD 寫入時序按鍵消抖和 LCD 驅(qū)動都是時間敏感邏輯憑感覺調(diào)參容易留下隱患。我習慣在掃描函數(shù)開頭翻轉(zhuǎn)一次某個空閑 GPIO比如 PA8然后讓示波器或者邏輯分析儀抓 PA8 的波形。正常運行時 PA8 應該輸出一組周期穩(wěn)定的方波周期就是主循環(huán)調(diào)用KeyScan_Tick的間隔。如果波形寬窄不一說明主循環(huán)里有長延時阻塞如果周期漂移超過 20%要考慮HAL_Delay的使用位置是否合理。LCD 時序的驗證方法可以把 PA8 的翻轉(zhuǎn)挪到LCD_WriteNibble函數(shù)里同時抓 E 引腳和 PA8。兩個下降沿之間的時間差就是一次 nibble 寫入的耗時正常情況下應該小于 5us。如果發(fā)現(xiàn) E 的下降沿和 PA8 翻轉(zhuǎn)之間間隔在幾十微秒以上說明 GPIO 初始化參數(shù)里把輸出速率設成了低檔應改用GPIO_Speed_50MHz。5.2 把“掃描-顯示-計算”拆成三個獨立模塊最后一步不是加功能而是把代碼切成三個不互相引用的模塊key管掃描和狀態(tài)機lcd管顯示calc管求值。模塊之間只通過兩個接口通信KeyEvent_Handle收到的每個按鍵事件都寫入表達式緩沖計算結(jié)束后把結(jié)果通過LCD_ShowString輸出。這樣做的好處是單元測試時可以直接用字符串調(diào)用Calc_Eval不需要碰鍵盤硬件更換顯示模組時只改lcd模塊計算邏輯紋絲不動。工程上判斷模塊拆分是否合理的標準是把calc模塊單獨復制到 PC 上加一個main函數(shù)能不能用標準 C 編譯器直接跑通所有測試用例。如果能這套計算器的邏輯核心就與硬件完全解耦了。本文還有配套的精品資源點擊獲取