上篇我们把这套MM32 eMiniBoard的环境跑通、串口能打印了,接下来很多人的下一个动作就是让板子“发出声音”和“感知外界”——也就是PWM驱动无源蜂鸣器,外加用ADC旋钮调整LED亮度。这篇参考例程(下)就把这两个外设一次讲透。内容不复杂,但全是实际调试容易卡壳的地方。适合已经把开发环境搭建好、想做点交互外设的入门阶段朋友。
先提醒一句:如果你刚拿到板子,对PWM和ADC还停留在“调用接口能出结果”的程度,那这篇正好帮你把背后的电路逻辑、参数推算、干扰来源都补上。等跑通声音和亮度调节,你再回头看会发现,这两个外设其实是嵌入式交互里最常用的组合:一个负责“输出状态”,一个负责“读取状态”。
1. 先想清楚“无源”两个字:为什么驱动方式和有源完全不一样
1.1 无源蜂鸣器必须先有“方波”才肯发声
很多人第一次接触无源蜂鸣器,以为和LED一样,给个高电平它就响了。结果程序里GPIO_SetBits一下,喇叭一声不吭,接着就开始怀疑硬件坏了。这不是硬件问题,是你还没理解“无源”两个字的含义。
有源蜂鸣器内部自带振荡电路,只要两端有直流电压,内部电路就会自行产生振荡信号驱动压电片,所以通电就响,但频率固定,音调改不了。无源蜂鸣器内部没有振荡电路,本质就是一个振膜加线圈或者压电陶瓷片,你给它直流电平,振膜只会停在某个方向不动,空气不被扰动,自然没有声音。必须给它连续的方波脉冲,让振膜不停地来回振动,才能发出声音。声音的频率取决于你给的方波频率,2kHz的方波听起来就是“滴滴”,4kHz就明显尖锐。
判断板子上是无源还是有源,最土的办法:拿两节电池的正负极去碰蜂鸣器的两个引脚,如果听到“咔嗒”一声,说明是无源蜂鸣器,只有振膜吸合释放的机械声;如果直接“嘀——”长鸣,那就是有源蜂鸣器。这个方法百试不爽。
1.2 为什么选择PWM而不是GPIO延时翻转
有人会问:既然无源蜂鸣器只要方波就能响,那我用GPIO输出高电平、延时、再输出低电平、再延时,循环往复,不也是方波吗?确实能响,但有两个问题:
第一,频率不稳定。延时靠软件循环,主循环里一旦插入其他任务(比如ADC采样、按键扫描),每一次循环的时间都会发生变化,蜂鸣器的音调就会忽高忽低,听起来像破音。但PWM是定时器硬件生成的波形,CPU不需要参与翻转,频率只取决于定时器的预分频和自动重载值,再忙也不会抖。
第二,GPIO翻转没法灵活控制占空比。无源蜂鸣器的驱动,理想情况是50%占空比的对称方波。你用GPIO翻转当然也能凑出50%,但一旦需要调整音量(改变占空比)、播放不同频率的旋律,用GPIO翻转代码会变得又长又乱。而PWM只需要改一个比较寄存器值,频率和占空比都可以独立调整。
1.3 板载电路的驱动元件:先看一眼原理图
我见过不少朋友卡在“明明有波形,蜂鸣器就是不响”这个坎上,最后发现是硬件接法问题。MM32 eMiniBoard这类板子,蜂鸣器通常不是MCU引脚直接驱动的,而是通过一个三极管或MOS管做电流放大。MCU引脚输出PWM方波,通过限流电阻接到三极管的基极,三极管控制蜂鸣器的通断。
为什么这样设计?因为蜂鸣器工作电流可能达到几十毫安,直接让MCU引脚去驱动,一方面电流不够,另一方面会影响芯片其他引脚的稳定性。三极管起到“小船拉大船”的作用,MCU只需要提供很小的基极电流去控制开关,大电流走外部电源回路。
所以你在初始化PWM之前,一定先打开板子原理图,看蜂鸣器接在哪个定时器的哪个通道上,中间有没有三极管,蜂鸣器的另一头接的是电源还是地。我以前拿到一块板子,没看原理图,默认蜂鸣器接在普通IO口上,结果初始化了半天才发现它用的是定时器复用引脚,还得先把GPIO配置成复用推挽模式。这个细节别看小,能省半小时。
2. PWM初始化里三个容易被忽略的开关
2.1 频率与占空比是怎么推出来的
PWM输出频率的计算公式不复杂:PWM频率 = 定时器时钟 /(预分频值 + 1)/(自动重载值 + 1)。
以系统时钟72MHz为例。如果我想让蜂鸣器发出2kHz的声音,可以先设预分频值PSC为71,这样定时器计数频率变成72MHz / 72 = 1MHz,也就是每计数一次用1微秒。然后设自动重载值ARR为499,那么一个完整周期就是500次计数,500微秒,频率正好是1MHz / 500 = 2kHz。
占空比则通过比较寄存器CCR来控制。当计数器计数到CCR的值时,输出电平翻转。要得到50%占空比,CCR设为ARR的一半,也就是250。代码写出来是这个感觉:
TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = 500 - 1; TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OCInitStructure.TIM_Pulse = 250;为什么蜂鸣器用50%占空比?因为无源蜂鸣器的振膜需要对称的推挽驱动。占空比偏离50%意味着正半周和负半周的能量不均衡,轻则声音变闷,重则蜂鸣器发热。2kHz到4kHz是人耳比较敏感的频率范围,我一般先用2kHz,如果觉得声音太闷、太低沉,再往3kHz左右调。这个没有绝对标准,以自己听着舒服为准。
2.2 高级定时器的主输出使能(最容易踩)
这是我踩过最深的一个坑。如果你用的是TIM1或者TIM8这类高级定时器来输出PWM,那么在你的初始化代码里,除了标准的TIM_Cmd(TIMx, ENABLE)之外,还必须加一句:
TIM_CtrlPWMOutputs(TIM1, ENABLE);这句话的作用是打开高级定时器的主输出使能(MOE位)。高级定时器比普通定时器多了一套刹车和死区控制逻辑,用于电机控制等安全场景。MOE位默认是关闭的,如果MOE为0,PWM波形会被强制禁止输出,引脚上永远是无效电平。很多人从普通定时器的例程改到TIM1,代码全都正确,就是忘了这一句,蜂鸣器死活不响。
如果你的固件库里没有TIM_CtrlPWMOutputs这个函数,也可以用寄存器直接操作,写TIM1->BDTR |= 0x8000,效果一样。具体用哪种看你手上的固件库版本,但原理是一样的。普通定时器如TIM2、TIM3、TIM4没有这个寄存器,也就不需要这一句。
2.3 预装载与更新事件:防止波形突变
还有一个容易被忽略的细节是预装载(Preload)使能。PWM的占空比和周期寄存器,默认有些是带影子寄存器的,有些不是。如果不使能预装载,修改CCR的值可能会在任意时刻直接生效,导致当前这个周期波形突然变宽或者变窄,声音里就会出现“啪嗒”的杂音。
标准做法是使能自动重载预装载和输出比较预装载:
TIM_ARRPreloadConfig(TIM1, ENABLE); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable);这样寄存器的值会等到定时器计数更新事件到来时才一次性装载,PWM波形过渡平顺。做蜂鸣器频率切换时尤其需要这个,不然每次改频率都是一声爆音。
3. ADC采样不是“读一次就给值”这么简单
3.1 电位器接线的两个常见错误
ADC调LED亮度的标准硬件接法,是把电位器三个引脚的两端分别接3.3V和GND,中间抽头接ADC输入引脚。旋转旋钮时,抽头电压在0到3.3V之间变化,MCU通过ADC读到一个0到4095的数值。
我见过两个非常初级的接线错误。第一个是把电位器两端都接到了3.3V,中间抽头无论怎么转都是3.3V,ADC读数永远是满量程。第二个是只接了两根线,中间的抽头没接,悬空状态下ADC引脚读到的电压完全不稳定,数值乱跳。电位器的“抽头”是核心信号输出点,必须可靠连接,最好用杜邦线插紧。
3.2 ADC引脚配置与采样时间
ADC引脚初始化时,GPIO模式必须设为模拟输入。很多人的代码里忘了这一步,或者把引脚复用成了别的功能,结果ADC读数要么固定是0,要么固定是4095。模拟输入模式下,引脚的上下拉电阻和数字输入电路都会被断开,ADC才能准确采样外部模拟电压。
初始化代码大概是这样:
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_Channel = ADC_Channel_0; ADC_InitStructure.ADC_SampleTime = ADC_SampleTime_239Cycles5; ADC_Init(ADC1, &ADC_InitStructure); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1));注意最后面的校准步骤,别省略。内部校准不跑一遍,ADC可能有一两个LSB的偏置误差,虽然不影响调光这种场景,但养成习惯总是好的。
采样时间这里我特意选了239.5周期,而不是最短的1.5周期或7.5周期。原因很简单:电位器输出阻抗不低,如果采样时间太短,内部采样电容还没来得及充满到输入电压,读到的值就会偏低,而且会随环境噪声波动。采样时间拉长,对高阻抗信号源的采样准确度明显改善。在需要高速连续采样的场景里采样时间不能拖太长,但调LED亮度这种应用完全不在乎。
3.3 多次采样取中值的滤波思路
电位器是机械结构,旋钮转动时碳膜和触点接触,会产生随机抖动。这个抖动反映在ADC读数上就是相邻两次采样值可能差几十甚至几百。如果你直接拿一次采样值去更新LED亮度,就会看到亮度跳来跳去。
最简单的软件滤波是连续读多次,排序后取中间值。比如读5次,取第3个,代码如下:
static uint16_t adc_get_filtered_value(void) { uint16_t buf[5]; for (int i = 0; i < 5; i++) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET); buf[i] = ADC_GetConversionValue(ADC1); } for (int i = 0; i < 4; i++) { for (int j = i + 1; j < 5; j++) { if (buf[i] > buf[j]) { uint16_t tmp = buf[i]; buf[i] = buf[j]; buf[j] = tmp; } } } return buf[2]; }为什么用中值而不是平均值?因为平均值会受到极端值的严重影响。假设连续5个数据是200、205、201、700、198,取平均是300,但真实值明显在200附近;取中值则是201,基本不受那个700的影响。电位器接触噪声的特点就是偶发大跳变,中值滤波正好对症。
4. 亮度映射的工程处理:线性拷贝会翻车
4.1 线性映射为什么“不跟手”
ADC读到的范围是0到4095,LED PWM的占空比范围是0到999(假设ARR设为999)。新手最容易写的映射是:
uint16_t duty = adc_value * 999 / 4095;代码没错,逻辑也没错,但你实际转旋钮会发现手感很怪:旋钮转到中间附近,LED已经感觉非常亮,继续往右转几乎看不出变化;反过来,起始阶段亮度又变化得太快,稍微一动就跳了一大截。
这不是程序bug,是人眼的感知特性决定的。人眼对亮度的感知近似对数曲线,对暗部的亮度变化非常敏感,对亮部的亮度变化很不敏感。所以线性控制亮度,视觉上会觉得“低端偏跳、高端偏顿”。要解决这个问题,必须做非线性映射。
4.2 用查表法实现亮度曲线
最经典的做法是Gamma校正,输出亮度正比于输入值的幂次,常见取2.0到2.5。但在单片机上每次做浮点幂运算有点浪费,而且这块板子的MCU未必有硬件浮点单元,效率不高。更稳妥的做法是预计算一张表,运行时直接查表。
把0到4095的ADC范围分成16档,每档跨度256,也就是直接取adc_value >> 8作为档位索引。每一档映射到一个PWM比较值,大致按下Gamma曲线的趋势来:
| 档位 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 占空比值 | 0 | 1 | 3 | 9 | 20 | 39 | 67 | 107 | 160 | 228 | 313 | 417 | 543 | 693 | 868 | 1000 |
这段每个格子对应一个亮度级别。低档位的亮度差控制得很小,高档位的亮度差拉得很大,视觉上每个档位之间的亮度变化感觉就比较均匀了。代码写起来就是这样:
static const uint16_t gamma_table[16] = { 0, 1, 3, 9, 20, 39, 67, 107, 160, 228, 313, 417, 543, 693, 868, 1000 }; uint8_t index = (uint16_t)(adc_value >> 8); uint16_t duty = gamma_table[index]; TIM_SetCompare1(TIM3, duty);如果你不想分档,想无级调节,也可以用整数近似模拟Gamma:把ADC右移减少精度,用乘法和位移组合代替浮点。比如把输出区间缩小到0到255,然后对每个值做一个查表转换,效果一样。查表法本质上是拿一点内存换计算速度,在这个场景下完全值得。
4.3 极性相反和通道复用的处理
还要留意LED的驱动极性。看板子原理图,LED有两种接法。一种是MCU引脚输出高电平点亮,一种是引脚输出低电平点亮(公共端接VCC,LED负极经过限流电阻接到引脚)。如果是低电平点亮,PWM的极性要反过来处理。
比如我的板子LED是低电平点亮,那么实际输出给LED的PWM比较值应该是:
uint16_t actual_duty = 1000 - gamma_table[index]; TIM_SetCompare1(TIM3, actual_duty);否则你向右转旋钮,ADC值增大,映射出的duty增大,但LED反而变暗。这个方向性问题调试时会让人怀疑人生,先想清楚极性能省不少时间。
还有一个常见误区:蜂鸣器和LED的PWM频率不能共用同一个定时器。无源蜂鸣器需要2kHz左右,而LED亮度PWM需要尽量高于10kHz,否则人眼能感觉到闪烁。同一个定时器的所有通道共享同一个周期寄存器,没办法同时输出两种频率。所以参考例程里蜂鸣器用TIM1,LED用TIM3,各管一套频率和占空比。这也是代码里为什么要分别初始化两个定时器的原因。
5. 联调阶段最常见的三种“症状”与排查思路
5.1 蜂鸣器不响:先看波形还是先查电路
蜂鸣器完全无声,第一步不是改代码,而是区分“没波形”还是“有波形但没声音”。有示波器或逻辑分析仪就直接看引脚波形;没有的话,可以用万用表量引脚对地电压,如果有PWM输出,万用表会量到一个平均值,大约是电源电压乘以占空比,比如3.3V的50%占空比,量出来约1.65V。
如果引脚连波形都没有,按这个顺序排查:
- 定时器时钟和GPIO时钟有没有同时打开。
- GPIO模式是不是复用推挽输出,而不是普通推挽输出。普通推挽输出模式下,定时器信号无法连到引脚上。
- 高级定时器有没有调用TIM_CtrlPWMOutputs开启主输出。
- CCR寄存器的值是不是0。如果你把占空比设成了0%,输出自然没有波形。
如果引脚有波形但蜂鸣器不响,重点查硬件。三极管电路有没有焊好,限流电阻阻值是不是异常,蜂鸣器正负极有没有接反。有些标了“无源蜂鸣器”的型号,引脚极性其实有标注,虽然多数无源蜂鸣器不区分正负,但稳妥起见还是按丝印标记接。
5.2 ADC要么满量程要么为0
ADC读数固定为4095,最常见的两个原因:引脚没有配成模拟输入模式,或者上一次复用配置把引脚占用了。我曾经在一个例程里同时初始化了GPIO复用为PWM输出,然后又拿同一个引脚去做ADC,结果是ADC永远读到满量程,查了半小时才意识到引脚冲突。
ADC读数固定为0,大概率是电位器接线问题:要么抽头没接好,要么滑动臂断路。先测量抽头对地电压,旋转旋钮看电压是否在0到3.3V之间变化。如果电压正常,再看ADC引脚和电位器之间的连线是否断开。
还有一个需要留意的地方:ADC输入电压不能超过VREF,否则读数会封顶。如果你把电位器接到5V而不是3.3V,ADC值会一直卡在4095。之前有朋友把电位器接到板子上的5V排针,怎么读都是满量程,改到3.3V就正常了。
5.3 LED亮度来回跳:中断与主循环的竞争
调好ADC和PWM后,容易出现另一个怪现象:旋钮不动,LED亮度却在明显闪烁。这个问题如果排除了电位器接触不良,多半是中断和主循环同时在写定时器的比较寄存器。
比如你在ADC中断里直接调用TIM_SetCompare1,主循环里又根据滤波结果调用TIM_SetCompare1,两个地方写的时机互相覆盖,就会看到亮度乱跳。正确做法是中断里只更新一个全局变量,主循环统一把变量写入定时器:
volatile uint16_t g_adc_raw = 0; void ADC_IRQHandler(void) { if (ADC_GetITStatus(ADC1, ADC_IT_EOC)) { g_adc_raw = ADC_GetConversionValue(ADC1); ADC_ClearITPendingBit(ADC1, ADC_IT_EOC); } } // 主循环中 uint16_t filtered = process_adc_filter(g_adc_raw); uint8_t index = filtered >> 8; TIM_SetCompare1(TIM3, gamma_table[index]);这样即使中断频繁触发,最终写定时器寄存器的动作只有一个主人,数据一致性就有保证。做嵌入式这种“共享资源访问”的规范,几乎每个项目都会用到,养成这个习惯能省很多后期排查的精力。
参考例程跑通之后,我建议你顺手做一个小扩展:用PWM输出不同的频率组合,让蜂鸣器播放一小段旋律,同时用ADC控制播放速度。这样既把定时器、ADC、中断这几个外设全串起来,也能真正体会到“外设组合起来才叫交互系统”这句话的实际意义。我在实际调试中最值钱的经验,就是先按硬件链路把“信号从哪里来、到哪里去”拆清楚,再动手写代码。以后你再遇到陌生的板子、陌生的外设,第一反应就不是到处翻例程,而是先看原理图、再对寄存器,整个调试思路会顺畅很多。