ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ZYNQ 7000 AMP双裸核实战:共享内存与SGI核间通信

ZYNQ 7000 AMP双裸核实战:共享内存与SGI核间通信

1. 为什么要在 ZYNQ 7000 上折腾 AMP 双裸核

ZYNQ 7000 系列本质上是一颗把双核 Cortex-A9 和 FPGA 可编程逻辑塞进同一块硅片里的异构芯片。绝大多数人拿到这块板子,第一反应是跑 Linux——毕竟官方 BSP 成熟、驱动齐全、生态友好。但当你真正面对硬实时场景,比如电机控制环路要求微秒级抖动、工业总线协议栈需要确定性响应、或者多轴运动控制里插补周期不能有任何调度延迟时,Linux 那套 CFS 调度器就会变成你的噩梦。你永远无法保证一个用户态线程在 50 微秒内一定拿到 CPU,哪怕你用了实时补丁、绑核、调优先级,内核里那些中断下半部、软中断、内存回收路径总会在某个时刻给你来一下。

AMP(Asymmetric Multiprocessing,非对称多处理)模式就是为这种场景准备的。它的核心思路很简单:两个 A9 核各跑各的,互不干扰,一个核专门负责硬实时任务,另一个核可以跑轻量级系统或者也跑裸核做辅助计算。两个核之间通过共享内存(Shared Memory)交换数据,用中断做事件通知。这样既保留了双核的算力,又避免了操作系统带来的不确定性。

我这次做的项目,就是让 CPU0 和 CPU1 都跑裸核程序(Bare-metal),完全不涉及任何操作系统。CPU0 负责主控逻辑和对外通信,CPU1 专门做一块计算密集型的信号处理任务。两者通过 OCM(On-Chip Memory)和 DDR 中的共享区域进行数据交互,用软件中断(SGI)做同步信号。整个方案跑下来,核间通信延迟稳定在亚微秒级别,抖动控制在几十纳秒以内,完全满足硬实时要求。

这篇文章适合谁看?如果你已经能在 ZYNQ 上跑通单核裸核程序,对 Vitis(或者老的 SDK)的工程结构有基本了解,现在需要把两个核都用起来并且让它们协同工作,那这篇内容就是为你准备的。我会把整个方案的来龙去脉、踩过的坑、实测数据都摊开讲,尽量让你少走弯路。

2. AMP 双裸核的启动流程与工程结构拆解

2.1 两个核到底是怎么各自跑起来的

ZYNQ 7000 的启动流程有个关键特点:上电复位后,CPU0 是主核,CPU1 处于被 hold 住的状态(通过复位寄存器控制)。CPU0 先执行 BootROM,然后根据启动模式引脚从 QSPI、SD 卡或者 JTAG 加载 FSBL(First Stage Boot Loader)。FSBL 跑完之后,通常会把控制权交给 SSBL(比如 U-Boot)或者直接跳转到裸核应用。在 AMP 模式下,我们需要在 CPU0 的代码里显式地把 CPU1 唤醒,并指定它从哪里开始执行。

具体来说,CPU1 的入口地址是通过SLCR(System Level Control Register)里的A9_CPU_RST_CTRL寄存器来设置的。你需要在 CPU0 的代码里做这么几件事:先把 CPU1 的入口地址写到0xFFFFFFF0(这是 CPU1 的复位向量地址),然后解除 CPU1 的复位状态,最后发送一个 SEV(Send 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 上可选的区域有几种:OCM(On-Chip Memory,256KB)、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 的用法与注意事项

两个核之间光有共享内存还不够,还需要一个事件通知机制。最常用的就是软件生成中断(SGI,Software Generated Interrupt)。Cortex-A9 的 GIC 支持 16 个 SGI,中断号从 0 到 15。你可以让 CPU0 往 CPU1 发送 SGI,CPU1 在中断服务程序里处理共享内存里的数据。

发送 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 主频 667MHz,DDR3 频率 533MHz。测试方法是:CPU0 往共享内存写一个 64 字节的数据包,发 SGI,CPU1 在中断里记录时间戳,然后回发 SGI,CPU0 记录收到回应的时间戳。两者之差就是往返延迟。

实测结果:OCM 共享内存 + non-cacheable 配置下,往返延迟平均 0.8 微秒,最大 1.2 微秒。DDR 共享内存 + cacheable 加手动维护下,往返延迟平均 1.5 微秒,最大 2.3 微秒。吞吐量方面,OCM 方式下持续传输速率大约 80MB/s,DDR 方式下能到 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 翻转加示波器这个组合虽然土,但在关键时刻比任何高级调试工具都管用。最后再分享一个小技巧:在共享内存里放一个版本号字段,两个核启动时都检查这个版本号,如果不匹配就报错停机,这样可以避免因为两个核的固件版本不一致导致的诡异问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进