Practical Guide to LEDs 4 - Matrix & Multiplexing
by nqtronix in Circuits > LEDs
15002 Views, 106 Favorites, 0 Comments
Practical Guide to LEDs 4 - Matrix & Multiplexing
You've heard about LEDs. Chances are you've already tinkered with them. But there are so much details you probably don't know about. Sadly the resources available are often incomplete or just unpractical. This guide takes you all the way from a beginner level to adept skills!
This is chapter 4 of a short series. Use the table of contents below to browse the content I've already published.
As your projects grow in size (and the amount of LEDs) you'll soon run out of I/Os to use. In this chapter we'll discover how to use a few I/Os to drive a whole bunch of LEDs, such as an 7 segment display or a huge LED dot matrix display!
Chapters:
Multiplexing I - Circuit
In the last chapter we've learned that LEDs are visible to the human eye even if they are only powered for short periods. This does not only allow us to set the brightness, but also to power LEDs sequentially. Only a part of all LEDs is driven by a few I/Os at once, additional I/Os select which part of the LEDs should be driven. I know this may sound overly complicated at first, but let me show you that this isn't the case.
I'm very aware that there are ICs which are made specifically for LED multiplexing. Allthough they are quite expensive they can be very handy to drive huge amounts of LEDs (64+). For smaller projects they are not required though, an ARDUINO can do the very same and it's likely you'd use one anyway. To better understand what's going on inside the chip, we'll program the ATMEGA328P (the chip of the ARDUINO) in C and without fancy libraries. Take a deep breath, this will be quite a bit to digest. It helps to understand the code from last chapter as we'll re-use quite a bit of it.
The example project: Driving a 4 digit 7 segment display (with decimal point)
The operating conditions:
- The common power supply voltage is
- USYS = 5V
- The 7 segment displays have the following values
- UF = 2.1V
- IF = 20mA
The goal:
A display with 4x8 segments would require 32 I/Os to be driven traditionally. An ATMEGA328P has only 23 I/Os, and you'll need a few for other hardware as well. The proposed solution will only require 12 I/Os, less than 40% of the original idea.
The idea:
Only one digit will be powered at a time, which is selected by one of 4 I/Os. This allows to share the same 8 I/Os to control the segments of all digits. The microcontroller needs to cycle trough all digits fast enough that no flickering will be visible.
The circuit:
For this chapter we'll use common anode 7-segment displays as they are more commonly used. A common anode has certain benefits for advanced circuits, but requires an odd circuit design. If you only have common cathode parts that's ok, too. You need to make a few minor changes to the circuit and code and it'll work as well.
In the last chapter the I/Os sourced current to the anode of the LEDs; the current was flowing out from the pin. A logic "1" corresponded with a lit LED. In this circuit the cathode of the LED is driven, so the pin must sink the current to turn the LED on. This is done by setting the output to 0V - a logic "0". This is not a huge deal, but the code needs to account for this.
To provide power to the LEDs P-Channel Mosfets are used. They behave similar to the N-Channel Mosfets used in previous chapter, except they require a negative Gate-Source voltage VGS to turn on. The two possible cases are:
- logic "1" = 5V ⇒ VGS = 0V ⇒ Mosfet off
- logic "0" = 0V ⇒ VGS = -5V ⇒ Mosfet on
Note that this only works when the signal voltage matches the supply voltage. If the supply voltage for the Mosfets is larger they will not switch off:
- logic "1" = 5V ⇒ VGS = -7V ⇒ Mosfet on
- logic "0" = 0V ⇒ VGS = -12V ⇒ Mosfet on
The resistors R1-R8 are chosen according to chapter 2.
R = (USYS - UF) / IF = 145Ω ≈ 150Ω
For your own design check if the Mosfets (AO3401 in this case) are suitable for the current of all 8 segments (8*20mA = 160mA). Since they are only on for ¼ of the time the total power dissipated is also ¼:
P = RDS(ON) * IDS² / 4 = 0.06Ω * (0.16A)² = 384µW
Downloads
Multiplexing II - Code
This time the all code can be downloaded as a .rar file. No need for copy & paste ;)
The ATMEGA328P does not have any build-in hardware to support multiplexing out-of-the-box. Instead we have to manually switch the outputs at the right time. Here is the rough plan of events which should happen regularly:
- Load segment data for digit N
- Turn on digit N
- Wait some time
- Turn of all digits
- Change N to the next digit
- Repeat.
On run of the plan above will be called "phase". After 4 phases each digit has been active once, which equals a complete "cycle". The term "duty cycle" refers to the relative on-time of a LED; with 4 phases the duty cycle is 1/4.
Activating the outputs:
Before we can run the plan above we need to enable the outputs, which can be easily forgotten. It is important to set the outputs to high beforeenabling them. If the commands would be executed in reverse order, all segments would be on for a short time.In this case it wouldn't damage the chip, but should be avoided as a good practice.
PORTB |= 0x0F; // prepare off-state for Mosfets PORTD = 0xFF; // prepare off-state for segments DDRB |= 0x0F; // Enable the output for the IO PB0 thru PB3 DDRD = 0xFF; // Enable the output for the the whole port
The purpose of interrups :
Although the plan above says "wait" it'd be incredible stupid to use a "delay" function as it causes 100% load. Instead we need to set up a timer, which runs simultaneously to our main code, and notifies us as soon as the wait time is over. This is done by using an "interrupt". As soon as an interrupt happens the microcontroller automatically does the following:
- Stop the current code & save where to continue
- Execute the specific "ISR" (Interrupt Service Routine) code
- Continue from the previous saved spot
While an interrupt is running no other interrupt can be executed, so it's best to keep all ISRs as short as possible.
Configuration of the Timer0:
Every timer supports various interrupt sources. For this application we'll use the overflow interrupt of Timer0. At each interrupt the active digit will be changed. To archive a flicker-less display at least 100 cycles per seconds are required. Each cycle consists of for phases so Timer0 must overflow at least 400 time per second. The prescaler needs to be set to
prescaler ≤ fclock / steps / fcycle = 8MHz / 256 / 400 ≈ 78
⇒ prescaler = 64
TCCR0A = 0x00; // Select normal mode without PWM channels TCCR0B = (1<<CS01)|(1<<CS00); // Set the prescaler to /64 and start the timer
Remember to enable the interrupt:
TIMSK0 = (1<<TOIE0); // Enable interrupts @ overflow sei(); // Enable interrupts in general
Programming the ISR:
This is where all the magic happens. To work the code must know two things:
- The current phase of the cycle, ie. which digit should be active
- The data to display, ie. which segments should be driven
This information is stored in two global variables, one of which is an array with 4 elements:
volatile uint8_t digit[4] = {0}; // stores the next output value // // #### #### #### #### // # # # # # # # # // #### #### #### #### // # # # # # # # # // #### #### #### #### // //digit NR: 3 2 1 0 volatile uint8_t current_phase = 0; // current active digit
The plan laid out above is not suitable for an ISR, it needs to be re-ordered that the wait time is at the beginning or end:
- Turn of all digits
- Change N to the next digit
- Load segment data for digit N
- Turn on digit N
- Wait some time
The resulting code is the following:
ISR(TIMER0_OVF_vect) // This code will be executed every overflow! { PORTB |= 0x0F; // Turn all Mosfets off // begin next phase if(current_phase == 0) // Counting down is faster current_phase = 3; else current_phase--; PORTD = ~(digit[current_phase]); // Update segments PORTB &= ~(1<<current_phase)|0xF0; // Turn Mosfet for current digit on }
As it does not matter in which order the digits are active, the phase number is counted down to benefit from the faster execution of if(current_phase == 0)
instead of if(current_phase == 3)
.
The ~
of PORTD = ~(digit[current_phase]);
inverts all bits, a "1" in memory matches a active segment. ~(1<<current_phase)
results in 7 "1"s and a "0" at the position of current_phase
. The operator &=
copies only all "0"s and thus turns only one digit on.
Defining individual symbols:
Somehow we need to tell the program which segments should be on for any desired character. Typing it in manually is not an option for me, so I wrote a include file (dispCodec.h) to do all the decoding for me. You only need to type in which I/O are tied to which segments and you can use any of the symbols defined below.
The example on the cover image is generated by the code:
digit[3] = DP_L; digit[2] = DP_E; digit[1] = DP_d; digit[0] = DP_4;
In this example the display is static, but you can change all characters at any time in the main loop or in additional ISRs. Have fun experimenting!
Increasing Brightness
You may notice that the LEDs are dimmer when driven multiplexed. This shouldn't be a huge surprise: Each LED is driven with a PWM signal, the same you've used before to control the brightness. This isn't much of an issue for indoor use, at least with the example above. If you plan on adding more phases per cycle (to control more digits) or using the device outdoors you may want to increase the brightness.
There are two ways to do this.
1. Increase the amount of segment drivers:
Instead of doubling the phase count it is also possible to increase the amount of pins to drive the segments. However this conflicts with the goal to use the least amount of I/Os possible, so it should be your last choice.
2. Increase the current of the LEDs:
For short times LEDs tolerate a multiple of their rated current, provided they have enough time in between to cool down. This is such a common practice that manufactures specifically include information about "peak forward current" into their datasheets.
To demonstrate the second method we'll upgrade the previous example.
The 7-segment displays used before were salvaged parts and the Chinese datasheet contains only basic information. Instead we'll design the circuit for a similar part from Kingbright.
Peak forward current:
First we need to decide how much current the LEDs can handle. The peak forward current from the datasheet (160mA) is only valid if the duty cycle is 1/10 and the pulse width is 0.1ms. To adopt for our duty cycle of 1/4 the maximum given value need to be reduced. The new duty cycle is 2.5 times larger, the given peak current needs to be divided by that:
ILED = IPEAK / 2.5 = 160mA / 2.5 = 64mA
At 64mA the forward voltage is about 2.2V as the diagram shows. This results in a new resistor value of
R = (USYS - UF) / IF = (5V - 2.2V) / 64mA = 43.75Ω ≈ 47Ω
New driver circuitry:
The increased current requires stronger drivers for the digits and segments. A quick check revels that the current Mosfets are sufficient:
P = RDS(ON) * IDS² / 4 = 0.06Ω * (8 * 0.064A)² /4 = 3.93mW
The ATEMEGA328P on the other hand can only handle 20mA (40mA if you push it) so additional transistors for the segments are required. The updated schematic is shown above. Resister values were calculated with the formula from last chapter:
R = (5V - 0.7V) * 100 / 0.064A = 6.72kΩ ≈ 5.6kΩ
Adopting the code:
The code stays almost identical, except now segments are driven with a high level signal - a logic "1". Therefore we need to alter the following lines:
PORTD = 0xFF; // prepare off-state for segments
⇒ PORTD = 0x00;
PORTD = ~(digit[current_phase]); // Update segments
⇒ PORTD = digit[current_phase];
Frequency adjustment:
The main reason to limit the current of LEDs is to prevent them from overheating. Pulsed current heats the LED only momentary, the shorter the pulse the better can the head be absorbed by the case. This is why the pulse width is given in the datasheet.
It is possible to decrease the pulse width by increasing the cycle frequency. As all switching is done manually within the code, it increases processor load significantly and should be therefore avoided. In my experience it will not effect the LEDs noticeably.
Adjusting Brightness
Sometimes projects with a 7-segment display require at times a different brightness level. The most common application are clocks which need to be readable at daytime and dim enough to let you sleep at night.
Now it's the time to take advantage of the fact that multiplexing is some kind of PWM!
To change the brightness of all LEDs simply turn of the digits before the end of the phase is reached. We use the same output compare register OCR0B as last chapter, but this time not to generate a PWM signal, but an interrupt instead. The interrupt is enabled with:
TIMSK0 = (1<<TOIE0)|(1<<OCIE0B); // Enable interrupts @ overflow & Compare Match B
All left to do is to move the "Turn all mosfets off" line into the new ISR:
ISR(TIMER0_COMPB_vect) // This code will be executed at a compare match { PORTB |= 0x0F; // Turn all Mosfets off }
The register OCR0B stores the current brightness value.
Tweaking the brightness of individual segments is done in a similar fashion. For each segment an individual interrupt is needed. To get along with only two output compare channels, the brightness value needs to be updated within every phase once per segment.
Unfortunately I don't have any example code at hand; none of my projects required individual segment dimming. This chapter has been very code-heavy without this already, so I decided to skip any further details and let you do your own research, if you want to.
Growing Huge With Shift Registers
No matter how fancy your multiplexing technique is, you'll run eventually out of I/Os to control all the LEDs. Of course, you could just pick a larger microcontroller, but they can be super expensive and usually don't come in handy DIP packages. There are several ICs which provide additional I/Os, the most common and perfectly suited part for LEDs is the 74HC595 8-bit shift register. They are available in a variety of cases, including the popular DIP-16 and SOIC-16, way cheaper than any larger microcontroller. For this step you'll need the datasheet to follow along, get it here if you don't have it.
So what are shift registers excatly?
Shift registers load and store one bit of data at a time. As the name suggests the data is "shifted" trough the memory: When the second bit is to be stored the previous value is moved to the next memory cell. NXP calls the data input DS (pin 14) and the signal to store the next bit SHCP (pin 11).
The beauty about this design is that you can "chain" as many shift registers together as you want. As soon as one is full, the last bit is pushed out of the Q7S (pin 9) output and can be fed into the next register. All other control signals can be shared across all ICs.
74HC595 do not directly put out the stored data to their outputs. Instead they have a second memory which is directly tied to the outputs. A signal on the STCP (pin 12) input will load the data from the shift memory into it. Without this feature you would unintentionally control the outputs while shifting the data through all components. In addition it has a OE (pin 13) input which disconnects all outputs (called Hi-Z state) if a logic "1" is applied. The last remaining input, MR (pin 10), resets the shift memory back to zeros whenever the input is logic "0".
Once again the first example will be modified to explain how it works in practice, even though there is no need for such a low LED count.
The circuit:
The segments are now connected to the 74HC595 instead of the microcontroller. I'd be also possible to control the mosfets through a second shift register, but to keep it simple they are connected traditionally. A small 100nF capacitor should be physically placed closed to the power inputs of the chip to provide power while switching. The 10k resistor R14 clears the contents of the shift registers whenever the ATMEGA is retested, R13 ensures the outputs are disabled during this time.
The names of the pins in the drawing of the shift register do not match up with the names above. It has been drawn according to IEC logic symbol convention which makes reading any schematic easier, if you know how. For now I recommend to just compare the pin numbers.
The SPI interface:
The ATMEGA328P features a Serial Peripheral Interface - SPI for short - which allows to send out a byte of data at once. The data output MOSI (Master Out Slave In) of the SPI module is connected to DS, the clock output SCK is connected to SHCP. In SPI master mode the SS pin can only be configured as an output, so it is tied to STCP. The remaining OE input can be connected to any pin you wish.
The Code:
To reduce the amount of work I need to put into this (I'd says it has been plenty already) I re-used some of my personal code-base. There are plenty of comments within the source code to explain what is going on. Do not this example has not been tested on hardware as a whole, but I can assure you all SPI related code works flawlessly. Here's a short overview over the individual files and their purpose:
- Config: Contains hardware specific information, in this case the pinout of the chip
- HC595_config.h: Contains all non-SPI pinout information
- SPI_config.h: Contains all SPI related pinout information
- Driver: Contains files to control external hardware
- HC595.c: Application code related to the shift register
- HC595.h: List of all commands
- HAL: (Hardware Abstration Layer) Contains files to control internal Hardware easily
- SPI.c: Application code related to the SPI module
- SPI.h: List of all commands
- Utility: Contains files to ease programming
- dispCodec.h: Assigns segment data to real expressions
- macros.h: Allows to specify any I/O as ,
- main.c: contains the application
The main.c code is very similar to the previous example. The code to define the outputs as been updated to:
PORTD |= 0x0F; // prepare off-state for Mosfets DDRD |= 0x0F; // Enable the output for the IO PB0 thru PB3
The driver for the shift registers is enabled by HC595_initialize();
, it sets up the SPI module and enables the outputs as defined in the Config folder.
The overflow ISR is change to:
ISR(TIMER0_OVF_vect) // This code will be executed every overflow! { // begin next phase if(current_phase == 0) // Counting down is faster current_phase = 3; else current_phase--; HC595_loadData(1, &digit[current_phase]); // Shift the right values into the registers HC595_updateDriver(); // Load the values to the output memory PORTD &= ~(1<<current_phase); // Turn Mosfet for current digit on }
HC595_loadData(1, &digit[current_phase]);
starts the process of sending 1 byte of data. The &
is required as the command requires a position of where to find the data within the memory instead of the actual data itself. Finally HC595_updateDriver();
toggles the STCP pin to output the data after the shifting has been completed.
Adding more shift registers:
For large dot-matrix displays you can use nearly infinite 74HC595 chained together. However the shifting process takes some time. I don't remember the exact speed of the code, but it was about 1us at a 16MHz clock and as fast as possible. The code supports sending out multiple bytes at once to improve speed even further. To send out an array called data
with 4 elements you'd type:
data [4] = {0, 1, 2, 3}; // Assign any values to an array HC595_loadData(4, data); // Shift out the first 4 elements of data HC595_updateDriver(); // Load the values to the output memory
Charlieplexing
No guide about multiplexing is complete without at least mentioning Charlieplexing. Charlieplexing takes advantage of the three states a I/O can have: High (logic "1"), Low (logic "0") and Hi-Z (input). Compared to normal multiplexing this method can decrease the pin count, but at the same time code and layout complexity increases significantly. Therefore I do not recommend this for most applications.
Still, there are a few project which are not possible without, such as the miniature LED cube (by @HariFun).
So, how does it work exactly?
With Charlieplexing only one LED is driven at a time. To do so one of all I/Os is set to High, one to Low and the remaining to Hi-Z. In the schematic above LED D1 is driven when PD0 is High, PD1 Low and PD2 Hi-Z. The code must cycle through all I/O combinations for lit LEDs. Obviously each LED will be only on for a short time, resulting in dim LEDs, especially if many LEDs are driven.
There are no simple solutions to increase the current while maintaining 3-state capabilities. However you can safely use the maximum current per pin of 40mA as each pin is only on for a short time.
Resistors are calculated as usual, except the the value is split in half. In any configuration two resistors will be in series, so their value adds up to the total, calculated value. Often the required resistor values will be very small. In such a case it is possible to rely on the internal resistance of the outputs. An ATMEGA328P has a typical output resistance of 25Ω@5V or 35Ω@3V. The total resistance in series to any LED is therfore 50Ω@5V or 70Ω@3V. The remaining calculations are identical to general multiplexing.
For now I have to leave you without any example code on this. If things go according to plan (which they usually don't), I have completed a project requiring charlieplexing by the beginning of 2017 and will share the code along with it.