文章目录
-
- 前言
- 一、串口格式化函数移植
- 二、i2c协议的概念与特点
- 三、IIC的时序
- 四、EEPROM
-
- [4.1 EEPROM概念](#4.1 EEPROM概念)
- [4.2 AT24C02](#4.2 AT24C02)
- [4.3 AT24C02的I2C通信](#4.3 AT24C02的I2C通信)
- 五、IMX6ULL上的I2C
- 六、I2C程序C语言编写
-
- [6.1 程序流程图](#6.1 程序流程图)
- [6.2 I2C初始化](#6.2 I2C初始化)
- [6.3 I2C写时序](#6.3 I2C写时序)
- [6.4 I2C读时序](#6.4 I2C读时序)

前言
在上篇文章【ARM 裸机开发 (IMX6ULL-mini)】EPIT/GPT 定时器、 UART 串口驱动中,介绍了定时器与串口,在最后编写了一个串口发送字符的函数。但是要实现字符串、数字等各种数据格式的发送,还是有一定工作量的。这时候肯定会想到C语言中的printf与scanf函数,这两个函数不就可以发送各种数据格式的数据吗?因此,在这篇文章中,我将介绍如何在ARM芯片代码中移植串口格式化函数 ,除此之外,这篇文章还将介绍i2c协议 的概念与IMX6ULL开发板 和EEPROM(电可擦除只读存储器)、LM75A温度传感器 之间的I2C通信。
一、串口格式化函数移植
移植的基本原理很简单,就是把printf和scanf函数映射到UART1上。stdio 里面的文件其实是从 uboot 里面移植过来的。uboot在后面的文章中也会讲到。

串口格式化函数移植主要有以下步骤:
-
由于增加了新的源码和头文件,所以Makefile也需要修改。
- 增加源程序路径和头文件路径
因为我们将串口格式化函数放在了一个新的文件夹中,所以还得添加路径,为编译器提供方向。

- 添加链接目录
因为串口格式化函数的编译需要用到GNU编译工具中的一些库,所以需要将其链接起来。

- 增加源程序路径和头文件路径
-
需要在uart.c中添加一个函数:void raise(int n);函数体为空即可,否则会报错
因为
printf函数使用出现错误时需要raise这个函数返回错误信息,但是我移植的库中并没有,因此需要在uart.c中写一个空raise来骗一下编译器。
完成上述操作后,我们就可以通过串口格式化函数来进行串口数据发送与接收了。
二、i2c协议的概念与特点
IIC(Inter-Integrated Circuit)又称I2C,是是IICBus简称,所以中文应该叫集成电路总线。

上面是IIC的使用场景,在IIC总线上的设备都有两根信号线,一根是数据线SDA,另一根是时钟线SCL。这两个信号线都是双向的。IIC总线在某一时刻,总线只允许有一个设备处于发送状态,所发生的数据被总线上所有的设备所接收。
所以IIC协议是一个通信方式为半双工、串行的通信协议。

因为 IIC总线规定,设备在空闲时,两根总线都处于高电平状态。所以数据线SDA和时钟线SCL都要外接上拉电阻。也就是使其具有线与特性,将引脚电平的控制交给对方。
除此之外,IIC通信还遵循高位先行的特点。
三、IIC的时序
IIC总线进行数据传送时,时钟线(SCL)上的信号为高电平期间,数据线(SDA)上的数据必须保持稳定。只有在时钟线(SCL)上的信号为低电平期间,数据线(SCL)上的高电平或低电平状态才允许变化。同时,SCL信号由数据启动发送的设备提供。输出到数据线(SDA)上的每个字节必须是8位。数据传送时,先传送最高位(MSB),后传送最低位(LSB)。以传输0xAC为例,时序图如下所示:

起始信号 :SCL时钟信号线为高电平时,SDA数据信号线由高变低出现下降沿。
数据传输 :
-
SCL时钟信号线为高电平时,SDA数据信号线电平保持稳定,此时接收方采样数据
-
SCL时钟信号线为低电平时,SDA数据信号线电平可以改变,此时发送方发送数据
应答信号 :第9个时钟周期,接收方需要向发送方回复应答信号(ACK低电平NACK高电平,至于应答信号是ACK还是NACK在后面解释IIC相关寄存器会提到)。
结束信号:SCL时钟信号线为高电平时,SDA数据信号线由低变高出现上升沿。
I2C协议规定,在起始信号后的第一个字节 是发送设备地址(7位)+数据流向位(1位) ,数据流向位即是主机与从机之间数据流向的标志。

数据流向位0:主机发送给从机1;从机发送给主机。
最大可挂从机数:27-1 = 127
由于0 号地址被协议预留为广播地址,不能给普通从设备,所以最大可挂从机数为127。
四、EEPROM
4.1 EEPROM概念
Electrically Erasable Programmable Read-Only Memory,电可擦除可编程只读存储器。
非易失存储器:断电数据不会丢,I2C 总线最经典的从设备(AT24Cxx 系列)。这篇文章中我们将使用的芯片型号为AT24C02。
I2C通信的常见速率:100KHZ、400KHZ、1.xM、3.xM
4.2 AT24C02
AT24C02的芯片操作手册中写到:
AT24Cxx 系列主要为下面三个型号:

AT24C02的特点 :

AT2402芯片引脚示意图:


4.3 AT24C02的I2C通信
在前面提到了I2C规定,在起始信号后的第一个字节发送的是设备地址与数据流向位,那么我们怎么知道这个地址呢,AT24C02的芯片操作手册
给出这样一个图:

我使用的AT24C02直接是一个模块,A0-A3默认接地,因此,这个AT24C02 EEPROM的设备地址就为0x50。
有了从机地址,我们还需要知道IMX6ULL与AT24C02的具体是怎么通信的。AT24C02的芯片操作手册也给出了时序图:
写时序 :

读时序 :

根据手册给的时序图,我们可以梳理出:
I2C写时序 :
-
主机发送起始信号+设备地址+写位, 主机等待从机回复ACK应答
-
主机向从机发送寄存器地址,等待ACK应答
-
主机向从机发送数据,等待从机ACK应答
-
主机发送停止信号
I2C读时序 :
-
主机发送起始信号+设备地址+写位,等待从机ACK应答
-
主机发送寄存器地址,等待从机ACK应答
-
主机重发起始信号+设备地址+读位,等待从机ACK应答
-
主机接受从机数据,主机回复ACK应答
-
当主机接受完最后一个字节数据,向从机回复NO ACK
-
主机发送停止信号
五、IMX6ULL上的I2C
5.1 IMX6ULL的I2C原理框图

5.2 相关寄存器
在IMX6ULL的参考手册中,我们可以得到这五个寄存器:

IADR
对于第一个寄存器IADR,这是IMX6ULL作为从机时才需要配置,但大多数情况下,IMX6ULL都是作为主机使用,因此,我们暂时不关注它了。
IFDR
I2C Frequency Divider Register,分频寄存器。

IC :I2C 时钟预分频编码,用来选择分频系数 ,对IPG_CLK_ROOT(I2C 模块输入根时钟)分频,产生 I2C 的 SCL 串行位时钟。
我们若要向这个寄存器中写入值来分频,就需要使用下面这个I2C_IFDR 分频表 :

I2CR
控制寄存器。


I2SR
状态寄存器。

I2DR
数据寄存器:存放数据。


从IMX6ULL原理图上可以知道,I2C1引出的引脚为42(SDA)、43(SCL)。
六、I2C程序C语言编写
6.1 程序流程图
由于I2C1所用的引脚是复用的,因此我们还需要对其进行引脚的复用和电气配置。结合上面的内容,我们可以得到这样一张流程图:

6.2 I2C初始化
由于标志位众多,我们直接将其定义为宏,提高代码的可读性:
c
// I2CR
#define IEN (7)
#define MSTA (5)
#define MTX (4)
#define TXAK (3)
#define RSTA (2)
// I2SR
#define ICF (7)
#define IBB (5)
#define IAL (4)
#define IIF (1)
c
void i2c1_init(void)
{
// 配置引脚复用功能
IOMUXC_SetPinMux(IOMUXC_UART4_TX_DATA_I2C1_SCL, 1);
IOMUXC_SetPinMux(IOMUXC_UART4_RX_DATA_I2C1_SDA, 1);
// 配置引脚电气属性
IOMUXC_SetPinConfig(IOMUXC_UART4_TX_DATA_I2C1_SCL, 0xF0B0);
IOMUXC_SetPinConfig(IOMUXC_UART4_RX_DATA_I2C1_SDA, 0xF0B0);
I2C1->I2CR &= ~(1 << 7); // I2C1失能
I2C1->IFDR = 0x15; // 640分频
I2C1->I2CR |= (1 << 7); // I2C1使能
}
- IMX6ULL 引脚是复用的,UART4_TX 可以复用成 I2C1_SCL;UART4_RX 可以复用成 I2C1_SDA。
- 开启软件输入路径(SDA 是双向,必须打开),I2C 的 SDA 需要读取外部电平,这个参数必须置 1。
- IPG_CLK_ROOT = 66MHz,分频640,I2C 标准模式(100kHz),工程小幅偏差。
6.3 I2C写时序
c
void i2c_write(I2C_Type *base, unsigned char dev_address, unsigned char reg_address, const unsigned char *data, int len)
{
// 通信前 仲裁丢失标志位清零 + 中断标记位清零
base->I2SR &= ~((1 << IIF) | (1 << IAL));
// 通信前,等待数据传输完毕
while ((base->I2SR & (1 << ICF)) == 0);
// 发送起始信号
base->I2CR |= (1 << MSTA);
// 主机模式:发送模式
base->I2CR |= (1 << MTX);
// 后续检测从机ACK信号
base->I2CR &= ~(1 << TXAK);
// 中断标记位清零
base->I2SR &= ~(1 << IIF);
// 发送设备地址 + 写位
base->I2DR = dev_address << 1; // 101 0000 0
// 等待从机ACK
while ((base->I2SR & (1 << IIF)) == 0);
// 中断标记位清零
base->I2SR &= ~(1 << IIF);
// 发送寄存器地址
base->I2DR = reg_address;
// 等待从机ACK
while ((base->I2SR & (1 << IIF)) == 0);
while (len--)
{
// 中断标记位清零
base->I2SR &= ~(1 << IIF);
// 发送数据
base->I2DR = *data++;
// 等待从机ACK
while ((base->I2SR & (1 << IIF)) == 0);
}
// 发送停止信号
base->I2CR &= ~(1 << MSTA);
// 等待总线空闲
while ((base->I2SR & (1 << IBB)) != 0)
{
delay_us(100);
}
}
- 初始化标志,等待上一次传输结束;
- 清除旧中断、仲裁丢失标志;
- 等待 I2C 模块上次字节传输完成,避免前后两次通信冲突。
- 配置为主机发送模式,准备发起起始位;
- 发送从机地址(写);
- 发送 EEPROM 内部存储地址(reg_address);
- 循环写入多字节数据。
6.4 I2C读时序
c
void i2c_read(I2C_Type *base, unsigned char dev_address, unsigned char reg_address, unsigned char *data, int len)
{
// 通信前 仲裁丢失标志位清零 + 中断标记位清零
base->I2SR &= ~((1 << IIF) | (1 << IAL));
// 通信前,等待数据传输完毕
while ((base->I2SR & (1 << ICF)) == 0);
// 发送起始信号
base->I2CR |= (1 << MSTA);
// 主机模式:发送模式
base->I2CR |= (1 << MTX);
// 后续检测从机ACK信号
base->I2CR &= ~(1 << TXAK);
// 中断标记位清零
base->I2SR &= ~(1 << IIF);
// 发送设备地址 + 写位
base->I2DR = dev_address << 1;
// 等待从机ACK
while ((base->I2SR & (1 << IIF)) == 0);
// 中断标记位清零
base->I2SR &= ~(1 << IIF);
// 发送寄存器地址
base->I2DR = reg_address;
// 等待从机ACK
while ((base->I2SR & (1 << IIF)) == 0);
// 重发起始信号
base->I2CR |= (1 << RSTA);
// 中断标记位清零
base->I2SR &= ~(1 << IIF);
// 重发设备地址 + 读位
base->I2DR = dev_address << 1 | 1;
// 等待从机回复ACK信号
while ((base->I2SR & (1 << IIF)) == 0);
// 主机模式:接收模式
base->I2CR &= ~(1 << MTX);
// 中断标记位清零
base->I2SR &= ~(1 << IIF);
// 代表只需要读取一个字节
if (len == 1)
{
// 代表收到一个字节后,后续回复NACK
base->I2CR |= (1 << TXAK);
}
// 假读
*data = base->I2DR;
// 读取len个字节数据
while (len-- != 0)
{
// 代表收到一个字节数据,并且主机回复了ACK
while ((base->I2SR & (1 << IIF)) == 0);
// 中断标记位清零
base->I2SR &= ~(1 << IIF);
// 代表数据全部接收完
if (len == 0)
{
// 发送停止信号
base->I2CR &= ~(1 << MSTA);
// ACK
base->I2CR &= ~(1 << TXAK);
// 等待总线空闲
while ((base->I2SR & (1 << IBB)) != 0)
{
delay_us(100);
}
}
else if (len == 1)
{
// NACK
base->I2CR |= (1 << TXAK);
}
*data++ = base->I2DR;
}
}
-
重复起始位 RSTA(随机读核心)
I2C 随机读流程:先要发送从机写地址 + 内部寄存器地址 ,告诉 EEPROM 要读取的存储位置;
不发送停止位,直接发送 RSTA 重复起始,再发送从机读地址,切换方向,从机开始往主机发数据。
如果不用 RSTA,先发 P 再 S,就是普通读,会丢失寄存器地址信息。
-
假读 dummy read
IMX6ULL 硬件 I2C 接收机制:
写入读地址之后,硬件就会自动接收第一个字节,存入移位寄存器,必须先读一次 I2DR 把这个预接收的字节 "取走",这一次读是 dummy 假读,不使用这个值,后面循环里才会依次读到有效数据。
很多人写 I2C 读函数踩坑,忘记 dummy read,读到的数据错位!
-
ACK / NACK 控制规则
I2C 读的时候,主机负责发 ACK/NACK:
- 还剩多个字节:主机回复
ACK(TXAK=0),通知从机继续发下一字节 - 只剩最后 1 个字节:提前设置
TXAK=1,收到最后一字节后主机回复NACK,通知从机不再继续传输 - NACK 之后主机发送停止位 P,通信结束。
- 还剩多个字节:主机回复