XCZU2EG双核AMP实战:从VITIS工程搭建到Cache一致性与IPI避坑

发布时间:2026/10/27 9:02:57
XCZU2EG双核AMP实战:从VITIS工程搭建到Cache一致性与IPI避坑

简介:本资源面向FPGA嵌入式开发工程师与高校研究者,聚焦XCZU系列MPSoC(含XCZU2CG、XCZU2EG、XCZU4EV)在VITIS平台下的双核AMP驱动实现,解决异构多核任务划分、核间通信与协同调度等工程问题,适合具备Zynq UltraScale+基础、希望深入掌握AMP架构的中高级开发者。压缩包共约2000个文件,整体70.39MB,以C源码与头文件为主体,辅以汇编、链接脚本、Makefile、Tcl脚本、Vivado工程文件及少量比特流与设备树,覆盖从硬件配置到软件编译的完整链路。目前已有108人学习下载。资源完整呈现双核职责划分、中断管理与数据共享协议设计,并包含驱动开发、任务调度、性能优化与调试验证等关键环节的工程痕迹,读者可据此理解VITIS工具链下AMP系统的构建流程与排错思路,为自动驾驶、图像处理、网络通信等低延迟并行场景提供可参考的实现范式。

1. 双核AMP不是“跑两个裸机”那么简单:XCZU2EG上把两颗Cortex-A53拆开用的真实代价

很多人第一次在 XCZU2EG 上听到 AMP(Asymmetric Multiprocessing,非对称多处理),脑子里浮现的画面是“两个核各跑各的裸机程序,互不打扰”。真上手才发现,共享内存的 cache 一致性、中断路由、启动顺序、链接地址,每一项都能让系统在启动三秒后静默挂死。XCZU2EG 属于 Zynq UltraScale+ MPSoC 的 EG 系列,双核 Cortex-A53 加双核 Cortex-R5F,PL 侧逻辑资源适中,做工业控制、电机驱动、协议转换这类“一颗核管实时、一颗核管交互”的场景非常合适。AMP 的价值就在于把 Linux 的复杂度和裸机/RTOS 的确定性拆到不同核上,而不是让一颗核既当爹又当妈。这篇笔记面向已经能点亮 ZU2EG 板子、想在 VITIS 里把双核 AMP 真正跑起来的工程师,从工程创建一路写到 cache 和中断的坑。

2. 在VITIS里搭出双核AMP工程:从platform到两个domain的完整落地

2.1 先想清楚为什么用AMP而不是SMP

SMP 让两颗 A53 跑同一个 Linux,调度器帮你分配任务,写起来省心,但实时性完全交给内核的 CFS 调度器,中断延迟抖动大。AMP 的核心思路是“分而治之”:CPU0 跑 Linux 或一个主控裸机,负责网络、文件系统、人机界面;CPU1 跑一个独立的裸机或 FreeRTOS 应用,专门处理对时序敏感的任务,比如 1ms 周期的电机环、高速 ADC 数据搬运。两者通过共享内存和 IPI(核间中断)通信。

选 AMP 的硬理由通常有三条:第一,实时任务不能被 Linux 的调度和中断关闭窗口拖累;第二,CPU1 的代码可以完全独立编译、独立烧录,出问题不影响主控;第三,某些外设(比如 PL 侧的 AXI 从机)需要一颗核独占访问,避免锁竞争。代价也很直接:你得自己管内存映射、自己写核间通信协议、自己处理 cache 一致性。VITIS 提供的 domain 机制,本质上是帮你把两个独立的 BSP 和链接脚本塞进同一个 platform 里,但核间那层“胶水”还得自己写。

2.2 创建platform与两个standalone domain

打开 VITIS,先建 platform project,选 XCZU2EG 对应的器件型号(具体以你板子上的实际丝印为准,ZU2EG 有不同封装和速度等级)。在 platform 里添加两个 domain:一个给 psu_cortexa53_0,一个给 psu_cortexa53_1。两个 domain 都选 standalone,不要一个 Linux 一个 standalone 混搭,除非你明确知道 Linux 侧的 remoteproc 怎么配。

关键操作在 domain 的 BSP 设置里:CPU0 的 domain 默认使用 DDR 低地址段,CPU1 的 domain 必须把链接地址挪到另一块不重叠的区域。常见做法是 CPU0 用 0x00000000 起始的 2GB,CPU1 用 0x80000000 起始的 512MB。这个地址不是随便拍的,要对照 ZU2EG 的 DDR 控制器映射和你的板子实际内存大小。如果两个 domain 的 .text 或 .data 段有重叠,编译能过,但运行到某一刻 CPU1 会跑飞,现象是串口突然没输出,调试器连上去发现 PC 指针在非法地址。

# 在 VITIS 的 platform 工程里,domain 的 linker script 通常由 BSP 自动生成 # 但你需要手动确认 CPU1 的 lscript.ld 里 MEMORY 区域 # 打开 CPU1 domain 的 lscript.ld,检查: MEMORY { psu_ddr_0_MEM_0 : ORIGIN = 0x80000000, LENGTH = 0x20000000 psu_ocm_ram_0_MEM_0 : ORIGIN = 0xFFFC0000, LENGTH = 0x40000 } # 而 CPU0 的 lscript.ld 应该是: MEMORY { psu_ddr_0_MEM_0 : ORIGIN = 0x00000000, LENGTH = 0x80000000 psu_ocm_ram_0_MEM_0 : ORIGIN = 0xFFFC0000, LENGTH = 0x40000 }

上面这段链接脚本的差异是 AMP 能跑起来的第一道门槛。ORIGIN 决定代码段起始,LENGTH 决定可用范围。两个 domain 的 DDR 区域绝对不能交叠,OCM(On-Chip Memory)可以共用,但如果你在 OCM 里放共享数据,要自己保证访问时序。改完链接脚本后,分别编译两个 domain,VITIS 会生成两个 elf 文件。注意:不要用同一个 CPU 的 domain 去编译两份代码,VITIS 的 domain 是跟 CPU 核绑定的,选错核编译出来的 elf 烧进去行为完全不对。

2.3 启动顺序与FSBL的改造

ZU2EG 的启动流程是 BootROM → FSBL → 你的应用。默认 FSBL 只加载一个 elf 到 CPU0,要让 CPU1 也跑起来,得改 FSBL 或者用 VITIS 的“Run Configuration”分别加载。调试阶段最简单的方式是在 VITIS 里建两个 Run Configuration,一个 attach 到 psu_cortexa53_0,一个 attach 到 psu_cortexa53_1,分别下载各自的 elf。但量产时不能靠 IDE,得让 FSBL 把两个 elf 都搬进 DDR。

常见做法是在 FSBL 的 main.c 里,加载完 CPU0 的 elf 后,手动把 CPU1 的 elf 从 Flash 拷贝到 0x80000000,然后写 CPU1 的入口地址到对应的复位向量寄存器。这部分代码 VITIS 不会自动生成,得自己加。一个容易翻车的点是:CPU1 的入口地址必须 4 字节对齐,且写入复位向量后需要执行一次 SEV 指令唤醒 CPU1。如果 CPU1 一直没输出,先查复位向量寄存器的值对不对,再查 CPU1 的 elf 是不是真的被搬到了链接地址上。

// FSBL 中加载 CPU1 应用的简化逻辑(伪代码,具体寄存器名以 ZU2EG TRM 为准) #define CPU1_ENTRY_ADDR 0x80000000 #define CPU1_RESET_VEC 0xFFFF0000 // 举例,实际查手册 // 1. 从 QSPI 或 SD 卡读取 cpu1.elf 到 DDR 0x80000000 read_elf_to_ddr("cpu1.elf", CPU1_ENTRY_ADDR); // 2. 写 CPU1 的复位向量 Xil_Out32(CPU1_RESET_VEC, CPU1_ENTRY_ADDR); // 3. 执行 SEV 唤醒 CPU1 __asm__ volatile("sev");

这段代码的逻辑是:先把 CPU1 的二进制搬到它链接时约定的地址,然后告诉硬件“CPU1 从这里开始执行”,最后用 SEV 指令触发事件唤醒。参数里 CPU1_ENTRY_ADDR 必须和 CPU1 domain 的链接脚本 ORIGIN 完全一致,差一个字节都会跑飞。CPU1_RESET_VEC 的具体地址每个 MPSoC 型号不同,查 ZU2EG 的技术参考手册。如果你在 FSBL 里读 elf 失败,检查 Flash 分区表和文件系统格式,别在文件路径上浪费一整天。

3. 共享内存与核间中断:AMP的“通信黑匣子”怎么搭才不丢数据

3.1 共享内存的地址规划与cache一致性

两颗 A53 各自有 L1 D-Cache 和共享的 L2 Cache。CPU0 往共享内存写一个结构体,CPU1 读的时候如果 cache 没同步,读到的可能是旧值。这是 AMP 最经典的翻车点:调试时单步走一切正常,全速跑就丢包。解决方式有两种:一是把共享内存区域配置成 non-cacheable,在 MMU 页表里标记;二是用 cache 维护指令,写完后 clean,读之前 invalidate。

我一般会选第一种,省心。在 CPU0 和 CPU1 的 MMU 配置里,把共享内存段(比如 0x70000000 起始的 1MB)设为 Device 或 Normal Non-Cacheable。这样两颗核读写都直接打到 DDR,不存在一致性问题。代价是访问速度慢一点,但对于核间通信这种低频操作完全可接受。如果你非要走 cacheable 路线,记住顺序:写方先写数据,再执行 DC CIVAC(clean and invalidate),然后发 IPI;读方收到 IPI 后先 DC IVAC(invalidate),再读数据。顺序反了就是血泪经验。

// 共享内存结构体定义,放在两个 domain 都能访问的头文件里 typedef struct { volatile uint32_t flag; // 通信标志,volatile 防止编译器优化 volatile uint32_t cmd; // 命令字 volatile uint32_t data[64]; // 数据区 volatile uint32_t checksum; // 校验 } amp_shared_t; // 共享内存基地址,必须在两个 domain 的链接脚本之外单独保留 #define AMP_SHARED_BASE 0x70000000 #define AMP_SHARED ((amp_shared_t *)AMP_SHARED_BASE) // CPU0 发送命令的流程 void cpu0_send_cmd(uint32_t cmd, uint32_t *data, uint32_t len) { for (int i = 0; i < len; i++) { AMP_SHARED->data[i] = data[i]; } AMP_SHARED->cmd = cmd; AMP_SHARED->checksum = calculate_checksum(data, len); AMP_SHARED->flag = 1; // 最后写 flag,保证数据先可见 // 如果共享内存是 cacheable,这里需要 DC CIVAC // Xil_DCacheFlushRange(AMP_SHARED_BASE, sizeof(amp_shared_t)); // 然后发 IPI 给 CPU1 send_ipi_to_cpu1(); }

这段代码的关键在写顺序:先写数据区和命令字,最后写 flag。flag 是“数据就绪”的信号,如果先写 flag 再写数据,CPU1 可能看到 flag=1 但数据还是旧的。volatile 保证每次访问都从内存读,不会被编译器缓存到寄存器。checksum 是可选的,但在调试阶段非常有用,能帮你判断是数据没传过来还是传过来被改了。如果共享内存配成 non-cacheable,DC CIVAC 那行可以注释掉;如果是 cacheable,必须加,且要在写 flag 之前执行。

3.2 IPI中断的配置与触发

ZU2EG 的 IPI 通过 GIC(Generic Interrupt Controller)实现,每个 CPU 核有 16 个软件生成中断(SGI)。CPU0 要通知 CPU1,就是往 GIC 的 SGI 寄存器写 CPU1 的核 ID 和中断号。VITIS 的 standalone BSP 里,GIC 的初始化通常由 XScuGic 驱动完成,但 IPI 的中断服务函数得自己注册。

配置步骤:在 CPU1 的代码里,初始化 GIC,注册 SGI 中断号(比如 0)的处理函数,使能该中断。在 CPU0 的代码里,初始化 GIC 后,通过 XScuGic_SoftwareIntr 函数发送 SGI 给 CPU1。注意:SGI 的中断号范围是 0~15,不要和共享外设中断(SPI)搞混。另一个坑是 GIC 的 CPU 接口初始化,两颗核都要各自初始化自己的 GIC CPU 接口,不能只初始化一颗。

// CPU1 侧:注册 IPI 中断处理函数 #include "xscugic.h" static XScuGic gic_inst; void ipi_handler(void *callback_ref) { // 收到 CPU0 的 IPI,读取共享内存 if (AMP_SHARED->flag == 1) { uint32_t cmd = AMP_SHARED->cmd; // 处理命令 process_command(cmd, (uint32_t *)AMP_SHARED->data); AMP_SHARED->flag = 0; // 清标志,准备下一次 } } int setup_ipi_interrupt(void) { XScuGic_Config *cfg = XScuGic_LookupConfig(GIC_BASEADDR); XScuGic_CfgInitialize(&gic_inst, cfg, cfg->CpuBaseAddress); // 注册 SGI 0 的处理函数 XScuGic_Connect(&gic_inst, 0, (Xil_ExceptionHandler)ipi_handler, NULL); XScuGic_Enable(&gic_inst, 0); // 使能 GIC CPU 接口 Xil_ExceptionInit(); Xil_ExceptionRegisterHandler(XIL_EXCEPTION_ID_INT, (Xil_ExceptionHandler)XScuGic_InterruptHandler, &gic_inst); Xil_ExceptionEnable(); return 0; } // CPU0 侧:发送 IPI 给 CPU1 void send_ipi_to_cpu1(void) { // 目标 CPU1 的核 ID,ZU2EG 上 A53_1 通常是 0x01 XScuGic_SoftwareIntr(&gic_inst, 0, 0x01); }

CPU1 的代码里,setup_ipi_interrupt 完成 GIC 初始化和 SGI 0 的注册。XScuGic_Connect 把中断号和你的处理函数绑起来,XScuGic_Enable 使能该中断。Xil_ExceptionRegisterHandler 注册的是 GIC 的总中断处理入口,这一步不能漏,否则中断来了没人管。CPU0 的 send_ipi_to_cpu1 里,XScuGic_SoftwareIntr 的第三个参数是目标 CPU 的掩码,ZU2EG 上 A53_0 是 0x01,A53_1 是 0x02,具体查 GIC 手册。如果 CPU1 收不到中断,先确认 GIC 的 CPU 接口有没有使能,再确认 SGI 中断号有没有被其他代码占用。

3.3 一个可复现的核间通信验证流程

搭好共享内存和 IPI 后,别急着上业务逻辑,先写一个最小验证:CPU0 每隔一秒往共享内存写一个递增的计数器,发 IPI;CPU1 收到后把计数器加 1 写回另一个位置,再发 IPI 给 CPU0;CPU0 收到后打印。这个来回能跑通,说明内存映射、cache 配置、中断路由都对了。

验证时用串口打印,CPU0 和 CPU1 各用一个 UART,或者共用一个 UART 但加互斥。如果只有一个串口,建议 CPU0 打印,CPU1 把结果写到共享内存让 CPU0 代打。跑通后,把计数器换成实际命令,再逐步加数据量。每次加数据量后观察是否丢包,丢包通常是 cache 没维护或者 IPI 发太快导致覆盖。IPI 本身不排队,CPU1 还没处理完上一次,CPU0 又发一次,flag 被覆盖就丢数据。解决办法是加握手:CPU0 发之前检查 flag 是否为 0,不为 0 就等待或重试。

4. 双核AMP避坑实录:从启动挂死到cache翻车的5个具体排查

4.1 现象:CPU1没有任何串口输出,调试器也连不上

原因:CPU1 的 elf 没有被正确加载到 DDR,或者复位向量写错了。FSBL 默认只加载 CPU0 的 elf,CPU1 的加载逻辑需要自己加。另一个可能是 CPU1 的链接地址和 CPU0 重叠,导致 CPU0 跑起来后把 CPU1 的代码覆盖了。

解决:先用 VITIS 的 Run Configuration 手动 attach CPU1,看能不能下载 elf 并运行。如果能,说明 elf 本身没问题,问题在 FSBL 加载流程。检查 FSBL 里读 elf 的代码,确认读到的数据长度和 elf 文件大小一致。再检查 CPU1 的链接脚本 ORIGIN,确保和 FSBL 里的加载地址一致。最后用 XSCT(Xilinx Software Command-line Tool)读 CPU1 的复位向量寄存器,看值对不对。

4.2 现象:CPU1能跑,但一访问共享内存就挂

原因:共享内存区域没有在 MMU 里正确配置,CPU1 访问了未映射的地址,触发数据 abort。或者共享内存被配成了 cacheable,但没做 cache 维护,读到了非法数据导致后续逻辑跑飞。

解决:检查两个 domain 的 MMU 配置表,确认共享内存段有映射,且属性是 non-cacheable 或正确维护。在 CPU1 的 translation table 里,找到共享内存地址对应的页表项,看 descriptor 的 bit 2~4 是不是配置成了 Normal Non-Cacheable。如果不确定,先把共享内存改成 non-cacheable 跑一遍,能通再试 cacheable。

4.3 现象:核间通信偶尔丢包,跑几分钟丢一次

原因:IPI 发送太快,CPU1 还没处理完上一次的 flag,CPU0 又写了新数据并置 flag,导致旧数据被覆盖。或者共享内存是 cacheable,CPU0 写完数据后没有 clean cache,CPU1 读到的是 cache 里的旧值。

解决:加握手协议。CPU0 发数据前检查 flag 是否为 0,不为 0 就等待或记录丢包计数。如果共享内存是 cacheable,在写 flag 之前执行 DC CIVAC,CPU1 收到 IPI 后先 DC IVAC 再读数据。用 checksum 验证数据完整性,丢包时打印 checksum 对比,能快速定位是覆盖还是 cache 问题。

4.4 现象:VITIS编译报collect2.exe错误,提示找不到某个符号

原因:两个 domain 的 BSP 里引用了同一个库,但链接时符号重复定义。或者 CPU1 的代码里用了 CPU0 的 BSP 函数,但 CPU1 的 domain 没有包含对应的库。

解决:检查两个 domain 的 BSP 设置,确保各自只包含自己需要的库。如果两个 domain 都要用同一个驱动(比如 UART),在每个 domain 的 BSP 里分别使能,不要跨 domain 引用。collect2.exe 错误通常是链接阶段的符号冲突,看错误信息里提到的符号名,去对应的源文件里搜,确认它被编译进了哪个 domain。

4.5 现象:系统跑一段时间后CPU1无响应,复位才能恢复

原因:CPU1 的代码里发生了未处理的异常(比如数据 abort、预取 abort),但没有异常处理函数,CPU1 跳到了非法地址。或者 CPU1 的栈溢出,踩到了其他数据区。

解决:在 CPU1 的代码里注册异常处理函数,至少把异常类型和地址打印出来。检查 CPU1 的栈大小,standalone BSP 默认的栈可能只有几 KB,如果用了递归或大局部变量,很容易溢出。在链接脚本里把 CPU1 的栈改大,比如 64KB,并放在独立的段里。如果异常处理函数打印出数据 abort,检查访问的地址是否在 MMU 映射范围内。

5. 用XSCT脚本自动化双核加载:量产时不用手点VITIS

调试阶段用 VITIS 的 Run Configuration 没问题,但量产时每次手点太不现实。XSCT 是 Xilinx 的命令行工具,可以脚本化完成 platform 初始化、elf 下载、CPU 启动。我一般会写一个 tcl 脚本,把 FSBL、CPU0 elf、CPU1 elf 的加载和启动全部串起来,产线工人只需要跑一条命令。

# xsct_amp_load.tcl # 连接目标板 connect # 复位系统 rst -system # 下载 FSBL dow fsbl.elf # 运行 FSBL,等待 DDR 初始化完成 con after 2000 stop # 下载 CPU0 应用 dow cpu0_app.elf # 下载 CPU1 应用 dow cpu1_app.elf # 启动 CPU0 con -id 0 # 启动 CPU1 con -id 1 # 打印状态 puts "AMP system started"

这个脚本的逻辑是:先连接 JTAG,复位整个系统,下载 FSBL 并运行,等 DDR 初始化完成后暂停。然后分别下载 CPU0 和 CPU1 的 elf,最后用 con -id 分别启动两颗核。after 2000 是等 2 秒,具体时间看你的 DDR 初始化速度,不够就加。con -id 0 和 con -id 1 分别对应两颗 A53。如果 CPU1 没起来,检查 dow 的时候有没有指定正确的核,XSCT 默认可能下到 CPU0。

脚本跑通后,把它集成到产线工具里,每次上电自动加载。注意:XSCT 脚本里的 elf 路径要用绝对路径,相对路径在不同工作目录下会找不到文件。另外,如果板子的启动模式是 QSPI 或 SD,XSCT 的 JTAG 加载会和 BootROM 冲突,需要先设置启动模式为 JTAG 或者用 FSBL 从 Flash 加载。我习惯在脚本开头加一句 puts 打印当前时间,方便产线记录每块板子的加载耗时,超过阈值就报警。

这套方案值不值得做?如果你的产品需要一颗核跑实时控制、一颗核跑通信协议,且对启动时间有要求,AMP 是绕不开的。ZU2EG 的双核 A53 加 PL 资源,做 EtherCAT 从站、多轴电机驱动、高速数据采集都够用。坑主要在 cache 和启动流程,把这两块摸透,后面就是常规的嵌入式开发。希望帮到你。

本文还有配套的精品资源,点击获取