CH32V003J4M6 - Breadboard voltmeter
In this article, I explain the coding part for my miniature breadboard voltmeter project. So far, my CH32 tutorial series only involved the CH32V003F4P6 microcontroller, but this project uses its smaller, 8-pin sibling, the CH32V003J4M6. I wanted to miniaturize my circuit as much as possible, so I picked this microcontroller. The fun part about this project is that one can easily copy-paste the necessary code together based on my tutorial videos and articles published so far, especially, by using the ADC-related code and the OLED display driver code.
However, I received some comments on my project video suggesting that even with all the resources I handed out, some people still couldn't finish the task. It seems that some people expect to be spoon-fed with knowledge and information for free. Although I am releasing the resources in this article, I'm afraid I have to disagree with those demanding people. We, makers/tinkerers…etc. do not necessarily publish stuff to keep these people entertained for free. Sometimes a published project is a good way of showcasing skills and knowledge or some interesting stuff. However, the fact that we demonstrate our projects does not automatically grant access to our resources. I spent numerous hours reading the datasheets and reference manuals, developing and testing the code and circuit, writing the article and making the video. And obviously, all the components had to be purchased for the circuit. No one paid me to do it, I just do it out of passion and to showcase my skills and knowledge. Yet, these demanding people think that after we spent hundreds, sometimes thousands of dollars (time included) on these projects, they should automatically get access to them, just because we published something relevant.
CH32V003J4M6 microcontroller
So, this microcontroller is nearly the same as the CH32V003F4P6 I used in the previous tutorials. It has the same flash, memory and CPU capabilities, it has almost the same peripherals (it does not have SPI), but it only has 6 GPIO pins instead of 18. But this is perfectly enough for my little project.
I need four GPIO pins for this project. 2 pins are for the two analogue channels and two pins for the i2c communication. I picked the same i2c pins as the native ones (PC1 and PC2), however, I keep using the bit-banged i2c library I implemented previously. It already has everything I need, so why not keep using it if it works!?
The ADC will be assigned to PA2 (A0) and PC4 (A2) pins. PA2 is a bit tricky because it also has OSCO as an alternative function which is used for the external oscillator, and if this pin is not treated correctly, then A0 will provide wrong readings. Especially, because I don’t use any external oscillators but I use the internal oscillator (HSI) of the microcontroller. This must be handled according to the code.
ADC configuration
First and foremost, we need to make a little adjustment in the system_ch32v00x.c file. By default, the code instructs the microcontroller to use an external oscillator (HSE) by having the #define SYSCLK_FREQ_48MHz_HSE 48000000 line uncommented and the rest commented. Although, this is not applicable to this microcontroller because we use it without an external oscillator. The microcontroller will still work without this line being set up correctly, however, certain peripherals such as the A0 channel will not. Therefore, the #define SYSCLK_FREQ_48MHZ_HSI 48000000 must be uncommented and the rest should be commented. This will tell the microcontroller to use the internal oscillator and it will free up the PA2 for the analogue channel.
//#define SYSCLK_FREQ_8MHz_HSI 8000000 //#define SYSCLK_FREQ_24MHZ_HSI HSI_VALUE #define SYSCLK_FREQ_48MHZ_HSI 48000000 //#define SYSCLK_FREQ_8MHz_HSE 8000000 //#define SYSCLK_FREQ_24MHz_HSE HSE_VALUE //#define SYSCLK_FREQ_48MHz_HSE 48000000
Regarding the ADC, I will use a slightly different approach than previously. Instead of DMA, I just raw-dog the ADC reading and do it manually. There are no time-critical or resource-critical tasks in this project anyway, so I can afford to read and switch the analogue channels manually.
I take care of both the ADC and the GPIOs in the function I wrote for the ADC. Since one ADC pin is on port A and the other is on port C, I have to enable both of these ports’ clock sources. Then, both PA2 and PC4 must be set to GPIO_Mode_AIN in order to make them work as analogue inputs.
Then, I disable both the ADC_ScanConvMode and ADC_ContinuousConvMode which means that only 1 channel is sampled at a time and only a single sample is taken during sampling. Also, even though I will use two channels for sampling, I will sample only one channel at a time, so the ADC_NbrOfChannel is set to 1.
Then I enable the ADC and run a calibration.
void ADC_Multichannel_Init() { ADC_DeInit(ADC1); ADC_InitTypeDef ADC_InitStructure = {0}; GPIO_InitTypeDef GPIO_InitStructure = {0}; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div8); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); 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 = DISABLE; 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_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); }
ADC reading
As I said, the channels are manually controlled and read one by one. I created two functions for this task. The first function ADC_ReadChannel is a universal function that reads the ADC channel that is received as a parameter. It simply configures the ADC with the channel, enables the conversion, waits until the EOC flag (end of conversion) changes and fetches the conversion value and passes it to a 16-bit variable. Finally, the function returns this 16-bit variable.
Then, the other function reads the 2 channels after each other. I created a buffer array that holds these two conversion values. One can assign the conversion results to specific variables as well, but I found this approach more comfortable. So, what happens here is that the ADC_ReadChannel function passes the return value to each of the OLEDBuffer array items, and that’s that. Later on, these values in the buffer are printed on the OLED display.
Then, the ADC conversion must be converted into a voltage value which is done in the printADCChannel() function. I actually have two of these functions in my code, one for each ADC channels. It performs the same exercise I showed in my ADC-related tutorial. It first converts the ADC reading into a voltage value. I also introduced a multiplier, 3.636, which is the multiplier for the voltage divider I created with a 10k and a 3k9 resistor. Then the code separates the whole and the decimal parts of the floating point number into two separate integers. Then these integers are formatted and stored in the char buffer I introduced at the beginning of the printADCChannel() function. Finally, the buffer is passed to the printNumber() function that takes care of the printing on the OLED display.
uint16_t ADC_ReadChannel(uint8_t channel) { ADC_RegularChannelConfig(ADC1, channel, 1, ADC_SampleTime_3Cycles); ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); uint16_t result = ADC_GetConversionValue(ADC1); return result; } void ReadMultipleChannels(void) { OLEDBuffer[0] = ADC_ReadChannel(ADC_Channel_0); Delay_Ms(20); OLEDBuffer[1] = ADC_ReadChannel(ADC_Channel_2); Delay_Ms(20); }
void printADCChannel() { char buffer[16]; float adcVoltage = 3.636 * (vref * OLEDBuffer[0])/1024.0f; int wholePart = (int) adcVoltage; int decimalPart = (int)((adcVoltage-wholePart)*100); sprintf(buffer, "%d.%02d", wholePart, decimalPart); printNumber((uint8_t *)buffer, 0, 10); }
Printing on the OLED display
This one is a bit different from my previously introduced library for printing text on an OLED display. I use the same bit-banged i2c code, however, the fonts and the way I print on the display are a bit different. But it is not too difficult.
So, firstly, I wanted to increase the size of the printed characters. Therefore, first, I created a large table with 72 columns and 40 rows which are the same dimensions as the dimensions of the display I use for this circuit. Then I had to decide how large characters I wanted to print and how to accommodate the characters on the display. A quick reminder from the previous lecture: the display is divided into 5 rows, where each row has 8 pixels height (5x8 = 40). I wanted to print “double-height” characters, meaning a character would be printed in two lines. Then by looking at the table and considering the available area on the display, I decided to create a 12-column wide cell for each character. This allows a nice aspect ratio and creates a readable look.
Then I manually drew all the possible characters’ bitmaps in this table. All the numbers from 0-9, as well as the minus symbol and the decimal point.
We already know from the previous lesson how the bitmaps are converted into characters, so I won’t bother explaining them again. The only difference here now is that a character is stored in 24 bytes. 12 bytes of pixels for the first row of the character and another 12 bytes for the bottom part.
0x00, 0b11111000, 0b11111100, 0b00000110, 0b00000110, 0b00000110, 0b00000110, 0b00000110, 0b00000110, 0b11111100, 0b11111000, 0x00, 0x00, 0b00011111, 0b00111111, 0b01100000, 0b01100000, 0b01100000, 0b01100000, 0b01100000, 0b01100000, 0b00111111, 0b00011111, 0x00
As an example, I show the “0” character with its 2x12 bytes. To make it easier to read, I introduced a linebreak after the first 12 bytes, but it is probably better to read it on GitHub. For non-zero values, I used binary format to represent the 8 pixels of a column because then it can be directly converted into the green blocks you see beside this text block.
So, the first byte is zero, therefore the first column is all 0 pixels. When a column is not zero, the reading direction does not matter. However, we must establish a convention. The easiest way (for me) to create the bitmap was to read the bytes in the normal way, so from left to right. This means that the bitmap matches the pattern of the byte from bottom up. So, the first bit of the byte (0bX1110000) becomes the bottom pixel in the selected row (D7 in the illustration on the side), the second bit of the byte (0b0X110000) becomes the second pixel from the bottom and so on. Up until the 12th column because the next, 13th column, must be printed in the next line under the first column.
This is done in a very similar way as I did in the previous tutorial. First, I target the starting column in the first row and prepare the address values. Then I read out the first 12 bytes from the corresponding letter’s bitmap. Then I generate the address for the next line and print the second 12 bytes from the corresponding letter’s bitmap.
Voltmeter in action. Only the first 12 bytes are printed on the display (for demonstration purposes), therefore the bottom of the characters are chopped off.
Pixel bitmap of the OLED display
Each byte should be read from right to left to match the LSB→MSB direction. Otherwise, read the byte left to right and fill the pixels up MSB→LSB.
void printCharacter_Large(uint8_t page, uint8_t segment, uint8_t letter) { I2C_Write_Command(0x22); I2C_Write_Command(0xb0 + page); I2C_Write_Command(0x04); uint16_t address = (0x11 << 4) | 0x0C; address += segment; uint8_t newLower = address & 0x0F; uint8_t newHigher = (address >> 4) & 0x0F; I2C_Write_Command(newLower); I2C_Write_Command(0x10 | newHigher); for (uint8_t i = 0; i < 12; i++) { I2C_Write_Data(font_large[letter][i]); } I2C_Write_Command(0x22); I2C_Write_Command(0xb0 + page + 1); I2C_Write_Command(0x04); I2C_Write_Command(newLower); I2C_Write_Command(0x10 | newHigher); for (uint8_t i = 12; i < 24; i++) { I2C_Write_Data(font_large[letter][i]); } }
Selecting the character from the bitmap array
This part is also somewhat similar to the previous article’s implementation, however, there are a few special characters that I had to treat separately.
The code checks the content of the buffer that we filled in with the printADCChannel() function. The code checks it character-by-character. First, it checks for invalid characters which are outside the ASCII table’s readable part. Then I introduced a character called fontPosition. This is the position of the font in the bitmap array I created manually. Basically, the index of the array. If the currentCharacter is between 0x30 and 0x39, which means that it is a number between 0-9, we just shift the character’s value by 0x2f which is one character before the 0 in the ASCII table. The shifting is not 0x30 because in my array there is a character (space) before 0, so we shift one less.
Otherwise, if the character is something else, a space, a minus symbol or a decimal point, I manually assign the font position to them.
Then the printCharacter_Large() function is called which prints the selected character on the display.
Finally, the code checks the current printing position on the display and determines the next character’s position. In fact, I always print shorter numbers than 6 characters, so I could technically get rid of that part of the code, but I leave it in the code for educational purposes.
Buy the relevant products using my affiliate links
Get the PCB from my PCBWay project site:
void printNumber(const uint8_t *receivedBuffer, uint8_t pageStart, uint8_t columnStart) { uint8_t column = columnStart; for(uint8_t i = 0; receivedBuffer[i] != '\0'; i++) { char currentCharacter = receivedBuffer[i]; if(currentCharacter < 0x20 || currentCharacter > 0x7E) { continue; } uint8_t fontPosition = 0; if((int)currentCharacter >= 0x30 && (int)currentCharacter<=0x39) { fontPosition = (int)currentCharacter - 0x2f; } else if ((int)currentCharacter == 0x20) { fontPosition = 0; } else if ((int)currentCharacter == 0x2d) { fontPosition = 11; } else if ((int)currentCharacter == 0x2e) { fontPosition = 12; } printCharacter_Large(pageStart, column, fontPosition); column += 12; if(column > 72-12) { column = columnStart; pageStart += 3; } if(pageStart > 4) { break; } } }
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.