CH32V003F4P6 - SPI communication with ADXL345

In this article, I continue the lecture series with the SPI communication. I cover the basics of this communication protocol using a simple accelerometer, ADXL345. This sensor can, in fact, use I2C communication, and I already covered that topic in an earlier article; however, to really benefit from its high-speed sampling capabilities, SPI must be used. I will show how to initialise the SPI on this chip to match the requirements of the accelerometer, then I will implement a few simple functions to initialise the sensor and read data out of it.

 

In this project, I use the 4-wire SPI setup, which means that I need MISO, MOSI, SCLK and CS pins to establish communication between the microcontroller and the accelerometer. Similarly to the I2C communication, the communication is based on a master-slave arrangement where the master generates the clock signal and initiates the communication, and the slave receives the clock signal and responds to the communication. One master (typically a microcontroller) can have several slave devices. The devices are distinguished by their CS line. Therefore, SPI does not suffer from the same issue as I2C when multiple devices have the same address, and we need to use some hardware tricks to operate both devices on the same bus. Furthermore, SPI allows full-duplex (simultaneous two-way) communication, whereas I2C only allows half-duplex (still two-way but not simultaneous) communication.

I picked the ADXL345 accelerometer to demonstrate the principles of the SPI communication. It is a 3-axis accelerometer with a high resolution (13-bit) and measurement capabilities up to +/-16g. As I mentioned in the intro, the chip is capable of using I2C to communicate with the microcontroller, but I will use it with 4-wire SPI to achieve high sampling speeds. The chip has many fancy built-in features such as single/double tap detection, activity monitoring, free fall detection and so on…

 

CH32V003F4P6 development board

 

ADXL345 3-axis accelerometer

 

Basic SPI communication implementation

Before writing the hardware-specific code for the accelerometer, we need to be able to send and receive data using the SPI bus. So, apart from initialising the SPI peripheral, we need to implement communication functions.

First, we need to be able to initialise the SPI peripheral. The SPI pins on the CH32V003F4P6 chip are distributed on port C, between pins 4-7, although one of the pins, the CS (chip select), can be selected freely.

I assigned the chip select pin to PC4 because the rest of the pins are close to this pin. The GPIO mode must be set to GPIO_Mode_Out_PP, which means that the pin is used as a push-pull output. In simple words, we can toggle this pin as we want. Right after initialising this pin, I pull it high, because the accelerometer requires so.

PC5 and PC6 are the SCLK and MOSI (SDA) pins. They are both defined in the GPIO_Mode_AF_PP mode, which means that these GPIO pins are set up using their alternative function (AF) in push-pull (PP) mode. Alternative function because they are not used as simple GPIO pins, but SPI pins, and push-pull because they will be toggled.

Finally, PC7 is the MISO (SDO) pin. This must be set up as a floating input (GPIO_Mode_IN_FLOATING) because this pin is driven by the slave, and the slave is going to toggle it according to the returned data.

We use bidirectional SPI communication, so the direction is set as SPI_Direction_2Lines_FullDuplex. Of course, this microcontroller is the master, so the SPIMode is chosen accordingly. The data is transferred in 8-bit chunks, so the data size is set to SPI_DataSize_8b.

Two very important lines regarding the clock are the polarity (CPOL) and phase (CPHA). The polarity must be set to high (SPI_CPOL_High), and the phase should be set to SPI_CPHA_2Edge, which means that the second clock edge indicates when the data should be sampled and shifted. This is specific to the accelerometer, so one should always check the datasheet to see how the SPI clocks should be configured for a given device.

Since we control the chip select (CS) pin manually, we need to apply the SPI_NSS_Soft setting, which means that the chip select pin is not according to the default hardware pin layout.

I set the baud rate prescaler to 32, which should result in a 1.5 MHz SPI clock speed. Good enough.

Again, a chip-dependent setting, although many chips allow reconfiguring this setting: bit order. I set the bit order to SPI_FirstBit_MSB. So, the bit order is “MSB first”.

Finally, I pass the settings to the SPI registers and then enable the SPI bus.

 
void SPI_Initialize(void)
{
    GPIO_InitTypeDef GPIO_InitStructure = {0};
    SPI_InitTypeDef  SPI_InitStructure = {0};

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC | RCC_APB2Periph_SPI1, ENABLE);

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; 
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOC, &GPIO_InitStructure);
    GPIO_WriteBit(GPIOC, GPIO_Pin_4, Bit_SET);

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_6; 
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; 
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOC, &GPIO_InitStructure);

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(GPIOC, &GPIO_InitStructure);

    SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex; 
    SPI_InitStructure.SPI_Mode = SPI_Mode_Master; 
    SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; 
    SPI_InitStructure.SPI_CPOL = SPI_CPOL_High; 
    SPI_InitStructure.SPI_CPHA = SPI_CPHA_2Edge; 
    SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; 
    SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_32; 
    SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; 
    SPI_Init(SPI1, &SPI_InitStructure);

    SPI_Cmd(SPI1, ENABLE);
}
 

SPI transfer

This function implements a full-duplex SPI transaction. We send one byte to the slave and (hopefully) the slave returns one byte.

First, we send the data to the slave, and then we wait until the TXE (transmit (TX) buffer empty (E)) is set. This indicates that the SPI peripheral has moved the byte into its shift register and is driving it out on MOSI.

Then, we wait until the RXNE (Receive (RX) buffer Not (N) Empty (E)) is set, which means that the peripheral has finished shifting in 8 clock pulses and has placed the received byte from MISO into its receive register.

Finally, we read and return the received byte.

Write register (ADXL345)

This is a more hardware-specific function, but it is more or less universal as long as we send stuff to the slave device in 8-bit chunks. The function expects 2 parameters: the register’s address and the value we want to pass to it. Since we decided to use the SPI_NSS_Soft setting, we need to toggle the chip select pin manually. The transaction requires the CS pin to go low, so first we pull it low. Then, using the already implemented SPI_Transfer() function, we first send the register address we want to modify, and then we send the actual value we want to write in the register. After this transaction, we set the CS high again.

 

Read register (ADXL345)

This is an even more hardware-specific function, but it still tells the story. The function needs 3 parameters: the address of the register we want to read from, the number of bytes we want to read out from it and the buffer we want to use to store the returned values.

First, we build the command byte by combining the received register address with 0x80. 0x80 (1000 0000) sets D7 to 1, which means that we want to read.

Then we have to check if we need to read more than 1 byte. If so, we set bit 6 of the recently created command byte to 1, which will tell the chip to automatically increment the address on each clock. This auto-increment is especially useful when we read out the accelerometer data. This data is stored in six bytes (2 bytes/axis) between 0x32 and 0x37 registers. So with this auto-increment, we can read all three axes’ data in one go and pass it to the corresponding buffer.

Then, we set the chip select pin to low to start the SPI frame. This is followed by sending the command byte to the slave. Then, based on the number of bytes, we send “i” amount of dummy bytes (0x00) to the slave to shift out the response from the corresponding register. Each received byte is passed to the i-th item of the buffer array.

After the transaction is over, the chip select pin is pulled high again to finish the SPI transaction.

 
uint8_t SPI_Transfer(uint8_t data)
{    
    SPI_I2S_SendData(SPI1, data); 

    while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET);     
    while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET); 

    return SPI_I2S_ReceiveData(SPI1); 
}
 
void ADXL345_WriteRegister(uint8_t registerAddress, uint8_t registerValue)
{
    GPIO_WriteBit(GPIOC, GPIO_Pin_4, Bit_RESET);

    SPI_Transfer(registerAddress);
    SPI_Transfer(registerValue);

    GPIO_WriteBit(GPIOC, GPIO_Pin_4, Bit_SET);
}
 
void ADXL345_ReadRegister(uint8_t registerAddress, int numberOfBytes, uint8_t *buffer)
{
    uint8_t commandByte = 0x80 | registerAddress; 

    if(numberOfBytes > 1)
    {
        commandByte |= 0x40;
    }

    GPIO_WriteBit(GPIOC, GPIO_Pin_4, Bit_RESET);
    SPI_Transfer(commandByte);

    for(int i = 0; i < numberOfBytes; i++)
    {
        buffer[i] = SPI_Transfer(0x00); 
    }

    GPIO_WriteBit(GPIOC, GPIO_Pin_4, Bit_SET);     
}
 

Initialising the ADXL345 chip

To get the expected data from the chip, we need to initialise it with the correct settings.

First, I set up the POWER_CTL register (0x2D). The only bit in the register that is set to 1 is the D3 bit. This means that the chip is set to measurement mode.

Then, the Data_FORMAT register (0x31) is also simple. It has the same bit (D3) set to 1 and the rest is zero. It is perhaps important to highlight that D6, the SPI Bit is set to 0 in order to set the chip to 4-wire SPI mode. Then D1 and D0 are both 0 because the measurement range is set to +/-2 g.

Then the BW_RATE (0x2c) register is set to 0x0f (1111) which means that the device is set to 3200 Hz output data rate. Just for fun, I set the device to its highest sampling rate.

Finally, to check if the chip is recognised, I use its DEVID (0x00) register to fetch the device ID. The chip holds a fixed device ID which is 0xR5 (229). So, when the chip returns 0xE5, then we can be sure that the communication is correct. Since reading a register involves both read and write operations, if we are able to get 0xE5 from the chip, it also means that our code is good.

 

Data acquisition

Finally, we can read the acceleration values from the chip.

If you followed my earlier tutorials, especially the one for the ADC converter, then you know that printing floating-point numbers with this microcontroller is not that simple. So, first, I made a function that prints the acceleration in g-units as a floating point number by carefully formatting two integers that I derived from the float.

The function accepts one parameter, which is the raw data from the accelerometer. Then this value is scaled up by a factor of 10000 and then divided by 256 in order to convert the raw data into g units.

Dividing this scaled value by 10000 gives us the integer (whole) part of the g units. Then, a modulo (%) by 10000 and taking the absolute* value gets us the four-digit fraction part. As a side note, if we want more digits, then we need to multiply and divide by a larger number. 100000 gives 5 digits, 1000000 gives 6 digits…etc.

*Note: To use the abs() function, we need to include the stdlib.h library!

Then, with a smart way of printing, the string we print will look like a floating point number on the terminal, even though it is just a combination of two integers separated by a dot.

Then, the actual action, the sampling, is done in the ADXL345_ReadAcceleration() function. We simply read the corresponding register (0x32) and tell the code that we want 6 bytes, and we want to pass these bytes to our buffer. After the buffer has the values, we extract the acceleration values and assign them to the corresponding axis variable. We know that the subsequent buffer item pairs (0-1, 2-3, 4-5) are the x, y and z values. So, the first byte of the data can be left as-is, but the second byte has to be stored in a 16-bit number and then has to be shifted up by 8 bits. Then this shifted value and the first byte must be combined by the bitwise OR operation. This will result in a 13-bit number represented in a 16-bit variable.

Finally, I print the values in a certain format. The reason why I print them as it is shown is because I want to print the results in the serial plotter of the Arduino IDE so we can see the 3 axes in real time.

Then I just call this function in the while(1) loop, which results in continuously printed values on the serial terminal.

 
void ADXL345_Init()
{
    ADXL345_WriteRegister(0x2D, 0x08);
    Delay_Ms(100);

    ADXL345_WriteRegister(0x31, 0x08); 
    Delay_Ms(100);

    ADXL345_WriteRegister(0x2C, 0x0F);
    Delay_Ms(100);
      
    uint8_t who; 
    ADXL345_ReadRegister(0x00, 1, &who);

    if(who == 0xE5) 
    {
        printf("Chip recognized - %d.\n", who);
    }
    else 
    {
        printf("Chip not recognized - %d.\n", who);    
    }
}
void printAccelG(int16_t raw)
{    
    int32_t scaled = (int32_t)raw * 10000 / 256; 
    int whole     =  (int)scaled / 10000; 
    int frac      =  (int)abs(scaled % 10000); 
    printf("%d.%04d", whole, frac); 
}

void ADXL345_ReadAcceleration()
{
    ADXL345_ReadRegister(0x32, 6, buffer);

    int16_t x = (int16_t)(((int16_t)buffer[1]<<8) | buffer[0]); 
    int16_t y = (int16_t)(((int16_t)buffer[3]<<8) | buffer[2]);
    int16_t z = (int16_t)(((int16_t)buffer[5]<<8) | buffer[4]);
   
    printf("X:");
    printAccelG(x);
    printf(",Y:");
    printAccelG(y);
    printf(",Z:");
    printAccelG(z);
    printf("\r\n");
}

If you found this content useful, please consider joining my YouTube channel’s membership or leaving a donation.

Also, please consider using my affiliate links when buying relevant gadgets.


Next
Next

Creating a working PCB badge from a visual design