简单聊一下双 PWM 音频播放原理及实现以及 PCM 播放测试 ...... 矜辰所致
前言
上一篇文章我们测试了一下 SBC 音频解码然后通过 双 PWM 播放音频的效果,当时更多的是想看看效果,只做了一些必要的解释说明。考虑到后面自己应用上需要有一些修改,有些必要的原理还是要了解一下,这样后期的做一些应用也知道去改什么地方。
所以本文我们简单学习一下双 PWM 的原理 以及测试一下 PCM 音频播放。
相关文章:
.
我是矜辰所致,全网同名,尽量用心写好每一系列文章,不浮夸,不将就,认真对待学知识的我们,矜辰所致,金石为开!
目录
- 前言
- [一、 双 PWM 原理](#一、 双 PWM 原理)
-
- [1.1 基础说明](#1.1 基础说明)
- [1.2 程序中双 PWM 的体现](#1.2 程序中双 PWM 的体现)
- [1.3 PWM 的频率](#1.3 PWM 的频率)
- [1.4 音量调节](#1.4 音量调节)
- [二、 PCM 播放测试](#二、 PCM 播放测试)
-
- [2.1 PCM 数据说明](#2.1 PCM 数据说明)
- [2.2 音频文件生成](#2.2 音频文件生成)
- [2.3 APP修改测试](#2.3 APP修改测试)
- 结语
一、 双 PWM 原理
现在有了 AI ,很多通用技术的理论了解并不是什么难事,我们简单说明一下,主要还是结合一下程序中如何实现做一些分析。
1.1 基础说明
双 PWM(Dual PWM / Differential PWM)音频驱动的本质,是用两路互补的 PWM 信号来驱动扬声器,通过差分放大还原出模拟音频波形。
双 PWM 音频:
两路互补的数字开关信号 → 差分驱动扬声器 → 利用感性负载滤除载波 → 还原模拟声音
相对于单路 PWM ,双路 PWM 方案有以下优点:
- 抗干扰强,外界电磁干扰同时耦合到两根线,差分相互抵消,信噪比更高;双路 PWM 喇叭线圈等效 LC 低通,自动滤除高频载波 。
- 输出功率翻倍,差分峰峰值 = 2×VDD,同等供电下音量更大;
- 无直流偏置:静态时两路 50% 占空比,扬声器两端电压差为 0,音圈不会静态偏移,不烧喇叭、低音更扎实;
- 外围电路无需输出电容,单端需要隔直电容,差分直接耦合
常见波形与 PCM 映射逻辑如下:
音频采样值 → 调制为两路互补 PWM:
PWM_A 占空比 = 50% + Δ
PWM_B 占空比 = 50% - Δ
举个例子 :
设 PWM 周期固定,满幅占空比 0~100%:
.
静音(PCM=0):PWM + 占空比 50%,PWM - 占空比 50%;两路电压平均值相等,喇叭两端压差 = 0,不发声。
.
正半周音频(PCM>0):PWM + 占空比升高,PWM - 占空比同步降低;喇叭左端平均电压 > 右端,电流正向流过音圈。
.
负半周音频(PCM<0):PWM + 占空比降低,PWM - 占空比同步升高;喇叭右端平均电压> 左端,电流反向流过音圈。
还有一种单极性交替 PWM (本文测试示例就使用的这种):
pcm>0:PWM+输出幅值,PWM2=0
pcm<0:PWM-输出幅值,PWM1=0
等价 H 桥单向导通:
正半周:左 PWM 输出方波,右侧恒低,电流正向流过喇叭
负半周:右 PWM 输出方波,左侧恒低,电流反向流过喇叭
过零点(pcm=0)两路都是 0,无静态压差
1.2 程序中双 PWM 的体现
在我们的测试程序中,有这么一段数据的处理:
c
for(uint32_t i=0;i<(out_len>>1);i++) {
if(pcm_buf[i] >0) {
*p_data = pcm_buf[i]/8; // 正值 → PWM1 有输出
*p_data2 = 0; // PWM2 = 0
}else {
*p_data2 = 0-pcm_buf[i]/8; // 负值 → PWM2 有输出
*p_data = 0; // PWM1 = 0
}
p_data ++;
p_data2 ++;
}
当音频信号为正(>0)时:p_data(PWM 通道 1)输出对应的占空比,p_data2(PWM 通道 2)静止在 0 电平。
当音频信号为负(<0)时:p_data2(PWM 通道 2)输出对应的占空比(取绝对值),p_data(PWM 通道 1)静止在 0 电平。
代码中的数据事直接决定 PWM 的占空比的,为什么上面的数据需要除 8 :
*p_data = pcm_sample / 8;
因为 这个数值是直接决定 PWM 的占空比的,在程序中, PWM 的占空比 也和总周期计数有关,这个值肯定不能大于总计数,这个我们需要先往下看,先了解一下 PWM 的频率有关的内容 。
1.3 PWM 的频率
PWM 音频播放,频率要调整到音频的采样率 , 在使用 MCU 的时候,一般的步骤如下:
- 确定/设置 定时器时钟来源;
- 确定/设置 有没有分频;
- 根据目标 PWM 载波频率,推算自动重载值。
对于本次的示例,自动重载值变量为 max_cnt :
c
// 根据采样率选择固定计数值
if(rate == CH5XX_AUDIO_SAMPLE_RATE_16K)
{
max_cnt = 4875;
}
这个值是怎么算出来的:
16K 采样配套:max_cnt = 4875
单次完整 PWM 周期计数总长 = max_cnt + 1 = 4876
16K 场景 PWM 载波频率:
F(pwm) = 78000000 / 4876 ≈ 15996.72 Hz
8K 采样率是 16K 的一半,所以 PWM 周期总计数必须翻倍:
总计数:4876*2 = 9752
max_cnt = 9752-1 = 9751
8K 下载波频率:
F(pwm) = 78000000 / 9752 ≈ 7998.36 Hz
好了,现在知道了在 16K 模式下面,最大重装载值为 4875, 也就可以解释为什么上面需要 *p_data = pcm_sample / 8; 了,示例中 pcm_sample 是一个 int16_t 的变量,数值范围:-32768 ~ 32767 。
如果不缩放直接赋值,大于 4875,会溢出失效。除以 8:最大输出 32767/8 ≈ 4095,小于 4875,留有安全余量;
这个值决定了占空比的大小,示例中是为了把他设置到合理范围,那么这里有一个比较重要的问题需要说明,就是改变这个单个的占空比,并不会改变声音内容,它改变的是声音大小!这里很多初学者会不太理解,但是要记住 。
❤️ 瞬时占空比数值:只决定当前音量大小;❤️
❤️ 占空比随时间变化的节奏 / 快慢:决定是什么声音、音调、语音内容。❤️
1.4 音量调节
上面我们已经说到了,改变瞬时占空比,就可以改变音量大小,我们在示例基础上,直接做了一个修改,实际应用中,就可以根据数值调整百分比,直接上一下有关部分的代码:
c
#define VOL_MAX 256
// 改变 audio_vol 的值改变声音大小 0 静音-> 256 最大
uint16_t audio_vol = 64; // 0静音,256最大
for(uint32_t i=0;i<(out_len>>1);i++)
{
int16_t raw = pcm_buf[i];
// 音量缩放核心行
int32_t vol_pcm = (int32_t)raw * audio_vol / VOL_MAX;
if(vol_pcm >0)
{
*p_data = vol_pcm / 8;
*p_data2 = 0;
}
else
{
*p_data2 = (0 - vol_pcm) / 8;
*p_data = 0;
}
p_data ++;
p_data2 ++;
}
二、 PCM 播放测试
OK,上面把一些需要了解的关键点理了一下,我们本文还要测试一下 PCM 播放 (实际应用中直接处理 PCM 的情况应该不会太多,因为不压缩数据量实在太大了,但是既然测试了,那就记录一下,而且可以方便的用来验证上面我们提到的原理部分的一些内容)。
2.1 PCM 数据说明
我们知道 PCM 就是原始的音频数据,我们嵌入式语音采样就是采样的原始的音频数据。 但是呢,因为原始的音频数据很大,一般应用中,都不太可能本地存储下来,除非是明确知道语音数据不大,Flash 空间足够,往往都是采集到了传输出去,而且很多时候都会压缩传输。这是采集相关的话题,后期我们应用可能需要分析说 。
PCM 音频理论上来说的占用空间 :
16k:
1 秒:32000 Byte ≈ 31.25 KB
5 秒:160000 Byte ≈ 156.25 KB
10 秒:320000 Byte ≈ 312.5 KB
15 秒:480000 Byte ≈ 468.75 KB
8K 比 16K 小一半
2.2 音频文件生成
PCM 文件使用 ffmpeg 工具可以直接生成,而且电脑端通过 ffplay 可以直接验证,这个我们可以先准备好,PC 端可以直接验证 。
生成 PCM 指令:
bash
# 把mp3 转成 8K采样率 的 单声道 PCM
ffmpeg -i 004.mp3 -ar 8000 -ac 1 -f s16le 004p8k.pcm
# 把 mp3 转成 16K采样率 的 单声道 PCM
ffmpeg -i 004.mp3 -ar 16000 -ac 1 -f s16le 004p16k.pcm
# 把 mp3 的前 5S 转成 16K采样率 的 单声道 PCM
ffmpeg -t 5 -i 004.mp3 -ar 16000 -ac 1 -f s16le 004p16k5s.pcm
查看 PCM 大小:
bash
#windows 下
dir 004p8k.pcm
示例如下:
bash
C:\Users\OWNER\Desktop\Demo抽烟报警>dir 004p8k.pcm
驱动器 C 中的卷是 系统
卷的序列号是 6E0E-5AEB
C:\Users\OWNER\Desktop\Demo抽烟报警 的目录
2026/06/26 17:13 81,502 004p8k.pcm
1 个文件 81,502 字节
0 个目录 50,758,230,016 可用字节
C:\Users\OWNER\Desktop\Demo抽烟报警>dir 004p16k.pcm
驱动器 C 中的卷是 系统
卷的序列号是 6E0E-5AEB
C:\Users\OWNER\Desktop\Demo抽烟报警 的目录
2026/06/26 17:13 163,004 004p16k.pcm
1 个文件 163,004 字节
0 个目录 50,757,672,960 可用字节
PCM 文件这里 dir 查出来的大小是真实的大小。
使用如下指令可以在 PC 端直接播放 PCM 进行测试 :
bash
#8K 单声道 s16le
#-ch_layout mono:单声道,替代旧版 -ac 1
ffplay -f s16le -ar 8000 -ch_layout mono 004p8k.pcm
#16K 单声道 s16le
ffplay -f s16le -ar 16000 -ch_layout mono 004p16k.pcm
最后把 PCM 转成 Hex 文件:
bash
srec_cat.exe 004p8k.pcm -binary -offset 32768 -o 004p8k_h.hex -intel
srec_cat.exe 004p16k.pcm -binary -offset 32768 -o 004p16k_h.hex -intel
或者
bin2hex.exe --offset=32768 004p8k.pcm 004p8k_h.hex
2.3 APP修改测试
现在存放的就是原始 PCM 数据,解压代码也不需要,从 Flash 读出来数据直接交给 PWM 输出就行了,简单修改了一下,直接上一下有关代码:
c
case CH5XX_PWM_DMA_GET:
{
uint32_t *p_data = p_event->p_data;
uint32_t *p_data2 = p_event->p_data2;
uint32_t length = p_event->len; // 需要填充的采样点数
switch(read_data_sta){
case 0:
{
// 计算还剩多少字节PCM数据
uint32_t remain_bytes = sample.length - read_index;
uint32_t need_bytes = length * 2; // 每个采样16bit=2字节
if(remain_bytes == 0) {
read_data_sta = 1;
break;
}
// 实际能读的字节数
uint32_t copy_bytes = (remain_bytes < need_bytes) ? remain_bytes : need_bytes;
uint32_t copy_samples = copy_bytes / 2;
// 直接从Flash读PCM数据
// 关键修复:Flash数据拷贝到RAM再读取
// 使用__wrap_memcpy高速拷贝Flash数据到RAM缓存
__wrap_memcpy(pcm_buf, (uint8_t *)(sample.start_addr + read_index), copy_bytes);
for(uint32_t i = 0; i < copy_samples; i++) {
int16_t pcm_sample = pcm_buf[i];
if(pcm_sample > 0) {
*p_data = pcm_sample / 8;
*p_data2 = 0;
} else {
*p_data2 = (0 - pcm_sample) / 8;
*p_data = 0;
}
p_data++;
p_data2++;
}
read_index += copy_bytes;
// 如果数据不够,补零(静音)
for(uint32_t i = copy_samples; i < length; i++) {
*p_data++ = 0;
*p_data2++ = 0;
}
if(read_index >= sample.length) {
PRINT("PCM play end\r\n");
ch5xx_audio_stop();
}
}
break;
case 1:
ch5xx_audio_stop();
break;
default:
break;
}
}
break;
因为音频效果文章不好放,我就把我测试过程和结果描述一下。
先测试 16K 播放,就测试一个音频:
c
audio_record_t music_record[] = {
{ 0x000000, 163004 }, // 大小按照原始大小
};
ch5xx_audio_init(CH5XX_AUDIO_SAMPLE_RATE_16K,audio_handler);

可以正常播放,只是声音感觉没有直接 SBC 解码好,但是语音完全够用。
再测试一下 8K :
c
audio_record_t music_record[] = {
{ 0x000000, 163004 }, // 大小按照原始大小
};
ch5xx_audio_init(CH5XX_AUDIO_SAMPLE_RATE_8K,audio_handler);
我开始以为会多给了后半段会破音,但是测试下来,后半段没有尖锐的声音 ,这个在代码中处理好了,声音差了很多,语音也能听清楚,但是杂音比较明显。
因为改到了 8K, 我们的 PWM 最大重装载值翻倍了,我们可以改成如下方式把声音放大:
c
if(pcm_sample > 0) {
*p_data = pcm_sample / 4;
*p_data2 = 0;
} else {
*p_data2 = (0 - pcm_sample) / 4;
*p_data = 0;
}
结语
本文我们简单说明了一下双 PWM 音频播放的基本原理,虽然并没有对着完全的源码分析,但是对于我们应用需要修改到的地方都有提到,不仅是在 CH585 上面,所有的 MCU 使用此方式驱动音频对应部分都是类似的处理。
之后我们还测试了 PCM 的播放,总的来说,作为语音播放来说 16K 完全可以满足需求,8K 的声音就没有那么清晰,会有杂音 (个人测试,仅供参考,与自己应用层代码处理也有关系) 。
对于语音播放,博主还计划要测试一下 ADPCM 解码播放说明,比较 ADPCM 也是开源的,而且对单片机也是很友好的。
好了,本文就到这里。谢谢大家!