# 一套储能 BMS 的充电电流限值，是怎么一步步算出来的

在储能系统里，有一个数字每天都在被追问：**"现在最多能充多少电？"**

这个数字不是拍脑袋定的，也不是简单地从参数表里读出来的。它是一个经过层层约束、反复取最小、还带了一堆防抖逻辑之后才诞生的结果。它决定了 PCS（储能变流器）此刻允许从电网吸取多少功率，也决定了电池能不能安全地、长久地活下去。

这篇文章我想讲清楚这件事。我会以一套真实的量产代码为底本一套基于S32K146 的储能 BMU 主控固件，电流限值的计算就落在 `CurrLimCalc.c`和 `CurrLimFall.c`这两个文件里。我会尽量用第一人称，把我读这段代码时真正想到的东西讲出来：它每一步在防什么、为什么这么设计、以及一个资深工程师看到这些代码时会会心一笑的地方。

先交代一个背景，免得后面看不懂。这套系统是磷酸铁锂（LFP）储能电池，标称容量 280Ah，16 串 × 15 个 PACK 组成一堆，总电压大概在 700V 上下。BMS 每 100ms 算一次限值，算完通过 CAN 报文（协议号 51）上报给 PCS。PCS 拿到这个数，就知道自己该出多少力了。

## 一、先看整个算法的骨架：一个「多约束取最小」的漏斗

我第一次读 `CurrLimCalcTask()` 的时候，印象最深的是它的结构——它不是一个复杂的算法，而是一个**漏斗**。电流限值从最宽松的「硬件上限」出发，每过一个约束就被往下压一次，压到最后，输出的一定是所有约束里**最严格的那个**。

充电这条链，代码是这样走的：

```c
chgHCurr   = GetChgHReqMaxCurr();         /* 1. 硬件上限 */
tempMulLim = CalcTempChgMulCurr();        /* 2. 温度倍率 */
chUpLim    = min(chgHCurr, tempMulLim);   /* 取小 */

if (CalcGetGroupNeedChgLimStateHook())  chUpLim = 0;   /* 3. 故障限流 → 直接 0 */

chUpLim = ChgLimDownByTempH(...);         /* 4. 高温降额 */
chUpLim = ChgLimDownBySubTemp(...);       /* 5. 温差降额 */
chUpLim = ChgLimCurrByPowerLim(...);      /* 6. 功率折算 */

tempMulLim = GetFallChgLimCurr();         /* 7. 末端恒压回落 */
chUpLim = min(chUpLim, tempMulLim);       /* 取小 */

gGLimCPInfo_51[ChgC] = chUpLim;           /* 8. 写进全局数组 */
```

放电那条链几乎是对称的，只是参数不同。这个「漏斗」结构是整个算法的灵魂，我会在文章末尾专门讲它为什么好。

但在讲漏斗之前，我得先讲清楚漏斗的**最上游**——那个「硬件上限」是怎么定的。

## 二、第一个约束：硬件上限，以及它为什么比告警值小 1A

`GetChgHReqMaxCurr()` 这个函数，名义上是「取硬件允许的最大充电电流」，但它里面藏了一个非常细腻的细节：

```c
u16 GetChgHReqMaxCurr(void)
{
    u16 curr = gGBmuGenPara_102[eBmuGenPara102_ChgCMaxLim];   /* 参数里的充电电流上限 */

    /* 如果上限 ≥ 告警值，就把上限压到告警值以下 */
    if (curr >= gGBmuGenPara_102[eBmuGenPara102_ChgCH2Lim])
    {
        if (gGBmuGenPara_102[eBmuGenPara102_ChgCH2Lim] > 20)
        {
            curr = gGBmuGenPara_102[eBmuGenPara102_ChgCH2Lim] - 10;   /* 比告警值小 1A */
        }
        else
        {
            curr = gGBmuGenPara_102[eBmuGenPara102_ChgCH2Lim];
        }
    }
    return curr;
}
```

（注意代码里所有电流的单位都是 0.1A，所以 `-10` 实际上是「减 1A」。）

这个逻辑我读了好几遍才明白它为什么要这么写。它防的是一个很隐蔽、但很致命的隐患：**限值触顶告警值**。

设想一下：如果「允许的最大充电电流」和「过流告警阈值」是同一个值，会发生什么？当 PCS 真的按照这个限值去充电时，电流恰好顶在告警阈值上，任何一个采样毛刺、任何一点过冲，都会立刻触发「过流告警」。于是系统陷入一个尴尬的循环：BMS 让 PCS 充到 100A，PCS 充到 100A，BMS 一看「哎呀 100A 告警了」——但这个 100A 明明是 BMS 自己让 PCS 充的。

所以聪明的做法是：**把限值永远压在告警值以下一点**。你告警值是 101A，我限值就给你 100A，中间留 1A 的余量。这样限值和告警之间就有一道缓冲，不会自己跟自己打架。

这个「留余量」的思想，我在整个 BMS 代码里反复见到，它几乎成了一种工程本能。后面你会看到，它贯穿了所有环节。

## 三、第二个约束：温度倍率——磷酸铁锂的脾气

过了硬件上限，下一个约束是温度。电池不是在任何温度下都能满功率充放电的，尤其是磷酸铁锂，它对温度特别敏感。

代码里的做法是查表。`CalcTempChgMulCurr()` 先把当前平均温度，在一张温度表上定位：

```c
const s16 gTempCurLimitTable[TEMP_CURRLIMIT_NUM] =
{
    -10, -5, 0, 5, 10, 15, 20, 25, 35, 40, 45, 50, 55, 60, 70
};
```

然后去查对应温度档位的「倍率」，再乘上标称容量，得到这一温度下允许的电流：

```c
result = (u32)gGroupParaRO_115[index] * GetGroupStandCapAPI() / 10000;
```

`gGroupParaRO_115` 是一张「不同温度充电倍率表」，存在 EEPROM 里，可配置。比如 25℃ 时倍率是 100%（1C，即 280A），10℃ 时可能只有 60%（0.6C），而到了 0℃ 以下，磷酸铁锂的充电倍率会被压得很低——因为低温充电会析锂，对电池造成不可逆的伤害，甚至带来安全隐患。

这里其实藏着磷酸铁锂和三元锂电池的一个关键区别。三元锂在低温下的充电倍率可以稍微放宽一点，但磷酸铁锂在低温（尤其是 0℃ 以下）充电必须非常保守。所以这张温度倍率表，本质上就是把「电化学的安全边界」翻译成了「软件里的一个数字」。

我在读这段时还注意到一个细节：代码里有一段被 `#if 0` 注释掉的逻辑，是专门处理「低温 + 低 SOC」的极端情况的——温度低于 0℃ 且 SOC 低于 5% 时，直接禁止充电。虽然这段在当前版本被禁用了，但它透露出一个信息：**写这套代码的人，对磷酸铁锂的低温析锂风险是有清晰认知的**。

## 四、第三个约束：高温降额，以及无处不在的「滞回」

温度倍率表解决的是「低温」和「常温」，但「高温」这一侧，代码用了另一套更精细的机制——**线性降额**。

`ChgLimDownByTempH()` 的核心是一行线性插值：

```c
dowmCur = ((u16)(endTemp - nowTemp) * currLim) / (u16)(endTemp - startTemp + 1);
```

意思是：当温度超过 `startTemp`（开始降额的起始温度）之后，每升高一度，电流限值就线性地往下砍一截；等温度升到 `endTemp`（降额到零的温度），限值正好砍到 0。这是一条从 `startTemp` 到 `endTemp` 的**直线**，斜率由这两个温度决定。

这个「线性降额」本身不稀奇，稀奇的是它前后那一堆**滞回（hysteresis）逻辑**。看这段：

```c
if ((nowTemp < sHisTemp) && ((nowTemp + 2) >= sHisTemp))    /* 温度回降 2℃ 以内 */
{
    nowTemp = sHisTemp;                                     /* 视为温度没变 */
}
else if ((nowTemp < sHisTemp) && ((nowTemp + 3) >= sHisTemp)) /* 回降 3℃ */
{
    nowTemp += 1;                                           /* 只回退 1℃ */
    sHisTemp = nowTemp;
}
else
{
    sHisTemp = nowTemp;                                     /* 正常更新 */
}
```

这段代码，我愿称之为整个限流算法里最见功力的一处。它解决的是这样一个问题：

**假设温度正好在降额起始点附近抖动**——比如 `startTemp` 是 45℃，而实际温度在 44.9℃ 和 45.1℃ 之间来回跳。如果没有滞回，那么电流限值就会「进入降额 → 退出降额 → 进入降额 → 退出降额」地疯狂抖动，PCS 收到的指令一会儿高一会儿低，整个系统都在跟着抽搐。

滞回的逻辑就是：**进来的时候痛快地进，出去的时候要拖一拖**。温度往上走，超过 45℃ 就立刻开始降额，不犹豫；但温度往回落的时候，从 45℃ 回落到 44℃，我不急着把限值涨回去，而是再观察一下，确认温度真的稳定回落了，才慢慢恢复。

这个「进来快、出去慢」的滞回，用两三个静态变量就实现了，成本极低，但它避免了系统在边界点上反复横跳。**这几乎是所有做实时控制的工程师刻在骨子里的东西**——任何带阈值的控制，都必须想清楚「边界抖动」这个问题。

## 五、第四个约束：温差降额——保护的是「一致性」

高温降额看的是「绝对温度」，温差降额看的则是「电芯之间的差异」。

`ChgLimDownBySubTemp()` 的输入是 `GetGCellMaxTempAPI() - GetGCellMinTempAPI()`，也就是整堆电池里最高温度和最低温度之间的差值。这个差值大，说明电池堆内部温度分布不均匀，有些电芯热、有些电芯冷。

为什么不均匀就要降额？因为温度不均匀意味着电芯之间的一致性在变差。同一堆电池里，热的那几节衰减更快、内阻更高、电压更容易冲到上限；而冷的那几节还没充饱。如果还维持大电流充电，热的那几节会被逼得更紧，加速劣化，甚至触发单体过压。

所以代码的逻辑是：温差超过某个阈值（`ChgHTDnDifT`）之后，就开始按百分比降额：

```c
if (subTemp >= gGBmuHigLevPara_103[eBmuHigLevPara103_ChgHTDnDifT])
{
    if (nowTemp >= (gGBmuHigLevPara_103[eBmuHigLevPara103_ChgHTDnFstT] + 3))
    {
        /* 温差大 + 温度高 → 降 2 档 */
        dowmCur = currLim - ((u32)currLim * ChgHTDnRate * 2 / 1000);
    }
    else if (nowTemp >= (CalcGetChgCDnFstHTempHook() - 1))
    {
        /* 温差大 + 温度中等 → 降 1 档 */
        dowmCur = currLim - ((u32)currLim * ChgHTDnRate / 1000);
    }
    ...
}
```

注意这里是**分档降额**：温差大但温度不算特别高，降 1 档；温差大而且温度还高，降 2 档。而且同样的滞回逻辑也在这里重演了一遍——温度滞回、温差滞回，都做了。

这个「温差 + 温度」双条件的分档设计，反映的是一个很成熟的判断：**单一的温差或单一的温度都说明不了全部问题，但两者叠加就是危险的信号**。温差大说明一致性差，温度高说明已经接近热边界，两者同时出现，就必须更狠地降额。

## 六、第五个约束：功率折算——把「电流上限」翻译成「电流上限」

过了温度这一关，还有一个看似绕、实则必要的步骤：功率折算。

`ChgLimCurrByPowerLim()` 做的事很简单：

```c
sumVolt = GetGCellSumVoltAPI();          /* 总电压 */
if (sumVolt > 0)
{
    chgPowerCur = ChgPMaxLim * 10000 / sumVolt;   /* I = P / U */
}
```

就是欧姆定律的一个变形：`I = P / U`。给定一个「最大充电功率」`ChgPMaxLim`，除以当前总电压，就得到一个「功率约束下的最大电流」。

为什么要这一步？因为电流和功率是两套不同的约束，都要满足。有时候电流还没到上限，但电压已经很高了，`P = U × I` 会先触到功率上限。这时候真正限制你的不是电流，而是功率。所以要把功率上限也折算成一个电流值，然后和前面的电流限值取小。

这里面其实还藏着一个物理直觉：**电池电压是随 SOC 变化的**。充满时电压高，同样功率对应的电流就小；放空时电压低，同样功率对应的电流就大。所以「功率折算成电流」这一步，必须用**实时电压**去算，不能用固定电压。代码里 `GetGCellSumVoltAPI()` 取的就是实时总电压，这个细节处理对了，限值才准。

## 七、第六个约束：末端恒压回落——CC-CV 的软件实现

这是整个限流算法里，我最想讲的一部分，因为它触及了锂电池充电最本质的东西：**CC-CV 充电特性**。

锂电池的标准充电曲线是这样的：先**恒流（CC）**，电流保持不变，电压慢慢升高；当单体电压升到充电截止电压（对磷酸铁锂，大概 3.65V）附近时，转入**恒压（CV）**，电压保持不变，电流开始逐渐减小，直到电流小到某个阈值（比如 0.05C），充电才算真正结束。

为什么不能一直恒流充到满？因为电池在接近充满时，内部极化加剧，如果继续用大电流硬充，电压会瞬间冲过头，轻则加速衰减，重则析锂、鼓包、热失控。所以临近充满时，必须**让电流降下来**，让电压稳稳地停在截止电压上。

这套「末端恒压」在代码里，是由 `CurrLimFall.c` 实现的。它的核心是一个**台阶式的查表回落**，而不是一个真正的 PID 闭环（PID 部分被注释掉了，作为预留）。

充电侧的入口是 `CVPIDCtrlChgCLim()`。它的逻辑是：实时盯着最高单体电压，一旦发现最高电压进入了末端区间（`maxVolt >= CVCalcChgPIDAimVolt(0)`），就标记「进入恒压阶段」，并记录下这一刻的实际电流作为基准：

```c
/* 进入恒压，记录基准电流 */
if (GetGSampOutCurrAPI() < 0)          /* 正在充电，电流为负 */
{
    if ((0 - GetGSampOutCurrAPI()) <= (GetGBattAllCapAPI() * 7 / 10))
    {
        sFallChgMaxCurr = GetGBattAllCapAPI() * 7 / 10;   /* 基准取 0.7C */
    }
    else
    {
        sFallChgMaxCurr = 0 - GetGSampOutCurrAPI();       /* 基准取当前实际电流 */
    }
}
```

注意这个基准电流的上限是 **0.7C**。这是刻意设计的——如果你进入恒压时电流还高达 1C，那说明之前的恒流阶段充得太猛了，末端回落必须更果断；反过来，如果进入恒压时电流已经很小了，那就以实际电流为基准，慢慢收尾。

然后，随着电压一步步逼近截止电压，目标电流就按一张**分段表**逐档往下掉：

```c
/* 目标电流 = 基准电流 × 分段表百分比 */
curr = (u16)((u32)sFallChgMaxCurr * gGroupParaRO_119[tabNum] / 100);
```

`gGroupParaRO_119` 是一张存在 EEPROM 里的「电压/SOC 平滑回落分段表」，它把末端电压区间分成若干段（`SLOW_CURRLIMIT_NUM` 段），每段对应一个电流百分比。电压每抬升一个档位，电流目标就降到对应的百分比，直到最后降到截止电流（`ChgCFinLim`，比如 0.05C）。

放电侧的末端回落是对称的——盯着最低单体电压，电压越逼近放电截止电压（磷酸铁锂约 2.5V），放电电流限值就越低。

这个「台阶式回落」的好处是**简单、稳定、可配置**。它不需要调 PID 参数，不需要担心闭环震荡，只要把分段表配好了，末端曲线就固定了。对一个以「安全、可靠」为第一优先级的大储能系统来说，这种「宁可简单、不可复杂」的选择，是很务实的。

## 八、最后一步：电流乘电压，得到功率限值

电流限值算完之后，还要顺手把「功率限值」也算出来。代码在 `CurrLimCalcTask` 的末尾：

```c
gGLimCPInfo_51[eLimCPInfo51_ChgC] = chUpLim;   /* 充电电流限值 */
gGLimCPInfo_51[eLimCPInfo51_DhgC] = dhUpLim;   /* 放电电流限值 */

if (eWORK_RUN == GetGWorkStateAPI())
{
    /* 功率 = 电流 × 总电压 */
    gGLimCPInfo_51[ChgP] = (chUpLim * GetGSampSumVoltAPI() + 5000) / 10000;
    gGLimCPInfo_51[DhgP] = (dhUpLim * GetGSampSumVoltAPI() + 5000) / 10000;
}
else
{
    gGLimCPInfo_51[ChgP] = 0;
    gGLimCPInfo_51[DhgP] = 0;
}
```

`+ 5000` 是四舍五入（因为整数除法会截断，加半个单位再除就是四舍五入）。这个细节不大，但能看出写代码的人对数值精度是在意的。

算出来的这四个值——充电电流、放电电流、充电功率、放电功率——就是协议 51 报文的全部内容，通过 CAN 发给 PCS。PCS 拿到之后，就把自己的充放电功率钳在这个范围内。

## 九、回到那个「漏斗」：为什么这个设计是好的

现在回头看第一节的漏斗结构，我想说说它为什么好。

**第一，它把「复杂」藏在了「简单」的骨架里。** 每个约束都是一个独立的函数，各管各的，互不干扰。要加一个新约束（比如将来要加一个「内阻异常的降额」），只需要在漏斗里再插一级取小，不影响其他约束。这是典型的**可扩展性设计**。

**第二，「取最小」本身就是安全的设计哲学。** 你永远输出所有约束里最严格的那个，这就从结构上保证了——**只要有一个约束觉得「危险」，系统就会保守**。这个「宁紧勿松」的倾向，在安全攸关的系统里是正确到不能再正确的选择。

**第三，每个约束都有自己的防抖。** 硬件上限有「比告警值小 1A」的余量，高温降额有温度滞回，温差降额有温差滞回，末端回落有台阶式的平滑。这些防抖让限值曲线变得**平滑、稳定**，而不是在边界点上疯狂抖动。对一个下游是 PCS 这种大功率设备的系统来说，限值的平滑，直接决定了整个系统的稳定。

## 十、写在最后

我读这套限流代码，最大的感受是：**它没有一行是「炫技」的，但每一行都踩在点上。**

它没有用复杂的卡尔曼滤波、没有用神经网络、甚至连 PID 都注释掉了，就用「查表 + 线性插值 + 分档 + 滞回」这些最朴素的手段，把一个对安全至关重要的功能做到了稳、准、可配置。

这其实是一个很深刻的道理：**在安全攸关的工业系统里，「简单可靠」永远比「高级」值钱。** 你能用一个别人一眼就能看懂、三年后还能维护、现场出了故障能快速定位的算法解决问题，这本身就是一种能力，而且是一种比「会用高级算法」更稀缺的能力。

如果你也在读这套代码，我建议你带着这三个问题去读：为什么限值要永远比告警值小一点？为什么高温降额要有滞回？为什么末端要用台阶式而不是 PID？这三个问题想通了，你就不仅读懂了代码，还读懂了写代码的人——读懂了一个在高压、大电流、安全责任面前，选择用朴素手段守住底线的工程师。
