简介:基于STM32单片机的语音导盲系统毕业设计资料包,面向电子信息、自动化、通信工程等专业学生,适合用作物联网/嵌入式方向的毕设、课设或项目初期演示。资料包含完整软硬件方案:C工程源码、原理图接线图、详细设计文档与配套说明,代码在开发板上验证通过,可直接编译下载;若有基础,也可在此基础上扩展语音识别、避障控制等功能,二次开发空间充足。包内共100个文件,以C源码(41个)与H头文件(43个)为主,另含启动汇编、uvprojx工程配置、Hex烧录文件、PDF文档、接线图及辅助脚本,整体约905KB,结构紧凑,便于对照原理图与代码理解系统工作流程,可用于单片机/嵌入式综合实训,也是毕业设计初期立项演示的完整素材。目前已有154人学习下载,适合需要快速搭建导盲系统原型或用于毕业设计参考的开发者。
1. STM32语音导盲系统的设计骨架:主控选择与链路组成
"前方两米有障碍物,请右转"——这句话从传感器捕捉到障碍物到尾音响起,整个链路应该在300毫秒内完成。视觉障碍用户对环境的判断,依赖的就是这条从传感器到喇叭的反应速度。这套基于STM32语音导盲系统的毕业设计,核心就是用一个STM32F103芯片把超声波测距、阈值决策、语音播报串成一条低延迟链路。
源码基于标准外设库编写,工程里有 stm32f10x_tim.c、stm32f10x_adc.c、stm32f10x_usart.c、stm32f10x_i2c.c、stm32f10x_can.c 等驱动文件,把常见外设都覆盖到了。配套原理图、接线图、详细文档和已测试源码,适合做毕设课设,也适合想搞明白"传感器采集+语音交互"怎么落地的嵌入式初学者。接下来我从系统时钟与外设驱动讲起,逐步展开语音控制和调试排错。
2. STM32F103外设驱动拆解:时钟、定时器与ADC精度控制
2.1 从 system_stm32f10x.c 看系统时钟初始化
拿到工程的第一步不是看 main,而是打开源文件列表里的 system_stm32f10x.c。它决定了整个系统跑多快、外设总线时钟从哪来。这个文件里的 SystemInit() 会在进入 main 之前由启动文件调用,完成三件事:把外部8MHz晶振(HSE)切换为主时钟源,经过PLL锁相环倍频到72MHz,再通过AHB、APB1、APB2三条总线分频给不同外设。
默认配置下,APB1 被分频到 36MHz,APB2 是 72MHz。这个差异直接决定了你要访问的外设挂在哪条总线上:USART1、ADC1、GPIOA 到 GPIOC 挂在 APB2 上,而 TIM2 到 TIM4、USART2、I2C1 挂在 APB1 上。很多人外设初始化报错或波特率算错,多半是没先看这个文件里宏定义的分频值,结果按 72MHz 去算某个 APB1 外设的时钟,得到的结果全是偏的。
// system_stm32f10x.c 中关键宏定义(默认值) #define PLL_SOURCE_HSE 1 // 选择外部晶振作为PLL输入 #define RCC_CFGR_PLLMULL9 9 // 8MHz * 9 = 72MHz // 如果外部晶振不是 8MHz,比如板子上是 12MHz, // 需要改 PLLMULL 使输出接近 72MHz,否则串口波特率偏差会越来越大这段配置的要点是:PLLMULL 必须是整数倍频,且最终输出不能超过芯片标称的 72MHz。用 12MHz 晶振时把倍频数改成 6,得到 72MHz;用 ST-Link 板载的 8MHz 晶振就保持默认。调试语音模块时如果出现第一个字节对、后面全乱码的情况,优先怀疑这里,而不是怀疑语音模块坏了。
2.2 TIM定时器:超声波测距的输入捕获实现
语音导盲的距离感知,工程里最常见的方案是 HC-SR04 超声波模块。它需要一根引脚发 10us 的触发脉冲,然后测量 Echo 引脚上高电平持续的时间。测量高电平宽度有两个思路:一个是外部中断里开定时器轮询,一个是直接用 TIM 的输入捕获。这个工程采用的是输入捕获加从模式复位,硬件上自动清零计数,不占 CPU。
具体做法是把 Echo 接到 TIM2_CH1(PA0),触发脉冲用普通 GPIO 翻转。配置 TIM2 时把预分频设为 71,那么在 72MHz 的 APB1 下,计数频率是 1MHz,计数器的值直接就是微秒。设置上升沿清零计数并触发捕获,下降沿计入捕获寄存器 CCR1,读 CCR1 就得到完整的脉冲微秒数。
// TIM2_CH1 输入捕获配置(PA0 接 HC-SR04 的 Echo) void TIM2_Capture_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 捕获引脚必须浮空输入 GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = 0xFFFF; // 16位自动重装,最大65535us TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; // 72MHz / 72 = 1MHz,即1us TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_ICInitStructure.TIM_Channel = TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; // 先捕获上升沿 TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; // 不分频 TIM_ICInitStructure.TIM_ICFilter = 0x0F; // 滤波抗干扰 TIM_ICInit(TIM2, &TIM_ICInitStructure); TIM_SelectInputTrigger(TIM2, TIM_TS_TI1FP1); // 把上升沿作为从模式触发源 TIM_SelectSlaveMode(TIM2, TIM_SlaveMode_Reset); // 上升沿时清零计数器 TIM_SelectMasterSlaveMode(TIM2, TIM_MasterSlaveMode_Enable); TIM_Cmd(TIM2, ENABLE); } // 触发一次测距并计算距离(单位:厘米) float HC_SR04_Measure(void) { uint16_t pulse_us; GPIO_SetBits(GPIOA, GPIO_Pin_1); // Trig = PA1 Delay_Us(10); // 至少10us的触发脉冲 GPIO_ResetBits(GPIOA, GPIO_Pin_1); while (TIM_GetFlagStatus(TIM2, TIM_FLAG_CC1) == RESET); // 等待下降沿捕获 pulse_us = TIM_GetCapture1(TIM2); TIM_ClearFlag(TIM2, TIM_FLAG_CC1); return pulse_us / 58.0f; // 声速340m/s,往返时间换算单程距离 }配置里有两个容易踩坑的地方。一是浮空输入必须由外部信号驱动,HC-SR04 的 Echo 输出能力足够,但如果线太长或者干扰大,ICFilter 加大到 0x0F 之后重测。二是 16 位计数器最长只能计 65535us,对应约 11 米距离,导盲场景两三米以内足够,超出范围要加溢出判断,否则会发生回绕。距离换算的 58 这个系数来自声速约 340m/s,往返时间除回来就是单程距离,在温度变化大时可以用 331.4 + 0.6*温度 修正声速。
2.3 ADC 多通道采集:电池电压与近距离补偿
语音导盲系统如果只会报距离,走到跟前才提醒,体验并不好。工程原理图里还留了 ADC 采集通道,典型用途是监测电池电压,顺带可以接一个红外距离传感器做近距离的盲区补偿。超声波在 10 厘米以内精度下降,此时红外反射式传感器反而可靠,两路信号在软件里取一个置信度权重。
STM32F103 的 ADC 是 12 位逐次逼近型,参考电压默认是 VREF 即 3.3V,单通道采样要先把对应引脚配置成模拟输入,然后启动校准。涉及电池电压采集时,原理图上通常会做一个电阻分压,把 4.2V 锂电池电压分到 3.3V 以内,分压比的计算直接决定你读出的电压准不准。
// ADC1 通道 4(PA4)单次转换,读取电池分压 void ADC1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); // ADC时钟 = 72MHz / 6 = 12MHz,手册要求不超过14MHz GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; // 模拟输入,不要设置上下拉 GPIO_Init(GPIOA, &GPIO_InitStructure); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_4, 1, ADC_SampleTime_55Cycles5); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); // 校准必须在使能之后做一次 while (ADC_GetCalibrationStatus(ADC1)); } uint16_t ADC_Read(void) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET); // 等待转换完成 return ADC_GetConversionValue(ADC1); // 0~4095 } // 电压换算:val * 3.3 / 4096,再乘原理图上的分压比,就是电池实际电压ADC 初始化看似简单,实际有三个点容易疏忽。RCC_ADCCLKConfig 分频必须保证 ADC 时钟不超过 14MHz,否则采样值会出现非线性失真;校准必须在 ADC 使能之后、首次转换之前执行,跳过校准会让小信号误差放大;多次采样不建议只读一次,简单做 8 次累加右移 3 位的软件平均,就能把电源纹波干扰压下去。
2.4 从 stm32f10x_rcc.c 看外设时钟开关与功耗
这个工程里所有外设文件都依赖 stm32f10x_rcc.c 提供的时钟使能函数。STM32 的每个外设在上电默认关闭时钟,访问寄存器之前必须先打开对应的 APB1 或 APB2 总线时钟,否则读回来的数据要么是 0,要么直接触发硬件错误。功耗优化同样体现在这里:语音播报的间隔期里,不用的外设可以时钟关闭或进入待机。比如播报完成后的 500ms 内测距已经结束,可以把 ADC 的时钟关掉,等到下一个测距周期再打开,待机电流能从十几毫安降到 3 毫安以内。
提示:排查程序跑飞却找不到逻辑错误时,先检查每个外设初始化前有没有对应的 RCC_APBxPeriphClockCmd。这是 STM32 和 51 单片机最大的习惯差异。
3. 语音模块与USART通信实战:从字节协议到播报策略
3.1 语音模块选型:SYN6288 与 JQ8900 的通信差异
语音导盲系统里,语音提示的质量决定了用户体验。工程里用的语音方案大致分为两派:一类是 SYN6288 这类文本转语音芯片,串口收到 GB2312 编码的中文字符串后直接合成语音;另一类是 JQ8900 这类把声音预先烧录在 flash 里的播报模块,串口发送编号指令触发播放。两者的串口指令帧结构差异很大,代码不能通用。
SYN6288 的帧格式固定为:帧头 FD + 数据长度 + 命令字 + 参数 + 文本 + 异或校验;JQ8900 则是一种简化指令帧,典型的是 AA 开头的固定长度帧。实现在驱动层时,我给语音模块单独开一个 USART 发送接口,上层只传播放索引或字符串,把模块差异封装在底层,这样换模块时只改驱动文件,不动主逻辑。
3.2 USART1 驱动与播放指令的完整代码
无论哪种语音模块,前提都是先把串口配置对。工程里语音模块接在 USART1,PA9 发送、PA10 接收,波特率一般取 9600,模块上电前就要确认拨码开关或固件配置和代码一致。收发用阻塞方式即可,语音播报是毫秒级操作,不会阻塞主循环太久。下面给出针对 JQ8900 播报第 n 段语音的发送函数。
// USART1 初始化:PA9=TX, PA10=RX,波特率可配 void USART1_Config(uint32_t baud) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // TX 复用推挽输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; // RX 浮空输入 GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = baud; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); } // JQ8900 播报第 index 段语音(index 从 1 开始) void Voice_Play(uint16_t index) { uint8_t cmd[8]; uint8_t i; cmd[0] = 0xAA; // 帧头 cmd[1] = 0x08; // 后续数据长度 cmd[2] = 0x01; // 命令字:播放 cmd[3] = 0x00; // 保留参数 cmd[4] = (index >> 8) & 0xFF; // 语音号高字节 cmd[5] = index & 0xFF; // 语音号低字节 cmd[6] = 0x07; // 帧尾 cmd[7] = 0; for (i = 0; i < 7; i++) { cmd[7] += cmd[i]; // 校验:前面字节累加取低8位 } for (i = 0; i < 8; i++) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, cmd[i]); } }发送函数里最需要留意的两点。一是数据位长度必须是 8 位,JQ8900 不支持 9 位,配错之后模块会一直不回应;二是指令之间要有 40ms 以上的间隔,模块内部处理完一条指令需要时间,连续发两条会出现丢指令。如果模块连接的是 USART2 或 USART3,不要忘了它们是挂在 APB1 上的,波特率生成器输入时钟只有 36MHz,直接复用本配置要改预分频,不能用 USART1 那套原封不动。
3.3 播报策略:决策树的优先级与去重
语音模块本身只是执行者,真正决定用户是否被吵到的是主控里的播报策略。工程里我习惯把播报决策拆成三层:感知层计算距离和电量,策略层决定要不要说话,执行层再调用 Voice_Play。策略层的核心是阈值判断加时间去重。比如小于 0.5 米播报"危险",0.5 到 1 米播报"前方有障碍",1 到 2 米不播报,超过 2 米播报一次"前方畅通"并在一分钟内不再重复。
这样设计的原因有两个。第一个是语音播报是人的感知通道,不是数据通道,连续高频播报会让人烦躁;第二个是超声波传感器本身有 20Hz 左右的测量上限,在 2 米量程内,每秒测 5 次已经足够覆盖步行速度,每次触发都播报会造成指令积压。把播报状态定义在一个结构体里,每次测量更新状态但不立即说话,由策略层比较状态变化量决定动作,整体的语音延迟反而更稳定。
| 距离区间 | 播报内容 | 触发条件 | 冷却时间 |
|---|---|---|---|
| < 0.5m | 危险,请停下 | 每次检测到 | 500ms |
| 0.5~1m | 前方一米有障碍物 | 距离缩短时 | 1s |
| 1~2m | 前方有障碍物 | 首次进入 | 2s |
| > 2m | 不播报 | - | - |
| 电量 < 3.3V | 电量偏低请充电 | 每秒 | 30s |
这套阈值如果在调试阶段频繁改,每次都重新编译烧录很费时间。工程里的 stm32f10x_flash.c 提供了一个思路:把阈值版本号和几组距离区间写在内部 Flash 的末尾扇区,上电时先读 Flash,读不到合法标志再用代码里的默认值。调试时只用串口更新参数,不用每次擦除重烧,这也是量产前常见的参数管理方式。表里的阈值不是固定值,走路的步幅大小、拐杖的扫探范围都会影响,可以在配套文档的配置参数说明里查,也可以在代码顶部用宏定义,方便按使用场景调整。
4. Keil工程编译、接线验证与常见故障排查
4.1 Keil 工程配置:芯片型号、宏定义与下载器
源码拿到手先看工程后缀,这套基于标准外设库的工程建议用 Keil uVision5 打开,打开前确认已安装对应芯片的器件支持包。打开工程后第一步是右键 Target 选择 Options for Target,在 Device 页确认芯片型号是 STM32F103C8 还是 STM32F103ZE。如果打开工程后 Device 显示灰色或无法编译,说明本机没装对应该型号的 Pack。
C/C++ 选项卡里的 Define 必须包含 USE_STDPERIPH_DRIVER,这个宏让工程链接标准外设库的驱动代码,同时把 stm32f10x_conf.h 里的外设头文件包含进来。少了这个宏,编译会报一堆 undefined identifier。下载器在 Debug 页配置,用 ST-Link 就选 ST-Link Debugger,用 J-Link 就选 J-Link,下载方式建议用 SWD 四线,只占 PA13 和 PA14,方便接线排查。
4.2 从接线图.bmp 到原理图的引脚核对
资源里的接线图.bmp 一般画的是模块级连接,告诉你 HC-SR04、语音模块、电源各自插哪个引脚;原理图是 STM32 最小系统到外设的完整电气连接。这两个图一定要对着改代码里的引脚宏。比如接线图把 Echo 画在 PA0,原理图里也得是 PA0,如果发现原理图里 PA0 已经被别的模块占用,说明模块复用了引脚,这时要么改代码引脚,要么飞线。
拿到原理图先核对三处:3.3V 电源是否稳定供给到 STM32 的 VDDA 和 VDD 引脚,每个电源引脚旁边的 100nF 去耦电容是否在图纸上,BOOT0 是否通过一个 10k 电阻下拉到地。这三处是最容易在打样后出现无法烧录、上电跑飞的问题源头。使用 STM32F103C8T6 最小系统板的话,板上已经把最小系统画好了,直接对照外设引脚分配接传感器即可,但要注意 C8T6 的 Flash 只有 64KB,如果工程代码较大且开了最高优化仍然放不下,检查是否误用了高容量型号的链接脚本。
4.3 编译报错与硬件故障排查清单
编译阶段最常见的错误集中在缺少启动文件、宏未定义、外设时钟未开。启动文件 startup_stm32f10x_md.s 要和芯片型号匹配,中等容量(C8、CB、R8、RB)用 md,大容量(ZE 等)用 hd,用错会导致中断向量错位,程序上电后卡在 HardFault。硬件阶段如果烧录成功但代码不跑,我一般从电源开始往上查。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 烧录后无反应 | BOOT0 被拉高 | 确认 BOOT0 接地 |
| 语音模块不发声 | 串口 TX/RX 接反 | 确认 PA9 接模块 RX |
| 距离读数恒为 0 | Echo 没接对或 IO 损坏 | 用示波器或万用表量 PA0 电平 |
| 播报有杂音 | 电源纹波大、没共地 | 模块和单片机共地,加 100uF 电容 |
| 上电反复复位 | 去耦电容缺失或晶振虚焊 | 检查 8MHz 晶振并联电容是否按原理图焊接 |
排查的原则是先把软硬件隔离。先写一个只翻转 LED 的最小程序,确认下载链路和最小系统;再把语音模块单独接串口调试助手,直接发指令帧确认模块本身能响;最后才连上传感器调主逻辑。这个顺序能省掉一半以上"看起来代码有问题"的查错时间。
注意:改完原理图或飞线后,不要只改代码里的一个引脚就上电,先拿万用表量一下新引脚的悬空电平,确认没有短路到相邻引脚。
5. 从毕设到可用产品:数据流优化与扩展方向
5.1 用有限状态机重构播报逻辑
第四章的播报策略用 if 堆叠也能工作,但状态多了以后边界条件很难覆盖。常见做法是把导盲过程定义成几个状态:IDLE、APPROACH、STOP、DETOUR、LOW_POWER,每个状态只关心自己的进入条件。状态切换放在一个 10ms 的周期任务里执行,测距结果作为输入,切换动作里只做两件事:更新内部计数器和调用播报。这样改写之后,加新功能时不用动旧的判断分支。比如加一个偏航提醒模块,只需要在 DETOUR 状态里多一个条件判断,而不是在原来 if-else 里硬插一段。
5.2 把 I2C 和 CAN 用起来:扩展传感器与多板通信
源码里带了 stm32f10x_i2c.c 和 stm32f10x_can.c,毕设里可能只是初始化,但扩展方向很明确。I2C 总线可以接 OLED 显示调试信息,或者接 MPU6050 姿态传感器识别用户身体前倾姿态。CAN 总线适合挂第二块控制板,比如把导盲杖的震动马达控制器单独做成一个节点,通过 CAN 发送简单的 ID 加数据帧,减少主控板走线。扩展时注意 CAN 需要 120 欧终端电阻,I2C 需要上拉电阻,原理图上没有就要自己补上。
5.3 数据流检查清单与最终验证
产品化之前,把这条链路的数据流在纸上画一遍:传感器捕获,数据处理,状态机决策,语音模块指令帧,喇叭。任何一个环节的延迟放大,都会直接体现在用户听觉上。用示波器分别量一下 Echo 引脚和串口 TX 引脚,能直观看到从测距脉冲到语音指令发出的时间差。手持一根 1 米木棍在传感器正前方匀速移动,听播报内容是否和木棍距离一致,这个简单实验能覆盖 90% 的策略逻辑问题。
最终验证时在工程里加一个串口调试输出,把每次测到的距离、状态、播报内容打成一行文本,固定波特率输出,比对决策是否符合预期。确认无误后再关掉调试输出,整个系统的语音延迟、功耗和稳定性基本就已经到了可以拿着去答辩演示的状态。
本文还有配套的精品资源,点击获取