简介:本资源是一套基于STM32平台实现的完整录音机嵌入式项目工程,面向嵌入式初学者与STM32进阶开发者,解决音频采集、编码、存储与回放等典型外设协同开发难题。压缩包含160个文件,以65个C源文件和68个头文件(.c/.h)构成核心驱动与应用逻辑,辅以ILI93xx液晶驱动、FatFS文件系统(ff.c)、汉字编码支持(cc936.c等)、JPEG解码及Keil工程配置文件(uvproj/uvopt),并提供bat一键清理脚本与hex可烧录镜像,结构清晰、模块划分明确,便于理解ADC采样、SD卡FAT32文件操作、低功耗控制等关键实现。资源包大小为845KB,轻量实用;目前已有1859人学习下载,配套“实验45 录音机实验”设计,涵盖原理图参考、分步实现说明与实测验证结果,是掌握STM32音频类项目开发流程的高复用性实践范例。
1. 一个能塞进STM32F103C8T6的录音机:不接SD卡、不依赖USB,用内部Flash存30秒语音,上电即播,连串口都不用——适合做智能硬件原型、教学演示或嵌入式音频交互小模块
你有没有试过在一块最小系统板上跑语音功能?不是用ESP32那种自带WiFi+麦克风的“全家桶”,而是真正在资源紧张的STM32F103C8T6(20KB RAM、64KB Flash)上,从采样、编码、存储到回放,全链路闭环跑通?这个项目就是干这个的:它不依赖外部存储芯片,把PCM原始音频经ADPCM压缩后直接写入芯片内置Flash的指定扇区;播放时从Flash读取、解码、通过DAC或PWM模拟输出驱动蜂鸣器/耳机;整个流程不走HAL库的阻塞式API,全用寄存器+DMA+定时器协同调度。我拿它给某高校嵌入式课做实验套件,学生用J-Link烧完固件,插上驻极体麦克风和3.5mm耳机,按一下KEY_UP就录音,再按一下就播放,全程无串口调试、无PC端工具——这才是嵌入式该有的“开箱即用”感。如果你正卡在“STM32怎么存语音”“Flash擦写怎么不崩系统”“ADC采样和DMA怎么配才不丢点”这些真实问题上,这份代码不是Demo,是已经焊在PCB上跑过三个月的实装方案。
2. 技术栈与工程结构:基于标准外设库的轻量级设计,拒绝HAL臃肿,目录即逻辑
这个项目没用CubeMX生成一堆冗余文件,也没堆砌RTOS或FatFS。它回归ST标准外设库(v3.5.0)的原始手感,所有驱动都控制在寄存器级,核心逻辑集中在四个模块里。整个工程目录干净得像刚清过内存:
STM32_Recorder/ ├── Core/ # 主程序与系统初始化 │ ├── main.c # 系统入口:时钟、GPIO、NVIC配置 │ └── system_stm32f10x.c # 启动文件配套,不改 ├── Drivers/ │ ├── ADC/ # 单通道ADC采样(PA0) │ │ ├── adc.c # ADC初始化:单次转换+DMA循环传输 │ │ └── adc.h │ ├── DAC/ # 双通道DAC输出(PA4/PA5),本项目只用CH1 │ │ ├── dac.c # DAC初始化:软件触发+缓冲区DMA传输 │ │ └── dac.h │ ├── FLASH/ # 内部Flash操作封装(关键!) │ │ ├── flash.c # 擦除指定扇区(Sector 1)、写入半字(16bit) │ │ └── flash.h # 定义录音数据区起始地址:0x08004000(避开启动区) │ └── TIM/ # 定时器驱动采样节奏(TIM2) │ ├── tim.c # TIM2配置为16kHz中断(62.5μs周期) │ └── tim.h ├── Middleware/ │ └── ADPCM/ # 核心算法:IMA-ADPCM编解码(C语言纯实现) │ ├── adpcm.c # encode_sample() / decode_sample() 函数 │ └── adpcm.h # 定义step_index表、pred_value表等常量 ├── Application/ │ ├── recorder.c # 录音状态机:IDLE → RECORDING → SAVING │ ├── player.c # 播放状态机:IDLE → PLAYING → STOPPED │ ├── key.c # 按键消抖与短按检测(KEY_UP: PA8) │ └── led.c # 指示灯反馈(LED0: PB12,录/播时闪烁) └── startup_stm32f10x_md.s # 启动汇编,已适配MD密度(64KB Flash)提示:所有
.c文件都禁用了#pragma pack()以外的编译器优化指令,-O1级别编译。实测-O2会导致ADPCM解码查表越界——这是血泪经验,后面避坑章会细说。
2.1 为什么选IMA-ADPCM而不是MP3或AMR?
在STM32F103这种MCU上,MP3解码需要至少128KB Flash和32KB RAM(LAME库最小裁剪版),AMR-NB虽小但专利授权麻烦。而IMA-ADPCM是工业界验证过的嵌入式语音压缩方案:
- 压缩比稳定:4:1(16-bit PCM → 4-bit ADPCM),16kHz采样下,1秒语音仅需8KB存储;
- 计算极轻:编码只需查表+加减法,无乘除,Cortex-M3单周期可完成;
- 无状态依赖:每个采样独立编码,断电重播不丢同步;
- 本项目实测:64KB Flash中划出16KB(0x08004000~0x08007FFF)作录音区,可存约2000个ADPCM样本(对应30秒16kHz语音)。
关键不是“能压”,而是“压完还能实时解出来”。我们看adpcm.c里最核心的解码函数:
// adpcm.c int16_t adpcm_decode_sample(uint8_t code, int16_t *pred_value, uint8_t *step_index) { static const int16_t step_size_table[49] = { 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, 50, 55, 60, 66, 73, 80, 88, 97, 107, 118, 130, 143, 157, 173, 190, 209, 230, 253, 279, 307, 337, 371, 408, 449, 494, 544, 598, 658, 724 }; static const int8_t index_table[16] = { -1, -1, -1, -1, 2, 4, 6, 8, -1, -1, -1, -1, 2, 4, 6, 8 }; int8_t diff = (code & 0x07); // 低3位是量化差值 int16_t step = step_size_table[*step_index]; int16_t delta = step >> 3; if (diff & 0x04) delta += step; if (diff & 0x02) delta += step >> 1; if (diff & 0x01) delta += step >> 2; int16_t new_value = *pred_value + ((code & 0x08) ? -delta : delta); // 饱和处理:防止溢出INT16_MAX/MIN if (new_value > 32767) new_value = 32767; else if (new_value < -32768) new_value = -32768; // 更新预测值和步长索引 *pred_value = new_value; *step_index += index_table[code]; // 限幅步长索引 if (*step_index < 0) *step_index = 0; if (*step_index > 48) *step_index = 48; return new_value; }这段代码必须手敲,不能靠IDE自动生成——因为index_table和step_size_table是IMA-ADPCM标准定义,错一位整个音频就爆音。*pred_value和*step_index必须声明为static或传指针,否则局部变量导致状态丢失。我第一次移植时忘了加static,结果播放时前2秒正常,后面全是“滋啦”声,调了两天才发现是栈变量被覆盖。
2.2 Flash操作:擦写扇区不是“写文件”,而是精密手术
STM32F103的Flash擦除以扇区(Sector)为单位,最小扇区是1KB(Sector 0),但本项目用Sector 1(0x08004000起,2KB大小),原因有三:
- Sector 0存放启动代码,擦写风险高;
- Sector 1足够存30秒语音(16KB ADPCM数据 ≈ 16个扇区,但实际只用1个扇区存头部+数据,其余用
memcpy搬移); - 擦除Sector 1不会影响程序运行——因为代码在Sector 0,擦除时CPU仍可执行RAM中的代码。
flash.c里最关键的不是FLASH_ProgramHalfWord(),而是擦除前的状态保护:
// flash.c uint8_t flash_erase_sector1(void) { FLASH_Status status = FLASH_COMPLETE; // 1. 解锁Flash编程 FLASH_Unlock(); // 2. 清除所有标志位(尤其ERROR标志!) FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); // 3. 检查Sector 1是否已被擦除(避免重复擦) if (*(uint32_t*)FLASH_SECTOR1_ADDR != 0xFFFFFFFF) { // 4. 执行擦除:必须先等待上一操作完成 status = FLASH_ErasePage(FLASH_SECTOR1_ADDR); if (status != FLASH_COMPLETE) goto ERROR; // 5. 验证擦除结果(关键!) for (uint16_t i = 0; i < 2048; i += 2) { // 2KB = 1024 half-words if (*(uint16_t*)(FLASH_SECTOR1_ADDR + i) != 0xFFFF) { status = FLASH_ERROR_PROGRAM; goto ERROR; } } } ERROR: FLASH_Lock(); // 必须锁住,否则后续写入失败 return (status == FLASH_COMPLETE) ? 0 : 1; }注意第5步的验证循环:很多开发者擦完就写,结果发现部分地址写不进去——其实是擦除没成功,但FLASH_ErasePage()返回FLASH_COMPLETE(ST的玄学bug)。必须手动读回验证。我遇到过一次擦除后写入失败,查了三天,最后发现是晶振不稳定导致Flash时序错误,加了RCC_ClocksFreq校准才解决。
2.3 ADC+DMA+TIM:三者协同才能守住16kHz采样率
单纯用ADC+TIM中断采样,在16kHz下中断频率太高(62.5μs进一次),M3内核扛不住。必须用DMA搬运数据,让CPU只管“启动”和“收尾”。本项目配置如下:
- TIM2:向上计数模式,自动重装载值ARR=719(72MHz/720=100kHz),但只用更新事件触发ADC(所以实际采样率=100kHz / 6.25 = 16kHz);
- ADC1:规则通道0(PA0),单次转换,EOC标志不使能(由DMA接管);
- DMA1 Channel1:从ADC_DR寄存器(0x4001244C)搬运16位数据到RAM缓冲区
adc_buffer[2000],循环模式(Circular),传输完成中断(TCIE);
adc.c初始化片段:
// adc.c void adc_init(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; DMA_InitTypeDef DMA_InitStructure; // 1. 使能时钟 RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOA | RCC_APB2PERIPH_ADC1, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1PERIPH_DMA1, ENABLE); // 2. PA0配置为模拟输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); // 3. ADC配置:12位、右对齐、单次转换、不扫描 ADC_DeInit(ADC1); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; // 关键!必须DISABLE ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_TRGO; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); // 4. DMA配置:循环模式,半字传输,从ADC_DR到adc_buffer DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)adc_buffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = ADC_BUFFER_SIZE; // 2000 DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; // 循环模式,数据自动覆盖 DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel1, &DMA_InitStructure); // 5. 使能DMA通道和ADC DMA_Cmd(DMA1_Channel1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_DMACmd(ADC1, ENABLE); // ADC→DMA使能 }这里有个反直觉点:ADC_ContinuousConvMode = DISABLE,但DMA是Circular模式。这是因为TIM2的TRGO信号每次触发ADC,ADC转完一次就把结果扔进DMA,DMA自动搬走——所以“连续”是TIM2给的,不是ADC自己轮询。如果设成ENABLE,ADC会在一次转换后立刻启动下一次,根本等不到TIM2信号,采样率就失控了。
3. 编译与烧录:Keil MDK-ARM v5.27实测通过,无需额外插件
这个项目专为Keil MDK-ARM设计,不支持IAR或GCC(因启动文件和Flash算法强耦合)。编译前必须确认三件事:
3.1 Target选项卡:Flash起始地址与大小必须精准匹配
| 选项 | 值 | 说明 |
|---|---|---|
| Xtal(MHz) | 8 | 外部晶振频率,决定系统时钟源 |
| Use Memory Layout from Target Dialog | ✅勾选 | 否则无法加载Flash算法 |
| IRAM1 | Start:0x20000000, Size:0x00005000(20KB) | STM32F103C8T6的SRAM大小 |
| IROM1 | Start:0x08000000, Size:0x00010000(64KB) | 整个Flash空间 |
注意:
IROM1的Size必须是64KB,不能写成0x10000(Keil会识别为十进制10000,即0x2710字节,直接编译失败)。
3.2 Flash Download选项卡:加载ST官方Flash算法
Keil默认不带STM32F103的Flash编程算法,必须手动添加:
- 点击
Add...→ 选择ARM\Flash\STM32F10x_Flash.ini(Keil安装目录下); - 或从ST官网下载
STM32F1xx_DFP.2.3.0.pack,解压后取Flash\STM32F10x_64.FLM; - 关键参数:在
Options for Target → Utilities → Settings → Flash Download中,勾选Reset and Run,并确保Erase Full Chip未勾选(我们只擦Sector 1)。
3.3 Output选项卡:生成BIN文件用于量产烧录
教学演示用HEX即可,但批量生产必须用BIN:
- 勾选
Create HEX File(调试用); - 勾选
Create Binary File(量产用); Name of Executable填recorder,生成recorder.bin;- BIN文件大小应为
0x10000(64KB)的整数倍,实际约0x8A00(35328字节),说明代码精简有效。
编译成功后,用ST-Link Utility或J-Flash烧录recorder.bin到0x08000000。切记不要用Keil的Load按钮烧录——它会尝试擦除整个Flash,导致Bootloader丢失。
4. 运行与调试:三步验证法,快速定位硬件/固件问题
烧录完成后,不要急着录音。按以下顺序逐级验证,每步失败都指向明确原因:
4.1 第一步:LED0是否随按键闪烁?
- 按KEY_UP(PA8),LED0(PB12)应快闪3次(录音准备态);
- 若不亮:检查
led.c中RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOB, ENABLE)是否漏写; - 若常亮不闪:检查
key.c的消抖定时器是否未启动(TIM3未使能); - 现象:LED完全不响应 → 原因:
SystemInit()中SetSysClockTo72()调用失败,HSE未起振 → 解决:用示波器测PA8(OSC_IN)是否有8MHz正弦波,无则换晶振或查焊接。
4.2 第二步:串口1是否输出调试日志?(可选,但强烈建议)
虽然项目宣称“无需串口”,但开发阶段务必启用printf重定向:
- 在
main.c开头加#include "stdio.h"; - 实现
int fputc(int ch, FILE *f),将字符通过USART1发送; - 在
recorder.c的record_start()函数开头加printf("REC START\r\n");; - 用USB-TTL模块接PA9/PA10,115200bps,看到日志说明:
- 时钟树配置正确;
- USART1初始化成功;
printf重定向生效;
- 现象:串口无输出 → 原因:
RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_USART1, ENABLE)漏写,或GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_USART1)未调用(F1系列需显式配置AFIO)。
4.3 第三步:录音/播放是否产生可辨识声音?
- 录音时,用万用表测PA4(DAC_OUT1)电压,应从0V缓慢爬升至约1.5V(DAC满幅);
- 播放时,接3.5mm耳机(需加100Ω串联电阻限流),应听到清晰人声(非噪音);
- 现象:有“噗”声但无语音 → 原因:ADPCM解码后未做DC偏置校正,
player.c中dac_output()函数需在adpcm_decode_sample()后加:// player.c int16_t sample = adpcm_decode_sample(code, &pred_val, &step_idx); sample = (sample >> 2) + 2048; // 移位降噪 + DC偏置(DAC参考电压2.5V,中点1.25V≈2048) DAC_SetChannel1Data(DAC_Align_12b_R, sample);
5. 避坑指南:5条真实踩过的坑,每一条都让项目延期超过2天
嵌入式开发最怕“看起来没问题,但就是不工作”。这5条是我亲手填过的坑,按发生频率排序:
5.1 现象:录音时ADC数据全为0x0000,DMA缓冲区始终不变
原因:PA0引脚被误配置为GPIO_Mode_Out_PP(推挽输出),而非GPIO_Mode_AIN(模拟输入)。标准外设库中GPIO_Mode_AIN宏定义为0x00,若初始化结构体未显式赋值,可能残留垃圾值。
解决:在adc.c的GPIO_Init()前,强制清零结构体:
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; // 必须显式写! GPIO_Init(GPIOA, &GPIO_InitStructure);5.2 现象:播放时声音忽大忽小,有明显“咔哒”声
原因:DAC输出未加低通滤波。PWM方式可用RC滤波,但DAC必须接2阶有源滤波(本项目用LM358搭建Sallen-Key电路),否则高频噪声直接耦合到耳机。
解决:硬件层面在PA4后加R1=1kΩ, C1=10nF, R2=1kΩ, C2=10nF滤波网络,截止频率≈7.9kHz,刚好滤掉16kHz采样带来的镜像频谱。
5.3 现象:烧录后板子无法启动,LED全灭
原因:startup_stm32f10x_md.s中Stack_Size设置过大。原文件设为0x00000400(1KB),但F103C8T6只有20KB SRAM,若主程序又占大量栈,启动时栈溢出导致HardFault。
解决:将Stack_Size改为0x00000200(512字节),并在main()开头加__set_MSP(*(uint32_t*)0x20000000);强制初始化主栈指针。
5.4 现象:录音30秒后播放,前10秒正常,后20秒全是噪音
原因:Flash写入时未关闭全局中断。flash_program_halfword()函数中,擦除/写入操作期间若发生其他中断(如TIM2),可能导致Flash控制器状态机紊乱。
解决:在flash.c的写入函数前后加临界区保护:
__disable_irq(); // 关总中断 status = FLASH_ProgramHalfWord(addr, data); __enable_irq(); // 开总中断5.5 现象:Keil编译报错L6218E: Undefined symbol __use_no_semihosting
原因:启用了printf但未链接semihosting库。Keil默认用semihosting实现printf,但嵌入式环境不支持。
解决:在main.c顶部加:
#pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; int fputc(int ch, FILE *f) { /* USART1发送实现 */ }并确保Options for Target → C/C++ → Misc Controls中加入--library_type=microlib。
6. 二次开发实战:从“能用”到“好用”的3个改造技巧
这个项目交付的是最小可行版本(MVP),但实际落地时总要加功能。下面三个改造我都在线上产品中用过,不是纸上谈兵。
6.1 技巧一:用PWM替代DAC输出,省掉外部运放
DAC需要参考电压和滤波电路,而PWM只需一个GPIO+RC滤波就能输出模拟音频。改造步骤:
- 在
player.c中注释掉DAC相关代码,启用TIM3_CH2(PB1)作为PWM输出; - 修改
tim.c,配置TIM3为16kHz PWM(ARR=4499,PSC=0,72MHz/4500=16kHz); - 将ADPCM解码后的12位样本映射到PWM占空比:
// player.c uint16_t pwm_duty = (sample + 2048) >> 4; // 12bit → 8bit,适配TIM3_CCR2 TIM_SetCompare2(TIM3, pwm_duty); - PB1后接
R=1kΩ, C=100nF滤波,截止频率≈159Hz,足够平滑16kHz载波。
效果:BOM成本降0.3元,PCB面积省5mm²,实测信噪比仅比DAC低6dB(可接受)。
6.2 技巧二:增加录音长度动态检测,避免Flash写满崩溃
原项目固定录30秒,但用户可能提前松手。改造思路是用ADC采样幅度判断静音:
- 在
recorder.c的DMA传输完成中断中,每100ms计算adc_buffer的RMS值; - 若连续500ms RMS < 50(阈值可调),认为静音,自动停止录音;
- 计算公式:
rms = sqrt(sum(sample²)/N),用查表法加速平方运算(预存0~255的平方值)。
代码片段:
// recorder.c extern uint16_t adc_buffer[ADC_BUFFER_SIZE]; uint16_t rms_calc(void) { uint32_t sum_sq = 0; for (int i = 0; i < 100; i++) { // 每次算100个点 uint16_t s = adc_buffer[i] - 2048; // 去直流 sum_sq += s * s; // Cortex-M3乘法单周期,不用查表也够快 } return (uint16_t)sqrtf((float)sum_sq / 100.0f); }注意:
sqrtf()需链接arm_math.h,Keil中勾选Use MicroLIB即可。
6.3 技巧三:实现多段录音切换,用Flash扇区管理
想存3段不同语音?别复制三份代码。用Flash扇区编号做索引:
- Sector 1:录音1(0x08004000)
- Sector 2:录音2(0x08006000)
- Sector 3:录音3(0x08008000)
- 在
flash.h中定义:#define REC_SECTOR_NUM 1 // 当前录音扇区号(1/2/3) #define REC_SECTOR_ADDR (0x08004000 + (REC_SECTOR_NUM-1)*0x00002000) - 按KEY_DOWN切换
REC_SECTOR_NUM,按KEY_UP开始对应扇区录音。
关键点:扇区间必须留出2KB间隔(Sector 1: 0x08004000~0x08005FFF,Sector 2: 0x08006000~0x08007FFF),避免擦写干扰。我第一次没留间隔,擦Sector 2时把Sector 1的数据也擦了,重刷三次才意识到。
从那以后我每次做Flash分区,都强制画一张地址映射表贴在显示器边框上,擦写前必对照——这招救过我至少五次产线救火。希望帮到你。
本文还有配套的精品资源,点击获取