Professional Documents
Culture Documents
au
Up until now, we have only used a small number of individual LEDs as outputs. LEDs are adequate as
simple indicators, but many applications need to be able to display information in numeric, alphanumeric or
graphical form. Although LCD and OLED displays are becoming more common, there is still a place, when
displaying numeric (or sometimes hexadecimal) information, for 7-segment LED displays.
This lesson shows how lookup tables can be used to translate a numeric digit into the specific pattern of
segments needed to display that digit. And we’ll see how multiplexing makes it possible for a device with
only 14-pins, such as the PIC16F1824, to drive multiple 7-segment displays.
In summary, this lesson covers:
Using lookup tables to drive a single 7-segment display
Using multiplexing to drive multiple displays
with examples implemented using XC8 (running in “Free mode”).
7-segment LED display modules typically come in one of two varieties: common-anode or common-cathode.
In a common-cathode module, the cathodes belonging to each segment are wired together within the module,
and brought out through one or two (or sometimes more) pins. The anodes for each segment are brought out
separately, each to its own pin. Typically, each segment would be connected to a separate output pin on the
PIC, as shown in the circuit diagram on the next page1.
The common cathode pins are connected together and grounded.
1
The segment anodes are connected to PIC pins in the (apparently) haphazard way shown, because this reflects the
connections on the Gooligum training board. You’ll often find that, by rearranging your PIC pin assignments, you can
simplify your PCB layout and routing – even if it makes your schematic messier!
Although a single pin can source or sink up to 25 mA, the combined maximum for all the pins on the
16F1824 (industrial version) is 170 mA and since all segments may be lit at once (when displaying ‘8’), we
need to limit the current per pin to 170 mA ÷ 7 = 24 mA. The 330 Ω resistors limit the current to 10 mA,
well within spec while giving a bright display, and allowing plenty of overhead for adding LEDs or other
outputs on the unused pins, in future.
Lookup tables
To display each digit, a corresponding pattern of segments must be lit, as follows:
Segment: A B C D E F G
Pin: RC1 RC2 RC4 RC3 RA4 RA1 RA0
0 on on on on on on off
1 off on on off off off off
2 on on off on on off on
3 on on on on off off on
4 off on on off off on on
5 on off on on off on on
6 on off on on on on on
7 on on on off off off off
8 on on on on on on on
9 on on on on off on on
We need a way to determine the pattern corresponding to the digit to be displayed, and that is most
effectively done using a lookup table.
The problem with such a declaration for XC8 is that the compiler has no way to know whether the array
contents will change, so it is forced to place such an array in data memory (which even in large 8-bit PICs is
a very limited resource) and add code to initialise the array on program start-up – wasteful of both data and
program space.
If instead the array is declared as ‘const’, the compiler knows that the contents of the array will never
change, and so can be placed in ROM (program memory).
For example, to store the binary patterns to be applied to PORTA and PORTC, corresponding to each digit,
we could use the following array declarations:
// pattern table for 7 segment display on port A
const uint8_t pat7segA[10] = {
// RA4 = E, RA1:0 = FG
0b010010, // 0
0b000000, // 1
0b010001, // 2
0b000001, // 3
0b000011, // 4
0b000011, // 5
0b010011, // 6
0b000000, // 7
0b010011, // 8
0b000011 // 9
};
Looking up the display patterns is easy; the digit to be displayed is used as the array index.
To set the port pins for a given digit, we then have:
LATA = pat7segA[digit]; // lookup port A and C patterns
LATC = pat7segC[digit];
Alternatively, we could use a single lookup table with patterns specifying all seven segments of the display,
and to then extract the parts of each pattern corresponding to various pins.
For example:
// pattern table for 7 segment display on ports A and C
const uint8_t pat7seg[10] = {
// RC4:1,RA4,RA1:0 = CDBAEFG
0b1111110, // 0
0b1010000, // 1
0b0111101, // 2
0b1111001, // 3
0b1010011, // 4
0b1101011, // 5
0b1101111, // 6
0b1011000, // 7
0b1111111, // 8
0b1111011 // 9
};
Bits 6:3 of each pattern provide the PORTC bits 4:1, so to get the value for PORTC, shift the pattern two
bits to the right, and mask off bit 0:
LATC = (pat7seg[digit] >> 2) & 0b011110;
Pattern bits 1:0 give the values of PORTA bits 1:0 (RA1 and RA0). We don’t need to do any shifting; the
bit positions already align, so to extract these bits, we can simply AND them with a mask:
(pat7seg[digit] & 0b00000011)
Finally, we need to OR these two expressions together, to build the value to load into PORTA:
LATA = (pat7seg[digit] & 1<<2) << 2 |
(pat7seg[digit] & 0b00000011);
Whether you would choose to do this in practice (it seems quite clumsy here) is partly a matter of personal
style, and also a question of whether the space savings, from using only one pattern array, are worth it; we’ll
compare the program space used by both approaches after the following example.
If you are using the Gooligum training board, you can build the circuit by:
placing shunts (six of them) across every position in jumper block JP4, connecting segments A-D, F
and G to pins RA0-1 and RC1-4
placing a single shunt in position 1 (“RA/RB4”) of JP5, connecting segment E to pin RA4
placing a shunt across pins 1 and 2 (“GND”) of JP6, connecting digit 1 to ground.
All other shunts should be removed.
If you are using Microchip’s Low Pin Count Demo Board, you will have to supply your own 7-segment
display module, and connect it (and the current-limiting resistors) to the board. This can be done via the 14-
pin header on the Low Pin Count Demo Board.
Be careful, because your 7-segment display module may have a different pin-out to that shown above.
If you have a common-anode display, you will need to wire it correctly and make appropriate changes to the
code presented here, but the techniques for driving the display are essentially the same.
Complete program
The following program incorporates the code fragments presented above, along with code and techniques
from previous lessons:
/************************************************************************
* *
* Description: Lesson 10, example 1b *
* *
* Demonstrates use of lookup tables to drive a 7-segment display *
* *
* Single digit 7-segment display counts repeating 0 -> 9 *
* 1 second per count, with timing derived from int RC oscillator *
* (single pattern lookup array) *
* *
*************************************************************************
* *
* Pin assignments: *
* RA0-1,RA4, RC1-4 = 7-segment display bus (common cathode) *
* *
************************************************************************/
#include <xc.h>
#include <stdint.h>
0b1101011, // 5
0b1101111, // 6
0b1011000, // 7
0b1111111, // 8
0b1111011 // 9
};
// configure ports
TRISA = 0; // configure PORTA and PORTC as all outputs
TRISC = 0;
// configure oscillator
OSCCONbits.SCS1 = 1; // select internal clock
OSCCONbits.IRCF = 0b0111; // internal oscillator = 500 kHz
// delay 1 sec
__delay_ms(1000);
}
}
}
The following table compares the source code length and resource usage for the version of this example with
two lookup tables with direct port updates, versus the version (above) that used a single combined lookup
array with more complex pattern extraction for each port.
Count_7seg_x1
As you can see, the single combined lookup table version is shorter (fewer lines of source code) than the
table-per-port version. But even with only one table in memory, the XC8 compiler still generates larger code
than the two-table version – due to the instructions needed to extract the patterns from each array entry.
In this case, the added complexity of the code needed to extract bit patterns from a combined lookup array
isn’t worth it – the generated code becomes bigger overall, and the combined-table version is arguably harder
to understand. Nevertheless, if the lookup tables were much longer (say 50 entries instead of 10), it would
be a different story – the space saved by storing only a single table in memory would more than make up for
the extra instructions needed to decode it. Sometimes you simply need to try both ways, to see what’s best.
Interrupt-driven Multiplexing
To display multiple digits, as in (say) a digital clock, the obvious approach is to extend the method used
above for a single digit. That is, where one digit requires 7 outputs, two digits would apparently need 14
outputs; four digits would need 28 outputs, etc. At that rate, you would very quickly run out of output pins,
even on the bigger PICs!
A technique commonly used to conserve pins is to multiplex a number of displays (and/or inputs – a topic
we’ll look at another time).
Display multiplexing relies on speed, and human persistence of vision, to create an illusion that a number of
displays are on at once, whereas in fact they are being lit rapidly in sequence, so quickly that it appears that
they are all on continuously.
To multiplex 7-segment displays, it is usual to connect each display in parallel, so that one set of output pins
on the PIC drives every display at once, the connections between the modules and to the PIC forming a bus.
If the common cathodes were all grounded, every module would display the same digit (feebly, since the
output current would be shared between them).
To enable a different digit to be displayed on each module, the individual displays need to be switched on or
off under software control, and for that, transistors are usually used, as illustrated below:
Note that it is not possible to connect the common cathodes directly to the PIC’s outputs; the combined
current from all the segments in a module will be up to 70 mA – too high for a single pin to sink. Instead,
the output pin is used to switch a transistor on or off.
Almost any NPN transistor2 could be used for this, as is it not a demanding application. It’s also possible to
use FETs; for example, MOSFETs are usually used to switch high-power devices.
When the output pin is set ‘high’, the transistor’s base is pulled high, turning it ‘on’. The 1 kΩ resistors are
used to limit the base current to around 4.4 mA – enough to saturate the transistor, effectively grounding the
module’s common cathode connection, allowing the display connected to that transistor to light.
These transistors are then used to switch each module on, in sequence, for a short time, while the pattern for
that digit is output on the display bus. This is repeated for each digit in the display, quickly enough to avoid
visible flicker (preferably at least 70 times per second).
The approach taken in the single-digit example above – set the outputs and then delay for 1 sec – won’t
work, since the display multiplexing has to continue throughout the delay.
Ideally the display multiplexing would be a “background task”; one that continues steadily while the main
program is free to perform tasks such as responding to changing inputs. As we saw in lesson 5, that’s an
ideal application for timer-based interrupts.
A timer, such as Timer0, would be used to trigger a regular interrupt. The interrupt service routine displays
each digit, one at a time, in succession. If the interrupt is triggered at (say) 1 ms intervals, one digit would be
displayed for 1 ms, then the next digit for another 1 ms, and so on. If there are three digits, as in the circuit
above, each digit will be on for 1 ms, then off for 2 ms, with the whole display being refreshed every 3 ms.
But before going all the way to a three-digit, multiplexed example, let’s start by converting the previous
single-digit example, so that a timer-based interrupt, running every 2 ms, is used to maintain the display.
The main loop will do nothing more than increment the ‘digit’ variable, which the ISR will display3.
This means that ‘digit’ has to be declared as a global variable, before main() or the interrupt function, so
that it can be accessed by both the ISR and the main program:
/***** GLOBAL VARIABLES *****/
uint8_t digit = 0; // digit to be displayed by ISR
The variable is initialised to ensure that it holds a defined, legal value (between 0 and 9) when the ISR,
which references it, runs.
2
If you had common-anode displays, you would normally use PNP transistors as high-side switches (between VDD and
each common anode), instead of the NPN low-side switches shown here.
3
It is usually a good idea, when developing an application, to build it up one piece at a time, so that you can test each
section, concept or algorithm, as you go.
We’ll use the digit labelled ‘DIGIT3’ in the circuit above. To enable that digit, we need to raise RC0.
Note, however, that when we write a looked-up pattern value to LATC, the whole of LATC is overwritten,
not only the bits used for the display.
That means that the following sequence will not work:
LATCbits.LATC0 = 1; // raise RC0 to enable display
LATA = pat7segA[digit]; // output port A and C patterns
LATC = pat7segC[digit];
This is because the final ‘LATC = pat7segC[digit]’ overwrites every bit of LATC, including RC0.
If you want the digit enable operation to stick, you have to put it after the write to LATC:
LATA = pat7segA[digit]; // output port A and C patterns
LATC = pat7segC[digit];
LATCbits.LATC0 = 1; // raise RC0 to enable display
It is good practice to encapsulate the pattern table definitions, along with the code responsible for outputting
those patterns, as a function:
void set7seg(uint8_t digit); // display digit on 7-segment display
The function is then self-contained – only the function needs to “know” about the pattern tables; they are
never accessed directly from other parts of the program, and if we ever wanted to change the way that a digit
is displayed, we’d only have to modify this function. This approach also makes it easier to reuse the code in
other programs.
So we have:
/***** Display digit on 7-segment display *****/
void set7seg(uint8_t digit)
{
// pattern table for 7 segment display on port A
const uint8_t pat7segA[10] = {
// RA4 = E, RA1:0 = FG
0b010010, // 0
0b000000, // 1
0b010001, // 2
0b000001, // 3
0b000011, // 4
0b000011, // 5
0b010011, // 6
0b000000, // 7
0b010011, // 8
0b000011 // 9
};
0b011110, // 3
0b010100, // 4
0b011010, // 5
0b011010, // 6
0b010110, // 7
0b011110, // 8
0b011110 // 9
};
// lookup pattern bits and output them (via port latch registers)
LATA = pat7segA[digit];
LATC = pat7segC[digit];
}
Because we’re writing the whole of PORTA and PORTC (via their output latches) in this function, the
“digit enable” bits, corresponding to RA5, RC0 and RC5, are always cleared (because the digit pattern
tables contain ‘0’s for these bits). Thus, all the displays are blanked whenever a new digit is output.
So, after calling this function, we must enable the display:
// display digit
set7seg(digit); // output digit
DISP_EN = 1; // enable display
You can see that this will be easy to extend to multiple digits; all we need do is enable different displays,
after outputting the appropriate digit pattern on the 7-segment bus.
Placing this code into an interrupt service routine (see lesson 5), we have:
/***** INTERRUPT SERVICE ROUTINE *****/
void interrupt isr(void)
{
// *** Service Timer0 interrupt
//
// TMR0 overflows every 2.048 ms
//
// Displays single digit on 7-segment display
//
// (only Timer0 interrupts are enabled)
//
INTCONbits.TMR0IF = 0; // clear interrupt flag
Assuming that we’re using the internal RC oscillator, running at the default 500 kHz, Timer0 is set up to
overflow (generating an interrupt) every 2.048 ms, and the timer interrupt enabled, by:
// configure Timer0
OPTION_REGbits.TMR0CS = 0; // select timer mode
OPTION_REGbits.PSA = 1; // no prescaling
// -> increment TMR0 every 8 us
// -> TMR0 overflows every 2.048 ms
// enable interrupts
INTCONbits.TMR0IE = 1; // enable Timer0 interrupt
ei(); // enable global interrupts
With this interrupt code running in the background, taking care of displaying the current contents of the
‘digit’ variable, all the main loop code has to do is update the value of ‘digit’ every second:
/*** Main loop ***/
for (;;)
{
// display each digit from 0 to 9 for 1 sec
// (digit is displayed by ISR)
for (digit = 0; digit < 10; digit++)
{
// delay 1 sec
__delay_ms(1000);
}
}
That’s one of the main advantages of using a timer-based “background” interrupt to maintain the display;
your main code only has to update the display contents, without worrying about the mechanics of how it is
displayed, making the main code easier to follow.
Whenever you use display (or other) multiplexing, you must decide on a multiplexing rate – in this case, how
quickly the digits are updated, corresponding to the tick speed (how often then timer-based interrupt runs).
Making the rate faster (shorter ticks), means less perceptible flicker, but the faster the multiplexing rate, the
greater the proportion of time spent updating the display. So you need to find a sensible balance – generally
speaking it’s a good idea to update the display as slowly as you can get away with, to maximise the time
available for other tasks.
In this case, if each of three digits is updated at a rate of 2 ms per digit, the whole 3-digit display is updated
every 6 ms, so the display rate is 1 ÷ 6 ms = 167 Hz – fast enough to avoid perceptible flicker.
Since the exact display refresh rate doesn’t matter, we can use an “easy to generate” 2.048 ms tick, as in the
single-digit example above.
To represent the time count, we can store the minutes and seconds as integer variables:
uint8_t mins = 0; // time counters (displayed by ISR)
uint8_t secs = 0;
Then to extract the tens digit (by dividing seconds by ten) and display it, using the function developed above,
we can simply write:
set7seg(secs/10); // output tens digit
TENS_EN = 1; // enable tens display
Similarly, the ones digit is returned by the expression ‘secs%10’, which gives the remainder after dividing
seconds by ten:
set7seg(secs%10); // output ones digit
ONES_EN = 1; // enable ones display
This code assumes that the symbols ‘TENS_EN’ and ‘ONES_EN’ have been defined:
// Pin assignments
#define MINS_EN LATCbits.LATC5 // minutes digit enable (RC5)
#define TENS_EN LATAbits.LATA5 // tens digit enable (RA5)
#define ONES_EN LATCbits.LATC0 // ones digit enable (RC0)
Although using arithmetical operators such as ‘/’ and ‘%’ seems natural when programming in C, their use
can greatly increase the size of the generated code. The XC8 compiler deploys general integer division and
modulus routines to evaluate the expressions ‘secs/10’ and ‘secs%10’, which are not optimised for the
specific “divide by 10” and “find remainder after dividing by 10” tasks that they are used for here.
The size of the generated code is not a problem in this example, because the PIC16F1824 has more than
enough program memory available.
However, the time that these integer division and modulus operations take to complete is a problem, if they
are performed within an ISR which is run every 2 ms – if any of these operations take 2 ms or more to run,
the ISR won’t complete before the next interrupt. The multiplexing won’t work correctly, and there will be
little time available for the main loop to do anything.
It is possible to reduce the code size, and the time needed to run it, by using the expressions ‘secs/10U’ and
‘secs%10U’ (with the ‘U’ telling the compiler that these are unsigned integer constants – which you might be
excused for thinking was already obvious...). This causes the XC8 compiler to use unsigned integer division
and modulus routines, which are somewhat shorter than the general (signed) versions. But they are still large
and slow, and make the multiplexing routine run poorly.
A possible solution is to avoid the use of division and modulus operators altogether, by using separate
variables to store “tens” and “ones”, instead of a single variable for seconds.
However, in this example we can reduce the impact of the slow execution of these ‘/’ and ‘%’ operators by
running the PIC more quickly – configuring the internal RC oscillator to run at 8 MHz (you could go up to
32 MHz if you wish – see lesson 7), instead of the default 500 kHz.
Sometimes increasing the processor speed isn’t an option, and you may need to find a more efficient (if less
straightforward) way to implement your program – or consider writing parts of it in assembly language.
The “multiplex counter” variable, ‘mpx_cnt’, is used to keep track of which digit to display, so that they can
be displayed correctly in sequence, each time the ISR is called.
Since this variable has to retain its value, from one invocation of the ISR to the next, we need to declare it as
static variable within the ISR:
static uint8_t mpx_cnt = 0; // multiplex counter
However, when selecting between blocks of code, based on various possible values of a single variable, it is
more normal to use the C ‘switch’ statement:
switch (mpx_cnt)
{
case 0:
set7seg(secs%10); // output ones digit
ONES_EN = 1; // enable ones display
break;
case 1:
set7seg(secs/10); // output tens digit
TENS_EN = 1; // enable tens display
break;
case 2:
set7seg(mins); // output minutes digit
MINS_EN = 1; // enable minutes display
break;
}
// Increment mpx_cnt, to select next digit for next time
mpx_cnt++;
if (mpx_cnt == 3) // reset count if at end of digit sequence
mpx_cnt = 0;
Note that the multiplex count is updated in a separate increment and test (in case the end of the sequence has
been reached) operation, after the ‘switch’ statement.
This is in the form of a state machine, where for each current state, we explicitly state what the next state
will be. It is particularly appropriate when the states are not purely sequential, as they are here. As ever, you
can choose whichever approach seems best.
It is a good idea to blank the display, by clearing the digit enable lines, before outputting each new digit
pattern on the display bus – this avoids “ghosting” (visible in low light) due to PORTA being updated while
the pattern for the previous digit is still being output on PORTC.
So the ‘set7seg’ function becomes:
/***** Display digit on 7-segment display *****/
void set7seg(uint8_t digit)
{
// pattern table for 7 segment display on port A
...
// disable displays
LATA = 0; // clear all digit enable lines on PORTA
LATC = 0; // and PORTC
Once again, with the display being updated by the interrupt code, in the background, the main loop code has
nothing to do except increment the counters, once per second:
for (;;)
{
// count minutes:seconds from 0:00 to 9:59
// (displayed by ISR)
for (mins = 0; mins < 10; mins++)
{
for (secs = 0; secs < 60; secs++)
{
__delay_ms(1000); // delay 1 sec
}
}
}
Complete program
Here is the complete program, incorporating the above code fragments.
One point to note is that TMR0 is never initialised; there’s no need, as it simply means that there may be a
delay of up to 2 ms before the display begins for the first time, which isn’t at all noticeable.
/************************************************************************
* Description: Lesson 10, example 2b *
* *
* Demonstrates use of timer-based interrupt-driven multiplexing *
* to drive multiple 7-seg displays *
* *
* 3 digit 7-segment LED display: 1 digit minutes, 2 digit seconds *
* counts in seconds 0:00 to 9:59 then repeats, *
* with timing derived from int RC oscillator *
* *
*************************************************************************
* Pin assignments: *
* RA0-1,RA4, RC1-4 = 7-segment display bus (common cathode) *
* RC5 = minutes digit enable (active high) *
* RA5 = tens digit enable *
* RC0 = ones digit enable *
* *
************************************************************************/
#include <xc.h>
#include <stdint.h>
// Pin assignments
#define MINS_EN LATCbits.LATC5 // minutes digit enable (RC5)
#define TENS_EN LATAbits.LATA5 // tens digit enable (RA5)
#define ONES_EN LATCbits.LATC0 // ones digit enable (RC0)
// configure ports
TRISA = 0; // configure PORTA and PORTC as all outputs
TRISC = 0;
// configure oscillator
OSCCONbits.SCS1 = 1; // select internal clock
OSCCONbits.IRCF = 0b1110; // internal oscillator = 8 MHz
// configure Timer0
OPTION_REGbits.TMR0CS = 0; // select timer mode
OPTION_REGbits.PSA = 0; // assign prescaler to Timer0
OPTION_REGbits.PS = 0b011; // prescale = 16
// -> increment TMR0 every 8 us
// -> TMR0 overflows every 2.048 ms
// enable interrupts
INTCONbits.TMR0IE = 1; // enable Timer0 interrupt
ei(); // enable global interrupts
break;
case 1:
set7seg(secs/10); // output tens digit
TENS_EN = 1; // enable tens display
break;
case 2:
set7seg(mins); // output minutes digit
MINS_EN = 1; // enable minutes display
break;
}
// Increment mpx_cnt, to select next digit for next time
mpx_cnt++;
if (mpx_cnt == 3) // reset count if at end of digit sequence
mpx_cnt = 0;
}
// disable displays
LATA = 0; // clear all digit enable lines on PORTA
LATC = 0; // and PORTC
Conclusion
We have seen in this lesson that lookup tables can be effectively implemented in C as initialised arrays
qualified as ‘const’. We also saw that C bit-manipulation expressions make it reasonably easy to extract
more than one segment display pattern from a single table entry, making it seem natural to use a single
lookup table – but that the extra instructions that the compiler generates to perform this pattern extraction
may not be worth the savings in lookup table size.
We also saw that it was quite straightforward to use timer-based interrupt-driven multiplexing to implement
a multi-digit display, without needing to be as concerned (as we were in the assembler versions) about how
to store the values being displayed. This allowed us to use simple arithmetic expressions such as ‘secs/10’
and as ‘secs%10’, but at a significant cost in generated code size and execution time – demonstrating that
what seems easy or natural in C, may not always the most efficient way to do something.
Of course it is useful, when using C, to be aware of which program structures use more memory or need
more instructions to implement than others (such as including floating point calculations when it is not
necessary). But if you really need efficiency, as you often will with these small devices, it’s difficult to beat
assembler.
The next lesson makes use of our new ability to display numeric values, covering analog-to-digital
conversion and simple arithmetic and arrays.