Table of Contents
PIC18: 7-segment displays
In fact they are quite simple, because they consist of 8 leds (decimal point included) connected in parallel and with the cathode (or anode) in common; by turning on specific leds ('segments') a number or letter is displayed.
Here we see the two types of displays:
When used with PICs the different anodes of a single 7-segment display are connected to different PORTx pins and the common cathodes (from now on we will talk about common cathode displays only) are grounded:
By making certain PORTD pins high the corrispondent display segments are lit and numbers or letters appear; for instance to display '0' the correct sequence is 'abcdef', and the value b'00111111' must be sent to PORTD. Here is the complete table for all values from 0 to 9:
In case we have multiple displays PORT lines should be doubled or multiplied; even if a single PIC could have that many pins a better approach is to use a trick called strobing or multiplexing, by which the same PIC pins are connected to the same segment on all digits and other PIC pins, which drive the base of two or more transistors, turn on each digit just for a fraction of time, by sinking current.
The common cathodes cannot be directly connected to RE0:RE2 to sink current, because a single PIC port can only sink a maximum of 25mA; with 330ohm resistors and considering the voltage drop of a LED, the current that passes through a single segment is 10mA, so even with only 3 segments lit (for instance to display the digit '7') the risk of damaging ports and the PIC itself is high. This is the reason why transistors have been used in this case: a small base current controls the larger collector current.
Let's say we want to display the number 248. We want to display the units and so we copy the binary value '01111111' to PORTD; then we set PORTE,0 high to display the sequence for 8 on the rightmost digit for a bunch of microseconds; the other displays stay off, because there is no base current on the other transistors. Then, to display the tenths, after a few microseconds we:
- clear PORTE,0 and PORTE,2
- output '01100110' (binary value to display 4) to PORTD
- set PORTE,1
The same process will be followed for the hundreds digit. Just to give an idea, if we keep the delay between a digit and another artificially high, this is what we get:
The smaller we keep the delay, the faster the switch between the digits will be:
If the delay is small enough, the human eye won't notice the switch and all digits will appear on at the same time:
Enough with the theory for now: let's display some real values on the digits!
Assembly code Main routine
For this example we are going to use three digits as the image below, which will display the value of the A/D conversion from the analog trimmer of Freedom II. The main routine will be very light because it will only wait for the following interrupts to occur:
- A/D conversion interrupt from trimmer
- TMR0 overflow interrupt
The first one intercepts the operation of the trimmer and, if activated, calls the appropriate sub to do the A/D conversion.
The purpose of the second interrupt routine is to decide which digit has to be turned on; when the timer overflows 1 is left shifted in DIGITS register, from bit 0 to bit 2. What happens next is, if for instance DIGITS,0 is set:
- only PORTE,0 is set high, which lets pass current to the base of the transistor of the units digit (the rightmost one), in order to activate it
- the value of units is transformed in the proper way to display them, as in the table above
- PORTD is loaded with this value
- only the units digit displays it, the others not being activated by the saturation of the corresponding transistor
As soon as TMR0 overflows, DIGITS file register is shifted left and now DIGITS,1 is set, which sets high PORTE,1 and saturates the tenths digit's transistor; tenth's value is loaded in PORTD and displayed only on the corresponding digit. When TMR0 overflows once more it's the turn of the hundreds to be displayed; the fourth overflow re-sets DIGITS back to bit 0.
Now that the overall behaviour of the program should be clear, let's dig a bit deeper into the assembly code.
From bytes to units, tenths and hundreds
When an A/D conversion occurs, the resulting byte value is passed to the sub Byte2Digits; the purpose of this sub is to split a byte into its units, tenths and hundreds. It first obtains the hundreds:
LoopHundreds subwf number,F ; number = number - 100 bnc GetTenths ; branch if not carry (that is borrow) incf hundreds,F ; get hundreds digit goto LoopHundreds
In the first instruction .100 is subtracted from _number_; if there is no borrow (that is Carry=1 - see branch instructions), that means that number is higher than 100, and so hundreds is incremented by one and the program goes back to LoopHundreds. If instead there is a borrow (Carry=0) the remaining value is lower than 100, we have found the value for hundreds and we can go on calculating the tenths. Once tenths are obtained, in much the same way as in hundreds, the remaining value represents units. Those single values can now be displayed onto the corresponding digits.
In order to display a digit, 8 units for instance, its binary value b'00001000' loaded into PORTD doesn't make sense: b'01111111' should be loaded instead; the same goes for the other values: there should be a mechanism to establish a one-to-one correspondence between each real value, from '0' to '9', and its _display_ value. A way of doing this is by using look-up tables; the real value is added to the Program Counter (PC), which jumps to a location which, in turn, returns the literal value of the display value. Like this:
(movf realValue,W) movwf PCL retlw b'00111111' ; value for digit '0' retlw b'00000110' ; value for digit '1' retlw b'01011011' ; value for digit '2' retlw b'01001111' ; value for digit '3' retlw b'01100110' ; value for digit '4' retlw b'01101101' ; value for digit '5' retlw b'01111101' ; value for digit '6' retlw b'00000111' ; value for digit '7' retlw b'01111111' ; value for digit '8' retlw b'01101111' ; value for digit '9'
Here the real value is copied to W and then added to Program Counter Low to get the display value returned; cool, isn't it? Well, things are not so simple…
As we have seen in a previous article, istructions takes two bytes (a word); for instance that's how a listing file (.lst) for the look-up table code could be:
Address Value Disassembly Source ------- ----- ----------- ------ ... 000004 0c3f retlw 0x3f retlw b'00111111' ; value for digit '0' 000006 0c06 retlw 0x6 retlw b'00000110' ; value for digit '1' 000008 0c5b retlw 0x5b retlw b'01011011' ; value for digit '2' 00000a 0c4f retlw 0x4f retlw b'01001111' ; value for digit '3' 00000c 0c66 retlw 0x66 retlw b'01100110' ; value for digit '4' 00000e 0c6d retlw 0x6d retlw b'01101101' ; value for digit '5' 000010 0c7d retlw 0x7d retlw b'01111101' ; value for digit '6' 000012 0c07 retlw 0x7 retlw b'00000111' ; value for digit '7' 000014 0c7f retlw 0x7f retlw b'01111111' ; value for digit '8' 000016 0c6f retlw 0x6f retlw b'01101111' ; value for digit '9' ...
If 8 (the real value) is added to PCL, this last one become address 0x00000c (0x000004 + 0x8) and the displayed value is '4', not '8'! We need first to double WREG in order to take care of the double byte words that program memory is made of; so the last operation will be 0x000004 + 0xf = 0x000014 and '8' is aptly displayed. The code:
addwf WREG,W ; W+W = 2W (16bit program words); offset x2 movwf PCL retlw b'00111111' ; value for digit '0' ...
That one above works…if we are lucky! There's one important piece missing: we only take care of PCL, Program Counter Low (the first 8 address bits), what about the other bits of PC?
PCLATH and PCLATU
When we have to change PC it's important that all 21 bits be modified simultaneously; we can modify only PCL, which is a SFR which reads and writes to the lower bits of PC, but there aren't SFRs to direcly change the other 13 bits of PC. Instead there are two registers, _PCLATH_ and _PCLATU_, whose contents are written synchronously to their corresponding PC sectors whenever PCL is written to (such as in _movwf PCL_):
In the above way all 21 bits of PC are modified at once; so it's important, before writing to PCL, to have PCLATH and PCLATU ready with the correct values; secondly the code should be robust enough to take into account possible carry-outs from the previous bytes (more on this later).
0000f6 24e8 addwf 0xe8, 0, 0 addwf WREG,W ; W+W = 2W (16bit program ; words) 0000f8 26f9 addwf 0xf9, 0x1, 0 addwf PCL,F ; program counter jumps ; to location based on the ; value of W reg and then ; returns with literal 0000fa 0c3f retlw 0x3f retlw b'00111111' ; value for digit '0' 0000fc 0c06 retlw 0x6 retlw b'00000110' ; value for digit '1' 0000fe 0c5b retlw 0x5b retlw b'01011011' ; value for digit '2' 000100 0c4f retlw 0x4f retlw b'01001111' ; value for digit '3' 000102 0c66 retlw 0x66 retlw b'01100110' ; value for digit '4' 000104 0c6d retlw 0x6d retlw b'01101101' ; value for digit '5' 000106 0c7d retlw 0x7d retlw b'01111101' ; value for digit '6' 000108 0c07 retlw 0x7 retlw b'00000111' ; value for digit '7' 00010a 0c7f retlw 0x7f retlw b'01111111' ; value for digit '8' 00010c 0c6f retlw 0x6f retlw b'01101111' ; value for digit '9' ...
In the above (not working) example PCL takes care of the lower bits (from 0xF6 to 0xFE and then 0x00,0x02 and so on), but PCLATH should handle the middle bits and PCLATU the leftmost higher bits; initially PCLATH should be 0x00, then it should change to 0x01.
_7SegDisplayTable andlw b'00001111' ; Mask out any erroneous upper nibble addwf WREG,W ; W+W = 2W (16bit program words); offset x2 addlw LOW Table_7 ; W = offset x2 + LOW PC [7:0] movwf TableTemp ; Store away LOW PC; PCL is ready, now take care of PCLATH and PCLATU movlw HIGH Table_7 ; W = HIGH PC [15:8] movwf PCLATH ; copy W to PCLATH clrf WREG ; W = 0 addwfc PCLATH,F ; PCLATH = 0 + any carry from previous 'addlw LOW Table_7' ; (clrf and mov* don't touch C flag) movlw UPPER Table_7 ; W = UPPER PC [20:16] movwf PCLATU ; copy W to PCLATU clrf WREG ; W = 0 addwfc PCLATU,F ; PCLATU = 0 + any carry from previous 'addwfc PCLATH,F' ; (clrf and mov* don't touch C flag) movf TableTemp,W ; Restore LOW PC (TableTemp) together with the correct values of ; PCLATH and PCLATU movwf PCL ; "The contents of PCLATH and PCLATU are transferred to the program ; counter by any operation that writes PCL." Table_7 retlw b'00111111' ; value for digit '0' retlw b'00000110' ; value for digit '1' retlw b'01011011' ; value for digit '2' retlw b'01001111' ; value for digit '3' retlw b'01100110' ; value for digit '4' retlw b'01101101' ; value for digit '5' retlw b'01111101' ; value for digit '6' retlw b'00000111' ; value for digit '7' retlw b'01111111' ; value for digit '8' retlw b'01101111' ; value for digit '9'
So, here is what we call a robust code! Let's see the relevant lines in depth.
Low, High and Upper
First of all W is masked, in order to avoid spurious values higher than 0xFF:
Then we have WREG doubled and we have it added to the low byte of the address located at the label Table_7, which marks the beginning of the look-up table:
addlw LOW Table_7
PCL, PCLATH and PCLATU represent different bytes of a 21-bit address; a way to split the address into those parts is by using the operands LOW, HIGH and UPPER. Here's a definition taken from MPASM Assembler User's Guide:
Let's see this thing in practice; suppose we want to display '8' and we got the look-up table saved in the same program locations as above, that is:
0x0000fa ... retlw b'00111111' ; value for digit '0' ... 0x00010a ... retlw b'01111111' ; value for digit '8' 0x00010c ... retlw b'01101111' ; value for digit '9'
So first we have 8 doubled, that is 0x10; we add this value to 0x0000fa and we save the result into TableTemp:
0x fa + 0x 10 = --------- 0x(1)0a --> PCL |------> Carry
But since TableTemp, as all data locations, is an 8-byte value, only 0x0a is saved into it and a Carry is originated; the first value will be our PCL.
Let's deal with PCLATH now and so let's consider the [15-8] bytes of the address:
movlw HIGH Table_7 movwf PCLATH
Hey, but wait! We got a Carry from our previous sum (addlw LOW Table_7), so let's add it to PCLATH; as written in the comments CLRF and MOV* operations in between don't touch C flag. The instruction _addwfc_ is just what we need for this purpose (PCLATH + Carry); we just have to clear WREG, which in this case is not really needed:
clrf WREG addwfc PCLATH,F
Here's the sum of PCLATH and Carry:
0x00 + 0x01 = ------- 0x01 --> PCLATH
The same process is used to get PCLATU, but in this case it's just 0x0; as soon as PCL is written to (movwf PCL) at the same time PCLATH and PCLATU are transferred to their corrisponing positions in the 21-bit address (see image above), that is 0x00010a which is indeed the value for digit '8'!
And that pretty much completes the description of the code, which can be downloaded in the .zip file below.
Here's we can watch the final result:
“The Essential PIC18 Microcontroller” by Sid Katzen