ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

FPGA实现IIC主机控制器:从协议到Verilog状态机设计实战

FPGA实现IIC主机控制器:从协议到Verilog状态机设计实战 简介FPGA常用通信总线IIC Verilog代码是一份面向FPGA入门者和嵌入式开发者的实战工程包以Quartus项目形式展示如何使用Verilog语言实现IIC协议并完成对EEPROM的字节读写访问。代码包含IIC主控模块、仿真测试文件以及相关约束配置适合用于学习IIC起始/停止条件、7位地址、ACK机制和时序分频等关键知识点。资源压缩包共85个文件核心内容为v格式的Verilog源码与tb仿真测试文件同时包含Quartus工程配置qpf/qsf、编译报告rpt、引脚分配pin和可直接下载的sof配置文件整体仅390KB结构完整且便于动手验证。已有3469人学习下载资源提供了从源码阅读、仿真调试到硬件上板验证的完整链路初学者可参照工程快速搭建自己的IIC读写模块也可在此框架上扩展多个IIC设备支持或优化通信效率。 同事前阵子从网上下了一段IIC的Verilog代码仿真能过上板就是读不到传感器数据。他跑来问我我让他先用逻辑分析仪抓SDA和SCL结果发现从设备根本没回ACK主机却还在自顾自地发数据。这种问题我见太多了——IIC这玩意儿看着比SPI简单只有两根线但真自己用FPGA写状态机的时候起始条件、ACK窗口、数据稳定这几个坑一个都躲不掉。这篇把我在FPGA上写IIC主机代码的完整思路放出来从协议底层规则到状态机设计、可综合代码、Testbench模拟从设备、上板实测避坑一次性讲透。适合正在学FPGA通信、准备自己写IIC控制器而不是直接调IP核的开发者参考。1. IIC的底层协议写Verilog前先把这三条规则刻在脑子里1.1 数据变化和采样的窗口IIC最容易被新手忽略的一点是数据线SDA的变化只允许发生在SCL低电平期间SCL高电平期间SDA必须保持稳定。这句话不是协议文档里的废话它直接决定了你的状态机该怎么设计。网上有些教程写IIC的时候SDA和SCL是“想怎么拉就怎么拉”的仿真波形看着也对因为仿真里从设备不会抗议。但一上板从设备采样到的数据就是错的尤其当你接的是EEPROM或者陀螺仪这类对时序敏感的设备时基本必挂。正确理解是这样的在一个SCL时钟周期内前一半SCL为低这时候主机可以改变SDA后一半SCL为高这时候SDA必须稳定从设备就在SCL高电平的中间时刻去采样SDA。所以你的Verilog状态机里天然需要把每个SCL周期拆成“低半周期”和“高半周期”来分别处理这也是后面四时隙设计法的基础。1.2 起始条件、停止条件和ACK/NACKIIC的起始条件START是SCL为高电平期间SDA由高变低。停止条件STOP是SCL为高电平期间SDA由低变高。注意是“SCL为高期间”发生跳变这和普通数据传输正好相反——普通数据传输要求SCL高时SDA不能动所以起始和停止条件本身就是一种“打破规则”的信号用来告诉总线上所有设备主机要开始通信或结束通信了。这个区别你写代码时必须想清楚。我见过不少人把起始条件写在SCL低电平期间结果从设备根本没识别到START后续所有数据全部作废。另一个容易搞错的是ACK/NACK——从设备在第9个时钟周期拉低SDA表示ACK收到没问题不拉低就是NACK没收到或无法处理。主机在读数据时第9个时钟要主动回NACK告诉从设备“别再发了”然后发STOP。这个“主机回NACK”很多人会漏漏了之后从设备会继续输出数据总线时序就全乱了。1.3 从主时钟到SCL的分频计算IIC标准速率常见的有100kbps标准模式和400kbps快速模式。在FPGA里SCL不是独立时钟而是由你的系统时钟分频产生。假设系统时钟是50MHz想要100kHz的SCL那么一个SCL周期就是50MHz / 100kHz 500个系统时钟周期。由于一个SCL周期包含高半周期和低半周期所以半周期对应的分频数是500 / 2 - 1 249。这里减1是因为计数器从0开始计数数到249正好250个时钟形成一个半周期。代码里一般写成参数化localparam integer HALF CLK_FREQ / (2 * IIC_FREQ) - 1;这样以后改IIC_FREQ就能直接切换速率不用手算分频系数。我做这块的习惯是默认100kHz调试时如果发现波形上升沿太缓再降到50kHz试先把功能跑通再提速度。2. 状态机规划四时隙法为什么最适合IIC主机2.1 用户侧接口设计写IIC主机之前先把对外接口想清楚。你不可能让用户直接操作SCL/SDA——那相当于让别人自己写时序。我一般会暴露这样一组接口启动信号start、读写控制rw、设备地址dev_addr、写数据w_data、读数据r_data、完成信号done、ACK错误标志ack_error。用户只需要拉高start一个周期模块自己完成整个IIC事务结束后done拉高一个周期同时ack_error告诉用户这次通信有没有被从设备拒绝。这个接口设计的思路是把IIC的时序细节全部封装在模块内部外部使用者只关心“发起一次传输”和“传输结果”。实际项目里上层逻辑大概率是一个状态机在轮询多个传感器如果每次通信都要去手动拉SCL、摆SDA代码会膨胀到没法维护。封装成事务级接口后上层状态机只需要等done再发起下一次start就行。2.2 四时隙的拆分逻辑说到状态机核心技巧是把每一个SCL周期拆成4个时隙phase 0到3每个时隙持续HALF个系统时钟周期phase 0SCL为低前半段允许改变SDAphase 1SCL为低后半段保持SDA稳定phase 2SCL为高前半段从设备开始采样phase 3SCL为高后半段继续保持同时准备锁存数据为什么用四时隙而不是直接用SCL的上升沿和下降沿做事件驱动因为在FPGA里SCL是普通IO信号不是时钟资源用它做always块的触发条件既容易产生毛刺又跨时钟域不安全。四时隙法用一个统一的计数器产生相位信号所有逻辑都在系统时钟上升沿下工作时序干净、可综合、可复现这才是FPGA的正规做法。这个法的另一个好处是采样SDA的动作可以放在phase 3的中后段给信号留出了建立时间而不是在SCL刚拉高那一刻立刻采样。实际总线上的波形是有上升沿坡度的如果上拉电阻偏大、总线电容偏大SCL和SDA边沿会比较缓刚拉高就采样很容易采到上一拍的残值。2.3 inout三态端口处理新手最容易翻车的地方IIC是开漏加外部上拉结构FPGA侧的inout端口必须做成三态输出。这里的核心规则是要输出低电平时拉低要输出高电平时必须释放高阻让外部上拉电阻把电平拉高绝不能推挽输出高电平。如果改成推挽输出1万一从设备同时在拉低SDA就是电源对地短路轻则通信失败重则烧IO。代码上我一般用两个使能信号assign scl scl_en ? 1b0 : 1bz; assign sda sda_en ? 1b0 : 1bz;scl_en、sda_en为1时拉低总线为0时释放总线。这样主机的输出天然就是开漏行为。读取时直接使用sda这个wire作为输入三态释放状态下读到的就是外部上拉或从设备驱动的电平符合IIC的多设备线或逻辑。3. 可综合的IIC主机Verilog代码逐段拆解3.1 参数、端口和分频器下面给出一份可以直接综合的IIC主机代码我按自己的工程习惯注释好了。module iic_master #( parameter CLK_FREQ 50_000_000, // 系统时钟频率单位Hz parameter IIC_FREQ 100_000 // 目标SCL频率单位Hz )( input wire clk, input wire rst_n, // 用户侧接口 input wire start, // 启动一次传输单周期脉冲 input wire rw, // 0写 1读 input wire [6:0] dev_addr, // 7位从设备地址 input wire [7:0] w_data, // 要写入的数据 output wire [7:0] r_data, // 读回的数据 output reg done, // 传输完成单周期脉冲 output reg ack_error, // ACK错误标志1从设备无响应 // IIC物理接口 inout wire scl, inout wire sda ); // 半周期分频计数HALF1个系统时钟 半个SCL周期 localparam integer HALF CLK_FREQ / (2 * IIC_FREQ) - 1; reg [15:0] half_cnt; reg [1:0] phase; always (posedge clk or negedge rst_n) begin if (!rst_n) begin half_cnt 0; phase 0; end else if (half_cnt HALF) begin half_cnt 0; phase phase 1b1; // phase自动在0~3之间循环 end else begin half_cnt half_cnt 1b1; end end // 状态定义 localparam IDLE 4d0; localparam START_HOLD 4d1; // 起始条件后的保持 localparam ADDR_SEND 4d2; // 发送7位地址1位读写位 localparam ADDR_ACK 4d3; // 等待从设备地址ACK localparam DATA_WRITE 4d4; // 写模式下发送数据字节 localparam WRITE_ACK 4d5; // 等待从设备数据ACK localparam DATA_READ 4d6; // 读模式下接收数据字节 localparam READ_NACK 4d7; // 主机回NACK结束读取 localparam STOP_SETUP 4d8; // 准备停止条件 localparam STOP_RELEASE 4d9; // 释放总线完成停止条件 reg [3:0] state, next_state; reg [7:0] sh_out; // 发送移位寄存器 reg [7:0] w_sh; // 写数据移位寄存器 reg [7:0] r_data_r; // 接收移位寄存器 reg [3:0] bit_cnt; // 位计数器从8递减到1 reg scl_en; // 1拉低SCL0释放 reg sda_en; // 1拉低SDA0释放 assign scl scl_en ? 1b0 : 1bz; assign sda sda_en ? 1b0 : 1bz; assign r_data r_data_r; always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end3.2 状态机核心代码与注释always (posedge clk or negedge rst_n) begin if (!rst_n) begin scl_en 0; sda_en 0; sh_out 0; w_sh 0; r_data_r 0; bit_cnt 0; done 0; ack_error 0; next_state IDLE; end else begin done 0; // 默认不拉高done需要时单独置1 case (state) // ---------- 空闲与起始条件 ---------- IDLE: begin scl_en 0; sda_en 0; if (start) begin sda_en 1; // SCL为高时拉低SDA产生START next_state START_HOLD; end end START_HOLD: begin case (phase) 2d0, 2d1: begin // 保持SDA为低SCL为高确保从设备识别到START end 2d2: begin scl_en 1; // 拉低SCL准备进入字节传输 end 2d3: begin sh_out {dev_addr, rw}; bit_cnt 8; next_state ADDR_SEND; end endcase end // ---------- 发送地址 读写位 ---------- ADDR_SEND: begin case (phase) 2d0: begin if (bit_cnt ! 0) begin sda_en ~sh_out[7]; // 发0拉低发1释放 sh_out {sh_out[6:0], 1b0}; end end 2d2: begin scl_en 0; // SCL拉高SDA已稳定从设备采样 end 2d3: begin scl_en 1; // SCL拉低为下一位做准备 if (bit_cnt ! 0) begin bit_cnt bit_cnt - 1b1; if (bit_cnt 1) next_state ADDR_ACK; end end endcase end // ---------- 地址ACK检测 ---------- ADDR_ACK: begin case (phase) 2d0: begin scl_en 1; sda_en 0; // 释放SDA让从设备可以拉低回ACK end 2d2: begin scl_en 0; // SCL拉高准备采样ACK end 2d3: begin scl_en 1; if (!sda) begin // 有ACK继续 if (!rw) begin w_sh w_data; bit_cnt 8; next_state DATA_WRITE; end else begin bit_cnt 8; next_state DATA_READ; end end else begin // 无ACK说明地址错误或从设备不在总线上 ack_error 1; next_state STOP_SETUP; end end endcase end // ---------- 写数据 ---------- DATA_WRITE: begin case (phase) 2d0: begin if (bit_cnt ! 0) begin sda_en ~w_sh[7]; w_sh {w_sh[6:0], 1b0}; end end 2d2: begin scl_en 0; end 2d3: begin scl_en 1; if (bit_cnt ! 0) begin bit_cnt bit_cnt - 1b1; if (bit_cnt 1) next_state WRITE_ACK; end end endcase end // ---------- 写数据ACK检测 ---------- WRITE_ACK: begin case (phase) 2d0: begin scl_en 1; sda_en 0; end 2d2: begin scl_en 0; end 2d3: begin scl_en 1; if (!sda) begin // 从设备正确收到数据 next_state STOP_SETUP; end else begin // 从设备拒绝数据常见于EEPROM正在内部擦写 ack_error 1; next_state STOP_SETUP; end end endcase end // ---------- 读数据 ---------- DATA_READ: begin case (phase) 2d0: begin sda_en 0; // 释放SDA从设备驱动数据 end 2d2: begin scl_en 0; end 2d3: begin scl_en 1; r_data_r {r_data_r[6:0], sda}; // 采样 if (bit_cnt ! 0) begin bit_cnt bit_cnt - 1b1; if (bit_cnt 1) next_state READ_NACK; end end endcase end // ---------- 主机回NACK结束读操作 ---------- READ_NACK: begin case (phase) 2d0: begin scl_en 1; // sda_en保持为0也就是SDA保持释放高电平 // 从设备看到主机回NACK后停止驱动数据线 end 2d2: begin scl_en 0; end 2d3: begin scl_en 1; next_state STOP_SETUP; end endcase end // ---------- 停止条件 ---------- STOP_SETUP: begin case (phase) 2d0: begin scl_en 1; // 先确保SCL为低 end 2d1: begin sda_en 1; // SCL低时拉低SDA end 2d2: begin scl_en 0; // 释放SCLSDA仍为低 end 2d3: begin sda_en 0; // 释放SDA产生上升沿即STOP next_state STOP_RELEASE; end endcase end STOP_RELEASE: begin done 1; // 整个IIC事务完成 next_state IDLE; end endcase end end endmodule这份代码里最关键也最容易写错的地方在于改变SDA必须发生在phase 0也就是SCL为低电平的时段释放SDA则用于表示输出1。两个移位寄存器各司其职——sh_out发地址w_sh发写数据这样代码可读性更好不会混在一起。实际工程里我已经用这套结构跑过EEPROM读写和加速度传感器的寄存器配置稳定性没有问题。3.3 直接套用时的修改点如果用的系统时钟不是50MHz只需要改CLK_FREQ参数代码里所有分频都是自动计算的。如果SCL需要400kHz把IIC_FREQ改成400_000即可但要注意系统时钟最好不低于20MHz否则HALF分频数太小相位切换和信号建立时间都会吃紧。读操作这里目前是“先发设备地址读方向然后直接读一个字节”如果你接的是EEPROM需要额外先发送“要读取的内部地址”然后再发一个STARTrepeated start或直接切到读方向。这是EEPROM的器件级协议不是IIC总线本身的职责用的时候要把这个“器件命令序列”放到外部状态机里或者在本模块外面再包一层。我的代码里的读操作更适用于“直接读出型”的传感器设备这类设备读寄存器前不需要发寄存器地址。4. Testbench和从设备模拟没有硬件也能把时序跑起来4.1 给IIC总线挂一台虚拟从设备仿真IIC主机最尴尬的是测试平台上只有一个主机SDA没人驱动你又怎么验证ACK和读数据所以必须在Testbench里写一个最小从设备模型能识别START、能回ACK、能在读模式下输出数据。硬件上IIC总线靠上拉电阻保证空闲高电平仿真里对应的是pullup语句这个不能省——很多新手仿真IIC时波形出现一大堆z或x多半就是忘了给scl和sda挂上拉pullup(scl); pullup(sda);从设备模型的核心思路是用寄存器给SDA做一个独立的三态驱动器和主机的SDA驱动器做线或。这样主机释放总线时从设备能拉低从设备释放时主机也能拉低。核心骨架如下reg sda_drive 0; reg sda_oe_tb 0; assign sda sda_oe_tb ? sda_drive : 1bz;然后检测SCL的上升沿在上升沿采样SDA把采样到的bit拼成字节。第9个时钟周期如果收到的是设备地址且读写位为写就把sda_oe_tb置1拉低模拟ACK如果读写位为读则从第9个周期开始让从设备接管SDA按位输出数据。主机最后回NACK时从设备释放SDA模型逻辑结束。这个从设备模型不需要写得多完整只要能正确回ACK、能在读模式下回一个固定字节比如8hA5就足够验证主机的时序逻辑了。真正的完整从设备行为IIC设备数据手册里有详细时序图遇到特殊器件再对着时序图扩展。4.2 仿真波形中要重点看的三个位置仿真跑完不要只看结果对不对要学会看波形找问题。我一般固定查看三个位置第一起始条件。放大波形到START附近确认SDA是在SCL为高时产生的下降沿。如果发现SDA先于SCL拉低或者跳变时SCL还没稳定为高从设备大概率不认。第二ACK窗口。看第9个SCL高电平期间SDA有没有被从设备拉低。如果SDA一直是高说明从设备模型没工作或者地址对不上。第三数据采样点。在SCL高电平中间SDA必须稳定——不要在SCL刚变高或者快变低的时候出现跳变那就是违反了“SCL高电平期间SDA必须稳定”的规则。ModelSim或Vivado Simulator里建议把scl、sda、state、phase、bit_cnt这几个信号一起加进波形窗口。state和phase能看到状态机的跳转节奏bit_cnt能看到字节传输进度排查问题会比只盯sda/scl快得多。5. 上板实测上拉电阻、波形定位和常见故障速查5.1 上拉电阻取多大数据手册不会告诉你的细节IIC协议标准里没有规定死上拉电阻的阻值因为它取决于总线电容和通信速率。我实测下来3.3V供电、100kHz、走线长度在10厘米以内时4.7kΩ到10kΩ都能正常工作。但如果是400kHz快速模式或者总线上挂了多个设备导致总线电容变大每根线对地电容可能到100pF以上建议用2.2kΩ到4.7kΩ否则SCL和SDA的上升沿会明显变缓导致从设备在高电平窗口采不到正确的电平。判断上拉电阻是否合适有一个土办法用示波器看SDA或SCL的上升沿。如果上升沿出现了明显的圆弧状缓坡甚至像一条斜线慢慢爬上去那就是上拉电阻太大了换小一档再测。如果波形上有过冲振铃说明电阻太小或走线太长适当加大电阻或者降低速率。5.2 用逻辑分析仪/示波器按顺序查波形上板调试时先用逻辑分析仪抓SDA和SCL按下面的顺序过一遍总线空闲时SDA和SCL是不是都保持高电平如果有一根线被拉低最可能的原因是从设备处于异常状态给它彻底断电重新初始化。START位置对不对SDA下降沿发生时SCL必须为高。地址字节的8个bit是不是按高位先发并且最后一位是读写位这里最容易出问题的是设备地址搞错比如EEPROM地址是0x50你要发的是7位地址拼接上读写位后才是完整字节很多人会直接把0x50当设备地址发出去结果第8位对不上。第9个时钟有没有ACK没有ACK就检查硬件连接、设备地址、供电还有代码里sda_en释放的时间点是不是太晚——如果释放太晚从设备想拉低ACK都拉不动。数据字节后有没有再回一次ACKEEPROM这类设备在写数据时如果正在内部擦写会回NACK这时需要等待或重试。这个顺序基本能覆盖90%的IIC上板问题。我自己的经验是大部分人栽在第3步和第4步一个是地址拼位错了一个是sda释放时机慢了。5.3 常见故障速查表把我在多个项目里踩过的坑整理成表格遇到问题直接对照故障现象可能原因排查/解决方向波形总线上一直有设备拉低SDA从设备上电时序异常或总线死锁断电复位从设备检查供电是否正常主机发了START但从设备不响应起始条件产生在SCL低电平期间确认SDA下降沿发生在SCL为高后地址对但第9位没有ACKsda_en释放过晚从设备拉不动总线检查ADDR_ACK的phase 0是否及时释放SDA能写能读但数据错位采样点太早采到SCL刚升高的毛刺把SDA采样放到phase 3中段留出建立时间400kHz下波形严重畸形上拉电阻太大或总线电容过高换小阻值上拉或降速到100kHz写EEPROM数据后马上读读到旧值EEPROM内部擦写周期未完成在写命令后延时5~10ms再读仿真正常但上板不通忘记给inout端口做三态确认sda和scl都用了高阻输出不是推挽最后再说一个我后来越用越顺的经验上板调试时把ack_error这个信号引到一个GPIO上接LED。一旦从设备没回ACKLED直接亮起来不用每次出问题都去抓波形。IIC调试的难点从来不是代码量而是“你以为它在工作其实它从第一个起始条件开始就没被识别过”。把协议规则一条条对应到状态机里再用逻辑分析仪验证每个关键点这套代码才能真正用稳。本文还有配套的精品资源点击获取
返回列表