做嵌入式这几年,凡是碰过工业控制、机器人、实时通信的项目,迟早都会面临同一个尴尬:主控芯片性能足够强,但Linux的实时性就是差点意思。尤其是在RK3568这种四核A55的平台上,跑个EtherCAT主站、运动控制算法,或者要处理微妙级的中断响应,纯Linux方案总让人心里不踏实。我这次要分享的,就是把RK3568做成一个“一芯双系统”的玩法——通过AMP(非对称多处理)架构,让Linux和RT-Thread在同一颗芯片上各司其职,Linux负责上层业务、网络、人机界面,RT-Thread去扛硬实时任务。整个过程熟练之后,从编译RT-Thread镜像到启动双系统,3分钟真的够用。这篇文章会从AMP方案设计、Linux侧remoteproc配置、RT-Thread移植,到双系统通信和调试技巧,全部串起来讲一遍,适合手里有RK3568开发板、又不想加第二颗MCU的朋友。
先说清楚一个前提,所谓“3分钟搞定”不是玄学,指的是在交叉编译工具链、Linux内核源码、RT-Thread BSP都准备好的情况下,改设备树、编镜像、加载启动这一套纯操作耗时。第一次做的人肯定要踩几个坑,花半天到一天很正常,不过把流程捋顺之后,后面就是流水线作业。
1. AMP双系统方案的整体设计思路
1.1 为什么是AMP而不是SMP
很多人一听到“四核A55跑Linux”,第一反应是SMP(对称多处理),四个核统一调度,跑一个操作系统。但做实时控制的人很快会发现,SMP模式下Linux的调度器、中断处理、自旋锁,都会带来不可控的延迟。哪怕打了PREEMPT_RT补丁,也依然绕不开内核里那些关中断的临界区。
AMP的思路则是把芯片当成“一栋楼里的两户人家”:Linux住客厅,RT-Thread住卧室,各有各的门牌号(CPU核心),各有各的厨房(内存区域),互不打扰。RK3568恰好适合这么玩,四核Cortex-A55本身就支持把不同核心分配给不同操作系统,每个核还有独立的通用定时器,中断控制器GIC也支持按核路由中断。
在这种架构下,Linux侧偶尔卡顿、跑个高负载任务,完全不影响RT-Thread那一侧的中断响应和任务切换。这就是工业现场最看重的东西:确定性。
1.2 为什么选RT-Thread作为实时核
既然要AMP,副核跑什么系统就得选型。裸机方案最简单,但写起业务逻辑来太痛苦,尤其涉及网络协议栈、文件系统、复杂状态机的时候,裸机开发效率低得让人抓狂。
RT-Thread的好处是它本身就是一个轻量级实时操作系统,调度器是优先级抢占式的,任务切换时间确定,中断响应快。更关键是生态,RT-Thread的驱动框架、设备模型、IPC机制(信号量、消息队列、事件集)都现成,而且支持POSIX接口的一大部分,从裸机迁移过来成本很低。我用的RT-Thread版本是5.x,BSP里已经带了RK3568的板级支持,包含串口驱动、GIC中断初始化、定时器驱动,省了最底层的移植功夫。
另外还有一个很现实的因素:成本。RT-Thread开源免费,商用也没有授权费,这对产品落地很重要。你要是用QNX或者VxWorks,光授权费就能让老板脸色变三变。
1.3 核与内存资源怎么分配
RK3568一共四个核,我采用的是Linux占CPU0和CPU1,RT-Thread占CPU2和CPU3。为什么不是Linux占3个核、RT-Thread只占1个?因为实时控制任务通常有多个:EtherCAT周期通信一个任务、运动规划一个任务、安全逻辑一个任务,都堆在一个核上虽然也能跑,但耦合度高,调试起来头大。两个核给RT-Thread,一个管通信,一个管算法,逻辑清晰。
内存划分上,DDR总共4GB,我给RT-Thread预留了64MB,在0x10000000起始地址。这个地址不是拍脑袋定的,需要考虑Linux内核/rootfs的内存布局,预留区域必须落在Linux不会乱碰的区域。常见做法是在设备树里用reserved-memory声明,Linux内核会自动将这个区域排除出页表管理。
外设分配就更讲究了。EtherCAT从站控制器(ESC)、PWM输出、编码器接口这些实时性要求高的外设,统统挂在RT-Thread那一侧。而显示、USB、网卡、WiFi这些业务相关外设,继续留在Linux侧。
| 资源类型 | Linux侧 | RT-Thread侧 |
|---|---|---|
| CPU核心 | CPU0、CPU1 | CPU2、CPU3 |
| 内存 | 其余DDR | 0x10000000起的64MB |
| 定时器 | 通用定时器 | 各核私有定时器 |
| 典型外设 | 显示、USB、网卡、WiFi | EtherCAT、PWM、编码器 |
| 主要任务 | 人机界面、网络服务、数据存储 | 实时通信、运动控制、安全逻辑 |
这样划分之后,两个系统几乎没有资源竞争,实时核的每一个中断都有确定的响应路径。
2. 环境准备与镜像编译
2.1 硬件与软件清单
先说硬件,我手上的板子是正点原子的ATK-DLRK3568,核心板加底板的组合,不过这套流程在野火、飞凌这些RK3568板卡上也都通用,区别只在于设备树里的串口、GPIO编号不同。
核心硬件清单就四样:
- RK3568开发板一套(含电源、网线)
- USB转串口模块两个(双系统独立日志必备,后面调试技巧会讲)
- Ubuntu 20.04/22.04主机一台,用于交叉编译
- SD卡或USB烧录工具,用于更新Linux侧rootfs
软件侧需要准备的东西多一些,我列个清单:
- RK3568 Linux SDK(包含内核源码、设备树、根文件系统构建工具)
- aarch64-linux-gnu交叉编译工具链
- RT-Thread源码,建议直接git clone官方仓库
- RT-Thread env工具或scons构建工具
2.2 RT-Thread BSP编译的若干关键点
RT-Thread的RK3568 BSP在源码bsp/rockchip目录下,不同版本目录名可能有差异,我用的版本里是直接支持RK3568的。进入BSP目录,先用menuconfig做配置,这一步有讲究,不能一路默认。
需要特别确认的是“AMP支持”这个配置项。RT-Thread的RK3568 BSP默认可能是跑在全部核心上的SMP版本,做AMP时要切到单核/双核启动模式,让RT-Thread不被Linux的启动流程干扰。
// menuconfig中需要确认的配置 RT-Thread Kernel → [*] Enable SMP (2) The number of cores [*] AMP mode support这里有个坑,如果RT-Thread跑双核,那么RT-Thread自身的SMP也要使能,并且要确认它不会去启动CPU0和CPU1——那两个核归Linux管。BSP里的启动汇编代码通常会有CPU ID判断,只有CPU2和CPU3才进入RT-Thread的启动流程,这个逻辑在board/startup相关代码里。
配置完成后执行scons编译,产物是rtthread.elf,这个elf文件就是之后交给Linux remoteproc加载的镜像。注意不要编译成bin格式,elf里包含了加载地址和入口地址信息,remoteproc框架能自动解析。
2.3 Linux内核配置
Linux侧需要确保以下内核选项打开,否则remoteproc和rpmsg都会缺东西:
CONFIG_REMOTEPROC=y CONFIG_ROCKCHIP_REMOTEPROC=y CONFIG_RPMSG_VIRTIO=y CONFIG_RPMSG_CHAR=yCONFIG_REMOTEPROC是remoteproc框架的总开关,ROCKCHIP_REMOTEPROC是瑞芯微平台的实现驱动,RPMSG_VIRTIO则是基于virtio的核间通信层。这几个选项通常在rockchip默认内核里已经开启,但如果你的内核是裁剪过的,一定要回头确认。
另外还有一个容易被忽略的选项:CONFIG_HOTPLUG_CPU,如果这个开关打开,Linux会在系统初始化时把所有核都纳入CPU管理,即使设备树里标记了某些核为disabled,系统也可能通过cpu_up把它拉起来,导致和remoteproc抢核。我做AMP时直接在内核配置里把这个选项关了。
3. Linux侧AMP框架与设备树配置
3.1 remoteproc框架到底干了什么
remoteproc是Linux内核里管理远程处理器的标准框架,它的核心工作可以概括成四件事:把固件(就是RT-Thread的rtthread.elf)加载到指定内存;把目标CPU核从内核调度中摘除并启动到固件入口地址;管理远程处理器的生命周期,比如启动、停止、异常恢复;为上层提供RPMsg通信通道。
这个过程比较像给电脑插U盘,内核自动挂载文件系统。你只需要往/sys/class/remoteproc/remoteproc0/state这个虚拟文件里写start,remoteproc驱动就会自动完成从解析elf到启动RT-Thread的全流程。
3.2 设备树配置实录
设备树是ARM平台开发绕不过去的一环,AMP方案的成败有一半在这里。我的RK3568设备树里,核心是三个节点:预留内存、remoteproc设备、mailbox邮箱。
预留内存的写法如下,这段要放在root节点下:
reserved-memory { #address-cells = <2>; #size-cells = <2>; ranges; rproc_reserved: rproc@10000000 { no-map; reg = <0x0 0x10000000 0x0 0x04000000>; }; };no-map属性很重要,它告诉内核这块内存不要建立页表映射,直接保留给RT-Thread独占。reg里的地址是0x10000000,大小256MB?不对,0x04000000是64MB,刚好是之前规划的内存大小。
remoteproc节点要放在soc节点下,并且要关联到预留内存:
rproc: remoteproc@10000000 { compatible = "rockchip,rk3568-rproc"; reg = <0x0 0x10000000 0x0 0x04000000>; memory-region = <&rproc_reserved>; interrupts = <GIC_SPI 60 IRQ_TYPE_LEVEL_HIGH>; interrupt-parent = <&gic>; clocks = <&cru CLK_GPU>; clock-names = "rproc"; status = "okay"; };mailbox节点通常复用rk3568芯片自带的mailbox驱动,这里的interrupts要指定一个没有被Linux占用的中断号,RT-Thread侧也要用同一个中断号做接收通知。
设备树写完后,重新编译内核和设备树,烧录到板子上。这一步建议单独做一次,确认Linux还是正常启动的,再往下走AMP。
3.3 启动RT-Thread的标准流程
设备树就绪后,启动RT-Thread就变成了一条命令的事:
echo start > /sys/class/remoteproc/remoteproc0/state如果一切正常,几秒钟后RT-Thread的启动日志就会通过它自己那路串口打出来。首次启动建议用dmesg看Linux侧日志确认加载过程:
dmesg | grep -i rproc正常会看到elf解析成功、内存映射建立、CPU2和CPU3被释放、远程处理器启动之类的日志。
如果想开机自动启动,可以用systemd服务,在rootfs里建一个oneshot服务,开机执行上面的echo命令。我实际项目里就是这么干的,系统上电后全自动进入双系统状态,不需要人工干预。
4. RT-Thread侧移植与双系统通信
4.1 RT-Thread的内存与中断配置
RT-Thread这侧首先要确保链接脚本里的内存布局,和Linux设备树里预留的内存完全一致。具体来说,rtthread.elf里定义的RAM起始地址就是0x10000000,大小64MB。这个通常在BSP的链接脚本里配置,我用的版本是board/linker.lds或者board/scatter文件。
因为RT-Thread是运行在CPU2和CPU3上的,中断控制器GIC的初始化需要按当前核来路由。RT-Thread的GIC驱动一般会自动判断当前CPU ID,但有些BSP写死了CPU0,这在AMP模式下就会出问题,中断根本收不到。检查方法是看RT-Thread BSP里gic_dist_init和gic_cpu_init的调用,确保传给GIC的是实际CPU ID。
定时器这侧不用太纠结,ARM的通用定时器每个核都有私有的物理定时器,RT-Thread的arch timer驱动天然支持按核初始化。
4.2 共享内存与RPMsg通信的原理和配置
双系统通信的核心是RPMsg,但RPMsg的底层其实就两块:一块共享内存和一组用于通知的mailbox中断。数据通过共享内存交换,哪个系统往共享内存里写了新消息,就触发一次mailbox中断,通知对方来取。
共享内存的分配我建议直接用预留内存里的另一块区域,比如在64MB的RT-Thread专用内存里,再划分出头部4MB作为通信缓冲区,地址0x10000000到0x10400000。这部分区域RT-Thread和Linux都要映射,但映射方式要注意cache一致性,我吃过亏,一会儿在问题排查里细说。
Linux侧的RPMsg驱动通常会自动探测共享内存里的vring结构,RT-Thread侧则需要在BSP配置里打开RPMsg相关的组件和驱动。
4.3 一个简单的双向通信示例
我建议第一个AMP项目不要写复杂功能,先跑一个回环测试,把通信链路打通再说。Linux侧可以利用rpmsg_char接口写一个简单的测试程序:
#include <stdio.h> #include <string.h> #include <unistd.h> #include <fcntl.h> int main(void) { int fd = open("/dev/rpmsg0", O_RDWR); char buf[128]; while (1) { memset(buf, 0, sizeof(buf)); strcpy(buf, "hello rt-thread from linux"); write(fd, buf, strlen(buf) + 1); read(fd, buf, sizeof(buf)); printf("recv: %s\n", buf); sleep(1); } return 0; }RT-Thread侧用rt_virtio_rpmsg驱动注册一个回调函数,收到消息后原样回传,一个回环测试就完成了。跑通之后,再替换成实际业务消息,比如EtherCAT主站的状态机命令、运动控制的目标位置等。
5. 调试技巧与常见问题排查实录
5.1 双路串口独立日志法
AMP调试最怕的就是两个系统打架,日志混在一起。我的做法是给Linux和RT-Thread各分配一个独立串口:Linux的console用UART0,RT-Thread的rt_kprintf输出到UART2。两个串口各接一个USB转串口模块,主机上开两个终端,分别看各自日志。
这样做的好处不用多说,两个系统互不干扰,RT-Thread哪怕死机了,Linux照样跑;反过来LGLinux再崩溃,RT-Thread的实时任务也不受影响。排查问题时对照两个终端的日志时间戳,能很快定位是哪一侧先出了问题。
5.2 状态查看与性能验证手段
Linux侧可以随时查看remoteproc的运行状态:
cat /sys/class/remoteproc/remoteproc0/state能查到的状态包括offline、suspended、running等。RT-Thread侧则可以通过它自己的shell(msh)查询线程状态、信号量使用情况、CPU占用率。
要验证AMP方案的实时性,我最推荐的办法是GPIO引脚法:在RT-Thread的中断处理入口拉高一个GPIO,在出口拉低,用示波器看这个GPIO的脉冲宽度和抖动。我的实测结果,在Linux侧满负荷跑压力测试时,RT-Thread的中断响应时间抖动依然在微秒级别,这就是AMP的价值所在。
5.3 常见问题速查表
| 问题表象 | 可能原因 | 解决办法 |
|---|---|---|
| RT-Thread启动后无日志 | 链接脚本内存起始地址和设备树预留地址不一致 | 核对rtthread.elf加载地址与reserved-memory的reg配置 |
| Linux内核soft lockup | CONFIG_HOTPLUG_CPU未关闭,Linux把RT-Thread的核抢了回去 | 关闭内核配置,重新编译 |
| 共享内存数据总是读不对 | cache一致性问题,没有标记non-cacheable | 共享内存区域在设备树里加no-map或在内核页表里设为强序 |
| 两个系统串口日志互相干扰 | console串口和rt_kprintf串口复用 | 规划不同UART作为各自日志输出 |
| remoteproc加载elf失败 | 固件格式不对或权限不足 | 确认rtthread.elf有可读权限,dmesg查看具体错误码 |
| CPU2/CPU3没被释放 | devicetree中cpu节点没有正确配置 | 在cpu@2和cpu@3节点中确认status="disabled" |
5.4 几个值得养成的操作习惯
AMP项目有个特殊的地方,设备树改动频繁,而设备树出错的症状往往很隐蔽。我建议每次修改设备树后,先用fdtdump或dtc反编译确认改动生效,再烧录进系统。
RT-Thread侧每改一次配置,重新生成rtthread.elf之后,最好用readelf检查加载段地址是否还在预留内存范围内:
readelf -l rtthread.elf重点看LOAD段的VirtAddr和PhysAddr,有一个跑到预留内存外面,轻则启动失败,重则把Linux的内存踩了,系统直接panic。
再补一个容易忽略的问题:如果Linux侧开了看门狗,AMP模式下RT-Thread启动慢一点,看门狗超时把Linux重启了,那就很冤。调整看门狗超时时间,或者等RT-Thread启动后再喂狗,都是可行方案。
我个人在实际操作中的体会是,AMP混合部署真正的难点不在于编译和配置,而在于建立“分治”的思维——你要把一个原本单系统解决的问题,拆成两个系统协同解决。资源边界、通信协议、异常处理策略,必须在动手之前就想清楚,否则项目后期返工的成本远大于一开始的规划投入。目前这套方案我已经稳定跑了大半年,RT-Thread负责EtherCAT和运动控制,Linux负责Web配置界面和数据采集,双方配合得很顺。如果你也在做类似的实时控制项目,不妨从RT-Thread双核+LPEinux双核的划分开始尝试,后续还可以根据实际负载再微调。