做ZynqMP的项目时间久了你会发现,真正限制系统性能的往往不是单核算力,而是四个Cortex-A53到底该怎么分配活。我踩过不少坑之后,最终落地了一套很实用的分工方式:A53-0跑完整的Linux,负责网络、文件系统、人机交互这些"不喜欢被中断打扰"的活;A53-1到A53-3跑裸机程序,专门伺候高速数据流。这种AMP架构在工业运动控制、软件无线电、机器视觉采集这些场景里非常吃香,Linux的生态优势和裸核的低延迟优势都被用上了。
这篇就顺手把整个配置流程完整整理出来,包括Bootgen启动镜像怎么组织、Device Tree怎么给Linux"画地盘"、裸核与Linux之间怎么共享内存和中断、大数据搬运的实测路径,以及我在调试过程中总结出的一些容易踩的坑。写这篇的时候我尽量按"从开机到跑起来"的顺序讲,方便你照着流程一步步搭。
1. 为什么选"A53-0跑Linux、其他核裸奔"而不是别的方案
1.1 单Linux系统和全裸机方案的短板
四个核全跑Linux(SMP)是最省事的做法,因为所有核共享一个内核和文件系统,进程调度、内存管理都不用自己操心。但在实时性要求高的场景里,Linux内核的调度延迟、中断下半部、内存管理带来的不确定性,会让一个本应5微秒内完成的数据搬运变成几十上百微秒的抖动。全裸机方案则相反,中断响应可以做到0.5微秒以内,但网络协议栈、文件系统、USB这些复杂功能要全部自己折腾,开发工作量直接翻几倍。
一个很典型的折中需求是:系统既要能通过千兆网把处理结果上传到上位机,又要在ADC/DAC数据流上做到严格等时处理。前者Linux太香,后者裸机太稳。把两者放在一个芯片的不同核上,就是AMP(非对称多处理)的核心价值。
1.2 AMP与SMP、OpenAMP的取舍
我在给项目组做技术评审的时候画过一张对比表,列了三种主流多核方案的差异:
| 方案 | 核间调度 | 开发难度 | 实时性 | 典型适用场景 |
|---|---|---|---|---|
| 四个核全Linux SMP | 内核统一管理 | 低 | 中等(PREEMPT_RT可改善) | 业务复杂、实时性要求不高的设备 |
| 两个核Linux + 两个核OpenAMP | remoteproc加载固件 | 中 | 中高 | 需要动态加载固件、核间消息灵活的仪器 |
| 一个核Linux + 三个核裸机 | 自研共享内存+IPI | 中高 | 高 | 高速采集、运动控制、雷达信号处理 |
OpenAMP为Linux与裸机/RTOS核之间的通信封装好了RPMsg、virtio等协议,开发效率很高。但它引入了一层抽象,对于追求极致吞吐的大数据流场景来说,这层抽象有时候反而成了瓶颈。我在这套方案里选择全手工的"共享内存+IPI中断"通信,就是想把可控性把握在自己手里,同时把共享内存的带宽完全用在数据搬运上。
1.3 什么样的业务适合放到裸核
裸核不是万能的。它没有内存保护、没有调度器、更没有现成的驱动框架。适合放在裸核上的任务有三个特征:数据路径固定、逻辑简单可预测、对延迟敏感。比如PL端ADC通过AXI DMA以1.2GB/s速率写入DDR的原始采样流,裸核只需要做包头解析、格式转换、丢包判断,这种任务用Linux的中断加拷贝处理就太奢侈了。反过来说,如果任务需要频繁查文件、调数据库、跑复杂协议栈,还是老老实实放Linux,别为难裸核。
2. 启动链路:从Bootgen到FSBL,让四个核各就各位
2.1 Bootgen的.bif分区与destination_cpu
要让FSBL在开机阶段把不同的ELF加载到不同的核上,关键在Bootgen的配置文件(.bif)。我在Vitis里通常这样组织:
the_ROM_image: { [bootloader, destination_cpu=a53-0] ./fsbl.elf [destination_cpu=a53-0] ./pmufw.elf [destination_cpu=a53-0] ./bl31.elf [destination_cpu=a53-0] ./u-boot.elf [destination_cpu=a53-1] ./bare_metal_app1.elf [destination_cpu=a53-2] ./bare_metal_app2.elf [destination_cpu=a53-3] ./bare_metal_app3.elf }每个[destination_cpu]属性就是那个"分配器"。Bootgen会为每个分区制作分区头,FSBL在启动时逐个解析分区头,看到destination_cpu为目标核ID,就把该分区的数据搬运到对应DDR地址,然后通过写APU的RVBAR寄存器并发送启动事件,让这个核从指定地址开始执行。
这个文件我习惯把辅助核的ELF放在u-boot.elf后面,这样分区顺序清晰,出问题时用Vitis里的bootgen日志能按顺序定位到具体是哪个镜像加载失败。顺序本身没有硬性限制,但保持"先主核、后从核"的排列会让后续排查舒服很多。
2.2 链接脚本:不是随便选个地址就行
裸机ELF在Vitis里生成时,链接脚本(ld script)里写的VMA和LMA决定了程序的实际运行地址。我的习惯是把三个裸核程序固定在DDR低地址的独立区域,跟Linux/U-Boot的使用空间完全隔离。下面是一个典型的链接脚本分配片段:
MEMORY { mem_region : ORIGIN = 0x00000000, LENGTH = 0x00100000 }也就是说,A53-1的程序从0x00000000开始,占1MB。A53-2放到0x00100000,A53-3放到0x00200000。这块区域在后面的Device Tree中会被专门reserved。链接地址必须和实际运行地址一致,否则FSBL加载后第一条指令就跳飞。需要注意,DDR的起始地址以你工程的地址映射为准,如果板子上DDR从0x80000000开始,这里的ORIGIN要相应改成0x80000000。
另一个细节是裸核镜像不要开太大的栈和堆。四个裸核互相独立,栈都放在各自的DDR区域内,一旦栈溢出写穿到相邻区域,轻则数据错乱,重则直接把另一个核的程序段冲垮。我一般把裸核的Stack Size控制在64KB以内,并在启动早期用一个全局标志变量做栈指针边界检查,每次主循环都校验一遍。
2.3 FSBL把裸核启动起来之后,U-Boot不会乱碰它吗
这是很多第一次做AMP的人最担心的事情。实际上,FSBL启动完a53-1/2/3后,它们就独立跑了。U-Boot之后只在A53-0上运行,启动Linux内核,全程不会去操作其他核的DDR区域,只要我们保证地址不重叠。而Linux内核启动初期对SMP核的扫描,在下一章我们会通过Device Tree来禁掉,避免它试图重新开机或关闭裸核。这套链路搭好之后,四个核从上电到各跑各的,全程不需要人工干预。
3. Device Tree里做减法:不让Linux知道还有别的核
3.1 必须禁用cpu1/2/3的status属性
FSBL虽然把裸核启动好了,但Linux内核在启动时会对设备树里的cpu节点逐个调用CPU hotplug逻辑,尝试通过PSCI把每个secondary CPU拉起来。如果设备树里还保留cpu1/2/3,Linux就会去操作这些已经在跑裸机程序的核,结果不堪设想。所以板级设备树里必须做这样的裁剪:
&cpu1 { status = "disabled"; }; &cpu2 { status = "disabled"; }; &cpu3 { status = "disabled"; };在部分老版本设备树里,这四个核是直接写在dtsi中的,覆盖方式可能不同,但思路不变。内核只看到cpu0一个启动核,SMP拓扑里就只有孤零零一个CPU,它自然不会去找其他核的麻烦。这一步做完后,可以用cat /proc/cpuinfo确认处理器数量确实是1。
3.2 reserved-memory预留共享池
光裁核还不够,Linux还要知道"哪些内存我不能碰"。如果不做限制,Linux的页分配器很容易把裸核程序和共享缓冲区所在的内存分配出去,一旦内核写数据,裸核的程序段或数据就会被冲掉。正确的做法是在设备树里使用reserved-memory预留:
reserved-memory { #address-cells = <2>; #size-cells = <2>; ranges; baremetal_reserved: baremetal@0 { reg = <0x0 0x00000000 0x0 0x00300000>; no-map; }; shm_mem: shm@00300000 { reg = <0x0 0x00300000 0x0 0x02000000>; no-map; }; };这里我用0x00000000到0x002FFFFF三段各1MB的区域放三个裸核程序,再从0x00300000开始预留32MB作为共享数据缓冲。no-map属性告诉内核:这块区域不要建立页表映射,也不要被页分配器分配出去。共享内存区域也可以配compatible = "shared-dma-pool",但根据我实际测试,对于这套场景no-map就足够了,Linux侧用/dev/mem的mmap方式直接访问。
3.3 bootargs的mem参数是双刃剑
很多教程喜欢在bootargs里加mem=xxG来裁剪Linux可见内存,这在U-Boot不复杂的系统里确实管用。但我建议优先用reserved-memory,而不是mem=。mem=是全局裁剪,它会把整个低端物理内存从内核可用池里切掉,还会影响CMA区域以及某些驱动的DMA地址判断。我遇到过用mem=裁剪后,PL内DMA驱动分配的DMA缓冲地址落在裁剪边界之外的诡异问题,排查了一下午才发现是bootargs和reserved-memory同时生效造成的地址错位。现在我只用reserved-memory,启动参数保持干净。
3.4 CMA很容易和共享内存打架
ZynqMP的PetaLinux默认会启用CONFIG_CMA,而CMA区域往往被内核安排在DDR高地址还是低地址并不固定。一旦CMA通过DMA API分配出去的缓冲物理地址和你的共享内存重叠,就会发生两个核同时往同一段内存写数据的冲突,表现非常随机。排查手段是启动后执行cat /proc/meminfo | grep Cma,再对照共享内存地址确认没有交集。更稳妥的办法是在设备树里给CMA也指定固定区域,或者把共享内存放在CMA通常不会触及的DDR低地址段。
4. 裸核与Linux的通信:共享内存、IPI和cache一致性
4.1 共享内存的数据结构别裸奔
共享内存不是把几个结构体扔在固定地址就完事了。在AMP场景下,两个核在同一块内存上读写,必须约定好数据写完后如何发布、对方如何知道可以读。我在这套系统里用的是一组环形缓冲加控制块:
typedef struct { volatile uint32_t head; // 写指针,由裸核更新 volatile uint32_t tail; // 读指针,由Linux更新 uint32_t data_size; // 单帧字节数 uint32_t frame_count; // 累积帧计数 uint32_t ack; // 确认标志 uint8_t reserved[32]; // 对齐填充 uint8_t data[4096]; // 数据区,单帧最大4KB } shm_ring_t;head和tail都加volatile,这是必须的。裸核写完data之后,先执行一次数据同步屏障,再更新head;Linux侧读到head变化后,要先读数据,再把tail更新为消费位置。控制块放在共享区头部,实际大数据缓冲放在共享区偏移0x1000处,这样可以给控制块单独留出空间,避免和密集的写入数据互相干扰。
这种结构设计下,单帧数据一般按512字节对齐,刚好匹配L2 cache line大小,避免陷入伪共享——即两个核频繁修改同一个cache line导致无谓的一致性开销。我在共享内存里刻意加了reserved填充,就是为了让head和tail不会落在同一cache line里。
4.2 IPI中断怎么触达对方
数据放上共享内存后,还得让Linux侧知道。两种方案:轮询和中断。轮询最简单,Linux侧一个线程20ms读一次head,看是否有新数据。但这种做法对大流量数据来说很浪费CPU,而且无法保证及时性。我最终用的是IPI(核间中断)。
在裸核侧,IPI通过GIC的SGI机制实现。裸机程序里用XScuGic驱动,注册一个软件中断处理函数:
XScuGic GicInst; static void ipi_handler(void *data) { shm_ring_t *ring = (shm_ring_t *)data; ring->ack = ring->frame_count; // 置ack标志 } XScuGic_Connect(&GicInst, INT_ID_SGI_WAKE_LINUX, (Xil_InterruptHandler)ipi_handler, (void *)ring); XScuGic_Enable(&GicInst, INT_ID_SGI_WAKE_LINUX);当裸核完成一批数据写入后,调用:
XScuGic_SoftwareIntrTrigger(&GicInst, INT_ID_SGI_WAKE_LINUX);这会触发目标核的SGI中断。SGI编号建议选16到31之间的值,需要和Linux侧接收的IRQ号保持一致。Linux侧要收到这个中断,正常做法是给共享内存物理地址对应的中断号写一个小的UIO驱动或者内核模块。如果不想碰内核模块,也有一个土办法但很实用:Linux侧开一个实时线程,通过sched_setscheduler设置SCHED_FIFO,以1ms周期轮询head标志。实测在普通数据量下CPU占用率不会超过3%,对于要求不高的项目足够上线。
4.3 cache一致性到底要不要手动维护
这是AMP最容易被忽视的地方。四个A53同属一个cluster,L1 Cache之间有硬件snoop机制,理论上一个核写入的数据,另一个核读的时候能拿到最新值。但真实项目里,DMA外设、PL端DMA以及MMU页表属性的差异,都会让这个"理论上"失效。比如PL的AXI DMA往共享内存写数据,DMA是绕过cache写DDR的,裸核的L1/L2里可能还残留着旧的cache line,直接读就是旧数据。
我在这套架构下采取了两层保险:
第一层,在MMU页表里把共享数据区配置为Normal Non-Cacheable,这样所有CPU和DMA访问共享区都是直通DDR,不经过cache。代价是读写的延迟略高,但换来的是彻底规避一致性难题。裸机侧可以通过Xil_SetTlbAttributes接口来设置:
Xil_SetTlbAttributes(0x00300000, 0x44C);第二层,对于裸核内部私有的数据缓冲(不需要和Linux共享的部分),保持Cacheable,提高处理性能。只有写到共享区边界之前,才执行一次Xil_DCacheFlush(),把脏数据刷到内存。
很多教程会把"手动flush/invalidate"当唯一解,但这在大数据流里很容易漏掉某一个临界路径上的缓存操作,排查起来极其痛苦。能用non-cacheable映射共享区,就尽量用,工程上省心很多。
4.4 内存屏障不能省
即使配置了non-cacheable,编译器重排和CPU乱序也可能把"先写数据再更新head"的顺序打乱。我在两个核的代码里都加了内存屏障:
裸核写完数据:
barrier(); ring->head = new_head;Linux侧读到head变化后:
ring->tail = consumed_pos; barrier();在ARMv8架构下,裸机侧用dsb sy指令或者借助Xilinx的Xil_DCache相关宏;Linux侧的内核文档明确要求使用smp_mb()或者READ_ONCE/WRITE_ONCE宏。这些屏障指令保证多核场景下的内存序,缺了它,即使数据都写进DDR了,另一核仍然可能在乱序下先读旧head再读新数据。
5. 大数据搬运的落地:DMA搬运与实测路径
5.1 数据流整体架构
我搭建的验证平台是ZCU104开发板,PL端挂了一个高速ADC模块,通过AXI Stream接口进入PL的DMA,然后DMA以描述符方式把数据写入DDR共享区域。整体数据路径如下:
PL ADC -> AXI Stream -> PL DMA Engine -> DDR共享区 -> A53-1裸核解析/降采样 -> DDR结果区 -> Linux读走并通过网口上传
A53-2和A53-3在这个平台上一个做FFT预处理,一个做阈值判决。三个裸核通过共享内存接力,互相之间也用SGI中断通知状态变化。
5.2 裸核侧的DMA搬运
ZynqMP的PL DMA一般用Xilinx DMA IP核,驱动在裸机侧可以直接使用xdma的简单模式。关键点在于描述符表必须在DDR中连续存放,而且地址要按cache line对齐。我遇到过一个现象:描述符表最后一个字节没有对齐到64字节,结果DMA偶尔把下一次传输的起始地址错掉,直接导致整批数据错位。后来把所有描述符数组按64字节对齐,问题彻底消失。
裸核收到DMA完成中断后才开始读共享区数据,避免轮询DMA寄存器浪费CPU。处理完成后再写结果区,并通过SGI通知Linux侧。整个路径上,所有跨核的数据交换都走non-cacheable映射,而裸核内部临时计算的数据继续走cacheable,这样兼顾了一致性和计算性能。
如果你要直接让DMA把数据从PL搬到某个裸核私有的cacheable缓冲区,那我建议在启动DMA前先做cache invalidate,否则DMA写入的数据很可能被CPU的旧缓存覆盖掉。这个坑我在早期的版本里踩过一次,之后养成了"DMA搬运前invalidate、DMA搬运后clean"的习惯。
5.3 Linux侧的用户态读取
Linux侧我用一个普通的C程序,通过mmap直接映射共享内存物理地址到用户空间:
int fd = open("/dev/mem", O_RDWR | O_SYNC); void *base = mmap(NULL, 0x02000000, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0x00300000);然后读取结构体里的head和frame_count字段,做完整性校验。校验方式很朴素:裸核在每个数据帧里写入一个自增序列号,Linux端检查序列号是否连续;如果出现空洞,说明中间丢帧。DMA传输本身不会丢帧,丢帧通常意味着CPU处理不过来,或者共享内存缓冲区太小导致旧数据被覆盖。这个自增序列号是排查丢帧问题最重要的抓手,比看任何调试计数器都好用。
5.4 实测带宽与CPU占用
我在不同的缓冲区大小下测了一轮,结果如下:
| 缓冲区大小 | DMA写入速率 | 裸核处理耗时(全帧解析) | Linux读取速率 | CPU占用(Linux) |
|---|---|---|---|---|
| 1MB | 1.2 GB/s | 0.82 ms | 850 MB/s | 22% |
| 4MB | 1.2 GB/s | 0.78 ms | 920 MB/s | 18% |
| 16MB | 1.2 GB/s | 0.75 ms | 950 MB/s | 15% |
裸核处理耗时几乎不随缓冲区大小变化,说明瓶颈在DMA数据校验和内存访问上,不在缓冲区拷贝。Linux读取速率低于DMA写入速率,是因为mmap之后的用户态校验消耗了一部分带宽,如果去掉帧校验字段,纯memcpy可以跑到接近DMA线速。
从CPU占用看,Linux侧通过/dev/mem mmap方式读写,几乎不产生内核态切换,整体开销很低。如果换成普通read/write,每帧拷贝一次到内核缓冲区,CPU占用大概率会翻倍。这套mmap的做法在数据量没超过1GB/s前都是够用的,再往上就得考虑在内核态直接用DMA映射了。
6. 调试AMP时的几个血泪教训
6.1 裸核程序跑飞了,Linux一点提示都没有
这是AMP调试最磨人的地方。Linux跑在A53-0上,裸核在A53-1上跑飞,Linux侧没有任何异常,因为你根本没有给它任何监控手段。我的经验是裸核主循环一定要有一个心跳变量,每循环一次就更新,Linux侧通过共享内存持续读这个心跳——一旦发现心跳停止,第一时间就定位到具体是哪个核的哪一段程序出了问题。
裸核侧还要在启动初期安排一个"内存自检"函数,扫描自己链接脚本所在的区域,确认FSBL加载的数据没有被U-Boot覆盖。这个自检通常只要比较几个关键跳转指令的首字节,如果发现和预期不符,直接在UART上打印错误码。
6.2 一切正常,但Linux Oops了
我遇到过最诡异的一次是,Linux在长时间跑某项业务时突然Oops,调用栈指向一个莫名地址。查了几天,最后用devmem逐个比对共享内存,发现Linux的某个驱动通过DMA API分配的内存和裸核共享区发生了重叠。原因是设备树reserved-memory没有把共享区的地址范围完整覆盖,裸机ELF实际加载时多占用了几KB,越过了我预留的边界。从那以后,我每个裸核镜像的链接区域都多加10%的冗余空间,并且共享区用地址对齐到64字节的方式扩到略大一些。这类问题还有一个隐蔽变种:Linux内核模块加载时分配的模块内存正好落在共享区内,导致共享内存被模块的全局变量覆盖。所以共享区预留宁大勿小,别抠那几百KB。
6.3 SGI中断不触发,排查思路
裸核写SGI寄存器后Linux侧没反应,这种情况首先要确认中断号是否在两边一致。GPU和PS的GIC中断号有重叠区域,裸机工程和Linux设备树里的中断号如果对不上,信号根本不会到达CPU。其次要检查共享内存里的状态字段:裸核是不是真的把数据写完了?有时候裸核卡在DMA等待描述符上,一直没走到发SGI那一行。
调试时我习惯用小工具:在Linux边执行devmem 0x00300000 32,直接看共享内存第一个字的帧计数。如果这个值在动,说明裸核至少在主循环里活着;如果不动,说明它卡在上游某个地方。配合XSDB连接A53-1,在PC窗口可以看到当前正在执行的函数地址:
xsdb targets -set -filter {name =~ "A53#1"} rwr pcPC如果停在某个异常向量地址,基本就是程序访问越界或者指令异常导致的。需要明确的是,XSDB连接裸核的方式和连接Linux侧的gdb是不同的,A53-1作为一个独立运行核,可以直接通过JTAG的target filter选中,不需要经过Linux的任何接口。
6.4 U-Boot环境变量无意中覆盖了裸核程序
我在一段时间内反复遇到更新U-Boot环境变量后裸核程序无法启动。后来一查,U-Boot的env保存区默认在DDR低地址空间,正好和我分配的裸核镜像地址重叠。解决方法是修改U-Boot配置里的CONFIG_ENV_ADDR到裸核区域之外的地址,或者把env保存到SD卡分区,不在DDR里留env备份。这类问题隐蔽性极强,表面看是程序没加载上,实际是启动配置在暗中改内存。
6.5 用U-Boot命令手动验证内存映射
调完设备树后,我习惯在U-Boot阶段做一次快速验证:用md命令把裸核程序区的前若干字节打印出来,看ELF头是否还在,再用cp命令把一段固定数据写到共享内存区起始地址,确认该地址能被读写。这样能提前规避大量"Linux起不来的问题其实是设备树保留区域错误"的情况。等到Linux起来后用devmem再验证一次,整个链路就心里有数了。
最后一提,这套方案里最不值得省的就是注释和内存地图文档。AMP模式下没有操作系统帮你统一管理资源,每个核的DDR占用、中断号、共享区偏移,全靠团队约定。我在项目里维护了一张内存分配表,一页A4纸而已,但每次排查问题都靠它省下至少半天时间。工程上长期稳定运行的秘密,往往不在于技术指标多高,而在于这些边界有没有被提前画清楚。