STM32入门教程:硬件I2C读写MPU6050超详细代码解析(基于江科大课程)


⚠️ 时效性提示:本文基于 STM32F103C8T6 + 标准库(STM32F10x_StdPeriph_Lib_V3.5.0)编写,适用于 Keil5 开发环境。若你使用 HAL 库或 STM32CubeMX,配置逻辑相通但 API 接口不同,请注意区分。
一、前言
前面我们学了软件模拟 I2C,用 GPIO 手动翻转电平来模拟时序。当时我就有个疑问:既然软件 I2C 能用,为什么还要搞硬件 I2C?
答案很简单——硬件 I2C 是 STM32 内部专门的硬件外设,它自己帮我们生成起始信号、发送字节、检测应答、处理时钟同步,CPU 只需要配置好参数,然后等着硬件把活干完就行。相比软件 I2C,硬件 I2C 的时序更标准、更稳定,而且不占用 CPU 时间(配合中断或 DMA 效果更佳)。
这节课我们就来把软件 I2C 替换成硬件 I2C,目标还是驱动 MPU6050。整个替换过程可以概括为三步:
-
开启 I2C 外设时钟,初始化 GPIO 为复用开漏模式
-
用结构体配置 I2C 参数,使能外设
-
用硬件 I2C 的 EV 事件标志位,重写读写时序函数
下面一步步来拆解。
二、知识点讲解
2.1 硬件 I2C 的引脚不能随便选!
这是软件 I2C 和硬件 I2C 最大的区别。
软件 I2C 的 SCL 和 SDA 可以接任意 GPIO,因为是我们自己用代码控制电平翻转。但硬件 I2C 的引脚是芯片内部固定映射的,必须查数据手册里的复用功能表。
以 STM32F103C8T6 为例:
| I2C 外设 | SCL 引脚 | SDA 引脚 | 所在总线 |
|---|---|---|---|
| I2C1 | PB6 | PB7 | APB1 |
| I2C2 | PB10 | PB11 | APB1 |
江科大课程里,PB6、PB7 被 OLED 占用了,所以 MPU6050 接的是 PB10(SCL)和 PB11(SDA),对应 I2C2。
⚠️这一点一定要记牢!不是你想接哪就接哪。
2.2 GPIO 为什么要配成"复用开漏"?
I2C 协议要求总线是线与逻辑——也就是说,只有当所有设备都输出高电平时,总线才是高电平;只要有一个设备拉低,总线就是低电平。
开漏输出的特点是:输出高电平时实际上是高阻态,电平由外部上拉电阻决定;输出低电平时直接接地。这就天然支持了"线与"。
而 复用(Alternate Function) 的意思是:把引脚的控制权交给内部的 I2C 硬件外设,而不是普通的 GPIO 寄存器。
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD; // 复用开漏输出,I2C 专用模式
2.3 I2C 初始化结构体六大参数详解
标准库给我们封装好了 I2C_InitTypeDef 结构体,六个成员看起来多,其实一个个拆开很好懂:
typedef struct
{
uint32_t I2C_ClockSpeed; // SCL 时钟频率,单位 Hz,最大 400000(400KHz)
uint16_t I2C_Mode; // 工作模式:I2C 模式或 SMBus 模式
uint16_t I2C_DutyCycle; // 快速模式下 SCL 占空比(Tlow/Thigh)
uint16_t I2C_OwnAddress1; // STM32 自身作为从机时的设备地址
uint16_t I2C_Ack; // 应答使能:接收数据后是否自动回复 ACK
uint16_t I2C_AcknowledgedAddress; // 地址长度:7 位地址或 10 位地址
} I2C_InitTypeDef;
我的理解:
-
I2C_ClockSpeed:就是设置 SCL 跳得多快。MPU6050 支持最高 400KHz,但新手建议先配 100KHz(标准模式),等调通了再往上升。 -
I2C_DutyCycle:快速模式(400KHz)下才需要关心,标准模式(100KHz)随便选。选I2C_DutyCycle_2就行,意思是低电平时间是高电平的 2 倍。 -
I2C_OwnAddress1:我们 STM32 当主机,这个填0x00就行,从机模式才用得上。 -
I2C_Ack:必须使能! 否则你读完一个字节,STM32 不会回 ACK,从机以为你出错了,直接罢工。
2.4 硬件 I2C 的"EV 事件"是什么鬼?
这是硬件 I2C 最让新手头疼的地方。软件 I2C 是我们自己 bit-banging,每一步干了什么心里门儿清。但硬件 I2C 是个黑盒,我们只能通过读取状态标志位来判断硬件执行到哪一步了。
ST 官方把这些状态标志位封装成了"事件(Event)",常见的事件有:
表格
| 事件名 | 含义 | 什么时候产生 |
|---|---|---|
| EV5 | 起始条件已发送(SB=1) | 调用 I2C_GenerateSTART() 后 |
| EV6 | 地址已发送并收到 ACK(ADDR=1) | 发送完从机地址+读写位后 |
| EV8 | 数据寄存器空,可以发下一个字节(TxE=1) | 发送数据写入 DR 寄存器后 |
| EV8_2 | 最后一个字节发送完毕(BTF=1) | 发送停止前检查 |
| EV7 | 数据寄存器非空,可以读取(RxNE=1) | 接收到数据后 |
我的理解:你可以把 EV 事件想象成"硬件给你发的微信消息"。比如你说"发送起始信号",硬件回你一条"EV5:起始信号已发出",你确认收到后再说"发送地址",硬件再回你"EV6:地址已发送且对方回了 ACK"……就这样一问一答,不能跳步。
三、个人理解 / 心得
-
关于时钟配置:STM32 的 I2C 挂在 APB1 总线上,而 APB1 的时钟默认是 36MHz(系统时钟 72MHz 二分频)。这个 36MHz 是 I2C 外设计算 CCR 分频值的基准,不需要手动算 CCR,标准库的
I2C_Init()函数会根据你填的I2C_ClockSpeed自动算好。这一点比寄存器开发省心太多了。 -
关于"复用开漏"和"普通开漏"的区别:我一开始以为
GPIO_Mode_Out_OD(普通开漏)也能用,毕竟也是开漏。但实际上,普通开漏是由 GPIO 输出数据寄存器控制的,而 I2C 的时序是由硬件外设自动生成的,必须用GPIO_Mode_AF_OD把控制权交给硬件。这是很多人调不通硬件 I2C 的隐藏雷区。 -
关于 ACK 时机的把控:读数据的时候,必须在读最后一个字节之前,先关闭 ACK 并发送 STOP。如果顺序搞反了,总线会卡死。这个时序在参考手册里有严格说明,代码里一定要按规矩来。
四、踩坑记录
| 坑点 | 现象 | 解决方案 |
|---|---|---|
| 引脚选错 | 代码逻辑全对,但 MPU6050 无应答 | 查数据手册,确认 I2C 复用引脚,不能随意指定 |
| GPIO 模式错配成 Out_OD | I2C 时序完全紊乱,示波器看波形不对 | 必须设为 GPIO_Mode_AF_OD(复用开漏) |
| 忘记使能 I2C 外设时钟 | 所有寄存器操作无效,程序卡死在等待 EV 事件 | RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE) 别漏 |
| 读数据时 ACK 关闭时机错误 | 读最后一个字节后总线死锁,后续通信失败 | 倒数第二个字节读完后关 ACK + 发 STOP,再读最后一个 |
五、完整代码实现
硬件代码基本和软件的实现差不多,接线图也和我之前写的软件配置的接线图一样。
5.1 MPU6050_Reg.h(头文件)
#ifndef __MPU6050_H
#define __MPU6050_H
#include "stm32f10x.h" // 包含 STM32 标准库头文件,提供寄存器定义和库函数声明
//======================== 宏定义区:MPU6050 寄存器地址 ========================
#define MPU6050_SMPLRT_DIV 0x19 // 采样率分频寄存器地址:控制数据输出速率
#define MPU6050_CONFIG 0x1A // 配置寄存器地址:设置数字低通滤波器(DLPF)
#define MPU6050_GYRO_CONFIG 0x1B // 陀螺仪配置寄存器地址:设置量程和自检
#define MPU6050_ACCEL_CONFIG 0x1C // 加速度计配置寄存器地址:设置量程和自检
#define MPU6050_ACCEL_XOUT_H 0x3B // 加速度计 X 轴高 8 位数据寄存器地址
#define MPU6050_ACCEL_XOUT_L 0x3C // 加速度计 X 轴低 8 位数据寄存器地址
#define MPU6050_ACCEL_YOUT_H 0x3D // 加速度计 Y 轴高 8 位数据寄存器地址
#define MPU6050_ACCEL_YOUT_L 0x3E // 加速度计 Y 轴低 8 位数据寄存器地址
#define MPU6050_ACCEL_ZOUT_H 0x3F // 加速度计 Z 轴高 8 位数据寄存器地址
#define MPU6050_ACCEL_ZOUT_L 0x40 // 加速度计 Z 轴低 8 位数据寄存器地址
#define MPU6050_TEMP_OUT_H 0x41 // 温度传感器高 8 位数据寄存器地址
#define MPU6050_TEMP_OUT_L 0x42 // 温度传感器低 8 位数据寄存器地址
#define MPU6050_GYRO_XOUT_H 0x43 // 陀螺仪 X 轴高 8 位数据寄存器地址
#define MPU6050_GYRO_XOUT_L 0x44 // 陀螺仪 X 轴低 8 位数据寄存器地址
#define MPU6050_GYRO_YOUT_H 0x45 // 陀螺仪 Y 轴高 8 位数据寄存器地址
#define MPU6050_GYRO_YOUT_L 0x46 // 陀螺仪 Y 轴低 8 位数据寄存器地址
#define MPU6050_GYRO_ZOUT_H 0x47 // 陀螺仪 Z 轴高 8 位数据寄存器地址
#define MPU6050_GYRO_ZOUT_L 0x48 // 陀螺仪 Z 轴低 8 位数据寄存器地址
#define MPU6050_PWR_MGMT_1 0x6B // 电源管理寄存器 1 地址:控制睡眠模式和时钟源
#define MPU6050_PWR_MGMT_2 0x6C // 电源管理寄存器 2 地址:控制各轴待机
#define MPU6050_WHO_AM_I 0x75 // 设备 ID 寄存器地址:固定返回 0x68,用于检测器件是否存在
#define MPU6050_ADDRESS 0xD0 // MPU6050 的 I2C 写地址(0x68 << 1 | 0)
#endif
5.2 MPU6050.c(源文件)
#include "stm32f10x.h" // Device header
#include "MPU6050_Reg.h"
// MPU6050器件地址 A0引脚拉低=0xD0
#define MPU6050_ADDRESS 0xD0
void MPU6050_WaitEvent(I2C_TypeDef* I2Cx, uint32_t I2C_EVENT)
{
uint32_t Timeout;
Timeout = 10000; //给定超时计数时间
while (I2C_CheckEvent(I2Cx, I2C_EVENT) != SUCCESS) //循环等待指定事件
{
Timeout --; //等待时,计数值自减
if (Timeout == 0) //自减到0后,等待超时
{
/*超时的错误处理代码,可以添加到此处*/
break; //跳出等待,不等了
}
}
}
/**
* 函 数:MPU6050写寄存器
* 参 数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述
* 参 数:Data 要写入寄存器的数据,范围:0x00~0xFF
* 返 回 值:无
*/
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
I2C_GenerateSTART(I2C2, ENABLE); //硬件I2C生成起始条件
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT); //等待EV5
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter); //硬件I2C发送从机地址,方向为发送
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED); //等待EV6
I2C_SendData(I2C2, RegAddress); //硬件I2C发送寄存器地址
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING); //等待EV8
I2C_SendData(I2C2, Data); //硬件I2C发送数据
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED); //等待EV8_2
I2C_GenerateSTOP(I2C2, ENABLE); //硬件I2C生成终止条件
}
/**
* 函 数:MPU6050读寄存器
* 参 数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述
* 返 回 值:读取寄存器的数据,范围:0x00~0xFF
*/
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
uint8_t Data;
I2C_GenerateSTART(I2C2, ENABLE); //硬件I2C生成起始条件
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT); //等待EV5
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter); //硬件I2C发送从机地址,方向为发送
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED); //等待EV6
I2C_SendData(I2C2, RegAddress); //硬件I2C发送寄存器地址
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED); //等待EV8_2
I2C_GenerateSTART(I2C2, ENABLE); //硬件I2C生成重复起始条件
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT); //等待EV5
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Receiver); //硬件I2C发送从机地址,方向为接收
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED); //等待EV6
I2C_AcknowledgeConfig(I2C2, DISABLE); //在接收最后一个字节之前提前将应答失能
I2C_GenerateSTOP(I2C2, ENABLE); //在接收最后一个字节之前提前申请停止条件
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_RECEIVED); //等待EV7
Data = I2C_ReceiveData(I2C2); //接收数据寄存器
I2C_AcknowledgeConfig(I2C2, ENABLE); //将应答恢复为使能,为了不影响后续可能产生的读取多字节操作
return Data;
}
/**
* 函 数:MPU6050初始化
* 参 数:无
* 返 回 值:无
*/
void MPU6050_Init(void)
{
/*开启时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE); //开启I2C2的时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); //开启GPIOB的时钟
/*GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure); //将PB10和PB11引脚初始化为复用开漏输出
/*I2C初始化*/
I2C_InitTypeDef I2C_InitStructure; //定义结构体变量
I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; //模式,选择为I2C模式
I2C_InitStructure.I2C_ClockSpeed = 50000; //时钟速度,选择为50KHz
I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; //时钟占空比,选择Tlow/Thigh = 2
I2C_InitStructure.I2C_Ack = I2C_Ack_Enable; //应答,选择使能
I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; //应答地址,选择7位,从机模式下才有效
I2C_InitStructure.I2C_OwnAddress1 = 0x00; //自身地址,从机模式下才有效
I2C_Init(I2C2, &I2C_InitStructure); //将结构体变量交给I2C_Init,配置I2C2
/*I2C使能*/
I2C_Cmd(I2C2, ENABLE); //使能I2C2,开始运行
/*MPU6050寄存器初始化,需要对照MPU6050手册的寄存器描述配置,此处仅配置了部分重要的寄存器*/
MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01); //电源管理寄存器1,取消睡眠模式,选择时钟源为X轴陀螺仪
MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00); //电源管理寄存器2,保持默认值0,所有轴均不待机
MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09); //采样率分频寄存器,配置采样率
MPU6050_WriteReg(MPU6050_CONFIG, 0x06); //配置寄存器,配置DLPF
MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18); //陀螺仪配置寄存器,选择满量程为±2000°/s
MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18); //加速度计配置寄存器,选择满量程为±16g
}
/**
* 函 数:MPU6050获取ID号
* 参 数:无
* 返 回 值:MPU6050的ID号
*/
uint8_t MPU6050_GetID(void)
{
return MPU6050_ReadReg(MPU6050_WHO_AM_I); //返回WHO_AM_I寄存器的值
}
/**
* 函 数:MPU6050获取数据
* 参 数:AccX AccY AccZ 加速度计X、Y、Z轴的数据,使用输出参数的形式返回,范围:-32768~32767
* 参 数:GyroX GyroY GyroZ 陀螺仪X、Y、Z轴的数据,使用输出参数的形式返回,范围:-32768~32767
* 返 回 值:无
*/
void MPU6050_GetData(int16_t *AccX, int16_t *AccY, int16_t *AccZ,
int16_t *GyroX, int16_t *GyroY, int16_t *GyroZ)
{
uint8_t DataH, DataL; //定义数据高8位和低8位的变量
DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H); //读取加速度计X轴的高8位数据
DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L); //读取加速度计X轴的低8位数据
*AccX = (DataH << 8) | DataL; //数据拼接,通过输出参数返回
DataH = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_H); //读取加速度计Y轴的高8位数据
DataL = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_L); //读取加速度计Y轴的低8位数据
*AccY = (DataH << 8) | DataL; //数据拼接,通过输出参数返回
DataH = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_H); //读取加速度计Z轴的高8位数据
DataL = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_L); //读取加速度计Z轴的低8位数据
*AccZ = (DataH << 8) | DataL; //数据拼接,通过输出参数返回
DataH = MPU6050_ReadReg(MPU6050_GYRO_XOUT_H); //读取陀螺仪X轴的高8位数据
DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L); //读取陀螺仪X轴的低8位数据
*GyroX = (DataH << 8) | DataL; //数据拼接,通过输出参数返回
DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H); //读取陀螺仪Y轴的高8位数据
DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L); //读取陀螺仪Y轴的低8位数据
*GyroY = (DataH << 8) | DataL; //数据拼接,通过输出参数返回
DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H); //读取陀螺仪Z轴的高8位数据
DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L); //读取陀螺仪Z轴的低8位数据
*GyroZ = (DataH << 8) | DataL; //数据拼接,通过输出参数返回
}
5.3 main.c(主函数调用示例)
#include "stm32f10x.h" // 包含 STM32 标准库头文件
#include "OLED.h" // 包含 OLED 显示驱动头文件(用于数据显示,江科大课程配套)
#include "MPU6050.h" // 包含 MPU6050 驱动头文件
int main(void)
{
OLED_Init(); // 初始化 OLED 显示屏,准备显示加速度/陀螺仪数据
MPU6050_Init(); // 初始化 I2C2 外设和 MPU6050 寄存器,配置完成后传感器开始工作
int16_t AX, AY, AZ, GX, GY, GZ; // 定义 6 个 16 位有符号整型变量,分别存储三轴加速度和三轴陀螺仪数据
while (1) // 进入无限循环,持续读取并显示数据
{
MPU6050_GetData(&AX, &AY, &AZ, &GX, &GY, &GZ); // 调用函数读取 MPU6050 的 6 轴原始数据,通过指针传参回写
OLED_ShowSignedNum(1, 1, AX, 5); // 在第 1 行第 1 列显示加速度 X 轴数据,占 5 位宽度,带符号
OLED_ShowSignedNum(2, 1, AY, 5); // 在第 2 行第 1 列显示加速度 Y 轴数据
OLED_ShowSignedNum(3, 1, AZ, 5); // 在第 3 行第 1 列显示加速度 Z 轴数据
OLED_ShowSignedNum(1, 8, GX, 5); // 在第 1 行第 8 列显示陀螺仪 X 轴数据
OLED_ShowSignedNum(2, 8, GY, 5); // 在第 2 行第 8 列显示陀螺仪 Y 轴数据
OLED_ShowSignedNum(3, 8, GZ, 5); // 在第 3 行第 8 列显示陀螺仪 Z 轴数据
}
}
六、总结表格
| 对比维度 | 软件模拟 I2C | 硬件 I2C |
|---|---|---|
| 引脚选择 | 任意 GPIO,灵活 | 固定复用引脚,需查表 |
| GPIO 模式 | 开漏输出 Out_OD |
复用开漏 AF_OD |
| 时序控制 | 代码 bit-banging,CPU 全程参与 | 硬件自动生成,CPU 等待事件即可 |
| 稳定性 | 受中断和延时影响,时序可能抖动 | 硬件时序精准,抗干扰强 |
| 代码复杂度 | 需手动实现 Start/Stop/ACK 等 | 调用库函数,关注 EV 事件标志位 |
| CPU 占用 | 高,发送 1 字节需多次翻转 GPIO | 低,配置后硬件自动完成 |
| 适用场景 | 引脚受限、简单调试 | 正式项目、高速通信、多设备总线 |
更多工程代码:STM32_Project: 基于江科大的STM32的教程编写的程序
七、验证方式
-
硬件验证:OLED 正常显示 6 轴数据,晃动开发板时 AX/AY/AZ 数值明显变化,静止时 AZ 约为 16384(±2g 量程下对应 1g)。
-
软件验证:在
MPU6050_Init()后读取WHO_AM_I寄存器(0x75),应返回固定值0x68,可单步调试确认。 -
示波器验证:SCL 频率约为 50KHz,SDA 波形在 ACK 位有被拉低的低电平脉冲。
写这篇文章的时候,我把江科大的视频又回顾了一遍,发现硬件 I2C 的难点其实不在代码量,而在对 EV 事件和时序的理解。软件 I2C 是我们自己掌控每一步,硬件 I2C 则是"委托"给外设,我们要学会"看标志位办事"。刚开始可能会觉得 EV5、EV6 这些事件很抽象,但对照参考手册里的时序图多看几遍,就会明白硬件其实就是在帮我们做软件 I2C 里那些 MyI2C_W_SDA()、MyI2C_W_SCL() 的事情,只是它做得更快、更准。
从软件 I2C 过渡到硬件 I2C,是 STM32 学习中很重要的一步。它让我们开始真正使用芯片内部的硬件外设,而不是把 MCU 当做一个单纯的 GPIO 控制器来用。希望这篇笔记能帮到你,如果有哪里写错了,欢迎评论区指正!
📌 开发环境:Keil5 + STM32F103C8T6 最小系统板 + ST-Link
📌 参考资料:江科大 STM32 入门教程、《STM32F10xxx 参考手册》
📌 前置文章:江科大STM32入门:硬件I2C外设结构与通信时序深度解析
如果本文对你有帮助,欢迎点赞 👍 收藏 ⭐!有任何问题欢迎在评论区交流,一起学习进步!

更多推荐

所有评论(0)