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 | while (1) { |
__WFI() 让 CPU 进入 Sleep。注意是 Sleep,不是 Deep Sleep:PWM、UART、GPIO 都还要继续工作,风扇不能因为 CPU 睡了就停。
真正的事情分散在中断里:
| 中断 | 优先级 | 负责什么 |
|---|---|---|
| UART2 | 0(最高) | 接收 BLE 透传字节,分发给四个解析器 |
| GPIOB | 默认 | PB4 双边沿,检测 BLE 连接 / 断开 |
| BTIM1 | 默认 | 关机散热的 1 秒倒计时 |
| SysTick | 默认 | 1ms 时基,只用于指令帧的接收超时 |
中断只做短操作:减一个计数、置一个标志、收一个字节。关风扇、回传、Flash 操作这类可能耗时的事情,统一放到主循环的 ShutdownCooling_Poll() 里。这样做的代价是主循环不能省,但换来了中断响应时间和逻辑边界的可控。
1 | TCV3 上位机 |
三路 PWM:2kHz 与 1000 级
三路风扇分别接在 PA3、PA4、PA5,对应 GTIM1 的 CH4、CH3、CH2。PWM 初始化里有两个关键数字:
1 | GTIM_InitStruct.Prescaler = 4 - 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 | void FanPWM_Restore(void) |
这类问题很难靠读主路径发现,只有把“关机散热结束 → 再发模式指令”这条路径真的走一遍,才会暴露。
一条 UART,四个解析器
BLE 模块是透传的,所有字节原样落到 UART2。但同一条字节流上跑着四种东西:
- 单字符风扇模式:
'0'~'6' - 版本查询:
V\n - 关机散热帧:
T<分钟>\n - OTA 请求:
OTA\n
每个解析器都必须遵守同一个规则:只有收到自己的帧头,才消费后续字节。
原因很直接:T1\n 里的 '1' 同时也是一个合法的风扇模式。如果分发顺序写错,启动满速散热的同一条指令会顺手把风扇设成 25%,甚至在倒计时中误判成“用户切换模式”,回一个 CAN 把散热取消掉。
UART 中断里的实际顺序是这样:
1 | FwVersion_InputByte(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 | T3\n → 全速散热 3 分钟后停风扇 |
收到合法帧后,ScBegin() 做四件事:
1 | FanPower_On(); |
计时资源分得很清楚:
- 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 | req->magic = OTA_REQ_MAGIC; /* "OTA1" */ |
为什么不用 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. 前缀判断“现在是引导程序在说话”,从而知道升级请求真的被接管了。
同一版本号只能对应一份固件。已烧录的版本号不能复用,否则无法判断设备里到底是哪一版。版本号、协议号、文档三者对不上,比功能少一个更麻烦——因为你会开始怀疑设备。
测试:把状态机从板子上拿下来
这类固件最难测的不是“正常发一条指令”,而是边界:半帧、超时、取消、断开、掉电、坏固件。
项目里做了两层验证:
- 宿主机逻辑测试:把真实的
shutdown_cooling.c编译到 PC 上,HAL 全部打桩,直接断言它发出的OK / WAIT / TO / FIN / CAN。覆盖T0、T60、T61、T123、半帧超时、重复取消、倒计时中重新下发等边界; - 硬件在环:直连 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。
产品与上位机说明:智能散热小桌板