Using I2C Communication with Zig on ESP-IDF: Master Driver Guide

The zig-esp-idf-sample repository provides idiomatic Zig wrappers in imports/i2c.zig that expose the ESP-IDF I²C master driver through the idf.i2c namespace, offering type-safe bus management, device probing, and transactional APIs with Zig error handling.

The kassane/zig-esp-idf-sample project demonstrates how to leverage Zig's type safety and error handling with the ESP-IDF hardware abstraction layer. When using I2C communication with Zig on ESP-IDF, developers can utilize thin wrappers around the C driver that convert ESP-IDF error codes into Zig errors while maintaining full access to the underlying i2c_master_bus_config_t and transaction structures.

Understanding the Zig I2C Architecture on ESP-IDF

The idf.i2c Namespace

All I²C functionality is centralized under the idf.i2c namespace, which aggregates bindings from imports/i2c.zig. This module exposes a BUS helper struct for lifecycle management and standalone convenience functions for device probing and data transactions. According to the source code in imports/idf.zig, the idf module re-exports these bindings to provide a unified hardware interface.

Core Components and File Structure

The I²C implementation spans several key files in the repository:

  • imports/i2c.zig: Contains the BUS struct with add and del methods, plus probe, transmit, receive, and transmitReceive functions. Each function wraps the corresponding ESP-IDF C API and routes errors through errors.espCheckError.
  • main/examples/i2c-scan.zig: Demonstrates a complete application that initializes a bus and scans the 7-bit address space.
  • imports/idf.zig: Aggregates all peripheral modules, exposing idf.i2c as the public entry point.

Setting Up an I2C Master Bus in Zig

To begin using I2C communication with Zig on ESP-IDF, you must first instantiate a master bus handle. The idf.i2c.BUS.add function wraps i2c_new_master_bus and accepts a pointer to an i2c_master_bus_config_t struct.

const std = @import("std");
const idf = @import("esp_idf");
const sys = idf.sys;

const SDA_PIN = 21;
const SCL_PIN = 22;

// Configure the master bus
const bus_config = sys.i2c_master_bus_config_t{
    .i2c_port = -1,               // Auto-select a free I²C peripheral
    .sda_io_num = SDA_PIN,
    .scl_io_num = SCL_PIN,
    .clk_source = sys.I2C_CLK_SRC_DEFAULT,
    .glitch_ignore_cnt = 7,
    .intr_priority = 0,
    .trans_queue_depth = 0,
    .flags = .{
        .enable_internal_pullup = 1,
        .allow_pd = 0,
        .clk_flags = 0,
    },
};

var bus_handle: sys.i2c_master_bus_handle_t = null;

// Initialize the bus with Zig error handling
idf.i2c.BUS.add(&bus_config, &bus_handle) catch |err| {
    std.log.err("I²C bus init failed: {s}", .{@errorName(err)});
    return;
};

The BUS.add wrapper forwards the configuration to the ESP-IDF driver and converts any ESP_ERR_* code into a Zig error via errors.espCheckError.

Scanning for I2C Devices

Before communicating with a specific sensor, verify that it acknowledges its 7-bit address. The idf.i2c.probe function wraps i2c_master_probe, performing a zero-byte write with a configurable timeout.

const target_address: u16 = 0x76;  // Example: BMP280 default address

if (idf.i2c.probe(bus_handle, target_address, 50)) {
    std.log.info("Device detected at 0x{X:0>2}", .{target_address});
} else {
    std.log.warn("No device at 0x{X:0>2}", .{target_address});
}

For a complete scan of the valid 7-bit address space (0x03 to 0x77), see the reference implementation in main/examples/i2c-scan.zig.

Performing I2C Transactions

Once a device is detected, you must add it to the bus to obtain a device handle, then execute read or write operations. The Zig wrappers in imports/i2c.zig provide three primary transaction types.

Write Operations with transmit

The idf.i2c.transmit function wraps i2c_master_transmit, sending a byte buffer to the device.

var dev_handle: sys.i2c_master_dev_handle_t = null;
const dev_cfg = sys.i2c_device_config_t{
    .dev_addr_len = sys.I2C_ADDR_BIT_LEN_7,
    .device_address = 0x76,
    .scl_speed_hz = 100_000,
};

// Register device on the bus
try idf.i2c.BUS.addDevice(bus_handle, &dev_cfg, &dev_handle);

// Write configuration register
const config_bytes = [_]u8{0xF4, 0x27};  // Reg 0xF4, value 0x27
try idf.i2c.transmit(dev_handle, &config_bytes, 100);

Read Operations with receive

The idf.i2c.receive function wraps i2c_master_receive, filling a buffer with data from the device.

var buffer: [6]u8 = undefined;
try idf.i2c.receive(dev_handle, &buffer, 100);

std.log.info("Raw data: {X:0>2}", .{buffer});

Combined Write-Read with transmitReceive

Many sensors require a register address write followed immediately by a read (repeated-start condition). The idf.i2c.transmitReceive function wraps i2c_master_transmit_receive.

// Example: Reading temperature from a BMP280
const reg_addr = [_]u8{0xFA};  // Temperature MSB register
var raw_temp: [3]u8 = undefined;

try idf.i2c.transmitReceive(dev_handle, &reg_addr, &raw_temp, 100);

// Convert 20-bit value (simplified)
const temp_raw = (@intCast(u32, raw_temp[0]) << 12) |
                 (@intCast(u32, raw_temp[1]) << 4) |
                 (@intCast(u32, raw_temp[2]) >> 4);

After transactions complete, release the device handle with idf.i2c.BUS.removeDevice(dev_handle).

Error Handling in I2C Operations

Every wrapper in imports/i2c.zig routes ESP-IDF return codes through errors.espCheckError, converting C-style error codes into Zig error unions. This allows the use of try and catch for flow control rather than manual error code inspection.

When idf.i2c.BUS.add or any transaction function encounters an ESP_ERR_* code, it immediately returns a Zig error that can be caught and logged using @errorName(err). This integration ensures that using I2C communication with Zig on ESP-IDF maintains the language's safety guarantees while interfacing with C drivers.

Summary

  • The kassane/zig-esp-idf-sample repository provides idiomatic Zig wrappers for the ESP-IDF I²C master driver in imports/i2c.zig.
  • The idf.i2c.BUS struct manages bus lifecycle through add and del methods, while addDevice and removeDevice handle individual device handles.
  • Device discovery uses idf.i2c.probe to check for ACK responses across the 7-bit address space.
  • Data transfer relies on transmit, receive, and transmitReceive for write-only, read-only, and combined write-read operations respectively.
  • All functions convert ESP-IDF error codes into Zig error unions via errors.espCheckError, enabling standard try/catch error handling.

Frequently Asked Questions

How do I configure the I2C bus speed in Zig?

Pass the desired frequency in the i2c_device_config_t struct when calling idf.i2c.BUS.addDevice. Set the scl_speed_hz field to your target frequency (e.g., 100_000 for standard mode or 400_000 for fast mode). The bus handle itself does not store the speed; each device handle attached to the bus can specify its own clock rate.

What is the difference between transmitReceive and separate transmit and receive calls?

The idf.i2c.transmitReceive function generates a repeated-start condition (Sr) between the write and read phases, keeping the bus under the master's control without releasing the line. This is required by many I2C sensors that expect a register address write followed immediately by a data read. Separate transmit and receive calls would generate a stop condition between operations, causing some devices to reset their internal state.

How does error handling work when an I2C device does not respond?

All I2C wrapper functions return Zig error unions. If a device fails to acknowledge its address or a transaction times out, the underlying ESP-IDF driver returns an error code such as ESP_ERR_NOT_FOUND or ESP_ERR_TIMEOUT. The errors.espCheckError function converts these into Zig errors that you can catch using catch blocks or propagate with try, allowing you to handle communication failures using standard Zig error handling patterns.

Where can I find a complete working example of I2C scanning?

The repository includes a ready-to-build example in main/examples/i2c-scan.zig. This file demonstrates initializing the master bus, iterating through the valid 7-bit I2C address range (0x03 to 0x77), and using idf.i2c.probe to detect responding devices. You can copy this file into your project or reference it as a template for implementing device discovery logic.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →