ESP-IDF esp_hal_i2c 实践:仅用 HAL 函数编写裸机 I2C 主机驱动(hal_i2c 示例全解析)
2026/9/13 17:31:31 网站建设 项目流程

ESP-IDF esp_hal_i2c 实践:仅用 HAL 函数编写裸机 I2C 主机驱动(hal_i2c 示例全解析)

【免费下载链接】esp-idfEspressif IoT Development Framework. Official development framework for Espressif SoCs.项目地址: https://gitcode.com/GitHub_Trending/es/esp-idf

本文以 ESP-IDF 的 hal_i2c 测试工程 为主体,讲解如何不依赖 RTOS、中断与动态内存分配,只使用esp_hal_i2c的 HAL 函数与底层 LL 寄存器接口,从零写一个最简 I2C 主机(Master)驱动。读完后你能掌握:该工程的接线与配置方法、构建烧录步骤,以及 hal_i2c.c 中初始化、命令 FIFO 编排、轮询等待等核心实现原理,从而能够在 bootloader 等裸机场景或第三方系统中复用硬件 I2C 外设。

支持的目标芯片ESP32ESP32-C2ESP32-C3ESP32-C5ESP32-C6ESP32-C61ESP32-H2ESP32-H21ESP32-H4ESP32-P4ESP32-S2ESP32-S3ESP32-S31

1. 这个示例解决什么问题

hal_i2c 工程 README 明确了两点定位:

  1. 这是一个纯 HAL I2C 测试,它不能与esp_driver_i2c中的新版 I2C 驱动同时工作
  2. 它演示了仅依赖I2C、GPIO 和 XTAL三种硬件特性的最简 I2C 主机驱动写法,因此可以在无中断、无内存分配的裸机环境(bare metal)中运行。

它面向两类典型场景:

  • 为裸机应用编写 I2C 驱动,例如 bootloader 阶段访问 I2C 器件;
  • 在任意第三方系统或应用中移植 ESP 硬件 I2C 组件,即直接使用 IDF HAL 函数而非整套 ESP-IDF 驱动框架。

README 同时说明了一个边界:受限于示例可用的源码量,本工程聚焦"如何使用 HAL 函数",而不是提供一套完美 API,实际应用中你可以根据自己的需要自行组织更好的接口。

与 esp_hal_i2c 组件的关系

esp_hal_i2c 组件 README 指出,HAL 层分两个子层:HAL(上层)定义操作外设所需的步骤与数据(初始化、start/stop 等);Low-Level(底层)soc组件寄存器文件之上的翻译层,把寄存器配置封装成通用概念。组件还提供esp_err_t风格的 HAL API(i2c_hal.c/i2c_hal_iram.c),可用于辅助实现自己的驱动——这正是本工程hal_i2c组件所做的示范。注意官方提示:该 HAL 组件仍处于积极开发中,接口不保证跨版本稳定与向后兼容

2. 工程结构

工程位于 components/esp_hal_i2c/test_apps/hal_i2c/,目录组织如下:

components/esp_hal_i2c/test_apps/hal_i2c/ ├── CMakeLists.txt # 项目级 CMake,含 G1 组件依赖检查 ├── main/ │ ├── CMakeLists.txt # 注册 hal_i2c_main.c,依赖 hal_i2c / esp_hal_i2c │ ├── Kconfig.projbuild # SCL/SDA 引脚与频率的 menuconfig 配置 │ └── hal_i2c_main.c # app_main:初始化 + 写 + 写读回验 └── components/hal_i2c/ ├── CMakeLists.txt # 依赖 esp_hal_i2c / esp_hal_clock ├── hal_i2c.h # hal_i2c_config 与 3 个 API 声明 └── hal_i2c.c # 纯 HAL/LL 实现的 I2C 主机驱动

其中 main/CMakeLists.txt 显示主程序依赖hal_i2c esp_hal_i2c;components/hal_i2c/CMakeLists.txt 显示驱动组件依赖esp_hal_i2c esp_hal_clock(时钟频率查询)。项目顶层 CMakeLists.txt 还启用了 G1 组件依赖检查(check_dependencies.py),提示当前工程遵循严格的组件分层依赖规则。

3. 硬件准备与引脚配置

3.1 硬件要求与接线

运行本工程需要一块 ESP32 / ESP32-S / ESP32-C / ESP32-P / ESP32-H 系列开发板,I2C 总线上接一个从设备(示例按 EEPROM 处理)。条件允许时,建议用逻辑分析仪或示波器观察波形。

README 给出的默认引脚分配:

SDASCL
ESP I2C MasterI2C_MASTER_SDAI2C_MASTER_SCL
DEVICE1SDASCL
DEVICEnSDASCL

这些默认值可在menuconfig中修改,对应配置项定义在 main/Kconfig.projbuild:

配置项含义默认值
I2C_MASTER_SCLI2C Master 时钟线 GPIO 号4
I2C_MASTER_SDAI2C Master 数据线 GPIO 号5
I2C_MASTER_FREQUENCY主机 SCL 频率(Hz)400000(Fast Mode)

hal_i2c_main.c 通过CONFIG_I2C_MASTER_SCL/CONFIG_I2C_MASTER_SDA/CONFIG_I2C_MASTER_FREQUENCY这三个宏读取上面的配置。

关于上拉电阻,存在两处说法,使用时请注意区分:

  • 工程 README 称无需外部上拉,因为驱动会启用内部上拉电阻;
  • 而 hal_i2c.c 的源码注释指出:SDA/SCL 必须配置为开漏且线上必须有上拉,内部上拉非常弱,强烈建议使用外部上拉电阻

因此对信号完整性要求高的实际项目,按源码注释加上外部上拉更稳妥。

4. 构建、烧录与预期输出

4.1 构建与烧录

idf.py -p PORT flash monitor

其中PORT替换为实际串口设备。退出串口监视器输入Ctrl-]。完整的 ESP-IDF 配置与构建流程可参考官方 Getting Started Guide(README 中的跳转链接,此处不展开)。

4.2 预期输出

I (300) hal_i2c_main: HAL I2C initialized successfully I (320) hal_i2c_main: HAL I2C write-read successfully

4.3 主程序做了什么

hal_i2c_main.c 的app_main()流程:

#define SCL_IO_PIN CONFIG_I2C_MASTER_SCL #define SDA_IO_PIN CONFIG_I2C_MASTER_SDA #define MASTER_FREQUENCY CONFIG_I2C_MASTER_FREQUENCY #define TIMEOUT_MS (20) static const uint8_t slave_address = 0x50; // 7 位从机地址(示例按 EEPROM) void app_main(void) { hal_i2c_config i2c_config = { .freq = MASTER_FREQUENCY, .i2c_port = I2C_NUM_0, .scl_pin = SCL_IO_PIN, .sda_pin = SDA_IO_PIN, }; ESP_ERROR_CHECK(hal_i2c_init(&i2c_config)); // 以 EEPROM 为从设备:向地址 0x0000 写入 0x33 和 0x44,然后读回 uint8_t tx_data[4] = {0x00, 0x00, 0x33, 0x44}; ESP_ERROR_CHECK(hal_i2c_write(I2C_NUM_0, slave_address, tx_data, 4, TIMEOUT_MS)); uint8_t rx_data[2]; ESP_ERROR_CHECK(hal_i2c_write_read(I2C_NUM_0, slave_address, tx_data, 2, rx_data, 2, TIMEOUT_MS)); ESP_LOGI(TAG, "Read back value 1 is %x, value two is %x\n", rx_data[0], rx_data[1]); }

即:初始化 I2C0 → 写操作(2 字节器件地址 + 2 字节数据)→ 写读操作(重复 START 后从 0x0000 读回 2 字节验证),全部走阻塞轮询,timeout_ms固定为 20 ms。

5. 核心 API 与驱动实现剖析

5.1 hal_i2c_config 与四个 API

hal_i2c.h 定义了配置结构体与 3 个操作函数:

typedef struct { int scl_pin; /*!< SCL PIN, -1 means not change the current pin */ int sda_pin; /*!< SDA PIN, -1 means not change the current pin */ uint32_t freq; /*!< SCL frequency */ i2c_port_t i2c_port; /*!< i2c port */ } hal_i2c_config; esp_err_t hal_i2c_init(hal_i2c_config *cfg); esp_err_t hal_i2c_write(i2c_port_t port_num, uint16_t addr, const uint8_t *txdata, uint32_t txlength, uint32_t timeout_ms); esp_err_t hal_i2c_read(i2c_port_t port_num, uint16_t addr, uint8_t *rxdata, uint32_t rxlength, uint32_t timeout_ms); esp_err_t hal_i2c_write_read(i2c_port_t port_num, uint16_t addr, const uint8_t *txdata, uint32_t txlength, uint8_t *rxdata, uint32_t rxlength, uint32_t timeout_ms);
  • 引脚传-1表示保持当前引脚不变;
  • 返回值统一为ESP_OK/ESP_ERR_INVALID_STATE(端口未初始化或不存在)/ESP_ERR_INVALID_ARG/ESP_ERR_TIMEOUT(单次命令超时)。

驱动内部维护一个静态上下文数组,覆盖目标芯片上的全部 I2C 端口(SOC_I2C_NUM > 1时含 I2C1),见 hal_i2c.c。

5.2 hal_i2c_init:时钟、GPIO 与寄存器配置

hal_i2c_init() 完成四件事:

  1. 时钟使能与寄存器复位PERIPH_RCC_ATOMIC()保护下执行i2c_ll_enable_bus_clock()i2c_ll_reset_register(),再i2c_ll_enable_controller_clock()开启控制器时钟;
  2. GPIO 引脚配置(SDA 与 SCL 对称处理,见 L83-L104):
    • gpio_ll_set_level(…, 1):先拉高,因为 I2C 在 1→0 跳变时有效;
    • gpio_ll_od_enable():置为开漏输出(I2C 硬性要求);
    • gpio_ll_pullup_en()+gpio_ll_pulldown_dis():使能内部上拉、禁止下拉(使用外部上拉时此项可忽略);
    • esp_rom_gpio_connect_out_signal()/esp_rom_gpio_connect_in_signal():把 GPIO 引脚与i2c_periph_signal[port].sda_out_sig / sda_in_sig(SCL 同理)互连。注释特别说明:作为主机时,SDA 和 SCL都需要out/in 双向信号连接;
  3. 控制器模式配置i2c_ll_set_mode(dev, I2C_BUS_MODE_MASTER)设为主机模式、i2c_ll_enable_pins_open_drain()开漏、i2c_ll_set_data_mode()收发均 MSB first(I2C 标准要求)、复位 TX/RX FIFO,并且i2c_ll_disable_intr_mask()关闭全部中断——这是"无中断裸机驱动"的关键;
  4. 总线时钟计算:注释明确"init clock, always use xtal in hal driver",即始终使用XTAL作为 I2C 时钟源(i2c_ll_set_source_clk(dev, SOC_MOD_CLK_XTAL))。XTAL 频率的获取做了芯片差异兼容:支持时钟树(SOC_CLK_TREE_SUPPORTED)的芯片用clk_hal_xtal_get_freq_mhz(),否则退回clk_ll_xtal_get_freq_mhz()。随后i2c_ll_master_cal_bus_clk(xtal_freq * MHZ, freq, &clk_cal)根据 XTAL 频率与目标freq计算分频参数,i2c_ll_master_set_bus_timing()写入时序寄存器,最后i2c_ll_update(dev)生效。

这解释了 README 所说的"仅依赖 I2C、GPIO 和 XTAL":初始化路径不触碰系统时钟切换、电源管理或 RTOS 服务。

5.3 命令 FIFO 编排:WRITE / READ / STOP / END

ESP32 系列 I2C 控制器是基于命令列表的硬件状态机:软件把若干命令单元(RESTART / WRITE / READ / STOP / END)写入命令寄存器 FIFO,控制器依次执行。i2c_format_cmd() 组装每条命令的五个字段:

字段含义
op_code操作码,各目标芯片的具体宏定义见{target}/hal/i2c_ll.h中的I2C_LL_CMD_RESTART/WRITE/READ/STOP/END
ack_valREAD 时控制器发出的 ACK 位(RSTART/STOP/END/WRITE 时忽略)
ack_expWRITE 时期望从机发出的 ACK 位
ack_en是否校验从机 ACK
byte_num该命令传输的字节数

以 hal_i2c_write() 为例,一次写入的命令序列是:

  1. 若总线忙(i2c_ll_is_bus_busy())先i2c_ll_master_fsm_rst()复位状态机,并复位 TX/RX FIFO;
  2. I2C_LL_CMD_RESTART:发出 START;
  3. 地址字节(addr & 0xFF) << 1 | 0(读/写位为 0)写入 TX FIFO,跟一条I2C_LL_CMD_WRITE(byte_num=1);
  4. 数据分段循环:每段最多I2C_LL_GET(FIFO_LEN) - 1字节(为命令槽留出 FIFO 空间),数据写入 TX FIFO 后跟一条WRITE命令,再跟一条END命令——表示"先执行已入队的命令,后续命令等软件追加",然后i2c_ll_update()+i2c_ll_start_trans()启动传输,并i2c_wait_done()等待本批命令完成;
  5. 全部数据发完后,追加I2C_LL_CMD_STOP,启动并等待完成,返回ESP_OK

hal_i2c_write_read() 在此基础上演示了重复 START(Repeated START):先完成写阶段(地址+写位 + 数据,STOP 前用 END 收尾),然后重新入队RESTART+ 地址字节(读位置 1)+ WRITE,进入读阶段。读阶段按 FIFO 容量分段,并处理了 I2C 协议的 ACK/NACK 细节:

  • 最后一段只有 1 字节时,直接READ(ack_val=NACK, 1 字节)+STOP,即对最后一字节回 NACK;
  • 最后一段大于 1 字节时,拆成READ(ack_val=ACK, n-1 字节)+READ(ack_val=NACK, 1 字节)+STOP
  • 非最后一段则READ(ack_val=ACK, n 字节)+END,读完本段后从 RX FIFO 取数并继续下一批。

独立的 hal_i2c_read() 逻辑与上述读阶段相同,只是起始命令为 START + 读地址。

5.4 无中断的超时等待机制

裸机轮询的核心是 i2c_wait_done():

while (i2c_ll_master_is_cmd_done(dev, cmd_idx) == 0) { RECORD_TIME_ELAPSED(wait_time); if (time_get_us_by_ccount(wait_time) > timeout_us) { return ESP_ERR_TIMEOUT; } }

它用esp_cpu_get_cycle_count()读取 CPU 计数器,把经过的 cycle 数按CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ换算成微秒(time_get_us_by_ccount()),超过timeout_ms(主程序传 20 ms)即返回ESP_ERR_TIMEOUT。整个驱动不注册任何中断、不分配任何内存,全部是static/全局上下文加调用者提供的缓冲,这正是它能跑在 bootloader 等裸机环境的原因。

5.5 与 esp_driver_i2c 的互斥检查

hal_i2c.c 末尾 用__attribute__((constructor))注册了一个启动时检查函数:

extern __attribute__((weak)) esp_err_t i2c_acquire_bus_handle(int port_num, void *i2c_new_bus, int mode); if ((void *)i2c_acquire_bus_handle != NULL) { abort(); }

新版 I2C 驱动(esp_driver_i2c)中i2c_acquire_bus_handle有实际实现,若其被链接进固件则该符号非空,程序直接abort()。这与 README 中"本测试是纯 HAL I2C 测试,不能与esp_driver_i2c驱动配合工作"的声明一一对应——两套驱动会争抢同一硬件外设,工程选择用链接期符号探测在启动时快速失败。

6. 局限与使用建议

  • 不保证 API 稳定esp_hal_i2c组件尚在积极开发,本示例的hal_i2c_init/write/read/write_read只是演示性封装,跨 IDF 版本使用时需以当前仓库源码为准;
  • 仅主机模式:驱动固定I2C_BUS_MODE_MASTER,未实现从机功能;未启用仲裁检测(i2c_ll_enable_arbitration(dev, false));
  • 写 ACK 未强制校验:示例中 WRITE 命令的ack_en传的是NOT_CHECK_ACK_VALUE,对从机无应答(NACK)的总线错误不会显式报错,实际项目中可参考 LL 层的ack_exp/ack_en字段自行加强校验;
  • 时钟源固定 XTAL:总线频率计算基于 XTAL 实测值,与 README 所述"依赖 I2C、GPIO、XTAL"一致;在 XTAL 配置特殊的平台上(如部分 26 MHz 晶振变体)需注意CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ与 XTAL 频率宏的正确性;
  • 上拉电阻:README 与源码注释对内部上拉的可用性表述有出入(见第 3.1 节),高可靠性设计建议加外部上拉;
  • 适用边界:该驱动适合 bootloader 启动流程、极简固件、或需要把 ESP I2C 移植进第三方 RTOS/裸机框架的场景;在完整 ESP-IDF 应用中,常规功能开发仍应优先使用 esp_driver_i2c 提供的i2c_master_*接口,本示例的价值在于展示其底层"命令 FIFO + 轮询"的硬件工作方式。

7. 参考资料(仓库内路径)

  • 示例说明:components/esp_hal_i2c/test_apps/hal_i2c/README.md
  • HAL I2C 组件说明:components/esp_hal_i2c/README.md
  • 驱动实现:components/esp_hal_i2c/test_apps/hal_i2c/components/hal_i2c/hal_i2c.c、hal_i2c.h
  • 主程序与配置:main/hal_i2c_main.c、main/Kconfig.projbuild
  • 官方 I2C 驱动(对比参考):components/esp_driver_i2c/i2c_master.c

【免费下载链接】esp-idfEspressif IoT Development Framework. Official development framework for Espressif SoCs.项目地址: https://gitcode.com/GitHub_Trending/es/esp-idf

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询