Skip to main content
BlogNewsTop News

Zephyr and the BBC Microbit V2 Tutorial Part 3: I2C

By January 18, 2022No Comments

Written by Frank Duignan, Electronics Engineer and Lecturer at TU Dublin, Ireland

This is part 3 of a tutorial series by Frank Duignan. Part 1 focused on GPIO – you can read it here and Part 2 focused on analogue input and output – you can read it here.

The BBC Microbit V2 includes an LSM303AGR IC. This can sense acceleration and magnetic fields in three dimensions. It’s typical use is for tracking orientation and direction of motion. It communicates with the NRF52833 using an I2C (Inter-Integrated Circuit) serial bus. This has two signal lines as shown in the extract from the schematic below:

I2C_INT_SCL : Serial clock line for internal I2C communications

I2C_INT_SDA : Serial data line for internal I2C communications.

The phrase “internal” is used as these signals are not brought out to the edge connector and are used for on-board communications only.

A third connection called I2C_INT_INT allows the LSM303 interrupt the NRF52833 when something “interesting” happens e.g. the device is dropped or tapped.

The trace below shows the a data exchange between the NRF52833 and the LSM303. The I2C_INT_SCL line is used to pace the transmission and reception of data. This signal is generated by the NRF52833 which is playing the role of a master or controller device; the LSM303 is a slave or peripheral device. The trace shows two signals and also a higher level interpretation of what is going between the devices.

The I2C transaction begins with a Start signal. This is a High-Low transition of the SDA line when the SCL is high. Next follows an address value. Each I2C device has a manufacturer set 7 or 10 bit address, this discussion will deal with 7 bit addresses only. Several different devices can exist on the same I2C bus. When a controller wishes to communicate with a peripheral it must first send the peripheral’s address. Other I2C devices on the bus effectively disconnect from the bus for the remainder of that transaction. An additional bit is added just after the 7 bit address called the Read/Write bit. If this bit is a 0 then a write transaction is about to take place; if it’s a 1 then a read operation will follow. Altogether then the controller sends 8 bits at the start of a transaction : the 7 bit address and an R/W bit. It then sets it’s SDA signal to a high impedance state so that it can listen for a returning signal from the peripheral.

If a peripheral on the I2C bus recognizes the 7 bit address it pulls the SDA line to 0. This is an Acknowledge signal. If there is no peripheral with that address then the SDA line will remain high.

The LSM303 has a number of internal registers. Typical interactions with it involve reading from and writing to these registers. Immediately following the I2C addressing phase the register number of interest is transmitted. In the above trace this value is hex AA. The slave acknowledges this write. The transaction shown above actually consists of two parts. The first part writes the register number of interest to the LSM303, the second part reads data from that register (and the one following). The controller indicates this by sending a start signal again (labelled Sr = repeated start). If that had been the end of the transaction the controller would have sent a stop signal.

The last phase of this transaction is a read of the registers within the LSM303. Once again the I2C address is sent but this time the least significant bit i.e. the R/W bit is a 1 to indicate that a register read is required. Following the Ack from the peripheral the controller puts its SDA line into a high impedance or listening state and outputs 16 clock pulses. The peripheral takes control of the SDA line and sends back two bytes of data. A Negative Acknowledge or NACK signal is sent by the controller to indicate that the transaction is done. It follows this by sending a Stop signal (a low to high transition of the SDA line when the SCL line is high).

Overall the transaction in the graph above is a read of registers 0x2A and 0x2B in the LSM303. These contain the low and high byte values for the Y acceleration. Each byte could have been read separately (slower) but the LSM303 allows you read multiple bytes in successive registers by setting the most significant bit of the register number to a ‘1’ so register number 0xAA represents a transaction involving register number 0x2A and subsequent registers within the LSM303.

The code for interfacing with the LSM303 is shown below. Inside the function lsm303_ll_begin a pointer to the I2C device structure for device I2C_1 is obtained. The code then attempts to read a particular register within the LSM303 which contains a known value. This is a mechanism to allow us check to see whether the device is actually on the bus. In this case, the register number in question is 0x0f and it should contain the value 0x33 (decimal 51). If all of this works ok the function then sets the operating mode of the accelerometer : +/- 2g range with 12 bit resolution.

The function lsm303_ll_readAccelY performs the transaction shown in the I2C trace above. It makes use of the Zephyr I2C API function i2c_burst_read. This function takes the following arguments:

A pointer to the I2C device

The address of the I2C peripheral

The number of the register from which you want to read

A pointer to a receive buffer

A count of the number of bytes required.

Two other function lsm303_ll_readRegister and lsm303_ll_writeRegister facilitate reading and writing of single bytes from/to registers.

Note the way lsm303_ll_readAccelY combines the low and high bytes and scales them to a value of acceleration scaled up by a factor of 100.

static const struct device *i2c;
int lsm303_ll_begin()
{
    int nack;
    uint8_t device_id;
    // Set up the I2C interface
    i2c = device_get_binding("I2C_1");
    if (i2c==NULL)
    {
        printf("Error acquiring i2c1 interface\n");
        return -1;
    }   
    // Check to make sure the device is present by reading the WHO_AM_I register
    nack = lsm303_ll_readRegister(0x0f,&device_id);
    if (nack != 0)
    {
        printf("Error finding LSM303 on the I2C bus\n");
        return -2;
    }
    else   
    {
        printf("Found LSM303.  WHO_AM_I = %d\n",device_id);
    }
    lsm303_ll_writeRegister(0x20,0x77); //wake up LSM303 (max speed, all accel channels)
    lsm303_ll_writeRegister(0x23,0x08); //enable  high resolution mode +/- 2g
     
    return 0;
}
 
int lsm303_ll_readAccelY() // returns Temperature * 100
{
    int16_t accel;
    uint8_t buf[2];
    buf[0] = 0x80+0x2a; 
    i2c_burst_read(i2c,LSM303_ACCEL_ADDRESS,0xaa, buf,2);
    accel = buf[1];
    accel = accel << 8;
    accel = accel + buf[0];
    accel = accel / 16; // must shift right 4 bits as this is a left justified 12 bit result
    // now scale to m^3/s * 100.
    // +2047 = +2g
    int accel_32bit = accel; // go to be wary of numeric overflow
    accel_32bit = accel_32bit * 2*981 / 2047;
    return accel_32bit;    
}
 
int lsm303_ll_readRegister(uint8_t RegNum, uint8_t *Value)
{
        //reads a series of bytes, starting from a specific register
    int nack;   
    nack=i2c_reg_read_byte(i2c,LSM303_ACCEL_ADDRESS,RegNum,Value);
    return nack;
}
int lsm303_ll_writeRegister(uint8_t RegNum, uint8_t Value)
{
    //sends a byte to a specific register
    uint8_t Buffer[2];    
    Buffer[0]= Value;    
    int nack;    
    nack=i2c_reg_write_byte(i2c,LSM303_ACCEL_ADDRESS,RegNum,Value);
    return nack;
}

These functions can be used as follows to send the Y acceleration to the serial port.

void main(void)
{
    int ret;
         
    ret = lsm303_ll_begin();
    if (ret < 0)
    {
        printf("\nError initializing lsm303.  Error code = %d\n",ret);  
        while(1);
    }
    while(1)
    {    
        printf("Accel Y (x100) = %d\n",lsm303_ll_readAccelY());
         k_msleep(100);
 
    }

This particular example illustrates a low level (hence the “ll” in the function names) transaction with the LSM303 over the I2C bus. Zephyr includes a driver for the LSM303 (and may other I2C devices) which performs initialization and scaling.

The following modifications have to be made to prj.conf to trigger the inclusion of the I2C driver into the program:

CONFIG_STDOUT_CONSOLE=y
CONFIG_GPIO=y
CONFIG_I2C=y

Also, app.overlay needs to be modified so that the I2C interface is enabled and pins are assigned to it as follows:

&i2c1 {
        compatible = "nordic,nrf-twim";
        status = "okay";
        sda-pin = < 16 >;   // P0.16 = I2C_INT_SDA
        scl-pin = < 8 >;    // P0.8 = I2C_INT_SCL
};

Full code for this example can be found on github.