SYS NOMINAL

CHANNEL 01 / ANIM

Anim

Knowledge Bureau · 嵌入式系统与技术档案

DOC NO. ANIM-20261022-4A TRANSMISSION REV 4.5.1 PAGE 01 / 01

TRANSMISSION ANIM-20261022-4A

4KB RAM 里的三路风扇:Desktop_Temperature 固件拆解

从纯中断架构、关机散热状态机到 20KB 应用区的 OTA,记录 CW32L010 散热固件如何把失败路径也算进设计里。

CONTENTS / 目录6 LABELS

这是《智能散热小桌板》的固件篇。硬件、上位机与安装说明在 智能散热小桌板;这篇只看一块 CW32L010 里的代码。

固件快照:应用 1.2.4、协议 1.1、引导程序 0.1.4。下面提到的行为都以这份代码为准。

小桌板的风扇控制,表面上看很简单:电脑把温度算好,通过蓝牙发一个字符,单片机设置三路 PWM。

但把需求往后推一步,问题就变了:

  • 电脑关机了,蓝牙断了,风扇还要继续吹三分钟;
  • 串口收到半条指令,后面的所有指令不能跟着失效;
  • 固件升级到一半掉电,设备不能只剩一个刷不回去的引导程序;
  • 4KB RAM、20KB 应用区,没有 RTOS,也没有动态内存。

这篇文章拆的就是这些“往后一步”的问题。

先把约束钉死

这块板子的主控是武汉芯源的 CW32L010F8,Cortex-M0+,8MHz。资源表比功能列表更能解释代码为什么长这样:

项目 数值 对设计的影响
Flash 64KB 引导程序、应用、暂存区、元数据都要提前分区
应用区 20KB 当前镜像约 16.6KB,余量不到 4KB
RAM 4KB 全局变量按字节算,不能有动态分配
堆 / 栈 512B / 1024B 栈顶还要专门留 16B 给 OTA 标志
主频 8MHz 没有 FPU,也没有硬件除法器
PWM 2kHz / 1000 级 比较值直接等于协议里的 0–1000

“没有硬件除法器”不是一句性能备注。它意味着任何一次 / 或 % 都会拉进 __aeabi_uidiv 之类的库函数;在中断里做这件事,时间和代码体积都要重新算。

温度控制板

主循环只做两件事

初始化顺序是固定的:时钟、BLE 串口、BLE 连接检测、风扇电源、PWM、版本查询、OTA 入口、关机散热、NVIC。之后进入一个几乎没有逻辑的主循环:

1
2
3
4
while (1) {
ShutdownCooling_Poll();
__WFI();
}

__WFI() 让 CPU 进入 Sleep。注意是 Sleep,不是 Deep Sleep:PWM、UART、GPIO 都还要继续工作,风扇不能因为 CPU 睡了就停。

真正的事情分散在中断里:

中断 优先级 负责什么
UART2 0(最高) 接收 BLE 透传字节,分发给四个解析器
GPIOB 默认 PB4 双边沿,检测 BLE 连接 / 断开
BTIM1 默认 关机散热的 1 秒倒计时
SysTick 默认 1ms 时基,只用于指令帧的接收超时

中断只做短操作:减一个计数、置一个标志、收一个字节。关风扇、回传、Flash 操作这类可能耗时的事情,统一放到主循环的 ShutdownCooling_Poll() 里。这样做的代价是主循环不能省,但换来了中断响应时间和逻辑边界的可控。

1
2
3
4
5
6
7
8
9
10
TCV3 上位机
│ BLE
▼
BT24-T 透传模块
│ UART2 / 115200
▼
CW32L010
├── GTIM1 CH2/3/4 ──> PA3/PA4/PA5 ──> 三路 PWM
├── PB1/PB2/PB3 ──> 三路风扇电源
└── PB4 <── BLE 连接状态

三路 PWM:2kHz 与 1000 级

三路风扇分别接在 PA3、PA4、PA5,对应 GTIM1 的 CH4、CH3、CH2。PWM 初始化里有两个关键数字:

1
2
GTIM_InitStruct.Prescaler   = 4 - 1;
GTIM_InitStruct.ReloadValue = 1000 - 1;

8MHz ÷ 4 ÷ 1000 = 2kHz,同时得到 1000 级分辨率,也就是 0.1%。

这里有一个很舒服的巧合:ARR + 1 正好是 1000,所以比较值可以直接写成协议里的 0–1000,不需要做 duty × 分辨率 ÷ 1000 的换算。对没有硬件除法器的 M0+ 来说,这不只是省一条指令,而是让中断路径里少一个不确定因素。

代价是 2kHz 落在人耳比较敏感的频段,低速档可能听到轻微啸叫。项目里试过把 PWM 提到 25kHz(PSC=0、ARR=319),啸叫会消失,但 320 级分辨率和“0–1000”的协议语义对不上,需要额外换算;最后为了不扩大改动面,还是回到了 2kHz。这是一个典型的取舍:听得见的噪声、控制精度、协议一致性,只能选两个。

一个 TurnOff 留下的坑

FanPWM_TurnOff() 不只是把占空比写成 0,它还会禁用三个输出通道,并停掉整个 GTIM1。而 FanPWM_SetMode() 只写比较寄存器,FanPWM_SetDutyCycle() 只负责使能通道——它们都不会重新启动定时器。

于是第一版关机散热收尾后再点模式按钮,风扇不会转:定时器已经停了。

后来补了一个显式的恢复函数:

1
2
3
4
5
6
7
void FanPWM_Restore(void)
{
GTIM_Cmd(CW_GTIM1, ENABLE);
GTIM_OC4Cmd(CW_GTIM1, ENABLE);
GTIM_OC3Cmd(CW_GTIM1, ENABLE);
GTIM_OC2Cmd(CW_GTIM1, ENABLE);
}

这类问题很难靠读主路径发现,只有把“关机散热结束 → 再发模式指令”这条路径真的走一遍,才会暴露。

一条 UART,四个解析器

BLE 模块是透传的,所有字节原样落到 UART2。但同一条字节流上跑着四种东西:

  • 单字符风扇模式:'0' ~ '6'
  • 版本查询:V\n
  • 关机散热帧:T<分钟>\n
  • OTA 请求:OTA\n

每个解析器都必须遵守同一个规则:只有收到自己的帧头,才消费后续字节。

原因很直接:T1\n 里的 '1' 同时也是一个合法的风扇模式。如果分发顺序写错,启动满速散热的同一条指令会顺手把风扇设成 25%,甚至在倒计时中误判成“用户切换模式”,回一个 CAN 把散热取消掉。

UART 中断里的实际顺序是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
FwVersion_InputByte(mode);
OtaApp_InputByte(mode);

const uint8_t in_shutdown_frame = ShutdownCooling_ParserBusy();
ShutdownCooling_InputByte(mode);

if (!in_shutdown_frame && mode >= '0' && mode <= '6') {
if (ShutdownCooling_IsRunning()) {
ShutdownCooling_Cancel();
}
FanPWM_Restore();
FanPWM_SetMode((int8_t)mode);
}

注意 in_shutdown_frame 是在喂入当前字节之前读取的。ParserBusy() 也没有使用“上一个字节是否被消费”的遗留标志,而是直接看解析器的当前状态。早期版本用的是遗留标志,结果整帧处理完后标志还停在 1,紧跟 T1\n 后面的第一个模式字符会被误判成散热帧的延续,直接丢掉。这个 bug 不影响主路径,却会让“先设置散热、再点模式”这种真实操作偶发失灵。

半帧不能把整条链路卡死

BLE 通知可能被拆成多条,T3\n 可能只到了 T3。如果解析器一直停在“等待数字”的状态,后面的所有指令都会被当成它的延续。

所以关机散热解析器有一个 1 秒超时:帧头之后超过 1 秒没有新字节,就回 TO.<已解析位数>.<秒数>\n,然后复位到空闲。上位机收到 TO 会自动重发一次;不需要用户知道发生过什么。

关机散热:电脑关了,风扇还要再吹一会儿

这个功能的场景很具体:用户点击“关机散热”,Windows 开始关机,TCV3 退出,BLE 断开。此时风扇必须继续全速运行 N 分钟,然后自己断电。

所以倒计时不能放在上位机,也不能依赖蓝牙心跳。它必须在 MCU 里独立完成。

协议是一条带结束符的 ASCII 帧:

1
2
3
T3\n     → 全速散热 3 分钟后停风扇
T60\n → 上限 60 分钟
T0\n → 拒绝(WAIT);取消请重新选择模式

收到合法帧后,ScBegin() 做四件事:

1
2
3
4
5
6
7
8
9
10
11
12
FanPower_On();
FanPWM_Restore();
FanPWM_SetMode(FAN_PWM_MODE_6); /* 全速 */

s_remaining_sec = total_sec;
s_finished = 0;
shutdown_cooling_active = 1;

ScStartSecondTick(); /* BTIM1: 1 秒节拍 */
ScSend("OK.");
ScSendUint(total_sec);
ScSend("\n");

计时资源分得很清楚:

  • BTIM1:PSC=7999、ARR=999,8MHz → 1Hz,负责倒计时。GTIM1 已经被 PWM 占用,基本定时器正好做纯计时。
  • SysTick:HAL 的 1ms 时基,只负责帧接收超时。1 秒粒度对“半帧”来说太粗了。

状态变量只有十几个字节:剩余秒数、解析状态、已解析分钟数、位数、帧起始 tick、完成标志。没有数组,没有动态内存。

几个必须写对的状态

场景 行为
倒计时中收到模式指令 取消散热,回 CAN,随后按新模式运行
倒计时中收到新的 T<n>\n 以新时长重新计时,不会出现两套倒计时
倒计时中 BLE 断开 不关风扇,散热继续走完
正常结束 停 BTIM1,先关 PWM,再关风扇电源,回 FIN
结束后再发模式指令 立即恢复,不锁定
帧被截断 1 秒后回 TO,解析器复位

其中“倒计时中 BLE 断开”需要特别小心。BLE 断开中断平时会关风扇电源;如果这里无条件执行,关机散热会被提前中止。所以断开分支先看一眼 ShutdownCooling_IsRunning(),正在散热就跳过关风扇动作。这是 v1.2.4 修掉的问题——之前的版本会在蓝牙断开时把散热打断。

结束收尾也有顺序:先停 PWM,再断风扇电源。反过来的话,电源断开瞬间 PWM 可能仍为高电平,风扇会在掉电过程中被灌入一段无效驱动。

还有一个容易忽略的细节:ScFinish() 回完 FIN 后,如果 BLE 仍然连接,会把风扇电源重新打开、PWM 设为 0%,让风扇处于“待命”状态;如果 BLE 已经断开,就保持断电。这样上位机收到 FIN 切回自动模式时,下一帧模式指令可以立即生效。

实测

关机散热用直连探针验证过:OK.60 到 FIN 相隔约 60.5 秒,误差约 0.9%;中途主动断开 BLE 80 秒,倒计时仍然走完并自行关风扇,重连后设备正常。

这比“功能能跑”更重要:它证明倒计时真的没有偷偷依赖上位机。

OTA:让 20KB 应用区可以自我更新

当应用区只有一份时,原地升级是危险的:传输到一半掉电,设备就只剩引导程序,连“重新升级”的入口都可能没有。

这个项目把 Flash 拆成了四块:

地址 大小 用途
0x0000 – 0x4FFF 20KB 引导程序,常驻,日常不重烧
0x5000 – 0x9FFF 20KB 应用镜像
0xA000 – 0xEFFF 20KB OTA 暂存区
0xF000 – 0xF1FF 512B 元数据页(升级结果)

暂存区:先别动正在运行的应用

数据帧先写到 0xA000 的暂存区,不碰 0x5000。传输期间设备照常运行旧固件;掉电只会损坏暂存区,旧应用毫发无损。只有整镜像 CRC32 校验通过后,引导程序才把暂存区搬到应用槽。

这不能消除风险,但把风险窗口从“整个传输过程”缩短到了“校验通过后的搬运过程”。这是很典型的嵌入式取舍:不是让失败不发生,而是让失败发生在损失最小的位置。

请求标志:放在 SRAM 顶部

应用收到 OTA\n 后,不自己擦 Flash,而是往 SRAM 顶部写一个请求标志,回 OTAOK\n,然后软复位:

1
2
3
4
req->magic  = OTA_REQ_MAGIC;      /* "OTA1" */
req->invert = ~OTA_REQ_MAGIC; /* 双重校验 */
SendString("OTAOK\n");
NVIC_SystemReset();

为什么不用 Flash 标志?因为写 Flash 要开时钟、解锁、擦页,任何一步失败,外部表现都只是“点了升级没反应”。写 SRAM 没有这些失败模式;而 Cortex-M0+ 的软复位不会清 SRAM,标志能活到引导程序。

这个标志放在 0x20000FF0,栈顶在 0x20000FF0,栈向下生长,永远不会踩到它。这个不变量在两个链接脚本里都有 ASSERT 兜底。项目曾经把标志放在会被栈覆盖的位置,现象就是“命令发了、设备复位了、但照常启动旧应用”——看起来像升级入口失效,实际是标志已经被栈踩掉了。

启动闸门:坏固件不能把设备锁死

暂存区解决了传输安全,但还有一种更隐蔽的失败:固件结构合法、CRC 也正确,但初始化到一半就崩了。设备每次启动都进这个坏固件,OTA 入口也进不去,只能拆机。

所以引导程序在跳转应用之前,把 0x20000FF8 置为 PENDING;应用初始化完成后写 DONE。如果下一次启动看到的还是 PENDING,就累计一次启动失败。连续 3 次失败,引导程序不再跳转,停在 OTA 模式等重刷。

应用侧这一行很简单:

1
*(volatile uint32_t *)OTA_BOOT_FLAG_ADDR = OTA_BOOT_FLAG_DONE;

它必须放在所有外设初始化完成之后、进入主循环之前:太早,坏固件也会被确认;太晚,正常固件要等很久才算数。

配套地,HardFault_Handler 从原来的 while (1) 改成了 NVIC_SystemReset()。死循环不会产生“下一次启动”,闸门永远不会计数;复位后才有“重启 → 计数 → 停 OTA”的恢复路径。偶发 HardFault 也可能靠复位自愈。

OTA 协议:控制帧 + 数据帧 + 逐帧应答

控制指令是 ASCII,数据是二进制:

发送 回传 作用
OTA\n OTAOK\n 进入升级模式
<数据帧> A.XX\n / E.XX\n 逐帧 ACK / NAK
OTAE\n OTAOK\n / OTAFAIL\n 结束传输、校验并激活
OTAQ\n OTAQOK\n 放弃升级,回到原应用
V\n V<版本>\n 查询当前运行的程序版本

数据帧是 0xA5 + 序号 + 长度 + 负载 + CRC16,负载最多 128 字节;CRC16 使用 CCITT-FALSE,覆盖序号、长度和负载。每帧都等应答,而不是一口气连发——BLE 透传模块有内部缓冲,无应答连发必然会丢帧,而丢一帧,整镜像 CRC 就必然失败。

另外两个时间参数也很关键:

  • 500ms:帧内字节间隔超时。半帧超时后丢弃并回到等待控制指令的状态,否则后续字节都会被当成它的延续;
  • 300ms:OTA 成功应答发出后、复位前的等待。BLE 模块转发需要时间,发完立刻复位,上位机可能收不到那条 OTAOK,把一次成功的升级误判为失败。

版本号不是装饰

这里有三个版本号,各管各的:

  • 应用版本 1.2.4:固件功能变化时递增;
  • 协议版本 1.1:上位机需要同步适配时才递增;
  • 引导程序版本 0.1.4:独立于应用。

V\n 的答案取决于当前跑的是哪个程序:应用回 V1.2.4.1.1,引导程序回 V0.1.4。上位机通过 V0. 前缀判断“现在是引导程序在说话”,从而知道升级请求真的被接管了。

同一版本号只能对应一份固件。已烧录的版本号不能复用,否则无法判断设备里到底是哪一版。版本号、协议号、文档三者对不上,比功能少一个更麻烦——因为你会开始怀疑设备。

测试:把状态机从板子上拿下来

这类固件最难测的不是“正常发一条指令”,而是边界:半帧、超时、取消、断开、掉电、坏固件。

项目里做了两层验证:

  1. 宿主机逻辑测试:把真实的 shutdown_cooling.c 编译到 PC 上,HAL 全部打桩,直接断言它发出的 OK / WAIT / TO / FIN / CAN。覆盖 T0、T60、T61、T123、半帧超时、重复取消、倒计时中重新下发等边界;
  2. 硬件在环:直连 BLE 探针,验证真实定时器、真实 UART、真实风扇电源。

OTA 也做了端到端升级验证:应用受理请求 → 软复位 → 引导程序接管 → 传镜像 → 校验 → 搬移 → 新应用启动并自报版本。应用镜像目前约 16.6KB,20KB 的应用区已经用掉 80% 左右;下次再加功能,先要考虑的是 Flash 分区,而不是先写代码。

复盘

写完这块固件,我最大的感受不是“4KB 真小”,而是:资源限制最终会变成行为约束。

  • 没有硬件除法器,所以 PWM 分辨率最好取 2 的幂,或者让比较值直接等于协议值;
  • RAM 只有 4KB,所以状态机不能保存历史,只能保存“下一步做什么”;
  • 应用区只有 20KB,所以 OTA 不能做双备份,只能用暂存区 + 启动闸门,把风险压缩到可接受的范围;
  • 中断栈只有 1KB,所以中断里不能做 Flash 操作和长回传,必须把收尾放到主循环。

另一个体会是:回传不是日志,是协议的一部分。 上位机收到 WAIT / TO 会自动重发,所以这两个回传必须准确;FIN 会触发自动模式,所以收尾必须只执行一次;V 会被用来判断版本兼容性,所以版本号不能写死在文档里,只能来自 main.h 和 boot/ota.h。

小桌板最终只是一个会吹风的小物件。但在这块 4KB 的芯片里,风扇、定时器、串口、Flash 和失败恢复被压进了同一套状态机。它不神奇,只是把每一条“如果这时断了怎么办”提前写进了代码。


固件版本:1.2.4 / 协议:1.1 / 引导程序:0.1.4。
产品与上位机说明:智能散热小桌板