STM32 使用IO口模拟I2C时序
上一篇《I2C协议详解》
我们了解了I2C的操作流程,这一篇,我们就使用I2C,来对EEPROM进行操作吧。
我们做两种选择:
1.时序由IO口模拟高低电平,需要了解协议并按照协议操作相应的IO口。
2.时序由硬件自行产生,不需要人工干预;
由硬件产生的I2C时序,我们借助Stm32Cube配置实现便可,我们这一篇,抛开Stm32Cube,手撕代码,根据I2C的时序,一步步地实现I2C对EEPROM的读写吧。
我们分为几个步骤来对EEPROM进行操作:
1. 发动"看硬件原理图"技能,确定I2C连线;
2. 发动"乾坤大拷贝"技能,配置对应的IO口;
3. 发动"手撕代码"技能,写出相应时序;
1. 发动"看硬件原理图"技能,确定I2C连线;
第9页,找到EEPROM那一块,EEPROM用的是AT24C02,只有2K Bits,不是很大。AT24C02由ATMEL公司生产,其命名规则为 AT24Cxx,xx可以=02,04,32,64,128,256,512,1M 等等,xx也表示容量,单位是 Bits,注意,是位,不是字节,要换成字节要除以8,所以,AT24C02只有 256 个字节。别问我怎么知道的,AT24C02的规格书告诉我的,要记得看原理图,看规格书喔~~~


再找呀找呀找朋友,找到 SCL连到PB6,SDA连到PB9。咋找?搜索呗,搜引脚 I2C1_SCL就行了。

确定了I2C连线,SCL=PB6,SDA=PB9,接下来,我们就
2. 发动"乾坤大拷贝"技能,配置对应的IO口;
先新建两个文件,io_i2c.c/io_i2c.h,我们就在这里面写i2c时序。
并且把文件添加进工程项目里参与编译。

配置IO口,详见《STM32CubeMx 创建第一个工程》,把那段代码拷贝过来,改一下配置就行了。
void IOI2C_GpioInit(void){GPIO_InitTypeDef GPIO_InitStruct;__HAL_RCC_GPIOB_CLK_ENABLE();/*Configure GPIO pin Output Level */// PB6 = SCL/PB9 = SDAHAL_GPIO_WritePin(GPIOB, GPIO_PIN_6|GPIO_PIN_9, GPIO_PIN_SET);/*Configure GPIO pin : PC7 */GPIO_InitStruct.Pin = GPIO_PIN_6|GPIO_PIN_9;GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; // 记不记得i2c要上拉?GPIO_InitStruct.Pull = GPIO_PULLUP; // GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);}
记得在初始化时调用一下它喔:
MX_GPIO_Init();MX_DMA_Init();MX_UART4_Init();MX_TIM1_Init();MX_TIM2_Init();/* USER CODE BEGIN 2 */USR_UartInit();IOI2C_GpioInit(); // 我在这,快看这里~/* USER CODE END 2 */printf("Start System: Io I2C.\r\n");
3. 发动"手撕代码"技能,写出相应时序,I2C时序,请参照《I2C协议详解》。
看图,会有高电平持续一段时间,低电平持续一段时间的操作,这个持续一段时间,就是延时,关于延时,详细参照《STM32精准延时》。

这个持续多长时间怎么算呢?
根据I2C时钟频率,可以算出周期,再根据周期,算出持续多长时间。
For Example:
100kHz的频率,也就是1秒钟内,有100,000个时钟周期。
那1个时钟周期,也就是 1/100,000秒 = 1,000/100,000毫秒 = 1,000,000/100,000微秒,根据小学数学,算出,100kHz频率的时钟周期是 10微秒,一高一低一周期,那么,延时 = 5us。
在《STM32精准延时》篇中,我们做了个us级的精准延时,用上:
#define I2C_Delay USER_Delay1us(5)
如果要其它延时呢?
delay = 1,000,000 / freq / 2 = 500,000 / freq
算出来delay与时钟频率的关系:
1us = 500kHz,2us = 250kHz,3us = 166.67kHz,4us = 125kHz,5us = 100kHz,
6us = 83.33kHz,7us = 71.4kHz,8us = 62.5kHz,9us = 55.56kHz,10us = 50kHz。
选一个,但要符合芯片的最高支持速率喔。图:400k

知道延时,也知道SCL对应的IO口pb6,那么,要在SCL上生成一个周期为 10us 的方波,怎么写程序呢?
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); // 高电平USER_Delay1us(5); // 持续5usHAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); // 低电平USER_Delay1us(5); // 持续5us
我们还可以把IO口拉高拉低写成宏定义:
#define SwI2cSetScl() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET)#define SwI2cClrScl() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET)#define SwI2cCheckScl() HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_6)#define SwI2cSetSda() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_SET)#define SwI2cClrSda() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_RESET)#define SwI2cCheckSda() HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_9)
这样的好处是,如果后面,你不想用 pb6 和 pb9 作为 scl 和 sda,你想换两根io口,只需要改初始化和这些宏定义就行了。
现在,可以开始写时序了。
a、START: SCL为高电平,SDA从高电平跳变至低电平,表示开始发送数据:
SwI2cSetSda();SwI2cSetScl();I2C_Delay;SwI2cClrSda();I2C_Delay;SwI2cClrScl();I2C_Delay;
b、TRANSMIT: START之后,就可以发送数据了,数据在SCL高电平有效,发送8-Bit数据(1BYTE),
i2c传输数据,是从高位最先传输的,比如,0xaa = 10101010b,先传 bit7=1,接下来再传bit6=0……最后传bit0=0:
uint8_t bit_sel;bit_sel = 0x80;while (bit_sel) {if (bit_sel & data)SwI2cSetSda();elseSwI2cClrSda();I2C_Delay;SwI2cSetScl();I2C_Delay;SwI2cClrScl();bit_sel >>= 1;}
data&0x80,data&0x40,data&0x20,data&0x10, data&0x08,data&0x04,data&0x02,data&0x01,
这一系列的操作,就把data从bit7取到bit0,按照要求,设置 sda 高低电平,记得,scl一个周期取一次sda值,高电平有效。
3、ACK:在第9个SCL周期,次设备将SDA拉低,主设备检测SDA为低电平,表示收到数据;如果在一个时钟周期内都没有ACK,说明从设备有问题,主设备必须发STOP,表示传输结束。
// ackSwI2cSetSda(); // 释放sda线,等待从设备把sda接低SwI2cSetScl();I2C_Delay;ack = SwI2cCheckSda(); // 检测 sda 电平SwI2cClrScl();I2C_Delay;if(ack) // 从设备在一个时钟周期内没有响应。{// 发送 stop 信号,传输结束debug_msg("io_i2c not ack. \r\n"); // 打印return I2CSW_BUSY; // 返回错误信息}
如果继续有数据要发,重复b、c步骤,如果没有,发STOP。
d、STOP:SCL为高电平,SDA从低电平跳变至高电平,表示发送数据结束;
SwI2cClrSda();SwI2cSetScl();I2C_Delay;SwI2cSetSda();I2C_Delay;
好啦,I2C时序就这样了,我们接着就用 I2C时序,来对 AT24C02 进行操作。
首先,需要一个设备地址,因为I2C总线上允许挂多个设备,设备地址,用于识别哪个设备。
看原理图+AT24C02 datasheet


A2/A1/A0引脚接地,为000,最低位(LSB)为低电平是写,高电平为读。
所以,地址为 0xA0(写),0xA1(读)。
根据datasheet描述,AT24C02的操作一般有 字节写、页写、随机读、连续读。
字节写和页写:


字节写:就是写一个字节。
START -> 设备地址 -> 寄存器地址(写eeprom哪个存储单元) -> 数据 -> STOP
页写:就是写一页。
START -> 设备地址 -> 寄存器地址(起始) -> 一连串数据 -> STOP
页写中的一连串数据,到底是多大的串呢?最大8BYTE,别问我怎么知道,DATASHEET。
根据描述,页写,其实跟字节写很相似,只不过是,寄存器地址的低3位,会自动增加。
页写存储单元 0x10,8个字节数据,
0x10 = 0001 0000b,寄存器低3位自动增加就是:
0001 0000b = 数据1,0001 0001b = 数据2,……,0001 0111b = 数据8
页写存储单元 0x12,8个字节数据呢?
0x12 = 0001 0010b,寄存器低3位自动增加就是:
0001 0010b = 数据1,0001 0011b = 数据2,……,0001 0111b = 数据5
0001 0000b = 数据6,0001 0001b = 数据7
结合上面的文字描述,存储单元地址到0001 0111b后,回滚到0001 0000b了,这是需要注意的地方,所以,页写,要注意处理页的边界问题。
写的代码如下:
1、存储单元地址传入*pMemAddr和memAddrLen,因为AT24C32/64/128/256,它的存储单元地址是16位的,这样这个函数也用在其它EEPROM上,而不需要再写一个函数了。
2、这里暂未对页写的页边界进行处理,因为其它EEPROM的页不是8个字节,页的处理,留在EEPROM写的时候做。
3、如果需要写一个字节,这个函数的最后一个参数 len 写1就行了。
uint8_t IOI2C_WriteBlock(uint8_t devAddr, uint8_t *pMemAddr, uint8_t memAddrLen, const uint8_t *pData, uint16_t len){uint8_t ctl;uint8_t startLimit = I2C_TIME_OUT;//Validate input parameterif (pData == NULL)return FALSE;if (len == 0)return FALSE;// I2C Start, Wait while device is busyctl = devAddr | I2C_WR;while (IOI2C_Start(ctl) != IOI2C_OK) { startLimit--;if(!startLimit) {debug_msg("IOI2C_WriteBlock start error.\r\n");return IOI2C_BUSY;}}// send slave write sub-addresswhile (memAddrLen--) {if (IOI2C_TransmitByte(*pMemAddr++) != IOI2C_OK) {debug_msg("IOI2C_WriteBlock write mem address error.\r\n");return IOI2C_BUSY;}}// write data from buffer into slave devicewhile(len--){if(IOI2C_TransmitByte(*pData++) != IOI2C_OK) // Data address{debug_msg("IOI2C_WriteBlock write data(%d) error.\r\n", len);return IOI2C_BUSY;} }IOI2C_Stop(); // end stop operationreturn IOI2C_OK;}
随机读和连续读:

随机读,就是随便读个字节,读哪就是哪。
START->设备地址->存储单元地址->START->设备地址->读->STOP
连续读,就是随便读n个字节,读哪块算哪块,就是在随便读的基础上不停地读,直到读够了再发STOP
START->设备地址->存储单元地址->START->设备地址->读->读->读->……->读->STOP
直接撕代码:
1、存储单元地址传入*pMemAddr和memAddrLen,因为AT24C32/64/128/256,它的存储单元地址是16位的,这样这个函数也用在其它EEPROM上,而不需要再写一个函数了。
2、既然叫随便读,那就不存在着页边界的问题了。
3、如果需要读一个字节,这个函数的最后一个参数 len 写1就行了。
4、每读完一个字节,第9个SCL,需要主设备向从设备发ACK,告诉从设备收到,下一个。
uint8_t IOI2C_ReadBlock(uint8_t devAddr, uint8_t *pMemAddr, uint8_t memAddrLen, uint8_t *pData, uint16_t len){uint8_t ctl;uint8_t startLimit = I2C_TIME_OUT;//Validate input parameterif (pData == NULL)return FALSE;if (len == 0)return FALSE;// I2C Start, Wait while device is busyctl = devAddr | I2C_WR;while (IOI2C_Start(ctl) != IOI2C_OK) { startLimit--;if(!startLimit) {debug_msg("IOI2C_ReadBlock start error.\r\n");return IOI2C_BUSY;}}// send slave read sub-addresswhile (memAddrLen--) {if (IOI2C_TransmitByte(*pMemAddr++) != IOI2C_OK) {debug_msg("IOI2C_ReadBlock write mem address error.\r\n");return IOI2C_BUSY;}}// Repeat start for read operationctl = devAddr | I2C_RD;if (IOI2C_Start(ctl) != IOI2C_OK){debug_msg("IOI2C_ReadBlock Repeat Start Error.\r\n");return IOI2C_BUSY;} //Read data from slave device into bufferwhile(--len){ *pData++ = IOI2C_GetByte(); // Read data into bufferIOI2C_SendAck(); // send ack to slave} *pData = IOI2C_GetByte(); // Read last data byteIOI2C_Stop(); // end stop operationreturn IOI2C_OK;}
其实写完这两个函数,基本上也差不多了,在应用中调用这两个函数,便可对EEPROM进行读写,不过为了程序可读性和可移植性,我们再来写个at24c02.c和at24c02.h,将 at24c32 的读写逻辑封装一下,在这个封装里面,我们会处理"页写"的回滚逻辑,为了保证可靠性,我们会多读或者多写几次。
套路:新建文件 -> 加入编译器,往上看,新建 io_i2c.c 里有步骤。
开始继续手撕代码,我们就实现以下5个函数:
uint8_t AT24C02_ReadByte(uint8_t memAddr){uint8_t data, count=0;uint8_t result = IOI2C_BUSY;do {result = I2C_READBUF(&memAddr, &data, 1);if(++count > 1)printf("AT24C04_ReadByte error,count = %d \r\n",count);} while ((count < CHECK_LIMIT) && (IOI2C_OK != result));return data;}uint8_t AT24C02_WriteByte(uint8_t memAddr, uint8_t data){uint8_t count=0;uint8_t result = IOI2C_BUSY;do {result = I2C_WRITEBUF(&memAddr, &data, 1);if(++count > 1)printf("AT24C04_WriteByte error,count = %d \r\n",count); } while ((count < CHECK_LIMIT) && (IOI2C_OK != result));return result;}uint8_t AT24C02_ReadBuffer(uint8_t memAddr, uint8_t *buff, uint16_t len){uint8_t count=0;uint8_t result = IOI2C_BUSY;do {result = I2C_READBUF(&memAddr, buff, len);if(++count > 1)printf("AT24C04_ReadBuffer error,count = %d \r\n",count); } while ((count < CHECK_LIMIT) && (IOI2C_OK != result));return result;}uint8_t AT24C02_WriteBuffer(uint8_t memAddr, uint8_t *buff, uint16_t len){uint8_t dataCnt, count=0;uint8_t result = IOI2C_BUSY;uint8_t pagesize;// Write buffer of datawhile (len) {// Number of bytes available in current page.pagesize = PAGE_SIZE - (memAddr % PAGE_SIZE);// Is current page has enough byte for the buffer.if (len > pagesize){// Open new page?if (pagesize == PAGE_SIZE) // No{// CASE3: The rest of the buffer is more than a page size.dataCnt = PAGE_SIZE; // Yes}else{// CASE2: Current page has not enough byte for the buffer,// write some data in this page and the rest in other// page(s).dataCnt = pagesize; // No}}else{// CASE1: Current page has enough byte for the buffer.// CASE4: Finished up the rest of the buffer.dataCnt = len; // Yes}// Write to EEPROM and make sure successcount = 0;do {result = I2C_WRITEBUF(&memAddr, buff, dataCnt);if(++count > 1)printf("AT24C04_WriteBuffer error,count = %d \r\n",count); } while ((count < CHECK_LIMIT) && (IOI2C_OK != result));buff += dataCnt; // Adjust pointer to B_CountmemAddr += dataCnt; // Adjust register addresslen -= dataCnt; // Adjust buffer countdelay_ms(10);}return result;}void AT24C02_Debug(uint8_t func, uint16_t pa1, uint16_t pa2, uint16_t pa3){uint16_t cnt;uint8_t temp[256];printf("AT24C02_Debug: func(%d), pa1(%d), pa2(%d), pa3(%d).\r\n",func, pa1, pa2, pa3);switch (func) {case 0: {printf("func0: read at24c02 byte: addr(0x%x) data(0x%x).\r\n",pa1, AT24C02_ReadByte((uint8_t)pa1) );}break;case 1: {memset(temp, 0, 256);AT24C02_ReadBuffer((uint8_t)pa1, temp, pa2);printf("func1: read buffer: addr(0x%x) len(%d).", pa1, pa2);for (cnt = 0; cnt < (uint8_t)pa2; cnt++) {if (cnt % 16 == 0)printf("\r\n");printf("0x%x, ", temp[cnt]);}printf("\r\n");}break;case 2: {printf("func2: at24c02 write: addr(0x%x) data(0x%x).\r\n", pa1, pa2);AT24C02_WriteByte((uint8_t)pa1, (uint8_t)pa2);}break;case 3: {for (cnt = 0; cnt < 256; cnt++)temp[cnt] = cnt;printf("func3: write buffer: addr(0x%x) len(%d).\r\n", pa1, pa2);AT24C02_WriteBuffer((uint8_t)pa1, temp, pa2);}break;default:printf("func0: read at24c04 byte.\r\n");printf("func1: read at24c04 buffer.\r\n");printf("func2: write at24c04 byte.\r\n");printf("func3: write at24c04 buffer.\r\n");break;}}
好了,在Debug函数里,调用
static void DebugCmdProceed(void){switch(debArray[0]){case DEBCMD_TEST:printf("DEBCMD_TEST: Parm1: %d / parm2: %d \r\n", debArray[1], debArray[2]);break;case DEBCMD_EEPROM:AT24C02_Debug((uint8_t)debArray[1], debArray[2], debArray[3], debArray[4]);break;}}
编译、烧录、运行、连上串口调试助手、执行:
1.、写字节
@cmd 0x1 2 0 0xac
@cmd 0x1 2 1 0xac
@cmd 0x1 2 2 0xac

2、读字节
@cmd 0x1 0 0
@cmd 0x1 0 1
@cmd 0x1 0 2

看,读回的就是写进去的吧?
3.、写一串,就往0x13存储单元写14个字节吧。
@cmd 0x1 3 0x13 14

4、读一串,就从0x12读16个字节吧。
@cmd 0x1 1 0x12 16

看0x13起,14个字节,就是写入的。
整个工程及代码呢,请上百度网盘上下载:
链接:https://pan.baidu.com/s/19usUcgZPX8cCRTKt_NPcfg
密码:07on
文件夹:\Stm32CubeMx\Code\IoI2c.rar
本章自己手撕的代码主要是 io_i2c.c/io_i2c.h 和 at24c02.c/at24c02.h
问题:
上面讲到使用延时,编写I2C的时序,根据计算得出,延时和时钟频率的关系是:
1us = 500kHz,2us = 250kHz,3us = 166.67kHz,4us = 125kHz,5us = 100kHz,
6us = 83.33kHz,7us = 71.4kHz,8us = 62.5kHz,9us = 55.56kHz,10us = 50kHz。
那么,如果,我想要用 400kHz 呢?300kHz呢?懵了吧?
下一篇,我带你进入,硬件I2C的世界,不管你用 400k,300k都没有问题。
上一篇:《I2C协议详解》
下一篇:《STM32 使用硬件I2C接口读写EEPROM》
回目录:《目录》
本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!
