恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
ZYNQ7020双核AMP实战:Linux与裸机并行实现微秒级实时控制
首页
资讯中心
/
ZYNQ7020双核AMP实战:Linux与裸机并行实现微秒级实时控制
ZYNQ7020双核AMP实战:Linux与裸机并行实现微秒级实时控制
发布时间:2026/9/29 16:19:40
ZYNQ7020这颗芯片玩过Xilinx Zynq系列的朋友应该都不陌生。它内部集成了双核Cortex-A9处理器PS端和Artix-7架构的FPGA逻辑PL端单芯片就能把软件控制和硬件加速两件事一起干了。但很多人拿到这块板子之后往往只用了其中一个核跑Linux另一个核就让它闲着——说实话挺浪费的。我第一次接触ZYNQ7020的时候也是这么干的后来项目里遇到一个实时性要求极高的场景Linux侧要跑网络通信和文件系统但电机控制环路必须在微秒级响应Linux的调度抖动根本扛不住。那时候才真正开始研究AMPAsymmetric Multiprocessing非对称多处理模式让一个核跑Linux另一个核跑裸机程序各干各的活互不干扰。这套方案的核心价值在于Linux负责管理和通信裸机负责实时和确定性。两者通过共享内存和中断进行数据交换既保留了Linux生态的丰富性又拿到了裸机级别的实时响应。适合谁看如果你手上有ZYNQ7020的开发板已经能跑通基本的Linux系统但苦于某些任务实时性不够或者你想把FPGA逻辑和ARM核的协同做到极致那这篇内容应该能帮你省下不少翻文档的时间。我下面会从硬件资源分配、启动流程、核间通信机制、裸机侧代码框架、Linux侧驱动配合这几个维度把整个双核并行的实现过程拆开讲。最后会给出一套可以直接参考的工程结构你拿到之后根据自己的板子和需求改改就能用。1. 为什么ZYNQ7020的AMP模式值得折腾1.1 对称多处理SMP在实时场景下的天然短板Linux默认跑在ZYNQ7020的双核上时用的是SMP模式。两个A9核被内核统一调度任务可以在任意核上迁移缓存一致性由硬件维护看起来很美。但问题就出在统一调度这四个字上。Linux内核的CFS调度器设计目标是公平和吞吐量不是确定性。一个高优先级的中断线程理论上可以抢占普通进程但实际上从硬件中断触发到用户态线程真正执行中间要经过中断控制器、内核中断处理、软中断、调度器决策、上下文切换这一长串路径。我实测过在ZYNQ7020跑Linux 4.14的情况下一个GPIO中断到用户态响应抖动范围大概在50微秒到200微秒之间极端情况下能到毫秒级。这个数字对于普通的物联网网关、数据采集来说完全够用但如果你要做电机FOC控制、高速PWM同步、或者精确的脉冲序列生成那就完全不够看了。更麻烦的是Linux内核里任何一个驱动出问题、内存紧张触发OOM、或者文件系统卡住整个系统都会受影响。你的实时任务和这些杂事共享同一个内核风险是绑定的。1.2 AMP模式到底解决了什么问题AMP的核心思路很简单把两个核物理隔离各跑各的系统。CPU0跑LinuxCPU1跑裸机程序或者RTOS。两个核有各自独立的栈、堆、中断向量表不共享调度器不共享内核数据结构。它们之间通过事先约定好的共享内存区域和软件生成中断SGI来通信。这样做的好处是实时性有保障裸机侧的代码执行时间是确定的中断响应延迟可以做到纳秒级只要你的中断处理写得够精简。没有调度器在中间插一脚没有其他任务抢CPU。故障隔离Linux侧崩溃了裸机侧照常运行。对于工业控制场景这意味着即使上层管理系统挂了底层的控制环路依然稳定。资源利用最大化ZYNQ7020的双核不用白不用与其让一个核闲着不如让它承担确定性任务。当然代价也是有的。AMP模式下两个核之间的缓存一致性需要手动维护共享内存的访问要加内存屏障调试也比单系统麻烦。但这些成本相对于它带来的实时性提升在特定场景下是完全值得的。1.3 什么样的项目适合上AMP不是所有项目都需要AMP。如果你只是跑个Web服务器、做个数据转发SMP模式下的Linux完全够用没必要给自己找麻烦。但如果你遇到以下情况就该考虑AMP了控制环路周期小于1毫秒且抖动要求严格需要精确的硬件时序配合比如PL侧逻辑和PS侧软件的同步系统中有安全关键任务不能受Linux系统状态影响需要频繁访问PL侧寄存器且访问延迟要可预测我个人的经验是当你的实时任务周期低于500微秒或者你对抖动的容忍度低于10微秒时AMP就是必选项。否则你会在Linux的调度器上浪费大量时间做优化最后发现还是达不到要求。2. 硬件资源划分与启动流程设计2.1 内存映射的规划原则AMP模式下两个核共享同一片DDR内存但必须划分清楚各自的领地。ZYNQ7020的DDR通常挂在地址0x00000000到0x3FFFFFFF1GB或者0x00000000到0x1FFFFFFF512MB范围内。我的建议是Linux侧占用低地址区域比如0x00000000到0x1FFFFFFF512MB这是Linux内核和用户空间使用的。裸机侧占用高地址区域比如0x1F000000到0x1FFFFFFF16MB放在Linux内存的尾部。共享内存在裸机区域里再划出一块比如0x1F000000到0x1F0FFFFF1MB作为两个核交换数据的缓冲区。这里有个关键点Linux的设备树里必须把裸机占用的内存区域标记为reserved-memory否则Linux内核会把它当成可用内存分配出去导致裸机程序的数据被覆盖。这个坑我踩过调试了一整天才发现是Linux把裸机代码段给冲了。设备树里的写法大概是这样reserved-memory { #address-cells 1; #size-cells 1; ranges; baremetal_region: baremetal1f000000 { reg 0x1f000000 0x1000000; no-map; }; };no-map这个属性很重要它告诉Linux内核不要为这块区域建立页表映射这样Linux就完全不会碰它。2.2 启动流程谁先跑谁后跑ZYNQ7020的启动流程是固定的上电后CPU0先执行BootROM然后根据启动模式QSPI、SD卡、JTAG加载FSBLFirst Stage Boot Loader。FSBL负责初始化DDR、配置PL、加载SSBL通常是U-Boot到DDR然后把控制权交给U-Boot。在AMP模式下我们需要在FSBL或者U-Boot阶段就把裸机程序的二进制文件加载到指定的内存地址并且让CPU1从那个地址开始执行。具体有两种做法做法一FSBL加载法。修改FSBL代码在加载完U-Boot之后把裸机ELF文件也加载到DDR的指定位置然后通过写SLCR寄存器0xF8000000区域的来释放CPU1的复位设置CPU1的入口地址。这种做法比较底层但控制最直接。做法二U-Boot加载法。在U-Boot里使用fatload或者tftpboot命令把裸机二进制加载到内存然后用cpu release命令启动CPU1。这种做法更灵活适合开发阶段频繁更新裸机程序。我一般推荐做法二因为U-Boot的命令行调试起来方便不需要每次改FSBL都重新生成BOOT.BIN。具体命令大概是这样# 在U-Boot命令行中 fatload mmc 0:1 0x1F000000 baremetal.bin cpu 1 release 0x1F000000这里0x1F000000就是裸机程序的入口地址也是我们之前规划的裸机区域起始地址。2.3 裸机程序的链接脚本要点裸机程序编译出来的二进制必须链接到我们规划好的地址上。链接脚本linker script里要明确指定代码段、数据段、堆栈的位置。一个典型的链接脚本片段MEMORY { DDR : ORIGIN 0x1F000000, LENGTH 0x00F00000 SHARED : ORIGIN 0x1FF00000, LENGTH 0x00100000 } SECTIONS { .text : { *(.vectors) *(.text) } DDR .data : { *(.data) } DDR .bss : { *(.bss) } DDR .shared : { *(.shared) } SHARED }注意.vectors段要放在最前面因为ARM的异常向量表必须位于入口地址的起始位置。共享内存段单独放在SHARED区域这样两个核都能通过固定地址访问。3. 核间通信共享内存与软件中断的配合3.1 共享内存的数据结构设计两个核之间交换数据最直接的方式就是共享内存。但共享内存不能随便读写必须有一套约定好的数据结构否则会出现读了一半被写的问题。我的做法是定义一个环形缓冲区ring buffer生产者写、消费者读通过读写指针的原子更新来同步。结构体大概长这样typedef struct { volatile uint32_t head; volatile uint32_t tail; uint32_t size; uint8_t data[SHARED_BUF_SIZE]; } ring_buffer_t;head和tail用volatile修饰防止编译器优化掉看似多余的读写。在ARM架构下还需要在读写指针前后加内存屏障__sync_synchronize()或者dmb指令确保内存访问顺序符合预期。这里有个细节两个核对共享内存的访问必须是非缓存的或者要手动做cache flush/invalidate。如果裸机侧开了数据缓存写共享内存后必须执行Xil_DCacheFlush()读之前要执行Xil_DCacheInvalidate()。否则Linux侧看到的数据可能是旧的。我建议在MPUMemory Protection Unit配置里把共享内存区域设为Non-cacheable这样最省心。3.2 软件生成中断SGI的使用光有共享内存还不够因为一方写完数据后另一方怎么知道数据来了轮询太浪费CPU最好的方式是中断通知。ARM的GICGeneric Interrupt Controller支持16个SGISoftware Generated Interrupt编号0到15。我们可以约定一个SGI编号比如SGI 0作为核间通知信号。裸机侧发送中断的代码// 向CPU0发送SGI 0 XScuGic_SoftwareIntr(IntcInstance, 0, XSCUGIC_SPI_CPU0_MASK);Linux侧接收中断需要注册一个中断处理函数。在Linux内核里SGI的中断号是IPI_IRQ相关的但更常见的做法是通过irqchip驱动暴露出来的接口来注册。具体实现方式因内核版本而异我一般是在设备树里声明一个interrupt-parent然后在驱动里用request_irq()注册。注意SGI是核间私有中断只能由CPU发送给CPU不能由外设触发。发送时需要指定目标CPU的掩码比如XSCUGIC_SPI_CPU0_MASK表示发给CPU0。3.3 通信协议的设计命令数据分离在实际项目中核间通信往往不只是传数据还要传命令。比如Linux侧要告诉裸机侧开始采集裸机侧要回复采集完成数据在缓冲区X。这时候就需要一个简单的协议。我的做法是把共享内存分成两个区域命令区和数据区。命令区放一个固定大小的结构体包含命令码、参数、状态数据区放实际的采样数据或者控制参数。每次通信时先写命令区再写数据区最后发中断。接收方先读命令区根据命令码决定怎么处理数据区。命令结构体示例typedef struct { uint32_t cmd; // 命令码 uint32_t param; // 参数 uint32_t status; // 执行状态 uint32_t data_len; // 数据长度 } ipc_cmd_t;这种设计的好处是逻辑清晰扩展方便。新增命令只需要定义新的命令码不需要改通信机制本身。4. 裸机侧代码框架与实时任务实现4.1 裸机程序的初始化流程裸机程序从CPU1启动后第一件事是初始化自己的运行环境。这个流程和普通的裸机程序差不多但有几个AMP特有的步骤设置异常向量表把Xil_ExceptionInit()和XScuGic_CfgInitialize()调用起来注册中断处理函数。初始化MPU把共享内存区域设为Non-cacheable把代码和数据区域设为Cacheable。初始化共享内存清空环形缓冲区设置初始的head和tail。初始化外设根据项目需求初始化GPIO、定时器、SPI等。使能中断最后打开GIC的CPU接口让中断可以送达。这里有个容易忽略的点CPU1的全局中断使能必须在GIC初始化之后。如果顺序反了中断可能丢失或者触发异常。我一般把Xil_ExceptionEnable()放在最后一步。4.2 实时控制环路的实现裸机侧最核心的任务就是实时控制环路。以电机控制为例典型的流程是定时器中断触发比如每50微秒一次在中断里读取编码器位置、电流采样值执行PID计算更新PWM占空比清除中断标志返回这个流程必须尽可能短。我实测过在ZYNQ7020的A9核跑667MHz的情况下一个精简的PID计算加PWM更新大概需要2到3微秒。剩下的时间可以用来做通信、数据记录等非实时任务。PID代码本身不复杂但要注意几点typedef struct { float kp; float ki; float kd; float integral; float prev_error; float output_min; float output_max; } pid_t; float pid_update(pid_t *pid, float setpoint, float feedback, float dt) { float error setpoint - feedback; pid-integral error * dt; float derivative (error - pid-prev_error) / dt; float output pid-kp * error pid-ki * pid-integral pid-kd * derivative; pid-prev_error error; // 输出限幅 if (output pid-output_max) output pid-output_max; if (output pid-output_min) output pid-output_min; return output; }积分限幅是必须的否则积分项会无限累积导致输出饱和后无法快速恢复。这个坑我在实际项目里踩过电机启动时积分项直接爆掉转速失控。4.3 裸机侧的中断优先级管理ZYNQ7020的GIC支持中断优先级配置。在AMP模式下裸机侧的中断优先级要仔细规划最高优先级控制环路定时器中断确保不被其他中断打断。次高优先级核间通信中断及时响应Linux侧的请求。普通优先级外设中断比如UART接收、GPIO输入。优先级数值越小优先级越高。在XScuGic_SetPriorityTriggerType()里设置。我一般把控制环路设为0x10核间通信设为0x20其他设为0x80以上。提示裸机侧的中断处理函数里不要做浮点运算除非你确认FPU上下文已保存不要调用可能阻塞的函数不要访问Linux侧的内存。保持中断处理短小精悍。5. Linux侧的配合设备树、驱动与用户空间接口5.1 设备树中的reserved-memory与中断配置Linux侧要做的第一件事是在设备树里声明保留内存和核间中断。保留内存的写法前面已经提过这里补充中断部分的配置。SGI中断在设备树里通常不需要单独声明因为它是GIC的一部分。但如果你要通过一个自定义驱动来接收SGI需要在驱动里直接使用GIC的API。更常见的做法是使用irq_domain和generic irq框架在驱动probe时申请中断。一个简化的驱动框架static irqreturn_t ipc_irq_handler(int irq, void *dev_id) { // 读取共享内存中的命令 // 处理命令 // 发送回复中断 return IRQ_HANDLED; } static int ipc_probe(struct platform_device *pdev) { int irq platform_get_irq(pdev, 0); request_irq(irq, ipc_irq_handler, 0, ipc, NULL); // 映射共享内存 // 注册字符设备或sysfs接口 return 0; }5.2 用户空间如何访问共享内存用户空间程序要访问共享内存最方便的方式是通过mmap映射/dev/mem。但/dev/mem需要root权限而且在新版内核里默认是禁用的。更好的做法是在驱动里实现mmap把共享内存区域映射到用户空间。驱动里的mmap实现static int ipc_mmap(struct file *filp, struct vm_area_struct *vma) { unsigned long pfn virt_to_phys(shared_mem) PAGE_SHIFT; return remap_pfn_range(vma, vma-vm_start, pfn, vma-vm_end - vma-vm_start, vma-vm_page_prot); }用户空间拿到映射后的地址就可以像访问普通内存一样读写共享内存了。但要注意用户空间的读写和裸机侧的读写之间没有同步机制需要配合中断或者轮询标志位来协调。5.3 一个完整的通信流程示例假设Linux侧要发送一个设置PID参数的命令给裸机侧Linux用户空间程序打开/dev/ipc设备mmap共享内存。用户空间把命令码CMD_SET_PID和参数写入共享内存的命令区。用户空间通过ioctl或者写文件的方式通知驱动发送SGI。驱动调用GIC的API向CPU1发送SGI 0。裸机侧收到中断读取命令区解析出PID参数更新到自己的控制结构体。裸机侧把执行状态写回命令区发送SGI 1给CPU0。Linux驱动收到SGI 1唤醒等待的进程用户空间读取状态。这个流程看起来步骤多但实际执行时间在微秒级。关键是每一步都要有明确的同步点不能假设对方已经准备好了。6. 调试与踩坑那些文档里不会写的事6.1 裸机程序跑飞的第一排查方向裸机程序启动后没反应或者跑飞了最常见的原因有三个第一入口地址不对。检查链接脚本里的起始地址和U-Boot里cpu release的地址是否一致。我遇到过因为链接脚本里写了0x00100000但U-Boot里写的是0x1F000000结果CPU1从错误的地方取指令直接挂掉。第二异常向量表没放对位置。ARM的异常向量表必须在入口地址的最前面。如果链接脚本里把.text放在.vectors前面中断一来就跳转到错误的地方。检查objdump -h的输出确认.vectors段在最低地址。第三MPU配置错误。如果MPU把代码区域设成了Non-cacheable性能会下降但不会跑飞。但如果把栈区域设成了只读一压栈就触发异常。检查MPU的region配置确保栈区域有读写权限。6.2 共享内存数据不一致的排查两个核看到的数据不一样或者数据更新不及时通常是缓存问题。排查步骤确认共享内存区域在MPU里是Non-cacheable的。如果必须用Cacheable确认写后执行了Xil_DCacheFlush()读前执行了Xil_DCacheInvalidate()。检查编译器的优化选项volatile变量不能被优化掉。用JTAG调试器同时连接两个核分别查看同一地址的数据对比是否一致。我遇到过一次诡异的情况裸机侧写共享内存后立即读读到的还是旧值。后来发现是编译器把读操作重排到了写操作之前。加了__sync_synchronize()之后问题消失。内存屏障在AMP编程里不是可选项是必选项。6.3 Linux侧中断收不到的常见原因Linux驱动注册了中断处理函数但裸机侧发SGI后Linux没反应。检查以下几点GIC的CPU接口是否使能了对应的SGI。在Linux启动日志里搜索GIC相关的信息确认SGI范围没有被屏蔽。中断号是否正确。SGI 0在Linux里的中断号不一定是0可能是IPI_IRQ加上偏移。用cat /proc/interrupts查看已注册的中断。驱动里的request_irq是否成功返回。如果返回负值检查中断号和触发方式。裸机侧发送SGI的目标CPU掩码是否正确。如果发给了CPU1自己CPU0当然收不到。6.4 性能调优的几个实用技巧减少核间通信频率能批量传的数据就批量传不要每来一个数据就发一次中断。我一般把通信周期控制在1毫秒以上除非实时性要求特别高。共享内存对齐把共享内存的起始地址对齐到缓存行大小32字节或64字节避免伪共享false sharing。裸机侧中断处理尽量短把非关键操作放到主循环里做中断里只做最紧急的事。Linux侧用实时内核补丁如果Linux侧也有实时任务可以考虑打上PREEMPT_RT补丁减少调度延迟。但注意这不能替代AMP只是锦上添花。7. 工程结构与构建流程参考7.1 目录组织一个清晰的工程目录能让后续维护轻松很多。我的习惯是project/ ├── fsbl/ # FSBL源码 ├── u-boot/ # U-Boot源码 ├── linux/ # Linux内核源码 ├── baremetal/ # 裸机程序 │ ├── src/ │ │ ├── main.c │ │ ├── ipc.c │ │ ├── pid.c │ │ └── timer.c │ ├── include/ │ └── lscript.ld # 链接脚本 ├── shared/ # 共享头文件 │ └── ipc_protocol.h ├── driver/ # Linux驱动 │ └── ipc_driver.c └── scripts/ # 构建脚本 ├── build_baremetal.sh ├── build_linux.sh └── package_boot.shshared/目录放两个核都要用的头文件比如命令码定义、共享内存结构体。这样改一处两边都生效避免不同步。7.2 构建流程裸机程序的构建用Xilinx的arm-none-eabi-gcc工具链编译命令大概是这样arm-none-eabi-gcc -mcpucortex-a9 -mfpuvfpv3 -mfloat-abihard \ -I./include -I../shared \ -T lscript.ld -nostartfiles \ -o baremetal.elf src/*.c arm-none-eabi-objcopy -O binary baremetal.elf baremetal.binLinux驱动单独编译成.ko模块用内核的构建系统make -C /path/to/linux M$PWD modules最后把FSBL、U-Boot、Linux镜像、设备树、裸机bin打包成BOOT.BIN和image.ub放到SD卡或者QSPI里。7.3 版本匹配的注意事项Xilinx的工具链版本、Linux内核版本、U-Boot版本之间是有依赖关系的。我建议用Vivado 2018.3或者2019.1这类比较成熟的版本对应的Linux内核是4.14或4.19U-Boot是2018.01或2019.01。版本太新可能遇到驱动不兼容版本太旧又缺少一些特性。提示如果你用的是PetaLinux它会把FSBL、U-Boot、Linux、设备树都集成在一起构建起来方便很多。但PetaLinux对裸机程序的支持需要手动配置在petalinux-config里指定裸机ELF的路径。8. 实际项目中的经验沉淀8.1 通信协议要留扩展余地我第一个AMP项目里命令码只定义了4个结果后来需求增加要加新的命令不得不改协议、改两边代码、重新测试。后来我学乖了命令码用32位高8位表示类别低24位表示具体命令。这样一类命令可以扩展1600万条基本用不完。共享内存结构体里也预留一些reserved字段以后加参数不用改结构体大小。8.2 裸机侧的看门狗不能省裸机程序跑飞了没人知道这是很危险的。我在裸机侧加了一个软件看门狗主循环里定期喂狗如果超过一定时间没喂就触发系统复位。同时Linux侧也可以通过共享内存里的心跳标志来判断裸机侧是否还活着。如果心跳停了Linux侧可以记录日志、报警、甚至重启裸机核。8.3 调试信息输出要分流裸机侧用UART打印调试信息会严重影响实时性。我的做法是裸机侧把调试信息写到共享内存的日志缓冲区Linux侧的一个用户空间程序负责读取并打印到控制台。这样裸机侧只是内存拷贝不影响控制环路。日志缓冲区也用环形结构满了就覆盖旧数据。8.4 启动顺序的依赖要理清Linux侧驱动可能在裸机侧还没初始化完成时就加载了。如果驱动在probe时就去读共享内存可能读到垃圾数据。解决办法是在共享内存里放一个magic number和init_done标志裸机侧初始化完成后才设置这个标志。Linux驱动probe时先检查标志没准备好就返回-EPROBE_DEFER等下次再试。这套AMP方案我在三个项目里用过从电机控制到高速数据采集稳定性都很好。最关键的是把边界划清楚哪些事归Linux管哪些事归裸机管通信协议怎么定异常怎么处理。这些想明白了代码写起来就是水到渠成的事。如果你刚开始接触ZYNQ7020的AMP建议先从一个最简单的LED闪烁加串口通信做起把启动流程和通信机制跑通再往上加实时任务。别一上来就搞复杂的控制算法那样出了问题很难定位是AMP机制的问题还是算法的问题。