第三十三章 基于I2C协议的EEPROM读写测试实验

本章在讲什么

前面的实验都是 FPGA "自娱自乐"(驱动 LED、屏幕),本章开始 FPGA 要和板上的另一颗芯片对话了。对话对象是 EEPROM 存储器 AT24C64——一种掉电不丢失数据的非易失性存储器(数据可保存 100 年,擦写 100 万次),常用来存配置参数、校准数据这类小批量信息。

对话的语言是 I2C 协议:只需一根时钟线 SCL、一根数据线 SDA,就能完成双向传输。本章的任务是:向 EEPROM 的地址 0~255 分别写入数据 0~255,写完再读出来逐一比对——全对则 LED 常亮,有任何错误则 LED 闪烁。

这一章的信息量大,但拆开看就三块:协议知识(I2C 怎么通信)→ 驱动设计(状态机怎么实现协议)→ 应用层(读写流程怎么编排)

核心概念拆解

33.1 认识 AT24C64:容量、页、器件地址

AT24C64 容量 64Kbit = 8192 字节,内部组织为 256 页 × 每页 32 字节。可以把它想象成一本书:256 页,每页 32 行,读写以字节为基本单位。

两个关键结论:

  • 器件地址是 7'h50:I2C 总线上每个从机都有器件地址。AT24C64 的地址 = 固定的 1010 + 3 位可编程位(由 A2/A1/A0 引脚接 GND 或 VCC 决定)。开发板上这三个引脚全部接地,所以地址为 7'b101_0000,即 7'h50。板上另一个 I2C 器件 RTC(PCF8563)地址固定为 7'h51,两者靠地址区分,并联在同一条总线上。
  • 字地址要两个字节:8192 个存储单元需要 13 位地址(2^13 = 8KB),而 I2C 以字节为单位传输,所以 AT24C64 的字地址必须用双字节(高 8 位 + 低 8 位)发送。这就是后面代码里 bit_ctrl = 1 的原因。

33.2 I2C 协议规则:两根线上的"交通法规"

物理层:I2C 器件采用开漏输出,SCL/SDA 都要接上拉电阻,总线空闲时两根线都是高电平,任何器件拉低都会把总线拉低("线与"关系)。

时序层核心规则只有几条:

动作 规则
起始信号 SCL 高电平时,SDA 由高变低
停止信号 SCL 高电平时,SDA 由低变高
传输数据 SCL 低电平时 SDA 允许变化,SCL 高电平时 SDA 必须稳定
应答(ACK) 每传完 8 位,第 9 个时钟由接收方拉低 SDA 表示收到;若 SDA 为高则为非应答(NACK),表示传输失败
位序 高位在前(MSB first)

一次完整传输的骨架是:起始 → 器件地址(7bit)+读写位(1bit) → 应答 → 字地址 → 应答 → 数据 → 应答 → 停止。读写位为 0 是写、为 1 是读,所以 AT24C64 的写命令字节是 8'b1010_0000,读命令字节是 8'b1010_0001

读操作的四种姿势:当前地址读(读地址指针指向的下一个单元)、随机读、当前地址连续读、随机地址连续读。写操作分单次写(字节写)和页写(连续写,但一页最多 32 字节,写满会回卷覆盖页首数据)。

其中随机读的时序最"奇怪"也最巧妙:想读地址 X 的数据,主机先发一次不带数据的写操作(虚写,Dummy Write),把芯片内部的地址指针拨到 X;然后不发停止信号,而是再次发一个起始信号(重复起始),这次带上读命令,从机就开始吐数据了。本质上随机读 = "没有数据段的字节写" + "当前地址读"。

33.3 系统架构:驱动与业务分层

工程分四个模块:

top_e2prom
 ├── e2prom_rw      (业务层:决定写什么、读什么、何时触发)
 ├── i2c_dri        (协议层:把请求翻译成 I2C 波形)
 ├── rw_result_led  (结果展示:LED 常亮/闪烁)
 └── EEPROM AT24C64(从机,挂在 scl/sda 总线上)

e2prom_rw 只管"发号施令":输出触发信号 i2c_exec、读写控制 i2c_rh_wl、字地址 i2c_addr[15:0]、写数据 i2c_data_w[7:0]i2c_dri 收到触发后按协议走完整个时序,回传完成信号 i2c_done、读出数据 i2c_data_r 和应答标志 i2c_ack这种分层让 i2c_dri 可以直接复用到任何 I2C 器件上(下一章的 RTC 就要用)。

33.4 i2c_dri 驱动模块:四倍时钟 + 八状态状态机

驱动时钟 dri_clk:SCL 频率定为 250KHz,直接从 50MHz 分频系数太大(200),所以先生成 SCL 的 4 倍频时钟 dri_clk(1MHz)作为模块工作时钟——4 倍频的意义是每个 SCL 周期被分成 4 份,足够在固定的节拍上安放起始信号、数据位、应答采样:

assign clk_divide = (CLK_FREQ/I2C_FREQ) >> 2'd2;  // 200/4 = 50

always @(posedge clk or negedge rst_n) begin
    if(!rst_n) begin
        dri_clk <= 1'b0;
        clk_cnt <= 10'd0;
    end
    else if(clk_cnt == (clk_divide[8:1] - 10'd1)) begin  // 计到24
        clk_cnt <= 10'd0;
        dri_clk <= ~dri_clk;                              // 翻转得1MHz
    end
    else
        clk_cnt <= clk_cnt + 10'b1;
end

写数据时在 SCL 低电平中间更新 SDA,读数据时在 SCL 高电平期间采样,这样永远不会踩到"数据变化撞上采样"的冲突。

SDA 双向控制:SDA 是双向线,主机发数据、从机回应答都要用它,FPGA 内部用三态门 + 方向控制信号 sda_dir 解决:

assign sda    = sda_dir ? sda_out : 1'bz;  // 输出或高阻(释放总线)
assign sda_in = sda;                        // 始终监测总线

主机要发数据时 sda_dir = 1;发完 8 位后拉低 sda_dir 释放总线,让从机在第 9 个时钟回应答。

三段式状态机共 8 个状态:

localparam st_idle    = 8'b0000_0001; // 空闲
localparam st_sladdr  = 8'b0000_0010; // 发器件地址+读写位
localparam st_addr16  = 8'b0000_0100; // 发16位字地址高8位
localparam st_addr8   = 8'b0000_1000; // 发字地址低8位(或单字节地址)
localparam st_data_wr = 8'b0001_0000; // 写数据
localparam st_addr_rd = 8'b0010_0000; // 重发起始+读命令
localparam st_data_rd = 8'b0100_0000; // 读数据
localparam st_stop    = 8'b1000_0000; // 停止

跳转路径浓缩成一句话:idle →(触发)→ sladdr →(bit_ctrl=1)→ addr16 → addr8 →(wr_flag?)→ 写: data_wr → stop / 读: addr_rd → data_rd → stop → idle。每个状态内部用计数器 cnt 按 dri_clk 节拍逐位摆出 SCL/SDA 波形,状态结束拉高 st_done 触发跳转。以发器件地址状态为例:

st_sladdr: begin
    case(cnt)
        7'd1 : sda_out <= 1'b0;              // SCL高时拉低SDA=起始信号
        7'd3 : scl     <= 1'b0;
        7'd4 : sda_out <= SLAVE_ADDR[6];     // 逐位发器件地址
        ...
        7'd32: sda_out <= 1'b0;              // 第8位:0=写
        7'd36: begin sda_dir <= 1'b0;  sda_out <= 1'b1; end // 释放总线
        7'd38: begin                         // 第9个时钟:检查应答
            st_done <= 1'b1;
            if(sda_in == 1'b1)               // 高=非应答=出错
                i2c_ack <= 1'b1;
        end
        ...
    endcase
end

33.5 e2prom_rw:写间隔 5ms 是硬性要求

业务层用 flow_cnt(0~3)编排"先写后读":

  • flow_cnt=0/1(写循环):计数器 wait_cnt 数满 WR_WAIT_TIME=5000(dri_clk 周期 1μs,即 5ms)后拉高一拍 i2c_exec 触发一次单次写(地址 0 写 0,地址 1 写 1……);收到 i2c_done 后地址和数据各加 1。写满 256 个后拉高 i2c_rh_wl 切入读模式。
  • flow_cnt=2/3(读循环):连续触发随机读(驱动内部自动完成虚写+重复起始),每读完一个字节就比对 i2c_addr[7:0] != i2c_data_r,同时检查 i2c_ack;有任何不一致或 NACK,rw_result = 0,全部通过则 rw_result = 1,最后输出一拍 rw_done

为什么写完要等 5ms:AT24C64 数据手册规定一次写入(写周期)最长 10ms,写完必须等芯片内部把数据烧录好才能进行下一次操作,否则数据写丢。EEPROM 只对写操作有间隔要求,读操作不需要间隔。教材实测 5ms 够用,但不建议再缩短。

33.6 结果显示与仿真:LED 一盏灯讲故事,TB 要挂从机模型

rw_result_led 极简单:测试完成前 LED 常暗;rw_result=1 常亮;rw_result=0 则以 0.25s 间隔闪烁(L_TIME 参数按工作时钟换算,dri_clk 为 1MHz 时 0.25s/1μs/2 = 125000)。

仿真的关键点是必须挂一个 EEPROM 仿真模型EEPROM_AT24C64.v,从芯片官网获取),因为 I2C 主机发的每个字节都需要从机回应答——没有模型应答,状态机每一步都会判定 NACK 而失败。TB 里还有一行值得记住:

pullup(iic_sda);   // SDA 为高阻态时上拉为高,模拟板上拉电阻

另外 TB 把 MAX_BYTE 从 256 改成 3、L_TIME 改成 1 来缩短仿真时间——和上一章 defparam 缩短 DIV_CNT 是同一个思路。

图片对照

I2C总线物理拓扑结构图

图:I2C 总线拓扑——EEPROM(7'h50) 与 RTC(7'h51) 并联在同一条两线总线上

随机地址读时序

图:随机读时序——先虚写拨地址指针,再重复起始发读命令,是本章最需要吃透的时序

EEPROM读写测试系统框图

图:系统框图——业务层(e2prom_rw)与协议层(i2c_dri)信号交互一目了然

I2C驱动模块状态跳转图

图:i2c_dri 八状态跳转图——对照代码阅读效果最佳

实验现象

图:上板现象——LED 常亮表示 256 字节读写校验全部通过

初学者容易踩的坑

  1. 器件地址和字地址混为一谈:器件地址 7'h50 是"找哪颗芯片",字地址 16 位是"芯片内部的哪个存储单元",两者都要发、缺一不可。写命令字节 1010_0000 的最后一位是读写位,不是地址的一部分。
  2. 忘了写间隔或间隔太短:连续两次写操作之间没有 5ms 延时,后写的数据会失败,表现为读回校验时部分数据错误。读操作则不需要间隔——给读也加 5ms 只会白白拖慢测试。
  3. SDA 忘了释放总线:主机发完 8 位后必须把 sda_dir 拉低(输出高阻),否则主机输出和从机应答"打架",应答永远读不对。每个状态的收尾动作里都藏着这个细节。
  4. BIT_CTRL 参数配错:换成 AT24C02 这类单字节字地址器件时,BIT_CTRL 必须改成 0,否则状态机会多发一个字节的高位地址,整个时序全乱。这个参数就是为跨器件复用准备的。
  5. 仿真没有挂从机模型:只例化顶层就仿真,SDA 上永远读不到应答,i2c_ack 一直报错。记住 pullup(iic_sda) 和 EEPROM 模型是仿真的两个必备件。
  6. 首位遗漏起始信号的条件:起始信号要求"SCL 为高时拉低 SDA",如果 SCL 已经是低电平时去拉 SDA,从机根本不认——这也是为什么驱动时钟必须是 SCL 的 4 倍,给"先高后拉低"留出独立节拍。

小结 & 下一步

本章完成了三个层次的跨越:

  • 协议层:掌握了 I2C 的起始/停止条件、位传输规则、应答机制、器件寻址,以及随机读"虚写+重复起始"的巧妙设计;
  • 驱动层:用"4 倍频时钟 + 8 状态三段式状态机 + 三态门双向控制"实现了一个通用 I2C 驱动,它是可以带走的可复用资产;
  • 业务层:学会了用 flow_cnt 状态流编排"写→等→读→比"的完整测试流程,并理解了 EEPROM 写周期延时这种器件特性约束。

下一步:第三十四章将把这套 I2C 驱动直接复用到另一颗 I2C 器件——RTC 实时时钟芯片 PCF8563 上,读取时间并在 LCD 屏幕上显示。届时你会看到"换芯片只换器件地址和寄存器布局"的分层设计红利。