ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

嵌入式AMP多核通信:RPMSG与共享内存原理及实践

嵌入式AMP多核通信:RPMSG与共享内存原理及实践 做嵌入式这行尤其是跟异构多核SoC打交道的朋友大概率都撞上过这么个问题主核跑着Linux副核跑着裸机程序或者RTOS两边各干各的完全没交集。我最早在音频处理项目里折腾这事的时候主核是Cortex-A系列副核是DSP板子上电后两个核心各跑各的固件互相都不知道对方存在。要把数据从一侧搬到另一侧再带上控制命令、同步状态核间通信这块如果不先趟平后面业务逻辑写得再漂亮也白搭。这篇就把我实际项目中反复用到的AMP架构核间通信方案摊开讲清楚核心是RPMSG协议和它底下那块共享内存。RPMSG不是凭空冒出来的它是OpenAMP框架里负责消息传递的组件底层是virtio的vring机制再往下就是实实在在的物理内存和核间中断。这篇文章适合想要在AMP架构下做双核通信的嵌入式工程师也适合刚接触OpenAMP、对RPMSG和共享内存原理还想再抠细一点的开发者。我会把原理、代码流程、踩过的坑一次讲完尽量让新手能跟着跑通老人也能有收获。1. 为什么AMP架构下的核间通信是个真问题1.1 SMP和AMP两种多核架构的本质差异先把概念摆正。多核芯片现在很普遍但“多核”和“多核”差别很大。SMP对称多处理是多个CPU核心共享同一份内存视图、跑同一个操作系统调度器把任务动态扔到空闲核心上核心之间通过内核的同步原语自旋锁、信号量、RCU这些互相配合。在SMP模式下应用层基本感知不到CPU数量的变化你写一个多线程程序操作系统自动帮你分发到不同核上执行。AMP非对称多处理就完全不一样了。AMP里面每个核心或者每个核心簇运行的是各自独立的操作系统甚至有的核心干脆裸奔连RTOS都不上。更关键的是每个核心有自己独立的内存映射不一定能看到对方的内存空间。比如一个SoC里面集成了一颗Cortex-A内核跑Linux处理网络协议栈和用户界面同时还集成了一颗Cortex-M内核跑裸机程序做电机控制这两个核心之间没有任何共享的调度器也没有共同的系统调用接口它们是完全独立的两个“小世界”。这种架构在汽车电子、工业控制、音频处理、基站设备里非常常见。主核承担复杂逻辑和通信栈副核或者DSP承担实时控制和高强度计算。好处很明显——实时任务不会被Linux的调度延迟干扰坏处也很明显——你必须在两个世界之间自行建立沟通的桥梁。1.2 核间通信的三条路共享内存、硬件中断与消息机制核间通信翻来覆去其实就三板斧共享内存、硬件中断、消息邮箱。共享内存是最直观的——双方约定一块物理内存区域一个写一个读数据吞吐量最大。但共享内存有个致命问题就是它的可见性受缓存影响两个CPU核心各自都有L1/L2缓存可能看到同一块物理内存的不同副本。我在后面的章节还会专门讲缓存一致性的坑这里先记住光靠往内存里写数据是远远不够的。硬件中断是另一个基础设施。一方处理完数据后通过核间中断IPIInter-Processor Interrupt或者mailbox信箱机制通知另一方“活儿干完了可以接着干了”。这种机制解决的是“事件通知”的问题而不是“数据搬运”的问题。中断能传的信息量很小往往只是一个信号或者一个几十字节的短消息但它足够快而且能让对方从阻塞中立刻醒来这是共享内存做不到的。消息邮箱和门铃机制doorbell介于两者之间它能往邮箱寄存器里塞几个字的数据通常用作传递指针或者短消息。RPMSG这套方案本质上就是把这三板斧组合起来用共享内存负责数据搬运硬件中断负责事件通知消息格式负责语义表达。你可以把这三个东西类比成一个办公楼的内部通信系统——共享内存是走道里的公共文件柜硬件中断是打电话告诉对方“文件放好了”而RPMSG是文件柜里的标准文件袋上面写好寄件人和收件人确保文件能被正确的人拿到。1.3 RPMSG在OpenAMP体系中的角色OpenAMPOpen Asymmetric Multi-Processing是一个开源框架专门为了解决AMP场景下的通信、生命周期管理而设计。它有三大核心组成。最底下的是libmetal负责抽象硬件操作包括内存映射、设备访问、缓存操作、原子操作、中断处理等。libmetal这层的价值在于它把各种硬件平台上的差异性封装起来上面所有代码写出来都是平台无关的。中间层就是RPMSG它是基于libmetal的核间消息传递库实现了virtio-vring缓冲协议向应用层提供端点endpoint和通道channel的抽象。最上层是remoteproc这个主要在Linux侧负责加载远程固件、管理远程处理器生命周期以及和远程端的RPMSG建立对接。Linux侧还有rpmsg_bus驱动负责把RPMSG通道接到Linux的虚拟总线上这样应用层通过字符设备就能访问。裸机侧、RTOS侧则直接使用librpmsg的API。这套体系里RPMSG解决了两个关键问题第一它定义了消息格式每次传输不是一个裸的数据块而是一个带有源地址、目的地址、长度信息的“信封”第二它提供了流控机制通过vring环形缓冲和中断机制保证发送方不会把共享内存写爆接收方不会在空队列上死等。可以说RPMSG就是AMP系统里的“TCP/IP协议栈”它把共享内存的原始读写能力包装成了一个可靠、易用的消息通信服务。2. RPMSG原理拆解从共享内存到消息信封2.1 vring共享内存上的环形队列RPMSG消息传递的地基是vring。vring这个名词来源于virtio标准原本用于虚拟机的虚拟IO设备通信后来被OpenAMP借鉴到核间通信场景。它的核心思路是在同一块共享内存上维护一个环形缓冲区队列发送方和接收方通过读写这个队列来描述“我要发数据”和“我把数据处理完了”。一个vring由两个环形队列组成——available ring和used ring。以一次数据发送为例发送方先在共享内存的buffer区域找到一个空闲的描述符把数据填进去然后把描述符的索引写到available ring的尾部更新写指针紧接着发送一个硬件中断kick通知接收方。接收方从available ring的头部取出描述符索引根据索引找到对应的buffer读出数据处理完之后把描述符索引放回used ring再通知发送方“这个buffer我回收了你可以再次使用”。两个方向要对称地各配一套这样的机制所以完整的RPMSG通道至少要有两个vring一个用于主到从的数据传输一个用于从到主的数据传输。在这个过程中最关键的是内存屏障memory barrier。发送方写入数据后、更新available ring指针前必须保证先前的写入操作对外设或者对端CPU可见否则接收方可能拿到的是旧数据。我在实践中第一次遇到这个问题时现象是通信偶尔丢包后来反复排查才发现是代码里漏了内存屏障指令。这个问题非常隐蔽尤其在使用多级缓存的高性能CPU上简直就是埋在地下的雷。2.2 消息信封rpmsg_hdr与端点RPMSG的数据传输单位是消息message每一条消息都带一个消息头这个头就是rpmsg_hdr。结构体用C语言描述大概是这个样子struct rpmsg_hdr { uint32_t src; uint32_t dst; uint32_t reserved; uint16_t len; uint16_t flags; uint8_t data[]; } __packed;src是发送端端点的地址dst是接收端端点的地址len是data区域有效载荷的长度flags用于标记特殊类型比如确认消息。这里的src和dst不是物理地址而是逻辑端点地址类似于网络里的端口号。为什么消息头里要有src和dst呢因为实际使用中往往不只是一对一通信。一个远程核上可能同时跑着多个业务模块一个模块负责电机控制一个模块负责传感器采集还有一个模块负责故障诊断。如果所有业务都用同一个物理通道传数据接收方拿到一段裸数据根本不知道应该交给谁。RPMSG的解决办法就是在消息头里带上端点地址接收方收到消息后查看src字段把它路由到对应的端点上触发该端点注册的回调函数。打个比方vring相当于一条物理光纤RPMSG通道相当于这条光纤上的逻辑链路而端点就是链路上的服务端口。应用层创建端点时绑定一个回调函数之后凡是目标地址指向这个端点的消息到达系统就会自动调用这个回调函数开发者只需要在回调里处理数据即可。这种设计把底层繁琐的描述符分配、ring管理、中断处理全部隐藏起来应用层代码清爽很多。2.3 通道建立名字服务机制有了消息格式和端点接下来要解决一个更实际的问题两端如何约定通信地址比如裸机端说“我想用端点地址0x80和Linux端通信”Linux端怎么知道这件事RPMSG里面有两种通道建立方式。第一种是本地先创建通道然后通过名字服务Name ServiceNS通告对方。具体来说裸机端调用rpmsg_create_ept时除了指定端点地址还要提供一个服务名比如“rpmsg-client-sample”。创建成功后系统会自动发一条特殊的NS消息给对端这条消息的内容包含服务名和端点地址。对端收到NS消息后会根据服务名去匹配自己这边的设备或者应用匹配成功就建立起一条逻辑通道之后两端就能互发消息了。第二种方式是远端发起请求。Linux端可以通过rpmsg_create_ept创建一个端点同样带服务名裸机端收到NS通知后会检查自己是否有同名服务如果有就建立通道两边对接。名字服务这个机制大大简化了多服务并存时的地址管理你不再需要手动约定每个任务用哪个端点地址了只需要约定服务名就可以。它的原理很类似DNS只是比DNS简单得多本质就是一条特殊格式的RPMSG消息。通道建立好之后这个NS通道其实还是存在的只是业务用户通常不会直接感知。3. 实操在AMP项目里跑通RPMSG3.1 前置准备内存布局和中断配置理论部分讲多了容易飘回到实际项目里要想把RPMSG跑起来第一步不是写代码而是把硬件资源分配清楚。这里我以一个典型的Cortex-A跑Linux、Cortex-M跑裸机的AMP项目为例说说踩过的配置步骤。内存布局是首要任务。Linux侧需要在设备树中预留一段物理内存给共享区域使用这段内存要同时能被两个核心访问并且建议不要被Linux内核的常规内存管理回收。你会在设备树里看到类似reserved-memory节点里面定义了一个vdev0buffer区域用于放置vring描述符和消息buffer还有两个分别用于双向传输的vring区域。reserved-memory { #address-cells 2; #size-cells 2; ranges; vdev0buffer90000000 { compatible shared-dma-pool; reg 0x0 0x90000000 0x0 0x100000; no-map; }; vring090100000 { compatible shared-dma-pool; reg 0x0 0x90100000 0x0 0x10000; no-map; }; vring190200000 { compatible shared-dma-pool; reg 0x0 0x90200000 0x0 0x10000; no-map; }; };注意no-map参数它可以防止Linux内核在初始化时把这段物理内存映射到内核地址空间里避免两套内存管理逻辑打架。然后是中断配置Linux侧通过remoteproc设备树节点里的mboxes属性或者interrupt-parentinterrupts属性把核间中断通道和mailbox通道配置好。这是RPMSG的“门铃”没有它对端发来数据、共享内存里的状态变了本地核心根本不知道消息就只能烂在缓冲区里。裸机侧同样要知道共享内存区域的物理地址和中断号。这些参数通常通过一个平台头文件定义在交叉编译裸机固件时编译进去。如果两边对共享内存地址的认知不一致后面通信必然失败调试时第一件事就应该核对这块地址两边是否写的一样。3.2 裸机侧的初始化、收发代码流程裸机侧使用librpmsg整体初始化流程非常清晰整理成代码片段供参考#include metal/device.h #include metal/uart.h #include openamp/open_amp.h static struct metal_device *shm_dev; static struct rpmsg_virtio_device rvdev; static struct rpmsg_device *rdev; // 接收回调远端发来的消息会到这里 static void rpmsg_read_cb(struct rpmsg_endpoint *ept, void *data, size_t len, uint32_t src, void *priv) { /* 处理收到的数据比如通过串口打印或者控制某个外设 */ } void app_main(void) { // 1. 打开共享内存设备 metal_device_open(generic, shm, shm_dev); // 2. 初始化vring绑定中断 struct metal_io_region *io metal_device_io_region(shm_dev, 0); rpmsg_virtio_init(rvdev, 0, 0, 0, io, io, NULL, NULL, NULL); // 3. 获取rpmsg设备对象 rdev rpmsg_virtio_get_rpmsg_device(rvdev); // 4. 创建端点指定服务名注册回调 struct rpmsg_endpoint ept; rpmsg_create_ept(ept, rdev, my-service, RPMSG_ADDR_ANY, RPMSG_ADDR_ANY, rpmsg_read_cb, NULL); // 5. 主循环轮询接收事件也可以在中断中处理 while (1) { rpmsg_virtio_poll(rvdev, 0); } }发送方要发数据时调用rpmsg_sendconst char *msg hello from M core; int ret rpmsg_send(ept, msg, strlen(msg) 1); if (ret 0) { /* 处理发送失败 */ }这里有几个容易踩的坑。第一rpmsg_send是阻塞式的如果发送缓冲区满了它可能会一直等所以不要在一个对实时性要求苛刻的中断上下文中直接调用它最好在任务上下文或者单独线程里发送。第二创建端点时传入的RPMSG_ADDR_ANY表示让系统自动分配端点地址如果对端是Linux它会在NS消息里感知到你分配的这个地址。第三回调函数的执行上下文要弄清楚到底是中断上下文还是任务上下文这决定了你在回调里能不能调用阻塞操作。不同平台实现有差异务必在读文档时搞清楚。裸机侧轮询的写法是最简单的实际项目中为了效率我更喜欢把rpmsg_virtio_poll挂到核间中断里执行这样只要有消息到达CPU立刻从低功耗状态唤醒处理不需要空转浪费功耗。3.3 Linux主核侧收发与端到端验证Linux侧的链路稍微复杂一点因为REMOTEPROC、RPMSG总线、字符设备驱动是层层叠起来的。流程大概是先把编译好的远程固件放入文件系统加载remoteproc驱动把固件推给远程核远程核启动后RPMSG总线会注册一个rpmsg_ctrl设备应用层可以在/dev/rpmsg_ctrl0上操作当裸机端创建了带服务名的端点Linux侧会根据服务名匹配到对应的RPMSG设备并在/sys/class/rpmsg/rpmsg0等路径下创建设备节点。最简单的验证方法是用自带的echo测试。先把裸机端的固件烧进去让它运行一个loopback服务——收到什么消息就原样发回什么。Linux侧这样做# 启动远程处理器 echo start /sys/class/remoteproc/remoteproc0/state # 查看rpmsg设备 ls /sys/class/rpmsg/ # 打开rpmsg字符设备发送测试数据 echo hello from A core /dev/rpmsg_ctrl0 cat /dev/rpmsg_ctrl0如果配置正确cat会打印出远程核回传的“hello from A core”。这个loopback跑通说明整条链路已经通了剩下的就是业务改造。要提醒的是不同厂商SDK在设备节点路径和方式上可能有差异有的用/dev/rpmsg_ctrl0有的用/dev/rpmsg0还有的需要先在/sys/class/remoteproc写固件名不要照着网上教程盲目敲命令先查看自己板子上的节点名。我自己在实际项目里第一次跑通这个流程Linux向裸机端发了一个“start audio”命令然后裸机端回传一包音频数据那一刻感觉整个世界都通了。后面再做数据流的设计、缓存一致性优化就有了抓手。4. 共享内存的真正痛点缓存一致性与性能调优4.1 缓存一致性问题与处理方案RPMSG的消息传输跑起来了但如果你直接拿代码去量产大概率会遇到一些时有时无的诡异问题比如数据偶尔是旧的、偶尔是乱的。这些问题十有八九都指向缓存一致性。CPUs在访问DDR内存时并不是直接读物理内存而是先经过L1/L2缓存。两个CPU核心各自有独立的缓存就可能出现这种场景A核心往共享内存地址0x90000000写了数据数据先停留在A核的L2 cache里还没有真正写回DDRB核心从同一个地址去读读到的仍然是DDR里面的旧数据于是通信就出现了“丢包”或者“陈旧数据”的现象。解决这个问题有三种常见手段。第一种最粗暴也最可靠把共享内存区域配置成非缓存、非缓冲属性。在Linux设备树中用no-map保留的地址如果用ioremap_wc或者dma_alloc_coherent来映射系统会保证读写直接落到DDR绕过缓存。裸机侧也类似在MMU页表配置中把这部分内存的属性设为Device或者Strongly-ordered。这种方案的缺陷是访问速度慢因为每次读写都直通DDR但在核间通信这种低频控制消息场景里完全够用。第二种就是主动刷缓存。发送方写完数据后显式执行flush操作把缓存内容写回DDR接收方在读数据前显式执行invalidate操作让缓存失效重新从DDR拉最新内容。RPMSG底层早就封装了这些操作比如libmetal的metal_cache_flush和metal_cache_invalidate很多平台在vring初始化时会自动handle但在业务自定义共享数据区时你得自己加。第三种是使用硬件缓存一致性协议比如arm的CCI/CMN总线或者SoC自带的一致性扩展。这样做性能最好但也不是所有平台都支持配置也复杂实际项目的运用并不像前两种那样普及。我在绝大多数项目里用的都是第一种和第二种混合先把大块数据放非缓存区将高频小消息放到可缓存的共享区并手动维护flush。4.2 内存对齐、分配与生命周期管理共享内存区域的分配和生命周期管理是另一个大坑。RPMSG的vring对内存对齐有一定要求——描述符表和buffer区域都要求按4字节甚至64字节对齐。如果裸机侧malloc出来的地址没有对齐vring初始化可能会失败或者在传输过程中产生奇怪的硬fault。我在代码里一般会先定义一个专用的内存池或者直接使用静态数组并加上对齐属性#define SHM_SIZE (8 * 1024) __attribute__((aligned(16))) static char shm_pool[SHM_SIZE];然后把这个shm_pool的地址传给RPMSG库。别图省事用通用malloc堆碎片和不对齐问题会把你的调试时间无限拉长。生命周期管理更麻烦。共享内存里面不仅有vring描述符还有消息Buffer。消息Buffer的分配和回收直接影响发送失败率和内存碎片。RPMSG内部使用事务式的buffer管理——发送时会借用vring里的一个buffer消息被确认处理后buffer会归还到池子。如果对端处理速度慢buffer池告急rpmsg_send就会返回资源不足错误。看着像是共享内存大小不够本质是背压处理不足。调优的时候vdev0buffer区域的大小直接影响并发吞吐太小则高负载时发送失败太大则浪费DDR空间并且可能造成缓存污染。我曾经在音频项目里把每个buffer从512字节调到2KB并发消息数量立刻上去了音频中断导致的丢包问题也随之解决。所以Buffer大小并没有标准值一定要针对实际消息长度分布做统计分析再做配置。4.3 性能调优vring数量、buffer大小、批量收发把RPMSG跑通只是开始真正让它在项目中发挥作用还要做一轮性能优化。三个方面最值得花时间。首先是vring数量的选择。RPMSG通道可以配置多对vring每对负责不同的优先级和方向。比如控制命令走一对高优先级的vring大数据块走另一对低优先级的vring这样可以避免大数据块阻塞控制命令。有些平台甚至支持中断屏蔽、轮询模式切换高实时场景下可以直接让接收侧轮询vring不依赖中断延迟反而更低。其次是单次消息大小的选择。RPMSG是面向消息的协议理论上单条消息长度可以很大但长消息会占用多个vring buffer增加传输时间和出错概率。我在项目里的习惯是把超过1MB的数据拆分成多个小消息接收端重组。这样既不会压垮buffer池也方便在传输过程中插入进度检查和流控。最后是批量收发。如果业务数据是高频小包比如每毫秒产生一个传感器读数逐个调用rpmsg_send会产生大量中断开销。可以把多个读数拼成一个结构体数组一次发送整包接收端解包后分发给不同业务逻辑。我调过的电机控制系统就是这么干的——原来1kHz的控制频率通知每次都单独发消息CPU占用率居高不下拼包后一次中断处理10个消息样本CPU占用直接降了40%。调优不是把参数改大就完事要先看瓶颈在中断、DDR带宽还是CPU处理再用perf、trace工具验证。5. 常见问题速查表与调试技巧5.1 通信失败排查手册我把实际项目里遇到过的RPMSG通信问题整理成一张速查表方便大家按图索骥。症状可能原因排查手段消息完全收不到共享内存物理地址两端不一致核对设备树reserved-memory与裸机头文件地址消息偶尔丢失或乱码缓存一致性问题检查共享内存是否映射为non-cache或者手动flush/invalidate发送时rpmsg_send卡死对端没有启动/等待超时确认对端remoteproc已startvring poll正在运行接收侧死机缓冲区越界、对齐不对检查buffer大小设置、描述符内存是否被其他模块踩踏只有一端能通端点地址不匹配或名字服务未生效打印端点地址核对NS消息内容高负载时消息延迟变大buffer池耗尽、中断风暴增大vdev0buffer、调整消息大小、考虑批量收发这里要特别提一下很多“收不到”的问题实际上是“地址不对”。尤其裸机侧代码里用的是地址而Linux侧传的是物理地址有时候中间人转换出错导致两边看到的共享内存并不是同一块物理区域。我调试时会专门写一段代码让两端往共享内存固定偏移写一个魔数然后读出来比对这样可以快速验证地址映射是否正确。另一个隐蔽问题是字节序。有些SoC的DSP核心和ARM核心字节序不同或者用DMA读取消息时字节序翻转明明逻辑正确但是数据在内存里被解释成大端/小端反了。网络调试时看到英文文本变乱码就优先查这个。5.2 调试工具和方法RPMSG调试比普通单核程序调试要复杂因为出问题的时候两个核心可能已经互相干扰了。我常用的调试方法有三种。第一种是日志输出配合时间戳。在Linux端用ftrace或者printk带时间戳在裸机端用串口或者环形日志缓冲区输出同样带时间戳的记录。两边日志对齐后可以精确判断消息从一端发出到另一端收到用了多长时间也能定位消息是在发送队列里卡住还是在接收回调里处理超时。第二种是使用JTAG调试器直接在共享内存地址设置硬件断点或者watchpoint。当一端写入共享内存时另一端CPU会立即停下来实时观察数据被谁改写了。这个方法在排查内存越界踩踏、指针被篡改的问题时非常高效。第三种特别实用是搭一个“监控通道”。在共享内存区域旁边单独开辟一小块状态区两端各自周期性更新状态字和序列号把通信状态、buffer占用、最近一次错误码都写到里面去。主核Linux侧可以通过一个简单的用户态工具读取这块状态区实时监控远端是否存活、通信是否健康。这在量产现场排障时几乎是救命稻草一上来就能判断是应用层逻辑问题还是底层通信问题不用抓瞎。总结下来RPMSG和共享内存这套方案我已经用了很多年从最初只会在例程基础上改名字到现在能够独立设计双核通信协议最大的体会是一定要把底下的物理资源——共享内存、中断、缓存——想清楚再往上面套协议。协议层再怎么优雅底层内存和打断链路不稳都是白搭。先做通loopback再做业务再优化路就会顺很多。
返回列表