CH32V003F4P6 - ADC basics

In this article, I continue the CH32V003 tutorial series. We already covered the basics with GPIOs, USART and timers. Now, let’s continue with ADC, analog-to-digital conversion. It is great that I already covered these three other topics because all of them will be useful in this project.

Introduction

 

Getting readings from the ADC

Let’s discuss the AD converter a little bit, but I am not going to talk about AD converters in general because they are described elsewhere in detail. Here, we just focus on the CH32V003F4P6’s ADC.

The ADC supports 8 external channels and 2 internal signal source sampling sources with up to 24 MHz clock input. It can do single and continuous conversion, scan between channels, can be triggered…etc. The resolution of the ADC is 10-bit and its input voltage range is the full range between 0 to VDD.

Although the chip has an internal reference which is connected internally to the ADC’s 8th channel, this can not be used as a reference voltage. At least, I could not find a way. I could only use 3.3 V (VDD) as the reference value. The voltage readings are actually quite OK. It seems to be better (more accurate and linear) than the ESP32 which is “famous” for its garbage built-in ADC.

Since the ADC resolution is 10 bits, and the voltage reference is 3.3 V, Vref becomes 3.3 and 2^n = 2^10 = 1024.

To initialize the ADC, quite many parameters must be set. However, they are straightforward. In the first example, I just simply want to do a continuous sampling and print it on the serial terminal on my computer.

Pin PC4 is used as the input of the ADC. To mimic a variable voltage, I used a potentiometer that is connected to 3.3 V (pin 1) and ground (pin 3), and its wiper (pin 2) is connected to PC4 pin on the development board.

I am still avoiding using interrupts because I am saving it for a future article, so I just simply query the ADC for a conversion result by calling the ADC_GetConversionValue() function. This function returns the raw 10-bit reading that we can substitute in the previously presented formula to convert the reading into voltage.

However, when we want to print the results on the terminal, we encounter some issues. The print() function can not deal with floating-point numbers. If you try to print it directly, it will not show anything.

 
void InitializeADC()
{
     ADC_InitTypeDef ADC_InitStructure = {0};
     GPIO_InitTypeDef GPIO_InitStructure = {0};

     RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);
     RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
     RCC_ADCCLKConfig(RCC_PCLK2_Div8);

     GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4;
     GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
     GPIO_Init(GPIOC, &GPIO_InitStructure); 

     ADC_DeInit(ADC1);
     ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;
     ADC_InitStructure.ADC_ScanConvMode = DISABLE;
     ADC_InitStructure.ADC_ContinuousConvMode = ENABLE; 
     ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; 
     ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
     ADC_InitStructure.ADC_NbrOfChannel = 1; 
     ADC_Init(ADC1, &ADC_InitStructure);

     ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 1, ADC_SampleTime_241Cycles);


     ADC_Cmd(ADC1, ENABLE);
     ADC_ResetCalibration(ADC1);
     while(ADC_GetResetCalibrationStatus(ADC1));
     ADC_StartCalibration(ADC1);
     while(ADC_GetCalibrationStatus(ADC1));

     ADC_SoftwareStartConvCmd(ADC1, ENABLE);
}
 

To solve the issue with the float, we can “trick the system” and print the float as if it were just two integers and a dot.

The printADCVoltage() function fetches an ADC conversion and then converts it to a set of characters that will look like a float on the serial terminal.

First, the code checks if the conversion is non-zero. I made this check because when I first tried to print a float, I thought that I messed something up with the ADC configuration and it is not printing anything. Then, if the conversion is non-zero, the code proceeds.

It calculates the voltage value based on the recently performed conversion. I wrote a function that performs the substitution of the parameters into the formula I presented in the introduction. So, the adcVoltage variable directly gets the reading in Volts units. Let’s say, we read 3.1415 V.

Then, this float is cast into int which will cause it to “lose” its decimals. The wholePart variable becomes 3.

Then the decimal part will first subtract 3 from 3.1415 and then it will multiply it by 10000. This will first result in 0.1415, and then 1415. 1415 will be the decimalPart.

Finally, in the printf() function we combine these two parts, essentially sticking wholePart and decimalPart together.

The result printed on the serial terminal will be 3.1415.

 
void printADCVoltage()
{
    uint16_t adcValue = ADC_GetConversionValue(ADC1);

    if (adcValue == 0)
    {
        printf("ADC Value is zero\n");
    }
    else
    {
        float adcVoltage = calculateVoltage(adcValue); 
        int wholePart = (int)adcVoltage; 
        int decimalPart = (int)((adcVoltage - wholePart) * 10000); 
        printf("%d.%04d\n", wholePart, decimalPart); 
    }
}
float calculateVoltage(uint16_t ADCbits)
{
    float vref = 3.3f;
    float adcVoltage = (vref * ADCbits)/1024.0f;

    return adcVoltage;
}
 

Oversampling and averaging

Now we can run the ADC in the background continuously and whenever we need data from it, we can fetch a conversion value. However, this value is not always stable and accurate. There are a few tricks that allow us to improve the quality of the obtained data.

We can take more ADC readings than needed and averaging them can improve the resolution of the measurement. Averaging reduces noise and it can even gain a few extra bits beyond the native resolution of the ADC.

For example, the CH32V003F4P6 has a 10-bit ADC. Let’s say we want to achieve a 12-bit effective resolution. To gain 2 extra bits, we need to sample 2^(2n) = 2^4 = 16 samples.

Then we just need to average the samples to gain a reading with an improved resolution.

However, this is more of a mathematics thing than a programming or embedded-related one.

A good reading on oversampling is provided by Texas Instruments in their application note.

 
uint16_t ADC_OversampleAndAverage(ADC_TypeDef* ADCx)
{
    uint32_t sum_adc_readings = 0;

    for (int i = 0; i < 16; i++)
    {
        sum_adc_readings += ADC_GetConversionValue(ADCx);
    }

    uint16_t averaged_adc_value = sum_adc_readings >> 4;

    return averaged_adc_value;
}
 

Multichannel acquisition

Now let’s briefly see how we can get data from more than one channel at a time. The initialization of the ADC does not differ too much from the previously introduced way. Basically, we need to change the number of channels and then we need to configure the channels we want to use as an output.

Let’s define two physically available channels and the internal reference as the three channels we want to use.

The physical channels are connected to the GPIO pins, so we need to add them to the code and set their mode as analog input (AIN). I selected PC4 and PD2 which are A2 and A3, respectively. Then, we also need to enable the single scan mode conversion so that the ADC scans a group of analog channels and performs a single conversion for all the selected channels one by one. I believe that this is the same as multiplexing (MUX) in other AD converters.

However…

We need to introduce DMA (Direct Memory Access) for this task. I’ve been trying and trying to make multiple channels work without it, but I failed. Then I figured, I could do this with DMA instead. All of a sudden I started receiving correct values, so I guess I solved the issue by using DMA.

As you can see towards the end of the code after the configuration of the three channels, I enabled DMA for ADC1. Also, if you compare the initialization with an earlier one, you will notice that I did not start the conversion here. This is because I will start it in the main() function after initializing the ADC and the DMA.

ADC_MultiChannel_Init();
DMA_Tx_Init(DMA1_Channel1, (u32)&ADC1->RDATAR, (u32)ADCBuffer, 3);
DMA_Cmd(DMA1_Channel1, ENABLE);
ADC_SoftwareStartConvCmd(ADC1, ENABLE);

There is one peculiar possible pitfall that I want to discuss here because I managed to encounter it and it caused me a little headache.

So, the DMA_MemoryDataSize is set to be a half-word. To recall some C++ knowledge: a word is 2 bytes or 16 bits.

However…

This is a 32-bit chip and not a 16-bit chip, so a word is 32 bits (4 bytes) and a half word is 16 bits (2 bytes).

So when the DMA_MemoryDataSize is set to be a half-word, then the DMA is instructed to transfer 16-bit and not 8-bit (half-word). 16 bits is enough because the conversion result is a 10-bit number, so it is totally fine to store it on 16 bits.

Also, the DMA_Mode must be set to circular, so when the third conversion is done, the ADC automatically restarts the conversion from the first channel again.

The buffer size must be set to three because we have 3 channels and each channel can be stored on a single, 16-bit memory.

 

Get this board using my affiliate link by clicking on the picture!

 

Get this programmer using my affiliate link by clicking on the picture!

 
void ADC_MultiChannel_Init(void)
{
    ADC_InitTypeDef ADC_InitStructure;
    GPIO_InitTypeDef GPIO_InitStructure;

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC | RCC_APB2Periph_GPIOD, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
    RCC_ADCCLKConfig(RCC_PCLK2_Div8);

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
    GPIO_Init(GPIOC, &GPIO_InitStructure); 
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
    GPIO_Init(GPIOD, &GPIO_InitStructure); 

    ADC_DeInit(ADC1);

    ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;
    ADC_InitStructure.ADC_ScanConvMode = ENABLE;
    ADC_InitStructure.ADC_ContinuousConvMode = ENABLE;
    ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
    ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
    ADC_InitStructure.ADC_NbrOfChannel = 3;
    ADC_Init(ADC1, &ADC_InitStructure);

    ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 1, ADC_SampleTime_241Cycles);
    ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 2, ADC_SampleTime_241Cycles);
    ADC_RegularChannelConfig(ADC1, ADC_Channel_Vrefint, 3, ADC_SampleTime_241Cycles);

    ADC_DMACmd(ADC1, ENABLE);
    ADC_Cmd(ADC1, ENABLE);

    ADC_ResetCalibration(ADC1);
    while (ADC_GetResetCalibrationStatus(ADC1));
    ADC_StartCalibration(ADC1);
    while (ADC_GetCalibrationStatus(ADC1));
}
void DMA_Tx_Init(DMA_Channel_TypeDef *DMA_CHx, uint32_t peripheralAddress, uint32_t memoryAddress, uint16_t bufferSize)
{
    DMA_InitTypeDef DMA_InitStructure = {0};

    RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);

    DMA_DeInit(DMA_CHx);
    DMA_InitStructure.DMA_PeripheralBaseAddr = peripheralAddress;
    DMA_InitStructure.DMA_MemoryBaseAddr = memoryAddress;
    DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;
    DMA_InitStructure.DMA_BufferSize = bufferSize;
    DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
    DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
    DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
    DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
    DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;
    DMA_InitStructure.DMA_Priority = DMA_Priority_VeryHigh;
    DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;
    DMA_Init(DMA_CHx, &DMA_InitStructure);
}
 

Converting and printing multichannel acquisition

Finally, let me show you a nice way (in my opinion) to print the acquired three channels.

Actually, it is not super sophisticated because the DMA nicely stores the data in the buffer and we already implemented most of the code for the previous example where we printed a single channel’s conversion.

It is just a for() loop that iterates 3 times (3 channels). At each iteration, it reads the ith item of the buffer and passes it to the calculateVoltage() function that converts the raw ADC reading into voltage. Then the function extracts the whole and decimal parts from the float so then it can print it with the printf() function. I added a text that tells which channel is being printed. When the 3 channels are printed, a new line is printed so the next 3 readings are printed in a new line. Call this function in the while(1) part of the code.

 
void printADCVoltage_Multi()
{
    for(int i = 0; i < 3; i++)
    {
        float adcVoltage = calculateVoltage(ADCBuffer[i]); 
        int wholePart = (int)adcVoltage; 
        int decimalPart = (int)((adcVoltage - wholePart) * 10000);
        printf("Channel - %d : %d.%04d ",i+1 , wholePart, decimalPart);
    }
    printf("\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.

Previous
Previous

CH32V003F4P6 - Interrupts

Next
Next

CH32V003F4P6 - Timers and PWM