开发环境:
MDK:Keil 5.30
开发板:GD32F207I-EVAL
MCU:GD32F207IK
21.1 I2C工作原理
21.1.1 I2C串行总线概述
I2C总线是PHLIPS公司推出的一种双线式半双工串行总线,是具备多主机系统所需的总线裁决和高低速器件同步功能的高性能串行总线。用于连接微控器及外围设备。I2C总线只有两根双向信号线。一根是数据线SDA,另一根是时钟线SCL。
物理层
1)它只使用两条总线线路 :一条双向串行数据线(SDA),一条串行时钟线(SCL)。见下图。
2)每个连接到总线的设备都有一个独立的地址,主机可以利用这个地址进行不同设备之间的访问。
3)多主机同时使用总线时,为了防止数据冲突,会利用仲裁方式决定由哪个设备占用总线。
4)具有三种传输模式 :标准模式的传输速率为 100 Kbit/s ,快速模式为 400 Kbit/s ,高速模式下可达 3.4 Mbit/s,但目前大多12C设备尚不支持高速模式。
5)片上的滤波器可以滤去总线数据线上的毛刺波以保证数据完整。
6)连接到相同总线的 IC 数量受到总线的最大电容 400 pF 限制
I2C总线通过上拉电阻接正电源。当总线空闲时,两根线均为高电平。连到总线上的任一器件输出的低电平,都将使总线的信号变低,即各器件的SDA及SCL都是线“与”关系。
每个接到I2C总线上的器件都有唯一的地址。主机与其它器件间的数据传送可以是由主机发送数据到其它器件,这时主机即为发送器。由总线上接收数据的器件则为接收器。
在多主机系统中,可能同时有几个主机企图启动总线传送数据。为了避免混乱, I2C总线要通过总线仲裁,以决定由哪一台主机控制总线。
协议层
I2C的协议包括起始和停止条件、数据有效性、响应、仲裁、时钟同步和地址广播等环节,由于我们使用的是 STM32 集成的硬件I2C接口,并不需要用软件去模拟 SDA 和SCL 线的时序。
这两幅图表示的是主机和从机通信时 SDA 线的数据包序列。
其中 S 表示由主机的I2C接口产生的传输起始信号(S),这时连接到I2C总线上的所有从机都会接收到这个信号。
起始信号产生后,所有从机就开始等待主机紧接下来广播的从机地址信号(SLAVE_ADDRESS),在I2C总线上,每个设备的地址都是唯一的。当主机广播的地址与某个设备地址相同时,这个设备就被选中了,没被选中的设备将会忽略之后的数据信号。根据I2C协议,这个从机地址可以是 7 位或 10 位。
在地址位之后,是传输方向的选择位,该位为 0 时,表示后面的数据传输方向是由主机传输至从机。该位为 1 时,则相反。
从机接收到匹配的地址后,主机或从机会返回一个应答(A)或非应答信号,只有接收到应答信号后,主机才能继续发送或接收数据。
若配置的方向传输位为写数据,广播完地址,接收到应答信号后,主机开始正式向从机传输数据(DATA),数据包的大小为 8 位。主机每发送完一个数据,都要等待从机的应答信号(A),重复这个过程,可以向从机传输 N 个数据,这个 N 没有大小限制。当数据传输结束时,主机向从机发送一个停止传输信号(P),表示不再传输数据。
若配置的方向传输位为读数据,广播完地址,接收到应答信号后,从机开始向主机返回数据(DATA),数据包大小也为 8 位。从机每发送完一个数据,都会等待主机的应答信号(A),重复这个过程,可以返回 N 个数据,这个 N 也没有大小限制。当主机希望停止接收数据时,就向从机返回一个非应答信号,则从机自动停止数据传输。
I2C接口特性
1)STM32 的中等容量和大容量型号的芯片均有多达两个的I2C总线接口。
2)能够工作于多主模式或从模式,分别为主接收器、主发送器、从接收器及从发送器。
3)支持标准模式 100 Kbit/s 和快速模式 400 Kbit/s,不支持高速模式。
4)支持 7 位或 10 位寻址。
5)内置了硬件 CRC 发生器 / 校验器。
6)I2 C 的接收和发送都可以使用 DMA 操作。
7)支持系统管理总线(SMBus)2.0 版。
I 2 C 架构
I2C的所有硬件架构就是根据 SCL 线和 SDA 线展开的(其中 SMBALERT 线用于 SMBus)。SCL 线的时序即为I2C 协议中的时钟信号,它由I2C 接口根据时钟控制寄存器(CCR)控制,控制的参数主要为时钟频率。而 SDA 线的信号则通过一系列数据控制架构,在将要发送的数据的基础上,根据协议添加各种起始信号、应答信号、地址信号,实现以 I 2 C 协议的方式发送出去。读取数据时则从 SDA 线上的信号中取出接收到的数据值。发送和接收的数据都被保存在数据寄存器(DR)上。
21.1.2 I2C总线的数据传送
数据位的有效性规定
I2C总线进行数据传送时,时钟信号为高电平期间,数据线上的数据必须保持稳定,只有在时钟线上的信号为低电平期间,数据线上的高电平或低电平状态才允许变化。
起始和终止信号
SCL线为高电平期间,SDA线由高电平向低电平的变化表示起始信号;SCL线为高电平期间,SDA线由低电平向高电平的变化表示终止信号。
起始和终止信号都是由主机发出的,在起始信号产生后,总线就处于被占用的状态;在终止信号产生后,总线就处于空闲状态。连接到I2C总线上的器件,若具有I2C总线的硬件接口,则很容易检测到起始和终止信号。每当发送器件传输完一个字节的数据后,后面必须紧跟一个校验位,这个校验位是接收端通过控制SDA(数据线)来实现的,以提醒发送端数据我这边已经接收完成,数据传送可以继续进行。
数据传送格式
字节传送与应答
每一个字节必须保证是8位长度。数据传送时,先传送最高位(MSB),每一个被传送的字节后面都必须跟随一位应答位(即一帧共有9位)。
由于某种原因从机不对主机寻址信号应答时(如从机正在进行实时性的处理工作而无法接收总线上的数据),它必须将数据线置于高电平,而由主机产生一个终止信号以结束总线的数据传送。
如果从机对主机进行了应答,但在数据传送一段时间后无法继续接收更多的数据时,从机可以通过对无法接收的第一个数据字节的“非应答”通知主机,主机则应发出终止信号以结束数据的继续传送。
当主机接收数据时,它收到最后一个数据字节后,必须向从机发出一个结束传送的信号。这个信号是由对从机的“非应答”来实现的。然后,从机释放SDA线,以允许主机产生终止信号。
总线的寻址
I2C总线协议有明确的规定:采用7位的寻址字节(寻址字节是起始信号后的第一个字节)。
寻址字节的位定义
D7~D1位组成从机的地址。D0位是数据传送方向位,为“0”时表示主机向从机写数据,为“1”时表示主机由从机读数据。
主机发送地址时,总线上的每个从机都将这7位地址码与自己的地址进行比较,如果相同,则认为自己正被主机寻址,根据R/T位将自己确定为发送器或接收器。
从机的地址由固定部分和可编程部分组成。在一个系统中可能希望接入多个相同的从机,从机地址中可编程部分决定了可接入总线该类器件的最大数目。如一个从机的7位寻址位有4位是固定位,3位是可编程位,这时仅能寻址8个同样的器件,即可以有8个同样的器件接入到该I2C总线系统中。
数据帧格式
I2C总线上传送的数据信号是广义的,既包括地址信号,又包括真正的数据信号。
在起始信号后必须传送一个从机的地址(7位),第8位是数据的传送方向位(R/T),用“0”表示主机发送数据(T),“1”表示主机接收数据(R)。每次数据传送总是由主机产生的终止信号结束。但是,若主机希望继续占用总线进行新的数据传送,则可以不产生终止信号,马上再次发出起始信号对另一从机进行寻址。
在总线的一次数据传送过程中,可以有以下几种组合方式:
A)主机向从机发送数据,数据传送方向在整个过程中不变;
注:有阴影部分表示数据由主机向从机传送,无阴影部分则表示数据由从机向主机传送。A表示应答, A表示非应答(高电平)。S表示起始信号,P表示终止信号。
B)主机在第一个字节后,立即从从机读数据。
C)在传送过程中,当需要改变传送方向时,起始信号和从机地址都被重复产生一次,但两次读/写方向位正好反相。
要想了解对I2C的主从模式详细了解,参看STM32F10xxx参考手册的I2C接口章节。
21.2 AT24Cxx存储器原理
21.2.1 AT24Cxx概述
AT24C01/02/04/08/16是一个1K/2K/4K/8K/16K位串行CMOS,EEPROM内部含有128/256/512/1024/2048个8位字节CATALYST公司的先进CMOS技术实质上减少了器件的功耗,AT24C01/02有一个8字节页写缓冲器AT24C04/08/16有一个16字节页写缓冲器,该器件通过I2C总线接口进行操作有一个专门的写保护功能。AT24C01/02每页有8个字节,分别为16/32页;AT24C04/08/16每页有16个字节,分别为32/64/128页。
工作特点
与400KHz I2C总线兼容
1.8到6.0伏工作电压范围
低功耗CMOS技术
写保护功能当WP为高电平时进入写保护状态
页写缓冲器
自定时擦写周期
100万次编程/擦除周期
可保存数据100年
8脚DIP SOIC或TSSOP封装
温度范围商业级和工业级
AT24Cxx的引脚定义如下:
Note: For use of 5-lead SOT23, the software A2, A1, and A0 bits in the device address word must be set to zero toproperly communicate.
21.2.2总线时序
I2C总线时序如下:
其读写周期的电压范围如下:
写周期时间是指从一个写时序的有效停止信号到内部编程/擦除周期结束的这一段时间。在写周期期间,总线接口电路禁能,SDA保持为高电平,器件不响应外部操作。
21.2.3器件寻址
主器件通过发送一个起始信号启动发送过程,然后发送它所要寻址的从器件的地址。8位从器件地址的高4位固定为(1010)。接下来的3位(A2、A1、A0)为器件的地址位,用来定义哪个器件以及器件的哪个部分被主器件访问,上述8个AT24C01/02,4个AT24C04,2个AT24C08,1个AT24C16可单独被系统寻址。从器件8位地址的最低位,作为读写控制位。“1”表示对从器件进行读操作,“0”表示对从器件进行写操作。在主器件发送起始信号和从器件地址字节后,AT24C01/02/04/08/16监视总线并当其地址与发送的从地址相符时响应一个应答信号(通过SDA线)。AT24C01/02/04/08/16再根据读写控制位(R/W)的状态进行读或写操作。
字节写
在字节写模式下,主器件发送起始命令和从器件地址信息(R/W)位置发给从器件,在从器件产生应答信号后,主器件发送AT24Cxx的字节地址,主器件在收到从器件的另一个应答信号后,再发送数据到被寻址的存储单元。AT24Cxx再次应答,并在主器件产生停止信号后开始内部数据的擦写,在内部擦写过程中,AT24Cxx不再应答主器件的任何请求。
页写
用页写,AT24C01/02可一次写入8 个字节数据,AT24C04/08/16可以一次写入16个字节的数据。页写操作的启动和字节写一样,不同在于传送了一字节数据后并不产生停止信号。主器件被允许发送P(AT24C01:P=7;AT24C02/04/08/16:P=15)个额外的字节。每发送一个字节数据后AT24Cxx产生一个应答位并将字节地址低位加1,高位保持不变。
如果在发送停止信号之前主器件发送超过P+1个字节,地址计数器将自动翻转,先前写入的数据被覆盖。
接收到P+1字节数据和主器件发送的停止信号后,AT24Cxx启动内部写周期将数据写到数据区。所有接收的数据在一个写周期内写入AT24Cxx。
读字节
读操作允许主器件对寄存器的任意字节进行读操作,主器件首先通过发送起始信号、从器件地址和它想读取的字节数据的地址执行一个写操作。在AT24Cxx应答之后,主器件重新发送起始信号和从器件地址,此时R/W位置1,AT24Cxx响应并发送应答信号,然后输出所要求的一个8位字节数据,主器件不发送应答信号但产生一个停止信号。
顺序读
在AT24Cxx发送完一个8位字节数据后,主器件产生一个应答信号来响应,告知AT24Cxx主器件要求更多的数据,对应每个主机产生的应答信号AT24Cxx将发送一个8位数据字节。当主器件不发送应答信号而发送停止位时结束此操作。
从AT24Cxx输出的数据按顺序由N到N+1输出。读操作时地址计数器在AT24Cxx整个地址内增加,这样整个寄存器区域可在一个读操作内全部读出,当读取的字节超过E(对于24WC01,E=127;对24C02,E=255;对24C04,E=511;对24C08,E=1023;对24C16,E=2047)计数器将翻转到零并继续输出数据字节。
典型应用
ATC02的典型电路如下:
根据AT24C02的芯片资料,我们会发现AT24C02有三个地址A0,A1,A2。同时,我们会在资料的Device Address介绍发现I2C器件一共有七位地址码,还有一位是读/写(R/W)操作位,而在AT24C02的前四位已经固定为1010。R/W为1则为 读操作,为0则为写操作。R/W位我们要设置为0(写操作)。
规则为:1010(A0)(A1)(A2)(R/W)
例子1:
那么对应的A0,A1,A2都是接的VCC,所以为A0=1,A1=1,A2=1;可以知道AT24C02的从设备写地址为10101110(0xae),读设备地址为10101111(0xaf)。
例子2:
那么对应的A0,A1,A2都是接的GND,所以为A0=0,A1=0,A2=0;可以知道AT24C02的从设备写地址为10100000(0xa0),读设备地址为10100001(0xa1)。
21.3 I2C寄存器描述
I2C有6类寄存器,详细的介绍请参考GD32F2XXX参考手册的I2C寄存器描述部分。在这里笔者只讲最重要的2个寄存器。
数据寄存器
数据寄存器的详细描述如下所示。
时钟寄存器
时钟寄存器是I2C中比较重要的一个寄存器,时钟信号的信号的稳定是I2C正常工作的前提。
21.4硬件设计及连接
本文是使用I2C协议对EEPROM进行读写操作,具体的硬件连接如下。
从硬件链接可以得到AT24C02的地址是0xA0,I2C的接口是I2C0。
21.5硬件I2C
21.5.1具体代码实现
首先看看I2C的初始化。这有两部分。
一部分是I2C的GPIO初始化。
/*
brief configure the GPIO ports
param[in] i2c_typedef_enum i2c_id
param[out] none
retval none
*/
void i2c_gpio_config(i2c_typedef_enum i2c_id)
{
/* enable GPIO clock */
rcu_periph_clock_enable(I2C_BUS_SCL_GPIO_CLK[i2c_id]);
rcu_periph_clock_enable(I2C_BUS_SDA_GPIO_CLK[i2c_id]);
/* Config I2C_SCL */
gpio_init(I2C_BUS_SCL_GPIO_PORT[i2c_id], GPIO_MODE_AF_OD, GPIO_OSPEED_50MHZ, I2C_BUS_SCL_PIN[i2c_id]);
/* Config I2C_SDA */
gpio_init(I2C_BUS_SDA_GPIO_PORT[i2c_id], GPIO_MODE_AF_OD, GPIO_OSPEED_50MHZ, I2C_BUS_SDA_PIN[i2c_id]);
}
/*
brief configure the I2C0 interfaces
param[in] i2c_typedef_enum i2c_id
param[out] none
retval none
*/
void i2c_mode_config(i2c_typedef_enum i2c_id)
{
/* enable I2C clock */
rcu_periph_clock_enable(I2C_BUS_CLK[i2c_id]);
/* configure I2C clock */
i2c_clock_config(I2C_BUS[i2c_id], I2C_SPEED, I2C_DTCY_2);
/* configure I2C address */
i2c_mode_addr_config(I2C_BUS[i2c_id], I2C_I2CMODE_ENABLE, I2C_ADDFORMAT_7BITS, I2C_OWN_ADDRESS7);
/* enable I2C0 */
i2c_enable(I2C_BUS[i2c_id]);
/* enable acknowledge */
i2c_ack_config(I2C_BUS[i2c_id], I2C_ACK_ENABLE);
}
主要配置I2C模式、低电平占空比、I2C寻址模式以及通信速率,最后使能I2C设备。
初始化完成后就是对AT24C02的读写操作,严格按照相应的时序操作就行。
字节写
在字节写模式下,向AT24C02中写数据时序如下:
操作时序如下:
1.MCU先发送一个开始信号(START)启动总线
2.接着跟上首字节,发送器件写操作地址(DEVICE ADDRESS)+写数据(0xA0)
3.等待应答信号(ACK)
4.发送数据的存储地址。24C02一共有256个字节的存储空间,地址从0x00~0xFF,想把数据存储在哪个位置,此刻写的就是哪个地址。
5.发送要存储的数据,在写数据的过程中,AT24C02会回应一个“应答位0”,则表明写AT24C02数据成功,如果没有回应答位,说明写入不成功。
6.发送结束信号(STOP)停止总线。
代码很简单,跟着时序来就行。
/*
brief write one byte to the I2C EEPROM
param[in] i2c_typedef_enum i2c_id
p_buffer: pointer to the buffer containing the data to be written to the EEPROM
param[in] write_address: EEPROM's internal address to write to
param[out] none
retval none
*/
void eeprom_byte_write(i2c_typedef_enum i2c_id, uint8_t *p_buffer, uint8_t write_address)
{
/* wait until I2C bus is idle */
while(i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_I2CBSY));
/* send a start condition to I2C bus */
i2c_start_on_bus(I2C_BUS[i2c_id]);
/* wait until SBSEND bit is set */
while(!i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_SBSEND));
/* send slave address to I2C bus */
i2c_master_addressing(I2C_BUS[i2c_id], eeprom_address, I2C_TRANSMITTER);
/* wait until ADDSEND bit is set */
while(!i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_ADDSEND));
/* clear the ADDSEND bit */
i2c_flag_clear(I2C_BUS[i2c_id], I2C_FLAG_ADDSEND);
/* wait until the transmit data buffer is empty */
while(SET != i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_TBE));
/* send the EEPROM's internal address to write to : only one byte address */
i2c_data_transmit(I2C_BUS[i2c_id], write_address);
/* wait until BTC bit is set */
while(!i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_BTC));
/* send the byte to be written */
i2c_data_transmit(I2C_BUS[i2c_id], *p_buffer);
/* wait until BTC bit is set */
while(!i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_BTC));
/* send a stop condition to I2C bus */
i2c_stop_on_bus(I2C_BUS[i2c_id]);
/* wait until the stop condition is finished */
while(I2C_CTL0(I2C_BUS[i2c_id]) & 0x0200);
}
页写
用页写,AT24C01可一次写入8 个字节数据,AT24C02/04/08/16可以一次写入16个字节的数据。页写操作的启动和字节写一样,不同在于传送了一字节数据后并不产生停止信号。每发送一个字节数据后AT24Cxx产生一个应答位并将字节地址低位加1,高位保持不变。
如果在发送停止信号之前主器件发送超过P+1个字节,地址计数器将自动翻转,先前写入的数据被覆盖。
接收到P+1字节数据和主器件发送的停止信号后,AT24Cxx启动内部写周期将数据写到数据区。所有接收的数据在一个写周期内写入AT24Cxx。
代码很简单,和字节写不同的是,数据会一直发,直到主机发送停止信号。
/*
brief write more than one byte to the EEPROM with a single write cycle
param[in] i2c_typedef_enum i2c_id
p_buffer: pointer to the buffer containing the data to be written to the EEPROM
param[in] write_address: EEPROM's internal address to write to
param[in] number_of_byte: number of bytes to write to the EEPROM
param[out] none
retval none
*/
void eeprom_page_write(i2c_typedef_enum i2c_id, uint8_t *p_buffer, uint8_t write_address, uint8_t number_of_byte)
{
/* wait until I2C bus is idle */
while(i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_I2CBSY));
/* send a start condition to I2C bus */
i2c_start_on_bus(I2C_BUS[i2c_id]);
/* wait until SBSEND bit is set */
while(!i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_SBSEND));
/* send slave address to I2C bus */
i2c_master_addressing(I2C_BUS[i2c_id], eeprom_address, I2C_TRANSMITTER);
/* wait until ADDSEND bit is set */
while(!i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_ADDSEND));
/* clear the ADDSEND bit */
i2c_flag_clear(I2C_BUS[i2c_id], I2C_FLAG_ADDSEND);
/* wait until the transmit data buffer is empty */
while(SET != i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_TBE));
/* send the EEPROM's internal address to write to : only one byte address */
i2c_data_transmit(I2C_BUS[i2c_id], write_address);
/* wait until BTC bit is set */
while(!i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_BTC));
/* while there is data to be written */
while(number_of_byte--)
{
i2c_data_transmit(I2C_BUS[i2c_id], *p_buffer);
/* point to the next byte to be written */
p_buffer++;
/* wait until BTC bit is set */
while(!i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_BTC));
}
/* send a stop condition to I2C bus */
i2c_stop_on_bus(I2C_BUS[i2c_id]);
/* wait until the stop condition is finished */
while(I2C_CTL0(I2C_BUS[i2c_id]) & 0x0200);
}
任意写
在实际过程中,我们经常需要任意写数据,这里就调用页写的操作,来实现任意字节的写操作。
/*
brief write buffer of data to the I2C EEPROM
param[in] i2c_typedef_enum i2c_id
p_buffer: pointer to the buffer containing the data to be written to the EEPROM
param[in] write_address: EEPROM's internal address to write to
param[in] number_of_byte: number of bytes to write to the EEPROM
param[out] none
retval none
*/
void eeprom_buffer_write(i2c_typedef_enum i2c_id, uint8_t *p_buffer, uint8_t write_address, uint16_t number_of_byte)
{
uint8_t number_of_page = 0, number_of_single = 0, address = 0, count = 0;
address = write_address % I2C_PAGE_SIZE;
count = I2C_PAGE_SIZE - address;
number_of_page = number_of_byte / I2C_PAGE_SIZE;
number_of_single = number_of_byte % I2C_PAGE_SIZE;
/* if write_address is I2C_PAGE_SIZE aligned */
if(0 == address)
{
while(number_of_page--)
{
eeprom_page_write(i2c_id, p_buffer, write_address, I2C_PAGE_SIZE);
eeprom_wait_standby_state(i2c_id);
write_address += I2C_PAGE_SIZE;
p_buffer += I2C_PAGE_SIZE;
}
if(0 != number_of_single)
{
eeprom_page_write(i2c_id, p_buffer, write_address, number_of_single);
eeprom_wait_standby_state(i2c_id);
}
}
else
{
/* if write_address is not I2C_PAGE_SIZE aligned */
if(number_of_byte < count)
{
eeprom_page_write(i2c_id, p_buffer, write_address, number_of_byte);
eeprom_wait_standby_state(i2c_id);
}
else
{
number_of_byte -= count;
number_of_page = number_of_byte / I2C_PAGE_SIZE;
number_of_single = number_of_byte % I2C_PAGE_SIZE;
if(0 != count)
{
eeprom_page_write(i2c_id, p_buffer, write_address, count);
eeprom_wait_standby_state(i2c_id);
write_address += count;
p_buffer += count;
}
/* write page */
while(number_of_page--)
{
eeprom_page_write(i2c_id, p_buffer, write_address, I2C_PAGE_SIZE);
eeprom_wait_standby_state(i2c_id);
write_address += I2C_PAGE_SIZE;
p_buffer += I2C_PAGE_SIZE;
}
/* write single */
if(0 != number_of_single)
{
eeprom_page_write(i2c_id, p_buffer, write_address, number_of_single);
eeprom_wait_standby_state(i2c_id);
}
}
}
}
主要分为两种情况,写的地址正好是一页的开始,另外一种是在一页的中间。不管如何,始终遵循的原则就是最大智能写一页,可以从一页的中间开始。
读字节
读操作允许主器件对寄存器的任意字节进行读操作,主器件首先通过发送起始信号、从器件地址和它想读取的字节数据的地址执行一个写操作。在AT24Cxx应答之后,主器件重新发送起始信号和从器件地址,此时R/W位置1,AT24Cxx响应并发送应答信号,然后输出所要求的一个8位字节数据,主器件不发送应答信号但产生一个停止信号。
读取字节的时序如下:
1.MCU先发送一个开始信号(START)启动总线
2.接着跟上首字节,发送器件写操作地址(DEVICE ADDRESS)+写数据(0xA0)
注意:这里写操作是为了要把所要读的数据的存储地址先写进去,告诉AT24Cxx要读取哪个地址的数据。
3.发送要读取内存的地址(WORD ADDRESS),通知AT24Cxx读取要哪个地址的信息。
4.重新发送开始信号(START)。
5.发送设备读操作地址(DEVICE ADDRESS)对AT24Cxx进行读操作 (0xA1)。
6.AT24Cxx会自动向主机发送数据,主机读取从器件发回的数据,在读一个字节后,MCU会回应一个应答信号(ACK)。
7.发送一个“非应答位NAK(1)”。发送结束信号(STOP)停止总线。
顺序读
在AT24Cxx发送完一个8位字节数据后,主器件产生一个应答信号来响应,告知AT24Cxx主器件要求更多的数据,对应每个主机产生的应答信号AT24Cxx将发送一个8位数据字节。当主器件不发送应答信号而发送停止位时结束此操作。
从AT24Cxx输出的数据按顺序由N到N+1输出。读操作时地址计数器在AT24Cxx整个地址内增加,这样整个寄存器区域可在一个读操作内全部读出,当读取的字节超过E(对于24WC01,E=127;对24C02,E=255;对24C04,E=511;对24C08,E=1023;对24C16,E=2047)计数器将翻转到零并继续输出数据字节。
我们常用的方式就是连续读取,代码很简单。
/*
brief read data from the EEPROM
param[in] i2c_typedef_enum i2c_id
p_buffer: pointer to the buffer that receives the data read from the EEPROM
param[in] read_address: EEPROM's internal address to start reading from
param[in] number_of_byte: number of bytes to reads from the EEPROM
param[out] none
retval none
*/
void eeprom_buffer_read(i2c_typedef_enum i2c_id, uint8_t *p_buffer, uint8_t read_address, uint16_t number_of_byte)
{
/* wait until I2C bus is idle */
while(i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_I2CBSY));
if(2 == number_of_byte)
{
i2c_ackpos_config(I2C_BUS[i2c_id], I2C_ACKPOS_NEXT);
}
/* send a start condition to I2C bus */
i2c_start_on_bus(I2C_BUS[i2c_id]);
/* wait until SBSEND bit is set */
while(!i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_SBSEND));
/* send slave address to I2C bus */
i2c_master_addressing(I2C_BUS[i2c_id], eeprom_address, I2C_TRANSMITTER);
/* wait until ADDSEND bit is set */
while(!i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_ADDSEND));
/* clear the ADDSEND bit */
i2c_flag_clear(I2C_BUS[i2c_id], I2C_FLAG_ADDSEND);
/* wait until the transmit data buffer is empty */
while(SET != i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_TBE));
/* enable I2C*/
i2c_enable(I2C_BUS[i2c_id]);
/* send the EEPROM's internal address to write to */
i2c_data_transmit(I2C_BUS[i2c_id], read_address);
/* wait until BTC bit is set */
while(!i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_BTC));
/* send a start condition to I2C bus */
i2c_start_on_bus(I2C_BUS[i2c_id]);
/* wait until SBSEND bit is set */
while(!i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_SBSEND));
/* send slave address to I2C bus */
i2c_master_addressing(I2C_BUS[i2c_id], eeprom_address, I2C_RECEIVER);
if(number_of_byte < 3)
{
/* disable acknowledge */
i2c_ack_config(I2C_BUS[i2c_id], I2C_ACK_DISABLE);
}
/* wait until ADDSEND bit is set */
while(!i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_ADDSEND));
/* clear the ADDSEND bit */
i2c_flag_clear(I2C0, I2C_FLAG_ADDSEND);
if(1 == number_of_byte)
{
/* send a stop condition to I2C bus */
i2c_stop_on_bus(I2C_BUS[i2c_id]);
}
/* while there is data to be read */
while(number_of_byte)
{
if(3 == number_of_byte)
{
/* wait until BTC bit is set */
while(!i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_BTC));
/* disable acknowledge */
i2c_ack_config(I2C_BUS[i2c_id], I2C_ACK_DISABLE);
}
if(2 == number_of_byte)
{
/* wait until BTC bit is set */
while(!i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_BTC));
/* send a stop condition to I2C bus */
i2c_stop_on_bus(I2C_BUS[i2c_id]);
}
/* wait until the RBNE bit is set and clear it */
if(i2c_flag_get(I2C_BUS[i2c_id], I2C_FLAG_RBNE))
{
/* read a byte from the EEPROM */
*p_buffer = i2c_data_receive(I2C_BUS[i2c_id]);
/* point to the next location where the byte read will be saved */
p_buffer++;
/* decrement the read bytes counter */
number_of_byte--;
}
}
/* wait until the stop condition is finished */
while(I2C_CTL0(I2C_BUS[i2c_id]) & 0x0200);
/* enable acknowledge */
i2c_ack_config(I2C_BUS[i2c_id], I2C_ACK_ENABLE);
i2c_ackpos_config(I2C_BUS[i2c_id], I2C_ACKPOS_CURRENT);
}
最后看下主函数吧。
/*
brief main function
param[in] none
param[out] none
retval none
*/
int main(void)
{
//systick init
sysTick_init();
// led init
led_init(LED1);
//usart init 115200 8-N-1
com_init(COM1, 115200, 0, 1);
// DMA config
usart0_dma_init();
/* USART DMA 发送使能 */
usart_dma_enable(USART0, USART_DMA_TRANSMIT);
/* USART DMA接收使能 */
usart_dma_enable(USART0, USART_DMA_RECEIVE);
/* initialize EEPROM */
I2C_EE_Init(IIC0);
i2c_24c02_test(IIC0);
while(1)
{
led_toggle(LED1);
delay_ms(1000);
}
}
很简单,往AT24C02中写入数据,然后再读取数据,读写测试的函数如下:
/*
brief I2C read and write functions
param[in] i2c_typedef_enum i2c_id
param[out] none
retval I2C_OK or I2C_FAIL
*/
uint8_t i2c_24c02_test(i2c_typedef_enum i2c_id)
{
uint16_t i;
uint8_t i2c_buffer_write[BUFFER_SIZE];
uint8_t i2c_buffer_read[BUFFER_SIZE];
printf("\r\nAT24C02 writing...\r\n");
/* initialize i2c_buffer_write */
for(i = 0; i < BUFFER_SIZE; i++)
{
i2c_buffer_write[i] = i;
printf("0x%02X ", i2c_buffer_write[i]);
if(15 == i % 16)
{
printf("\r\n");
}
}
/* EEPROM data write */
eeprom_buffer_write(i2c_id, i2c_buffer_write, EEP_FIRST_PAGE, BUFFER_SIZE);
printf("AT24C02 reading...\r\n");
/* EEPROM data read */
eeprom_buffer_read(i2c_id, i2c_buffer_read, EEP_FIRST_PAGE, BUFFER_SIZE);
/* compare the read buffer and write buffer */
for(i = 0; i < BUFFER_SIZE; i++)
{
if(i2c_buffer_read[i] != i2c_buffer_write[i])
{
printf("0x%02X ", i2c_buffer_read[i]);
printf("Err:data read and write aren't matching.\n\r");
return I2C_FAIL;
}
printf("0x%02X ", i2c_buffer_read[i]);
if(15 == i % 16)
{
printf("\r\n");
}
}
printf("I2C-AT24C02 test passed!\n\r");
return I2C_OK;
}
当然在读写测试之前应该对AT24C02进行初始化操作。
/**
* @brief I2C 外设(EEPROM)初始化
* @param i2c_typedef_enum i2c_id
* @retval 无
*/
void I2C_EE_Init(i2c_typedef_enum i2c_id)
{
/* 选择EEPROM要写入的地址 */
#ifdef EEPROM_BLOCK0_ADDRESS
/* 选择 EEPROM Block0 来写入 */
eeprom_address = EEPROM_BLOCK0_ADDRESS;
#endif
#ifdef EEPROM_BLOCK1_ADDRESS
/* 选择 EEPROM Block1 来写入 */
eeprom_address = EEPROM_BLOCK2_ADDRESS;
#endif
#ifdef EEPROM_BLOCK2_ADDRESS
/* 选择 EEPROM Block2 来写入 */
eeprom_address = EEPROM_BLOCK2_ADDRESS;
#endif
#ifdef EEPROM_BLOCK3_ADDRESS
/* 选择 EEPROM Block3 来写入 */
eeprom_address = EEPROM_BLOCK3_ADDRESS;
#endif
i2c_gpio_config(i2c_id);
i2c_mode_config(i2c_id);
}
21.5.2实验现象
下载好程序后,打开串口助手,可以看到如下信息。
最后,我们使用逻辑分析来查看数据。
使用的400kHz的速率,可以看到数据的写操作和前面分析的时序是一样的,完全吻合。
21.6软件I2C
21.6.1具体代码实现
首先实现I2C的协议。
/**
* @brief I2C_Delay, I2C总线位延迟,最快400KHz
* @param None
* @retval None
*/
static void I2C_Delay(void)
{
uint8_t i;
//I2C_Delay_us(2);
for (i = 0; i < 15; i++);
}
/**
* @brief I2C_Start, CPU发起I2C总线启动信号
* @param None
* @retval None
*/
void I2C_Start(void)
{
I2C_SDA_OUT();
/* 当SCL高电平时,SDA出现一个下跳沿表示I2C总线启动信号 */
I2C_SDA_1();
I2C_SCL_1();
I2C_Delay();
I2C_SDA_0();//START:when CLK is high,DATA change form high to low
I2C_Delay();
I2C_SCL_0();
I2C_Delay();
}
/**
* @brief I2C_Stop, CPU发起I2C总线停止信号
* @param None
* @retval None
*/
void I2C_Stop(void)
{
I2C_SDA_OUT();
/* 当SCL高电平时,SDA出现一个上跳沿表示I2C总线停止信号 */
I2C_SDA_0();//STOP:when CLK is high DATA change form low to high
I2C_SCL_1();
I2C_Delay();
I2C_SDA_1();
}
/**
* @brief I2C_SendByte, CPU向I2C总线设备发送8bit数据
* @param ucByte : 等待发送的字节
* @retval None
*/
void I2C_SendByte(uint8_t ucByte)
{
uint8_t i;
I2C_SDA_OUT();
/* 先发送字节的高位bit7 */
for (i = 0; i < 8; i++)
{
if (ucByte & 0x80)
{
I2C_SDA_1();
}
else
{
I2C_SDA_0();
}
I2C_Delay();
I2C_SCL_1();
I2C_Delay();
I2C_SCL_0();
if (i == 7)
{
I2C_SDA_1(); // 释放总线
}
ucByte <<= 1; /* 左移一个bit */
I2C_Delay();
}
}
/**
* @brief I2C_ReadByte, CPU从I2C总线设备读取8bit数据
* @param None
* @retval 读到的数据
*/
uint8_t I2C_ReadByte(void)
{
uint8_t i;
uint8_t value;
I2C_SDA_IN();
/* 读到第1个bit为数据的bit7 */
value = 0;
for (i = 0; i < 8; i++)
{
value <<= 1;
I2C_SCL_1();
I2C_Delay();
if (I2C_SDA_READ())
{
value |= 1;
}
I2C_SCL_0();
I2C_Delay();
}
return value;
}
/**
* @brief I2C_WaitAck, CPU产生一个时钟,并读取器件的ACK应答信号
* @param None
* @retval 返回0表示正确应答,1表示无器件响应
*/
uint8_t I2C_WaitAck(void)
{
uint8_t re;
I2C_SDA_IN();
I2C_SDA_1(); /* CPU释放SDA总线 */
I2C_Delay();
I2C_SCL_1(); /* CPU驱动SCL = 1, 此时器件会返回ACK应答 */
I2C_Delay();
if (I2C_SDA_READ()) /* CPU读取SDA口线状态 */
{
re = 1;
}
else
{
re = 0;
}
I2C_SCL_0();
I2C_Delay();
return re;
}
/**
* @brief I2C_Ack, CPU产生一个ACK信号
* @param None
* @retval None
*/
void I2C_Ack(void)
{
I2C_SDA_OUT();
I2C_SDA_0(); /* CPU驱动SDA = 0 */
I2C_Delay();
I2C_SCL_1(); /* CPU产生1个时钟 */
I2C_Delay();
I2C_SCL_0();
I2C_Delay();
I2C_SDA_1(); /* CPU释放SDA总线 */
}
/**
* @brief iI2C_NAck, CPU产生1个NACK信号
* @param None
* @retval None
*/
void I2C_NAck(void)
{
I2C_SDA_OUT();
I2C_SDA_1(); /* CPU驱动SDA = 1 */
I2C_Delay();
I2C_SCL_1(); /* CPU产生1个时钟 */
I2C_Delay();
I2C_SCL_0();
I2C_Delay();
}
/**
* @brief I2C_Cfg_GPIO, 配置I2C总线的GPIO,采用模拟IO的方式实现
* @param None
* @retval None
*/
static void I2C_Cfg_GPIO(void)
{
/* enable the led clock */
rcu_periph_clock_enable(I2C_RCC_CLK);
/* configure GPIO port */
/* 配置 IIC_SCL 引脚为推挽输出 */
gpio_init(I2C_GPIO_PORT, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, I2C_SCL_PIN);
/* 配置 IIC_SDA 引脚为推挽输出 */
gpio_init(I2C_GPIO_PORT, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, I2C_SDA_PIN);
//gpio_bit_set(I2C_GPIO_PORT, I2C_SCL_PIN);
//gpio_bit_set(I2C_GPIO_PORT, I2C_SDA_PIN);
/* 给一个停止信号, 复位I2C总线上的所有设备到待机模式 */
I2C_Stop();
}
/**
* @brief i2c_CheckDevice, 检测I2C总线设备,CPU向发送设备地址,然后读取设备应答来判断该设备是否存在
* @param address:设备的I2C总线地址
* @retval 返回值 0 表示正确, 返回1表示未探测到
*/
uint8_t I2C_CheckDevice(uint8_t address)
{
uint8_t ucAck;
I2C_Cfg_GPIO(); /* 配置GPIO */
I2C_Start(); /* 发送启动信号 */
/* 发送设备地址+读写控制bit(0 = w, 1 = r) bit7 先传 */
I2C_SendByte(address | I2C_WR);
ucAck = I2C_WaitAck(); /* 检测设备的ACK应答 */
I2C_Stop(); /* 发送停止信号 */
return ucAck;
}
注释很清楚,对照I2C的协议看就行。
接着就是实现AT2C02的读写操作。
/**
* @brief EEPROM_CheckOk, 判断串行EERPOM是否正常
* @param None
* @retval 1 表示正常, 0 表示不正常
*/
uint8_t EEPROM_CheckOk(void)
{
if (I2C_CheckDevice(EEPROM_DEV_ADDR) == 0)
{
return 1;
}
else
{
/* 失败后,切记发送I2C总线停止信号 */
I2C_Stop();
return 0;
}
}
/**
* @brief EEPROM_ReadBytes, 从串行EEPROM指定地址处开始读取若干数据
* @param _usAddress : 起始地址
* _usSize : 数据长度,单位为字节
* _pReadBuf : 存放读到的数据的缓冲区指针
* @retval 0 表示失败,1表示成功
*/
uint8_t EEPROM_ReadBytes(uint8_t *_pReadBuf, uint16_t _usAddress, uint16_t _usSize)
{
uint16_t i;
/* 采用串行EEPROM随即读取指令序列,连续读取若干字节 */
/* 第1步:发起I2C总线启动信号 */
I2C_Start();
/* 第2步:发起控制字节,高7bit是地址,bit0是读写控制位,0表示写,1表示读 */
I2C_SendByte(EEPROM_DEV_ADDR | I2C_WR); /* 此处是写指令 */
/* 第3步:发送ACK */
if (I2C_WaitAck() != 0)
{
goto cmd_fail; /* EEPROM器件无应答 */
}
/* 第4步:发送字节地址,24C02只有256字节,因此1个字节就够了,如果是24C04以上,那么此处需要连发多个地址 */
I2C_SendByte((uint8_t)_usAddress);
/* 第5步:发送ACK */
if (I2C_WaitAck() != 0)
{
goto cmd_fail; /* EEPROM器件无应答 */
}
/* 第6步:重新启动I2C总线。前面的代码的目的向EEPROM传送地址,下面开始读取数据 */
I2C_Start();
/* 第7步:发起控制字节,高7bit是地址,bit0是读写控制位,0表示写,1表示读 */
I2C_SendByte(EEPROM_DEV_ADDR | I2C_RD); /* 此处是读指令 */
/* 第8步:发送ACK */
if (I2C_WaitAck() != 0)
{
goto cmd_fail; /* EEPROM器件无应答 */
}
/* 第9步:循环读取数据 */
for (i = 0; i < _usSize; i++)
{
_pReadBuf[i] = I2C_ReadByte(); /* 读1个字节 */
/* 每读完1个字节后,需要发送Ack, 最后一个字节不需要Ack,发Nack */
if (i != _usSize - 1)
{
I2C_Ack(); /* 中间字节读完后,CPU产生ACK信号(驱动SDA = 0) */
}
else
{
I2C_NAck(); /* 最后1个字节读完后,CPU产生NACK信号(驱动SDA = 1) */
}
}
/* 发送I2C总线停止信号 */
I2C_Stop();
return 1; /* 执行成功 */
cmd_fail: /* 命令执行失败后,切记发送停止信号,避免影响I2C总线上其他设备 */
/* 发送I2C总线停止信号 */
I2C_Stop();
return 0;
}
/**
* @brief EEPROM_WriteBytes, 向串行EEPROM指定地址写入若干数据,采用页写操作提高写入效率
* @param _usAddress : 起始地址
* _usSize : 数据长度,单位为字节
* _pWriteBuf : 存放读到的数据的缓冲区指针
* @retval 0 表示失败,1表示成功
*/
uint8_t EEPROM_WriteBytes(uint8_t *_pWriteBuf, uint16_t _usAddress, uint16_t _usSize)
{
uint16_t i,m;
uint16_t usAddr;
/*
写串行EEPROM不像读操作可以连续读取很多字节,每次写操作只能在同一个page。
对于24xx02,page size = 8
简单的处理方法为:按字节写操作模式,没写1个字节,都发送地址
为了提高连续写的效率: 本函数采用page wirte操作。
*/
usAddr = _usAddress;
for (i = 0; i < _usSize; i++)
{
/* 当发送第1个字节或是页面首地址时,需要重新发起启动信号和地址 */
if ((i == 0) || (usAddr & (EEPROM_PAGE_SIZE - 1)) == 0)
{
/* 第0步:发停止信号,启动内部写操作 */
I2C_Stop();
/* 通过检查器件应答的方式,判断内部写操作是否完成, 一般小于 10ms
CLK频率为200KHz时,查询次数为30次左右
*/
for (m = 0; m < 100; m++)
{
/* 第1步:发起I2C总线启动信号 */
I2C_Start();
/* 第2步:发起控制字节,高7bit是地址,bit0是读写控制位,0表示写,1表示读 */
I2C_SendByte(EEPROM_DEV_ADDR | I2C_WR); /* 此处是写指令 */
/* 第3步:发送一个时钟,判断器件是否正确应答 */
if (I2C_WaitAck() == 0)
{
break;
}
}
if (m == 1000)
{
goto cmd_fail; /* EEPROM器件写超时 */
}
/* 第4步:发送字节地址,24C02只有256字节,因此1个字节就够了,如果是24C04以上,那么此处需要连发多个地址 */
I2C_SendByte((uint8_t)usAddr);
/* 第5步:发送ACK */
if (I2C_WaitAck() != 0)
{
goto cmd_fail; /* EEPROM器件无应答 */
}
}
/* 第6步:开始写入数据 */
I2C_SendByte(_pWriteBuf[i]);
/* 第7步:发送ACK */
if (I2C_WaitAck() != 0)
{
goto cmd_fail; /* EEPROM器件无应答 */
}
usAddr++; /* 地址增1 */
}
/* 命令执行成功,发送I2C总线停止信号 */
I2C_Stop();
return 1;
cmd_fail: /* 命令执行失败后,切记发送停止信号,避免影响I2C总线上其他设备 */
/* 发送I2C总线停止信号 */
I2C_Stop();
return 0;
}
/**
* @brief EEPROM_Erase
* @param None
* @retval None
*/
void EEPROM_Erase(void)
{
uint16_t i;
uint8_t buf[EEPROM_SIZE];
/* 填充缓冲区 */
for (i = 0; i < EEPROM_SIZE; i++)
{
buf[i] = 0xFF;
}
/* 写EEPROM, 起始地址 = 0,数据长度为 256 */
if (EEPROM_WriteBytes(buf, 0, EEPROM_SIZE) == 0)
{
printf("擦除eeprom出错!\r\n");
return;
}
else
{
printf("擦除eeprom成功!\r\n");
}
}
/**
* @brief EE_Delay
* @param nCount
* @retval None
*/
static void EEPROM_Delay(__IO uint32_t nCount) //简单的延时函数
{
for(; nCount != 0; nCount--);
}
/**
* @brief AT24C02 初始化
* @param None
* @retval None
*/
void EEPROM_Init(void)
{
/*-----------------------------------------------------------------------------------*/
if (EEPROM_CheckOk() == 0)
{
/* 没有检测到EEPROM */
printf("没有检测到串行EEPROM!\r\n");
}
}
/**
* @brief AT24C02 读写测试
* @param None
* @retval None
*/
void EEPROM_Test(void)
{
uint16_t i;
uint8_t write_buf[EEPROM_SIZE];
uint8_t read_buf[EEPROM_SIZE];
/*------------------------------------------------------------------------------------*/
/* 填充测试缓冲区 */
for (i = 0; i < EEPROM_SIZE; i++)
{
write_buf[i] = i;
}
/*------------------------------------------------------------------------------------*/
if (EEPROM_WriteBytes(write_buf, 0, EEPROM_SIZE) == 0)
{
printf("写eeprom出错!\r\n");
return;
}
else
{
printf("写eeprom成功!\r\n");
}
/*写完之后需要适当的延时再去读,不然会出错*/
EEPROM_Delay(0x0FFFFF);
/*-----------------------------------------------------------------------------------*/
if (EEPROM_ReadBytes(read_buf, 0, EEPROM_SIZE) == 0)
{
printf("读eeprom出错!\r\n");
return;
}
else
{
printf("读eeprom成功,数据如下:\r\n");
}
/*-----------------------------------------------------------------------------------*/
for (i = 0; i < EEPROM_SIZE; i++)
{
if(read_buf[i] != write_buf[i])
{
printf("0x%02X ", read_buf[i]);
printf("错误:EEPROM读出与写入的数据不一致");
return;
}
printf(" %02X", read_buf[i]);
if ((i & 15) == 15)
{
printf("\r\n");
}
}
printf("eeprom读写测试成功\r\n");
}
代码很简单,和使用硬件I2C的逻辑是一样的。
最后看下主函数吧。
/*
brief main function
param[in] none
param[out] none
retval none
*/
int main(void)
{
//systick init
sysTick_init();
// led init
led_init(LED1);
//usart init 115200 8-N-1
com_init(COM1, 115200, 0, 1);
// DMA config
usart0_dma_init();
/* USART DMA 发送使能 */
usart_dma_enable(USART0, USART_DMA_TRANSMIT);
/* USART DMA接收使能 */
usart_dma_enable(USART0, USART_DMA_RECEIVE);
printf("eeprom 软件模拟i2c测试例程 \r\n");
EEPROM_Init();
EEPROM_Test();
while(1)
{
led_toggle(LED1);
delay_ms(1000);
}
}
主函数中重点关注EEPROM_Test()函数,这就是对AT24C02的读写操作。
21.6.2实验现象
下载程序,连接串口打印信息如下。
最后,我们使用逻辑分析来查看数据
欢迎访问我的网站
BruceOu的哔哩哔哩
BruceOu的主页
BruceOu的博客
BruceOu的CSDN博客
BruceOu的简书
BruceOu的知乎
资源获取方式
1.关注公众号[嵌入式实验楼]
2.在公众号回复关键词[GD32开发实战指南]获取资料提取码