简介:面向电子竞赛参赛队伍与需要掌握单片机语音系统的嵌入式学习者,这份资源提供了一套完整的数字化语音存储与回放实现方案,内容涵盖语音采集、ADC采样、PCM/ADPCM编码、存储器管理、DAC解码回放、按键交互和低功耗设计等核心环节,可帮助读者理解系统级软硬件协同工作流程。压缩包共40个文件,包括汇编源码与C程序、头文件、编译生成的lst/obj/map/sym中间文件、Keil工程配置以及两篇PDF设计文档,整包约750KB,文件规模适中,可直接对照工程结构和说明文档快速上手。目前已有515人学习下载,特别适合电子设计竞赛赛前准备;借助完整的可编译工程,读者不仅能复现语音录放过程,还能在此基础上修改编码策略、优化存储空间和电源管理,提升排查与二次开发能力。
1. 基于单片机的数字化语音存储与回放:为什么第一步是算采样率这笔账
做基于单片机的数字化语音存储与回放系统设计,最容易翻车的地方不是焊电路,也不是写代码,而是开头的“采样率拍脑袋”。语音是连续波形,单片机只认离散数字,中间靠采样、量化、存储、重建四步完成转换;而“多少采样率、多少位量化”直接决定了后续 Flash 够不够存、声音闷不闷、电路要不要加滤波器。这篇笔记按我实际做过的可靠方案,把方案选型、信号链路、采样与读写状态机、以及高频踩坑一次讲清,适合课程设计、毕业设计仿制和语音记录类小工程参考。
2. 方案选型先算账:采样率、量化位数与存储介质互相制约
2.1 采样率定多少:从语音带宽和奈奎斯特说起
人耳能听到 20Hz 到 20kHz,但语音电话只需要 300Hz 到 3.4kHz,这是长期工程实践压出来的带宽。奈奎斯特采样定理要求采样率至少是信号最高频率的两倍,所以电话系统把采样率定在 8kHz 是有依据的:既能完整保留语音的清晰度,又把数据量压到最低。
8kHz 之下声音发闷,超过 16kHz 之后普通喇叭也放不出更多细节,只会成倍吃掉存储空间。我的选择习惯是这样的:
- 只做对讲、留言、提示音:8kHz 采样,够用且容量最省;
- 想保留更多语调和气声:11.025kHz,听感更亮;
- 追求接近 FM 广播听感:22.05kHz,但每秒数据量翻到 22KB,1MB Flash 只能录 46 秒。
采样率一旦定高,后面所有硬件和软件都要跟着加预算,所以第一步不是焊板子,而是拿计算器把账算明白。
2.2 量化位数:8bit 还是 12bit,用 SNR 公式而不是感觉来定
单片机自带的 ADC 一般是 12 位,但“采集 12 位”和“存储 12 位”是两回事。量化位数决定信噪比上限,公式是 SNR ≈ 6.02N + 1.76dB,N 是位数。8bit 大约 50dB,12bit 大约 74dB,16bit 接近 98dB。
50dB 的底噪在安静环境里能明显听到“沙沙”声,但作为语音录放完全可接受,这正是电话语音用 8bit 编码的原因。如果你直接存 12bit 线性数据,每样本要占 16bit 存储,容量翻倍,换来的是听力上并不明显的提升。更聪明的做法是采集时用 12bit,存储时做 μ-law 压扩压成 8bit,音质接近 11bit 线性且容量减半,这个在第 6 章展开。
所以量化位数的结论是:容量紧张用 8bit 存储,追音质用 12bit 采集加压扩,别把 ADC 位数直接当存储位数用。
2.3 存储介质对比:内部 Flash、SPI NOR 和 SD 卡怎么选
存储介质是整个系统最容易选错的地方。我列过一张对比表,直接照着选就行:
| 存储介质 | 容量范围 | 写入特点 | 寿命 | 适用场景 |
|---|---|---|---|---|
| 单片机内部 Flash | 几十到几百 KB | 按页编程,按扇区擦除 | 1 万到 10 万次擦写 | 短录音演示,省硬件 |
| 外部 SPI NOR Flash | 1MB 到 64MB | 页编程快,扇区擦除几十毫秒 | 1 万到 10 万次擦写 | 课程设计、小型产品首选 |
| EEPROM | 几 KB | 可按字节写,擦写寿命百万次 | 寿命长但容量太小 | 只适合存配置参数 |
| SD 卡 | GB 级 | 块设备,需文件系统或裸块管理 | 寿命与品质相关 | 录音笔类长时录音 |
做单片机的数字化语音存储与回放系统,我一般首选外部 SPI NOR Flash,容量 1MB 起步。它接线只有 6 根,驱动简单,扇区擦除和页编程时序可控,拿来理解语音存储原理最合适。SD 卡虽然容量大,但要处理 FAT 文件系统、坏块和掉电保护,复杂度会吞掉你调音质的精力。
2.4 系统参数定稿:一张表把采样率、缓冲和容量锁死
综合上述分析,我常用的参数组合如下:
| 参数 | 取值 | 说明 |
|---|---|---|
| 采样率 | 8kHz | 语音带宽 3.4kHz,满足奈奎斯特 |
| ADC 位数 | 12bit 采集 | 内置 ADC 典型位数 |
| 存储编码 | 8bit μ-law | 容量减半,音质优于线性 8bit |
| 每块采样点数 | 1024 点 | 约 128ms 一块,中断负担适中 |
| DMA 双缓冲 RAM | 2 × 1024 × 2 字节 | 约 4KB,绝大多数单片机可接受 |
| Flash 容量 | 1MB SPI NOR | 存 μ-law 数据约 128 秒录音 |
| 扇区管理 | 4KB 扇区,预擦除 | 避免擦除阻塞采样 |
这些参数定下来后,硬件原理图和软件状态机就都有依据了。接下来先画信号链路,再写驱动程序。
3. 硬件信号链路:驻极体麦克风到扬声器的放大、采样与重建
3.1 驻极体麦克风的前端偏置与 30dB 增益怎么给
驻极体麦克风内部集成了场效应管,必须给它一个直流偏置才能工作。常见做法是接一颗 2.2kΩ 上拉电阻到电源,信号从麦克风输出端经 1μF 耦合电容送到放大器。耦合电容和放大器的输入电阻组成高通滤波器,截止频率大约在几十赫兹,用来去掉直流偏置,避免直流被放大后顶到 ADC 量程边界。
放大级我推荐用单运放同相放大,增益由反馈电阻和接地电阻决定,公式是 G = 1 + Rf / Rg。这里有一个新手常犯的错误:把增益算得太大。麦克风典型输出在 10mV 到 50mV 之间,如果按 100 倍放大,50mV 会被推到 5V,直接削顶失真。我给到 33 倍左右,也就是 Rf 取 33kΩ、Rg 取 1kΩ,这样最大输出约 1.65V,给 ADC 留足裕量,同时说话轻声时也有足够幅度。
放大输出端再串一个 100Ω 电阻到 ADC 输入引脚,可以抑制数字开关噪声串入模拟通路。如果在实际调试中发现底噪大,先检查偏置电阻是否接在模拟电源上,而不是数字电源。
3.2 抗混叠滤波:为什么采样前要砍掉一半以上的频率
采样率定在 8kHz 时,理论上 4kHz 以上的信号都会发生混叠,折返到低频段变成刺耳的“金属声”。比如现场有一个 6kHz 的噪声,采样后你会听到一个 2kHz 的幽灵信号,而且它叠加在真实语音上,后期无法去掉。
所以前端必须在采样前加低通滤波器。预算有限时,一阶 RC 就够了,截止频率取 3.4kHz:R 取 4.7kΩ,C 取 10nF,算下来 fc ≈ 3.39kHz。如果对音质要求高,就用二阶有源低通,在 8kHz 处能多衰减一倍。这里提个醒:很多人把滤波器放在 DAC 输出端,以为能同时解决采样前的问题,实际上混叠发生在采样那一瞬间,重建滤波器救不回来。
3.3 ADC 采样管线:定时器触发、DMA 搬运与关键参数计算
ADC 采样必须严格等间隔,靠软件延时循环去触发 ADC 是玄学,抖动会让回放音调发飘。正确做法是用定时器产生触发事件,ADC 每次收到触发就启动一次转换,转换结果由 DMA 自动搬到内存缓冲区,CPU 全程不参与单点搬运。
采样率的计算公式是:Fs = 定时器时钟 /[(PSC + 1)×(ARR + 1)]。假设定时器时钟 72MHz,PSC 取 71,ARR 取 124,那么 Fs = 72MHz /(72 × 125)= 8000Hz,正好 8kHz。代码框架如下:
/* 以某款自带12位ADC和DMA的32位单片机为例 */ /* 寄存器名按实际芯片参考手册对应,这里只展示配置逻辑 */ TIM->PSC = 71; /* 72MHz/(71+1)=1MHz,先分频到1MHz */ TIM->ARR = 124; /* 1MHz/(124+1)=8kHz 触发事件 */ TIM->CR |= TRIG_ENABLE; /* 打开定时器触发输出 */ ADC->TRIG = TIM_EVENT; /* ADC选择定时器事件作为触发源 */ ADC->SMP = 10; /* 采样时间档位,选单片机上偏大的档,保证信号建立 */ ADC->CR |= ADC_ON | TRIG_EN; /* 使能ADC和外部触发 */ DMA->SRC = ADC_DR; /* DMA源:ADC数据寄存器 */ DMA->DST = dma_buf[0]; /* DMA目的:第一块采样缓冲 */ DMA->LEN = 1024; /* 每块1024个采样点 */ DMA->MODE = CIRCLE; /* 循环模式,块满后自动回绕 */参数上有两点建议。第一,采样时间档不要一味求快,12 位 ADC 的采样电容需要时间充电,信号源内阻偏高时会出现幅度衰减,表现为高频发闷;选参考手册里偏长的采样时间更稳。第二,DMA 一次搬 1024 点在一块,约 128ms 触发一次块结束中断,CPU 负担很轻;如果 RAM 紧张,可以改成 256 点一块,代价是中断频率升到 32Hz,主循环要更及时地处理数据。
3.4 DAC 重建与回放功放:镜像频率和直流爆音两个坑
DAC 输出的本质是阶梯波,频域上除了原始语音,还有以 8kHz 为间隔的镜像频率。这些镜像如果不滤掉,会变成高频“滋滋”声,喇叭越灵敏越明显。回放端必须加低通重建滤波器,一阶 RC 在 8kHz 处只衰减约 7dB,听感仍有残留;我一般用二阶,R 取 10kΩ、C 取 4.7nF,截止频率约 3.4kHz,8kHz 处能压掉 14dB 以上。
功放部分注意两点。一是输入一定要加隔直电容,DAC 输出的直流偏置被功放放大后会流过扬声器线圈,轻则发热,重则烧喇叭;二是功放增益不要超过 20dB,扬声器是 8Ω 小功率时,过高的电压摆幅只会提前削顶。整个链路从“麦克风 → 放大 → 抗混叠 → ADC → 存储 → DAC → 重建滤波 → 功放 → 喇叭”,每一级都按 3.4kHz 带宽设计,信号才不会在某处变形。
4. 单片机软件实现:双缓冲、Flash 扇区管理与回放状态机
4.1 录音主循环:双缓冲框架与中断里只做一件事
录音软件的核心是“中断里只读书指针,主循环里写 Flash”。ADC 用 DMA 循环模式写缓冲区,块满时触发中断,中断里把当前这块数据地址交给主循环,然后立刻返回。主循环检测到标志位后,把整块数据写到 Flash。
/* DMA双缓冲采样的通用框架 */ #define BUF_LEN 1024 /* 一块1024点,8kHz下约128ms */ static uint16_t dma_buf[2][BUF_LEN]; static uint16_t *filled_block; /* 刚填满的块 */ static volatile uint8_t block_ready = 0; /* 由DMA块满中断调用,中断里只记录指针 */ void on_dma_block_done(uint16_t *buf) { filled_block = buf; block_ready = 1; } /* 主循环任务:把满块写入Flash */ void record_service(void) { uint32_t addr = current_write_addr; if (!block_ready) return; block_ready = 0; flash_page_program(addr, filled_block, BUF_LEN * 2); current_write_addr = addr + BUF_LEN * 2; }为什么必须双缓冲?如果只有一个缓冲区,DMA 和主循环会同时访问同一块内存,写 Flash 期间 DMA 还在采样,数据会被覆盖。双缓冲保证 DMA 写一块,主循环读另一块,写完再互换。参数上,1024 点一块时主循环有整整 128ms 来完成写 Flash 操作,时间非常充裕;即使调小到 256 点,也有 32ms 窗口,仍在常见 SPI Flash 的页编程时间之上。
4.2 Flash 写入策略:先擦后写、预擦扇区与循环地址管理
NOR Flash 的特性是“擦除后才能写、按扇区擦除、按页编程”。擦除一个 4KB 扇区需要几十到一百多毫秒,这个时间如果发生在录音中途,主循环会被堵住,采样数据就会丢失。解决思路是预擦除:在主循环空闲时,提前把下一个要用的扇区擦好,录音任务只需要页编程,全程不会遇到长阻塞。
/* 预擦除任务:每次空闲时检查并擦除下一个扇区 */ static uint32_t pre_erased_sector = 0; void preerase_service(void) { uint32_t next_sector; next_sector = (current_write_addr / 4096 + 1) * 4096; if (next_sector != pre_erased_sector) { flash_erase_sector(next_sector); pre_erased_sector = next_sector; } }注意一个边界:录音结束地址所在的扇区不需要预擦除,如果 next_sector 超过了设置的录音结束地址,要主动终止任务,否则会把录音末尾之后的保留区擦掉。循环录音的地址管理上,我会额外预留一个扇区做“缓冲保护”。比如 1MB Flash 从地址 0x10000 开始录音,到 0xFFFFF 结束,那么实际可用扇区要减掉一个,否则写指针回绕时会撞到坏块或参数区,这是很多仿制者容量对不上的原因之一。
4.3 回放状态机:定时器逐点输出与主循环预读缓冲
回放和录音是相反的过程:定时器以 8kHz 触发中断,中断里从缓冲区取一个样本写入 DAC;缓冲区播完前,主循环从 Flash 读出下一块数据,预装载到另一块缓冲区。这样中断里永远有数据可取,声音不卡顿。
/* 回放双缓冲框架:预读Flash,由定时器中断逐点输出 */ static uint16_t play_buf[2][BUF_LEN]; static uint8_t play_using = 0; /* 当前正在播放的缓冲 */ static uint16_t play_idx = 0; static uint32_t play_addr = 0; static uint32_t play_end_addr = 0; static volatile uint8_t block_consumed = 0; void on_playback_timer(void) { DAC->DR = play_buf[play_using][play_idx++]; if (play_idx >= BUF_LEN) { play_idx = 0; block_consumed = 1; /* 通知主循环该换块了 */ } } void playback_service(void) { if (!block_consumed) return; block_consumed = 0; play_using ^= 1; /* 切换到另一块缓冲播放 */ if (play_addr < play_end_addr) { flash_read(play_addr, play_buf[play_using], BUF_LEN * 2); play_addr += BUF_LEN * 2; } else { playback_stop(); /* 播完自动停止 */ } }这里有一个新手容易写翻车的细节:中断里访问的 play_using 和主循环修改的 play_using 是同一个变量,理论上存在竞争。我实际处理时会给它加 volatile,并在切换缓冲时用临界保护:先关中断、改 play_using、再开中断。另外,回放开始时先预读两块缓冲再启动定时器,否则第一个 128ms 内会有一段空白。这套状态机把“采样 → 写 Flash”和“读 Flash → 输出”完全对称,录放切换只需要改变当前任务,不会互相干扰。
5. 五个高频避坑记录:从放不出声到声音发闷的完整排查
5.1 回放全是嗡嗡底噪:录音正常,问题在模拟地与参考电压
现象:录音时用耳机直接听放大器输出,感觉很干净;一录一回放,底噪变成持续的“嗡嗡”声,音量开大后更明显。原因:麦克风放大电路和单片机数字电路共用同一条地线,DMA 和 Flash 读写的大电流在地线上产生压降,噪声被前端放大后进入采样。另一个隐蔽来源是 ADC 参考电压引脚没有去耦。解决:把模拟地和数字地做星形单点连接,功放和单片机分开供电;参考电压引脚对地并联 10μF 钽电容和 100nF 陶瓷电容,位置尽量靠近引脚。排查顺序是:先拔掉功放输入,若噪声消失说明问题在功放级;再短路麦克风输入,若噪声仍在说明问题在前置放大和地回路。
5.2 声音整体变调:内部 RC 振荡器让采样率悄悄偏了
现象:回放语速明显偏快或偏慢,听着像“松鼠说话”或“慢速磁带”。原因:单片机的内部 RC 振荡器精度通常在 ±2% 左右,8kHz 采样率会偏差 160Hz,回放时偏差直接变成音调偏移;温度变化还会漂移。解决:把定时器的时钟源切换到外部晶振,这是最彻底的办法。如果硬件已经定型,只能通过软件校准:用示波器实测定时器触发引脚的实际频率,微调 ARR 值补误差。判断采样率是否准确还有一个简单方法:录一段 1kHz 正弦波,回放后用示波器看波形频率,如果显示 1.02kHz 或 0.98kHz,说明采样率有偏差。
5.3 录音中段卡顿:Flash 擦除几十毫秒,阻塞了采样任务
现象:录音开头正常,几秒后每过一段时间出现一次明显断音,时间间隔接近擦除一个扇区所需的时间。原因:Flash 扇区擦除期间 CPU 被阻塞了几十到一百多毫秒,而 DMA 仍在采样,缓冲区被填满后发生覆盖,主循环根本来不及取走。解决:把擦除动作从录音流程里移出去,用上一章讲的预擦除任务,在主循环空闲时提前擦完下一个扇区;同时把录音采样率降到 8kHz + 8bit 存储,单位时间数据量减半,每块缓冲能扛的时间更长。还有一个兜底方案:内存充裕时把双缓冲各扩到 2048 点,给主循环更长的写 Flash 窗口。
5.4 高频滋滋声和爆音:DAC 输出缺少重建低通滤波器
现象:回放语音内容清晰,但说话间隙里有持续的“嘶嘶”声,音量越大越明显;播放开始和结束瞬间还有“啪”的爆音。原因:DAC 输出的阶梯波带有 8kHz 及其整倍数的镜像频率,直连功放后这些高频分量被放出来了;爆音则来自播放开始时缓冲区里是随机初值,DAC 从随机电平跳变到语音信号,形成冲击。解决:DAC 输出加二阶低通,截止频率 3.4kHz,把镜像压掉;播放开始前把缓冲区内容全部填充为中间电平,比如 1.65V,再启动定时器,让 DAC 从稳定电平起步。录制时也可以先录 0.1 秒静音作为参考电平,回放时用同一电平对齐。
5.5 录音时长比算出来的短:位与字节的糊涂账和坏块预留
现象:1MB Flash 按 8kHz、8bit 计算应该能录 128 秒,实际只录到 100 秒左右就提示满。原因:第一,把 1MB 当成了 1 兆字节,实际多数 SPI Flash 标注的 1MB 是 1 兆位,等于 128KB 字节,这是最普遍的翻车;第二,Flash 厂商出厂可能有坏块,部分型号特意在首尾保留区不出字;第三,循环录音预留的缓冲扇区本来就要扣掉。解决:读取芯片 ID 后查数据手册确认字节容量,然后减去保留区、坏块和缓冲扇区,得到真正的可用地址范围。上电自检时扫描一遍扇区,把擦除后仍无法写通的标记为坏块,录音地址管理跳过这些区域,这样就不会录到一半死机。
6. 把音质往上顶一档:μ-law 压扩、抗混叠与验证三件套
6.1 μ-law 压扩把 12bit 塞进 8bit:查表实现与参数
直接存 12bit 线性数据要占 2 字节,容量压力大;直接截断成 8bit 会损失小信号细节,轻微说话声会被量化噪声吃掉。μ-law 压扩是电话语音的标准做法,对小信号分配更多量化级、大信号分配更少量化级,用 8bit 达到接近 11bit 的主观音质。实现上不用现场算对数,离线生成两张表即可:
/* μ-law压扩表:录音压缩,μ=255 */ static uint8_t ulaw_encode[4096]; /* 12bit线性值映射到8bit码字 */ /* 回放扩展表 */ static int16_t ulaw_decode[256]; /* 8bit码字映射回12bit线性值 */ /* 初始化:由离线脚本计算生成,μ=255 */ void ulaw_tables_init(void) { int16_t linear; for (linear = 0; linear < 4096; linear++) { /* 按μ-law公式归一化后量化到256级 */ ulaw_encode[linear] = calc_mulaw(linear); } /* 反向表同理 */ }编码表 4KB、解码表 512KB 的 RAM 占用在绝大多数单片机上都能接受,如果 RAM 紧张就改成压缩时公式计算、回放时查表,解码端永远只查 256 项。采样端保持 12bit 采集,存储和传输用 8bit,回放前扩展回 12bit 再送 DAC,容量减半但音质损失远小于直接截断。
6.2 验证三件套:定时器周期、正弦回放、频谱谐波检查
系统做完后不要只听“感觉还行”,用三个客观手段验证。第一,示波器量定时器触发引脚,周期应该是 125μs,误差超过 1% 就要查晶振和分频配置。第二,输入 1kHz 正弦波,录 5 秒回放,示波器看 DAC 输出波形,应该是平滑正弦曲线而不是阶梯感明显的折线,阶梯感说明重建滤波器不够。第三,把回放信号送到音频接口,用 FFT 看频谱:除了 1kHz 基波,二次、三次谐波应该依次衰减;如果 6kHz 或 7kHz 附近出现明显能量峰,说明镜像频率没有滤干净,回头加强重建滤波。这三关全过,系统的数字化语音存储与回放才算真正闭环。我自己的习惯是每次改完硬件先看参考电压纹波、再录满整段才敢交给别人,“先测后听”帮我挡掉了一半说不清来路的玄学问题,希望帮到你。
本文还有配套的精品资源,点击获取