嵌入式产品的功能越来越复杂,一颗 SoC 内部往往要同时承担业务计算、实时控制、音视频处理和电源管理等工作。不同任务对性能、实时性和功耗的要求各不相同,处理器也随之有了不同的分工。
应用处理器比如 Arm Cortex-A,可以运行 Linux,负责复杂业务和系统资源管理;而 Cortex-M、Cortex-R 等处理器则可以运行 RTOS 或裸机程序,处理那些对响应及时性要求较高的任务。它们各自运行一套软件,按照不同的要求完成自己的工作。
这些工作最终要配合起来,才能实现产品需要的功能,因此处理器之间还存在大量信息往来。一侧需要把控制任务和相关数据交给另一侧,另一侧处理完后,也要把执行结果或异常状态反馈回来。当一项功能跨越了不同的软件环境,原来程序内部的一次调用,就可能变成向另一个处理器发送请求、等待对方处理、再接收结果的过程。要让这样的协作顺利进行,就必须解决消息和数据如何在不同处理器之间传递的问题。
1. 核间通信的对象与硬件基础
不同处理器之间交换消息和数据,就是我们这里要讲的核间通信,通常简称 IPC,全称是 Inter-Processor Communication。Linux 的进程间通信也缩写为 IPC,读资料时需要结合上下文加以区分。
这里我们重点看分别运行 Linux、RTOS 或裸机程序的处理器。它们虽然在同一颗 SoC 里,却各自执行自己的代码。一个程序要请另一侧帮忙完成任务,就需要把命令和相关数据传过去。如果几个 CPU 核由同一个操作系统管理,调度和内存等工作已经由系统统一安排,运行在不同核上的程序也可以直接使用系统提供的通信机制。
然而分别运行两套软件时,Linux 里的程序不能拿着一个函数地址,就让 RTOS 去执行它。双方需要先约定好消息怎么写、放在哪里、收到后由谁处理,再按这个约定交换信息。
处理器通过片上总线访问内存和外设,如果存在一块双方都能访问的区域,一侧就可以把数据写进去,供另一侧读取。数据准备好后,还需要通知接收方,让它知道现在可以开始读取和处理了。下面这张图把访问路径和中断路径放在了一起:

图 1:实线表示总线连接,虚线表示中断通知。双方通过总线访问可共享的寄存器和内存,中断则用来通知处理器开始处理。
即使能够访问同一块内存,接收方也不会自动知道发送方什么时候写好了数据、这次应该读哪一段,这些信息仍需要双方通过约定的状态来表达。接收方可以反复检查状态,也就是轮询,但这样需要一直花时间查看有没有新消息。如果希望数据准备好后再提醒对方,就需要一种能由发送方触发、向接收方产生中断的硬件。
2. Mailbox 的通道与消息收发
为了在消息就绪时通知对端,SoC 中通常会集成用于核间通信的 Mailbox 硬件。Mailbox 翻译成中文就是“邮箱”,顾名思义,就是用来发消息的。Mailbox 有多种实现,例如 Arm 的 PL320、MHU,以及芯片厂商自行设计的控制器。它们提供中断通知能力,有些还带有保存短消息的数据寄存器,具体通道数量、寄存器布局和收发方式各有不同。
这里我们选择 Arm PL320 作为典型例子,来讲解 Mailbox 怎样通过寄存器和中断完成消息交接。PL320 的完整名称是 PrimeCell Inter-Processor Communications Module,属于 Arm PrimeCell 系列的片上外设 IP,可集成到 SoC 中供不同处理器通信。处理器通过 AHB 总线访问 PL320 的寄存器,它最多支持 32 个 Mailbox,每个 Mailbox 有各自的控制寄存器,还可以带有数据寄存器。具体集成多少个 Mailbox、配多少个数据寄存器,在芯片设计时就已经确定。具体可参考官方手册:Arm PL320 技术参考手册。
以一条从 A 核发往 R 核的短消息为例,发送方需要把消息放到对端能够读取的位置,再通知对端取走。接收方读完后,还要反馈确认,让发送方知道这次交接已经完成。在 PL320 中,这条消息就保存在 Mailbox 的数据寄存器里,发送通知和接收确认也由软件操作相应寄存器来触发。
要看懂双方怎样完成交接,我们先看看一条通道包含哪些寄存器,再沿着一次收发过程看它们怎样配合。
2.1 通道中的配置、数据与控制
为了让 A 核写入的消息能够被 R 核取到,双方需要使用同一个 Mailbox,围绕 PL320 为它提供的那组寄存器完成收发。软件把这一组资源作为一条消息通道,并为它配置发送方、接收方和中断。其中,来源和目标配置决定由谁发送、通知谁,中断屏蔽配置决定相应通知能否产生中断。要传的命令和参数放进数据寄存器,什么时候发出通知、什么时候反馈确认,则由 SEND 寄存器控制。
在 Linux 的 PL320 IPC 驱动所采用的配置中,每条消息使用 DR0 到 DR6 共七个 32 位数据寄存器,合起来能放 28 字节。传一个命令编号、几个参数或一个状态值,这样的空间就足够了。有了这些配置,A 核就可以把命令和参数写进约定的数据寄存器,再通过控制寄存器发出通知,PL320 则根据通道配置找到需要通知的 R 核。通道把存放消息的位置和通知对象对应起来,双方才能围绕同一条消息进行操作。
2.2 一次消息的发送、接收与确认
通道配置好以后,发送方就可以使用它传递消息,但写入数据和发出通知有先后要求:必须先把消息写完整,再通知对方来读。接收方取走消息后也需要反馈确认,发送方才能知道这次交接已经完成。
下面采用接收方读取后主动确认的方式,沿图中的 Channel 1 看 A 核到 R 核的一次交接。Channel 2 用于反向发送,过程相同。实线表示软件访问寄存器,虚线表示硬件产生中断。

图 2:每条通道各有发送、接收通知、确认和完成通知四个动作。数字在两条通道中分别从 1 开始。
A 核先将消息写入 Channel 1 的数据寄存器,再写 SEND 触发发送。Mailbox 根据这条通道的目标和中断配置,向 R 核发出通知。R 核进入中断处理,读取同一条通道的数据寄存器,取得 A 核写入的消息。读取完成后,R 核写 SEND 反馈确认,硬件再向 A 核产生完成通知。这样,A 核就知道 R 核已经按约定取走了这条消息。
R 核取走消息以后,可能还需要一段时间才能做完消息要求的事情,因此通道确认只能说明消息已经按约定交接,不能说明业务任务已经完成。如果 A 核还需要知道任务处理得怎么样,就要等待 R 核另外返回业务响应。
R 核收到一个耗时任务时,可以先取走消息、完成通道确认,再去执行任务。任务完成以后,它通过反向通道发送结果,这是另一条消息。驱动等到发送完成通知后,就可以继续处理这条通道上的发送工作,而等待任务结果的业务程序还要继续等响应。两者等待的对象不同,调试时也需要分别判断。
3. Mailbox 与共享内存
前面我们把短命令和少量参数直接放进 Mailbox 的数据寄存器,但如果要传的数据超过了寄存器容量,双方就得拆成多次发送,再把收到的内容重新拼起来。对于都能访问同一块内存的处理器,还可以把完整数据放到共享内存中,避免为了几个寄存器的容量反复拆分。发送方把数据写进共享内存后,只需要在 Mailbox 中放入位置、长度等少量信息,再通知对端来读。这样,数据由共享内存保存,Mailbox 用来传递少量控制信息并发出通知,可传数据的大小就不再受那几个数据寄存器的容量限制。
3.1 数据与通知分开传递
把数据和通知分开以后,接收方需要根据通知里的信息找到共享区中的数据。发送方先把数据写入共享缓冲区,再用 Mailbox 告诉对方数据在哪里、有多长,接收方收到中断后,就按双方约定的位置读取。

图 3:上方是共享缓冲区的数据交接,下方是 Mailbox 通知及通道确认。共享缓冲区的释放时机还需要由协议规定。
Linux 和 RTOS 可能把同一块物理内存映射到不同的地址,因此 Linux 中的指针值传给 RTOS,未必能让它找到同一块数据。双方通常约定相对共享区的偏移,再分别加上各自看到的共享区起始地址,就能找到对应的数据。找到正确的位置以后,还要保证读到的是发送方刚写入的内容。如果两侧缓存不保持一致,新的数据可能还留在发送方的缓存里,对端从内存中读到的仍是旧内容,需要驱动按平台要求做好缓存处理。
数据写入和通知发出的顺序也会影响接收结果,发送方应先让数据对接收方可见,再告诉它来读。如果通知已经到达,数据却还没有准备好,接收方即使找对了位置,也可能读不到这次发送的内容。
接收方开始读取以后,发送方还不能马上用下一条消息覆盖这块缓冲区,因为接收程序可能仍在使用里面的数据。即使 Mailbox 通道已经确认,发送方也要等到双方约定的缓冲区释放条件满足,才能重新写入。因此,接收方需要在不再使用这块数据后归还缓冲区,让发送方知道这块空间可以再次使用。双方除了约定怎样找到和读取数据,也要约定怎样反馈这个释放状态,才能避免新旧消息相互覆盖。
3.2 连续消息需要缓冲池和队列
如果每次只发一条消息,发送方等对方读完再写通常还能接受,但连续发送时,一个共享缓冲区就会让双方互相等待。上一条消息还没读完,下一条就没有地方放,直接覆盖又会破坏对方正在读取的数据。为了让发送方继续准备后面的消息,我们可以多放几块缓冲区,让新消息先写到空闲的位置。
不过,缓冲区一多,双方还得记清楚哪些装了新消息、应该先处理哪一块、哪些已经用完,仅靠一个“有新消息”的中断已经无法表达这些状态。这时可以用队列记录待处理的消息:发送方从缓冲池里取一块空闲缓冲区,写好数据,再把它的位置和长度等信息放进队列,接收方就能沿着队列逐条找到需要处理的数据。接收方取出一条记录后,按照其中的位置找到缓冲区,读取并处理数据,用完后再归还。缓冲池因此可以反复提供存放消息的空间,队列则让双方知道哪些数据已经准备好、接下来应该处理什么。

图 4:一次发送包含写数据、提交描述、通知对端三个阶段,接收侧按描述读取数据。图中省略了缓冲区回收路径。
图中的 IPCF 实现将缓冲池编号、缓冲区编号和数据长度保存在队列记录中,发送方提交记录后,由平台对应的驱动通知对端检查队列,接收方再根据记录找到数据。不同平台的通知硬件可以不同,缓冲池和队列仍然按这个分工配合。
接收方被中断唤起后,需要检查队列里有哪些消息等待处理,因为一次中断到达时,队列中可能已经积累了多条记录,不能把一次中断直接当作一条消息。如果缓冲区都被占用了,发送方就需要等待空闲空间,或者告诉调用者暂时发不出去。双方按照队列和缓冲池记录的状态继续收发,才能在连续传送时避免覆盖还没处理完的数据。
4. RPMsg 的消息与传输组织
前面我们已经能把数据送到另一个核,也能用缓冲区和队列连续传送,但业务往往还需要把请求交给另一端的某个程序,等它处理后再拿到结果。对发起请求的程序来说,数据到达对端内存只是过程中的一步,还得有对应的程序接收和处理,才算真正建立起两端的通信。
当多个业务共用一条核间链路时,接收侧需要判断每条消息应该交给谁,返回结果时也要知道发回哪里。如果各个业务都自己处理寄存器、队列和缓冲区,它们还得协调这些公共资源的使用,避免彼此干扰。Mailbox 硬件能保存短消息、产生中断,却不知道消息属于哪个业务,也不会替业务找到接收程序。更复杂的业务需要一套更完整的软件机制,把一端程序的发送,连接到另一端程序的接收和处理。
这些软件功能可以自己实现,也可以交给现成的消息框架,让业务通过统一接口发送和接收数据。RPMsg 就是 Linux 中常见的一种选择,它在底层通信能力之上,为不同业务提供消息寻址和分发。
4.1 从传输机制到业务接口
使用这样的消息框架以后,业务程序就可以通过发送接口指定接收对象,接收端则注册处理消息的函数,由框架在收到消息后找到对应入口。RPMsg 的全称是 Remote Processor Messaging,提供的正是这套收发接口和消息分发机制。业务因此不需要每次发送消息都自己操作寄存器、管理共享缓冲区,再判断收到的数据该交给谁,而是把这些公共工作交给框架及其底层驱动。
为了把消息实际送到另一个处理器,RPMsg 仍然需要底层的数据传递和通知机制,所以 Mailbox 和 RPMsg 可以出现在同一条通信路径上。下面这张图采用常见的 VirtIO RPMsg 实现,消息放在共享内存里,VirtIO 队列管理消息缓冲区,Mailbox 提醒对端来检查队列。

图 5:业务接口向下进入 RPMsg 和 VirtIO 队列,数据落在共享内存,队列通知经平台驱动到达 Mailbox。remoteproc 位于侧面,参与远端运行管理及平台通知。
图中的用户程序通过字符设备的读写接口使用 RPMsg,内核里的业务驱动也可以直接调用 RPMsg 接口。对业务来说,重点是能够发送和接收消息,底层的寄存器和队列操作交给相应驱动完成。图中还有一个 remoteproc,负责远端处理器的启动、停止等运行管理,并配合准备通信资源。远端软件和通信资源就绪以后,上层业务才能通过图中的接口,使用下面的消息传输和硬件通知能力。
4.2 消息头与 Endpoint 寻址
业务通过发送接口指定了接收对象,接收侧也需要从消息里找到这个信息,才能把数据交给对应程序。常见的 VirtIO RPMsg 实现会在业务数据前面加一个消息头,把发送方、接收方和数据长度一起传过去。

图 6:消息头共 16 字节,src、dst、reserved 各占 4 字节,len 和 flags 各占 2 字节,后面紧跟业务数据。
消息头里的 src 表示从哪个端点发出,dst 表示发给哪个端点,len 表示后面的业务数据有多长。这些端点地址是软件用来区分收发对象的编号,和数据存放的共享内存地址用途不同:一个用来找到接收者,另一个用来找到数据。
这里的 Endpoint,也就是端点,是业务接收消息的入口,业务会把一个端点地址和自己的接收函数关联起来。Linux 收到消息后,根据 dst 找到这个入口,再把数据交给对应函数,同一条核间链路上的消息就能分别送到不同业务。对方要返回结果时,通常就把请求中的 src 当作响应的目的地址,让响应沿着同样的分发过程回到发起请求的一侧。
至于这条消息要求做什么、对应哪一次请求、任务是否成功,还需要双方约定业务数据的格式,例如在数据里放入命令号、请求序号和处理结果。RPMsg 把消息送到对应入口,业务程序再按这个格式理解和处理内容。
4.3 Channel 与远端服务接入
有了端点地址,Linux 就能把收到的消息交给对应的接收函数。不过,在开始收发之前,它还得知道远端提供了什么服务,并找到能够处理这个服务的驱动。服务接入以后,驱动才能建立相应的接收入口,与远端通信。
如果双方启用了 RPMsg 的名称服务,远端可以先发一条声明,告诉 Linux 自己的服务名和地址。Linux 据此创建一个 rpmsg_device,再匹配相应驱动。驱动接入后,就可以通过端点与这个远端服务收发消息。RPMsg 中的这种服务通信关系通常称为 Channel,对应软件里的服务,和前面 PL320 中由寄存器组成的硬件通道含义不同。多个 RPMsg 服务可以共用底层队列和通知通道,收到消息后再按端点地址分给各自的业务。
因此,服务名和端点地址用在不同的阶段:服务接入时,Linux 根据服务名匹配驱动,开始通信以后,再根据每条消息中的端点地址找到接收函数。先有处理这类服务的驱动,再有具体的消息分发,业务才能持续收发。
4.4 Virtqueue 中的消息交接
前面讲清了消息发给谁、收到后交给哪个函数,实际传送时还需要把装有消息的缓冲区交给对端,并在用完后收回来。我们在连续发送时用到的队列,就负责记录这些交接状态,在常见的 VirtIO RPMsg 实现里,这项工作由 Virtqueue 完成。
Linux 发送时,先取一个空闲缓冲区,填好 RPMsg 消息头和业务数据,再把这块缓冲区交给发送队列。队列中的描述符记录数据的位置和长度,远端从可用环里得知有新缓冲区,再按描述符找到消息。需要提醒远端时,平台驱动通过 Mailbox 等机制发出通知,让远端来检查队列。通知中不必带上完整的业务数据,因为消息内容和描述它的位置、长度等信息已经准备在共享内存里。
远端取走消息、用完缓冲区以后,会通过已用环把它交还。Linux 收到这个反馈,就能把缓冲区用于后续消息。这里归还的是一块通信缓冲区,业务任务的处理结果仍然通过响应消息返回。反方向发送也需要缓冲区,由 Linux 先把空缓冲区放进接收队列,远端取来填入消息,再通过已用环交还。Linux 读出消息并交给端点回调后,就把空出来的缓冲区重新放回队列,供远端继续发送。
业务通过 RPMsg 发送数据时,还需要了解底层实现的单条消息容量。例如,Linux v6.18 的 VirtIO RPMsg 驱动使用 512 字节消息缓冲区,减去 16 字节消息头,还能放 496 字节业务数据。在这份实现中,发送接口会把业务数据复制到消息缓冲区,再交给队列传递,所以使用共享内存并不意味着业务数据自动实现了零拷贝。如果业务要传大块数据,可以另准备一块共享数据区,再用 RPMsg 发送位置、长度和处理状态。大数据按共享内存的约定交给对方,控制消息则继续使用 RPMsg 的端点和收发接口。
5. Linux 与 RTOS 的一次完整收发
前面分别看过消息怎样找到接收对象、缓冲区怎样交接,以及 Mailbox 怎样通知对端,现在把这些动作放进一次 Linux 与 RTOS 的请求和响应中。我们从 Linux 程序发出请求开始,沿着消息经过的路径,看看 RTOS 怎样接到任务,又怎样把处理结果送回原来的程序。
5.1 收发之前需要准备什么
在 Linux 程序调用发送接口之前,两侧通信软件要先运行起来,并准备好双方约定的共享区、消息队列和端点。否则,程序即使调用了接口,也可能因为没有可用的通信资源或接收对象而无法完成发送。
Linux 中常用 remoteproc 管理远端处理器,平台驱动提供启动、停止和通知远端等操作。固件可以通过 resource table 告诉 Linux 需要哪些通信资源,框架再配合完成准备。有的平台会先在其他启动阶段运行远端固件,Linux 后续再接入它,因此具体由谁启动远端、什么时候准备通信资源,还要看平台的启动流程。检查通信是否就绪时,不能只看远端固件有没有运行,还需要确认共享区、队列和服务是否准备好。只有这些条件都满足,业务程序才有可以实际使用的收发通路。
5.2 从发送请求到收到响应
这些准备完成后,我们让 Linux 用户程序通过字符设备发出一条请求,RTOS 处理后返回结果。下面这张图把一次收发放在四条泳道里,沿着箭头就能看到数据和通知分别经过哪里。

图 7:业务数据通过共享区交接,Mailbox 通知促使对端检查队列。图中 read() 放在响应入队之后,实际也可以提前阻塞等待。
用户程序调用 write(),把请求交给字符设备驱动,驱动再调用 RPMsg 的发送接口。RPMsg 传输驱动填好消息头和数据,把缓冲区提交到队列,再按需要通知远端。RTOS 收到 Mailbox 中断后,检查队列并取出消息。接下来根据消息里的 dst 找到接收入口,把请求交给对应业务处理。业务处理完成后,RTOS 把结果放进一条响应消息,通过返回方向的队列发给 Linux。Linux 取出响应,再根据目的端点找到接收函数。字符设备驱动把收到的数据留给用户程序读取,应用最终通过 read() 拿到结果。
应用通过 write() 成功提交请求以后,仍然需要等待响应,才能判断任务是否按预期完成。发送接口成功只说明数据已经提交给发送路径,缓冲区归还则说明传输资源可以复用,这两个状态都不能代替业务处理结果。如果同时存在多条请求,应用还需要根据请求序号判断收到的响应对应哪一次调用,并约定等待超时以后怎样处理。消息框架负责收发和分发,任务的成功条件以及失败处理,仍然要由两侧业务共同约定。
调试时可以沿着这条收发路径逐步检查:发送接口报错,先查看参数和空闲缓冲区;如果队列里已经有消息、对端却没有反应,就继续检查通知是否发出;如果对端已经收到中断,却读不到新数据,就需要检查队列、地址映射和缓存处理;数据已经取出而业务没有收到时,再检查目的端点和接收函数,把问题定位到实际没有完成的那个环节。
6. 总结
只传一个短命令时,Mailbox 加上简单的软件处理就可以完成收发,数据放不进寄存器时,就需要共享内存提供更大的存放空间,连续发送又需要缓冲池和队列管理这些数据。当业务需要从一端程序向另一端程序发送请求、接收结果时,还要解决消息发给谁、收到后由谁处理,以及多个业务怎样共用通信资源的问题。RPMsg 这样的消息框架把这些公共的软件工作组织起来,供业务通过统一的接口使用。
| 通信需求 |
需要的机制 |
软件仍需明确的事情 |
| 少量命令、参数和状态 |
Mailbox 短消息或其他通知机制 |
消息格式、确认语义、业务响应 |
| 较多数据或连续数据 |
共享内存、通知、缓冲池及队列 |
地址约定、可见性、所有权和回收 |
| 多业务通过统一接口协作 |
RPMsg 等消息框架及其传输后端 |
服务接入、端点分配和业务协议 |
在一套采用 VirtIO RPMsg 的通信方案里,业务通过 RPMsg 指定接收对象,传输驱动把消息写进共享内存,再通过队列交接缓冲区,按需要借助 Mailbox 通知对端。接收侧沿着这条路径取出消息,最后根据端点地址交给对应业务,各部分因此能够共同完成一次核间通信。
看一套实际的核间通信方案时,也可以从业务要发给谁开始,顺着数据的存放位置、通知方式和接收函数往下找,再看结果怎样返回。把一次请求和响应完整地走一遍,就能理解各个硬件和软件模块在其中承担的工作。