1. 为什么要折腾“不带DDR的ZYNQ”:OCM在无外置内存方案中的真实位置
1.1 先搞清楚ZYNQ里OCM到底是什么
ZYNQ这类芯片和普通单片机最大的不同,就是它内部集成了ARM Cortex-A9双核处理器,但这并不意味着离开了外部DDR它就不会工作。很多人拿到Vivado SDK的第一反应是新建工程、用默认链接脚本、在DDR地址0x00100000处跑一个Hello World,于是潜意识里形成了“ZYNQ必须有DDR”的印象。实际上,PS内部还有一个常被忽略的存储区——OCM(On-Chip Memory,片上存储器)。
OCM是集成在PS内部的SRAM,不是PL端的Block RAM,也不是挂在AXI总线上的外设。以Zynq-7000系列为例,OCM由两部分组成:低端256KB区域,地址范围从0x00000000到0x0003FFFF;高端64KB区域,地址范围从0xFFFC0000到0xFFFCFFFF。低端OCM正是BootROM在启动阶段存放FSBL的地方。换句话说,芯片上电后、DDR还完全没被初始化之前,CPU执行的代码就放在OCM里。
把OCM理解成“芯片自带的暂存仓库”比较直观:外部DDR相当于一个需要通电、配置、等待训练完成的仓储中心,而OCM是CPU身边的小型储物柜。存储柜只有256KB,但胜在随取随用、不需要任何初始化流程,CPU复位后就能直接读写。
1.2 什么情况下值得砍掉DDR
我接触无DDR的ZYNQ方案,最初来自一个成本敏感的项目:产品只做简单的串口协议转换和数据采集,PL面只用了一些轻量逻辑,整板物料成本被压得很紧。DDR颗粒、DDR电源、布线空间、PCB层数,这些都是实打实的成本,砍掉DDR可以节省好几块钱,还能把板子面积缩小一圈。当时我反复问自己:一个256KB的程序真的能覆盖需求?回答是,如果应用被约束为裸机单任务、代码尺寸被刻意精简,很多设备根本用不上几十MB的内存。
抛开成本因素,还有两类场景非常依赖OCM。一类是硬件调试早期:PCB刚贴片回来,DDR芯片可能没焊好,或者DDR电源有问题,此时想验证PS能不能正常工作、PL能不能加载比特流,OS、烧写工具、PetaLinux统统派不上用场,写一个不超过几十KB的裸机测试程序,直接放到OCM里跑串口输出,是最快的验证路径。
另一类是启动加载链路设计:BootROM本来就把FSBL放进OCM执行,如果第一级加载器足够小,可以直接让程序在OCM里完成硬件初始化、外设配置,再决定后续加载动作。这在安全启动、远程更新、工厂测试中都很常见——你先有一个不依赖DDR的“最小系统”,再让这个最小系统去初始化更大的世界。
需要强调,在ZYNQ体系里试一下“无DDR跑程序”不是旁门左道,而是理解芯片启动流程的关键一步。当你亲手把程序放到OCM并成功跑起来,你对BootROM、FSBL、链接脚本、内存映射这些概念的理解会提升一个层级。
1.3 为什么多数人的第一反应是“不可能”
绕不开的原因是默认工具链的路径依赖。Vivado SDK创建一个应用工程时,生成的链接脚本会自动把DDR区域设为最大的可用内存区域。FSBL更是直接把DDR初始化当成必经步骤。如果你不做任何修改,让FSBL去初始化一片不存在的外设,CPU访问的是空洞地址,轻则读到随机数据,重则触发数据异常,表现就是程序卡死、串口无输出、JTAG连接异常。
一旦你理解FSBL默认行为只是Xilinx工程师为你准备的“标准路径”,而不是芯片的物理限制,问题就清楚多了:我们完全可以修改FSBL跳过DDR初始化,同时把应用链接脚本指向OCM,最后让CPU在OCM里执行完整程序。这既不是重写芯片驱动,也不需要高深技巧,关键只是动手前把内存映射和启动流程理顺。
2. 不带DDR时,BootROM与FSBL的真实行为
2.1 BootROM阶段:FSBL落在哪里
很多人以为FSBL是从启动设备被“读取”出来,然后“运行”的,但对它运行时的存储位置却不清楚。Zynq上电后,BootROM固化在芯片内部,它负责根据MIO引脚的启动模式设置,从QSPI Flash、SD卡、NAND等介质中读取FSBL镜像,然后放入OCM。BootROM本身的代码很小,不依赖DDR控制器,也不需要任何外部存储初始化。
因此,无DDR设计里BootROM的流程和有DDR设计完全一样,BootROM不会因为DDR不存在而罢工。它只关心两件事:启动设备是否可读,以及读出来的FSBL头部是否满足校验要求。只要FSBL二进制能被BootROM正确解析并搬运到OCM,CPU就会跳转到OCM中FSBL的入口地址开始执行。整个过程是芯片内部机制,外部有没有DDR完全不参与。
这里有一个容易踩的误区:有人以为BootROM会把FSBL放到DDR里,所以没有DDR就无法完成任何启动。实际上BootROM第一个加载阶段永远使用OCM。FSBL之所以看起来“活在DDR里”,那是因为FSBL运行起来之后,默认的链接脚本和运行地址都在DDR中,FSBL在初始化DDR后会把自身需要的数据和代码加载到DDR。但注意,FSBL的“源码”在编译的时候已经决定了它的起始运行地址,如果你的FSBL链接脚本把起始地址放在0x00100000,而DDR不存在,那么BootROM在把FSBL放到DDR时就会失败。这正好回答了为什么无DDR方案里不但要改应用链接脚本,还要让FSBL在OCM里运行。
2.2 FSBL默认依赖DDR的三个环节
FSBL并不是只有一个“初始化DDR”的函数那么简单,它对DDR的依赖体现在三个层面上的联动,只看代码很难全暴露出来。
第一个环节是HAL层的初始化。FSBL启动后会调用XFsbl_InitDdr之类的函数,这个函数会配置DDR控制器寄存器、执行DDR Training(训练时序参数)、等待DDR PLL锁定。如果DDR缺失,寄存器操作本身不会崩溃,但状态寄存器永远等不到“ready”标志,程序会陷入等待循环或报错分支。
第二个环节是地址空间的假设。FSBL内部很多数据结构、分区表信息都被链接到DDR地址。Vivado SDK默认生成的FSBL工程里,linker script中分配了DDR地址空间,FSBL函数的栈、堆以及运行时代码都假设DDR已经可用。如果你只是简单地在FSBL代码里跳过了DDR初始化函数,但仍然让FSBL链接到DDR地址运行,第一次函数调用就会出错。
第三个环节是镜像搬运的目标地址。FSBL从启动设备读取二级镜像时,是根据应用ELF文件里的段地址来决定目标位置的。如果你的应用在无DDR设计里依然被编译链接到DDR地址,FSBL会把代码搬到不存在的地址。我们后面要做的链接脚本修改,本质上就是把这个目标地址从DDR改成OCM。
2.3 把最终程序整个塞进OCM的真实容量约束
OCM的256KB听起来不大,但和传统嵌入式单片机相比已经很可观。一个精简的裸机串口程序,关闭优化的情况下可能也就几十KB,开启优化后可以压到十几KB。中断向量表、堆栈段、数据段全部加起来,只要程序逻辑不涉及大型协议栈、不依赖文件系统、不跑重量级RTOS,是完全塞得下的。
实际操作时还要注意两点。第一,OCM的低端256KB区域,BootROM在启动初期会占用一部分,不过一旦FSBL接管并跳到应用入口,这部分地址就已经被释放;如果你的FSBL在跳转前还需要从OCM里读取自己的表格,就得避开重叠区。第二,高端OCM(0xFFFC0000,64KB)虽然也可用,但它和低端OCM不是连续的地址空间,链接脚本里需要用两个MEMORY区域来分别描述,不能简单地合并成一个连续范围。
这些约束听起来麻烦,实际上会逼着你写出更干净的嵌入式代码。我见过不少工程师在DDR充足时写出“堆到几MB都不心疼”的程序,换到OCM环境后,为了省几千字节不得不认真分析栈深度、优化全局变量、裁剪打印信息——训练出来的代码质量反而更扎实。
3. 工程改动第一步:把链接脚本收进OCM
3.1 找到并读懂lscript.ld
在Vivado SDK里创建一个裸机应用工程后,工程目录下会有一个lscript.ld文件。它由SDK根据硬件平台BSP自动生成,默认内容大致是:
MEMORY { ps7_ddr_0_S_AXI_BASEADDR : ORIGIN = 0x00100000, LENGTH = 0x1FF00000 ps7_ram_0_S_AXI_BASEADDR : ORIGIN = 0x00000000, LENGTH = 0x00004000 ps7_ram_1_S_AXI_BASEADDR : ORIGIN = 0xFFFF0000, LENGTH = 0x0000FE00 }这里可以看到Xilinx默认把容量最大的DDR区域排在最前面,而且程序默认的入口点、堆栈、初始化代码段都被安排到DDR区域里。ps7_ram_0代表低端OCM,ps7_ram_1代表高端OCM,但默认只分配了很小的空间。我们要做的第一件事就是把MEMORY区域的定义改掉,让所有代码块都进入OCM。
一个稳妥的做法是直接用一个地址连续的OCM区域:
MEMORY { ps7_ram_0_S_AXI_BASEADDR : ORIGIN = 0x00000000, LENGTH = 0x00040000 }0x00040000就是256KB,正好覆盖低端OCM的完整范围。如果你的应用确实需要用到高端OCM,可以再补一段:
ps7_ram_1_S_AXI_BASEADDR : ORIGIN = 0xFFFC0000, LENGTH = 0x00010000但我要提醒一句:想真正跑通无DDR方案,好习惯是先把程序全部放在低端OCM里,因为拿到0x00000000这个起始地址,和BootROM/FSBL的默认行为最兼容,调试干扰最少。高端OCM等程序在低端OCM跑通之后再考虑。
3.2 向量表、堆、栈在OCM里的排布方式
链接脚本不只是定义一段内存范围那么简单,它内部的SECTIONS指令决定了代码段、只读数据段、可读写数据段、BSS段、堆和栈分别被放哪里。SDK默认生成的lscript.ld里已经有下面这段结构:
SECTIONS { .text : { *(.text) *(.text.*) *(.gnu.linkonce.t.*) } > ps7_ddr_0_S_AXI_BASEADDR .init : { KEEP (*(.init)) } > ps7_ddr_0_S_AXI_BASEADDR .fini : { KEEP (*(.fini)) } > ps7_ddr_0_S_AXI_BASEADDR .rodata : { __rodata_start = .; *(.rodata) *(.rodata.*) } > ps7_ddr_0_S_AXI_BASEADDR .data : { *(.data) *(.data.*) } > ps7_ddr_0_S_AXI_BASEADDR .bss : { *(.bss) *(.bss.*) } > ps7_ddr_0_S_AXI_BASEADDR .heap : { __heap_start = .; . = . + HEAP_SIZE; __heap_end = .; } > ps7_ddr_0_S_AXI_BASEADDR .stack : { __stack_start = .; . = . + STACK_SIZE; __stack_end = .; } > ps7_ddr_0_S_AXI_BASEADDR }所有> ps7_ddr_0_S_AXI_BASEADDR都指向DDR。我们不需要逐行理解每一段是什么意思,只需要记住一个原则:把这里所有的输出段指向区域改成ps7_ram_0_S_AXI_BASEADDR。你可以用文本编辑器打开lscript.ld,把DDR区域的名称替换成OCM区域名称。注意文本中可能有一堆*(.text .text.*)之类的模式,这些是链接器通配符,不涉及修改,只需要改段后面的内存区域归属。
你可能已经注意到,链接脚本顶部往往通过_STACK_SIZE = 0x1000;这样的符号定义堆栈大小。对于OCM环境,我建议把STACK_SIZE压到0x1000(4KB)以内,因为OCM总共就256KB,栈开得越大,留给业务逻辑的空间就越少。如果是纯裸机程序、没有递归调用,4KB栈已经足够。
3.3 容量上限与对齐检查:不要等链接失败才后悔
链接脚本改完后,重新编译工程,链接器会做两件事:一是把所有输入节拼装成最终的ELF文件,二是检查总占用是否超过MEMORY区域定义的LENGTH。如果超了,会报类似“region ‘ps7_ram_0_S_AXI_BASEADDR’ overflowed by 15872 bytes”的错误。这不是坏事,它逼着你提前看清程序尺寸。
推荐在命令行执行:
arm-none-eabi-size 你的工程目录/Debug/xxx.elf输出会显示text、data、bss三部分的大小。text代表代码和只读数据,data代表已初始化全局变量,bss代表未初始化全局变量。三者之和再加上栈空间,就是OCM的实际占用。如果text有300KB,那就不要挣扎了,要么裁剪代码,要么换带DDR的方案。
还要注意对齐问题。ARM Cortex-A9要求中断向量表按一定边界对齐,链接脚本本身通常会为.isr_vector或_vector_table加KEEP和. = ALIGN(4)之类的修饰,不建议随意改动。如果程序一开始乱跳、中断不触发,第一件事就去查链接脚本里向量表的对齐和起始地址是不是0x00000000。
4. 定制最小FSBL:跳过DDR初始化却不破坏启动链路
4.1 修改FSBL代码或编译选项
FSBL在无DDR场景里是个微妙的存在。BootROM把FSBL放进OCM执行,但如果FSBL源码的链接地址在DDR,那它一运行就会崩。所以第一步是回到FSBL工程,把FSBL自己的链接脚本也改成OCM地址。Vivado SDK的FSBL工程默认同样生成lscript.ld,修改方法和前面应用工程的修改别无二致,只是把FSBL工程里所有内存区域指向OCM的256KB。
这件事做完后,FSBL才能在无DDR的物理环境下安全运行。接下来才是跳过DDR初始化。
打开FSBL主函数,一般位于fsbl_main.c或xfsbl_main.c。函数调用链里有一句核心调用:
Status = XFsbl_InitDdr(); if (Status != XFSBL_SUCCESS) { XFsbl_ErrorUpdate(XFSBL_ERR_DDR_INIT_FAIL); goto ErrorHandler; }这是FSBL默认初始化DDR的入口。对于无DDR设计,我通常把这一整段判断注释掉,或者用一个条件宏包起来。你要想保持工程整洁,可以在fsbl_hooks.c里自己写一个空实现的DDR初始化回调函数,然后在编译选项中定义对应的宏,让FSBL编译进空函数而不是默认的DDR配置流程。具体宏名在不同Vivado版本里略有差异,我自己更倾向直接修改源码,因为FSBL代码是Xilinx提供的开放源码,注释掉那一行并不影响其他外设初始化。
注意,FSBL除了显式初始化DDR,还可能通过MIO和时钟配置间接引用DDR引脚。如果硬件设计上没有连接DDR,FSBL里与DDR相关的MIO配置最好也从初始化列表里去掉,否则可能出现引脚复用冲突。
4.2 一段极简OCM加载器的等效实现
如果你觉得修改FSBL不够痛快,还有一个更彻底的方法:直接写一个比FSBL短得多的自研加载器,替代Xilinx FSBL。这个加载器的任务非常简单:初始化UART、初始化QSPI或SD卡控制器、读取应用镜像、把镜像搬到OCM指定地址、跳转到入口。它不需要DDR,不需要复杂的Handoff参数传递,也不需要支持PCW寄存器设置。
我曾经在一个量产项目里就采用了这种方案。板子上连FSBL都不跑,BootROM直接加载我自研的bootloader到OCM,bootloader再判断是需要进入烧写模式还是正常启动。这个bootloader代码量控制在40KB左右,完全可以在256KB的OCM区域里从容运行,后续应用则被加载到OCM的另一块可用地址。
这种做法的好处是启动链路完全透明、可控。坏处是需要自己处理启动设备驱动,工作量比单纯注释代码大。对于只是想验证无DDR方案是否可行的朋友,我还是建议从修改FSBL开始,先把流程跑通,再决定要不要自己做精简加载器。
4.3 生成BOOT.BIN并烧入QSPI/SD的关键步骤
当FSBL和应用工程都编译好之后,需要用Bootgen工具把它们打包成BOOT.BIN。BIF文件内容大致如下:
the_ROM_image: { [bootloader] fsbl.elf hello_world.elf }第一行指定FSBL为bootloader,第二行是我们要加载的应用。Bootgen会根据每个ELF文件的段地址来决定把数据放到哪里。因为我们的应用ELF已经被链接到OCM地址,所以Bootgen会把应用代码放到0x00000000开始的区域。然后执行:
bootgen -image bootimage.bif -o i BOOT.bin再用QSPI烧写工具或SD卡格式化工具把BOOT.BIN写入启动介质。设置好启动引脚后上电,如果一切正常,你会看到无DDR系统从QSPI启动后自动运行OCM里的应用。这个小实验能彻底改变你对ZYNQ启动流程的理解。
4.4 跳过FSBL直接下载与设备启动的路径选择
在实际项目里,我还遇到过不需要固化启动介质、只想通过JTAG快速验证OCM程序的情况。这种场景可以绕过FSBL,直接用SDK或XSCT把ELF下载到OCM执行。具体流程在下一章详述,但这里要说明一点:JTAG下载方式适合调试,不适合产品化验证。因为真实上电时芯片一定会经历BootROM和FSBL链路,如果这个链路没跑通,JTAG下能跑不代表产品能正常启动。
所以在无DDR方案验证中,我的建议是两条腿走路:开发阶段用JTAG快速迭代,验证阶段务必把BOOT.BIN烧到启动介质上完整复位启动一次。很多“奇奇怪怪的问题”只在冷启动时暴露,比如FSBL把某个外设初始化了一半、BootROM占用的OCM区域和应用重叠等。
5. 下载与验证:没有DDR时的调试技巧
5.1 用XSCT把ELF直接灌进OCM
Vivado软件自带的XSCT(Xilinx Software Command-Line Tool)是调试无DDR工程的利器。硬件连接好JTAG后,启动XSCT,执行:
connect targets -set -filter {name =~ "ARM*#0"} rst -system dow hello_world.elf conrst -system会把整个PS系统复位,同时让CPU停止在复位状态。这步不能省,尤其在你之前可能通过JTAG加载过带DDR的程序时,不复位会导致某个外设处于脏状态,程序行为变得不可预测。dow命令会把ELF中的各个段下载到它们链接的地址——因为我们链接脚本已指向OCM,所以它会下载到0x00000000附近。最后con让CPU开始执行。
如果在执行dow时遇到“存储器写入失败”或“目标地址错误”,多半是链接脚本里还残留DDR地址。用info mem或memmap命令查看目标存储映射,确认0x00000000区间在调试器的内存视图里是有效区域。
5.2 确认程序真的在OCM里跑,而不是幻觉
有一种情况特别迷惑人:程序加载成功、串口也打印了东西,但你并不确定它是从OCM运行的,可能FSBL或调试器偷偷把代码复制到了其他地方。要验证实际执行位置,有一个很朴素的方法:查看链接生成的map文件。
map文件会列出每个段的加载地址(Load Address)和运行地址(Virtual Address)。如果OCM Link脚本生效,你会看到.text段的运行地址从0x00000000开始。再配合XSCT的内存读取命令:
mrd 0x00000000 8这会把OCM开头的8个字打印出来。正常情况下应该看到ARM中断向量表的前几条指令,比如跳转指令ea000016之类的十六进制数据。如果你读到的全是0xFF或0x00000000,说明ELF没有正确加载到OCM起始地址。
我还习惯在代码里故意写一个只读全局变量:
volatile uint32_t ocm_magic = 0xDEADBEEF;程序运行时,用XSCT的mrd去读该变量地址,看是否真是0xDEADBEEF。如果读到正确值,说明数据段确实被链接进OCM并正确初始化了。
5.3 常见踩坑对照:OCM溢出、向量表、BSS清零
无DDR的OCM调试中,最常见的故障可以整理成下面这张表:
| 症状 | 可能原因 | 对策 |
|---|---|---|
| 链接时提示溢出 | 程序总量超过256KB | 裁剪代码、开启-O2优化、检查是否有库函数引入大量代码 |
| 启动后PC跑到0xFFFF... | 向量表不在0x00000000 | 检查lscript.ld中.text段和第0个MEMORY区域的起始地址 |
| 全局变量初值不对 | BSS段未清零或data段初始化拷贝未执行 | 确认crt0.S提供的启动代码被链接进.text段 |
| 中断完全不触发 | GIC或异常向量表配置地址错误 | 确认_vector_table的链接位置和XScuGic的CPU接口设置 |
| 程序偶尔跑飞 | 栈溢出,覆盖相邻代码段 | 减小栈大小、检查递归、用call stack监控栈指针 |
其中BSS清零问题最容易掉以轻心。OCM是SRAM,上电初始值可能是随机值。标准启动代码会把BSS段清零,并把data段从加载地址拷贝到运行地址。如果你自己写启动代码或精简了crt0.S,必须保证这两个动作存在,否则未初始化全局变量初始值随机,程序行为就会像“薛定谔的猫”。
另外一个经验是:在无DDR调试阶段,尽量用-O0关闭优化或-O1轻量优化。原因很简单,-O2可能对程序做激进的指令重排和常量化,导致源码级断点位置不准,误导你判断执行路径。等程序跑稳定了,再开启-O2验证最终行为。
6. 一个能跑的最小示例与量产扩展思路
6.1 UART输出Hello的小工程拆解
我用一个最低限度的串口打印程序来收束前面的内容。这个demo只用到ZYNQ的UART外设,不涉及DDR,不涉及PL,适合验证OCM启动链路。
第一步,在Vivado里创建一个基于Zynq的硬件工程,只保留PS端UART1(MIO引脚)、QSPI或SD控制器(按启动介质选择),不添加DDR IP,不勾选DDR Bank,甚至可以把DDR控制器在Zynq配置界面里直接关掉。生成比特流后Export Hardware并启动SDK。
第二步,在SDK里创建FSBL工程,按第四章的方法修改FSBL链接脚本并跳过DDR初始化,重新编译。
第三步,创建Application工程,修改lscript.ld,把内存区域改为OCM。主函数可以这样写:
#include "xparameters.h" #include "xuartps.h" #include "xil_printf.h" #define UART_BASEADDR XPAR_XUARTPS_0_BASEADDR int main(void) { XUartPs uart; XUartPs_Config *cfg; cfg = XUartPs_LookupConfig(UART_BASEADDR); XUartPs_CfgInitialize(&uart, cfg, UART_BASEADDR); XUartPs_SetBaudRate(&uart, 115200); xil_printf("\r\nOCM BOOT OK, run from 0x00000000\r\n"); while (1) { /* 空循环保持程序运行 */ } return 0; }这个程序编译出来非常小,text段大约十几KB。用JTAG按第五章的方式下载,串口助手会看到打印信息。之后再按4.3节生成BOOT.BIN烧入启动介质,复位后同样打印,就代表无DDR启动链路完全打通。
6.2 从OCM验证平滑过渡到带DDR的正式设计
很多人会问:无DDR验证完以后,如果产品最终还是要加DDR,前面的工作是不是白做了?答案是不仅不白做,还能让正式的带DDR设计更稳。因为你在OCM阶段已经把启动链路、外设初始化、应用程序骨架全部验过一遍,等DDR焊上板子后,只需要把FSBL里的DDR初始化恢复、把应用链接脚本改回DDR地址,就能在成熟骨架上继续堆功能。
我个人的习惯是在工程里保留两套链接脚本,一套lscript_ocm.ld,一套lscript_ddr.ld,通过编译配置切换。这样即使量产版本带DDR,我也能在现场诊断时快速编出一版OCM固件,在不起DDR的情况下测试核心硬件,这对定位“DDR颗粒接触不良”“DDR电源纹波大”这类硬件问题极有帮助。你可以把无DDR的OCM诊断固件当成一套独立的硬件自检系统,它不依赖任何外部存储,永远能在最恶劣的硬件状态下给你留一扇门。
6.3 使用OCM做程序运行区时需要养成的几个习惯
无DDR方案一旦要上产品,有几个习惯最好提前养成。第一,全局变量能省则省,能用局部变量就不用全局变量,能放到.rodata的常量绝不放.data,因为.data段要占双份空间,一份在Flash里存初始值,一份在OCM里放运行时副本。第二,谨慎使用printf族函数,这些函数会把内部缓冲和格式化逻辑带进来,代码尺寸暴涨,必要时自己写一个极简的字符输出函数。第三,定期检查map文件,把明显异常的节找出来,比如某个驱动意外引入了4KB对齐表格。
OCM不是用来承载庞大业务的,它是启动链路的接棒者、硬件自检的看门人、也是低成本产品里那道最后的防线。搞清楚它的脾气,你才算真正驾驭了ZYNQ这套系统,而不是被默认工程模板牵着走。