
简介一个基于51系列单片机实现的语音计算器项目面向正在做课程设计、毕业设计或嵌入式初学进阶的开发者。项目围绕按键输入、数值计算、语音播报三大功能展开涉及TTS语音合成芯片如ISD1760驱动、矩阵键盘扫描、UART串口通信、定时器和中断服务等典型单片机外设应用能帮助读者将理论知识与实际系统设计打通。压缩包共29个文件整体仅78KB包含C语言源文件calculator.c、cal.c、ISD1760.c等、配套头文件.h、Keil工程配置.uvproj/.uvopt以及大量备份与列表文件目录直观便于对照学习。目前已有153人学习下载。通过阅读代码可完整看到一个用51单片机驱动1602液晶、ISD1760语音模块的计算器方案包括按键消抖、运算逻辑、语音分段播报等细节适合作为参考模板或二次开发基础。1. 语音计算器不是“给51单片机挂个麦克风”那么简单接到一个“51单片机的语音计算器程序”工程包大多数人会先翻源码然后发现真正卡人的地方不在语音也不在计算而在怎么把这两件事在 8 位机上串起来。一套完整的语音计算器程序至少包含四段逻辑语音模块把声音识别成命令词51 单片机把命令词映射成按键事件表达式解析器在片内求值最后把结果播报或显示。任何一段断了表现都是“没反应”。这个工程同时踩中了 51 单片机开发里最典型的几类问题模块选型、串口协议解析、小 RAM 下的字符串与数值处理。和 STM32 上堆外设库慢慢调的思路不同51 上每一步都要替编译器省资源省 flash省那几百字节的片内 RAM。下面按一条实际能复现的路径讲先定硬件与引脚再打通语音到按键的链路然后写算式引擎最后处理播报与工程打包。新手照做能跑通做过几年嵌入式的读者重点看协议解析和运算边界两章。2. 语音计算器的硬件链路语音模块选型与 51 单片机引脚规划2.1 先定语音识别方案三条路线怎么选语音计算器里最贵的硬件往往不是 51而是语音模块。常见路线有三条。第一条是 LD3320 这类本地识别芯片51 通过 SPI 或并行总线写入识别词表芯片本地完成识别命中后通过中断引脚通知主机。好处是词表可以随时改坏处是识别率对环境和普通话发音敏感而且驱动代码量大词表管理和串口调试都挤在有限的 flash 里。第二条是 SU-03T 以及同体系的离线模组在电脑端网页平台配置词条和应答播报固件烧进模组后独立工作识别结果通过 UART 帧发出来。语音计算器这类固定词表场景这条路最省事识别一致性比较好51 侧只需要写串口解析和映射。第三条是挂蓝牙或 WiFi 模组走云端识别词表再大也能处理。但 51 上做音频采集上行、还得处理断网重连和延迟对课程设计和工程演示来说投入产出比太低。我做这类工程的常规选型是主方案 SU-03T想深入理解识别驱动就换 LD3320 自己写词表接口。三者对比如下方案与51的接口词表管理识别表现51侧工作量LD3320SPI / 并行代码写词表依赖环境大看驱动SU-03T 离线模组UART平台配置后烧录较好小看协议云端识别串口 / WiFi服务端下发好但依赖网络大协议复杂2.2 引脚分配一个串口不够用的常规解法语音计算器的外设比普通计算器多一圈语音模块、LCD、矩阵键盘、蜂鸣器。引脚规划要在画原理图之前定死不然焊完发现冲突只能飞线。常见分配表如下以 STC89C52 为例外设接口引脚SU-03T 语音模块UART RX/TXP3.0、P3.1LCD16024 位数据总线P2.0~P2.3 控制 P0 数据4x4 矩阵键盘行列扫描P1.0~P1.7蜂鸣器GPIOP2.7注意两个点。第一P0 口是开漏输出接 LCD 数据线必须加上拉电阻很多人仿真没事、实机花屏就是这个原因。第二51 单片机经典内核只有一个串口语音模块占掉 P3.0/P3.1 之后调试日志就没地方打了。我一般的做法是运行期间串口只接语音模块调试信息通过一个拨码开关在“语音”和“USB 转串口”之间切换如果你用的是 STC12/STC15 这类带第二串口的增强型直接开第二组串口调试和语音互不打扰省心很多。2.3 电平匹配与验证代码串口通了再谈识别SU-03T 这类模块的工作电压是 3.3V而 51 在 5V 供电时 IO 输出的高电平接近 5V。直连轻则收发不稳定重则烧语音芯片。常规做法用 AMS1117-3.3 单独给模块供电51 的 TX 到模块 RX 串一个 1kΩ 电阻分压模块 TX 到 51 RX 方向一般可以直接连因为 51 的输入高电平阈值约 2V3.3V 电平足够识别。接好线先别写业务逻辑做一个串口回环验证// uart_echo.c —— 51单片机串口回环测试验证语音模块链路 #include reg52.h void Uart_Init(unsigned int baud) { TMOD 0x0F; // 定时器1模式位清零 TMOD | 0x20; // 定时器18位自动重装作波特率发生器 TH1 256 - (unsigned char)(11059200UL / 12 / 32 / baud); TL1 TH1; TR1 1; // 启动定时器1 SM0 0; SM1 1; // 串口方式18位UART REN 1; // 允许接收 EA 1; // 总中断 ES 1; // 串口中断 } void Uart_ISR(void) interrupt 4 { unsigned char ch; if (!RI) return; RI 0; // 清除接收标志 ch SBUF; // 读走接收缓冲 SBUF ch; // 原样回发演示用正式代码先判TI }代码里三个参数说明一下。11059200UL / 12 / 32 / baud里的 12 是 8051 的机器周期倍数32 是串口波特率发生器的 16 分频再乘 SMOD 的 2SMOD0 时算出来的 30 就是 9600 波特率下定时器每发一位要计的数。晶振用 11.0592MHz 就是为了让这个除法除尽换成 12MHz 晶振波特率会偏到 10417长时间传帧会偶发乱码。回环跑通后再用串口助手单独连语音模块给模块发配置好的测试帧看 51 能不能把模块的应答帧原样打给调试口。这一步没通后面所有识别逻辑都先别写。Proteus 可以做逻辑验证但电平匹配这种模拟器看不出来的问题建议直接在实机上测。提示P0 口做数据总线必须加上拉电阻Proteus 里不接也能通过仿真实机花屏先查这里。3. 语音计算器的识别结果解析51 单片机怎么把语音变成按键3.1 先读协议文档帧头、长度、校验在哪里拿到 SU-03T 或者任何一款离线语音模组第一件事不是写代码而是把它的串口协议说明打开确认三件事帧头是什么、长度字段在哪、校验算法是什么。不同批次固件的帧格式不完全一样常见是“连续帧头 数据长度 命令字 数据 校验和”的结构帧头多为 0xFD 或 0xAA 开头。这块最容易犯的错是照抄网上的解析代码、却不改帧长度。语音计算器工程里词条数量一般十几个命令字可能只有一位但帧头如果实际是四个字节而不是两个你按两个字节找命令字解析出来全是乱的。这类参数我习惯全部写成宏放在文件头部改模块固件版本时只动一处。3.2 中断里攒帧主循环里处理不占大 RAM 的接收状态机51 的资源紧张我不建议在中断里做整帧处理和查表只做“攒字节”。用一个状态机把进来的字节放进缓冲区凑满一帧就置一个标志主循环看到标志再做解析和映射。// voice_uart.c —— 语音模块串口帧接收状态机 #define FRAME_HEAD 0xFD // 帧头字节按模块文档修改 #define HEAD_LEN 2 // 连续帧头个数 #define FRAME_MAX 16 // 帧最大长度按词表规模放宽 unsigned char rx_buf[FRAME_MAX]; // 帧缓冲区 unsigned char rx_cnt 0; // 已收字节数 unsigned char frame_ok 0; // 完整帧标志 void VoiceUart_ISR(void) interrupt 4 { unsigned char ch; if (!RI) return; RI 0; ch SBUF; if (rx_cnt HEAD_LEN) { // 先吃连续帧头 if (ch FRAME_HEAD) { rx_buf[rx_cnt] ch; } else { rx_cnt 0; // 帧头不对重新等 } return; } rx_buf[rx_cnt] ch; if (rx_cnt FRAME_MAX) { // 缓冲区收满 rx_cnt 0; frame_ok 1; // 通知主循环 } }参数说明HEAD_LEN必须和模块文档里的连续帧头个数一致写少了会把帧头之后的数据字节误当新帧头写多了整帧永远凑不满FRAME_MAX要大于“帧头 长度 命令字 词条 ID 校验”的总长语音计算器的词表不大16 字节通常够用。主循环里处理时注意处理完一帧要立刻清掉frame_ok并且对校验和做检查不通过直接丢帧。对识别模块来说偶发丢一帧比解析错一帧安全得多用户说第二遍就是了。提示中断里只做“攒字节”和置标志不要在中断里做查表或回调。51 的中断栈很浅放不下太多函数调用。3.3 词条到按键的映射表查表永远比 if-else 链清晰帧解析完了核心问题是把“词条 ID”变成“计算器按键”。如果写一串 if-else词表一改代码就乱字也好认也好改我一般写成表把词条 ID 和按键一一对应// voice_map.h —— 语音词条与计算器按键的映射表 typedef struct { unsigned char voice_id; // 模块输出帧中的词条ID unsigned char key; // 映射的按键0~9 - * / C } VoiceKeyMap; code VoiceKeyMap voice_key_map[] { {0x01, 1}, {0x02, 2}, {0x03, 3}, {0x04, 4}, {0x05, 5}, {0x06, 6}, {0x07, 7}, {0x08, 8}, {0x09, 9}, {0x0A, 0}, {0x0B, }, {0x0C, -}, {0x0D, *}, {0x0E, /}, {0x0F, }, {0x10, C} }; unsigned char VoiceIdToKey(unsigned char id) { unsigned char i; for (i 0; i sizeof(voice_key_map) / sizeof(voice_key_map[0]); i) { if (voice_key_map[i].voice_id id) { return voice_key_map[i].key; // 命中返回按键 } } return 0; // 未命中返回0主循环忽略 }这里有两个容易被忽略的细节。第一表用code关键字放程序区不占 51 那 128或 256字节的片内数据 RAM表越大这个习惯越值钱。第二模块里“加”“加上”“加号”可以是三个词条映射到同一个语音上更贴合口语识别率反而比只设一个“加”高。3.4 去抖与重识别识别对了是功能识别错了是灾难语音模块识别到一次词条会发一帧但用户一句话说长了或者环境里有回音模块可能连发两帧同样的命令。语音计算器不像键盘没有机械触点却也有“弹跳”问题。常见做法是加最小识别间隔上一次有效识别后 300 毫秒内的帧直接丢弃。这个值根据现场调环境安静可以收到 200背景噪声大建议放到 400。丢了也不用提示用户听不到播报自然会再说一遍比播报“我没听清”省事。如果词表里设置了“归零”“取消”优先把它们做成高可靠词条让用户在任何混乱状态都有后悔药。4. 语音计算器的算式引擎51 单片机上的表达式解析与四则运算4.1 先想清楚按键流和算式不是一回事语音或矩阵键盘给你的是一串按键事件比如 “1”“2”“”“3”“4”“”而计算核心需要的是表达式 “1234”。中间这个缓冲区就是状态机数字追加到末尾运算符发现末尾也是运算符就替换而不是追加按下才触发求值按下C清空。这块最容易犯两个错。一是缓冲区没定上限用户连续说十几位数字内存直接溢出二是按下之后没有把结果变成下一次计算的起点导致连续计算时第二次按键把结果和新数字拼在一起。常规做法是把结果显示出来之后再把这个结果写回表达式缓冲区作为下一次输入的起点。4.2 中缀转后缀再求值8 位机上也能跑的标准解法带括号的四则运算顺序扫描没法处理优先级。课程设计里很多人写死“先乘除后加减”一遇到括号就崩。我在语音计算器上用的还是最经典的解法中缀转后缀调度场算法再用数字栈求值。51 上跑这一套完全够用从按下到出结果只有几毫秒到十几毫秒。// calc.c —— 中缀表达式转后缀并求值整数四则运算支持括号 #define EXPR_MAX 16 #define STACK_MAX 16 char expr[EXPR_MAX 1]; // 表达式缓冲区 char postfix[EXPR_MAX * 2]; // 后缀串插了空格所以要2倍 long calc_result; static char OpRank(char op) { if (op || op -) return 1; if (op * || op /) return 2; return 0; } unsigned char Calc(char *e) { char opst[STACK_MAX]; // 运算符栈 long num[STACK_MAX]; // 数字栈 unsigned char opt 0, nt 0; unsigned char oi 0, i; for (i 0; e[i] ! \0 e[i] ! ; i) { char c e[i]; if (c 0 c 9) { postfix[oi] c; // 数字直接输出 continue; } postfix[oi] ; // 分隔多位数 if (c () { opst[opt] c; } else if (c )) { while (opt 0 opst[opt - 1] ! () { postfix[oi] opst[--opt]; postfix[oi] ; } if (opt 0) opt--; // 丢掉左括号 } else { while (opt 0 opst[opt - 1] ! ( OpRank(opst[opt - 1]) OpRank(c)) { postfix[oi] opst[--opt]; // 弹出高优先级运算符 postfix[oi] ; } opst[opt] c; // 当前运算符压栈 } } while (opt 0) { // 收尾弹出剩余运算符 if (opst[opt - 1] () return 1; // 括号不匹配 postfix[oi] opst[--opt]; postfix[oi] ; } postfix[oi] \0; i 0; while (postfix[i] ! \0) { if (postfix[i] 0 postfix[i] 9) { long v 0; while (postfix[i] 0 postfix[i] 9) { v v * 10 (postfix[i] - 0); // 拼多位数 i; } num[nt] v; } else if (postfix[i] || postfix[i] - || postfix[i] * || postfix[i] /) { long b, a; if (nt 2) return 2; // 运算符前缺操作数 b num[--nt]; a num[--nt]; switch (postfix[i]) { case : a b; break; case -: a - b; break; case *: a * b; break; case /: if (b 0) return 3; // 除零 a / b; break; } num[nt] a; } else { i; // 跳过空格 } } if (nt ! 1) return 4; // 操作数不配对 calc_result num[0]; return 0; }这段代码里有三个针对 51 的取舍要说明。第一运算对象全部用long32 位不要用int。Keil C51 里int是 16 位乘法稍微大一点就溢出结果看起来像“算错了”实际是类型问题。long在 8051 上除法用软件模拟慢一点但计算器一次只算一个表达式可以接受。需要留意的是表达式缓冲区的长度 16 超过 long 的 10 位上限输入层最好限制连续数字位数或在Calc()里对数字拼接溢出做一次检查。第二后缀串postfix申请了EXPR_MAX * 2的大小因为运算符之间插了空格输出长度会超过输入长度。把后缀缓冲区开得和输入一样大是中缀转后缀最常见的隐性溢出轻则覆盖相邻变量重则随机复位。第三运算符栈和数字栈都是局部数组51 的硬件堆栈很浅这种大数组必须声明成全局或静态不然 Keil 编译能过跑起来就莫名其妙复位。上面代码里数组全部放在函数外就是这个原因。注意Keil C51 的int是 16 位算式引擎里所有中间值都用long这是计算结果“偶尔不对”的头号原因。4.3 键盘与语音统一入口永远只维护一份按键逻辑物理矩阵键盘和语音按键最终都要落到计算逻辑上。如果语音一套逻辑、键盘一套逻辑后面改个“按等号自动播报”要改两处早晚漏一处。我习惯让两边都调用同一个OnKey()// main.c —— 物理键盘和语音共用的按键入口 void OnKey(unsigned char key) { unsigned char len strlen(expr); if (key 0 key 9 || key ( || key )) { if (len EXPR_MAX) { // 防溢出超出长度忽略 expr[len] key; expr[len 1] \0; Display_Text(expr); } } else if (key || key - || key * || key /) { if (len 0) return; // 表达式不能以运算符开头 if (expr[len - 1] || expr[len - 1] -) { expr[len - 1] key; // 连续运算符替换末位 } else { expr[len] key; expr[len 1] \0; } Display_Text(expr); } else if (key ) { if (Calc(expr) 0) { Display_Long(calc_result); // 显示十进制结果 LongToString(expr, calc_result); // 结果写回支持连续计算 } else { Display_Error(); // 除零、括号不匹配等 expr[0] \0; // 出错了直接清空 } } else if (key C) { expr[0] \0; // 清空重来 Display_Text(expr); } }这里没用sprintf因为 Keil C51 里 printf 家族函数会拉进一大坨格式化代码flash 浪费严重。自己写一个LongToString用% 10和/ 10循环把 32 位数拆成十进制字符十行代码解决还顺带避开了 printf 的格式串陷阱。矩阵键盘扫描用常规的逐行拉低扫描法扫描到键值后调同一个OnKey()语音和键盘的行为在任何时候都保持一致。5. 语音播报、防误识别与语音计算器工程打包5.1 把结果“说”出来三种播报方案播报是语音计算器区别于普通计算器的关键体验。常见做法有三种。一是让语音模块自己播报预置词条。SU-03T 这类模组在词条配置阶段就可以给每个词条配应答播报比如“数字五”“加号”“等于”51 得到计算结果后把结果按位发给模块它就逐位念出来不加硬件但播报节奏受模块控制。二是外接 SYN6288 这类 TTS 芯片51 把整句话通过串口发过去效果自然但要占多一组串口和额外供电。三是把常用提示做成固定录音存进语音录放电路“归零了”“请再说一遍”这类提示用这方案最省心。课程设计和大多数工程演示我推荐方案一。给一段按位播报的代码// voice_calc.c —— 把计算结果按位转成词条ID播报 void VoiceSayResult(long v) { unsigned char buf[12]; unsigned char i 0, j; if (v 0) { VoiceSayId(ZERO_ID); return; } // 零 词条ID while (v 0 i sizeof(buf) - 1) { buf[i] (unsigned char)(v % 10); // 从个位拆起 v / 10; } for (j i; j 0; j--) { VoiceSayId(DIGIT_ID buf[j - 1]); // 高位在前 } }VoiceSayId内部就是前面写的串口发帧函数DIGIT_ID是模块词表里“零”的起始 ID。这段代码放在Calc()成功分支之后显示和播报同步做别只更新屏幕忘了声音。5.2 三个防误识别技巧词条命名、去抖和回读词条命名别用单音节。“五”和“我”在识别模型里容易混配置成“数字五”这种双字结构识别率明显更高“加号”“等号”同理。去抖参数和播报配合调上电先测一段环境底噪安静房间用 200 毫秒吵的场合放到 400 毫秒。数字回读是免费的正确性反馈模块词条配置里的应答播报就能实现用户每说一个数听到一个回读错了立刻说“取消”不用等算式算完。5.3 打包一个别人能跑起来的 zip工程清单和两个隐藏信息这类 zip 往往来自课程设计或开源分享最怕别人拿到手编译不过。给工程打包时我按下面这张清单检查文件文件作用缺失后果main.c / uart.c / calc.c / display.c主流程、串口、算式、显示编译直接报错voice_map.h词条与按键映射行为完全不对词表配置平台导出说明语音词条和播报必须重配模组原理图或接线文档引脚、电平匹配硬件连错烧模块Keil 工程文件编译目标和下载配置版本对不上打不开认真做的话在 README 里写清三件事晶振频率决定波特率、语音模块固件的帧格式版本、调试口和语音口的切换方式。别人拿到 zip 能不看代码就烧录跑通这个包才算完整交付。有一个验证技巧值得单独说拿串口助手直接连语音模块逐个核对词条的“识别后串口帧”和“应答播报”是否符合预期再把确认过的词条 ID 填进映射表。识别不准的词条去模块平台调阈值和词条措辞而不是在 51 代码里打补丁。这个习惯能帮你把整块联调时一半的“玄学问题”提前消灭掉。本文还有配套的精品资源点击获取