Zynq AMP双核通信实战:基于Vitis 2023/2024的完整实现与踩坑指南
最开始接触Zynq的时候我犯过一个很典型的错误拿着7010却始终只用一个核跑业务另一个核闲着。后来为了做一个既要求实时控制、又需要跑协议栈的项目才开始认真研究AMP双核通信。用Vitis 2023.1到2024.1这一路做下来发现网上大量资料还停留在老的SDK和2019版本的流程上版本一变界面、菜单、烧写方式都不一样照着老教程走很容易卡死。这篇就把我这段时间在zynq 7010/020上基于Vitis 2023/2024实现AMP双核通信的完整思路、具体操作和踩坑过程整理出来。里面既包括FSBL如何把CPU1镜像搬到内存、共享内存怎么划分、SGI中断怎么触发这类核心机制也包括我实际调试时遇到的“CPU1启动即跑飞”“共享数据总是对不上”等真实问题希望给准备上双核的同学一点参考。1. 先从原理说起Zynq双核的AMP模式到底解决什么问题1.1 为什么用AMP而不是SMPZynq的ARM Cortex-A9是双核但很多第一次接触的人会把双核和SMP画等号——以为只要上了Linux、系统自动调度两个核就是双核了。SMP确实是这样但AMP完全是另一回事两个核各自运行独立的程序甚至可以是完全不同的操作系统或者裸机程序彼此通过硬件机制显式通信。为什么要AMP我举一个实际场景某设备需要在一个核上跑Modbus/CanOpen协议栈外部响应要求微秒级另一个核需要跑复杂的任务调度或与PL里的逻辑交互。如果用SMPLinux调度器可能随时把任务切到不同核上缓存和中断的确定性会打折扣如果两个应用都想独立可控AMP反而是更可靠的方案。用餐厅类比SMP像两个厨师共用一份菜单、听同一个领班调度AMP像两个独立工作室各做各的菜但要用同一个走廊传菜共享内存和按铃中断协调。很多业务其实属于后者——各干各的只交换少量结果数据。1.2 两颗核的“君臣”关系CPU0引导CPU1在Zynq的默认启动流程里上电后只有CPU0会执行BootROMCPU1处于WFI状态由CPU0通过SEV事件唤醒。完整过程是BootROM加载FSBL到OCMFSBL在CPU0上执行FSBL初始化DDR、复位控制器、配置PS端的时钟和MIOFSBL根据Boot Image的partition信息把CPU0应用拷贝到指定DDR地址跳转执行在AMP模式下如果CPU1的ELF也被打包进Boot ImageFSBL会同时把它拷贝到CPU1的指定地址CPU0可能是FSBL也可能是应用代码向地址0xFFFFFFF0写入CPU1的入口地址然后执行SEV指令CPU1被唤醒后从0xFFFFFFF0读出入口地址并跳转过去执行。这里有个核心点0xFFFFFFF0是CPU1的启动地址寄存器。只要往这个地址写值再触发SEVCPU1就会跳到对应地址。在Vitis环境下CPU0应用里可以直接用Xil_StartCpu(1, CPU1_START_ADDR)完成这一步而不需要自己写汇编。1.3 7010和7020在AMP通信上几乎没有差别把7010和7020放在一起写是因为双核ARM Cortex-A9在这两颗芯片上完全一致。7010和7020的区别主要在PL侧逻辑单元、DSP、BRAM数量完全不同7020大约是7010的3倍资源。但PS端的CPU主频、Cache、GIC、DDR控制器基本一致所以AMP通信相关的代码、链接地址、驱动完全可以在两片之间直接复用。项目Zynq-7010Zynq-7020逻辑单元28K85KDSP Slice80220Block RAM2.1Mb4.9MbARM Cortex-A9双核主频相同双核主频相同AMP通信代码通用通用实际项目中如果PL只是做简单接口7010把双核跑起来完全够用如果将来要升级PL逻辑后端通讯代码基本不用动这是我们选择板卡时值得考虑的一点。2. Vitis 2023/2024工程搭建别再被老教程带偏2.1 版本选择与老工程的打开方式从2020版本开始Xilinx把SDK整合进Vitis嵌入式开发不再单独叫“SDK”。2023.1、2023.2和2024.1我都用过AMP工程的创建流程大同小异但界面细节一直在变老教程里说的“勾选Enable AMP”的入口在不同版本可能藏在platform设置里。如果你手里有旧版本SDK工程想打开Vitis并不能直接识别老workspace。建议流程打开Vitis后选空workspace然后通过File→Import导入旧应用工程同时需要重新生成platform。最快的办法是回到Vivado里重新导出XSA然后在新Vitis里重建platform和应用工程虽然多花几分钟但能避免一堆兼容性问题。2.2 创建platform时的AMP关键配置在Vitis 2023/2024新建platform操作系统选standalonedomain选ps7_cortexa9_0和ps7_cortexa9_1这两个核会各有一个domain。如果不勾选AMP一般默认只给CPU0创建BSP。具体操作在Vivado中完成硬件设计后File→Export Hardware勾选Include bitstream如果你的设计有PL逻辑纯PS AMP可以不勾得到xsa文件后在Vitis中新建Platform Project选该xsa在platform.spr的Board Support Package页面里确认两个processor的domain都已创建关键一步找到FSBL组件的设置项或platform的启动选项把“CPU1 boot address”设成一个DDR地址比如0x20000000。不同版本这个字段可能显示为“CPU1 Start Address”或“AMP CPU1 Boot Address”如果这个选项不存在还可以在FSBL源码的配置头文件里手动修改比如在xfsbl_config.h或类似位置搜索CPU1_START_ADDR这个宏改成0x20000000改完重新编译platform确保生成的fsbl.elf带AMP支持。这里我要补一句手工改FSBL源码是兜底方案图形界面能设置成功就优先用图形界面因为自带的AMP支持还会处理DDR初始化和CPU1分区加载等细节。2.3 双应用工程的组织方式platform建好后在同一个platform下新建两个Application Project第一个选ps7_cortexa9_0模板选Empty Application作为Core0应用第二个选ps7_cortexa9_1模板选Hello World或Empty Application作为Core1应用编译顺序上建议先platform再两个app。然后打开Core1应用的Linker Script默认情况下所有段都在0x00100000附近这不行必须把Core1的.text、.data、.bss、heap、stack改到0x20000000之后的区域比如text: 0x20000000data/bss: 0x20040000heap/stack: 0x20080000总大小控制在约2MB以内核心原则CPU0和CPU1的应用不能占用同一段DDR。CPU0在0x00100000CPU1的起始地址要比CPU0高两者之间留足余量同时在更高位预留共享内存区。内存规划如果乱套后面调试会非常痛苦。2.4 Boot Image组成与Flash烧写前的注意事项多核应用的boot image要把两个ELF都打进去一般顺序是FSBL、CPU1应用、CPU0应用或按工具推荐的地址排序。用Vitis的Create Boot Image工具添加三个输入fsbl.elf平台编译生成的FSBLcpu1_app.elf作为data partitionload地址设置成CPU1入口地址0x20000000cpu0_app.elf作为boot partitionload地址0x00100000。如果直接对QSPI Flash执行Program FlashVitis报“a valid fsbl file is required for flash operation”是很常见的问题原因就是Program Flash界面需要你提供一个fsbl.elf用于初始化DDR否则它不知道怎么搬运、怎么起boot镜像。这时先把platform工程编译好在Program Flash界面的FSBL File一栏选中生成的fsbl.elf就行。3. 通信实现的关键细节共享内存、缓存一致性与软件中断3.1 地址规划先走一步动手写代码之前我建议先把DDR划分清楚。以1GB DDR为例常用的划分方式是区域地址范围用途CPU0应用0x00100000 ~ 0x20000000Core0裸机程序CPU1应用0x20000000 ~ 0x30000000Core1裸机程序共享内存0x30000000 ~ 0x30001000双核通信缓冲其他DDR以上之外的区域留给可能的Linux或PL DMA共享内存区可以再细分前4KB放控制结构后几KB放数据缓冲。实际项目里如果对实时性要求高可以把这部分映射为cache一致性的non-cache区如果对吞吐量要求高则用cache加flush/invalidate配合。3.2 启动握手先让CPU1“报个到”CPU0通过Xil_StartCpu把CPU1唤醒后CPU1并不一定马上就绪。它要从0x20000000开始执行进入main函数初始化GIC、映射MMU然后才准备接收数据。所以第一个通信动作应该是状态握手而不是直接发业务数据。典型流程CPU1的main函数末尾往共享内存的status字段写0xA5A5_0001表示“Core1已就绪”CPU0启动后在业务循环内轮询共享内存的status设一个超时时间比如500ms如果一直等不到就报错握手完成前CPU0绝对不要给CPU1发SGI中断否则CPU1还没注册中断处理SGI会直接触发默认处理函数或者被忽略。这个步骤看似简单但我见过很多AMP代码没做握手CPU0急着发送结果CPU1的下游状态完全不对。3.3 共享内存的缓存一致性到底怎么解决这是AMP通信里最坑、也最容易被新手忽略的地方。ARM Cortex-A9带了L1 D-CacheCPU在写共享内存时数据很可能停留在cache里并没有真正落到DDRCPU1去读DDR时自然拿到旧值。反方向也一样CPU1的cache里可能留了旧副本CPU0明明写了新数据CPU1却把cache里的旧数据读了出来。处理方式有两种。方式一把共享内存区映射为非缓存。在BSP中通过MMU属性设置最常见做法是在应用的main开头调用#include xil_mmu.h Xil_SetTlbAttributes(0x30000000, NORM_NON_CACHE);这样两个核对这段地址读写时都不经过缓存数据直接走DDR。缺点是非缓存读写的性能会下降一些但通常AMP通信数据量不大几百字节一次性能完全可接受这也是我最推荐的方式。方式二保持cache用flush/invalidate手工同步。写侧的数据流是这样CPU0向共享内存写入数据调用 __DSB() 确保写入指令真正执行调用 Xil_DCacheFlush()把cache中被修改的数据写回DDR发送SGI中断通知CPU1CPU1收到中断后调用 Xil_DCacheInvalidate()先把本地cache中的对应地址旧数据作废再访问共享内存读取新数据。如果这两个动作搞反比如CPU1读之前忘了invalidate就会读到cache里的旧值——这非常隐蔽因为DDR里已经是最新数据了但CPU1自己的cache不配合。还有个更隐蔽的问题两个核对共享内存区的缓存策略必须一致。如果CPU0把该区域映射为非缓存CPU1却映射为write-back那么CPU1在读取、修改时可能产生奇怪现象比如数据丢失、顺序颠倒。经验法则要么两边都非缓存要么两边都Write-back Cache不要混用。3.4 SGI软件中断实现核间通知共享内存负责“数据传送”SGI负责“门铃通知”。裸机下用一个简单的例子说明。CPU0发送消息并通知CPU1// 共享内存结构体 typedef struct __attribute__((packed)) { volatile uint32_t magic; volatile uint32_t cmd; volatile uint32_t length; volatile uint8_t payload[256]; } amp_shared_t; // 发送侧CPU0 memcpy((void *)shared-payload, txBuffer, len); shared-cmd CMD_DATA_READY; shared-length len; __DSB(); Xil_DCacheFlush(); // 如果用cache方案 XScuGic_SoftwareIntr(GicInst, SGI_ID_8, (1 1)); // 发送SGI给CPU1CPU1中断处理侧static void SgiHandler(void *CallbackRef) { Xil_DCacheInvalidate(); // 如果用cache方案 rxLen shared-length; memcpy(rxBuffer, (void *)shared-payload, rxLen); Xil_DCacheFlush(); shared-status STATUS_ACK; __DSB(); // 接收到的数据放到队列真正业务处理放到主循环 }这段代码背后有几件事要注意SGI ID可以选0~15之间的任意值同一处理器上必须先注册中断处理函数再允许对方发送XScuGic_SoftwareIntr函数的第三个参数是目标CPU掩码(1 1)表示发给CPU1如果希望发给CPU0就是(1 0)中断处理完成后没有发“反向SGI”示例实际可以根据协议补一个ACK中断或让CPU0轮询status字段中断处理函数里尽量只做轻量工作把真正的业务处理放到主循环防止长时间关中断影响实时性。3.5 简单的通信协议设计共享内存通信不是裸拷贝就完事建议设计一个带头部的小协议。头部字段至少包含magic、cmd、length、seq数据区之后加crc校验。magic用于识别共享内存是否被错误初始化seq用于区分新旧消息crc用于验证数据完整性。#define SHARED_BASE 0x30000000 #define SHARED_SIZE 0x1000 typedef struct __attribute__((packed)) { volatile uint32_t magic; // 0xA5A5A5A5 volatile uint32_t cmd; volatile uint32_t length; volatile uint32_t seq; volatile uint8_t data[2048]; volatile uint32_t crc; } amp_packet_t;考虑并发时裸机AMP没有真正的锁机制自旋锁在双核缓存不一致时很容易死锁。更稳妥的做法是“双缓冲标志位”两个缓冲区轮流使用当CPU0占用buffer0写数据CPU1同时读buffer1写完后再通过中断交换标志。这样能显著降低竞争概率避免两个核同时操作同一块内存。4. 实测踩坑实录从启动卡死到数据对不上的完整排查链路4.1 坑一CPU1被唤醒了但程序根本没跑起来现象是CPU0端Xil_StartCpu返回成功CPU1却没执行任何代码点灯不亮串口无输出。排查步骤先在CPU1 main入口加一个断点用Vitis同时连两个核的调试会话发现CPU1始终没有断点命中再看CPU1的Linker Script发现.text地址仍然是默认的0x00100000——这不是CPU1的镜像地址FSBL把它加载到0x20000000之后CPU1无程序可执行修改Linker Script后重试发现CPU1还是跑不起来查看0xFFFFFFF0寄存器的内容发现FSBL压根没有把CPU1入口地址写进去因为Vitis的Create Boot Image只加了CPU0的ELF没有加CPU1的ELF加上CPU1的ELF作为data partition并指定load地址后问题解决。这个坑的教训是AMP的启动链路是“FSBL、CPU1镜像、CPU1入口地址、SEV唤醒”任何一环断了都不行。调试时不要只盯应用代码先把镜像打包和启动寄存器检查一遍。4.2 坑二Cache把共享内存“骗”了有段时间我的通信出现随机性故障CPU1十分钟正常突然一次收到脏数据或者CPU0连续发两条消息CPU1一条都收不到正确的。排查过程先用Vitis的Memory Monitor直接查看0x30000000处的物理内存值。CPU0写完后如果在DDR里能看到最新值说明写路径没问题如果看不到问题多半在cacheCPU0写完后加Xil_DCacheFlush()DDR里能看到最新值了但CPU1读的时候依然可能读旧值于是意识到读侧也需要Xil_DCacheInvalidate()后来一劳永逸直接在两个核的main里分别调用Xil_SetTlbAttributes(0x30000000, NORM_NON_CACHE)把共享区设为非缓存改完后再跑48小时数据全部一致。在这个问题上我获得的经验是如果项目没有极高的吞吐量需求只管把共享内存设成非缓存。虽然性能略低但省下来的调试时间完全值回票价。只有确定需要大量DMA交互或大数据吞吐时才值得用flush/invalidate的cache方案。4.3 坑三Vitis烧写Flash时报FSBL文件无效现象用Vitis的Program Flash想烧BOOT.BIN到QSPI结果弹窗提示“a valid fsbl file is required for flash operation”。原因Vitis在执行flash编程时需要一个FSBL来初始化DDR、配置时钟然后才能把要烧写的镜像通过DDR写入flash。它不会像我们想象的那样直接拿烧写器写裸flash。没有fsbl.elfVitis就不知道如何建立烧写通道。解决步骤确认platform工程已经成功Build生成路径下有fsbl.elf在Program Flash的“FSBL file”栏选择这个文件如果workspace本来就有fsbl.elf但Vitis没有识别用绝对路径重新指定某些版本还要求Boot Image中必须包含FSBL可以在Create Boot Image界面把fsbl.elf添加为bootloader partition。这个报错和Linux、AMP关系不大属于Vitis的常见问题网上热搜里也高频出现所以值得单独记录一下。4.4 坑四中断一触发CPU1就跑飞CPU1业务中断一触发程序直接进入异常或者PC跳到0x00000000之类的地方。排查到这里问题基本锁定在CPU1的中断向量表(IVT)上。Zynq默认的异常向量表地址是0x00000000这个地址对应OCM但OCM同时被FSBL、BootROM等占据CPU1在自己的应用空间里并没有对应向量表。如果BSP把VBAR寄存器指向了0x00000000中断触发时就会跑飞。解决方法是确认Linker Script中vector table段_vector_table被加载到CPU1的DDR区域并确保BSP里的Xil_ExceptionInit会把VBAR配到该地址。大多数情况下只要你把CPU1的所有段都放到了0x20000000之后并且从0x20000000开始执行这个坑就能绕开。但万一你在链接脚本里把.vectors单独放在了低地址马上就会踩中。4.5 坑五DDR地址重叠导致CPU0被“暗算”有个项目里CPU0跑得好好的CPU1一开始运行CPU0的某个任务就异常退出或者干脆整个系统重启。用Vitis看寄存器发现是CPU0触发了Data Abort但程序指针指向的代码明明没有访问非法地址。后来把两个应用的符号表导出来对比发现CPU1的.data段被链接到了0x00100000附近和CPU0应用重叠CPU1启动时写入.data段初始值直接覆盖了CPU0的一部分代码。这个问题非常隐蔽因为链接时CPU1自己不会报错它只觉得这是合法的DDR完全不知道旁边住着CPU0。排查建议建立工程后第一时间导出memory map分别查看两个应用的.text/.data/.bss/heap/stack地址段确保所有段都在自己约定的区域内。不同DDR区域之间留出隔离带不要让heap/stack贴着对方代码区否则运行久了更容易踩踏。5. 进阶扩展AMP双核与Linux、上位机、外部设备协同5.1 当一个核想跑Linux混合AMP架构很多产品最终形态是CPU0跑Linux处理网络和UICPU1跑裸机实时控制。这种混合AMP在工程上非常常见。流程和裸机AMP基本一致但多了一个复杂的启动负责方——Linux。用PetaLinux 2025.1生成引导镜像时常见产物是boot.bin、boot.scr和image.ub。boot.bin里包含FSBL、bitstream和ATF/U-Bootimage.ub统一封装内核、设备树和根文件系统。制作SD卡时只需要在FAT32分区放入这三个文件根文件系统再放一个EXT4分区U-Boot启动后会自动加载。和本文前面讲的方法并不冲突只是FSBL之后交给U-Boot而不是直接跳CPU0裸机程序。要让Linux不占用CPU1的专属内存需要在设备树中留出reserved-memory区域并配置remoteproc/rpmsg框架让Linux把cpu1_app.elf加载到预留地址并触发CPU1启动。Linux侧可以使用rpmsg框架收发消息裸机侧则按照自定义的共享内存协议来处理。裸机侧的实现思路和前面章节相同只是唤醒动作从Xil_StartCpu变成remoteproc的RPROC_START。5.2 裸机USB通信方案libusb和双核分工热搜里有“zynq裸机usb通信方案 基于libusb”这其实是Zynq PS自带USB控制器在裸机下的典型用法。如果你把USB Device功能和一个数据处理任务分开AMP双核也是一个天然分工方式CPU0的裸机程序实现USB Device协议栈CPU1负责算法或控制逻辑两块通过共享内存加SGI交换数据。PC端上位机可以使用libusb来访问这个自定义USB设备不需要额外写内核驱动。编译时注意libusb的交叉编译它依赖libudev在嵌入式Linux上需要预先配置好交叉编译链。不过要提醒一点裸机USB Device驱动占用的中断和DMA可能与AMP的SGI或共享内存区域有冲突部署前最好先用Vitis的BSP设置把中断号、DMA通道的分配情况确认清楚。5.3 用Qt SerialPort做上位机时的编译心得另外一条热搜是“qt zynq serialport 库 编译”。在AMP架构中两个核的调试日志经常会通过两个不同的UART输出。嵌入式Linux侧如果用Qt交叉编译SerialPort模块编译时需确保Qt源码里通过-configure开启了serialport模块目前默认是开启的同时交叉编译时需要指定-sysroot以及对应编译器。如果只是临时查看CPU1的串口日志我反而更推荐用某个终端工具分屏看两个串口没必要每次都为一个小工具去交叉编译Qt。只有在正式产品需要提供人机界面、数据实时绘图时才值得把这套东西编译到目标板上。最后分享一个让我后来省了很多事的习惯在创建AMP工程的第一天就把内存规划表写进项目文档并且把“CPU1镜像一定要打进Boot Image”“共享内存区统一非缓存”这两条规则写进团队checklist。Vitis版本一年一个样2023和2024的菜单名称已经有差异但AMP的底层机制没有变过抓住FSBL引导、0xFFFFFFF0启动地址、共享内存一致性、SGI中断这几个核心遇到工具界面的变化都能很快适应。如果看完这篇你也准备把双核用起来建议先从最简单的“CPU0发SGI、CPU1点灯”开始跑通后再往上加业务这个路径最不容易被坑。