FB pixel

Create your own simple oscilloscope with ADC and 1602 LCD | Embedded C Programming - Part 28

Published


Hi there! Once we have started learning the analog modules of the PIC18F14K50 MCU, we will discuss everything related to it accordingly.

In the previous tutorial, we started with the analog comparator, and this one will be devoted to the analog-to-digital (ADC) module. As its name suggests, this module converts the input analog signal into digital code, which the MCU program can use. With the PIC10F200 MCU, we had to add some external components and some calculations to measure the input voltage, and in PIC18F14K50, everything is done by the hardware ADC module, and we get the final result from it.

The task for the current tutorial will not be easy. We will create an oscilloscope showing the input signal’s waveform in the character 1602 LCD. Yes, that’s not a joke! We can do this. For sure, the resolution of this oscilloscope will be very low - just 16 pixels in height and 24 pixels, so this project doesn’t pretend to be a practical device but more like a demonstration of what can be done if you have enough imagination (and don’t have a normal display 🙂).

In this tutorial, we will use only one new module - ADC. But it has a lot of parameters and settings, so its consideration will take quite a lot of time.

ADC Module

Before you proceed with the following reading, please familiarize yourself with ADC operation theory if you don’t know it.

So the ADC in the PIC18F14K50 MCU has a 10-bit resolution which means that the input signal converts into the digital value from 0 to 1023. The PIC18F14K50 has a single ADC unit with 11 inputs - 9 external and 2 internal. These inputs all come to the analog multiplexer, which connects one of them to the sample-and-hold circuit of the ADC. The 9 external inputs are merged with the IO pins, and the internal ones are the output of the fixed and programmable reference voltage sources.

From the output of the sample-and-hold circuit, the signal goes to the converter’s input. ADC of the PIC18F14K50 MCU is the successive approximation register (SAR) type. This type has a relatively high speed and a mediocre resolution, compared to other types. The result of the conversion is saved in two registers - ADRESL and ARDRESH, which are the lower and upper bytes of the result, respectively.

Speaking of the result, it can be aligned right or left. Alignment right means that the LSB of the conversion result will match with the LSB of the ADRESL register, and the upper six bits of the ADRESH register will be unused. Alignment left is the opposite - the MSB of the conversion result will match with the MSB of the ADRESH register, and the lower six bits of the ADRESL register will be unused. The latter option can be used if you don’t need high accuracy. In this case, you can only read the ADRESH register, containing the upper 8 bits of the conversion result.

The reference voltage of the ADC is selectable. The positive reference voltage can be chosen between the supply voltage VDD, external reference voltage applied to the dedicated pin VREF+, or internal fixed reference voltage source (FVR). The negative reference voltage can be chosen between the ground level voltage (VSS), or external reference voltage applied to the dedicated pin (VREF-).

The ADC can generate the interrupt on completion of the conversion, which can be used to wake up the MCU from sleep mode.

That’s all the general information about the ADC module. The rest of it I will provide when describing the configuration registers. The ADC module has three registers - ADCON0, ADCON1, and ADCON2. Let’s start considering them one by one.

The ADCON0 register has the following bits:

  • bits #7 and #6 are unused and read as 0.
  • bits #5-#2 - CHS3, CHS2, CHS1, and CHS0, respectively (Channel Select) control the input multiplexer and select the ADC input channel according to the following table

CHS3

CHS2

CHS1

CHS0

Channel

0

0

0

0

Reserved

0

0

0

1

Reserved

0

0

1

0

Reserved

0

0

1

1

AN3

0

1

0

0

AN4

0

1

0

1

AN5

0

1

1

0

AN6

0

1

1

1

AN7

1

0

0

0

AN8

1

0

0

1

AN9

1

0

1

0

AN10

1

0

1

1

AN11

1

1

0

0

Reserved

1

1

0

1

Reserved

1

1

1

0

DAC

1

1

1

1

FVR

As you can see, some combinations are reserved to match the channel number and the CHS value. The last two combinations are the internal inputs from the programmable reference voltage (DAC) and fixed reference voltage (FVR) sources.

  • bit #1 - GO/DONE is the AD conversion status bit. This bit plays two roles. Writing 1 to it starts the AD conversion, and writing 0 terminates the current conversion, after which the result remains uncompleted. Once the conversion is started, this bit remains 1 until the conversion is done, after which it is reset to 0 by the hardware. So, when reading, this bit shows if the conversion is in progress (bit is 1) or if it is completed (bit is 0).
  • bit #0 - ADON (ADC module ON). This bit enables the ADC module when it is set to 1. When this bit is 0, ADC is disabled and consumes no power.

The ADCON1 register has the following bits:

  • bits #7 - #4 are unused and read as 0.
  • bits #3-#2 - PVCFG1 and PVCFG0 (Positive Voltage reference ConFiGuration) select the positive voltage reference source according to the following table:

PVCFG1

PVCFG0

Positive voltage reference source is supplied…

0

0

internally by VDD

0

1

externally through the VREF+ pin

1

0

internally through FVR

1

1

Reserved

  • bits #1-#0 - NVCFG1 and NVCFG0 (Negative Voltage reference ConFiGuration) select the negative voltage reference source according to the following table:

NVCFG1

NVCFG0

Negative voltage reference source is supplied…

0

0

internally by VSS

0

1

externally through the VREF- pin

1

0

Reserved

1

1

Reserved

The ADCON2 register has the following bits:

  • bit #7 - ADFM (AD conversion result ForMat). If this bit is 0 then the result is left justified, and if it is 1 then the result is right justified.
  • bit #6 is unused and read as 0.
  • bits #5-#3 - ACQT2, ACQT1, and ACQT0, respectively (AD ACquisition Time select). Acquisition time is how long the AD sample-and-hold circuit holds the capacitor connected to the input channel after the GO/DONE bit is set before the conversion begins. This time is measured in the number of ADC clock ticks (TAD) and is configured according to the following table:

ACQT2

ACQT1

ACQT0

Acquisition time

0

0

0

0

0

0

1

2 TAD

0

1

0

4 TAD

0

1

1

6 TAD

1

0

0

8 TAD

1

0

1

12 TAD

1

1

0

16 TAD

1

1

1

20 TAD

Actually, selecting the proper acquisition time is a challenging task and requires many calculations based on the parameters of the external circuit and the environment temperature. I will not provide them here, but you can read about them in chapter 17.3 of the PIC18F14K50 data sheet.

  • bits #2-#0 - ADCS2, ADCS1, and ADCS0, respectively (AD conversion Clock Select), select the clock source of the ADC module. The TAD time directly depends on it as TAD = 1 / fADC. The clock frequency of the ADC module is selected according to the following table:

ADCS2

ADCS1

ADCS0

ADC clock source

0

0

0

FOSC/2

0

0

1

FOSC/8

0

1

0

FOSC/32

0

1

1

FRC

1

0

0

FOSC/4

1

0

1

FOSC/16

1

1

0

FOSC/64

1

1

1

FRC

The FOSC source is already familiar to us - it is the clocking source of the whole MCU (remember that the implementation of each command takes 4 ticks, so real CPU productivity is 4 times smaller). The FRC is the dedicated internal oscillator with a nominal frequency of 600 kHz. There are strict requirements for the ADC frequency - it should be selected so that the TAD lies between 0.7us and 4 us.

Also, you need to configure the analog inputs properly when using the ADC module. First, they should be configured as inputs, so the corresponding bits of the TRISx register should be set as 1. And second, the corresponding bits of the ANSEL or ANSELH register should also be set as 1. We already talked about the ANSEL(H) registers in tutorial 16, so please refer to it for details.

As I already said, the conversion result is stored in the ADRESH and ADRESL registers, and its format depends on the alignment bit ADFM of the ADCON2 register. Finally, let’s consider the interrupt-related bits of the ADC module, which are located in the PIE1, PIR1, and IPR1 registers.

The bit that enables the interrupt is located in the register PIE1:

  • bit #6 - ADIE (AD conversion complete Interrupt Enable). When this bit is 1, AD conversion complete interrupt is enabled, and when it is 0 - disabled.

The Interrupt flag is located in the register PIR1:

  • bit #6 - ADIF (ADC Interrupt Flag). This bit is set to 1 when the AD conversion is completed (it must be cleared by software). If it is 0, then the AD conversion either is not completed or has not been started.

And finally, the interrupt priority is located in the register IPR1:

  • bit #6 - ADIP (ADC Interrupt Priority). If this bit is 1 then the ADC interrupt is high priority, otherwise, it’s a low priority.

And that’s enough information about the ADC module to write the program that uses it. As you can see, this module is not too complex, even though it has a lot of settings. As usual, please refer to chapter 17 of the PIC18F14K50 data sheet for more information.

Schematic Diagram

The schematic diagram is shown in Figure 1.

Figure 1 - Schematic diagram with the PIC18F14K50 with 1602 character LCD
Figure 1 - Schematic diagram with the PIC18F14K50 with 1602 character LCD

This schematic diagram is very similar to the one presented in tutorial 24 but even simpler: it consists of the PIC18F14K50 MCU (DD1) and 1602 character LCD (X2), which are connected in the same way as there. There are no other external components, though - only the analog input AIN, whose positive output of no more than the VDD voltage goes to the pin RA4, which is merged with the analog input AIN3, and the negative output is connected to the ground (don’t mix them up!)

I will explain the algorithm for showing the graphic information on the character display later when we consider the program code. And for now, that’s everything about the device schematic diagram so we can proceed to the next step.

Program Code Description

Let’s create a new project now. I’ve called it “Oscilloscope” but you can give it any name you want. Then create the new “main.c” file in it, as we are used to doing.

The same as in tutorial 24, we will use the separate “config.h” file in which the configuration bits are defined. Also, we will need to copy and paste the “lcd_1602.h” and “lcd_1602.c”. One can take them from tutorial 22 in the same way I showed you. After all, your project should look like in Figure 2

Figure 2 - Project structure with all required files
Figure 2 - Project structure with all required files

If you compare the schematic diagrams (Figure 1) in the current tutorial and Tutorial 24, you will notice that the connection of the display is equal, so if you took the LCD-related files from it, you don’t need to make any changes to it. And if you copied them from one of the previous tutorials, please remember to change the pin assignment; otherwise, your display won’t work properly.

Now let’s consider the “main.c” file and write the following code.

#include <xc.h>

#include "lcd_1602.h"

#include "config.h"

#define THRESHOLD 256 //Synchronization threshold

volatile uint8_t count; //Counter of the AD conversions

volatile uint16_t adc_data[50]; //Array to store the AD conversions

void refresh_display (void) //Function to refresh the display view

{

uint8_t min, max;//Minimum and maximum values of the neighbor conversions

uint8_t character[2][8]; //Array for the display character generator

uint16_t display_buffer[24];//Display's canvas area

uint8_t start_index = 25; //Start index of the adc_data array

uint32_t max_v = adc_data[0];//Maximum voltage

uint32_t min_v = adc_data[0];//Minimum voltage

uint32_t avg_v = adc_data[0];//Average voltage

for (uint8_t i = 1; i < 50; i ++)//Loop throughout the whole adc_data array

{

if (adc_data[i] > max_v) //If the current adc_data element is greater than max_v

max_v = adc_data[i]; //then assign the max_v as the current adc_data element

if (adc_data[i] < min_v) //If the current adc_data element is smaller than min_v

min_v = adc_data[i]; //then assign the min_v as the current adc_data element

avg_v += adc_data[i]; //Accumulate the adc_data array values

}

avg_v /= 50; //Calculate the average voltage

min_v = (min_v * 325) / 10240; //Convert the minimum voltage into 0.1V

max_v = (max_v * 325) / 10240; //Convert the maximum voltage into 0.1V

avg_v = (avg_v * 325) / 10240; //Convert the average voltage into 0.1V

for (uint8_t i = 0; i < 25; i ++)//Loop to find the start element in the adc_data array

{

if ((adc_data[i] < THRESHOLD) && (adc_data[i + 1] >= THRESHOLD))//If the current element is smaller than THRESHOLD and the next element is greater than THRESHOLD

{

start_index = i; //then save the index of the current element

break; //and leave the loop

}

}

for (uint8_t i = 0; i < 24; i ++) //Loop to fill the display's canvas buffer

{

min = (adc_data[i + start_index] >= adc_data[i + start_index + 1]) ? adc_data[i + start_index + 1] >> 6 : adc_data[i + start_index] >> 6;// Select the minimum value between the neighbor ones

max = (adc_data[i + start_index] >= adc_data[i + start_index + 1]) ? adc_data[i + start_index] >> 6 : adc_data[i + start_index + 1] >> 6;// Select the maximum value between the neighbor ones

display_buffer[i] = 0; //Clear the current display_buffer element

for (uint8_t j = min; j <= max; j ++) //Loop from the min to max values

{

display_buffer[i] += 1 << j; //Add the 1 at the corresponding position

}

}

for (uint8_t k = 0; k < 4; k ++)//Loop to generate the characters for the display

{

for (uint8_t i = 0; i < 8; i ++) //Loop to form 8 lines of the character generator

{

character[0][7 - i] = 0; //Clear the current character[0] element

character[1][7 - i] = 0; //Clear the current character[1] element

for (uint8_t j = 0; j < 5; j ++)//Loop to convert the display_buffer into the character

{

character[0][7 - i] += ((display_buffer[4 - j + k * 6] & (1 << (i + 9))) >> (i + 9)) << j;//Convert the upper line

character[1][7 - i] += ((display_buffer[4 - j + k * 6] & (1 << i)) >> i) << j; //Convert the lower line

}

}

lcd_create_char(k, character[0]); //Create the character of the upper line

lcd_create_char(k + 4, character[1]);//Create the character of the lower line

}

lcd_set_cursor(1, 1); //Set the cursor at the first position of the upper line

for (uint8_t i = 0; i < 4; i ++) //Loop to send four generated characters

lcd_data(i); //to the upper line

lcd_set_cursor(1, 2); //Set the cursor at the first position of the lower line

for (uint8_t i = 4; i < 8; i ++) //Loop to send four generated characters

lcd_data(i); //to the lower line

lcd_set_cursor (6, 1); //Set the cursor at the sixth position of the upper line

lcd_write_number(min_v, 1); //Write the minimum voltage

lcd_write_string("V<U<"); //Write the text

lcd_write_number(max_v, 1); //Write the maximum voltage

lcd_write_string("V "); //Write the text

lcd_set_cursor (6, 2); //Set the cursor at the sixth position of the lower line

lcd_write_string("AVG:"); //Write the text

lcd_write_number(avg_v, 1); //Write the average voltage

lcd_write_string("V "); //Write the text

}

void __interrupt() INTERRUPT_InterruptManager (void)

{

if(INTCONbits.PEIE == 1) //If peripheral interrupts are enabled

{

if(PIE1bits.ADIE == 1 && PIR1bits.ADIF == 1)//If ADC interrupt was triggered

{

PIR1bits.ADIF = 0; //Clear ADC interrupt flag

adc_data[count] = (ADRESH << 8) + ADRESL;//Read the ADC conversion result

count ++; //Increment the count value

if (count >= 50) //If count exceeds the adc_data array size

{

INTCONbits.PEIE = 0; //Disable all peripheral interrupts

refresh_display(); //Refresh the display

count = 0; //Reset the count value

__delay_ms(500); //Perform 500 ms delay

INTCONbits.PEIE = 1; //Enable peripheral interrupts

}

}

else if(PIE2bits.TMR3IE == 1 && PIR2bits.TMR3IF == 1) //Id Timer3 interrupt was triggered

{

PIR2bits.TMR3IF = 0; //Clear Timer3 interrupt flag

ADCON0bits.GO_nDONE = 1; //Start ADC conversion

TMR3H = 0xFA; //Set the Timer3 counter to get

TMR3L = 0xCB; //the period of 166.625 us

}

}

}

void main(void)

{

//GPIO configure

TRISAbits.RA4 = 1; //Configure RA4 (Charge control pin) as input

ANSELbits.ANS3 = 1; //Disable digital buffer at RA4 (AN3) pin

//Oscillator module configuration

OSCCONbits.IRCF = 6; //Set CPU frequency as 8 MHz

OSCTUNEbits.SPLLEN = 1; //Enable PLL

//Timer 3 configuration

T3CONbits.RD16 = 1; //Enable read/write in one 16-bit operation

T3CONbits.T3CKPS = 0; //Prescaler 1:1

T3CONbits.TMR3CS = 0; //Internal clock FOSC/4

T3CONbits.TMR3ON = 1; //Timer3 is enabled

TMR3H = 0xFA; //Set the Timer3 counter to get

TMR3L = 0xCB; //the period of 16.625 us

PIE2bits.TMR3IE = 1; //Timer3 interrupt enable

//ADC module configuration

ADCON0bits.CHS = 3; //Select the CH3 for conversion

ADCON1bits.PVCFG0 = 0; //Positive voltage reference

ADCON1bits.PVCFG1 = 0; //supplied internally by VDD

ADCON1bits.NVCFG0 = 0; //Negative voltage reference

ADCON1bits.NVCFG1 = 0; //supplied internally by VDD

ADCON2bits.ADFM = 1; //AD conversion result is right justified

ADCON2bits.ACQT = 0; //Acquisition time is 0;

ADCON2bits.ADCS = 2; //AD conversion clock is Fosc/32

ADCON0bits.ADON = 1; //Enable ADC module

PIE1bits.ADIE = 1; //AD conversion complete interrupt enabled

//Interrupts configuration

INTCONbits.GIE = 1; //Enable global interrupts

INTCONbits.PEIE = 1; //Enable peripheral interrupts

lcd_init(0, 0); //Initialize the LCD without cursor and blinking

count = 0; //Reset the count variable

while (1)

{

}

}

This program is one of the most extensive, and most likely the most difficult among the others we wrote in previous tutorials.

In line 1, we, as usual, include the “xc.h” file to be able to use the PIC MCU-related variables, functions, and macros. In line 2, we include the “config.h” file, in which the configuration bits are defined. And finally, in line 3, we include the “lcd1602.h” file, which consists of the 1602 LCD-related functions.

In line 5, we define the THRESHOLD macro, which specifies the synchronization threshold for the displayed graph. I will explain this part later, and now keep in mind that we have such a value.

In lines 7 and 8 we declare two global variables: count (line 7), which is the counter of the ADC measurements, and the array of 50 16-bit elements adc_data (line 8) into which the consequent ADC measurements will be stored. The size of the array also is not random and will be explained in a proper time.

In lines 10-81, there is the function refresh_display in which all the calculations are done. We will consider it the last so you can understand what we have before we call it.

In lines 83-109, there is the interrupt subroutine, which we also will put aside for now.

Let’s first consider the program’s main function, which is located in lines 111-154. You can notice that this function has only the initialization part, and the main loop of it (lines 151-153) is empty. That’s because everything will be done inside the interrupt subroutine. I said several times that we shouldn’t put any big code inside the interrupt handler functions, but this is the case when this is acceptable.

So let’s start with the initialization part of the program (lines 113-149).

In line 114, we configure the RA4 pin as the input. This pin is merged with the analog input AIN3 (see Figure 1), and as I mentioned before, to use it as the ADC input, we need to configure it as the input and disable the digital input buffer, which we do in line 115. After power-up, the TRISx and ANSEL(H) bits are already set as 1, but it’s always a good idea to configure them properly implicitly unless you are in a desperate lack of flash memory.

In lines 117-118, we set the CPU frequency as 32 MHz.

In lines 122-128, we configure Timer3 in a similar way as in the previous tutorial:

  • Enable 16-bit read/write operation (line 122)
  • Set the timer prescaler as 1:1 (line 123)
  • Set the Timer3 clock source as Fosc/4 (line 124)
  • Enable the Timer3 (line 125).

In this program, we won’t use the ECCP module, so we don’t care which timer it’s assigned to. Here, Timer3 will be used as a timer that generates the interrupts in a defined interval. When these interrupts happen, we will start the AD conversion. So, using the timer, we can be sure that the measurements of the input analog signal will be done with a constant period. This period is set in lines 126-127 when we implicitly write into the Timer3 registers TMR3H and TMR3L the values 0xFA and 0xCB, respectively. Let’s calculate to which period these values correspond.

Timer3 is a 16-bit timer that always counts up. The current case will calculate from the initial value 0xFACB (joint value of the TMR3H and TMR3L registers) to the end value 0xFFFFF. The difference between these values is (0xFFFF - 0xFACB + 1) = 0x0535, or 1333 in decimal format. We add 1 because the interrupt is generated when the timer’s value changes from 0xFFFF to 0x0000 giving one extra tick. As the prescaler of the timer is 1:1 (line 123), and the CPU frequency is 32 MHz, each timer’s tick takes 1 / (32 / 4) = 0.125 us. And 1333 ticks take 1333 x 0.125 = 16.625 us.

Now, we can take a break regarding the program. Clear your mind for further explanation of what outcome we intend to see.

The 1602 LCD looks like in Figure 3.

Figure 3 - 1602 LCD
Figure 3 - 1602 LCD

Each dark rectangle is 5x8 dots, and the distance between the rectangles is one dot. As I told you in Tutorial 18, apart from the predefined characters, the HD44780 driver also has the CGRAM area, which can be used to generate our own characters. We will use this area to display the input signal’s waveform. Unfortunately, the number of manual characters is just 8, so we can’t spread the graphical image on the whole display. We can make either a thin graph of one line, 8 characters width, or a tall graph of two lines but only 4 character width.I used the second option, so that the whole graph will occupy just the area of 2x4 characters.

Also, not to have the step in the graph between the characters, I decided that the empty space between them would also be considered an active area. So, in this case, the resolution of the graph area in pixels will be 23 x 17 (4 x 5 pixels + 3 empty lines and 2 x 8 pixels + 1 empty line).

Then it would be good if one character corresponds to some round number in a horizontal direction, for example, 1 ms. So you can use the empty spaces as grid lines. As the distance between the empty spaces is 6 dots, to get the resolution of 1 ms/div, we need to read the input signal every 1 / 6 = 0.16667 ms, or 166.67 us. And the closest value that can be achieved by the timer using the maximum CPU frequency is 16.625 us - the value that we have set in lines 126-127.

As for the vertical resolution, we will use just 16 bits of 17. There are two reasons for this. First, it simplifies the calculations, as we will be able to use a 16-bit value, and to deal with 17 bits we would need to switch to 32-bit values. And second, I usually use the supply voltage provided by the PICKit as 3.25V. And if we divide 3.25 by 16, we will get a vertical resolution of almost precisely 200 mV/pixel, or 1.6V/div. If we divide 3.25 by 17, the resolution will be 191 mV which is also not bad but still worse than the even 200 mV/pixels we get when using 16 pixels.

OK, now you know what we want to get. Later I will explain how to achieve it, but let’s return to the program code. We left it at lines 126-127, where we set the initial value of Timer3.

In line 128, we enable the Timer3 overflow interrupt, which, as I said, we will use to start the AD conversion.

In lines 131-140, we configure the ADC module according to the information I provided you earlier. In line 131, we select the AIN3 channel as the active input of the ADC multiplexer by setting the CHS bits combination as 3, which means that CHS3 = 0, CHS2 = 1, CHS1 = 1, and CHS0 = 0.

In lines 132-133, we select the VDD as the positive reference voltage source by setting both PVCFGx bits as 0, and in lines 134-135, we select the VSS as the negative reference voltage source by setting both NVCFGx bits as 0.

In line 136, we set the conversion result alignment as right justified by setting the ADFM bit as 1. In line 137, we set the acquisition time to 0. The sampling period of 16.625 us will be enough for both auto acquisition and conversion, so we don’t need to add more acquisition time.

In line 138, we set the ADC clock frequency as Fosc/32 by setting the ADCS bits as 2, which means that ADCS2 = 0, ADCS1 = 1, and ADCS0 = 0. As you remember, we set the Fosc as 32 MHz in lines 118-119, so the ADC clock frequency is 32 / 32 = 1 MHz, which gives the TAD of 1 / 1 MHz = 1 us. This value fits into the required range of 0.7 - 4 us.

After all configurations, we enable the ADC module by setting the ADON bit as 1 (line 139). Then we enable the AD conversion complete interrupt by setting the bit ADIE of the PIE1 register as 1 (line 140). And now we can consider the ADC module to be configured.

In line 143, we enable all unmasked interrupts, and in line 144, we enable the peripheral interrupts.

In line 145, we initialize the 1602 LCD without the cursor and blinking.

Finally, in line 148, we assign 0 to the count variable to start filling the adc_data array from the first element (which, as you know, has the number 0)

That’s all about the initialization. And as I already said, the main loop of the program (lines 150-152) is empty this time.



Now let’s consider the INTERRUPT_InterruptManager function, which handles both Timer3 overflow (lines 101-107) and ADC conversion complete (lines 87-100) interrupts.

In line 85, we check if the peripheral interrupts are enabled because Timer3 overflow and ADC conversion complete interrupts belong to the peripherals. In line 87, we check if the ADC conversion complete interrupt is enabled (PIE1bits.ADIE == 1) and if the corresponding interrupt flag is set (PIR1bits.ADIF == 1). In this case, we consider that the current ADC conversion is done. Then we clear the interrupt flag (line 89) because, as I said before, we must clear it with the firmware. Next, we read the ADC conversion result into the current element of the adc_data array (line 90). Here, I showed you how to read the whole 10-bit result similar to reading the ECCP or timer’s 16-bit value, but we don’t need such high accuracy as for the vertical resolution of 16 pixels, four bits for the result is quite enough.

In line 91, we increment the count value to read the next AD conversion result into the next adc_data element. In line 92, we check if the count value is equal to or greater than 50, which means that the array is now full, and we can switch to its processing. But before this, we need to do some preparations. First, we disable all peripheral interrupts by setting the PEIE bit of the INTCON register as 0 (line 94). This is needed to stop further ADC conversions while we still need to complete processing the current set. After that, we invoke the refresh_display function, inside which we make all calculations and result displaying (line 95).

Then we reset the count value back to 0 to start the new data collection cycle (line 96). Next, we perform the 500 ms delay (line 97) not to update the display image very often. You can adjust this value though, if you want.

Finally, in line 98, we re-enable the peripheral interrupts, so now both Timer3 overflow and ADC conversion complete interrupts will be active again, and the new cycle of collecting the date will start.

In line 101, we check if the Timer3 overflow interrupt is enabled (PIE2bits.TMR3IE == 1) and if the corresponding interrupt flag is set (PIR2bits.TMR3IF == 1). In this case, we reset the interrupt flag (line 103), then start the new ADC conversion by setting the bit GO_nDONE of the ADCON0 register as 1 (line 104) and finally set the initial values of the TMR3H and TMR3L registers (line 105-105) the same as in lines 126-127 to keep having the same timer period.

That’s all about the interrupt manager, and now we need to consider the refresh_display function, which is the longest and the most complex in the current program (and actually in all previous programs as well).

Before we start with the refresh_display function, let me show you the final result we want to obtain (Figure 4).

Figure 4 - the appearance of the oscilloscope
Figure 4 - the appearance of the oscilloscope

As you see, there are four active areas in the LCD apart from the static text:

  1. Scope area, in which the waveform of the input signal is shown. As I already said, because of the limited volume of the CGRAM in the LCD, this area is just 2x4 characters.
  2. Minimum voltage found in the whole adc_data array.
  3. Maximum voltage found in the whole adc_data array.
  4. Average voltage of the whole adc_data array.

With positions 2-4, everything is clear - finding the array’s minimum, maximum, and average values are very simple tasks. But as for the scope area, it needs a more detailed explanation.

I will show you how the image is formed in the example. Let’s consider that we got the following sequential values from the ADC: [0, 130, 412, 720, 1023, 1023, 862, 350…].

I decided to simplify the drawing process and draw the graph as a set of vertical lines. Each line is drawn between the previous and the next ADC readings, and each line is drawn from the smaller value to the bigger. Also, as the graph height is just 16 pixels, we need to normalize the initial values, which are 16-bit ones, among which 10 lower bits represent the result, and the upper 6 bits are not used. The 16 pixels can fit into 4 bits, so we need to shift the initial values to the right by 10 - 4 = 6 bits which corresponds to dividing them by 26 = 64. Let’s do this and see the array after conversion: [0, 2, 6, 11, 15, 15, 13, 5…].

Now we need to find the lower and bigger values among the neighboring ones to draw the lines between them: 0-2, 2-6, 6-11, 11-15, 15-15, 13-15, 5-13…

Now let’s see the result of the drawing (Figure 5).

Figure 5 - Example of the drawing
Figure 5 - Example of the drawing

That’s how everything will look eventually. The grayed rows and columns are the ones that will not be displayed, so the lines there will not be visible (you can see how it looks in Figure 4). But how is this all performed in the program code? Let’s consider this in more detail.

Each column in figure 5 corresponds to the 16-bit element of the dedicated array, which in our program is called display_buffer. Each cell in Figure 5 corresponds to one bit of the display_buffer element. The black cell is 1, and the white cell is 0. So according to Figure 5, the first seven elements of the display_buffer array will have the following values:

display_buffer[0] = 0b0000000000000111 = 0x0007
display_buffer[1] = 0b0000000001111100 = 0x007C
display_buffer[2] = 0b0000111111000000 = 0x0FC0
display_buffer[3] = 0b1111100000000000 = 0xF800
display_buffer[4] = 0b1000000000000000 = 0x8000
display_buffer[5] = 0b1110000000000000 = 0xE000
display_buffer[6] = 0b0011111111100000 = 0x3FE0

In this way, all 24 elements are formed.

But that’s not all yet. We now need to convert the display_buffer array into another array that is suitable for sending to the CGRAM of the display to generate the characters. In tutorial 18 I already told you how the CGRAM is organized, so you can refer to Figure 4 of it to refresh your memory. As you can see, the characters are generated line-wise, and the display_buffer has the values column-wise. Also, the first element of the CGRAM corresponds to the top line, while the display buffer starts from the bottom. The number of the CGRAM addresses is shown in the last column in Figure 5. In our program; there is the array character which consists of the CGRAM data. This array is two-dimensional. The first group of 8 bytes is used to generate the characters for the first line of the LCD, and the second group of 8 bytes is used to generate the characters for the second line.

The conversion from the display_buffer to the character is not very simple and requires a lot of operations with the bits. Let’s see how it’s done using Figure 5 as an illustration. Let’s start with the bottom row, which corresponds to the seventh byte of the second group of the character array, simply saying - to the character[1][7]. As you can see, this byte can be collected with the bit #0 of the display_buffer[0]-display_buffer[5]:

character[1][7] = (display_buffer[4] & 0x01) + (display_buffer[3] & 0x01) << 1 + (display_buffer[2] & 0x01) << 2 + (display_buffer[1] & 0x01) << 3 + (display_buffer[0] & 0x01) << 4

The bitwise AND operation (&) is used to distinguish the single bit of the array. In our case, it’s bit #0; that’s why we perform the AND operation between the display_buffer element and the number 0x01. After that, we need to shift the distinguished bit to the right place of the character[1][7] byte. As you can see in Figure 5, the display_buffer[0] element corresponds to bit #4 of the character[1][7] byte (compare this image with Figure 4 of tutorial 18).

In the same way, we distinguish the bit #0 in the next display_buffer elements and shift them to the right place. The next character[1] element is calculated similarly:

character[1][6] = ((display_buffer[4] & 0x02) >> 1) + ((display_buffer[3] & 0x02) >> 1) << 1 + ((display_buffer[2] & 0x02) >> 1) << 2 + ((display_buffer[1] & 0x02) >>1) << 3 + ((display_buffer[0] & 0x02) >> 1) << 4

As you see, now we need to distinguish bit #1, that’s why we now use 0x02 in the AND operation. Then we need to put the bits in the right place by the left shift. But as we use the second bit of the display_buffer, we need to shift every bit at one position to the right first. Yeah, I understand. This is complex, so you probably need to think a bit to realize how it’s all done. Believe me, I also spent some time before I managed to do it properly.

Actually, the other character elements are calculated in the same way:

character[1][5] = ((display_buffer[4] & 0x04) >> 2) + ((display_buffer[3] & 0x04) >> 2) << 1 + ((display_buffer[2] & 0x04) >> 2) << 2 + ((display_buffer[1] & 0x04) >> 2) << 3 + ((display_buffer[0] & 0x04) >> 2) << 4 ...

character[0][1] = ((display_buffer[4] & 0x8000) >> 15) + ((display_buffer[3] & 0x8000) >> 15) << 1 + ((display_buffer[2] & 0x8000) >> 15) << 2 + ((display_buffer[1] & 0x8000) >> 15) << 3 + ((display_buffer[0] & 0x8000) >> 15) << 4

You can notice a pattern here so we can make the conversion in a loop. When we return to the program, I will show you how it’s done. Also, it would be nice if you remembered that the display_buffer is not limited by five elements, and we need to generate the characters for the remaining six CGRAM addresses, which I will also show you later.

Now I have explained everything you need to understand the program code, so let’s return to it. As you remember, we only need to consider the refresh_display function, which lies in lines 10-81.

In lines 12-18, we declare the local variables of the function:

  • min and max (line 12) are the lower and upper neighboring values in the adc_data array, which I already discussed.
  • character array (line 13) is used to generate the characters using the CGRAM of the 1602 LCD. I already explained its use in detail.
  • display_buffer (line 14) is a kind of canvas on which we draw the graph. This one is also already explained.
  • start_index (line 15) is the number of the element of the adc_data array from which we start its “drawing”. By default, we set it to the middle of the array - 25. It synchronizes the graph view, which I will discuss a bit later.
  • max_v (line 16), min_v (line 17), and avg_v (line 18) are the minimum,maximum, and average voltages in the saved chunk of the measurements. We assign the initial value of the adc_data[0] to all of them.

In lines 19-26, there is a loop in which we find the maximum and minimum values and accumulate the average value of the voltage. Please note that we start the loop from 1, not from 0 (line 19), as we have already assigned adc_data[0] to all three variables used. The algorithm is quite straightforward. In line 21, we check if the current element of adc_data is greater than max_v. If so, we assign this element to the max_v (line 22). In line 23, we check if the current element of adc_data is smaller than min_v. If so, we assign this element to the min_v (line 24). In line 25, we added the current adc_data value to the avg_v.

In line 27, we divide the avg_v value by 50 to calculate the average voltage of all 50 elements.

In lines 28-30, we convert the ADC result into a real voltage of 0.1V. As you know, for 10-bit ADC, the voltage is calculated as follows:

In our case, the reference voltage VREF is the difference between the VDD and VSS. As I said, I usually set the PICKit output voltage as 3.25V so that we can consider this value as VREF. Taking this into account, we have:

To get rid of the decimal point, let’s multiply the numerator and denominator by 100:

And as we want to get the voltage is 0.1 V, we need to divide the denominator by 10, so finally, we have:

As you see, this equation perfectly matches the one in lines 28-30.

In line 31-38, we search for the start element of the array from which we will start drawing the graph. Before considering how it’s implemented, let’s first see what lies under it.

In real oscilloscopes, you might see the “Trigger” option or “Synchronization.” It is used to steady the image on the oscilloscope screen (I am talking about the periodic input signals). The oscilloscope can start the measurement cycle at any moment during the input signal’s period. So if you display it then as is, it will “jump” after each refresh of the screen. To avoid this, the oscilloscope reads more samples than can fit into the display and then finds the position in the saved array where the input graph crosses the threshold value in the selected direction (either up or down, this option also is available in real oscilloscopes). Then it outputs the graph from this position.

In real oscilloscopes, the threshold value can be changed. Also, the trigger options are selectable, but for simplicity, will have the constant threshold level of ¼ of the max input value and the constant synchronization option - “up,” so the graph will be synchronized by its rising edge crossing the threshold.

Let’s now see how it’s done in our program. The logic is the following - we are searching for the synchronization condition in the first 25 elements of the array. If it’s found, we will send the graph from this fount position. If not, we just send the last 25 elements without any synchronization. So, we start the loop to search within the first 25 elements of the array (line 31). In this loop, we check if the current adc_data element is lower than the THRESHOLD and the next element is greater or equal than the THRESHOLD (line 33). This condition corresponds to crossing the threshold level by the graph from below, and if it meets, we save the current i value as start_index (line 35) and then leave the loop (line 36).

In lines 39-48, there is a loop in which we compose the display_buffer in the way I have explained earlier. Here we generate 24 elements of the display_buffer (line 39), but the last element will not be displayed, so that it could be omitted. In line 41, we find the minimum value between the current and the next elements of the adc_data:

min = (adc_data[i + start_index] >= adc_data[i + start_index + 1]) ? adc_data[i + start_index + 1] >> 6 : adc_data[i + start_index] >> 6;

Here we use the conditional operation “? :” about which you can read, for instance, here. You see that the adc_data element index is defined here as i + start_index. In this way, we consider the synchronization shift. Also, during the assignment, we shift the adc_data to the right by 6 bits, so the min value will have only 4 higher bits of the result which are in the range of 0…15.

In line 42, we find the maximum value between the current and the next elements of the adc_data in the same way as the minimum value.

In line 43, we clear the current display_buffer element, as next, we will accumulate the bits in it. In line 44, we start the nested loop from the min value to the max value. Inside this loop, we add to the current display_buffer element the value 1 << j (line 46). This value adds 1 at the bit position defined by the variable j, which in turn changes from min to max. By doing this, fill the display_buffer with ones in the bits in positions from min to max, the result of which you can see in Figure 5.

Once the display_buffer is filled, we can convert it into a series of characters to send to the CGRAM of the display. We do this in the loop in lines 49-63. As you can see, there are three nested loops. The first one (line 49), with the variable k that changes from 0 to 4, defines the number of the character in horizontal direction. The second loop (line 51) with the variable i that changes from 0 to 8 defines the number of the line in the current character. And the final loop (line 55) with the variable j that changes from 0 to 5 defines the number of the bit inside the line in the current character (such a House that Jack built…).

In lines 53-54, we clear the elements of the character array with the indexes [0][7-i] and [1][7-i]. As I already mentioned, the first index defines the number of the 1602 LCD line - 0 for upper line, and 1 for lower line. The second index defines the number of the line of the character where 0 is the top line, and 7 is the bottom line. As I already explained and shown in Figure 5, the direction of the bits in the display_buffer and character arrays is the opposite, that’s why we use the index 7-i instead of just i. As i starts from 0, we first will start with the character[x][7] element moving from the bottom to the top.

The most interesting happens in lines 57, 58 of the inner loop. Here, we convert the display_buffer into the character array. Line 57 corresponds to the upper line, and line 58 corresponds to the lower line. The difference between them is that for the upper line we use the index i + 9 (just to remind you, this index corresponds to the number of a bit in the display_buffer). If you look at Figure 5, you can see that the bit 7 of the character[0] (right column) corresponds to the bit 9 of the display_buffer (left column), that’s why we add 9. Other than that these lines are identical. At first sight, they look monstrous, but they are built based on the formulas I have already provided you for some of the character elements.

Compare these equations

character[1][7] = (display_buffer[4] & 0x01) + (display_buffer[3] & 0x01) << 1 + (display_buffer[2] & 0x01) << 2 + (display_buffer[1] & 0x01) << 3 + (display_buffer[0] & 0x01) << 4

character[1][6] = ((display_buffer[4] & 0x02) >> 1) + ((display_buffer[3] & 0x02) >> 1) << 1 + ((display_buffer[2] & 0x02) >> 1) << 2 + ((display_buffer[1] & 0x02) >>1) << 3 + ((display_buffer[0] & 0x02) >> 1) << 4

character[1][5] = ((display_buffer[4] & 0x04) >> 2) + ((display_buffer[3] & 0x04) >> 2) << 1 + ((display_buffer[2] & 0x04) >> 2) << 2 + ((display_buffer[1] & 0x04) >> 2) << 3 + ((display_buffer[0] & 0x04) >> 2) << 4

with the line 58:

character[1][7 - i] += ((display_buffer[4 - j + k * 6] & (1 << i)) >> i) << j;

You can see that they are the same, I just gathered all five components of the sum into a loop. The only difference is the index of the display_buffer: 4 - j + k * 6. The first part (4 - j) should be clear, and it follows from the equations above, and the second part (k * 6) allows us to compose the next characters from the further elements of the display_buffer. As you remember, k changes from 0 to 4, which allows to embrace the display_buffer elements from 0 to 24.

And that’s all about the bits manipulations. After the inner loop, we have the composed character arrays, so now we can invoke the function lcd_create_char to generate the new characters for the upper and lower LCD lines (lines 61-62).

In line 64, we set the cursor at the first position of the first line. Then we send four characters with the addresses 0, 1, 2, 3 to display (lines 65, 66). These addresses correspond to the characters we have generated for the upper line of the LCD. Then, in line 68, we set the cursor at the first position of the second line and send the next four characters with the addresses 4, 5, 6, 7 (lines 69, 70), which we have generated for the lower line.

In lines 72-80, we display the min_v, max_v, and avg_v values in the display along with the static text. I don’t see any reasons to consider these lines in more detail, as they are quite clear. If not, please refer to the tutorial 21 where the lcd_write_string and lcd_write_number functions are described.

And finally, that’s it! We did it! Surprisingly (in fact - no, not actually that surprising), the description of this program took a lot of time, but you agree - it’s quite complex. Let’s now assemble the circuit according to Figure 1, build and run the program code and make some tests.

Testing of the Oscilloscope

If nothing is connected to the AIN3 pin of the MCU, the result is obvious - we will have 0 everywhere, and the graph will be flat (Figure 6).

Figure 6 - Oscilloscope with no input signal
Figure 6 - Oscilloscope with no input signal

For testing the oscilloscope, you now need a signal generator. Fortunately, I have one which is quite simple but works well. It can generate the sine, saw, square, and triangle waves with an amplitude of about 3 V - just what we need. If you don’t have any, don’t be upset; in the next tutorial, I’ll show you how to make it based on the same PIC18F14K50 MCU. The other option is to use the sine wave generator we built based on the PIC10F200 MCU.

In Figure 7 I applied the sine wave signal with the 500 Hz frequency to the input AIN3.

Figure 7 - Sine wave signal with the 500 Hz frequency
Figure 7 - Sine wave signal with the 500 Hz frequency

Well, actually, figure 7 is the same as Figure 4 but without the red rectangles. The frequency of 500 Hz corresponds to the signal period of 1 / 500 = 0.002s = 2 ms. As you remember, I set the Timer3 period, so each LCD character corresponds to 1 ms. And in Figure 7, you can see that the sine wave has a period of exactly 2 characters which means that the horizontal resolution works properly. As for the vertical resolution, we agreed that each pixel corresponds to 200 mV. In Figure 7, you can see that two upper lines are unused by the graph, so it spends 8 pixels of the bottom character, one pixel between the characters, and 6 pixels of the top character. This gives us 15 pixels, or 15 x 200 = 3000 mV. In the LCD (Figure 7), you can see the maximum measured voltage is 2.9 V which corresponds to the measured one with quite a decent accuracy for such a simple device.

In Figure 8-10, there are saw, triangle, and square signals of the same frequency.

Figure 8 - Saw wave signal with the 500 Hz frequency
Figure 8 - Saw wave signal with the 500 Hz frequency
Figure 9 - Triangle wave signal with the 500 Hz frequency
Figure 9 - Triangle wave signal with the 500 Hz frequency
Figure 9 - Square wave signal with the 500 Hz frequency
Figure 9 - Square wave signal with the 500 Hz frequency

As you can see, all the signals are recognizable even with such a low resolution. Also, you can’t see it in the figures above, but the signal is relatively steady and doesn’t jump, which means the synchronization works well. You can notice in Figure 7-9, the wave starts at about ¼ of the amplitude, corresponding to the THRESHOLD value set in line 5. You can change it and see how that affects the result.

In Figure 10 and 11, there are the sine wave signals with the frequency of 200 Hz and 1 kHz, respectively.

Figure 10 - Sine wave signal with the 200 Hz frequency
Figure 10 - Sine wave signal with the 200 Hz frequency
Figure 11 - Sine wave signal with the 1 kHz frequency
Figure 11 - Sine wave signal with the 1 kHz frequency

The signal with the 1 kHz frequency looks pretty ugly here, but you still can see that each period here takes exactly one LCD character.

Its value is that the algorithms that lay upon it are used in real oscilloscopes, so you can build an effectively working device by using a normal display and adding some controls.

I think it's time to finish this tutorial. You need some time to understand everything we have done today. As I’ve said from the start, this topic is not an easy one, but on the bright side (worth bragging), you may use this knowledge to amuse your engineering friends.

As homework, I encourage you to experiment and practice. Fit the whole graph in one LCD line, but prolong it to 8 characters. This will reduce the vertical resolution twice but will expand the whole graph horizontally, so that you can see more.

Make Bread with our CircuitBread Toaster!

Get the latest tools and tutorials, fresh from the toaster.

What are you looking for?