第四章 核心外设驱动开发(下):定时器PWM输出、ADC采样与DMA数据传输
好,咱们接着往下聊。上一章我们把GPIO、UART和SPI这些基础外设过了一遍,这一章要啃的才是真正让充电桩“动起来”的核心——PWM控制电子锁、ADC采集电压电流、DMA搬运数据。这三个东西凑在一起,基本上就覆盖了充电桩固件里最关键的实时控制链路。
我个人习惯是把这三者放在一起讲,因为在实际项目中,它们往往是联动的。你想想看,PWM控制电子锁开合,ADC实时监测充电回路的状态,DMA负责把ADC的数据高速搬运到内存——这其实就是一条完整的数据采集与控制闭环。
4.1 定时器PWM输出:充电枪电子锁的精准控制
充电枪的电子锁,说白了就是一个微型电机驱动的锁止机构。插枪时锁住,拔枪时解锁。但这里有个坑——你不能简单地给个高电平就完事。电子锁的驱动需要特定的PWM波形,频率和占空比都有讲究。
我曾经踩过的坑:早期做的一款充电桩,电子锁用的是直流减速电机。我直接用GPIO输出高低电平控制,结果电机启动瞬间电流冲击太大,直接把MCU的IO口烧了。后来换成PWM软启动,电流曲线平滑多了。
PWM控制电子锁的核心参数:
| 参数 | 典型值 | 说明 |
|---|---|---|
| 频率 | 1kHz ~ 20kHz | 太低会听到电机啸叫,太高MOS管开关损耗大 |
| 占空比 | 50% ~ 100% | 启动时用50%软启动,稳定后切到100% |
| 死区时间 | 1μs ~ 5μs | 防止上下桥臂直通 |
来看一段实际的PWM初始化代码,我用的是STM32的HAL库,但思路是通用的:
// 定时器PWM初始化 - 用于电子锁控制
void LockPWM_Init(void)
{
TIM_HandleTypeDef htim;
TIM_OC_InitTypeDef sConfigOC = {0};
// 时钟配置:APB1定时器时钟为72MHz
__HAL_RCC_TIM3_CLK_ENABLE();
htim.Instance = TIM3;
htim.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz
htim.Init.CounterMode = TIM_COUNTERMODE_UP;
htim.Init.Period = 1000 - 1; // 1MHz / 1000 = 1kHz
htim.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
HAL_TIM_PWM_Init(&htim);
sConfigOC.OCMode = TIM_OCMODE_PWM1;
sConfigOC.Pulse = 500; // 初始占空比50%
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
HAL_TIM_PWM_ConfigChannel(&htim, &sConfigOC, TIM_CHANNEL_1);
// 启动PWM输出
HAL_TIM_PWM_Start(&htim, TIM_CHANNEL_1);
}
// 动态调整占空比 - 实现软启动
void LockPWM_SetDuty(uint16_t duty)
{
__HAL_TIM_SET_COMPARE(&htim, TIM_CHANNEL_1, duty);
}
嗯,这里要注意一点:电子锁的PWM频率不能随便选。我见过有人用50Hz去驱动,结果电机嗡嗡响,锁半天打不开。实际项目中,我建议用10kHz左右,人耳听不到,MOS管发热也还能接受。
我的个人经验:电子锁的PWM控制最好加一个“软启动+软停止”的渐变过程。比如从0%到100%用200ms渐变,这样既不会冲击电流,也不会因为突然断电导致锁卡死。
4.2 ADC采样:电压电流检测的精度与速度博弈
充电桩的ADC采样,说白了就是实时监控充电回路的状态。电压高了要报警,电流大了要降功率,漏电了要立刻切断。这些判断都依赖ADC的数据质量。
我遇到过最头疼的问题是什么?ADC采样值跳变。明明电压是稳定的220V,采回来却是210V到230V之间乱跳。后来排查发现是采样时序没对齐,电源纹波刚好落在采样窗口里。
ADC采样的关键配置:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 分辨率 | 12位 | 够用,16位太慢且没必要 |
| 采样时间 | 1.5 ~ 7.5个ADC时钟周期 | 信号源阻抗高时用长采样时间 |
| 转换模式 | 连续转换或注入转换 | 连续模式用于常规监控,注入模式用于紧急事件 |
| 参考电压 | 3.3V或内部参考 | 外部参考更稳定,但成本高 |
来看一个实际的ADC多通道采样代码:
// ADC初始化 - 三通道:电压、电流、温度
void ADC_Init(void)
{
ADC_HandleTypeDef hadc;
ADC_ChannelConfTypeDef sConfig = {0};
__HAL_RCC_ADC1_CLK_ENABLE();
hadc.Instance = ADC1;
hadc.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4;
hadc.Init.Resolution = ADC_RESOLUTION_12B;
hadc.Init.ScanConvMode = ENABLE; // 扫描模式
hadc.Init.ContinuousConvMode = ENABLE; // 连续转换
hadc.Init.DiscontinuousConvMode = DISABLE;
hadc.Init.ExternalTrigConv = ADC_SOFTWARE_START;
hadc.Init.DataAlign = ADC_DATAALIGN_RIGHT;
hadc.Init.NbrOfConversion = 3; // 3个通道
HAL_ADC_Init(&hadc);
// 配置通道0:电压检测(PA0)
sConfig.Channel = ADC_CHANNEL_0;
sConfig.Rank = 1;
sConfig.SamplingTime = ADC_SAMPLETIME_3CYCLES;
HAL_ADC_ConfigChannel(&hadc, &sConfig);
// 配置通道1:电流检测(PA1)
sConfig.Channel = ADC_CHANNEL_1;
sConfig.Rank = 2;
sConfig.SamplingTime = ADC_SAMPLETIME_3CYCLES;
HAL_ADC_ConfigChannel(&hadc, &sConfig);
// 配置通道2:温度检测(PA2)
sConfig.Channel = ADC_CHANNEL_2;
sConfig.Rank = 3;
sConfig.SamplingTime = ADC_SAMPLETIME_7CYCLES; // 温度信号慢,用长采样
HAL_ADC_ConfigChannel(&hadc, &sConfig);
HAL_ADC_Start(&hadc);
}
// 读取ADC值(轮询方式)
uint16_t ADC_ReadChannel(uint32_t channel)
{
HAL_ADC_Start(&hadc);
if (HAL_ADC_PollForConversion(&hadc, 10) == HAL_OK)
{
return HAL_ADC_GetValue(&hadc);
}
return 0;
}
避坑指南:ADC采样值一定要做数字滤波。我常用的方法是“中值平均滤波”——连续采5个值,去掉最大最小,剩下3个取平均。这样既能滤掉毛刺,又不会像单纯平均那样平滑过度。
4.3 DMA数据传输:让CPU从搬运工变成指挥官
你想想看,如果每次ADC转换完都要CPU去读寄存器,那CPU大部分时间都在干苦力。DMA就是来解决这个问题的——它负责把ADC的数据自动搬到内存里,CPU只管处理数据就好。
我记得第一次用DMA的时候,觉得这东西太神奇了。ADC每转换完一次,DMA就自动把数据搬走,CPU完全不用管。后来发现DMA的配置其实有很多门道,尤其是循环模式和中断配合。
DMA的核心配置参数:
| 参数 | 说明 |
|---|---|
| 传输方向 | 外设到内存(ADC→RAM)、内存到外设、内存到内存 |
| 传输模式 | 普通模式(传完停止)、循环模式(一直传) |
| 数据宽度 | 字节、半字(16位)、字(32位) |
| 优先级 | 低、中、高、最高(多个DMA通道时注意) |
来看一个ADC+DMA联动的完整示例:
// 定义DMA缓冲区
#define ADC_BUFFER_SIZE 3
uint16_t adc_buffer[ADC_BUFFER_SIZE] = {0};
// ADC + DMA初始化
void ADC_DMA_Init(void)
{
DMA_HandleTypeDef hdma_adc;
// 使能DMA时钟
__HAL_RCC_DMA1_CLK_ENABLE();
// 配置DMA
hdma_adc.Instance = DMA1_Channel1;
hdma_adc.Init.Direction = DMA_PERIPH_TO_MEMORY;
hdma_adc.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不变
hdma_adc.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增
hdma_adc.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;
hdma_adc.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD;
hdma_adc.Init.Mode = DMA_CIRCULAR; // 循环模式,一直采集
hdma_adc.Init.Priority = DMA_PRIORITY_HIGH;
HAL_DMA_Init(&hdma_adc);
// 将DMA与ADC关联
__HAL_LINKDMA(&hadc, DMA_Handle, hdma_adc);
// 启动ADC的DMA传输
HAL_ADC_Start_DMA(&hadc, (uint32_t*)adc_buffer, ADC_BUFFER_SIZE);
}
// DMA传输完成中断回调
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc)
{
// 数据已经在adc_buffer中,直接使用
// adc_buffer[0] = 电压值
// adc_buffer[1] = 电流值
// adc_buffer[2] = 温度值
// 这里可以做数据处理,比如计算功率、判断过流等
ProcessADCData(adc_buffer);
}
我曾经踩过的坑:DMA的循环模式一定要配合缓冲区大小来设计。如果缓冲区只有3个元素,但ADC有3个通道,那每次转换完刚好填满。但如果通道数变了,缓冲区大小没改,数据就会错位。我建议缓冲区大小设为通道数的整数倍,比如3通道就用6或9,方便做滑动窗口滤波。
4.4 三者联动:一个完整的充电桩控制场景
好了,现在我们把PWM、ADC、DMA串起来,看看实际充电桩里它们是怎么配合的。
场景是这样的:
- 用户插枪,电子锁PWM启动,软启动到100%占空比,锁住枪头
- ADC开始采集电压电流,DMA自动把数据搬到内存
- CPU每10ms检查一次ADC数据,判断是否过压、过流、漏电
- 如果一切正常,继续充电;如果有异常,PWM立刻输出0%,解锁电子锁
这个流程里,DMA保证了ADC数据不会丢失,PWM保证了电子锁的平滑控制,ADC提供了决策依据。三者缺一不可。
我的建议:在实际项目中,最好把ADC的DMA传输完成中断优先级设高一点。因为充电桩的故障保护需要快速响应,如果中断被其他任务阻塞,可能就来不及切断电源了。
嗯,这一章的内容就到这里。PWM、ADC、DMA这三个外设,说白了就是充电桩固件的“手、眼、脚”——PWM是手,控制执行机构;ADC是眼,感知外部状态;DMA是脚,高效搬运数据。下一章我们会讲充电协议栈的实现,那才是真正考验固件架构设计能力的地方。