ZYNQ双核AMP驱动实现:从启动到共享内存通信
2026/8/31 4:45:21 网站建设 项目流程

简介:本资源是面向嵌入式Linux驱动开发工程师与ZYNQ平台初学者的SDK级双核AMP驱动实践包,聚焦ZYNQ 7010 SoC上ARM Cortex-A9双核协同运行的底层实现,解决多核任务调度、核间通信及BSP适配等关键问题。压缩包共1430个文件,含399个头文件(.h)与313个源码文件(.c)构成完整驱动框架,55个Makefile支撑多阶段编译,24个TCL脚本用于硬件平台配置,另有设备树(DTS)、约束文件(XDC)、位流(BIT)及可执行镜像(ELF)等配套资源,整体31.54MB。已有88人学习下载,资源包含可直接导入Xilinx SDK的工程结构、libxil.a等基础库链接文件、system.bd系统级设计以及runme.bat一键运行脚本,目录组织清晰,覆盖从硬件抽象层到用户空间调用的全链路实现,特别适合开展AMP模式下的中断同步、内存共享与核间消息传递等深度实践。

1. 先想清楚:dual_core_amp 到底解决什么问题

1.1 AMP 和 SMP 的分界

ZYNQ 7010 这颗片子最容易被忽略的资源,就是内部那两个 Cortex-A9 核。很多人拿到板子第一件事是跑 Linux,跑起来之后以为双核已经在工作了,其实那是 SMP(对称多处理)——Linux 调度器把两个核当成对等资源,谁有空谁干活,应用层根本感知不到核心切换。

AMP(Asymmetric Multi-Processing,非对称多处理)不是这个玩法。AMP 是让两个核各自独立运行不同的程序,甚至一个跑 Linux、一个跑裸机,两个程序之间没有操作系统层面的关联,只通过硬件机制协同。ZYNQ 的 dual_core_amp 驱动,就是要把这套"各自启动、各自调度、互相通信"底层的 SDK 驱动链全都打通。

很多第一次接触的人以为这就是在 SDK 里建两个 Application 工程各写一个 main 就完事了,实际动手才发现,两个核要独立启动、独立配中断、独立访问外设,还得互相传数据,中间的材料比想象中多得多。

1.2 什么场景才值得用 AMP

我接触过的项目里,用 AMP 的动机基本逃不出这几类:

  • 实时性隔离。Core0 跑 Linux 处理网络协议、文件系统、人机交互,Core1 跑裸机程序做 1ms 级别的伺服控制、电机电流环或者高速 ADC 采集。Linux 的调度延迟在最坏情况下不可控,但裸机程序的中断响应时间是确定的。
  • 安全隔离。把关键控制逻辑和业务逻辑物理隔离在两个核上,即使一边崩了另一边还能继续工作,这在工业设备和医疗设备里很常见。
  • 省掉双芯片方案。原来用"主控 MCU + Linux SoC"两片器件的方案,可以直接用一颗 ZYNQ 替代,成本、功耗、板面积都降下来了。

反过来也要泼一盆冷水:如果两边任务只是要共享数据,又都不需要硬实时,直接用 Linux 多线程甚至 OpenAMP 的 RPMsg 可能更省事。AMP 的通信、调试、维护成本都不低,别为了用双核而用双核。

1.3 7010 的资源底子

ZYNQ 7010 在 Zynq-7000 家族里是入门款,但双核 Cortex-A9、SCU、GIC 中断控制器、私有定时器、OCM、DDR 控制器全都在。做 dual_core_amp 需要的硬件底层资源一个不少,唯一的限制是逻辑资源(CLB、BRAM)比较少,不过 AMP 主要用的是 PS 侧,PL 侧通常只挂一点简单的接口逻辑,所以 7010 完全够用。

2. 启动链路:第二个核是怎么被"喊醒"的

2.1 上电后的默认状态

ZYNQ 上电后,BootROM 先接管 CPU0,从启动介质(QSPI、SD、JTAG)读取 FSBL 并跳转执行。这时候 CPU1 在干什么?它被硬件保持在 WFE(Wait For Event)状态,一直在原地等待一个 event。

这是 ARM Cortex-A9 双核启动的一个基本约定:CPU0 是主核,负责初始化 DDR、时钟、外设,CPU1 从复位开始就进入等待状态,直到有人给它发一个 event 信号。这个"等待-唤醒"的握手动作,就是 dual_core_amp 启动链路的核心。

2.2 FSBL 与 image header 的约定

如果走正规启动流程(SD 卡或 QSPI 启动),FSBL 需要知道两件事:CPU0 的 app 放在哪、CPU1 的 app 放在哪。SDK 里生成 Boot Image 时,BIF 文件可以这样写:

image: { [bootloader] zynq_fsbl.elf app_cpu0.elf [core=1] app_cpu1.elf }

关键就是[core=1]这个属性。FSBL 在启动时解析 image header table,发现某个分区标记了 core=1,就会把该分区的内容加载到 ELF 里指定的地址,然后把 CPU1 应用的入口地址写入 BootROM 约定的释放地址0xFFFFFFF0,最后执行sev指令唤醒 CPU1。

CPU1 被唤醒后,回到复位向量重新取指,跳转到我们链接时指定的入口地址,整个启动过程就完成了。整个过程对应用层完全透明——你在 main 里写代码,根本不用关心自己是被谁拉起来的。

2.3 手工方式:在 FSBL 里塞一段启动代码

有些场景不想走 [core=1] 这条路,比如 CPU1 的 app 还没完全调好,只想在调试时手动拉起。这种时候可以直接在 FSBL 里加一段代码:

#define CPU1_APP_START_ADDR 0x10000000U #define CPU1_RELEASE_ADDR 0xFFFFFFF0U void start_cpu1(void) { /* 先把 CPU1 的入口地址写到 BootROM 约定位置 */ Xil_Out32(CPU1_RELEASE_ADDR, CPU1_APP_START_ADDR); dmb(); /* 保证写入对其他核可见 */ sev(); /* 发事件唤醒 CPU1 */ }

这段代码的原理是:CPU1 在 BootROM 阶段会不停轮询0xFFFFFFF0这个地址,一旦发现内容不是 0,就把它当作自己的跳转入口。所以只要确保 CPU1 的 app 已经被拷贝到0x10000000,再执行上面的函数,CPU1 就会跑起来。

提示:手工方式适合调试阶段,正式发布还是建议用 [core=1],让 FSBL 自动完成加载和释放,少一份自定义代码就少一份风险。

3. SDK 工程搭建:两个 App 共用一套硬件的正确姿势

3.1 工程结构规划

在 Vivado 里完成 ZYNQ PS 配置、导出硬件(Generate Output Products -> Export Hardware),打开 SDK 之后,我习惯建四个工程:

  • zynq_fsbl:FSBL,标准模板生成,不需要改代码(走 [core=1] 路径的话)。
  • app_cpu0:跑在 ps7_cortexa9_0 上的裸机程序,处理主业务。
  • app_cpu1:跑在 ps7_cortexa9_1 上的裸机程序,处理实时任务。
  • 一个共享的头文件目录(或公共库),放通信协议和地址宏定义。

这里最容易犯的错是:建第二个 app 的时候没注意选处理器,两个工程都默认建在ps7_cortexa9_0上。新建 Application Project 的向导里,Processor 下拉框一定要选ps7_cortexa9_1

3.2 BSP 的 USE_AMP 配置

CPU1 工程的 Board Support Package 需要做一个关键配置:在 BSP Settings 里找到 standalone 库的extra_compiler_flags,加上-DUSE_AMP=1

这个宏的作用是让 standalone 的启动代码和 cache 库函数以 AMP 模式编译。默认情况下,裸机程序初始化时会以为自己独占整个系统,会去配置 L2 Cache 控制器、SCU、GIC 等全局资源。如果 CPU0 和 CPU1 都这么干,两边会互相踩踏,表现就是系统启动到一半死机或者中断完全乱掉。

加上-DUSE_AMP=1之后,CPU1 的启动代码会跳过对全局控制器的重复配置,只初始化自己私有的部分。这一点不改的话,后面会遇到很多匪夷所思的诡异问题。

3.3 内存地址划分与链接脚本

两个裸机程序不能放在同一段 DDR 地址上,否则 CPU0 的代码和数据会被 CPU1 的启动过程覆盖。以常见的 1GB DDR(地址从 0x00100000 开始)为例,我通常这样划分:

地址区间用途归属
0x00100000 - 0x0FFFFFFFCPU0 代码、数据、堆栈Core0
0x10000000 - 0x1FFFFFFFCPU1 代码、数据、堆栈Core1
0x20000000 - 0x200FFFFF共享内存区Core0 + Core1
0x30000000 - 0x3FFFFFFFDMA 缓冲等预留按需分配

CPU0 的链接脚本(lscript.ld)基本不用动,默认就是从ps7_ddr_0_S_AXI_BASEADDR(也就是 0x00100000)开始的。CPU1 的链接脚本必须改,把原本指向 0x00100000 的 DDR region 改成 0x10000000:

MEMORY { ps7_ddr_0_S_AXI_BASEADDR : ORIGIN = 0x10000000, LENGTH = 0x10000000 ps7_ram_0_S_AXI_BASEADDR : ORIGIN = 0x00000000, LENGTH = 0x00030000 ps7_ram_1_S_AXI_BASEADDR : ORIGIN = 0x00080000, LENGTH = 0x00030000 }

同时要确认_vector_table、栈顶_stack、堆_heap都在新的 DDR region 范围内。改完链接脚本后再看 CPU1 程序的 map 文件,确保没有哪段落在 0x00100000 附近,否则启动必挂。

3.4 运行与调试两种模式

最省事的验证方式是做成 SD 卡启动:

  1. 用 SDK 的 Create Boot Image 生成 BOOT.BIN,BIF 里带上两个 app 和 [core=1]。
  2. 把 BOOT.BIN 拷到 SD 卡,板子拨到 SD 启动模式。
  3. 上电后两个核自动跑起来,串口 0 会看到两边的打印。

如果是 SDK 在线调试,流程会麻烦一点:先把位流下载到 PL,然后单独启动 CPU0 的 app,接着再启动 CPU1 的 app。启动 CPU1 的时候,Debug Configuration 里要注意 Reset Type 选择 "No Reset",否则整个系统会被重新复位,CPU0 就白跑了。

4. 共享内存与核间中断:驱动层的通信底座

4.1 共享内存区的属性设置

两个核要通信,第一件事是确定共享内存区。地址我们已经在第 3 章规划好了,但光有地址还不够,还要处理缓存一致性问题。

Cortex-A9 的 L1 Cache 是每个核私有的,L2 Cache 是共享的。如果没有特殊处理,CPU0 往共享内存写了一个值,这个值可能还留在 CPU0 的 L1 D-Cache 里,CPU1 读的时候读到的是 L1 Cache 里的旧值——这就是经典的"数据看不见"问题。

最稳妥的办法是把共享内存区配成 non-cacheable(不可缓存):

#include "xil_mmu.h" #define SHM_BASE 0x20000000U #define SHM_SIZE 0x100000U void shm_init(void) { Xil_SetTlbAttributes(SHM_BASE, NORM_NON_CACHE); }

Xil_SetTlbAttributes会修改 MMU 页表项,把这个地址区间的内存属性改成普通非缓存。这样两个核读写共享区都是直接操作 DDR,不会经过 Cache,也就不存在脏数据问题。

注意:这个操作依赖 MMU 已经使能。SDK 的 standalone BSP 默认会开启 MMU,所以直接调用即可。如果你想用 Cache 加速,也可以保持 Cache 属性,但每次读写后都得手动调Xil_DCacheFlushRange/Xil_DCacheInvalidateRange,工程上更容易出错。

4.2 一个简单的环形队列

共享内存区里具体放什么,我建议先从一个最朴素的环形队列开始,不要一上来就整复杂协议:

#define SHM_BASE 0x20000000U #define RING_BUF_SIZE 4096 typedef struct { volatile uint32_t wr; volatile uint32_t rd; volatile uint8_t buf[RING_BUF_SIZE]; } shm_ring_t; #define SHM_RING ((shm_ring_t *)SHM_BASE)

CPU0 作为生产者写数据:

int ring_write(uint8_t *data, uint32_t len) { uint32_t i; volatile shm_ring_t *ring = SHM_RING; for (i = 0; i < len; i++) { ring->buf[ring->wr % RING_BUF_SIZE] = data[i]; dmb(); ring->wr = (ring->wr + 1) % RING_BUF_SIZE; } return 0; }

CPU1 作为消费者轮询读取:

int ring_read(uint8_t *data, uint32_t len) { uint32_t i; volatile shm_ring_t *ring = SHM_RING; for (i = 0; i < len; i++) { if (ring->rd == ring->wr) { return i; /* 没有新数据 */ } data[i] = ring->buf[ring->rd % RING_BUF_SIZE]; dmb(); ring->rd = (ring->rd + 1) % RING_BUF_SIZE; } return len; }

这里wrrd都用了volatile,并且写数据、更新索引之间加了dmb()

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询