Timed 8×8 RGB LED Display with the BeagleBone Black PRU

Shaurya VijayShaurya Vijay

I often wonder how systems that require precise timing actually work. The BeagleBone Black’s Programmable Real-Time Units (PRUs) provide clock-cycle-level control of hardware. Unlike a regular ARM or x86 CPU running an operating system, a PRU can run firmware independently of Linux. This makes it useful to split a task: Linux handles coordination and configuration, while a PRU handles the real-time work.

For this project, I’m using an 8×8 RGB LED display to render text.

Brief description of the image

Why use a PRU?

A normal user-space program running under Linux cannot guarantee precise timing due to the CFS scheduler being non-deterministic, interrupts, random kernel activities, etc. The BeagleBone Black has two PRUs, each running at 200 MHz. They provide: – Predictable execution without Linux scheduler preemption. – A 5 ns clock cycle at 200 MHz. – Direct GPIO control without Linux GPIO system calls.

Building a custom Linux image

My first step was to build a custom Linux image with the drivers I needed loaded at boot. I chose Buildroot because embedded systems often need a minimal system image without the extra software that comes with a desktop distribution.

For this hardware setup, I also needed custom PRU firmware and a way for the firmware and Linux to communicate.

RPMsg is a message-passing mechanism built on the virtio framework. It requests resources through remoteproc. During firmware loading, remoteproc uses the resource table to provide shared buffers. Those buffers are stored in vring structures in DDR memory. Each PRU has two vrings: one for messages sent to the ARM and one for messages received from the ARM. System mailboxes notify the ARM or PRU when a message is available.

TI provides two RPMsg software components for Linux-based systems: – On the ARM Linux side, messages are received in kernel space. The rpmsg_pru interface module provides a character device that user-space programs can read and write. – On the PRU side, the PRU Software Support Package provides an RPMsg library. Firmware can call functions such as pru_rpmsg_receive and pru_rpmsg_send.

That means most of the Linux-side interaction happens through a character device, while PRU firmware uses the RPMsg library. After building the image and booting the BeagleBone Black, I checked that the PRUs and relevant kernel modules were available:

# for r in /sys/class/remoteproc/remoteproc*; do
>     echo "$r: $(cat "$r/name")"
> done
/sys/class/remoteproc/remoteproc0: wkup_m3
/sys/class/remoteproc/remoteproc1: 4a334000.pru
/sys/class/remoteproc/remoteproc2: 4a338000.pru

# lsmod | grep -Ei 'pru|pruss|rpmsg'
rpmsg_pru              16384  0
virtio_rpmsg_bus       24576  0
rpmsg_core             16384  2 rpmsg_pru,virtio_rpmsg_bus
pru_rproc              28672  1
pruss                  20480  1 pru_rproc
irq_pruss_intc         20480  1

TI also provides example firmware, which helped me get started with the boilerplate required to build a PRU program. I built the Echo example using the clpru compiler, loaded the resulting .out file, and tested communication through the character device:

printf 'hello PRU\n' > /dev/rpmsg_pru30
dd if=/dev/rpmsg_pru30 bs=512 count=1 2>/dev/null

The PRU replied:

hello PRU

I then implemented a simple state machine with two states: IDLE and RUNNING.

Brief description of the image

How this works is pretty interesting. The PRU has an interrupt controller that routes events into bits of register 31: – R31 bit 31 indicates an event from the timer. – R31 bit 30 indicates that RPMsg data is available from the ARM/Linux side.

The PRU has 32 general-purpose registers, R0 through R31. R30 and R31 have special uses for direct I/O: R30 is mainly used for output signals, and R31 is mainly used for input signals.

Brief description of the image

A system event identifies what triggered the interrupt. In my firmware, events can come from the timer or from a message arriving from Linux, such as START or STOP.

I added a test suite for the start and stop commands:

OpenPixel PRU self-test using /dev/rpmsg_pru30
PASS: STOP_TEST -> OK
PASS: PING -> PONG
PASS: GET_STATUS -> IDLE
PASS: GET_REMAINING -> 0
PASS: START_TEST -> OK
PASS: START_TEST -> BUSY
PASS: GET_STATUS -> RUNNING
  automatic run: 5901 ms remaining
  automatic run: 2847 ms remaining
  automatic run: 0 ms remaining
PASS: automatic run returned to IDLE
PASS: GET_REMAINING -> 0
PASS: START_TEST -> OK
PASS: GET_STATUS -> RUNNING
PASS: STOP_TEST -> OK
PASS: GET_STATUS -> IDLE
PASS: GET_REMAINING -> 0
All OpenMotion PRU tests passed.

Once the start and stop mechanism was working, I added a step pulse. A step pulse is a high-to-low or low-to-high transition that tells the hardware being controlled to execute one step, or microstep, depending on the driver configuration.

This exact use case is why I chose to work with PRUs in the first place. As visible in the following signal graph I obtained with a Logic Analyzer. Brief description of the image

I am monitoring the P9_31 pin which has been muxed in the dtb to operate as output pin for the PRUs and will show the signal which it emits. I have added a 5 μs pulse width on each rising edge, which gets sent periodically every second. Brief description of the image

Yay? The patient seems to be alive :)

My next goal was to render a letter, such as an X, on the 8×8 display. Each pixel uses three 8-bit color values, so one frame contains:

For my red X frame, the 48 unlit pixels are black. Each of the 16 red pixels has eight 1 bits in its red value, so there are 16 × 8 = 128 one bits. The other 1,408 bits are zero.

At a 24 MHz logic-analyzer sample rate, each sample lasts:

1 ÷ 24,000,000 = 41.67 ns

The measured pulse widths were:

Bit High samples High time Low samples Low time
0 8–9 333–375 ns 21–22 875–917 ns
1 17–18 708–750 ns 12–13 500–542 ns

And upon no surprise i find the 2 bits 0 and 1 next to each other exactly 375ns and 708ns wide.

First image Second image

Most bit periods measured 30 samples:

30 × 41.67 ns = 1.25 μs

The capture showed periods of 29–31 samples, or about 1.21–1.29 μs.

At a nominal 1.25 μs per bit:

1,536 × 1.25 μs = 1.92 ms

Theortically if we calculate max FPS we would get around (1/1.92ms) = 520 FPS which honestly comes as a surprise to me.

After the frame is transmitted, the signal stays low so the pixels can latch it.

Brief description of the image

Great once the signal is verified, I had to now send this to the RGB board, but that had its own problems. Firstly the one I ordered which looks like this:

First image Second image

As you can see it has only solder pads and that is gonna be a problem because I didn't have a soldering machine (yet). Well thanks to the generous people online for providing solderless connectors, I printed one for a 3 pin so I could connect to the V-, IN, V+ pads on this board.

Brief description of the image

I connected the display to the BeagleBone Black like this:

Panel pad BeagleBone Black connection
V− P9_ 1 or P9_ 2 (GND)
IN P9_ 31 (PRU data output)
V+ P9_ 5 or P9_ 6 (5 V)

I then ran a test script that sent a bright red X frame to the display and it worked! At the highest brightness, the image had too much flare, so I took a photo at a much lower brightness.

Brief description of the image

After verifying the signal and displaying a frame, I wanted to build a command-line tool that accepts text and displays it as a marquee. The tool should accept options such as brightness, speed, and color.

The plan is:

  1. Take input such as "TEST STRING".
  2. Convert each character into a 5×7 bitmap.
  3. Add an empty column after each character, making each character six columns wide.
  4. Send the bitmap and metadata to the PRU.

The bit map calculation for each letter is kinda simple. Suppose we take the letter F:

       columns →  0 1 2 3 4
row 0             # # # # #
row 1             # . . . .
row 2             # . . . .
row 3             # # # # .
row 4             # . . . .
row 5             # . . . .
row 6             # . . . .

Each column is stored as a byte:

Column Lit rows Calculation Byte
0 0–6 1 + 2 + 4 + 8 + 16 + 32 + 64 7F
1 0, 3 1 + 8 09
2 0, 3 1 + 8 09
3 0, 3 1 + 8 09
4 0 1 01
5 none Blank spacing column 00

So F is represented by:

7F 09 09 09 01 00

At four columns per second, the PRU waits about 250 ms between shifts. At 200 MHz, that interval is 50 million ticks. Each RPMsg packet has a 496-byte payload limit. The first 10 bytes hold metadata like this:

Byte range Contents
0–1 Magic A5, protocol version 02
2–3 Number of bitmap columns
4–6 RGB color after brightness scaling
7–8 Repeat count; zero means continuous
9 Speed in columns per second
10 onward One bitmap byte per column

That leaves 486 bytes for bitmap columns. Since each character uses six columns, a packet can hold up to 81 characters.

When I first sent the frame, I assumed the top-left pixel was (0, 0) and used a normal row-and-column layout. That assumption was wrong, and the panel showed a scrambled pattern:

After debugging, I mapped the panel’s actual pixel order:

             x →   0   1   2   3   4   5   6   7
                  ───────────────────────────────
         y = 0  │  7  15  23  31  39  47  55  63
             1  │  6  14  22  30  38  46  54  62
             2  │  5  13  21  29  37  45  53  61
             3  │  4  12  20  28  36  44  52  60
             4  │  3  11  19  27  35  43  51  59
             5  │  2  10  18  26  34  42  50  58
             6  │  1   9  17  25  33  41  49  57
             7  │  0   8  16  24  32  40  48  56

and hence this formula – pixel = x * 8 + (7 - y) to map each coordinate to each actual pixel on board.

The display expects color data in GRB order. The PRU converts each RGB pixel to GRB before sending it. For example, a red pixel (255, 0, 0) becomes 00 FF 00 on the wire. For each color byte, the PRU sends the most-significant bit first. It drives P9_31 high and then low for each bit. A 1 gets a longer high pulse than a 0.

When the text has fully scrolled off the left side, the PRU either starts another pass or sends a final blank frame and returns to IDLE. After fixing the pixel mapping, I was able to display the result:

Closing Thoughts

This project started out very differently. I had initially thought of motion related use cases such as stepper motors to make use of the PRUs and the precise signals. But the display use case seemed way easier and also looked cooler with future use cases such as dashboard displays by daisy chaining multiple of these. I think the logic analyzer has to be the coolest tool here enabling one to see signals at the nanosecond level which is still mind-boggling to me! 3D printing a connector for those 3 pins was kinda unexpected but fun to think of on the fly.

More than anything, this project helped me better understand where a real-time coprocessor fits into an embedded Linux system, and why having deterministic control over hardware can be so useful. Future plans for this mostly would be to render actual video frames but I don't think that part should be that hard considering we just need to create a similar bitmap for each of the frame of the video and based on my calculations as mentioned before we can achieve upto 500 frames per second which is pretty wild!

Ok BYE :)