ARTICLE DETAIL

资讯详情

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

I2C从机设计要点解析:状态机、时序与实战调试技巧

I2C从机设计要点解析:状态机、时序与实战调试技巧 简介这是一套面向数字 IC 与 FPGA 学习者的 I2C 从机 Verilog 设计实现资料包由作者 oxiaofengzi12 整理分享共 161 个文件压缩包约 1.39MB已有 577 人学习。I2C 总线仅用 SCL 与 SDA 两根信号线即可完成多主多从通信支持 100kHz、400kHz、3.4MHz 等速率资源聚焦从机侧实现包含时钟与数据边沿检测、通信状态机、数据缓冲、ACK 应答、地址解析及错误处理等核心模块并可从文件命名判断设计进一步融合了 AES 加密模块适合希望理解底层时序并探索安全通信扩展的读者。包内以 .v 源码为主辅以 .vhd、.c、.dat、.bin 等代码与数据文件同时附有 .xise/.prj 工程、.sdb/.dbs/.log/.xreport 综合仿真报告、.vcd/.wlf 波形记录方便对照源码、工程配置与仿真结果完整学习较多 .bak 备份文件保留了设计迭代过程便于比较修改思路。目录按源码、备份、工程与报告分类查找方便适合本科/研究生课程、毕业设计或 FPGA 入门进阶时作为 I2C 从机设计与验证的参考资料。1. 先搞明白从机为什么难没有主动权的设计最考验时序我当年第一次写I2C主机驱动时觉得这东西也就那样SCL拉低拉高、按字节送数据、等ACK全程节奏自己控制调试起来非常舒服。后来项目需要把一块传感器做成i2cslave端再接在总线上我才发现从机端完全不是一个难度级别。主机端出错了可以重发报文节奏飘了可以重新发起START但从机端一旦某个位没跟上整帧就废了而且主机还经常把责任算到你头上。所以动手写slave之前先得把角色差异想透。I2C总线是一个半双工、双线的同步串行总线SCL是时钟线SDA是数据线。主机负责产生SCL时钟并发起通信从机只能被动响应。正如你想象的那样主机端代码可以写成我发一个字节读完再发下一个但slave端不行——你不能说我现在忙你先停一下因为时钟线在主机手里。唯一能做的反制手段是时钟拉伸也就是把SCL拉低让主机等一等但前提是你的硬件引脚能直接驱动SCL。从机端真正考验人的地方在于所有动作都要卡在主机给出的时钟边沿上完成。SDA在SCL高电平期间必须保持稳定数据采样就在高电平中间SDA只能在SCL低电平期间翻转要是在高电平期间变了主机就会把它当作START或STOP条件整帧立刻结束。所以从机在哪个时刻采样、哪个时刻释放SDA、哪个时刻把数据送到引脚上每个节拍都环环相扣一步错步步错。这种情况下设计者的脑回路也得改一改。写主机驱动时你思考的是下一步我要发什么而写从机时你要思考的是总线现在处于什么状态、我下个边沿该做什么。很多初学I2C从机的朋友最大的问题不是不了解协议而是他们还在用主机思维写从机代码结果数据对不上、时序乱掉、甚至连地址匹配都做不好。后面我会逐个拆解。2. 位级拆解SCL/SDA的翻转与采样窗口决定了从机的每个动作2.1 起始、停止条件说到底是由SDA的非法跳变规定的先说一个反直觉的事实I2C总线上数据有效时SDA只能在SCL低电平期间变化SDA在SCL高电平期间的变化属于非法操作而协议正是用这种非法操作来表达START和STOP。START是SCL高电平期间SDA从高跳低STOP是SCL高电平期间SDA从低跳高。从机端想要识别一个字节首先得识别出START否则后续的地址接收无从谈起。所以slave设计的第一步并不是接收数据而是构建一个边沿检测逻辑持续监测SDA在SCL高电平期间的跳变。识别到START之后从机就进入等待地址状态识别到STOP则回到IDLE等待下一次START。这里有个容易忽视的细节总线上可能不止一个主机也可能出现START之后又被中断的异常情况。比如主机在发送过程中突然放弃直接发了STOP。从机如果没处理好STOP后的复位逻辑寄存器指针还停在半路下一帧开始后数据就会错位。我在设计状态机时特别加了一条任意状态下识别到STOP就回到IDLE并复位指针的规则这条规则后来在多次异常总线测试里帮我节约了大量排查时间。2.2 从机的采样点SCL高电平中间才是最佳窗口既然SDA在SCL高电平期间必须保持稳定那从机就应该在高电平期间把SDA采进来。具体来说推荐在SCL上升沿之后、等待一段时间再采样而不是在上升沿立刻采样。原因很简单真实总线上SDA的翻转不可能瞬间完成有线缆电容、上拉电阻、IO驱动能力这些因素上升沿之后SDA可能有几十纳秒到几百纳秒的建立时间。用小项目里的概念类比这就像你听到发令枪响之后不能马上冲得等旁边的人先迈步不然容易踩脚。采样点往后挪一点避开过渡区就能躲开大多数毛刺。在FPGA里实现时可以通过移位寄存器把SCL延时几个时钟周期再用延时后的SCL作为采样使能在MCU的GPIO模拟方案里则可以直接在循环里加入小延时或者用外部中断配合定时器来定位采样时刻。移位寄存器的做法顺手给个示意reg [3:0] scl_dly; always (posedge clk) begin scl_dly {scl_dly[2:0], scl}; end assign scl_posedge scl_dly[2] ~scl_dly[3]; // 原始scl上升沿 assign scl_negedge ~scl_dly[2] scl_dly[3]; // 原始scl下降沿 assign sample_point scl_dly[1]; // 高电平中段附近我实际工程中会用更高阶的延时链并根据总线速率调整抽头位置。100kHz和400kHz模式下的采样点位置要分开标定不能一套延时走天下。2.3 ACK位生成不是你随便拉低就算数从机接收完一个字节之后第9个时钟周期属于ACK位。这个位置上主机释放SDA从机如果接收正常就把SDA拉低表示ACK如果希望主机停止发送可以在第9个时钟释放SDA表示NACK。但这里有一个时钟细节第9个周期的低电平期间从机就要把SDA驱动为低并保持到高电平期间让主机在高电平中间采样到稳定的低电平。如果把拉低动作放到高电平期间才做主机采样时可能看到的是SDA还没稳定下拉的过渡态ACRK直接判定失败。之前调一块板子时波形上ACK位有明显台阶主机判定NACK数据一直重发。后来我用逻辑分析仪放大那一段发现问题出在slave释放SDA之后重新拉低太慢。解决办法是在第9个SCL低电平一开始就把SDA拉低不要等SCL变高再操作。这里的关键理念是从机的所有输出动作都应该在SCL低电平期间完成高电平期间只能用来维持当前电平。3. 可综合的Verilog从机状态机从IDLE到STOP的完整设计3.1 状态空间的划分不只是空闲、地址、数据三段很多教程把I2C从机状态机画成简单的三段式IDLE、ADDR、DATA。真正写出来之后会发现完全不够用。原因在于I2C传输中存在多个细分的节拍起始位后的地址字节、ACK位、数据字节、再ACK/Restart、STOP每两个“动作”中间都夹着一个“确认”节拍。如果状态不分细代码里就会到处是if/else判断当前是第几个时钟周期既不直观又容易漏状态。我习惯把从机状态机拆成这些状态IDLE等待STARTSTART刚检测到START准备接收地址ADDR接收8位地址读写方向位ADDR_ACK地址匹配后发送ACKDATA_IN接收数据字节DATA_IN_ACK数据接收后发送ACKDATA_OUT发送数据字节DATA_OUT_ACK等待主机ACK或NACKWAIT_STOP处理Restart或STOP前的悬挂状态这样做的好处是状态与总线节拍一一对应状态转移直接依赖SCL边沿事件逻辑上几乎不需要额外的计数器来算现在是第几个脉冲。计数器只需要在数据字节内部记录位号bit_cnt其他逻辑全部由状态本身承载。3.2 地址匹配、读写方向与寄存器指针自增地址匹配是slave端的第一个关键分流。标准7位地址模式下地址字节的高7位是设备地址最低位是R/W方向位。从机在ADDR状态里把收到的8位存下来比较前7位是否等于自己的地址再根据LSB决定进入发送模式还是接收模式。不匹配时当前状态机要回到IDLE吗答案是不能立刻回IDLE得老老实实等待STOP或下一次START。因为总线是共享的其他设备还在通信你不能在中间把状态机释放掉。通常的做法是进入一个IGNORE状态继续按SCL节奏接收后面的数据但不对任何字节产生ACK直到检测到STOP才回到IDLE。寄存器指针自增也很容易出错。比如从机有多个寄存器主机先写寄存器地址再连续读写数据每处理完一个数据字节指针就要加一。我见过不少人在状态机上把指针自增放在ACK发送的同时进行——这是错的因为主机发出下一字节时才会用到新指针而ACK期间拉低SDA和修改指针属于不同操作域逻辑上容易产生冲突。正确做法是在检测到数据字节完整接收的上升沿更新指针然后才进入ACK状态。// 寄存器地址/数据接收核心逻辑节选 always (posedge clk) begin if (reset) begin bit_cnt 0; shift_reg 0; reg_addr 0; state IDLE; end else begin case (state) ADDR: begin if (scl_posedge sda) shift_reg {shift_reg[6:0], sda}; if (bit_cnt 8) begin if (shift_reg[7:1] DEV_ADDR) state ADDR_ACK; else state IGNORE; bit_cnt 0; end end ADDR_ACK: begin if (scl_negedge) begin sda_out 1b0; // 拉低SDA发送ACK state (shift_reg[0] ? DATA_OUT : DATA_IN); end end DATA_IN: begin if (scl_posedge sda) shift_reg {shift_reg[6:0], sda}; if (bit_cnt 8) begin // 把shift_reg写入reg_addr指向的寄存器 regfile[reg_addr] shift_reg; reg_addr reg_addr 1; state DATA_IN_ACK; end end endcase end end注意这里为了说明省去了一些同步和边沿判断的细节实际设计时每个sda输入要先做两级寄存器同步防止亚稳态。3.3 节拍生成与超时保护FPGA里的I2C从机说到底是跟着外部SCL跑的。如果你的系统时钟远高于SCL那么一切边沿检测都比较从容但如果两者接近就会出现采样点不足、判决困难的问题。我的建议是系统时钟至少是SCL频率的20倍以上这样不但能做稳定的上升沿/下降沿检测还能在SDA建立时间内做多次采样过滤毛刺。对于400kHz的高速模式20倍就是8MHz如果跑1MHz的快速模式那就起码要20MHz。系统中没有这么高时钟时建议直接在硬件上改用带数字滤波的I2C控制器而不是自己搭逻辑。超时保护是个容易被忽略的点。总线上如果主机异常拉死SCL低电平从机状态机可能永远停在某个中间状态后续通信全部瘫痪。常见的兜底做法是利用一个定时器检测SCL低电平持续超过一定阈值比如10ms后强制复位状态机。虽然I2C规范里并没有这个机制但在量产设备上它是救命的。4. 纯软件从机的取舍中断、轮询和时钟拉伸的优先级4.1 用GPIO模拟从机到底行不行FPGA或者带硬件I2C外设的MCU做从机都没什么争议直接上外设就行。但也有人想在资源紧张的MCU上用GPIO模拟一个从机或者在没有硬件I2C外设的芯片上实现低速从机。这个方案不是不行但必须接受两个现实一是软件延迟不可控二是时钟拉伸会成为必需品。GPIO模拟从机时从机的响应速度和中断延迟密切相关。地址字节的8个时钟可能几十微秒就过去了如果中断响应不及时采样点早就错过了。我在实践中通常采用外部中断定时器捕获的组合把SCL上升沿和下降沿都配置为外部中断SDA电平在进入中断后立刻读取如果系统时钟够快这种方法能赶上100kHz标准模式但400kHz模式就非常勉强。另一种思路是让GPIO模拟从机主动启用时钟拉伸在每次字节边界把SCL拉低一段时间让主机等自己处理完数据处理完毕再释放SCL。这样从机的实时压力小很多但要求从机引脚必须支持开漏输出同时主机侧还得允许时钟拉伸——绝大多数主机都允许少部分老掉牙的硬件不行需要提前确认。4.2 寄存器读写的顺序和时间敏感操作纯软件从机在处理寄存器写操作时写寄存器本身可能涉及一些耗时操作比如触发ADC转换、更新PWM输出。如果这些操作放在I2C中断里同步执行中断占用时间会拉长直接影响下一个字节的接收。我的做法是ACK完成后立刻把数据存到缓冲并置一个待处理标志主循环或者更高级的中断里再执行实际写入。这样I2C中断只负责收数据把耗时操作移到外面始终让从机保持能及时响应总线。读取侧也类似在主机发起读之前预测主机要读取的地址并预先把数据准备好而不是等主机读取请求到达后才现场去翻寄存器。4.3 低功耗场景下的地址唤醒低功耗设备做从机时有个棘手的需求整个MCU都睡过去了但总线上主机一来从机得能醒来并完成通信。很多MCU的硬件I2C外设在低功耗模式下依然能检测到自己地址并触发唤醒中断但GPIO模拟方案不行——因为模拟从机依赖CPU持续执行代码而CPU睡了。这时硬件外设几乎是必须的或者采取折中方案让MCU保留一个外部中断监测SDA的起始条件检测到START后快速唤醒再补上后面的SCL中断处理。因为唤醒过程有毫秒级延迟主机那边要配合时钟拉伸才能不丢数据。有些从机设备则干脆在低功耗时不响应等主机报错之后重试设备中途醒来再接入总线这种方案虽然“粗暴”但在很多消费电子里是真实可用的。5. 屡试不爽的调试路径从波形定位到总线恢复5.1 第一件事永远是抓逻辑分析仪波形不是改代码从机调不通时我见过太多人盯着代码一行行改结果浪费一个下午。正确的调试顺序是先挂逻辑分析仪把SCL、SDA两路信号完整抓下来。看什么先看整帧的结构START是否被识别、地址字节是否完整、ACK位是高是低、数据字节错在哪一位。最近一次调试传感器从机时从机读出来的温度值偶尔跳变。我抓了半小时波形才发现问题不在I2C逻辑而是SDA线上拉电阻阻值偏大导致高电平上升过慢在高速传输时高电平还没有建立完全就被采成了低电平。这块板子的下拉电容偏大把上拉电阻从10k换到4.7k之后现象消失。如果你没有逻辑分析仪示波器也能看但写一段自动解码I2C的脚本会更省事。5.2 当主机一直收不到正确ACK时问题往往在SDA释放速度SDA是开漏结构主机和从机都会释放SDA让上拉电阻把它拉高。如果从机释放SDA太晚或者释放时引脚还处于输出低电平模式那总线上就会出现“应高而低”的电平主机读到的自然是ACK/NACK错误。这背后往往是两方面的原因。一是代码逻辑错误从机在第9个时钟之后还没把SDA方向切到输入导致SDA一直被拉低。二是芯片本身的IO端口配置问题有些GPIO切换输入输出需要重新配置方向寄存器这个操作耗时不确定可能导致释放晚了。排查技巧让主机发一个字节后故意等待较长的时间比如100us再继续如果等待拉长后ACK稳定了那基本就是释放速度问题。解决方式通常是用硬件I2C外设替代GPIO模拟或者在模拟实现里提前一个时序周期配置好SDA方向。5.3 总线死锁与恢复别一上来就断电重启总线死锁最常见的样子是SCL还正常跳动但SDA一直被某个设备拉低所有通信全部失败。原因可能是从机进入了异常状态SDA输出被锁定在低电平也可能是主机在通信过程中被复位没有发起STOP就释放了总线导致从机永远在等后续时钟。物理上最直接的恢复办法是让主机连续发送9个SCL时钟脉冲之后所有从机都会因为数据字节格式错误而释放SDA总线回到空闲。很多I2C控制器本身支持bus clear功能直接调用即可。如果系统不支持可以在初始化SDA为高电平的同时手动翻转SCL九下也能达到类似效果。我个人的习惯是在从机代码里加一个“异常帧计数”连续三次检测到未经验证的复位条件比如地址匹配之后突然出现STOP就强制复位自己的总线逻辑和寄存器文件。这既不干扰主机也不会影响其他设备属于从机自保的一种实用手段。5.4 关于水平触发还是电平触发一个容易忽略的历史包袱在做从机设计时还有一个小决定会影响你后面所有的调试体验外部中断的触发模式选择。I2C的SDA和SCL电平变化非常频繁如果外部中断配置为边缘触发调试过程中很容易因为引脚上的毛刺产生误中断导致状态机跳飞。如果配置为高/低电平触发中断函数里需要手动判断当前是开始通信还是结束通信代码稍复杂但抗干扰能力强得多。这块板子当时我改成了电平触发配合去抖延时整个从机的鲁棒性明显提升。另外提醒一句如果总线上有其他高速设备I2C的SDA/SCL线上强烈建议加RC滤波或使用带施密特触发的引脚我自己最小的RC组合是100欧姆串联加100pF对地电容实测对抑制边沿震荡很有帮助。写从机设计这件事说难也没有那么难说简单也绝不简单。它的难处在于所有逻辑都在等别人的时钟你只能顺势而为。用状态机把总线节拍老老实实拆开用逻辑分析仪去验证每一段的实际波形再配合ACK/NACK的开关和总线恢复手段绝大部分问题都能解决。这也是我在这几块板子上反复验证下来的结论。本文还有配套的精品资源点击获取
返回列表