ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ZynqMP AMP架构实战:A53多核分工与共享内存通信设计

ZynqMP AMP架构实战:A53多核分工与共享内存通信设计 做ZynqMP的项目时间久了你会发现真正限制系统性能的往往不是单核算力而是四个Cortex-A53到底该怎么分配活。我踩过不少坑之后最终落地了一套很实用的分工方式A53-0跑完整的Linux负责网络、文件系统、人机交互这些不喜欢被中断打扰的活A53-1到A53-3跑裸机程序专门伺候高速数据流。这种AMP架构在工业运动控制、软件无线电、机器视觉采集这些场景里非常吃香Linux的生态优势和裸核的低延迟优势都被用上了。这篇就顺手把整个配置流程完整整理出来包括Bootgen启动镜像怎么组织、Device Tree怎么给Linux画地盘、裸核与Linux之间怎么共享内存和中断、大数据搬运的实测路径以及我在调试过程中总结出的一些容易踩的坑。写这篇的时候我尽量按从开机到跑起来的顺序讲方便你照着流程一步步搭。1. 为什么选A53-0跑Linux、其他核裸奔而不是别的方案1.1 单Linux系统和全裸机方案的短板四个核全跑LinuxSMP是最省事的做法因为所有核共享一个内核和文件系统进程调度、内存管理都不用自己操心。但在实时性要求高的场景里Linux内核的调度延迟、中断下半部、内存管理带来的不确定性会让一个本应5微秒内完成的数据搬运变成几十上百微秒的抖动。全裸机方案则相反中断响应可以做到0.5微秒以内但网络协议栈、文件系统、USB这些复杂功能要全部自己折腾开发工作量直接翻几倍。一个很典型的折中需求是系统既要能通过千兆网把处理结果上传到上位机又要在ADC/DAC数据流上做到严格等时处理。前者Linux太香后者裸机太稳。把两者放在一个芯片的不同核上就是AMP非对称多处理的核心价值。1.2 AMP与SMP、OpenAMP的取舍我在给项目组做技术评审的时候画过一张对比表列了三种主流多核方案的差异方案核间调度开发难度实时性典型适用场景四个核全Linux SMP内核统一管理低中等PREEMPT_RT可改善业务复杂、实时性要求不高的设备两个核Linux 两个核OpenAMPremoteproc加载固件中中高需要动态加载固件、核间消息灵活的仪器一个核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_cpua53-0] ./fsbl.elf [destination_cpua53-0] ./pmufw.elf [destination_cpua53-0] ./bl31.elf [destination_cpua53-0] ./u-boot.elf [destination_cpua53-1] ./bare_metal_app1.elf [destination_cpua53-2] ./bare_metal_app2.elf [destination_cpua53-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放到0x00100000A53-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/3Linux就会去操作这些已经在跑裸机程序的核结果不堪设想。所以板级设备树里必须做这样的裁剪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: baremetal0 { reg 0x0 0x00000000 0x0 0x00300000; no-map; }; shm_mem: shm00300000 { 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里加memxxG来裁剪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之后先执行一次数据同步屏障再更新headLinux侧读到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同属一个clusterL1 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占用Linux1MB1.2 GB/s0.82 ms850 MB/s22%4MB1.2 GB/s0.78 ms920 MB/s18%16MB1.2 GB/s0.75 ms950 MB/s15%裸核处理耗时几乎不随缓冲区大小变化说明瓶颈在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纸而已但每次排查问题都靠它省下至少半天时间。工程上长期稳定运行的秘密往往不在于技术指标多高而在于这些边界有没有被提前画清楚。
返回列表