简介:STM32F103C8T6是一款基于ARM Cortex-M3内核的高性能微控制器,广泛应用于嵌入式开发。其内置的I2C通信接口可用于连接多种外设,如OLED显示屏、传感器等。本文提供的例程详细展示了如何在STM32F103C8T6上实现I2C通信功能,涵盖RCC时钟配置、GPIO引脚设置(PB6/SCL、PB7/SDA)、I2C主模式初始化及数据收发流程,并结合OLED模块实现显示控制。配套文件包含源码、工程文件及使用说明,帮助开发者快速掌握I2C协议在实际项目中的应用。
STM32F103C8T6与I2C通信技术深度实践:从协议解析到OLED驱动全栈实现
在嵌入式开发的日常中,你有没有遇到过这样的场景——明明代码写得一丝不苟,引脚接得清清楚楚,可OLED屏幕就是“黑屏”不亮?又或者I2C总线上挂了几个传感器,其中一个突然“失联”,主机读不到ACK,程序卡死在轮询里……😅
别急,这背后往往不是玄学,而是对I2C这根“两线江湖”的理解还不够透彻。今天咱们就以STM32F103C8T6为核心,深入到寄存器底层,把I2C通信的来龙去脉彻底扒个明白,并亲手实现一个SSD1306 OLED的完整驱动方案。准备好了吗?让我们从最基础但最关键的物理层开始,一步步构建起这套稳定可靠的通信体系吧!🚀
一、硬件基石:STM32F103C8T6上的I2C外设真相
STM32F103C8T6,江湖人称“蓝色药丸”,虽然身材小巧(LQFP48封装),但它内核可是正儿八经的ARM Cortex-M3,主频高达72MHz,还自带128KB Flash和20KB SRAM,在智能传感、工业控制等领域堪称性价比之王。而它内置的I2C1模块,正是我们今天要驯服的关键角色。
这个I2C1支持标准模式(100kbps)和快速模式(400kbps),通过SCL(时钟线)和SDA(数据线)两条线就能实现半双工同步通信。最大的优势是什么? 低引脚开销 + 多设备共用总线 。想象一下,你只需要两根线,就能连接温湿度传感器、EEPROM、实时时钟、OLED显示屏……简直是布线界的极简主义典范!
不过,这一切的前提是——硬件配置必须精准到位。否则,再多的软件技巧也救不了那根“罢工”的总线。🛠️
1.1 时钟使能:一切始于RCC
在STM32的世界里,所有外设都像是沉睡的巨人,必须由RCC(复位与时钟控制器)来唤醒。I2C1挂在APB1总线上(低速总线,最高36MHz),所以我们第一步就得给它通电:
// 启用I2C1时钟
RCC->APB1ENR |= RCC_APB1ENR_I2C1EN;
就这么一行代码,看似简单,却是后续所有操作的命门。如果忘了这一步,哪怕你后面把寄存器设置得再完美,I2C模块也是“聋子耳朵——摆设”。⚠️ 血泪教训提醒 :每次调试I2C失败,第一件事就是检查 RCC_APB1ENR 的第21位是否真的置1了!
flowchart TD
A[系统上电/复位] --> B{是否已使能RCC?}
B -- 是 --> C[设置RCC->APB1ENR |= I2C1EN]
C --> D[I2C1时钟激活]
D --> E[可安全访问I2C寄存器]
B -- 否 --> F[配置RCC系统时钟]
看,流程图已经告诉我们:没有时钟,就没有一切。
1.2 GPIO配置:开漏输出才是王道
接下来是GPIO配置。I2C协议要求SCL和SDA必须是 双向开漏结构 ,为什么?
因为多个设备共享同一根总线,必须避免“推挽冲突”——试想两个设备一个拉高一个拉低,岂不是直接短路?而开漏输出配合外部上拉电阻,天然实现了“线与”逻辑:谁都可以拉低,但只有上拉电阻能把线拉回高电平。
对于I2C1,默认使用PB6(SCL)和PB7(SDA)。我们需要将它们配置为“复用功能开漏输出”:
// 配置PB6(SCL)和PB7(SDA)
GPIOB->CRL &= ~(0xFF << 24); // 清除原有配置
GPIOB->CRL |= (GPIO_CRL_MODE6_1 | // 输出模式,10MHz
GPIO_CRL_CNF6_1 | // 复用开漏
GPIO_CRL_MODE7_1 |
GPIO_CRL_CNF7_1) << 24;
这里的关键参数是:
- MODE[1:0] = 10 → 最大输出速度10MHz(足够应付400kbps)
- CNF[1:0] = 10 → 复用功能开漏输出(I2C专用)
✅ 最佳实践 :即使STM32内部有弱上拉(约40kΩ),也强烈建议外加4.7kΩ~10kΩ的强上拉电阻。否则信号上升沿会变得“拖泥带水”,严重时直接违反I2C时序规范。
1.3 上拉电阻设计:不只是随便焊个电阻那么简单
说到上拉电阻,很多人觉得“差不多就行”。其实不然,它的取值直接影响通信稳定性,尤其是高速模式下。
I2C规定,标准模式下最大上升时间 $ t_r \leq 1000\,\text{ns} $。这个时间由上拉电阻 $ R_p $ 和总线电容 $ C_b $ 决定:
$$
t_r \approx 0.847 \times R_p \times C_b
$$
假设板子上有3个设备,每个贡献10pF输入电容,走线电容10pF,总共 $ C_b = 40\,\text{pF} $,则:
$$
R_p \leq \frac{1000}{0.847 \times 40} \approx 29.5\,\text{k}\Omega
$$
所以推荐值如下:
| 总线速度 | 推荐上拉电阻范围 | 典型应用 |
|---|---|---|
| 100 kbps | 4.7kΩ – 10kΩ | 多数传感器 |
| 400 kbps | 2.2kΩ – 4.7kΩ | 高速OLED、ADC |
小贴士:若MCU工作在3.3V,而其他设备支持5V容忍(5V-tolerant),可直接用3.3V上拉;否则需加电平转换电路,比如PCA9306这类芯片。
二、协议解码:I2C通信的底层语言
有了硬件基础,下一步就是搞懂I2C这门“语言”的语法规则。毕竟,如果你不知道怎么打招呼、怎么结束对话,人家从机根本不会搭理你。
2.1 起始与停止条件:会话的边界
I2C通信就像一场电话通话,有明确的开始和结束标志。
- 起始条件(START) :SCL为高时,SDA从高→低。
- 停止条件(STOP) :SCL为高时,SDA从低→高。
这两个动作只能由 主设备 发起。一旦检测到START,所有从设备都会竖起耳朵,准备接收地址。
sequenceDiagram
participant Master
participant Slave
Master->>Bus: SDA=H, SCL=H (Idle)
Master->>Bus: SDA↓ while SCL=H (START)
Master->>Slave: Send Address + R/W
Master->>Slave: Data Byte x N
Master->>Bus: SDA↑ while SCL=H (STOP)
注意:SDA的变化必须发生在SCL为低电平时,否则会被误判为START或STOP!这是模拟I2C(GPIO翻转)时最容易出错的地方。
还有一个高级技巧叫 重复起始 (Repeated START),即在一个事务中不发STOP,直接再来一次START。常用于“先写地址后读数据”的场景,比如读取某个寄存器值:
I2C_Start();
I2C_SendAddr(0x78); // 写
I2C_SendByte(reg); // 指定寄存器
I2C_Start(); // 不发STOP,直接Re-START
I2C_SendAddr(0x79); // 读
data = I2C_ReadByte();
I2C_Stop();
这样可以确保在整个过程中独占总线,避免被其他主设备打断。
| 条件类型 | SCL状态 | SDA变化方向 | 含义 |
|---|---|---|---|
| START | 高电平 | 高 → 低 | 开始一次新通信 |
| Repeated START | 高电平 | 高 → 低 | 切换操作模式,不释放总线 |
| STOP | 高电平 | 低 → 高 | 结束当前通信 |
2.2 地址帧:如何找到你的“目标设备”
I2C支持7位和10位地址模式,STM32F103常用的是7位。但要注意,实际传输的是8位字节:前7位是设备地址,第8位是读写控制位(R/W)。
例如,SSD1306的7位地址通常是 0x3C ,那么:
- 写地址 = 0x3C << 1 | 0 = 0x78
- 读地址 = 0x3C << 1 | 1 = 0x79
伪代码如下:
void I2C_SendAddress(uint8_t dev_addr_7bit, uint8_t rw) {
uint8_t addr_byte = (dev_addr_7bit << 1) | rw;
for (int i = 7; i >= 0; i--) {
if (addr_byte & (1 << i)) SDA_HIGH();
else SDA_LOW();
SCL_HIGH();
delay_us(T_HALF);
SCL_LOW();
delay_us(T_HALF);
}
// 等待ACK
SDA_INPUT_MODE();
SCL_HIGH();
uint8_t ack = !SDA_READ(); // 低电平为ACK
SCL_LOW();
SDA_OUTPUT_MODE();
}
关键点 :
- 逐位发送,MSB先行。
- 发完地址后切换SDA为输入,读取ACK。
- 若返回NACK(高电平),说明设备未响应,可能是地址错误、设备忙或断线。
2.3 数据传输:每字节9位的艺术
每次数据传输包含8位数据 + 1位ACK/NACK,共9个时钟周期。
规则很简单:
- 发送方输出8位数据(SCL上升沿采样)。
- 接收方在第9个SCL周期内拉低SDA表示ACK。
- 主接收模式下,最后一个字节通常由主机返回NACK,通知从机停止发送。
| 场景 | 发送方 | 接收方 | ACK/NACK |
|---|---|---|---|
| 主写:发送数据给从机 | 主设备 | 从设备 | 从设备应答ACK |
| 主读:从机发送数据 | 从设备 | 主设备 | 前N-1字节主设备ACK,最后一字节NACK |
| 地址错误或设备忙 | —— | 所有设备 | 无设备拉低SDA → NACK |
代码实现:
uint8_t I2C_WriteByte(uint8_t data) {
for (int i = 7; i >= 0; i--) {
SDA_WRITE((data >> i) & 0x01);
SCL_SET();
delay_us(1);
SCL_RESET();
delay_us(1);
}
SDA_IN_FLOAT();
SCL_SET();
delay_us(1);
uint8_t ack = !GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7);
SCL_RESET();
SDA_OUT_OD();
return ack;
}
2.4 时钟延展(Clock Stretching):慢速从机的生存之道
有时候,从设备处理不过来怎么办?比如EEPROM正在写入,需要几毫秒内部编程时间。这时候它可以通过 主动拉低SCL 来“拖延”主设备,这就是时钟延展。
主设备在发出SCL上升沿后,必须等待SCL真正变高才能继续,否则意味着从机还在“喘息”。
// 支持时钟延展的写函数
uint8_t I2C_WriteByte_ClockStretch(uint8_t data) {
for (int i = 7; i >= 0; i--) {
SDA_WRITE((data >> i) & 0x01);
SCL_SET();
// 等待SCL真正变高(可能被从机拉低)
uint32_t timeout = 1000;
while (!(GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_6)) && --timeout);
if (!timeout) return 0; // 超时
delay_us(1);
SCL_RESET();
delay_us(1);
}
// ... 读ACK部分略
return 1;
}
🔔 提示:STM32硬件I2C默认支持时钟延展,但在软件模拟I2C时必须手动处理超时,防止死循环。
三、主从博弈:STM32如何掌控I2C总线
在I2C世界里,STM32通常是绝对的主角——主设备。它负责发起通信、提供时钟、管理整个流程。
3.1 主发送模式:向从机写入数据
最常见的操作,比如给OLED发命令或数据。
流程:
1. START
2. 发送从机地址 + 写(R/W=0)
3. 接收ACK
4. 发送数据字节
5. 每字节后接收ACK
6. STOP
示例:向SSD1306写命令
void OLED_WriteCommand(uint8_t cmd) {
I2C_Start();
I2C_SendByte(0x78); // 写地址
if (!I2C_WaitAck()) { I2C_Stop(); return; }
I2C_SendByte(0x00); // 控制字节:命令模式
I2C_WaitAck();
I2C_SendByte(cmd);
I2C_WaitAck();
I2C_Stop();
}
其中 0x00 是控制字节,Co=0(连续模式),D/C#=0(命令)。
3.2 主接收模式:从从机读取数据
比如读取温度传感器的值。
流程更复杂一点:
1. START
2. 发送从机地址 + 写
3. 发送寄存器地址
4. Repeated START
5. 发送从机地址 + 读
6. 接收数据,前N-1字节ACK,最后一字节NACK
7. STOP
uint8_t I2C_ReadReg(uint8_t dev_addr, uint8_t reg) {
uint8_t data;
I2C_Start();
I2C_SendByte((dev_addr << 1) | 0); // 写
I2C_WaitAck();
I2C_SendByte(reg); // 指定寄存器
I2C_WaitAck();
I2C_Start(); // Re-START
I2C_SendByte((dev_addr << 1) | 1); // 读
I2C_WaitAck();
data = I2C_ReadByte(0); // 最后一字节NACK
I2C_Stop();
return data;
}
3.3 多主竞争:仲裁机制如何避免“打架”
理论上,I2C支持多主设备。当两个主设备同时发起通信时,通过 仲裁机制 解决冲突。
规则是“线与”逻辑:任何设备输出低电平,总线就是低。所以:
- 每个主设备在发送数据的同时也在监听SDA。
- 如果它发送高电平但检测到总线为低,说明别人正在发低电平,于是自动退出。
这种机制是非破坏性的,胜出者继续通信,失败者稍后再试。
graph TD
A[主1发送地址0x30] --> B[主2发送地址0x20]
B --> C{比较第一位}
C -->|主2发0, 主1发1| D[主1检测到冲突]
D --> E[主1退出仲裁]
E --> F[主2赢得总线]
优先级由地址决定——地址小者胜出。
四、实战驱动:点亮你的第一块OLED屏幕
终于到了激动人心的时刻——让SSD1306 OLED显示点东西出来!
4.1 SSD1306的I2C地址识别
先确认你的模块地址。常见的是 0x3C (写地址 0x78 ),但也可能是 0x3D ( 0x7A )。不确定?写个扫描程序:
uint8_t I2C_ScanDevice() {
for (uint8_t addr = 0x30; addr <= 0x40; addr++) {
I2C1->CR1 |= I2C_CR1_START;
while (!(I2C1->SR1 & I2C_SR1_SB));
I2C1->DR = (addr << 1);
delay_ms(1);
if (I2C1->SR1 & I2C_SR1_ADDR) {
I2C1->CR1 |= I2C_CR1_STOP;
return addr;
}
I2C1->CR1 |= I2C_CR1_STOP;
}
return 0;
}
4.2 控制字节:命令与数据的开关
SSD1306靠控制字节区分命令和数据:
- 0x00 :后续是命令
- 0x40 :后续是数据(像素)
stateDiagram-v2
[*] --> Idle
Idle --> Command_Mode: 发送0x00
Idle --> Data_Mode: 发送0x40
Command_Mode --> Command_Mode: 连续写命令(Co=0)
Data_Mode --> Data_Mode: 连续写数据(Co=0)
note right of Command_Mode
如:0xAE(关闭显示)
end note
note right of Data_Mode
写入GDDRAM,影响屏幕像素
end note
4.3 初始化序列:让屏幕“苏醒”
上电后必须执行一系列初始化命令:
const uint8_t init_cmd[] = {
0xAE, // Display OFF
0xD5, 0x80, // Set OSC Frequency
0xA8, 0x3F, // MUX Ratio: 63+1
0xD3, 0x00, // Display Offset
0x40, // Start Line
0x8D, 0x14, // Charge Pump ON
0x20, 0x02, // Page Addressing Mode
0xA1, // Segment Remap
0xC8, // COM Scan Direction
0xDA, 0x12, // COM Pins Config
0x81, 0xCF, // Contrast Control
0xD9, 0xF1, // Precharge Period
0xDB, 0x40, // VCOMH Level
0xA4, // Disable Entire Display On
0xA6, // Normal Display
0xAF, // Display ON
};
4.4 显存管理与刷新优化
直接操作GDDRAM太慢?加个本地缓冲区:
static uint8_t oled_buffer[128][8]; // 128×64 bit
void OLED_Set_Pixel(uint8_t x, uint8_t y, uint8_t color) {
uint8_t page = y / 8, bit = y % 8;
if (color) oled_buffer[x][page] |= (1 << bit);
else oled_buffer[x][page] &= ~(1 << bit);
}
void OLED_Refresh_Gram(void) {
for (uint8_t page = 0; page < 8; page++) {
OLED_Write_Command(0xB0 + page);
OLED_Write_Command(0x00);
OLED_Write_Command(0x10);
I2C_Start();
I2C_SendByte(0x78);
I2C_WaitAck();
I2C_SendByte(0x40); // 数据模式
for (int x = 0; x < 128; x++) {
I2C_SendByte(oled_buffer[x][page]);
I2C_WaitAck();
}
I2C_Stop();
}
}
五、工程落地:从代码到实物的闭环
最后,别忘了项目结构要清晰:
/Project_STM32_OLED_I2C
├── /Core
├── /Drivers
├── /Middleware/oled/
│ ├── i2c_oled.h/.c
│ ├── font.h
│ └── logo_data.h
└── /User/config/
编译烧录用Keil或GCC都行,关键是验证波形:
graph TD
A[编写C源码] --> B[配置Keil或CubeIDE]
B --> C{选择编译工具链}
C --> D[Keil: 生成.hex]
C --> E[GCC: 生成.bin]
D --> F[使用ST-Link下载]
E --> G[使用st-flash烧录]
F --> H[上电运行OLED显示]
G --> H
用示波器抓SCL/SDA,看看START、地址、ACK是不是都对得上。一旦看到屏幕亮起,那种成就感,简直比喝了一打红牛还爽!🎉
总而言之,I2C看似简单,实则暗藏玄机。从上拉电阻到时序控制,从ACK检测到缓冲区管理,每一个细节都决定了系统的成败。希望这篇文章能帮你打通任督二脉,从此不再怕“黑屏”、“NACK”这些拦路虎。继续加油,下一个项目见!💪
简介:STM32F103C8T6是一款基于ARM Cortex-M3内核的高性能微控制器,广泛应用于嵌入式开发。其内置的I2C通信接口可用于连接多种外设,如OLED显示屏、传感器等。本文提供的例程详细展示了如何在STM32F103C8T6上实现I2C通信功能,涵盖RCC时钟配置、GPIO引脚设置(PB6/SCL、PB7/SDA)、I2C主模式初始化及数据收发流程,并结合OLED模块实现显示控制。配套文件包含源码、工程文件及使用说明,帮助开发者快速掌握I2C协议在实际项目中的应用。
1819

被折叠的 条评论
为什么被折叠?



