上次把这块板子的工程模板和 GPIO 点灯部分过了一遍,这次继续往下推进,把 PWM 和 ADC 这两个外设放在一起玩。标题里这对组合听起来有点绕——PWM 驱动无源蜂鸣器、ADC 调整 LED 亮度,其实拆开看非常直白:一个负责输出波形,一个负责采样电压,两者配合起来,就能让开发板发出不同的音调,同时用一个电位器旋钮连续改变灯的亮度。
这篇是参考例程的下半部分,默认你已经跑通了工程编译下载的基本流程,对 GPIO 输出也不陌生。我会从方案选型讲到核心原理,再给出可以直接抄作业的代码和调试思路,最后把我在实际操作中踩过的坑整理成速查表。适合正在用 MM32 系列 MCU 或者任何 Arm Cortex-M 内核芯片入门的开发者,尤其是想搞懂定时器 PWM 和 ADC 到底怎么配合使用的人。
1. 整体设计与思路拆解
1.1 方案选型:为什么是 PWM 加 ADC
PWM 和 ADC 这两个外设单独拎出来都不算复杂,难的是把它们组合到一个需求里去思考。蜂鸣器需要频率信号才能发出声音,LED 需要可变的驱动强度才能调节亮度,而这两个需求恰好都能用 PWM 解决,只是侧重点不同。
无源蜂鸣器的发音原理决定了它必须被一定频率的方波驱动才能振动发声,方波的频率决定了音调高低。如果只用 GPIO 翻转来做,代码里要不停延时翻转引脚,频率稍微高一点 CPU 就被占满了,而且频率和时间都不好控制。换成硬件定时器生成 PWM,频率由定时器自动维护,CPU 只需要在需要的时候改一个比较值就能改变输出,效率和精度都高得多。
LED 亮度调节的本质是改变平均功率。直接调节电流当然可以,但硬件复杂度高,而且在单片机上很难做到连续可调。用 PWM 输出不同占空比的方波,LED 就会被等效为以不同平均电流点亮,人眼感知到的就是亮度变化。这里有个重要前提:PWM 频率必须足够高,快到人眼来不及分辨闪烁,一般 1kHz 以上就基本无感,我习惯用 20kHz 左右,这样算出来的占空比分辨率还能做得很高。
ADC 的角色则是一个“感知”环节。电位器把电压从 0V 到 VCC 连续分压,MCU 通过 ADC 采集这个电压值,再把它映射到 LED 的 PWM 占空比上。这就把物理世界的模拟量转换成了数字域的离散值,再转换回模拟输出,形成一个完整的闭环控制雏形。
1.2 参数选型背后的两个关键点
做这个例程之前要先想清楚两个关键参数:频率和分辨率。
无源蜂鸣器对频率很敏感。我见过不少新手随便设一个 ARR 值,结果蜂鸣器要么没声音,要么发出刺耳的“滋滋”声。大多数常见无源蜂鸣器的标称谐振频率在 2.7kHz 到 4kHz 之间,用接近谐振点的频率驱动,蜂鸣器发声效率最高、声音最响。所以我在这套方案里把蜂鸣器 PWM 频率定在 2.7kHz 附近,既能得到清晰的提示音,又不会超出大多数蜂鸣器的响应范围。
LED 那边的 PWM 频率则要高得多。如果 LED 也使用 2.7kHz,虽然人眼看不出来闪烁,但拍照或者用示波器观察时会看到明显的频闪现象。为了让亮度调节更平滑,我会把 LED 的 PWM 频率做到 20kHz,这样人眼完全感觉不到灯光抖动,同时对应的周期值能提供 12 位左右的占空比分辨率,ADC 采到的 12 位数据几乎可以直接映射,不会出现明显的亮度跳变阶梯。
ADC 采样还有个容易被忽略的点:采样时间不能太短。电位器不是理想元件,旋动过程中接触电阻会变化,采样时间太短会把瞬时噪声采进来。我在初始化时把采样时间设为最长挡位,虽然单次转换时间拉长,但对这种慢速调节场景无所谓,换来的稳定性很值。
1.3 为什么不用更复杂的方案
有人会问,LED 亮度调节为什么不用 DAC?道理很简单:很多 Cotex-M 内核 MCU 根本没有 DAC,或者只有一路。而 PWM 输出则不同,几乎每个定时器都能输出相位不同的多路 PWM,如果你要做三色 LED 混光或者多路舵机控制,PWM 的扩展性远胜 DAC。
还有人可能会想,蜂鸣器声音能不能用一段音频文件播放?对于带 DAC 和 DMA 的更高端 MCU 当然可以,但在这种入门级板子上,用 PWM 输出不同频率的方波来实现提示音才是性价比最高的方案。它的本质是让定时器工作在”输出比较翻转“模式附近,不断切换高低电平,驱动蜂鸣器膜片振动。掌握了这套方法,后续想扩展成多段旋律甚至按键音效,都只是在修改频率值和持续时间上做文章。
我把这套方案的资源占用和复杂度整理成下表,方便你评估是否能直接套用:
| 模块 | 使用外设 | 资源占用 | 复杂度 |
|---|---|---|---|
| 无源蜂鸣器 | 定时器 2 PWM 输出 | 1 个定时器通道 + 1 个引脚 | 低 |
| LED 亮度调节 | 定时器 3 PWM 输出 | 1 个定时器通道 + 1 个引脚 | 低 |
| 电位器采样 | ADC1 单通道轮询 | 1 个模拟输入引脚 | 低 |
| CPU 占用 | 主循环 + 软件滤波 | 极低,PWM 由硬件生成 | 低 |
这样的资源占用对入门级开发板来说非常宽裕,剩下的定时器、串口、中断资源还能留出来做别的功能。
2. 核心细节解析与实操要点
2.1 定时器 PWM 到底是怎么工作的
理解 PWM 不需要死记公式,你可以把定时器想象成一个循环往复的计数器,从 0 数到 ARR,然后再回到 0,如此循环。计数到 ARR 的时间就是 PWM 的一个完整周期。另一个寄存器 CCR 设定了一个比较阈值,当计数器小于 CCR 时输出高电平,当计数器大于等于 CCR 时输出低电平。
于是 PWM 波形就出现了:周期由 ARR 决定,频率就是计数器时钟频率除以 ARR+1;脉宽由 CCR 决定,占空比就是 CCR/(ARR+1)。这个模型的优点是计数过程完全由硬件完成,CPU 只需要在改变音调或亮度时更新 CCR 值即可。
对一个实际计算例子来说,假设系统时钟是 72MHz,把预分频器设为 71,则进入计数器的时钟就是 1MHz。蜂鸣器需要约 2.7kHz 频率,那么 ARR 就应该满足:1MHz / (ARR+1) ≈ 2700Hz,反推 ARR ≈ 370。想要 50% 占空比,CCR 设为 185 即可。这样的参数既好算又好记,实测声音也正常。
LED 那边的计算类似,如果要求 20kHz 频率,且希望占空比分辨率足够高,可以把预分频器设为 0,直接使用 72MHz 时钟驱动计数,ARR 设为 3599,这样 PWM 频率就是 72MHz / 3600 = 20kHz,占空比分辨率接近 12 位,可以和 ADC 值一一对应。
2.2 无源蜂鸣器与有源蜂鸣器的区别
新手最容易搞混的就是这两种蜂鸣器。有源蜂鸣器内部自带振荡电路,只要通上直流电就会响;无源蜂鸣器内部没有振荡源,必须由外部提供方波信号才能发声。
这个例程用的是无源蜂鸣器,因为它的频率完全可控,你能通过改变 PWM 频率发出不同音调,这是做音乐提示、按键反馈、报警器的基础。有源蜂鸣器虽然接线简单,但只能发固定一个音,玩法少很多。
实操中有个很容易踩的坑:无源蜂鸣器直接接上高电平是不会响的。我见过有人拿万用表测引脚电压,看到 3.3V 就觉得驱动没问题,实际上蜂鸣器膜片没有交变信号就不会振动。正确的验证方法是先确认引脚上有无方波脉冲,甚至可以直接把引脚在高低电平之间手动翻转,听到“嗒嗒”声就说明蜂鸣器本身是好的。
如果板子上没有现成的蜂鸣器驱动电路,外接的时候要注意驱动能力。普通 MCU 引脚直接带一个无源蜂鸣器往往电流不够,声音会很微弱,最好用一颗三极管做开关放大。电磁式蜂鸣器还要注意续流问题,关断瞬间会产生反向电动势,并联一个续流二极管能保护引脚。
2.3 ADC 采样与占空比映射的细节
ADC 的作用是把电位器分压得到的模拟电压转换成数字量。大多数 MCU 的 ADC 是 12 位分辨率,也就是说参考电压 3.3V 会被分成 4096 个台阶,每个台阶对应约 0.8mV。转动电位器时,ADC 的值会从 0 到 4095 线性变化。
但 ADC 读出来的数字不能直接写到 CCR 里,因为两边量程不一样。LED 的 PWM 周期是 3600,ADC 是 4096,需要做一次映射:新占空比 = ADC采样值 × 3600 / 4096。如果你直接取 ADC 的低 8 位或者高 8 位,亮度分辨率会明显下降,调到某个位置时灯会一级一级跳变,非常不舒服。
这里有个初学者常犯的错误:写代码时先做了除法再乘法,比如 raw / 4096 * 3600,整数运算会把小数截断,导致结果几乎全是 0。正确做法是先乘后除,或者直接写成 (raw * 3600) / 4096。我习惯再加一层上限保护,防止极端情况计算出超过 ARR 的值。
硬件上还有一个细节:ADC 的参考电压最好使用稳定的电源。如果直接拿 VCC 当参考电压,而 VCC 又纹波很大,测量结果会跟着电源一起晃。对于板载电位器娱乐场景影响不大,但如果后续做传感器采集,建议花点心思在参考电压上。
3. 实操过程与核心环节实现
3.1 引脚分配与电路检查
动手写代码之前,先把硬件连清楚。不同型号的板子引脚定义可能不一样,我下面用的是我手头这块板子最常见的映射关系,你在自己的板子上跑通前一定要对着原理图确认一遍:
| 功能 | 引脚 | 对应外设 | 备注 |
|---|---|---|---|
| 蜂鸣器 PWM | PB0 | TIM2_CH1 | 板载蜂鸣器驱动电路,直接输出 PWM 即可 |
| LED PWM | PA6 | TIM3_CH1 | 板载 LED,通常已串电阻 |
| 电位器采样 | PA1 | ADC1_IN1 | 旋转电位器中点接 PA1,两端分别接 GND 和 3.3V |
检查的时候有两个重点。第一,确认蜂鸣器和 LED 是接在定时器复用功能的引脚上,而不是随便找一个 GPIO。GPIO 必须配置成复用推挽输出模式,才能让定时器输出的波形走到引脚上。第二,确认电位器的接线方向,如果接反,旋转方向带来的效果会反过来,虽然不影响使用,但第一次调试时容易被误导。
3.2 初始化代码逐段讲解
下面这段代码用通用库函数风格来写,因为 MM32 不同系列、不同 SDK 版本的函数名差异很大,但你手里的官方例程一定能找到对应的同名函数。逻辑是最重要的,函数名只是外壳。
/* 蜂鸣器 PWM 初始化:TIM2_CH1 -> PB0 */ void BuzzerPWM_Init(void) { GPIO_InitTypeDef gpioInit; TIM_TimeBaseInitTypeDef timerInit; TIM_OCInitTypeDef ocInit; /* 1. 开启时钟 */ RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); /* 2. 配置 PB0 为复用推挽输出 */ GPIO_StructInit(&gpioInit); gpioInit.GPIO_Pin = GPIO_Pin_0; gpioInit.GPIO_Mode = GPIO_Mode_AF_PP; gpioInit.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &gpioInit); /* 3. 配置定时器时基:预分频 71,ARR=370,频率约 2.7kHz */ TIM_TimeBaseStructInit(&timerInit); timerInit.TIM_Prescaler = 71; timerInit.TIM_Period = 370; timerInit.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &timerInit); /* 4. 配置 PWM 模式 1,50% 占空比 */ TIM_OCStructInit(&ocInit); ocInit.TIM_OCMode = TIM_OCMode_PWM1; ocInit.TIM_OutputState = TIM_OutputState_Enable; ocInit.TIM_Pulse = 185; /* CCR = 185,即 185/(370+1) ≈ 50% */ ocInit.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM2, &ocInit); /* 5. 启动定时器 */ TIM_Cmd(TIM2, ENABLE); }初始化顺序确实有讲究。先开时钟是必然的,但 GPIO 和定时器的配置顺序并不算严格,关键是最后一定要记得启动定时器。我见过有人配置完所有寄存器,主循环也在跑,就是忘了调用 TIM_Cmd,PWM 始终不输出,排查半天才发现问题。
LED PWM 的初始化逻辑完全一样,只是用的定时器和引脚不同。我把 ARR 设为 3599,预分频器设为 0,这样 PWM 频率是 20kHz,占空比分辨率接近 12 位:
/* LED PWM 初始化:TIM3_CH1 -> PA6 */ void LedPwm_Init(void) { GPIO_InitTypeDef gpioInit; TIM_TimeBaseInitTypeDef timerInit; TIM_OCInitTypeDef ocInit; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_StructInit(&gpioInit); gpioInit.GPIO_Pin = GPIO_Pin_6; gpioInit.GPIO_Mode = GPIO_Mode_AF_PP; gpioInit.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpioInit); TIM_TimeBaseStructInit(&timerInit); timerInit.TIM_Prescaler = 0; timerInit.TIM_Period = 3599; timerInit.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &timerInit); TIM_OCStructInit(&ocInit); ocInit.TIM_OCMode = TIM_OCMode_PWM1; ocInit.TIM_OutputState = TIM_OutputState_Enable; ocInit.TIM_Pulse = 0; ocInit.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM3, &ocInit); TIM_Cmd(TIM3, ENABLE); }3.3 ADC 初始化与采样函数
ADC 的初始化比定时器稍微多一点步骤,包括配置模拟输入引脚、选择 ADC 时钟分频、设定数据对齐方式、开启校准。我习惯把 ADC 配置为单次转换模式,主循环中需要采样时再软件触发一次,读取结果。这样逻辑简单直观,也不需要额外开中断。
void Adc_Init(void) { GPIO_InitTypeDef gpioInit; ADC_InitTypeDef adcInit; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); GPIO_StructInit(&gpioInit); gpioInit.GPIO_Pin = GPIO_Pin_1; gpioInit.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &gpioInit); ADC_StructInit(&adcInit); adcInit.ADC_Mode = ADC_Mode_Independent; adcInit.ADC_ScanConvMode = DISABLE; adcInit.ADC_ContinuousConvMode = DISABLE; adcInit.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; adcInit.ADC_DataAlign = ADC_DataAlign_Right; adcInit.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &adcInit); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_239Cycles5); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); } uint32_t ReadAdc(void) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET); return ADC_GetConversionValue(ADC1); }这里有几个关键点值得说明。ADC 时钟不能无限分频,一般要求不超过 14MHz,所以在 72MHz 系统时钟下至少要 6 分频以上,否则采样精度会严重下降。采样时间我选了最长的 239.5 个周期,这样能很大程度上滤掉电位器旋转时产生的毛刺,对稳定性的提升比代码里写多少软件滤波都直观。
校准步骤不能省。某些 MCU 在 ADC 上电后如果不做校准,转换结果会存在几十 LSB 的偏移,最初我没加校准时,电位器拧到最左端 ADC 值不是 0 而是几十,LED 始终不能完全熄灭。后来老老实实加上校准流程,问题当场消失。
3.4 主循环:把 ADC 和 PWM 串起来
主循环的逻辑非常简单:扫描按键,如果按键按下就让蜂鸣器响 200ms,作为操作反馈;然后循环读取 ADC 值,映射后更新 LED 的 PWM 占空比。
int main(void) { BuzzerPwm_Init(); LedPwm_Init(); Adc_Init(); Key_Init(); while (1) { /* 按键触发蜂鸣器短鸣 */ if (Key_Scan()) { TIM_SetCompare1(TIM2, 185); /* 50% 占空比,发声 */ DelayMs(200); TIM_SetCompare1(TIM2, 0); /* CCR=0,停止发声 */ } /* 读取电位器电压,映射到 LED 亮度 */ uint32_t raw = ReadAdc(); uint32_t duty = (raw * 3599) / 4096; if (duty > 3599) { duty = 3599; } TIM_SetCompare1(TIM3, (uint16_t)duty); DelayMs(10); } }这里有个容易弄混的地方:把 CCR 设为 0,PWM 还是在输出的,只是输出恒为低电平,无源蜂鸣器没有信号就不会响,不会给蜂鸣器“断电”。这种做法在功能上是正确的,但如果你用的是有源蜂鸣器,CCR=0 就相当于一直不给电,反而不会响。这个例程里我们目标明确,就是无源蜂鸣器,所以这样写没问题。
主循环里的 10ms 延时不是随便加的。ADC 转换本身只需要几十微秒,但电位器手动调节的速度远慢于这个时间,没必要让主循环跑得飞快,那样反而会把 ADC 采样值频繁更新到 LED 上,造成不必要的抖动。10ms 对应 100Hz 的刷新率,人眼已经觉得亮度变化是实时的,CPU 也留出了大量余量。
3.5 实测效果与进一步调优
按照上面的代码跑通后,你会看到几个直观现象。电位器从最左端转到最右端,LED 亮度从完全熄灭平滑变化到最亮,中间几乎没有可见的阶梯跳变;按下按键时蜂鸣器会发出一声约 2.7kHz 的提示音,松开后声音停止。
如果你想体验不同音调,只需要修改蜂鸣器那一路的 ARR 和 CCR。比如把预分频器保持 71,ARR 改成 249,频率就变成 1MHz / 250 = 4kHz,声音会更尖锐;ARR 改成 499,频率就是约 2kHz,声音明显低沉一些。这个练习能帮你建立“频率值映射到听觉感受”的直觉,非常推荐亲自动手试一遍。
LED 那边也有一个值得玩的点:把 LED PWM 频率从 20kHz 降到 100Hz,然后用手机摄像头对准 LED,你会看到明显的横向滚动条纹,这就是频闪。20kHz 下用手机基本拍不出条纹,但高速摄影或者示波器还是能看到 PWM 方波。理解这个差别,以后你做灯控产品时就会主动考虑频闪对相机拍摄的影响。
4. 常见问题与排查技巧实录
4.1 蜂鸣器完全不响的排查路径
蜂鸣器不响是这套例程里最容易出现的问题,而且原因五花八门。我把排查路径总结成了一条线,按顺序查下去基本不会漏。
首先确认你的蜂鸣器是无源的。有些开发板出厂焊的是有源蜂鸣器,那 PWM 驱动根本不会响,因为它只需要直流电就能一直响。把蜂鸣器两端直接接 3.3V 和 GND,如果通电就响,那就是有源蜂鸣器,和你想要的效果完全两回事。
其次检查 GPIO 复用配置。如果引脚被配置成了普通推挽输出,定时器产生的 PWM 信号是无法送到引脚上的。用万用表频率挡测 PB0 对地电压,正常情况下应该能看到约 2.7kHz 的方波频率。如果万用表没有频率挡,也可以把 CCR 先改成固定值并保持 TIM_Cmd 开启,再用示波器看波形。
最后检查占空比。如果 CCR 初始化成 0,PWM 输出恒低,蜂鸣器不会有任何声音。把 CCR 临时改成 ARR 的一半,再用逻辑分析仪或者示波器看,信号正常了再往下调。
4.2 LED 亮度调节不正常的常见原因
LED 最典型的两个故障现象是“永远最亮”和“只有两三个档位”。
“永远最亮”一般是因为 GPIO 复用配置错误,导致 PWM 输出引脚没有真正连到定时器,引脚被拉到了某个固定电平。或者 ADC 引脚没有配置成模拟输入模式,读回来的值持续是满量程,映射出来的占空比自然最大。
“只有两三个档位”则多半是映射公式写错了。比如你写了 duty = raw / 4096 * 3599,整数运算先除后乘,raw 小于 4096 时结果恒为 0,LED 就始终是熄灭或者最小亮度;raw 等于 4095 时结果才是 3599,才会突然跳到最亮。改成先乘后除就正常了。
还有一个容易被忽略的坑是 ADC 数据的对齐方式。如果配置成左对齐,低位数据全是 0,看起来采样值也变化,但实际上分辨率被丢弃了一半以上,映射到 LED 时会出现明显台阶。尽量配置成右对齐,这样数值和物理电压的对应关系最直观。
4.3 常见问题速查表
| 故障现象 | 可能原因 | 检查建议 |
|---|---|---|
| 蜂鸣器没有声音 | 无源蜂鸣器被当成有源蜂鸣器接线 | 直接接直流电测试蜂鸣器类型 |
| 蜂鸣器没有声音 | 定时器没有启动 | 检查是否调用 TIM_Cmd |
| 蜂鸣器没有声音 | GPIO 复用模式未配置 | 用示波器/万用表频率挡测引脚输出 |
| 蜂鸣器发出“哒哒”声 | PWM 频率太低 | 复核预分频和 ARR,确保频率在 2kHz 以上 |
| LED 始终最亮 | ADC 引脚未配置模拟输入 | 检查 GPIO_Mode_AIN |
| LED 亮度跳变明显 | 映射公式先除后乘 | 改成 (raw * 3599) / 4096 |
| LED 调节方向相反 | 电位器两端接反 | 交换电位器两端接线 |
| ADC 采样值抖动 | 采样时间太短 | 采样时间改成最长挡位或软件滤波 |
| 部分 MCU 型号上蜂鸣器不响 | 高级定时器未使能主输出 | 若是 TIM1/TIM8,检查主输出使能寄存器 |
4.4 几个我后来才想明白的避坑经验
玩这套例程时我最大的感受是,芯片本身不难,难的是文档分散在各种参考手册里,尤其引脚复用关系很容易看走眼。我的习惯是拿到一块新板子,第一件事就是打开原理图,把蜂鸣器、LED、电位器的实际连接引脚标到代码注释里去,而不是直接套用网上的工程。
另一个经验是,蜂鸣器驱动最好加一个“测试模式”。我在调试初期写了个自检程序:上电后让蜂鸣器响 1 秒,LED 闪 3 次,然后才进入正常逻辑。这样每次烧录程序后,不需要按下按键就能判断外设初始化是否正常,排查问题速度快很多。
还有一个点,ADC 多读几次再求平均,比把采样时间调长更有效。电位器旋动时接触电阻变化是随机的,单纯延长采样时间对高频噪声有帮助,但对接触电阻造成的低频跳变作用有限。我最终用了“连续采 8 次,去掉最大最小值,再求平均”的做法,LED 亮度稳定性肉眼可见地提升。
这个例程做完之后,我又顺手把电位器换成了光敏电阻,原来的“手动调亮度”变成了“根据环境亮度自动调整”,整个过程只是换了传感器,代码逻辑几乎没动。这也说明了 PWM 加 ADC 这套组合的通用性:一个输出控制设备,一个输入感知环境,两者一旦打通,能做的小项目就非常多了。
最后再分享一个小技巧:把蜂鸣器的 CCR 从 185 改成 90,声音会明显变小,但不会变成 50% 音量,因为无源蜂鸣器发声本来就有非线性。想控制音量,最好的办法不是改占空比,而是调整驱动电压或串联电阻。严格来说,PWM 调蜂鸣器音量不是线性映射,知道这个特性,以后做提示音分级时才不会走弯路。