TL;DR: Multiplexing cannot remove complexity. In fact, nothing can remove complexity. If something is inherently complex, all you can do is move that complexity to an area where you can handle it.
In the case of multiplexing, it moves the complexity of
a ton of individual wires that are relatively slow to
a few wires that have to work like mad to get all that information across. With a few exceptions like charlieplexing, most techniques boil down to using some kind of predefined communication for an MCU running software to tell another, relatively dumb chip like a shift register, what to do.
I did this with SPI (Serial Peripheral Interface) a few years ago. In fact, I had both inputs (buttons) and outputs (LEDs) multiplexed into just 4 MCU pins. Since you asked about the outputs, here's my LED schematic:
This specific shift register might not match yours, but it's the same idea. Using the SPI signal names:
- CS = Chip Select
- When it goes low (Chip Selected for data transfer), the shift registers shift one bit per clock.
- When it goes high, the shift registers output whatever byte they've ended up with to their Qx pins.
- This is the only signal out of the four that is not managed by the SPI peripheral (a chunk of hardwired logic that is built into the MCU chip to control this type of communication while the software goes off and does something else). Thus, it could be any convenient output pin from the MCU.
- SCK = Serial ClocK
- Every transition from inactive to active shifts one bit from MOSI into the first shift register. All bits move over by one, and the one that comes out the other end goes into the next shift register, and so on as far as you want to go.
- I can't say high or low here because SPI allows the designer to determine which state means what. But in this case, the shift register's datasheet will probably determine it for you.
- MOSI = Master Out Slave In
- This is the actual data that you want to be displayed, presented one bit at a time and committed by the appropriate clock edge.
- MISO = Master In Slave Out
- This is data being received at the same time as you're commanding all those outputs. It's not used on this particular board, but connected straight through to the other connector.
- I have another variant of this board that does a similar thing with buttons and can daisy-chain with this one in any order and with any number of copies, so that the same SPI peripheral and the same software driver can read a ton of buttons and drive a ton of LEDs at the same time.
So that's the hardware design.
In software, I:
- Set up the SPI peripheral and two equal-sized arrays, one for LEDs and one for buttons
- They have to be equal-sized in software, even if they're not physically, because for every byte out, one has to come in. Of course, you could get around this requirement by making the ISRs a bit more complicated, but my project had them close enough to equal anyway that I didn't see the need.
- Define the appropriate Interrupt Service Routines (ISRs) so that this driver can "just work" without the main software having to do anything special
- A timer ISR starts a full refresh by loading the first LED byte into the SPI peripheral's shift register (built into the MCU), starting that transmission, and enabling the shift-done ISR
- The shift-done ISR continues the refresh by putting whatever has ended up in the SPI peripheral's shift register into the button array, writing the next LED value in its place, and starting that transmission. If it's just gone through the entire array, it also disables itself.
- Write the main software to just use those arrays as if they were magic. The ISRs take care of the rest.
Of course, that's a simplified explanation. You'll also have to twiddle the CS signal explicitly in the ISR(s) somewhere. Look through the datasheets and think carefully about timing.