第四章 核心外设驱动开发(下):定时器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串起来,看看实际充电桩里它们是怎么配合的。

场景是这样的:

  1. 用户插枪,电子锁PWM启动,软启动到100%占空比,锁住枪头
  2. ADC开始采集电压电流,DMA自动把数据搬到内存
  3. CPU每10ms检查一次ADC数据,判断是否过压、过流、漏电
  4. 如果一切正常,继续充电;如果有异常,PWM立刻输出0%,解锁电子锁

这个流程里,DMA保证了ADC数据不会丢失,PWM保证了电子锁的平滑控制,ADC提供了决策依据。三者缺一不可。

我的建议:在实际项目中,最好把ADC的DMA传输完成中断优先级设高一点。因为充电桩的故障保护需要快速响应,如果中断被其他任务阻塞,可能就来不及切断电源了。

嗯,这一章的内容就到这里。PWM、ADC、DMA这三个外设,说白了就是充电桩固件的“手、眼、脚”——PWM是手,控制执行机构;ADC是眼,感知外部状态;DMA是脚,高效搬运数据。下一章我们会讲充电协议栈的实现,那才是真正考验固件架构设计能力的地方。