项目麦克风:从模拟电压到声音数据
已经会读一次电压,接下来让程序连续记录声音。沿仓库的 SPU0410 麦克风驱动,弄清每个样本从哪里来、数组什么时候可以读,以及怎样从波动判断声音强弱。
板上器件为 SPU0410HR5H-PB。Knowles 原厂数据手册,H 版(分销商镜像):第 2 页看供电与直流输出,第 4 页看接口电路,第 5 页看引脚。板上连接见 硬件说明;采集配置在 adc.c,驱动在 spu0410.c 和 spu0410.h。
第 1 节 · 麦克风给出的是什么?
声音是空气压力的变化。麦克风内部的感声结构把这种变化转换成电信号,再由内部电路输出电压。我们接到 ADC 的,就是这个随声音变化的输出。它没有等待主控读取的声音寄存器,STM32 要自己连续测量输出电压。
板上麦克风的 4 号脚是输出,连到 STM32 的 PA1 / ADC1_IN6;供电和地按原理图连接。先核对引脚表和板上网络,再在 CubeMX 选择这个模拟通道。芯片供电、ADC 参考电压和输出的静态电压各有含义,不能只因都与电压有关就当成同一个数。
安静时,为什么读数也不等于零?
输出包含一个稳定的直流电压,声音让电压围绕它上下变化。这个稳定部分叫直流偏置。例如教学上假设基准读数约为 1000,声音可能让读数变成 1002、998、1004、996。相对基准的变化才是 +2、−2、+4、−4。
图里的基准只用于解释,实际值要测量。手册列出了特定供电条件下的直流输出,不能假定板上基准必定是 ADC 满量程的一半。仓库启动时采集一帧,计算均值作为初始基准,后续再缓慢跟踪变化。
一次读数只能知道此刻,连续样本才能描述声音
一个数字不能表示音调或整段声音,要把连续时刻的读数按先后放进数组。采样率表示每秒得到多少个输出样本;数组里 256 个连续样本就构成一小段声音,本篇称为一帧。若采样率为 f,一帧时长就是 256÷f 秒。
本项目采用 ADC 自由连续转换,软件启动后由转换器不断产生结果,没有使用定时器逐次触发。输出样本率受 ADC 时钟、采样时间、转换时间及硬件累加次数共同影响。不能因为后面的语音模型期望某个采样率,就认为硬件已经按那个速率采集;接入声音算法前,需要核对配置并测量回调间隔。时钟和定时器章节会进一步解释怎样计算、控制这个节奏。
第 2 节 · 怎样让结果不断进入数组?
基础篇的程序每次启动、等待,再取一个结果。连续记录声音时,我们希望 ADC 不断转换,由另一个硬件单元把结果搬到数组里。DMA 是直接存储器访问硬件,可以先理解为搬运数据的小助手:ADC 产生一个结果,它就把结果搬到内存的下一个位置。CPU 负责配置和处理成批数据,DMA 负责这些重复搬运。
沿 adc.c 的顺序,把每项设置对应到用途
- 接通输入。PA1 设为模拟输入、不启用上下拉,选择 ADC1 的第 6 通道。它让这根引脚进入转换器的模拟输入路径。
- 决定怎样转换。设置 12 位、单通道、连续转换;选择软件启动,无需主循环为每一个样本重新发起启动。采样时间选 20 个 ADC 时钟周期,校准在开始采集前完成。
- 理解硬件过采样。代码开启多次转换累加,再右移得到一个输出。当前库中
Oversampling.Ratio = 14表示累计 15 次,ADC_RIGHTBITSHIFT_4表示除以 16,所以输出为“15 次结果之和÷16”,并非 16 次的平均。这会影响幅度和输出速率,解释现有驱动时要保留这个实际行为。 - 指定从哪里搬、搬到哪里。DMA 请求来自 ADC1,方向是外设到内存;来源地址固定为 ADC 结果寄存器,目标地址每次递增。来源、目标数据宽度均为半字,也就是 16 位,和
uint16_t数组元素对应。 - 搬到数组末尾后继续。仓库使用循环链表模式,走完这一块缓冲区后回到开头继续写。adc.c 中单个节点的
DMA_NORMAL描述节点本身;整条链是否循环,要看HAL_DMAEx_List_SetCircularMode()和DMA_LINKEDLIST_CIRCULAR。这里先知道它能重复填充同一块数组,链表机制留到 DMA 章节展开。 - 接通完成通知。在 gpdma.c 中启用 GPDMA1 Channel0 中断,当前优先级为 6;ADC1 中断也启用,用来处理转换器事件和错误。缓冲区的半满、全满通知沿 DMA 中断进入回调。
adc.c 中建立并关联 DMA 配置,程序启动时也要先完成 DMA 时钟和中断初始化。不要只复制一个 HAL_ADC_Start_DMA() 调用,却遗漏输入通道、搬运宽度、循环方式和中断入口。
启动函数的三个参数是什么?
HAL_ADC_Start_DMA() 接收 ADC 管理对象的地址、目标缓冲区地址、要搬运的结果数量,返回 HAL_OK 表示启动成功。启动成功后函数就返回,数组会在后续逐渐填入数据;这时不能立刻认定整帧已经完成。
spu0410.c 使用全局静态的 mic_buf,长度 MIC_BUFFER_SIZE 为 256。数组在采集期间一直有效,不能放在一个返回后就失效的局部作用域里。启动位置在 mic_start() 中:
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)mic_buf, MIC_BUFFER_SIZE);
这里是 256 个结果、共 512 字节。指针强制转换用于匹配 HAL 接口参数类型,实际搬运宽度仍由 DMA 的 16 位配置决定。仓库这个调用没有检查返回值;排查启动失败时,需要同时观察该函数状态和后续是否收到完成通知,不能只看 active 标志。
HAL_ADC_Stop_DMA(&hadc1) 用于停止相应 ADC 采集和 DMA 搬运。数组填满时是否停止,则由循环模式决定;当前项目会继续填下一轮。
第 3 节 · DMA 在写数组,程序什么时候可以读?
如果程序从头读到一半,DMA 恰好又从开头写入下一轮,就可能把两轮数据混在一起。只知道数组地址还不够,还需要知道哪部分已经写完,以及它多久以后会再次被覆盖。
把 256 个位置分成两半:前半是下标 0~127,后半是 128~255。DMA 写完前半就通知一次;写完后半再通知一次,随后回到前半继续写。于是程序可以利用 DMA 正在写另一半的时间,先把已经完成的半区复制出来。
| 发生的事 | HAL 调用的回调 | 这一刻可处理的半区 |
|---|---|---|
| 本轮前 128 个结果搬完 | HAL_ADC_ConvHalfCpltCallback() | 前半 0~127;DMA 接着写后半 |
| 本轮 256 个结果全部搬完 | HAL_ADC_ConvCpltCallback() | 后半 128~255;DMA 接着写下一轮前半 |
回调参数 ADC_HandleTypeDef *hadc 告诉程序是哪一个 ADC。函数没有返回结果;程序在函数体里完成复制或通知。HAL 默认实现带 __weak 标识,项目定义同名函数提供自己的实现;通常由 HAL 在对应事件发生时调用,不需要主循环反复调用。
实际入口可沿 stm32u5xx_it.c 追踪:GPDMA1_Channel0_IRQHandler → HAL_DMA_IRQHandler → HAL 内部 ADC 搬运回调 → 上表中的用户回调。只启用 ADC1 的中断,不能代替 DMA 通道的中断。
为什么仓库还要准备一份完整帧?
DMA 的工作数组会不断被覆盖,声音处理却需要稳定的一整帧。仓库的做法是:在前半完成时,把前半复制到另一份数组;后半完成时,再把后半复制过去。两半都复制完成,就标记“整帧可读”。后面的计算读取这份完整帧。
对应到源码,copy_half_to(ch, idx) 把 mic_buf 的某个半区复制到指定通道的私有 frame,idx 为 0 表示前半、1 表示后半;ch_on_half_locked(ch, idx) 处理半区完成,并在刚拼好一帧时返回 1;ch_release_locked(ch) 在使用完后释放这帧,让后续数据有机会更新。先理解这三个动作,再阅读它们内部怎样维护标志。
复制也有时间要求:当前半区下一次开始写入之前,必须完成读取或复制。循环缓冲区容量有限,处理长期跟不上就会丢数据。源码里的 pending 标志记录有半区等待处理,并不保存无限历史,也不能凭这些标志保证永不丢帧;连续音频还需要检查实际处理速度和数据连续性。
第 4 节 · 从一帧读数得到声音强弱
第一步:减掉直流偏置
回到 1002、998、1004、996 这个例子。如果直接比较数值大小,稳定基准会淹没微小的声音波动。先减去基准 1000,就得到 +2、−2、+4、−4;正负表示相对基准向哪个方向变化。
启动时可用安静环境中的一帧均值估计基准。后面基准可能缓慢漂移,仓库用“旧基准×0.99998+当前读数×0.00002”逐点更新。旧值占绝大部分,使估计缓慢变化,避免基准跟着每一次声音波动一起跑。
第二步:平方后求平均
把 +2、−2、+4、−4 直接相加,结果为零,但显然存在波动。因此先平方再平均:(4+4+16+16)÷4=10。这样正负波动都贡献正值,波动越大,这个数通常越大。这里得到的是相对的平均平方量,用来衡量这套采集配置下的声音强弱。
只展开上面四个教学样本的计算;实际帧来自已经完成的采集
uint16_t raw[4] = {1002, 998, 1004, 996};
float dc = 1000.0f;
float sum = 0.0f;
for (int i = 0; i < 4; i++) {
float v = (float)raw[i] - dc;
sum += v * v;
}
float mean_square = sum / 4.0f; /* 结果为 10。 */
先转成带正负含义的浮点数再相减,避免把低于基准的样本当成无符号大整数。源码 mic_calc_energy() 对 256 个样本累计,使用较宽的整数保存平方和,并同时更新基准。mic_get_energy() 无参数,返回这帧的平均平方量;它不会重新采一帧。结果受麦克风增益、ADC 配置和距离影响,还不是经过声学校准的分贝值。
第三步:为什么还要数越过零点的次数?
幅度大只能说明波动强,敲击和说话都可能让它增大。减去基准以后,相邻样本从正变负或从负变正,就发生一次过零。把这些次数除以相邻样本对数,可以得到描述变化快慢的过零比例。
仓库的 mic_calc_zcr() 在符号改变时,还要求相邻差值大于 2 个计数,以排除一部分很小的抖动;对 256 个样本按 255 对相邻关系统计。函数名里的 zcr 表示过零率,读源码时认出这个含义即可。
mic_is_shouting() 把三个条件接起来:平均平方量超过 8000,过零比例在 0.12~0.45 之间,并通过连续帧计数判断。满足时计数增加,不满足时逐步减少;计数达到 2 才返回 1。这些是项目当前的经验阈值,是否适合具体板子,需要用实际采集数据调整。这里先学会怎样从原始读数得到判断依据。
第 5 节 · 把采集过程接回项目驱动
前面已经解释了声音、电压、转换、搬运、整帧和计算。现在再打开 spu0410.c,沿启动顺序读,就能知道每个函数为什么存在:
- 先完成硬件初始化。配置 DMA 时钟、中断,初始化 ADC1 和输入通道;基础篇的 battery_init 在声音采集前配置电池注入通道。
- 调用 mic_init。它无参数,先通过 mic_start 清理帧状态并启动 DMA;等待第一帧,超时则停止并返回 −1;成功拿到帧后估计初始直流基准,再返回 0。调用者必须检查结果。
- 回调拼帧。前、后半完成时分别复制;一帧齐全后发出通知,让后续处理开始。
- 使用已经完成的帧。喊叫检测调用 mic_is_shouting,计算强弱和过零比例,再释放这帧;关键词识别通过自己的接口取得去掉偏置的样本。
- 停止时关闭采集。mic_stop 无参数,内部调用 HAL_ADC_Stop_DMA,并清理当前状态;下一次重新启动时重新准备帧。
为什么又有两份私有帧?先看到两个使用者
项目里同一段声音要用于喊叫检测和关键词识别。如果它们共用一个“已读”标志,先处理的程序清掉标志,另一个就可能拿不到这一帧;如果一直等两个都处理完再更新,一方变慢又会拖住另一方。
因此,仓库为两个使用者各保留一份私有完整帧和对应状态:ch_mic 用于喊叫检测,ch_kws 用于关键词识别。二者来自同一条 DMA 采集流,但分别记录是否已经处理。这是读取需求增加以后才引入的管理,不需要在最初理解 ADC 时先背一套结构体。
| 现有接口 | 使用哪份数据 | 调用后的含义 |
|---|---|---|
mic_is_frame_ready() | 喊叫检测通道 | 无参数,非零表示有完整帧等待处理 |
mic_is_shouting() | 喊叫检测通道 | 无参数,返回 1 表示达到当前检测条件;内部使用并释放这帧 |
mic_set_kws_active(1) | 关键词识别通道 | 启用该通道,之后回调才为它复制数据 |
mic_get_frame(samples) | 关键词识别通道 | samples 指向至少 256 个 int16_t 元素;0 表示成功,−1 表示当前无法取得;成功后已释放该帧 |
这些接口各自使用自己的通道,不能只看见“frame”就认为读的是同一份数据。mic_get_frame 内部已经减去直流基准,调用者得到带正负的声音样本,不需要再减一次。
源码里的任务通知,先对应到这条数据路径
当前固件使用 FreeRTOS:回调拼好帧后设置事件位;main.c 的 Mic_Task 等待通知,再执行声音判断。事件位可以先理解为“有一帧可处理”的通知,临界区用来保护状态更新。完整驱动依赖这些任务和事件对象,直接放进只有 while(1) 的新工程还需要配套调整,不能只复制一个 mic_init 调用。
在当前项目已有通知对象和任务的条件下,Mic_Task 被唤醒后,核心处理只是调用前面已经解释过的接口:
int shouting = mic_is_shouting();
/* shouting 为 1 时,本轮达到项目设定的喊叫检测条件。 */
后面的 实时操作系统起步 和 任务协作 会展开怎样等待通知、从中断唤醒任务,以及为什么回调中不直接运行长时间的声音算法。到 DMA 章节 再详细追踪循环链表和缓冲区的管理。
空气压力变化 → 麦克风输出电压 → ADC 连续转换 → DMA 把结果写入数组 → 半区完成后复制 → 组成稳定的一帧 → 减去直流偏置 → 计算声音强弱或交给声音算法。
到这里,项目外设的数据入口就能串起来:按钮读电平,屏幕通过串行接口写像素,传感器通过总线读寄存器,电池和麦克风通过 ADC 测电压。接下来可以直接进入 无线通信,把采集到的数据送到手机,再到 应用协议约定消息格式。时钟、定时器和 DMA 的深入原理可以按需回看,学习路线见 ADC 基础篇结尾。
手册与源码对照
- Knowles SPU0410HR5H-PB,H 版:模拟输出、直流偏置、接口与引脚,分销商保存的原厂文档。
- ST RM0456:ADC1 连续转换、过采样和 DMA 请求。
- spu0410.c、adc.c 和 gpdma.c 分别对应声音帧管理、转换配置和 DMA 中断配置。