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 上听到 AMPAsymmetric Multiprocessing非对称多处理脑子里浮现的画面是“两个核各跑各的裸机程序互不打扰”。真上手才发现共享内存的 cache 一致性、中断路由、启动顺序、链接地址每一项都能让系统在启动三秒后静默挂死。XCZU2EG 属于 Zynq UltraScale MPSoC 的 EG 系列双核 Cortex-A53 加双核 Cortex-R5FPL 侧逻辑资源适中做工业控制、电机驱动、协议转换这类“一颗核管实时、一颗核管交互”的场景非常合适。AMP 的价值就在于把 Linux 的复杂度和裸机/RTOS 的确定性拆到不同核上而不是让一颗核既当爹又当妈。这篇笔记面向已经能点亮 ZU2EG 板子、想在 VITIS 里把双核 AMP 真正跑起来的工程师从工程创建一路写到 cache 和中断的坑。2. 在VITIS里搭出双核AMP工程从platform到两个domain的完整落地2.1 先想清楚为什么用AMP而不是SMPSMP 让两颗 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 起始的 2GBCPU1 用 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 区域绝对不能交叠OCMOn-Chip Memory可以共用但如果你在 OCM 里放共享数据要自己保证访问时序。改完链接脚本后分别编译两个 domainVITIS 会生成两个 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 CIVACclean and invalidate然后发 IPI读方收到 IPI 后先 DC IVACinvalidate再读数据。顺序反了就是血泪经验。// 共享内存结构体定义放在两个 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 可能看到 flag1 但数据还是旧的。volatile 保证每次访问都从内存读不会被编译器缓存到寄存器。checksum 是可选的但在调试阶段非常有用能帮你判断是数据没传过来还是传过来被改了。如果共享内存配成 non-cacheableDC CIVAC 那行可以注释掉如果是 cacheable必须加且要在写 flag 之前执行。3.2 IPI中断的配置与触发ZU2EG 的 IPI 通过 GICGeneric 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 的核 IDZU2EG 上 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 是 0x01A53_1 是 0x02具体查 GIC 手册。如果 CPU1 收不到中断先确认 GIC 的 CPU 接口有没有使能再确认 SGI 中断号有没有被其他代码占用。3.3 一个可复现的核间通信验证流程搭好共享内存和 IPI 后别急着上业务逻辑先写一个最小验证CPU0 每隔一秒往共享内存写一个递增的计数器发 IPICPU1 收到后把计数器加 1 写回另一个位置再发 IPI 给 CPU0CPU0 收到后打印。这个来回能跑通说明内存映射、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 的 elfCPU1 的加载逻辑需要自己加。另一个可能是 CPU1 的链接地址和 CPU0 重叠导致 CPU0 跑起来后把 CPU1 的代码覆盖了。解决先用 VITIS 的 Run Configuration 手动 attach CPU1看能不能下载 elf 并运行。如果能说明 elf 本身没问题问题在 FSBL 加载流程。检查 FSBL 里读 elf 的代码确认读到的数据长度和 elf 文件大小一致。再检查 CPU1 的链接脚本 ORIGIN确保和 FSBL 里的加载地址一致。最后用 XSCTXilinx 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 还没处理完上一次的 flagCPU0 又写了新数据并置 flag导致旧数据被覆盖。或者共享内存是 cacheableCPU0 写完数据后没有 clean cacheCPU1 读到的是 cache 里的旧值。解决加握手协议。CPU0 发数据前检查 flag 是否为 0不为 0 就等待或记录丢包计数。如果共享内存是 cacheable在写 flag 之前执行 DC CIVACCPU1 收到 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 或 SDXSCT 的 JTAG 加载会和 BootROM 冲突需要先设置启动模式为 JTAG 或者用 FSBL 从 Flash 加载。我习惯在脚本开头加一句 puts 打印当前时间方便产线记录每块板子的加载耗时超过阈值就报警。这套方案值不值得做如果你的产品需要一颗核跑实时控制、一颗核跑通信协议且对启动时间有要求AMP 是绕不开的。ZU2EG 的双核 A53 加 PL 资源做 EtherCAT 从站、多轴电机驱动、高速数据采集都够用。坑主要在 cache 和启动流程把这两块摸透后面就是常规的嵌入式开发。希望帮到你。本文还有配套的精品资源点击获取