ESP32 I2C 保姆级教程:协议时序 + 官方例程拆解 + XL9555 工程落地完整源码

上期我们介绍了ota,实际只介绍了ota的设备端部分,生产环境的ota需要有云端或者上位机一起配合,单方面在设备端做了ota功能,并不能产生实际的价值;而且ota属于升级功能,这部分实现仅仅是单纯的升级功能的本身实现,但实际工程项目和商业产品中,升级是一个大的系统工程,包含升级前后软硬的功能兼容性(协议、数据库等),软件版本管理,固件可信,升级计划,过程管理及监控等等,不是一两期内容能覆盖全的,有机会后面再说明。

下面我们介绍新的内容:i2c

实物图

原理图

Pasted image 20260621111405.png

Pasted image 20260621105954.png

ESP32通过3个管脚和XL9555相连:

1️⃣ SDA:ESP32-S3的IO41和XL9555的SDA相连

2️⃣ SCL:ESP32-S3的IO42和XL9555的SCL相连

3️⃣ INT:ESP32-S3的IO0通过跳线帽连接XL9555的INT

ESP32-IDF示例代码解析

  1. 使用peripherals/i2c/i2c_basic工程
  1. 分析示例代码:
cpp 复制代码
/*
 * SPDX-FileCopyrightText: 2024 Espressif Systems (Shanghai) CO LTD
 *
 * SPDX-License-Identifier: Unlicense OR CC0-1.0
 */
/* ==========================================================================
 * I2C 简单示例
 *
 * 本示例展示如何使用 ESP-IDF 的 I2C Master 驱动:
 *   1. 初始化 I2C 主机总线
 *   2. 通过 I2C 总线读写外部传感器(MPU9250)的寄存器
 *
 * 硬件连接:
 *   - ESP32 SDA 引脚 <---> MPU9250 SDA 引脚
 *   - ESP32 SCL 引脚 <---> MPU9250 SCL 引脚
 *   - 无需外接上拉电阻(驱动已启用内部上拉)
 *
 * 传感器简介:
 *   MPU9250 是一款 9 轴惯性测量单元(IMU),集成了三轴加速度计、
 *   三轴陀螺仪和三轴磁力计。
 * ========================================================================== */

#include <stdio.h>
#include "sdkconfig.h"              /* Kconfig 自动生成的配置头文件 */
#include "freertos/FreeRTOS.h"      /* FreeRTOS 基础定义 */
#include "freertos/task.h"          /* FreeRTOS 任务相关 API */
#include "esp_log.h"                /* ESP-IDF 日志输出模块 */
#include "driver/i2c_master.h"      /* I2C 主机驱动 API */

static const char *TAG = "example"; /* 日志标签,用于 ESP_LOGI 等宏 */
/* ==========================================================================
 * I2C 主机配置宏(部分值来自 menuconfig 图形化配置)
 * ========================================================================== */
#define I2C_MASTER_SCL_IO           CONFIG_I2C_MASTER_SCL       /*!< I2C 时钟线 GPIO 编号 */
#define I2C_MASTER_SDA_IO           CONFIG_I2C_MASTER_SDA       /*!< I2C 数据线 GPIO 编号 */
#define I2C_MASTER_NUM              I2C_NUM_0                   /*!< 使用 I2C 控制器 0 */
#define I2C_MASTER_FREQ_HZ          CONFIG_I2C_MASTER_FREQUENCY /*!< I2C 时钟频率(默认 400kHz) */
#define I2C_MASTER_TX_BUF_DISABLE   0                           /*!< 禁用发送缓冲区(不使用异步传输) */
#define I2C_MASTER_RX_BUF_DISABLE   0                           /*!< 禁用接收缓冲区(不使用异步传输) */
#define I2C_MASTER_TIMEOUT_MS       1000                        /*!< I2C 操作超时时间(毫秒) */

/* ==========================================================================
 * MPU9250 传感器寄存器定义
 * ========================================================================== */

#define MPU9250_SENSOR_ADDR         0x68        /*!< MPU9250 的 7 位 I2C 从机地址(AD0 引脚接地) */
#define MPU9250_WHO_AM_I_REG_ADDR   0x75        /*!< WHO_AM_I 寄存器地址,上电默认值为 0x71 */
#define MPU9250_PWR_MGMT_1_REG_ADDR 0x6B        /*!< 电源管理 1 寄存器地址,bit7 写 1 触发复位 */
#define MPU9250_RESET_BIT           7           /*!< 电源管理寄存器中的复位位(第 7 位) */

 /* ==========================================================================
 * 函数:mpu9250_register_read
 * --------------------------------------------------------------------------
 * @brief  从 MPU9250 的指定寄存器读取一段连续数据
 *
 * @param  dev_handle  I2C 设备句柄(由 i2c_master_bus_add_device 创建)
 * @param  reg_addr    要读取的起始寄存器地址
 * @param  data        接收数据的缓冲区指针
 * @param  len         要读取的字节数
 * @return esp_err_t   ESP_OK 表示成功,其他值表示失败
 *
 * 说明:
 *   该函数使用 i2c_master_transmit_receive 实现典型的 I2C 读操作:
 *     1. 主机先发送 1 字节的寄存器地址(写操作)
 *     2. 随后立即从从机读取 len 字节数据(读操作)
 *   超时时间由 I2C_MASTER_TIMEOUT_MS 宏定义,转换为 FreeRTOS tick。
 * ========================================================================== */
static esp_err_t mpu9250_register_read(i2c_master_dev_handle_t dev_handle, uint8_t reg_addr, uint8_t *data, size_t len)
{
    return i2c_master_transmit_receive(dev_handle, &reg_addr, 1, data, len, I2C_MASTER_TIMEOUT_MS / portTICK_PERIOD_MS);
}

/* ==========================================================================
 * 函数:mpu9250_register_write_byte
 * --------------------------------------------------------------------------
 * @brief  向 MPU9250 的指定寄存器写入一个字节
 *
 * @param  dev_handle  I2C 设备句柄
 * @param  reg_addr    目标寄存器地址
 * @param  data        要写入的 1 字节数据
 * @return esp_err_t   ESP_OK 表示成功,其他值表示失败
 *
 * 说明:
 *   将寄存器地址和数据拼接成 2 字节数组后,通过 i2c_master_transmit
 *   一次性发送。这是 I2C 写操作的典型流程。
 * ========================================================================== */

static esp_err_t mpu9250_register_write_byte(i2c_master_dev_handle_t dev_handle, uint8_t reg_addr, uint8_t data)
{
    uint8_t write_buf[2] = {reg_addr, data};  /* 第 1 字节:寄存器地址,第 2 字节:数据 */
    return i2c_master_transmit(dev_handle, write_buf, sizeof(write_buf), I2C_MASTER_TIMEOUT_MS / portTICK_PERIOD_MS);
}

/* ==========================================================================
 * 函数:i2c_master_init
 * --------------------------------------------------------------------------
 * @brief  初始化 I2C 主机总线并添加 MPU9250 设备
 *
 * @param[out] bus_handle  返回 I2C 总线句柄
 * @param[out] dev_handle  返回 I2C 设备句柄
 *
 * 步骤说明:
 *   1. 配置 I2C 总线参数(引脚、时钟源、毛刺滤波、内部上拉等)
 *   2. 调用 i2c_new_master_bus 创建总线实例
 *   3. 配置设备参数(7 位地址模式、从机地址、时钟速率)
 *   4. 调用 i2c_master_bus_add_device 将 MPU9250 挂载到总线上
 *
 * 注意:
 *   - 所有 ESP_ERROR_CHECK 宏在出错时会直接 abort,便于调试
 *   - 本示例启用了内部上拉电阻,无需外接上拉
 * ========================================================================== */

static void i2c_master_init(i2c_master_bus_handle_t *bus_handle, i2c_master_dev_handle_t *dev_handle)
{
    /* --- 1. 配置 I2C 总线参数 --- */
    i2c_master_bus_config_t bus_config = {
        .i2c_port      = I2C_MASTER_NUM,              /* 使用 I2C_NUM_0 控制器 */
        .sda_io_num    = I2C_MASTER_SDA_IO,           /* SDA 数据引脚编号 */
        .scl_io_num    = I2C_MASTER_SCL_IO,           /* SCL 时钟引脚编号 */
        .clk_source    = I2C_CLK_SRC_DEFAULT,         /* 使用默认时钟源 */
        .glitch_ignore_cnt = 7,                       /* 毛刺滤波计数(过滤短脉冲干扰) */
        .flags.enable_internal_pullup = true,          /* 启用 GPIO 内部上拉电阻 */
    };
    /* --- 2. 创建 I2C 主机总线 --- */
    ESP_ERROR_CHECK(i2c_new_master_bus(&bus_config, bus_handle));

    /* --- 3. 配置从设备参数 --- */
    i2c_device_config_t dev_config = {
        .dev_addr_length = I2C_ADDR_BIT_LEN_7,        /* 使用 7 位地址模式 */
        .device_address  = MPU9250_SENSOR_ADDR,       /* MPU9250 的 I2C 地址 0x68 */
        .scl_speed_hz    = I2C_MASTER_FREQ_HZ,        /* I2C 时钟频率 */
    };

    /* --- 4. 将 MPU9250 设备挂载到总线上 --- */
    ESP_ERROR_CHECK(i2c_master_bus_add_device(*bus_handle, &dev_config, dev_handle));
}

  

/* ==========================================================================
 * 函数:app_main
 * --------------------------------------------------------------------------
 * @brief  FreeRTOS 应用程序入口点
 *
 * 执行流程:
 *   1. 初始化 I2C 总线及 MPU9250 设备
 *   2. 读取 WHO_AM_I 寄存器,验证通信正常(期望值 0x71)
 *   3. 向电源管理寄存器写复位命令,演示写操作
 *   4. 卸载设备、删除总线,释放资源
 * ========================================================================== */

void app_main(void)
{
    uint8_t data[2];                          /* 通用数据缓冲区 */
    i2c_master_bus_handle_t bus_handle;       /* I2C 总线句柄 */
    i2c_master_dev_handle_t dev_handle;       /* I2C 设备句柄 */

    /* ---- 第 1 步:初始化 I2C 总线和设备 ---- */
    i2c_master_init(&bus_handle, &dev_handle);
    ESP_LOGI(TAG, "I2C initialized successfully"); 

    /* ---- 第 2 步:读取 WHO_AM_I 寄存器 ---- */
    /* MPU9250 上电后 WHO_AM_I 寄存器默认值为 0x71,用于验证通信是否正常 */
    ESP_ERROR_CHECK(mpu9250_register_read(dev_handle, MPU9250_WHO_AM_I_REG_ADDR, data, 1));
    ESP_LOGI(TAG, "WHO_AM_I = %X", data[0]);

    /* ---- 第 3 步:通过写寄存器复位 MPU9250 ---- */
    /* 向 PWR_MGMT_1 寄存器的 bit7 写入 1,触发设备复位 */
    ESP_ERROR_CHECK(mpu9250_register_write_byte(dev_handle, MPU9250_PWR_MGMT_1_REG_ADDR, 1 << MPU9250_RESET_BIT));
  
    /* ---- 第 4 步:反初始化,释放资源 ---- */
    ESP_ERROR_CHECK(i2c_master_bus_rm_device(dev_handle));  /* 从总线移除设备 */
    ESP_ERROR_CHECK(i2c_del_master_bus(bus_handle));        /* 删除总线实例 */
    ESP_LOGI(TAG, "I2C de-initialized successfully");
}
  1. 配置
  2. 调试
    由于和我们开发板上的芯片不一样,这里就不演示了。
    结果为:
bash 复制代码
I (328) example: I2C initialized successfully
I (338) example: WHO_AM_I = 71
I (338) example: I2C de-initialized successfully

关键知识点

硬件

基础知识

  • 全称:Inter-Integrated Circuit,集成电路间总线,飞利浦推出的低速串行通信协议。
  • 物理线 :仅两根信号线 + GND
    • SDA:数据线,双向传输(半双工)
    • SCL:时钟线,由主机产生时钟
  • 拓扑 :多主机、多从机总线结构,一根总线可挂载多个设备,靠7 位 / 10 位从机地址区分。
  • 电平 :开漏输出,必须外部上拉电阻(典型 4.7kΩ);总线空闲时 SDA、SCL 均为高电平。
    注意:若没有上拉电阻,总线就是悬空的,什么也传不了。
  • 典型速率
    • 标准模式:100kHz
    • 快速模式:400kHz
    • 高速模式:1MHz、3.4MHz、5Mhz
  • 适合场景
    • 传感器(温湿度、气压、IMU、环境光)
    • EEPROM(AT24C64 等小容量存储)
    • RTC 芯片(DS3231 等)
    • GPIO 扩展芯片(PCF8574)

1982 年,飞利浦的工程师面对一个问题:

电视机里有几十颗芯片,总线接线已经乱成一锅粥。

他们需要一种只用两根线就能管所有芯片的协议。

I2C 就是这样诞生的。

协议时序

起始信号、停止信号
  1. 起始信号(S)
    SCL 高电平时,SDA 由高电平跳变到低电平,代表一次通信开始。
  2. 重复起始信号(Sr)
    不发停止,直接再次发起始,常用于连续读写、切换读写方向。
  3. 停止信号(P)
    SCL 高电平时,SDA 由低电平跳变到高电平,结束本次传输,总线回归空闲。
数据位传输规则
  • 每一个数据位,在 SCL 高电平期间采样 SDA;SCL 低电平期间允许 SDA 电平翻转。
  • 一字节固定 8bit,高位先行(MSB first)。
  • 每发送完 1 字节(8 位),接收方必须回复 应答位 ACK/NACK (第 9 个时钟)
    • ACK(应答,0):接收方拉低 SDA,表示收到数据
    • NACK(非应答,1):SDA 保持高,代表未接收、通信结束
总线时序图

① 起始信号

当 SCL 为高电平期间,SDA 由高到低的跳变。起始信号是一种电平跳变时序信号,而不是 一个电平信号。该信号由主机发出,在起始信号产生后,总线就处于被占用状态,准备数据传 输。

② 停止信号

当 SCL 为高电平期间,SDA 由低到高的跳变。停止信号也是一种电平跳变时序信号,而不 是一个电平信号。该信号由主机发出,在停止信号发出后,总线就处于空闲状态。

③ 应答信号

发送器每发送一个字节,就在时钟脉冲 9 期间释放数据线,由接收器反馈一个应答信号。 应答信号为低电平时,规定为有效应答位(ACK 简称应答位),表示接收器已经成功地接收了 该字节;应答信号为高电平时,规定为非应答位(NACK),一般表示接收器接收该字节没有成 功。 观察上图标号③就可以发现,有效应答的要求是从机在第 9 个时钟脉冲之前的低电平期间 将 SDA 线拉低,并且确保在该时钟的高电平期间为稳定的低电平。如果接收器是主机,则在它 收到最后一个字节后,发送一个 NACK信号,以通知被控发送器结束数据发送,并释放 SDA线, 以便主机接收器发送一个停止信号。

④ 数据有效性

I2C总线进行数据传送时,时钟信号为高电平期间,数据线上的数据必须保持稳定,只有在时钟线上的信号为低电平期间,数据线上的高电平或低电平状态才允许变化。数据在 SCL 的上升沿到来之前就需准备好。并在下降沿到来之前必须稳定。

⑤ 数据传输

在I2C总线上传送的每一位数据都有一个时钟脉冲相对应(或同步控制),即在 SCL串行时钟的配合下,在 SDA上逐位地串行传送每一位数据。数据位的传输是边沿触发。

⑥ 空闲状态

I2C总线的SDA和SCL两条信号线同时处于高电平时,规定为总线的空闲状态。此时各个 器件的输出级场效应管均处在截止状态,即释放总线,由两条信号线各自的上拉电阻把电平拉高。

基本读写过程
  • 写操作

主机首先在 I2C 总线上发送起始信号,那么这时总线上的从机都会等待接收由主机发出的 数据。主机接着发送从机地址+0(写操作)组成的 8bit 数据,所有从机接收到该 8bit 数据后, 自行检验是否是自己的设备的地址,假如是自己的设备地址,那么从机就会发出应答信号。主机在总线上接收到有应答信号后,才能继续向从机发送数据。

注意:I2C 总线上传送的数据信号是广义的,既包括地址信号,又包括真正的数据信号。

写数据流程:起始 → 从机地址 (7bit)+ 读写位 (0 写) → 等待 ACK → 内部寄存器地址 → ACK → 发送数据字节 → ACK...... → 停止

  • 读操作

主机向从机读取数据的操作,一开始的操作与写操作有点相似,观察两个图也可以发现, 都是由主机发出起始信号,接着发送从机地址+1(读操作)组成的 8bit 数据,从机接收到数据验证是否是自身的地址。 那么在验证是自己的设备地址后,从机就会发出应答信号,并向主机 返回8bit 数据,发送完之后从机就会等待主机的应答信号。假如主机一直返回应答信号,那么 从机可以一直发送数据,也就是图中的(n byte + 应答信号)情况,直到主机发出非应答信号, 从机才会停止发送数据。

完整的读数据流程:起始 → 从地址 + 写位(0) → ACK → 寄存器地址 → ACK → 重复起始 → 从地址 + 读位 (1) → ACK → 读取数据 → 主机发 NACK 终止 → 停止

软件

函数

  1. i2c_new_master_bus --- 创建 I2C 主机总线
cpp 复制代码
esp_err_t i2c_new_master_bus(const i2c_master_bus_config_t *bus_config, i2c_master_bus_handle_t *ret_bus_handle)

功能: 根据配置参数分配并初始化一条 I2C 主机总线。

输入参数:

参数 类型 说明
bus_config const i2c_master_bus_config_t * I2C 总线配置结构体指针,包含端口号、SCL/SDA 引脚、时钟源、毛刺滤波计数、中断优先级、内部上拉使能等

输出参数:

参数 类型 说明
ret_bus_handle i2c_master_bus_handle_t * 输出参数,指向创建的总线句柄,后续所有总线级操作都需传入此句柄

返回值:

返回值 说明
ESP_OK 总线创建成功
ESP_ERR_INVALID_ARG 参数无效(如引脚号非法)
ESP_ERR_NO_MEM 内存不足,无法分配总线资源
ESP_ERR_NOT_FOUND 没有空闲的 I2C 控制器可用

注意事项:

  • i2c_port 可设为 -1 让驱动自动选择可用端口
  • flags.enable_internal_pullup 启用后驱动会自动使能 GPIO 内部上拉,但在高速模式下内部上拉可能不够强,建议外接上拉电阻

  1. i2c_master_bus_add_device --- 向总线添加 I2C 从设备
cpp 复制代码
esp_err_t i2c_master_bus_add_device(i2c_master_bus_handle_t bus_handle, const i2c_device_config_t *dev_config, i2c_master_dev_handle_t *ret_handle)

功能: 在已创建的总线上挂载一个 I2C 从设备,返回设备句柄供后续读写操作使用。

输入参数:

参数 类型 说明
bus_handle i2c_master_bus_handle_t 已创建的 I2C 总线句柄
dev_config const i2c_device_config_t * 设备配置结构体指针,包含地址长度(7/10位)、从机地址、SCL 时钟频率、ACK 检查等

输出参数:

参数 类型 说明
ret_handle i2c_master_dev_handle_t * 输出参数,指向创建的设备句柄,后续读写操作都需传入此句柄

返回值:

返回值 说明
ESP_OK 设备添加成功
ESP_ERR_INVALID_ARG 参数无效
ESP_ERR_NO_MEM 内存不足

注意事项:

  • device_address 填写的是原始 7/10 位地址,不包含读写位(驱动内部会自动处理 R/W 位)
  • dev_addr_length 需设为 I2C_ADDR_BIT_LEN_7(7 位)或 I2C_ADDR_BIT_LEN_10(10 位)
  • 同一条总线上可挂载多个设备,每个设备有独立的 scl_speed_hz
  • flags.disable_ack_check 设为 true 会跳过 ACK 检测(不推荐常规使用)

  1. i2c_master_transmit_receive --- I2C 先写后读(复合传输)
cpp 复制代码
esp_err_t i2c_master_transmit_receive(i2c_master_dev_handle_t i2c_dev, const uint8_t *write_buffer, size_t write_size, uint8_t *read_buffer, size_t read_size, int xfer_timeout_ms)

功能: 在 I2C 总线上执行"先发送数据,再接收数据"的复合操作。典型场景是向从设备发送寄存器地址,然后读取寄存器值。该函数会阻塞直到传输完成或超时。

输入参数:

参数 类型 说明
i2c_dev i2c_master_dev_handle_t 设备句柄
write_buffer const uint8_t * 待发送数据的缓冲区指针
write_size size_t 发送数据的字节数
read_size size_t 期望接收的字节数
xfer_timeout_ms int 超时时间(毫秒),-1 表示永久等待

输出参数:

参数 类型 说明
read_buffer uint8_t * 接收数据缓冲区指针,函数返回后其中存放从 I2C 总线读取到的数据

返回值:

返回值 说明
ESP_OK 传输成功
ESP_ERR_INVALID_ARG 参数无效(如空指针、size 为 0)
ESP_ERR_TIMEOUT 操作超时(总线忙或硬件异常)

注意事项:

  • 如果已通过 i2c_master_register_event_callbacks 注册了回调,此函数会变为异步模式,立即返回而不阻塞,传输完成信息通过回调获取
  • 异步模式下,write_bufferread_buffer 必须在回调触发前保持有效(不能被释放或修改)
  • 同步模式下,超时时间需合理设置,-1 可能导致死等

  1. i2c_master_transmit --- I2C 纯写传输
cpp 复制代码
esp_err_t i2c_master_transmit(i2c_master_dev_handle_t i2c_dev, const uint8_t *write_buffer, size_t write_size, int xfer_timeout_ms);

功能: 向 I2C 从设备发送数据。该函数会阻塞直到传输完成或超时。

输入参数:

参数 类型 说明
i2c_dev i2c_master_dev_handle_t 设备句柄
write_buffer const uint8_t * 待发送数据的缓冲区指针
write_size size_t 发送数据的字节数
xfer_timeout_ms int 超时时间(毫秒),-1 表示永久等待

输出参数:

返回值:

返回值 说明
ESP_OK 发送成功
ESP_ERR_INVALID_ARG 参数无效
ESP_ERR_TIMEOUT 操作超时(总线忙或硬件异常)

注意事项:

  • i2c_master_transmit_receive 一样,注册回调后会变为异步模式
  • 写操作期间如果从设备回复 NACK 且未禁用 ACK 检查,函数会立即返回错误(非超时)
  • 本示例中将寄存器地址和数据拼成 2 字节数组一次发送,是 I2C 写寄存器的标准做法

  1. i2c_master_bus_rm_device --- 从总线移除设备
cpp 复制代码
esp_err_t i2c_master_bus_rm_device(i2c_master_dev_handle_t handle)

功能: 将先前通过 i2c_master_bus_add_device 添加的设备从总线上移除并释放相关资源。

输入参数:

参数 类型 说明
handle i2c_master_dev_handle_t 要移除的设备句柄

输出参数:

返回值:

返回值 说明
ESP_OK 设备移除成功

注意事项:

  • 移除设备后,该设备句柄即失效,不可再用于读写操作
  • 应在删除总线前先移除所有设备

  1. i2c_del_master_bus --- 删除 I2C 主机总线
cpp 复制代码
esp_err_t i2c_del_master_bus(i2c_master_bus_handle_t bus_handle)

功能: 反初始化并释放 I2C 主机总线资源。

输入参数:

参数 类型 说明
bus_handle i2c_master_bus_handle_t 要删除的总线句柄

输出参数:

返回值:

返回值 说明
ESP_OK 总线删除成功
其他值 部分模块删除失败

注意事项:

  • 必须先调用 i2c_master_bus_rm_device 移除所有设备后,才能删除总线
  • 删除后总线句柄失效,不可再使用
  • 删除操作会释放 GPIO 引脚并关闭 I2C 外设时钟

使用XL9555获取KEY0的状态

XL9555介绍

XL9555 是一款 24 引脚的 CMOS 器件,支持 IIC 总线或 SMBus 接口进行驱动。XL9555器件是一个 16 位通用并行输入/输出(GPIO)扩展器,可用其 GPIO 连接按键、LED、传感器等,解决需要额外的 I/O 的需求。

⚫ XL9555 有如下特性:

⚫ IIC 总线至 16 位 GPIO 扩展器

⚫ 工作电源电压范围为 2.3 V 至 5.5 V

⚫ 低待机电流消耗

⚫ 5 V 容错 I/O 端口

⚫ 400 kHz 快速模式 IIC 总线时钟频率

⚫ SCL/SDA 输入上的噪声滤波器

⚫ 内部通电复位

⚫ 器件地址由 3 个硬件地址引脚决定,最多可在总线上挂载 8 个器件

⚫ 中断脚为开漏输出模式(低电平有效)

⚫ 16 个 I/O 引脚,默认为 16 个输入

简单概括一下,XL9555可使用400kHz速率的IIC通信接口与微控制器进行连接,也就是用2根通信线可扩展使用16个IO。XL9555器件地址会由三个硬件地址引脚决定,理论上在这个IIC总线上可挂载8个XL9555器件,足以满足IO引脚需求。XL9555上电进行复位,16个I/O口默认为输入模式,当输入模式的IO口状态发生变化时,即发生从高电平变低电平或者从低电平变高电平,中断脚会拉低。当中断有效后,必须对XL9555进行一次读取/写入操作,复位中断,才可以输出下一次中断,否则中断将一直保持。

引脚图:

XL9555 器件总共有 24 个管脚,分别为电源线 VCC、地线 GND、GPIO 口、通信线、地址 线,上图用不同底色标注出来了。16 个 IO 分为了 2 组,一组是 8 个,分为是 P0x 和 P1x,这些 IO 都可通过器件寄存器进行配置作为输出或者输出使用。通信线就是 SDA 和 SCL,中断线 INT 也划分过来通信线。而地址线就是 A0、A1 和 A2,用来决定器件地址。

  1. XL9555寻址
    要进行 IIC 通信,首先得知道器件地址,XL9555 器件地址是 7 位的,具体格式如下图。

    从上图可以知道,XL9555 器件地址由两部分组成,一部分就是"Fixed bits"即固定的 4 位 "0100";另一部分就是"Programmable bits"即可编程的 3 位"A2 A1 A0",在硬件上,都把这三个引脚接地处理,所以这三位为"000"。
    最终可得到,XL9555 器件地址为"0100000"即 0x20。
    读操作地址就为 0x41,即 0100 0001;
    写操作地址就为 0x40,即 0100 0000。
  2. XL9555寄存器介绍
    接下来,介绍一下 XL9555 器件的八个寄存器,如下图所示。

    由于在 IIC通信中,数据都是以字节作为单位,表示寄存器地址的数据也是 1个字节。由于 XL9555器件只有八个寄存器,所以这里 1个字节用 3个位表示,即 Table 5中的 B2、B1和 B0。 这 8个寄存器都是 XL9555器件的 16个 GPIO 进行配置,其实分为 4 种:输入查询、输出设置、 极性翻转和端口配置,每种都有两个寄存器对应的就是 P0 端口和 P1 端口。

地址 0x00 和 0x01 的寄存器是"Input Port0"和"Input Port1"寄存器,主要用于获取 P0 和 P1 的 IO 输入状态。寄存器如下图所示:

该寄存器只反应引脚输入逻辑电平情况,不管 IO 是设置成输入还是输出模式。打个比方, 从 0x00 地址处(Input Port 0 Register)读出的数据是 0x55,以二进制展开为 01010101,从高位 到低位对应的就是 P07~P00 的 IO 状态,P00 的输入电平状态就为高电平。

地址 0x02 和 0x03 的寄存器是"Output Port0"和"Output Port1"寄存器,主要用于设置 P0 和 P1 的 IO 输出电平。寄存器如下图所示:

该寄存器设置的是已经配置成输出模式的 IO 口的 IO 输出状态,1 代表的是高电平,0 代表 的都是低电平,配置 IO 为输出模式的寄存器为 Configuration Port 寄存器。寄存器的一些位值对 已经设置成输入模式的 IO 口是没有影响的。该寄存器还支持读取,读取到的值只是设置值,并 不是实际引脚电平值,实际电平值通过 Input Port 寄存器查询即可。

地址 0x04 和 0x05 的寄存器是"Polarity Inversion Port0"和"Polarity Inversion Port1"寄存器,用于对端口 0 和端口 1 进行极性翻转。该寄存器值默认为 0,所以对 IO 电平翻转功能 并没有启用。

地址 0x06 和 0x07 的寄存器是"Configuration Port1"和"Configuration Port0"寄存器,用 于配置 P0 和 P1 的 IO 输入/输出模式。寄存器如下图所示。

该寄存器某一个位设置成 1 即作为输入模式,设置成 0 即作为输入模式。打个比方,要向 0x06 地址处(Configuration Port 0 Register)写入的数据是 0x55,以二进制展开为 01010101,从 高位到低位对应的就是 P07~P00 的 IO 配置模式,P00、P02、P04、P06 这四个 IO 口即配置为输 入模式,而 P01、P03、P05、P07 这四个 IO 口配置为输出模式。XL9555 上电复位后,所有 IO 口默认都是输入状态,即上图这两个寄存器读出来的值都是 0xFF。

  1. XL9555 时序介绍
    ESP32-S3 是通过 IIC 总线跟 XL9555 进行通信的,对 XL9555 相关寄存器进行写入配置,对 其 16 个 IO 进行使用。这里的时序主要就是写寄存器时序和读寄存器时序。
    写时序

    上图中展示的是主机将单字节写入到寄存器的时序,主机在 IIC总线发送第 1个字节的数据 为 XL9555 的写操作地址 0x40(设备地址 0x20 << 1 | 0),用于寻找总线上找到 XL9555,在获得 XL9555 的应答信号之后,继续发送第 2 个字节数据,该字节数据是 XL9555 的寄存器地址,再 等到 XL9555 的应答信号,主机继续发送第 3 字节数据,这里的数据即是写入在第 2 字节寄存器 地址的数据。主机完成写操作后,可以发出停止信号,终止数据传输。
    读时序

    上图中展示的是主机从寄存器中读取一个字节数据的时序图。XL9555 读取数据的过程是一 个复合的时序,其中包含写时序和读时序。先看第一个通信过程,这里是写时序,起始信号产 生后,主机发送 XL9555 的写操作地址 0x40(设备地址 0x20<< 1 | 0),获取从机应答信号后, 接着发送需要读取的寄存器地址;在读时序中,起始信号产生后,主机发送 XL9555 的读操作 地址 0x41(设备地址 0x20 << 1 | 1),获取从机应答信号后,接着从机返回刚刚在写时序中寄存 器地址的数据,以字节为单位传输在总线上,主机接收到寄存器的数据后,发出非应答信号并 以停止信号结束通信过程。

管脚配置

从原理图看出:

SDA连接ESP32-S3的IO41,

SCL连接ESP32-S3的IO42,

INT连接ESP32-S3的IO0

KEY0对应的是XL9555的IO1_7

XL9555由于A0、A1、A2都连接地,所以地址为0x20。

代码

main.c

cpp 复制代码
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "driver/gpio.h"
#include "xl9555.h"
#include "driver/i2s_std.h"
#include "esp_log.h"

#define XL9555_SDA  GPIO_NUM_41
#define XL9555_SCL  GPIO_NUM_42

#define TAG     "main"
void xl9555_button_callback(uint16_t num_io,int level)
{
    switch(num_io)
    {
        case IO1_7:
            ESP_LOGI(TAG,"Button KEY0 check,level:%d",level);
            break;
        case IO0_2:
            ESP_LOGI(TAG,"Button2 check,level:%d",level);
            break;
        case IO0_3:
            ESP_LOGI(TAG,"Button3 check,level:%d",level);
            break;
        case IO0_4:
            ESP_LOGI(TAG,"Button4 check,level:%d",level);
            break;
        default:break;
    }
}

void i2c_and_xl9555_init(void)
{
    xl9555_init(XL9555_SDA,XL9555_SCL,GPIO_NUM_0,xl9555_button_callback);
    xl9555_ioconfig(0xFFFF);
}

void app_main(void)
{
    i2c_and_xl9555_init();
}

xl9555.c

cpp 复制代码
#include "xl9555.h"

#include "esp_log.h"

#include "freertos/FreeRTOS.h"

#include "freertos/task.h"

#include "freertos/event_groups.h"

#include <string.h>

  

static i2c_master_bus_handle_t xl9555_i2c_master = NULL;

static i2c_master_dev_handle_t xl9555_i2c_device  =NULL;

static xl9555_input_cb_t    xl9555_input_callback = NULL;

static gpio_num_t xl9555_isr_io = GPIO_NUM_NC;

static uint16_t xl9555_io_config = 0xFFFF;

static EventGroupHandle_t   xl9555_isr_event = NULL;

#define TAG "xl9555"

  

//交换二值

#define SWAP_NUM(X,Y)   do{\

    (X)=X^(Y);\

    (Y)=(X)^(Y);\

    (X)=(X)^(Y);\

}while(0)

  

#define XL9555_ISR_BIT    BIT0

#define LITTLE_ENDIAN   0

#define BIG_ENDIAN      1

  

//判断字节序

static inline int check_endian(void)

{

    static const uint32_t endian_value = 0x12345678;

    const char* endian_array = (const char*)&endian_value;

    if(endian_array[0] == 0x78)

        return LITTLE_ENDIAN;

    else if(endian_array[0] == 0x12)

        return BIG_ENDIAN;

    return -1;

}

  

/** 读取16位的GPIO电平值

 * @param data 返回的16个GPIO值,用位表示

 * @return 成功或失败

*/

esp_err_t xl9555_read_word(uint8_t reg,uint16_t *data)

{

    uint8_t memaddr_buf[1];

    memaddr_buf[0]  = reg;

    esp_err_t ret = i2c_master_transmit_receive(xl9555_i2c_device, &memaddr_buf[0], 1, (uint8_t*)data, 2, 500);

    if(check_endian() == BIG_ENDIAN)

    {

        //如果是大端字节序就交换高低两个字节,16位变量低位存放的是XL9555的前8个数据

        uint8_t* value_array = (uint8_t*)data;

        SWAP_NUM(value_array[0],value_array[1]);

    }

    return ret;

}

  

/** 写入16位的数据

 * @param ret寄存器地址

 * @param data 要写入的数据,XL9555一般是2个字节,所以这里用16位值

 * @return 成功或失败

*/

esp_err_t xl9555_write_word(uint8_t reg, uint16_t data)

{

    esp_err_t ret;

    uint8_t *write_buf = (uint8_t*)malloc(2 + 1);

    if(!write_buf)

        return ESP_FAIL;

    write_buf[0] = reg;

    memcpy(&write_buf[1],&data,2);

    if(check_endian() == BIG_ENDIAN)

    {

        //如果是大端字节序就需要交换

        SWAP_NUM(write_buf[1],write_buf[2]);

    }

    ret = i2c_master_transmit(xl9555_i2c_device, write_buf, 2 + 1, 500);

    free(write_buf);

    return ret;

}

  
  

/** 设置某个gpio口的电平

 * @param pin gpio口

 * @param val 电平值

 * @return 无

*/

esp_err_t xl9555_pin_write(uint16_t pin, int val)

{

    uint16_t r_data;

    xl9555_read_word(XL9555_INPUT_PORT0_REG,&r_data);

    if(val)

        r_data |= pin;

    else

        r_data &= ~pin;

    return xl9555_write_word(XL9555_OUTPUT_PORT0_REG, r_data);

}

  

/** 读取某个gpio口的电平

 * @param pin gpio口

 * @return 电平值

*/

int xl9555_pin_read(uint16_t pin)

{

    uint16_t r_data = 0;

    xl9555_read_word(XL9555_INPUT_PORT0_REG,&r_data);

    return (r_data & pin) ? 1 : 0;

}

  

/** 设置输入输出

 * @param config_value 配置值,如果对应的位是0,则对应的GPIO口设置为【输出】,如果是1,则为【输入】

 * @return 无

*/

esp_err_t xl9555_ioconfig(uint16_t config_value)

{

    esp_err_t err;

    xl9555_io_config = config_value;

    do

    {

        err = xl9555_write_word(XL9555_CONFIG_PORT0_REG, config_value);

        if (err != ESP_OK)

        {

            ESP_LOGE(TAG, "%s configure %X failed, ret: %d", __func__, config_value, err);

        }

        vTaskDelay(pdMS_TO_TICKS(100));

    } while (err != ESP_OK);

    return err;

}

  

/**

 * 外部中断服务函数

 * @param arg 中断引脚号,在注册中断回调函数时已通过参数带进来

 * @return 无

 */

static void IRAM_ATTR xl9555_exit_gpio_isr_handler(void *arg)

{

    uint32_t gpio_num = (uint32_t) arg;

    BaseType_t task_woken;

    if (gpio_num == xl9555_isr_io)

    {

        if (gpio_get_level(xl9555_isr_io) == 0)

        {

            xEventGroupSetBitsFromISR(xl9555_isr_event,XL9555_ISR_BIT,&task_woken);

        }

    }

}

  

/**

 * 外部中断初始化程序

 * @param 无

 * @return 无

 */

static void xl9555_isr_init(void)

{

    gpio_config_t gpio_init_struct;

    xl9555_isr_event = xEventGroupCreate();

    /* 配置BOOT引脚和外部中断 */

    gpio_init_struct.mode = GPIO_MODE_INPUT;                    /* 选择为输入模式 */

    gpio_init_struct.pull_up_en = GPIO_PULLUP_ENABLE;           /* 上拉使能 */

    gpio_init_struct.pull_down_en = GPIO_PULLDOWN_DISABLE;      /* 下拉失能 */

    gpio_init_struct.intr_type = GPIO_INTR_NEGEDGE;             /* 下降沿触发 */

    gpio_init_struct.pin_bit_mask = 1ull << xl9555_isr_io;      /* 设置的引脚的位掩码 */

    gpio_config(&gpio_init_struct);                             /* 配置使能 */

    /* 注册中断服务 */

    gpio_install_isr_service(0);

    /* 设置GPIO的中断回调函数 */

    gpio_isr_handler_add(xl9555_isr_io, xl9555_exit_gpio_isr_handler, (void*) xl9555_isr_io);

}

  

static void xl9555_intput_scan(void* param)

{

    esp_err_t ret;

    EventBits_t ev;

    uint16_t last_input = 0;

    xl9555_read_word(XL9555_INPUT_PORT0_REG,&last_input);

    while(1)

    {

        uint16_t input;

        ev = xEventGroupWaitBits(xl9555_isr_event,XL9555_ISR_BIT,pdTRUE,pdFALSE,pdMS_TO_TICKS(10*1000));

        if(ev & XL9555_ISR_BIT)

        {

            if ( gpio_get_level(xl9555_isr_io) != 0)

            {

                continue;

            }

            ret = xl9555_read_word(XL9555_INPUT_PORT0_REG,&input);        //读取输入寄存器

            if(ret == ESP_OK)

            {

                for(int i = 0;i < 16;i++)

                {

                    if(xl9555_io_config & (1 <<i))//判断是否已经将对应端口设置为输入

                    {

                        uint8_t value = input&(1<<i)?1:0;

                        uint8_t last_value = last_input&(1<<i)?1:0;

                        if(value != last_value && xl9555_input_callback)

                        {

                            xl9555_input_callback(1<<i,value);

                        }

                    }

                }

            }

            last_input = input;

        }

    }

}

  

/** 初始化xl9555芯片和用到的i2c总线

 * @param sda sda的gpio口

 * @param scl scl的gpio口

 * @param int_io 中断的gpio口

 * @param f 回调函数用于告知gpio口的电平跳变

 * @return 无

*/

void xl9555_init(gpio_num_t sda,gpio_num_t scl,gpio_num_t int_io,xl9555_input_cb_t f)

{

    uint16_t r_data;

    i2c_master_bus_config_t bus_config =

    {

        .i2c_port = 1,

        .sda_io_num = sda,

        .scl_io_num = scl,

        .clk_source = I2C_CLK_SRC_DEFAULT,

        .glitch_ignore_cnt = 7,

        .trans_queue_depth = 0,

        .flags.enable_internal_pullup = 1,

    };

    i2c_new_master_bus(&bus_config,&xl9555_i2c_master);

  

    i2c_device_config_t dev_config =

    {

        .dev_addr_length = I2C_ADDR_BIT_LEN_7,

        .device_address = XL9555_ADDR,

        .scl_speed_hz = 100000,

    };

    xl9555_input_callback = f;

    xl9555_isr_io = int_io;

    i2c_master_bus_add_device(xl9555_i2c_master, &dev_config, &xl9555_i2c_device);

  

    /* 上电先读取一次清除中断标志 */

    xl9555_read_word(XL9555_INPUT_PORT0_REG,&r_data);

  

    if(xl9555_isr_io != GPIO_NUM_NC)

    {

        xl9555_isr_init();

        xTaskCreatePinnedToCore(xl9555_intput_scan,"xl9555",4096,NULL,3,NULL,1);

    }

}

xl9555.h

cpp 复制代码
#ifndef __XL9555_H

#define __XL9555_H

  

#include "driver/gpio.h"

#include "esp_err.h"

#include "driver/i2c_master.h"

  
  

/* XL9555命令宏 */

#define XL9555_INPUT_PORT0_REG      0                               /* 输入寄存器0地址 */

#define XL9555_INPUT_PORT1_REG      1                               /* 输入寄存器1地址 */

#define XL9555_OUTPUT_PORT0_REG     2                               /* 输出寄存器0地址 */

#define XL9555_OUTPUT_PORT1_REG     3                               /* 输出寄存器1地址 */

#define XL9555_INVERSION_PORT0_REG  4                               /* 极性反转寄存器0地址 */

#define XL9555_INVERSION_PORT1_REG  5                               /* 极性反转寄存器1地址 */

#define XL9555_CONFIG_PORT0_REG     6                               /* 方向配置寄存器0地址 */

#define XL9555_CONFIG_PORT1_REG     7                               /* 方向配置寄存器1地址 */

  

#define XL9555_ADDR                 0x20                            /* XL9555地址(左移了一位)-->请看手册(9.1. Device Address) */

  

/* XL9555各个IO的功能 */

#define IO0_0                       0x0001

#define IO0_1                       0x0002

#define IO0_2                       0x0004

#define IO0_3                       0x0008

#define IO0_4                       0x0010

#define IO0_5                       0x0020

#define IO0_6                       0x0040

#define IO0_7                       0x0080

#define IO1_0                       0x0100

#define IO1_1                       0x0200

#define IO1_2                       0x0400

#define IO1_3                       0x0800

#define IO1_4                       0x1000

#define IO1_5                       0x2000

#define IO1_6                       0x4000

#define IO1_7                       0x8000

  

typedef void(*xl9555_input_cb_t)(uint16_t io_num,int level);

  

 /** 初始化xl9555芯片和用到的i2c总线

 * @param sda sda的gpio口

 * @param scl scl的gpio口

 * @param int_io 中断的gpio口

 * @param f 回调函数用于告知gpio口的电平跳变

 * @return 无

*/

void xl9555_init(gpio_num_t sda,gpio_num_t scl,gpio_num_t int_io,xl9555_input_cb_t f);

  

/** 读取某个gpio口的电平

 * @param pin gpio口

 * @return 电平值

*/

int xl9555_pin_read(uint16_t pin);

  

/** 设置某个gpio口的电平

 * @param pin gpio口

 * @param val 电平值

 * @return 无

*/

esp_err_t xl9555_pin_write(uint16_t pin, int val);

  

/** 写入16位的数据

 * @param ret寄存器地址

 * @param data 要写入的数据,XL9555一般是2个字节,所以这里用16位值

 * @return 成功或失败

*/

esp_err_t xl9555_write_word(uint8_t reg, uint16_t data);

  

/** 读取16位的GPIO电平值

 * @param reg 寄存器地址

 * @param data 寄存器地址的值

 * @return 成功或失败

*/

esp_err_t xl9555_read_word(uint8_t reg, uint16_t *data);

  

/** 设置输入输出

 * @param config_value 配置值,如果对应的位是0,则对应的GPIO口设置为【输出】,如果是1,则为【输入】

 * @return 无

*/

esp_err_t xl9555_ioconfig(uint16_t config_value);

#endif

调试

注意烧写后把IO0的跳线帽取掉,重启,再连上,否则会进入下载模式一直等待,因为它的中断线INT正好连的是ESP32-S3的BOOT,如下:

取掉,重启,再连上后:

按下KEY0

实操练习:

实现功能

使用KEY0开关蜂鸣器BEEP

答案在gitee:

https://gitee.com/zhangdong_road/esp32