MoteOS 移植教程(手把手)
本文假设读者:会用 IDE 打开工程、会点编译按钮,仅此而已。 不认识的词,第 0 章都翻译成大白话了;每一步都写了"点哪里、看到什么、为什么"。 照着做就行,不需要提前懂原理——原理会在做的过程中自然看懂。
第 0 章:先花几分钟,认识 MoteOS 需要的三样东西
这一章要干成的事:用大白话把"移植"这件事讲清楚——移植就是让 MoteOS 在你的芯片上 "按时打铃、没事打盹、挂请勿打扰牌子",三件事而已。读完这章,后面每一章都不会有看不懂的词。
MoteOS 是个"事件驱动的协作式内核"。把它想象成工厂的调度室:它不管你的产品具体怎么做, 只负责维持工厂的节奏——按时打铃(tick)、没活就打盹(wfi)、干活时挂"请勿打扰"牌子(临界区)。 它要在你的芯片上开工,只向你要这三样东西。
0.1 先把这些词翻译成大白话
后面会反复出现的词,先统一在这里认识一遍。每个词都给了生活化的比喻,忘了就翻回来:
| 词 | 大白话解释 |
|---|---|
| 单片机 | 一块比指甲盖还小的电脑。没有屏幕键盘,但便宜、省电,藏在台灯、电饭煲、遥控器里 |
| 寄存器 | 芯片内部的一排排小开关。往里面写一个数字,等于把某排开关拨成某个状态,外设就听话了 |
| 时钟 / 主频(Hz) | 芯片的心跳,一秒跳几次。72MHz 就是一秒跳 7200 万下,每跳一下芯片执行一小步指令 |
| 中断 | 芯片正埋头干活,突然门铃响了:它放下手里的活去开门,处理完门铃的事,再回来接着干 |
| ISR(中断处理函数) | 开门时干的那点活。铁规矩:越快越好,干完马上回去,别让客人久等 |
| SysTick | 芯片自带的"自动报时器",绝大多数芯片都有。到点就来按一次门铃,循环往复 |
| tick(节拍) | 报时器每 MOTE_TICK_MS(默认 1 毫秒)响一次,这一响就是一个节拍,也就是内核的心跳 |
| wfi | 让芯片"闭眼打盹"的一条指令。打盹几乎不耗电;门铃一响,芯片自动睁眼 |
| 临界区 | 干活时挂"请勿打扰"牌子的时间段。牌子挂着,门铃响了也先不接;摘了牌子才接 |
| Flash | 芯片的长期记忆:断电不忘,用来放程序 |
| RAM | 芯片的短期记忆:断电即忘,用来放运行中的数据 |
| 编译 | 把你写的代码翻译成芯片能听懂的机器语言 |
| 链接 | 把翻译好的各个部分拼装成一份完整说明书(也就是最终能烧进芯片的程序文件) |
| 头文件(.h) | 说明书的目录:告诉你有哪些零件(函数)可以用 |
| 源文件(.c) | 说明书的正文:零件具体怎么造、活具体怎么干 |
| IDE | 写代码用的编辑器软件套装。第 1 章的 Keil、第 2 章的 MounRiver Studio 都是 IDE |
| SDK | 芯片厂商给的现成工具包:启动文件、外设说明书、示例代码都在里面 |
| 启动文件 | 芯片开机时的"开机自检流程"。没有它,芯片通电后根本不会跑 |
| 向量表 | 芯片的"紧急电话簿":每种中断响铃该打给谁,电话簿里写得清清楚楚 |
| 弱符号 / 强符号 | 两个同名函数共存时:弱的是"替身",强的是"本尊"。本尊一出场,替身自动让位 |
| 时基 | 内核心里的"现在几点"。内核数着节拍过日子,时基就是它手腕上的表 |
| tickless | 空闲时把报时器拨到"下一件到点的事"再睡,而不是每 1ms 被叫醒一次,更省电 |
| 事件 / 队列 / handler / 邮箱 | 纸条 / 传达室的筐 / 工人 / 快递柜。这是内核内部的运转方式,细节见《使用教程》 |
0.2 MoteOS 到底要哪三样东西
| 要的东西 | 大白话是什么 | 谁提供 |
|---|---|---|
| tick(节拍) | 每 1ms 来一次的"心跳",由某个硬件定时器中断产生,中断里调用 mote_tick() |
芯片的 SysTick(绝大多数芯片都有) |
| wfi(低功耗) | 没事干时让 CPU 睡觉的指令,来中断会自动醒 | ARM/RISC-V 都有 wfi 指令 |
| 临界区 | "关中断/开中断"两个操作,保护共享数据 | 芯片内核头文件里现成的函数 |
所以移植 = 给 tick 中断接一根线 + 让 CPU 会睡觉 + 告诉内核怎么开关中断。 没有汇编,没有链接脚本,没有魔法。
想一想:为什么偏偏是这三样?
内核靠 tick 知道时间(闹钟才能响)、靠 wfi 在空闲时省电(电池才能耐用)、 靠临界区保护自己的账本(别人拿纸条时不会抄错)。想通了,后面每一章都顺理成章。
0.3 开始前准备一张对照表(后面会反复用)
不同芯片的"接线方案"收在不同的 port 目录里(port = 港口/接口,即"这颗芯片怎么接线的说明书")。 选对行,照着走就成:
| 你的芯片 | 用哪个 IDE | 用哪个 port 目录 |
|---|---|---|
| CH32V003/V007/V203/V307(WCH RISC-V) | MounRiver Studio | moteos/port/ch32v/ |
| CIU32F003(华大电子 M0+) | Keil MDK | moteos/port/cm0plus/ |
| CH32M030(WCH M0+) | Keil MDK | moteos/port/cm0plus/ |
| STM32F030(M0+) | Keil MDK | moteos/port/cm0plus/ |
| STM32F103(M3) | Keil MDK | moteos/port/cm3/ |
| 其他芯片 | 任意 | 看第 4 章(从模板写) |
WCH 两代 SDK 的差别(重要)
CH32V003/V007 是 V2 代,port 默认包含 ch32v00x.h,什么都不用配。
CH32V203/V307 是 V3 代,用同一个 ch32v 目录,但必须在工程宏定义里加:
-DMOTE_CH32_HAL_HEADER=<ch32v20x.h>(V203,中型)或
-DMOTE_CH32_HAL_HEADER=<ch32v30x.h>(V307,大型)。
另外青稞的临界区开关寄存器 INTSYSCR 默认按 CSR 0x800 处理,不同代次
手册的映射可能有差异:上板前按目标芯片手册核验,不一致就用
-DMOTE_CH32_INTSYSCR=<值> 覆盖——这直接决定关中断和 wfi 行为,
是检查清单里的必测项(见第 7 章)。
扩展阅读:比喻背后是什么
"纸条、传达室、快递柜"只是比喻。想了解内核内部真正怎么运转、 每个 API 在什么上下文能调,见《使用教程》第 1 章的工厂图、第 7 章铁律和附录 A。
第 1 章:路线 A —— Keil MDK + STM32F103(ARM 芯片)
这一章要干成的事:在 Keil 里从零搭出一个空工程,把 MoteOS 接进去,让灯按 500ms 闪起来。 一路只做四件大事:建工程 → 抄启动文件 → 抄 MoteOS 文件 → 接好 SysTick 这根线。 STM32F103 是 ARM 家族(Cortex-M3)的芯片,本章所有说法对第 0 章表格里"Keil MDK"一列的 芯片都通用,只是文件名按芯片型号换一下。
1.1 新建一个空白工程
工程 = 装你所有代码文件和配置的文件夹。Keil µVision5 就是一个 IDE(写代码的编辑器套装), 它管着"这个文件夹里哪些文件要翻译、怎么翻译"。
- 打开 Keil µVision5 → 菜单
Project → New µVision Project... - 选一个文件夹(比如
D:\mote_demo),工程名填blink,点保存 - 弹出芯片选择框:搜索框输入
STM32F103C8,选中 → 点 OK (这一步是在告诉 Keil:目标芯片是哪一颗,翻译出来的语言要对上它的"方言") - 弹出 "Manage Run-Time Environment" 窗口 → 直接点 Cancel(我们用不到 Keil 的软件包)
1.2 从 SDK 里拿三个启动文件
启动文件 = 芯片开机时的"开机自检流程"(没有它芯片通电后根本不会跑)。Keil 不会帮你生成, 要从 ST 官方标准库(就是 SDK,芯片厂商给的现成工具包)或你手头的例程里复制:
- 找到你的 STM32F10x 标准库目录,找到这三个文件:
Libraries\CMSIS\CM3\DeviceSupport\ST\STM32F10x\startup\arm\startup_stm32f10x_md.sLibraries\CMSIS\CM3\DeviceSupport\ST\STM32F10x\system_stm32f10x.cLibraries\CMSIS\CM3\CoreSupport\core_cm3.c(可省略,但建议加上)
- 把这三个文件复制到
D:\mote_demo里 - 回到 Keil,在左侧工程树里右键
Target 1→Add Group...,名字输入Startup(Group = 工程树里的一个"抽屉",用来给文件分类) - 右键刚建的
Startup组 →Add Existing Files to Group 'Startup'...,把那三个文件加进来
那个
.s文件里装着"紧急电话簿"(向量表):它保证"SysTick 报时器响铃时,电话会打到 名叫SysTick_Handler的函数那里"。这个函数正是第 3 章、第 4 章的主角。
1.3 复制 MoteOS 文件
把 MoteOS 仓库里这些文件复制到 D:\mote_demo\moteos\(保持目录结构):
moteos/mote.c ← 内核,必须
moteos/mote.h ← 内核头文件,必须
moteos/mote_config.h ← 配置文件,必须
moteos/mote_task.c ← 任务层(可选,本例用了)
moteos/mote_mail.c ← 邮箱(可选,本例用了)
moteos/port/mote_port.c ← 移植实现(SysTick 接管),必须
moteos/port/cm3/mote_port.h ← 你的芯片对应 port 头文件,必须只复制你需要的。最小系统只需要
mote.c、mote.h、mote_config.h、mote_port.c和对应的mote_port.h五个文件。 其中mote_port.c就是"接线工":它替你把 SysTick 报时器的门铃接到了内核的mote_tick()上。
回到 Keil,重复第 1.2 步的"建组加文件"操作:
- 新建组
MoteOS - 把
mote.c、mote_task.c、mote_mail.c、port/mote_port.c加进MoteOS组
1.4 添加头文件路径(最常见的报错根源)
头文件 = 说明书的目录。编译器(翻译官)默认只在工程目录里找目录, 要告诉它 MoteOS 的说明书目录放在哪:
- 点工具栏的魔术棒按钮(
Options for Target,快捷键Alt+F7) - 切到
C/C++标签页 - 找到
Include Paths那一栏,点右边的...按钮 - 点文件夹加号,添加这三条路径:
..\mote_demo\moteos ..\mote_demo\moteos\port\cm3 ..\mote_demo\moteos\port123也把你 SDK 的 CMSIS 头文件路径加进来(startup 相关头文件)。
- 依次点 OK 关闭
必读:报错先看头文件路径
"翻译官找不到目录"的报错信息往往长得像文件出错,让人查错方向。 记住第一条排查口诀:报错先看头文件路径——这是移植中最常见的报错根源, 90% 的"文件打不开"类报错都出在这里。
1.5 粘贴例程代码
在 Keil 里新建组 App,新建文件 main.c(File → New,另存为 D:\mote_demo\main.c),
内容直接用 examples/stm32f103/main.c 的完整代码(LED 500ms 闪烁 + 串口回环 + 心跳任务)。
(现在先不用看懂每一行——第 5 章会把这份代码拆开逐行讲。此刻它只负责"证明接线成功"。)
1.6 编译
编译 = 让翻译官把整个工程的代码翻译成芯片能听懂的机器语言,并拼装(链接)成一份完整说明书。
按 F7。出现 0 Error(s) 即成功——意思是翻译官说:"每个字我都认得,拼装也严丝合缝。"
1.7 报错了?对照这张表
翻译官脾气直,报错信息不带翻译。对照下表查:
| 报错信息 | 原因 | 怎么改 |
|---|---|---|
cannot open source input file "mote.h" |
头文件路径没加或加错 | 回 1.4 检查三条路径 |
SysTick_Handler multiply defined |
你的工程里有两个强符号 SysTick_Handler(MoteOS 的是弱符号,不会引发此报错) | 只保留一个:要么用你自己的并在里面调 mote_tick()(见第 3 章),要么删掉自己的交给 MoteOS |
'NULL' undeclared |
老版本 mote.h 缺 <stddef.h> |
更新 MoteOS 到最新(已修复) |
USART1_IRQHandler multiply defined |
你的代码里也写了串口中断函数 | 二选一:删掉其中一个,或把 mote_mail_send 加进你自己的 USART1_IRQHandler 里 |
GPIO_CNF_... undeclared |
你用的库没有 ST 老版寄存器宏 | 用你 SDK 自带的外设库函数写初始化(参考第 5 章) |
"符号" = 程序里每一个有名字的东西(函数名、变量名)。链接(拼装)时要核对名字, 重名就打架。
multiply defined= "这个名字出现了两次,我不知道该听谁的"。 而"弱符号/强符号"是解决重名打架的规矩:弱的是替身,强的是本尊;本尊一出场,替身自动让位。 MoteOS 自带的SysTick_Handler就是替身,你自己写的才是本尊——所以第 3 章的办法才能成立。
1.8 下载验证
下载(也叫烧录)= 把翻译好的程序抄进芯片的长期记忆(Flash),断电也不丢。烧完按一下复位, 芯片就会从头执行这份说明书。
Flash → Download(或 F8)。预期现象:
- LED 每 500ms 翻转一次(定时器驱动)
- 串口助手发什么回什么(邮箱回环)
- 另一颗 LED 每 1s 翻转一次(任务层驱动)
上板验证:三条现象缺一不可
下载只是把程序抄进 Flash;这一章的"完成"以上板实测为准: LED 500ms 翻转 + 串口回环 + 1s 心跳,三条都出现 = 三样东西(心跳、打盹、牌子) 全部接线成功。光看编译 0 Error 不算数。
第 2 章:路线 B —— MounRiver Studio + CH32V003(WCH RISC-V)
这一章要干成的事:换一家芯片厂、换一个 IDE,把第 1 章的流程再走一遍。 CH32V003 是 RISC-V 家族的芯片(WCH 公司出品),IDE 换成 MounRiver Studio(简称 MRS)。 流程骨架一模一样:建工程 → 抄 MoteOS → 加头文件路径 → 烧录验证,区别只在按钮长什么样。 走完这章你就明白:换芯片并不难,难的是第一颗芯片。
2.1 新建工程
- 打开 MounRiver Studio →
File → New → MounRiver Project - 模板选
CH32V003(或你的具体型号),填工程名 → Finish - 等它生成完成,确认工程里已经有
ch32v00x.h、core_riscv.h、startup等 WCH 官方文件 (这些是 WCH 的 SDK 部分:启动文件和说明书目录,跟第 1 章的 ST 标准库一个角色)
2.2 复制 MoteOS 文件
复制到工程目录(保持结构):
moteos/mote.c
moteos/mote.h
moteos/mote_config.h
moteos/mote_task.c ← 可选
moteos/mote_mail.c ← 可选
moteos/port/mote_port.c
moteos/port/ch32v/mote_port.h ← 注意:RISC-V 用 ch32v 目录!和第 1 章唯一的大区别:port 头文件从
cm3目录换成ch32v目录—— 因为两颗芯片讲不同的"方言",接线说明书也不同。别的全一样。
2.3 添加源文件到工程
- 在左侧工程树右键工程名 →
Import...→General → File System→ Next - From directory 选
你的工程目录\moteos,勾选那 4 个.c文件(含 port/mote_port.c)→ Finish - 确认 4 个文件出现在工程树里,且不是灰色(灰色 = 未参与编译)
"参与编译" = 翻译官会翻译这个文件。灰色文件会被翻译官无视, 症状就是"代码明明在,编译却像没看见"。
2.4 添加头文件路径
- 右键工程名 →
Properties C/C++ Build → Settings → GNU RISC-V Cross C Compiler → Includes- 在
Include paths (-I)里点加号,添加:${workspace_loc:/${ProjName}/moteos} ${workspace_loc:/${ProjName}/moteos/port/ch32v} ${workspace_loc:/${ProjName}/moteos/port}123 - Apply and Close
2.5 替换 main.c
用 examples/ch32v003/main.c 的内容替换工程里的 main.c。
注意编码:GBK 与 UTF-8 混用会乱码
编码 = 文字在电脑里存成什么格式。MRS 新建工程的 main.c 通常是 GBK 编码(国内老标准)。 如果你粘贴进去的 MoteOS 代码带 UTF-8 中文注释(国际标准),两种格式混在一起, 文字可能乱码或编译告警。两种办法: ① 在 MRS 里对 main.c 右键 → Properties → Resource,把 Text file encoding 改成 UTF-8; ② 保持英文注释。
2.6 编译与烧录
- 点锤子图标编译,
0 errors即成功 - 点甲虫图标(Debug),用 WCH-Link 烧录运行(WCH-Link = WCH 官方的小烧录器, 一根线把电脑上的程序抄进芯片)
- 现象同 1.8:LED 500ms 翻转 + 串口回环 + 1s 心跳
2.7 报错对照表(CH32V003 专版)
| 报错信息 | 原因 | 怎么改 |
|---|---|---|
unknown type name 'IRQn_Type' |
你的 SDK 版本 core_riscv.h 不能单独包含 | 更新 MoteOS(port/ch32v 已改含 ch32v00x.h) |
implicit declaration of function 'SysTick_Config' |
该版 SDK 没这个函数 | 更新 MoteOS(port/ch32v 里已内联补上) |
'GPIO_CNF_...' undeclared |
例程用了 ST 风格宏,WCH 没有 | 已改用寄存器字面量,更新例程 |
| 中文注释乱码 | GBK/UTF-8 混用 | 按 2.5 的编码说明处理 |
前两行都来自同一个老问题:WCH 不同版本的 SDK 内容有差异。更新 MoteOS 到最新版 就能兼容;这类"跟着 SDK 版本走"的坑,是芯片移植的日常,见怪不怪。
第 3 章:已有工程怎么接入(不动你的 SysTick)
这一章要干成的事:你手里已经有一个能跑的工程(自己的 SysTick、自己的延时函数、 自己的外设库),不想推倒重来,只想让 MoteOS"住进来"。好消息是:MoteOS 是客气的房客, 你家的装修它一样不动——只借三个接口。
先回忆第 1 章讲的规矩:MoteOS 自带的 SysTick_Handler 是弱符号(替身),
你自己工程里的 SysTick_Handler 是强符号(本尊)。链接时替身见本尊就自动让位,
不会打架报错。于是接入方案变得极简:
mote_port.c照常加入工程——它的SysTick_Handler是弱符号, 你自己的强符号SysTick_Handler会直接覆盖它,链接不报冲突- 在你自己的 SysTick 中断函数(一般在
stm32f10x_it.c或main.c)里加一行:
void SysTick_Handler(void)
{
mote_tick(); /* ← 就加这一行(固定拍;tickless 工程按 4.3 协议
* 调用 mote_tick_advance 并恢复固定拍重装) */
// ...你原来的代码,比如 Delay_Dec() 之类
}白话解释:SysTick 报时器每次响铃,电话都会打到这个函数(向量表里登记的)。 现在你只在开门时多喊一嗓子
mote_tick()——告诉内核"过了一拍"。 你原来开门时干的活(延时计数之类的)原样保留,互不打扰。
- 空闲时的 wfi 不需要你写:
mote_port.c自带的mote_idle()就是一条 wfi(固定拍)——低功耗默认就有。只有在你把mote_port.c从工程剔除、 自己写 port 实现(见第 4 章模板)时,才需要自写mote_idle。注意它是 强符号(与弱符号SysTick_Handler不同),不能一边留着mote_port.c一边再写一个同名函数,否则链接报mote_idle multiply defined。 签名带 deadline 入参:
void mote_idle(uint32_t next_due) /* next_due:内核给出的下一到期节拍 */
{
(void)next_due; /* 固定拍直接忽略;tickless 见 4.3 */
__WFI(); /* 你的芯片头文件自带 */
}白话解释:内核空闲时会来问一句"没事干,我想睡会儿,怎么睡?"—— 这个函数就是你的回答:"闭眼打盹(wfi)"。门铃一响它自动睁眼,不用你管。
next_due是内核顺口告诉你的"下一件到点的事在几点",固定拍模式下用不上, 先无视它((void)next_due;就是"收到,但我不看")。留用mote_port.c的话这段代码一行都不用写——它自带的回答就是 wfi。
别踩:mote_idle 是强符号
一边留着 mote_port.c、一边自写 mote_idle() → 链接报 mote_idle multiply defined。
只有把 mote_port.c 从工程剔除、按第 4 章模板自写 port 时,才需要写它。
- 头文件路径照第 1.4/2.4 节加。
主循环同理:如果你必须保留自己的 while(1),就用 mote_poll() 代替 mote_loop():
while (1) {
if (!mote_poll()) {
/* 没事件可处理。想省电就调 mote_sleep()(内核会判断
* 队列空且无到期项才真睡,见 4.2 的睡眠契约) */
}
}白话解释:
mote_loop()是内核自己的无限循环,一进去就不出来。 你想保留自己的while(1)主循环?可以——用mote_poll()手动驱动内核: 每圈喊它"查一次筐"(处理事件、响闹钟)。它返回"筐空了没事干"时, 你想省电就喊mote_sleep()让它睡到下一次门铃。睡多久、真睡假睡由内核判断 (睡眠契约见 4.2),你只是递了个"想睡"的请求。
第 4 章:没有现成 port 的芯片(照模板抄)
这一章要干成的事:第 0 章表格里没有你的芯片时,自己动手写"接线方案"。
好消息是:不用发明,只对照抄。打开 moteos/port/mote_port_template.c,
它就是全部答案(一份填空式参考答案,注释里列了四步)。四步 = 内核要的
三样东西(tick、wfi、临界区)+ 一个断言失败处理(4.5 节)。
4.1 tick:让定时器中断叫内核
找到你芯片上那个能精确报时的硬件定时器(任何芯片都有,只是名字不同), 把它的中断函数写成这样:
void Timer1_ISR(void) /* 你的 1ms 定时器中断,名字按芯片文档 */
{
mote_tick(); /* 内核内部只做 count++,极快 */
clear_timer1_flag(); /* 清你的中断标志 */
}中断里只干这两件事。所有"到点了要做什么"都由内核在主循环里完成。
白话解释:报时器响铃 → 你开门喊
mote_tick()(记一笔账:"过了一拍")→ 把门铃的响铃状态复位(不清的话它就一直响,等于每圈都在按铃)。 注意别把任何"干活"塞进这里——开门的人待得越久,其他事情被耽误得越久。 所以内核设计成:中断里只记账,真正"到点该干什么"留到主循环慢慢办。
4.2 睡觉:wfi
void mote_idle(uint32_t next_due)
{
(void)next_due; /* 固定拍移植忽略;见下方 tickless 小节 */
__asm volatile("wfi"); /* ARM/RISC-V 通用;其他架构查手册 */
}白话解释:这就是"闭眼打盹"指令的完整实现。注意
(void)next_due;这行—— 内核给了你"下一件到点的事"这个情报,固定拍模式暂时用不上,先收下不看。volatile是叮嘱翻译官"这条指令一个字都别给我优化掉"。
注意三个契约(内核依赖它们消除"漏睡"竞态):
先解释"竞态":两个人抢着用同一个东西、胜负取决于谁快谁慢,就容易出乱子。 睡眠最大的乱子是"漏睡"——明明有纸条来了,芯片却还在睡大觉,纸条被耽误。 下面三条契约就是为了杜绝这种事,你的实现必须遵守:
-
本函数在关中断状态下被调用。内核的睡眠流程是: "关中断 → 检查队列确实为空且无到期项 → 执行 wfi → 恢复中断"。 ARM/RISC-V 的 wfi 在存在 pending 中断时会立即醒来, 醒来后内核先恢复中断、让中断先处理,事件不会睡过头。 所以你的
mote_idle()实现要极短(wfi 级别),不要在里面开中断。白话解释:内核睡觉前会先挂上"请勿打扰"牌子再查一遍账本(筐空没空、闹钟响没响), 确认真没事才睡——查账和睡觉之间插不进任何意外。门铃在这期间响了也没关系: 芯片一闭眼就发现门铃是响的,立刻睁眼。所以你在
mote_idle()里 只写闭眼这一件事,千万别在里面摘牌子(开中断)或干别的。 -
tick 必须持续运行。唤醒依赖中断(默认 SysTick 每 1ms 一次), 最坏唤醒延迟 = 1 个 tick。深度睡眠(STOP/STANDBY 等停掉 tick 时钟的模式)不支持:内核没有自带唤醒源,使用这些模式需要你 自行处理唤醒竞态(先退出深睡、再按上述流程重查队列)。
只有"wfi 级"轻睡眠,才能拍胸脯说"不需要额外唤醒逻辑"; 深睡模式这句话不成立。
白话解释:wfi 只是"闭眼打盹",心跳(报时器)照常走,所以最迟 1ms 后 会被门铃叫醒——事件最多被耽误 1 拍。而"深度睡眠"等于把心脏也停了: 谁来按门铃?内核没有别的唤醒手段,这事就得你自己设计,别指望它。 一句话:内核只保证"打盹"级睡眠的可靠性,深度睡眠请自担风险。
-
青稞(CH32V)需板级确认:标准 RISC-V 规范规定 MIE=0 时 wfi 不睡眠、 继续执行;而本移植关中断用的是青稞私有 INTSYSCR(CSR 0x800), wfi 到底受不受它影响、pending 中断能否唤醒,WCH 文档未明确, 本仓库没有任何板级验证。上板后务必实测两项:空闲电流是否明显下降 (wfi 真的睡了)、tick 是否准时唤醒(没睡过头)。
白话解释:青稞是 WCH 家芯片内核的名字。标准说"门铃关了的时候闭眼无效", 但青稞关门的开关(INTSYSCR 这个特殊寄存器)和标准不一样,闭眼到底 灵不灵、门铃还响不响,厂商没写清楚,我们也没条件替你实测。 所以用青稞芯片的同学,上板后一定要亲手验证:电流降了才算真睡、 心跳准时才算没睡过头。这也是第 7 章检查清单里专门列了一条的原因。
4.3 tickless 空闲(可选进阶)
先白话讲清这个模式解决的问题:固定拍模式下,即使啥事没有,芯片也每 1ms 被 门铃叫醒一次——为了看时间。tickless 的思路是:反正下一件到点的事在 3 秒后, 那就把报时器拨到 3 秒后再响,中间睡个整觉。
next_due 是内核已知的下一到期节拍(定时器 + 延时投递中的最早者;
MOTE_TICK_NONE = 无任何到期项)。固定拍移植忽略它即可;要省功耗,
开启 MOTE_TICKLESS=1(配合 MOTE_PORT_HCLK_HZ)后,空闲时把 SysTick
重装到 next_due 再睡,唤醒后恢复固定拍。参考实现见
moteos/port/mote_port.c(Cortex-M 24 位 SysTick 裸寄存器访问 / WCH 青稞
64 位比较寄存器,两者都已实现),自写移植时遵守同一协议:
重要前提:
MOTE_TICKLESS=1还需要MOTE_PORT_HCLK_HZ这个宏—— 它告诉内核你芯片的主频是多少 Hz,必须 ≥1000(内核靠它把"下一件事几点" 换算成"报时器拨到几"),并且必须在工程级别全局定义(对所有参与编译的 文件都生效,只写在某个 .c 文件里是无效的)。填错了会怎样?报时器拨错刻度, 闹钟不是早响就是晚响,时间一长误差越积越大。
"重装"是什么意思?报时器是个倒计时的表:你设一个数,它每秒(每拍)减 1, 减到 0 就响铃、然后重新开始数你设的那个数。这个初始数就是"重装值"—— 固定拍下它是定死的,tickless 下每次睡觉前都换成"离下一件事还剩多少"。
协议四条:
-
进入 idle 先追平时基:上次重装以来已流逝但未入账的时间 (
mote_tick_advance(ms)入账;参考实现用"重装值 - 计数器剩余"直接 换算,周期余数累计,跨唤醒零漂移)。不要回读计数器做入账锚点: 写 VAL=0 后计数器存在 0 窗口(真实硬件一拍沿、分频时钟下可长达整拍; QEMU 的 ptimer 重载事件与 TCG 批执行之间同样有竞态),回读值为 0 时 "锚点 - 剩余"会整段下溢、炸掉时基。白话解释:追平时基 = 睡觉前先把账结清。上次拨表到这次睡觉之间又过去了 一小段时间,这段"欠账"要用"当初拨了多少 - 表还剩多少"来算——两数相减 永远算不错。千万别去"看表盘读现在几点"来对账:刚把表拨到 0 时, 表盘上有个瞬间显示 0(真硬件上一拍、分频时钟下甚至一整拍都可能是 0, 模拟器 QEMU 里也一样),这时"读到的 0 - 剩下的数"会算出负数, 一减就溢出成天文数字,内核的钟直接报废。这是真踩过的坑,务必绕开。
-
nap 上限受 SysTick 计数器位宽与 2^31 回绕数学共同约束,参考实现 取
min(计数器位宽换算的 ms, 2^30)(编译期常量)。白话解释:表盘有位数上限——24 位的表最多数到 1677 万左右,拨太远 它数不下;而且拨得越远,内核算"到没到点"的数学越容易转圈出错。 所以单次午睡时长有个封顶,参考实现取"表盘能表示的最长 ms"和 2^30 里较小的那个,编译时就定死了。
-
wfi 必须与重装同临界区完成(idle 在关中断状态下被调用,天然满足); pending 中断会立即唤醒,提前唤醒的部分拍留给下一次 idle 追平。 若平台/仿真模型对"重装后立即 wfi"产生伪唤醒(QEMU 即如此), 主循环退化为轮询但时基仍按计数器差额正确推进,只损失低功耗收益。
白话解释:拨表和闭眼必须是"请勿打扰"牌子下的连续动作——拨完表、 闭眼前不许任何人插队,否则表拨了却没睡成,账就乱了。若中途有人按门铃, 芯片立刻睁眼,这"提前醒来的半拍"先欠着,下次睡觉前追平。 另外预告一个真实情况:在模拟器(QEMU)里,"拨表后立刻闭眼"可能会被 模拟器当成"有新动静"而马上唤醒——芯片于是退化成"睁眼发呆"的轮询, 但账照样算得对,只是省电的好处没了。真芯片上是否发生要实测。
-
SysTick 中断处理(弱符号
SysTick_Handler,用户可重定义强符号接管) 负责把满拍时长入账(mote_tick_advance)并恢复固定拍重装。 wrap 判别靠 COUNTFLAG/CNTIF 读清零:ISR 与 idle 追平谁先读到标志 谁入账整拍,另一方只入账部分拍——互斥不重复。 若用户重定义了 SysTick_Handler,记得自行调用mote_tick()(固定拍)或按同样协议mote_tick_advance()(tickless)。白话解释:表转完一圈会举个小旗子(COUNTFLAG/CNTIF 标志)。开门的人 和睡觉前结账的人谁先看到旗子,谁就把"一整拍"记进账本,并把旗子放倒; 后到的那位只记"零头"。旗子被放倒后谁都看得到它倒过——这就保证 每一拍恰好被记一次账,不会被两个人记两遍。这里的函数是弱符号 (替身),你自己写同名函数(本尊)就会接管它——接管后记得自己按 上面两种模式(固定拍 / tickless)把账记上。
tickless 板级验证清单(未经任何真实芯片验证,上板必测)
MOTE_PORT_HCLK_HZ与实际主频一致(含时钟树切换后的最终 HCLK)- 空闲时 SysTick 中断间隔随 deadline 变化(示波器/电流波形)
- 无到期项时长睡:空闲电流显著低于固定拍,且任意外设中断能唤醒
- 提前唤醒(外设中断早于 deadline)后台时基无漂移:跑 1 小时, 定时器相位偏移 < 允许值
- 24 位(Cortex-M)/ 64 位(青稞)计数器上限换算正确,nap 不溢出
- 青稞 INTSYSCR 与 wfi 交互:关闭中断时 wfi 仍能睡眠且可被唤醒
白话解释最后一条清单里的"时钟树":芯片的主频像一棵树的树干, 各个外设的时钟是树枝,都能独立分叉调速。你的主频可能开机后被代码 切换过(比如从内部振荡器切到外部晶振),填
MOTE_PORT_HCLK_HZ时 要填切换完成后最终那个值,别填出厂默认值。
4.4 临界区:三个小函数(保存/恢复式)
先白话讲清临界区为什么存在:中断(门铃)随时可能打断正在干活的代码。 假如主循环正在往账本上写"第 5 号事件",写到一半门铃响了、门铃那位也来 往账本上写东西——账本就被写花了。对策:写账本期间挂"请勿打扰"牌子 (关中断),写完再摘(开中断)。
新建 mote_port.h,提供中断状态类型加三个函数:
#ifndef MOTE_PORT_H
#define MOTE_PORT_H
typedef uint32_t mote_crit_state_t; /* 中断状态类型 */
static inline mote_crit_state_t mote_crit_enter(void)
{
/* 1) 读出当前中断开关状态并保存 */
/* 2) 关全局中断 */
/* 3) 返回保存的状态 */
}
static inline void mote_crit_exit(mote_crit_state_t s)
{
/* 恢复 s 里的中断状态(不是无条件打开!) */
}
static inline uint32_t mote_crit_active(void)
{
/* 返回当前是否处于"关中断"(1=关,0=开) */
}
#endif白话解释这三个函数各干什么:
mote_crit_enter():挂牌子。但挂牌前先看一眼牌子原来挂没挂,把"原来 的状态"记在小纸条上带回来;mote_crit_exit(s):摘牌子。按小纸条恢复原状——原来就没挂牌子, 摘掉;原来就挂着,继续挂;mote_crit_active():问一句"牌子现在挂着吗?"(1=挂着,0=没挂)。 内核用它自查状态,避免在不该摘牌子的时候摘牌子。
为什么必须保存/恢复而不是"关/开"? 因为内核可能在你已经关了中断的上下文里运行(你自己的临界区、嵌套调用)。 如果 exit 无条件打开中断,就会破坏调用方的原子性——这是经典的隐蔽 bug。 保存/恢复式天然支持嵌套,怎么嵌套都不会错。
白话解释:想象三层办公室,里屋干活时挂了"请勿打扰",你进去帮忙又挂了一块。 走的时候你如果无条件把牌子全摘了,里屋的牌子也被你摘了——门铃一响 直接闯进去,里屋的账本当场写花。正确做法是只摘你挂的那块,里屋的 保持原样。这就是"保存/恢复式"的用意:谁挂的谁摘,不碰别人的牌子。
参考现成实现(直接抄对应的):
| 内核 | 状态来源 | 实现 |
|---|---|---|
| Cortex-M0+/M3 | PRIMASK | 裸汇编 MRS/CPSID/MSR,不依赖 CMSIS(见 port/cm0plus、port/cm3,同时兼容 GCC/AC6/AC5) |
| WCH RISC-V | INTSYSCR(CSR 0x800) | csrr/csrw 0x800(见 port/ch32v) |
| AVR 等 | 状态寄存器 SREG | in/out 指令保存恢复 SREG |
白话解释表格:每颗芯片"挂牌子的开关"位置不一样——ARM 家族的叫 PRIMASK、 WCH RISC-V 的叫 INTSYSCR、AVR 的叫 SREG,但功能都是"门铃总开关"。 移植层的活就是:把开关位置找出来,包成上面几个接口的形状。 "不依赖 CMSIS"意思是直接用汇编摸开关,不借厂商的工具箱—— 这样在 GCC、AC6、AC5 各种翻译官手里都能编译过。
还差两个小东西(照抄现成 port 头即可):如果你要沿用
mote_port.c的 SysTick 弱符号与 tickless 参考实现,你的mote_port.h还要定义—— ①MOTE_WEAK:弱符号关键字(GCC/Clang 用__attribute__((weak))); ② 平台标签MOTE_PORT_CORTEXM或MOTE_PORT_CH32:tickless 下内核据此 选择 SysTick 访问方式(24 位向下计数 / 64 位比较寄存器),缺失会直接#error。两样都只需从port/cm0plus或port/ch32v的对应行抄过来。
4.5 断言失败处理:mote_assert_fail(第四步)
内核的 MOTE_ASSERT 默认开启,触发时会回调 mote_assert_fail(file, line)。
它是弱符号:mote_port.c 自带一个"停机死循环"的默认实现;按本章自写
port 时(模板已替你写好),可以把停机换成复位或先记录再停机:
void mote_assert_fail(const char *file, int line)
{
/* 例:把 file/line 记进日志区,然后复位芯片(NVIC_SystemReset 等) */
(void)file;
(void)line;
for (;;) { } /* 默认:停机,方便调试器抓现场 */
}白话解释:断言 = 内核给自己设的"保险丝"——发现账本出现不可能的状态 (内部 bug)就熔断。默认熔断方式是"原地停机",调试器一看就知道停在哪; 产品里常换成"记录位置 → 复位",让设备自己重启而不是死机。 生产环境想彻底关掉断言(省 Flash/周期),构建时定义
-DMOTE_ASSERT(x)=((void)0)即可。
第 5 章:用起来——四件套迷你教程
这一章要干成的事:工程接好了,写几段代码感受一下内核的用法。 (以 STM32F103 为例,代码可直接粘贴到你的 main.c 附近) 四件套 = 闹钟(定时器)、纸条(事件)、快递柜(邮箱)、打卡机(任务层), 外加一个"纸条上刻数字"的小技巧,每个都配一段能直接抄的代码。
5.1 定时器:LED 闪烁
enum { EVT_LED = 0 };
static mote_timer_t led_timer;
static void led_handler(uint16_t evt, void *param, void *ctx)
{
GPIOB->ODR ^= (1u << 0); /* 翻转 PB0 */
}
static const mote_evt_entry_t table[] = {
[EVT_LED] = MOTE_ENTRY(led_handler, NULL),
};
int main(void)
{
/* 参数 = MOTE_TICK_MS 毫秒内的时钟周期数(不是频率)。
* 例:72MHz、1ms 节拍 → 72000000/1000 = 72000 个周期 */
SysTick_Config(SystemCoreClock / (1000 / MOTE_TICK_MS));
mote_init(table, sizeof(table) / sizeof(table[0]));
mote_timer_start(&led_timer, EVT_LED, NULL, 500, true); /* 每500ms触发 */
mote_loop();
}白话解释:这第一段代码是"定闹钟"的标准样板。
enum { EVT_LED = 0 };:给纸条编号起个名字,0 号纸条 = "该翻灯了";static mote_timer_t led_timer;:买一个闹钟,static 保证它永远在场;led_handler:工人。闹钟响了他来翻灯;table:值班表。登记"0 号纸条找 led_handler";SysTick_Config(...):给报时器定拍。参数是"一拍内芯片跳多少下" (不是频率!)——72MHz 芯片 1ms 跳 72000 下,公式一算正好;mote_init(...):把值班表交给内核;mote_timer_start(..., 500, true):闹钟每 500ms 响一次(true = 循环);mote_loop():内核主循环,一去不回。跑起来的世界:报时器每 1ms 响一次 → 内核心跳 +1 → 数到 500 → 闹钟响 → 自动投 0 号纸条 → 主循环取出纸条、查值班表、喊 led_handler → 灯翻转。
5.2 事件:按键通知主循环
enum { EVT_LED = 0, EVT_KEY = 1 };
/* 按键中断里: */
void EXTI0_IRQHandler(void)
{
mote_event_post(EVT_KEY, NULL); /* 从任意中断投递事件 */
EXTI->PR = EXTI_PR_PR0; /* 清中断标志 */
}
/* 主循环里: */
static void key_handler(uint16_t evt, void *param, void *ctx)
{
led_handler(evt, param, ctx); /* 按键 = 手动闪一下灯 */
}白话解释:按键一响铃(EXTI0 中断),开门的人只干一件事——往筐里塞一张 "按键按了"的纸条(
mote_event_post),再放倒门铃旗子,马上回去。 真正"闪一下灯"的活留给工人key_handler在主循环里干。这就是 "中断里只递纸条,不干活"的完整示范。
5.3 值传递:用 MOTE_P / MOTE_U32
/* 中断里投递 ADC 值(禁止传栈变量!) */
mote_event_post(EVT_ADC, MOTE_P(adc_result)); /* 32位值塞进指针 */
/* handler 里取回: */
static void adc_handler(uint16_t evt, void *param, void *ctx)
{
uint32_t v = MOTE_U32(param);
if (v > 3000) { /* 过压处理 */ }
}白话解释:纸条上能粘东西,但只能粘"一个小口袋"(一个指针)。想传数字?
MOTE_P(数字)把数字伪装成指针粘上去,工人收到后MOTE_U32(param)再还原成数字。注意注释里那条警告:粘的必须是"永远在场"的变量, 绝不能是局部变量(局部变量所在的栈就像临时桌面,函数一走桌面就清空了, 纸条上的地址会指向一堆垃圾)。想弄懂"为什么",看《使用教程》2.4。
5.4 邮箱:串口接收缓冲(大块数据)
MOTE_MAILBOX_DEF(uart_mb, EVT_UART, 32, 1); /* 32格×1字节:逐字节收发 */
void USART1_IRQHandler(void)
{
uint8_t c = USART1->DR;
mote_mail_send(&uart_mb, &c, 1); /* 中断里深拷贝入槽 */
/* 契约:len 必须 1..item_size,超长返回 MOTE_ERR_PARAM(不静默截断) */
}
static void uart_handler(uint16_t evt, void *param, void *ctx)
{
mote_mail_t *mb = param; /* param = 邮箱指针 */
uint8_t c;
while (mote_mail_recv(mb, &c) > 0) { /* 返回实际存入长度(此处恒为 1) */
/* 处理 c */
}
}白话解释:纸条粘不动"一大块数据"(比如串口连续来的字节流),用快递柜 (邮箱)。
MOTE_MAILBOX_DEF一口气造好柜子:32 个格子、每格 1 字节, 有货到自动投"来货了"纸条。串口每来一个字节,中断里mote_mail_send把字节复印一份放进格子(放货);工人被纸条叫来后逐格取货直到取空。 发送长度必须 1 到格子大小之间,超了直接拒绝(返回MOTE_ERR_PARAM), 不会悄悄截断——宁可明着拒绝,也不偷偷丢数据。
5.5 任务层:周期心跳
static void heartbeat(uint16_t evt, void *param, void *ctx)
{
/* 每 1000ms 自动被调用一次 */
}
static const mote_task_desc_t tasks[] = {
MOTE_TASK_DEF(1000, heartbeat, NULL),
};
int main(void)
{
/* ...mote_init 之后 */
mote_task_init(tasks, sizeof(tasks) / sizeof(tasks[0]));
mote_task_start(0); /* 启动 0 号任务;不启动不占 RAM */
}白话解释:闹钟响了要经过"投纸条 → 查表"两步,打卡机(任务层)更直接: 到点直接喊工人,不走纸条筐。适合固定节奏的活——按键扫描、屏幕刷新、喂狗。
MOTE_TASK_DEF(1000, heartbeat, NULL)登记一个"每 1000ms 喊一次"的工人;mote_task_start(0)让他上岗。不上岗的任务只占名单(Flash),不占 RAM—— 所以名单可以多写,上岗数看需要。
四件套选型口诀
- 到点干一次 → 闹钟(5.1)
- 发生一件事、立刻通知 → 纸条(5.2)
- 纸条上捎个数字 →
MOTE_P/MOTE_U32(5.3) - 一大块数据 → 快递柜(5.4)
- 固定节奏的周期活 → 打卡机(5.5)
分不清时问自己一句:"这件事多久发生一次?要不要带东西?带的是数字还是一坨数据?"
第 6 章:FAQ(踩坑大全)
这一章要干成的事:把大家移植时最常踩的十个坑集中解答。
用法:先通读,再对号入座
建议先把十个坑通读一遍,遇到症状时回来按编号对号入座; 排查时别忘了第 1 章的口诀——报错先看头文件路径。
Q1:LED 不闪,程序像死了一样?
检查:① SysTick_Config 参数算对没有;② mote_port.c 加进工程没有;③ 主循环是不是 mote_loop() 而不是你自己的 while(1)。
白话补充:这三条分别对应"报时器没拨对、心跳没接上、内核根本没在跑"。 排查顺序就是"先看表拨没拨,再看线接没接,最后看人上没上班"。
Q2:串口狂收数据时会丢字符?
邮箱槽数调大(MOTE_MAILBOX_DEF 的第 3 个参数);同时把队列槽数 MOTE_EVT_QUEUE_SIZE 调大。注意 mote_mail_send 返回 MOTE_ERR_FULL 时说明配置小了,返回 MOTE_ERR_PARAM 时说明发送长度超过格子大小(按实际报文长度设计 item_size,别给 1 字节报文开 64 字节的格子)。
白话补充:快递柜格子满了货就进不来(
MOTE_ERR_FULL= 柜满); 货比格子大也会被拒收(MOTE_ERR_PARAM= 货不合格)。两个错别混: 丢字先查是不是柜满,查完柜满再查格子尺寸。
Q3:handler 里能调什么 API?
handler 运行在主循环上下文,所以全部 API 都能调:mote_event_post*、mote_mail_send/recv、mote_timer_start/stop、mote_task_start/stop 都没问题。
Q4:中断里能调什么?
只有 mote_event_post*、mote_mail_send、mote_tick。定时器、任务 API 都只能在主循环上下文用。
白话补充:开门的人(中断)只许递纸条、放货、记心跳;闹钟、打卡机这些 "调度"性质的活只能由主循环来办。这不是限制,是设计——开门越快,大家等得越短。
Q5:handler 里写 while 等标志行不行? 不行。handler 必须毫秒级返回,否则整个系统卡死。长流程拆状态机 + 定时器(参考第 5 章),或分多步投递事件给自己。
白话补充:工人站在岗位上死等一个信号,整个工厂就围着他一个人停摆了。 想"过一会儿再干下一步",用 5.1 的闹钟或延时纸条(《使用教程》2.3)。
Q6:wfi 没省电?
用电流表测:空闲时电流应明显下降。没降?检查 mote_idle() 是不是真的执行了 wfi(有没有被强符号覆盖,见第 3 章)。
想进一步省电(避免每 1ms 醒一次):开 tickless(MOTE_TICKLESS=1),
空闲时内核会把 SysTick 重装到下一 deadline 再睡,见 4.3 及其板级验证清单。
白话补充:省电是可以用电流表直接看到的"硬指标"。没省电,先怀疑芯片 根本没闭眼(函数被覆盖或没实现);闭眼了还想更省,就上 tickless。
Q7:MOTE_TICK_MS 改成 10 会怎样?
tick 变成 10ms 一拍,SysTick_Config(SystemCoreClock / (1000 / MOTE_TICK_MS)) 保持这个公式即可,定时器精度变粗但更省电。tickless 模式下 deadline 计算也基于这一拍宽,无需额外处理。取值范围 1..1000,越界会编译期 #error 直接拦下(防静默出错)。
白话补充:心跳从 1ms 一拍放缓到 10ms 一拍,闹钟的最小刻度就变成 10ms, 精度粗了、但醒来的次数少了、更省电。公式不用改——它本来就是按拍宽算的。
Q8:事件 ID 可以不连续吗? 可以,但注册表是数组,ID 越大 Flash 浪费越多。建议枚举连续定义从 0 开始。
白话补充:值班表是一排编号格子,你写了个 200 号,前面 200 个空格子也得 空占着地方(Flash)。编号从 0 连续排,一格都不浪费。
Q9:队列满了怎么办?
mote_event_post 返回 MOTE_ERR_FULL,你可以重试或丢弃;状态类事件用 mote_event_post_replace(同 ID 只保留最新)。
白话补充:筐满时内核不硬塞也不崩,而是把"满了"如实告诉你,怎么处理由你定。 "按键当前是按下还是松开"这类只关心最新状态的事件,用 replace 版:同编号 纸条筐里永远只留一张最新的,手再抖也塞不爆。
Q10:handler 执行时间有没有上限? 协作式内核没有强制上限,全靠自觉(铁律 1)。参考值(按指令条数估算,无实测数据背书): 48MHz 下 handler 控制在 1ms 内返回,10ms 级事件调度余量充足。请按第 7 章检查清单实测确认。
白话补充:内核不设闹钟逼工人交班——它信任你自己守时(这就是"协作式" 名字的来历)。1ms 这个数只是参考,没有实测数据背书,务必自己实测 确认(方法见第 7 章)。
第 7 章:移植成功检查清单
这一章要干成的事:在说"移植完成"之前,逐项打勾。前两项是编译和体积的"纸面账", 后面每一项都要上板实测。
必读:本仓库没有任何官方板级实测数据
功耗、延迟、体积的所有数字,都需要你在自己的板子上实测—— "目测差不多"不算数,你的板子只有你自己能验。
- 编译 0 error 0 warning(开
-Wall -Wextra -Werror,与 CI 同口径) - 内核体积核对:map 文件里内核三件套 text <2.75KB(RV32)/ <2.5KB(M0+)、移植层 port text <512B、RAM <512B(本机实测 M0+ 三件套 2297B / port 14B / RAM 280B,见 README 与 docs/test.md;体积随工具链版本小幅浮动,以 CI 阈值与你自己 map 文件为准)
- map 文件里
SysTick_Handler只出现一次(在 mote_port.o 里) - LED 闪烁周期用逻辑分析仪/示波器实测 ≈ 设定值
- 中断延迟实测:DWT CYCCNT 或 GPIO 示波器测
mote_mail_send最坏路径(方法见使用教程附录 A),确认符合你的延迟预算 - 低功耗唤醒实测(RISC-V 青稞必做):空闲电流明显下降(wfi 生效)+ tick 准时唤醒、事件不睡过头
- tickless 专项(若开启
MOTE_TICKLESS=1):按 4.3 的 tickless 板级验证清单逐项实测(HCLK 换算、提前唤醒追平、计数器位宽、长睡功耗) - 周期相位实测:示波器观察连续周期触发间隔,确认无累积漂移(尤其 handler 慢的场景)
- 串口高波特率(115200 以上)连续收发不丢字
- 空闲电流明显下降(wfi 生效)
- 重启 100 次无异常(看门狗场景下无卡死)
白话补充几个词:
- map 文件:翻译官交的"拼装详单",写着每个函数最终放在哪、占多少字节。 体积核对就是翻这份详单——数字达标才算"真的小"。
- 逻辑分析仪 / 示波器:能看见电信号波形的仪表。灯闪得准不准、 睡眠电流降没降,用仪器看波形、读电流,而不是"目测差不多"。
- 中断延迟实测的方法在《使用教程》附录 A:进临界区前置高一个引脚、 退出拉低,用示波器量脉冲宽度。这是把"估算"变成"实测"的唯一途径。
- 看门狗:芯片里的"防死机保险丝",程序卡死就自动重启。有它的时候, 重启 100 次看会不会卡在哪个环节。
全部打勾,恭喜:这颗芯片的 MoteOS 移植完成。欢迎把实测数据回馈给仓库, 让下一颗芯片的移植者少踩一个坑。