1. 项目概述为什么要在双核A7上折腾RT-Thread最近在做一个工控边缘网关的项目主控选型时看中了全志的R128芯片它内置了两个Cortex-A7核心。项目对实时性有明确要求一些关键的传感器数据采集和协议转换必须在严格的时间窗口内完成用传统的Linux虽然生态丰富但实时性始终是个心病打上PREEMPT_RT补丁后性能有所改善但总感觉“不够纯粹”。于是我想到了RT-Thread这款国产的实时操作系统它内核小巧实时性有保障生态也在快速发展。但官方BSP板级支持包通常针对单核场景这颗双核A7该怎么玩是把两个核都跑上RT-Thread还是玩点“混合动力”这便有了这篇移植笔记。简单说这个项目就是将RT-Thread实时操作系统移植到一颗双核Cortex-A7架构的处理器上并探索其多核应用模式。它适合两类朋友一类是正在评估或使用类似全志R128、NXP i.MX7等双核A7芯片需要硬实时能力的嵌入式开发者另一类是对RT-Thread多核支持、AMP非对称多处理模式感兴趣想深入理解RTOS在SMP对称多处理与AMP之间如何设计与协作的工程师。通过这篇笔记你不仅能获得一个可运行的移植模板更能理解在多核异构此处指同构双核但运行不同系统或任务或同构多核场景下资源划分、核间通信、启动流程这些核心问题的解决思路。2. 核心思路与方案选型SMP还是AMP拿到双核芯片第一个要决断的就是拓扑结构。Cortex-A7支持SMP即两个核地位对等共享内存由一个操作系统统一调度。RT-Thread主线版本已经支持SMP听起来很美好但我们需要仔细权衡。方案一纯RT-Thread SMP模式。这是最“正统”的思路。让RT-Thread以SMP模式运行管理两个A7核心。所有任务、中断、资源由RT-Thread统一管理。优势很明显编程模型简单符合常规RTOS多线程开发习惯任务可以在两个核上动态迁移理论上能更好地均衡负载。但深入一想我们的项目需求是部分功能要求高实时性部分功能需要丰富的网络协议栈和文件系统。如果全部跑在RT-Thread上虽然实时性达标但那些复杂的网络服务如HTTPS、MQTT在RT-Thread上实现和维护成本较高且生态软件丰富度不如Linux。方案二AMP模式RT-Thread Bare-metal/另一RTOS。即非对称多处理。一个核比如CPU0运行RT-Thread专注于高实时性任务另一个核CPU1运行另一个简单的裸机程序或另一个轻量级RTOS负责一些非实时或功能简单的模块。这种模式资源隔离性好但核间通信IPC需要自己精心设计通常通过共享内存加硬件信号量或自旋锁实现增加了软件架构的复杂性。对于我们的项目这有点像“杀鸡用牛刀”两个强大的A7核只跑轻量任务有点浪费。方案三AMP模式RT-Thread Linux。这才是我们最终选择的方案也是当前业界在类似芯片如i.MX7, R128上常见的混合系统架构。让CPU0启动后引导运行RT-Thread负责实时控制、数据采集CPU1则在启动初期置于休眠状态由CPU0上的RT-Thread在适当时机通过核间中断SGI唤醒并跳转到Linux内核的入口地址启动Linux。Linux则负责上层复杂的应用、网络服务、图形显示等。两者通过共享内存通常是一块预留的DDR区域进行数据交换通过核间中断通知对方。注意选择方案三意味着你需要同时面对RT-Thread和Linux两个系统的移植、调试和通信问题门槛较高。但它的优势是兼顾了“实时”与“富生态”是工业领域非常实用的架构。我们选择方案三。因此这篇移植笔记的重点将分为两大部分一是让RT-Thread在CPU0上正确运行起来包括基础驱动、内存管理、定时器等二是实现RT-Thread对CPU1的引导与Linux的启动并建立稳定的核间通信机制。这比单纯的SMP移植更具挑战也更有价值。3. 硬件环境与软件准备工欲善其事必先利其器。首先得把家当理清楚。3.1 硬件平台明细主控芯片全志R128-S3举例。双核Arm Cortex-A7主频最高1.2GHz集成64MB DDR2内存丰富的外设如GPIO、UART、SPI、I2C、ADC等。调试工具J-Link或DAP-Link调试器支持Cortex-A用于初始的单步调试和固件下载。一套串口调试工具USB转TTL至少需要两个串口一个用于RT-Thread系统日志console一个用于后续Linux的console。开发板R128评估板。确认原理图特别是启动模式引脚BOOT_SEL、串口引脚、调试接口JTAG/SWD的连接方式。3.2 软件与源码准备RT-Thread源码从GitHub官方仓库获取最新master分支代码。我们主要关心bsp板级支持包目录我们需要在其中为我们自己的板子创建一个新目录例如bsp/r128-s3。交叉编译工具链使用arm-none-eabi-gcc还是arm-linux-gnueabihf-gcc这里有个关键点。如果CPU1要启动Linux那么为Linux编译的代码必须使用后者带硬浮点、支持Linux ABI。但为了简化在移植RT-Thread内核本身时我们可以先使用arm-none-eabi-gcc因为它更精简不依赖特定C库便于调试最底层的启动和异常。后续编译RT-Thread的用户态组件和Linux时再切换。我推荐先准备gcc-arm-11.2-2022.02-x86_64-arm-none-eabi.tar.xz。构建系统RT-Thread使用scons作为构建工具。确保Python和scons已安装。参考设计仔细研读芯片的官方数据手册、TRM技术参考手册以及ARM Cortex-A7的架构参考手册。同时找到芯片原厂或社区已有的单核A7 RT-Thread移植例如针对全志F1C100s的作为参考理解其启动文件startup_gcc.S、链接脚本link.lds的写法。3.3 工程目录结构初始化在RT-Thread的bsp目录下创建我们的平台目录bsp/r128-s3/ ├── applications/ # 用户应用目录 ├── drivers/ # 板级驱动如串口、GPIO、时钟初始化 │ ├── drv_usart.c │ ├── drv_gpio.c │ └── board.c # 板级硬件初始化核心文件 ├── libraries/ # 可能需要的芯片库文件可从SDK提取 ├── rtconfig.py # 工程构建配置 ├── SConscript # SCons构建脚本 ├── link.lds # 链接脚本定义内存布局 └── startup_gcc.S # 汇编启动文件最核心这个结构是骨架后续的血肉需要我们一点点填充。4. 第一阶段让RT-Thread在CPU0上跑起来这是最基础也是最关键的一步。如果单核都跑不稳双核和AMP就是空中楼阁。4.1 编写启动文件startup_gcc.S启动文件是用汇编写的它决定了芯片上电后执行的第一段代码。对于Cortex-A7我们需要做以下几件事定义异常向量表ARM AArch32架构要求向量表位于地址0x00000000或0xFFFF0000。我们的芯片通常从0x00000000启动。向量表包含复位、未定义指令、软中断、预取指中止、数据中止、IRQ、FIQ等异常的处理入口。复位向量指向我们的_start标签。.section .vectors, ax .global _vectors _vectors: ldr pc, _start /* 复位 */ ldr pc, _undef_entry /* 未定义指令 */ ldr pc, _swi_entry /* 软中断 */ ldr pc, _pabt_entry /* 预取指中止 */ ldr pc, _dabt_entry /* 数据中止 */ nop /* 保留 */ ldr pc, _irq_entry /* IRQ */ ldr pc, _fiq_entry /* FIQ */_start入口关闭中断设置CPU为SVC模式初始化栈指针SP。这里有个重要细节Cortex-A7在多核下每个核都有自己的SP寄存器。我们需要在代码中区分CPU0和CPU1的栈。通常我们在链接脚本中定义两个栈区域然后在启动时根据CPU ID通过读MPIDR寄存器获取来设置对应的SP。.global _start _start: /* 1. 进入SVC模式禁用IRQ和FIQ */ cpsid if, #0x13 /* 2. 获取当前CPU ID (0 or 1) */ mrc p15, 0, r0, c0, c0, 5 and r0, r0, #0x03 /* 3. 根据CPU ID设置栈指针 */ ldr r1, _stack_top /* _stack_top是CPU0栈顶在链接脚本定义 */ ldr r2, _stack1_top /* _stack1_top是CPU1栈顶 */ cmp r0, #0 moveq sp, r1 movne sp, r2 /* 4. 清零BSS段 */ ldr r0, _bss_start ldr r1, _bss_end mov r2, #0 bss_clear_loop: cmp r0, r1 strlt r2, [r0], #4 blt bss_clear_loop /* 5. 跳转到C语言入口 rtthread_startup */ bl rtthread_startup实操心得在AMP模式下我们计划让CPU1去启动Linux因此上述启动流程主要是为CPU0准备的。CPU1的启动流程我们会单独控制。但启动文件里区分CPU ID并设置不同栈的代码仍然保留这是一个好习惯也为未来可能切换到SMP模式留有余地。4.2 配置链接脚本link.lds链接脚本告诉链接器如何把代码、数据放到内存的哪个位置。对于R128我们需要知道它的内存映射。假设其内部SRAM地址为0x00020000大小为32KBDDR内存地址为0x80000000大小为64MB。MEMORY { /* 内部SRAM用于存放异常向量表和初始栈速度最快 */ SRAM (rwx) : ORIGIN 0x00020000, LENGTH 32K /* 外部DDR主存 */ DDR (rwx) : ORIGIN 0x80000000, LENGTH 64M } SECTIONS { . 0x00020000; /* 加载地址从SRAM开始 */ .vectors : { *(.vectors) } SRAM .text : { *(.text*) } DDR .rodata : { *(.rodata*) } DDR .data : { _data_start .; *(.data*) _data_end .; } DDR AT DDR /* AT指定加载地址这里和运行地址相同 */ .bss : { _bss_start .; *(.bss*) *(COMMON) _bss_end .; } DDR /* 栈空间定义CPU0栈放在DDR开头附近CPU1栈紧随其后 */ .stack (NOLOAD) : { . ALIGN(8); _stack_top .; . 0x4000; /* 16KB栈 for CPU0 */ _stack0_end .; _stack1_top .; . 0x4000; /* 16KB栈 for CPU1 */ _stack1_end .; } DDR _end .; }注意事项异常向量表.vectors必须放在芯片上电后能直接取指的位置通常是0地址。有些芯片支持内存重映射Remap我们的做法是直接将其放在SRAM开头并确保芯片的启动模式配置正确使得上电后PC指针能指向0x00020000。4.3 实现板级初始化board.cboard.c中的rt_hw_board_init()函数是C语言世界的第一站。在这里我们要完成系统时钟初始化PLL、各总线时钟。串口初始化将rt_hw_console_output函数指向串口发送函数这是rt_kprintf能工作的前提。定时器初始化为RT-Thread的时钟节拍SysTick提供硬件定时器。Cortex-A7有自带的私有定时器Private Timer我们可以用它。内存堆初始化。告诉RT-Thread可用的内存空间起始和结束地址。void rt_hw_board_init(void) { /* 1. 初始化系统时钟 */ system_clock_init(); /* 2. 初始化串口0为控制台 */ uart_init(0, 115200); rt_console_set_device(uart0); /* 3. 初始化Cortex-A7私有定时器为SysTick源 */ rt_hw_timer_init(); /* 设置定时器中断频率为1000Hz (1ms tick) */ rt_hw_timer_start(1000); /* 4. 初始化内存堆 */ rt_system_heap_init((void*)0x80010000, (void*)0x80200000); /* 5. 板级外设初始化如GPIO、I2C等可以放在这里或后期按需初始化 */ rt_hw_pin_init(); #ifdef RT_USING_COMPONENTS_INIT /* RT-Thread 组件自动初始化 */ rt_components_board_init(); #endif }4.4 配置构建系统rtconfig.py SConscriptrtconfig.py定义全局编译选项、工具链路径、模块使能开关。import os # 工具链前缀 CROSS_TOOL gcc if CROSS_TOOL gcc: PLATFORM gcc EXEC_PATH r/your/path/to/gcc-arm-11.2/bin/ TARGET_EXT elf CC os.path.join(EXEC_PATH, arm-none-eabi-gcc) CXX os.path.join(EXEC_PATH, arm-none-eabi-g) AS os.path.join(EXEC_PATH, arm-none-eabi-as) AR os.path.join(EXEC_PATH, arm-none-eabi-ar) LINK os.path.join(EXEC_PATH, arm-none-eabi-gcc) ... # RT-Thread内核与组件配置 RT_NAME RT-Thread RT_USING_SMP False # 第一阶段先关闭SMP RT_USING_CONSOLE True RT_USING_DEVICE True RT_USING_TIMER_SOFT False RT_USING_PIN True ...SConscript描述如何编译本BSP目录下的源文件并链接到内核。from building import * cwd GetCurrentDir() src Glob(*.c) Glob(drivers/*.c) startup [startup_gcc.S] group DefineGroup(BSP, src, depend [], CPPPATH [cwd, cwd /drivers]) objs [group] objs.append(DefineGroup(Startup, startup)) Return(objs)完成以上步骤后在bsp/r128-s3目录下执行scons命令应该能成功编译生成rtthread.elf文件。通过调试器将其加载到SRAM的起始地址0x00020000并复位CPU如果串口有输出RT-Thread的版本信息和msh 提示符那么恭喜你单核RT-Thread已经成功运行5. 第二阶段引导CPU1与启动Linux这是AMP模式的核心。我们的目标是CPU0上的RT-Thread启动后由它来设置CPU1的启动参数唤醒CPU1并引导其跳转到Linux内核镜像所在的内存地址。5.1 准备Linux内核镜像首先你需要为你的目标板编译一个Linux内核。这通常涉及配置内核make ARCHarm menuconfig确保包含对您芯片SoC的支持、设备树Device Tree以及必要的驱动。编译后会得到zImage压缩的内核镜像和对应的设备树二进制文件.dtb。我们需要将这两个文件加载到DDR内存中。一个简单的方法是将它们打包到RT-Thread的镜像中或者通过调试器直接写入到DDR的某个固定地址例如0x81000000。我们假设zImage在0x81000000设备树在0x82000000。5.2 CPU1的启动流程设计Cortex-A7多核上电后通常只有CPU0Primary Core开始执行代码CPU1Secondary Core处于等待状态WFE。CPU0需要通过写寄存器向CPU1发送一个事件通常是一个核间中断SGI并设置好CPU1开始执行的地址称为“启动地址”或“旋转地址”spin-table地址。设置CPU1的启动地址在共享内存中例如0x8000F000定义一个简单的“旋转代码”区域。这段代码通常就是一条跳转指令跳转到真正的Linux内核入口。但更常见的做法是直接设置一个函数指针地址。ARM Linux内核使用的是一种叫spin-table的方法。我们在内存中约定一个位置比如0x8000F000CPU0将Linux内核的入口地址kernel_entry_addr写在这里并将该地址的“状态”字段设置为可跳转。CPU1被唤醒后会轮询这个地址直到状态有效然后读取入口地址并跳转。唤醒CPU1在RT-Thread中我们需要实现唤醒函数。这涉及到操作GIC通用中断控制器或芯片特定的核间通信寄存器发送一个特定的SGISoftware Generated Interrupt中断给CPU1。#define CPU1_WAKEUP_ADDR 0x8000F000 void rt_hw_secondary_cpu_up(void) { uint32_t *spin_table (uint32_t*)CPU1_WAKEUP_ADDR; /* 步骤1: 将Linux内核入口地址写入约定位置 */ /* 假设kernel_entry是Linux内核的物理入口地址例如0x80008000 */ uint32_t kernel_entry 0x80008000; spin_table[0] kernel_entry; /* 入口地址 */ spin_table[1] 1; /* 状态标志设为1表示“go” */ /* 数据同步屏障确保写入对CPU1可见 */ __DSB(); /* 步骤2: 发送SGI中断唤醒CPU1 */ /* 写入GIC的寄存器发送SGI ID 0 给 CPU1 */ /* 寄存器地址和操作需查阅芯片手册 */ uint32_t *gicd_sgir (uint32_t*)0x1F001000; /* GIC Distributor SGIR寄存器示例地址 */ *gicd_sgir (1 24) | (0x1 16) | 0; /* Target CPU1, SGI ID 0 */ }5.3 Linux启动参数传递ATAGS / Device TreeCPU1启动Linux时需要传递一些参数比如内存大小、命令行参数cmdline、设备树地址等。现在主流使用设备树DTB。我们需要将设备树二进制文件的物理地址通过寄存器r2传递给Linux内核。 在CPU0设置启动地址的代码中我们不仅要设置内核入口还要按照Linux内核的spin-table或psci协议的要求设置好r00, r1机器ID, r2设备树地址。对于spin-table这些参数通常放在约定内存结构的特定字段里。5.4 RT-Thread侧的核间通信IPC基础CPU0和CPU1现在运行Linux需要交换数据。最基础的IPC是共享内存核间中断。定义共享内存区域在链接脚本中预留一段不被RT-Thread和Linux使用的内存区域。例如在DDR末尾预留64KB。/* 在link.lds的MEMORY中增加 */ SHAREDMEM (rw) : ORIGIN 0x83FF0000, LENGTH 64K在board.c中初始化时不要将这片区域加入系统堆。实现简单的邮箱机制在共享内存中定义一个结构体包含数据缓冲区、读/写索引、状态标志等。RT-Thread和Linux侧的驱动都需要能够访问这个结构体。中断通知当RT-Thread向共享内存写入数据后通过发送SGI中断例如ID 1通知Linux侧有数据到达。反之Linux侧也可以通过写寄存器触发CPU0的IRQ。这需要双方正确配置GIC分配好私有中断号。踩坑实录核间中断的调试非常棘手。务必确保双方操作的是同一个GIC实例并且中断号、目标CPU掩码设置正确。一个有效的调试方法是先让RT-Thread发送一个中断然后在Linux下用cat /proc/interrupts查看该SGI中断是否被CPU1接收并计数。同样Linux发送中断后可以在RT-Thread的中断处理函数中打印日志来确认。6. 系统集成与调试实战将上述所有模块整合并烧录到板子上进行调试。6.1 镜像打包与烧录我们需要一个最终的可烧录镜像如.bin或.img文件。这个镜像应该包含RT-Thread的二进制代码从SRAM起始地址开始。预留的共享内存区域初始化为0。Linux的zImage和.dtb文件放在约定的DDR地址。可以使用dd命令或者编写简单的打包脚本将这些二进制块拼接成一个文件。烧录工具可以使用全志官方的PhoenixSuit或LiveSuit或者通过调试器的Flash编程功能。6.2 上电调试流程连接调试器与串口将JTAG/SWD调试器和两个串口RT-Thread console, Linux console都连接到电脑。加载并单步调试启动文件首先只加载RT-Thread的ELF文件到SRAM在_start处设置断点单步执行观察SP是否设置正确BSS段是否清零能否成功跳转到rtthread_startup。观察RT-Thread启动日志全速运行在第一个串口看到RT-Thread的启动信息并进入msh。手动触发CPU1启动在RT-Thread的msh中执行一个命令例如secondary_boot该命令调用我们写的rt_hw_secondary_cpu_up()函数。观察Linux启动日志此时第二个串口连接着Linux的调试UART应该开始滚动输出Linux内核的启动信息直到出现Linux登录提示符。测试基础IPC在RT-Thread侧编写一个测试线程周期性地向共享内存写入数据并发送SGI。在Linux侧编写一个内核模块或用户空间程序通过devmem或自定义驱动读取共享内存并验证数据正确性。反之亦然。6.3 常见问题与排查技巧实录问题现象可能原因排查思路与解决方法RT-Thread无任何串口输出1. 启动地址错误2. 时钟未初始化3. 串口引脚复用或配置错误4. 栈指针设置错误导致早期崩溃1. 确认烧录地址与链接脚本ENTRY(_start)及芯片启动模式匹配。2. 在startup_gcc.S的最开始在初始化栈之前添加几行汇编代码通过操作GPIO点亮一个LED确认芯片已运行。3. 使用调试器单步跟启动代码检查执行流是否异常跳转。RT-Thread启动后卡在某个组件初始化1. 堆内存范围设置错误2. 定时器中断未正确配置3. 驱动依赖的硬件未初始化1. 检查rt_system_heap_init传入的地址范围是否有效、未与其他区域重叠。2. 确认SysTick定时器中断号、优先级配置正确中断处理函数被调用。3. 使用list_device命令查看设备是否成功注册。执行secondary_boot后Linux无输出1. CPU1启动地址设置错误2. 核间中断未成功送达3. Linux内核镜像或设备树地址错误4. CPU1的底层状态如MMU、缓存未正确设置1. 在RT-Thread中在发送SGI前先读取共享内存的启动地址打印出来确认是否正确。2. 在Linux内核源码的secondary_startup或spin_table相关代码处添加早期打印可能需要修改内核源码并重新编译看CPU1是否被唤醒并执行。3. 使用调试器同时连接两个核在CPU1的启动地址处设置断点看CPU1的PC指针是否跳转过去。4. 确保CPU0在唤醒CPU1前已禁用CPU1的MMU和缓存或者Linux内核的入口代码能处理这种状态。核间通信数据不一致1. 缓存一致性问题2. 共享内存地址映射不一致3. 数据竞争未加锁1.这是AMP下最常见也最头疼的问题在RT-Thread和Linux侧访问共享内存前执行缓存失效Invalidate操作写入共享内存后执行缓存写回Clean操作。对于Cortex-A7使用CP15协处理器指令或__DSB(),__DMB()内存屏障。2. 确认双方访问的是相同的物理地址。RT-Thread通常用物理地址Linux内核模块需要用ioremap或memremap将物理地址映射到内核虚拟地址。3. 对共享内存中的关键数据结构使用原子操作或自旋锁。Linux启动后系统不稳定1. 内存冲突RT-Thread与Linux使用重叠内存2. 中断冲突双方配置了相同的中断源1. 严格划分内存空间。在Linux的设备树中通过/memreserve/节点保留出RT-Thread和共享内存使用的物理地址范围防止Linux内核使用。2. 在设备树中明确分配中断资源。通常核间通信的SGI中断0-15是预留给软件使用的可以安全分配。硬件外设中断需要协商好归属避免一个中断被两个OS同时配置和响应。6.4 性能优化与稳定性考量当双核系统基本跑通后就需要考虑优化和长期运行了。缓存与内存屏障如前所述这是AMP模式数据一致性的生命线。务必在每一次跨核数据访问前后使用合适的内存屏障指令DSB,DMB,ISB。中断隔离除了通信用的SGI确保两个OS管理的中断源完全隔离。可以通过硬件设计外设连接到不同核或软件配置在GIC中分配中断实现。看门狗与系统监控考虑为RT-Thread和Linux分别设置看门狗。或者设计一个“心跳”机制通过核间通信互相报告健康状态一旦一方异常另一方可以执行安全恢复操作如重启对方或整个系统。调试信息分离将两个OS的调试串口分开是非常明智的选择。如果资源有限只能共用一个串口则需要设计严格的日志协议避免输出交织混乱。7. 总结与展望移植工作到这一步一个双核Cortex-A7分别运行RT-Thread和Linux的混合实时系统已经搭建起来了。这个过程就像在芯片上划分出两个独立的“王国”RT-Thread王国精干高效负责时间紧迫的任务Linux王国资源丰富负责处理复杂业务。而共享内存和核间中断就是连接两个王国的“高速公路”和“驿道”。回顾整个过程最耗费精力的往往不是代码编写而是调试尤其是涉及到底层硬件操作、缓存一致性、双核同步的时候。我的体会是一定要善用调试工具无论是单步跟踪、内存查看还是利用GPIO点灯这种最原始但有效的调试手段。同时充分阅读芯片手册和ARM架构手册理解每一行配置代码背后的硬件行为是解决问题的根本。这个移植框架还有很多可以深化和扩展的地方。例如可以完善RT-Thread侧的驱动生态使其能直接管理更多板载外设可以优化核间通信协议实现更高效、更稳定的消息队列或远程过程调用RPC机制甚至可以探索在RT-Thread侧运行一些轻量级的网络协议栈与Linux侧的应用进行更丰富的交互。双核A7的平台为我们提供了兼顾实时性与通用性的强大硬件基础而RT-Thread的灵活性与开放性则让这种混合架构的软件设计成为可能。希望这篇笔记能为你打开一扇门在嵌入式混合系统设计的道路上走得更远。
双核Cortex-A7上RT-Thread与Linux混合系统移植实战
1. 项目概述为什么要在双核A7上折腾RT-Thread最近在做一个工控边缘网关的项目主控选型时看中了全志的R128芯片它内置了两个Cortex-A7核心。项目对实时性有明确要求一些关键的传感器数据采集和协议转换必须在严格的时间窗口内完成用传统的Linux虽然生态丰富但实时性始终是个心病打上PREEMPT_RT补丁后性能有所改善但总感觉“不够纯粹”。于是我想到了RT-Thread这款国产的实时操作系统它内核小巧实时性有保障生态也在快速发展。但官方BSP板级支持包通常针对单核场景这颗双核A7该怎么玩是把两个核都跑上RT-Thread还是玩点“混合动力”这便有了这篇移植笔记。简单说这个项目就是将RT-Thread实时操作系统移植到一颗双核Cortex-A7架构的处理器上并探索其多核应用模式。它适合两类朋友一类是正在评估或使用类似全志R128、NXP i.MX7等双核A7芯片需要硬实时能力的嵌入式开发者另一类是对RT-Thread多核支持、AMP非对称多处理模式感兴趣想深入理解RTOS在SMP对称多处理与AMP之间如何设计与协作的工程师。通过这篇笔记你不仅能获得一个可运行的移植模板更能理解在多核异构此处指同构双核但运行不同系统或任务或同构多核场景下资源划分、核间通信、启动流程这些核心问题的解决思路。2. 核心思路与方案选型SMP还是AMP拿到双核芯片第一个要决断的就是拓扑结构。Cortex-A7支持SMP即两个核地位对等共享内存由一个操作系统统一调度。RT-Thread主线版本已经支持SMP听起来很美好但我们需要仔细权衡。方案一纯RT-Thread SMP模式。这是最“正统”的思路。让RT-Thread以SMP模式运行管理两个A7核心。所有任务、中断、资源由RT-Thread统一管理。优势很明显编程模型简单符合常规RTOS多线程开发习惯任务可以在两个核上动态迁移理论上能更好地均衡负载。但深入一想我们的项目需求是部分功能要求高实时性部分功能需要丰富的网络协议栈和文件系统。如果全部跑在RT-Thread上虽然实时性达标但那些复杂的网络服务如HTTPS、MQTT在RT-Thread上实现和维护成本较高且生态软件丰富度不如Linux。方案二AMP模式RT-Thread Bare-metal/另一RTOS。即非对称多处理。一个核比如CPU0运行RT-Thread专注于高实时性任务另一个核CPU1运行另一个简单的裸机程序或另一个轻量级RTOS负责一些非实时或功能简单的模块。这种模式资源隔离性好但核间通信IPC需要自己精心设计通常通过共享内存加硬件信号量或自旋锁实现增加了软件架构的复杂性。对于我们的项目这有点像“杀鸡用牛刀”两个强大的A7核只跑轻量任务有点浪费。方案三AMP模式RT-Thread Linux。这才是我们最终选择的方案也是当前业界在类似芯片如i.MX7, R128上常见的混合系统架构。让CPU0启动后引导运行RT-Thread负责实时控制、数据采集CPU1则在启动初期置于休眠状态由CPU0上的RT-Thread在适当时机通过核间中断SGI唤醒并跳转到Linux内核的入口地址启动Linux。Linux则负责上层复杂的应用、网络服务、图形显示等。两者通过共享内存通常是一块预留的DDR区域进行数据交换通过核间中断通知对方。注意选择方案三意味着你需要同时面对RT-Thread和Linux两个系统的移植、调试和通信问题门槛较高。但它的优势是兼顾了“实时”与“富生态”是工业领域非常实用的架构。我们选择方案三。因此这篇移植笔记的重点将分为两大部分一是让RT-Thread在CPU0上正确运行起来包括基础驱动、内存管理、定时器等二是实现RT-Thread对CPU1的引导与Linux的启动并建立稳定的核间通信机制。这比单纯的SMP移植更具挑战也更有价值。3. 硬件环境与软件准备工欲善其事必先利其器。首先得把家当理清楚。3.1 硬件平台明细主控芯片全志R128-S3举例。双核Arm Cortex-A7主频最高1.2GHz集成64MB DDR2内存丰富的外设如GPIO、UART、SPI、I2C、ADC等。调试工具J-Link或DAP-Link调试器支持Cortex-A用于初始的单步调试和固件下载。一套串口调试工具USB转TTL至少需要两个串口一个用于RT-Thread系统日志console一个用于后续Linux的console。开发板R128评估板。确认原理图特别是启动模式引脚BOOT_SEL、串口引脚、调试接口JTAG/SWD的连接方式。3.2 软件与源码准备RT-Thread源码从GitHub官方仓库获取最新master分支代码。我们主要关心bsp板级支持包目录我们需要在其中为我们自己的板子创建一个新目录例如bsp/r128-s3。交叉编译工具链使用arm-none-eabi-gcc还是arm-linux-gnueabihf-gcc这里有个关键点。如果CPU1要启动Linux那么为Linux编译的代码必须使用后者带硬浮点、支持Linux ABI。但为了简化在移植RT-Thread内核本身时我们可以先使用arm-none-eabi-gcc因为它更精简不依赖特定C库便于调试最底层的启动和异常。后续编译RT-Thread的用户态组件和Linux时再切换。我推荐先准备gcc-arm-11.2-2022.02-x86_64-arm-none-eabi.tar.xz。构建系统RT-Thread使用scons作为构建工具。确保Python和scons已安装。参考设计仔细研读芯片的官方数据手册、TRM技术参考手册以及ARM Cortex-A7的架构参考手册。同时找到芯片原厂或社区已有的单核A7 RT-Thread移植例如针对全志F1C100s的作为参考理解其启动文件startup_gcc.S、链接脚本link.lds的写法。3.3 工程目录结构初始化在RT-Thread的bsp目录下创建我们的平台目录bsp/r128-s3/ ├── applications/ # 用户应用目录 ├── drivers/ # 板级驱动如串口、GPIO、时钟初始化 │ ├── drv_usart.c │ ├── drv_gpio.c │ └── board.c # 板级硬件初始化核心文件 ├── libraries/ # 可能需要的芯片库文件可从SDK提取 ├── rtconfig.py # 工程构建配置 ├── SConscript # SCons构建脚本 ├── link.lds # 链接脚本定义内存布局 └── startup_gcc.S # 汇编启动文件最核心这个结构是骨架后续的血肉需要我们一点点填充。4. 第一阶段让RT-Thread在CPU0上跑起来这是最基础也是最关键的一步。如果单核都跑不稳双核和AMP就是空中楼阁。4.1 编写启动文件startup_gcc.S启动文件是用汇编写的它决定了芯片上电后执行的第一段代码。对于Cortex-A7我们需要做以下几件事定义异常向量表ARM AArch32架构要求向量表位于地址0x00000000或0xFFFF0000。我们的芯片通常从0x00000000启动。向量表包含复位、未定义指令、软中断、预取指中止、数据中止、IRQ、FIQ等异常的处理入口。复位向量指向我们的_start标签。.section .vectors, ax .global _vectors _vectors: ldr pc, _start /* 复位 */ ldr pc, _undef_entry /* 未定义指令 */ ldr pc, _swi_entry /* 软中断 */ ldr pc, _pabt_entry /* 预取指中止 */ ldr pc, _dabt_entry /* 数据中止 */ nop /* 保留 */ ldr pc, _irq_entry /* IRQ */ ldr pc, _fiq_entry /* FIQ */_start入口关闭中断设置CPU为SVC模式初始化栈指针SP。这里有个重要细节Cortex-A7在多核下每个核都有自己的SP寄存器。我们需要在代码中区分CPU0和CPU1的栈。通常我们在链接脚本中定义两个栈区域然后在启动时根据CPU ID通过读MPIDR寄存器获取来设置对应的SP。.global _start _start: /* 1. 进入SVC模式禁用IRQ和FIQ */ cpsid if, #0x13 /* 2. 获取当前CPU ID (0 or 1) */ mrc p15, 0, r0, c0, c0, 5 and r0, r0, #0x03 /* 3. 根据CPU ID设置栈指针 */ ldr r1, _stack_top /* _stack_top是CPU0栈顶在链接脚本定义 */ ldr r2, _stack1_top /* _stack1_top是CPU1栈顶 */ cmp r0, #0 moveq sp, r1 movne sp, r2 /* 4. 清零BSS段 */ ldr r0, _bss_start ldr r1, _bss_end mov r2, #0 bss_clear_loop: cmp r0, r1 strlt r2, [r0], #4 blt bss_clear_loop /* 5. 跳转到C语言入口 rtthread_startup */ bl rtthread_startup实操心得在AMP模式下我们计划让CPU1去启动Linux因此上述启动流程主要是为CPU0准备的。CPU1的启动流程我们会单独控制。但启动文件里区分CPU ID并设置不同栈的代码仍然保留这是一个好习惯也为未来可能切换到SMP模式留有余地。4.2 配置链接脚本link.lds链接脚本告诉链接器如何把代码、数据放到内存的哪个位置。对于R128我们需要知道它的内存映射。假设其内部SRAM地址为0x00020000大小为32KBDDR内存地址为0x80000000大小为64MB。MEMORY { /* 内部SRAM用于存放异常向量表和初始栈速度最快 */ SRAM (rwx) : ORIGIN 0x00020000, LENGTH 32K /* 外部DDR主存 */ DDR (rwx) : ORIGIN 0x80000000, LENGTH 64M } SECTIONS { . 0x00020000; /* 加载地址从SRAM开始 */ .vectors : { *(.vectors) } SRAM .text : { *(.text*) } DDR .rodata : { *(.rodata*) } DDR .data : { _data_start .; *(.data*) _data_end .; } DDR AT DDR /* AT指定加载地址这里和运行地址相同 */ .bss : { _bss_start .; *(.bss*) *(COMMON) _bss_end .; } DDR /* 栈空间定义CPU0栈放在DDR开头附近CPU1栈紧随其后 */ .stack (NOLOAD) : { . ALIGN(8); _stack_top .; . 0x4000; /* 16KB栈 for CPU0 */ _stack0_end .; _stack1_top .; . 0x4000; /* 16KB栈 for CPU1 */ _stack1_end .; } DDR _end .; }注意事项异常向量表.vectors必须放在芯片上电后能直接取指的位置通常是0地址。有些芯片支持内存重映射Remap我们的做法是直接将其放在SRAM开头并确保芯片的启动模式配置正确使得上电后PC指针能指向0x00020000。4.3 实现板级初始化board.cboard.c中的rt_hw_board_init()函数是C语言世界的第一站。在这里我们要完成系统时钟初始化PLL、各总线时钟。串口初始化将rt_hw_console_output函数指向串口发送函数这是rt_kprintf能工作的前提。定时器初始化为RT-Thread的时钟节拍SysTick提供硬件定时器。Cortex-A7有自带的私有定时器Private Timer我们可以用它。内存堆初始化。告诉RT-Thread可用的内存空间起始和结束地址。void rt_hw_board_init(void) { /* 1. 初始化系统时钟 */ system_clock_init(); /* 2. 初始化串口0为控制台 */ uart_init(0, 115200); rt_console_set_device(uart0); /* 3. 初始化Cortex-A7私有定时器为SysTick源 */ rt_hw_timer_init(); /* 设置定时器中断频率为1000Hz (1ms tick) */ rt_hw_timer_start(1000); /* 4. 初始化内存堆 */ rt_system_heap_init((void*)0x80010000, (void*)0x80200000); /* 5. 板级外设初始化如GPIO、I2C等可以放在这里或后期按需初始化 */ rt_hw_pin_init(); #ifdef RT_USING_COMPONENTS_INIT /* RT-Thread 组件自动初始化 */ rt_components_board_init(); #endif }4.4 配置构建系统rtconfig.py SConscriptrtconfig.py定义全局编译选项、工具链路径、模块使能开关。import os # 工具链前缀 CROSS_TOOL gcc if CROSS_TOOL gcc: PLATFORM gcc EXEC_PATH r/your/path/to/gcc-arm-11.2/bin/ TARGET_EXT elf CC os.path.join(EXEC_PATH, arm-none-eabi-gcc) CXX os.path.join(EXEC_PATH, arm-none-eabi-g) AS os.path.join(EXEC_PATH, arm-none-eabi-as) AR os.path.join(EXEC_PATH, arm-none-eabi-ar) LINK os.path.join(EXEC_PATH, arm-none-eabi-gcc) ... # RT-Thread内核与组件配置 RT_NAME RT-Thread RT_USING_SMP False # 第一阶段先关闭SMP RT_USING_CONSOLE True RT_USING_DEVICE True RT_USING_TIMER_SOFT False RT_USING_PIN True ...SConscript描述如何编译本BSP目录下的源文件并链接到内核。from building import * cwd GetCurrentDir() src Glob(*.c) Glob(drivers/*.c) startup [startup_gcc.S] group DefineGroup(BSP, src, depend [], CPPPATH [cwd, cwd /drivers]) objs [group] objs.append(DefineGroup(Startup, startup)) Return(objs)完成以上步骤后在bsp/r128-s3目录下执行scons命令应该能成功编译生成rtthread.elf文件。通过调试器将其加载到SRAM的起始地址0x00020000并复位CPU如果串口有输出RT-Thread的版本信息和msh 提示符那么恭喜你单核RT-Thread已经成功运行5. 第二阶段引导CPU1与启动Linux这是AMP模式的核心。我们的目标是CPU0上的RT-Thread启动后由它来设置CPU1的启动参数唤醒CPU1并引导其跳转到Linux内核镜像所在的内存地址。5.1 准备Linux内核镜像首先你需要为你的目标板编译一个Linux内核。这通常涉及配置内核make ARCHarm menuconfig确保包含对您芯片SoC的支持、设备树Device Tree以及必要的驱动。编译后会得到zImage压缩的内核镜像和对应的设备树二进制文件.dtb。我们需要将这两个文件加载到DDR内存中。一个简单的方法是将它们打包到RT-Thread的镜像中或者通过调试器直接写入到DDR的某个固定地址例如0x81000000。我们假设zImage在0x81000000设备树在0x82000000。5.2 CPU1的启动流程设计Cortex-A7多核上电后通常只有CPU0Primary Core开始执行代码CPU1Secondary Core处于等待状态WFE。CPU0需要通过写寄存器向CPU1发送一个事件通常是一个核间中断SGI并设置好CPU1开始执行的地址称为“启动地址”或“旋转地址”spin-table地址。设置CPU1的启动地址在共享内存中例如0x8000F000定义一个简单的“旋转代码”区域。这段代码通常就是一条跳转指令跳转到真正的Linux内核入口。但更常见的做法是直接设置一个函数指针地址。ARM Linux内核使用的是一种叫spin-table的方法。我们在内存中约定一个位置比如0x8000F000CPU0将Linux内核的入口地址kernel_entry_addr写在这里并将该地址的“状态”字段设置为可跳转。CPU1被唤醒后会轮询这个地址直到状态有效然后读取入口地址并跳转。唤醒CPU1在RT-Thread中我们需要实现唤醒函数。这涉及到操作GIC通用中断控制器或芯片特定的核间通信寄存器发送一个特定的SGISoftware Generated Interrupt中断给CPU1。#define CPU1_WAKEUP_ADDR 0x8000F000 void rt_hw_secondary_cpu_up(void) { uint32_t *spin_table (uint32_t*)CPU1_WAKEUP_ADDR; /* 步骤1: 将Linux内核入口地址写入约定位置 */ /* 假设kernel_entry是Linux内核的物理入口地址例如0x80008000 */ uint32_t kernel_entry 0x80008000; spin_table[0] kernel_entry; /* 入口地址 */ spin_table[1] 1; /* 状态标志设为1表示“go” */ /* 数据同步屏障确保写入对CPU1可见 */ __DSB(); /* 步骤2: 发送SGI中断唤醒CPU1 */ /* 写入GIC的寄存器发送SGI ID 0 给 CPU1 */ /* 寄存器地址和操作需查阅芯片手册 */ uint32_t *gicd_sgir (uint32_t*)0x1F001000; /* GIC Distributor SGIR寄存器示例地址 */ *gicd_sgir (1 24) | (0x1 16) | 0; /* Target CPU1, SGI ID 0 */ }5.3 Linux启动参数传递ATAGS / Device TreeCPU1启动Linux时需要传递一些参数比如内存大小、命令行参数cmdline、设备树地址等。现在主流使用设备树DTB。我们需要将设备树二进制文件的物理地址通过寄存器r2传递给Linux内核。 在CPU0设置启动地址的代码中我们不仅要设置内核入口还要按照Linux内核的spin-table或psci协议的要求设置好r00, r1机器ID, r2设备树地址。对于spin-table这些参数通常放在约定内存结构的特定字段里。5.4 RT-Thread侧的核间通信IPC基础CPU0和CPU1现在运行Linux需要交换数据。最基础的IPC是共享内存核间中断。定义共享内存区域在链接脚本中预留一段不被RT-Thread和Linux使用的内存区域。例如在DDR末尾预留64KB。/* 在link.lds的MEMORY中增加 */ SHAREDMEM (rw) : ORIGIN 0x83FF0000, LENGTH 64K在board.c中初始化时不要将这片区域加入系统堆。实现简单的邮箱机制在共享内存中定义一个结构体包含数据缓冲区、读/写索引、状态标志等。RT-Thread和Linux侧的驱动都需要能够访问这个结构体。中断通知当RT-Thread向共享内存写入数据后通过发送SGI中断例如ID 1通知Linux侧有数据到达。反之Linux侧也可以通过写寄存器触发CPU0的IRQ。这需要双方正确配置GIC分配好私有中断号。踩坑实录核间中断的调试非常棘手。务必确保双方操作的是同一个GIC实例并且中断号、目标CPU掩码设置正确。一个有效的调试方法是先让RT-Thread发送一个中断然后在Linux下用cat /proc/interrupts查看该SGI中断是否被CPU1接收并计数。同样Linux发送中断后可以在RT-Thread的中断处理函数中打印日志来确认。6. 系统集成与调试实战将上述所有模块整合并烧录到板子上进行调试。6.1 镜像打包与烧录我们需要一个最终的可烧录镜像如.bin或.img文件。这个镜像应该包含RT-Thread的二进制代码从SRAM起始地址开始。预留的共享内存区域初始化为0。Linux的zImage和.dtb文件放在约定的DDR地址。可以使用dd命令或者编写简单的打包脚本将这些二进制块拼接成一个文件。烧录工具可以使用全志官方的PhoenixSuit或LiveSuit或者通过调试器的Flash编程功能。6.2 上电调试流程连接调试器与串口将JTAG/SWD调试器和两个串口RT-Thread console, Linux console都连接到电脑。加载并单步调试启动文件首先只加载RT-Thread的ELF文件到SRAM在_start处设置断点单步执行观察SP是否设置正确BSS段是否清零能否成功跳转到rtthread_startup。观察RT-Thread启动日志全速运行在第一个串口看到RT-Thread的启动信息并进入msh。手动触发CPU1启动在RT-Thread的msh中执行一个命令例如secondary_boot该命令调用我们写的rt_hw_secondary_cpu_up()函数。观察Linux启动日志此时第二个串口连接着Linux的调试UART应该开始滚动输出Linux内核的启动信息直到出现Linux登录提示符。测试基础IPC在RT-Thread侧编写一个测试线程周期性地向共享内存写入数据并发送SGI。在Linux侧编写一个内核模块或用户空间程序通过devmem或自定义驱动读取共享内存并验证数据正确性。反之亦然。6.3 常见问题与排查技巧实录问题现象可能原因排查思路与解决方法RT-Thread无任何串口输出1. 启动地址错误2. 时钟未初始化3. 串口引脚复用或配置错误4. 栈指针设置错误导致早期崩溃1. 确认烧录地址与链接脚本ENTRY(_start)及芯片启动模式匹配。2. 在startup_gcc.S的最开始在初始化栈之前添加几行汇编代码通过操作GPIO点亮一个LED确认芯片已运行。3. 使用调试器单步跟启动代码检查执行流是否异常跳转。RT-Thread启动后卡在某个组件初始化1. 堆内存范围设置错误2. 定时器中断未正确配置3. 驱动依赖的硬件未初始化1. 检查rt_system_heap_init传入的地址范围是否有效、未与其他区域重叠。2. 确认SysTick定时器中断号、优先级配置正确中断处理函数被调用。3. 使用list_device命令查看设备是否成功注册。执行secondary_boot后Linux无输出1. CPU1启动地址设置错误2. 核间中断未成功送达3. Linux内核镜像或设备树地址错误4. CPU1的底层状态如MMU、缓存未正确设置1. 在RT-Thread中在发送SGI前先读取共享内存的启动地址打印出来确认是否正确。2. 在Linux内核源码的secondary_startup或spin_table相关代码处添加早期打印可能需要修改内核源码并重新编译看CPU1是否被唤醒并执行。3. 使用调试器同时连接两个核在CPU1的启动地址处设置断点看CPU1的PC指针是否跳转过去。4. 确保CPU0在唤醒CPU1前已禁用CPU1的MMU和缓存或者Linux内核的入口代码能处理这种状态。核间通信数据不一致1. 缓存一致性问题2. 共享内存地址映射不一致3. 数据竞争未加锁1.这是AMP下最常见也最头疼的问题在RT-Thread和Linux侧访问共享内存前执行缓存失效Invalidate操作写入共享内存后执行缓存写回Clean操作。对于Cortex-A7使用CP15协处理器指令或__DSB(),__DMB()内存屏障。2. 确认双方访问的是相同的物理地址。RT-Thread通常用物理地址Linux内核模块需要用ioremap或memremap将物理地址映射到内核虚拟地址。3. 对共享内存中的关键数据结构使用原子操作或自旋锁。Linux启动后系统不稳定1. 内存冲突RT-Thread与Linux使用重叠内存2. 中断冲突双方配置了相同的中断源1. 严格划分内存空间。在Linux的设备树中通过/memreserve/节点保留出RT-Thread和共享内存使用的物理地址范围防止Linux内核使用。2. 在设备树中明确分配中断资源。通常核间通信的SGI中断0-15是预留给软件使用的可以安全分配。硬件外设中断需要协商好归属避免一个中断被两个OS同时配置和响应。6.4 性能优化与稳定性考量当双核系统基本跑通后就需要考虑优化和长期运行了。缓存与内存屏障如前所述这是AMP模式数据一致性的生命线。务必在每一次跨核数据访问前后使用合适的内存屏障指令DSB,DMB,ISB。中断隔离除了通信用的SGI确保两个OS管理的中断源完全隔离。可以通过硬件设计外设连接到不同核或软件配置在GIC中分配中断实现。看门狗与系统监控考虑为RT-Thread和Linux分别设置看门狗。或者设计一个“心跳”机制通过核间通信互相报告健康状态一旦一方异常另一方可以执行安全恢复操作如重启对方或整个系统。调试信息分离将两个OS的调试串口分开是非常明智的选择。如果资源有限只能共用一个串口则需要设计严格的日志协议避免输出交织混乱。7. 总结与展望移植工作到这一步一个双核Cortex-A7分别运行RT-Thread和Linux的混合实时系统已经搭建起来了。这个过程就像在芯片上划分出两个独立的“王国”RT-Thread王国精干高效负责时间紧迫的任务Linux王国资源丰富负责处理复杂业务。而共享内存和核间中断就是连接两个王国的“高速公路”和“驿道”。回顾整个过程最耗费精力的往往不是代码编写而是调试尤其是涉及到底层硬件操作、缓存一致性、双核同步的时候。我的体会是一定要善用调试工具无论是单步跟踪、内存查看还是利用GPIO点灯这种最原始但有效的调试手段。同时充分阅读芯片手册和ARM架构手册理解每一行配置代码背后的硬件行为是解决问题的根本。这个移植框架还有很多可以深化和扩展的地方。例如可以完善RT-Thread侧的驱动生态使其能直接管理更多板载外设可以优化核间通信协议实现更高效、更稳定的消息队列或远程过程调用RPC机制甚至可以探索在RT-Thread侧运行一些轻量级的网络协议栈与Linux侧的应用进行更丰富的交互。双核A7的平台为我们提供了兼顾实时性与通用性的强大硬件基础而RT-Thread的灵活性与开放性则让这种混合架构的软件设计成为可能。希望这篇笔记能为你打开一扇门在嵌入式混合系统设计的道路上走得更远。