EMS 3.6 固件解剖
架构拆解 + 功能案例讲解

AlphaESS 平台 EMS 3.6 储能固件 从七层目录结构、uCOS-II 任务模型,到四个真实功能的完整数据链路

这是一份面向工程与售后的通读材料:把一台储能主控的固件拆成「入口 → 调度 → 设备 → 通信」四段, 再用削峰填谷、电池加热、并机控制、故障码聚合四个功能,把代码怎么跑起来讲清楚。 所有函数名与行号都来自明文源码 platform-ems-3-6-plain/,可逐条核对。

产品
AlphaESS 平台 EMS 3.6
储能能量管理系统主控
固件版本
V0.11.676
git 04e72700
主控芯片
GD32F407ZG
Cortex-M4
操作系统
uCOS-II(抢占式)
OS_TICKS_PER_SEC = 1000
工程 / 机型
Keil MDK · Target EMS_3_0
6 家逆变器厂商协议
应用层规模
191 份 .c/.h 解析
17 个 RTOS 任务

0先建一张地图

这份固件的「一层一事」非常清晰:user/ 只负责把任务拉起来, logic/ 是唯一的业务大脑,midware/ 只跟设备打交道翻译协议, net/ 只管跟云端说话。读代码时只要先判断 「我关心的这件事,属于哪一层」,绝大多数问题都能定位到两三个文件之内。

一次完整的控制闭环可以概括成下面这句话,后文所有案例都是这句话的实例化:

一句话概括这台 EMS 在干什么

电表 / CT / BMS / 逆变器把实时量送到内存里的几张全局表 → logic/ 按当前运行模式算出「目标功率」→ 经过安规限值、并机分配、云端调度这几道约束钳位 → 再由 midware/inverter/ 按各家厂商协议组帧,写进逆变器。

1整体分层:七个目录,一条数据流

工程根目录下的七个模块目录,正好对应固件的六个语义层。下图把目录、职责与依赖方向画在一起。

图 1 · 模块分层与调用方向(自上而下为「上层调用下层」)
应用层 业务层 数据层 设备层 通信 / 交互层 基础层 user/ 主程序入口、17 个任务的静态创建、版本信息 main.c(uCOS-II 启动 · 信号量 / 标志组初始化) · version.c logic/ 能量调度核心(业务大脑) logic.c 总调度 · ac_control.c · dc_control.c · dispatch.c · handle_priority.c · generator.c · csip_aus.c · self_test.c data/ 数据与持久化:故障码聚合、系统配置、断电续传、日志 data.c(EMS 位图) · system_config/(Flash 读写) · resume_data/ · fault_warn_log/ · net_event_log/ midware/ 设备中间件:协议与设备适配(全工程最大的一块) 逆变器 inverter/ 电表 / CT meter/ BMS 电池 bms/ 充电桩 evc/ AFCI 电弧 afci/ 接口 / 协议 interface/ 以太网 / 无线 ethernt/ 另有 auxiliary/ (电量积分 · RTC · 设备功能表)与 debug/ (ATE · 板级测试 · USMART) 逆变器子目录: alphaess/ ginlong/ goodwe/ inovance/ kelong/ set/ parallel_comm/ net/ 网络应用层 · 与云端 EMS 服务器 ems.c · server.c · ems_boot_handle.c · *_download.c hmi/ 人机界面 · LCD / LED / 按键 lcd.c · led.c · key.c · led/(应用层灯效与配置) common/ 通用工具:加密与校验、数据格式转换、固件升级文件解析、调试 shell aes/ md5/ simple_encryption/ · json.c crc.c convert.c · hex_file_parse.c s19.c · usmart/ 硬件与 RTOS 底层(本资料覆盖边界之外,仅作参照) GD32F407ZG · UCOSII/ 内核 · HALLIB/ 标准外设库 · hardware/ · system/
依赖方向单向向下:上层调用下层,下层通过全局数据表反向暴露数据。

读代码的实用判据

  • 要改控制策略 → 只动 logic/,不要碰 midware/。
  • 要加一家逆变器厂商 → 只加 midware/inverter/ 下一个子目录,并让 inverter.c 门面认识它。
  • 要改上报内容或周期 → 看 net/ems.c 与 server.c。
  • 要查为什么某条数据是 0 → 先看谁 memset 了那张表,再看对应任务的通信丢失分支。

2uCOS-II 任务模型:17 个任务怎么分工

全部任务在 user/main.c 的 start_task() 里用 OSTaskCreateExt() 静态创建。优先级数字越小越高(uCOS-II 约定), 优先级与栈尺寸的宏集中定义在 UCOSII/uCOS-CONFIG/app_cfg.h。

两份「周期」不完全一致,本表以源码为准

app_cfg.h 里每个优先级定义后面带一句注释周期,知识库 README 的任务表也沿用了它; 但任务循环尾部的 OSTimeDlyHMSM() 实参在多处与之不同。 下表 周期列 = 我从源码循环尾部实际读到的值,与注释不同的地方在括号里标出注释值。

表 1 · 17 个任务(优先级与栈尺寸来自 app_cfg.h:58-100,周期来自各任务循环尾部)
任务函数 优先级 栈
OS_STK 条目
周期 / 触发 职责
task_system_data7512 ≈500 ms
data/data.c:263
故障码聚合、各类复位
task_system_aux8512 400 ms
system_aux.c:2092
电量积分统计、RTC、看门狗喂狗
task_meter9256 300 ms
meter/meter.c:5881
电表 / CT / BackupBox 采样
task_inverter10512 80 ms(注释写 100)
inverter/inverter.c:1188
逆变器 SCI 通道收发包
task_bms11256 100 ms
bms/bms.c:2933
电池 CAN 通信、加热控制
task_system_logic_control12512 280 ms(注释写 300)
logic/logic.c:2957
能量调度核心计算
task_interface_parse13512 500 ms
interface/interface.c:230
干接点 / DRM / 接口解析
task_ethernet_monitor14256 500 ms(注释写 1000)
ethernt/ethernet.c:1554
网络链路监控(无线 / 网络异常检测)
task_inverter_can15256 30 ms(注释写 50)
inverter/inverter.c:1285
逆变器 CAN 通道 + 并机
task_net_app16512 25 ms(注释写 30)
net/ems.c:7286
云端通信业务
task_ethernet171024 未核实
ethernt/ethernet.c:986-1526
以太网收发(W5500 socket 状态机)
task_sampling18256 ADC-DMA 信号量
meter/ct.c:883
模拟量采样(CT)
task_modbus_rtu19256 UART 信号量
interface/interface.c:364
Modbus RTU 从站
task_parallel_sci20256 100 ms
inverter/inverter.c:1305
并机 SCI 通道
task_hmi_parse21512 10 ms
hmi/key.c:8964
按键 / LCD / LED
task_usmart22512 100 ms
debug/shell_usmart.c:62
调试 shell(受 DEBUG_SUMART 门控)
start_task30256 一次
user/main.c:358
创建上述任务后自挂起

栈尺寸的单位是 OS_STK 条目数(app_cfg.h 原文注释 “Size of the task stacks (# of OS_STK entries)”), 不是字节数——4 字节一条,所以 512 实际是 2 KB 栈空间。

2.1任务之间怎么同步

同步原语在 user/main.c 的 event_init() 里一次性创建:

一次握手在 task_inverter 里长这样(inverter.c:1182-1188): 等到逻辑侧发来 PEND_LOGIC_CALCULATE_DONE 后,立刻回一个 POST_INV_CALCULATE_DONE,并 OSTimeDlyResume(TASK_INVERTER_CAN_PRIO) 把 CAN 任务唤醒去取下发值。这就是「逻辑算完 → 逆变器马上发」的低延迟通道。

3控制闭环:从采样到厂商协议组帧

下面这张图是全篇的核心。四个案例都只是把这张图里某一段放大来看。

图 2 · 五段式控制闭环
① 采样 电表 / CT / BMS / 逆变器 Modbus · CAN · ADC-DMA task_meter / task_bms task_sampling / task_inverter ② 缓存到全局数据表 g_t_inv_info · g_t_meter g_t_bat_info · g_t_ct g_t_ems_info · g_t_energy_info 各任务直接读写,无互斥量 ③ logic/ 算目标功率 按当前运行模式选算法 logic_sys_running_info_handle → 序控模式优先级链 g_t_logic_info.s32_ac_total_set_* ④ 约束层钳位 安规限值(G100 / §14a / CSIP) 并机功率分配 · 调度指令 电池充放限值 / 视在功率限幅 logic_charge_dischg_power_limit ⑤ 厂商协议组帧下发 按机型走 6 家协议之一 CAN / Modbus-RTU alphaess · ginlong · goodwe inovance · kelong · set 闭环:下一个调度周期重新采样,用实测值修正上一轮的目标功率 电表功率 / SOC / 电网状态发生变化时,③④ 会重新算一遍 数据单向向上汇聚,控制指令单向向下执行
图 2 中 ③④ 两步是「业务」与「约束」的分层:先按策略算出想要多少功率,再被安规与并机条件削到实际允许值。

3.1③ 的入口:一次调度周期干了什么

task_system_logic_control(logic/logic.c)每轮大致走三步:

  1. 汇总采集:logic_sys_running_info_handle() 把逆变器、电表、BMS、并机、充电桩的采样量 装进控制参数表 g_t_logic_info.t_logic_control_parameter。
  2. 选模式:按序控模式优先级链决定这一轮走哪个 case (logic.c:2300 附近的 switch),例如 TIME_PEAK_SHAVING_MODE、DISPATCH_MODE、FORCE_BAT_FEEDIN_MODE、 SELFUSE_MODE。logic_set_sequencecontrol_mode() 可以从外部强制覆盖当前模式。
  3. 算 + 限:执行该模式的算法写 s32_ac_total_set_inv_power / s32_dc_total_set_bat_power 等目标量,再统一过 logic_charge_dischg_power_limit() (充放电功率限值的总入口,内部按 DC 控 / AC 控条件编译分发)。

离网或故障时,由 other_logic_handle() 把 AC/DC 设定功率清零—— 这是「先安全、再经济」的体现。

4跨模块核心全局变量:真正的模块间接口

这台 EMS 没有用消息队列做模块解耦,而是靠 8 张全局结构体表。它们是阅读代码时最该先建立的坐标系。

表 2 · 八张核心全局表
变量定义位置说明
g_t_inv_info midware/inverter/inverter.c 逆变器总表:配置 / 运行 / 安规 / 控制 / 命令 / 参数 / 日志 / AFCI 八大子块
g_t_logic_info logic/logic.c 调度总表:配置表 + 三张扩展配置表 + 控制参数 + 各模式中间量。 削峰填谷的时段与阈值全在这里
g_t_bat_info midware/bms/bms.c 电池信息(SDRAM 定位,#pragma pack(1) 紧凑排布)。 加热状态机 e_bms_heat_FSM 就挂在这张表的 t_static_data 下
g_t_ems_info data/data.c EMS 故障位图 EMS_FAULT_U。故障码聚合案例的主角
g_t_system_base_info
(及 g_system_function_info、g_system_custom_info、g_system_msg_page_id)
data/system_config/system_config.c 系统基础参数 / 功能使能位 / 自定义参数 / 消息页号,全部走 Flash 持久化
g_t_energy_info midware/auxiliary/system_aux.c 电量统计数据(由 task_system_aux 积分维护)
g_t_sunspec_mudbus midware/interface/modbus/ Sunspec 寄存器映射表(783 项),对外提供标准 Modbus 视图
g_t_meter midware/meter/meter.c 电表 / CT 数据表(电网表、PV 表、MID 三相表各自的配置与运行表)

这张表同时也是一个风险清单

逆变器通信任务、CAN 任务、并机任务、net/server.c、hmi/key.c 都会直接读写上述结构体,全程没有互斥量或临界区保护;system_aux.c 里还存在整表 memset 重建的竞态。这是静态分析发现的最大一类共性风险(详见知识库 代码解析/README.md 第六节)。

5静态分析得出的几个共性坑

以下结论全部来自源码静态阅读,未做编译与板级验证,列在这里是为了让后文案例的「踩坑」有共同背景。

5.1编译期机型开关处于「全关闭」状态(影响面最大)

midware/inverter/inverter.h:172 定义 INVERTER_TYPE = INVERTER_NULL(0), 而所有厂商分支的条件写法都是 #if ((INVERTER_TYPE == INVERTER_MAX) || (INVERTER_TYPE == XXX_TYPE))。 按 EMS_3_0.uvprojx 里现有的宏(只有 USE_STDPERIPH_DRIVER, GD32F407)展开, 全部厂商分支与 INV_DC_CTRL / INV_AC_CTRL 都不参与编译。

对这一条的诚实态度

这显然不是实际出货行为(因为 inverter.c 里到处是 #if ... || (INVERTER_TYPE == INVERTER_MAX) 配运行时 get_inverter_type() 判断的写法)。 合理推断是实际量产工程用 -D 在别处覆盖了这个宏, 但 platform-ems-3-6/ 是加密二进制、不可读,没有办法验证。 所以这条只作为「读源码时的注意事项」记录:看到 #if 包住的功能时,先确认它在真实工程里到底编没编进去。

5.2其它几类问题(简要)

6第二部分:四个功能案例

四个案例分别代表固件的四种典型工作形态,覆盖了全篇的四个层次:

案例代表的工作形态主战场它回答的问题
① 削峰填谷 经济调度 —— 按时间表主动充放电 logic/logic.c + ac_control.c / dc_control.c 「什么时候充、什么时候放、放多少」由谁决定
② 电池加热 设备保护 —— 带故障处置的状态机 midware/bms/bms.c 「状态机怎么设计、失败后怎么办」
③ 并机控制 多机协同 —— 双通道组网与功率分配 midware/inverter/parallel*.c 「主机怎么分配、从机怎么执行、链路怎么保活」
④ 故障码聚合 故障体系 —— 从设备故障到屏幕上的 E 码 data/data.c + data/fault_warn_log/ 「现场报 E00007 到底发生了什么」

每个案例都按同一套结构讲:解决什么问题 → 数据怎么流 → 关键函数与行号 → 现场场景 → 容易踩的坑。

7功能案例(一)

01

削峰填谷(Peak Shaving / Valley Filling)

峰谷套利 · 按时间段主动充放电 · 抬升谷期负荷、压低峰期负荷

一句话:让电池在电价贵的时段多放一点、在电价便宜的时段多充一点,顺便不让变压器在峰期过载。

这个功能解决什么问题

家庭或工商业储能的经济性主要来自峰谷价差。但「什么时候算峰、什么时候算谷」是用户配的, 电压等级和用电曲线又各不相同,所以固件不能把「峰谷」写死成 8:00–20:00, 而是把一天切成 最多 2 段削峰窗口 + 2 段填谷窗口,全部由配置表驱动。 峰期的目标不是「放到底」,而是「把从电网取的功率压到一个上限以内」——这个上限就是核心参数 u16_peak_value。

数据怎么流进来 → 在哪算 → 怎么发出去

  1. 流进来 用户的时段与阈值从云端下发,落进 Flash 的调度配置表 g_t_logic_info.t_config_data(LOGIC_CONFIG_TAB_T,定义在 logic/logic.h)。 每段窗口记成「分钟数」,字段名形如 PeakStartTime1_minutes / PeakEndTime1_minutes / ValleyStartTime1_minutes / ValleyEndTime1_minutes(各两段)。
  2. 判断 task_system_logic_control 每轮调用 logic_control_by_time() logic/logic.c:1450, 在里面用 logic_comparing_time_periods() logic/logic.c:1386 把「当前星期 + 当前分钟数」逐段比对。 只有使能位 u8_peak_shaving_en 为真时才做这段判断 logic/logic.c:1507。
  3. 落中间量 比对结果写成两个总表里的标志: u8_time_peak_clip_flag(现在处于削峰窗口)与 u8_time_valley_fill_flag(现在处于填谷窗口); 同时把阈值搬进来:s32_set_peak_value = u16_peak_value, 以及放电截止 SOC u8_peakshving_discharge_cut_soc logic/logic.c:1515-1546。
  4. 选模式 模式优先级链命中 case TIME_PEAK_SHAVING_MODE:,调用 time_peakshaving_command() logic/logic.c:2326 → 550。 这个函数只在两个标志上做分发,并用条件编译区分 AC 控 / DC 控机型。
  5. 算目标功率 交流耦合(AC 控):ac_ctrl_time_peak_clip_command() logic/ac_control.c:1674 → time_peak_clip_calculate() logic/ac_control.c:728。 填谷侧:ac_ctrl_time_valley_fill_command() logic/ac_control.c:1723 → time_valley_fill_calculate() logic/ac_control.c:749。
    直流耦合(DC 控):dc_ctrl_time_peak_clip_command() logic/dc_control.c:1987 → dc_time_peak_clip_calculate() logic/dc_control.c:1963; dc_ctrl_time_valley_fill_command() logic/dc_control.c:1951 → dc_time_valley_fill_calculate() logic/dc_control.c:1869。
  6. 限幅下发 time_peakshaving_command() 末尾统一调用 logic_charge_dischg_power_limit(),把目标值钳进电池充放能力与安规限值, 最终落到 s32_ac_total_set_inv_power / s32_dc_total_set_bat_power, 由 midware/inverter/ 按厂商协议组帧发给逆变器。
图 3 · 峰谷时段与功率动作(示意,非实测曲线)
填谷窗口 ValleyStartTime / ValleyEndTime 动作:充电(电表功率被抬到谷值) time_valley_fill_calculate() 削峰窗口 PeakStartTime / PeakEndTime 动作:放电(把取电压到上限内) time_peak_clip_calculate() 两个窗口之外 不在削峰也不在填谷窗口 → 两个标志都为 FALSE 模式链落到其它模式(如自发自用 / 调度 / 强充强放) 00:00 08:00 18:00 24:00 削峰上限 s32_set_peak_value = u16_peak_value 回差(死区)s32_set_delta_value:低于「上限 − 回差」才允许重新启动调节,避免在阈值附近来回抖 谷期:电池充电,等效抬升负荷 峰期:电池放电,把取电压到上限之下 蓝线 = 从电网取的功率(示意)
时段只是一个门;真正的功率值由「实测取电功率与上限的差值」实时算出来,因此不同负荷下放电量也不同。

关键函数与行号

作用函数 / 符号位置
模式分派(进入削峰填谷模式)case TIME_PEAK_SHAVING_MODElogic/logic.c:2325
削峰填谷命令总入口(只在两个标志上分发)time_peakshaving_command()logic/logic.c:550
时段总判断 + 中间量落地logic_control_by_time()logic/logic.c:1450
「当前星期 + 分钟数」是否落在某段窗口内(支持跨天)logic_comparing_time_periods()logic/logic.c:1386
削峰填谷使能判据(总开关)u8_peak_shaving_enlogic/logic.c:1507
AC 侧削峰状态机 + 计算ac_ctrl_time_peak_clip_command()
time_peak_clip_calculate()
logic/ac_control.c:1674
logic/ac_control.c:728
AC 侧填谷ac_ctrl_time_valley_fill_command()
time_valley_fill_calculate()
logic/ac_control.c:1723
logic/ac_control.c:749
DC 侧削峰 / 填谷dc_ctrl_time_peak_clip_command() / dc_time_peak_clip_calculate()
dc_ctrl_time_valley_fill_command() / dc_time_valley_fill_calculate()
logic/dc_control.c:1987 / :1963
logic/dc_control.c:1951 / :1869
读放电截止 SOClogic_read_peakshving_discharge_cut_soc()logic/logic.c:3077
充放电功率限值总入口logic_charge_dischg_power_limit()logic/logic.c(由 time_peakshaving_command() 末尾调用)

它到底是怎么算的(AC 侧削峰)

time_peak_clip_calculate() logic/ac_control.c:728 只有两个分支加一个空分支, 核心是一句话:用「实测取电功率」减去「目标上限」,差多少就补多少放电。

  • 超上限了:s32_pgrid_metert ≥ s32_set_peak_value 时, s32_ac_total_set_inv_power = s32_pinvt + |s32_pgrid_metert − s32_set_peak_value| —— 在「当前逆变功率」的基础上补上超出量,把取电压回上限。
  • 明显低于上限了:s32_pgrid_metert ≤ s32_set_peak_value − s32_set_delta_value 时, 反向减掉一个量,且这个量要再除以 u8_regulator_speed(调节速率档位) 做渐进回收,避免功率阶跃。
  • 中间那段什么都不做:落在「上限 − 回差」与「上限」之间时保持原样。 这就是回差(死区),防止系统在阈值附近来回抖。

外层还有一层状态机抖动抑制:s_u8_peak_status / s_u8_peak_old_status (ac_control.c:1676-1677)。低于上限时先切到 NO_INVERTER_POWER_STATE 置 0; 但若上一轮还在调节态(POWER_REGULATION_STATE),就保持调节态继续算, 算完再判断「补完之后是否已经低于上限」,是则当轮又切回置 0。这样阈值附近不会出现 「调一下停一下」的振荡。DC 侧 dc_ctrl_time_peak_clip_command() 逻辑完全对称, 只是目标量换成 s32_dc_total_set_bat_power,并且放电量额外乘了 1.1 的裕度系数 logic/dc_control.c:1968。

填谷为什么先「改写电表功率」

time_valley_fill_calculate() logic/ac_control.c:749 的第一步不是直接给电池下充电功率, 而是就地改写逻辑表里的电表读数: s32_pgrid_metera/b/c 各减去 s32_set_valley_value / u8_inv_phase_num(三相平分),总量也同步减, 并把 s32_max_feedin_power 置 0 logic/ac_control.c:757-761。

改完再去跑自发自用的状态机 fsm_total_power_ac_control_state_handle() / fsm_total_power_ac_control();如果状态落到了 PV_TRACK_FEEDIN_GRID_STATE,就顺手把 u8_ac_total_set_bat_forced_charge_en 置真,强制电池进入充电 logic/ac_control.c:765-768。

这个「改写电表」的设计意图

填谷的本质是「让系统以为现在负荷更大,于是多充点电」。 复用已有的自发自用状态机、只修改它的输入,比再写一套充电算法要省得多—— 这是这套固件里很典型的一种「以数据欺骗策略算法」的复用手法。 代价是可读性变差:读代码时如果不知道这里改写过, 就会以为自发自用状态机拿到的是真实电表值。

一个具体的现场场景

场景:用户反馈「设定 18:00–21:00 削峰,但 18:10 电池不放电」

  1. 先确认时段真的被判进来了。时段字段是「分钟数」,例如 18:00 记作 1080。 如果云端下发时把 18:00 写成了 1800(当成「18 点 00 分」直译), 就落到了 30:00,永远不命中。先看 PeakStartTime1_minutes 的实际取值。
  2. 再确认总开关与模式。削峰填谷要求 u8_peak_shaving_en 为真 logic/logic.c:1507; 并且序控模式链必须落到 TIME_PEAK_SHAVING_MODE。 如果此刻有云端调度指令或强制充放电在生效,它们的优先级更高, 削峰填谷这一段根本不会执行——现象就是「时段对了但没动作」。
  3. 最后看是不是「已经低于上限」。削峰的目标是压到 s32_set_peak_value 以下。 如果 18:10 全家还没回家,取电功率本来就低于上限, 那么 NO_INVERTER_POWER_STATE 会把设定功率置 0, 电池不放电是正确行为。这时用户看到的「不放电」其实说明策略在正常工作。

判断方法:看 s32_pgrid_metert(电网表总有功)与 s32_set_peak_value 的关系。 前者低于后者 → 无需削峰。

容易踩的坑

坑 1 · 把「削峰」理解成「一定放电」

削峰是闭环压上限,不是「定时放电」。取电低于上限时设定功率被主动置 0 logic/ac_control.c:1700-1701。现场考核「有没有放电」是错的问题, 应该问「取电有没有被压到上限以下」。

坑 2 · 填谷就地改写电表值,排查时会看错数据

time_valley_fill_calculate() 会扣减 s32_pgrid_metera/b/c 与总量 logic/ac_control.c:757-761。 在这个函数之后的同一轮里,逻辑表里的电表功率已经不是实测值。 如果在这之后打印调试信息并与电表实际读数对照,会对不上,从而误判为「电表读数错误」。

坑 3 · AC 控 / DC 控两套实现,行号不同但名字极像

两套函数只差前缀与一两个字母:time_peak_clip_calculate()(AC)对 dc_time_peak_clip_calculate()(DC); ac_ctrl_time_valley_fill_command() 对 dc_ctrl_time_valley_fill_command()。 更麻烦的是 time_peakshaving_command() 内部用 #ifdef INV_AC_CTRL / #ifdef INV_DC_CTRL 分发 logic/logic.c:554-568。 在明文基线里这两个宏是否被定义,取决于工程宏配置(见 §5.1), 所以「改了没生效」的第一嫌疑就是改错了那一侧。

坑 4 · 两个窗口重叠时会被合并

两段削峰窗口的判据是「或」关系: l_u8_time_peak_flag |= (l_u8_temp_flag == 0) logic/logic.c:1517。 所以两段窗口即使重叠也只表现为「在削峰」,不会叠加放电量; 同样,如果用户把填谷窗口配进了削峰窗口里,两个标志会同时为真, 而 time_peakshaving_command() 用 if / else if 让削峰优先 logic/logic.c:552-570——填谷会被静默跳过。

8功能案例(二)

02

电池加热(Battery Heating)

低温环境下的充电保护 · 三态状态机 NORMAL / INV_HEAT / INV_HEAT_FAULT

一句话:冬天电池太冷充不进电,就先借逆变器的电给电池加热,加热失败要能识别出来并体面退出。

这个功能解决什么问题

锂电池在 0 ℃ 以下充电会析锂,损伤寿命甚至引发安全风险,所以 BMS 在低温时会主动禁止充电 (欠压 / 低温保护)。但用户冬天还是想用这套系统。于是需要一条「先热起来,再充」的路径:

  • 逆变器加热:由逆变器输出电流经过加热膜/加热继电器把电池包加热。
  • 电池自加热:电池内部自己有加热回路,EMS 只负责下指令。

这两种能力是否具备由电池上报。EMS 侧的做法是用同一套三态状态机抽象两种加热, 再在入口处按能力位择路——这样上层判断逻辑(能否加热、失败怎么办)只写一遍。

数据怎么流进来 → 在哪算 → 怎么发出去

  1. 流进来 电池通过 CAN 上报:加热请求、自加热能力位、加热台数、温度、过热码、加热故障码、 继电器状态。落进 g_t_bat_info(BATTERY_TABLE_T,midware/bms/bms.h)。 预处理入口是 handle_bat_heat_prestore_info() / handle_bat_heat_soc_info()。
  2. 入口择路 task_bms(100 ms)每轮调 bat_heat_handle() midware/bms/bms.c:2921 → 2692。 函数内部按电池上报的加热能力位分派: 能力位走自加热 → bat_self_heat_deal() midware/bms/bms.c:2716 → 2467; 否则走逆变器加热 → bat_inv_heat_deal() midware/bms/bms.c:2720 → 2297。
  3. 判能否加热 bat_heat_execute_check() midware/bms/bms.c:1655 返回 0 才允许加热;另有一串前置条件: 离网/并网判据 bat_heat_inv_ongrid_check() midware/bms/bms.c:1683、 逆变器状态 bat_heat_inv_state_check() :1849、 欠压强充 bat_heat_low_vol_state_check() :1634、 充电需求 bat_charge_demand_check() :2216。
  4. 进入加热 三步走:bat_heat_into_1_step() midware/bms/bms.c:1895 先停电池功率(u8_bat_forbid_chg_dischg_flag = TRUE), 等 3 秒再向逆变器下加热指令(u8_inv_heat_cmd = 1) midware/bms/bms.c:1898、1906-1910; 接着 bat_heat_into_2_step() :1924 给电池下发指令; 最后 bat_heat_into_sta_check() :1944 确认状态并切态。 那 3 秒是防止带电切继电器(源码注释原文如此)。
  5. 监控与故障 加热过程中持续跑:bat_heat_vol_current_check() midware/bms/bms.c:1994(电流电压异常)、 bat_heat_hardware_fault_check() :2033(加热膜硬件故障)、 bat_heat_fault_check() :1776。 任一异常 → 切 BMS_INV_HEAT_FAULT_FSM midware/bms/bms.c:2344 / 1967 / 2014 / 2041 / 2052 / 2070。
  6. 退出加热 正常退出:bat_heat_drop_out() midware/bms/bms.c:2082 ——停逆变器加热 → 发退出码 0x55 → 等继电器断开, 判定在 bat_heat_exit_check() :1706。 无加热请求时由 bat_heat_no_request_deal() :2197 收尾 (清指令、清故障、放开电池功率)。
    异常退出:bat_heat_fault_drop_out() midware/bms/bms.c:1814 的第 2 步等 3100 ms 后置加热故障标志 :1828; 故障态由 bat_heat_fault_fsm_deal() :2137 管理。
图 4 · 电池加热三态状态机(BMS_HEAT_STATE,midware/bms/bms.h:204-208)
NORMAL(正常 / 待机) BMS_NORMAL_FSM 不加热,电池功率正常放开 INV_HEAT(加热中) BMS_INV_HEAT_FSM 电池功率已被禁止 INV_HEAT_FAULT(加热故障) BMS_INV_HEAT_FAULT_FSM 置 E0079(bit26),面板显示 2 满足加热条件 execute_check()==0 经 into_1/2/3_step 三步 加热异常 过热码 / 加热故障码 电流电压异常 / 超时 加热结束 / 加热请求清除 / 用户关使能 / 系统升级 bat_heat_drop_out() → 发 0x55 → bat_heat_exit_check() 故障态至少呈现 10 分钟,且恢复机会 < 3 次,才允许回正常态 bms.c:2618(> 360000 ms)· bms.c:2620(recovery_count < 3)
逆变器加热与电池自加热各有一套状态机(bat_inv_heat_deal() 用总表里的 e_bms_heat_FSM,bat_self_heat_deal() 用文件内静态变量 s_bat_self_heat_fsm), 但三态命名与迁移条件一致。

关键函数与行号

作用函数 / 符号位置
加热总入口(按能力位择路)bat_heat_handle()midware/bms/bms.c:2692
择路分派(自加热 / 逆变器加热)bat_self_heat_deal() / bat_inv_heat_deal()bms.c:2716 / bms.c:2720
逆变器加热状态机本体(三个 case)bat_inv_heat_deal()midware/bms/bms.c:2297
case 分别在 :2302 / :2360 / :2388
电池自加热状态机本体bat_self_heat_deal()midware/bms/bms.c:2467
状态枚举(三态)与状态变量BMS_HEAT_STATE
g_t_bat_info.t_static_data.e_bms_heat_FSM
midware/bms/bms.h:204-208
midware/bms/bms.h:804
能否加热的总判据bat_heat_execute_check()midware/bms/bms.c:1655
进加热三步bat_heat_into_1_step() / bat_heat_into_2_step() / bat_heat_into_sta_check()bms.c:1895 / :1924 / :1944
退加热(正常)bat_heat_drop_out() / bat_heat_exit_check()bms.c:2082 / :1706
退加热(故障)与故障态管理bat_heat_fault_drop_out() / bat_heat_fault_fsm_deal() / bat_heat_into_fault_deal()bms.c:1814 / :2137 / :2065
加热期间的三个检查bat_heat_vol_current_check() / bat_heat_hardware_fault_check() / bat_heat_fault_check()bms.c:1994 / :2033 / :1776
无请求时收尾bat_heat_no_request_deal()midware/bms/bms.c:2197
加热故障置位 / 清除(写 EMS 位图 bit26 = E0079)set_bat_heat_fault_flag()定义 data/fault_warn_log/fault_warn_log.c:2147-2157
置 1 bms.c:1828、:2611;清 0 bms.c:2306、:2455

三态各自在做什么

NORMAL —— 正常态

每轮先判断「这一轮该不该做点什么」:加热能力/使能是否具备(bat_heat_execute_check())、 是否有加热请求、是否处于欠压强充。 条件齐了就调 bat_heat_into_1_step() 开始三步进加热 midware/bms/bms.c:2307-2340。 如果一直不满足,则走 bat_heat_no_request_deal() 把上一轮可能残留的指令清干净 midware/bms/bms.c:2350。

INV_HEAT —— 加热态

关键判据在 midware/bms/bms.c:2362: (u8_heat_state == TRUE) && (bat_heat_execute_check() == 0) && (bat_heat_inv_ongrid_check() != 0) —— 三个条件同时成立才继续留在加热态: 电池自己还报着要加热、EMS 判断可以加热、并且当前不该离网加热。 任一不成立就切回 NORMAL 并下发退出。

INV_HEAT_FAULT —— 故障态

进入时做三件事 midware/bms/bms.c:2388-2393: 下发 0x55 退出自加热、把面板显示量 u8_heat_state_display 写成 2、 并置加热故障标志(即 EMS 位图 bit26 → 故障码 E0079)。 随后进入「等待恢复」:必须挂满 10 分钟(> 360000 ms, midware/bms/bms.c:2618) 且电池连接正常,才允许把 u8_heat_fault_recovery_count 加一并回正常态; 而这个恢复机会最多用 3 次(midware/bms/bms.c:2620)。

为什么故障要「最少挂 10 分钟」

源码注释写得很直白:「加热故障最少呈现 10 分钟故障状态能上传,且预留故障恢复的时间」 midware/bms/bms.c:2618。 两个目的:一是让云端有足够时间把这条故障采样上报; 二是给瞬间干扰(继电器抖动、采样毛刺)留出自动恢复的窗口,避免一次误判就让用户当天没热水用。 代价是故障码出现后不会立刻消失——现场如果看到 E0079 挂了一会儿就没了, 这属于设计行为,不代表没故障。

一个具体的现场场景

场景:北方 12 月,早上用户发现电池不充电,屏幕上有 E0079

  1. 先确认是不是加热故障,而不是低温保护。 E0079(bit26)由 set_bat_heat_fault_flag() 置位, 触发点只有两个:电池自加热进故障态 bms.c:2611, 或异常退出流程第 2 步等 3100 ms 后 bms.c:1828。 而单纯的低温禁充不会报 E0079——那种情况要看 BMS 自己的告警码 (W70000 段)。
  2. 看三个候选根因。 进入故障态的判据在 bms.c:2591-2596 与 bms.c:2576-2580: ① BMS 报过热码(bms_read_over_temperature_code()); ② BMS 报加热故障码(bms_read_heat_fault_code()); ③ 电池连接检查异常(bat_bat_connet_check()); ④ 或者加热进行了 9000 ms 还没见效。 现场对应:加热膜断路 / 加热继电器不吸合 → ④; 温度传感器故障 → ①②;电池通信断 → ③。
  3. 看恢复次数用完了没有。 u8_heat_fault_recovery_count 到 3 之后,系统不再自动回正常态。 现场表现就是「E0079 一直挂着,重启才消」。 只有加热请求被清除、或用户关闭使能且非欠压强充时,这个计数才会重置为 0。

排查顺序建议:先量加热膜通断 → 再查加热继电器 → 最后看温度传感器与电池 CAN。 因为「9000 ms 没见效」是最容易命中的那一条。

容易踩的坑

坑 1 · 两套状态机,状态变量不在同一个地方

bat_inv_heat_deal() 的状态存在全局总表 g_t_bat_info.t_static_data.e_bms_heat_FSM(bms.h:804); 而 bat_self_heat_deal() 用的是文件内静态变量 s_bat_self_heat_fsm 与子状态数组 s_heat_self_fsm_sub[] midware/bms/bms.c:2469。 调试时如果只看前者,走自加热路径的机器会「看不到状态变化」,从而误判为状态机没跑。

坑 2 · 进加热那 3 秒是必须等的,不是延时冗余

bat_heat_into_1_step() 先把 u8_bat_forbid_chg_dischg_flag 置真停电池功率, 再等 > 3000 ms 才下逆变器加热指令 midware/bms/bms.c:1898、1906。 源码注释:「防止带电切继电器,加热前先停电池功率 3S」。 如果为了「加快响应」把这 3 秒改小,会变成带电切换加热继电器, 属于硬件损伤风险,不是性能优化。

坑 3 · 系统升级期间加热会被强制退出

判断条件写在 midware/bms/bms.c:2584 附近: 只要有固件下载/升级在进行(IsSystemDownload() / IsSystemUpgrade() / get_updt_status_current() 不为空闲),就清显示量并强制回 NORMAL。 所以「升级过程中加热莫名其妙停了」是设计如此, 不要在升级窗口里做加热功能验证。

坑 4 · 面板显示量 2 与故障码不是一回事

u8_heat_state_display 是给 HMI 用的显示枚举 (进入故障态时写 2,midware/bms/bms.c:2392), 它是「加热状态」的展示;而 E0079 是写进 EMS 位图 bit26 的故障。 两者在同一处被写入,但生命周期不同:位图那条只在 bms.c:2306 / bms.c:2455 两处被清 0。 查「屏幕上还有没有加热异常」要看显示量,查「故障码有没有消」要看位图。

9功能案例(三)

03

并机控制(Parallel / Multi-machine)

主机 / 从机角色分工 · CAN 与 SCI 双通道 · 功率分配与心跳保活

一句话:多台机器并成一套系统时,指定一台当主机统一算总账,其余从机执行分到的份额,两条独立链路保证不失联。

这个功能解决什么问题

一台 EMS 带多台逆变器时,每台机器都能看到自己的电表、自己的电池,但看不到别人的。 如果各算各的,就会出现「A 机猛放电、B 机猛充电」这种互相抵消的低效甚至危险工况。 并机控制要解决三件事:

  • 角色:谁是主机(掌握系统控制权、负责算总量与分配)、谁是从机(只执行)。
  • 分配:把系统级目标功率按电池 SOC、额定能力等因素分到每一台,并避免来回争抢。
  • 保活:链路断了要能被发现,并且不会把「断链」误当成「从机功率为 0 所以系统缺功率」。

数据怎么流进来 → 在哪算 → 怎么发出去

  1. 流进来 从机的运行数据(电网/逆变/备电功率、PV 功率、电池 SOC 与电流、额定能力、故障位) 通过 CAN 扩展帧或 SCI(Modbus-RTU)汇总到主机,落进 g_t_host_machine_running_info[] / g_t_host_machine_networking_info[](PARALLEL_MAX_NUM 个槽位)。
  2. 选通道 配置项 u8_parallel_com_type_can 与 inverter_get_parallel_mode() 决定走哪条: PARA_COM_TYPE_USART3 / PARA_COM_TYPE_UART4 对应 SCI(midware/inverter/parallel_comm/parallel_sci.c), 否则走 CAN(midware/inverter/parallel_comm/parallel_can.c)。 parallel_data_handle() 一进来就先检测这几个配置有没有变, 变了就 parallel_parallel_comm_init() 清空各机档案 midware/inverter/parallel.c:981-988。
  3. 分角色 u8_parallel_en != FALSE 才启用; 再按 u8_parallel_mode 分流: 非 0 → 主机 parallel_host_machine_logic_handle() midware/inverter/parallel.c:992 → 334; 为 0 → 从机 parallel_from_machine_logic_handle() midware/inverter/parallel.c:996 → 850; 未启用并机则走单机直通 parallel_one_machine_logic_handle() midware/inverter/parallel.c:1003。
  4. 主机算 + 分配 parallel_host_machine_logic_handle() 负责功率分配主逻辑 (SOC 均衡 + 双权重 + 满功率优先 + 追零), 并用 parallel_sys_soc_limit() 下发逐机禁充/禁放限值与系统预留 SOC 强充标志 midware/inverter/parallel.c:334。 分配结果再由 parallel_read_*_total_set_* 一族接口对外暴露。
  5. 从机执行 parallel_from_machine_logic_handle() 把主机发来的指令写回本地执行器; 读接口如 parallel_read_system_bat_low_soc()、 parallel_read_system_grid_power()、 parallel_read_system_bat_average_soc() 让上层逻辑按「系统总量」而非「本机量」做决策 midware/inverter/parallel.c:850 起。
  6. 组网与保活 从机申请地址 → 主机分配 → 组网完成,三段握手在 SCI 侧用 0x41 组网开始 / 0x42 地址分配 / 0x43 组网完成 midware/inverter/parallel_comm/parallel_sci.h:23-25。 握手与心跳失联门限都是 15 s: SCI 主机侧 parallel_sci.c:651、 SCI 从机侧 parallel_sci.c:1546、 CAN 主机侧 parallel_can.c:491、 CAN 从机侧 parallel_can.c:861。 从机侧超时会清掉自己的并机地址并清空运行数据,意味着要重新组网才能恢复 parallel_sci.c:1546-1555。
图 5 · 并机角色分工与双通道链路
主机(Host) u8_parallel_mode != 0 算系统总量 · 分配功率 parallel_host_machine_logic_handle() parallel.c:334 从机 1 parallel_mode == 0 执行分到的份额 parallel_from_machine_logic_handle() parallel.c:850 从机 N(最多 16 机) PARALLEL_MAX_NUM = 16 先申请地址,再执行 parallel.h:15 槽位 1..15(槽 0 为主机) 并机总线(二选一,由配置决定) CAN 扩展帧 · 或 · SCI / Modbus-RTU(功能码 0x03 读遥测 / 0x10 写控制 · 读寄存器 0x00 起 52 个、写寄存器 0x50 起 31 个) 心跳失联门限统一 15 s;从机侧失联会清掉并机地址,需重新组网恢复 下发 下发
主机与从机跑的是同一个任务(task_inverter_can,30 ms), 角色差异只体现在 parallel_data_handle() 里的一次 if 分派 midware/inverter/parallel.c:990-1004。

关键函数与行号

作用函数 / 符号位置
并机业务总入口(配置变化检测 + 角色分派)parallel_data_handle()midware/inverter/parallel.c:970
被 inverter/inverter.c:1276 调用
主机侧功率分配主逻辑parallel_host_machine_logic_handle()midware/inverter/parallel.c:334
从机侧写回本地 / 单机直通parallel_from_machine_logic_handle()
parallel_one_machine_logic_handle()
parallel.c:850
parallel.c:1003
逐机禁充禁放限值 + 系统预留 SOC 强充标志parallel_sys_soc_limit()parallel.c:60(主机循环内 :401 调用)
CAN 通道主循环parallel_can_comm_handle()
parallel_can_comm_host_machine_handle() / parallel_can_comm_from_machine_handle()
parallel.c:1025
parallel_comm/parallel_can.c
SCI 通道主循环(按并机模式选 UART)parallel_sci_comm_handle()
parallel_sci_comm_host_machine_handle() / parallel_sci_comm_from_machine_handle()
parallel.c:1053
parallel_comm/parallel_sci.c
CAN 侧组网:从机申请、主机三级策略分配parallel_host_machine_networking_handle()
send_from_machine_networking_info()
parallel_from_machine_networking_handle()
midware/inverter/parallel_comm/parallel_can.c
SCI 侧三段式组网握手handle_join_request() / host_send_networking_data_to_salve() / parallel_handle_networking_response() / parallel_handle_assign_response()
命令字 0x41 / 0x42 / 0x43
parallel_comm/parallel_sci.c
parallel_sci.h:23-25
SCI 寄存器映射(52 个读 / 31 个写)Parallel_Set_Para_to_Reg_Addr() / Parallel_get_para_from_reg_addr() / REG_PARALLEL_QUERY_MODBUS_Eparallel_sci.h:20-22(数量与地址)
系统级汇总量读取(电网/逆变/备电/PV/电池、最低与平均 SOC、额定功率)parallel_read_system_grid_power()、parallel_read_system_bat_low_soc()、parallel_read_system_bat_average_soc()、parallel_read_system_rated_* 等midware/inverter/parallel.c
主机/从机在机台数统计parallel_getParallelNum() / parallel_machine_isWorkInNormalMode()midware/inverter/parallel.c
并机故障位:是否丢了主机 / 是否丢了从机ems_set_fault_bit(20, …) → E0034
ems_set_fault_bit(21, …) → E0035
midware/inverter/parallel.c:1014
midware/inverter/parallel.c:1015

两个故障位容易被混淆

parallel_data_handle() 的最后三行(midware/inverter/parallel.c:1006-1016) 把并机相关的两个故障位一次性算完,方向正好相反:

  • E0034(bit20,parallel_host_lost)—— 本机是从机,报「找不到主机」。 判据直接跟随 g_t_from_machine_networking_info.u8_parallel_lost_flag。
  • E0035(bit21,parallel_form_lost)—— 本机是主机,报「有从机掉了」。 判据是循环 i = 1 .. PARALLEL_MAX_NUM-1, 只要有任意一台从机的 u8_parallel_lost_flag 为真, 用局部变量 l_u8_from_machine_lost 汇总成本机故障 midware/inverter/parallel.c:1006-1012。

E0035 不告诉你是哪一台

主机只能知道「有从机丢了」,因为汇总用的是布尔或。要定位具体哪台, 得去看各从机槽位(g_t_host_machine_networking_info[i])的地址是否被清 0、 运行数据是否为零。现场如果只看到 E0035,不要问「是哪台」,要先按槽位逐个排。

一个具体的现场场景

场景:三机并机系统,用户报「第三台不工作,且有故障码」

  1. 先确认这台是主机还是从机。 看 u8_parallel_mode。如果第三台是主机而报的是 E0034, 那说明它的 u8_parallel_mode 配置被写成了从机值—— 这是个纯配置问题,改配置即可,不必动线。
  2. 确认走的是哪条链路。 u8_parallel_com_type_can 与 inverter_get_parallel_mode() 决定 CAN 还是 SCI。两台机器若一个配 CAN、一个配 SCI, 物理上都接着线但互相收不到帧,表现为 15 s 后报并机通信类故障。 这是现场最容易犯的错:只看线接好了,没看通道配置一致。
  3. 看是不是「丢了之后就再也没回来」。 从机侧超时会清 u8_parallel_addr 并 memset 运行数据 parallel_sci.c:1546-1555。 因为地址被清了,即使线恢复了也不会自动回复,必须重新组网。 现场要做的动作是「重新走一遍并机组网/上电」,而不是「插拔一下线等它自己好」。
  4. 最后看功率分配是否公平。 主机分配依据 SOC 均衡与满功率优先, parallel_set_full_power_priority_enable() / parallel_set_bat_soc_threshold() 会影响结果。 如果用户抱怨「某台总是出工少」,先核对各台电池 SOC 与满功率优先阈值配置, 再看各台额定能力是否被正确读到 (parallel_read_system_rated_charge_power() 等求和接口)。

容易踩的坑

坑 1 · 15 s 门限意味着「短暂插拔不会报」,但也意味着「报了就是断了很久」

四条通道的失联判据都是 ≥ 15000 ms parallel_sci.c:651、:1546;parallel_can.c:491、:861。 现场如果用户说「我就拔了一下线」,那不足以产生这个故障码—— 要么实际断了 15 s 以上,要么问题在别处(配置、通道选择)。

坑 2 · 配置变化会导致各机档案被清空

parallel_data_handle() 每轮都比对 u8_parallel_en / u8_parallel_mode / u8_parallel_com_type_can 三个量,任一变化就调 parallel_parallel_comm_init() midware/inverter/parallel.c:981-988。 这意味着改一次并机配置会让整系统重新组网。 如果现场频繁看到并机故障码且找不到硬件原因, 要怀疑是不是有别的逻辑在反复改这几个配置位(例如云端同步)。

坑 3 · 并机任务与逆变器任务共享全局表,且没有互斥

task_inverter_can(30 ms)里顺序执行 逆变器 CAN 收发、inverter_lost_handle_can()、parallel_can_comm_handle()、 parallel_data_handle() midware/inverter/inverter.c:1270-1276, 而 task_parallel_sci(100 ms,独立任务)同时也在读写 g_t_inv_info / 并机各表,全程没有互斥量。 这意味着「偶发一次的读到的数据不一致」在原理上是可能的。 排查偶发问题时要把它作为一个候选解释,而不是假设数据一定自洽。

坑 4 · 单机模式也走同一个入口

并机未使能时会落到 parallel_one_machine_logic_handle() midware/inverter/parallel.c:1003。 也就是说单机系统同样会跑 parallel_data_handle()。 调试单机问题时不要以为「没开并机所以并机代码不执行」, 那两个故障位的写入点在单机模式下也会执行(因为是函数末尾无条件调用)。

10功能案例(四)

04

故障码聚合(Fault Aggregation)

各设备故障 → EMS 32 位位图 → E 码 → 故障日志 → 屏幕与云端

一句话:全系统几十路故障来源,先压成主控里的一个 32 位整数,再翻译成人能看懂的 E 码,最后进入日志并上传。

这个功能解决什么问题

一台储能系统里的故障来源极其分散:逆变器有自己三组 64 位故障字, 电表会通信丢失,电池有 CAN 上的保护码/告警码,AFCI 会自检失败, 急停干接点会动作,并机链路会断,Flash 会写坏…… 如果每处都各自上报,云端和屏幕要处理几十种格式。

固件的做法是两层归一:

  1. 第一层:压缩成位图。 主控自己判定的故障全部压进 g_t_ems_info.u_ems_fault(类型 EMS_FAULT_U)这个 32 位整数里, 一个 bit 一个故障。定义在 data/data.h:67-114。
  2. 第二层:位图翻译成码值。 位图变化时,由一张固定对照表把 bit 位置翻译成 E00xx 形式的码值,写进故障日志。设备自身上报的故障码则走另一条通道 (它们本来就是码值,不需要翻译,见附录的分区表)。

数据怎么流进来 → 在哪算 → 怎么发出去

  1. ① 判定 各任务在自己的循环里做判据。例如 task_inverter 调 inverter_lost_handle() midware/inverter/inverter.c:115-190、 task_meter 调 meter_fault_handle() midware/meter/meter.c:4539-4598、 task_bms 调 bms_comm_detect() midware/bms/bms.c:56-69。
  2. ② 置位 统一入口是 ems_set_fault_bit(u8 index, u8 value) data/data.c:338-348, 参数 index 是 bit 位置而不是故障码值: value 非 0 就 u32_all |= (1 << index), 为 0 就 &= ~(1 << index)。 另外有 3 个位绕过这个函数、直接写位域:bit9、bit25、bit26。
  3. ③ 聚合 task_system_data(≈500 ms)在 data/data.c 里跑一次比较: 拿上一轮存下来的位图副本 g_t_error_log_tab.u_ems_err_log.u32_all 与当前 g_t_ems_info.u_ems_fault.u32_all 做整体比较, 不同才进翻译流程 data/fault_warn_log/fault_warn_log.c:516。 这是"变化触发"而不是"周期全量"——位图没变就什么都不做。
  4. ④ 翻译 对照表是一个 30 项的 FAULT_CODE_PAIR_T 数组, 每项形如 {EMS_FAULT_BMS_LOST, E0007},把「位域枚举名」与「码值」配对 data/fault_warn_log/fault_warn_log.c:518-549。 然后逐项比对两轮位图在这一个 bit 上的差异: 新置 1 的写一条「故障发生」,新清 0 的写一条「故障恢复」 fault_warn_log.c:551-566。
  5. ⑤ 落日志 双向写入都走 save_error_log_data_to_cache(ERROR_EMS, FAULT_OCCURRED/FAULT_CLEAR, 1, 1, 码值), 进入故障告警日志缓存(data/fault_warn_log/),并带故障来源分类 ERROR_EMS。
  6. ⑥ 显示与上云 屏幕:历史故障页把码值格式化成 E%05u, 并前置一个「已恢复」标记 hmi/ui/ui_history_error_log.c:139-165。 云端:故障日志与实时状态由 net/ems.c / server.c 上报, 并机、串口等故障的实时位也会随状态包上传。
图 6 · 故障码聚合链路:从 6 类来源到一个位图,再到 E 码
① 故障来源(分散在各设备与各任务) 逆变器 失联 / 从机丢失 / 机型未指定 bit3 · bit10 · bit16 电表 / CT 电网表 / PV表 / MID表丢失 bit4 · bit5 · bit29 · bit18 BMS 电池 通信丢失 / 加热故障 / 混装超限 bit6 · bit26 · bit28 AFCI / 急停 自检失败 / EPO 干接点动作 bit27 · bit24 并机链路 丢主机 / 丢从机 bit20 · bit21 本地与其余 RTC / Flash / EEPROM / WiFi bit1 · bit2 · bit13 · bit25 统一走 ems_set_fault_bit()(另有 3 处直接写位域) ② EMS 主控故障位图 g_t_ems_info.u_ems_fault(EMS_FAULT_U,data/data.h:67-114) 0123 4567 891011 12131415 16171819 20212223 24252627 2829 3031 只有 bit0 ~ bit29 有定义(30 个位);bit30 / bit31 是占位。 全工程 42 处写位图,落在 23 个位上 —— 其余 7 位是死位。 task_system_data 每轮比对「上一轮副本 vs 当前位图」,不同才处理 ③ 变化检测 g_t_error_log_tab.u_ems_err_log.u32_all != g_t_ems_info.u_ems_fault.u32_all fault_warn_log.c:516 ④ 位 → 码 映射表 30 项 FAULT_CODE_PAIR_T 数组 {EMS_FAULT_BMS_LOST, E0007} fault_warn_log.c:518-549 逐 bit 差异 → 双向写日志 FAULT_OCCURRED → 「故障发生」 FAULT_CLEAR → 「故障恢复」 fault_warn_log.c:551-566 ⑤ 屏幕显示(历史故障页) snprintf(buf, " E%05u NO.%2u ", code, id) 行首有 NO = 该故障已恢复;无 NO = 正在发生 hmi/ui/ui_history_error_log.c:139-165 ⑥ 云端 / App 故障日志与实时状态位随状态包上报 net/ems.c · net/server.c 设备自身故障码(逆变器/BMS/充电桩)走各自通道 注意:位图没有「整体清零」的地方。某位置 1 后若代码里没有对应清 0 分支,就会一直挂到断电/复位。
整条链路是「变化触发」的:位图两轮相同则完全不产生日志记录,所以状态稳定时不会有重复日志。

关键函数与行号

作用函数 / 符号位置
位图类型定义(32 位联合体:整字 / 4 字节 / 30 个位域)EMS_FAULT_Udata/data.h:67-114
位图所在的全局表g_t_ems_info(EMS_TAB_T)data/data.c 定义,类型见 data/data.h
统一的置位 / 清位函数ems_set_fault_bit(u8 index, u8 value)data/data.c:338-348
EEPROM 类故障的聚合与自愈eeprom_fault_handle()
clear_eeprom_fault_temp()
data/data.c:211-242
data/data.c:187-202
位图变化检测 + 翻译 + 写日志(在 fault_warn_log 的周期处理里)data/fault_warn_log/fault_warn_log.c:516-566
位 → 码 对照表(30 项)FAULT_CODE_PAIR_T ems_fault_code_info[]fault_warn_log.c:518-549
加热故障位 bit26 的写入 / 清除set_bat_heat_fault_flag()(直接写位域)fault_warn_log.c:2147-2157
WiFi 不适配 P1 位 bit25 的写入 / 清除meter_set_grid_wifi_P1_fault()(直接写位域)midware/meter/meter.c:7039-7049
屏幕格式化(E%05u + NO 标记)snprintf(… "E%05u" …)hmi/ui/ui_history_error_log.c:146 / 150 / 158
英国 G100 把 6 个通讯位聚合成「禁止馈网」l_u8_com_fault 聚合 → STATE3_FOBID_FEEDIN + OVER_STATE3_COM_FAULTlogic/logic.c:390-395 → logic/logic.c:506-511

谁在扫描这些位(决定故障恢复的响应速度)

位的置位/清除分散在各任务里,所以一个故障从条件成立到码值出现在日志里,延迟取决于扫描它的任务周期。 下表来自源码逐点核对:

任务位置周期负责的位
task_inverterinverter.c:118880 msbit3、bit16、bit22
task_inverter_caninverter.c:128530 msbit10、bit20、bit21
task_bmsbms.c:2933100 msbit6、bit17、bit19、bit26、bit28
task_metermeter/meter.c:5881300 msbit4、bit5、bit25、bit29
task_system_datadata/data.c:263≈500 msbit2
task_system_auxsystem_aux.c:2092400 msbit1
task_interface_parseinterface/interface.c:230500 msbit24
task_samplingmeter/ct.c:844随 ADC-DMA 采样中断bit18
alphaess_inverter_comm_usart(由 task_inverter 调)inverter.c:111680 msbit23

一个具体的现场场景

场景:用户报「屏幕上出现 E00007」——这到底意味着什么?

先解码。屏幕显示格式是 E%05u,所以 E00007 就是码值 7。 查对照表(data/fault_warn_log/fault_warn_log.c:518-549) 码值 7 对应 EMS_FAULT_BMS_LOST,即位图 bit6。 含义是:主控与电池之间的 CAN 通信丢了。

再看这一行有没有 NO 前缀,判断是"正在发生"还是"已恢复":

  • E00007 NO. 1 —— 行首没有 NO,表示故障正在发生。
  • NO E00007 NO. 1 —— 行首有 NO,表示该故障已经恢复。

这个反转很容易看错:行首的 NO 是「已恢复」标记, 而 NO. 1 里的 NO. 是序号。排查时只看没有行首 NO 的那些行。 格式实现在 hmi/ui/ui_history_error_log.c:139-165。

然后按判据逐条排。E00007 由 bms_comm_detect() midware/bms/bms.c:56-69 置位,三个条件必须同时成立:

  1. 开机已满 60 秒(OSTimeGet() > 60 * 1000)。 上电 60 秒内这个函数既不置位也不清零,位图保持原状。 所以「刚上电就报电池没了」这种事不会发生——刚上电找不到电池要等一分钟。
  2. 电池连接标志为 FALSE(g_t_bat_info.t_run_info.u8_connect == FALSE)。 这个标志被置 FALSE 的路径是 距上次收到有效 CAN 帧 > 10 s midware/bms/bms.c:2899-2905, 同时会调 bms_lost_handle() 清掉整块电池运行数据 midware/bms/bms.c:1133-1141。
  3. 不是被外部 PV 充电器主机接管的模式 (pvcharger_read_machine_mode() != PVCHARGR_FROM_E)。

由此可以推出三个现场结论:

  • SOC 变成 0 或「--」是连带现象,不是第二个故障。 因为 bms_lost_handle() 把电池运行数据整块清了。 看到「E00007 + 电池数据全没了」不要去查电池数据为什么不显示。
  • 报出来就说明至少断了 10 秒以上,不能用「刚插拔了一下线」解释。
  • 清位点有三个:正常恢复 bms.c:66、 配置为「系统未接入电池」bms.c:2750、 电池固件升级期间 bms.c:2760。 「升级电池固件时 E00007 消失」是设计行为,不是修好了。

排查顺序:先看电池 BMS 指示灯与 CAN 接线(A/B 是否反接、终端电阻)→ 再查电池侧是否被强制关机 → 最后看是否配置了「未接电池」把这一位屏蔽掉了。

其他几个高价值的组合现象

「机器不卖电了」很可能不是独立问题

在英国 G100(G98/G99)安规下,logic/logic.c:390-395 把 bit3 / bit4 / bit5 / bit6 / bit9 / bit10 (即 E0004 E0005 E0006 E0007 E0010 E0011)六个位或在一起; 只要有一个为 1,就会在 logic.c:506-511 把系统状态强制切成 STATE3_FOBID_FEEDIN(禁止馈网),原因码 OVER_STATE3_COM_FAULT。

所以现场反馈「不卖电」时,先查这 6 个码有没有一个挂着, 不要直接去查逆变器防逆流参数。这是「一个故障导致另一个症状」的典型。

E0010 与 E0035 完全是两件事,别看名字像就混

E0010(bit9,EMS_FAULT_INV_SLAVE_LOST)虽然叫「从机丢失」, 但它是盛能杰逆变器内部主从 DSP 之间通讯错误的纯镜像—— EMS 只是把逆变器上报的那一位原样抄过来 midware/inverter/set/set_comm_usart_s5.c:1077, EMS 侧没有独立判据。换 EMS 主板解决不了,要查逆变器。

E0035(bit21,EMS_FAULT_PARALLEL_FORM_LOST)才是 并机系统里主机发现「有从机掉了」 midware/inverter/parallel.c:1015。

E0003 会自愈,闪一下的不用管

clear_eeprom_fault_temp() data/data.c:187-202 会遍历 32 个配置页:如果某页只有「读失败」而没有「写失败」, 就判定它是临时坏值,主动把「读失败」标志清掉。 现场含义:偶尔闪一次就消失的 E0003 可以观察,一直挂着的才需要处理。

容易踩的坑

坑 1 · ems_set_fault_bit 的第一个参数是「bit 位置」,不是故障码

ems_set_fault_bit(7, TRUE) 置的是 bit7 = E0008, 而不是「E0007」。要置 E0007 必须写 ems_set_fault_bit(6, TRUE)。 源码里两种写法都存在(有的地方写 bit 位置,有的地方直接写位域名), 看代码时要养成先数一遍的习惯。

坑 2 · 三个位绕过统一函数,用位域名直接写

bit9、bit25、bit26 是直接写 g_t_ems_info.u_ems_fault.bit.xxx = …, 不走 ems_set_fault_bit(): bit9 在 midware/inverter/set/set_comm_usart_s5.c:1077, bit25 在 midware/meter/meter.c:7043/7047, bit26 在 data/fault_warn_log/fault_warn_log.c:2151/2155。 后果是:用「搜 ems_set_fault_bit」的方式定位故障来源,会漏掉这三个位。

坑 3 · 位图没有整体清零,某些故障只能靠断电消

全工程搜不到 u32_all = 0 或对 g_t_ems_info 的 memset。 所以只要某位置了 1 而代码里没有对应的清 0 分支,这个故障就会一直挂着。 有三个位属于这种情况(永久锁存):E0014(bit13)、 E0031(bit17)、E0033(bit19)。 看到「重启一次就没了」不要以为修好了,那是断电清掉了位图。

坑 4 · 不报 ≠ 没坏

「丢通讯」类故障的门限都在 15 s ~ 30 s,瞬断不报; 而且离网 / UPS 状态、电网频率 < 45 Hz、断点续传或升级中这三种工况下, 一部分码被故意设计成不报。 例如电网表丢失在 cUPS 模式或电网频率低于 45.00 Hz 时不置位。 现场判断设备好坏不能只看有没有故障码。

11附录:故障码体系速查

AEMS 位图 30 位全表

EMS_FAULT_U 只有 30 个位有含义(bit0 ~ bit29),bit30 / bit31 是占位。 下表把「bit 位置 / 故障码 / 枚举名 / 位域名」并列,方便在代码与屏幕之间来回换算。

表 A · 位图定义来自 data/data.h:67-114;码值映射来自 fault_warn_log.c:518-549
bit故障码枚举名位域名状态
0E0001EMS_FAULT_NET_FAULTu8_net_fault死位 · 永不报
1E0002EMS_FAULT_RTC_FAULTu8_rtc_faultRTC 读失败,自恢复
2E0003EMS_FAULT_EEPROM_FAULTu8_eeprom_faultFlash 读写失败,带自愈
3E0004EMS_FAULT_INV_LOSTu8_inv_lost逆变器 SCI 失联
4E0005EMS_FAULT_GRID_METER_LOSTu8_grid_meter_lost电网侧电表丢失
5E0006EMS_FAULT_PV_METER_LOSTu8_pv_meter_lostPV 侧电表丢失
6E0007EMS_FAULT_BMS_LOSTu8_bms_lost电池 CAN 丢失(上电 60 s 内不报)
7E0008EMS_FAULT_BAT_VOLT_LOWu8_bat_volt_low死位 · 永不报
8E0009EMS_FAULT_BACKUP_OVERLOADbackup_overload死位 · 永不报
9E0010EMS_FAULT_INV_SLAVE_LOSTinv_slave_lost盛能杰内部通讯错误镜像 位域直写
10E0011EMS_FAULT_INV_MASTER_LOSTinv_master_lost逆变器 CAN 失联
11E0012EMS_FAULT_PARALLEL_COMM_ERRORparallel_comm_error死位 · 永不报
12E0013EMS_FAULT_PARALLEL_MODE_DIFFERparallel_mode_differ死位 · 永不报
13E0014EMS_FAULT_FLASH_FAULTu8_flash_fault永久锁存
14E0015EMS_FAULT_SDRAM_ERRORu8_sdram_error死位 · 永不报
15E0016EMS_FAULT_EXTENSION_CAN_ERRORu8_extension_can_error死位 · 永不报
16E0017EMS_FAULT_INV_TYPE_NOT_SPECIFIEDinv_type_not_specified配置类故障,非硬件故障
17E0031EMS_FAULT_INV_LOST_BAT_SHUTDOWNu8_inv_lost_bat_shutdown永久锁存 · 需持续失联 12 h
18E0032EMS_FAULT_SAMPLING_ANOMALYsampling_anomalyCT 采样异常,需持续 2 s
19E0033EMS_FAULT_INV_SATUSE_ABNORMAL_BAT_SHUTDOWNinv_satuse_abnormal_bat_shutdown永久锁存 · 6 条件同时成立 2 s
20E0034EMS_FAULT_PARALLEL_HOST_LOSTparallel_host_lost本机是从机,找不到主机
21E0035EMS_FAULT_PARALLEL_FORM_LOSTparallel_form_lost本机是主机,有从机丢了
22E0036EMS_FAULT_PV_CHARGER_HOST_LOSTpv_charger_host_lost仅 PV 充电器模式为从机时可能报
23E0040EMS_FAULT_INV_INITIALIZATION_FAILEDinv_initialization_failed3 s 未完成参数初始化,置位分支带 return
24E0041EMS_FAULT_EPO_TRIGGERepo_trigger急停动作,重新开机成功才清
25E0042EMS_FAULT_WIFI_P1_PAIRING_FAULTu8_wifi_P1_pairing_fault通信棒不适配 P1 位域直写
26E0079EMS_FAULT_BAT_HEAT_FAULTu8_bat_heat_fault加热故障 位域直写
27E0085EMS_FAULT_AFCI_SELF_CHECK_FAULTafci_self_check_fault自检连失败 4 次,同时停机
28E0092EMS_FAULT_BAT_MIXED_INSTALLATION_NUM_ERRORu8_bat_mixed_installation_num_error安装方案合规性告警
29E0094EMS_FAULT_MID_METER_LOSTu8_mid_meter_lostMID 三相表丢失,功能未使能则强制清 0
30占位(bit30)无含义—
31占位(bit31)无含义—

「活位」共 23 个:20 个通过 ems_set_fault_bit() 写入 (索引集合为 {1,2,3,4,5,6,10,13,16,17,18,19,20,21,22,23,24,27,28,29}), 加上 3 个位域直写的 bit9 / bit25 / bit26。 其余 7 个是死位,E0001 E0008 E0009 E0012 E0013 E0015 E0016—— 它们的码表登记是完整的,但当前版本没有任何代码路径会把它们置 1,现场永远不会看到。

死位意味着什么

要么是历史遗留(同类逻辑已迁到别的结构体上),要么是给后续版本预留的位置。 排查时不要在这 7 个码上花时间。 如果现场真的看到了这 7 个码中的某一个,说明固件版本与这份资料不一致,不要在当前代码里找它。

B全量 336 个故障码:11 个分组

码值的数值本身就是来源标识。注意 EMS 分册只覆盖 E0001~E0094 这一组(30 条), 其余 306 条属于设备自身上报,走各自的翻译通道。

表 B · 数据来源:fault_warn_log.c 的 11 张 FAULT_CODE_PAIR_T 对照表
分组码值范围条数
EMS 故障码(主控自判定)E0001 ~ E009430
BMS 故障码E60000 ~ E6002922
BMS 保护码P75000 ~ P750089
BMS 告警码W70000 ~ W7001513
逆变器故障码E100000 ~ E10014064
逆变器故障扩展 1E100122 ~ E10040364
逆变器故障扩展 2E100404 ~ E10045625
逆变器告警码W110000 ~ W11010362
逆变器告警扩展 1W110108 ~ W1101375
充电桩故障码(一代 EVC)E200000 ~ E20003121
充电桩故障码(二代 EVC-II)E200000 ~ E20003121
合计336

也就是说,看到码值就能立刻判断来源: E0xxxx 五位数以内是 EMS 自判; E1xxxxx / W11xxxx 来自逆变器; E2xxxxx 来自充电桩; E6xxxx / W7xxxx / P75xxx 来自 BMS。

C屏幕显示格式:怎么读那一行

格式是 E%05u,不是分册里的 E0007

屏幕上故障码 7 显示为 E00007(五位补零), 而内部资料里通常写作 E0007(去掉补零即得)。 格式实现在 hmi/ui/ui_history_error_log.c:139-165, 三条 snprintf 分别在 :146(告警 W)、 :150(已恢复的故障)、:158(正在发生的故障)。

屏幕上的样子含义
   E00007 NO. 1行首没有 NO → 故障正在发生
NO E00007 NO. 1行首有 NO → 该故障已恢复
NO W110001 NO. 2告警码(W 前缀),已恢复

这组标记最容易看反:行首的 NO 才是「已恢复」, 而 NO. 1 里的 NO. 只是序号。 排查时只关注行首没有 NO 的那些行。

D资料本身的不确定之处(如实说明)

以下条目是阅读源码时无法确定、或存在内部矛盾的地方。写在这里是为了让使用者知道结论的强度边界, 而不是把不确定的东西说成确定的。

1 · 编译期机型宏 INVERTER_TYPE 的值不可信

明文基线里 midware/inverter/inverter.h:172 的 INVERTER_TYPE = INVERTER_NULL(0),而厂商分支的条件是 #if ((INVERTER_TYPE == INVERTER_MAX) || (INVERTER_TYPE == XXX_TYPE)), user/EMS_3_0.uvprojx 里也没有任何 INVERTER_TYPE 的宏定义。 按字面读会有两个荒谬结果:

  • bit16 / E0017:条件里 (INVERTER_TYPE == INVERTER_NULL) 恒为真, 则只要 u16_oem_flag != OEM_HHE,E0017 就永远挂着——这显然不是实际出货行为。
  • bit28 / E0092:其前提函数 get_risk_of_system_installation_plan() 整个实现包在 #if ((INVERTER_TYPE == INVERTER_MAX) || (INVERTER_TYPE == SET_TYPE)) 里, 按字面读会恒返回 FALSE,E0092 永远不报。

合理推断是实际量产工程用 -D 在别处覆盖了这个宏(应为 INVERTER_MAX, 因为 inverter.c 里到处是 #if ... || (INVERTER_TYPE == INVERTER_MAX) 配运行时 get_inverter_type() 的写法), 但 platform-ems-3-6/ 是加密二进制、不可读,没有证据。 所以本页对这两条都按「字面条件」与「现场实际含义」两种读法并列,没有替哪一方下判断。 建议后续用能确认机型宏的渠道核实一次。

2 · 几个数值的语义未追到底

  • E0033 的条件 inverter_get_bat_breaker_statuse() == 2: 只照代码写了「逆变器报告电池断路器状态 = 2」, 没有追查这个 2 在逆变器协议里代表「断开」还是「异常」。
  • E0032 的判据 u16_ct_volt_a < 300: 源码没有标注这个量的单位(同文件 cal_grid_vol() 里也没有注释)。 这里按原文写数值 300,没有换算成伏特,请勿直接理解为 300 V。
  • u8_factory_flag(E0031 的条件之一): 只在 data/system_config/system_config.c:108 的 system_sn_init() 里被置 TRUE,另作为 FactoryFlag 参数注册给服务器 (net/server.c:3944)可被下发改写。 没有找到把它置 FALSE 的自动逻辑,所以「什么时候算出厂完成」这一点没查清, 「实际含义是还没走完出厂配置」只是推断。

3 · 两处「任务周期」的说法互相矛盾,本页按源码取

知识库 代码解析/README.md 的任务表与 app_cfg.h 的注释一致, 但与各任务循环尾部的 OSTimeDlyHMSM() 实参在 5 处不同。 本页表 1 采用源码实参,并在括号里标出注释值。差异清单: task_inverter 80 vs 100、 task_inverter_can 30 vs 50、 task_system_logic_control 280 vs 300、 task_net_app 25 vs 30、 task_ethernet_monitor 500 vs 1000。

4 · 本页有一处明确「未核实」

task_ethernet(优先级 17,栈 1024)的稳定周期没有核实: 它的函数体很长(ethernt/ethernet.c:986-1526), 主循环区间内我没有找到固定延时(OSTimeDlyHMSM / OSSemPend), 只有一处「无法自销故障重启系统」分支里的 10 秒延时 ethernt/ethernet.c:1525,那不是循环节拍。 因此表 1 里这一格标为「未核实」,没有采用注释里的 5 ms 说法。 实情可能是该任务的节拍由 W5500 socket 收发阻塞决定,但这一点同样没有验证。

5 · 结论性质与图形工具的说明

  • 本页所有技术结论均来自源码静态阅读,取自知识库 docs/knowledge/,未做编译与板级验证。
  • 图中的 SVG 为手绘内联图形(为统一浅色配色与打印效果), 其中图 3 的功率曲线是示意曲线,非实测数据;其余图形均按代码里的实际结构与数值绘制。
  • 工作区内的 archify 技能已用 node bin/archify.mjs doctor 验证可用, 但本页未采用其输出——原因是需要统一浅色主题与单一文件样式,手绘更直接可控。 这不影响图形所表达的事实,仅说明工具取舍。