交流充电桩嵌入式代码设计:STM32状态机与PWM控制详解

交流充电桩嵌入式代码设计:STM32状态机与PWM控制详解 简介基于STM32F10x的电动汽车交流充电桩完整源码工程面向嵌入式开发者和充电桩方案设计人员聚焦STM32单片机在充电桩主控中的实际应用重点解决充电电流电压控制、与电动汽车通信等核心问题。压缩包共129个文件以49个h头文件与46个c源码文件为主体同时包含hex固件、uvprojx工程文件及zbak备份文件整体仅478KB结构清晰便于直接移植和二次开发。目前已有57人在CSDN学习下载适合需要参考充电桩实现细节的中级嵌入式开发者。源码覆盖充电电流电压控制、通信交互等关键模块集成STM32标准外设库以及LCD、ADC、FSMC等外设驱动并附带可直接烧录的hex文件方便动手验证完整充电流程工程内备份文件较多可对比分析不同调试阶段。整体控制逻辑兼顾了充电过程的安全性与稳定性有助于理解供电、控制、通讯三大部分如何协同是一份难得的实战参考工程。1. 电动汽车交流充电桩的代码写什么从继电器控制到充电状态机交流充电桩的控制核心比想象中薄但代码难点却不在“控制”而在“握手”。一辆车插上充电枪之后车端会通过充电枪的CP控制导引引脚改变电压桩端需要实时检测CC/CP电阻、输出1kHz的PWM占空比并据此调整继电器闭合同时还要监听S2开关状态和漏电流信号。如果只写一个“检测到按键就吸合继电器”的程序那大概率充满电不自动停、拔枪拉弧、甚至绝缘异常时继续输出。真正的产品级代码是一套有限状态机配合ADC采样、定时器PWM、DMA、通信协议和看门狗才能在车、桩、电网三者之间不抢跑不漏判。需要明确的是这套代码并不复杂到需要给单片机跑RTOS多数成熟方案就是用STM32F103、STM32G431这类单核MCU裸机跑一个5ms时基的主循环。下面按“外设分配 → 状态机构建 → 通信 → 调试验证”的顺序把交流充电桩最小可用的代码骨架拆开讲一遍。2. 引脚和片内外设规划交流充电桩里的GPIO、ADC、PWM和DMA2.1 交流充电桩哪些引脚碰不得CC和CP接口的电气约束在配置STM32的GPIO前先想清楚充电桩与车之间是有线电连接不是单纯按钮。CP引脚在停发PWM时是12V直流车辆连接后会被拉低到9V、6V甚至3V用来表达连接状态和充电准备状态CC引脚通过电阻编码来标识线缆容量常见阻值有680Ω10A、220Ω16A、100Ω32A。GPIO不能直接接这些信号因为它们是模拟电平CP还需要用定时器输入捕获或ADC间接测量不能当成普通按键的开关量。对于STM32建议这样分配PA8或PB13等带TIM1互补输出的通道用于产生1kHz的CP PWM。一个定时器输入捕获通道接CP信号用于回读占空比诊断PWM输出是否正常。两个ADC通道一个采集CP电压一个采集CC电压。继电器控制脚选一个推挽输出的GPIO且必须串联RC电路防误动作。下面这张表格列出我在空板项目里常用的资源对应关系实际板上会有差异但思路一致。功能推荐外设配置要点说明1kHz CP PWMTIM1_CH1 或 TIM3_CH1PSC71ARR719频率1kHz占空比由车辆请求决定回读CP占空比TIM3_CH2 输入捕获上升沿和下降沿分别捕获判断PWM输出是否被外部异常拉死CP电压检测ADC1_IN0采样时间拉到239.5周期软件滤波判断状态A/B/C/DCC电阻检测ADC1_IN1用分压电阻把0-12V映射到0-3.3V换算线缆容量继电器控制GPIO Push-Pull高有效并串联RC吸合时先发PWM再闭锁漏电/绝缘故障EXTI下降沿外部中断置故障标志立即断开并进入Error状态提示CP信号线在车端和桩端各有上拉电阻测量时必须用二极管隔离或高阻分压否则ADC引脚直接参与直流回路轻则采样跳变重则把MCU引脚灌坏。2.2 用STM32CubeMX生成工程避免每次重写启动文件交流桩这类项目通常用STM32CubeMX生成初始化代码再在用户代码段追加状态机。生成时要留意三个选项在SYS里选Timebase Source为TIM6或TIM7别把SysTick占用方便后面做软件延时和看门狗。ADC配置选扫描连续模式用DMA搬数因为主循环还要跑Modbus和状态机不能阻塞在转换上。PWM输出频率配置为1000Hz先不要开主输出等状态机判定成功后使能。代码生成后在main.c里加几个全局变量。使用HAL库初始化时我们习惯这样写/* USER CODE BEGIN PV */ volatile uint16_t adc_buffer[2]; // 0CP电压1CC电压 volatile uint8_t upwm_duty; // 目标占空比单位0.1% volatile uint8_t actual_duty; // 当前输出的占空比 /* USER CODE END PV */这种写法避开了在中断回调里声明全局变量导致的编译器优化问题。volatile是必须的因为ADC DMA会随时写入缓冲主循环读的时候不能命中寄存器缓存。如果Keil5里还没有对应芯片的设备包编译时会报“没有该型号”先在Pack Installer里把STM32F1或STM32G4的DFP装好再回来编译工程。2.3 PWM参数的数值换算为什么ARR是719而不是1000要给一块72MHz主频的STM32F103产生1kHz信号PSC设为71、ARR设为719实际频率是72MHz / 72 / 720 1000Hz。如果主频是64MHz的G431PSC63ARR639。占空比只需要改CCR1但要注意CCR的值要和ARR1做比例换算不能把ARR当成720直接覆盖成720否则会出现轻微周期抖动。推荐用一个宏表示PWM周期点数#define PWM_PERIOD_CYCLES 720 // 等于 ARR1 void set_cp_duty(uint8_t duty_percent) { __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, (PWM_PERIOD_CYCLES * duty_percent) / 100); }这里的duty_percent是整数例如26表示26%占空比。调用后CCR大概是187输出26%占空比。交流充电桩里占空比决定车辆允许的最大充电电流10%-96%之间才有意义。具体电流换算要跟着GB/T 18487.1的表格走不能自己发明线性公式因为10%以下和85%以上是特殊状态用于其他控制指令。注意没有车辆连接时桩端应输出无PWM的12V直流检测到状态B后延时2秒再发PWM给车辆控制器上电时间。3. 状态机设计交流充电桩充电流程从空闲到充满的每一帧代码3.1 状态定义用枚举把充电过程显式化软件工程师拿到功能需求后第一件事就是列状态表。交流桩的典型状态有STANDBY待机继电器断开无PWM。CONNECTED车辆已插枪CC电阻接入但CP电压还是12V说明S2未闭合。READYCP电压降到9V左右车辆请求充电桩发PWM但未合继电器。CHARGINGCP电压进入6V区间继电器吸合正在充电。FAULT故障锁定需要人工复位或重新插拔。车端可能在中途按下S2开关停止充电所以状态机不能只靠上升沿需要在主循环里周期扫描。我常用一个5ms时基每隔15ms采样一次CP/CC3次采样一致才变更状态避免触点抖动导致误判。3.2 switch-case状态机主体下面这段是简化版的主状态机。它不依赖RTOS主循环每5ms调用一次。typedef enum { ST_IDLE 0, ST_CONNECTED, ST_READY, ST_CHARGING, ST_FAULT } charge_state_t; charge_state_t current_state ST_IDLE; void charge_state_machine(uint16_t cp_mv, uint16_t cc_mv, uint8_t fault_flag) { switch (current_state) { case ST_IDLE: relay_off(); stop_pwm_output(); if (cc_mv 800 cc_mv 3000) { // CC电阻接入 current_state ST_CONNECTED; log_state(plugged); } break; case ST_CONNECTED: if (fault_flag) { current_state ST_FAULT; break; } if (cp_mv 8000 cp_mv 10000) { // 状态BS2闭合 delay_ms(50); stop_pwm_output(); // 先不发PWM current_state ST_READY; } break; case ST_READY: if (cp_mv 5000 cp_mv 7000) { // 状态C车辆准备就绪 relay_on(); start_pwm_output(CP_DUTY_16A); current_state ST_CHARGING; } else if (cp_mv 10000) { // 回到状态A拔枪或断开 stop_pwm_output(); current_state ST_IDLE; } break; case ST_CHARGING: if (fault_flag) { relay_off(); stop_pwm_output(); current_state ST_FAULT; } else if (cp_mv 10000) { // 拔枪 relay_off(); stop_pwm_output(); current_state ST_IDLE; } break; case ST_FAULT: relay_off(); stop_pwm_output(); if (user_ack()) { current_state ST_IDLE; } break; } }这段代码的逻辑顺序是有考量的先处理故障再处理拔枪最后才是正常迁移否则一个正在充电的过程中突然掉电程序会误判为已充满。实际项目中start_pwm_output()里不是立即把占空比拉满而是每50ms步进5%让车端预充电过程平缓一些。3.3 CP电压采样与滤波不能直接拿ADC值做判断CP引脚在直流上叠加了1kHz的PWM如果直接用ADC连续读数值会在高低电平之间跳动。通常做法是用ADC以2ms周期采样连续采16次去掉最大最小值后取平均得到一个相对稳定的直流分量。再通过定时器输入捕获测量PWM的占空比两个信息配合判断车端状态。uint16_t get_filtered_adc(ADC_HandleTypeDef *hadc, uint32_t channel) { uint32_t sum 0; uint16_t min_v 4095, max_v 0; for (int i 0; i 16; i) { uint16_t val read_adc_channel(hadc, channel); if (val min_v) min_v val; if (val max_v) max_v val; sum val; } return (uint16_t)((sum - min_v - max_v) / 14); }实际产品里这个函数不能放在主循环里做因为16次采样要花30ms左右。更合适的做法是把滤波放到定时器中断中断里只填一个原始值到环形缓冲区主循环读滤波结果。判定阈值也要留回滞区间进入状态C用5000-7000mV离开状态C用大于8000mV防止临界电压抖动。3.4 PWM生成与启停顺序继电器和PWM谁先动交流充电桩的继电器吸合不能直接控制一个空载继电器。车辆端在状态B/C/D之间变换时桩端的继电器动作点应该放在车端接触器闭合之后。推荐的动作顺序是检测到CC和CP满足条件后在READY状态输出PWM让车辆的充电控制单元收到占空比。延时20-50ms等待车端预充电接触器闭合。再吸合站内继电器。停止充电时先断继电器再停止PWM输出。void start_pwm_output(uint16_t duty) { __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, duty); __HAL_TIM_ENABLE(htim1); HAL_GPIO_WritePin(PWM_EN_GPIO_Port, PWM_EN_Pin, GPIO_PIN_SET); } void stop_pwm_output(void) { HAL_GPIO_WritePin(PWM_EN_GPIO_Port, PWM_EN_Pin, GPIO_PIN_RESET); __HAL_TIM_DISABLE(htim1); }PWM_EN是外部驱动芯片的使能引脚防止寄存器被误改后直接输出异常电平。如果只有定时器输出没有外部使能也可以把CCR寄存器先清零再关闭定时器两种方式都要保证在REM位关闭后GPIO输出固定低电平。4. Modbus与CAN通信充电桩上报数据和读取电表电量4.1 用Modbus-RTU读电能表帧结构、CRC16和超时处理很多交流桩里的单相电表支持Modbus-RTU。读电表累计电量的功能码是0x04起始寄存器地址和长度因表而异但单相电表的电量寄存器一般占两个字。具体代码要注意两点一是波特率常见9600或115200二是CRC16计算不能放在串口中断里否则中断时间太长会丢字节。下面给出常见的Modbus读寄存器函数uint16_t crc16_modbus(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ data[i]; for (uint8_t j 0; j 8; j) { if (crc 1) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; } void modbus_read_registers(uint8_t slave_id, uint16_t start_reg, uint16_t count) { uint8_t frame[8]; frame[0] slave_id; frame[1] 0x04; frame[2] (uint8_t)(start_reg 8); frame[3] (uint8_t)(start_reg 0xFF); frame[4] (uint8_t)(count 8); frame[5] (uint8_t)(count 0xFF); uint16_t crc crc16_modbus(frame, 6); frame[6] (uint8_t)(crc 0xFF); frame[7] (uint8_t)(crc 8); HAL_UART_Transmit(huart2, frame, 8, 100); }这个函数把请求帧发出去后接下来由串口空闲中断接收响应。注意不要在单片机里用阻塞式等待200ms的方式收数据那样会漏掉最多3帧最好用DMA空闲中断做不定长接收。接收完要重新计算CRC并和帧尾比对同时检查响应的功能码是否带0x80错误位。如果电表地址配置错了帧发出去后不会收到应答这时状态机要能连续读错3次后降级为“无计量”模式不能因为电表故障卡死整个充电。4.2 与后台通信CAN帧结构参考和ByteOrder有后台的充电桩通常走CAN 2.0A波特率250kbps或500kbps。CAN数据里状态上报和心跳帧使用周期发送充电中每2秒一帧空闲每10秒一帧。帧ID如果厂商没规定我一般用0x181、0x281、0x381。定义结构体时注意CAN总线是大端字节序STM32侧需要手动转换。typedef struct { uint32_t state; // 1空闲, 2充电中, 3故障 uint32_t current; // 单位0.1A uint32_t voltage; // 单位0.1V uint32_t power; // 单位0.1W uint32_t soc; // 0-100 } charge_report_t; void pack_report_to_can(uint8_t data[8], charge_report_t *rpt) { uint8_t *p data; *p (rpt-state 4) | ((rpt-current 8) 0x0F); *p rpt-current 0xFF; *p rpt-voltage 0xFF; *p rpt-power 0xFF; *p rpt-soc 0xFF; *p 0x00; *p 0x00; *p 0x00; }CAN发送要处理总线关闭状态。特别是低速或总线忙时HAL_CAN_AddTxMessage返回HAL_BUSY不能简单丢弃。重试次数超过3次就进入FAULT并记录故障码。另外如果充电桩同时挂了蓝牙和4G模块通常CAN只用于车桩直连蓝牙走UART透传不要把两路数据混在同一根线缆上否则调试排障会非常痛苦。4.3 参数掉电存储EEPROM还是Flash充电桩需要保存设备地址、费率、最大充电电流等参数。小容量产品用AT24C02外部EEPROM比较省事在STM32G4系列上也可以用内部Flash模拟EEPROM。区别在于AT24C02写一页要5ms而内部Flash擦除需要先整页擦。建议频率低、要移植的配置项用I2C EEPROM变化频繁的计量值不要直接写Flash而是充电结束或者掉电前再写入。下面是一段简化的参数写入封装typedef struct { uint16_t magic; // 0x5A5A uint16_t max_current; uint16_t slave_id; } param_block_t; void param_save(param_block_t *p) { HAL_FLASH_Unlock(); FLASH_EraseInitTypeDef erase { .TypeErase FLASH_TYPEERASE_PAGES, .PageAddress PARA_PAGE_ADDR, .NbPages 1 }; uint32_t page_err 0; HAL_FLASHEx_Erase(erase, page_err); uint32_t *words (uint32_t *)p; for (int i 0; i sizeof(param_block_t) / 4; i) { HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, PARA_PAGE_ADDR i * 4, words[i]); } HAL_FLASH_Lock(); }写入前要注意看PARA_PAGE_ADDR是不是足够对齐Flash写入地址必须是4字节对齐。如果中途掉电参数区会变成0xFF所以读取时要先检查magic字段不对就直接用默认参数并重新写一遍默认值。这样至少能保证设备上电后处于可运行状态而不是卡死在配置恢复流程里。5. 调试交流充电桩代码逻辑分析仪抓PWM数据用串口看5.1 最小验证电路没有真车或没有测试负载时可以用一个68kΩ电位器模拟CC电阻用两个固定电阻分压模拟CP电压。接法很直接把PA8的PWM输出串一个1kΩ电阻接到GND这时PWM占空比可以用万用表频率档或逻辑分析仪测量。确认仪器入口带宽至少1MHz淘宝上常见的8通道24MHz逻辑分析仪就够用。抓波形时要看两个点频率是否为1kHz占空比是否稳定在设定值每个上升沿之间间隔是否一致。5.2 常见失败模式与对照表现象原因处理车辆不进入充电状态未发PWM或占空比不在10%-96%内用示波器量PA8同时看CC/CP电压换算充电20秒后停止Modbus读不到电表数据或CRC校验失败查看串口打印的请求帧和应答帧拔枪后继电器不释放CP状态卡在CHARGING检查CP电压是否回到12V增加3次滤波上电即故障初始继电器触点和漏电流传感器干扰加100ms延时后再启动ADC和PWM5.3 用串口打印状态机和参数不用等仿真器嵌入式调试比JTAG快很多的方法是把当前状态用串口打印出来。在main循环里每500ms打印一行CSV格式的数据printf(t%d,cp%d,cc%d,st%d,pwm%d,relay%d\n, HAL_GetTick(), filtered_cp_mv, filtered_cc_mv, (int)current_state, actual_duty, relay_status);在Keil5里勾选MicroLIB后直接重定向fputc即可。打印的数值要和外部万用表读数对比尤其是CP电压如果差了超过200mV就要查分压电阻本身的精度。逻辑分析仪测到的占空比和代码里配置的差异在1%以内都是正常超过1%就要查定时器时钟树配置是否用了PLL。提示调试阶段不要把PWM输出直接连到继电器的驱动端先用LED串1kΩ电阻做状态指示避免代码误操作把大功率交流负载接通。建议在产品验证阶段做10万次插拔测试观察状态机在最极端时序下会不会卡死。本文还有配套的精品资源点击获取