news 2026/10/17 1:06:42

HI3556V200双系统核间通信:IPCM/virt_tty/sharefs选型与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HI3556V200双系统核间通信:IPCM/virt_tty/sharefs选型与实战

做 HI3556V200 双系统开发的人,基本都会在某个阶段被同一件事卡住:A 核上 Linux 的业务和 M 核上 LiteOS 的实时逻辑,到底怎么把消息和文件传过来?尤其第一次看到 SDK 里同时出现 IPCM、virt_tty、sharefs 三个通信方案时,心里大概率会冒出一串问号:这三者有什么不同?该用哪个?能不能混着用?

我是在调一个视频分析模块的时候被这个问题绊住的。Linux 侧负责网络和算法调度,LiteOS 侧负责底层采集和实时控制,两侧必须交换参数和运行状态。最开始的方案是直接往固定内存地址写数,结果 cache 一致性问题、地址冲突问题一个接一个冒出来,最后才意识到,这套平台上已经有现成的、经过验证的核间通信框架,只是需要先搞清楚每个通道的"脾气"。这篇笔记就是把我在 IPCM / virt_tty / sharefs 三条通路上实际调试的经验整理出来,给同样在啃双系统通信的人做个参照。

1. 双系统的定位与通信需求:先知道谁在说话

1.1 两个 CPU 各管一摊

HI3556V200 的双系统,本质上就是一颗 SoC 里面放了两种不同定位的核心。A 核这一侧跑完整 Linux,拥有网络协议栈、文件系统、丰富的用户态库,适合做算法调度、网络服务、业务逻辑这些"重而复杂"的事情;M 核这一侧跑 LiteOS,任务切换快、中断响应确定、没有复杂内核机制兜底,适合做寄存器级控制、高频采集、硬件事件响应这些"快而固定"的事情。

我在实际项目里的感受是,双系统开发最容易犯的错误,不是把活分错了边,而是把"通信"想得太简单。两边各自跑起来之后,总有一些数据是必须跨核流动的。比如 LiteOS 侧检测到异常输入,需要立刻上报给 Linux 侧;Linux 侧下发了新参数,LiteOS 侧要实时收到并生效;一段调试日志,两边都要能看;一套算法模型文件,Linux 侧下载后 LiteOS 侧要能访问。这些需求看似都是"传数据",但数据形态完全不同,所以引出了三条不同的通道。

1.2 三种通信通道的宏观定位

在深入代码之前,我建议先把这三者的定位用一个类比记下来:

  • IPCM 像打电话:传达指令、状态、事件,内容短,强调"要说清楚、有应答",是控制面。
  • virt_tty 像传真专线:一条持续的双工字节流,不管内容格式,强调"源源不断地传",是数据面。
  • sharefs 像共享文件夹:把一块物理内存包装成文件系统,Linux 侧可以像读写普通文件一样操作,适合传大块静态数据,比如日志文件、模型文件、配置文件。

这个类比在选型时非常管用。我见过有人在 IPCM 上硬传大块数据,结果把消息队列撑爆;也见过有人用 sharefs 做高频小消息交互,结果文件读写开销远大于通信本身。数据形态和通道形态对不上,后面全是坑。

另外还要明确一点,这三条通信通道并不是只能选其一。实际项目中它们往往是配合使用的,后面第五部分我会讲我在项目里的具体组合方式。

2. IPCM 通道:控制消息的主干道

2.1 IPCM 的设计形态与初始化细节

IPCM(Inter-Processor Communication Module)在 SDK 里对应的是一套完整的核间消息传递框架。它不走普通内存共享的裸读写,而是把消息按照"邮箱"或者"消息队列"的语义来管理。每次发送的基本单元是一条带消息 ID、源地址、目的地址、长度和内容数据的消息。接收方按消息 ID 注册回调,消息到达后被框架主动派发。

我理解 IPCM 的设计初衷是:它希望两侧开发者不要直接操弄物理地址,而是像调用一个模块接口一样完成跨核通信。Linux 侧的设备树里通常有一个专用节点,节点里描述 IPCM 所需的中断号、寄存器地址、内存池大小等资源。初始化时,驱动要把本端可用的消息通道注册进去,并指定消息池的深度和单条消息的最大长度。

这里有一处容易忽略的配置。消息池深度和单条消息长度直接影响收发成功率,而不是越大越好。池子太大浪费内存,池子太小遇到流量峰值会丢消息或阻塞。常见的做法是先按业务峰值估算:假设对端最多每秒上报 200 条事件,每条消息体不超过 256 字节,那么消息池至少要比峰值速率留出 1.5 到 2 倍余量,单条消息长度设成 512 字节,留足协议头扩展空间。

设备树节点一般长这样(这是从 SDK 示例里拆出来的,具体寄存器地址以你的 SDK 为准):

communicate_ipcm: ipcm { compatible = "vendor,ipcm-controller"; reg = <0x5A000000 0x10000>; interrupts = <0 45 4>; // 消息池数量、单条消息大小可在这里通过属性配置 message-pool-size = <1024>; message-max-size = <512>; };

设备树配置好之后,Linux 侧驱动正常工作,会生成 /dev/ipcm 或者类似命名的节点。我建议启动后先确认这个节点存在,再继续往下调。

2.2 用户态收发代码的参考写法

用户态代码通常用 open + ioctl + write/read 这套组合。不同 SDK 的 ioctl 命令字不一样,但是语义类似。一个典型的发送流程是:构造消息结构体、填写消息 ID 和目的模块、填入数据、调用发送接口、等待发送结果。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <sys/ioctl.h> #include <unistd.h> #define IPC_MSG_SRC_LINUX 1 #define IPC_MSG_DST_LITEOS 2 #define IPC_MSG_TYPE_CMD 100 typedef struct { unsigned int src; unsigned int dst; unsigned int type; unsigned int len; unsigned char data[512]; } ipcm_message_t; int main(void) { int fd = open("/dev/ipcm", O_RDWR); if (fd < 0) { perror("open ipcm failed"); return -1; } ipcm_message_t msg; memset(&msg, 0, sizeof(msg)); msg.src = IPC_MSG_SRC_LINUX; msg.dst = IPC_MSG_DST_LITEOS; msg.type = IPC_MSG_TYPE_CMD; msg.len = 32; strcpy((char *)msg.data, "switch_to_1080p"); int ret = write(fd, &msg, sizeof(msg)); if (ret < 0) { perror("write ipcm failed"); close(fd); return -1; } close(fd); return 0; }

注意我使用的是 write 而不是 ioctl,是因为部分 SDK 把整条消息直接当成一块数据写入驱动,驱动内部再解析成一条 IPCM 消息。如果 SDK 明确要求用 ioctl 传命令字,那就按照 SDK 的 ioctl 方式写。核心思路都是先把消息体填好,再交给驱动。

接收方向一般会有一个后台线程阻塞在 read 上。LiteOS 侧回消息后,Linux 侧驱动把消息从内核态拷出来,read 返回的字节数就是实际消息长度。我在调试阶段发现,接收线程里的处理逻辑一定不能做耗时操作,比如写日志到磁盘、同步调网络接口。否则消息积压在队列里,对端感知的响应延迟会变大,甚至会触发超时重传。

2.3 调 IPCM 时最容易翻车的几个地方

第一个坑是阻塞超时。IPCM 的发送接口默认可能是阻塞模式,对端如果长时间不消费消息,发送线程会一直卡住。我当时从现象看过去是"程序停住不动",一开始还以为是死锁,后来查了驱动的发送超时时间,才发现是对端根本没有注册对应的消息 ID 回调,消息发过去没人接收,池子被占满。解决办法就是给发送操作加超时,超时时间建议设置成 200 到 500 毫秒,超过就报错并打印对端状态,方便定位。

第二个坑是消息结构体字节对齐。结构体里如果有成员长度不对齐,两个核之间编译器默认的 packed 规则不一样,消息内容会错位。最典型的例子是 Linux 侧默认 4 字节对齐发过去,LiteOS 侧按 1 字节紧凑对齐接收,解析出来的数据完全错乱。我的习惯是所有跨核消息结构体显式按 4 字节对齐,并且用固定长度的数组代替指针。千万不要在消息里直接传指针,A 核的指针在 M 核那边毫无意义。

第三个坑是消息 ID 的分配管理。项目初期大家各写各的模块,消息 ID 冲突了以后,LiteOS 侧同一个 ID 只能注册一个回调,后注册的会把先注册的覆盖掉,导致某个功能莫名其妙失效。后来我把所有跨核消息 ID 集中放进一个头文件统一管理,每个模块划分独立区间,比如采集模块从 1 到 100,算法模块从 101 到 200,才彻底解决了冲突问题。

3. virt_tty 通道:把字节流当串口用

3.1 虚拟串口到底是什么

virt_tty 在很多文档里被写作 virtio-serial 或者虚拟串口,逻辑上和普通 UART 很像,但物理介质不是引脚,而是内存中的一块环形缓冲区。Linux 侧认到的设备节点是 /dev/ttyVIRT*,对应用层来说,它就是串口:可以 open,可以配置 termios,可以 read/write,甚至可以用 select、epoll 监听。

我一开始不太理解平台上为什么要专门做一条这样的通道,后来想明白了:控制消息用 IPCM,但视频分析过程中的候选框数据、特征向量、实时日志流,这些数据量大、格式任意、不需要逐条应答,用 IPCM 的消息池根本撑不住。这时候 virt_tty 的字节流模型就非常合适。它天然支持流式读写,应用层想按照自己的帧格式切片都行。

这套东西还有一个隐藏优势:你可以把任何现成的串口调试工具直接对接上来。在 PC 上经常用串口助手的人,到了板子上仍然可以用类似思路去验证 virt_tty 通不通。换句话说,virt_tty 把核间通信的门槛降到了"你只要会开串口就会用"的程度。

3.2 打开虚拟串口的标准姿势

打开 /dev/ttyVIRT0 之后,强烈建议立刻把终端设置为原始模式(raw mode),否则串口驱动可能做一些行缓冲、信号处理、回车换行转换之类的额外行为。跨核数据里面如果包含二进制内容,一旦被驱动做了换行转换,数据就变了。

#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <termios.h> #include <string.h> int main(void) { int fd = open("/dev/ttyVIRT0", O_RDWR | O_NOCTTY); if (fd < 0) { perror("open virt_tty failed"); return -1; } struct termios tty; tcgetattr(fd, &tty); cfmakeraw(&tty); tty.c_cc[VTIME] = 0; tty.c_cc[VMIN] = 1; tcsetattr(fd, TCSANOW, &tty); const char *msg = "hello from linux"; int n = write(fd, msg, strlen(msg)); printf("write %d bytes to virt_tty\n", n); char buf[256]; n = read(fd, buf, sizeof(buf)); if (n > 0) { printf("received %d bytes\n", n); } close(fd); return 0; }

cfmakeraw 这个函数是 Linux 内核提供的 POSIX 扩展接口,本质上就是把 raw 标志、关闭回显、关闭软件流控、关闭信号字符全部设置好。如果你在某个库里面找不到 cfmakeraw,也可以通过手动设置 c_iflag、c_oflag、c_lflag 来实现相同效果,网上有很多现成的配置代码。

我在实际代码里很少直接在收发线程中做字符串处理,因为一个完整的业务数据包往往需要多次 read 才能读完。虚拟串口不保证一次 read 返回的就是完整应用层消息,所以必须在应用层自己定义帧边界。我的做法是固定消息头(如 4 字节魔数 + 2 字节长度),收到头部后按长度继续读,接收端做成状态机,这样可以稳定处理黏包和半包。

3.3 流控、缓冲区和丢数据的教训

virt_tty 用起来比普通串口简单,但也不是完全没有坑。我第一次用的时候,发现从 Linux 侧一次性写 8KB 数据到 LiteOS 侧,LiteOS 侧只收到了前面一小段,后面全丢了。排查后确认,虚拟串口底层环形缓冲区的大小是有限的,对端消费速度跟不上发送速度时,新数据会直接覆盖尚未读取的旧数据。

这种丢包很难从驱动层解决,需要从应用层做两手准备。第一是减小单次发送量,每秒发送的总量不要超过物理通道能够吸收的上限;第二是在业务协议里加入序列号或者数据块编号,对端收到后发现编号跳动,就知道中间丢数据了,然后请求重传。

另外一个值得注意的点是 write 返回值的含义。write 返回的字节数只代表数据进入了驱动缓冲区,不代表对端应用已经收到和处理完。对延迟敏感的业务,最好的办法是在协议层加确认:发送方写入数据后等待一个 ACK,超时后选择重发或报错。我见过有人忽略了这一层,在调试时看到 write 全部成功就认为对端已经处理,结果业务表现和数据对不上,排查了很久才发现是对端在忙时丢了 ACK。

还有一个通信方向上的问题是全双工链路的两端对应关系。virt_tty 设备通常分成多路,Linux 侧的 /dev/ttyVIRT0 可能对应 LiteOS 侧的逻辑串口 0,LiteOS 侧的逻辑串口 1 可能会映射到 Linux 侧的 /dev/ttyVIRT1。开始调之前一定要先看 SDK 的端口映射表,否则可能出现"Linux 发给 VIRT0,LiteOS 在串口 1 上等,永远等不到数据"的尴尬局面。

4. sharefs 通道:用文件系统掩盖内存细节

4.1 共享内存为什么要套一层文件系统

sharefs 的思路其实很优雅:把一块预留的 DDR 物理内存,用文件系统的形式暴露给 Linux 用户态。用户不再需要关心这块内存的物理地址在哪里,只需要 mount 一个共享文件系统,然后在挂载目录下读写文件即可。LiteOS 侧则通过 SDK 提供的接口直接访问同一块物理内存上对应的文件数据。

我当时第一次看到 sharefs 的用法时,觉得这个东西在同场景下有替代方案,比如 Linux 侧直接 mmap 一段共享内存,LiteOS 侧也映射同样物理地址,两边各自读写。但后来我发现 mmap 方式有几个麻烦:物理地址分配要手动协调,内存保护要靠自己实现,文件和数据的组织也要自己做,而 sharefs 把这些都封装掉了。文件这个抽象层对开发者非常友好,天然具备定位、截断、命名、多文件隔离的能力。

举个例子,Linux 侧下载了一个新算法模型,大小可能几十 MB。如果走 IPCM,消息池根本承载不了;如果走 virt_tty,传输时间可以接受,但需要把数据流重新组装成文件,还得管理写一半断电的情况。而用 sharefs,Linux 侧直接把模型文件写入共享目录,LiteOS 侧从共享目录读取,整个过程既不需要协议组帧,也不需要序列化和反序列化,文件系统自己把大象装进了冰箱。

4.2 挂载与读写实践

sharefs 的挂载路径在 SDK 里通常是预留好的。启动后,Linux 侧执行挂载命令,把共享内存块挂载到一个空目录。以下命令只是示意,具体节点名、挂载类型和参数必须以 SDK 文档为准。

mkdir -p /mnt/sharefs mount -t simfs none /mnt/sharefs

挂载完成后,在这个目录下创建的普通文件,实际上就写在共享内存块里。我用一段简单代码演示 Linux 侧的读写:

#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <string.h> int main(void) { int fd = open("/mnt/sharefs/config.bin", O_CREAT | O_RDWR, 0644); if (fd < 0) { perror("open sharefs failed"); return -1; } const char *cfg = "sensor_mode=0\nae_enable=1\n"; ssize_t n = write(fd, cfg, strlen(cfg)); fsync(fd); close(fd); printf("write %ld bytes to sharefs\n", n); return 0; }

LiteOS 侧通常会有专门的接口打开和管理共享文件,不需要在 LiteOS 里实现一套完整文件系统,只需要能够按名字找到共享区域里的数据块,然后按偏移量访问。在实际使用中,我会在共享目录下按业务功能划分子目录,比如 model/、config/、log/。这样子目录隔离的好处是,当有多个模块同时往共享区写数据时,文件名冲突概率被大幅降低。

4.3 并发与一致性问题,以及我的规避做法

sharefs 最大的坑不是读写本身,而是并发和一致性。

首先是 cache 一致性问题。A 核和 M 核操作同一块 DDR 物理内存时,如果某一侧开启了 cache,另一侧写入的数据在 cache 没有失效之前可能读不到最新值。这个问题的解决方案在 SDK 里一般会被封装掉,框架会主动做 cache 刷新或物理地址映射时关 cache。但如果你绕过 SDK 直接操作共享区域,一致性就要自己承担。我建议优先使用 SDK 提供的接口,不要自己造轮子。

第二个问题是并发互斥。两个核同时写共享区的同一个文件,文件内容可能交错、混乱。我在项目里用了一个很简单的约定:共享文件按写入方向区分目录,Linux 写 config,LiteOS 只读 config;LiteOS 写 status,Linux 只读 status。如果确实需要双向读写同一个文件,就通过 IPCM 发送一个"我要锁文件"的命令,另到边收到应答后再操作。共享内存自身通常不提供锁机制,锁是要架在 IPCM 之上的。

第三个问题是掉电残留。共享文件系统是内存态的,系统重启之后,之前写的文件可能还在,也可能被初始化程序清空。我在项目里养成了一个习惯:Linux 侧启动脚本里固定检查挂载目录,如果发现上一次退出时留下的临时文件,先按业务规则决定是清理还是保留。业务逻辑不能假设共享文件在上电后一定处于干净状态。

5. 三种通道的实际选型与组合打法

5.1 一张表说清差异

调试完三条通道之后,我把它们的核心差异整理成了一张表,后面做技术方案评审时直接拿这张表来说事。

维度IPCMvirt_ttysharefs
数据模型消息队列/邮箱字节流文件
典型消息大小几百字节以内不定长,可持续流式大文件,几十 MB 级别
实时性高,毫秒级中,受字节流调度影响低,文件读写耗时明显
适用场景命令、状态、事件、应答视频结构化数据流、实时日志模型文件、配置文件、离线日志
实现复杂度中,需要管理消息 ID 和回调低,串口编程体验低,文件读写体验
需要额外注意消息 ID 冲突、超时粘包半包、流控丢包cache 一致性、并发互斥

这张表不是说哪条通道更好,而是说哪条通道更合适。我见过有人在分享会上说"我这一版只用了 IPCM,完美解决了所有通信需求",其实是因为他的业务恰好都是命令和状态,数据流根本不存在。如果你的系统里有大块数据要跨核移动,IPCM 硬扛一定会在某个临界点上崩溃。

5.2 我项目里的组合用法

我负责的视频分析模块最终采用了三条通道混合的架构。LiteOS 侧负责图像采集和底层曝光控制,Linux 侧负责算法推理和网络服务。具体组合是这样的:

  • 控制链路走 IPCM。Linux 下发切换分辨率、调节帧率、开启算法等命令;LiteOS 上报曝光异常、采集帧计数、硬件错误事件。每条消息都很短,实时性要求高。
  • 数据链路走 virt_tty。算法需要的图像帧描述信息、检测结果坐标、跟踪特征,以自定义二进制帧格式持续传输。这里数据量大、持续流动,适合字节流。
  • 模型和配置走 sharefs。Linux 侧把更新后的算法模型和摄像头参数文件写入共享目录,LiteOS 侧在启动时或收到 IPCM 通知后读取。大文件不用走消息池,也不用流式拆分。

这套组合在业务上各司其职。每一条链路都只做自己最擅长的事,通信压力被分散,排障也快:命令慢了查 IPCM,数据丢了查 virt_tty,文件坏了查 sharefs,不会出现所有问题都挤在一坨串口日志里的情况。

5.3 快速验证链路是否打通的办法

最后分享几个我在联调阶段常用的验证手段,这些手段能帮你快速判断是哪条链路出了问题,而不是怀疑人生。

第一,验证 IPCM 是否连通。在 LiteOS 侧写一个回环回调:收到类型为 ECHO 的消息后,原样把消息体返回给 Linux。Linux 侧写一个小工具,循环发 1000 条 ECHO 消息,统计返回时间和丢包率。如果返回时间抖动大,去看消息池是否满了,或者对端中断触发是否及时。

第二,验证 virt_tty 是否丢包。Linux 侧连续按序号发送一堆小包,比如发 10000 个包,每个包里面带一个递增序号,LiteOS 侧收到后回传带序号的确认。Linux 侧统计从 1 到 10000 有没有缺口。如果有缺口,定位是环形缓冲区太小还是对端消费太慢。

第三,验证 sharefs 是否一致。Linux 侧往共享目录写一个带固定魔数的文件,LiteOS 侧读取后把文件内容和自身状态合并成新文件写回另一个共享路径。Linux 侧多次轮询直到发现新文件,再做内容校验。这个过程能顺手确认两侧访问共享内存的一致性逻辑是否正常。

这些验证脚本不需要写得多复杂,关键是能快速暴露问题。链路打通之后,再往上面叠业务逻辑就心里有底了。

我在几次双系统项目里最深的体会是:通信框架本身只是半成品,真正的工程质量来自应用层协议和调试习惯。消息格式提前定义好,消息 ID 集中管理,通道选型先考虑数据形态,联调阶段做好回环验证,这套东西做扎实了,双系统通信其实比想象中稳定得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 1:06:24

双节锂电池主动均衡设计:MP2642GR-Z与STM32F732IE协同方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:05:42

基于MP2642GR-Z与STM32F412RE的2串电池被动均衡方案设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:05:27

K-Means聚类算法C++实现:从原理到工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:05:16

0/1背包问题详解:动态规划、状态转移方程与滚动数组优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:04:39

双串锂电池主动均衡方案:MP2642GR-Z与PIC18LF46K42协同设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:04:29

双节锂电主动均衡设计:MP2642GR-Z与STM32L011K4工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华