本文章主要目的是介绍一种通讯协议——I²C。顺便一说,葡挞真香

I²C时序初步认识

在硬件上,I²C总线是由时钟总线SCL(Serial Clock Line)和数据总线SDA(Serial Data Line)两条线构成,连接到总线上的所有器件的SCL都连在一起,所有SDA都连在一起。I²C 总线是开漏引脚并联的结构,因此我们外部要添加上拉电阻。对于开漏电路外部加上拉电阻,就组成了线“与”的关系。总线上线“与”的关系就是说,所有接入的器件保持高电平,这条线才是高电平,而任何一个器件输出一个低电平,那这条线就会保持低电平,因此可以做到任何一个器件都可以拉低电平,也就是任何一个器件都可以作为主机。

I²C 总线的上拉电阻

绝大多数情况下,我们使用单片机作为主机,而总线上挂载的多个从机每一个都有自己唯一的地址,在信息传输的过程中,通过这唯一的地址就可以正常识别到属于自己的信息。

时序流程图

从上图中可以看到,I²C时序流程分为起始信号、数据传输部分、停止信号,数据传输部分不同与UART,一次可以传输很多个字节,而每个字节后面跟了一位应答位ACK(acknowledge),有点类似于UART的停止位。

下面我们详细讲讲这三个部分吧。

  1. 起始信号:I²C通信的起始信号的定义是SCL位高电平器件SDA由高电平向低电平变化产生一个下降沿,表示起始信号。
  2. 数据传输:不同于UART的低位在前,高位在后,I²C通信是高位在前,低位在后。I²C没有固定波特率,但是有时序的要求,当SCL 在低电平的时候,SDA 允许变化,也就是说,发送方必须先保持 SCL 是低电平,才可以改变数据线 SDA,输出要发送的当前数据的一位;而当 SCL 在高电平的时候,SDA 绝对不可以变化,因为这个时候,接收方要来读取当前 SDA 的电平信号是 0 还是 1,因此要保证 SDA 的稳定。8 位数据位后边跟着的是一位应答位。
  3. 停止信号:SCL 为高电平期间,SDA 由低电平向高电平变化产生一个上升沿,表示结束信号。

I²C寻址模式

I²C 通信的起始信号(Start)后,首先要发送一个从机的地址,这个地址一共有7位,紧跟着的第8位是数据方向位(R/W),“0”表示接下来要发送数据(写),“1”表示接下来是请求数据(读)。当我们发送完了这 7 位地址和 1 位方向后,如果发送的这个地址确实存在,那么这个地址的器件应该回应一个 ACK(拉低 SDA 即输出“0”),如果不存在,就回应 NO ACK(SDA将保持高电平即“1”)。

普中51开发板上有个EEPROM(24C02),让我们写一个程序访问一下我们板子上的EEPROM地址,另外再写一个不存在的地址,看看他们是否都能返回一个ACK。

EEPROM模块

板子上EEPROM的型号是24C02,根据数据手册,24C02的 7 位地址中,其中高 4 位是固定的 0b1010,而低 3 位的地址取决于具体电路的设计,由芯片上的 A2、A1、A0 这 3 个引脚的实际电平决定,那这板子上的地址也就是0b1010000,也就是0x50。

摘自24C02数据手册

我们用 I²C 的协议来寻址 0x50,另外再寻址一个不存在的地址 0x62,寻址完毕后,把返回的 ACK 显示到我们的 1602 液晶上。

代码实现

LCD1602.c

// 基于普中51开发板——LCD1601驱动
#include <REG52.H>

#define LCD1602_DB P0

sbit LCD1602_RS = P2 ^ 6;
sbit LCD1602_RW = P2 ^ 5;
sbit LCD1602_EN = P2 ^ 7;

/*等待液晶准备好*/
void lcdWaitReady()
{
    unsigned char sta;

    LCD1602_DB = 0xFF;
    LCD1602_RS = 0;
    LCD1602_RW = 1;
    do
    {
        LCD1602_EN = 1;
        sta = LCD1602_DB; // 读取状态字
        LCD1602_EN = 0;
    } while (sta & 0x80); // bit_7为1时表示液晶正忙,重复检测直到其等于1为止
}
/*向LCD1602液晶写入一字节命令,cmd为待写入命令值*/
void lcdWriteCmd(unsigned char cmd)
{
    lcdWaitReady();
    LCD1602_RS = 0;
    LCD1602_RW = 0;
    LCD1602_DB = cmd;
    LCD1602_EN = 1;
    LCD1602_EN = 0;
}
/*向LCD1602液晶写入一字节命令,dat为待写入命令*/
void lcdWriteDta(unsigned char dat)
{
    lcdWaitReady();
    LCD1602_RS = 1;
    LCD1602_RW = 0;
    LCD1602_DB = dat;
    LCD1602_EN = 1;
    LCD1602_EN = 0;
}
/*设置显示RAM起始地址,亦即光标位置,(x,y)为对应屏幕上的字符坐标*/
void lcdSetCursor(unsigned char x, unsigned char y)
{
    unsigned char addr;

    if (y == 0)
    {                    // 由输入的屏幕坐标计算显示RAM的地址
        addr = 0x00 + x; // 第一行字符地址从0x00起始
    }
    else
    {
        addr = 0x40 + x; // 第二行字符地址从0x40起始
    }
    lcdWriteCmd(addr | 0x80); // 设置RAM地址
}
/*在液晶上显示字符串,(x,y)为对应屏幕上的起始坐标,str为字符串指针*/
void lcdShowStr(unsigned char x, unsigned char y, unsigned char *str)
{
    lcdSetCursor(x, y); // 设置起始地址
    while (*str != '\0')
    {                        // 连续写入字符串数据,直到检测到结束符
        lcdWriteDta(*str++); // 先取str指向的数据,然后str自加1
    }
}
/*区域清除,清除从(x,y)坐标起始的len个字符*/
void lcdAreaClear(unsigned char x, unsigned char y, unsigned char len)
{
    lcdSetCursor(x, y);
    while (len--)
    {
        lcdWriteDta(' ');
    }
}
/*清屏指令*/
void lcdFullClear()
{
    lcdWriteCmd(0x01);
}
/*初始化1602液晶*/
void initLCD1602()
{
    lcdWriteCmd(0x38); // 16*2显示,5*7点阵,8位数据接口
    lcdWriteCmd(0x0C); // 显示器开,光标关闭
    lcdWriteCmd(0x06); // 文字不动,地址自动+1
    lcdWriteCmd(0x01); // 清屏
}

main.c

#include <REG52.H>
#include <INTRINS.H>
// 一个_nop_()的时间就是一个机器周期
#define I2CDelay() \
    {              \
        _nop_();   \
        _nop_();   \
        _nop_();   \
        _nop_();   \
    }
sbit I2C_SCL = P2 ^ 1;
sbit I2C_SDA = P2 ^ 0;

bit I2CAddressing(unsigned char addr);
extern void initLCD1602();
extern void lcdShowStr(unsigned char x, unsigned char y, unsigned char *str);

void main()
{
    bit ack;
    unsigned char str[10];

    initLCD1602(); // 初始化液晶

    ack = I2CAddressing(0x50); // 查询地址位0x50的器件
    str[0] = '5';              // 将地址和应答位转换为字符串
    str[1] = '0';
    str[2] = ':';
    str[3] = (unsigned char)ack + '0';
    str[4] = '\0';
    lcdShowStr(0, 0, str); // 显示到液晶上
    // 同理
    ack = I2CAddressing(0x62);
    str[0] = '6';
    str[1] = '2';
    str[2] = ':';
    str[3] = (unsigned char)ack + '0';
    str[4] = '\0';
    lcdShowStr(8, 0, str);

    while (1)
        ;
}
/*产生总线起始信号*/
void I2CStart()
{
    I2C_SDA = 1; // 首先确保SCL和SDA都是高电平
    I2C_SCL = 1;
    I2CDelay();
    I2C_SDA = 0; // 先拉低SDA
    I2CDelay();
    I2C_SCL = 0; // 再拉低SCL
}
/*产生总线停止信号*/
void I2CStop()
{
    I2C_SCL = 0; // 首先确保SCL和SDA都是低电平
    I2C_SDA = 0;
    I2CDelay();
    I2C_SCL = 1; // 先拉高SCL
    I2CDelay();
    I2C_SDA = 1; // 再拉低SDA
    I2CDelay();
}
/*IIC总线写操作,dat为待写入字节,返回值为从机应答位ACK的值*/
bit I2CWrite(unsigned char dat)
{
    bit ack;            // 用于暂存应答位
    unsigned char mask; // 用于探测字节内某一位值得掩码变量

    for (mask = 0x80; mask != 0; mask >>= 1) // 从高位到低位依次进行
    {
        if ((mask & dat) == 0)
        {
            I2C_SDA = 0;
        }
        else
        {
            I2C_SDA = 1;
        }
        I2CDelay();
        I2C_SCL = 1; // 拉高SCL
        I2CDelay();
        I2C_SCL = 0; // 再拉低SCL,完成一个位周期
    }
    I2C_SDA = 1; // 8位数据发送完毕后,主机释放SDA,以检测从机应答值
    I2CDelay();
    I2C_SCL = 1;   // 拉高SCL
    ack = I2C_SDA; // 读取此时的SDA值,即为从机的应答值
    I2CDelay();
    I2C_SCL = 0; // 再拉低SCL完成应答位,并保持住总线

    return ack; // 返回从机应答值
}
/*IIC寻址函数,即检查地址为addr的器件是否存在,返回值为器件应答值*/
bit I2CAddressing(unsigned char addr)
{
    bit ack;

    I2CStart();                // 产生起始位
    ack = I2CWrite(addr << 1); // 别忘了加上读写位(R/W)

    I2CStop(); // 产生结束位

    return ack;
}

I²C通信协议规定了三种标准的传输速率:标准模式(Standard-mode)下的100kbps(千比特每秒)、快速模式(Fast-mode)下的400kbps和高速模式(High-speed mode)下的3.4Mbps(兆比特每秒)。鉴于所有I²C设备均保证支持标准模式,而不必然支持其他更高速率,因此,在设计兼容性强的I²C通信程序时,我们通常采用100kbps的速率。这要求实际程序生成的时序参数不得超过标准模式的要求,明确来说,即时钟线(SCL)的高电平和低电平持续时间均不得少于5微秒(us)。为了满足此速率要求,我们在时序函数中嵌入了一个总线延时函数I2CDelay(),该函数由四条NOP(无操作)指令构成,并已通过宏定义define在文件头部定义。结合改变SCL电平的操作至少占用一个CPU周期,我们能够确保不超过100kbps的速率限制。若日后需要提升通信速度,只需简单地减少该总线延时函数中的延时周期即可。

EEPROM

EEPROM是“Electrically Erasable Programmable Read-Only Memory”的缩写,中文通常称为“电可擦除可编程只读存储器”。简单来说,EEPROM是一种可以在不拆除电路的情况下通过电信号擦除和重新编程的存储芯片。

EEPROM可以让你保存数据,即使在断电之后数据也不会丢失。这就像是你有一个小型的记事本,即使你不在,记事本上的内容也会一直保留。你可以随时回来擦除旧的内容或者写上新的内容,而且这个过程可以重复很多次。

24C02是一种常见的EEPROM芯片型号,它可以存储2Kb(也就是2048位)的数据。不过,我们通常按字节来计算,所以24C02实际上可以存储256字节的数据,因为每个字节包含8位。

24C02芯片通过I2C总线与其他设备通信。I2C是一种串行总线,它只需要两根线(一个是数据线SDA,一个是时钟线SCL)就可以让多个芯片相互通信。这意味着你可以通过这两根线来发送指令给24C02,告诉它你想读取或者写入的数据。

在实际应用中,24C02可以用来保存各种小量的重要数据,比如设备的配置信息、校准参数或者用户的偏好设置等。因为它的存储容量有限,所以它不适合存储大量的数据,比如音乐或者照片。但是对于那些只需要保存少量数据,并且需要在断电后仍然保留这些数据的场合,24C02是一个非常理想的选择。

EEPROM 单字节读写操作时序

  1. 字节写入

    字节写入

    确认轮询

    Figure 2

  2. 随机读取

    随机读取

    Figure 5

下面写一个程序,读取EEPROM的0x02这个地址上的一个数据,不管这个数据之前是多少,我们都将读出来的数据加 1,再写到 EEPROM 的 0x02 这个地址上。

程序

LCD1602.c

略,同上

I2C.c

#include <REG52.H>
#include <INTRINS.H>

#define I2CDelay() \
    {              \
        _nop_();   \
        _nop_();   \
        _nop_();   \
        _nop_();   \
    }
sbit I2C_SCL = P2 ^ 1; // 参考电路原理图
sbit I2C_SDA = P2 ^ 0;

/*产生总线起始信号*/
void I2CStart()
{
    I2C_SDA = 1; // 首先确保SCL和SDA都是高电平
    I2C_SCL = 1;
    I2CDelay();
    I2C_SDA = 0; // 先拉低SDA
    I2CDelay();
    I2C_SCL = 0; // 再拉低SCL
}
/*产生总线停止信号*/
void I2CStop()
{
    I2C_SCL = 0; // 首先确保SCL和SDA都是低电平
    I2C_SDA = 0;
    I2CDelay();
    I2C_SCL = 1; // 先拉高SCL
    I2CDelay();
    I2C_SDA = 1; // 再拉低SDA
    I2CDelay();
}
/*IIC总线写操作,dat为待写入字节,返回值为从机应答位ACK的值*/
bit I2CWrite(unsigned char dat)
{
    bit ack;            // 用于暂存应答位
    unsigned char mask; // 用于探测字节内某一位值得掩码变量

    for (mask = 0x80; mask != 0; mask >>= 1) // 从高位到低位依次进行
    {
        if ((mask & dat) == 0)
        {
            I2C_SDA = 0;
        }
        else
        {
            I2C_SDA = 1;
        }
        I2CDelay();
        I2C_SCL = 1; // 拉高SCL
        I2CDelay();
        I2C_SCL = 0; // 再拉低SCL,完成一个位周期
    }
    I2C_SDA = 1; // 8位数据发送完毕后,主机释放SDA,以检测从机应答值
    I2CDelay();
    I2C_SCL = 1;   // 拉高SCL
    ack = I2C_SDA; // 读取此时的SDA值,即为从机的应答值
    I2CDelay();
    I2C_SCL = 0; // 再拉低SCL完成应答位,并保持住总线

    return (~ack); // 返回从机应答值取反
}
/*总线读操作,并发送非应答信号,返回值为读到的字节*/
unsigned char I2CReadNAK()
{
    unsigned char mask;
    unsigned char dat;

    I2C_SDA = 1; // 首先确保主机释放SDA
    for (mask = 0x80; mask != 0x00; mask >>= 1)
    { // 从高位到低位依次进行
        I2CDelay();
        I2C_SCL = 1; // 拉高SCL
        if (I2C_SDA == 0)
        {                 // 读取SDA的值
            dat &= ~mask; // 为0时,dat中对应位请0
        }
        else
        {
            dat |= mask; // 为1时,dat中对应位置1
        }
        I2CDelay();
        I2C_SCL = 0; // 再拉低SCL,以便从机发出下一位
    }
    I2C_SDA = 1; // 8位数据发送完后,拉高SDA,发送非应答信号(no ack)
    I2CDelay();
    I2C_SCL = 1; // 拉高SCL
    I2CDelay();
    I2C_SCL = 0; // 再拉低SCL完成非应答位,并保持住总线

    return dat;
}
/*总线读操作,并发送应答信号,返回值为读到的字节*/
unsigned char I2CReadACK()
{
    unsigned char mask;
    unsigned char dat;

    I2C_SDA = 1; // 首先确保主机释放SDA
    for (mask = 0x80; mask != 0x00; mask >>= 1)
    { // 从高位到低位依次进行
        I2CDelay();
        I2C_SCL = 1; // 拉高SCL
        if (I2C_SDA == 0)
        {                 // 读取SDA的值
            dat &= ~mask; // 为0时,dat中对应位请0
        }
        else
        {
            dat |= mask; // 为1时,dat中对应位置1
        }
        I2CDelay();
        I2C_SCL = 0; // 再拉低SCL,以便从机发出下一位
    }
    I2C_SDA = 0; // 8位数据发送完后,拉低SDA,发送应答信号(ack)
    I2CDelay();
    I2C_SCL = 1; // 拉高SCL
    I2CDelay();
    I2C_SCL = 0; // 再拉低SCL完成非应答位,并保持住总线

    return dat;
}

main.c

#include <REG52.H>

extern void initLCD1602();
extern void lcdShowStr(unsigned char x, unsigned char y, unsigned char *str);
extern void I2CStop();
extern void I2CStart();
extern unsigned char I2CReadNAK();
extern bit I2CWrite(unsigned char dat);
unsigned char E2ReadByte(unsigned char addr);
void E2WriteByte(unsigned char addr, unsigned char dat);

void main()
{
    unsigned char dat;
    unsigned char str[10];

    initLCD1602();              // 初始化液晶
    dat = E2ReadByte(0x02);     // 读取指定地址上的一个字节
    str[0] = (dat / 100) + '0'; // 转换为十进制字符串格式
    str[1] = (dat / 10 % 10) + '0';
    str[2] = (dat % 10) + '0';
    str[3] = '\0';
    lcdShowStr(0, 0, str);  // 显示再液晶上
    dat++;                  // 将其数值加1
    E2WriteByte(0x02, dat); // 再写回对应的地址上

    while (1)
        ;
}
/*读取EEPROM中的一个字节,addr为字节地址*/
unsigned char E2ReadByte(unsigned char addr)
{
    unsigned char dat;

    I2CStart();
    I2CWrite(0x50 << 1);
    I2CWrite(addr);
    I2CStart();
    I2CWrite((0x50 << 1) | 0x01);
    dat = I2CReadNAK();
    I2CStop();

    return dat;
}
/*向EEPROM中写入一个字节,addr为字节地址*/
void E2WriteByte(unsigned char addr, unsigned char dat)
{
    I2CStart();
    I2CWrite(0x50 << 1);
    I2CWrite(addr);
    I2CWrite(dat);
    I2CStop();
}

EEPROM多字节读取时序

确认轮询

顺序读取

Figure 6

程序

LCD1602.c

略,同上

I2C.c

略,同上

main.c

#include <REG52.H>

extern void initLCD1602();
extern void lcdShowStr(unsigned char x, unsigned char y, unsigned char *str);
extern void I2CStop();
extern void I2CStart();
extern unsigned char I2CReadNAK();
extern unsigned char I2CReadACK();
extern bit I2CWrite(unsigned char dat);
void E2Read(unsigned char *buf, unsigned char addr, unsigned char len);
void E2Write(unsigned char *buf, unsigned char addr, unsigned char len);
void memToStr(unsigned char *str, unsigned char *src, unsigned char len);

void main()
{
    unsigned char i;
    unsigned char buf[5];
    unsigned char str[20];

    initLCD1602();
    E2Read(buf, 0x90, sizeof(buf));   // 从EEPROM中读取一段数据
    memToStr(str, buf, sizeof(buf));  // 转换为十六进制字符串
    lcdShowStr(0, 0, str);            // 显示到液晶上
    for (i = 0; i < sizeof(buf); i++) // 数据依次+1、+2、+3
    {
        buf[i] = buf[i] + 1 + i;
    }
    E2Write(buf, 0x90, sizeof(buf)); // 再写回EEPROM中

    while (1)
        ;
}
/*将一段内存数据转换为十六进制格式的字符串,
str为字符串指针,src为源数据地址,len为数据长度*/
void memToStr(unsigned char *str, unsigned char *src, unsigned char len)
{
    unsigned char tmp;

    while (len--)
    {
        tmp = *src >> 4;
        if (tmp <= 9)
        {
            *str++ = tmp + '0';
        }
        else
        {
            *str++ = tmp + 'A' - 10;
        }
        tmp = *src & 0x0F;
        if (tmp <= 9)
        {
            *str++ = tmp + '0';
        }
        else
        {
            *str++ = tmp + 'A' - 10;
        }
        *str++ = ' ';
        src++;
    }
    *str = '\0';
}
/*E2读取函数,buf为数据接收指针,addr为E2中的起始地址,len为读取长度*/
void E2Read(unsigned char *buf, unsigned char addr, unsigned char len)
{
    do // 确认轮询
    {
        I2CStart();
        if (I2CWrite(0x50 << 1))
        {
            break;
        }
        I2CStop();
    } while (1);
    I2CWrite(addr);
    I2CStart();
    I2CWrite((0x50 << 1) | 0x01);
    while (len > 1)
    {
        *buf++ = I2CReadACK();
        len--;
    }
    *buf = I2CReadNAK();
    I2CStop();
}
/*E2写入函数,buf为源数据指针,addr为E2中的起始地址,len为写入长度*/
void E2Write(unsigned char *buf, unsigned char addr, unsigned char len)
{
    while (len--)
    {
        do // 确认轮询
        {
            I2CStart();
            if (I2CWrite(0x50 << 1))
            {
                break;
            }
            I2CStop();
        } while (1);
        I2CWrite(addr++);
        I2CWrite(*buf++);
        I2CStop();
    }
}

EEPROM的页写入

页写入

确认轮询

程序

LCD1602.c

略,同上

I2C.c

略,同上

EEPROM.c

#include <REG52.H>

extern void I2CStart();
extern void I2CStop();
extern unsigned char I2CReadACK();
extern unsigned char I2CReadNAK();
extern bit I2CWrite(unsigned char dat);

/*E2读取函数,buf为数据接收指针,addr为E2中的起始地址,len为读取长度*/
void E2Read(unsigned char *buf, unsigned char addr, unsigned char len)
{
    do // 确认轮询
    {
        I2CStart();
        if (I2CWrite(0x50 << 1))
        {
            break;
        }
        I2CStop();
    } while (1);
    I2CWrite(addr);
    I2CStart();
    I2CWrite((0x50 << 1) | 0x01);
    while (len > 1)
    {
        *buf++ = I2CReadACK();
        len--;
    }
    *buf = I2CReadNAK();
    I2CStop();
}
/*E2写入函数,buf为源数据指针,addr为E2中的起始地址,len为写入长度*/
void E2Write(unsigned char *buf, unsigned char addr, unsigned char len)
{
    while (len > 0)
    {
        do
        { // 确认轮询
            I2CStart();
            if (I2CWrite(0x50 << 1))
            {
                break;
            }
            I2CStop();
        } while (1);
        I2CWrite(addr);
        while (len--)
        {
            I2CWrite(*buf++);
            addr++;
            /*检查地址是否到达边界,24C02每页8字节,
            所以检测低三位是否为0即可,到达页边界时,
            跳出循环,结束本次写操作*/
            if ((addr & 0x07) == 0)
            {
                break;
            }
        }
        I2CStop();
    }
}

main.c

#include <REG52.H>

extern void initLCD1602();
extern void lcdShowStr(unsigned char x, unsigned char y, unsigned char *str);
extern void E2Read(unsigned char *buf, unsigned char addr, unsigned char len);
extern void E2Write(unsigned char *buf, unsigned char addr, unsigned char len);
void memToStr(unsigned char *str, unsigned char *src, unsigned char len);

void main()
{
    unsigned char i;
    unsigned char buf[5];
    unsigned char str[20];

    initLCD1602();
    E2Read(buf, 0x8E, sizeof(buf));   // 从EEPROM中读取一段数据
    memToStr(str, buf, sizeof(buf));  // 转换为十六进制字符串
    lcdShowStr(0, 0, str);            // 显示到液晶上
    for (i = 0; i < sizeof(buf); i++) // 数据依次+1、+2、+3
    {
        buf[i] = buf[i] + 1 + i;
    }
    E2Write(buf, 0x8E, sizeof(buf)); // 再写回EEPROM中

    while (1)
        ;
}
/*将一段内存数据转换为十六进制格式的字符串,
str为字符串指针,src为源数据地址,len为数据长度*/
void memToStr(unsigned char *str, unsigned char *src, unsigned char len)
{
    unsigned char tmp;

    while (len--)
    {
        tmp = *src >> 4;
        if (tmp <= 9)
        {
            *str++ = tmp + '0';
        }
        else
        {
            *str++ = tmp + 'A' - 10;
        }
        tmp = *src & 0x0F;
        if (tmp <= 9)
        {
            *str++ = tmp + '0';
        }
        else
        {
            *str++ = tmp + 'A' - 10;
        }
        *str++ = ' ';
        src++;
    }
    *str = '\0';
}
最后修改:2024 年 04 月 03 日
如果觉得我的文章对你有用,请随意赞赏