1. 为什么要在 ZYNQ 7000 上折腾 AMP 双裸核ZYNQ 7000 系列最让人又爱又恨的地方就是它那颗双核 Cortex-A9。爱的是两个核都能跑到 666MHz 甚至 766MHz算力在嵌入式领域相当能打恨的是大多数项目里第二个核要么被 Linux 晾在一边要么干脆被关掉省电。我接触过的不少工业控制和数据采集项目CPU0 跑 Linux 做网络通信和文件系统CPU1 却闲得发慌而同时 FPGA 侧的实时数据又需要低延迟处理Linux 的调度抖动根本扛不住微秒级的响应要求。这时候 AMPAsymmetric Multiprocessing非对称多处理模式就派上用场了。简单说就是两个核各跑各的裸机程序互不干扰通过共享内存交换数据。CPU0 可以继续跑 Linux 或者裸核负责管理CPU1 专职跑实时任务两边通过一块约定的内存区域来传递命令和数据。这种架构在电机控制、高速数据采集、协议转换网关里非常常见核心诉求就一个实时性要硬延迟要可预测。但 AMP 不是配置一下就能跑起来的。我踩过的坑包括CPU1 启动后直接跑飞、共享内存数据出现撕裂、Cache 一致性问题导致读到旧数据、中断无法正确路由到 CPU1。这些问题在单核开发里根本遇不到一旦碰上调试起来相当折磨人。这篇文章就把我从零搭建 ZYNQ 7000 AMP 双裸核共享内存交互的完整过程拆开讲包括启动流程、内存规划、Cache 处理、中断路由和实际调试技巧适合已经有一定 ZYNQ 裸机开发基础、想往多核方向走的工程师参考。2. AMP 架构整体设计与内存规划2.1 为什么选 AMP 而不是 SMPZYNQ 7000 的 Cortex-A9 双核支持 SMP对称多处理Linux 下默认就是 SMP 模式两个核一起跑内核调度。但 SMP 的问题在于Linux 的调度器不会保证你的实时任务一定在某个核上执行中断也可能被任意核处理上下文切换带来的延迟抖动可能达到几百微秒甚至毫秒级。对于需要稳定在 10 微秒以内响应的场景SMP 基本没法用。AMP 的思路完全不同两个核在物理上隔离各自有独立的代码段、数据段和栈空间CPU0 跑一个裸机程序或者 LinuxCPU1 跑另一个裸机程序。两边通过共享内存通信通信协议自己定义。这样做的好处是实时性完全可控CPU1 的代码不会被任何其他任务打断中断响应延迟只取决于代码本身的执行时间。代价也很明显两个核之间没有操作系统层面的同步机制所有同步都要自己实现内存需要手动划分不能重叠Cache 一致性需要手动维护。但这些代价换来的是确定性的实时性能在工业场景里这笔账算得过来。2.2 内存地图怎么划分ZYNQ 7000 的 DDR 通常从 0x0000_0000 开始大小取决于具体型号和板卡设计常见的有 512MB 和 1GB。AMP 模式下两个核必须使用不同的内存区域绝对不能重叠否则一个核写数据可能覆盖另一个核的代码或栈。我的划分方案是这样的内存区域起始地址大小用途CPU0 代码与数据0x0010_0000256MBCPU0 裸机程序或 LinuxCPU1 代码与数据0x1010_0000128MBCPU1 裸机程序共享内存区0x1810_00001MB双核通信缓冲区保留区0x1820_0000剩余其他用途这里有几个关键点需要说明。CPU0 的起始地址设为 0x0010_0000 而不是 0x0000_0000是因为 ZYNQ 的 OCMOn-Chip Memory和部分外设映射在低地址区域避开这些区域可以防止意外访问。CPU1 的代码起始地址 0x1010_0000 是相对于 DDR 基址的偏移实际使用时需要根据 DDR 大小调整。共享内存区选 1MB 是经验值。太小了不够用太大了浪费。1MB 可以放 256 个 4KB 的数据块或者做双缓冲加命令队列绰绰有余。地址 0x1810_0000 选在 CPU1 区域之后确保不会和任何一方的代码段冲突。注意内存划分必须在链接脚本和 FSBLFirst Stage Boot Loader里同时体现任何一处不一致都会导致核间访问异常。2.3 启动流程设计ZYNQ 的启动流程是BootROM 加载 FSBLFSBL 初始化 DDR 和部分外设然后加载 CPU0 的应用程序。CPU1 默认处于 WFEWait For Event状态需要 CPU0 主动唤醒。AMP 模式下CPU1 的启动有两种方式。第一种是 CPU0 通过 SLCRSystem Level Control Register寄存器直接释放 CPU1 的复位然后 CPU1 从约定的地址开始执行。第二种是使用 Xilinx 提供的XFsbl_StartAppCpu1接口在 FSBL 阶段就把 CPU1 的应用程序加载到指定地址并启动。我推荐第二种方式因为 FSBL 已经完成了 DDR 初始化CPU1 启动时可以直接访问 DDR不需要重复初始化。具体做法是在 FSBL 的main.c里调用XFsbl_StartAppCpu1传入 CPU1 应用程序的入口地址。这个地址就是链接脚本里 CPU1 代码段的起始地址。CPU0 这边需要在启动后等待 CPU1 完成初始化。我的做法是在共享内存里放一个握手标志CPU1 初始化完成后把标志置为 0x55AA55AACPU0 轮询这个标志确认 CPU1 就绪后再开始发送命令。3. 双裸核工程搭建与关键配置3.1 两个裸机工程的创建与链接脚本在 Xilinx SDK 或者 Vitis 里创建两个独立的 Application Project分别命名为cpu0_app和cpu1_app。两个工程都选择Empty Application模板不要用Hello World因为模板里包含的打印代码会引入不必要的依赖。链接脚本是 AMP 配置的核心。CPU0 的链接脚本里代码段起始地址设为 0x0010_0000堆栈大小根据实际需求调整我一般给 0x1000064KB的栈空间。CPU1 的链接脚本里代码段起始地址设为 0x1010_0000栈空间同样给 64KB。这里有个容易忽略的细节两个工程的.bss段和.data段必须完全分开不能有任何重叠。我见过有人把两个工程的链接脚本写成一样的结果 CPU1 启动后直接覆盖了 CPU0 的数据系统跑几分钟就崩了。检查方法是编译完成后查看.map文件确认两个工程的地址范围没有交集。共享内存区不需要在链接脚本里分配直接在代码里用指针访问固定地址即可。但要注意共享内存区的地址必须在两个工程里保持一致建议定义一个公共头文件两边都包含。3.2 CPU1 启动代码的关键细节CPU1 的启动代码和普通裸机程序不太一样。CPU1 被唤醒后首先需要设置自己的栈指针然后跳转到main函数。这段代码通常用汇编写放在 CPU1 工程的启动文件里。关键步骤包括关闭 CPU1 的 Cache 和 MMU如果不需要设置 VBARVector Base Address Register指向 CPU1 自己的中断向量表初始化栈指针然后调用main。这里最容易出错的是 VBAR 的设置如果 VBAR 指向了 CPU0 的向量表CPU1 一旦发生中断就会跳到 CPU0 的代码里执行后果不可预测。CPU1 的main函数里第一件事是初始化共享内存的握手标志然后进入主循环等待 CPU0 的命令。主循环里不要用while(1)空转最好加一个wfe指令让 CPU1 在等待命令时进入低功耗状态收到事件后再唤醒。这样既能省电又能减少总线争用。3.3 共享内存数据结构设计共享内存的数据结构设计直接决定了通信效率和可靠性。我的方案是采用环形缓冲区加命令队列的方式。环形缓冲区用于批量数据传输比如 CPU1 采集的 ADC 数据要传给 CPU0 处理。缓冲区大小设为 512KB分成 128 个 4KB 的块每个块有一个状态标志空、满、正在写、正在读。CPU1 写数据时先检查块状态确认是空才写入写完把状态改为满。CPU0 读取时先检查状态确认是满才读取读完把状态改为空。命令队列用于控制指令传递比如 CPU0 通知 CPU1 开始采集、停止采集、修改采样率等。队列用数组实现每个元素是一个结构体包含命令码、参数和校验字段。队列的头尾指针也放在共享内存里两边通过原子操作更新。typedef struct { volatile uint32_t head; volatile uint32_t tail; volatile uint32_t count; uint32_t reserved[5]; } ring_buffer_ctrl_t; typedef struct { volatile uint32_t status; uint32_t length; uint8_t data[4088]; } shared_block_t; typedef struct { ring_buffer_ctrl_t ctrl; shared_block_t blocks[128]; } shared_memory_t;这个结构体里volatile关键字非常重要它告诉编译器这些变量可能被外部修改不要做优化。reserved字段用于对齐确保结构体大小是 64 字节的整数倍方便 Cache 操作。4. Cache 一致性与中断路由实战4.1 Cache 问题为什么这么坑Cortex-A9 每个核都有自己的 L1 Cache 和共享的 L2 Cache。在 AMP 模式下CPU0 写共享内存时数据可能只写到了 CPU0 的 L1 Cache 里还没有同步到 DDR。CPU1 读同一块内存时如果 CPU1 的 L1 Cache 里恰好有旧数据的缓存行它就会读到旧数据而不是 CPU0 刚写的新数据。这个问题在单核开发里根本不存在因为单核自己写自己读Cache 是一致的。但双核各写各读Cache 就不一致了。解决方法是手动维护 Cache 一致性具体操作包括写共享内存后执行 Clean 操作把 Cache 里的数据刷到 DDR读共享内存前执行 Invalidate 操作把 Cache 里的旧数据标记为无效强制从 DDR 重新读取。Xilinx 提供了Xil_DCacheFlushRange和Xil_DCacheInvalidateRange函数分别对应 Clean 和 Invalidate 操作。使用时传入共享内存的起始地址和长度即可。// CPU0 写共享内存后 Xil_DCacheFlushRange((u32)shared_mem, sizeof(shared_memory_t)); // CPU1 读共享内存前 Xil_DCacheInvalidateRange((u32)shared_mem, sizeof(shared_memory_t));注意Flush 和 Invalidate 操作的开销不小不要每次读写几个字节就调用一次。我的做法是把共享内存按 4KB 分块每次操作一整块减少 Cache 维护次数。4.2 中断如何正确路由到 CPU1ZYNQ 的中断控制器GIC支持将中断路由到指定的 CPU。默认情况下所有中断都路由到 CPU0。要让 CPU1 处理某些中断需要在 GIC 的配置寄存器里设置目标 CPU。具体操作是修改 GIC 的ICDIPTR寄存器把对应中断号的目标 CPU 设为 CPU1。比如如果 CPU1 要处理 PL 侧的中断就需要把该中断号的目标 CPU 改为 CPU1。这个配置通常在 CPU0 的初始化代码里完成因为 GIC 的全局配置只能由 CPU0 修改。配置完成后CPU1 还需要使能自己的中断控制器接口也就是 GIC 的 CPU Interface。这包括设置ICCICR寄存器使能中断设置ICCPMR寄存器设置优先级掩码。这些操作在 CPU1 的启动代码里完成。我踩过的一个坑是CPU1 的中断向量表没有正确设置导致中断触发后跳到了错误地址。后来发现是 VBAR 寄存器在 CPU1 启动时被设置成了 CPU0 的向量表地址。解决方法是在 CPU1 的启动汇编里显式设置 VBAR指向 CPU1 自己的向量表。4.3 共享内存通信协议实现通信协议的核心是状态机和握手信号。我设计的协议包含以下几个状态空闲、CPU0 发送命令、CPU1 执行命令、CPU1 返回结果、CPU0 读取结果。CPU0 发送命令时先把命令写入命令队列然后更新队列的 tail 指针最后触发一个软件中断SGI通知 CPU1。CPU1 收到中断后读取命令队列执行命令把结果写入结果队列然后触发 SGI 通知 CPU0。CPU0 收到中断后读取结果。这里的关键是 SGI 的使用。Cortex-A9 支持 16 个软件中断编号 0 到 15。我一般用 SGI 0 作为 CPU0 通知 CPU1 的信号SGI 1 作为 CPU1 通知 CPU0 的信号。SGI 的触发通过写 GIC 的ICDSGIR寄存器实现指定目标 CPU 和中断号即可。// CPU0 触发 SGI 0 通知 CPU1 XScuGic_SoftwareIntr(intc, 0, XSCUGIC_SPI_TARGET_CPU1); // CPU1 触发 SGI 1 通知 CPU0 XScuGic_SoftwareIntr(intc, 1, XSCUGIC_SPI_TARGET_CPU0);提示SGI 的优先级要设得足够高否则可能被其他中断阻塞导致通信延迟增大。5. 调试过程中遇到的典型问题与排查5.1 CPU1 启动后跑飞这是最常见的问题表现是 CPU1 启动后没有任何输出或者输出乱码。原因通常有三个链接脚本地址错误、栈指针未初始化、向量表未设置。排查方法是先用调试器连接 CPU1查看 PC 指针停在哪里。如果 PC 停在 0x0000_0000 附近说明向量表没设置对。如果 PC 停在某个非法地址说明链接脚本里的代码段地址和实际加载地址不一致。如果 PC 正常但栈操作崩溃说明栈指针指向了非法区域。我的经验是CPU1 的启动汇编里栈指针设置要在调用任何 C 函数之前完成。因为 C 函数的局部变量和函数调用都依赖栈栈没设好一调用就崩。5.2 共享内存数据撕裂数据撕裂的表现是 CPU0 读到的数据一半是新的一半是旧的或者长度字段和实际数据不匹配。原因是 CPU0 在 CPU1 写数据的过程中读取了共享内存。解决方法是用双缓冲或者状态标志。双缓冲的思路是准备两块缓冲区CPU1 写 A 块时 CPU0 读 B 块写完后交换。状态标志的思路是每块数据加一个状态字段CPU1 写之前把状态设为正在写写完后设为写完成CPU0 只在状态为写完成时才读取。我推荐状态标志方案实现简单可靠性高。状态字段要用volatile修饰并且读写顺序要正确先写数据再写状态先读状态再读数据。5.3 Cache 一致性导致的偶发错误Cache 问题最恶心的地方在于它是偶发的。可能跑几个小时才出现一次而且每次出错的位置都不一样。我遇到过一次CPU1 采集的数据偶尔会出现几个字节的偏差查了两天才发现是 Cache 没维护。排查 Cache 问题的方法是暂时关闭两个核的 Cache看问题是否消失。如果关闭 Cache 后问题消失基本可以确定是 Cache 一致性导致的。然后逐步开启 Cache定位是哪段代码缺少 Flush 或 Invalidate 操作。注意关闭 Cache 会严重影响性能只适合调试阶段使用。正式代码里必须正确维护 Cache不能靠关闭 Cache 来规避问题。5.4 中断丢失或重复触发中断丢失的表现是 CPU1 偶尔收不到 CPU0 的命令或者 CPU0 偶尔收不到 CPU1 的响应。原因可能是 SGI 触发太快前一个还没处理完后一个就来了导致中断被合并。解决方法是在 SGI 处理函数里先清除中断标志再处理业务逻辑。如果业务逻辑执行时间较长可以在处理完后检查是否还有未处理的命令有的话手动再触发一次 SGI。中断重复触发的表现是 CPU1 对同一条命令执行了两次。原因可能是中断标志没有正确清除或者命令队列的指针更新有竞态。解决方法是在中断处理函数里加锁或者用原子操作更新队列指针。问题现象可能原因排查方法解决方案CPU1 无输出链接地址错误查看 map 文件修正链接脚本数据偶发偏差Cache 不一致关闭 Cache 测试加 Flush/Invalidate命令丢失SGI 合并查看中断计数处理完后检查队列命令重复执行指针竞态加日志打印原子操作更新指针系统跑几分钟崩溃内存重叠检查两个 map重新划分内存6. 性能优化与稳定性提升经验6.1 减少 Cache 维护开销Cache 维护是 AMP 模式下最大的性能开销来源。每次 Flush 或 Invalidate 都要操作整个 Cache 行如果共享内存区域很大开销相当可观。我的优化方法是把共享内存按 4KB 分块每次只维护当前操作的那一块而不是整个共享内存区。另外可以把共享内存区配置为 Non-Cacheable 属性。这样就不需要手动维护 Cache 了但代价是访问速度变慢。对于数据量不大、实时性要求极高的场景Non-Cacheable 反而是更好的选择。配置方法是在 MMU 页表里把共享内存区的属性设为 Device 或 Strongly Ordered。6.2 用中断替代轮询早期我的方案是 CPU0 轮询共享内存的标志位看 CPU1 有没有返回结果。这种方式实现简单但 CPU0 会一直占用总线而且响应延迟取决于轮询周期。后来改成中断方式CPU1 写完结果后触发 SGICPU0 在中断里读取结果响应延迟从毫秒级降到了微秒级。中断方式的另一个好处是 CPU0 可以在等待期间处理其他任务不需要空转。对于 CPU0 跑 Linux 的场景中断方式更是必须的因为 Linux 下没法在用户态轮询物理地址。6.3 共享内存的原子操作多核环境下对共享内存的读写必须是原子的。Cortex-A9 提供了 LDREX 和 STREX 指令用于实现原子操作。Xilinx 的 BSP 里也提供了Xil_AtomicAdd等原子操作函数。我的做法是对于简单的计数器用原子操作对于复杂的数据结构用自旋锁保护。自旋锁的实现可以用 LDREX/STREX 指令也可以用 GIC 的硬件锁。不过 ZYNQ 的 GIC 硬件锁资源有限我一般用软件自旋锁。static inline int spin_lock(volatile uint32_t *lock) { uint32_t tmp; do { __asm__ volatile( ldrex %0, [%1]\n cmp %0, #0\n strexeq %0, %2, [%1]\n : r(tmp) : r(lock), r(1) : cc ); } while (tmp); return 0; }提示自旋锁在单核上没意义但在双核 AMP 下是必需的。加锁范围要尽量小只保护共享数据的读写不要保护整个业务逻辑。6.4 实测性能数据在我的测试板上ZYNQ 7020DDR3 533MHzAMP 双裸核共享内存通信的实测数据如下测试项数据SGI 中断响应延迟约 1.2 微秒4KB 数据块传输时间约 8 微秒命令往返延迟约 15 微秒连续传输带宽约 380 MB/s连续运行 72 小时错误数0这个性能对于大多数工业控制场景已经足够了。如果需要更低的延迟可以考虑把共享内存放到 OCM 里OCM 的访问延迟比 DDR 低得多但容量只有 256KB需要精心规划。7. 几个容易被忽略的细节7.1 FSBL 里的 CPU1 启动地址FSBL 启动 CPU1 时传入的地址必须是 CPU1 应用程序的入口地址而不是链接脚本里的代码段起始地址。这两个地址通常是一样的但如果链接脚本里有.boot段或者对齐填充入口地址可能会偏移。最保险的做法是查看 CPU1 工程的.map文件找到_start符号的地址把这个地址传给 FSBL。7.2 两个工程的 BSP 配置要一致CPU0 和 CPU1 的 BSP 里有些配置必须一致比如 DDR 的时序参数、UART 的波特率、GIC 的基地址。如果两边配置不一致可能出现 CPU0 能正常打印但 CPU1 打印乱码的情况。我的做法是让两个工程共用同一个 BSP 配置只修改必要的部分。7.3 调试器的使用技巧调试 AMP 双核程序时普通的单核调试器不够用。需要支持双核调试的调试器比如 Xilinx 的 System Debugger 或者 Lauterbach 的 Trace32。调试时可以先暂停 CPU0单独调试 CPU1确认 CPU1 的代码能正常运行后再让两个核一起跑。另外可以在共享内存里放一个调试日志区两个核都把关键信息写进去然后通过 CPU0 的串口打印出来。这样即使 CPU1 没有串口输出也能看到它的运行状态。7.4 上电顺序与复位处理ZYNQ 上电后CPU0 先启动CPU1 处于复位状态。如果 CPU0 的代码里没有释放 CPU1 的复位CPU1 就永远不会启动。释放 CPU1 复位的操作是写 SLCR 的A9_CPU_RST_CTRL寄存器清除 CPU1 的复位位。另外如果系统需要支持软复位两个核的复位处理要协调好。我的做法是 CPU0 负责整个系统的复位CPU1 收到复位命令后先保存必要数据到共享内存然后进入 WFE 等待 CPU0 重新启动它。8. 这套方案还能怎么扩展AMP 双裸核共享内存的架构搭好之后扩展方向很多。比如可以在 CPU1 上跑一个简单的实时操作系统RTOS把任务调度、信号量这些机制用起来代码结构会更清晰。也可以把共享内存通信封装成一套 RPC 框架CPU0 调用 CPU1 的函数就像调用本地函数一样开发效率会高很多。另一个方向是和 PL 侧的配合。CPU1 可以通过 AXI 总线直接访问 PL 的寄存器实现硬件加速和软件控制的结合。比如 CPU1 负责配置 DMAPL 负责数据搬运搬完后 PL 触发中断通知 CPU1CPU1 再通过共享内存通知 CPU0。这种软硬件协同的架构在高速数据采集和图像处理里非常有用。我在实际项目里还遇到过需要三个核协同的场景ZYNQ 7000 只有两个 A9 核但可以加上 MicroBlaze 软核形成 A9 A9 MicroBlaze 的三核架构。MicroBlaze 跑在 PL 里通过 AXI 和 A9 通信共享内存的机制同样适用。不过 MicroBlaze 的字节序和 A9 可能不同通信协议里需要处理字节序转换。最后分享一个小技巧共享内存的起始地址最好选在 1MB 对齐的边界上这样 MMU 页表配置更简单Cache 维护的粒度也更整齐。我一般选 0x1810_0000 这种地址低 20 位全是 0操作起来不容易出错。