Analog to Digital Conversion

18 minute read

In this post, we’ll set up analog to digital conversion (ADC) on a PIC18F452, which is a way of converting analog to digital values and can be useful for all kinds of sensors. First, we’re going to test it on a potentiometer — depending on the pot’s rotation, a number from 0 to 9 will be displayed on a 7-segment display from the previous post. Second, we’re going to make an IR sensor which will detect how close an object is to it or if the object is white or black. This can be useful for example for a line follower robot when trying to detect if an object is in front of it or when trying to follow a black line on a white background.

What is ADC?

ADC, or analog to digital conversion is a way of converting a range of analog values into digital values, since microcontrollers understand only those. ADC can be used to read the values from sensors, such as a potentiometer, IR sensor, ultrasonic sensor, temperature sensor and so on by converting the range of voltages, say 0V to 5V to a digital number scale, say, from 0 to 1023. The range of scale depends on the A/D resolution — in case of PIC18F452 it’s 10-bit, meaning it has 2^{10} possible values from 0 to 2^{10}-1=1023.

The software

We’ll start by configuring the ADC inside the program, so open the program from before.

To make the code more readable, I decided to restructure the program a bit by creating a function init7SegmentDisplay() which will set the direction of the pins used by the 7-segment display and set the default values for pins to display:

1
2
3
4
void init7SegmentDisplay() {
  TRISD = 0b00000000; // 7-segment display pins to output
  LATD = ~0b10000000; //set every D pin to HIGH except the decimal point (display will show just D.P.)
}

Similarly, the initialization of ADC will go into the function initADC(), where we’ll write all its configuration.

Configure ADC

Pins which can be used for ADC are labeled AN0 to AN7, which correspond to pins RA0, RA1, RA2, RA3, RA5, RE0, RE1, RE2 (see PIC18F452 datasheet page 3). First, we set all the AN pins to input, although we’ll just be using one of them:

1
2
// all analog pins (AN0 - AN7) to input
TRISAbits.RA0 = TRISAbits.RA1 = TRISAbits.RA2 = TRISAbits.RA3 = TRISAbits.RA5 = TRISEbits.RE0 = TRISEbits.RE1 = TRISEbits.RE2 = 1;

Now, ADC uses 2 registers for its configuration — ADCON0 and ADCON1. By setting the individual bits of each register we can configure different parts of the ADC. (You can see which bits control which part in the datasheet on p. 181 REGISTER 17-1 and 17-2 and you can see the steps we’re following to get an A/D conversion on p. 184.)

First, we need to set which pins will be analog inputs, which digital I/Os and if we want to use voltage reference pins. Since AN pins can be used either as analog inputs, or as general digital inputs/outputs, we need to set which of them we want to use for ADC and which we want to leave as general I/O pins for other use. Since we’re only going to use one pin for ADC reading (AN0), the other pins can be used as general I/Os. Also, we need to set the voltage reference pins V_{REF-} and V_{REF+}. They’re used by the ADC to know the minimum voltage V_{REF-} and the maximum voltage V_{REF+} that it’s going to read; using this, it can then convert the input voltage to a number relative to these 2 voltages. For a common case where V_{REF-} = GND and V_{REF+} = V_{CC}, we can free the 2 V_{REF} pins by choosing a setting which does not contain them and the PIC will provide these reference voltages internally. This is also our case, since the potentiometer’s readings will be between GND and VCC. To set all this, we need to look into the table in the datasheet on p. 182 REGISTER 17-2 bit 3-0, where are shown the possible combinations of which pins will be analog, which digital and which V_{REF-} and V_{REF+} and to which values of PCFG bits of the ADCON1 register they correspond. In our case where we want only AN0 to be analog and the rest to be digital with no V_{REF-} and V_{REF+} pins, it’s the before-last option which sets PCFG3-PCFG0 bits to 1110 as follows:

1
2
3
4
ADCON1bits.PCFG3 = 1;
ADCON1bits.PCFG2 = 1;
ADCON1bits.PCFG1 = 1;
ADCON1bits.PCFG0 = 0;

Now we’re going to set the input channel, which is the pin where we want to measure the voltage to be used for the A/D conversion — I chose the first analog pin AN0 (corresponding to RA0). This is done by setting the bits CHS2-CHS0 of the ADCON0 register. On p. 181 REGISTER 17-1 bit 5-3 of the datasheet you can see which channel corresponds to which pins and which CHS setting corresponds to each channel — the corresponding channel for the AN0 pin we want is channel 0, which is set by setting the ADCON0 bits CHS2-CHS0 to 000:

1
2
3
ADCON0bits.CHS2 = 0;
ADCON0bits.CHS1 = 0;
ADCON0bits.CHS0 = 0;

Now we need to set the conversion clock. Here, we set the T_{AD}, which is the time required to convert one bit. If you’re lazy, you can look up the frequency F_{OSC} of your microcontroller (i.e. of your crystal — in my case it’s 20MHz) in the table 17-1 on p. 186 and you’ll get a corresponding T_{AD}. Alternatively, you can calculate it. The T_{AD} needs to be set in terms of T_{OSC}, which is the period of one cycle of the microcontroller. According to the datasheet (p. 186 17-2), for correct A/D conversion this T_{AD} time must be the minimum T_{AD} time of 1.6µs — the lower, the better. Since we only know the frequency of our microcontroller F_{OSC}=20MHz, we need to calculate its period, which is T_{OSC}=1/F_{OSC}=50ns. Since we know the duration of one cycle of the MCU (50ns), we need to find a multiplier, the T_{AD}, which will make that time at least 1.6µs: T_{AD}=1.6\mu s/50ns=32. So, we’ve determined that the best T_{AD} is 32 T_{OSC}, so the time needed to convert one bit is 32 times the period of one cycle of the MCU. And you can check that this is the case also from the aforementioned table 17-1 on p. 186 for a 20MHz PIC frequency. To set this, according to the datasheet we need to set the bits ADCS2-ADCS0 to 010 respectively:

1
2
3
ADCON1bits.ADCS2 = 0;
ADCON0bits.ADCS1 = 1;
ADCON0bits.ADCS0 = 0;

Now we’re going to set the justification of the result either to the left or right. After a voltage reading is converted to a 10bit value, it is stored in registers ADRESH and ADRESL (analog to digital result high and low registers). Since each register is 8-bits wide, the result will fill one whole register and 2 bits from the other register. Now imagine those 2 registers next to each other with ADRESH to the left and ADRESL to the right (datasheet p. 187, fig. 17-4). A left-justified result will take up the left 10 bits, so all ADRESH bits and the 2 leftmost bits of ADRESL will contain the result with the remaining 6 rightmost bits of ADRESL set to 0. Oppositely, a right-justified result will take up the whole ADRESL register and the 2 rightmost pins of ADRESH, with the remaining 6 leftmost ADRESH pins set to 0. For me, a right-justified result is easier to handle since it’s easy to combine those 2 registers together to get a 10bit binary result, while in a left-justified result we’d need to do some more work. To set the result to be right justified, we need to set the ADFM (analog to digital format select) bit of ADCON1 register to 1. (If we wanted it to be left justified, we would set ADFM to 0.)

1
ADCON1bits.ADFM = 1;

Finally, we’re ready to turn on the A/D module by writing 1 into the ADCON0’s ADON bit. (If we would want to turn it off again we’d write there 0 instead.)

1
ADCON0bits.ADON = 1;

Read ADC converted value

So, since ADC is now configured and running, that’s it for our initADC() function and we may proceed with defining another function ADCread(). This function will use the ADC module to convert the voltage reading into a digital value which will be stored in the ADRESH and ADRESL registers and the return it as an integer.

First, we’ll create a delay_us(int us) function which will wait a given number of microseconds. This is from the same reason as was with the delay_ms(int ms) function which we’ve created before, namely that the argument us of the already supplied __delay_us(int us) function can not exceed a certain “magic” value. We’ll use this function in a moment.

1
2
3
void delay_us(int us) {
  for(int i = 0; i < us; i++) __delay_us(1);
}

First, before we start reading any value we need to wait a so-called acquisition time, which is the time needed to charge a holding capacitor. Before the capacitor is charged, we can’t do any readings. The acquisition time can be calculated using some complicated equations but this is unnecessary since we won’t need cutting-edge frequency of the readings. Therefore, for general use, the acquisition time of 20µs should be enough. ((I didn’t come to any official general-purpose value but I found on 2 websites that the value 20µs should be enough.)) Also note that here, unlike with the T_{AD} time, the acquisition time can be longer than 20µs and it shouldn’t affect the quality of readings (just, of course, the speed).

1
delay_us(20);

After we’ve waited the acquisition time, we’re ready to read and convert the value on the AN0 pin by setting the GO bit of the ADCON0 register to 1. This will start the A/D conversion.

1
ADCON0bits.GO = 1;

Now we need to wait till the conversion is over, at which point the ADCON0’s GO pin will be set by the system to 0. So we’ll wait till this happens by doing nothing:

1
while(ADCON0bits.GO == 1);

After the process is complete, the converted 10-bit value is stored in the ADRESH and ADRESL registers, right justified. To join these 2 registers to get a 10-bit binary number, we need first to shift ((you can find more about shifts and other binary operations on Wikipedia.)) the ADRESH register 8 bits to the left and then add it to the ADRESL register. This will move the ADRESH one whole register to the left and place it, as it were, to the left of ADRESL; then, since the number is right justified, we’ll just add those 2 registers and get a 16-bit number with 6 leftmost bits set to 0, which makes it a 10-bit number which we wanted.

1
return ((ADRESH<<8) + ADRESL);

Convert range 0-1023 to 0-9

The last function we’re going to need is function int convert1023to10(int value) which will convert a value from range 0-1023 to a value from range 0-9. This will be useful since the output value from ADC is in the range 0-1023 but on the 7-segment display, we’re only able to display numbers in the range 0-9. The function works by dividing the range 0-1023 into 10 roughly equal parts and if the the number is in the nth part it will return the converted value as n. In a special case when the number to be converted is not within the range 0-1023, it returns -1, which, remember, will be displayed on the 7-segment display as DP (decimal point).

1
2
3
4
5
6
7
8
9
10
11
12
13
int convert1024to10(int value) {
         if(value >= 0         && value < 1024*1/10) return(0);
    else if(value >= 1024*1/10 && value < 1024*2/10) return(1);
    else if(value >= 1024*2/10 && value < 1024*3/10) return(2);
    else if(value >= 1024*3/10 && value < 1024*4/10) return(3);
    else if(value >= 1024*4/10 && value < 1024*5/10) return(4);
    else if(value >= 1024*5/10 && value < 1024*6/10) return(5);
    else if(value >= 1024*6/10 && value < 1024*7/10) return(6);
    else if(value >= 1024*7/10 && value < 1024*8/10) return(7);
    else if(value >= 1024*8/10 && value < 1024*9/10) return(8);
    else if(value >= 1024*9/10 && value < 1024)      return(9);
    else                                             return(-1);
}

Main function

Now that we have all the functions at hand, we can proceed with our main() function. Since for every action we’ve made a specific function, the main() function will look much more readable. First, we’ll need one integer for storing the result of the A/D conversion — ADCvalue which will be in the range 0-1023, and then another int for storing the result of the 1024-to-10 conversion convertedValue, which we’ll then display.

1
int ADCvalue, convertedValue;

Now we’re going to initialize both our 7-segment LED display from before and also the ADC.

1
2
init7SegmentDisplay();
initADC();

Now we’re going to have an infinite loop in which we’ll read value from ADC (in the range 0-1023), convert it to an integer in the range 0-9, display the converted result on the 7-segment display, and wait some time (50ms).

1
2
3
4
5
6
while(1) {
  ADCvalue = ADCread();
  convertedValue = convert1024to10(ADCvalue);
  displayNumber(convertedValue);
  delay_ms(50);
}

Complete program

So, the program is now finished and here’s the complete source code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
#include <xc.h>

#pragma config  OSC = HS     // High-Speed oscillator
#pragma config  WDT = OFF    // Watchdog timer OFF
#pragma config  LVP = OFF    // Low-voltage programming OFF

#define _XTAL_FREQ  20000000 // 20MHz crystal

void delay_ms(int ms) {for(int i = 0; i < ms; i++) __delay_ms(1);} // wait milliseconds

void delay_us(int us) {for(int i = 0; i < us; i++) __delay_us(1);} // wait microseconds

void init7SegmentDisplay(){
    TRISD = 0b00000000; // 7-segment display pins to output
    LATD = ~0b10000000; // set every D pin to HIGH except the decimal point (display will show just DP)
}

void initADC(){
    // all analog pins (AN0 - AN7) to input
    TRISAbits.RA0 = TRISAbits.RA1 = TRISAbits.RA2 = TRISAbits.RA3 = TRISAbits.RA5 = TRISEbits.RE0 = TRISEbits.RE1 = TRISEbits.RE2 = 1;

    // bit configurations (AN0 analog, AN1-AN7 digital, no Vref pins)
    ADCON1bits.PCFG3 = 1;
    ADCON1bits.PCFG2 = 1;
    ADCON1bits.PCFG1 = 1;
    ADCON1bits.PCFG0 = 0;

    // select input channel 0 (RA0)
    ADCON0bits.CHS2 = 0;
    ADCON0bits.CHS1 = 0;
    ADCON0bits.CHS0 = 0;

    // select conversion clock (32 Tosc)
    ADCON1bits.ADCS2 = 0;
    ADCON0bits.ADCS1 = 1;
    ADCON0bits.ADCS0 = 0;

    // right justified result
    ADCON1bits.ADFM = 1;

    // turn on ADC
    ADCON0bits.ADON = 1;
}

unsigned int ADCread(){
    delay_us(20); // wait acquisition time
    ADCON0bits.GO = 1; // start A2D conversion
    while(ADCON0bits.GO == 1); // wait till the conversion is over
    return ((ADRESH<<8) + ADRESL);  // join the 2 result registers to get a 10-bit number
}

// our function from the post about 7-segment LED display
void displayNumber(int number) {
    switch(number) {
        case  0: LATD = ~0b00111111; break;
        case  1: LATD = ~0b00000110; break;
        case  2: LATD = ~0b01011011; break;
        case  3: LATD = ~0b01001111; break;
        case  4: LATD = ~0b01100110; break;
        case  5: LATD = ~0b01101101; break;
        case  6: LATD = ~0b01111101; break;
        case  7: LATD = ~0b00000111; break;
        case  8: LATD = ~0b01111111; break;
        case  9: LATD = ~0b01101111; break;
        default: LATD = ~0b10000000; break;
    }
}

// our function from the post about 7-segment LED display
int convert1024to10(int value) {
         if(value >= 0         && value < 1024*1/10) return(0);
    else if(value >= 1024*1/10 && value < 1024*2/10) return(1);
    else if(value >= 1024*2/10 && value < 1024*3/10) return(2);
    else if(value >= 1024*3/10 && value < 1024*4/10) return(3);
    else if(value >= 1024*4/10 && value < 1024*5/10) return(4);
    else if(value >= 1024*5/10 && value < 1024*6/10) return(5);
    else if(value >= 1024*6/10 && value < 1024*7/10) return(6);
    else if(value >= 1024*7/10 && value < 1024*8/10) return(7);
    else if(value >= 1024*8/10 && value < 1024*9/10) return(8);
    else if(value >= 1024*9/10 && value < 1024)      return(9);
    else                                             return(-1);
}

int main() {
    int ADCvalue, convertedValue;

    init7SegmentDisplay(); // initialize 7-segment display
    initADC(); // initialize ADC

    while(1) {
        ADCvalue = ADCread(); // read ADC value
        convertedValue = convert1024to10(ADCvalue); // convert value from range 0-1023 to 0-9
        displayNumber(convertedValue); // display number in range 0-9 on display
        delay_ms(50); // wait some time
    }

    return 0;
}

The hardware

What you’ll need

  • 10kΩ potentiometer for testing the ADC
  • IR LED for emitting IR
  • 120Ω resistor for the IR LED (depends on the LED — check your LED’s parameters)
  • IR phototransistor/photodiode for collecting IR
  • calibration resistors / potentiometer needed to calibrate the IR phototransistor (for me, 5MΩ was good but it may be much less for you)
  • previous setup where we interfaced a 7-segment LED display with PIC18F452

Potentiometer setup

Here we’re going to set up a potentiometer circuit as shown below.

Potentiometer sensor

If the pot is turned to its most counterclockwise position the display will show 0 and by turning it in the clockwise direction, the number shown will increase till the pot is in its most clockwise position when 9 would be shown. This is because what we’re measuring and sending to ADC is Vin, which is the voltage drop (difference of potentials) between Vin and GND, which is caused by the resistance between Vin and GND since the region is a part of the potentiometer. If the pot is in its most counterclockwise position, Vin = GND, so the voltage drop has the lowest value and it will be converted to number 0 on the scale 0-1023 and the display will show 0. If we start to turn it in a clockwise direction, the voltage drop between Vin and GND increases due to the resistance and the value converted will increase as well — the display will show numbers 1, 2, 3, etc. If we turn the pot to its most clockwise position, Vin = Vcc so the voltage drop has the maximum value so ADC will convert it to 1023 and the display will show number 9.

Now we’re going to take a look at an infrared sensor setup which will work on a similar principle.

IR sensor setup

Now we’re going to set up an IR sensor setup which will consist of 2 parts — IR emitter and receiver. See the schematic below.

Infrared sensor

The IR emitter will be just an IR LED (D1) in series with a protective resistor (Rled). You should check the parameters of your LED and choose an appropriate resistor so that it won’t melt. The LED will emit IR which will then be detected by the IR receiver.

The IR receiver will be a phototransistor or a photodiode (D2) in series with a calibration resistor (Rc) and between them we’ll have Vin which goes to the ADC. Vin, similar to our potentiometer setup, will be the voltage drop across the photodiode D2.

The photodiode works in such a way that when there’s no IR light hitting it, its resistance will be large and oppositely when there’s a lot of IR hitting it, its resistance will be low. We can make use of this if we put the IR LED next to the IR photodiode so that both face the same direction. If we now place an object, say, a hand, before the sensor, the IR light emitted by the LED bounces off the hand and gets reflected back to the photodiode. Now if the object is far from the sensor, only a very small portion of the IR gets reflected back to the photodiode, while if the object is close to the sensor, a large portion of the IR gets reflected. We can measure how much of the light gets reflected by measuring Vin, which is the voltage drop across the photodiode — if there’s a lot of IR light reflected to the photodiode, its resistance would be low and so the voltage drop Vin across it would be low; and if there’s a little IR reflected, the photodiode’s resistance would be high and the voltage drop across it would also be high.

However, we must have a resistor Rc so that we can calibrate the possible range of voltage drops Vin. The problem is how to choose its value. As I mentioned, when there’s no IR light hitting the photodiode, its resistance would be high, and if it would be much higher than that of calibration resistor Rc, there’d be a much larger voltage drop across the photodiode than the resistor — and in that case, our Vin reading should be close to Vcc so the A/D converted value should be close to 1023. In the opposite case when there’s a lot of IR light hitting the diode, its resistance would be low, and if it would be much lower than that of Rc, there’d be a much lower voltage drop across the photodiode than the resistor — in that case, the Vin reading should be very low and the A/D converted value should be close to 0. To achieve this, we must choose a calibration resistor Rc with a value low enough so that when there’s no IR hitting the photodiode, the photodiode’s resistance will be higher than that of Rc; and high enough so that when there’s a lot of IR hitting the photodiode, the photodiode’s resistance would be lower than that of Rc. As you can see, to find the right resistor value, we need to try different resistors and see if the resulting numbers displayed are what we want for our application.

Apart from sensing if an object is in front of the sensor, it can also be used to detect if an object in front of it is white or black. The principle is similar but in this case if the object is white, it will reflect a lot of the IR while if it’s black, it will absorb a lot of light and a smaller portion of the IR gets reflected back. For this specific task, the value of Rc = 5MΩ worked well for me but for you it might differ greatly based on your phototransistor. The display now shows numbers closer to 0 if the object is white and numbers closer to 9 if it’s black. This particular application can be used e.g. for a line follower detecting a black line on a white surface.

In the next post, I’ll show you how to get an LCD display running so that we can display all sorts of things like characters, strings and numbers.

Documentation

Leave a comment