MoteOS 使用教程(从零开始)

本文写给从来没有接触过单片机编程的读者:不需要会 C 语言,不需要认识 "寄存器"、"中断"、"编译"这些词。凡是正文里要用到的技术名词,第一次出现时 都会用生活中的东西打比方讲清楚。你唯一要做的准备:已经照着《移植教程》 (docs/porting.md)把最基本的工程跑起来过一遍(那份教程会手把手带你做)。 遇到不认识的词,随时翻第 0 章术语表。我们慢慢讲,不赶时间。


第 0 章:术语表(不认识哪个查哪个)

这一章是"字典"。后面正文里出现的每一个名词,这里都有一句话解释。 不用背,先扫一眼有个印象,读正文时忘了哪个词就回来查。

0.1 名字和缩写

词 全称/含义 一句话解释
event / evt event = 事件 一张纸条,写着"发生了什么事",比如"按键按下了""串口来数据了""时间到了"。evt 只是 event 的缩写,代码里用短的
handler 处理函数 专门处理某一类纸条的工人。纸条一到,内核就喊他干活
回调(callback) — handler 这类函数的统称。特点:不是你主动喊它,而是系统"反过来"喊它("回调"由此得名)
param parameter = 参数 纸条附带的一个"小口袋"(一个指针),可以装一个数值,或指向某块数据
ctx context = 上下文 注册 handler 时你交给他的"自备工作台"。内核喊 handler 时原样转交。用不用随你
post 投递 把纸条塞进传达室门口筐里的动作。不是"马上干活",而是"放进筐里排队"
队列(queue) — 传达室门口的筐:先放进去的纸条先被处理
tick 节拍 内核的心跳,每 MOTE_TICK_MS(默认 1ms)跳一次,由硬件定时器中断驱动
tickless 无节拍空闲 低功耗模式:没事情时内核把 tick 定时器拨到"下一件到点的事"再睡,不再每 1ms 醒一次(配置 MOTE_TICKLESS=1,见移植教程)
注册表 — 一张对照表:纸条编号 → 对应的工人。内核靠它知道"纸条该给谁"
定时器(timer) — 闹钟。到点自动往筐里投一张纸条
邮箱(mailbox) — 快递柜。传递"一大块数据"(数组/结构体)用的
任务(task) — 打卡机。周期性地固定喊某个 handler 来干活
ISR / 中断 Interrupt Service Routine 门铃响时去开门干的那点活(一段特殊代码,比如"串口收到一个字节"触发的代码)。开门动作要快

0.2 代码里的符号

写法 含义 例子
_t 结尾的类型 _t = type。C 语言惯例:以 _t 结尾的名字是"自定义类型" mote_timer_t = MoteOS 的定时器类型;uint32_t = 32 位无符号整数
MOTE_ 前缀 MoteOS 专属标记 防止和你的代码、其他库撞名。看到 MOTE_ 开头就知道是内核的东西
void * 万能指针 可以指向任何类型的数据。param 就用它,所以能装任何东西
uint8_t / uint16_t / uint32_t 定宽整数 8/16/32 位的无符号整数。跨芯片大小永远不变,嵌入式代码的标准写法
static 两种作用 ① 修饰变量:让变量永久存在(放全局区,函数返回也不消失);② 修饰函数:只在本文件内可见
const 只读 配合芯片特性,const 数据通常被放进 Flash(长期记忆),省 RAM(短期记忆)
NULL 空指针 = "没有" 用在"不需要带东西"的场合
&x 取地址 把变量 x 的"位置"告诉别人,别人通过指针操作它
(uint8_t)x 强制类型转换 把 x 按"8 位无符号整数"重新看待(截断高位)
enum 枚举 给编号起名字。EVT_LED 比 0 好记一万倍
sizeof(x) 求大小 算出 x 占多少字节。sizeof(arr)/sizeof(arr[0]) = 数组元素个数

0.3 两个关键观念

观念一:handler 是"被叫去的",不是"主动跑"的

传统写法:你写 main,你控制流程,你想什么时候干什么就干什么。 MoteOS 写法:你写 handler(工人),内核在纸条到来时喊他。工人干完活 必须马上回来(毫秒级),把控制权交回内核。

观念二:中断里只许"递纸条",不许"干活"

中断 = 门铃响了去开门。开门动作打断了正在干的事情,开得越久,其他事情 就被拖得越久。所以开门时(中断里)只许干一件事:把纸条塞进筐里(post), 真正的活留给工人在主循环里干。

0.4 先认识那台"指甲盖电脑"(新增小节)

MoteOS 是给单片机用的。单片机(也写作 MCU)就是一块比指甲盖还小的电脑: 里面有一颗会算数的大脑,有一点点记忆,还伸出几根"腿"(引脚)去连外面的灯、 按键、传感器。它没有鼠标键盘,全靠代码指挥。下面这些词,第 1 章起会反复出现:

词 生活里的比喻 再补一句
单片机(MCU) 一块比指甲盖还小的电脑 大脑 + 记忆 + 引脚。咱们写的程序就是教它怎么干活的指令
寄存器 芯片内部一排排小开关 往里写一个数字 = 拨动几个开关。灯亮不亮、串口收不收,全靠这些开关
时钟 / 主频(Hz) 芯片的心跳 一秒跳几次。72MHz = 一秒跳 7200 万次。跳一次,芯片就干一步活
中断 门铃 芯片正干活,门铃响了,就放下手里的活去开门,开完门回来接着干
SysTick 芯片自带的"自动报时器" 到点就来按门铃,提醒内核"又过去 1 毫秒啦"。怎么接线见《移植教程》
tick(节拍) 报时器响一下 报时器每 MOTE_TICK_MS(默认 1 毫秒)响一次,响一次 = 时间走一格
中断处理函数(ISR) 开门时干的那点活 门铃一响芯片去开门,开门干的这点活一定要快,别让客人久等
wfi 让芯片"闭眼打盹"的指令 没事干时打盹省电,门铃一响自动醒
临界区 干活时挂"请勿打扰"牌子的时间段 这段时间谁都不许打断(不许响门铃),因为手上的活必须一口气干完
编译 把代码翻译成芯片能听懂的语言 你写的是人话(C 语言),编译器把它翻成芯片认识的"机器话"
头文件 说明书目录 告诉你"这个库提供哪些功能函数",编译器照着它检查你有没有喊错名字
IDE 写代码用的编辑器软件套装 编辑器 + 编译 + 烧录(把程序塞进芯片)打包在一起
Flash 芯片的长期记忆 断电也不忘,用来放程序本身
RAM 芯片的短期记忆 断电就忘,用来放运行中的数据(变量)
栈 RAM 里放临时草稿的区域 函数进来时用的草稿纸,函数一返回,草稿纸就被收走另作他用
时基 内核心里记的"现在几点" 内核数着 tick 的响铃次数,就知道现在几点了

第 1 章:MoteOS 的思考方式 + 第一个程序(点灯)

1.1 一张图看懂内核

把 MoteOS 想象成一家小工厂:

  • 事件(event) = 一张纸条,写着"发生了什么事"
  • handler = 一个工人,专门处理某一类纸条
  • post = 把纸条塞进传达室门口的筐(队列)里
  • 主循环(mote_loop) = 传达室大爷,不断从筐里拿纸条、看编号、喊对应工人来干; 筐空了、闹钟也没到点,他就眯一会儿(wfi 睡觉,tickless 模式下还会把 闹钟拨到最近一次"到点时刻"再睡)

整个工厂的运转图:

                            传达室
  门铃响(中断里塞纸条) ┌──────────────────────────────┐
  闹钟到点(自动塞纸条) ──▶│ 筐(队列)  ──▶ 大爷取出纸条 │
  你的代码(主动塞纸条) ──▶│ 看编号 → 查值班表 → 喊工人  │
                          └──────────────────────────────┘
                                    │
                     工人(handler)干完活,马上回来
                                    │
                                    ▼
                    大爷继续拿下一张纸条(循环往复)
12345678910

写 MoteOS 程序 = 只做三件事:

  1. 定义"纸条有哪些种类"(事件 ID 枚举)
  2. 定义"每类纸条谁来处理"(handler + 注册表)
  3. 在需要的时候"塞纸条"(post)

再记住一条:工人干活要快(毫秒级),干不完就撕成几张小纸条分几次干。

1.2 点灯程序,每一行、每个词都解释

先看完整代码,看不懂没关系,紧接着有逐行白话讲解:

#include "mote.h"          /* 包含内核头文件。头文件 = 说明书目录(见术语表 0.4)。
                            * include = 把这份目录抄进来,编译器才知道有这些函数 */

enum { EVT_LED = 0 };      /* enum 枚举:给纸条种类起名字。
                            * EVT_LED = 0 号纸条,意思是"灯该翻转了"。
                            * 编号必须从 0 连续编(为什么:见第 7 章铁律 4)。
                            * EVT 是 Event 的缩写,前缀统一好辨认 */

static mote_timer_t led_timer;  /* 定义一个"闹钟"变量。
                                 * mote_timer_t:_t 结尾 = 类型名(见术语表)。
                                 * static:让这个变量永久存在(函数返回也不消失)。
                                 * 闹钟要一直活到"响",所以必须 static 或全局。
                                 * 如果写在函数里且不加 static,函数一返回闹钟就废了 */

static void led_handler(uint16_t evt, void *param, void *ctx)
{   /* 定义一个 handler(工人)。签名(参数列表)照着抄就行,内核要求这个格式:
     *   evt   = 收到的是几号纸条(一个 handler 注册多个事件时用它区分)
     *   param = 纸条上粘的东西(小口袋,现在用不上)
     *   ctx   = 注册时你交给他的工作台(现在用不上)
     *   static:这个函数只在本文件用,加 static 是好习惯 */
    GPIOB->ODR ^= (1u << 0);         /* 干活的代码:翻转 PB0 引脚的灯 */
}

static const mote_evt_entry_t table[] = {
    /* 值班表(注册表)!一个数组,每一项 = "几号纸条 → 找谁"。
     * const:这张表运行时永远不变,编译器把它放进 Flash(长期记忆),不占 RAM。
     * [EVT_LED] = ... :只填第 0 格,其他格子自动为"空" */
    [EVT_LED] = MOTE_ENTRY(led_handler, NULL),
    /* MOTE_ENTRY(handler, ctx):打包成表项。
     * 第一个参数 = 找谁(led_handler);
     * 第二个参数 = 工作台(ctx),这里不需要,填 NULL(空指针="没有") */
};

int main(void)
{
    SysTick_Config(SystemCoreClock / 1000);
    /* 让芯片每 1ms 产生一次 tick 中断(内核心跳,详见《移植教程》)。
     * SysTick = 芯片自带的自动报时器;SysTick_Config 的参数 =
     * 两次按门铃之间数多少个心跳(时钟周期),不是频率。
     * 72MHz 芯片:1ms 有 72000 个心跳,SystemCoreClock/1000 = 72000 → 正好 1ms */

    mote_init(table, 1);
    /* 把值班表交给内核。第二个参数 = 表有几项 = 最大编号+1。
     * 我们只有 0 号纸条,所以是 1 */

    mote_timer_start(&led_timer,   /* 开闹钟。& = 取地址:把闹钟变量的位置告诉内核 */
                     EVT_LED,      /* 闹钟响了,投 0 号纸条 */
                     NULL,         /* 纸条上不粘东西 */
                     500,          /* 500ms 响一次 */
                     true);        /* true = 循环闹钟(响完自动定下一次) */
    mote_loop();                   /* 内核主循环开始运行,永不返回 */
    /* 此后的世界:闹钟响 → 自动投 EVT_LED 纸条 → 大爷拿纸条 → 查表 → 喊 led_handler → 灯翻转 */
}
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253

逐行白话版(跟上面的注释是一一对应的):

  1. #include "mote.h":把内核的"说明书目录"抄进来。就像开饭馆前先看一眼 供货商目录,知道有哪些食材(函数)可以点。
  2. enum { EVT_LED = 0 }:给第 0 种纸条起名叫 EVT_LED(灯该翻转了)。 编号必须从 0 开始连续排,原因见第 7 章铁律 4。
  3. static mote_timer_t led_timer:买一个闹钟,放在永远不会被收走的地方。 static 保证这个闹钟活到响为止(如果放在函数里又不加 static, 函数一返回闹钟就被当废品收走了,永远响不了)。
  4. led_handler:定义工人。他要干的活就一行——把灯的开关拨一下(灯亮变暗、 暗变亮)。他干完马上回来,不磨蹭。
  5. table:值班表。只填了 0 号格:"0 号纸条 → 找 led_handler"。 这张表用 const 修饰,被放进 Flash(长期记忆),不占 RAM(短期记忆)。
  6. SysTick_Config(...):给芯片的自动报时器上发条,让它每 1 毫秒按一次门铃 (这是"tick 节拍"的来源,具体接法见《移植教程》)。
  7. mote_init(table, 1):把值班表交给内核(传达室大爷)。
  8. mote_timer_start(...):设定闹钟——每 500 毫秒响一次,响了就往筐里投一张 0 号纸条。
  9. mote_loop():大爷开始上班,主循环开始。从这一刻起,程序进入"纸条驱动" 的世界,再也不用你操心调度。

程序跑起来后发生了什么?(跟着走一遍就全懂了)

芯片每 1ms 产生 tick 中断 → 内核的心跳计数器 +1
数到 500 → 内核发现闹钟到期 → 自动往筐里塞一张 [EVT_LED] 纸条
主循环从筐里拿出纸条 → 查值班表 → 喊 led_handler 干活 → 灯翻转
筐空了 → 让 CPU 睡觉(省电)
1ms 后 tick 中断叫醒 CPU → 继续数数……
12345

要点:实时性来自闹钟,不来自 handler 的手速

灯闪得准不准,取决于闹钟(定时器)到点投纸条,不取决于 handler 写得快慢。 handler 慢只会拖累"处理",不会改变"到点"——这就是实时性的来源。


第 2 章:纸条(事件)怎么递——post 三兄弟

纸条要寄到传达室,有几种寄法:普通寄(post)、覆盖寄(replace)、定时寄 (delayed)。先想清楚"纸条代表的是'发生次数'还是'当前状态'",再选寄法, 这一章就通了。

2.1 普通投递 mote_event_post

最常用的寄法:把一张纸条塞进筐里排队。

mote_status_t r = mote_event_post(EVT_LED, NULL);
/* mote_status_t = 内核的"返回状态"类型,用来告诉你结果 */
12
  • 谁都能递:中断里、handler 里、main 里都行
  • 返回值必须看一眼,就像寄快递要看回执:MOTE_OK = 递进去了; MOTE_ERR_FULL = 筐满了(默认 16 张纸条全没处理完)。满的时候你自己决定: 重试、丢弃、还是调大 MOTE_EVT_QUEUE_SIZE

2.2 覆盖投递 mote_event_post_replace

场景:按键手抖,中断风暴一口气递了 20 张"按键按下"纸条——筐爆了。

mote_event_post_replace(EVT_KEY, NULL);
1

效果:筐里如果已有 EVT_KEY 纸条,只更新最新那张,不再新增。 手再抖,筐里永远只有一张按键纸条。

什么时候用:事件代表"当前状态"而不是"发生次数"时——按键、ADC 当前值、 界面刷新。这类事件一律用 replace。

2.3 延时投递 mote_event_post_delayed

就像给未来的自己寄一张定时明信片:"请 100ms 后把这张纸条放进筐。"

mote_event_post_delayed(EVT_LED, NULL, 100);   /* 100ms 后再把纸条放进筐 */
1

适合"延迟关屏""开机 3 秒后自检"。同时最多 MOTE_DELAYED_MAX(默认 4)张在路上, 超了返回 MOTE_ERR_FULL。 ms 传 0 或 ≥2^31(约 24.8 天)返回 MOTE_ERR_PARAM(与定时器同口径的运行时校验)。

两个配套 API(第 2 章新增):

/* 覆盖版:同一 EVT 只留最新一张在路上(重复调用不占新格子) */
mote_event_post_delayed_replace(EVT_LED, NULL, 100);

/* 取消:把还在路上的延时纸条收回来。返回 MOTE_ERR_NOT_FOUND = 没找到
 * (可能已经投递出去了,也可能 evt/param 对不上) */
mote_event_cancel_delayed(EVT_LED, NULL);
123456

典型用途:按键按下 3 秒后休眠;3 秒内再次按键就把旧的 cancel 掉再 post 一张新的(或用 replace 版直接覆盖)。

2.4 纸条上带东西:MOTE_P / MOTE_U32

纸条的 param 是个 void *(万能指针),能粘一个数值或指向数据。 把它想成纸条上缝的"小口袋":可以塞一张写着数字的便签,也可以塞一张 "门牌号"(指向某块数据的位置)。

uint32_t adc_val;                    /* 全局变量:永远存在,可以粘 */
/* 中断里:把数值刻在纸条上(≤32 位都能刻) */
mote_event_post(EVT_ADC, MOTE_P(adc_val));
/* MOTE_P(值) = 把数值伪装成指针塞进纸条。P = Param 的缩写 */

/* handler 里:把数值取回来 */
static void adc_handler(uint16_t evt, void *param, void *ctx)
{
    uint32_t v = MOTE_U32(param);    /* 把纸条上的指针还原成 32 位数值 */
    if (v > 3000) { /* 电压超了 */ }
}
1234567891011

MOTE_P(值) 就像把数字写在便签上再塞进口袋;MOTE_U32(param) 是把便签 拿出来读数字。一来一回,数值就跟着纸条"旅行"了一趟。

为什么不能粘栈变量? 看反面教材:

void some_function(void)
{
    uint8_t data[4] = {1,2,3,4};     /* data 是局部变量,住在"栈"上 */
    mote_event_post(EVT_RX, data);   /* 把 data 的地址粘到纸条上 */
}   /* ← 函数返回,栈上这块内存立刻被回收、被别的东西占用 */

/* 等大爷喊工人处理纸条时,data 地址指向的内容已经变成垃圾 → 程序莫名抽风 */
1234567

栈 = RAM 里的"草稿纸区"(见术语表 0.4):函数一进来领一张草稿纸写写画画, 函数一返回,草稿纸就被收走、给别的函数接着用。你把草稿纸的门牌号塞进纸条, 等工人按门牌号找过去,纸上的字早就不是你的了。

必读:纸条只粘"永远在场"的东西

粘全局/静态变量(永远在),或粘 MOTE_P(数值)(刻在纸条上)。 大块数据走第 4 章邮箱。粘栈变量 = 工人找过去时,草稿纸已经被别人改写。

2.5 返回值的四种"回执"(新增小节)

内核的"寄件回执"一共四种,全篇通用,这里一次说清:

返回值 生活比喻 常见场景
MOTE_OK 顺利签收 post 递进筐、mail send 入柜、闹钟设定成功
MOTE_ERR_FULL 地方满了,装不下 筐满、延时槽满、柜子格满、任务槽满
MOTE_ERR_PARAM 你给的要求不合理,被当场退回 时长传 0 或 ≥2^31、超格长度、非法的策略值
MOTE_ERR_NOT_FOUND 没找到你要找的东西 取消延时纸条时,纸条已经寄出 / 编号对不上

第 3 章:闹钟(定时器)怎么定

定时器 = 闹钟:设好"几点响、响几次",到点它自动往筐里投一张纸条, 你什么都不用管。闹钟的设定/关停/改时间都只能在主循环上下文里做 (handler 或 main),中断里不许碰(铁律 3,见第 7 章)。

3.1 单次闹钟(响一次就扔)

static mote_timer_t t;                    /* 闹钟变量必须 static 或全局 */

mote_timer_start(&t, EVT_XXX, NULL, 1000, false);
/* 参数顺序:闹钟变量 → 响了投几号纸条 → 纸条上粘什么 → 多少 ms 响 → 是否循环 */
/* false = 只响一次 */
12345

五个参数逐个翻译成大白话:

  1. &t:告诉内核"用这个闹钟"。& 是取地址(把闹钟放哪儿告诉人家)
  2. EVT_XXX:闹钟响了,往筐里投几号纸条
  3. NULL:纸条上不粘东西(需要粘数值就写 MOTE_P(x),见 2.4)
  4. 1000:1000 毫秒(1 秒)后响
  5. false:只响这一次,响完闹钟自己作废

3.2 循环闹钟(一直响)

mote_timer_start(&t, EVT_XXX, NULL, 50, true);       /* 每 50ms 响一次 */
1

最后一位换成 true,闹钟就变成"循环闹钟":响完自动定下一次,永远不退休。 想让它停,用下面的 stop。

3.3 关掉 / 改时间

mote_timer_stop(&t);               /* 关掉。没开过也安全,不会出错 */
mote_timer_restart(&t, 2000);      /* 把时间改成 2 秒后响。前提:它当前是开着的 */
12

3.4 五个要注意的点

  1. 变量必须 static(或全局):闹钟要持续存在直到响(原因见 1.2)
  2. handler 里可以随意开/关闹钟——这是把"长流程拆成多步"的官方姿势(见 7.1)
  3. 中断里不能碰定时器——定时器 API 只能在主循环上下文用(铁律 3)
  4. 时长上限约 24.8 天(2^31-1 ms,回绕比较的数学边界),超出直接返回 MOTE_ERR_PARAM(运行时校验,不依赖可关闭的断言);更长的间隔用 "周期闹钟 + 计数"自己累积。 说说"回绕"是怎么回事:内核用 32 位计数器记时间,像摩托车的里程表, 满格了就会转回 0 重新数。内核比较"谁先到点"用的是对回绕安全的比较方法, 所以即使碰上"翻表"那一刻也不会失灵;但数学上能表示的时长最多是 2^31-1 毫秒(约 24.8 天),这是刻意的安全边界
  5. 闹钟内部按到期时刻排队:内核把闹钟按"什么时候响"排序, 没到点的闹钟每次 poll 只需看一眼队头(O(1)),闹钟多了也不拖慢主循环; mote_timer_restart 改时间后会自动重新排队

3.5 队列满时的三种策略(重要)

先讲故事:筐(队列)满了,可闹钟偏偏这时候响了,纸条塞不进去。怎么办? 三种策略,就是三种面对"满筐"的做法:

  • DROP:塞不进去就当场撕掉,闹钟作废。适合"过期作废"的通知(超时检测)
  • RETRY:塞不进去就捏在手里,下一拍再塞。适合"迟到没关系"的通知(灯闪烁)
  • LATEST:同编号的纸条只留最新一张。适合"只关心最新状态"的通知(ADC 值)

用 mote_timer_start_ex 指定策略(末尾多一个参数):

/* 策略一 DROP(严格截止):到期即投,失败即弃并释放定时器。
 * 适合超时检测——事件要么准时出现,要么永不出现 */
mote_timer_start_ex(&t, EVT_TMO, NULL, 100, false, MOTE_TIMER_POLICY_DROP);

/* 策略二 RETRY(默认):单次定时器满队重试,事件"至少一次"送达但可能迟到。
 * 适合 LED 闪烁这类迟到无所谓的场景 */
mote_timer_start_ex(&t, EVT_LED, NULL, 500, false, MOTE_TIMER_POLICY_RETRY);

/* 策略三 LATEST:replace 语义,队列里同 ID 只留最新一份。
 * 适合状态类事件(ADC 值、位置更新),天然防堆积 */
mote_timer_start_ex(&t, EVT_ADC, NULL, 20, true, MOTE_TIMER_POLICY_LATEST);
1234567891011

选型速查

场景 策略
超时检测、协议截止时间 DROP(严格截止)
迟到无所谓(闪烁、心跳) RETRY(默认)
状态类、只关心最新值 LATEST

默认的 mote_timer_start = RETRY(单次)/ DROP(周期,满队丢当次并计入 mote_dropped_count())。 RETRY 满队时定时器不释放,下一拍自动重试(所以是"至少一次",最坏晚一拍); 重试的失败只是"暂缓",不计入 mote_dropped_count() 也不触发丢事件钩子—— 事件最终会送达,不是丢弃(周期定时器满队丢当次才是真丢弃,照常计数)。 policy 传越界值返回 MOTE_ERR_PARAM(运行时校验)。

RETRY 的"暂缓"长这样(每格 = 1 拍):

到期 ●(筐满,塞不进)──▶ 暂缓:捏在手里,不计数、不触发丢事件钩子
     ├── 下一拍再试(又满)──▶ 还是暂缓,同上
     └── 某拍筐有空位 ──▶ 塞进去了 → 最终送达,绝不会凭空消失
123

只有周期定时器"满队丢当次"才是真正的丢弃,才会在 mote_dropped_count() 里 +1(监控丢了多少事件的办法见第 8 章排查表)。

3.6 循环闹钟的相位稳定(重要)

循环闹钟按绝对相位触发:每次到期,下次到期时刻是 上次到期 + 周期, 而不是"本次触发时刻 + 周期"。

  • handler 或主循环忙了 3ms(周期 10ms):下一次仍在 20/30/40ms 触发, 不会漂移到 23/33/43ms——延迟不会逐周期累积
  • 一口气错过好几拍:只补投一张纸条(合并),相位照旧
  • 极端落后(超过 MOTE_TIMER_CATCHUP_MAX,默认 1000 拍):放弃旧相位、 从当前时刻重新对齐(防御性,防止推进循环过长)

画成时间轴(理想拍点每 10ms 一格):

忙了一小会儿(3ms):        │ 一次延迟不传染下一拍
理想拍点:   10    20    30    40    50
实际触发:   10    21    30    40    50   ← 21 那拍晚了 1ms,30 照旧

主循环卡了很久(错过好几拍):│ 只补一张纸条(合并),相位照旧
理想拍点:   10    20    30    40    50
实际触发:   10  ────(卡住)────  43    50  ← 43 补一张,之后照旧
1234567

对时、采样时刻、协议心跳这类需要相位稳定的场景直接依赖这一语义。 注意单次触发的时刻抖动仍取决于主循环的空闲程度(协作式内核的特点使然)。


第 4 章:快递柜(邮箱)——大块数据怎么传

纸条的小口袋只能粘一个小数值。要传一坨数据(串口字节流、传感器报文), 用邮箱。

邮箱 = 快递柜:发送方把货复印一份放进格子,接收方凭纸条(事件)来取。 数据是"复印"进去的,所以原数据之后怎么变都不影响柜子里的副本。

MOTE_MAILBOX_DEF(uart_mb,      /* 柜子名字,随便起(宏会帮你生成对应变量) */
                 EVT_UART,     /* 有货到柜时,投几号纸条(取件通知) */
                 32,           /* 柜子有几个格子 */
                 1);           /* 每个格子多大(字节):串口逐字节收发 → 1 字节一格 */
1234

四个参数翻译成大白话:

  1. uart_mb:给柜子起的名字。这个宏会帮你变出一个真正的柜子变量
  2. EVT_UART:每当有货进柜,自动往筐里投这张"取件通知"纸条, 告诉工人"快递到了,来取"
  3. 32:柜子一共 32 个格子(格子全满就塞不进了,会返回 MOTE_ERR_FULL)
  4. 1:每个格子能装 1 字节(1 个字节 ≈ 装一个字符的量),串口逐字节收发正好

长度契约(必读)

每格最大 item_size 字节(1..255,MOTE_MAILBOX_DEF 编译期强制), mote_mail_send 的 len 必须 1..item_size(超长或 0 返回 MOTE_ERR_PARAM, 不再静默截断);mote_mail_recv 返回实际存入的字节数。 每格额外花 1 字节 RAM 记录长度。

4.1 经典用法:中断放货,handler 取货

/* 串口中断里——放货(中断里唯一允许的"数据类"操作)。
 * 注意:send 在关中断状态下执行(与事件入队同一临界区原子完成),
 * 顺序为"先入队、成功后入箱"——入队失败时柜子不动、无残留;
 * 中断延迟与拷贝字节数成正比——延迟预算与实测方法见附录 A */
void USART1_IRQHandler(void)
{
    uint8_t c = (uint8_t)USART1->DR;   /* 读串口寄存器,截成 8 位存进 c */
    mote_mail_send(&uart_mb, &c, 1);
    /* 三个参数:柜子 → 货物的地址(&c = c 的位置)→ 复印多少字节 */
}

/* handler 里——取货 */
static void uart_handler(uint16_t evt, void *param, void *ctx)
{
    /* 纸条的 param 就是"哪个柜子来货了"(内核自动粘上的柜子指针) */
    mote_mail_t *mb = (mote_mail_t *)param;   /* 把万能指针还原成"柜子类型"指针 */
    uint8_t c;

    while (mote_mail_recv(mb, &c) > 0) {   /* 取一格;空柜返回 -1 */
        /* 处理 c,直到把所有格子清空 */
    }
}
12345678910111213141516171819202122

逐段白话:

  • 中断里的活:串口收到一个字节(门铃响了),把字节装进 c, mote_mail_send 把它复印一份塞进柜子,同时自动往筐里投一张 EVT_UART 纸条(取件通知)。放完货马上回去,开门动作极短。
  • 工人(handler)的活:收到纸条后,从纸条的小口袋里掏出柜子的门牌号 (param 里就是哪个柜子来货了),然后一个劲儿地 mote_mail_recv 取货, 每取一格处理一格,直到柜子空了(返回 -1 = 空柜)。
  • 收货和投递都发生在**挂"请勿打扰"牌子的时间段(临界区)**里一口气完成, 不会做一半被人打断(原子 = 要么全做完,要么全没做)。

4.2 格子数怎么算(防丢数据)

公式:格子数 ≥ 中断最坏情况下一口气来的字节数 ÷ 每格字节数

例:串口 115200bps = 每秒约 11520 字节。假设最忙时 handler 10ms 没空处理,来了 115 字节:

  • 每格 1 字节(逐字节收发):格子数 ≥ 115,取 128(此时格子数 = 字节数)
  • 每格 64 字节(按帧收,如整条报文):格子数取 115÷64 向上取整 + 1 ≈ 3,取 4~8 更保险

逐字节方案格子多但每格小,按你的 RAM 余量和延迟预算选。

mote_mail_send 返回 MOTE_ERR_FULL = 格子全满 = 配置小了,调大或降波特率; 返回 MOTE_ERR_PARAM = len 超格或为 0,检查发送长度。

4.3 注意

  • 发送长度必须 1..格子大小:超长直接拒绝(返回 MOTE_ERR_PARAM),不再静默截断
  • mote_mail_recv 返回实际存入的字节数(1..格子大小),空柜返回 -1——不会回吐整格残留
  • 一个 handler 可以管多个柜子:靠 param 区分是哪个柜子来的
  • 柜子字段被写坏/非法构造时,mote_mail_recv 同样返回 -1(运行时拒绝, 不让来路不明的柜子参与流转);每格的长度域(lens)被写坏为 0 或 超过格子大小时同样返回 -1,不会按垃圾长度去越界读

4.4 放货的顺序为什么是"先入队、后入箱"(新增小节)

一笔放货其实是两件事:① 往筐里投"取件通知"纸条;② 把货复印进格子。 内核的顺序是先投纸条、纸条投进去了才放货,而且两步合并在同一个临界区 (挂"请勿打扰"牌子的时间段)里一口气做完:

mote_mail_send(&mb, &c, 1) 开始
  │
  ├─ 挂上"请勿打扰"牌子(进临界区,门铃暂停)
  │
  ├─ 第 1 步:把"取件通知"纸条投进筐
  │     ├─ 投进去了 ──▶ 第 2 步:把货复印进格子 ──▶ 收工
  │     └─ 投失败(筐满)──▶ 直接收工:格子一个没动
  │                          (无残留——绝不会出现"货进了格子
  │                            却没纸条"的尴尬状态)
  │
  └─ 摘下"请勿打扰"牌子(出临界区)
1234567891011

为什么非要这个顺序?反过来(先放货、后投纸条)的话,如果纸条没投进去, 货却已经躺在格子里,永远不会有人来取,还占着格子——这就是"残留"。 先入队后入箱,则最坏情况只是"这趟没放成",柜子干净如初。

代价是:中断里每放一次货,芯片都要挂着"请勿打扰"牌子做完拷贝。 拷贝的字节越多,牌子挂得越久。这笔账怎么算、怎么量,见附录 A。


第 5 章:打卡机(任务层)——周期性的活

定位说明:任务层不是抢占式 RTOS 的"任务"

任务层本质是**"带上下文的周期回调"便捷层**——到点直接调 handler,不走事件队列、 不吃 post_replace 语义。适合固定节奏的扫描类工作;需要事件驱动的复杂流程, 请用"handler + 定时器拆状态机"(第 7 章铁律 1 的姿势)。

它不是抢占式 RTOS 的"任务":不抢占、无独立栈、handler 被主循环直接同步调用、 与事件队列完全无关,只是名字顺口。对熟悉 RTOS 的读者请勿按任务语义理解。

闹钟和打卡机的区别:

闹钟(定时器) 打卡机(任务层)
工作方式 响一次 → 递一张纸条 → handler 收纸条干活 到点直接喊 handler 干活,不走纸条筐
状态 无(闹钟自己没记忆) 有专属状态槽
适合 零散事件、事件流 固定的周期工作:按键扫描、屏刷、喂狗
static void key_scan(uint16_t evt, void *param, void *ctx)
{
    /* 每 10ms 自动被叫来一次:读按键、消抖、发 EVT_KEY 纸条 */
}

static const mote_task_desc_t tasks[] = {
    /* 任务名单(描述符表):放 Flash,占的 RAM 可忽略 */
    MOTE_TASK_DEF(10, key_scan, NULL),
    /* MOTE_TASK_DEF(周期ms, handler, ctx):
     *   周期ms  = 多久叫一次
     *   handler = 叫谁
     *   ctx     = 交给他自带的"工作台"(原样传给 handler 第三个参数),不用就 NULL */
};

int main(void)
{
    /* 在 mote_init 之后 */
    mote_task_init(tasks, 1);   /* 把名单交给内核。1 = 名单上有 1 个任务 */
    mote_task_start(0);         /* 0 号任务打卡上班(按名单上的顺序编号) */
    /* 不 start 的任务 = 不占 RAM(名单在 Flash 里) */
    /* 想停:mote_task_stop(0); */
}
12345678910111213141516171819202122

白话解读:打卡机 = 一排固定轮班的岗位。MOTE_TASK_DEF(10, key_scan, NULL) 的意思是"每 10 毫秒,喊 key_scan 来干一次活"。和闹钟最大的不同: 闹钟是"响了 → 塞纸条 → 工人被纸条叫来"(绕了传达室一圈),打卡机是 "到点直接喊人"(不走纸条筐)。

槽位池:MOTE_TASK_SLOT_MAX(默认 4)= 同时上班的任务上限。 名单可以写 20 个任务,但同时只能有 4 个在打卡(第 5 个 mote_task_start 返回 MOTE_ERR_FULL)。 停掉一个就能再开一个。任务 handler 收到的 evt 固定是 MOTE_EVT_TASK(内核专用编号)。

周期校验:描述符 period_ms 为 0 或 ≥2^31(约 24.8 天)的任务无法启动, mote_task_start 返回 MOTE_ERR_PARAM(运行时校验,与定时器同口径)。 0 周期会退化成"每 poll 直接喊一次 handler",等同忙循环,别想钻空子。


第 6 章:完整实战——智能小夜灯

需求:光线暗时 LED 渐亮渐暗(呼吸);按键切换模式;串口可查询状态。

#include "mote.h"

/* 1. 纸条种类:连续编号,从 0 开始 */
enum {
    EVT_ADC = 0,     /* ADC 采完了(中断递) */
    EVT_KEY = 1,     /* 按键按下了(消抖后递) */
    EVT_QUERY = 2,   /* 串口来了查询命令(邮箱递) */
};

/* 2. 全局状态(纸条要粘的、多个 handler 要共享的,都放这) */
static uint32_t g_brightness;      /* 当前亮度 0~100 */
static bool g_breath_on;           /* 呼吸模式开关 */

/* 3. 柜子与闹钟(长度契约见第 4 章:len 必须 ≤ item_size) */
MOTE_MAILBOX_DEF(uart_mb, EVT_QUERY, 32, 1);
static mote_timer_t breath_timer;

/* 4. 工人:各管一摊 */
static void adc_handler(uint16_t evt, void *param, void *ctx)
{
    uint32_t v = MOTE_U32(param);
    if (v < 500) {                        /* 光线暗 */
        g_breath_on = true;               /* 开呼吸 */
    }
}

static void key_handler(uint16_t evt, void *param, void *ctx)
{
    g_breath_on = !g_breath_on;           /* 按键切换模式 */
}

static void query_handler(uint16_t evt, void *param, void *ctx)
{
    char reply[32];
    int n = sprintf(reply, "brightness=%lu breath=%d\r\n",
                    g_brightness, g_breath_on ? 1 : 0);
    /* 把 reply 前 n 字节通过串口发出去 */
}

static void breath_step(uint16_t evt, void *param, void *ctx)
{
    /* 每 20ms 被喊一次:亮度走一步,形成呼吸效果 */
    static int8_t dir = 1;               /* static 局部变量:函数结束也不丢,记住方向 */
    g_brightness += dir;
    if (g_brightness >= 100) dir = -1;
    if (g_brightness <= 5)   dir = 1;
    pwm_set(g_brightness);
}

/* 5. 值班表 + 名单 */
static const mote_evt_entry_t table[] = {
    [EVT_ADC]   = MOTE_ENTRY(adc_handler, NULL),
    [EVT_KEY]   = MOTE_ENTRY(key_handler, NULL),
    [EVT_QUERY] = MOTE_ENTRY(query_handler, NULL),
};

static const mote_task_desc_t tasks[] = {
    MOTE_TASK_DEF(20, breath_step, NULL),
};

int main(void)
{
    SysTick_Config(SystemCoreClock / 1000);   /* 参数 = 1ms 内的时钟周期数(见 1.2) */
    mote_init(table, sizeof(table) / sizeof(table[0]));
    /* sizeof(table)/sizeof(table[0]) = 表有几项:总字节数 ÷ 每项字节数。
     * 用这个写法,以后加纸条不用改这个数字 */

    mote_task_init(tasks, 1);

    mote_task_start(0);                  /* 呼吸任务上班 */
    mote_timer_start(&breath_timer, EVT_ADC, NULL, 100, true); /* 每 100ms 采一次光 */

    mote_loop();
}

/* 6. 中断们:只负责递纸条,绝不多干 */
void ADC_IRQHandler(void)
{
    mote_event_post(EVT_ADC, MOTE_P(adc_result));   /* 采样完成,把数值刻在纸条上 */
}

void KEY_IRQHandler(void)
{
    mote_event_post_replace(EVT_KEY, NULL);         /* 防抖:纸条只留一张 */
}

void USART1_IRQHandler(void)
{
    uint8_t c = USART1->DR;
    mote_mail_send(&uart_mb, &c, 1);                /* 放货 */
}
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091

拆解:为什么这么设计

  • 中断全部一行流(递纸条/放货)→ 中断里待的时间最短 → 实时性最好
  • 每个功能一个 handler,互不干扰 → 好改、好测、好复用
  • 呼吸效果放任务层(固定节奏),灯光判断放事件(异步到达)→ 各回各家

顺着代码走一遍这台小夜灯的一天:

每 100ms 闹钟响 ──▶ 投 [EVT_ADC](纸条上刻着光照值)
   └─▶ adc_handler 读值:太暗?把"呼吸开关"打开
按键按下(门铃)──▶ 递 [EVT_KEY](replace:筐里永远只有一张)
   └─▶ key_handler:呼吸开关翻个面
串口来命令(门铃)──▶ 货进柜 + 投 [EVT_QUERY]
   └─▶ query_handler:把当前亮度/模式拼成一句话,发回去
每 20ms 打卡机响 ──▶ 直接喊 breath_step:亮度 +1 或 -1,形成呼吸
1234567

思考题:把小夜灯改成"人来灯亮"

假设再挂一个"人体感应"模块:感应到人就亮,人走后 5 秒灭。

  1. 需要新增几号纸条?谁来处理它?
  2. "人走后 5 秒灭"用哪一章的哪个 API 实现?5 秒内人又来了,怎么把旧的安排取消掉?
  3. "有人/没人"这类状态事件,三种 post(普通 / 覆盖 / 延时)里选哪种最合适?

答案不唯一,重点是先把"纸条流"在纸上画出来。


第 7 章:四条铁律(违反的后果都演示给你看)

必读:四条铁律不是可选项

MoteOS 运转良好全靠这四条规矩。踩了任何一条,程序都会"看似能跑,偶尔抽风"。 下面每条都配了反面教材,动手写代码前先通读一遍。

铁律 1:handler 必须毫秒级返回,绝不阻塞

工人被喊来干活,干完必须马上回去。他一旦赖着不走,传达室大爷就卡在等他 这一步,后面的纸条全都没人处理。

反面教材(千万别这么写):

static void bad_handler(uint16_t evt, void *param, void *ctx)
{
    while (!(USART1->SR & USART_SR_RXNE)) { }   /* 死等串口数据 → 全厂停工! */
}
1234

后果:主循环卡在这个 handler 里,筐里的纸条越堆越高,其他 handler 全部饿死(系统假死)。 正确姿势:把长流程拆成多步,用闹钟/延时纸条推进:

static void step1(uint16_t evt, void *param, void *ctx)
{
    start_something();                          /* 启动动作(不等待) */
    mote_event_post_delayed(EVT_STEP2, NULL, 100);   /* 100ms 后再走下一步 */
}
12345

就像煮面:不是傻站着等水开,而是"先把锅架上,设个闹钟,100ms 后闹钟响 再回来下面"。

铁律 2:纸条只粘永久变量或数值(反面教材见 2.4)

纸条上粘的地址,必须指向"永远都在"的东西(全局变量、static 变量), 或者干脆用 MOTE_P(数值) 把数字直接刻在纸条上。粘"草稿纸"(栈变量) = 等工人找过去,纸已经被人改写了。

铁律 3:哪些 API 能在哪里调(背不下来就抄)

API 中断里 handler/主循环里
mote_event_post*(含 _delayed / _delayed_replace) 可以 可以
mote_event_cancel_delayed 可以 可以
mote_mail_send 可以 可以
mote_tick / mote_tick_advance 可以(移植层专用) 可以
mote_next_due 可以 可以
mote_ticks / mote_dropped_count / mote_set_drop_hook 可以(自带临界区) 可以
mote_timer_start/stop/restart 不行 可以
mote_task_start/stop 不行 可以
mote_mail_recv 不行 可以

口诀:开门(中断)时只许递纸条、放快递;闹钟、打卡机、取货,都回到传达室再干。

补一句:mote_loop() 是"大爷全自动上班"。如果你必须保留自己的 while(1) 主循环,可以改用 mote_poll() 单步驱动内核(每次处理一件事),没事干时喊 mote_sleep() 让它睡到下一件到点的事再醒——具体写法见《移植教程》。

铁律 4:事件 ID 从 0 连续枚举

ID 就是值班表的下标,表按"最大 ID+1"占 Flash。ID 写成 200 号,前面 200 格就白占了。 就像 200 个座位的礼堂,只坐最后一排,前面 199 排全空着浪费——编号从 0 连续排, 座位才能紧挨着坐满。


第 8 章:出毛病了?排查表

症状 最可能的原因 查哪里
LED 完全不闪 tick 没接上 SysTick_Config 调了没?mote_port.c 加工程没?
事件递了没反应 值班表没登记 / ID 超界 [EVT_X] = MOTE_ENTRY(...) 写了没?表大小传对没?
任务(打卡机)不执行 没调 mote_task_init / mote_task_start,或 id 越界 main 里两步都调了没?看 mote_task_start 返回值(MOTE_ERR_PARAM = id 越界或周期非法,MOTE_ERR_FULL = 槽位已满)
偶尔丢数据 队列/柜子小了 MOTE_EVT_QUEUE_SIZE、邮箱槽数调大,注意返回值
想监控丢了多少事件 — 读 mote_dropped_count():因队列满/事件无效被实际丢弃的累计数(单次 RETRY 定时器的满队重试不计入——它最终会送达)。想知道"丢的是哪个事件",用 mote_set_drop_hook() 注册回调(注意:钩子在关中断上下文运行,只允许事件/邮箱 API,禁止定时器/任务 API;钩子内再次触发的丢弃不会递归回调本钩子——防重入)
中断里改全局变量偶发抽风 中断和 handler 抢数据 数据只走纸条/柜子传,共享变量加临界区
省不了电 mote_idle 没生效 见移植教程 FAQ Q6
系统周期性卡一下 某个 handler 太慢 用 mote_ticks() 在 handler 头尾打点计时

看时间:uint32_t now = mote_ticks(); 返回系统节拍数(单位 MOTE_TICK_MS)。 mote_ticks() 就是内核心里的"现在几点"——内核数着 tick 的响铃次数过日子, 问它一句就能知道过了多久。

先在 PC 上测逻辑

MoteOS 内核可以在电脑上跑(cmake --build build && ctest), 业务逻辑先在电脑上验证,再上板,事半功倍。


第 9 章:FAQ

Q1:handler 里能 sleep/延时吗? 没有这个 API。想要"过一会再干"→ mote_event_post_delayed 或定时器。

Q2:一个 handler 能注册多个事件吗? 能。在值班表里多写几行 [EVT_A] = MOTE_ENTRY(h, NULL), [EVT_B] = MOTE_ENTRY(h, NULL), handler 里用 evt 参数区分是哪个纸条。

Q3:ctx 参数是干嘛的? MOTE_ENTRY(handler, ctx) 的第二个参数,会原样传给 handler 的第三个参数。 用来给 handler 配"工作台":MOTE_ENTRY(h, &my_device_config),一个 handler 服务多个设备。 不需要就传 NULL。

Q4:post 的纸条一定按顺序处理吗? 筐是 FIFO(先进先出)。同一时刻最多排队 MOTE_EVT_QUEUE_SIZE(默认 16)张。

Q5:没有注册的 ID 递进去会怎样? 纸条被内核默默丢掉,不会崩。这是安全网,但也说明你的值班表漏登记了。 丢弃会被计入 mote_dropped_count() 并触发丢事件钩子(如果注册了)—— 所以漏登记的事件在可靠性监控里是"看得见"的,不是无声无息。

Q6:任务和"定时器+事件"到底选哪个? 任务 = 固定节奏的周期活(扫描、刷新、喂狗),且不需要在别处被触发; 定时器+事件 = 触发式、灵活(暂停/改周期/多种事件混流)。

Q7:MOTE_DELAYED_MAX 用完了还能递延时纸条吗? 返回 MOTE_ERR_FULL。要么调大配置,要么改用定时器。 注意延时槽是固定池(每次 poll 线性扫描 MOTE_DELAYED_MAX 个槽), 而定时器是按到期时刻排序的链表(每次 poll 只遍历到期节点, 空转 O(1)),几十个定时器也没问题,不建议堆上百个(需要更多就用 软件时间轮或换 RTOS)。

Q8:中断里想干点复杂的活? 正确姿势:中断只递纸条,把活写在 handler 里。这就是 MoteOS 的全部哲学。

Q9:handler 里用 static 局部变量和用全局变量有区别吗? 功能上都是"永久存在";区别是作用域:static 局部变量只有这个 handler 能碰,更安全,推荐。


附录 A:中断延迟预算(临界区时长)

门铃响了多久才有人开门,就是"中断延迟"。 内核里最影响它的,是 "请勿打扰"牌子(临界区)挂多久——牌子挂着的这段时间,门铃响了也没人开。

先声明:下面的数字是按指令条数的估算,不是板级实测。 内核的中断延迟由三部分组成:

中断延迟 ≈ 硬件中断响应时间 + tick 处理 + 内核临界区(取最长者)
1

内核临界区时长取决于你的配置与主频,来源(按最坏路径):

操作 临界区内做的事 规模
mote_event_post 队列入队 O(1),十几条指令
mote_event_post_replace 从新到旧扫描队列找同 ID O(队列长度),最坏 = MOTE_EVT_QUEUE_SIZE
mote_event_post_delayed / _replace / mote_event_cancel_delayed 线性扫描延时槽池(找空槽/找同 ID) O(MOTE_DELAYED_MAX)
mote_timer_start_ex / mote_timer_restart 排序链表插入(找插入点) O(定时器数量),几十个定时器仍很短
mote_mail_send 拷贝 len 字节(≤ item_size)+ 事件入队 O(len),每 4 字节约几条指令
mote_tick / mote_tick_advance 关中断 + 自增 + 恢复 约 10 条指令

前四项是中断可调用 API(中断延迟 = 中断响应 + 其中最长者); 定时器 API 虽仅限主循环,其临界区同样会延迟中断响应。 mote_next_due 与睡眠判定只发生在主循环/空闲路径,不计入中断延迟。

粗算公式(48MHz 主频、-Os,按每字节 6~8 周期估算):

  • 普通 post(队列 16):约 30 周期 ≈ 1µs 以内
  • 64 字节格子的邮箱 send:约 500 周期 ≈ 10µs 量级
  • 队列 255 的 replace 全扫描:约 2500~4000 周期 ≈ 50~80µs 量级
  • 延时槽 16 的槽池扫描:约 200~400 周期 ≈ 5~10µs 量级

这些是估算值,实际值必须实测(编译器版本、Flash 等待周期、流水线都会影响)。 两种实测方法:

  1. DWT 周期计数器(Cortex-M3 及以上):DWT->CYCCNT 在 mote_mail_send 前后打点取差值
  2. GPIO 示波器法(任何芯片):进临界区前置高一个 GPIO、退出拉低, 对 mote_mail_send(最坏路径)打点,示波器量脉冲宽度即可覆盖最坏情况

实测值超预算时的对策(按性价比排序):

  1. 缩小邮箱 item_size(延迟与它成正比)
  2. 缩小 MOTE_EVT_QUEUE_SIZE(replace 扫描与它成正比)
  3. 大块数据改走"指针 + 所有权移交"(自己保证生命周期,不拷贝)

必读:以上数字没有任何官方板级实测背书

本内核没有任何官方板级实测数据——上面的粗算值受编译器版本、Flash 等待周期、 流水线等因素影响,使用前请按前文两种方法自行测量,并在你的预算内做决定。 这两种实测方法是《移植教程》最终检查清单的正式步骤之一,两篇文档口径一致。


附录 B:非阻塞串口发送(正反例对照)

铁律 1 说 handler 必须毫秒级返回,但"发一串数据"天然是慢操作。三种写法的对比 (115200 波特率 = 每字节约 87µs):

反面教材一:handler 里等 TC(传输完成)

for (int i = 0; i < n; i++) {
    while (!(USART1->SR & USART_SR_TC)) { }  /* 等整个字节从引脚发完! */
    USART1->DR = buf[i];
}
1234

32 字节回环 = handler 阻塞约 2.8ms。数据一多直接违反铁律 1, 主循环饿死、其他 handler 排队。这是早期例程踩过的坑,现在已修正。

反面教材二:handler 里等 TXE(稍好,仍不推荐)

for (int i = 0; i < n; i++) {
    while (!(USART1->SR & USART_SR_TXE)) { }  /* 只等 0~1 个字节时间 */
    USART1->DR = buf[i];
}
1234

每字节最多等 87µs,32 字节最多约 2.8ms 的最坏情况仍然存在(只是常数变小)。 例程里用这种写法并标注了"生产代码请改发送中断",因为例程要短。 你自己写代码,请用下面这种:

正确姿势:环形缓冲 + TXE 发送中断(handler 零忙等)

/* 1. 全局:环形缓冲(容量按你的最坏突发决定) */
#define TX_BUF_SIZE 64
static uint8_t tx_buf[TX_BUF_SIZE];
static volatile uint16_t tx_head;   /* 中断写 */
static volatile uint16_t tx_tail;   /* 主循环写 */
static bool tx_active;

/* 2. 主循环/handler 里:数据丢进缓冲,开 TXE 中断,立即返回 */
static void uart_send(const uint8_t *data, uint16_t len)
{
    for (uint16_t i = 0; i < len; i++) {
        uint16_t next = (uint16_t)((tx_tail + 1) % TX_BUF_SIZE);
        if (next == tx_head) {
            /* 满:丢数据/记日志,绝不等待 */
            return;
        }
        tx_buf[tx_tail] = data[i];
        tx_tail = next;
    }
    USART1->CR1 |= USART_CR1_TXEIE;  /* 开发送中断 */
}

/* 3. 中断里:搬一个字节;搬空了关中断 */
void USART1_IRQHandler(void)
{
    if (USART1->SR & USART_SR_TXE && (USART1->CR1 & USART_CR1_TXEIE)) {
        if (tx_head != tx_tail) {
            USART1->DR = tx_buf[tx_head];
            tx_head = (uint16_t)((tx_head + 1) % TX_BUF_SIZE);
        } else {
            USART1->CR1 &= ~USART_CR1_TXEIE;  /* 发完,关中断省电 */
        }
    }
    if (USART1->SR & USART_SR_RXNE) {
        uint8_t c = (uint8_t)USART1->DR;
        mote_mail_send(&uart_mb, &c, 1);      /* 接收仍走邮箱 */
    }
}
1234567891011121314151617181920212223242526272829303132333435363738

白话解读:把环形缓冲想成一张转盘。主循环/handler 把要发的字节一个个 放上转盘(写在 tx_buf 里),放完喊一声"开始发!"(开 TXE 中断), 自己立刻收工——整个过程只是放几个字节,永远毫秒级。真正的发送由"门铃"驱动: 串口每腾出位置就按一次门铃,中断里从转盘拿一个字节发出去;转盘空了就关掉门铃 省电。发的人和寄的人互不等待,谁也不堵住谁。

handler 里调 uart_send() 只是几个字节的 memcpy 级操作,永远毫秒级返回—— 这才是"无阻塞延时 API"哲学在串口上的正确打开方式。

最近更新时间: 2026-08-13
贡献者: Lioyae