FPGA实验记录五:I2C读取AHT10温湿度传感器

FPGA实验记录五:I2C读取AHT10温湿度传感器

一、AHT10温湿度传感器

1. 简介

AHT10,新一代温湿度传感器在尺寸与智能方面建立了新的标准:它嵌入了始于回流焊的双列扁平无引脚SMD封装,底面4*5mm,高度1.6mm。传感器输出经过标定的数字信号,标准I²C格式。

image-20220730151602195

AHT10配有一个全新设计的ASIC专用芯片、一个经过改进的MEMS半导体电容式湿度传感元件和一个标准的片上温度传感元件,其性能已经大大提升甚至超出了前一代传感器的可靠水平,新一代温湿度传感器,经过改进使其在恶劣环境下的性能更加稳定。

每一个传感器都经过校准和测试,在产品表面印有产品批号。由于对传感器做了改良和微型化改进,因此它的性价比更高,并且最终所有设备都将得益尖端的节能运行模式。

image-20220730151742595

I2C总线上仅能连接一个AHT10,连上后便不再支持其他I2C部件。

2. 传感器性能

image-20220730152257292

image-20220730152343771

3. 布线规则和信号完整性

如果SCL和SDA信号线相互平行且非常接近,有可能导致信号串扰和通讯失败。解决方法是在两个信号线之间放置VDD/GND,将信号线隔开,或使用屏蔽电缆。此外,降低SCL频率也可能提高信号传输的完整性。须在电源引脚VDD/GND之间加一个10μF的去耦电容,用于滤波。次电容应尽量靠近传感器。

此外,为了提高传感器的可靠性,电路板在layout时避免在传感器底部布线或覆盖设计。

针对本次实验而言,这里的10μF去耦电容不需要理会,在此贴出只是为了文章的严谨性。

4. 接口定义

image-20220730153536259

  • VDD、GND电源引脚:AHT10的供电范围为1.8-3.6V,推荐电压为3.3V。
  • SCL串行时钟:用于微处理器(FPGA)与AHT10之间的通讯同步。由于接口包含了完全静态逻辑,因而不存在最小SCL频率。
  • SDA串行数据:用于传感器的数据输入和输出。
    • 当向传感器发送命令时,SDA在串行时钟SCL的上升沿有效,且当SCL为高电平之后,SDA必须保持稳定。在SCL下降沿之后,SDA值可被改变。SDA的有效时间在SCL上升沿之前的TSU和下降沿之后的TH0。
    • 当从传感器读取数据时,SDA在SCL变低以后有效,且维持到下一个SCL下降沿。(标准I2C)

image-20220730214056639

5. 命令与时序

每个传输序列都以Start状态作为开始,并以Stop状态结束。(I2C格式)

命令 释义 二进制 十六进制
首字节 包含设备地址的读写控制字(Read:1,Write:0)。 0111_000x 0x38<<1 + 1/0
查看状态字/读取测量值 实际上是读控制字,意味着在非触发测量时,可以读取状态字;触发测量后80ms可以读取1个状态字+温湿度值共5个字 0111_0001 0x71
初始化字 0XE1 初始化传感器各种状态,拥有两个命令参数字节,分别是0x08和0x00。 1110_0001&0000_1000&0 0XE1&0x08&0x00
触发测量 0xAC 读取温湿度,拥有两个命令参数字节,分别是0x33和0x00。 1010_1100&0011_0011&0 0xAC&0x33&0x00
软复位 0XBA 用于在无需关闭和再次打开电源的情况下,重新启动传感器系统。接收到此命令后传感器系统开始重新初始化,并恢复默认设置状态,软复位所需时间不超过20ms。 1011_1010 0xBA

传感器读取流程:

  1. 上电后等待40ms,读取温湿度值之前,首先要看状态字节的校准时能Bit[3]是否为1(通过发送0x71可以获取1个字节的状态字),如果不为1,要发送0XE1命令以及两个参数0x08和0x00。

    命令参数什么意思?我这里可以从函数的角度去看他,理解为0XE1(0X08,0X00)。即只发送一个函数名还不行,还需要两个参数传递进去才能触发它应有的功能。那只能传递这两个参数吗?有其他的吗?目前在手册上还没有发现其他参数。

image-20220730182641007

  1. 直接发送0xAC命令与相应的命令参数0x33和0x00来触发测量。

image-20220730171715013

  1. 等待80ms,发送读控制字读取状态和测量值。(一共6字节,其中状态1字节,温湿度各2.5字节)

image-20220730171722445

软复位:

image-20220730193936348

6. 信号转换

相对湿度转换:

相对湿度RH都可以根据SDA输出的相对湿度信号**SRH[19:0]**通过如下公式计算获得

(结果以%RH表示)

image-20220730191458415

温度转换:

温度T都可以通过将温度输出信号**ST[19:0]**带入到下面的公式计算得到

(结果以℃表示)

image-20220730191552431

二、I2C协议

关于I2C的相关知识,我上一篇博客已经写的很清楚了,可以参考FPGA基础协议二:I2C读取E2PROM

三、逻辑设计

1. 实验要求:

通过I2C协议使用FPGA读取AHT10所测定的温湿度值。

2. 设计思路:

总体需求:使用FPGA通过串口和I2C协议将AHT10测定的温湿度值传递到上位机中进行显示。

测量温湿度流程:

  1. 上电后等待40ms

  2. 发送 读取状态命令0X71然后 接收状态字

  3. 如果状态字校验位bit[3]!=1,则发送起始位+写控制字 + 初始化命令0XE1(0x08,0x00)+停止位,否则跳过此步;

    校准位检验在上电时检查即可,后面便可以不再检查。

  4. 发送起始位+ 写控制字 + 触发测量命令0XE1(0X33,0X00)+停止位

  5. 等待80ms;

  6. 发送起始位+ 读控制字,然后等待接收状态数据以及温湿度数据,接收完6字节后发送停止位

  7. 将温湿度数据进行信号转换,然后通过串口发送到上位机中进行展示;

  8. 重复5-7步骤。

**或者,**也可以非常粗鲁地跳过第2步,直接执行1,3–8步骤,因为初始化化后校验位必为1,这样就可以省略很多不必要的流程。

本次实验也将跳过第2步来简化操作。

模块设计:

  • 需要一个ATH读写控制模块,来控制命令发送;
  • 需要一个I2C接口模块来讲数据进行I2C协议格式的转换;
  • 需要一个IO_CTRL模块来控制SDA的输入输出状态;
  • 需要一个Data_driver数据处理模块,来讲数据信号进行合理转换;
  • 需要一个Uart_Tx模块来讲数据发送给上位机,同时配备一个fifo来暂存发送字节;

3. 模块框图:

image-20220802144021065

uart_tx: 串口发送模块,用于将程序存于rdfifo中的温湿度数据并串转换后发送给上位机进行展示;

aht10_rwctrl: AHT10读写控制模块,用来向AHT10温湿度传感器发送命令,以及处理i2c接口模块接收到并串并转换后的数据,比如将温湿度字节根据公式进行信号转换后存入data_driver中进行处理;

data_driver: 数据处理模块,用于将从AHT10读取的温湿度数据处理切割成数个字节,使其通过串口打印在上位机时符合精度要求;

i2c_interface:i2c接口模块,用来把接收到的指令并串转换为I2C协议的格式然后传输到AHT10存储器中,或将AHT10传输回来的信息进行串并转换传回到rwctrl模块中;

io_ctrl: SDA总线控制模块,负责控制这根inout双向数据总线何时input,何时output。(对data_driver而言没有太大的关系,把data_driver放在里面只是为了方便接收rwctrl模块出来的数据)

4. 状态机:

负责产生时钟的是主状态机,这里i2c接口模块负责控制SCLK,即产生时钟。

主状态机:

image-20220716222717962

  • IDLE:空闲状态,这个时候SCLSDA都为高电平,等待主状态机的命令。

  • START:发送起始位,此时SDA将在SCL处于高电平时拉低。起始位只存在于读写控制字之前,往后的操作是以ack应答信号为依据来进行的。

    因为读写控制字都是被发送的,所以应该不存在由START跳转到RECEIVE这一说

  • SEND:此时sda发送数据,即由I2C接口模块AHT10发送串行数据,不管是发送并串转换后的读写控制字命令或是命令参数,都是由I2C接口模块占用SDA总线AHT10发送串行信号;

  • R_ACK:i2c接口模块接收来自AHT10的应答信号。

  • RECEIVE:此时sda接收数据,即由AHT10占用SDA总线I2C接口模块发送串行数据,这时I2C接口模块属于接收方,所以我认为将此状态命名为RECEIVE状态会更好。

  • S_ACK:i2c接口模块是接收方时,会向AHT10发送应答信号

  • STOP:发送停止位。此时SDA将在SCL处于高电平时拉高。

    不是每一个数据帧都有停止位,起始位只存在于操作结束时的那个数据帧后面。

从状态机:

image-20220730210040563

  • WAIT: 上电后等待40ms;

  • IDLE:空闲状态;

  • INIT_REQ:初始化请求,用来检测状态字的校验位bit[3],如果校验位不为1,则发送初始化命令;

    发送什么命令,什么控制字由字节计数器决定,下面的读操作也一样

  • WAIT_INIT:串并转换/并串转换需要时间,这个状态就是用来等待转换完成然后返回一个done信号告诉aht10_rwctrl模块该发下一个字节了,并再次进入WAIT_INIT状态,在完成校验位的检测(为1的情况下)或初始化完成后进入DONE状态;

    整个init初始化操作在上电之后走一遍即可。

  • RD_REQ:读温湿度请求,首先发送触发测量命令,收到done信号后再等待80ms后再发送读控制字;

  • WAIT_RD:同样是一个等待串并转换/并串转换完成的状态,在收完6个字节的状态字、温湿度值再回到DONE状态;

  • DONE:表操作完成,直接进入idle状态即可。

四、代码实现

0. param.v参数模块

//i2c读写命令参数
`define CMD_START 4'b0001
`define CMD_WRITE 4'b0010
`define CMD_READ  4'b0100
`define CMD_STOP  4'b1000

//AHT10外设地址参数定义
`define     I2C_ADR 7'b0111_000  //6'b1010_00xy x:Block地址 y:读写控制位 WR_BIT/RD_BIT
`define     WR_BIT  1'b0    //bit0
`define     RD_BIT  1'b1    //bit0

//ANT10命令参数
//读命令 读状态/读数据
`define AHT_READ 8'b0111_0001
//初始化命令序列
`define AHT_INIT_0 8'b1110_0001
`define AHT_INIT_1 8'b0000_1000
`define AHT_INIT_2 8'b0000_0000
//触发测量命令序列
`define AHT_MEAS_0 8'b1010_1100
`define AHT_MEAS_1 8'b0011_0011
`define AHT_MEAS_2 8'b0000_0000
//软复位命令
`define AHT_SO_RST 8'b1011_1010

//读字节长度
`define RD_BYTE 11

//初始化模式定义
//`define METHODICAL
`define CRASSLY //粗暴模式,略过检验直接初始化
//初始化字节长度
`ifdef CRASSLY
    `define INIT_BYTE 4
`else
    `define INIT_BYTE 6
`endif

//串口参数定义

`define STOP_BIT  1'b1
`define START_BIT 1'b0

这里需要注意的是初始化操作的字节数与读操作的字节数是怎么来的。

INIT_BYTE:

这里我用ifdef语句定义了两种初始化模式,一个是CRASSLY粗暴模式,就是直接跳过检验校验位来初始化的方法,分别是写控制字+初始化命令+2个命令参数 = 4个字节;第二种模式因为需要检验,所以多了一个读控制字和一个接收字节。

RD_BYTE:

写控制字 + 触发测量命令 + 2个命令参数 = 4个字节

读控制字 + 接收到的状态字 + 接收到的5个温湿度字 = 7个字节

共计11个字节。

1. aht10_rwctrl模块

1.1 状态机:

    assign wait2idle = state_c ==WAIT && end_40ms;
    /* 初始化状态 */
    assign idle2init = state_c == IDLE && !rd_flag;
    assign init2wait = state_c == INIT && (1'b1);
    assign wait2init = state_c == WAIT_INIT && done && cnt_byte < (`INIT_BYTE-1) ;
    assign init2done = state_c == WAIT_INIT && end_byte;
    /* 读状态 */
    assign idle2read  = state_c == IDLE && rd_flag;
    assign read2wait  = state_c == READ && (1'b1);
    assign wait2read  = (state_c == WAIT_RD && ((done && cnt_byte < (`RD_BYTE-1)&& cnt_byte!=3)||end_80ms));
    assign read2done  = state_c == WAIT_RD && end_byte;
    /* 完成任务 */
    assign done2idle  = state_c == DONE && (1'b1);//转身即逝
  • wait2idle:上电等待40ms后进入IDLE状态;

  • idle2init:当在非读状态下直接进入初始化操作(rd_flag在init2done为真时拉高);

  • init2wait:进入INIT状态后等待命令发送完成;

  • wait2init:在并串转换完成并收到done信号且当前操作字节<4时继续回到INIT状态发送命令;

  • init2done:完成所有操作字节后进入DONE状态,此时rd_flag拉高,后面将不再进行初始化;

  • idle2read:处于IDLE状态并且已经完成初始化操作就能直接进入READ状态;

  • read2wait:进入READ状态发送完命令后等待并串转换或等待接收时的串并转换完成;

  • wait2read:该命令分两种情况,一种是在发送完触发测量命令后还需再等待80ms才能进入下一个READ状态,另一个情况是“其余情况”;

  • read2done:完成所有读操作字节;

  • done2idle:直接回归IDLE即可。

1.2 计数器:

//40ms计数器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        cnt_40ms <= 1'b0;
    end 
    else if (add_40ms && cnt_40ms < DELAY_40MS) begin
            cnt_40ms <= cnt_40ms + 1'b1;
    end
end
assign add_40ms = 1'b1;
assign end_40ms = add_40ms && cnt_40ms == DELAY_40MS;

//80ms计数器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        cnt_80ms <= 1'b0;
    end 
    else if (add_80ms) begin
        if (end_80ms) begin
            cnt_80ms <= 1'b0;
        end else begin
            cnt_80ms <= cnt_80ms + 1'b1;
        end
    end
end
assign add_80ms = add_80flag;
assign end_80ms = add_80ms && cnt_80ms == DELAY_80MS;

//80ms延时计数触发器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        add_80flag <= 1'b0;
    end 
    else if ((state_c==WAIT_RD & done)&&cnt_byte == 3) begin
        add_80flag <= 1'b1;
    end
    else if (cnt_byte!=3) begin
        add_80flag <= 1'b0;
    end 
    else begin
        add_80flag <= add_80flag;
    end
end

//byte计数器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        cnt_byte <= 1'b0;
    end 
    else if (add_byte) begin
        if (end_byte) begin
            cnt_byte <= 1'b0;
        end
        else begin
            cnt_byte <= cnt_byte + 1'b1;
        end
    end
    else begin
        cnt_byte <= cnt_byte;
    end
end
assign add_byte = ((state_c==WAIT_RD & done) && cnt_byte!=3) || 
                    (state_c==WAIT_INIT & done) || end_80ms;
assign end_byte = add_byte && (cnt_byte == ((state_c == INIT || state_c == WAIT_INIT)?(`INIT_BYTE-1):(`RD_BYTE-1))); //写操作需要4个字节的空位,读操作则需要11个字节

40ms延时计数器:上电即开启,完成计数后将停止工作;

80ms延时计算器的开启条件:当前字节计数为3,状态为等待读并且已经收到了done信号(此时触发测量命令的最后一个命令参数发送完毕);

Byte计数器:发送与接收字节的计数器,非常重要。起始条件有三种,基本上都跟done信号有关,前两种一个是初始化状态下的add条件,一个是读状态下的add条件,读状态又有一个特殊情况,即第三个字节需要多等待80ms。

1.3 命令发送模块

根据字节计数器和当前状态来决定发送什么样的数据。为了优化代码结构,所以这里应用了TASK。

//命令与数据
always @(posedge clk or negedge rst_n) begin 
    if (!rst_n) begin
        TX(1'b0,4'b0,10'b0);
    end
    else if (state_c == INIT) begin
        case(cnt_byte)  
            0           : TX(1'b1,(`CMD_START|`CMD_WRITE),{`I2C_ADR,`WR_BIT}) ;  //带起始位的写控制字
            1           : TX(1'b1,`CMD_WRITE,`AHT_INIT_0);  //初始化命令
            2           : TX(1'b1,`CMD_WRITE,`AHT_INIT_1);  //初始化命令参数1
            `INIT_BYTE-1: TX(1'b1,(`CMD_WRITE|`CMD_STOP),`AHT_INIT_2);  //带停止位的初始化命令参数2
            default     : TX(1'b1,`CMD_WRITE,0);
        endcase
    end 
    else if (state_c == READ) begin
        case(cnt_byte)  
            0           : TX(1'b1,(`CMD_START|`CMD_WRITE),{`I2C_ADR,`WR_BIT}) ;  //带起始位的写控制字
            1           : TX(1'b1,`CMD_WRITE,`AHT_MEAS_0);  //触发测量命令
            2           : TX(1'b1,`CMD_WRITE,`AHT_MEAS_1);  //触发测量命令参数1
            3           : TX(1'b1,(`CMD_WRITE|`CMD_STOP),`AHT_MEAS_2);   //带停止位的触发测量命令2
            4           : TX(1'b1,(`CMD_START|`CMD_WRITE),`AHT_READ);   //带起始位的读控制字
            `RD_BYTE-1  : TX(1'b1,(`CMD_READ|`CMD_STOP),0);//发0表此时收数据,不再需要传输,带停止位是因为这是最后一个字节
            default     : TX(1'b1,`CMD_READ,0);
        endcase
    end 
    else begin
        TX(1'b0,tx_cmd,tx_data);
    end 
end

task TX;
    input               req;
    input    [03:00]    cmd;
    input    [07:00]    data;
    begin
        tx_cmd = cmd;
        tx_req = req;
        tx_data = data;
    end
endtask

assign cmd = tx_cmd;
assign req = tx_req;
assign wr_data = tx_data;
  • 初始化操作的前四个字节分别是写控制字、初始化命令、初始化命令参数*2;
  • 读操作按顺序则是写控制字、触发测量命令3个字节、读控制字、接收6字节;

其他时间除了请求信号以外应保持不变防止数据覆盖(这只是我的拙见)。

另外可以看到第一个字节和最后一个字节都使用了按位或 '|' 运算符,这相当于发送了两个命令,可以在下面的I2C接口模块看到它们的作用。

1.4 温湿度数据处理

//温湿度数值转换
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        humi_r <= 20'b0;
    end 
    else if (cnt_byte == 1) begin
        humi_r <= 20'b0;
    end
    else if (done && state_c == WAIT_RD) begin
        case (cnt_byte)
            6: humi_r <= {humi_r[11:0],rd_data};
            7: humi_r <= {humi_r[11:0],rd_data};
            8: humi_r <= {humi_r[15:0],rd_data[7:4]};
            default: humi_r <= humi_r;
        endcase
    end
end
assign humi_data = (((humi_r<<6)+(humi_r<<5)+(humi_r<<2))*10'd1000)>>20;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        temp_r <= 20'b0;
    end 
    else if (cnt_byte == 1) begin
        temp_r <= 20'b0;
    end
    else if (done && state_c == WAIT_RD) begin
        case (cnt_byte)
            8: temp_r <= {temp_r[15:0],rd_data[3:0]};
            9: temp_r <=  {temp_r[11:0],rd_data};
            10: temp_r <= {temp_r[11:0],rd_data};
            default: temp_r <= temp_r;
        endcase
    end
end
assign temp_data = ((((temp_r<<7)+(temp_r<<6)+(temp_r<<3))<<6)+
                    (((temp_r<<7)+(temp_r<<6)+(temp_r<<3))<<5)+
                    (((temp_r<<7)+(temp_r<<6)+(temp_r<<3))<<2))>>20;
assign aht10_vld = (cnt_byte>8)&&end_byte;

这里是按照手册给的公式进行运算,用移位运算符替代了乘法器和除法器来节省空间,优化结构。

这里给humi*1000是为了精准到小数点后三位;同理temp*100也是为了精确到小数点后两位。

aht10_vld代表着读取操作完成,数据转换完成。

这里还要注意,因为移位运算的关系,temp_rhumi_r的位宽一定要给足,不然移着移着就变成0了,这里我一路摸索最后给了40位宽,量大管饱。

image-20220802194553827

2. I2C接口模块

负责将数据通过串并转换/并串转换处理成符合i2c协议的数据的模块。

2.1 状态机

    localparam  IDLE = 7'b000_0001,    //初始状态
                START= 7'b000_0010,    //发送起始位
                SEND = 7'b000_0100,    //写/发
                RECEIVE = 7'b000_1000,    //读/收
                R_ACK= 7'b001_0000,     //作为发送方接收应答位
                S_ACK= 7'b010_0000,     //作为接收方发送应答位
                STOP = 7'b100_0000;     //发送停止位
    
    wire                idle2start      ;
    wire                idle2send       ;
    wire                idle2receive    ;
    wire                start2send      ;
    //wire                start2receive      ;start并不能跳转到receive,因为读数据的时候不需要起始位
    wire                send2rack       ;
    wire                receive2sack    ;
    wire                rack2stop       ;
    wire                sack2stop       ;
    wire                rack2idle       ;
    wire                sack2idle       ;
    wire                stop2idle       ;

    reg       [06:00]       state_c     ;
    reg       [06:00]       state_n     ;

//状态机
always @(posedge clk or negedge rst_n) begin 
    if (rst_n==0) begin
        state_c <= IDLE ;
    end
    else begin
        state_c <= state_n;
    end
end
always @(*) begin 
    case(state_c)  
        IDLE :begin
            if (idle2start)
                state_n = START;
            else if(idle2send)
                state_n = SEND ;
            else if(idle2receive)
                state_n = RECEIVE ;
            else 
                state_n = state_c ;
        end
        START :begin
            if (start2send) begin
                state_n = SEND;
            end else begin
                state_n = state_c ;
            end
        end
        SEND :begin
            if(send2rack)
                state_n = R_ACK ;
            else 
                state_n = state_c ;
        end
        RECEIVE :begin
            if(receive2sack)
                state_n = S_ACK ;
            else 
                state_n = state_c ;
        end
        R_ACK :begin
            if(rack2idle)
                state_n = IDLE ;
            else if(rack2stop)
                state_n = STOP ;
            else 
                state_n = state_c ;
        end
        S_ACK :begin
            if(sack2idle)
                state_n = IDLE ;
            else if(sack2stop)
                state_n = STOP ;
            else 
                state_n = state_c ;
        end
        STOP :begin
            if(stop2idle)
                state_n = IDLE ;
            else 
                state_n = state_c ;
        end
        default : state_n = IDLE ;
    endcase
end

assign idle2start   = state_c == IDLE    && req && (cmd&`CMD_START);
assign idle2send    = state_c == IDLE    && req && (cmd&`CMD_WRITE);
assign idle2receive = state_c == IDLE    && req && (cmd&`CMD_READ);
assign start2send   = state_c == START   && end_bit && (cmd_r&`CMD_START);
assign send2rack    = state_c == SEND    && end_bit ;
assign receive2sack = state_c == RECEIVE && end_bit ;
assign rack2stop    = state_c == R_ACK   && end_bit && (cmd_r&`CMD_STOP);
assign sack2stop    = state_c == S_ACK   && end_bit && (cmd_r&`CMD_STOP);
assign rack2idle    = state_c == R_ACK   && end_bit && (cmd_r&`CMD_STOP) == 0;
assign sack2idle    = state_c == S_ACK   && end_bit && (cmd_r&`CMD_STOP) == 0;
assign stop2idle    = state_c == STOP    && end_bit;
  • idle2start:接收到起始命令和有效请求即可,因为读写控制模块发送命令时使用的按位或操作,所以接收到的命令是包含两个1的,这个时候去按位与原本的CMD_START也是可以获得真值的,这就是使用按位或运算符的好处,下面的停止位也同理。
  • idle2send:收到写命令和有效请求即可。

看到这里可能会有一点疑惑,第一个字节发送了两个命令,如此看来可以同时激活idle2start和idle2send,那这个时候idle会往哪里跳呢?所以就要注意上面状态转移always语句块的写法了,根据优先级,一定要把idle2start放到第一个if语句里,这样即使激活两个信号后根据if的优先级也能只跳到START状态。

  • idle2receive:收到读命令和有效请求即可。
  • start2send:这里的end_bit的条件是cnt_bit=0,用来衡量起始位是否结束。
  • send2rack:这里的end_bit则是7。
  • receive2sack:同上。
  • rack2stop:同样是根据命令和bit计数器来决定
  • sack2stop:同上。
  • rack2idle:bit计数器完成计数且没有收到停止命令就能回去。
  • sack2idle:同上。
  • stop2idle:这里的endbit也是0。

cmd_r代表着cmd打一拍。

2.2 计数器

分为bit计数器和scl时钟计数器。这里我设想的是sda传输速率为200kbit/s左右,再根据50Mhz时钟进行换算,得到一个scl时钟周期 = 250个50Mhz的时钟周期。

localparam I2CYC = 249,     //I2C一个时钟周期
                I2C_HALF = 124, //I2C半个时钟周期
                I2C_BE_HA = 64, //I2C前半时钟周期的中点
                I2C_AF_HA = 189;//I2C后半时钟周期的中点

//scl周期计数器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        cnt_scl <= 1'b0;
    end 
    else if (add_scl) begin
        if (end_scl) begin
            cnt_scl <= 1'b0;
        end else begin
            cnt_scl <= cnt_scl + 1'b1;
        end
    end
end
assign add_scl = (state_c!= IDLE);
assign end_scl = add_scl && cnt_scl == I2CYC;


//bit计数器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        cnt_bit <= 1'b0;
    end 
    else if (add_bit) begin
        if (end_bit) begin
            cnt_bit <= 1'b0;
        end else begin
            cnt_bit <= cnt_bit + 1'b1;
        end
    end
end
assign add_bit = (state_c != IDLE) && end_scl;
assign end_bit = add_bit && cnt_bit == ((state_c == SEND || state_c == RECEIVE)?3'd7:1'b0);//8个数据位+1个应答位

bit计数器的开始条件就是每完成一个scl时钟周期就+1,停止条件则根据当前状态进行判断,发送和接收都是8bit,起始位与停止位1bit即可。

2.3 SCL时钟控制

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        scl_r <= 1'b1;//空闲时间高电平
    end
    else if (idle2start | idle2send | idle2receive) begin
        scl_r <= 1'b0;
    end
    else if (add_scl && cnt_scl == I2C_HALF) begin
        scl_r <= 1'b1;
    end
    else if (end_scl && ~stop2idle) begin
        scl_r <= 1'b0;
    end
end

开始条件:当从空闲状态解脱时就能拉低开始了。

拉高条件:除了空闲状态,每当scl时钟计数器满足125次,即半个周期后就得拉高。

继续技术:当每计数完一个时钟周期后并且没有收到停止命令时拉低。

2.4 串并转换与并串转换

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        sda_out <= 1'b1;//空闲时间高电平
    end
    else if (state_c == START) begin    //严谨
        if (cnt_scl == I2C_BE_HA) begin
            sda_out <= 1'b1;
        end
        else if (cnt_scl == I2C_AF_HA) begin
            sda_out <= 1'b0;
        end
    end
    else if (state_c == STOP) begin
        if (cnt_scl == I2C_BE_HA) begin
            sda_out <= 1'b0;
        end 
        else if(cnt_scl == I2C_AF_HA) begin
            sda_out <= 1'b1;
        end
    end
    else if (state_c == SEND && cnt_scl == I2C_BE_HA) begin
        sda_out <= tx_data[7-cnt_bit];//数据位要在时钟低电平时变化
    end
    else if (state_c == S_ACK && cnt_scl == I2C_BE_HA) begin
        sda_out <= (cmd_r&`CMD_STOP)?1'b1:1'b0;
    end
end

//串并转换器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        rd_data_r <= 1'b0;
    end 
    else if (state_c == RECEIVE && cnt_scl == I2C_AF_HA) begin
        rd_data_r[7-cnt_bit] <= i2c_sda_i;
    end
end

起始位和停止位都需要单独注意,一个是在SCL高电平时拉低,另一个则是拉高。

并串转换时要在SCL低电平时拉低数据,这里取一个低电平的中间值能够避免出错,并且让时序图也更加美观易懂。

SACK:发送应答信号,这个时候需要注意是否要发送停止位,如果此时已经是最后一个字节,那么直接拉高发送停止位即可。

串并转换则是在SCL时钟拉高时进行数据转换。

3. io_ctrl模块

因为SDA这根总线比较特殊,传输是双向的,半双工,所以我们需要这样一个模块来决定SDA什么时候收,什么时候发。

最开始我觉得这样单独列出来一个模块没有必要,因为我认为在I2C接口模块完成这样一个收发切换功能也是可行的;后面意识到分开写有不少好处

  1. 编写逻辑更加严谨,不会使接口模块的代码过于臃肿,可读性也会增强不少,让人一看就懂
  2. 顶层例化更加方便,也可针对这I2C的功能进行单独仿真

这一模块最重要的是SDA总线的输出或输入使能,这里我选择输出使能,因为大部分时间都在输出。

module i2c_ctrl (
    input               clk     ,
    input               rst_n   ,
/*输入信号*/
    input               busy    ,
    input               key     ,
    input               din_vld ,
    input    [09:00]    din_data,
/*输出信号*/
    output              dout_vld,
    output   [09:00]    dout_data,
    output              scl     ,
    inout               sda     //
);
//参数定义

//中间信号定义
    wire                req     ;
    wire     [03:00]    cmd     ;
    wire                s_ack   ;
    wire                done	;
    wire     [09:00]    wr_data	;
    wire     [09:00]    rd_data	;
    wire                sda_out_en;
    wire                sda_out   ;
    wire                sda_in    ;
    wire     [19:00]    temp    ;
    wire     [19:00]    humi    ;
    wire                aht10_vld;

//实例化
e2promrw_ctrl u_rw_ctrl(
    /* input                */.clk      (clk     ),
    /* input                */.rst_n    (rst_n   ),
/*uart输入信号*/
    /* input                */.key      (key     ),
    /* input                */.din_vld  (din_vld ),
    /* input    [07:00]     */.din_data (din_data),
    /* input                */.busy     (busy    ),//发送忙碌信号,防止发送覆盖
/*i2c接口输入信号*/
    /* input                */.done     (done    ),//代表串并转换完成
    /* input                */.s_ack    (s_ack   ),//接收串口的应答信号以进行下一步操作
    
    /* input    [07:00]     */.rd_data  (rd_data ),

/*i2c接口输出信号*/
    /* output               */.req      (req     ),
    /* output   [03:00]     */.cmd      (cmd     ),
    /* output   [07:00]     */.wr_data  (wr_data ),
/*uart输出信号*/
    /* output               */.dout_vld (dout_vld),
    /* output   [07:00]     */.dout_data(dout_data) 

);
i2c_interface u_i2c_interface(
    /* input                */.clk       (clk       ),
    /* input                */.rst_n     (rst_n     ),
/*输入信号*/    
    /* input                */.req       (req       ),
    /* input    [07:00]     */.wr_data   (wr_data   ),
    /* input    [03:00]     */.cmd       (cmd       ),
/*输出到控制模块信号*/
    /* output               */.done      (done      ),
    /* output               */.sack      (s_ack      ),
    /* output   [07:00]     */.rd_data   (rd_data   ),
/*输出到E2PROM*/
    /* output               */.scl       (scl       ),
    /* output               */.i2c_sda_oe(sda_out_en),
    /* output               */.i2c_sda_o (sda_out   ),
    /* output               */.i2c_sda_i (sda_in    )
);

assign sda = sda_out_en?sda_out:1'bz;
assign sda_in  = sda;
    
data_driver u_data_driver(
    /* input                */.clk      (clk      ),
    /* input                */.rst_n    (rst_n    ),
/* 输入信号 */
    /* input                */.aht10_vld(aht10_vld),
    /* input    [19:00]     */.humi     (humi     ),
    /* input    [19:00]     */.temp     (temp     ),
/* 串口 */
    /* input                */.busy     (busy     ),
    /* output   [07:00]     */.dout_data (dout_data ),
    /* output               */.dout_vld  (dout_vld  ) 
);

endmodule

不输出的时候给予SDA高阻态即可,这样就能接收E2PROM的信息了;sda_in作为sda的输入端则要一直恒等于sda以来接收讯息。

在这里例化Data_driver模块的目的是为了使其与aht10_rwctrl模块之间的数据传输更加方便。

4. data_driver模块

首先这个模块会把数据传递给tx模块,所以用一个fifo来缓存待发送数据会好一点。

    rdfifo	rdfifo_inst (
	.aclr       (~rst_n     ),
	.data       (rd_data    ),
	.clock      (clk        ),
	.rdreq      (rd_rdreq   ),
	.wrreq      (rd_wrreq   ),
	.q          (rd_q       ),
	.empty      (rd_empty   ),
	.usedw      (rd_usedw   ),
	.full       (rd_full    )
	);

    assign rd_data = data;
    assign rd_wrreq = ~rd_full&&start;//这个使用将E2PROM中读出来的数据存入
    assign rd_rdreq = ~busy && ~rd_empty;//非满,TX不忙
    
    assign dout_vld = rd_rdreq;
    assign dout_data = rd_q;

这里注意到了有一个start信号,这里的start信号与读写控制模块的aht10_vld有关

//start
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        start <= 1'b0;
    end 
    else if (aht10_vld) begin
        start <= 1'b1;
    end
    else if (end_byte) begin
        start <= 1'b0;
    end
end

当aht10_vld电平拉高,代表着读写控制模块数据处理完成,此时再对数据进行二次处理。

end_byte是这里的字节计数器,它的存在仅仅是用于组合逻辑,所以根据时钟沿进行技术就行了。

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        cnt_byte <= 1'b0;
    end 
    else if (add_byte) begin
        if (end_byte) begin
            cnt_byte <= 1'b0;
        end else begin
            cnt_byte <= cnt_byte + 1'b1;
        end
    end
    else begin
        cnt_byte <= cnt_byte;
    end
end
assign add_byte = start;
assign end_byte = add_byte && cnt_byte == 24;

接下来是数据处理:

always @(*) begin 
    if(start)begin
        case(cnt_byte)  
            0 :	data = 8'b01010100;  //"t"
            1 :	data = 8'b01100101;  //"e"
            2 :	data = 8'b01101101;  //"m"
            3 :	data = 8'b01110000;  //"p"
            4 :	data = 8'b00111010;  //":"
            5 :	data = temp_1+10'd48;  //"x"
            6 :	data = temp_2+10'd48;  //"x"
            7 :	data = 8'b00101110;  //"."
            8 :	data = temp_3+10'd48;  //"x"
            9 :	data = temp_4+10'd48;  //"x"
            10 :data = 8'b01100000;  //"°"
            11 :data = 8'b01000011;  //"C"
            12 :data = 8'b00111011;  //";"
            13 :data = 8'b01001000;  //"h"
            14 :data = 8'b01110101;  //"u"
            15 :data = 8'b01101101;  //"m"
            16 :data = 8'b01101001;  //"i"
            17 :data = 8'b00111011;  //":"
            18 :data = humi_1+10'd48;  //"x"
            19 :data = humi_2+10'd48;  //"x"
            20 :data = 8'b00101110;  //"."
            21 :data = humi_3+10'd48;  //"x"
            22 :data = humi_4+10'd48;  //"x"
            23 :data = humi_5+10'd48;  //"x"
            24 :data = 8'b00100101;  //"%"
            default : data = 8'b01011000;//"X"
        endcase
    end
end

assign temp_1 = (temp/1000)-5;//十位
assign temp_2 = temp%1000/100;//个位
assign temp_3 = temp%100/10;//小数点后一位
assign temp_4 = temp%10;//小数点后两位

assign humi_1 = humi/10000;//十位
assign humi_2 = humi%10000/1000;//个位
assign humi_3 = humi%1000/100;//小数点后一位
assign humi_4 = humi%100/10;//小数点后两位
assign humi_5 = humi%10;//小数点后三位

比较简单,请结合效果图进行理解:

image-20220802195446205

5. uart_tx模块

略。

五、仿真&上板

1. 仿真:

I2C:

image-20220802200736907

这里可以看到SDA上的一个整体流程:

  1. 上电后等待40ms

  2. 发送 读取状态命令0X71然后 接收状态字

  3. 如果状态字校验位bit[3]!=1,则发送起始位+写控制字 + 初始化命令0XE1(0x08,0x00)+停止位,否则跳过此步;

    校准位检验在上电时检查即可,后面便可以不再检查。

  4. 发送起始位+ 写控制字 + 触发测量命令0XE1(0X33,0X00)+停止位

  5. 等待80ms;

  6. 发送起始位+ 读控制字,然后等待接收状态数据以及温湿度数据,接收完6字节后发送停止位

image-20220802200859679

这根蓝线是等待的40ms。

image-20220802200933766

中间这里有一段时间时钟持续高电平,这里是发送触发测量3字节后等待的80ms。

数据一次处理:

image-20220802201145134

这里可以看到数据处理的一个流程,这里全是Z是因为仿真使用的I2C从机只能反馈高阻态。

数据二次处理:

image-20220802201316936

可以看到这里的计数器正在工作,但因为传递进来的全是高阻态所以不太好看。

总体图:

image-20220802201423409

2. 上板测试:

Signal Tap:

image-20220801160606762

image-20220801191229550

这里分两次抓取了读操作的11个字节进行观察,可以看到SDA成功发送了命令接收到了应答信号,也成功接收到了数据并发送了应答信号。这里与仿真结果一致。

串口调试助手:

image-20220802195814814

六、总结

如果理解了I2C协议的本质,那么这次实验不过是I2C读取E2PROM的一次换皮工程而已。

末、参考文献

AHT10技术手册

FPGA基础协议二:I2C读写E²PROM

猜你喜欢

转载自blog.csdn.net/ChenJ_1012/article/details/126189574