ESP32 – How to Use I2C with ESP-IDF

This tutorial covers the basics of using I2C with ESP-IDF and demonstrates how to read temperature from a simple temperature sensor (TI TMP102). You can watch a video version of this guide here:

What is I2C?

Inter-Integrated Circuit (I2C) is a synchronous, multi-master, multi-slave serial communication protocol developed by Philips Semiconductors in the early 1980s. Despite its relatively low transmission speeds compared to modern protocols, I2C remains incredibly popular for connecting microcontrollers to peripheral devices like sensors, displays, and memory chips.

I2C uses only two wires for communication:

  • SDA (Serial Data): Carries the actual data being transmitted
  • SCL (Serial Clock): Provides the clock signal that synchronizes data transmission

The protocol operates on a master-slave architecture where one device (the master/controller) initiates communication and controls the clock line, while other devices (slaves/targets) respond when addressed. Multiple devices can share the same I2C bus, with each slave having a unique 7-bit address.

Key characteristics of I2C include:

  • Open-drain outputs requiring pull-up resistors
  • Start and stop conditions to frame communications
  • Acknowledgment bits to confirm successful data reception
  • Support for different speed modes (Standard: 100 kHz, Fast: 400 kHz, High-speed: up to 3.4 MHz)

SparkFun has a great introduction to I2C tutorial, if you would like to dive into the technical details of the protocol.

Hardware Connections

If you would like to use real hardware for this guide, you will need:

Connect the ESP32 development board to the TMP102 breakout board as follows:

ESP32 IoT Temperature Sensor TMP102 Fritzing Diagram

Note that I’ll also demonstrate how to use QEMU to emulate the ESP32 and TMP105 later in the tutorial (if you prefer to avoid using real hardware).

Reading Temperature from the TMP102

The TMP102 is a digital temperature sensor that communicates via I2C and provides temperature readings with 12-bit resolution (0.0625°C per bit). Understanding how to read from its temperature register requires examining the sensor’s internal structure and communication protocol, which we can obtain from the TMP102 datasheet.

TMP102 I2C Address and Registers

The TMP102 has a default I2C address of 0x48 when the ADD0 pin is connected to ground (which is the default for the SparkFun breakout board). The sensor contains four main registers:

  • Temperature Register (0x00): Contains the current temperature reading (read-only)
  • Configuration Register (0x01): Controls sensor operation modes
  • T_LOW Register (0x02): Sets lower temperature threshold
  • T_HIGH Register (0x03): Sets upper temperature threshold

Temperature Data Format

The temperature register stores data in a 12-bit format across two bytes:

  • Byte 1 (MSB): Contains temperature bits T11 through T4
  • Byte 2 (LSB): Contains temperature bits T3 through T0 in the upper 4 bits, with the lower 4 bits always zero

To convert the raw temperature data to degrees Celsius:

  1. Combine the two bytes: temperature = (byte1 << 8) | byte2
  2. Right-shift by 4 bits to align the 12-bit value: temperature >>= 4
  3. Multiply by the resolution: temperature_celsius = temperature * 0.0625

For negative temperatures, the sensor uses two’s complement format, so the same conversion process applies automatically when using signed integer variables.

Making I2C Calls in ESP-IDF

ESP-IDF provides a comprehensive I2C master driver API that simplifies communication with I2C devices. The typical workflow involves three main steps:

  1. Configure the I2C bus with i2c_new_master_bus()
  2. Configure the device on the bus with i2c_master_bus_add_device()
  3. Use i2c_master_transmit_receive() for the common pattern of writing a register address and then reading data from that register

Create Demo Project

For this tutorial, I’ll assume you already have the ESP-IDF toolchain installed and working (if not, check out my Getting Started with ESP-IDF post first). Create a new project with the following structure:

/workspace/apps/i2c_temperature_demo/
├── CMakeLists.txt
└── main/
    ├── CMakeLists.txt
    └── main.c

In the top-level CMakeLists.txt, enter the following:

# Required CMake version
cmake_minimum_required(VERSION 3.16)

# Set up ESP-IDF build environment
include($ENV{IDF_PATH}/tools/cmake/project.cmake)

# Project name
project(app)

In main/CMakeLists.txt, enter the following:

idf_component_register(
    SRCS "main.c"
    INCLUDE_DIRS ""
)

In main/main.c, enter the following:

#include <stdio.h>

#include "driver/i2c_master.h"
#include "freertos/FreeRTOS.h"

// Settings
static const i2c_port_num_t i2c_port = 0;           // -1 for auto-select
static const gpio_num_t i2c_sda_pin = 5;            // GPIO number for SDA
static const gpio_num_t i2c_scl_pin = 6;            // GPIO number for SCL
static const uint8_t i2c_glitch_ignore_cnt = 7;     // 7 is typical
static const uint16_t tmp10x_addr = 0x48;           // TMP102/105 I2C address
static const uint32_t tmp10x_scl_speed_hz = 100000; // 100kHz (standard mode)
static const uint32_t sleep_time_ms = 1000;

// Constants
static const uint8_t tmp10x_reg_temp = 0x00;

void app_main(void)
{
    esp_err_t esp_ret;
    i2c_master_bus_handle_t i2c_bus;
    i2c_master_dev_handle_t tmp10x_dev;
    uint8_t reg;
    uint8_t data[2];
    int16_t temperature;

    // Set I2C bus configuration
    i2c_master_bus_config_t bus_config = {
        .i2c_port = i2c_port,
        .sda_io_num = i2c_sda_pin,
        .scl_io_num = i2c_scl_pin,
        .clk_source = I2C_CLK_SRC_DEFAULT,
        .glitch_ignore_cnt = i2c_glitch_ignore_cnt,
        .flags.enable_internal_pullup = true,
    };

    // Initialize the I2C bus
    esp_ret = i2c_new_master_bus(&bus_config, &i2c_bus);
    if (esp_ret != ESP_OK) {
        printf("Error: Failed to initialize I2C bus\r\n");
        abort();
    }

    // Set I2C device configuration
    i2c_device_config_t dev_config = {
        .dev_addr_length = I2C_ADDR_BIT_LEN_7,
        .device_address = tmp10x_addr,
        .scl_speed_hz = tmp10x_scl_speed_hz,
    };

    // Initialize the TMP10x I2C device on the bus
    esp_ret = i2c_master_bus_add_device(i2c_bus, &dev_config, &tmp10x_dev);
    if (esp_ret != ESP_OK) {
        printf("Error: Failed to initialize I2C device\r\n");
        abort();
    }

    // Superloop
    while (1) {

        // Delay
        vTaskDelay(sleep_time_ms / portTICK_PERIOD_MS);

        // Store register address in buffer
        reg = tmp10x_reg_temp;

        // Read temperature
        esp_ret = i2c_master_transmit_receive(tmp10x_dev, &reg, 1, data, 2, -1);
        if (esp_ret != ESP_OK) {
            printf("Error: Failed to read temperature\r\n");
            continue;
        }

        // Convert data to temperature (deg C)
        temperature = (data[0] << 8) | data[1];
        temperature >>= 4;
        temperature *= 0.0625;

        // Print temperature
        printf("Temperature: %d deg C\r\n", temperature);
    }
}

Understanding the Code

Let’s examine the key sections of the provided main.c file:

Global Configuration Variables

static const i2c_port_num_t i2c_port = 0;
static const gpio_num_t i2c_sda_pin = 5;
static const gpio_num_t i2c_scl_pin = 6;
static const uint8_t i2c_glitch_ignore_cnt = 7;
static const uint16_t tmp10x_addr = 0x48;
static const uint32_t tmp10x_scl_speed_hz = 100000;

These constants define the hardware configuration: which I2C port to use, which GPIO pins for SDA and SCL, the sensor’s I2C address, and the communication speed (100 kHz for standard mode). 

Bus and Device Setup

The code initializes the I2C bus with pull-up resistors enabled and a glitch filter count of 7. The i2c_glitch_ignore_cnt sets the number of clock cycles that the I2C controller should ignore I2C noise for. In other words, the I2C controller will only consider signal changes that persist for more than 7 clock cycles as valid. The device is then added to the bus with the TMP102’s address and communication speed.

Main Reading Loop

The core functionality occurs in an infinite loop:

while (1) {
    vTaskDelay(sleep_time_ms / portTICK_PERIOD_MS);
    reg = tmp10x_reg_temp;
    esp_ret = i2c_master_transmit_receive(tmp10x_dev, &reg, 1, data, 2, -1);
    if (esp_ret != ESP_OK) {
        printf("Error: Failed to read temperature\r\n");
        continue;
    }
    temperature = (data[0] << 8) | data[1];
    temperature >>= 4;
    temperature *= 0.0625;
    printf("Temperature: %d deg C\r\n", temperature);
}

This loop:

  1. Waits for the specified delay period
  2. Writes the temperature register address (0x00) to the sensor
  3. Reads two bytes of temperature data
  4. Converts the raw data to degrees Celsius
  5. Prints the result

Note that the code uses integer arithmetic for simplicity, truncating decimal portions of the temperature reading.

Error Handling

The code implements proper error checking after each ESP-IDF function call. For bus and device initialization, errors cause the program to abort. During temperature reading, errors are logged but the program continues, attempting to read again after the next delay period.

Building and Flashing the Program

To build and flash this I2C temperature monitoring program:

1. Set Up the Build Environment

Navigate to your project directory and configure the target ESP32 variant:

cd /path/to/your/project
idf.py set-target esp32s3  # or esp32, esp32c3, etc.

2. Build the Project

Compile the project and generate the binary files:

idf.py build

This creates the necessary binary files in the build directory, including the bootloader, partition table, and application binary.

3. Flash to Hardware

Connect your ESP32 device and flash the program:

python -m esptool.py --port "<SERIAL_PORT>" --chip auto --baud 921600 \
    --before default_reset --after hard_reset write_flash \
    --flash_mode dio --flash_freq 40m --flash_size auto \
    0x0 build/bootloader/bootloader.bin \
    0x8000 build/partition_table/partition-table.bin \
    0x10000 build/your_app_name.bin

Replace <SERIAL_PORT> with your actual serial port and adjust the binary paths as needed.

4. Monitor Serial Output

Open a serial monitor to view the temperature readings:

python -m serial.tools.miniterm "<SERIAL_PORT>" 115200

You should see the temperature value being printed to the console. Feel free to lightly touch or breath on your TMP102 sensor to watch the temperature rise.

Running with QEMU Emulation

ESP-IDF supports QEMU emulation for testing I2C code without physical hardware. The emulated environment includes a virtual TMP105 sensor (very similar to the TMP102) that responds to the same I2C commands.

Build for QEMU

Set the target back to the standard ESP32 (QEMU works best with this variant in my experience):

idf.py set-target esp32
idf.py build

Launch QEMU

Start the emulated ESP32 with the I2C temperature sensor:

idf.py qemu

This command compiles the project if needed and launches QEMU with the appropriate virtual hardware configuration.

Interact with the Emulated Sensor

Once QEMU is running, you can access the QEMU monitor console by pressing Ctrl+A followed by C. This allows you to interact with the virtual TMP105 sensor. For example, we can adjust the temperature (in thousandths of a degree Celsius):

(qemu) stop
(qemu) qom-get /machine/unattached/device[26] temperature
(qemu) qom-set /machine/unattached/device[26] temperature 15000
(qemu) cont

These commands stop the emulation, check the current virtual temperature (25°C = 25,000 millidegrees), set it to 15°C, and resume execution. The running ESP32 program will detect and display the new temperature value.

To exit the emulator, return to the monitor console (Ctrl+A, C) and type:

(qemu) quit

Going Further

We covered how to interface with temperature sensors using ESP-IDF’s I2C master driver. The same principles apply to other I2C devices: configure the bus, register the device, and use the transmit/receive functions to communicate. I recommend checking out the various I2C examples in ESP-IDF if you’d like to see how to use other I2C functionality.

If you would like to go further, check out my IoT Firmware Development with ESP32 and ESP-IDF course. In the course, we cover the basics of ESP-IDF, reading from sensors, connecting to the internet via WiFi, posting data via HTTP REST calls, securing connections with TLS, and interacting with MQTT brokers.

IoT Firmware Development with ESP32 and ESP-IDF

Leave a Reply

Your email address will not be published. Required fields are marked *