简介:本资源是一套基于51单片机的矩阵按键简易计算器完整开发工程,面向电子类初学者、单片机课程实践者及嵌入式入门开发者,解决硬件交互逻辑理解难、按键扫描与运算逻辑整合不清晰等典型学习痛点。压缩包共14个文件,涵盖核心源码(main.c)、编译输出(.hex、.obj、.lst)、工程配置(.uvproj、.uvopt、.bak备份)、原理图文档(CT107D单片机竞赛板V10原理图.pdf)及调试辅助文件(.plg、.lnp、.M51),全面支撑从代码编写、编译烧录到硬件验证的全流程学习。资源大小仅133KB,轻量易下载,结构紧凑且具备典型竞赛板适配性。已有885人学习下载,读者可直接获取可运行的完整项目工程、配套原理图、逐行注释的主程序逻辑及标准Keil开发环境配置模板,特别适合用于单片机课程设计、电赛备赛训练或按键输入+LCD显示类功能模块的快速复现与原理剖析。
1. 矩阵按键简易计算器:用 4×4 按键阵列+单片机实现可复位、带退格、支持连续运算的嵌入式计算器,适合电子课程设计与硬件入门实战
你手头有一块带 4×4 矩阵按键的开发板,想快速做出一个能加减乘除、支持括号(简化版)、有清屏/退格、结果实时显示在数码管或 LCD 上的计算器——不是仿真,是真焊在板子上、按下去有物理反馈、掉电不丢逻辑的硬核小系统。这个“矩阵按键 简易计算器.zip”就是为这类场景打包的最小可行方案:它不依赖复杂操作系统,不调用 GUI 库,核心逻辑用纯 C 实现,编译后烧录进 STM32F103C8T6 或 ATMEGA328P 就能跑。它解决的不是“能不能算”,而是“怎么让 16 个物理按键准确映射到 0–9、+−×÷、=、C、← 这 13 个功能键,且抗抖动、防连击、状态可追溯”。对某高校电子技术实验课的学生、准备毕业设计的本科生、或是想补全“按键→扫描→解析→计算→显示”全链路实操经验的初阶嵌入式开发者来说,这不是玩具代码,而是一份可拆解、可调试、可扩展的硬件交互范本。它背后涉及的矩阵扫描时序、表达式中缀转后缀(Shunting Yard)、栈式运算执行、以及数码管动态扫描与按键扫描的时序协同,全是嵌入式工程师必须亲手拧过螺丝的真实能力点。
2. 从原理到引脚:为什么选 4×4 矩阵而非独立按键?如何用 8 根 IO 实现 16 键识别?
2.1 矩阵按键的本质:IO 资源换按键数量的物理权衡
独立按键每键需占用 1 个 GPIO,16 键就要 16 根线;而矩阵按键将按键排成 4 行×4 列网格,行线(Row)接 MCU 输出,列线(Col)接 MCU 输入(带弱上拉),仅用 4+4=8 根 IO 即可识别全部 16 个交叉点。其本质是分时复用:每次只让 1 行输出低电平(其余行高阻态),再读取 4 列是否被拉低——若第 2 列为低,则说明第 1 行第 2 列按键按下。这种结构省 IO,但引入了两个关键挑战:机械抖动(按键弹跳导致单次按下被误读为多次)和鬼键(多键同时按下时列线间通过按键形成意外通路,导致错误识别)。因此,“简易计算器”的“简易”不在于逻辑简单,而在于它用确定性时序和状态机,在资源受限前提下把这两个问题封死在底层驱动里。
2.2 典型硬件连接与初始化:以 STM32F103C8T6 为例的寄存器级配置
假设使用常见的最小系统板,矩阵按键接法如下:
- 行线 R0–R3 → PA0–PA3(推挽输出,初始高电平)
- 列线 C0–C3 → PA4–PA7(浮空输入,外部上拉至 3.3V)
初始化代码需关闭默认上拉/下拉,并配置输出模式:
// STM32F103 标准外设库写法(兼容性强) void MatrixKey_Init(void) { RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitTypeDef GPIO_InitStruct; // 初始化行线:推挽输出,初始高电平 GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_SetBits(GPIOA, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3); // 全高 // 初始化列线:浮空输入(依赖外部上拉) GPIO_InitStruct.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 关键:不能设为上拉!否则列线始终为高,无法检测拉低 GPIO_Init(GPIOA, &GPIO_InitStruct); }注意:列线必须设为
IN_FLOATING(浮空输入),而非IN_PULLUP。因为外部已加 10kΩ 上拉电阻,若 MCU 内部再启用上拉,会形成双上拉,导致列线电压被抬高,按键按下时拉低幅度不足,ADC 采样或数字读取易失效。这是新手最常翻车的第一步。
2.3 行扫描与键值解码:带消抖的状态机实现
核心函数MatrixKey_Scan()每 10ms 被 SysTick 中断调用一次,执行完整扫描周期:
#define KEY_DEBOUNCE_TIME 20 // 20ms 消抖计数(对应 2 次 10ms 扫描) uint8_t key_state[4][4] = {0}; // 存储每个键的当前稳定状态:0=未按下,1=已按下 uint8_t key_press_count[4][4] = {0}; // 按下计数器,用于消抖 uint8_t MatrixKey_Scan(void) { static uint8_t row = 0; uint16_t col_mask; uint8_t key_val = 0xFF; // 默认无键按下 // 步骤1:置当前行为低,其余行为高阻(用开漏或高阻模拟) GPIO_ResetBits(GPIOA, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3); switch(row) { case 0: GPIO_ResetBits(GPIOA, GPIO_Pin_0); break; case 1: GPIO_ResetBits(GPIOA, GPIO_Pin_1); break; case 2: GPIO_ResetBits(GPIOA, GPIO_Pin_2); break; case 3: GPIO_ResetBits(GPIOA, GPIO_Pin_3); break; } // 步骤2:延时 10us,让电平稳定 for(volatile uint16_t i=0; i<100; i++); // 步骤3:读取列线状态(低电平有效) col_mask = GPIO_ReadInputData(GPIOA) & 0xF0; // 取高4位(PA4–PA7) // 步骤4:逐列判断,更新消抖计数器 for(uint8_t col=0; col<4; col++) { if( (col_mask & (0x10 << col)) == 0 ) { // 该列为低 if(key_press_count[row][col] < KEY_DEBOUNCE_TIME) { key_press_count[row][col]++; } if(key_press_count[row][col] == KEY_DEBOUNCE_TIME) { key_state[row][col] = 1; // 确认按下 key_val = row * 4 + col; // 映射为 0–15 的键值 } } else { key_press_count[row][col] = 0; // 松开则清零 key_state[row][col] = 0; } } // 步骤5:切换到下一行,循环扫描 row = (row + 1) % 4; return key_val; }参数说明:
KEY_DEBOUNCE_TIME = 20表示需连续 20 次(即 200ms)扫描到同一键按下才确认,远高于典型抖动时间(5–10ms),确保鲁棒性;row使用模 4 计数器实现轮询,避免固定行扫描导致某行响应延迟;key_val返回 0–15 的原始键值,后续由按键映射表转换为功能码(如键值 12 → ‘C’,键值 14 → ‘=’)。
3. 表达式解析与计算引擎:中缀转后缀 + 双栈执行,避开浮点与递归陷阱
3.1 为什么不用eval()?嵌入式环境下的安全与确定性约束
在 PC 端,Python 的eval("1+2*3")一行搞定;但在单片机上,eval需要解释器、内存管理、字符串解析引擎——STM32F103C8T6 仅 20KB RAM,根本无法承载。更危险的是,eval会执行任意代码,一旦用户误输1+system("rm -rf /")(虽在嵌入式无此命令,但逻辑漏洞可能被利用),系统就不可控。因此,“简易计算器”采用确定性有限状态机 + 栈式计算:所有输入字符先存入缓冲区,当用户按=时,启动严格语法检查的中缀转后缀(Shunting Yard)算法,再用双栈(操作数栈 + 运算符栈)执行,全程无动态内存分配、无递归调用、无浮点运算(全整数),保证硬实时与内存安全。
3.2 中缀表达式到后缀表达式的转换:处理优先级与括号的完整流程
以输入"12+3*4"为例,转换规则如下(基于 Dijkstra 的 Shunting Yard 算法精简版):
| 步骤 | 输入字符 | 操作符栈 | 输出队列 | 说明 |
|---|---|---|---|---|
| 1 | '1' | [] | [1] | 数字直接入队 |
| 2 | '2' | [] | [12] | 连续数字合并为 12 |
| 3 | '+' | [+] | [12] | '+' 入栈(栈空) |
| 4 | '3' | [+] | [12,3] | 数字入队 |
| 5 | '*' | [+,*] | [12,3] | '*' 优先级 > '+',入栈 |
| 6 | '4' | [+,*] | [12,3,4] | 数字入队 |
| 7 | 结束 | [] | [12,3,4,*,+] | 栈内运算符依次弹出 |
最终后缀式为12 3 4 * +,执行时:
- 遇数字压入操作数栈:
[12,3,4] - 遇
*:弹出 4,3 → 3*4=12 → 压入[12,12] - 遇
+:弹出 12,12 → 12+12=24 → 压入[24] - 结果为 24。
关键约束:
- 不支持负数(如
-5),因需额外状态机识别前置符号; - 不支持小数点(如
3.14),因整数运算更可靠; - 括号仅支持
(和),深度限 1 层(避免栈溢出)。
3.3 双栈执行器的 C 实现:栈大小、溢出保护与错误码返回
#define MAX_STACK_SIZE 20 int32_t num_stack[MAX_STACK_SIZE]; uint8_t num_top = 0; char op_stack[MAX_STACK_SIZE]; uint8_t op_top = 0; int32_t Calculate_Postfix(const char* postfix, uint8_t len) { int32_t a, b, result; uint8_t i = 0; while(i < len && postfix[i] != '\0') { if(postfix[i] >= '0' && postfix[i] <= '9') { // 解析连续数字(支持多位数) int32_t num = 0; while(i < len && postfix[i] >= '0' && postfix[i] <= '9') { num = num * 10 + (postfix[i] - '0'); i++; } if(num_top >= MAX_STACK_SIZE) return -1; // 溢出 num_stack[num_top++] = num; continue; } if(postfix[i] == '+' || postfix[i] == '-' || postfix[i] == '*' || postfix[i] == '/') { if(num_top < 2) return -2; // 操作数不足 b = num_stack[--num_top]; a = num_stack[--num_top]; switch(postfix[i]) { case '+': result = a + b; break; case '-': result = a - b; break; case '*': result = a * b; break; case '/': if(b == 0) return -3; // 除零错误 result = a / b; break; default: return -4; } if(num_top >= MAX_STACK_SIZE) return -1; num_stack[num_top++] = result; } i++; } return (num_top == 1) ? num_stack[0] : -5; // 栈中应只剩1个结果 }血泪经验:
MAX_STACK_SIZE = 20是经测试的安全值:16 位数字最大 65535,4 位数 × 4 位数 = 8 位数,20 层足够覆盖123+456*789-...类长表达式;- 所有分支都带
return错误码(-1 至 -5),上层 UI 可据此显示Err; - 除零检查
if(b == 0)必须存在,否则硬件除零异常会导致 HardFault。
4. 显示驱动与人机协同:数码管动态扫描与按键-显示时序解耦
4.1 为什么选共阴数码管?静态 vs 动态扫描的功耗与清晰度权衡
本项目配套的硬件通常采用 4 位共阴数码管(如 SM42056),每位有 a–g + dp 共 8 段,公共端(COM)接 PNP 三极管控制。选择共阴而非共阳,是因为 STM32 的 GPIO 灌电流能力(25mA)远强于拉电流(3mA),共阴结构下段码由 MCU 输出高电平点亮,驱动更可靠。静态扫描需 4×8=32 根线,不现实;故采用动态扫描:每 5ms 轮流点亮 1 位(COM 选通),同时送出对应段码,利用人眼视觉暂留(>50Hz)实现“全亮”假象。关键在于:扫描频率必须 ≥200Hz(即每位 ≤5ms),否则会出现闪烁;而按键扫描需 ≥100Hz(≤10ms),二者频率接近,若不隔离,按键响应会受显示刷新拖累。
4.2 时序解耦设计:SysTick 中断分频驱动显示,主循环处理按键与逻辑
采用双时间基:
- SysTick 中断(1kHz):每 1ms 进入中断,用计数器分频实现 5ms 显示扫描周期;
- 主循环(while(1)):专注按键扫描、表达式构建、计算触发,不参与显示刷新。
volatile uint8_t display_pos = 0; volatile uint8_t display_buffer[4] = {0}; // 存储 4 位要显示的数字(0–15,10=A,11=b...) void SysTick_Handler(void) { static uint16_t display_timer = 0; display_timer++; if(display_timer >= 5) { // 每5ms刷新一位 display_timer = 0; // 1. 关闭上一位显示(COM 全高,段码全0) GPIO_SetBits(GPIOB, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3); // COM 全高(PNP 截止) GPIO_ResetBits(GPIOA, GPIO_Pin_0 | GPIO_Pin_1 | ... | GPIO_Pin_7); // 段码全0 // 2. 设置当前位段码(查表得 8 位段码) uint8_t seg_code = seg_table[display_buffer[display_pos]]; GPIO_Write(GPIOA, seg_code); // 3. 选通当前 COM(低电平有效) GPIO_ResetBits(GPIOB, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3); switch(display_pos) { case 0: GPIO_ResetBits(GPIOB, GPIO_Pin_0); break; // COM0 选通 case 1: GPIO_ResetBits(GPIOB, GPIO_Pin_1); break; case 2: GPIO_ResetBits(GPIOB, GPIO_Pin_2); break; case 3: GPIO_ResetBits(GPIOB, GPIO_Pin_3); break; } display_pos = (display_pos + 1) % 4; } }提示:
seg_table[]是预定义的 16 字节数组,如seg_table[0] = 0x3F(0 的段码),seg_table[10] = 0x77(A 的段码)。所有段码均按共阴设计,高电平点亮。
4.3 表达式输入与结果显示的协同策略:缓冲区管理与光标模拟
用户输入"12+3"时,屏幕应显示12+3;按=后显示15。这需要两个缓冲区:
input_buf[16]:存储用户输入的字符('0'–'9', '+', '-', etc.),最大 15 字符 +\0;display_buf[4]:仅存最后 4 位有效数字(用于数码管显示),超长则左移截断。
// 主循环中处理按键 uint8_t key = MatrixKey_Scan(); if(key != 0xFF) { switch(key_map[key]) { // key_map[] 将 0–15 映射为 ASCII 或功能码 case '0'...'9': if(input_len < 15) { input_buf[input_len++] = key_map[key]; input_buf[input_len] = '\0'; Update_Display_Buffer(); // 将 input_buf 最后4位转为 display_buf } break; case 'C': input_len = 0; input_buf[0] = '\0'; memset(display_buf, 0, sizeof(display_buf)); break; case '=': if(input_len > 0) { int32_t res = Calculate_Expression(input_buf); if(res >= 0) { snprintf(temp_str, sizeof(temp_str), "%ld", res); // 取 temp_str 最后4位填入 display_buf int start = strlen(temp_str) - 4; if(start < 0) start = 0; for(int i=0; i<4 && temp_str[start+i]; i++) { display_buf[i] = (temp_str[start+i] >= '0' && temp_str[start+i] <= '9') ? temp_str[start+i] - '0' : 10; // 非数字映射为 A } } else { // 错误码,显示 "Err" display_buf[0] = 14; display_buf[1] = 17; display_buf[2] = 17; display_buf[3] = 15; // E r r } } break; } }玄学细节:Update_Display_Buffer()不直接复制input_buf,而是遍历并过滤非数字/运算符字符,再取末尾 4 位——这样输入"12345+678"时,显示"678"而非乱码,符合用户直觉。
5. 避坑指南:矩阵按键计算器的 4 个高频翻车点与硬核排查法
5.1 现象:按键偶尔失灵,或同一按键多次触发
原因:消抖时间KEY_DEBOUNCE_TIME设置过小(如 5),未覆盖机械抖动峰值;或扫描周期过长(>20ms),导致按键释放后下次扫描才捕获。
解决:将KEY_DEBOUNCE_TIME设为 20(200ms),并在MatrixKey_Scan()开头加Delay_ms(10)强制 10ms 周期;用示波器抓 PA0(R0)和 PA4(C0)波形,确认低电平持续时间 ≥15ms。
5.2 现象:输入"2+3*4"计算结果为20(即(2+3)*4),而非14
原因:中缀转后缀时,运算符优先级表未正确定义*和/高于+和-,导致+先弹出栈。
解决:检查GetPrecedence(char op)函数,必须返回:'+'/'-' → 1,'*'/'/' → 2;在ShuntingYard()中,当新运算符优先级 ≤ 栈顶时才弹出栈顶。
5.3 现象:数码管某一位常亮不灭,或亮度明显偏暗
原因:COM 选通信号未在切换前彻底关断,导致两位 COM 同时导通,电流分流;或段码输出后未及时关闭 COM,造成“鬼影”。
解决:在SysTick_Handler()中,严格遵循“关所有 COM → 写段码 → 开当前 COM”顺序;用万用表测 COM 引脚电压,确认非选通时刻为 3.3V(PNP 截止),选通时刻为 0.2V(PNP 饱和)。
5.4 现象:输入长表达式(如"1234+5678")后按=,数码管显示Err
原因:Calculate_Postfix()中num_stack或op_stack溢出,或snprintf()缓冲区不足导致temp_str截断。
解决:将temp_str定义为char temp_str[16](支持 10 位数字+符号);在Calculate_Postfix()开头加if(len > 30) return -6;限制输入长度;用sizeof(display_buf)替代硬编码4,增强可移植性。
6. 进阶技巧:添加退格键、支持连续运算与硬件自检,让计算器真正“可用”
6.1 退格键(←)的底层实现:不只是删字符串,更要维护表达式语法合法性
退格键不能简单input_len--,因为用户可能删到运算符前,留下非法片段如"12+"。正确做法是:从input_buf[input_len-1]开始向前扫描,跳过数字,找到第一个非数字字符(+,-,*,/,(,))或开头,然后截断:
case '<': // ← 键 if(input_len > 0) { input_len--; // 回退到上一个“token”边界:数字结尾或运算符前 while(input_len > 0 && input_buf[input_len-1] >= '0' && input_buf[input_len-1] <= '9') { input_len--; } if(input_len > 0 && (input_buf[input_len-1] == '+' || input_buf[input_len-1] == '-' || input_buf[input_len-1] == '*' || input_buf[input_len-1] == '/')) { input_len--; // 连运算符一起删 } input_buf[input_len] = '\0'; Update_Display_Buffer(); } break;价值点:这样删"123+45"会变成"123",删"123+45*"会变成"123+",始终保证末尾是合法 token,避免计算时语法错误。
6.2 连续运算支持:按=后自动将结果作为下一次运算的左操作数
用户输入"2+3="得5,再按*4=应得20。这需要保存上次结果到last_result,并在新表达式以运算符开头时自动前置:
int32_t last_result = 0; bool pending_result = false; // 标记上次按了 = // 在 '=' 处理分支末尾: last_result = res; pending_result = true; // 在输入新字符时: if(pending_result && (key_map[key] == '+' || key_map[key] == '-' || key_map[key] == '*' || key_map[key] == '/')) { // 自动插入 last_result 和该运算符 snprintf(input_buf, sizeof(input_buf), "%ld%c", last_result, key_map[key]); input_len = strlen(input_buf); pending_result = false; } else if(pending_result) { // 新输入数字,清除 pending pending_result = false; input_len = 0; input_buf[0] = '\0'; }6.3 硬件自检:上电时自动检测按键与数码管,避免“黑屏不知哪坏了”
在main()开头加入:
void Hardware_SelfTest(void) { // 1. 数码管全亮测试:4 位全显示 8 for(uint8_t i=0; i<4; i++) display_buf[i] = 8; Delay_ms(1000); // 2. 按键扫描测试:依次点亮 4×4 按键对应的 LED(如有),或串口打印键值 printf("SelfTest: Press any key...\r\n"); while(1) { uint8_t k = MatrixKey_Scan(); if(k != 0xFF) { printf("Key %d pressed\r\n", k); Delay_ms(500); break; } Delay_ms(10); } }我的习惯:每次新焊一块板子,必先运行自检;若数码管不全亮,立刻查 COM 驱动电路;若按键无响应,用万用表量 PA4–PA7 是否有 3.3V 上拉电压——这比对着代码猜三天高效得多。
希望帮到你。
本文还有配套的精品资源,点击获取