ZYNQ 7000 AMP双裸核实战:共享内存与SGI核间通信
1. 为什么要在 ZYNQ 7000 上折腾 AMP 双裸核ZYNQ 7000 系列本质上是一颗把双核 Cortex-A9 和 FPGA 可编程逻辑塞进同一块硅片里的异构芯片。绝大多数人拿到这块板子第一反应是跑 Linux——毕竟官方 BSP 成熟、驱动齐全、生态友好。但当你真正面对硬实时场景比如电机控制环路要求微秒级抖动、工业总线协议栈需要确定性响应、或者多轴运动控制里插补周期不能有任何调度延迟时Linux 那套 CFS 调度器就会变成你的噩梦。你永远无法保证一个用户态线程在 50 微秒内一定拿到 CPU哪怕你用了实时补丁、绑核、调优先级内核里那些中断下半部、软中断、内存回收路径总会在某个时刻给你来一下。AMPAsymmetric Multiprocessing非对称多处理模式就是为这种场景准备的。它的核心思路很简单两个 A9 核各跑各的互不干扰一个核专门负责硬实时任务另一个核可以跑轻量级系统或者也跑裸核做辅助计算。两个核之间通过共享内存Shared Memory交换数据用中断做事件通知。这样既保留了双核的算力又避免了操作系统带来的不确定性。我这次做的项目就是让 CPU0 和 CPU1 都跑裸核程序Bare-metal完全不涉及任何操作系统。CPU0 负责主控逻辑和对外通信CPU1 专门做一块计算密集型的信号处理任务。两者通过 OCMOn-Chip Memory和 DDR 中的共享区域进行数据交互用软件中断SGI做同步信号。整个方案跑下来核间通信延迟稳定在亚微秒级别抖动控制在几十纳秒以内完全满足硬实时要求。这篇文章适合谁看如果你已经能在 ZYNQ 上跑通单核裸核程序对 Vitis或者老的 SDK的工程结构有基本了解现在需要把两个核都用起来并且让它们协同工作那这篇内容就是为你准备的。我会把整个方案的来龙去脉、踩过的坑、实测数据都摊开讲尽量让你少走弯路。2. AMP 双裸核的启动流程与工程结构拆解2.1 两个核到底是怎么各自跑起来的ZYNQ 7000 的启动流程有个关键特点上电复位后CPU0 是主核CPU1 处于被 hold 住的状态通过复位寄存器控制。CPU0 先执行 BootROM然后根据启动模式引脚从 QSPI、SD 卡或者 JTAG 加载 FSBLFirst Stage Boot Loader。FSBL 跑完之后通常会把控制权交给 SSBL比如 U-Boot或者直接跳转到裸核应用。在 AMP 模式下我们需要在 CPU0 的代码里显式地把 CPU1 唤醒并指定它从哪里开始执行。具体来说CPU1 的入口地址是通过SLCRSystem Level Control Register里的A9_CPU_RST_CTRL寄存器来设置的。你需要在 CPU0 的代码里做这么几件事先把 CPU1 的入口地址写到0xFFFFFFF0这是 CPU1 的复位向量地址然后解除 CPU1 的复位状态最后发送一个 SEVSend Event指令唤醒它。CPU1 醒来之后就会从你设定的地址开始取指执行。这里有个容易踩的坑CPU1 的入口地址必须是 32 字节对齐的而且最好放在 OCM 或者 DDR 的低地址区域确保 MMU 和缓存配置在跳转之前就已经就绪。我一开始把入口地址设在了 DDR 的高地址段结果 CPU1 起来之后取指异常查了半天才发现是 MMU 映射没覆盖到那个区域。2.2 Vitis 工程里怎么组织两个核的代码在 Vitis 里做 AMP 双裸核工程结构有两种常见做法。第一种是建两个独立的 Application 工程分别编译出两个 elf 文件然后用一个 FSBL 或者自定义的 bootloader 把两个 elf 都加载到内存里。第二种是建一个工程里面包含两个 core 的源码目录通过编译选项区分。我个人更推荐第一种因为两个核的功能差异大独立工程便于分别调试和版本管理。具体操作上你先建一个 Platform 工程基于你的板子选好对应的 BSP。然后在 Platform 里使能两个 CPU 核。接着建两个 Application 工程一个选 ps7_cortexa9_0另一个选 ps7_cortexa9_1。每个工程里都有自己的main函数和链接脚本。链接脚本需要特别注意两个核的代码段和数据段不能重叠共享内存区域要显式保留出来不能分配给任何一个核的堆栈或堆区。我一般会在链接脚本里这样划分CPU0 的代码从0x00100000开始CPU1 的代码从0x00200000开始共享内存区域放在0x00300000到0x003FFFFF这一段大小 1MB。OCM 的 256KB 也可以拿来用但 OCM 容量有限适合放一些频繁交换的小数据结构比如命令队列、状态标志之类的。2.3 启动顺序里的那些坑启动顺序这件事说起来简单做起来容易翻车。CPU0 必须先完成自己的初始化——包括时钟、DDR 控制器、MMU、缓存、中断控制器——然后才能去唤醒 CPU1。如果你在 DDR 还没初始化好的时候就让 CPU1 跑起来它取指就会失败整个系统直接挂死。还有一个细节CPU1 起来之后它的 MMU 和缓存配置是独立的。也就是说CPU0 配好的 MMU 页表CPU1 不一定能用。你需要给 CPU1 也配一套 MMU 页表或者在 CPU1 的启动代码里重新初始化 MMU。我一开始偷懒想着两个核共用一套页表结果 CPU1 访问共享内存时出现了缓存一致性问题数据读出来是旧的。后来老老实实给 CPU1 单独配了页表并且把共享内存区域都设成 non-cacheable 或者 write-through 模式问题才解决。另外CPU1 的中断控制器GIC也需要单独初始化。GIC 的 distributor 是全局的但 CPU interface 是每个核独立的。CPU1 要接收软件中断SGI必须使能自己的 CPU interface并且在 distributor 里把对应的 SGI 中断号使能。3. 共享内存交互的底层机制与缓存一致性处理3.1 共享内存到底该放在哪里共享内存的物理位置选择直接影响到访问延迟和带宽。ZYNQ 7000 上可选的区域有几种OCMOn-Chip Memory256KB、DDR外部内存、以及 FPGA 侧的 BRAM通过 AXI 接口访问。OCM 的访问延迟最低大概几个时钟周期就能拿到数据但容量小只有 256KB而且通常有一部分被 BootROM 占用了。DDR 容量大但访问延迟高而且涉及到缓存一致性问题。BRAM 适合和 FPGA 逻辑交互的场景但需要额外的 AXI 互联配置。我的做法是把控制命令、状态标志、小数据包放在 OCM 里把大数据缓冲区放在 DDR 里。OCM 的 256KB 里我划了 64KB 给共享区域剩下的留给两个核各自的堆栈和关键代码。DDR 里划了 1MB 作为数据交换缓冲区。这样既保证了控制路径的低延迟又满足了大数据吞吐的需求。3.2 缓存一致性AMP 模式下最容易被忽视的坑这是 AMP 双裸核开发中最核心、也最容易出问题的地方。Cortex-A9 每个核都有自己的 L1 数据缓存和指令缓存L2 缓存是两个核共享的。当你把一块内存区域标记为 cacheable 时CPU0 写进去的数据可能只停留在 CPU0 的 L1 缓存里CPU1 去读同一块内存时读到的是 DDR 里的旧数据。这就是经典的缓存一致性问题。在 SMP 模式下硬件会自动维护缓存一致性通过 SCU 和 MESI 协议。但在 AMP 模式下两个核各跑各的硬件一致性机制默认是不生效的。你有两个选择一是把共享内存区域配置成 non-cacheable这样所有访问都直接打到 DDR 或 OCM不存在缓存不一致的问题但性能会下降二是保持 cacheable但在每次写完之后手动执行 cache flush读之前执行 cache invalidate。我实测下来对于小数据量的控制信息直接设成 non-cacheable 最省心性能损失可以接受。对于大数据块用 cacheable 加手动维护的方式吞吐量能高好几倍。具体操作上CPU0 写完数据后调用Xil_DCacheFlushRange()CPU1 读之前调用Xil_DCacheInvalidateRange()。注意这两个函数的参数是虚拟地址和长度而且长度要按缓存行大小32 字节对齐否则会漏刷或者多刷。还有一个更隐蔽的坑编译器优化。如果你在代码里写了while(flag ! 1);这样的轮询等待编译器可能把这个读操作优化到循环外面导致死循环。解决办法是把 flag 声明为volatile或者用内存屏障指令DSB来强制刷新。我一般会在共享内存的读写操作前后都加上dsb()和dmb()确保内存访问顺序符合预期。3.3 中断同步SGI 的用法与注意事项两个核之间光有共享内存还不够还需要一个事件通知机制。最常用的就是软件生成中断SGISoftware Generated Interrupt。Cortex-A9 的 GIC 支持 16 个 SGI中断号从 0 到 15。你可以让 CPU0 往 CPU1 发送 SGICPU1 在中断服务程序里处理共享内存里的数据。发送 SGI 的代码很简单调用XScuGic_SoftwareIntr()或者直接写 GIC 的ICDSGIR寄存器。但有几个细节要注意第一SGI 的中断优先级和触发方式需要提前配置好第二接收方的 CPU interface 必须使能对应的 SGI 中断号第三如果你在中断服务程序里做了太多事情会影响实时性建议只做标志置位或者简单的数据搬运复杂处理放到主循环里。我一般会用两个 SGI一个用于 CPU0 通知 CPU1 有新数据另一个用于 CPU1 通知 CPU0 处理完成。这样形成一个简单的握手协议避免数据覆盖。中断服务程序里只做一件事设置一个 volatile 标志位然后清除中断源。主循环里轮询这个标志位再做实际处理。4. 双核通信协议设计与实测性能数据4.1 一个简单可靠的环形缓冲区协议共享内存的访问需要一套协议来避免竞争和覆盖。我设计了一个基于环形缓冲区Ring Buffer的通信协议每个方向一个缓冲区CPU0 到 CPU1 一个CPU1 到 CPU0 一个。每个缓冲区有一个写指针和一个读指针写指针由发送方维护读指针由接收方维护。发送方写完数据后更新写指针然后发 SGI 通知接收方接收方读完数据后更新读指针然后发 SGI 通知发送方可以继续写。这个协议的关键在于指针的原子性。在 32 位 A9 上对齐的 32 位读写是原子的所以指针变量只要 4 字节对齐就不需要额外的锁。但要注意编译器的重排序写数据必须在更新写指针之前完成读数据必须在更新读指针之后才能进行。我在代码里用dmb()内存屏障来保证顺序。缓冲区的大小需要根据实际数据速率来定。我这边 CPU0 到 CPU1 的数据是每毫秒一帧每帧 256 字节所以缓冲区设成 4KB 就绰绰有余了。CPU1 到 CPU0 的返回数据更小每帧 64 字节2KB 的缓冲区足够。缓冲区太小会导致频繁等待太大则浪费内存一般留出 4 到 8 帧的余量比较合适。4.2 实测延迟与吞吐量我在一块 ZYNQ 7020 板子上跑了实测。CPU 主频 667MHzDDR3 频率 533MHz。测试方法是CPU0 往共享内存写一个 64 字节的数据包发 SGICPU1 在中断里记录时间戳然后回发 SGICPU0 记录收到回应的时间戳。两者之差就是往返延迟。实测结果OCM 共享内存 non-cacheable 配置下往返延迟平均 0.8 微秒最大 1.2 微秒。DDR 共享内存 cacheable 加手动维护下往返延迟平均 1.5 微秒最大 2.3 微秒。吞吐量方面OCM 方式下持续传输速率大约 80MB/sDDR 方式下能到 200MB/s 以上。对于我的应用场景OCM 的延迟已经足够了所以最终选了 OCM 方案。这里要说明一下延迟数据受很多因素影响中断控制器的响应时间、缓存状态、DDR 控制器的仲裁策略等等。不同板子、不同配置下数据会有差异但量级上不会有太大变化。4.3 调试手段怎么知道两个核都在正常工作调试 AMP 双核系统比单核麻烦得多因为你没法同时用两个调试器连两个核大多数情况下。我的做法是在共享内存里划一块调试区域每个核定期往里面写心跳计数器和状态码。然后用 CPU0 的串口打印出来或者用 FPGA 侧的 ILA集成逻辑分析仪抓取共享内存的 AXI 总线信号。另一个有用的技巧是用 GPIO 翻转来做时间测量。在中断服务程序的入口和出口各翻转一个 GPIO 引脚用示波器看波形就能直观地看到中断响应时间和处理时间。这个方法虽然原始但非常可靠不依赖任何软件工具链。还有一点两个核的 printf 不能同时往同一个 UART 写否则输出会混乱。我一般只让 CPU0 负责串口输出CPU1 的调试信息通过共享内存传给 CPU0由 CPU0 统一打印。5. 那些让我熬夜的坑与排查过程5.1 CPU1 起来之后跑飞了第一次把 CPU1 唤醒的时候串口没有任何输出CPU0 也卡死了。排查过程是这样的先用 JTAG 连上 CPU0发现它停在了一个异常处理函数里。查看异常类型是 prefetch abort说明 CPU1 取指失败了。回头检查 CPU1 的入口地址发现我设的是0x00100000但链接脚本里 CPU1 的代码段实际被链接到了0x00200000。原因是 Vitis 在编译 CPU1 工程时链接脚本没有正确指定起始地址默认用了 CPU0 的地址。解决办法是在 CPU1 工程的链接脚本里显式指定.text段的起始地址为0x00200000并且在 CPU0 的唤醒代码里把入口地址改成0x00200000。改完之后 CPU1 正常启动串口输出了预期的调试信息。这个坑的教训是两个核的链接脚本一定要仔细检查确保代码段、数据段、堆栈段都不重叠。最好在 map 文件里确认一下各个段的实际地址。5.2 共享内存数据时对时错CPU1 正常跑起来之后发现它读到的共享内存数据有时候是对的有时候是旧的。这个问题困扰了我两天。一开始怀疑是中断丢失后来用 GPIO 翻转法确认中断每次都到了。又怀疑是内存屏障没加对加了dmb()之后情况有所改善但偶尔还是出错。最后定位到问题是缓存行伪共享False Sharing。共享内存里有两个相邻的变量一个是 CPU0 写的命令标志一个是 CPU1 写的状态标志。它们恰好落在同一个 32 字节的缓存行里。CPU0 写命令标志时会把整个缓存行加载到自己的 L1 缓存CPU1 写状态标志时也会把同一个缓存行加载到自己的 L1 缓存。两个核各自修改缓存行里的不同字节然后各自写回 DDR后写的会覆盖先写的导致数据丢失。解决办法很简单把两个变量用填充字节隔开确保它们落在不同的缓存行里。我用了__attribute__((aligned(32)))来强制对齐每个共享变量都独占一个缓存行。改完之后问题彻底消失。5.3 中断嵌套导致的死锁有一次在测试高负载场景时系统突然挂死。用 JTAG 查看两个核的状态发现 CPU0 在等一个标志位CPU1 也在等一个标志位典型的死锁。分析代码后发现CPU0 在中断服务程序里发送了 SGI 给 CPU1然后等待 CPU1 的回应。但 CPU1 此时正在处理一个更高优先级的中断没有及时响应 SGI。而 CPU0 在中断里等待导致它自己的低优先级中断也无法处理形成了循环等待。解决办法是避免在中断服务程序里做阻塞等待。发送 SGI 之后立即返回把等待逻辑放到主循环里。如果确实需要在中断里做同步就用超时机制等不到就放弃避免无限等待。另外合理配置中断优先级也很重要确保关键的 SGI 不会被其他中断长时间阻塞。6. 从单核到双核一些可以复用的经验6.1 先跑通单核再上双核我的建议是不要一上来就搞双核。先把 CPU0 的裸核程序跑通确保时钟、DDR、UART、中断都正常工作。然后再把 CPU1 的代码单独在 CPU0 上跑一遍验证逻辑正确性。最后再把 CPU1 的代码搬到 CPU1 上加上核间通信。这样分步走出问题的时候容易定位是哪个环节的毛病。我见过有人直接把两个核的代码一起上结果系统挂死连串口输出都没有根本无从下手。分步调试虽然看起来慢但实际上节省了大量排查时间。6.2 共享内存的初始化要小心共享内存区域在两个核的启动代码里都要初始化。但要注意如果 CPU0 先初始化了共享内存然后 CPU1 起来又初始化一遍就会把 CPU0 写的数据覆盖掉。我的做法是CPU0 负责初始化共享内存的头部结构比如缓冲区指针、魔数校验CPU1 起来之后先检查魔数如果魔数正确就跳过初始化直接使用。魔数我一般用一个固定的 32 位值比如0xDEADBEEF或者0x5A5A5A5A。CPU1 读到这个值就知道共享内存已经就绪了。如果读到的是全 0 或者全 F说明 CPU0 还没初始化完CPU1 就等待或者报错。6.3 版本管理和构建自动化两个核的代码分开管理但共享一些头文件比如共享内存的结构体定义、SGI 中断号、魔数等。我用 Git 的 submodule 或者简单的符号链接来共享这些头文件确保两个工程用的是同一份定义。构建的时候写一个简单的 Makefile 或者 shell 脚本依次编译两个工程然后用bootgen工具把两个 elf 打包成 BOOT.bin。这里有个细节bootgen的 BIF 文件里需要指定两个 elf 的加载地址和入口地址。加载地址是 elf 被搬运到内存的目标地址入口地址是 CPU 开始执行的地址。对于 CPU0 的 elf入口地址就是它的_start对于 CPU1 的 elf入口地址也是它的_start但 CPU1 不会自动执行需要 CPU0 显式唤醒。6.4 实测中的性能调优如果发现核间通信延迟比预期高可以从这几个方面排查第一检查共享内存的缓存属性non-cacheable 比 cacheable 延迟低但吞吐量也低根据实际需求选择第二检查中断优先级配置SGI 的优先级如果太低会被其他中断延迟第三检查 DDR 控制器的仲裁配置两个核同时访问 DDR 时可能会有竞争第四检查 CPU 频率和 DDR 频率是否跑到了标称值有时候时钟配置不对会导致性能减半。我在调优时发现把 SGI 的优先级设成最高0并且把共享内存放在 OCM 里延迟能从 1.5 微秒降到 0.8 微秒。这个提升对于硬实时控制来说非常关键。7. 这套方案还能怎么扩展如果你已经跑通了双裸核共享内存通信下一步可以考虑几个方向。一是加入 FPGA 逻辑让 PL 侧也参与到数据交换中形成 CPU0 CPU1 FPGA 的三方协同。二是把其中一个核换成跑轻量级 RTOS比如 FreeRTOS另一个核保持裸核这样既能处理复杂任务又能保证硬实时。三是把共享内存通信协议标准化做成一个可复用的库方便在不同项目之间移植。我个人在实际操作中的体会是AMP 双裸核方案的门槛主要在启动流程和缓存一致性这两块把这两个问题搞定了剩下的就是常规的嵌入式开发工作。另外调试手段一定要提前准备好GPIO 翻转加示波器这个组合虽然土但在关键时刻比任何高级调试工具都管用。最后再分享一个小技巧在共享内存里放一个版本号字段两个核启动时都检查这个版本号如果不匹配就报错停机这样可以避免因为两个核的固件版本不一致导致的诡异问题。