长文测试:Zephyr 应用层踩坑清单

长文测试:Zephyr 应用层踩坑清单

本文字数:2701  阅读时长:6分钟

阅读量:—

文章目录 展开 / 收起

这篇长文主要是给站点做压力测试用的:标题够多、有代码、有表格、有引用,用来检查目录折叠、目录跟随高亮、阅读时长统计和相关文章是否正常。内容本身也确实是这两个月在 hms_zep 上踩过的坑,顺手整理成清单。

为什么单独写一篇长文

短文章测不出目录的问题。目录组件通常只在标题数量超过一屏时才暴露毛病:折叠高度不对、点击跳转偏移、滚动高亮不跟手、PJAX 切换后监听器重复注册。这些都需要一篇足够长的文章才能复现。

所以这篇的目标很明确:

  • 一级标题不少于 8 个,二级标题若干;
  • 至少包含 3 段代码、1 个表格、1 张图片、1 段引用;
  • 正文长度超过 2000 字,阅读时长落在 5 分钟以上。

如果你的站点打开这篇时目录能正常展开、滚动时高亮会跟着走、文末能推荐出其他文章,那这套文章模板基本就成型了。

线程、优先级与栈

Zephyr 里最先要建立的直觉是:优先级数字越小越优先,而且协作式线程(负数优先级)一旦不让出 CPU,同优先级以下的线程全部饿死。应用层一般只用 K_PRIO_PREEMPT(x),把 x 控制在 0–15 之间。

栈大小怎么估

栈不是越大越好,RAM 只有 128 KB 的时候,几个 4 KB 的栈就能把内存吃光。经验值:

线程类型常见栈大小备注
纯状态机/轮询1 KB不调用 printf
带日志的 worker2 KB日志走 deferred 模式
协议解析2–3 KB局部数组要算进去
shell3 KBZephyr 默认就要这么多

更靠谱的办法是打开 CONFIG_THREAD_ANALYZER 或 CONFIG_INIT_STACKS,跑一段时间后看栈水位,再留 20% 余量。

c
K_THREAD_STACK_DEFINE(proto_stack, 2048);
static struct k_thread proto_thread;

k_thread_create(&proto_thread, proto_stack,
                K_THREAD_STACK_SIZEOF(proto_stack),
                proto_entry, NULL, NULL, NULL,
                K_PRIO_PREEMPT(8), 0, K_NO_WAIT);
k_thread_name_set(&proto_thread, "proto");

别在中断里做重活

中断上下文不能睡眠,也不能拿互斥锁。只要看到 k_mutex_lock(..., K_FOREVER) 出现在 ISR 调用链里,就一定会出事。正确的做法是中断里只做三件事:清标志、把数据塞进队列、唤醒线程。

c
static void uart_isr(const struct device *dev, void *user_data)
{
    uint8_t chunk[32];
    int n = uart_fifo_read(dev, chunk, sizeof(chunk));
    for (int i = 0; i < n; i++) {
        k_msgq_put(&rx_q, &chunk[i], K_NO_WAIT);
    }
}

内存:静态分配优先

嵌入式项目里最怕的不是内存不够,而是内存碎片。k_malloc 用多了,跑几天之后就会出现「明明还有 20 KB,却申请不到 1 KB」的情况。所以除了启动阶段,运行期一律静态分配:

  • 队列用 K_MSGQ_DEFINE;
  • 线程栈用 K_THREAD_STACK_DEFINE;
  • 缓冲区用 static uint8_t buf[N];
  • 需要池化就用 k_mem_slab。

如果确实需要动态内存,至少把 CONFIG_HEAP_MEM_POOL_SIZE 显式写出来,别依赖默认值。

日志:级别与后端

日志是最容易被忽视的性能杀手。默认 CONFIG_LOG_MODE_DEFERRED=y 时,LOG_INF 只是把指针塞进队列,开销很小;但一旦改成 immediate 模式,每条日志都会在调用者上下文同步写串口,一条 100 字节的日志可能阻塞几毫秒。

判断日志是否拖慢系统,有个简单办法:把某条高频日志的级别从 INF 提到 DBG,看 CPU 占用有没有明显变化。如果没有变化,说明瓶颈不在这里;如果变化很大,就要考虑限流。

c
LOG_MODULE_REGISTER(hms_usb, CONFIG_LOG_DEFAULT_LEVEL);

/* 高频路径:只在状态变化时打日志 */
if (new_state != old_state) {
    LOG_INF("usb state: %d -> %d", old_state, new_state);
}

另外,第三方模块的日志级别要单独压。CONFIG_USBD_CDC_ACM_LOG_LEVEL_WRN=y 这一行就消灭了控制台每 20 ms 一次的 hexdump 刷屏。

zbus:轻量消息总线

zbus 解决的是「模块之间不想互相 include」的问题。它的模型很简单:通道持有最后一条消息,观察者订阅通道。发布者只管往通道里放,谁关心谁自己取。

四类观察者

  • listener:在发布者上下文同步执行,必须极短,当 ISR 写;
  • async listener:丢到 workqueue 执行,适合稍微重一点的回调;
  • subscriber:自己的线程 + msgq,只收到「通道变了」的通知;
  • message subscriber:线程 + FIFO,收到消息副本,需要 net_buf 池。

几个容易踩的点

  1. 通道和观察者是链接段里的全局符号,ZBUS_CHAN_DEFINE 必须写在文件作用域,不能放类里;
  2. subscriber 只通知、不排队,连续发布两次只会读到最新值,要保留中间值就用 message subscriber;
  3. 发布时通道是加锁的,listener 里再去读同一个通道并用 K_FOREVER,会直接死锁;
  4. ISR 里发布只能用 K_NO_WAIT。
cpp
ZBUS_CHAN_DEFINE(hms_evt, hms::bus::Event, nullptr, nullptr,
                 ZBUS_OBSERVERS(hms_evt_mon), ZBUS_MSG_INIT(0, 0));

static void on_evt(const struct zbus_channel *chan)
{
    const auto *ev = static_cast<const hms::bus::Event *>(zbus_chan_const_msg(chan));
    /* listener 里只做拷贝和计数,绝不阻塞 */
    record(ev->code, ev->arg);
}
ZBUS_LISTENER_DEFINE(hms_evt_mon, on_evt);

USB CDC:字节流不是字符流

USB CDC ACM 在上面看是一个串口,但它本质是字节流 + 批量端点。几个实际遇到过的问题:

  • uart_tx() 在没开 CONFIG_UART_ASYNC_API 时返回 -ENOTSUP,因为在 uart_internal.h 里它只有在 async API 打开时才转发到驱动的 tx;
  • CDC ACM 驱动没有实现 .tx,所以发送只能逐字节 uart_poll_out();
  • 主机没打开串口时,TX ring 满了会丢数据,而且只打一次 WRN_ONCE,很容易被忽略;
  • 没有 VBUS 检测时,usbd_enable() 在无主机的情况下会反复收到 suspend 事件。

自定义协议建议自己做分帧(比如 COBS + CRC16),不要指望 USB 帮你保留消息边界。

看门狗:喂狗的位置决定它能抓什么

最原始的喂狗方式是开个定时器,每 2 秒喂一次。这种做法只能抓「CPU 完全跑飞」,抓不到「线程卡死」——因为定时器在中断里照样跑。

更有效的做法是把喂狗放进一个监督线程:

  1. 每个关键模块定期调用 beat(module),只写一个原子时间戳;
  2. 监督线程按固定周期检查时间戳,超过窗口就标记陈旧;
  3. 只有所有必需模块都不陈旧时才 wdt_feed();
  4. 必需模块由系统启动时按实际初始化结果设置,避免外设故障变成复位循环。

参数怎么定

  • 检查周期:看门狗超时的 1/16 左右;
  • 陈旧窗口:4 倍检查周期;
  • 超时时间:要大于最坏情况下的合法阻塞时间。

以 4 秒超时为例,250 ms 检查、1000 ms 窗口,基本能覆盖一次完整的 USB 枚举 + 日志刷盘。

性能与体积

改完一轮功能,记得回头看两个数字:FLASH 和 RAM。一个朴素的办法是每次构建都记录 arm-zephyr-eabi-size 的输出,做一张趋势表。

阶段FLASHRAM
基础外设131544 B29728 B
加 zbus134348 B28352 B
加看门狗监督136484 B29632 B
加 USB 协议139552 B31723 B
加事件推送147272 B32000 B

能看到两次明显跳变:协议层引入了 2 KB 线程栈和 512 B 队列;事件推送则主要是消息缓冲和观察者表。

检查清单

每次要发版之前,按这个顺序过一遍,能省掉大部分低级问题:

  1. west build -p always 干净构建通过,没有新增警告;
  2. 分层检查脚本通过,没有跨层 include;
  3. 主机侧单元测试通过(分帧、CRC、TLV);
  4. 上板跑一遍冒烟:启动日志、shell 命令、USB 枚举、看门狗;
  5. 断电重启三次,确认没有偶发初始化失败;
  6. 记录体积变化,超过 2 KB 要说明原因。

常见错误速查

  • HardFault 落在 k_sys_fatal_error_handler:先看栈溢出,再看空指针;
  • shell 命令参数读不到:Zephyr 会把 argv 移到最深一层命令,参数从 argv[1] 开始;
  • USB 插上没反应:先确认 CONFIG_USB_DEVICE_STACK_NEXT,再看 VBUS 检测;
  • 看门狗一直复位:检查必需模块是不是包含了没起来的那个;
  • 日志把串口堵死:确认日志级别,优先压第三方模块。

写到这里差不多 2500 字了。如果目录能正常展开、滚动高亮能跟上、文末相关文章有推荐,那这套模板就可以放心用来写正式文章了。

分享这篇 微博 X