Table of Contents

PIC18: 7-segment displays

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:

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:

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.

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:

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:

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:

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.

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 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…

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

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:

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