Using an I2C Device Without a Library

Sometimes you might want to use a sensor with a development board, but later find out there’s no library. Utilizing an I2C module and reading/writing to its registers directly can be daunting at first. This guide is to show how to do that.

Laying the Groundwork

In this post, I won’t get into the nitty-gritty of how I2C works. Generally, a message is sent in this format: start condition, 7 or 10-bit address, read/write flag, acknowledge bit, and then one or several 8-bit data frames, with a stop condition at the end. For this example, I will be using an MPR121 capacitive touch sensor, along with an Arduino Uno, but much of what is discussed here is also applicable to most other modules and boards.

MPR121 module

Datasheet Exploration

Before any code can be written, the datasheet for the respective device must be read through in order to find out about various registers and operating modes. Datasheets list everything from pin descriptions to register definitions, and even package information.

Datasheet for MPR121

Know Your Board

Different boards use different I2C libraries. For instance, Arduino and Particle use the Wire library for handling communication, whereas the Azure Sphere Module has its own set of functions, and other boards might not even have a native library.

Most libraries have the following functionality:

  • Write n bytes
  • Read n bytes
  • Write then immediately read from address

By combining these simple operations, data can be written and read easily.

Configure the Module

Most I2C modules require some setup at the beginning to get going. This could be because a power mode needs to be changed, sampling rates must be set, or interrupts need to be enabled. Read the datasheet to know which registers need to be configured at startup. For example, the MPR121 IC needs to know the level of filtering to be done on raw data, which is accomplished by writing values to the registers at 0x5C and 0x5D.

Read From It

Almost every I2C-enabled sensor is able to be read from, or else it would be useless as a sensor. On the datasheet, find where the data registers are located. Most sensors have 8-bit registers, which means that any data larger than 255 (an unsigned byte), has to be split among several registers and then parsed together. Although this sounds complicated, it is actually quite simple in practice.

Most datasheets have a register map like this

For the MPR121, binary touch data is stored in registers 0x00 and 0x01. The MPR121 stores pin states (0 or 1) in two bytes, with the LSB (Least Significant Bit) corresponding to either touch-pin 0 or touch-pin 8, since bytes have only 8 bits. This means that the data for the 12 pins is split between the two registers. To combine them into one larger value, the following operation is performed:

Read data from register 0x00 and 0x01, storing the 8-bit values into variables:

byte reg0, reg1;

Then, combine them into a single unsigned int using:

uint16_t pin_vals = (reg1 << 8) + reg0;

uint16_t is the datatype for a number that is 2 bytes (hence the 16, which is the number of bits) and unsigned, which means it can only be positive, and it also means the first bit will not be treated as a sign flag. Since reg1 is the MSB value, it needs to be bit-shifted 8 spaces to the left to allow for the LSB value, reg0, to be added on at the end. For example, imagine pins 2, 7, and 11 were active; reg0 would be 10000100, and reg1 would be 00001000.

00001000 << 8 = //reg1 << 8
100000100 //reg0
00001000100000100 //New combined value

Now, to see if a pin is active, take that pin’s value and use it to shift a 1 that many spaces to the left, then do a bit-wise & operation to combine them.

uint16_t current_pin_vals = 00001000100000100;
uint8_t pin_to_read = 2;
bool pin_value;
pin_value =
_BV(pin_to_read); //The _BV(n) macro simply means to left-shift a 1 n-spaces left (1 << n)
/* Here it is in binary
00000000000000100 // 1 << 2 or 100
00000000000000100 //This equates to true

Make a Custom Library

After getting to know which registers perform which operations, it gets a little tedious to write many instructions just to get a single value in several places. Code portability is very important when writing programs for various systems, which is why it makes sense to create and use your own library. This allows you to pass different values with ease and take advantage of object-oriented programming paradigms, such as inheritance and passing objects to other functions. While the MPR121 IC already has several libraries for different platforms, other modules might not, so it becomes necessary to create them.

Go to Source
Author: Evan Rust

By admin

I'm awesome! What else would I say about myself.