第一次在ZynqMP上把四个A53拆成“一个跑Linux,三个跑裸机”的时候,我差点被启动顺序和cache一致性折腾到自闭。网上讲ZynqMP多核的教程不少,但大多数只停留在“能用”的层面,真到要处理大数据、要高吞吐、要保证数据不串味的时候,坑一个接一个。
这篇东西就是我自己的完整配置记录:怎么让A53-0正常跑Linux,然后让A53-1/2/3各跑各的裸机程序,核之间用共享内存 + 中断通信,同时把大数据搬运的cache一致性问题一次性说清楚。内容会覆盖方案选型、启动链路、U-Boot与remoteproc两种启动方式、设备树配置、共享内存设计,以及一堆我在调试时踩过的坑。代码和步骤都是我实测可跑的,不是PPT架构,照着做基本能通。
1. 方案选型:Linux + 裸机异构多核到底怎么分
1.1 为什么不是四核全跑Linux
很多人拿到ZynqMP第一反应是“四核A53,直接SMP跑Linux不香吗”。确实,如果只是做控制面、跑跑应用、上云网关,四核Linux一体跑是最省事的。但一旦涉及硬实时控制、高速数据采集、或者需要确定性延迟的数据处理,纯Linux方案就有点力不从心。
Linux的进程调度、中断延迟、Cache换入换出、DMA映射开销,这些都会让你在计算端到端时延的时候心里发虚。尤其你面对的是一路高速ADC的连续数据流、或者FPGA侧塞过来的帧级数据,一个调度抖动就可能丢帧。反过来,全裸机也不行,复杂的网络协议栈、文件系统、OTA升级、远程调试,你要用裸机代码自己撸一遍,那项目就不用干别的了。
所以最实用的组合就是:Linux管“面”,裸机管“点”。A53-0跑Linux负责网络、存储、日志、用户交互,A53-1/2/3跑裸机,各自负责一路大数据处理或者实时控制回路,核间通过共享内存和中断做数据交换。这是我在实际项目里最顺手的架构,灵活性和实时性都兼顾了。
1.2 核间通信方案:IPI + 共享内存
核间通信要解决两件事:数据怎么传,以及怎么让对方知道你传了数据。
数据传递最直接的方式是共享内存。ZynqMP上A53各核都能访问完整的DDR地址空间,所以划出一块物理内存给核间共用就行。但这里有个重要前提:共享内存的访问属性必须一致,否则会出现数据明明写进去了,对面读出来却是旧的,这问题后面我会细讲。
通知机制我推荐用IPI(Inter-Processor Interrupt)而不是纯轮询。ZynqMP内部有专用的IPI控制器,硬件上每个核都能给其他核发中断,软件上不需要像GPIO那样绕远路。裸机侧用Xilinx BSP自带的XIpiPs驱动,Linux侧用mailbox框架,两边对接很成熟。
1.3 核间职责划分建议
以一个典型项目为例:A53-0跑Linux做整个系统的上位大脑,A53-1专门处理FPGA/DMA灌进来的高速数据帧(比如图像、波形、原始采样点),A53-2做实时控制闭环(PID、伺服、阈值告警、响应外部突发),A53-3留作冗余或做离线分析任务,也可以跑轻量级RTOS(FreeRTOS)管理一堆传感器。
把大数据的处理任务单独放到裸机核上,收益非常明显:没有调度抖动,堆栈和内存都是静态分配,不存在缺页问题,处理延迟基本是确定的。而有Linux的A53-0即使偶发高负载,也不会拖累数据通路。
2. 环境准备与启动链路梳理
2.1 工具链和硬件环境
做这个方案我用的是Xilinx Vitis 2023.1(配对应版本的Vivado),板卡是ZCU106,Linux跑在A53-0上。如果你用的是其他型号,流程大同小异,无非是地址、外设号有些差别。
需要的工具:
- Vitis IDE:用来生成三个裸机核的ELF工程
- PetaLinux或自编译内核:用来出Linux镜像和设备树
- 串口终端:调试A53-0、U-Boot日志输出
- JTAG调试器:调试裸机核的必备工具,能用Vitis Hardware Manager挂上去看寄存器
2.2 从BootROM到Linux的完整启动链
ZynqMP的启动链路比普通MPU长一截,先把这个顺序理清楚后面遇到启动问题才能排查:
BootROM → FSBL → PMUFW(PMU固件)→ U-Boot/ATF → LinuxFSBL负责初始化DDR、加载PMU固件、然后跳转U-Boot。U-Boot会加载ATF和Linux内核。这里有一个关键点:默认情况下FSBL只会把A53-0带起来,其他三个A53核都处于WFI等停状态。要让它们跑裸机,必须在U-Boot阶段或Linux起来以后再释放。
PMU固件也值得一提。ZynqMP的电源/时钟管理都被PMU接管,U-Boot里的cpu release、Linux里的PSCI调用,很多都要过PMU这一层。所以PMU固件不对,其他核也可能起不来。
2.3 多核启动方式对比:U-Boot cpu release vs remoteproc
启动其他裸机核有两种主流方式,我在项目里都试过,直接说结论:
| 对比项 | U-Boot cpu release | Linux remoteproc |
|---|---|---|
| 操作复杂度 | 低,命令两行搞定 | 中,需要配置设备树、驱动、firmware |
| 对裸机程序的控制力 | 启动后Linux完全不管 | 可在Linux侧start/stop管理 |
| 固件升级方式 | 每次改固件要烧U-Boot环境或重新加载 | 改/lib/firmware下的ELF即可 |
| 生产环境推荐度 | 调试验证阶段合适 | 正规产品建议用这个 |
我的建议是:调试前期用U-Boot cpu release快速验证裸机程序能否跑通,产品化阶段切到remoteproc。这样做的好处是前期不会因为设备树、驱动配置一堆问题干扰裸机逻辑调试,后期又有一个正规的管理通道。
3. 完整配置流程(可直接照抄)
3.1 生成三个裸机核的固件
在Vitis里创建裸机工程的时候,平台选择对应板卡的“psu_cortexa53_1”、“psu_cortexa53_2”、“psu_cortexa53_3”作为processor即可。每个核单独建一个Application Project,语言用C,BSP用默认的standalone。
这里有个很多人容易忽略的地方:Vitis默认生成的BSP里,MMU和D-Cache是开启的。后面做共享内存的时候,如果不单独配置内存属性,裸机侧把数据写到共享区域后,数据可能只停留在L1/L2 Cache里,没有真正落到DDR。你可以在BSP设置里临时关掉D-Cache验证,也可以像我一样手动把共享内存区域设成Non-Cacheable,具体代码在3.5节讲。
3.2 预留内存与链接脚本调整
DDR地址空间要提前划分好,不然Linux会把所有内存全部吃掉,你固件和共享内存都没地方放。我的划分如下(以DDR基址0x0为例):
| 地址区间 | 大小 | 用途 |
|---|---|---|
| 0x00000000 - 0x0FFFFFFF | 256MB | Linux内核及用户态 |
| 0x10000000 - 0x1003FFFF | 256KB | A53-1裸机程序和堆栈 |
| 0x10040000 - 0x1007FFFF | 256KB | A53-2裸机程序和堆栈 |
| 0x10080000 - 0x100BFFFF | 256KB | A53-3裸机程序和堆栈 |
| 0x11000000 - 0x11000FFF | 4KB | 核间控制结构体、标志位 |
| 0x12000000 - 0x1FFFFFFF | 224MB | 大数据缓冲区(按帧/块分配) |
裸机工程里的链接脚本lscript.ld要改成对应的加载和运行地址。比如A53-1的text段起始地址设成0x10000000,堆栈设在它之后。Vitis生成的链接脚本一般都有_vector_table入口地址,改这个即可。
改完以后,用Vitis把三个核的ELF转成裸二进制文件(bin),方便后面直接加载到内存跑:
aarch64-none-elf-objcopy -O binary rproc_a53_1.elf rproc_a53_1.bin3.3 U-Boot侧cpu release启动
在调试阶段,我习惯用U-Boot直接把裸机核拉起来。先把rproc_a53_1.bin放到SD卡的boot分区,U-Boot命令行下执行:
fatload mmc 0:1 0x10000000 rproc_a53_1.bin cpu release 1 0x10000000第一行把裸机bin加载到0x10000000,第二行让编号为1的A53核从0x10000000开始执行。这里要注意,cpu release后面的地址必须是裸机程序的入口地址,如果你的bin文件加载地址和链接脚本里的运行基址不是同一个,起不来就从这个方向查。
正常情况下,串口会看到U-Boot打印类似“release cpu 1”的提示,裸机核的串口打印(如果裸机程序里设了打印)或者外设动作就能确认跑起来了。三个裸机核就分别release三次,每次对应不同地址。
3.4 Linux侧remoteproc与设备树配置
产品化的时候我从cpu release切到了remoteproc。Linux侧要做三件事:预留内存、加remoteproc设备树节点、放firmware文件。
设备树里的预留内存和remoteproc节点示意:
reserved-memory { #address-cells = <2>; #size-cells = <2>; ranges; rproc_a53_1_reserved: rproc@10000000 { no-map; reg = <0x0 0x10000000 0x0 0x00100000>; }; rproc_a53_1_shm: rproc-shm@11000000 { no-map; reg = <0x0 0x11000000 0x0 0x00001000>; }; }; remoteproc0: remoteproc@0 { compatible = "xlnx,zynqmp-a53-remoteproc"; reg = <0x0 0x10000000 0x0 0x00100000>; firmware = "rproc_a53_1.elf"; memory-region = <&rproc_a53_1_reserved>, <&rproc_a53_1_shm>; mboxes = <&ipi_mailbox_pmu0>, <&ipi_mailbox_pmu0>; mbox-names = "tx", "rx"; };firmware文件拷到Linux根文件系统的/lib/firmware/目录下,然后启动remoteproc:
echo start > /sys/class/remoteproc/remoteproc0/state这个命令会把ELF加载到预留内存里并启动对应核。如果要停止:
echo stop > /sys/class/remoteproc/remoteproc0/state必须提醒一句:如果设备树里没有正确的memory-region,Linux可能会把固件加载到被自己管理的内存里,轻则启动失败,重则把内核数据踩坏,所以预留内存一定要配好。
3.5 核间通信与共享内存代码示例
共享内存的控制结构可以定义成这样:
typedef struct { volatile uint32_t ready_flag; /* 生产者置1,消费者清0 */ volatile uint32_t frame_len; /* 数据长度 */ volatile uint32_t frame_id; /* 帧序号,用于丢帧检测 */ uint8_t reserved[52]; /* 凑到64字节,避免伪共享 */ uint8_t data[0]; /* 数据区起始 */ } shm_ctrl_t;这里有个细节:volatile不是万能的,它只保证编译器不优化,不保证CPU乱序执行和Cache一致性问题。在Linux侧写共享标志位之前,我习惯加一条内存屏障指令:
asm volatile("dmb ish" ::: "memory"); *ctrl->ready_flag = 1; asm volatile("dmb ish" ::: "memory");裸机侧配共享内存为Non-Cacheable的代码:
#define SHM_BASE 0x11000000 #define SHM_SIZE 0x1000 /* 把共享控制区设为不可Cache,绕开Cache一致性问题 */ Xil_SetTlbAttributes(SHM_BASE, NORM_NON_CACHE);在裸机侧读flag之前建议再确认一次区域属性,我见过很多次裸机侧忘了设属性,导致Linux发来的标志一直读不到,或者读到的永远是第一次的旧值。排查这种问题非常耗时间,先把这个检查了。
4. 大数据场景下的数据一致性与性能调优
4.1 cache一致性到底怎么回事
这是整个方案里最容易被低估、也最容易翻车的环节。先厘清一个概念:A53同簇内的多个核,L1数据Cache之间是有硬件一致性机制的,并不是每个核各干各的。但问题是,一旦某个访问路径不是通过CPU的缓存一致性协议来维护,事情就变了。
最常见的场景是:Linux侧写了一段大数据到DDR,比如从网口收进来的包,然后通知裸机核去处理。Linux侧写的时候经过Cache,数据还留在Cache里没落DDR;裸机核如果配置了Non-Cacheable属性,它去DDR里读,读到的是旧数据。反过来也一样,裸机核写完了数据,数据在它自己的Cache里,Linux侧去读还是旧值。
这是纯软件配置不当导致的一致性问题。更复杂的是DMA和FPGA也来掺和,DMA访问DDR时根本不经过CPU的Cache,那就必须手动做cache flush和invalidate操作。Linux驱动的dma_alloc_coherent申请的内存天然是cache一致的,但如果你直接操作物理地址,那就得自己保证。
4.2 多核共享内存的规范化操作
我总结了几条规矩,这些规矩是踩了不少坑换来的:
第一,共享内存的访问属性必须对齐。裸机侧如果把共享区设成Non-Cacheable,Linux侧映射这块区域时也不要偷偷走Cache。用/dev/mem做映射的时候尤其要小心,默认映射属性可能是Device或Strongly-ordered,和裸机侧的Normal Non-Cacheable不匹配,一样会出怪问题。
第二,控制标志和数据区尽量分开。频繁改动的flag一类的变量,放在一段独立的小区域里,并做64字节对齐。如果控制结构体和大数据缓冲紧挨着,Cache line的伪共享会让性能掉得很难看。我见过一个项目,两个核频繁改同一个结构体里相邻的字段,导致双方互相拖慢,性能直接跌了一半。
第三,大数据本身切割成固定块处理。每个块有自己的状态字段:空闲、就绪、处理中、完成。生产者写完数据后置为“就绪”,消费者处理完置回“空闲”。这种简单状态机比队列好排查,丢帧也好定位。
第四,如果做的是持续大流量处理,裸机侧尽量用状态位轮询代替每次中断。中断适合短消息、控制命令,不适合高频数据帧。高频数据逐帧中断,IPI中断开销和Cache污染反而会拖累吞吐。
4.3 大数据吞吐优化实战
我优化过的一条数据处理链路,简单描述一下:FPGA通过DMA把每帧4MB的数据写入0x12000000开始的缓冲区,写完触发一次IPI给A53-1裸机核。裸机核读完数据做特征提取,把结果写回共享区的另一个小缓冲,然后置flag通知Linux侧A53-0。
初期版本吞吐只能到大概300MB/s,后来做了三件事提到了700MB/s以上:
第一,把大缓冲区用Non-Cacheable映射改成Cacheable,同时在DMA搬运结束后用一次Xil_DCacheInvalidateRange主动失效,让CPU后续读的时候从DDR重新拉。一下子减少了每次写Cache miss的开销。
第二,做了双缓冲。FPGA写完第0块,通知裸机核处理第0块的同时,DMA已经开始写第1块。数据和消息都在流水线上,处理延迟变成了流水线延迟,而不是“等一帧处理完再收下一帧”。
第三,把帧状态从“两个核都频繁读写同一个变量”改成“生产者只写状态,消费者只清状态,且状态字段按块分布在不同cache line上”。改完以后,实测伪共享开销基本消失了。
这套优化思路对大部分ZynqMP大数据处理场景都适用:Cache策略配合硬件DMA的节奏,比盲目追求“全Non-Cacheable”高效得多。
5. 常见问题与排查技巧实录
5.1 裸机核启动失败,怎么都跑不起来
先确认CPU是否真的被release了。U-Boot下用cpu status看各核状态,或者JTAG连上去看PC寄存器有没有跑到预期地址。
如果连JTAG看到PC停在0x0或者异常向量表里,大概率是入口地址不对,或者bin文件根本没加载到位。用md 0x10000000看内存内容,确认加载进去的确是裸机代码。
一个很容易忽略的坑:A53核要从EL3或EL2进入,如果你在U-Boot的cpu release之后发现裸机程序一跑就进入undef exception,多半是EL状态和异常向量表配置不匹配。裸机BSP默认是按EL3编译的,如果上层的ATF把核设到了EL2/EL1,异常向量表地址和Handler都对应不上。解决方法是确认BSP的异常级别和启动环境一致,或者直接用最新的Vitis模板,它会处理这个问题。
5.2 共享数据读到旧值/脏值
这种问题典型现象是:Linux侧写一个flag,裸机侧死等都等不到;或者裸机侧明明把结果写到共享区了,Linux侧读出来还是上上次的旧内容。
第一步,先检查共享内存的映射属性,把裸机侧共享区设成Non-Cacheable,或者用Xil_DCacheInvalidateRange/Xil_DCacheFlushRange手动维护。第二步,检查Linux侧有没有用mmap的cache属性;如果走/dev/mem,建议映射时用MAP_SHARED且确认页表属性是Non-Cacheable或Device类型。
第三步,如果属性都没问题,就看编译优化。变量声明要加volatile,并且不要在多个线程里同时读写同一个标志而不加锁,哪怕只是原子操作,也要保证可见性。我遇到过最隐蔽的一次,是编译器把整个死循环里的flag读取优化成了寄存器循环,在-O2下根本看不到内存更新。
5.3 remoteproc加载固件失败
启动remoteproc时常见报错是“resource table not found”或者“failed to get memory-region”。
资源表缺失的话,需要检查ELF是否包含resource table段。Vitis生成裸机ELF时默认不带remoteproc resource table,你可以用官方提供的模板加上,或者你自己在裸机工程里定义一个resource table结构体并放到特定段。
memory-region的报错几乎都是设备树写错。reg和memory-region里的地址必须和预留内存一致,且预留内存必须no-map,意思是不让Linux建页表映射。我一开始图省事写成了reusable,结果Linux把这段内存分配给别的驱动了,固件加载进去被踩得面目全非。
另外,remoteproc框架要求firmware文件名和设备树里的firmware属性一致,而且必须是ELF格式。你在调试阶段用bin文件直接加载没问题,remoteproc可不认bin,它要解析ELF段信息。
5.4 性能瓶颈与缓存行伪共享
大数据场景性能上不去,优先查两类问题:Cache策略和缓存行冲突。
第一类问题是每次CPU读数据都miss,导致吞吐全卡在DDR延迟上。解决办法是DMA写完数据后做一次Invalidate,后续处理时数据命中Cache,处理完做一次Clean和Invalidate,再让DMA去搬。Linux侧如果是驱动参与,直接用dma_map_single并设定正确的DMA方向,让内核把该做的cache操作都做了,比自己裸操作可靠。
第二类问题是不同核频繁修改同一cache line上的不同字段。一个64字节的cache line是硬件同步的最小单位,两个核对同一条line里的不同字段写操作会互相抢占。解决办法是让高频写的字段分散到不同line,或者干脆每核私有一份数据,定期汇总。这两个手段我都在实际项目里用过,效果立竿见影。
5.5 关于多核方案稳定性的几点体会
整个方案调试下来,我个人最大的感觉是:ZynqMP多核链路并不复杂,复杂的是各种“软配置”之间的隐性耦合。地址划分、Cache属性、设备树预留、启动顺序、中断归属,这些东西单独看都简单,叠在一起就很容易出鬼。
所以我的建议是:在项目排期里专门留出多核联调的时间,不要指望一次把全部功能接起来。先让Linux起来,再用U-Boot release一个裸机核,只跑一个LED翻转或串口打印;通了以后再加共享内存、加IPI、加大数据流。每加一层就验证一层,这样即使出问题,也能瞬间定位到是启动、通信、还是Cache的问题。
最后再说一个技巧:平时调试多核数据通路,可以在裸机核里定期写一个心跳计数到共享内存,Linux侧用一个小工具每秒读一次。这个心跳比任何日志都好使,能直观反映核有没有卡死、通信链路有没有断、Cache有没有缓存住旧值。我后期几乎不离手,谁用谁知道。