4、软件逻辑与初始化顺序故障排查
软件层面的PWM故障,说实话,比硬件问题更隐蔽。硬件出问题,示波器一抓波形就露馅了。软件逻辑错了,代码编译通过,芯片跑得欢,就是输出不对。我见过太多工程师抱着示波器在板子上戳半天,最后发现是初始化顺序搞反了。
这一章,咱们就聊聊软件逻辑里的几个大坑。PWM初始化函数调用顺序、中断优先级冲突、DMA配置错误、主循环与PWM更新打架。每个坑我都踩过,有的还不止一次。
核心观点:软件逻辑故障,本质上是时序和资源冲突。芯片不会说谎,它只是严格按照你写的代码执行。你写错了,它就执行错了。
4.1 PWM初始化函数调用顺序错误
初始化顺序这事儿,看着简单,但最容易出问题。我个人的习惯是:先配置时钟,再配置GPIO,然后配置定时器,最后配置PWM输出通道。这个顺序不能乱。
为什么?你想想看,如果你先配置了定时器的PWM模式,但时钟还没开,定时器寄存器根本写不进去。或者GPIO还没配成复用功能,PWM信号就输出到了普通GPIO上,那能对吗?
我遇到过的一个典型案例:某次调试一个四轴飞行器的电调,PWM输出死活没波形。查了两天,最后发现是GPIO初始化放在了定时器初始化之后。定时器已经把PWM通道配置好了,但GPIO还是默认的浮空输入模式。信号根本没送到引脚上去。
正确的初始化顺序应该是这样的:
// 正确的PWM初始化顺序
void PWM_Init(void)
{
// 第一步:使能时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE);
// 第二步:配置GPIO为复用功能
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; // TIM3_CH1
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;
GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;
GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 第三步:配置定时器时基
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
TIM_TimeBaseStructure.TIM_Prescaler = 83; // 84MHz / 84 = 1MHz
TIM_TimeBaseStructure.TIM_Period = 999; // 1kHz PWM
TIM_TimeBaseStructure.TIM_ClockDivision = 0;
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure);
// 第四步:配置PWM通道
TIM_OCInitTypeDef TIM_OCInitStructure;
TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1;
TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable;
TIM_OCInitStructure.TIM_Pulse = 500; // 50%占空比
TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High;
TIM_OC1Init(TIM3, &TIM_OCInitStructure);
// 第五步:使能定时器
TIM_Cmd(TIM3, ENABLE);
// 第六步:如果是高级定时器,还需要使能主输出
// TIM_CtrlPWMOutputs(TIM1, ENABLE);
}
注意:高级定时器(TIM1、TIM8等)有一个额外的刹车功能和主输出使能位。如果你用的是高级定时器,别忘了调用 TIM_CtrlPWMOutputs() 使能主输出。我见过有人在这上面卡了整整一个下午。
4.2 中断配置与优先级冲突
PWM相关的中断,最常见的是更新中断(溢出中断)和捕获比较中断。中断配置错了,轻则PWM波形抖动,重则系统死机。
优先级冲突是个大问题。我记得有一次做伺服驱动器,PWM中断里要做电流环计算,计算量比较大。结果PWM中断优先级设得太低,被一个串口中断抢了。每次串口发数据,PWM波形就出现一个毛刺。电机跟着抖一下,那叫一个酸爽。
我的建议是:
- PWM中断优先级要设高——尤其是做实时控制时,PWM周期是系统的时基,不能被打乱。
- 中断服务函数要短——PWM中断里只做最必要的事,比如更新比较值、读取ADC结果。复杂的运算放到主循环或后台任务里。
- 注意中断嵌套——如果多个中断共享优先级组,要确保不会出现优先级反转。
这里给一个中断优先级配置的参考:
// 中断优先级配置示例
// 使用NVIC_PriorityGroup_2(2位抢占优先级,2位子优先级)
void NVIC_Configuration(void)
{
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
// TIM3更新中断 - 高优先级
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = TIM3_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0; // 最高抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
// 串口中断 - 低优先级
NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2; // 较低抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
}
小技巧:调试中断优先级问题时,可以在中断服务函数里翻转一个GPIO,用示波器看这个GPIO的波形。如果波形出现异常抖动或丢失,说明中断响应有问题。这个方法我用了十年,屡试不爽。
4.3 DMA传输配置错误
DMA配合PWM,通常用于自动更新占空比。比如你要输出一个正弦波PWM,用DMA把正弦表数据自动搬运到定时器的比较寄存器里。这个方案效率很高,但配置起来坑也不少。
常见的DMA配置错误有:
- 传输方向搞反了——DMA从内存到外设,还是从外设到内存?PWM更新占空比,应该是内存到外设。
- 地址增量设置错误——内存地址要增量,外设地址不增量。这个搞反了,数据就全乱了。
- 传输完成中断没开——DMA传完一轮数据后,需要重新加载新的数据表。如果没开传输完成中断,DMA就停在那里了。
- 循环模式没使能——如果你要连续输出波形,DMA必须工作在循环模式。
我曾经犯过一个低级错误:DMA的传输数据宽度设置错了。定时器的比较寄存器是16位的,我设成了32位传输。结果每次DMA写入,都把比较寄存器和旁边的寄存器一起覆盖了。PWM波形完全乱套。
正确的DMA配置示例:
// DMA配置 - 用于自动更新PWM占空比
void DMA_Config(void)
{
DMA_InitTypeDef DMA_InitStructure;
RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA1, ENABLE);
DMA_DeInit(DMA1_Stream5); // TIM3_UP对应DMA1 Stream5
DMA_InitStructure.DMA_Channel = DMA_Channel_5;
DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&TIM3->CCR1; // 外设地址:比较寄存器
DMA_InitStructure.DMA_Memory0BaseAddr = (uint32_t)&SineTable[0]; // 内存地址:正弦表
DMA_InitStructure.DMA_DIR = DMA_DIR_MemoryToPeripheral; // 方向:内存到外设
DMA_InitStructure.DMA_BufferSize = 256; // 传输长度
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; // 外设地址不增量
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; // 内存地址增量
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; // 16位
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; // 16位
DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; // 循环模式
DMA_InitStructure.DMA_Priority = DMA_Priority_High;
DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Disable;
DMA_InitStructure.DMA_FIFOThreshold = DMA_FIFOThreshold_HalfFull;
DMA_InitStructure.DMA_MemoryBurst = DMA_MemoryBurst_Single;
DMA_InitStructure.DMA_PeripheralBurst = DMA_PeripheralBurst_Single;
DMA_Init(DMA1_Stream5, &DMA_InitStructure);
// 使能DMA传输完成中断(可选,用于重新加载数据)
DMA_ITConfig(DMA1_Stream5, DMA_IT_TC, ENABLE);
// 使能DMA
DMA_Cmd(DMA1_Stream5, ENABLE);
}
4.4 主循环与PWM更新逻辑冲突
这个坑比较隐蔽。主循环里如果直接修改PWM的占空比,而PWM中断也在修改同一个寄存器,就会产生竞争条件。
举个例子:主循环里根据按键输入调整PWM占空比,同时PWM中断里根据ADC采样结果做闭环控制。两个地方都在写TIMx->CCR1,最后谁写进去的值,完全取决于时序。结果就是PWM输出忽大忽小,系统不稳定。
解决方案其实不复杂:
- 使用影子寄存器——很多定时器都有影子寄存器,写入CCR的值会在下一个更新事件才生效。这能避免在PWM周期中间被修改。
- 加互斥保护——如果必须在主循环里修改,可以关中断再修改,改完再开中断。但关中断时间要短,不能影响PWM的实时性。
- 用双缓冲——准备两个缓冲区,一个给PWM中断用,一个给主循环写。主循环写完后,通过一个标志通知中断切换缓冲区。
我个人比较推荐双缓冲方案。虽然代码量多一点,但安全可靠,不会出现数据不一致的问题。
// 双缓冲方案示例
volatile uint16_t pwm_buffer[2]; // 两个缓冲区
volatile uint8_t active_buffer = 0; // 当前使用的缓冲区索引
// 主循环中更新占空比
void Update_PWM_Duty(uint16_t new_duty)
{
uint8_t inactive_buffer = 1 - active_buffer; // 找到空闲缓冲区
pwm_buffer[inactive_buffer] = new_duty; // 写入空闲缓冲区
__disable_irq();
active_buffer = inactive_buffer; // 切换缓冲区
TIM3->CCR1 = pwm_buffer[active_buffer]; // 更新比较值
__enable_irq();
}
// PWM中断服务函数
void TIM3_IRQHandler(void)
{
if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET)
{
// 使用当前缓冲区中的值
uint16_t duty = pwm_buffer[active_buffer];
// ... 其他处理
TIM_ClearITPendingBit(TIM3, TIM_IT_Update);
}
}
警告:在多任务系统中,如果PWM更新逻辑和主循环共享了全局变量,一定要加 volatile 关键字。否则编译器优化后,可能从寄存器里读旧值,而不是从内存里读新值。这个bug非常难查,我吃过一次亏。
知识体系总览
下面这张图把软件逻辑与初始化顺序的故障排查思路串起来了。你可以把它当作排查时的路线图。
软件逻辑的故障排查,说白了就是跟代码的执行顺序和资源竞争做斗争。每次遇到PWM输出异常,先别急着动硬件,把初始化流程、中断配置、DMA参数、主循环逻辑过一遍。很多时候,问题就出在这些看似不起眼的地方。