9361 这颗芯片,国内做蜂窝物联网的人应该都很熟悉,尤其做 NB-IoT 模组二次开发、OpenMCU 类项目的朋友,基本绕不开它。我这个项目最特别的地方,是没跑 RTOS,完全用 no-os 裸机方案承载全部业务,并且把设备参数做成了可配置内容,附带一套通过串口交互的控制台指令。是的,你没听错,无操作系统,主循环加中断,照样把蜂窝协议栈调度、远程升级、低功耗休眠这些都撑起来了。这篇文章我打算把整个项目的设计思路、配置框架、控制台实现、以及调试过程中的真实踩坑记录下来,希望能给正在类似项目上挣扎的人一些参考。
先说结论,这个项目最终交付的固件形态是这样的:一套 no-os 工程,主循环轮询事件队列,UART 中断接收控制台数据,配置参数全部抽象成结构体存在片内 Flash,控制台支持查看配置、修改参数、手动触发网络动作。效果是同一套固件可以适配不同客户、不同频段、不同业务逻辑,再也不用为每家客户单独维护代码分支。下面我按模块逐步拆,尽量把每个关键决策背后的原因也讲清楚,而不是只贴代码。
1. 为什么选 no-os:裸机方案怎么撑起蜂窝业务
1.1 no-os 不是不用系统,而是选最小运行底座
很多开发者看到 no-os 的第一反应是“裸奔”“不稳”“什么都得自己写”,其实这是误解。no-os 的正确理解应该是:根据业务的真实复杂度,选择最小的运行底座,而不是默认把所有工程都套上一个 RTOS。
9361 这类蜂窝芯片有个特点:射频协议栈已经在芯片内部实现了,应用层代码不需要自己负责复杂的任务调度和资源管理。换句话说,协议栈已经把“最复杂”的部分消化掉了,留给应用层的业务逻辑通常很单一。拿典型的表计类设备来说,无非是定时采集、定时上报、接收平台下发指令、处理远程升级,最多再加一个异常报警。这种业务模型,你用 while(1) 加状态机就能写得很干净,硬上一套 RTOS 反而会引入任务划分和通信问题。
我自己刚开始也犯过这个毛病,总觉得不用 RTOS 像缺了点东西,结果任务建了四五个,信号量、消息队列、事件标志组全都用上了,代码量翻倍不说,调试时还得满世界找“哪个任务把队列塞满了”。后来我把整个工程改造成 no-os,业务逻辑全部收敛到主循环,行为立刻变得可预测:执行到哪一行、下一步跳到哪里,全都在代码里看得清清楚楚。
1.2 裸机工程的目录与分层组织
no-os 不代表所有代码堆在一个 main.c 里。我习惯把工程按“驱动层、服务层、应用层”三层拆分,类似项目的目录结构通常是这样的:
app/ ├── main.c // 主循环、事件分发 ├── app_state.c // 业务状态机 ├── app_network.c // 网络注册、数据上报 ├── app_sensor.c // 传感器采集 ├── config/ │ ├── cfg_manager.c // 配置加载/保存/校验 │ ├── cfg_table.c // 配置项定义与默认值 │ └── cfg_flash.c // Flash 读写与双槽管理 ├── console/ │ ├── console.c // 命令分发入口 │ ├── ringbuf.c // 串口环形缓冲 │ └── cmd_table.c // 命令表 ├── drivers/ │ ├── drv_uart.c │ ├── drv_flash.c │ └── drv_gpio.c └── services/ ├── event_queue.c // 事件队列 └── log.c // 日志输出分层原则很朴素:驱动层不调用服务层,服务层不调用应用层,层与层之间通过回调函数或者事件结构体交互。这样做的好处是,当协议栈升级或者硬件换板时,我只需要检查驱动层的接口是否兼容,应用逻辑几乎不用动。实际维护中这个优势被放得很大,因为固件版本迭代最频繁的地方往往在业务侧,而不是底层。
1.3 no-os 换来了什么、放弃了什么
任何选型都是取舍。no-os 换来的是三样东西:第一是行为可预测,所有代码按照主循环的节奏线性执行,不存在任务抢占带来的随机性问题;第二是资源占用少,没有 RTOS 内核的 RAM 开销,也没有任务栈这种难以估算的内存消耗,固件体积和运行内存可以压得很低;第三是调试直观,打断点、看变量、单步执行,每一步都对应真实业务逻辑,不需要在任务上下文之间来回跳。
放弃的东西也很明显:没有任务抢占,意味着一个耗时操作会阻塞其他逻辑;没有优先级调度,所有事件只能按到达顺序排队处理;没有并发隔离,某个模块的 bug 可能直接拖垮整个主循环。因此,业务逻辑里不能出现长时间阻塞的循环,所有耗时的操作(比如 Flash 擦写、网络发送)都必须尽量做成异步流程,或者切成小块分时处理。
2. 可配置内容:把变化点从代码里抽出去
2.1 三层配置模型:默认值、Flash、运行态
做多客户适配时,最痛苦的事情莫过于不同客户对固件参数的要求完全不一样。有的客户要用特定频段,有的客户改了设备 ID 规则,有的客户要求 GPIO 电平极性反过来,还有的客户上报周期要半小时一次,而另一个客户要五分钟一次。最初我面对这种需求的方法是每个客户维护一个编译分支,改一个宏就重新编译一个固件,结果分支越来越多,每个版本都要重新测试,版本管理乱成一锅粥。
后来我意识到,真正的问题不是编译参数,而是没有把“变化点”从代码里抽出来。正确的做法是定义一个全局配置结构体,把产品身份、网络行为、外设映射这些可变参数全部收进去。比如:
typedef struct { uint8_t version; // 配置结构体版本 uint16_t device_id; // 设备ID char apn[32]; // 接入点名称 uint8_t band; // 工作频段 uint16_t report_interval; // 上报间隔,单位秒 uint8_t gpio_level; // IO 电平极性 uint8_t rmt_enable; // 远程升级开关 uint8_t log_level; // 日志输出级别 uint8_t reserved[16]; // 预留空间 } app_config_t;配置来源分三层:编译期内置的默认配置、存放在 Flash 里的持久化配置、运行中临时修改的内存配置。启动时固件先把默认配置拷贝到 RAM,再去 Flash 检查有没有合法配置,如果有就用 Flash 内容覆盖对应字段。运行中控制台修改的配置只临时存在于 RAM,确认无误后再手动触发保存,把 RAM 配置写回 Flash。这样既保证了设备首次上电可以直接跑默认配置,又支持了现场远程调参。
2.2 双槽轮换与掉电保护
配置保存涉及 Flash 擦写,这是整个项目里坑最多的地方。9361 片内 Flash 的擦写次数和擦写粒度都有限制,不能每次用户改一个参数就全片擦写一遍,更不能让擦写过程被掉电打断。我最终采用的是“双槽轮换”方案:把配置存储区分成两个独立的 slot,写入时只操作当前活跃的 slot,写完后轮换激活标志。
#define CFG_MAGIC 0xC369 #define CFG_SLOT_NUM 2 #define CFG_SLOT_SIZE 512 static bool slot_valid(int slot) { uint8_t *base = cfg_region_base(slot); if (*(uint16_t *)(base + 0) != CFG_MAGIC) return false; uint16_t crc = *(uint16_t *)(base + 2); return crc == calc_crc(base + 4, sizeof(app_config_t)); } bool cfg_load(app_config_t *cfg) { memcpy(cfg, &default_cfg, sizeof(app_config_t)); for (int i = 0; i < CFG_SLOT_NUM; i++) { if (slot_valid(i)) { memcpy(cfg, cfg_region_base(i) + 4, sizeof(app_config_t)); return true; } } return false; }工作原理很简单:每次写入前,先计算整个配置结构体的 CRC 校验值,连同魔数一起写进 slot 头部;读取时校验魔数和 CRC,校验通过才认为该 slot 有效。如果写第一个 slot 写到一半断电,重启后校验失败,系统自动检查第二个 slot,第二个 slot 里存放的是上一次成功保存的旧配置,仍然可用。这样掉电最多丢失“最后一次修改”,绝对不会出现完全无法启动的情况。
好在 9361 的 Flash 驱动直接支持按扇区擦写,我把两个 slot 放在了不同扇区,进一步降低了互相干扰的可能。实测连续断电 200 次,配置没有一次丢失,这个数据算是把方案验证过了。
2.3 配置校验、版本兼容与字段扩展
配置结构体里的 version 字段非常关键。产品迭代以后,配置结构体经常会新增字段,老设备上的 Flash 里存的是旧版本配置。如果不管不顾直接读取,新增字段就是随机值,轻则行为异常,重则直接触发校验失败。我的处理方式是:读取配置后先判断 version 是否小于当前版本,如果小于,则用默认配置补齐新增字段,再把整个结构体重写回 Flash。
每个配置项还需要挂一个校验函数。比如上报间隔不能是 0,不能超过一个合理上限;APN 字符串必须非空且长度不能超过数组边界;频段参数必须在芯片支持的范围内。控制台修改参数时,必须先调用对应 setter 函数,通过校验才能写入,否则直接返回错误码。这个习惯帮我挡掉了很多现场乱七八糟的调试请求,比如有人想试 report_interval=0,直接就被校验逻辑拦住了。
3. 控制台指令:手写一套可用的命令解析框架
3.1 命令表加回调函数的核心设计
控制台指令的本质是从串口读一行文本,按空格拆出命令名和参数,再调用对应的处理函数。命令数量少的时候用 if-else 或者 switch-case 还能应付,命令一多,维护起来就特别痛苦。我最终采用命令表加回调函数的设计,每条命令就是命令表里的一项:
typedef void (*cmd_handler_t)(int argc, char *argv[]); typedef struct { const char *name; const char *help; cmd_handler_t handler; } cmd_entry_t; static const cmd_entry_t cmd_table[] = { { "help", "print all commands", cmd_help }, { "cfg", "print runtime config", cmd_cfg }, { "cfgset", "set config item", cmd_cfgset }, { "cfgsave", "save config to flash", cmd_cfgsave }, { "netinfo", "query network status", cmd_netinfo }, { "send", "send hex data", cmd_send }, { "reboot", "system reboot", cmd_reboot }, };命令表放在 const 段,不占 RAM,命令查找只需要遍历一次表项做字符串比较。新增命令时往表里填一行,顺手把帮助文案写好,help 指令就能自动展示所有命令。整个框架的核心查找逻辑大概是这样的:
static void dispatch(char *line) { char *argv[8] = {0}; int argc = split_args(line, argv, 8); if (argc == 0) return; for (int i = 0; i < ARRAY_SIZE(cmd_table); i++) { if (strcmp(argv[0], cmd_table[i].name) == 0) { cmd_table[i].handler(argc, argv); return; } } console_print("unknown command: %s\r\n", argv[0]); }这个设计看起来简单,但后期扩展性极强。你可以很自然地在命令节点上附加权限标记、记录执行时间、统计调用次数,甚至做成按键宏一样的东西。
3.2 环形缓冲与主循环消费模型
控制台底层是 UART,9361 的 UART 外设支持接收中断。我最早犯过的错误是把命令解析逻辑直接写在中断服务函数里,短命令没问题,长命令和连续粘贴多条命令时,中断被占用的时间过长,后续字符直接丢失。后来我把接收模型改成了标准的“中断写缓冲,主循环读缓冲”模式:
static volatile uint8_t ring[256]; static volatile uint16_t head, tail; void UART_IRQHandler(void) { if (UART_GET_IT(UART_IT_RXNE)) { uint8_t byte = UART_RECEIVE(); uint16_t next = (head + 1) & (RING_SIZE - 1); if (next != tail) { // 预留一个空位,防止满状态误判 ring[head] = byte; head = next; } } }主循环里每一轮都从缓冲取字节,按照换行符切成一行完整命令,再交给 dispatch。切行逻辑里要处理 \r 和 \n 共存的情况,很多串口工具发命令默认是 \r\n,如果只认 \n,行尾就多出一个 \r,导致命令找不到。我一般统一把 \r 和 \n 都作为行结束符,遇到连续换行则跳过空行。
环形缓冲我做了两点优化:第一,缓冲区大小至少 256 字节,能装下一整条较长的命令和参数;第二,因为生产者是中断、消费者是主循环,必须保证只有一方写头、一方读尾,而且头尾更新要配合关中断保护,避免出现内存竞争。这里有个容易被忽略的点:串口波特率越高,FIFO 溢出风险越大,19200 以上的波特率建议直接把 FIFO 和环形缓冲都打开,宁可多花一点 RAM,也不能让数据悬在半路。
3.3 常用指令设计:从查询到修改,一键可达
实际交付时,我的指令集刻意保持精简,够用就好,不会为了功能丰富把控制台搞成一个复杂系统。常用指令如下:
| 指令 | 功能 | 典型用法 |
|---|---|---|
| help | 列出所有命令 | help |
| cfg | 查看运行配置 | cfg |
| cfgset | 修改配置项 | cfgset report_interval 60 |
| cfgsave | 保存配置到 Flash | cfgsave |
| netinfo | 查询网络状态、信号强度 | netinfo |
| send | 发送十六进制数据 | send 01020304 |
| reboot | 重启模组 | reboot |
cfgset 的参数解析是重点。我的实现是每个配置项注册一个 setter 函数指针,控制台层不需要知道字段类型是整数还是字符串,全部委托给配置管理器。比如:
static bool set_report_interval(const char *val) { long v = strtol(val, NULL, 10); if (v < 10 || v > 86400) return false; runtime_cfg.report_interval = (uint16_t)v; return true; }send 命令用于发送测试数据,这在实际场景里特别有用。平时开发不可能每次发数据都通过业务逻辑触发,直接控制台手动指定一条十六进制报文,配合抓包工具能快速定位协议栈问题。
4. 配置和控制台怎么驱动蜂窝网络业务
4.1 配置项如何影响注册、上报与低功耗
配置项不是一堆躺在内存里的死数据,它们必须真正影响业务行为。比如频段 band 会决定模组在哪个频点上扫描网络,这个参数如果和现场运营商开通的频段不匹配,设备就会一直注册不上网络。再比如上报间隔 report_interval,它会直接影响设备进入连接态的频繁程度,间隔太短电池撑不住,间隔太长平台侧数据实时性差,这个值通常需要结合客户的业务场景反复调。
在 no-os 模式下,网络注册和数据上报都不是同步完成的,而是通过回调异步通知。协议栈发起注册后,应用层不能阻塞等待结果,而是继续跑主循环,等注册结果事件从队列里来了再处理。这就要求配置项的修改不能简单“改了就算”,有些参数需要实时生效。比如切换频段时必须调用协议栈的重配置接口,先停射频、再重新扫描;但如果当前正有数据在传输,就不能立刻改,得在状态机里等当前操作完成。
实际控制台上网调试的典型流程是:先用 cfgset band 设置频段,再 reboot 重启,通过 netinfo 查看是否注册成功。如果注册失败,十有八九是频段参数和现场环境不匹配,这时改参数重来一遍即可。这个流程在没做成配置化之前,每次都得重新编译固件,一套调试流程动不动半小时起步,做成控制台指令后,一分钟内就能完成一次环境验证。
4.2 事件队列:no-os 工程的心脏
主循环加事件队列,是我这个 no-os 项目里最满意的一个设计。协议栈回调、定时器回调、控制台命令、传感器采集完成,所有这些异步来源统一转换成事件,塞进一个静态数组实现的 FIFO。主循环每轮只做一件事:从队列里取事件,然后进入对应的处理函数。
typedef struct { uint16_t type; uint32_t arg0; uint32_t arg1; } event_t; #define EVENT_QUEUE_SIZE 16 static event_t ev_queue[EVENT_QUEUE_SIZE]; static uint8_t ev_head, ev_tail; bool event_post(uint16_t type, uint32_t arg0, uint32_t arg1) { uint8_t next = (ev_head + 1) % EVENT_QUEUE_SIZE; if (next == ev_tail) return false; // 队列满 ev_queue[ev_head] = (event_t){type, arg0, arg1}; ev_head = next; return true; } bool event_wait(event_t *ev, uint32_t timeout_ms) { if (ev_head != ev_tail) { *ev = ev_queue[ev_tail]; ev_tail = (ev_tail + 1) % EVENT_QUEUE_SIZE; return true; } // 简单阻塞等待,低功耗场景可配合唤醒定时器 return false; } int main(void) { sys_init(); while (1) { event_t ev; if (event_wait(&ev, 0)) { handle_event(&ev); } console_poll(); lowpower_enter(); } }有人可能会问,为什么不用信号量这种更“系统化”的机制?答案很简单,事件队列天然把多个异步源头的动作串行化,主循环同一时刻只处理一个事件,不会出现两个任务同时修改同一个状态变量的竞争问题。这比信号量保护共享资源要直观得多。
事件队列的深度要结合业务评估。我的项目里事件类型不超过二十种,最坏情况下队列深度 16 足够用,如果队列满导致 post 失败,我再根据事件类型决定是缓存还是丢弃。比如传感器采样事件丢了还能等下一轮,而平台下发的关键指令丢了就会影响业务,所以关键指令的事件在 post 失败时用静态变量补记一个标志位,下一个主循环周期优先处理。
4.3 控制台指令不直接做事,只投递事件
控制台命令和事件队列的结合,是代码职责分离的关键。命令解析成功后,处理函数不直接去操作协议栈或外设,而是把“动作”打包成事件投递到队列,由应用状态机决定下一步怎么做。
举个例子,send 指令的 handler 只做两件事:解析十六进制字符串、post 一个“发送数据”事件。应用状态机收到这个事件后,先判断当前是否处于已连接状态:如果是,构造报文并调用协议栈接口发送;如果不是,直接在应答里提示 not connected。这样控制台只管输入格式,状态机只管业务动作,两者的边界非常清晰。
这种设计还有一个好处:在后续增加远程控制台时,远程协议只要复用同一个事件投递接口,就能让网络侧指令和串口指令走完全一样的业务路径,不需要为远程通道单独写一套状态机。也就是说,控制台真正变成了一个“前端”,而业务状态机才是唯一的“后端”。
5. 踩坑实录与现场排查技巧
5.1 中断里做解析导致丢数据,排查过程折腾了一天
这个坑我必须放在第一位,因为它花了整整一天才定位。现象是这样的:调试控制台时,收短命令一切正常,但粘贴一长串命令或者快速连续输入多条命令时,会出现缺字节、命令被截断的情况。
我当时的排查方法是分两步定位数据通道:先在 UART 中断入口翻转一个 GPIO,用示波器看中断触发是否正常;再在解析函数入口打印接收计数。两次比对后发现,中断触发了,但解析函数收到的字节不完整。问题就出在中断服务函数里做了解析操作,UART 中断被长时间占用,后续字节在 FIFO 里被新数据覆盖,或直接溢出丢失。
解决办法是严格遵循“中断里只收字节、主循环再做解析”的原则。中断里最多更新环形缓冲头和尾,解析、比较、执行这些步骤全部挪到主循环。改完以后即使 115200 波特率下连续发送几百字节,也没再丢过数据。这个经验其实在串口驱动教科书里写过无数遍,但真正踩一次,尤其在自己以为“中断里写几行代码有什么关系”时踩一次,才会有更深的理解。
5.2 命令缓冲溢出和参数越界的防御
控制台用的行缓冲区是固定的,长度为 128 字节。如果一行超过 128 字节,我的处理是直接丢弃这行,并输出 line too long,同时把缓冲区清空。如果不清空缓冲,残留的半行数据会误导解析器,后面输入的所有命令都会错位,产生一堆 unknown command。这个“一次坏数据影响后续所有命令”的问题,往往比溢出本身更隐蔽。
参数个数同样受限,我固定 argv 数量上限为 8,超过的部分丢弃并提示。字符串参数在存储时先检查长度,不能直接 memcpy,否则会覆盖配置结构体后面其他字段。这些边界防御看起来啰嗦,但在现场调试时非常救命,因为现场工程师偶尔会用串口工具发送一些“奇奇怪怪”的内容,固件必须能从容地处理非法输入而不是卡死。
5.3 Flash 擦写、掉电与临界区保护
双槽方案解决的是“数据可靠性”,但写入过程中的软件保护同样重要。9361 写 Flash 的时候不能被打断,如果这时来一个串口中断,可能导致写操作进入未知状态。所以我在 Flash 驱动里加了临界区保护:写 Flash 前关全局中断,写完后恢复。代价是中断被延迟了几十毫秒,串口接收的数据会堆积在 FIFO 里,但因为环形缓冲足够大,完全没问题。这个操作在早期没有做时,出现过配置写到一半设备重启,然后配置区两个 slot 全部失效的情况,原因就是写入期间被一个复位看门狗打断了。
我这里想特别提醒一个容易被忽略的细节:如果配置保存操作是从主循环发起的,可以在保存前把看门狗喂一次,并确保保存函数不会被看门狗超时打断。否则配置区正写到一半,看门狗把系统复位了,再好的双槽方案也会因为连续写入动作不完整而出问题。
5.4 低功耗、串口唤醒与命令完整判断
产品要求低功耗,控制台又需要随时能唤醒调试,这组矛盾是我花了不少时间调的。我的解决方案是:主循环在没有事件处理时进入休眠,唤醒源包括 UART 接收中断和定时器。设备平时躺在低功耗模式,插上串口线敲一条命令,字符到达就能把系统唤醒,功耗基本不增加,调试时又非常方便。
实现中踩过一个具体问题:如果命令解析后直接进入休眠,下一条完整命令到达时,串口中断虽然能唤醒系统,但解析逻辑还没来得及执行完,系统又因为新字符到达进入了下一个休眠周期,命令永远执行不了。我的解决办法是增加一个“软超时”机制:收到最后一个字符后,主循环启动一个 50ms 的软定时器,期间如果没有任何新字符到达,才认为这一行命令完整,开始解析。这个 50ms 不能设太长,否则用户体感命令响应变慢;也不能太短,否则串口工具发送的字符有间隔时会被拆成多条命令。实测 20~50ms 是合理的区间。
6. 项目收尾经验与后续演进
6.1 什么项目适合 no-os,什么项目别硬上
做了这个项目之后,我对 no-os 的适用范围有了更清晰的认识。如果你的业务是典型的事件驱动型物联网终端,业务状态机清晰、功能边界固定,比如水表、燃气表、烟感、定位器这种,no-os 绝对值得尝试。反过来,如果你的设备同时运行蓝牙、Wi-Fi、本地显示、传感器融合这些高并发任务,或者多个业务模块之间需要复杂的优先级调度,那我建议老老实实上 RTOS,别为了追求裸机而把复杂度转移到自己的状态机里。
判断标准很简单:画出你的业务状态图,如果状态之间是清晰的有向边,没有太多并行分支,基本就是 no-os 的合适场景。如果状态图多到你自己都记不住,说明业务复杂到该用系统去托管了。
6.2 控制台功能还能怎么扩展
当前这套控制台框架并不复杂,但因为采用了命令表机制,扩展成本很低。我建议有需要的话可以从这几个方向演进:
第一个是命令自动补全。在命令行输入时,按 Tab 键匹配命令名,实现需要维护一个命令前缀索引,对调试效率提升非常明显,尤其是命令多了之后手打容易出错。
第二个是批量命令执行。把多条指令用分号或换行连接成一批,生产测试治具可以直接发送批量脚本,整个产测流程的测试时间能缩短一半以上。这个我在生产阶段实践过,效果非常好。
第三个是远程控制台。把串口控制台映射到网络通道,设备部署在现场也能通过网络执行调试指令。做这个功能时必须考虑访问控制和身份验证,不能单纯地把串口转发到网络,否则等于给设备开了个后门。我当时的方案是用设备 ID 加临时密钥做鉴权,只允许在特定时段建立远程会话。
6.3 日志输出和调试习惯的一点心得
最后分享一个调试 no-os 程序的习惯:我在每个关键函数入口加一个“进函数/出函数”的日志宏,编译时通过开关控制是否开启。开发阶段全量打印,交付阶段按日志级别裁剪。这个习惯配合控制台输出,在业务状态机出问题时能快速定位是哪条路径出了问题,而不需要靠猜测和加断点一点点试。
日志对齐这个小细节非常实用。用 console_print 输出表格时,制表符在不同串口工具里显示宽度不一致,对齐效果会乱。我的做法是固定用空格填充,或者使用 %-10s 百分号格式化控制字段宽度。这样现场工程师用任何串口工具查看输出时,表格都整整齐齐,一眼就能看懂设备状态。
这个项目做下来,我最深的体会是:no-os 不等于落后,它只是把系统层面的复杂性转移到了应用层的状态机里。只要状态机设计得足够清楚、事件路径足够简洁,裸机工程完全可以做出稳定、可维护、易扩展的产品。控制台指令和可配置内容这两样东西,则是整个方案里承上启下的关键环——没有它们,开发调试效率会低好几个档次。后续如果再遇到类似的蜂窝项目,我会毫不犹豫地复用这套架构。