• 正文
  • 相关推荐
申请入驻 产业图谱

告别手写队列!这个库让你的嵌入式跨线程通信更优雅

13小时前
218
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

嵌入式 Linux 项目里,卡人的往往不是驱动写不出来,而是应用层线程越来越多之后,模块之间开始互相拿对方的函数指针、结构体指针。采集线程直接调 UI 刷新,业务线程直接改日志模块内部状态。改一个接口,半个仓库跟着红;联调时还要猜「到底是谁先上的锁」。

本文不讲跨进程 IPC(进程间通信),只讲同一进程里、多个 pthread 之间怎么传事件。读完你能带走两样东西:一张「什么时候用手写队列、什么时候用消息分发库」的判断图,以及开源小库 embedmq 在 Linux 上怎么从编译跑到接到第一条回调。

https://github.com/w4ysonch/embedmq

MIT 许可

先把问题说清楚

嵌入式 Linux 上常见的通信写法大致有三类:

    • 直接函数调用:延迟最低,但模块边界糊成一团

pthread

     互斥锁 + 条件变量 + 自建环形缓冲:能用,可每加一条业务链路就多一套锁、一个队列句柄、两份发送/接收封装上 D-Bus / ZeroMQ:能力强,适合跨进程或跨机器;对「进程内几个线程通个气」来说偏重,调试链路也更长

先用一张对照表把四类写法放在同一尺子上(embedmq 落在「进程内、事件多」这一格):

写法 典型范围 耦合程度 维护成本 一句话适用
直接函数调用 同线程 / 强同步 小项目低,一涨就升 调用链短、接口少变
手写 queue + mutex 同进程少事件 每条链路一套样板 两线程、消息种类固定
embedmq 同进程多事件 低(认事件名) 注册/投递统一 模块常增删、要解耦
D-Bus / ZeroMQ 跨进程 / 跨网络 部署与调试更重 进程边界外通信

板子上跑一个网关、一个采集盒子、一个带本地 UI 的设备时,前期两三个线程直接调用往往能撑住。人一多、功能一涨,你会发现:谁能调谁、谁持有谁的锁,全靠口头约定;新人上手要靠翻代码猜调用图。更糟的是,某个模块想做单元测试,得先把半个进程的依赖 stub 出来。现场排障时,你也很难用「只开采集、关掉 UI」这种方式缩小问题面——因为它们在源码里缠在一起。

下面这张图是本篇的阅读动线:从「互相持指针」走到「约定事件名再分发」。

GitHub 上有个库叫 embedmq(作者 Wayson Chan),定位很窄:极简、零依赖、进程内消息分发。核心 API 就三个:创建队列、注册处理器、发消息。Linux 下底层是 pthread + POSIX 信号量;换 PAL(平台抽象层)文件后,同一套调用也能落到 FreeRTOS 或裸机。MCU 侧能复用是加分项,但本文只展开嵌入式 Linux 用户态怎么用。

三个函数,够用了

心智模型可以记成邮局:

embedmq_create():开一个邮局(消息队列实例)

embedmq_register():某类信件(事件名)交给谁处理

embedmq_post():把信件丢进邮箱

发送方不必知道接收方长什么样,只认事件名字符串。这就是「消息总线」相对「直接调函数」的差别:依赖从「我认识你的函数」变成「我认识这个事件名」。

划重点:消息总线像公司公告栏——谁关心「电池变了」就自己订阅;直接调函数像你拎着文件去敲对方工位门。前者改接收方接口时,发送方代码往往不用动;后者一改,调用点全要跟着改。出错时常见现象是:A 线程拿着 B 模块的锁,B 又在等 A 的回调,死锁日志里一堆谁也说不清的调用栈。换成事件投递后,回调通常在分发路径上执行,模块边界比互相直接调用清晰一截。

Linux 上一段最小例子(演示注册与投递,真实项目里 post 多半发生在采集线程):

typedef struct {
    int level;
    float voltage;
} battery_t;

static void on_battery(const void *data, size_t size, void *ctx) {
    const battery_t *b = data;
    printf("battery: %d%% %.2fVn", b->level, b->voltage);
}

int main(void) {
    embedmq_t *q = embedmq_create(NULL);
    embedmq_register(q, "battery.changed", on_battery, NULL);

    battery_t b = { .level = 85, .voltage = 4.05f };
    embedmq_post(q, "battery.changed", &b, sizeof(b));

    embedmq_destroy(q);  /* 会等到待处理消息发完再退出 */
    return 0;
}

embedmq_destroy() 会阻塞到队列里待处理消息分发完,避免进程退出时消息半截丢掉。载荷按字节拷进内部缓冲,所以栈上临时结构体 post 出去一般没问题;但要注意 size 别传错,否则对端按结构体解读会读到垃圾。若载荷里带指针,拷的只是指针值本身,指向的内存生命周期仍要你自己管——这点和手写队列一样,库不会替你做深拷贝。

日常开发里,采集、业务、UI/日志可以各自占一个线程,只通过事件名打交道。UI 线程不必 #include 采集模块的内部头文件,业务线程也不必把日志对象指针传来传去。联调时也可以临时只注册某一个 handler,观察某类事件是否按预期到达,缩小排查范围。

看图:同一进程内,三条线程怎么挂在 embedmq 上。

旁注一句:若你还在 MCU 上写 FreeRTOS 或裸机,embedmq 同一套 API 也能用(静态缓冲、主循环里 poll 等)。那是另一条移植路径,细节看项目文档即可,本文不展开。

事件名先哈希成数字,分发才快

很多自制「事件表」在分发时对事件名做 strcmp。事件种类一多、post 一勤,热路径上反复扫字符串就浪费 CPU,在主频不高的嵌入式 SoC 上更明显。

embedmq 的做法是:注册时用 FNV-1a(一种很快的非加密哈希)把事件名压成 uint32_t(库文档里当 UUID 用),插入有序数组;分发时对整数做二分查找。热路径上不再做字符串比较。

划重点:哈希可以想成「把门牌号压成固定长度的房间号」。工程里体现为:post("battery.changed", …) 内部先算出数字 ID,再找 handler。出错时若你手滑把事件名写成 "battery.change"(少了个 d),哈希对不上,表现就是「发出去了,回调永远不进」——排查时先打印双方用的事件名字符串,再比 UUID。事件名建议统一成点分层风格,例如 sensor.tempui.battery,发送与注册两边各写一份常量或宏,减少拼写漂移。

循环里反复发同一种事件时,可先 embedmq_uuid() 缓存 ID,再用 embedmq_post_id(),少算一次哈希。热路径上大致只剩一次信号量操作和一次 memcpy 进环形缓冲。

下图是一条消息从注册到回调的路径。

什么时候用,什么时候别用

embedmq 适合:同一进程、多个线程之间的事件通知和数据拷贝投递。例如 ADC/传感器采样进业务线程、电量或告警变化刷本地 UI、状态机向外广播「进入某状态」、日志线程订阅若干感兴趣的事件名做落盘。

别拿它硬顶这些场景:

    跨进程:用 D-Bus、Unix domain socket、共享内存等 IPC跨机器 / 网络 RPC:用 ZeroMQ、gRPC 一类单线程里同步调用链:直接函数调用更简单,不必绕一圈队列需要严格实时、微秒级硬期限的控制环:先评估调度与回调耗时,别默认「有 MQ 就够实时」

划重点:进程内消息库解决的是「线程怎么解耦」;D-Bus 解决的是「进程怎么说话」。搞混了会出现两种尴尬:要么一个进程内硬上 IPC,延迟和调试成本都上去;要么两个进程共用一个「以为是总线」的库,结果根本通不了。选型时先问一句:发送方和接收方是不是同一个 pid

决策可以按这张图走。

两条线程、一种消息,手写 queue + mutex 往往更直观,代码路径也短。事件名开始成串、模块开始互相躲着改接口、新人读不懂「谁调谁」时,再上这类库更划算。库不是越通用越好,边界清楚才省事。你也可以把 embedmq 当成「团队内约定事件名的脚手架」:哪怕以后换成别的实现,事件名清单本身仍是有价值的接口文档。

Linux 上怎么跑通

仓库地址:https://github.com/w4ysonch/embedmq

MIT 许可。仓库里有 API 说明和设计文档。建议先在 x86 开发机或板子的用户态跑通自带测试,再往业务工程里拷源文件。

git clone https://github.com/w4ysonch/embedmq
cd embedmq
cmake -B build && cmake --build build
./build/test_embedmq

不想用 CMake 时,把 src/ 下源文件和 Linux 对应的 pal/embedmq_pal.c 拷进工程,加好头文件路径,链上 pthread 即可。FreeRTOS / 裸机的 CMake 开关和静态内存用法项目文档里有,需要移植到 MCU 时再查,不必和 Linux 路径搅在一起。

上手时建议按下面几条自检:

先在 Linux 主机或板子用户态跑通 test_embedmq事件名写成点分层字符串,发送与注册必须完全一致高频同名事件缓存 UUID,再用 post_id退出前走 destroy,确认队列排空明确边界:

只做进程内;跨进程另选 IPC回调里别做长时间阻塞;

重活丢给专门线程或再 post 一条「慢任务」事件

embedmq 体量不大,解决的就是嵌入式 Linux 里那件具体的事:线程多了以后,别再靠互相持指针硬耦合。

相关推荐

登录即可解锁
  • 海量技术文章
  • 设计资源下载
  • 产业链客户资源
  • 写文章/发需求
立即登录