☰
STM32低成本音频播报方案:用PWM加RC滤波实现DAC输出
2026/9/28 15:52:05 网站建设 项目流程

想给STM32加个音频播报功能,第一反应是用芯片自带的DAC。结果一查手册,手上这块F103C8T6只有一个12位DAC,两路输出还分别占用PA4和PA5,如果这两根引脚已经被其他外设占用了,就得另想办法。后来试了用PWM加RC滤波当DAC用,效果出乎意料地好,语音提示、开机音、简单音效完全没问题。这篇就把整个方案的参数计算、滤波电路设计、CubeMX配置和代码实现从头到尾写清楚,给同样想低成本给STM32加音频输出的朋友做个参考。

这个方案适合已经会配置定时器PWM输出、想进一步玩音频的嵌入式初学者,也适合做项目时需要“能发声”但不想外挂音频芯片的开发者。核心思路就三件事:把音频样本转成占空比序列、用定时器PWM持续输出这个序列、再用RC低通滤波把PWM方波“磨平”成模拟波形。下面按从理论到实操的顺序拆开讲。

1. 整体设计与思路拆解

1.1 为什么放着现成DAC不用,非要拿PWM凑合

STM32F103系列带DAC的型号其实不多。F103C8T6虽然内置DAC,但只有一路12位分辨率,输出引脚固定在PA4,另一路PA5和比较器共用。更麻烦的是,12位DAC的建立时间在几微秒级别,输出阻抗也比较高,驱动后级电路基本都要加运放缓冲。用PWM做DAC的好处在于,每个定时器都有至少一个PWM输出通道,引脚可选范围大,占空比本质上就是数字量到模拟电平的映射,不需要额外复用外设资源。

PWM DAC的原理可以这样理解:一个周期内PWM高电平的时间比例叫占空比,经过RC低通滤波后,输出电压就是这个占空比对应VCC的平均值。8位分辨率对应256级占空比,3.3V供电下理论最小步进约12.9mV。把音频波形离散成256级电压,再以一定的采样率连续刷新占空比,RC滤波器输出的就是还原后的音频波形。

1.2 这个方案能做什么、不能做什么

能做的场景:

  • 语音播报:空气质量检测仪播报数值、倒车雷达播报距离、设备状态语音提示
  • 简单音效:按键音、报警声、开机旋律
  • 低频波形发生器:正弦波、三角波、任意自定义波形输出

不太适合的场景:

  • 高保真音乐播放:8位分辨率底噪明显,量化台阶能听出来
  • 44.1kHz立体声音乐:单路PWM都难应付,更不要说左右声道
  • 对频响要求苛刻的测量仪器:RC滤波的幅频特性不够平坦,输出阻抗又高

定位很清楚,这是一个“低成本、能出声、能学原理”的方案。真正追求音质,直接上I2S加外置DAC芯片,比如MAX98357A这类单芯片方案,省心得多。

1.3 整个系统由哪几部分构成

拆开看一共四个环节:音频数据准备、DMA搬运、PWM生成、滤波重建。

音频数据准备环节,把WAV文件转成C语言数组,每个元素对应一个PWM占空比值。DMA搬运环节的作用是,不让CPU逐个赋值CCR寄存器,而是由定时器触发DMA自动把数组里的值搬到PWM的CCR寄存器。PWM生成环节由定时器完成,CCR变化,占空比就跟着变。滤波重建环节在硬件上完成,RC低通网络滤掉PWM载波频率,保留音频信号。

这四个环节里,决定音质好坏的关键在参数设计:PWM频率、采样率、RC截止频率三者必须匹配,否则会出现载波泄漏、声音发闷或者波形失真。下一节专门讲计算。

2. PWM DAC系统参数设计:分辨率、采样率和截止频率

2.1 分辨率由ARR值决定,同时绑定了PWM频率上限

定时器输出PWM时,ARR决定计数周期,CCR决定比较值。占空比等于CCR除以ARR加一。ARR越大,可表示的占空比级数越多,分辨率越高。但ARR变大意味着计数周期变长,PWM频率就降下来了。STM32F103定时器最高时钟是72MHz,PWM频率计算公式为:

Fpwm = 72MHz / ((PSC + 1) * (ARR + 1))

以8位分辨率为例,ARR设为255,PSC设为0,PWM频率约281.25kHz。这个频率远高于音频带宽,滤波后残留的载波纹波很小。如果把分辨率提高到12位,ARR等于4095,同样的PSC下PWM频率降到约17.58kHz。这个频率只比音频上限高不到三倍,一阶RC滤波很难滤干净,至少要三级滤波才能勉强压住载波泄漏。再往上到16位分辨率,ARR等于65535,PWM频率只剩约1.1kHz,已经完全不适合做音频了。

所以这个方案的第一个经验认知:8位分辨率是性能和实现难度的最佳平衡点。虽说明面上12位DAC芯片指标更好,但在72MHz主频的F103上,12位PWM DAC的实际听感反而不如8位,因为载波噪声会盖过分辨率提升带来的好处。F4系列主频168MHz以上才适合跑12位PWM DAC。

分辨率、PWM频率和主频的关系可以看这张表:

分辨率ARR值F103(72MHz)PWM频率适合音频?
8位255281.25kHz推荐,滤波容易
10位102370.3kHz可以,滤波压力稍大
12位409517.6kHz不推荐,载波难滤除
16位655351.1kHz完全不适合

2.2 采样率决定音质上限,奈奎斯特定理是硬约束

音频数据刷新的速率叫采样率,通俗说就是每秒钟更新多少次占空比。常用语音采样率是8kHz,也就是电话音质,人声清晰度够用,但音乐高频细节基本丢弃。16kHz采样率的声音明亮不少,22.05kHz接近FM广播音质。奈奎斯特定理要求,采样率必须大于信号最高频率的两倍,否则会产生混叠失真。8kHz采样率下能还原的最高频率是4kHz,所以后级滤波器的截止频率不宜高于4kHz,否则引入的信号高频噪声会混进音频带内。

很多人会把PWM频率和采样率混为一谈。PWM频率是载波频率,是重复次数,它决定滤波难度;采样率是音频数据刷新的速率,它决定音质上限。两者是独立的参数,实际使用中用两个定时器分别控制,这一点在设计时要想清楚。

采样率的具体实现方式,用定时器TIM2产生更新事件,触发DMA把数组中的下一个值搬运到PWM定时器的CCR寄存器。所以TIM2的更新频率就是采样率。如果TIM2时钟也是72MHz,PSC设0,ARR设8999,采样率正好是8kHz:

Fs = 72MHz / (9000) = 8kHz

想在8kHz采样率下播放更高频率的音频信号,数据本身就不包含高于4kHz的成分,所以不用考虑。

2.3 RC截止频率计算:先定音频上限,再反推R和C

一阶RC低通滤波器的截止频率公式是:

fc = 1 / (2 * π * R * C)

截止频率的含义是,信号频率等于fc时,幅度衰减到原来的0.707倍(约-3dB),频率继续升高,每十倍频程衰减20dB。对PWM载波而言,281.25kHz相对4kHz截止频率有约37dB的衰减,相当于幅度压到原来的1.4%左右。这个残留纹波对于语音播报已经可以接受,但如果要更干净,用两级RC就能在多压一倍。

具体参数选择,我常用4.7kΩ电阻配10nF电容,算下来:

fc = 1 / (2 * 3.14 * 4700 * 10e-9) ≈ 3386Hz

这个截止频率略低于4kHz最大音频上限,会损失一点点极高频细节,但换来的是载波衰减更狠。如果播放的是16kHz采样率的音频,截止频率要放宽到7kHz左右,比如2.2kΩ配10nF,这时载波衰减就不能指望一级RC完成了,需要上两级甚至三级滤波。具体电路在下一节展开。

有人会问,R和C能不能随便选。不能。R值太大会导致输出阻抗过高,后级负载阻抗一接上来截止频率就跑偏;R值太小又会让滤波电路消耗过多电流。经验上4.7kΩ到10kΩ比较合适,C选10nF到100nF,保证截止频率落在音频范围内。

2.4 三个关键参数的权衡:主频、数组长度和滤波复杂度

设计一个实际项目时,PWM频率、采样率、滤波电路个数是互相牵制的。

  • PWM频率越高,滤波越容易,但ARR上限被分辨率卡死
  • 采样率越高,音质越好,但DMA搬运频繁,CPU负担增加,音频数组占用的Flash也成倍增长
  • 滤波级数越多,载波越干净,但电路越复杂,而且每一级都会带来通带衰减

我自己的取舍标准是:语音播报场景,8kHz采样率加8位分辨率加一级RC,够用。16kHz采样率加8位分辨率加两级RC,效果明显提升。如果是做产品原型需要播放提示音,第一种方案成本最低,一个电阻一个电容就搞定。实际项目中,三级无源RC和两级RC加上运放缓冲,听感差别很小,所以不要盲目堆滤波级数。

3. 滤波电路设计:从一阶RC到有源低通

3.1 一阶RC无源低通:最简方案,搭起来就能响

一阶RC滤波只需要一个电阻和一个电容。PWM输出引脚串联电阻,电容一端接电阻另一端接地,输出电压从电容两端取。这个电路的等效输出阻抗就等于R值。R取4.7kΩ时,后级如果直接接功放输入端,功放输入阻抗通常有几十千欧,影响不大。但如果后级是低阻负载,比如几十欧的耳机负载,输出阻抗太高,截止频率和幅值都会严重漂移,声音会变得很轻很闷。

所以一阶RC版本建议后面接高输入阻抗的功放模块,或者先经过运放缓冲再输出。PAM8403这类数字功放板输入阻抗在几十千欧到一百千欧以上,直接接没问题。LM386功放模块输入阻抗也有50kΩ左右,同样可以直连。实际测试中,一级RC滤波后播放语音,能听到轻微的“沙沙”底噪,类似FM广播的静噪声,但不影响内容听清楚。

面包板搭法很简单:PA6引脚串4.7kΩ电阻,电阻后端接10nF电容到地,音频信号从电阻电容的连接点引出,接到功放输入端。10nF电容我是用两个4.7nF并联凑出来的,实际用标称10nF的MLCC电容更省事。

3.2 两级RC级联:无源滤波的性价比升级

一级RC滤波在截止频率之后按每十倍频程20dB衰减,256kHz的PWM载波相对3.4kHz的截止频率大约衰减37dB,能听出底噪但不算严重。还想更干净,最直接的办法是再串联一级RC。两级无源RC的衰减速度接近每十倍频程40dB。

但要特别注意,两级RC并不是简单地把第一级的输出直接接第二级的输入。第二级的输入阻抗会并联到第一级的输出端,等效改变了第一级的截止频率。正确的做法是,第一级R1和C1构成第一级低通,输出端接到第二级R2,R2后面接C2到地。计算两级等效截止频率时,不能简单的把两个截止频率相同的RC叠加算成fc不变,实际会向低频偏移。

需要在两级之间加一级电压跟随器做隔离,第一级RC输出的信号进入运放同相输入端,运放输出端直接连到第二级RC。运放输入阻抗极高,基本不吸取前级电流,输出阻抗极低,后级RC不会反过来影响前级。这种情况下两级RC可以独立计算,实际截止频率更可控。用LM358做电压跟随器最便宜,NE5532的噪声指标更好,但对这个场景差别不大。

两级RC的参数用R=4.7kΩ、C=10nF,每级截止频率都约3.4kHz,级联后-3dB点大约在2.5kHz左右,人声的齿音和部分高频细节被削掉一点,换来的是载波完全听不到。对语音提示类应用,音质反而更“干净”。如果想两级合起来截止频率仍在3.4kHz,可以把每级截止频率设到5kHz左右,比如R=3.3kΩ、C=10nF,这样合起来-3dB点接近3.5kHz。

3.3 有源二阶低通:Sallen-Key结构,参数一次到位

如果觉得两级无源RC的参数不好把控,可以直接上有源二阶低通滤波器。Sallen-Key结构只需要一个运放,四个无源器件,巴特沃斯响应下Q值取0.707,幅频特性最平坦。给出完整设计方法:

Sallen-Key低通的截止频率和Q值公式:

fc = 1 / (2 * π * sqrt(R1 * R2 * C1 * C2))

Q = sqrt(R1 * R2 * C1 * C2) / (C2 * (R1 + R2))

真正手算有点绕,实际工程中我一般用固定参数:R1等于R2等于10kΩ,C1等于10nF,C2等于4.7nF。代入公式:

fc = 1 / (2 * 3.14 * sqrt(10k * 10k * 10n * 4.7n)) ≈ 4.4kHz

这个参数下Q值接近0.707,幅频特性在通带内比较平坦,截止频率约4.4kHz,正好适配8kHz采样率的音频。运放供电用3.3V单电源,LM358这类轨到轨运放可以单电源工作,输入偏置要加个交叉偏置电阻从正相输入端虚地。但这里输入信号本身就是正电压PWM滤波后的直流分量加交流信号,默认在0到3.3V之间,不需要额外偏置电路。

用Sallen-Key的另一个好处是,运放输出阻抗极低,可以直接推动耳机功放或者接长导线,不用考虑阻抗匹配问题。实际听感上,有源二阶滤波比无源两级RC更通透一些,可能是因为通带衰减更小。

3.4 电源去耦和功放级搭配:容易被忽略的一环

PWM DAC输出的是模拟波形,对电源噪声很敏感。MCU数字电路高频开关产生的噪声会通过电源引脚耦合进PWM输出,导致音频底噪变大。最简单的对策是在MCU电源引脚旁边放100nF陶瓷电容加10μF电解电容的组合。如果MCU和功放共用一个电源,功放电源引脚也要单独加100μF电解电容和100nF陶瓷电容,并且功放的地线要单独走线回到电源地,不要在MCU附近共用。

功放模块的选择,LM386适合电池供电的小功率场景,PAM8403适合3.7V锂电池供电的便携设备,两者输入阻抗都能直接匹配RC滤波输出。音量控制用一个10kΩ电位器放在滤波电路和功放输入之间,电位器两端分别接信号和地,中间抽头接功放输入。注意电位器会改变后级等效负载,但10kΩ相对4.7kΩ滤波电阻已经足够大,影响可以接受。

如果你在面包板上搭建,PCB布局的坑更多。输入PWM信号线和功放输出线尽量分开走,避免平行。滤波电路的电容要尽可能靠近MCU引脚,减小寄生电感。实际上面包板做这个项目完全没问题,只是高频噪声会多一点,用屏蔽线连接功放输入能改善一些。

4. STM32固件实现:CubeMX配置、DMA搬运和播放控制

4.1 CubeMX配置:两个定时器各司其职

打开STM32CubeMX,芯片选STM32F103C8T6,我用的HAL库版本是1.8.0。需要配置的内容有:

Clock Configuration页面确认SYSCLK是72MHz。这里有个F103特有的坑,APB1总线的定时器时钟不是简单的PCLK1,而是PCLK1的两倍。如果APB1预分频设置成2,定时器时钟仍然是72MHz。CubeMX会自动计算,但你在手算采样率的时候要注意,别用36MHz去算。

TIM3配置为PWM Generation CH1,通道1选择PA6输出。Parameter Settings页面,Prescaler写0,Counter Period写255(对应ARR等于255,8位分辨率),Pulse先写128或占空比50%。在PWM模式下,ARR的值决定了PWM频率,PWM频率等于定时器时钟除以(ARR加一)再除以(PSC加一),这里是72MHz除以256,约281.25kHz。

TIM2配置为基础定时器,触发DMA。Prescaler写0,Counter Period写8999,这样更新事件频率是8kHz。关键步骤是在TIM2的DMA Settings页面添加DMA请求,选择TIM2_UP,Direction选择Memory To Peripheral,外设地址为TIM3的CCR寄存器地址。需要注意,DMA的外设地址不要填错,是htim3.Instance->CCR,也就是&(htim3.Instance->CCR)。数据宽度,外设和内存都选Half Word,即16位,因为CCR寄存器是16位,音频数组也用uint16_t类型存放。

还要在TIM3的PWM配置里,把CCR预装载使能打开。这样DMA写入CCR的值不会立即生效,而是等下一个PWM周期开始时才装载,避免在一个PWM周期中间突然跳变产生毛刺。TIM3自己也开了DMA请求?不要开。TIM3的DMA请求只用于更新事件,而我们要用的是TIM2的更新事件来触发DMA搬运,如果两个都开,CCR会被反复写,逻辑就乱了。

最后Project Manager里生成代码,Toolchain选择MDK-ARM。

4.2 音频数据准备:WAV转成C数组的两种方法

音频源文件先用Audacity处理。打开一个WAV文件后,首先Tracks菜单里Stereo Track to Mono转单声道,Effect菜单里Resample把采样率降到8000Hz,Effect里Normalize把音量峰值归一化到-1dB,避免播放时削波。处理完File菜单Export Export as WAV,Encoding选Signed 16-bit PCM。

拿到16位PCM数据后,需要转成8位无符号PWM占空比值。16位PCM的范围是-32768到32767,0对应静音。8位PWM占空比范围是0到255,3.3V供电下128对应1.65V中点是静音。转换公式是:

pwm_value = (pcm_sample >> 8) + 128

右移8位相当于取高8位,再加128是把有符号范围映射到无符号中点。直接用在线转换工具也能转,但样本多的时候还是脚本靠谱。分享一个Python脚本,用pydub库读取WAV生成C数组,我这里测试环境是Python 3.10加pydub 0.25.1:

from pydub import AudioSegment import numpy as np audio = AudioSegment.from_wav("input.wav") audio = audio.set_channels(1).set_frame_rate(8000).set_sample_width(2) samples = np.array(audio.get_array_of_samples()) pwm = ((samples >> 8) + 128).astype(np.uint16) print(f"const uint16_t audio_data[{len(pwm)}] = {{") for i in range(0, len(pwm), 12): row = ", ".join(str(v) for v in pwm[i:i+12]) print(f" {row},") print("};")

把生成的数组复制到main.c中,注意数组要加const修饰,存放在Flash而不是RAM,F103C8T6的RAM只有20KB,一段10秒8kHz采样率的音频数据就有8万个样本,每个样本占2字节,16万字节,必须放在Flash里。Flash容量64KB,也就是最多可以放32秒8kHz的语音音频,够用了。如果音频太长放不下,只能外挂Flash或者用压缩格式。

另外要做淡入淡出处理。直接播放时,数组的第一个值如果是128附近,而停止时的CCR可能是0,从一个电平跳到另一个电平就会产生爆音。处理方法是音频数据开头加16个采样从128渐变到第一个有效值,结尾也加16个采样渐变回128,爆音基本消除。这个处理在Python脚本里做:

fade_in = np.linspace(128, pwm[0], 16, endpoint=False).astype(np.uint16) fade_out = np.linspace(pwm[-1], 128, 16, endpoint=False).astype(np.uint16) pwm = np.concatenate([fade_in, pwm, fade_out])

4.3 主程序代码:启动DMA、播放控制和停止逻辑

CubeMX生成的代码基础上,主循环里只需要做三件事:启动DMA播放、检测停止按键、停止播放。

启动播放的核心函数:

HAL_TIM_Base_Start(&htim2); // 启动采样率定时器,使能更新中断 HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); // 启动PWM输出 HAL_TIM_Base_Start_DMA(&htim2, (uint32_t *)htim3.Instance->CCR, (uint32_t *)audio_data, AUDIO_LEN);

注意HAL_TIM_Base_Start_DMA是把DMA绑定到htim2上,第三个参数是外设地址,第四个是内存地址,第五个是传输长度。这里内存地址传的是audio_data数组名,外设地址传的是TIM3的CCR寄存器地址,代表每次TIM2更新事件触发后,DMA从audio_data取一个值写到CCR。

停止播放的处理要小心。直接调用HAL_TIM_Base_Stop_DMA会立刻停掉DMA,但CCR可能停在任意值,导致输出电平突变产生爆音。我处理的办法是,停止前先把CCR设置到128(接近中点),等待几个毫秒再停掉DMA和PWM:

void audio_stop(void) { __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 128); HAL_Delay(10); HAL_TIM_Base_Stop_DMA(&htim2); HAL_TIM_PWM_Stop(&htim3, TIM_CHANNEL_1); HAL_TIM_Base_Stop(&htim2); }

DMA设置为循环模式后,播完一遍会自动从头开始。如果想播完一遍自动停止,可以关掉循环模式,然后在DMA的传输完成中断回调里调用停止函数:

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { // 在这里可以做播放状态标记,或者音频数据循环切换 } }

如果只有一个音频段循环播放,DMA循环模式就够了。如果要做“按一下播一段”的效果,建议在DMA传输完成中断里停掉DMA,代码如下:

void HAL_TIM_OC_DelayElapsedCallback(TIM_HandleTypeDef *htim)

注意不同HAL库版本回调函数名可能有差异,老版本用HAL_TIM_PeriodElapsedCallback,新版本可能更细化。我这里用的库版本里,TIM2基本定时器更新事件走的是HAL_TIM_PeriodElapsedCallback。

4.4 实际运行效果:从示波器到人耳

搭好硬件、烧录固件后,先播放一个1kHz正弦波测试信号,验证系统基础功能。示波器探头接在RC滤波输出端,正常应该看到一个连续正弦波,频率1kHz,幅度约几百毫伏到1V之间。如果看到的是方波或者阶梯波,说明PWM频率太低或者RC滤波失效。如果波形上有高频率的毛刺,说明载波没有滤干净,需要调整截止频率。

没有示波器的话,直接接功放模块听音也行。播放语音时,正常能听清楚内容,背景有轻微沙沙声。如果播放音乐,能明显听出底噪和采样率不够导致的“数码味”。

我实测过播放一段8kHz采样率的普通话语音,一级RC滤波下听感清晰,底噪轻微;两级RC滤波后底噪基本消失,但高频齿音变弱。整体效果相当于老式电话的听感,对设备语音提示来说完全够用。

5. 常见问题与排查技巧实录

5.1 声音发闷,像嘴被捂住

先说结论:RC截止频率设太低了。8kHz采样率的音频,最高频率成分就是4kHz,但语音里大多数能量集中在1kHz到3kHz,如果截止频率设在2kHz以下,齿音、辅音部分会被削掉,听感发闷。解决方法:把RC截止频率往上调,或者检查采样率是不是真的8kHz。如果WAV文件本身是44.1kHz采样率,你直接重采样到8kHz,高频信息本来就会丢失,听起来闷是正常的,可以改用16kHz采样率配合更高截止频率。

还有一种常见情况是,两级RC级联后截止频率不是3.4kHz而是更低。两个截止频率3.4kHz的RC级联,-3dB点实际约2.2kHz。解决方案是用运放隔离,让两级RC互不影响,或者把每级RC截止频率提高到5kHz以上再级联。

5.2 播放时持续有“嘶嘶”声或者高频啸叫

嘶嘶声是PWM载波泄漏。一级RC滤波对281.25kHz载波的衰减约-37dB,有些人对这个量级敏感,有些人不敏感。先看PWM频率配置是否正确,如果ARR设成511(9位)而PSC没改,PWM频率掉到140kHz,同样RC参数下载波衰减少了6dB,嘶嘶声更明显。两种解法:提高PWM频率,或加滤波级数。

高频啸叫更可能是电源问题。笔记本USB口供电时,开关电源纹波会直接耦合到模拟输出。试试用电池供电,或者给MCU电源和功放电源之间加LC滤波,啸叫基本消失。我遇到过的情况是,把功放和MCU共用一个5V电源,负载变化时功放反馈到电源的噪声进入MCU,PWM输出被调制,听起来就是刺耳的蜂鸣。分开供电后问题彻底消失。

5.3 播放开始和结束时“啪”的爆音

爆音的原理是CCR寄存器从非静音值突变到静音值,或者反向突变,输出电平瞬间跳变。首次播放时,如果数组第一个值不是128附近,CCR从默认0跳到第一个采样值,振铃就会产生爆音。结尾同理。

解决办法有两个。第一个是软件层面的淡入淡出,前面Python脚本里已经写了,音频数据两头各加16个渐变样本,实测能消除大部分爆音。第二个是播放启动时序上,先启动PWM输出,再启动DMA,保证PWM已经开始输出稳定电平了,DMA才改变占空比。停止时把CCR先设到128,延时几十毫秒再停PWM,也可以避免爆音。

实际调试时还要注意DMA循环模式下播放到最后会跳回开头,如果数据首尾电平差很大,每循环一次就爆一次。这就是为什么淡入淡出处理要加在数据开头和结尾,让首尾都靠近128中点。

5.4 播放速度不对,声音变快或变慢

音调变高、播放变快,通常是采样率定时器配置偏高。TIM2的更新频率就是采样率,如果预分频算错,实际采样率不是8kHz而是10kHz,同一段音频会在更短时间里播完,音调也跟着升高。常见原因有三个:

第一个是时钟树配置不对。F103的APB1定时器时钟在APB1预分频不为1时自动乘2,如果CubeMX里APB1设成36MHz,你手算时用36MHz计算PSC和ARR,实际定时器时钟是72MHz,采样率就翻倍了。第二个是CubeMX生成的代码里修改了PSC或ARR之后没有重新计算。第三个是DMA搬运的数据宽度不对,设置成Byte后,每个样本只搬运低8位,数据量减半,播放时间也跟着减半。

排查方法很简单:播放一段已知时长的语音,用秒表计时。假设音频是15秒,如果实际播了7.5秒,说明采样率翻倍了。回到CubeMX检查APB1定时器时钟和TIM2参数就行。

5.5 DMA只搬运一次或者完全不搬运

症状是只听到一个单调的PWM音调,或完全没有声音。优先检查DMA配置。

第一,DMA请求源要选TIM2_UP,这一步经常有人选成TIM3_UP。如果选成TIM3_UP,TIM2更新事件不会触发DMA,CCR永远是初值。

第二,DMA外设地址要写成htim3.Instance->CCR,而不是直接写0x40000414这种硬编码地址。有些教程给的是寄存器地址,但HAL库配置时用的外设地址是基地址加偏移,直接填寄存器地址反而对不上。

第三,确认DMA模式选了Circular循环模式。如果选Normal普通模式,DMA搬运完一遍数组就停了,表现就是播放一次后没声音。如果你确实想单次播放,Normal模式配合传输完成中断是合理的,但很多人是配置成Normal却期望循环播放。

第四,确认HAL_TIM_Base_Start_DMA的第三个参数传的是外设地址寄存器指针,第四个参数是内存地址。传反了会导致DMA把CCR的值搬到内存,轻则数据错误,重则触发总线错误死机。

5.6 最后再提一个电源问题:底噪和地环路

这是最容易忽略的一环。MCU开发板、功放模块、USB供电三者之间形成的地环路,会引入50Hz交流声和开关电源噪声。我排查了很长时间才意识到,USB供电的GND和功放模块的GND之间有几毫伏的电位差,这个差值直接叠加到音频信号上。

解决的思路是单点接地:MCU的地和功放模块的地用一根粗导线连到电源地,不要在多个位置反复接地。如果项目要求严格,模拟地(滤波电容地、功放地)和数字地(MCU地)用0Ω电阻单点连接。这个技巧在传感器采集电路里常见,音频输出同样适用。

写在最后

这个PWM DAC方案我从面包板搭到PCB,前前后后调了一周,最大的体会是:不要一上来就追求高分辨率高采样率,先让最简配置跑通,再用示波器观察各级波形,逐步优化滤波参数。8位PWM加一级RC听起来条件简陋,实际上播报语音的效果远超预期,底噪轻微,人声清晰,已经达到了不少商用产品的语音提示质量。

当然它也有明显短板,播放音乐时底噪和量化失真能清楚感知,声音“脆硬”,不像真正DAC那样圆润。如果项目预算允许,直接上I2S加DAC芯片才是音乐播放的正道。但作为理解PWM原理、DAC原理和滤波器设计的入门项目,这套方案性价比极高,调通了它,你对定时器、DMA、RC滤波的理解会上升一个台阶。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询