
不带DDR的ZYNQ听起来像个“减配”方案实际做起来却有不少门道。很多人一听说ZYNQ就默认必须外挂DDR3/4觉得没DDR就玩不转Linux、跑不了复杂算法。但在一些对成本、体积、功耗极度敏感的嵌入式场景里去掉DDR恰恰是刚需。我之前接过一个项目PCB面积被卡得很死DDR走线区域根本没地方放于是只能把ARM核当作一个加强版单片机来用程序全部跑在片内OCM里。这个方案做出来后启动速度、稳定性、物料成本全部符合预期后面同事再评估类似项目时我都会直接把这套不带DDR、用OCM加载程序运行的方法甩过去。这篇文章就把我实际摸索出来的两条路线完整写出来一条是裁剪FSBL跳过DDR初始化另一条是自写极简加载器。同时会把OCM的内存布局、链接脚本设置、缓存注意事项、启动镜像打包和常见故障排查一次讲透。适合正在评估低成本ZYNQ方案、或已经在无DDR板卡上启动失败想找原因的朋友。1. 为什么要在没有DDR的ZYNQ上跑程序场景与限制1.1 哪些板子会“不带DDR”ZYNQ-7000系列本身没有片内大容量RAMPS端的Cortex-A9双核处理器正常工作需要外部存储器的支撑。传统开发板的标配是DDR3或DDR4颗粒而“不带DDR”的板子通常分为两类第一类是产品定义时为了省BOM成本和PCB面积主动砍掉DDR颗粒第二类是早期样机阶段DDR贴片异常或者设计时漏接了DDR部分但希望先用OCM验证关键功能。不管哪类情况都逃不开一个问题BootROM已经把启动流程写死了它默认FSBLFirst Stage Boot Loader会被加载到OCM里运行而FSBL面对的设计模型又默认你有一颗DDR存在。所以不带DDR时第一件要做的事就是逆着FSBL的默认逻辑去改造。1.2 OCM到底是什么能装下多少东西OCM全称On-Chip Memory也就是ZYNQ PS端内置的256KB SRAM。它被分成两个128KB区块地址范围从0x00000000到0x0003FFFF。别小看这256KB它位于片内访问延迟远低于外部DDR而且天然不存在DDR信号完整性、刷新、功耗那堆事。对大量裸机程序来说代码如果能控制在几十KB、数据区再占几十KB256KB完全够用。不过需要清醒一点OCM不是“无限量”的。FSBL本身运行时会占掉一部分OCM空间BootROM启动阶段也会保留一小块区域用于自身堆栈和临时数据。真正留给用户应用的地方大约能稳定使用200KB到240KB具体取决于你用的FSBL版本和加载器实现。如果应用要跑Linux那256KB是绝对不够的但如果只是跑一个裸机逻辑比如传感器采集、协议转换、简单电机控制、GPIO点灯甚至一个小型网络协议栈OCM完全能撑得住。1.3 没有DDR启动链上到底断了哪一环“启动链断开”这个说法特别形象。正常带DDR的方案里BootROM从QSPI/SD/NAND读入FSBL到OCMFSBL初始化DDR后再把FPGA比特流和应用程序拷到DDR最后跳转执行。无DDR方案里DDR这个中转站不存在了FSBL如果仍然按照默认流程去初始化DDR轻则执行了一段无效配置重则直接卡死在等待控制器就绪的状态中。用户应用如果还链接到DDR地址一跳转就是一记Data Abort。所以不带DDR时真正要解决的核心问题不是“程序怎么写”而是“程序怎么进去、跳到哪里”。后面所有方案都是围绕这个核心展开的。2. 启动流程拆解从BootROM到FSBL再到应用2.1 标准DDR方案的启动链路ZYNQ-7000的启动过程有一个标准链条。芯片上电后BootROM最先运行它根据启动模式引脚通常是MIO的拨码配置决定从哪里读取下一级镜像。BootROM会把QSPI Flash/SD卡等介质头部的一个特殊格式镜像解析出来将FSBL拷贝到OCM中然后跳转到FSBL入口执行。FSBL接管后做四件事初始化PS端的时钟和MIO、初始化DDR控制器、把FPGA比特流通过PCAP接口配置到PL端、然后从启动介质里把用户应用的ELF分区读取出来并搬运到指定地址最后跳转到应用入口。这里有个关键点FSBL本身是被链接到OCM地址运行的因为BootROM只认OCM这个落脚点。这也是为什么没有DDR时FSBL依然可以启动问题只出在它初始化DDR和加载应用到DDR这两步。2.2 无DDR时FSBL会卡在哪一步实际调试无DDR板卡时最常见的挂死点就是FSBL里的DDR初始化函数。很多FSBL代码在调用DDR初始化时会等DDR控制器的某些状态位返回ready如果芯片上根本没有接DDR颗粒或者DDR控制器的时钟配置根本无效状态位就永远等不到整个启动过程在JTAG下看表现为PC指针停在一个循环里反复读状态寄存器。第二个挂死点是应用搬运。FSBL会根据应用ELF里的段加载地址去搬数据默认应用的.text、.data一般被链接到了DDR起始地址比如0x00100000。FSBL把数据搬完了跳过去执行第一条指令或者第一条访存指令直接访问不存在的DDR物理地址在ARM架构下就会触发Data Abort异常系统复位或者卡在异常向量里看起来就是“启动失败”。2.3 解决思路让FSBL“断尾求生”或换自定义加载器想明白了问题出在哪解法就很清晰。第一种思路保留FSBL但把它对DDR的依赖全部去掉同时把用户应用链接到OCM地址让FSBL把应用搬运进OCM。第二种思路干脆不要标准FSBL自己写一个比FSBL更精简的第一阶段加载器只做必要的PS初始化、读取下一级镜像、搬运到OCM、跳转。两条路线我都跑通过各有优缺点下面分别讲清楚。3. 方案一修改FSBL跳过DDR初始化应用链接到OCM3.1 第一步Vivado里把DDR接口从PS配置中摘掉很多人做ZYNQ工程时习惯在Vivado里双击ZYNQ Processing System直接保留默认DDR配置不管板子上有没有DDR颗粒。这是导致后续FSBL永远尝试初始化DDR的根源。正确做法是在Vivado的PS配置界面里进入DDR Configuration把DDR控制器从“Enable”改成“Disable”或者干脆不勾选DDR接口。这样生成的硬件描述文件HDF里就不会包含DDR控制器的初始化参数PS端MIO也不会被配置成DDR引脚。这里有个实操细节如果把DDR从PS配置里拿掉Vivado可能会提示某些Bank电源域、PL侧参考时钟配置不完整需要额外检查一下与非DDR相关的PLL和复位配置是否正常。理论上PS端最小系统只需要配置静态时钟、UART、QSPI/SD以及PL侧的FCLK其他都可保持默认。3.2 第二步建立FSBL工程并裁剪DDR初始化在Vitis里新建一个“Zynq FSBL”应用工程导入从Vivado导出的HDF。如果硬件设计阶段已经把DDR摘掉那么生成的BSP包里ps7_init函数会自动跳过DDR控制器初始化。但为了保险我还是建议打开FSBL源码里的fsbl_main.c或main.c把关于DDR初始化的调用搜索一遍重点看ps7_init()执行完后是否还有独立的ddr_init()相关逻辑有些版本的FSBL会通过宏定义DDR_SAFETY_INIT或单独判断XPAR_PS7_DDR_0_S_AXI_BASEADDR是否存在来决定是否初始化DDR如果没有这个宏开关直接注释掉DDR初始化函数调用即可。另外FSBL初始化完成后可能会默认把堆栈或堆指针设置在DDR地址这也要改。FSBL源码里的链接脚本同样需要检查确保整个FSBL运行时不触碰DDR地址段。裁剪完成的标志是在JTAG下单步跟踪FSBL执行完ps7_init()后能顺利走到“Loading applications”之类的日志打印而不是停在某个DDR状态寄存器循环中。3.3 第三步用Vitis链接脚本把应用绑定到OCMFSBL那边的DDR依赖处理完后就要管住应用让它别老想着往DDR里跑。默认新建立的裸机BSP工程里链接脚本lscript.ld的MEMORY段通常会这样写MEMORY { ps7_ddr_0_S_AXI_BASEADDR : ORIGIN 0x00100000, LENGTH 0x1FF00000 ps7_ram_0_S_AXI_BASEADDR : ORIGIN 0x00000000, LENGTH 0x00030000 ps7_ram_1_S_AXI_BASEADDR : ORIGIN 0xFFFF0000, LENGTH 0x0000FE00 }如果你直接用这个脚本编译代码段默认会被安排到DDR地址无DDR板上必崩。修改方法很直接把可执行段、数据段、堆栈段全部指定到OCM对应的内存区域。如果希望应用不跟FSBL抢低地址可以把应用链接起始地址放到0x00020000也就是高OCM区块。修改后示例MEMORY { OCM_LOW : ORIGIN 0x00000000, LENGTH 0x00020000 OCM_HIGH : ORIGIN 0x00020000, LENGTH 0x00020000 } ENTRY(_vector_table) SECTIONS { .vectors : { *(.vectors) } OCM_LOW .text : { *(.text*) } OCM_LOW .data : { *(.data*) } OCM_LOW .bss : { *(.bss*) } OCM_LOW .heap : { __heap_start__ .; . . 0x1000; __heap_end__ .; } OCM_HIGH .stack : { __stack_start__ .; . . 0x4000; __stack_end__ .; } OCM_HIGH }注意向量表要放在地址0x00000000附近或者确保异常向量表能被正确映射到当前使用的地址。如果应用不想占用低OCM也可以把向量表放在OCM_HIGH的开头并设置VBAR寄存器但这会额外增加一些汇编代码对新手来说不如直接用低地址省心。编译后留意链接日志特别是“Region OCM_LOW overflowed”之类的报错。一旦出现溢出说明代码量超出OCM容量只能通过优化代码、裁剪无用模块解决。3.4 第四步生成BOOT.BIN并烧录验证在Vitis的Xilinx Tools菜单下选择“Create Boot Image”把FSBL工程生成的ELF放第一个分区应用ELF放第二个分区。这里有个重要参数是应用的启动地址它必须和应用ELF链接的入口地址一致否则FSBL跳过去之后找不到正确的入口。常见做法是在Boot Image工具里手动指定应用分区的Start Address为0x00000000或者你链接脚本里的入口地址。生成BOOT.BIN后通过QSPI Flash或SD卡启动。对QSPI方案可以用Vivado Hardware Manager直接加载BOOT.BIN到Flash的指定偏移处对SD方案把BOOT.BIN放到FAT32格式的SD卡根目录即可。验证方法千万不要只用“灯不亮”来判断推荐在应用里加一个UART串口打印或者用GPIO翻转配合示波器观察。我第一次跑通时空有LED亮心里还是不踏实后来加了串口打印才彻底确认程序确实在OCM里跑起来了。4. 方案二做一个只占OCM的自定义极简加载器4.1 为什么还需要更小的加载器修改标准FSBL的优点是省事缺点也很明显FSBL这个工程体积不小光是从QSPI/SD读取数据、解析ELF头、搬运数据、配置PL这些功能加起来就会在OCM里占据一块不小的地盘用户应用可用的最大尺寸会被压缩。如果应用本身代码量快要顶到200KB或者你想把整个OCM的利用率发挥到极致那最好抛弃标准FSBL自己写一个极简加载器。极简加载器做的事情只有三件初始化UART时钟、根据启动模式读取指定的二级镜像、把二级镜像搬到固定内存地址后跳转。因为不需要支持复杂的ELF解析这个加载器可以做到几KB以内把宝贵的OCM空间几乎全部留给应用。4.2 极简加载器的启动地址和代码骨架BootROM加载第一级镜像时并不会区分这个镜像是不是“正规”FSBL它只认启动头里的地址和长度。所以极简加载器完全可以伪装成FSBL把自己链接到OCM低地址比如0x00000000然后把真正的应用固定放在OCM高地址比如0x00020000。加载器只需要在启动后把位于QSPI/SD固定偏移处的应用数据读到OCM_HIGH即可。伪代码可以这样理解void _start(void) { // 1. 关闭D-Cache和I-Cache先保证执行环境一致 disable_dcache(); disable_icache(); // 2. 极简时钟初始化确保QSPI/UART能工作 init_uart(); init_qspi(); // 3. 从QSPI固定偏移读取应用镜像到0x00020000 qspi_read(APP_IMAGE_OFFSET, 0x00020000, APP_IMAGE_LENGTH); // 4. 如果是PL配置也可用PCAP直接下发 // configure_pl(); // 5. 跳转到应用入口 jump_to(0x00020000); }实际工程中QSPI的读取时序、等待状态、命令字都需要根据具体Flash芯片调整。这一步也是极简加载器里最容易翻车的地方建议优先复用Vivado生成的QSPI驱动代码而不是从头写裸寄存器操作。4.3 如何把应用打包进同一个BOOT.BIN极简加载器方案的BOOT.BIN打包方式和标准FSBL几乎一样但有几个关键差异。第一加载器ELF本身作为第一个分区时要确保它满足BootROM对FSBL分区的格式要求本质上它和FSBL必须处在一个“可被BootROM识别”的启动头框架下所以建议直接在Vitis工程里把加载器编译成独立ELF再通过Create Boot Image工具生成。第二应用分区不再需要ELF解析可以按裸二进制数据进行打包Boot Image工具允许添加Data分区启动时加载器自行把这段数据从Flash搬运到OCM目标地址。当然如果你希望BootROM或FSBL自动搬运则需要走ELF分区流程。4.4 与标准FSBL方案的成本对比这个方案多出来的工作量主要是加载器本身的代码。如果应用还在原型阶段建议先用方案一跑通整体逻辑等应用稳定了、对OCM空间有迫切需求了再切换到极简加载器。对比来看标准FSBL方案调试周期短但OCM可用空间被压缩了极简加载器方案初期要花费一到两天写Flash驱动和跳转逻辑后续却能把每一KB OCM都用在刀刃上。我在实际产品里最终用的是极简加载器因为应用里要放一个不小的协议栈缓冲区FSBL方案的空间不够换极简加载器后刚好塞下。5. OCM内存布局链接脚本、堆栈和向量表怎么安排5.1 256KB里要怎么分区OCM虽然只有256KB但不是所有区域都能随意使用。BootROM运行时需要O CM低端一部分空间FSBL或极简加载器又占掉一段用户应用的向量表、代码段、只读数据、读写数据、堆、栈全部挤在一起。合理的布局通常如下区域地址范围大小用途BootROM保留区0x00000000 - 0x00000FFF4KBBootROM临时变量与堆栈通常不建议用户覆盖加载器/FSBL区0x00001000 - 0x0001FFFF约124KB第一阶段引导代码工作区应用向量表与代码0x00020000 - 0x0002FFFF64KB应用.text、.rodata、.data应用BSS与堆0x00030000 - 0x00037FFF32KB未初始化变量与动态分配区应用栈0x00038000 - 0x0003FFFF32KB栈向下生长从高地址往低地址这个分区不是死的可以根据实际代码情况调整。栈大小要重点评估如果应用里有中断嵌套或者使用了一些比较深层的函数调用32KB都不一定够用反之纯点灯程序4KB栈都嫌多。5.2 链接脚本配置实例上面已经给过一份基础链接脚本这里补充一个更完整的实际配置思路。假设加载器占了低128KB应用从0x00020000开始那么应用工程的链接脚本可以这样写ENTRY(_vector_table) MEMORY { APP_CODE : ORIGIN 0x00020000, LENGTH 0x00010000 APP_DATA : ORIGIN 0x00030000, LENGTH 0x00008000 APP_STACK : ORIGIN 0x00040000, LENGTH 0x0 } SECTIONS { .text : { *(.vectors) *(.text*) *(.rodata*) } APP_CODE .data : { __data_start .; *(.data*) __data_end .; } APP_DATA .bss : { __bss_start .; *(.bss*) *(COMMON) __bss_end .; } APP_DATA . ALIGN(8); __heap_start .; . . 0x4000; __heap_end .; . ALIGN(8); __stack_top ORIGIN(APP_CODE) LENGTH(APP_CODE) LENGTH(APP_DATA) LENGTH(APP_STACK); ASSERT(__stack_top 0x00040000, Stack overflow into reserved area!) }这里把APP_CODE和APP_DATA分开便于监控哪一段涨得快。实际跟踪时如果发现.data段占得太多可以把一些大的只读常量放到Flash里按需读取但QSPI读取速度相对OCM会慢不少要权衡。5.3 缓存、AXI端口和时序需要注意的事项OCM是片内SRAM但在ZYNQ的地址空间中它仍然是一个从端口设备。Cortex-A9核心访问OCM要走一层总线因此并不能完全等同于访问内部紧耦合内存。实际使用中我建议直接关闭D-Cache或者使用非缓存映射。原因很简单OCM空间太小缓存命中率带来的性能提升不明显但D-Cache一致性问题却很可能造成“程序在JTAG下正常、离线启动就跑飞”的诡异现象。I-Cache可以保留至少不会引起数据一致性问题。OCM同时支持PS端CPU和PL端AXI端口访问但这不是免费的。PL侧如果频繁通过AXI_HP口读写OCM会跟CPU访问产生总线仲裁竞争导致执行时间抖动。实测中我遇到过一次PL侧DMA高速搬运OCM数据时CPU被明显拖慢的情况。这个问题的解决思路是尽量把PL需要的高频数据放到PL侧的BRAM/Uram中OCM只保留低频控制和状态数据避免两边互相抢带宽。6. 常见问题与排查技巧无DDR启动的翻车实录6.1 现象、原因对照速查表现象可能原因排查方向上电后完全没有打印输出BootROM没读到镜像或Flash启动模式错检查启动模式引脚检查QSPI烧录地址确认BOOT.BIN头部有效FSBL卡死在DDR状态寄存器循环Vivado HDF仍包含DDR控制器配置回Vivado把DDR接口禁用重新生成HDF和FSBL串口只打印FSBL信息应用没跑应用ELF链接到了DDR地址改链接脚本把应用重定位到OCM应用段复制完成后跳转即挂入口地址和链接脚本不一致核对Boot Image里的Start Address与实际ELF入口裸机程序跑起来后偶发乱码D-Cache未关闭OCM数据不一致在启动代码里禁用D-Cache或建立非缓存映射链接报“region overflow”代码/数据超出OCM容量裁剪功能、优化编译选项或改用极简加载器释放空间QSPI读取数据偶尔出错Flash读取时序、等待周期配置不对确认Flash型号的读命令和读模式对照数据手册修改QSPI控制器6.2 JTAG调试时的三个关键观察点用JTAG调试无DDR启动时不要只盯PC指针重点看三个地方。第一是FSBL日志里的DDR init输出如果启动日志压根没有这一行说明裁剪成功有的版本会直接打印“DDR init skipped”那就更放心了。第二是应用入口处的第一条指令单步到入口后看一下sp寄存器的值是否落在OCM高地址范围内如果sp指向DDR地址程序迟早会踩雷。第三是应用启动后访问的第一个外部外设地址确认这个外设在MIO或EMIO中被正确配置否则可能会因为访问未使能外设引起总线错误。6.3 避坑清单我从工程里总结的教训第一不要把整个FSBL的DDR初始化代码注释掉了事还要检查FSBL源码里有没有用到DDR地址的数组或者链接符号。有些FSBL版本在fsbl_handoff.S里会把一部分临时数据放到0x00100000附近这地方如果没有DDR跳转瞬间就崩。第二应用里如果要用printf输出调试信息先确认这个实现不会默认申请大块堆内存。Xilinx的裸机BSP提供的xil_printf比标准printf轻量很多推荐优先用它避免堆空间被无谓占用。第三尽量把启动镜像做成单一BOOT.BIN而不是分开烧写否则生产环节容易漏烧。极简加载器方案里应用作为Data分区跟加载器一起打包成BOOT.BIN整体烧录一次就完成生产管理会轻松很多。第四如果要跑看门狗注意喂狗任务不能占用过大的OCM缓冲区。有一次我把看门狗任务和主程序都放在OCM里结果栈溢出后看门狗任务先崩整个系统陷入死循环排查了很久才发现是栈空间不足并不是看门狗本身的配置问题。第五PL侧的比特流如果很大可以不用每次启动都加载。如果PL逻辑只做固定功能可以把比特流存到QSPI另一块区域FSBL或加载器只在需要时配置PL或者干脆跳过PL配置让PS单独启动这能把启动时间缩短一截。7. 关于这套方案的后续扩展不带DDR的ZYNQ跑OCM这套玩法验证性能量小、逻辑简单的固化程序非常合适。我自己验证完后还顺手做了两个扩展一个是把QSPI里存了好几份应用镜像加载器启动时根据拨码或按键选择运行哪一份相当于做一个低成本的多固件热切换另一个是在PL里做了一个简易FIFOPS用OCM做小容量数据缓冲FPGA负责高速采集并把数据通过AXI写入到SRAM数组里。虽然这些扩展在带DDR的板子上显得多此一举但在无DDR构架下每一个字节都珍贵反而逼着我把软件结构做得更精简这也算是这个方案的额外收获。最后分享一个实操时最值得记住的小技巧整套方案调通之前先用JTAG把FSBL和应用直接加载到OCM里验证逻辑再去折腾Flash启动。JTAG能跑Flash启动不起来问题基本出在镜像打包或者启动介质读取环节JTAG都跑不起来问题基本出在链接脚本或者DDR依赖裁剪上。把排查范围一刀切开定位速度会快很多。