How to Write an I2C device driver. Case Study: The NaviGuider Compass
Image Generated With MidJourney

How to Write an I2C device driver. Case Study: The NaviGuider Compass

When designing a circuit board, you often integrate pre-built components that enable specific functions. For example, an ROV Sensor Package might require a compass, depth sensor, temperature sensor, EEPROM memory, and more. Each of these components must communicate with the microcontroller, which collects data and processes it accordingly.

A key design decision emerges here: how should these components communicate? While power and ground are easy to distribute using a power bus, data transmission is where things start to get messy.

Traditional Approach: In classical analog communication, each component would need dedicated data lines connected to the microcontroller. As the number of sensors grows, so does the complexity—a microcontroller might require hundreds of wires, leading to a rat’s nest of tangled traces that makes the earbuds in your pocket look well-organized.

A Smarter Solution: What if we could simplify data transmission the same way we distribute power—by using a shared bus? Instead of a PCB that looks like a bird’s nest of despair, all components could communicate over just two wires.

That’s where I2C comes in. Think of I2C as the tiny librarian keeping a room full of noisy sensors in order—making sure each one gets to talk, but only when it’s their turn. With I2C, a microcontroller can effortlessly communicate with multiple devices using just two wires—solving the wiring nightmare and making circuit design far more elegant.

I2C solves the wiring mess, but it doesn’t solve everything. While it provides a way for devices to talk, it doesn’t magically teach them what to say. Introducing an I2C device to a microcontroller is like giving it a brand-new smart tv—sure, it’s plugged in, but unless you have the instruction manual, you’ll end up pressing buttons at random, hoping you don't get stuck watching Bonanza reruns all day.

That’s where an I2C device driver comes in. Think of it as the relationship counselor between the microcontroller and the sensor. It teaches them how to greet each other, how to exchange data, and—most importantly—how not to misinterpret random signals as catastrophic system failures. Without a proper driver, the microcontroller and the sensor are just two strangers locked in an eternal staring contest, waiting for the other to make the first move.

I2C Basics

I2C uses a 2-wire bus: SCL (clock) and SDA (data). Pull-up resistors keep the lines high (binary 1) when idle, and devices pull them low (binary 0) to communicate. Without pull-ups, the lines would just float aimlessly, which is about as useful as an unplugged toaster.

Each device has a 7-bit or 10-bit address, so the microcontroller (master) knows exactly who to talk to. The master sends the device address, then a register address, then either writes or requests data. If a device doesn’t respond, it’s either malfunctioning or just pretending not to be home.

Case Study: The NaviGuider Compass

In a recent ROV sensor package design, we needed a reliable compass, and the PNI NaviGuider I2C compass was chosen for the job. Naturally, that meant someone had to write a driver to make it play nice with the rest of the system—and that someone was me. (Lucky me.)

The first thing you want to do when writing a new device driver is to get acquainted with the device itself. This is where the datasheet comes in handy. And by "handy," I mean it will either be your best friend or an indecipherable cryptic manuscript, depending on how well it’s written. The datasheet reveals the pre-programmed I2C address and, more importantly, the register map—where all the crucial settings, data, and status bits live.

So, let’s dive in. 

You can find the device driver here if you want to check out the code: https://github.com/MarineAppliedResearch/Naviguider_Compass_I2C

When I check the table of contents, the first thing that stands out is the Product Overview on page 3. Reading through this section, I learned that the Naviguider Compass is built on the PNI SENtral-A2 sensor fusion coprocessor. Naturally, the next step is to search for more information on this processor—only to discover another datasheet. Great. Turns out, the Naviguider datasheet doesn’t list the necessary registers, so we’ll need to reference both datasheets at the same time. This is going to be fun.

Luckily, I have three screens: one for code, one for juggling datasheets, and one for Google/ChatGPT—because yes, I absolutely use them to look up anything I don’t understand. Going to the library and flipping through index cards just doesn’t cut it anymore—especially since my library doesn’t even have index cards.

Next, we scan through both datasheets to figure out how to get the device up and running. The first key piece of information we find is the I2C address—we’ll be talking to address 0x28. With that in hand, we can initialize our I2C device to communicate with this address:

I2CDevice *m_i2c_dev;
m_i2c_dev = new I2CDevice(0x28);        

Now that our I2C device is initialized, we can call write, read, and write_then_read functions on m_i2c_dev, allowing us to communicate with any register on the device.

Looking through the datasheet, we find a section called "Execution Modes." It explains that when the coprocessor powers on, it enters boot mode. To transition to main execution mode, the microcontroller must write a 1 to bit 0 of the Chip Control Register.

Next, we need to find this Chip Control Register. The datasheet tells us it's located at register 0x34 (hex), so we'll define it in code:

#define NAVIGUIDER_REG_CHIP_CONTROL (0x34)        

Now that we know the Chip Control Register is at 0x34, we need to write a 1 to bit 0 to transition the coprocessor into main execution mode. Here's the C++ code that does this:

bool NaviguiderCompass::startCPU(){
    bool returnVal = false;
	
    uint8_t registerAddress = NAVIGUIDER_REG_CHIP_CONTROL;
    uint8_t value = 0x01;  
	
    if (!m_i2c_dev->write(&value, 1, true, &registerAddress, 1)) {
        returnVal = false;
    }else{
	  returnVal = true;
    }
    return returnVal;
}        

According to the datasheet, once the sensors are configured, they will store event data in the First In, First Out (FIFO) buffer. To retrieve this data, we follow a simple algorithm:

  1. Check how much data is available – Read the Bytes Remaining register.
  2. Read the FIFO Buffer
  3. Loop through the FIFO Buffer, reading and handling events until no bytes remain.

First, let’s locate and define the relevant registers in our code:

#define NAVIGUIDER_REG_BYTES_REMAINING_LSB       (0x38)
#define NAVIGUIDER_REG_BYTES_REMAINING_MSB     (0x39)
#define NAVIGUIDER_FIFO_REGISTER                             (0x01)	        

Now that we have the Bytes Remaining Register, we need to check how much data is available before reading from the FIFO buffer.

Step 1: Check Available Data

First, we read the number of bytes in the FIFO:

uint16_t bytesAvailable = 0;

// Read the number of bytes available in the FIFO
uint8_t reg = NAVIGUIDER_REG_BYTES_REMAINING_LSB;

if (!m_i2c_dev->write_then_read(&reg, 1, (uint8_t *)&bytesAvailable, 2)) {
    return 0; // Communication error
}        

At this point, bytesAvailable tells us how much data we need to read.

Step 2: Read the FIFO Buffer

We allocate a buffer large enough to store the data and read it from the FIFO register:

// Now read the FIFO data from the appropriate register
uint8_t fifoBuffer[24*1024];		// Allow reading up to 24KB
uint8_t fifoDataReg = NAVIGUIDER_FIFO_REGISTER;

if (!m_i2c_dev->write_then_read(&fifoDataReg, 1, fifoBuffer, bytesAvailable)) {
    return 0; // Read error
}        

Now, fifoBuffer contains one or more events, which we need to process.

Step 3: Parse Events

Since multiple events may be packed into the buffer, we loop through and process each one:

uint32_t NaviguiderCompass::parseFifo(uint32_t size) {
    uint32_t index = 0;
    uint32_t bytesUsed;
    uint32_t bytesRemaining = size;

    if (size == 0) return size;

    // parse each block in the fifo until there is no data remaining
    do {
        bytesUsed = parseNextFifoBlock(&fifoBuffer[index], bytesRemaining);
        index += bytesUsed;
        bytesRemaining -= bytesUsed;
    } while (bytesUsed > 0 && bytesRemaining > 0);

    // Let caller know how many bytes we processed
    return size - bytesRemaining;
}        

The parseFifo function calls parseNextFifoBlock, which processes one event at a time and returns the number of bytes it consumed. The function is then called repeatedly until the entire FIFO buffer is empty.

Before implementing the parsing logic, we define the function’s structure to ensure it properly returns the number of bytes it processes. For now, it will just return the full size of the block, acting as a placeholder:

uint32_t NaviguiderCompass::parseNextFifoBlock(uint8_t* buffer, uint32_t size) {

    // Get the sensor id from the first byte in the buffer
    uint8_t sensorId = buffer[0];

    switch (sensorId) {
        // Check for 0, or defaults
        case 0:
            return size;
        default:
            return size;
    }
}        

Next, we check the datasheet for event types and define the ones we need. Since we're interested in orientation events (to get the compass heading), we identify the relevant registers:

#define NAVIGUIDER_REG_ORIENTATION 				(0x03)
#define NAVIGUIDER_REG_ORIENTATION_WAKE 			(0x43)        

Now, we update parseNextFifoBlock to process orientation events from the FIFO buffer. The datasheet specifies a scale factor for each sensor, which we apply when extracting data.

SensorData3Axis orientationData; // Defined elsewhere in code 

// Get orientation Data
 case NAVIGUIDER_REG_ORIENTATION:
 case NAVIGUIDER_REG_ORIENTATION_WAKE:
 {
     float scale = 360.0f / powf(2.0f, 15.0f);
     get3AxisSensorData(&orientationData, scale, buffer);  
     return 8;
 }        

To properly interpret the orientation data, we define two structures:

struct SensorData3Axis_RAW
{
	int16_t x;			// Raw X-axis sensor reading 
	int16_t y;			// Raw Y-axis sensor reading 
	int16_t z;			// Raw Z-axis sensor reading 
	uint8_t status;		
};

struct SensorData3Axis
{
	float x;			// Processed X-axis sensor value 
	float y;			// Processed Y-axis sensor value 
	float z;			// Processed Z-axis sensor value 
	float extraInfo;
}        

Now, we define a function to extract and scale the raw data:

uint8_t NaviguiderCompass::get3AxisSensorData(SensorData3Axis* data, float scale, uint8_t* buffer)
{
    // Temporary structure to hold raw sensor data
    SensorData3Axis_RAW rawData;

    // Copy raw data from the buffer, starting from index 1
    memcpy(&rawData, &buffer[1], sizeof(rawData));

    // Apply scaling factor to convert raw integer into floating-point
    data->x = (float)rawData.x * scale;
    data->y = (float)rawData.y * scale;
    data->z = (float)rawData.z * scale;

    // Store additional sensor status information
    data->extraInfo = rawData.status;

    return 1; // Indicate success
}        

According to the datasheet, orientationData.x provides the compass heading in a range of -180 to 180 degrees. However, most compass systems represent headings in a 0 to 359-degree range. To ensure consistency, we use modulo arithmetic to remap the values:

float NaviguiderCompass::getHeading() {
    return fmod(orientationData.x + 360.0f, 360.0f);
}        

And we turn the device on and… nothing. No heading data, nothing in the FIFO—just silence. Not even an error to give us a clue, just pure, unhelpful nothingness. Time to figure out what’s wrong. So, I dive into the datasheet, searching for answers like someone desperately flipping through an IKEA manual, hoping the missing step will reveal itself.

After some digging, I find the culprit: the sensors won’t send data until we explicitly tell them how often to do so. Makes sense—why would they start working without clear instructions? That would be far too convenient.

To set the sensor rates, we need to write a parameter—which sounds simple enough, except the datasheet casually assumes you already know what that means. Regrettably this datasheet is starting to feel like an indecipherable cryptic manuscript, after several hours I am able to intuit the following algorithm:

  • Write the page number for your desired data to the Parameter Page Select register.
  • Write the desired values to the Parameter Load register.
  • Write the parameter number to the Parameter Request register.
  • Poll the Parameter Acknowledgment register in a loop until it either returns 0x80 (error) or echoes back the parameter number we wrote—like a very reluctant conversation partner.
  • End the procedure by writing 0 to both the Parameter Request and Parameter Page Select registers, presumably to let the chip know we’re done bothering it.

The next step is to locate these registers in the datasheet and define them in our code. Hopefully, this time, the device will actually acknowledge our existence:

#define NAVIGUIDER_REG_PARAMETER_PAGE_SELECT   (0x54)
#define NAVIGUIDER_REG_LOAD_PARAM_BYTE_0       (0x5C)
#define NAVIGUIDER_REG_PARAMETER_REQUEST       (0x64)
#define NAVIGUIDER_REG_PARAMETER_ACKNOWLEDGE   (0x3A)        

Then we implement the algorithm in our writeParameter function:

uint32_t NaviguiderCompass::writeParameter(uint8_t page, const ParameterInformation* paramList, uint8_t numParams, uint8_t* values) {

    uint8_t i, paramAck, paramNum, pageSelectValue;
    uint16_t valIndex = 0;
   
    // Loop through all params
    for (i = 0; i < numParams; i++) {
        // Choose which page to select
        pageSelectValue = page | (paramList[i].DataSize << 4);

        // Step 1: Write the Page Number to Parameter Page Select Register
        uint8_t reg = NAVIGUIDER_REG_PARAMETER_PAGE_SELECT;
        if (!m_i2c_dev->write(&pageSelectValue, 1, true, &reg, 1))
        {
            return 0;
        }

        // Step2: Write the Parameter Load register with the values
        reg = NAVIGUIDER_REG_LOAD_PARAM_BYTE_0;
        if (!m_i2c_dev->write(&values[valIndex], paramList[i].DataSize, true, &reg, 1))
        {
            return 0;
        }

        // Step 3: Write to Param request Register
        paramNum = paramList[i].ParameterNumber | 0x80;
        reg = NAVIGUIDER_REG_PARAMETER_REQUEST;
        if (!m_i2c_dev->write(&paramNum, 1, true, &reg, 1))
        {
            return 0;
        }

        // Step 4, Read from the param ack register:
         // Poll Parameter Acknowledge Register (Use do-while like the original)
        uint8_t ack = 0;
        reg = NAVIGUIDER_REG_PARAMETER_ACKNOWLEDGE;
        do {
            if (!m_i2c_dev->write_then_read(&reg, 1, &ack, 1))
            {
               
                return 0;
            }

            if (ack == 0x80)
            {
                // Reset registers
                uint8_t resetVal = 0;
                reg = NAVIGUIDER_REG_PARAMETER_REQUEST;
                m_i2c_dev->write(&resetVal, 1, true, &reg, 1);

                reg = NAVIGUIDER_REG_PARAMETER_PAGE_SELECT;
                m_i2c_dev->write(&resetVal, 1, true, &reg, 1);

                return 0;
            }
        } while (ack != paramNum);

        valIndex += paramList[i].DataSize;
    }

    // Step 5: End the parameter transfer procedure by writing 0 to the Parameter Request register
    uint8_t resetVal = 0;
    uint8_t reg = NAVIGUIDER_REG_PARAMETER_REQUEST;
    if (!m_i2c_dev->write(&resetVal, 1, true, &reg, 1))
    {
        return 0;
    }

    // Step 6: End the parameter transfer procedure by writing 0 to the Parameter Request register
    resetVal = 0;
    reg = NAVIGUIDER_REG_PARAMETER_PAGE_SELECT;
    if (!m_i2c_dev->write(&resetVal, 1, true, &reg, 1))
    {
        return 0;
    }
    return 1;
}        

Now that we have a function to write parameters, we can use it to set the sensor rates. First, we need to locate the Parameter Page for sensor configurations and define it:

#define NAVIGUIDER_PARAMETER_PAGE_SENSOR_CONFIG   (0x05)        

And then we can write a function that will set our sensor rates:

uint32_t NaviguiderCompass::setSensorRate(uint8_t sensorId, uint8_t rate)
{
    uint8_t paramPage;
    ParameterInformation param;
    paramPage = NAVIGUIDER_PARAMETER_PAGE_SENSOR_CONFIG;

    param.ParameterNumber = sensorId;
    param.DataSize = 2; // Set the size of the parameter

    // Write the parameter to the sensor configuration
    uint32_t isWritten = 0;

    isWritten = writeParameter(paramPage, &param, 1, &rate);

    return isWritten;
}        

Now, in our original initialization function we can set the sensor rate for our first 4 sensors, according to rate numbers given in the Datasheet:

setSensorRate( 1, 0x0A); //0a
setSensorRate(2, 0x64); //64
setSensorRate(3, 0x0F); //0f
setSensorRate(4, 0x0F); //0f        

Suddenly the Naviguider Compass bursts into action—no longer a silent bystander, but a data-spewing powerhouse! The FIFO buffer overflows with fresh sensor readings, a relentless stream of information ready to be decoded. At last, we can extract our heading and claim victory over the void!

And just like that, we’ve got a working compass! Just call getHeading(), and you’ll get the latest reading from the NaviGuider I2C Compass—no magic involved, just cold, hard I2C logic.

Writing an I2C driver might seem intimidating at first, but once you break it down, it’s really just a scavenger hunt through the datasheet, tracking down the right I2C address and registers, setting up communication, and translating raw data into something useful. Of course, things rarely work perfectly on the first try—maybe the address is wrong, the pull-up resistors are missing, or the timing is off and your microcontroller is just staring blankly into that cold void. 

Once the basics are working, there’s always room for improvement, like handling errors better, using interrupts, or optimizing power consumption (because nothing says “professional” like a device that doesn’t burn unnecessary milliamps). And this isn’t just about compasses—this same process applies to almost any I2C sensor, so once you’ve done it once, you’ll start seeing patterns. 

If you’re curious to dig deeper, the full code is on GitHub, go ahead and make an improvement and send me a pull request. If you have thoughts, questions, or just want to share your own tales of I2C debugging misery, I’d love to hear them!

Very impressive, good work Isaac Travers

Like
Reply

Good work on structure. Have a few concerns, how much testing is required? At what scale do you suspect latency ? Is this approach more about simplifying complexity over "speed"? If so is it quantifiable

Beautifully written stuff! You do a wonderful job of explaining the code.

To view or add a comment, sign in

Others also viewed

Explore content categories