最近在调一块NXP i.MX8平台的板子,项目需求很典型:一边要跑Linux,处理显示、网络协议栈、图像识别这类重负载任务,另一边要有一个硬实时的控制回路,毫秒级周期去采传感器、驱动执行器。我把这两拨任务分别放到了i.MX8的Cortex-A核和Cortex-M4核上,A核跑Linux,M4核跑FreeRTOS,两个核之间用rpmsg做消息通信。
这篇文章从方案选型讲到最后的联调踩坑,把整个NXP i.MX8 + M4核心 + FreeRTOS + rpmsg应用示例完整梳理一遍。内容覆盖:为什么用rpmsg而不是自己写共享内存协议、M4侧FreeRTOS工程的搭建、rpmsg_lite库的使用、A侧Linux的设备树配置和remoteproc加载、用户空间如何通过/dev/rpmsg收发消息,以及我在调试中真实遇到的问题和排查方法。
如果你正在做i.MX8系列或者类似异构多核平台的开发,短期内想让A核和M核协同工作,这篇文章里的思路和代码可以直接当参考。
1. 项目整体设计与思路拆解
1.1 异构多核架构如何解决"实时性"和"重负载"的矛盾
i.MX8系列芯片内部集成了多种核心。以i.MX8QuadMax为例,里面有4个Cortex-A53、2个Cortex-A72,还有2个Cortex-M4F。A核是应用处理器,跑Linux没有问题;M4F是实时核心,适合跑RTOS做控制。A核和M4核各自有独立的内存和中断控制器,但又可以通过硬件Mailbox和共享内存互相通信,天生就是为这种"Linux + RTOS"双核协作场景准备的。
有人会问:直接把所有事都丢给A核,Linux难道不能做实时控制吗?实际项目里会碰壁。电机控制的周期是1kHz甚至5kHz,而Linux的调度延迟通常在几毫秒到几十毫秒之间波动,还会被其他进程和中断干扰,根本给不了确定的响应时间。另外,一些安全相关的传感器采集逻辑最好跑在独立核心上,避免被Linux侧的异常拖垮。所以异构分工是硬需求:A核跑算法和交互,M4核跑实时控制。
M4核心上跑什么RTOS,当时纠结了一下。裸机开发其实也能做,但我们的控制逻辑里有多个不同周期的任务:1kHz的电流环、100Hz的速度环、50Hz的状态上报,再加上故障保护逻辑。裸机只能靠一个超级while循环轮询,代码结构很快就烂了。FreeRTOS用任务加优先级的方式把这些周期任务理得很干净,而且NXP的MCUXpresso SDK对FreeRTOS有成熟的集成,官方demo里直接带了rpmsg_lite的例程,省掉一大半底层适配工作。
1.2 核间通信方案对比:为什么最终选了rpmsg
A核和M4核要协同,通信方式有好几种,我把当时考虑过的方案放在一张表里:
| 通信方案 | 实时性 | 带宽 | 开发工作量 | 可靠性 |
|---|---|---|---|---|
| 共享内存 + 自研协议 | 好 | 高 | 高,要自己解决同步、缓冲区管理、协议解析 | 一般,出错难排查 |
| 串口互联 | 差 | 低 | 低 | 稳定但慢,带宽完全不够用 |
| 以太网互联 | 中 | 中 | 中 | 板级复杂度高,延迟不可控 |
| rpmsg | 好 | 高 | 库封装完善,基本够用 | 内核级框架,成熟稳定 |
串口方案第一个被排除。我们在实际测试中试过用1.5Mbps的串口在两核之间传传感器波形数据,带宽完全不够,这还不算协议帧头、校验这些开销。以太网需要在两个核心之间额外挂MAC,板子复杂度上升,延迟也做不到微秒级。自研共享内存协议看着简单,但中断同步、多缓冲区管理、生命周期握手全要自己写,双核并发调试更是噩梦。
最后选了rpmsg。它的底层是Linux内核的remoteproc/rpmsg框架,思路和操作系统的虚拟化方案同源,NXP在SDK里提供了rpmsg_lite库给M4侧用,A侧直接走/dev/rpmsg字符设备,两边都能像操作socket一样收发消息,不需要自己维护共享内存的细节。
1.3 整个系统的软件分层
这个项目的软件架构可以分成四层,讲清楚之后后面看代码会顺很多:
- 第一层是硬件层:A核、M4核、共享内存区、Mailbox硬件单元。
- 第二层是基础软件层:A核跑Linux内核(包括remoteproc、rpmsg总线、mailbox驱动),M4核跑FreeRTOS。
- 第三层是通信层:A侧用rpmsg_char驱动给用户空间暴露/dev/rpmsg设备,M4侧用rpmsg_lite提供端点和消息收发API。
- 第四层是应用层:A核用户空间的业务程序,M4核FreeRTOS里的实时控制任务。
这样的分层好处很明显,调试时能一层层定位问题。比如消息收发失败,先看Mailbox中断是否触发,再看共享内存数据是否写入,最后才看应用层的发送逻辑。
2. RPMSG核心机制:共享内存里的消息通道
2.1 rpmsg与virtio:两个核是怎么通过环形队列对话的
rpmsg的全称是Remote Processor Messaging,它的底层机制继承自virtio标准。virtio本来是虚拟化领域里guest系统与host系统之间的I/O虚拟化框架,在i.MX8上被移植到了异构多核场景:A核Linux当"host",M4核FreeRTOS当"remote",两个核通过一段共享内存上的环形队列交换消息。
核心数据结构是vring,可以简单理解成一块共享内存里的环形缓冲区。vring里有一张描述符表,还有avail ring和used ring两个"指针环"。发送方把数据写到共享内存中的数据区,然后在描述符表里登记一个描述符(包含地址、长度和标志位),再把描述符索引推入avail ring,最后用Mailbox硬件中断通知对方来取。接收方从中断处理函数里读到avail ring的索引,取出描述符,从共享内存读出数据,处理完成后再把描述符索引放进used ring,表示这块缓冲我用完了,你可以继续写。
这个设计的巧妙之处在于:avail ring永远由发送方写入、接收方只读;used ring反过来。两个核永远不会并发写同一个结构,因此并不需要复杂的锁机制。了解这点之后,很多读写异常类问题就容易定位了。
2.2 一次消息收发的完整旅程
以A核Linux向M4核发送一条消息为例,把完整流程走一遍:
- A侧应用层向/dev/rpmsg0写入数据。
- Linux内核的rpmsg_char驱动把用户空间数据拷贝到内核缓冲区,触发rpmsg模块在共享内存的发送vring里申请一个描述符,指向该缓冲区。
- rpmsg模块把描述符索引写入avail ring,并调用mailbox驱动向M4核发送中断通知。
- M4核收到Mailbox中断,进入rpmsg_lite的中断处理函数,从avail ring取到描述符,根据描述符找到数据缓冲区。
- rpmsg_lite通过消息头里的端点地址,找到注册在对应端点上的应用回调函数,把数据指针和数据长度传进去。
- 应用回调处理完消息后,rpmsg_lite把描述符索引写入used ring,再通过Mailbox中断回复A核,A核驱动收到通知后释放内核缓冲区。
M4核向A核发送的路径完全对称,只是换成了另一个方向的vring。整个流程中,应用层几乎感知不到底层的中断和缓存同步动作,这也是rpmsg比自研共享内存协议舒服的地方。
2.3 Mailbox硬件中断:让消息通知不再靠轮询
两核之间的通知用的是Mailbox(在NXP文档里常叫MU,Message Unit)。MU相当于一个简单的硬件信箱:一侧往寄存器里写值,另一侧就会收到中断。
实际使用时,A核和M4核之间通常要分配三组MU通道:一组用于A向M4发送消息通知,一组用于M4向A发送消息通知,还有一组用于"doorbell"或者叫接收通知。在设备树里会看到tx、rx、rxdb三个通道的声明,分别对应这个角色。
我见过有人想在共享内存上做纯轮询来绕过Mailbox,消息是能通,但CPU占用非常高,M4核的实时任务频繁被轮询打断。用中断配合信号量,才是符合RTOS习惯的写法。这一点在后文的任务设计里会再展开。
3. M4侧FreeRTOS应用实现
3.1 SDK环境与示例工程
M4侧的开发我基于MCUXpresso SDK,它在官方仓库里已经带了rpmsg_lite的FreeRTOS示例工程。使用的工具链是arm-none-eabi-gcc,可以直接用MCUXpresso IDE编译,也可以用命令行make。
拿到SDK后,先找一下示例工程路径,通常在boards目录下类似demo_apps/rpmsg_lite的结构里。示例工程已经把rpmsg_lite库源码、链接脚本、FreeRTOS配置都安排好了,建议不要自己从零搭工程,直接复制示例改,能省掉大量环境问题。
我的做法是:先把官方例程编译一次,不修改任何代码,确认工具链和SDK版本能跑通,再开始往里面加自己的业务代码。版本方面有一点需要提醒,不同MCUXpresso SDK版本的目录结构和函数命名可能有差异,我文中的宏名和API以SDK自带示例为准,你照着SDK改即可。
3.2 初始化rpmsg和创建端点
M4侧的主要代码分成三块:初始化、创建端点、消息任务。下面是一段我整理过的核心逻辑。
#include "FreeRTOS.h" #include "task.h" #include "queue.h" #include "rpmsg_lite.h" #include "board.h" static struct rpmsg_lite_instance *s_rpmsg_instance; static struct rpmsg_lite_endpoint *s_rpmsg_ept; /* 收到消息的回调,在中断上下文执行 */ static int32_t app_rpmsg_callback(void *payload, uint32_t len, void *priv, uint32_t src) { struct rpmsg_lite_endpoint *ept = (struct rpmsg_lite_endpoint *)priv; /* 只做拷贝,并用信号量通知任务处理 */ xSemaphoreGiveFromISR(s_msg_sem, NULL); return 0; } static void app_rpmsg_task(void *param) { char send_buf[64]; uint32_t dst_addr = RL_EPT_DEF_EPT_ADDR; /* 第一步:初始化rpmsg实例 */ s_rpmsg_instance = rpmsg_lite_init(RL_GET_PAYLOAD_ADDR(), RL_PAYLOAD_SIZE, RL_IMX8QM_M4_LINK_ID); if (s_rpmsg_instance == NULL) { printf("rpmsg_lite_init failed\n"); vTaskDelete(NULL); return; } /* 第二步:创建端点并注册回调 */ s_rpmsg_ept = rpmsg_lite_create_ept(s_rpmsg_instance, RL_EPT_DEF_EPT_ADDR, app_rpmsg_callback, s_rpmsg_ept); if (s_rpmsg_ept == NULL) { printf("rpmsg_lite_create_ept failed\n"); vTaskDelete(NULL); return; } /* 第三步:告诉A侧,M4已经就绪 */ rpmsg_lite_send(s_rpmsg_ept, dst_addr, "ready", 6, 0); for (;;) { /* 等待信号量,然后处理收到的消息 */ xSemaphoreTake(s_msg_sem, portMAX_DELAY); /* 实际项目中这里做消息解析和业务处理 */ vTaskDelay(pdMS_TO_TICKS(1000)); } } int main(void) { BOARD_InitHardware(); s_msg_sem = xSemaphoreCreateBinary(); if (xTaskCreate(app_rpmsg_task, "rpmsg_task", 1024, NULL, tskIDLE_PRIORITY + 2, NULL) != pdPASS) { printf("xTaskCreate failed\n"); return -1; } vTaskStartScheduler(); for (;;) { } }这段代码里有个重点:rpmsg_lite_init的第三个参数是link_id,它表示当前用的通信链路。i.MX8不同型号的M4核对应的link_id不一样,比如i.MX8QM和i.MX8QXP就不同,如果写错,两边无法建立连接。这个宏在rpmsg_lite的头文件里有定义,按芯片型号选即可。
3.3 任务划分:回调、信号量和优先级的设计
刚开始我偷懒,在rpmsg回调函数里直接做消息解析和打印,结果发现M4的响应延迟变得不稳定。原因很简单:rpmsg_lite的回调运行在中断上下文,如果回调里做耗时操作,会阻塞整个M4核的调度,实时控制任务就被拖垮了。
正确的做法是让回调函数只做"通知"这一件事:把消息内容拷贝到任务缓冲区,然后给一个高优先级任务发信号量,真正的解析和处理全部放任务里。中断路径越短越好,实时任务优先级应该高于rpmsg处理任务。
我的优先级设计大致如下:
| 任务/中断 | 优先级 | 说明 |
|---|---|---|
| 实时控制任务 | 最高 | 1kHz周期任务,读传感器、算控制量、写PWM |
| rpmsg收发处理任务 | 中等 | 等待信号量,解析A侧下发指令,回传采样数据 |
| 状态上报任务 | 低 | 低频发送运行状态到A侧 |
| MU中断 | 中断优先级中等 | 只做vring相关处理,信号量通知任务 |
FreeRTOS里通过vTaskPrioritySet和xTaskCreate的第四个参数控制优先级。有一点要注意:MU中断的优先级配置不能比FreeRTOS要求的最大中断优先级更高,否则在临界区内触发中断会造成行为不确定。这个坑我是后来看寄存器手册才发现的,建议直接按SDK示例里的NVIC配置来。
3.4 链接脚本与共享内存布局
M4核的代码和共享内存的地址分配,是初学者最容易被绕晕的地方,却又是整个通信能否建立的基础。
以我的板子为例,A核和M4核协商使用以下地址空间:
- M4代码和向量表:0x92000000起始,大小16MB,由M4独占。
- rpmsg共享内存:0x90000000起始,大小2MB,A核和M4核都能访问。
M4侧的链接脚本必须把代码段、数据段、堆栈段放在0x92000000这个区域,同时给rpmsg_lite预留一段共享内存空间。SDK示例工程里的链接脚本已经做了正确安排,如果你修改过内存配置,要确保以下几个区域互不重叠:
- 向量表和代码段
- FreeRTOS堆栈
- rpmsg_lite的共享内存段
- M4核自己业务用的数据缓冲区
在A侧设备树里,0x90000000区域要声明为reserved-memory,并且标记为no-map,意思是这段物理内存Linux不能把它分配给其他子系统。如果内存区域被Linux占用,M4核写进来的数据可能被Linux覆盖,消息内容千奇百怪。
4. A侧Linux端配合实现
4.1 设备树配置:预留内存、Mailbox与rpmsg节点
A侧Linux要让M4核跑起来,第一件事是在设备树里把共享内存和Mailbox配置好。下面是设备树片段:
/ { reserved-memory { #address-cells = <2>; #size-cells = <2>; ranges; rpmsg_reserved: rpmsg@0x90000000 { no-map; reg = <0x00000000 0x90000000 0x00000000 0x800000>; }; }; imx8_rpmsg: rpmsg { compatible = "fsl,imx8qm-rpmsg"; mbox-names = "tx", "rx", "rxdb"; mboxes = <&lsio_mu1 0 1>, <&lsio_mu1 1 1>, <&lsio_mu1 2 1>; memory-region = <&rpmsg_reserved>; status = "okay"; }; imx8_m4: imx8-m4@0x92000000 { compatible = "fsl,imx8qm-m4"; reg = <0x00000000 0x92000000 0x00000000 0x1000000>; memory-region = <&rpmsg_reserved>; status = "okay"; }; };不同BSP版本里节点名称和compatible字段可能有差异,比如i.MX8QXP的rpmsg节点可能叫fsl,imx8qxp-rpmsg。反正思路一致:第一,reserved-memory标签为no-map;第二,rpmsg节点和remote proc节点都要绑定同一个memory-region;第三,mbox通道名称要和驱动匹配。
实际开发中我踩过一个坑:设备树里写了reserved-memory,但Linux内核的配置没有启用CMA和remoteproc相关模块,设备节点始终不出现。后来检查内核config,补上CONFIG_REMOTEPROC、CONFIG_RPMSG等选项才解决。
4.2 加载M4固件的两种路径
M4核心的固件有两种加载方式,我根据项目阶段选择。
第一种,U-Boot阶段加载。U-Boot的fatload或tftp命令把M4固件拷贝到0x92000000,然后用命令启动M4核,比如mu_start。这种方式的优点是不依赖Linux启动,M4核在系统上电早期就能工作。适合产品量产、需要快速启动控制的场景。
第二种,Linux的remoteproc框架动态加载。开发阶段我更喜欢这种方式,不需要反复重启整个系统,直接热更新M4固件:
cp rpmsg_example.elf /lib/firmware/ echo -n "rpmsg_example.elf" > /sys/class/remoteproc/remoteproc0/firmware echo -n "start" > /sys/class/remoteproc/remoteproc0/state执行后可以查看状态节点确认M4是否正常运行:
cat /sys/class/remoteproc/remoteproc0/state如果一切正常,状态会显示running。此时Linux的rpmsg驱动会自动和M4侧的rpmsg_lite完成握手,创建rpmsg总线设备,并在/dev目录下生成rpmsg字符设备。
4.3 用户空间通过/dev/rpmsg收发消息
M4侧创建好端点后,A侧Linux会生成/dev/rpmsg0设备。最简单的验证方式是用命令行直接试通:
echo "hello M4" > /dev/rpmsg0 cat /dev/rpmsg0但这种用法有个坑:字符设备的读操作在无数据时可能返回EAGAIN,cat命令会直接退出,看起来像"收不到消息"。实际消息可能只是还没到。更可靠的做法是写一个小程序,用阻塞或poll方式读取。
#include <stdio.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <poll.h> int main(void) { int fd; char buf[512] = {0}; struct pollfd pfd; int ret; fd = open("/dev/rpmsg0", O_RDWR | O_NONBLOCK); if (fd < 0) { perror("open"); return -1; } /* 通过ioctl查询本地端点地址,宏定义在rpmsg_char驱动头文件中 */ unsigned int local_addr = 0; ret = ioctl(fd, RPMSG_IOC_GET_ADDR, &local_addr); if (ret < 0) { perror("ioctl"); } printf("local addr: 0x%x\n", local_addr); /* 发送一条消息给M4 */ strcpy(buf, "hello from Linux"); ret = write(fd, buf, strlen(buf) + 1); printf("write ret: %d\n", ret); /* 轮询等待M4回复 */ pfd.fd = fd; pfd.events = POLLIN; ret = poll(&pfd, 1, 3000); if (ret > 0 && (pfd.revents & POLLIN)) { memset(buf, 0, sizeof(buf)); ret = read(fd, buf, sizeof(buf)); if (ret > 0) { printf("received: %s\n", buf); } } close(fd); return 0; }关于端点地址,这里再提醒一句。rpmsg通信的双方都要知道对方的端点地址。M4侧如果用了自动分配地址,Linux侧第一次发消息时可以先发送一个"握手"消息,从后续回复中获取M4的源地址;也可以在两端约定固定地址,这样双方从一开始就能对发消息。我的项目里用的是自动分配加初始化握手,逻辑比较简单,代码也容易理解。
5. 联调过程与踩坑实录
5.1 编译阶段:链接脚本、工具链和SDK版本
第一次把官方M4示例工程编译出来,链接器报错找不到入口向量表。查了一圈,定位到是链接脚本里向量表存放地址和我板子上M4实际的启动地址不一致。芯片手册里M4启动地址是0x92000000,但默认链接脚本写的是0x90000000。这种问题说白了,就是示例工程没有精确匹配我的板子,必须对照数据手册改。
另一个低级错误是忘了在Makefile里把rpmsg_lite库源码编进去,链接阶段冒出一堆undefined reference到rpmsg_lite_xxx函数的报错。如果你基于SDK的示例工程改,这个错一般不会遇到,因为SDK工程已经把库包含进去了。所以我始终建议:不要从零搭工程。
还有工具链版本。ARM的gcc版本太老会导致浮点ABI不匹配,编出来的固件在M4上跑飞。SDK文档一般会指定推荐版本,我后来统一用了SDK自带的工具链,问题消失。
5.2 运行阶段:设备节点不出现、消息丢包
调试过程中遇到最折磨人的问题,是remoteproc状态显示running,但/dev/rpmsg0始终不出现。
我检查步骤是这样的:
- 先看M4侧有没有打印就绪信息,结果串口没有任何输出。
- 用调试器挂到M4核,单步跑,发现程序卡在rpmsg_lite_init处。
- 对比A侧设备树reserved-memory地址和M4链接脚本里的共享内存地址,发现一个是0x90000000,另一个是0x8FF00000,虽然只差1MB,但两边根本不在同一段物理内存上。
把地址统一后,/dev/rpmsg0立刻出现。这个例子说明:异构多核调试时,内存地址的一致性检查应该放在第一步。
还遇到过一次消息"丢包":A侧写入消息后M4没反应,但过一会儿M4又收到了。定位后发现是A侧在短时间内高频发送,而M4的MU中断优先级不够,中断一直被实时任务抢占,消息堆积在vring里。后来我提高了MU中断的优先级,并让A侧适当控制发送节奏,问题解决。
5.3 性能与稳定性:Cache一致性和中断调优
rpmsg的消息传输路径经过共享内存,而A核和M4核对这个区域的Cache处理方式不完全一样。rpmsg_lite和Linux驱动本身对virtio的缓冲区做了Cache操作,但如果你自己另外在共享内存里定义数据区做业务数据传输,就一定要自己处理Cache同步。
我在项目里就是这么干的:A核把PID控制参数写进共享内存的业务区,M4核读时却拿到旧值。原因就是A核写的参数还留在Cache里,没有回写。解决办法是,写完后调用DMA或Cache的clean操作,M4读取前调invalidate操作。不同平台对Cache操作的API不一样,务必查一下参考手册。
还有个性能优化的经验:不要在回调里做耗时的字符串格式化或打印。一次中断进来,几十微秒能完成拷贝并触发信号量,但printf串口输出往往要花几毫秒。把打印放在任务里,用队列或信号量异步处理,M4的CPU占用率能降下一大截。
6. 常见问题速查表
把这段时间遇到的实际问题和排查方法整理成一个速查表,遇到类似情况可以直接对照。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| /dev/rpmsg0 无法创建 | M4与A侧共享内存地址不一致 | 检查设备树reserved-memory与M4链接脚本的地址 |
| remoteproc状态一直offline | 固件路径不存在或权限问题 | 确认固件已拷到/lib/firmware,检查日志 |
| cat /dev/rpmsg0 没反应 | 非阻塞read返回EAGAIN | 改用poll/select或阻塞模式读取 |
| 消息内容乱码 | 共享内存Cache一致性问题 | 在共享内存业务区显式clean/invalidate Cache |
| M4崩溃后A侧卡死 | remoteproc异常处理未配置 | 查看remoteproc的exception事件,配置看门狗 |
| MU中断频繁导致M4任务饥饿 | 回调函数做了耗时处理 | 中断只做拷贝,用信号量通知任务处理 |
| rpmsg_lite_init返回NULL | 共享内存大小不足或地址非法 | 增大reserved-memory,核对RL_PAYLOAD_SIZE |
| 双核通信偶尔超时 | Mailbox中断优先级配置不当 | 参照FreeRTOS要求降低中断优先级数值 |
| 用户空间write返回-EINVAL | 远程端点地址为0或未握手 | 先完成初始化握手,确认端点地址有效 |
排查时有一个通用思路:从硬件中断层往应用层逐级验证。M4侧用调试器打断点,A侧用dmesg看mailbox/rpmsg相关日志,再配合/dev/rpmsg设备做一个最小回显测试,通常很快就能定位到是内存地址问题、中断配置问题,还是应用层逻辑问题。
最后分享一点我的体会:异构多核通信项目,很多人一上来就直接写应用层代码,结果联调时问题层出不穷。我的建议是先把官方demo跑通,在一个只有打印回显的最小例程上验证双向链路,再逐步加入业务逻辑。rpmsg_lite和remoteproc的组合其实把底层细节封装得很到位,一旦把共享内存、端点和握手顺序这三个关键点理顺,剩下的就是普通的收发消息编程了。另外,调试的时候多利用/dev/rpmsg字符设备,它让A核侧的消息收发变得和操作普通文件一样直观,很多问题能快速定位到是A侧还是M4侧的问题。