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

> Master I2C communication with Zig on ESP-IDF using idiomatic Zig wrappers. Explore type-safe bus management, device probing, and transactional APIs with effective error handling.

- Repository: [Matheus C. França/zig-esp-idf-sample](https://github.com/kassane/zig-esp-idf-sample)
- Tags: how-to-guide
- Published: 2026-03-05

---

**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.

```zig
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.

```zig
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.

```zig
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.

```zig
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`.

```zig
// 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.