ESP32 micropython led matrix driver

Efficient driver for cheap LED matrix panels using the I2S peripheral of the ESP32 to display images with zero CPU load.

Similar projects worth following
I present you one of my "I'm bored and have fancy hardware flying around" projects ;)This micropython module is a driver for RGB LED matrix panels. Such panels are available very cheap, but they are intended to be used as part of a larger display where they are driven by an FPGA. Driving such a display from a CPU usually requires a very high CPU load in order to display a flicker-free image. This driver however uses the I2S peripheral of the ESP32 in conjunction with DMA to drive the display efficiently without involving the CPU at all.The code can be found on github: driver should support all display sizes, yet I only tested it with a 64x32 display. The main limitation is the available RAM, since the bitstream requires one byte per pixel per bit of color depth.

I2S magic

The I2S peripheral on micro controllers is usually intended to output digital sound to a DAC. But we hackers quickly figured out that it is perfectly suitable as generic bit stream output with precisely configurable timings. So there are various implementations to control WS2812 or similar LEDs through this.

But while usually I2S is limited to a single line / bit, the ESP32s I2S takes things to the next level. It can be configured to output up to 24 bits simultaneously! And that through a quite flexible DMA engine which allows arbitrary linked lists to specify the data. So now instead of a boring audio interface we are now looking at a component which can output any predefined data sequence.

Bitstream generation

The matrix only knows two states for each pixel and color, ON and OFF, so in order to display more colors, we need to PWM.

For a color depth of N bits, each image is split into N single images, each representing a single bit of the color value. We call these 'subimages' or 'subframes'. Displaying the image is as simple as displaying all the subimages in a row. However we need to display subimages for the higher-order bits longer. Transmitting them at a lower clock may be an option, but switching the clock would require CPU intervention. So instead we use the linked list feature of the DMA engine to just display these subimages more than once for each full draw cycle. This also allows to distribute the frames evenly across the draw cycle.

Eg we have a color depth of 4 bit and want to display a value of 10, it will be transmitted as 15 slices like the following:
1 1 0 1 1 0 1 1 0 1 1 0 1 1 0
As you can see, the total sum is 10 and the 1s and 0s are spread evenly across the frame.

  • 1 × ESP32
  • 1 × LED matrix panel
  • 1 × Level shifters

  • Adventures with .mpy modules

    Daniel01/03/2021 at 14:40 0 comments

    What are .mpy modules?

    Besides statically linked modules, Micropython also offers a way to dynamically load binary code. These are .mpy files, which can be copied to a device and imported just like an ordinary python module. This has a huge usability benefit over the "normal" binary modules, since users can simply install those on prebuilt Micropython installations. "Installing" a statically linked module requires to (re-)build Micropython yourself.

    So I thought having this driver as a dynamically loadable module would be neat.

    Limitations of dynamically loaded modules

    A dynamically loaded module has no direct access to the function provided by the platform / operating system so only a very limited table of functions is available which are defined in py/nativeglue.h. Normally, this would mean implementing a hardware driver with this system is a bad idea because all the HAL functions are unavailable. But since my driver comes with its own variant of the I2S driver anyway and otherwise does very little hardware specific, I wanted to give it a try and started planning.

    Non-IDF version of the I2S driver

    For this to work, we need to make a version of the parallel I2S driver, that does not rely on the ESP32 IDF. Looking at the driver, there are four categories interaction with the IDF:

    • Direct, memory-mapped hardware access
      We need to define the memory addresses of the peripherals. For this we can either use the original symbol tables or we define them ourself based on the defines in soc.h.
    • Interrupt allocation
      My implementation don't use the I2S interrupt, so we can ignore / remove interrupt related stuff.
    • GPIO setup
      The required GPIO functions from the IDF are mostly trivial or reside in the ROM anyway, so its easy to port the required functions.
    • Peripheral clock gating / reset
      In theory, this is trivial, but it requires access to the DPORT registers and that's where the trouble lies...

    DPORT mutual access mitigation

    The DPORT registers hold bits, to enable / disable and reset peripherals of the CPU. Since these registers are shared between both cores, an access mitigation is required in order to implement bit set / clear operations safely. Sadly, that is something I cannot do from within the dynamically loaded module as the required functions are not accessible. On a single core CPU, I could get away with disabling interrupts while accessing the registers. But since this is a dual-core, things are way more difficult.

    This leaves me with only two options:

    • Don't use mutual access mitigation and risk adverse side effects.
      Chances of this causing trouble are small, but its a terrible way of thinking...
    • Give up
      .mpy modules are not intended to be used as hardware drivers anyway. It would have been a nice solution though.

    For now I decided to not implement my driver as a .mpy module. But I'm still searching for solutions.

View project log

Enjoy this project?



Similar Projects

Does this project spark your interest?

Become a member to follow this project and never miss any updates