第一篇文章发出去之后,评论区里最典型的一类回复是:照着把主机状态机撸出来了,仿真跑得漂漂亮亮,start、stop、ACK、数据读写全对,结果一烧到板子上就露馅——要么SDA上根本没有像样的波形,要么从机死活不回ACK,要么跑着跑着总线直接锁死,只能断电重启。我去年调一块带摄像头和多个传感器小板的项目时,也把这些坑挨个踩了一遍。所以这篇第二讲我不打算再复述协议帧格式,而是专门聊仿真之外的部分:时钟分频怎么算才不出时序问题、开漏三态IO在FPGA引脚上怎么做、为什么仿真通过上板必翻车、以及我实际排障时碰到的几类典型故障。这篇内容适合已经把第一讲的Verilog代码跑通、正准备往真实板子上怼的朋友,也适合那种仿真通过但硬件死活不通、急需排查思路的苦主。
1. 这一篇要填的坑:波形在仿真里对,上板全乱的根因
先说一个我观察到的规律:凡是仿真通过、上板出问题的I2C设计,问题通常不在协议逻辑本身,而在"电气行为没被仿真"上。I2C协议看着简单,但它是一个多主、开漏、带上拉电阻、还要允许从机拖住时钟的真实总线协议,你如果只在仿真里用两个reg互相赋值,那仿真波形再漂亮也代表不了现场。
具体来说,仿真通过而上板失败的常见原因就那么几类:
- SCL和SDA的时序关系在硬件上并不是状态机里你"以为"的样子,组合逻辑延迟、IO引脚翻转速度、外部RC充电时间都会让实际波形的建立保持时间缩水。
- SDA的三态控制写错了。很多人在发送完第8位后忘了把SDA释放掉,导致在ACK窗口里主机自己把SDA拉低,从机再怎么回ACK都被你短路,最后读回来的永远是0。
- 没处理时钟延展。你在仿真里把从机当"哑巴",可真实从机(尤其是一些老式传感器)会在没准备好时把SCL拉低,你的主机还在傻乎乎地等着自己的SCL上升沿,结果时序直接乱套。
- 上拉电阻和总线电容的搭配不对,导致SCL上升沿过慢,从机采样误判。
- 多主机场景下仲裁根本没实现,两路主机同时拉总线,数据撞成一锅粥。
如果仿真里能把开漏结构、上拉RC、从机时钟延展都建进去,很多坑在上板前就能暴露。但现实是,大部分人在第一讲阶段写的仿真模型压根没有这些。所以这第二部分里我会从时序计算开始,先解决"设计是不是真的符合协议规范"的问题,再去讲验证和排障。
2. 从协议时序参数落实到代码时钟:SCL/SDA生成的数学账
2.1 先翻一遍I2C协议里的硬杠杠参数
写代码之前先把I2C规范里最关键的几个时序参数理清楚。I2C的速度档位中,最常用的是标准模式100kHz和快速模式400kHz,1MHz高速模式在FPGA和普通传感器之间用得少,而且对电气要求更苛刻,这里不展开。
| 参数 | 符号 | 标准模式(100kHz) | 快速模式(400kHz) |
|---|---|---|---|
| SCL最低低电平时间 | tLOW | 4.7us | 1.3us |
| SCL最低高电平时间 | tHIGH | 4.0us | 0.6us |
| 起始条件建立时间 | tSU;STA | 4.7us | 0.6us |
| 起始条件保持时间 | tHD;STA | 4.0us | 0.6us |
| 数据建立时间 | tSU;DAT | 250ns | 100ns |
| 数据保持时间 | tHD;DAT | 0 | 0 |
| 停止条件建立时间 | tSU;STO | 4.0us | 0.6us |
| 总线上升时间 | tR | 1000ns | 300ns |
这张表看着枯燥,但它是所有后续计算的源头。比如说SCL低电平时间,如果小于1.3us(快速模式),从机可能来不及处理数据;如果高电平时间小于0.6us,从机采样数据可能采到不确定电平。所以分频设计不是随手写个偶数分频就完事,得对着表里最严苛的参数逐项核对。
2.2 主模式SCL分频怎么算才不超标
假设系统里给FPGA的时钟是24MHz(周期约41.67ns),我目标是跑100kHz标准模式,那么SCL周期应该是10us,也就是需要分频系数240。这个240如果简单用120个时钟高电平、120个时钟低电平来实现,那高电平和低电平都是5us,都满足表格里4.7us和4.0us的要求,理论上是合格的。
但如果目标是400kHz快速模式,情况就有意思了。SCL周期2.5us,半周期1.25us,分频系数60,也就是高电平30个时钟、低电平30个时钟。高电平30个时钟约1.25us,大于0.6us,没问题;低电平30个时钟也约1.25us,但快速模式要求tLOW最少1.3us。这差了0.05us,严格来说就不达标。这时候就必须做非对称分频:比如高电平28个时钟约1.17us,低电平32个时钟约1.33us。高电平放宽了算仍然满足0.6us,低电平也终于超过了1.3us。别小看这0.05us的富裕量,实际板上总线电容一大、上拉电阻不匹配,下降沿倒是快,可上升沿会拖慢,内部计数器给的余量如果太小,很容易在临界状态出问题。
如果系统时钟是50MHz,周期20ns,节奏会更充裕。100kHz做250个时钟半周期就是5us,对称分频即可;400kHz做62个时钟半周期约1.24us,还是略低于1.3us,建议做成高60个时钟约1.2us、低65个时钟约1.3us的组合,出去的SCL频率约为400.6kHz,依然在协议允许范围附近。
下面是一个通用分频模块的Verilog写法,高低电平周期独立可配:
module scl_gen #( parameter CLK_FREQ = 24_000_000, parameter SCL_HIGH_CNT = 28, parameter SCL_LOW_CNT = 32 )( input wire clk, input wire rst_n, input wire scl_en, output reg scl_out ); reg [15:0] cnt; reg scl_level; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 16'd0; scl_level <= 1'b0; end else if (!scl_en) begin cnt <= 16'd0; scl_level <= 1'b0; end else begin if (scl_level == 1'b1) begin if (cnt >= SCL_HIGH_CNT - 1) begin scl_level <= 1'b0; cnt <= 16'd0; end else begin cnt <= cnt + 16'd1; end end else begin if (cnt >= SCL_LOW_CNT - 1) begin scl_level <= 1'b1; cnt <= 16'd0; end else begin cnt <= cnt + 16'd1; end end end end assign scl_out = scl_en ? scl_level : 1'b0; endmodule注意上面的模块只负责产生内部逻辑电平,真正驱动到引脚上还得再接三态IO。我这里想强调一个原则:SCL分频的所有计算都必须以当前需求的最严参数为基准,而不是只把平均频率对上就行。很多设计翻车就翻在"频率确实约等于400kHz,但高低电平不对称到离谱"这种情况。
2.3 数据翻转位置的取舍:为什么必须避着上升沿走
I2C协议最大的一个忌讳就是:SDA只能在SCL低电平期间变化,SCL上升沿的时候SDA必须稳定。这个道理第一讲肯定说过,但在实际编码里很多人会掉进一个细节陷阱——状态机的下一个状态跳变和SDA赋值发生在同一个时钟沿,而SCL的上升沿也是同一个时钟沿性产生的,于是SDA的翻转和SCL的上升沿在时间上挨得太近,到了板子上SDA波形还在爬坡,SCL就已经上升了。
正确的做法是在FPGA内部生成一个比SCL上升沿早很多的数据翻转窗口。比较简单可靠的方式是:状态机里单独用一个"data_change"信号,它在SCL为低电平的时候有效,所有的SDA数据翻转都限定在这个窗口内完成。SCL上升沿到来之前,SDA至少已经稳定了好几个时钟周期,用50MHz时钟就是几十到上百纳秒的建立时间余量。
还有一种更稳的做法是延迟一拍SCL:内部生成scl_logic,再把这个scl_logic延时一个系统时钟周期得到scl_out送出去,而SDA的翻转与scl_logic对齐。这样在物理引脚上,SDA的翻转比SCL的上升沿早了一个时钟周期,建立时间天然充裕。这种做法适合那些系统时钟不低、逻辑路径又比较长的设计,本质上就是用相位错开去换时序裕量。代价是SCL频率会略低一点点,不过只要不违反总线参数就完全可接受。
3. 三态IO和开漏:FPGA引脚上最容易埋雷的一环
3.1 真正的开漏和仿真里的三态z
很多第一次在FPGA上写I2C的人会习惯性地这样写:
assign sda_out = sda_reg;然后仿真一看SDA上波形整整齐齐,到了板子上发现多个设备挂在同一条总线上,对地短路、冲突、数据全乱——原因就是普通CMOS输出会同时驱动高和低,两个设备一个想拉高一个想拉低,直接打架。
FPGA没法直接提供"开漏"引脚结构,所以普遍的做法是用三态输出模拟开漏行为:对外永远只驱动低电平,不驱动高电平,需要输出高的时候就把引脚置为高阻态z,让外部上拉电阻把电平拉上去。对应的Verilog核心逻辑只有几行:
// sda_oe = 1 表示主动拉低;sda_oe = 0 表示释放总线 assign sda_pin = sda_oe ? 1'b0 : 1'bz;同理SCL:
assign scl_pin = scl_oe ? 1'b0 : 1'bz;这里最大的坑在于:仿真里三态z配合上拉模型很好用,但很多初学写的testbench根本没有上拉模型,于是看z就当成高电平,一上板子就彻底看不懂了。所以仿真环境里必须在SDA和SCL引脚上加上拉电阻模型:
pullup(sda_pin); pullup(scl_pin);还要意识到,FPGA的三态输出在释放瞬间并不是真正"什么都不做",引脚从驱动低切换到高阻态时,外部上拉开始充电,这个RC上升时间完全取决于上拉电阻和总线电容。这就是下一节要算的账。
3.2 上拉电阻阻值选取的快速计算法
上拉电阻的取值不是随手选个4.7k就完了,它受两个约束:上升时间不能太慢,灌电流不能太大。总线电容主要来自引脚封装、PCB走线、过孔、还有每个从机设备的输入电容,一条不大的PCB总线通常50pF到200pF之间,线长了或者节点多了还会更大。
上升时间的计算可以用一阶RC模型估算。判断电平从低到高达到VIH(一般是0.7×VDD)的时间大概是1.2倍的RC时间常数。400kHz快速模式要求总线上升时间不超过300ns,所以RC乘积不能超过250ns左右。假设总线电容100pF,那么上拉电阻就必须小于2.5kΩ,4.7kΩ这时候就不行了。如果电容到200pF,那就得用1kΩ上下才保险。
100kHz标准模式要求上升时间不超过1us,RC乘积上限大约是830ns,在200pF总线下用4.7kΩ也勉强压线,稍微再加两个设备可能就超了。所以我的习惯是:纯100kHz总线,优先选2.2kΩ;400kHz总线,优先选1kΩ或者1.5kΩ,留出余量。
上拉电阻减小之后还要看灌电流。总线被拉低时,VDD经过上拉电阻和开漏管的灌电流是VDD/Rp。3.3V供电配1kΩ,灌电流约3.3mA。多数FPGA引脚和传感器引脚能承受5mA以上的灌电流,通常没有问题。但如果你的开发板用3.3V供电又选了470Ω这种小阻值,灌电流会到7mA,有些弱驱动引脚就会出问题,个别器件手册还明确限制了IOL最大值,必须去翻数据手册看一眼。
3.3 电平转换和总线电容的几个实际考量
如果你的FPGA是3.3V IO,但某个从机是1.8V供电,直接硬接要么读回错误电平,要么把从机IO打坏。最简单的处理是用电平转换模块,常见的有分立MOSFET方案和集成转换芯片,也有一种是只做开漏总线电平转换:把SDA和SCL都当开漏信号,两侧各接各自电压域的上拉,中间用MOSFET管把低电平传递过去。这样一来总线协议不用变,只是两端的高电平范围不同。
这个方案在FPGA项目里特别常见,因为很多新传感器喜欢用1.8V IO。我实际用下来的建议是:电平转换芯片尽量选带方向自动判别的型号,不要自己用三极管搭,省那点成本不值得;其次转换芯片本身会引入几十纳秒的传播延迟,400kHz下通常还能接受,但1MHz下就要仔细核对时序预算。
总线电容这个事,平时仿真没人管,但板子上几十厘米的排线随便就能加上一两百pF。如果总线太长又没做好阻抗控制,除了上升沿变慢,还容易出现信号反射造成的毛刺。处理思路无非几条:走线尽量短;不要用那种特别细又特别长的杜邦线;必要时在总线上串联一个小电阻(几十欧)来抑制过冲,但这会进一步增加上升时间,必须搭配更小的上拉电阻。反正这几个参数是连在一起的,调一个就得重新核算另外几个。
4. 从机验证:仿真模型一定要按真实行为写
4.1 一个能ACK、能时钟延展的从机仿真模型
我在第一讲给的从机模型如果只是把SDA同步采样到移位寄存器,那它没法暴露主机的真实问题。一个好的仿真从机模型至少要包含三种行为:需要时钟延展时把SCL拉低一段时间;收到正确地址后在第9个时钟回ACK;在数据阶段能按地址写数据或回读数据。
时钟延展是用Verilog最容易写歪的部分。真实的从机时钟延展是:SCL已经被主机释放,外部上拉本来要把它拉高,但从机主动驱动SCL低电平,强行不让它升上去。仿真模型里可以直接用三态+强0来实现:
// 从机时钟延展逻辑:需要延展时把scl_pin拉低 assign stretch_req = (state == READY_WAIT) && !data_ready; assign scl_pin = stretch_req ? 1'b0 : 1'bz;由于Verilog里1'b0的驱动强度高于电阻上拉的pullup,仿真器会正确模拟出"从机拉低SCL"的效果。如果你的testbench里没有pullup,那这个行为根本表现不出来,这也是为什么我反复强调仿真模型必须带有上拉。
做好时钟延展的仿真模型后,你会发现一个很关键的事实:如果主机代码只是在内部计数到某个时刻就认为SCL已经上升,它根本看不出从机把SCL拉低这件事。因为主机看到的SCL是外部pin,而不是内部计数器生成的scl_reg。所以主机侧在等待SCL高的时候,必须去采样真正的引脚电平,或者至少采样经过引脚缓冲回来的信号,而不是内部逻辑信号。
4.2 逻辑分析仪抓波形怎么定位协议错误
等上了板子,调试最顺手的工具是逻辑分析仪。不过逻辑分析仪只能看到数字电平,看不到模拟的爬坡,所以它的定位思路很聚焦:时序上的错误基本逃不开几个固定位置。
- 启动条件对不对:正常是SCL高时SDA产生下降沿。如果SDA先降、SCL还没高起来,从机不会识别这是START。
- ACK位对不对:第9个SCL高电平期间,SDA应该被从机拉低。如果抓到SDA一直是高,说明从机压根没应答,可能是地址不对,也可能是SDA被主机自己没有释放。 -数据位顺序对不对:I2C是MSB在前,8个数据位加1个ACK位,共9个SCL脉冲。如果抓到的数据字节是反序的,那多半是移位寄存器接法写反了。
- STOP条件对不对:SCL高时SDA产生上升沿。
我调板子时习惯先把逻辑分析仪采样率打到最高,等抓到一组完整波形之后就缩小到协议解码视图,重点检查每个字节的ACK位。如果ACK位位置出现的是"主机自己拉低的电平",那基本就是SDA释放时机出了错,回代码里检查ACK窗口前一个状态是不是把sda_oe置成0了。
4.3 板上实测中容易出现的复位与上电时序
还有一个经常被忽略的上板问题:每次系统复位之后,I2C总线上可能残留上一次通信的半截状态,比如某个从机本来接收数据收到一半,突然主机关闭或复位了,它可能还在等剩余的时钟脉冲。这时候如果新代码一复位就先发起START,某些从机可能认不出START,因为对它们来说上一笔事务还没结束。
稳妥的做法是上电或复位后先发一个假的STOP条件,再做几轮空SCL脉冲(俗称九脉冲复位法),把可能有问题的从机状态机拉回空闲态,然后再开始正常通信。需要注意,这个恢复操作必须在SDA释放的情况下进行,不能一边发脉冲一边让SDA处于驱动状态。
实际项目里我还喜欢在上电初始化时先把I2C控制器放在reset状态,等系统时钟稳定几百微秒以后再释放reset,这样能避开电源上升期间的毛刺。对大多数传感器来说,它们的上电自检也需要几十毫秒,FPGA这边等一等再通信,能少很多无谓的NACK。
5. 三次真实翻车记录与排查过程
5.1 翻车一:ACK一直采样不到
那个项目里我主机读一个传感器,逻辑分析仪上地址字节发送完全正常,但第9个脉冲SDA一直保持高电平。按理说地址没错啊,怎么就没ACK呢。
排查过程先排除了地址问题,因为用示波器测过地址确实是手册上的值。然后用逻辑分析仪连续抓了几次,发现每次在第9个脉冲到来之前,SDA都有个很短的向下毛刺。这个毛刺正是问题所在:我的状态机在发送完第8位数据后,用了同一个始终周期把SDA输出数据同时切成了下一个字节的起始位或者直接清0,而SDA的三态控制释放比SCL上升沿晚了一步,导致在ACK窗口里我自己的逻辑把SDA拉低了。
修复很简单:状态机必须在第8位发送期间就把SDA输出释放,也就是让sda_oe先回到0,数据总线进入高阻,等SCL第9个上升沿到来时再去采样SDA引脚。这一步顺序颠倒,板上立刻翻车。仿真里看不出来是因为我原来的testbench从机模型没有开漏概念,SDA被主机强行拉低也照样能"回"ACK。
5.2 翻车二:偶发毛刺导致整个总线锁死
另一个情况是系统长时间跑着跑着突然I2C总线挂死,SCL和SDA全部被拉死或者一直停在某个电平上,只能断电重启。用逻辑分析仪抓了很久,发现挂死前总是伴随SCL上升沿附近出现一串很窄的毛刺。
进一步排查确认,毛刺来源是主板上的某路DC-DC开关噪声耦合到了I2C走线上,加上我的上拉电阻当时用的4.7k,总线上升沿本身就慢,更给了噪声干扰的机会。处理措施是:上拉电阻换成2.2k,缩短I2C走线,并且给SCL和SDA各加了一个20pF左右的接地电容滤波。改完以后毛刺消失,跑一整天的稳定性测试也没再挂。
更关键的是我在顶层逻辑里加了一个I2C总线超时看门狗:如果超过某个时间没有检测到预期的SCL边沿变化,就把I2C控制器整个复位,释放总线,重新初始化。因为从机一旦锁死,指望它自己恢复不现实,主机必须主动把SCL拨到至少9个脉冲让从机退出异常状态,或者干脆强制释放总线重新开始。有了这个兜底机制,即使再遇到偶发干扰,系统也能自愈而不是死等到断电。
5.3 翻车三:多主机同时抢总线,仲裁逻辑失效
第三个项目里有两块FPGA板卡都要通过同一根I2C总线去访问一个公共传感器,但如果两边同时发起事务,传感器回的数据就是乱的。我开始想着靠软件错峰轮询,结果实际跑起来发现两边启动时机不可控,总会有同时对总线发起访问的时刻。
正确解法是硬件仲裁。I2C仲裁本质上是一个位级别的"线与"比较:两个主机同时驱动SDA时,一旦某个主机想发1,但采样到总线上实际是0(说明另一个主机在拉低),它就输了,立刻停止发送并转入接收模式,让赢家继续。仲裁过程中SDA高位状态必须靠外部上拉体现,所以核心逻辑是每次驱动SDA之前先采样SDA引脚,跟你要输出的值做比较。
这个仲裁要在每一个数据位上做,包括地址位、数据位。如果只在启动条件时做一次比较,后面照样撞车。调试这个故障时我还发现一个细节:仲裁失败的从机必须马上把SDA释放,同时还要忽略后续可能来临的额外SCL脉冲,直到检测到新的START或者STOP为止。不然状态机容易在半途卡住。
仲裁逻辑补全之后,两块板卡再也没出现过互相抢总线导致的数据错乱。从那之后我养成了习惯:只要项目里存在两个以上I2C主机候选,仲裁逻辑就是必选项,而不是靠调度器去"碰运气"。
6. 写在最后的实操心得
文章写到这里,想分享几条我在多轮项目里沉淀下来的经验。
第一,别在仿真里放过任何一个z状态。我的testbench现在一律加上pullup模型,三态信号能不能正确释放,一眼就能看出来。凡是仿真里出现z飘着没人管的地方,大概率上板就会出问题。
第二,把最坏情况当作设计基准。算SCL高低电平时间时,不要只算个"约等于400kHz"就算完,要逐项核对表里的tLOW、tHIGH、tSU;DAT。PCB总线电容往大了估,上拉电阻往小了选,余量多留一点,现场调试时就能少吃很多苦。
第三,能低俗跑就先低俗跑。上板调试的第一件事永远是把I2C频率降到100kHz甚至50kHz,先把功能调通,再逐步提高频率。这样能把协议逻辑问题和电气信号完整性问题分开排查,不然两者混在一起,连从机为什么不回ACK都说不清。
第四,I2C的坑往往不在协议本身,而在你以为"协议已经通了"之后。仿真通过只是起点,真实总线上的电容、毛刺、多主机竞争、时钟延展、从机上电时序,每一项都要单独验证过,项目才算真正落地。每次翻车之后把根因记录下来的习惯,比任何调试工具都值钱。