MCU 固件启动即崩,bootloader 如何发现

MCU 固件启动即崩,bootloader 如何发现

向量表检测的盲区

MCU 的 bootloader 在跳转 App 之前,一般会读一次 App 分区首地址,把里面的第一个字当成初始栈指针,检查它是否落在 SRAM 范围内。在范围内就跳转,不在就认为 App 损坏。

先看 App 的 bin 在 Flash 里怎么摆放。以 STM32F4 为例,App 烧在内部 Flash 的 App 分区,bin 就是从分区起始地址开始逐字节铺开的一份镜像:

复制代码
App 分区起始地址
│
├─ 0x00  初始栈指针 MSP           ← 向量表第 0 项
├─ 0x04  复位向量 Reset_Handler   ← 向量表第 1 项
├─ 0x08  NMI 处理函数
├─ 0x0C  HardFault 处理函数
├─ ...   其余异常/中断向量
│
├─ .text    代码段(真正的程序逻辑)
├─ .rodata  只读常量
└─ .data    已初始化变量的初值

向量表固定在固件最开头,所以所谓的向量表检测,代码上只有这么几行:

c 复制代码
uint32_t sp = *(volatile uint32_t *)app_addr;   /* 读向量表第 0 项 = 初始 SP */
if (sp 落在 SRAM 地址范围内)                    /* 它像不像一个 RAM 地址 */
    jump_to_app();

读的是开头 4 个字节,判断标准只有一条:这个值在不在 SRAM 范围里。

这个检查漏掉的情况不少。代码段排在向量表后面,掉电或者坏块可能只擦坏中间几页,开头 4 字节还完好,检测就放行,程序跑到坏代码上 HardFault。SP 的判断范围是整段 SRAM,几十上百 KB,被破坏之后碰巧落进去的概率不低。复位向量就在 SP 后面 4 个字节,检测从来不读它,PC 坏成非法地址照样往里跳。

还有一种情况是固件一个字节都没坏,SP、PC、代码全对,只是启动逻辑有 BUG。检测必然通过,然后进入跳转、崩溃、看门狗复位、再跳转的循环。现场看到的现象是设备能上电、串口有输出,就是起不来,也没人知道它到底重启了多少次。

所以这里要回答的问题是固件有没有真正跑起来过,它的向量表像不像真的只是其中一小部分。

用 RTC 备份寄存器记失败次数

做法类似软件看门狗。bootloader 每次启动把计数加一,然后跳 App;App 完成核心初始化之后把计数清零。如果 App 起不来,清零这步永远走不到,计数一次一次往上累,累到阈值(比如 5)时 bootloader 判定固件损坏,不再跳转,转去恢复流程。
App Bootloader App Bootloader #mermaid-svg-tqlmxboM5BGCAbkA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-tqlmxboM5BGCAbkA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-tqlmxboM5BGCAbkA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-tqlmxboM5BGCAbkA .error-icon{fill:#552222;}#mermaid-svg-tqlmxboM5BGCAbkA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-tqlmxboM5BGCAbkA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-tqlmxboM5BGCAbkA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-tqlmxboM5BGCAbkA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-tqlmxboM5BGCAbkA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-tqlmxboM5BGCAbkA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-tqlmxboM5BGCAbkA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-tqlmxboM5BGCAbkA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-tqlmxboM5BGCAbkA .marker.cross{stroke:#333333;}#mermaid-svg-tqlmxboM5BGCAbkA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-tqlmxboM5BGCAbkA p{margin:0;}#mermaid-svg-tqlmxboM5BGCAbkA .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tqlmxboM5BGCAbkA text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-tqlmxboM5BGCAbkA .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-tqlmxboM5BGCAbkA .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-tqlmxboM5BGCAbkA .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-tqlmxboM5BGCAbkA .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-tqlmxboM5BGCAbkA #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-tqlmxboM5BGCAbkA .sequenceNumber{fill:white;}#mermaid-svg-tqlmxboM5BGCAbkA #sequencenumber{fill:#333;}#mermaid-svg-tqlmxboM5BGCAbkA #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-tqlmxboM5BGCAbkA .messageText{fill:#333;stroke:none;}#mermaid-svg-tqlmxboM5BGCAbkA .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tqlmxboM5BGCAbkA .labelText,#mermaid-svg-tqlmxboM5BGCAbkA .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-tqlmxboM5BGCAbkA .loopText,#mermaid-svg-tqlmxboM5BGCAbkA .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-tqlmxboM5BGCAbkA .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-tqlmxboM5BGCAbkA .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-tqlmxboM5BGCAbkA .noteText,#mermaid-svg-tqlmxboM5BGCAbkA .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-tqlmxboM5BGCAbkA .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tqlmxboM5BGCAbkA .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tqlmxboM5BGCAbkA .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tqlmxboM5BGCAbkA .actorPopupMenu{position:absolute;}#mermaid-svg-tqlmxboM5BGCAbkA .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-tqlmxboM5BGCAbkA .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tqlmxboM5BGCAbkA .actor-man circle,#mermaid-svg-tqlmxboM5BGCAbkA line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-tqlmxboM5BGCAbkA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ① 正常启动:App 主动清零 下次上电重新 +1,计数永远在 0~1 之间 ② 循环重启:清零永远不发生 loopApp 起不来(崩溃 / 跑飞) 看门狗复位,回到 bootloader,计数继续累加 计数 +1跳转核心初始化完成清零计数(归 0)计数 +1跳转崩溃计数达阈值 → 判定损坏,进入恢复

这段逻辑不关心 App 为什么坏,只关心它有没有活到清零那一刻。向量表坏、代码段坏、固件带 BUG 启动就崩,都会让计数涨上去。

计数器需要一个重启不丢、复位也不丢的位置。RTC 备份寄存器(BKP)满足这个条件,软复位和看门狗复位后内容都在,只有 VBAT 掉电才会丢。

寄存器的分配

RTC 备份寄存器在 VBAT 掉电后是随机值。计数器如果直接放一个整数,彻底断电再上电,寄存器里可能是 0x7FFFFFFF,bootloader 会当成失败次数已经爆掉,直接进恢复流程。

所以要配一个模数。一个 32 位 BKP 寄存器切成两段:

复制代码
┌─────────────────────────────┬──────────┐
│      高 29 bit:模数 magic   │ 低 3 bit │
│   (固定值 0x5A5A5A58)        │ 计数 0~7 │
└─────────────────────────────┴──────────┘

低 3 位放计数,取值 0 到 7;高 29 位放固定模数 0x5A5A5A58。读的时候先比高 29 位,对不上说明这是掉电后的随机值,计数从 0 开始算;对得上才认低 3 位。随机值撞上这个模数的概率是 2⁻²⁹,大约五亿分之一。

c 复制代码
#define OTA_BOOT_FAIL_COUNT_MASK      0x00000007u   /* 低 3 bit: 计数 */
#define OTA_BOOT_FAIL_COUNT_MAGIC     0x5A5A5A58u   /* 高 29 bit: 模数 */
#define OTA_BOOT_FAIL_COUNT_THRESHOLD 5u            /* 连续失败阈值 */

uint32_t ota_boot_fail_count_inc(void)
{
    uint32_t reg   = RTC->BKP1R;
    uint32_t count;

    if ((reg & ~OTA_BOOT_FAIL_COUNT_MASK) != OTA_BOOT_FAIL_COUNT_MAGIC)
        count = 0;                    /* 寄存器非法(掉电随机),从 0 重算 */
    else
        count = reg & OTA_BOOT_FAIL_COUNT_MASK;

    if (count < OTA_BOOT_FAIL_COUNT_MASK)
        count++;                      /* 封顶 7,不溢出 */

    RTC->BKP1R = OTA_BOOT_FAIL_COUNT_MAGIC | count;
    return count;
}

void ota_boot_fail_count_clear(void)
{
    RTC->BKP1R = OTA_BOOT_FAIL_COUNT_MAGIC;  /* 计数归零,保留模数 */
}

两侧各做什么

bootloader 启动时加一,用返回值决定要不要强制恢复:

c 复制代码
/* bootloader 主流程 */
uint32_t boot_count = ota_boot_fail_count_inc();   /* +1,并读当前计数 */
if (boot_jump_app_valid())     /* 向量表检查仍在,作为第一道粗筛 */
{
    boot_jump_to_app();             /* 跳 App,成功后 App 自行清零 */
}
else if (boot_count >= OTA_BOOT_FAIL_COUNT_THRESHOLD)
{
    enter_recovery();               /* 连续失败达阈值,判定 App 损坏 */
}

App 在核心初始化完成之后清零,这是固件活着的唯一证据:

c 复制代码
/* App 主流程 */
int app_start(void)
{
    init_all();                     /* 初始化外设、模块、FlashDB 等 */

    ota_boot_fail_count_clear();    /* 初始化成功 = 正常启动,清零计数 */

    start_scheduler();              /* 进入主循环 / 调度器,不再返回 */
    return 0;
}

清零的位置要放在核心初始化后面。App 如果在初始化中途崩了,clear 这行执行不到,计数不会被清,下一轮 bootloader 继续加一,直到触发阈值。

为什么不用 Flash

看一下写入频率就清楚了:bootloader 每次启动写一次,App 启动成功再写一次,设备正常开机一次就要写同一个位置两遍。每天开关几十次,一年几万次,NOR Flash 的擦写寿命典型只有 10 万次,撑不了多久,擦写过程中掉电还有写坏数据的风险。

RTC 备份寄存器是寄存器,读写次数没有限制,也没有擦写延迟,完全不需要等待。启动计数这种每次上电都要写、丢了也无所谓的场景,用它比用 Flash 合适。Flash 留给升级固件、保存参数这类低频但要持久的数据。

为什么不用 CRC

CRC 能发现 Flash 内容变了,但在这个场景下有两个问题。

CRC 的用法是算出一个值,跟事先存好的参考值比对。参考值必须和 Flash 里的固件严格一致,而这个一致性只在 OTA 下载时天然成立:下载流程边收边算,最后把 CRC 写进参数区或者固件头。

换一条烧录路径,这条链就断了。用 J-Link、ST-Link、DAP-Link 直接烧录时,工程师烧的是编译出来的 .hex 或 .bin,没人会去同步更新那笔 CRC。烧完上电,bootloader 一算,跟参数区里的旧值对不上(很多时候是 0),一个正常的固件就被判成损坏,触发强制恢复。产线首次烧录也一样,除非流程里额外加一步"烧录后写 CRC",而这一步很容易漏。

要修就得在构建和烧录流程里加 CRC 后处理,还要保证 J-Link 调试、产线烧录、OTA 每条路径都覆盖到。有人图省事直接 J-Link 烧一次,问题就又回来了。

第二个问题更麻烦。CRC 只能说明 Flash 里的字节没被改过,说明不了固件能不能跑。App 部分损坏,内容变了,CRC 能发现;固件有严重 BUG 一启动就崩,内容没变,算出来的 CRC 完全正确,CRC 什么也发现不了。

启动失败计数正好绕开这两点。它不依赖任何需要维护的记录,只看固件有没有在初始化完成后主动来清零。这个动作是固件自己发起的,所以跟烧录方式无关:OTA 烧的、J-Link 烧的、产线烧的,只要能跑都会清零;固件有 BUG 跑不起来就不会清零,计数一路涨到阈值。

CRC 校验 启动失败计数
检测对象 静态损坏(Flash 内容变了) 动态故障(跑不起来 / 循环重启)
触发速度 第一次启动即发现 连续 N 次失败才触发
烧录方式 依赖 CRC 记录维护,J-Link 直烧会误判 无关
维护成本 需构建/烧录流程统一加 CRC 无

两者可以一起用。CRC 做第一道快速筛查,前提是记录的维护跟得上;启动失败计数兜住 CRC 发现不了的运行时问题,以及记录缺失导致的误判。

放进完整的恢复流程

启动失败计数只能发现问题,解决还得靠恢复机制。bootloader 的流程是这样:
#mermaid-svg-f8ERPHjsIj9O9wvZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-f8ERPHjsIj9O9wvZ .error-icon{fill:#552222;}#mermaid-svg-f8ERPHjsIj9O9wvZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-f8ERPHjsIj9O9wvZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .marker.cross{stroke:#333333;}#mermaid-svg-f8ERPHjsIj9O9wvZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-f8ERPHjsIj9O9wvZ p{margin:0;}#mermaid-svg-f8ERPHjsIj9O9wvZ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .cluster-label text{fill:#333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .cluster-label span{color:#333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .cluster-label span p{background-color:transparent;}#mermaid-svg-f8ERPHjsIj9O9wvZ .label text,#mermaid-svg-f8ERPHjsIj9O9wvZ span{fill:#333;color:#333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .node rect,#mermaid-svg-f8ERPHjsIj9O9wvZ .node circle,#mermaid-svg-f8ERPHjsIj9O9wvZ .node ellipse,#mermaid-svg-f8ERPHjsIj9O9wvZ .node polygon,#mermaid-svg-f8ERPHjsIj9O9wvZ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-f8ERPHjsIj9O9wvZ .rough-node .label text,#mermaid-svg-f8ERPHjsIj9O9wvZ .node .label text,#mermaid-svg-f8ERPHjsIj9O9wvZ .image-shape .label,#mermaid-svg-f8ERPHjsIj9O9wvZ .icon-shape .label{text-anchor:middle;}#mermaid-svg-f8ERPHjsIj9O9wvZ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-f8ERPHjsIj9O9wvZ .rough-node .label,#mermaid-svg-f8ERPHjsIj9O9wvZ .node .label,#mermaid-svg-f8ERPHjsIj9O9wvZ .image-shape .label,#mermaid-svg-f8ERPHjsIj9O9wvZ .icon-shape .label{text-align:center;}#mermaid-svg-f8ERPHjsIj9O9wvZ .node.clickable{cursor:pointer;}#mermaid-svg-f8ERPHjsIj9O9wvZ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .arrowheadPath{fill:#333333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-f8ERPHjsIj9O9wvZ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-f8ERPHjsIj9O9wvZ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-f8ERPHjsIj9O9wvZ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-f8ERPHjsIj9O9wvZ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-f8ERPHjsIj9O9wvZ .cluster text{fill:#333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .cluster span{color:#333;}#mermaid-svg-f8ERPHjsIj9O9wvZ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-f8ERPHjsIj9O9wvZ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-f8ERPHjsIj9O9wvZ rect.text{fill:none;stroke-width:0;}#mermaid-svg-f8ERPHjsIj9O9wvZ .icon-shape,#mermaid-svg-f8ERPHjsIj9O9wvZ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-f8ERPHjsIj9O9wvZ .icon-shape p,#mermaid-svg-f8ERPHjsIj9O9wvZ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-f8ERPHjsIj9O9wvZ .icon-shape .label rect,#mermaid-svg-f8ERPHjsIj9O9wvZ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-f8ERPHjsIj9O9wvZ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-f8ERPHjsIj9O9wvZ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-f8ERPHjsIj9O9wvZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
上电 / 复位
启动失败计数 +1
计数 >= 阈值?
判定 App 损坏,进入恢复流程
App 向量表有效?
跳 App,App 正常启动后清零计数

计数一到阈值就不再跳固件,直接走恢复分支。固件不管是完全损坏、部分损坏还是带病循环重启,设备都不会硬砖,要么自动回滚,要么停在下载模式等人来处理。

几个需要注意的地方

计数器跨复位累加的前提是 RTC 备份域还有电。崩溃后由看门狗复位(IWDG 或者 NRST)没问题;如果是断电冷启动、VBAT 又没接电池,RTC 掉电,计数每次都从 1 重新开始。模数保证这种情况下不会误判,但断电场景下计数不累计。实际遇到的循环重启绝大多数是看门狗复位,不是反复断电。

3 位计数的上限是 7,阈值取 5 表示连续 5 次起不来就动手。阈值太小容易误伤,偶发一次启动失败就被判死;太大恢复太慢。具体取值看设备的看门狗超时和现场能接受多长的停机时间。

清零时机要选在核心初始化之后。一进 main 就清零的话,App 在初始化中途崩了计数早就归零,等于没做。

小结

向量表检测只能看出固件像不像一份有效的固件,启动失败计数看的是它有没有真的跑起来。一个 32 位 RTC 寄存器、一个模数、一个阈值,几十行代码,不占存储介质,也不增加 Flash 磨损。

相关推荐
Zevalin爱灰灰1 小时前
Linux入门第一部分(Ubuntu入门篇) 第二章 浅尝Ubuntu(二)
linux·嵌入式·1024程序员节
泡海椒1 小时前
JQuick-SQL REST API 数据接入:应用层调用接口后注册分析表
数据库·sql·1024程序员节
_MH88881 小时前
2026.10.24|距离上一次写 CSDN,刚好过去了一年
1024程序员节
XIU YUAN1 小时前
ESP32-C6 专业游戏合集系统 - 打造你的掌上游戏机
1024程序员节
程序猿乐锅1 小时前
【从0-1学习Python | 第七篇】Python 面向对象基础
开发语言·笔记·后端·python·学习·1024程序员节
lzqrzpt1 小时前
太阳能控制器选型要点:电路工艺与工程适配技术解析
python·1024程序员节
前端 贾公子1 小时前
LangGraph == 静态分支
1024程序员节
北极星电力网1 小时前
南方区域电力市场正式运行 全国统一电力市场体系建设取得重大突破
1024程序员节