FPGA实现DSMC协议从设备:RTL设计、仿真与调试实战
简介面向FPGA开发者的DSMC从设备源码与说明包解决LocalBus模式下与瑞芯微RK3576主控进行单次四字节读写交互的问题。设计基于紫光同创PG2L25H-6MBG325芯片并参考创龙科技例程覆盖模块头定义、中间信号定义、网络赋值、原语例化、三态使能信号控制、数据字节匹配、沿计数以及地址和数据使能等关键实现细节对于需要连续读写能力的应用还可在现有代码上修改并加入地址自增逻辑。资源共3个文件以inscode工程入口和html说明文档为主附带gitignore配置压缩包约7KB文件规模精炼工程结构清晰适合熟悉FPGA基础、准备快速移植LocalBus从设备接口或学习可编程逻辑下总线协议实现的工程师参考。已有96人学习内容紧凑可直接对照代码理解单次读写时序与信号控制减少从零搭建和调试的时间。1. DSMC协议从设备这个项目到底要做什么先说项目背景。我最近在做一个FPGA嵌入式控制板主控端通过一条串行总线访问FPGA内部的状态寄存器和配置寄存器。总线协议不是I2C也不是SPI而是项目里约定的DSMC协议——Device Synchronous Multi-access Control设备同步多路访问控制。FPGA这一侧要实现的就是DSMC总线上的从设备slave device。从设备这个词在FPGA开发里很常见但很多人一上来就写代码结果连从设备要干什么都没想清楚。从设备的核心职责只有四件事听话地接收主控命令、正确地解析地址和写数据、按地址返回读数据、再就是差错处理要能兜底。听起来简单真正写RTL的时候你就知道光一个帧头误判就能让你在Vivado仿真里折腾一下午。这个项目不只是把从设备跑通而是产出了一套可以直接复用、参数可调的Verilog源码。源码覆盖了链路接收、协议解析、寄存器读写、CRC8校验、应答帧生成这几个完整链路。如果你正在做FPGA通信协议相关的从设备实现或者想系统学习串行协议状态机的设计思路这份源码和这篇文章都能作为参考。我当时选FPGA而不是单片机来做这个从设备主要是两个原因。第一FPGA的时序是确定的不会像MCU那样因为中断响应延迟导致总线超时第二FPGA可以并行处理多个寄存器不用每条命令都跑一遍软件栈。实测下来DSMC从设备在FPGA上跑100MHz系统时钟单条命令的最坏响应延迟控制在几个时钟周期内比MCU方案稳定太多。花了两周多时间把从设备侧的协议解析、寄存器读写、中断上报全部调通RTL代码也整理成了可复用的源码。接下来我把整个实现过程拆开来讲从协议帧格式设计、状态机架构到仿真验证和ILA实测每一步都说清楚为什么这么做。2. 从设备侧的协议设计与系统架构2.1 帧格式设计不是越复杂越好DSMC协议的帧格式是项目前期就定好的我实现从设备时严格按这个格式解析。帧结构如下帧头1字节固定值0x5A用于帧同步命令1字节bit7为方向标志1表示读、0表示写bit6保留bit5-4为命令类型00访问寄存器、01读状态、10清中断、11保留bit3-0为地址长度指示地址2字节大端序高字节在前支持最大64KB寻址空间数据长度1字节表示后续数据字段的字节数最大64字节数据N字节写操作时由主控发给从设备读操作时由从设备应答返回校验1字节对帧头之后、校验字节之前的所有字节做CRC8校验多项式0x31这个帧格式不是凭空拍脑袋定的每一段都有它的理由。帧头用0x5A而不是0x55是因为0x5A在二进制里是01011010相邻位翻转频率更高方便在接收端验证采样时序的准确性。地址固定2字节是因为这个项目的寄存器空间规划不超过64KB2字节刚好够用。数据长度字段限制到64字节是为了让从设备的FIFO深度可控不至于因为一次突发传输占用太多Block RAM。CRC8多项式选了0x31CRC-8/MAXIM主要是和主控端保持一致。如果你要移植这个协议多项式可以随便换但两端必须一致。我见过有的项目从设备用CRC8、主控用CRC16两边调试了三天最后发现是校验算法不一致这种坑越早避开越好。2.2 时钟架构所有逻辑统一到系统时钟域DSMC总线的SCLK是外部主控提供的和FPGA系统时钟不同源。最省事的做法是在FPGA内部用PLL把SCLK倍频然后整个从设备逻辑跑在SCLK的同步时钟域里。但这样做有一个隐患如果主控跑着跑着突然总线挂起、SCLK停振从设备会一直停在一个不确定状态连复位信号都收不到。我当时用的方案是系统时钟100MHz作为唯一工作时钟SCLK通过4级寄存器打拍同步后在系统时钟域内检测SCLK上升沿并采样SDIO上的数据。这样即使SCLK出现毛刺或停止状态机也始终由系统时钟驱动不会僵死。CS_n信号同样需要异步处理。CS_n拉低表示一次传输开始拉高表示传输结束。因为CS_n可能在任何时刻变化所以也做了打拍同步并且用边沿检测生成enable脉冲。这个方案在工程上最稳代价只是多几个周期的响应延迟对DSMC这种低速协议完全够用。2.3 寄存器映射表地址规划要留余量从设备侧维护一份寄存器映射表所有可访问的寄存器在这个表里登记。我项目的映射表长这样地址名称读/写复位值说明0x00DEV_ID只读0x0000_0001设备ID0x04CTRL读写0x0000_0000控制寄存器0x08STATUS只读0x0000_0000状态寄存器0x10-0x2FBUF[0-7]读写0x0000_0000数据缓冲区这里有一个经验地址规划一定要留余量。0x04控制寄存器旁边我留了0x08到0x0F的空白段就是为了后续扩展版本号、固件升级标志这类寄存器时不用动已有定义。硬件寄存器地址一旦发布软件那边就会写死后面再想改就要忍受版本兼容的麻烦。2.4 顶层模块划分与数据流整个从设备RTL被分成了四个模块模块边界按数据流切分dsmc_rx串行接收模块负责SCLK采样、移位寄存、字节对齐dsmc_parser协议解析状态机负责帧头检测、命令解析、CRC校验dsmc_regfile寄存器文件模块负责地址译码和读写操作dsmc_tx串行发送模块负责把应答数据按时序串行输出值得强调的是解析和寄存器文件必须分开。如果解析状态机直接操作寄存器内容代码会耦合得很厉害后面想单独仿真寄存器读写逻辑都困难。数据流的走向是SDIO串行数据进dsmc_rx拼成8位字节后给dsmc_parser解析出有效命令后由dsmc_regfile执行读写读数据需要返回时经dsmc_tx模块变成串行数据发出去。这条链路清晰每一级的debug边界都很明确。3. 核心RTL模块的源码实现与设计思路3.1 接收模块采样点的选择是重中之重接收模块最核心的是采样点选择。DSMC总线的SCLK速率在项目里是1MHz系统时钟100MHz也就是每个SCLK周期有100个系统时钟周期。我采用SCLK上升沿采样的约定用同步后的SCLK信号去sample SDIO。采样逻辑的核心代码大致是这个思路reg [3:0] sclk_sync; reg sclk_pos_edge; always (posedge clk_sys or negedge rst_n) begin if (!rst_n) begin sclk_sync 4b0; end else begin sclk_sync {sclk_sync[2:0], sclk_in}; end end assign sclk_pos_edge (sclk_sync[2:1] 2b01);sclk_rising检测的是sclk_sync[2:1]等于2b01而不是sclk_sync[3:2]是因为需要给亚稳态留出打拍时间。第1级寄存器输出还可能处于亚稳态第2级才开始稳定所以用中间两级做边沿检测更保险。采样数据时我在检测到sclk_pos_edge后延迟半个系统时钟周期再采样sdio_in目的是避开信号翻转瞬间的毛刺窗口。实际仿真发现不延迟这半个周期偶尔会有采样到不确定值的边缘情况这在调试时非常恼人。3.2 协议解析状态机为什么用三段式协议解析是整个从设备的神经中枢。我采用了经典的三段式状态机写法状态跳转、状态动作、输出寄存分开三个always块。状态定义如下localparam IDLE 4d0; localparam HEADER 4d1; localparam CMD 4d2; localparam ADDR_H 4d3; localparam ADDR_L 4d4; localparam LEN 4d5; localparam DATA_RECV 4d6; localparam CRC 4d7; localparam RESPONSE 4d8; localparam ERROR 4d9;从IDLE开始每次收到一个字节就更新状态。这里有个细节帧头检测不是简单的等于0x5A就通过而是连续两次收到0x5A才算帧头有效。为什么因为如果总线空闲时SDIO被拉低再拉高可能恰好凑出一个0x5A。加一个字节的冗余确认可以大幅降低误同步概率。代价只是每帧多一个字节的开销对于寄存器操作这种短数据帧来说完全可接受。三段式状态机和一段式的区别在于状态跳转和输出逻辑分离代码可读性更好也特别好加调试逻辑。我用了一段式写过早期版本状态一多after synthesis之后很难追信号。3.3 CRC8校验的实现细节CRC8校验考虑过用查表法还是组合逻辑法。查表法需要256字节的ROM在FPGA里占用一个小的分布式RAM组合逻辑法直接用异或门级联延迟很小但代码不够直观。最终用了查表法因为DSMC总线速率低几个周期的查表延迟根本感觉不到但代码可维护性高得多。查表表项只留了16个半字节查表每次计算一个字节分两次查表这样表项更省。CRC的计算范围是从命令字节开始到数据字段最后一个字节结束不包括帧头。帧头只做同步用不参与校验。这个约定主控端和从设备端必须一致否则两边各算各的永远对不上。我在源码注释里专门用大写标注了这个约定。3.4 一个容易忽略的细节读操作时的总线方向切换DSMC总线是半双工的SDIO在写操作时由主控驱动在读操作时从设备驱动。方向切换有一个关键时序问题命令帧最后一位数据和应答帧第一位数据之间必须留出至少一个SCLK周期的总线空闲时间用来做方向切换避免两个驱动源同时驱动冲突。实现上我用一个三态门控制SDIO输出assign sdio_out (tx_en) ? tx_data_reg : 1bz;tx_en在读应答阶段拉高其他时间拉低。方向切换的时序在状态机里专门加了一个TURN_AROUND状态这个状态不传数据只是让总线处于高阻态。很多从设备设计失败就是没留这个总线转身时间导致读数据时第一个字节总是错的。4. 仿真验证与板上调试实测中的意外4.1 Testbench设计模拟器比被测模块更重要写Testbench不是随便给几个激励就行了。要验证从设备必须先写一个DSMC主设备模拟器Bus Functional Model它按协议生成写命令、读命令、错误帧。这个模拟器写得好不好直接决定了仿真的可信度。我在BFM里实现了以下场景正常写寄存器帧头写命令地址数据CRC正常读寄存器帧头读命令地址CRC然后检查SDIO方向是否切换应答数据是否正确CRC错误帧故意翻转一个数据位地址越界帧访问未定义的寄存器地址半截帧发完帧头发送端就拉高CS_n每个场景都用task封装好一个testbench跑完所有用例最后用$display打印PASS/FAIL。这里有个心得测试用例的自动判定一定要写在Testbench里靠肉眼在波形图里找问题十个用例能漏掉三四个。4.2 仿真里抓到的第一个bug状态机卡死在DATA_RECV第一次跑通仿真后发现一个奇怪现象连续发送16条写命令后有概率出现从设备不响应状态机卡在DATA_RECV状态不再跳转到CRC状态。排查过程费了不少劲。在波形图里比较卡死前后的信号发现卡死前DATA_RECV状态下收到一个帧头字节0x5A长度字段计数刚刚好走到零。问题出在计数器的归零逻辑上数据长度字段为0时DATA_RECV状态根本没有进入——但因为帧格式约定数据长度最小为1所以正常情况下不会触发。可当主控端软件突发异常发了一个长度为0的帧状态机就永远停在DATA_RECV。修复方法很直接在DATA_RECV状态的进入条件里加入长度判断如果长度为0则直接跳转到CRC状态。这个bug如果没有系统性仿真覆盖在实机上几乎不可能复现但一旦出现就是整个总线挂死的严重后果。所以异常帧的处理从设备设计时必须当成第一优先级宁可有几个时钟周期的处理开销也不能让状态机陷入死锁。4.3 ILA调试实录板上实测的时序悖论仿真全部通过后上板调试。我用Vivado的ILA集成逻辑分析仪抓了DSMC总线的实时波形结果发现了一个仿真完全没暴露的问题写寄存器的数据偶尔会错位一个SCLK周期。看ILA波形发现CRC校验通过、数据也进入寄存器文件了但写入寄存器的数据整体右移了一位。原本0x55变成了0xAA。追信号后发现是SCLK上升沿和SDIO数据变化沿在硬件上太接近SCLK边沿检测触发时SDIO还没有完全稳定。这个问题在仿真里不存在因为Testbench的时序是理想的。实机上信号有走线延迟、驱动器上升时间SCLK和SDIO的相对延迟会漂移。解决办法是在接收模块里采样SCLK上升沿后等待几个系统时钟周期再采样SDIO把采样点从边沿附近移到数据稳定窗口的中间。改成滞后采样后连续跑了几万条命令数据错位没有再出现。ILA使用的具体方法在Vivado里把dsmc_rx模块的几个关键信号sclk_sync、sdio_sync、bit_cnt、rx_byte添加进ILA采样深度设成1024触发条件设为CS_n下降沿。这样每次主控发起传输ILA都能抓住完整的命令帧波形。4.4 时序约束的教训不写约束等于白调这个项目在时序约束上栽过跟头。一开始图省事直接在Vivado里跑综合实现没做任何xdc约束结果ILA看到的信号时序和实际物理走线差别很大异步输入信号的时序路径完全不可控。正确的做法是给所有异步输入信号加约束。至少要做这几个绑定对SDIO、CS_n、SCLK这些外部输入信号设置Input Delay约束对同步打拍寄存器设置虚假路径约束避免Vivado把它们当作普通时序路径分析对系统时钟确保PLL的clock constraint正确生成并且检查Clocking Wizard的输出频率Input Delay的值根据DSMC总线实际走线长度和驱动端特性估算。我这边PCB走线很短约束设到2ns左右Vivado的时序收敛结果明显改善。5. 架构设计细节与避坑经验5.1 校验失败后的行为静默丢弃还是返回错误码对CRC校验失败的帧从设备有两种处理策略一是不做任何响应二是返回一个错误应答帧。我两种都试过。静默丢弃的实现简单但调试困难——主控端发了一个坏帧从设备没反应主控根本不知道是帧格式错了还是从设备挂了。返回错误应答帧的方式主控能明确收到NACK软件容易区分错误类型。代价是帧格式要增加一个应答字节的定义。我的做法是CRC失败时在应答阶段直接返回0xFF作为错误码并且在STATUS寄存器里置一个CRC错误标志位。这样主控既能看到实时错误码也能通过读STATUS寄存器获取历史错误统计。5.2 模块复用与参数化为一劳永逸布线DSMC从设备模块在写代码时做了充分参数化方便往其他项目里移植。参数包括DATA_WIDTH数据位宽默认8位可以改成16位与更宽的数据总线对接ADDR_WIDTH寄存器地址位宽默认10位对应1KB寄存器空间FIFO_DEPTH接收FIFO深度默认16突发长度较大时调大CRC8_INITCRC初始值默认0x00参数化在Verilog里用parameter实现但要注意Vivado的IP Integrator对Verilog参数化支持有限如果你要把DSMC从设备封装成IP核建议用AXI-Lite接口包装一层。我自己在导出IP时就遇到参数无法通过IP GUI暴露的问题最终是通过直接改xci的tcl脚本解决的这个坑值得提前知道。5.3 可测试性设计Debug接口要比协议接口多从设备协议接口只有SCLK、SDIO、CS_n三个引脚但板上调试远远不够用。我给模块加了三个内部测试信号在综合时通过keep属性保留fsm_state[3:0]当前主状态机的状态编码rx_byte_valid接收字节使能信号用于确认接收链路工作正常crc_error_pulseCRC错误脉冲用于ILA触发在Vivado里用(* keep true *)寄存器属性避免综合工具把这几个调试信号当作中间变量优化掉。没有这三个信号调试的效率会低很多。ILA触发条件可以精确设置为crc_error_pulse 1专抓错误帧场景。5.4 中断上报的工程化处理DSMC协议里有一个清中断命令类型它的实现容易疏忽。中断状态寄存器的清除方式有写1清0和写0清1两种约定。我用了写1清0——主控向中断标志位写1表示该中断已被处理。这个约定在MCU外设里很常见但FPGA开发者如果不熟悉容易搞反。中断的处理流程是中断条件产生时中断状态寄存器对应位置1同时通知主控可以通过DSMC总线读状态寄存器获取中断源主控处理完成后向中断状态寄存器写1清零。这个流程对多个中断源的场景尤其重要如果清中断逻辑写错会出现主控永远读到一个幽灵中断系统CPU占用率被拉满。6. 源码获取与后续扩展建议整套DSMC从设备源码我已经整理好包含dsmc_rx、dsmc_parser、dsmc_regfile、dsmc_tx四个核心模块以及完整的Testbench和Vivado约束文件。源码风格遵循可以综合的RTL代码规范模块间信号全部显式声明不加任何仿真专用的initial语句在可综合代码里。如果要在这个基础上继续扩展我会优先建议三个方向第一是增加多从设备支持当前实现地址空间已经在寄存器层面预留了设备号字段但要真正挂接多设备协议层还需要加片选仲裁逻辑第二是加DMA能力当前读写寄存器是一次一帧、每帧最多64字节如果需要大批量传输数据通道要改成AXI-Stream接口第三是加总线超时保护主控发起传输后SCLK异常停止时从设备能在一个可配置的超时周期后自动回到IDLE状态我在实际调试中还有一个体会DSMC从设备的地址译码逻辑最好支持非法地址的回归响应。当主控访问未定义的寄存器地址时直接返回全0数据比返回高阻态更安全因为高阻态会让主控读到的数据不确定增加调试难度。这个细节很多人会漏掉但对系统稳定性影响很大。最后分享一个调试技巧如果你也遇到主控偶尔读到错误数据的情况优先怀疑的不是FPGA逻辑而是总线方向切换时序和采样点位置。这两个地方是半双工串行协议最容易出错也最难查的问题我在这个项目里踩过的坑基本都集中在这两处。本文还有配套的精品资源点击获取

相关新闻

最新新闻

日新闻

周新闻

月新闻