1. 项目缘起:为什么要在RK3568上玩混合部署?
最近在折腾一个边缘计算网关的项目,核心需求是在一个设备上既要跑一个实时性要求高的数据采集和控制任务,又要运行一个功能丰富的Web管理界面和数据库服务。如果全用Linux,实时性任务在系统负载高时难免有延迟抖动;如果全用RT-Thread,那些复杂的网络服务和图形界面开发起来又比较费劲。这不,就想到了混合部署这条路子——让两个系统各司其职,跑在同一块芯片上。
手头正好有迅为电子的iTOP-3568开发板,核心是瑞芯微的RK3568。这颗芯片有四个Cortex-A55核心,性能足够,而且官方BSP支持比较完善,是尝试这种“一芯两用”玩法的理想平台。RT-Thread以其出色的实时性和小巧的内核著称,Linux则拥有庞大的软件生态,把它们俩凑到一块,听起来就像让一个严谨的工科生和一个创意十足的设计师搭档干活,理论上能迸发出不小的能量。
但说实话,一开始我心里也没底。虽然知道有AMP(非对称多处理器)这种架构,但具体到RK3568这块板子上,怎么把两个系统的镜像烧进去?内存怎么划分?两个系统之间怎么“打电话”(通信)?启动流程谁来主导?这些细节在官方文档里往往一笔带过,或者散落在不同的地方。这次折腾,就是要把这条从零开始的路蹚明白,把踩过的坑和最终跑通的方案记录下来。
2. 混合部署的基石:理解AMP架构与RK3568的硬件底子
在单颗RK3568上同时运行RT-Thread和Linux,并不是靠虚拟机或者容器技术,而是依赖于芯片底层的一种硬件能力——AMP模式。要玩转这个,得先对硬件和基础概念有点数。
2.1 什么是AMP(非对称多处理器)?
你可以把RK3568的四个A55核心想象成一个四人间宿舍。在通常的Linux系统中,就像宿舍长(Linux内核)管理着所有四个人,给大家分配任务(进程/线程),大家共用客厅的内存、卫生间的外设。这就是SMP(对称多处理器)模式。
而AMP模式,则是把这个四人间从中间隔开,变成两个独立的单间。我们指定一个核心(比如CPU0)给RT-Thread,让它独占这个房间和一部分家具(内存、外设)。剩下的三个核心(CPU1-3)和另一部分家具,则留给Linux。两个系统有各自独立的“房间”(执行环境),互不干扰。RT-Thread在自己的房间里可以专心致志地处理那些要求定时精准、响应快的任务,比如每毫秒读取一次传感器数据;而Linux则在它的空间里从容地运行Nginx、Python甚至数据库这些大家伙。
这种隔离带来了确定性的实时性能,因为RT-Thread的核心不会被Linux的调度器抢占或打扰。同时,两个系统又能通过一些共享的“传话筒”(如共享内存、邮箱中断)进行必要的通信,协同工作。
2.2 RK3568为混合部署提供了哪些硬件支持?
RK3568的硬件设计为这种玩法开了绿灯,这是我们能实现混合部署的前提:
- 多核启动与电源管理:芯片的BootROM支持从多个核心启动。我们可以配置让CPU0从某个地址(比如存放RT-Thread镜像的地方)开始执行,而让CPU1-3从另一个地址(比如Linux内核的入口)启动。同时,每个核心可以独立地被上电、下电或复位,这为系统间隔离和故障恢复提供了可能。
- 内存控制器与地址空间:RK3568的DDR内存地址空间是统一的(例如0x00a00000 - 0xFFFFFFFF)。在AMP模式下,我们需要在系统启动前,就通过配置文件或编译脚本,明确地、物理地划分出一块内存给RT-Thread(例如0x00a00000 - 0x01ffffff),另一块给Linux(0x02000000 - 0xFFFFFFFF)。两个系统在各自的“领地”内活动,不能越界访问,否则会导致内存访问错误或系统崩溃。这个划分是静态的,在运行时一般不会改变。
- 中断控制器(GIC):中断是系统响应外部事件的关键。RK3568使用GICv2中断控制器。在AMP配置下,我们需要仔细分配中断号。例如,将某个GPIO中断、定时器中断分配给RT-Thread处理,而将网卡、USB等设备的中断分配给Linux。这通常在设备树(Device Tree)中进行配置,告诉每个系统它拥有哪些中断资源。
- 外设与IO映射:和内存类似,芯片上的外设(UART, I2C, SPI, GPIO等)其寄存器都有特定的物理地址。在AMP中,我们必须决定哪个系统“掌管”哪个外设。例如,可能将UART0分配给RT-Thread用于调试打印,将UART1、UART2分配给Linux;将某组I2C和GPIO分配给RT-Thread连接传感器,另一组给Linux。这种分配同样需要在设备树中清晰定义,避免两个系统同时去配置同一个外设寄存器,造成冲突和不可预知的行为。
注意:硬件资源的划分(内存、中断、外设)是AMP方案设计中最关键、也最容易出错的一步。划分不合理会导致系统无法启动或功能异常。一个基本原则是:确保任何硬件资源在任意时刻,最多只被一个系统主动访问和控制。
3. 实战准备:构建双系统镜像与划分硬件资源
理论清楚了,接下来就是动手。我们需要准备两个系统的可执行文件,并告诉它们各自的“地盘”在哪里。
3.1 RT-Thread系统侧的准备
对于RT-Thread,我们通常需要编译生成一个rtthread.bin文件。这里以迅为提供的BSP为例。
获取与配置BSP:
- 从迅为官方或RT-Thread GitHub仓库获取
rt-thread/bsp/rockchip/rk3568的代码。 - 进入BSP目录,重点修改
board/Kconfig和board/SConscript等文件。我们的目标不是让RT-Thread驱动整个开发板,而是驱动我们分配给它的那部分资源。
- 从迅为官方或RT-Thread GitHub仓库获取
关键配置:链接脚本与内存定义:
- 修改链接脚本(通常是
board/linker_scripts.ld),明确指定RT-Thread的代码、数据存放的物理地址。例如:MEMORY { RAM (rwx) : ORIGIN = 0x00a00000, LENGTH = 24M /* RT-Thread独占24MB内存 */ } - 在
rtconfig.h或board.h中,通过RT_HW_HEAP_BEGIN和RT_HW_HEAP_END来定义RT-Thread的动态内存堆范围,必须落在上面定义的RAM区域内。 - 在
board.c的rt_hw_board_init()函数中,只初始化分配给RT-Thread的那些外设,比如特定的UART、GPIO、定时器。对于Linux管理的外设,不做任何操作。
- 修改链接脚本(通常是
编译与生成:
- 使用
scons命令进行编译。最终在BSP目录下生成rtthread.bin(或rtthread.elf)。这个文件包含了RT-Thread内核、我们编写的应用代码以及初始化数据,其加载地址就是我们链接脚本中指定的ORIGIN(RAM)。
- 使用
3.2 Linux系统侧的准备
Linux侧的工作主要围绕设备树(Device Tree)展开。设备树是描述硬件资源的一块数据,Bootloader(通常是U-Boot)会把它传递给Linux内核。在AMP场景下,我们需要准备一个“裁剪过”的设备树。
获取Linux内核与标准设备树:
- 使用迅为提供的Linux SDK,其中包含内核源码和针对iTOP-3568的标准设备树文件,例如
rk3568-itop-3568.dts。
- 使用迅为提供的Linux SDK,其中包含内核源码和针对iTOP-3568的标准设备树文件,例如
修改设备树以适配AMP:
- CPU节点:在
/cpus节点下,将分配给RT-Thread的核心(如cpu@0)的状态(status)设置为"disabled"。这样Linux内核在启动时就会忽略这个核心,不会去初始化它。cpus { cpu0: cpu@0 { device_type = "cpu"; compatible = "arm,cortex-a55"; reg = <0x0 0x0>; enable-method = "psci"; status = "disabled"; /* 关键:让Linux忽略CPU0 */ }; cpu1: cpu@1 { status = "okay"; /* Linux管理CPU1-3 */ }; // ... cpu2, cpu3 }; - 内存节点:修改
/memory节点,将Linux可用的内存范围调整为划分后的部分。例如,如果RT-Thread占用了0x00a00000 - 0x01ffffff(24MB),那么Linux的内存就从0x02000000开始。memory@a00000 { device_type = "memory"; reg = <0x0 0x02000000 0x0 0xfe000000>; /* 起始 0x02000000, 大小 ~254MB */ }; - 外设节点:将分配给RT-Thread的外设节点全部
status = "disabled";。例如,如果UART0给RT-Thread,就在&uart0节点中添加status = "disabled";。确保Linux不会去驱动这些设备。 - 保留内存(Reserved Memory):这是一个非常重要的步骤!我们需要明确告诉Linux,有一块内存(即RT-Thread使用的区域)已经被占用了,内核和用户程序绝对不能使用。这通过
/reserved-memory节点实现。reserved-memory { #address-cells = <2>; #size-cells = <2>; ranges; rtos_reserved: rtos@a00000 { reg = <0x0 0x00a00000 0x0 0x02000000>; /* RT-Thread的24MB地盘 */ no-map; /* 关键:告诉Linux不要映射此区域,完全保留 */ }; };
- CPU节点:在
编译设备树:
- 使用内核的DTC工具编译修改后的
.dts文件,生成.dtb文件:make dtbs。
- 使用内核的DTC工具编译修改后的
3.3 制作统一的Firmware镜像
现在我们有rtthread.bin和linux.dtb(以及Linux内核镜像Image、根文件系统rootfs.img)。我们需要一个“总指挥”来安排它们各就各位。这个总指挥通常是U-Boot,但镜像的打包格式需要遵循Rockchip的约定。
Rockchip平台通常使用其专属的rkbin工具和loader来初始化DDR并加载镜像。我们需要创建一个统一镜像包,其中按顺序包含了:
- Loader(如
rk3568_loader_v1.xx.bin): Rockchip的初阶引导。 - U-Boot(
u-boot.itb): 主引导程序。 - RT-Thread镜像(
rtthread.bin): 放置在约定的物理地址(如0x00a00000)。 - Linux内核镜像(
Image): 放置在另一个地址(如0x02000000)。 - 设备树(
linux-amp.dtb): 放置在紧邻内核的位置。 - 根文件系统(
rootfs.img): 可以是单独分区,也可以打包进镜像。
可以使用tools/mkimage脚本或编写一个打包脚本来完成这个工作,确保每个组件在最终镜像中的偏移地址是准确的。最终生成一个firmware.img文件,用于烧录。
4. 启动流程深度解析:从芯片上电到双系统并行
混合部署的启动链比单系统要复杂,理解每一步有助于调试。
BootROM阶段:芯片上电后,内置的ROM代码运行。它会根据Boot引脚配置,从指定的存储介质(如eMMC的特定扇区)加载第一级Loader到SRAM中执行。
Loader阶段:第一级Loader(来自
rkbin)初始化最基本的外设(如时钟、DDR控制器),然后将第二级Loader(通常是U-Boot SPL)和U-Boot Proper加载到DDR中。U-Boot阶段:
- U-Boot Proper开始执行。它从存储设备(或网络)加载我们打包好的
firmware.img到DDR的临时缓冲区。 - U-Boot解析镜像格式,将
rtthread.bin拷贝到其指定的物理地址(0x00a00000)。 - 将
Image和linux-amp.dtb拷贝到它们约定的加载地址(如0x02000000和0x04000000)。 - 关键一步:启动RT-Thread。U-Boot通过ARM的SMC(安全监控调用)或PSCI(电源状态协调接口)命令,将CPU0从“等待事件”状态唤醒,并跳转到
rtthread.bin的入口地址(0x00a00000)开始执行。此时,CPU0开始独立运行RT-Thread。 - 启动Linux:在完成RT-Thread启动(或并行地),U-Boot使用
booti或bootm命令,将CPU1-3唤醒,并跳转到Linux内核的入口地址(Image的加载地址),同时将设备树linux-amp.dtb的地址传递给内核。Linux内核开始在其分配的内存中启动,它从设备树中知道自己只管理CPU1-3以及部分内存和外设。
- U-Boot Proper开始执行。它从存储设备(或网络)加载我们打包好的
双系统并行运行:
- 此时,CPU0独立运行RT-Thread,CPU1-3运行Linux。两个系统在物理内存和硬件资源上完全隔离。
- RT-Thread会初始化自己的串口、定时器、任务调度器等。
- Linux内核会进行解压、设备树解析、驱动初始化、挂载根文件系统,最终启动用户空间的
init进程。
踩坑实录:启动顺序很重要!务必先启动RT-Thread,再启动Linux。因为如果Linux先启动,它可能会对所有的CPU核心进行一些底层的初始化(比如缓存、MMU),这可能会干扰到后来在CPU0上运行的RT-Thread。让RT-Thread先“占住”CPU0,是一种更稳妥的做法。
5. 核心挑战:实现RT-Thread与Linux间的通信
系统跑起来了,但它们是两个孤岛。要让它们协同工作,必须建立通信机制。这里介绍两种最常用、最实用的方法。
5.1 基于共享内存(Shared Memory)的数据交换
这是最高效的通信方式,适合传输大量数据或状态信息。原理是:在物理内存中划出一小块区域(例如1MB),这块内存在两个系统的页表里都映射到各自的虚拟地址空间,并且都能读写。
实现步骤:
- 定义共享内存区域:在设备树的
/reserved-memory节点中,除了RT-Thread的独占区域,再增加一块:shared_memory: shared@1f000000 { reg = <0x0 0x1f000000 0x0 0x00100000>; /* 1MB共享内存 */ no-map; /* Linux不自动映射,需驱动处理 */ }; - Linux侧驱动开发:编写一个内核模块(
shmem.ko)。在模块初始化时,通过ioremap或memremap将这块物理内存(0x1f000000)映射到内核的虚拟地址空间。可以暴露一个字符设备(/dev/shmem)给用户空间,让应用程序能够读写这块内存。或者,更简单点,直接通过/proc/iomem告知应用层这块内存的物理地址,让应用层通过/dev/mem来访问(需注意安全)。 - RT-Thread侧访问:在RT-Thread中,由于我们通常使用物理地址直接访问(关闭MMU或使用恒等映射),我们可以直接定义一个指针指向
0x1f000000这个物理地址,然后像操作普通数组一样读写数据。 - 同步机制:共享内存本身没有同步能力。为了避免两个系统同时写造成数据混乱,需要引入简单的软件同步机制,比如自旋锁或信号量。可以在共享内存的开头定义几个变量作为“锁”。由于RK3568是ARMv8-A,支持原子操作(如
LDREX/STREX指令),可以在RT-Thread和Linux内核模块中分别实现基于原子操作的锁来保护共享数据区。
实操心得:共享内存的地址一定要在设备树中预留好,并且确保两个系统映射的物理地址一致。首次测试时,可以先在共享内存中定义一个简单的结构体,包含一个计数器和一个锁变量。RT-Thread每秒递增计数器,Linux应用每秒读取并打印。这是验证通信链路是否打通的最快方法。
5.2 基于中断(Interrupt)的事件通知
共享内存解决了数据“是什么”的问题,中断则解决了“什么时候有数据”或“什么时候该做什么”的问题。我们可以利用一个GPIO引脚产生边沿信号,或者使用一个共享的片上邮箱(Mailbox)硬件(如果RK3568支持)来触发跨系统中断。
这里以更通用的GPIO中断为例:
- 硬件连接:选择一个未被其他功能占用的GPIO引脚(例如GPIO0_A0)。用一根杜邦线将其连接到一个空闲的GPIO引脚(例如GPIO0_A1)。实际上,在同一个芯片内部,我们只需要在软件上配置一个GPIO为输出,另一个为输入即可,物理上它们内部是连通的。
- Linux侧配置(发送端):将GPIO0_A0配置为输出模式。当Linux需要通知RT-Thread时(例如,新的控制命令已写入共享内存),就通过写GPIO寄存器将该引脚电平拉高或拉低。
- RT-Thread侧配置(接收端):
- 在RT-Thread的设备树(或板级配置)中,将GPIO0_A1配置为中断输入模式,上升沿或下降沿触发。
- 在RT-Thread中编写对应的中断服务函数(ISR)。当检测到引脚电平变化时,ISR被触发。
- 在ISR中,进行必要的处理,例如读取共享内存中的命令,或者设置一个信号量/事件标志,唤醒一个高优先级的任务来处理具体事务。
- 关键点:需要在设备树中确保这个GPIO的中断号是分配给RT-Thread的,而不是Linux。这在前面的设备树裁剪中已经完成。
- 双向通知:同理,可以再用另一组GPIO,实现从RT-Thread到Linux的中断通知。
避坑指南:GPIO中断是共享内存通信的完美补充。但要注意中断去抖。在软件中,可以在中断触发后,延迟几毫秒再读取GPIO状态进行确认,或者在ISR中暂时关闭该中断,由任务处理完后再重新开启,避免短时间内多次触发中断导致系统负载过高。
6. 调试技巧与常见问题排查
混合部署的调试是“双线作战”,需要一些特别的工具和思路。
串口调试:为两个系统分配独立的串口是最理想的。例如,UART0给RT-Thread的
console,UART1给Linux的console。这样你可以通过两个串口终端分别观察两个系统的启动日志和打印信息。如果只有一个串口,可以尝试让RT-Thread和Linux分时复用,但会非常混乱,不推荐。LED与GPIO调试法:在关键代码路径(如RT-Thread启动完成、任务开始运行、收到中断)设置不同的GPIO电平,用示波器或逻辑分析仪观察波形,是判断执行流和时序的硬核方法。
常见启动失败问题:
- 现象:RT-Thread或Linux卡住没有任何输出。
- 排查:
- 检查内存划分:这是头号嫌疑犯。确认
rtthread.bin的链接地址、Linux设备树中的memory节点和reserved-memory节点,三者定义的地址范围没有重叠,且都在有效的DDR地址空间内。 - 检查U-Boot加载地址:使用U-Boot的
md(内存显示)命令,在RT-Thread和Linux的加载地址处查看内容,确认镜像是否正确加载。例如,在U-Boot中执行md 0x00a00000,看开头几个字节是不是RT-Thread的魔数或可执行代码。 - 检查CPU状态:在U-Boot中,使用
smc或psci命令手动尝试启动CPU0到指定地址,观察是否有反应。使用cpu info或类似命令查看各核心状态。 - 简化测试:先尝试只启动RT-Thread(在U-Boot中不启动Linux),确保RT-Thread能独立运行。再尝试只启动Linux(在设备树中不禁用CPU0),确保Linux能正常运行。最后再组合。
- 检查内存划分:这是头号嫌疑犯。确认
通信失败问题:
- 现象:共享内存数据不同步,或中断无法触发。
- 排查:
- 共享内存:在Linux内核启动后,通过
/proc/iomem查看预留内存区域是否成功。在Linux用户空间,尝试用devmem工具直接读写共享内存的物理地址,看是否能操作。在RT-Thread侧,在初始化时向共享内存写入一个特定的魔数(如0xDEADBEEF),然后在Linux侧读取验证。 - 中断:首先确保GPIO引脚配置正确,没有其他功能复用。在Linux侧,通过
sysfs(/sys/class/gpio)手动设置GPIO输出电平,同时用示波器测量物理引脚电压,确认输出有效。在RT-Thread侧,将GPIO中断服务函数改为最简单的翻转一个LED或打印一句话,先确认中断是否能被触发。
- 共享内存:在Linux内核启动后,通过
性能与稳定性问题:
- 现象:系统运行一段时间后RT-Thread任务周期抖动,或Linux侧性能下降。
- 排查:
- 缓存一致性:这是AMP架构的经典难题。如果两个系统都需要访问共享内存,必须处理缓存。对于RK3568的Cortex-A55,需要确保在访问共享内存前后,执行缓存维护操作。在Linux侧,使用
dma_alloc_coherent分配的内存或使用flush_dcache_range/invalidate_dcache_range。在RT-Thread侧,如果开启了数据缓存,也需要使用CP15或CMSIS相关的缓存维护指令(如SCB_CleanDCache_by_Addr)。最省事的办法是,在设备树中将共享内存区域标记为no-map和non-cacheable,但会损失一些性能。 - 内存访问冲突:再次检查设备树,确保所有外设的寄存器区域只被一个系统控制。任何重叠都可能导致随机崩溃。
- 缓存一致性:这是AMP架构的经典难题。如果两个系统都需要访问共享内存,必须处理缓存。对于RK3568的Cortex-A55,需要确保在访问共享内存前后,执行缓存维护操作。在Linux侧,使用
折腾这么一圈下来,当你在两个串口终端分别看到RT-Thread的msh >和Linux的root@rk3568:~#提示符时,那种成就感是单系统启动无法比拟的。混合部署不是银弹,它引入了复杂性,但在对实时性和丰富生态有双重需求的场景下,它提供了一个非常优雅的解决方案。对于RK3568这样的多核平台,这无疑是释放其全部潜力的一种高级玩法。