FB pixel

Build Your Own Digital Thermometer | Embedded C Programming - Part 12

Published


Hello! This time we will make a digital thermometer based on the DS18B20 sensor and the 7-segment indicator which we considered in tutorial 6 and tutorial 7. Actually, I already made a tutorial about this for the PIC10F200 MCU but that time we used the LED indicator with a special driver TM1637, and now we will use a direct connection. We will also use the C programming language instead of assembly.

The task will also be the same: display the temperature value taken from the DS18B20 in celsius in the 3-digit 7-segment LED indicator. If the temperature is positive, display it in format “xx.x”, otherwise display it in the format “-xx”.

DS18B20 Sensor Description

I already described the DS18B20 sensor in this tutorial, but I’ll copy-paste the information about it here as well, to simplify your reading.

DS18B20 is a very popular sensor designed by the Dallas Semiconductor company (that’s why the sensor’s name starts with the ‘DS’) which was bought by the Maxim Integrated Corporation in 2001.

The price of the sensor starts from $0.60 at Aliexpress. The appearance of the sensor is shown in fig. 1.

Figure 1 - DS18B20 Sensor
Figure 1 - DS18B20 Sensor

As you can see, it’s just a chip in a TO-92 package. There are also modifications in the uSOP-8 and SOIC-8 packages (fig. 2, taken from the data sheet).

Figure 2 - DS180B20 Pinout
Figure 2 - DS180B20 Pinout

The DS18B20 chip only has 3 active pins - two are used for power (VDD and GND), and one (DQ) is used for communication with the microcontroller. This chip uses a special digital interface called 1-wire, which was also developed by Dallas Semiconductor. This interface is not as widespread as UART, I2C or SPI, and the majority of microcontrollers don’t have a hardware module which supports it. This interface has some advantages which make it popular, though. The most obvious is the low number of pins: except for power, it uses just one microcontroller pin. You can also connect several devices in parallel like an I2C bus. And furthermore, the 1-wire bus supports so called “parasitic power”, which means that devices can use the DQ pin both for powering and for sending the data, thus they truly only need just two wires to connect.

Like the I2C bus, 1-wire also has the Master-Slave architecture, which means that all communications are inspired by the Master device while Slave devices just follow its commands. As I said before, there can be several Slave devices connected in parallel. Each 1-wire device has a unique 64-bit address programmed at the factory, so there is no chance that you connect two devices with the same address to the same bus. The schematic diagram of the connection between the Master and the Slave via 1-wire bus is shown in fig. 3, again taken from the data sheet.

Figure 3 - 1-Wire Bus Connection
Figure 3 - 1-Wire Bus Connection

As you can see, the connection is similar to the I2C bus. Here we also have the pull-up resistor which should be 4.7 kOhm. The devices also have an open-collector output, and that means we will also need to reconfigure the microcontroller’s pin as an input (to send 1 to the bus) or as an output (to send 0 to the bus).

Let’s now see how to communicate using 1-wire. Like an I2C bus, 1-wire also has a special condition which always should be issued to start the communication. This condition is called Reset (fig. 4, taken from the data sheet).

Figure 4 - Reset Condition
Figure 4 - Reset Condition

Unlike an I2C bus, 1-wire doesn’t have the clock pulses, so to synchronize the Master and the Slave devices, they both need to strictly follow the specified timings.

To start the Reset, the Master pulls the bus down for at least 480 us. Then it releases the bus and waits for 15-60 us. If any device is present on the bus, it will respond to this reset pulse by pulling the bus down for 60-240 us. So after sending the Reset pulse, the Master device should wait for about 60 us and then read the bus state. If it’s 0, that means that the Slave device is present, and ready for the communication. If the bus state is 1, then there are no Slave devices, or they are disabled. After the Master reads the bus state it should wait for about 410 us to make sure that the Slave has released the bus.

After the start condition the Master sends the data. Usually the first byte is the command (we already mentioned them and will consider them in greater detail later). Sending a 1 or 0 to the bus also requires strict timings (fig. 5, taken from the data sheet).

Figure 5 - Sending ‘0’ and ‘1’ to Via the 1-wire Bus
Figure 5 - Sending ‘0’ and ‘1’ to Via the 1-wire Bus

To write 0 to the bus, the Master pulls the line down for at least 60 us, then it releases the bus for at least 1us (10-15 us is OK). To write 1 to the bus, the Master pulls the line down for at least 1 us (from my experience 8-10 us is OK as well) and then releases the bus for at least 60 us.

Finally, let’s see how reading from the bus is implemented (fig. 6, taken from the data sheet).

Figure 6 - Reading from the 1-wire Bus
Figure 6 - Reading from the 1-wire Bus

The Master device should set the bus low for more than 1 us (3-5 us is OK) and then release it. Then the Slave should either pull the bus low if it transmits 0, or leave the bus high if it transmits 1. The Master waits for about 15 us and then reads the bus state and saves it. Finally, the Master should wait for at least 45 us to make sure that the Slave has released the bus, and then it can start the new time slot to read the next bit.

That’s all about the 1-wire bus signaling. Now let’s briefly consider the logic level of the interface.

After sending the Reset pulse and receiving the presence confirmation from the Slave, Master should send one of the so-called ROM commands. These commands are used to select one of the devices that are present on the bus, using their unique 64-bit address. I will not describe them here, you can always refer to the data sheet. The only command that we will use is called Skip ROM and has the code 0xCC. This command is used to send the data to all the devices that are present on a bus, and as long as we have only one device, we can use it.

After sending the ROM command, the Master issues one of the device-specific commands. In our program we will use two of the commands specific for the DS18B20 sensor: Convert T (0x44) and Read Scratchpad (0xBE). The first one is used to start the temperature measurement and conversion. This process can take up to 750 ms. So it’s better to wait for about 1 second before reading the value. The second command is used to read the so-called Scratchpad. It consists of nine bytes, and includes the temperature value, the threshold values, the configuration register and the CRC byte. We will read just the two first bytes which represent the lower and the upper byte of the temperature correspondingly.

Speaking of the temperature, it’s stored in the format shown in fig. 7, taken from the data sheet.

Figure 7 - Temperature Value Format
Figure 7 - Temperature Value Format

As you see, the upper 5 bits represent the sign of the temperature (0 for positive, 1 for negative), the rest 11 bits represent the temperature value with the resolution of 2-4 C, or 0.0625 C.

And that’s all we need to know about the DS18B20 sensor, so now we can switch to the schematics diagram of the thermometer.

Schematics Diagram

The schematics diagram is shown in fig. 8.

Figure 8 - Schematics Diagram with the PIC18F14K50 with DS18B20 Sensor and 7-segment LED Indicator
Figure 8 - Schematics Diagram with the PIC18F14K50 with DS18B20 Sensor and 7-segment LED Indicator

This schematics diagram is very similar to the one presented in tutorial 5: it also consists of the PIC18F14K50 MCU (DD1), 7-segment 3-digits LED indicator (7Seg1), but the connection of the digits common pins of the indicator to the MCU is a bit different. Now we will use the RB4-RB6 pins. I made this to release the RB7 pin which is easier to configure as both input and output. The DS18B20 temperature sensor (DD2) is connected to this RB7 pin for this reason. Also, it uses the external pull-up resistor R9 of 4.7kOhm which is required for normal sensor operation (see fig. 3). R1 - R8 resistors limit the current that flows through the LEDs of the indicator. As in tutorial 6, I used the common anode indicator, so if you have the common cathode one, please refer to that tutorial to check for the differences in the program code.

In this tutorial we will not learn any new MCU modules, we will use just GPIO and oscillator modules, so we can proceed to the program code consideration.

Program Code Description

#define _XTAL_FREQ 16000000 //CPU clock frequency

#include <xc.h> //Include general header file

#define A 0b00001000 //Segment A is connected to RC3

#define B 0b10000000 //Segment B is connected to RC7

#define C 0b00000010 //Segment C is connected to RC1

#define D 0b00010000 //Segment D is connected to RC4

#define E 0b00100000 //Segment E is connected to RC5

#define F 0b01000000 //Segment F is connected to RC6

#define G 0b00000100 //Segment G is connected to RC2

#define DP 0b00000001 //Segment DP is connected to RC0

const uint8_t digit_gen[11] = //Digits generator

{

~(A+B+C+D+E+F), //0

~(B+C), //1

~(A+B+G+E+D), //2

~(A+B+C+D+G), //3

~(F+G+B+C), //4

~(A+F+G+C+D), //5

~(A+F+E+D+C+G), //6

~(A+B+C), //7

~(A+B+C+D+E+F+G), //8

~(D+C+B+A+F+G), //9

~(G) //-

};

uint8_t digit; //Digit number that is currently displayed

int16_t temp; //Temperature received from the DS18B20 sensor

uint8_t data[2]; //Array to read two bytes from the scratchpad

uint32_t tick; //5 ms counter

//=============1-wire functions======================

//---------1-wire bus reset-------------------------------

uint8_t ow_reset(void)

{

uint8_t presence; //Presence flag

TRISBbits.RB7 = 0; //Configure RB7 as output (set bus low)

__delay_us(480); //Set bus low for 480 us

TRISBbits.RB7 = 1; //Configure RB7 as input (set bus high)

__delay_us(70); //Waiting for the presence signal for 70us

if(PORTBbits.RB7 == 0) //If bus is low then the device is present

presence = 1; //Set presence flag high

else //Otherwise

presence = 0; //Set presence flag low

__delay_us(410); //And wait for 410 us

return (presence); //Return the presence flag

}

//---------Writing one bit to the device------------------------

void ow_write_bit (uint8_t bit)

{

TRISBbits.RB7 = 0; //Configure RB7 as output (set bus low)

if (bit) //If we send 1

{

__delay_us(6); //Perform 6 us delay

TRISBbits.RB7 = 1; //Configure RB7 as input (set bus high)

__delay_us(64); //Perform 64 us delay

}

else //If we send 0

{

__delay_us(60); //Perform 60 us delay

TRISBbits.RB7 = 1; //Configure RB7 as input (set bus high)

__delay_us(10); //Perform 10 us delay

}

}

//--------Reading one bit from the device-------------------------

uint8_t ow_read_bit (void)

{

uint8_t bit; //Value to be returned

TRISBbits.RB7 = 0; //Configure RB7 as output (set bus low)

__delay_us(6); //Perform 6 us delay

TRISBbits.RB7 = 1; //Configure RB7 as input (set bus high)

__delay_us(9); //Perform 9 us delay

bit = PORTBbits.RB7;//Save the state of the bus in the bit variable

__delay_us(55); //Perform 55 us delay

return (bit);

}

//---------Writing one byte to the device------------------------

void ow_write_byte (uint8_t byte)

{

for (uint8_t i = 0; i < 8; i++) //Loop to transmit all bits LSB first

{

ow_write_bit (byte & 0x01);//Send the bit

byte >>= 1; //Shift the byte at one bit to the right

}

}

//---------Reading one byte from the device-----------------------

uint8_t ow_read_byte (void)

{

uint8_t byte = 0; //Value to be returned

for (uint8_t i = 0; i< 8; i++)//Loop to read the whole byte

{

byte >>= 1; //Shift the byte at one bit to the right

if (ow_read_bit()) //If 1 has been read

byte |= 0x80; //Then add it to the byte

}

return (byte);

}

//============DS18B20 sensor functions=======================

//-----------Command to start the temperature conversion-----------

void convert_t (void)

{

if (ow_reset()) //If sensor is detected

{

ow_write_byte(0xCC);//Issue "Skip ROM" command

ow_write_byte(0x44);//Issue "Convert T" command

}

}

//---Reading and calculating the temperature from the ds18s20 sensor-------

int16_t read_temp (void)

{

int16_t tmp; //Calculated temperature

if (ow_reset()) //If sensor is detected

{

ow_write_byte (0xCC);//Issue "Skip ROM" command

ow_write_byte (0xBE);//Issue "Read Scratchpad" command

for (uint8_t i = 0; i < 2 ; i++)//Loop for reading 2 bytes from scratchpad

data[i] = ow_read_byte();//Save the received byte into the data array

tmp = data[0] + (data[1] << 8);//Calculating the temperature

tmp = (tmp * 10) / 16;//Convert positive temperature into 0.1 C

}

else //If sensor was not detected

tmp = -990; //Then return the value that sensor can't have

return (tmp); //And return the temperature value

}

//============Main function of the program=======================

void main(void) //Main function of the program

{

TRISC = 0; //Configure RC port as output

TRISB = 0x80; //Configure RB port as output, but RB7 is input

LATC = 0xFF; //Set all RC pins high

OSCCONbits.IRCF = 7; //Set CPU frequency as 16 MHz

while (1) //Main loop of the program

{

LATB = 0; //Set all RB pins low to shutdown all the segments

switch (digit) //Which digit to display

{

case 0: //If digit 1

if (temp >= 0) //If temperature is positive

LATC = digit_gen[temp / 100]; //Display the tens

else //If temperature is negative

LATC = digit_gen[10]; //Display "-"

LATBbits.LB4 = 1; //Turn on RB4 to which digit1 is connected

break;

case 1: //If digit 2

if (temp >= 0) //If temperature is positive

LATC = digit_gen[(temp % 100) / 10] & (~DP);//Display the ones

else //If temperature is negative

LATC = digit_gen[(-temp) / 100]; //Display the tens

LATBbits.LB5 = 1; //Turn on RB5 to which digit2 is connected

break;

case 2: //If digit 3

if (temp >= 0) //If temperature is positive

LATC = digit_gen[temp % 10]; //Display the fractional part

else //If temperature is negative

LATC = digit_gen[((-temp) % 100) / 10]; //Display the ones

LATBbits.LB6 = 1; //Turn on RB6 to which digit2 is connected

break;

}

__delay_ms(5); //The delay to let the segment be on for some time

digit ++; //Select next digit

if (digit > 2) //If digit number is higher than 2

digit = 0; //Then set the digit as 0

if ((tick % 200) == 0)//When modulo of tick/200 is 0

convert_t(); //Start temperature conversion

if ((tick % 200) == 180)//When modulo of tick/200 is 180

temp = read_temp();//Read the temperature value

tick ++; //Increase the tick value

}

}

The program is quite long now but actually it’s not very complex. Let’s consider it in more detail.

In line 1, we define the _XTAL_FREQ macro as 16000000 because we will overclock the CPU up to 16 MHz. Actually, we don’t need to do this and the majority of the timings would still be within the required limits but if we can set the exact timings, why not?

In lines 5-12, we define the bits of the LED indicator segments, and in lines 14-27 we define the digits generator array digit_gen. This part was described in great detail in tutorial 6. The only new thing is line 26. In it we define the generation of the minus sign, and its array index is 10, so if we call digit_gen(10), it will display minus in the corresponding position.

In lines 29-32, we define the required variables. In line 29 there is the digit variable which represents the number of the LED indicator digit that is currently on. In line 30 we define the temp variable, which is the temperature value in Celsius degrees multiplied by 10. The multiplication by 10 is required because we want to have the temperature with the accuracy of 0.1 degree but don’t want to use the floating point format because operations with it aren’t supported naturally by the PIC18 MCUs and require a lot of memory and time.

The data array defined in line 31 is needed to read two bytes from the DS18B20 scratchpad in which the temperature value is stored.

In line 32, we define the tick variable which is the counter of 5ms intervals which are formed by the delay function in line 177.

In lines 34-103 there are functions that perform the software implementation of the 1-wire protocol, so all of them start with the prefix “ow” which stands for “one wire”

In line 38, we define the presence variable which is the value that the function will return in the end.

In line 39, we set the bus low by writing the 0 to the bit #7 of the TRISB register. We use this register instead of LATB because we need to emulate the open-drain output as I described in the comment of fig. 3. So, in 1-wire bus implementation we will always operate with the TRISB register not LATB.

Then we perform the 480us delay (line 40) as is required according to fig. 4. After that, we release the bus (set it high by configuring the RB7 pin as input) (line 41) and wait another 70us (line 42). After that, we check the bus state (line 43) by reading bit #7 of the PORTB register. If a device is present, it will set the bus low, otherwise the bus will remain high. According to this, we set the presence value: if RB7 is low, we set it high (line 44) and vice versa (line 46). Then we perform another 410 us delay to make sure that the presence pulse is finished (line 47), and finally return the presence value (line 48) and end the function.

In lines 52-67, there is the ow_write_bit function which accepts a single parameter bit of uint8_t type. This parameter can be either 0 or 1 and represents the bit value we want to send to the bus. Sending the bit is implemented according to fig. 5. First we need to set the bus low (line 54). Then depending on the bit value (line 55) we need to perform the delay: either 6us (line 57) if bit is high or 60us (line 63) otherwise. Then we release the bus (lines 58 or 64) and perform another delay of 64us for “1” (line 59) or 10us for “0” (line 65) to finish the timeslot. And that’s all we need to send the bit.

In lines 70-80 there is the ow_read_bit function which reads one bit from the 1-wire bus and returns it as a function value. First we define the variable bit of type uint8_t that represents the value read from the bus. Reading of the bit is implemented according to fig. 6. As usual we first need to set the bus low (line 73). Then we perform the delay of 6us (line 74) and release the bus (line 75). After that, we implement another 9us delay (line 76) to make the overall delay from the pulse start 15us, and save the bus state, set by the DS18B20 sensor, into the bit variable (line 77). Finally, we perform another 55us delay to finish the timeslot (line 78) and return the bit value (line 79).

In lines 83-90 there is the ow_write_byte function. It accepts one parameter byte of uint8_t type which represents the byte that should be sent to the device. This function is quite simple. First, we make the “for” loop to transmit 8 bits of the byte parameter (line 85). In line 87 there is the ow_write_bit function invocation with the parameter “byte & 0x01”. Let’s consider it in more detail. In a 1-wire bus the data is transmitted LSB first. In the expression “byte & 0x01” we perform the bitwise AND between the byte value and the 0x01 constant. The result of this operation will be 1 only if the LSB of the byte is 1, otherwise it will be 0. So by using this expression we extract the LSB of the byte and send it to the bus. Then in line 88 we shift the byte at one bit to the right, so in the next iteration the second bit will be checked, then the third and so on.

In lines 93-103, there is the ow_read_byte function which reads the whole byte from the device and returns it. In line 95, we declare the variable byte and assign 0 to it. We will collect the received from the device bits in this variable to return it at the end of the function. In line 96, we make the “for” loop to receive 8 bits from the device. First, in line 98 we shift the byte value to the right. Then we read one bit from the device and check its value (line 99). If the received bit is 1, we add it as the MSB to the byte variable by implementing the bitwise OR operation “|” between the byte and the constant 0x80 (line 100). If the received bit is 0 we don’t add anything because we already assigned 0 to all bits of the byte variable in line 95.

One may ask why we add the new bit as MSB if the data is transmitted LSB first. Well, shifting the value to the right at every loop iteration will move the first received bit further and further from the MSB, and finally, after eight iterations it will become the LSB, so eventually everything is correct. In line 102 we return the received byte value and exit the function.

That’s all about the common 1-wire functions. Next, in lines 107-140 there are DS18B20-specific functions. The first one is called convert_t and is located at lines 107-114.

As I mentioned above, to start the temperature conversion, we need to send the special command “Convert T” which has the code 0x44 (line 112). But first we need to send the “Skip ROM” 0xCC command (line 111) to skip checking the device address. As long as we have only one device on the bus, such an approach is acceptable. All the commands should have a reset pulse header which we send in line 109 by invoking the function ow_reset. Moreover, this function checks if the device is present on the bus, so if there is no presence pulse followed by the reset condition, then lines 111-112 will be ignored.

And that’s all about the convert_t function. The next one, read_temp located at lines 117-132, is a bit more complicated.

First, we declare the variable tmp which will represent the calculated value of the temperature in 0.1 C. In other words, this is the temperature value, multiplied by 10. In line 120 we issue the reset condition on the 1-wire bus and check if the device is present. If it is, we call the “Skip ROM” 0xCC command (line 122), followed by the “Read Scratchpad” 0xBE command (line 123). After the last command, the device will be ready to send the 9 bytes from the scratchpad, from which we actually need only 2, so we will read only them. In line 124 we start the loop to read two bytes from the scratchpad into the data array (line 125). After that the data[0] and data[1] will have the lower and upper bytes of the temperature value, correspondingly. Now we need to merge them into one variable tmp, which we do in line 126. Shifting the data[1] value at 8 bits to the left will put it as the upper byte into the tmp variable. Now, the tmp value has the format shown in fig. 7. This format corresponds to the 2’s complement format normally used in the C language, so both positive and negative values will be recognized by the compiler correctly. In line 127 we multiply the value by 10 to get the temperature in 0.1 C, and then divide the value by 16 which actually means shifting the tmp value four (log216 = 4 or 24 = 16 if your logarithms are rusty) bits to the right. This moves all the 2’s complement with the negative degree (fig. 7) to the right side of the imaginary floating point (it wouldn’t be imaginary if we didn’t multiply the value by 10). The order of the mathematical operations is also important. We need to perform the multiplication prior to the division, otherwise we will lose the accuracy, and there will not be any sense in the x10 multiplication as the fractional part will always be 0.

If the sensor is not present on the bus (line 129), we assign the value -990 to the tmp variable. The sensor has the minimum temperature value -55 degrees, so the value -990 is not reachable by the sensor and can serve as an indicator of sensor absence. Finally, we return the tmp value and end the function (line 131).

Now all the auxiliary functions have been described, and we can switch to the main function of the program (lines 135-178). I will skip the explanation of the initialization part as it is the same as in tutorial 6. The only thing added is line 140 where we set the CPU frequency as 16 MHz (please refer to tutorial 10 for more information about the oscillation module).

The main loop is also very similar to the one in tutorial 6. We also turn off all the segments at the beginning (line 143), then check which digit is needed to be turned on in the switch construction (lines 144-167). But there is some difference inside the cases which we need to consider in more detail. So, according to the task we need to display data in different formats for positive (xx.x) and negative (-xx) temperatures. Thus, in each case part we check if the temp value is positive (lines 147, 154, 161). If it is, we display the hundreds of the temp (as you remember the temperature is multiplied by 10, so the hundreds of the temp correspond to the tens of the temperature value, tens of the temp correspond to the ones of temperature, and ones of the temp correspond to the fractional part of the temperature) in the first position (line 148), tens of the temp in the second position (line 155), and ones of the temp in the third position (line 162).

There is one interesting thing in line 155 which requires special consideration. According to the task we need to add the decimal point after the second position. So we implement the bitwise AND operation between the digit_gen[(temp % 100) / 10] and the (~DP) value. This will set the DP pin level low and thus turn it on. If you use the common cathode indicator, this line will look bit simpler:

LATC = digit_gen[(temp % 100) / 10] + DP;

If the temperature is negative, the displayed value should be a bit different. We display the minus sign at the first position by writing LATC = digit_gen[10] in line 150, then we display the hundreds of the negated temp (to make it positive) at the second position (line 157) by writing LATC = digit_gen[(-temp) / 100], and finally we display the tens of the negated temp at the third position (line 164) by writing LATC = digit_gen[((-temp) % 100) / 10].

After setting the correct digits shapes at the required positions, we turn on the corresponding digits in lines 151, 158, 165. Please pay attention as the pin numbers differ from tutorial 6, as we connected the common indicator pins to other MCU pins.

After turning on the segment we perform the 5ms delay (line 168), and select the next digit to be displayed (lines 169-171).

In line 172 we check if the modulo of division of the tick value by 200 is 0. This event becomes true every second because tick increments every 5ms (line 176), so when it becomes 200, the time passed is 200x5=1000ms. So if this condition is true we send the “Convert T” command to the device (line 173) to start the temperature conversion.

Then in line 174 we check if the module of division of the tick value by 200 is 180. This condition also becomes true every second but it is shifted to the “Convert T” sending condition by 180 ticks, which is 180x5=900ms. This is enough to calculate the temperature even with the highest resolution of 12 bit. So if this condition is true, we read the temperature value (line 175) to display it in the LED indicator.

In line 176, as I mentioned before, we increment the tick value every loop iteration.

And that’s all about the program code. Now you can assemble the circuit according to the schematics diagram (fig. 8), compile and download the code, and see how your thermometer works. Don’t forget that I skipped the configuration bits when I wrote the program code but you still need to add them to make the device work correctly. I talked about the configuration bits in detail in tutorial 2.

Also I want to mention that you are free to use the compiler optimisation. I talked about how to set it up in tutorial 10. The optimization allows to significantly reduce the code size. For instance when the optimization level is “0”, the size of the current program is 1640 bytes, and with the optimization level “s” the size reduces to 1272 bytes, thus the code becomes 22% more compact.

As homework, I suggest you convert the temperature into Fahrenheit and display it in these units. In the C language the mathematical operations are much more simple than in Assembly so you won’t have problems with this.

Make Bread with our CircuitBread Toaster!

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

What are you looking for?