PIC18: 7-segment displays

7seg-3d 7-segment displays are an effective and still widely used way of displaying alphanumeric data.

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:

7 seg led layout

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:

7 seg PIC

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:

display sequences

Multiplexing

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.

three 7 seg PIC

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:

2-4-8 animation slow

The smaller we keep the delay, the faster the switch between the digits will be:

2-4-8 animation fast

If the delay is small enough, the human eye won't notice the switch and all digits will appear on at the same time:

2-4-8 very fast

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 approriate 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 bit0 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 https://www.t3ch.it/pic18-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.

Look-up tables

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 corrispondence 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 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...

WREG doubled

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):

pclpclath pclatu

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:

andlw b'00001111'

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:

low high upper

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:

Reference

"The Essential PIC18 Microcontroller" by Sid Katzen

Add new comment

Filtered HTML

  • Quick Tips:
    • Two or more spaces at a line's end = Line break
    • Double returns = Paragraph
    • *Single asterisks* or _single underscores_ = Emphasis
    • **Double** or __double__ = Strong
    • This is [a link](http://the.link.example.com "The optional title text")
    For complete details on the Markdown syntax, see the Markdown documentation and Markdown Extra documentation for tables, footnotes, and more.
  • Allowed HTML tags: <a> <em> <strong> <cite> <blockquote> <code> <ul> <ol> <li> <dl> <dt> <dd> <img> <p> <h1> <h2> <h3> <h4> <h5> <div> <pre> <object>
  • You may insert videos with [video:URL]
  • Web page addresses and e-mail addresses turn into links automatically.

Plain text

  • No HTML tags allowed.
  • Web page addresses and e-mail addresses turn into links automatically.
  • Lines and paragraphs break automatically.
By submitting this form, you accept the Mollom privacy policy.