Systems & Stories

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:

  • 64 pixels × 3 bytes = 192 bytes
  • 192 bytes × 8 bits = 1,536 bits

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 :)

Next couple of blogs are going to be centered around Embedded Linux boards because that’s something that has always fascinated me. One of the best boards in the market with huge adoption, excellent documentation, open DTBs, detailed datasheets, and a very active open-source ecosystem is the BeagleBone Black — so naturally, I bought one.

It has always been fun messing around with boards, kernels, bootloaders and random hardware bring-up experiments. But what truly goes on from the instant power is applied to a fully operational Linux system is far more complex than most people realize. There’s an entire chain of events involving ROM code, SRAM, DDR initialization, PLL stabilization, memory calibration, bootloaders, kernel loading, and finally userspace startup.

The entire flow on the BeagleBone Black looks roughly like this:

At a very high level, power is first applied to the board, after which the AM335x Boot ROM begins executing. The ROM loads the SPL/MLO into SRAM, which then initializes clocks, DDR, PMICs and other low-level hardware. Once external DDR becomes usable, full U-Boot is loaded into memory, followed by the Linux kernel, device tree and finally the root filesystem before Linux userspace comes alive. The Boot ROM itself is burned directly into the AM335x SoC by Texas Instruments and is immutable by design. Since TI manufactures only the SoC and not the final board, the ROM has no knowledge about the exact DDR layout, PMIC configuration, GPIO routing, peripherals or memory timings used by a specific board like the BeagleBone Black. It intentionally performs only minimal initialization required to locate and load a secondary bootloader into SRAM.

That responsibility falls onto the board-specific SPL/MLO and U-Boot code provided by projects like BeagleBone. In other words, the actual usable boot sequence is really ROM → MLO/SPL → U-Boot → Linux Kernel → RootFS

My current development setup looks something like this:

The SD card only contains the earliest bootloader stages (MLO and u-boot.img). The actual Linux kernel (uImage) and Device Tree are served over TFTP from the host machine, while the root filesystem is mounted over NFS. UART logs are captured through a USB-UART adapter connected to the host machine so that every stage of the boot process can be monitored live.

This setup is incredibly convenient because kernel iterations become extremely fast. Device Trees can be modified instantly without reflashing SD cards, rootfs changes appear immediately over NFS, and complete boot logs are always visible over UART. Once the Boot ROM loads the SPL into SRAM, the real board bring-up begins. At this stage, the system is still operating in an extremely primitive state. DDR is not initialized yet, most peripherals are unavailable, the normal serial subsystem does not exist yet, and only a tiny amount of SRAM is available for execution. That means debugging this stage is not as simple as adding a printf() somewhere in the code. To enable very early UART debugging support inside SPL, I enabled the following U-Boot configuration options:

CONFIG_DEBUG_UART=y
CONFIG_DEBUG_UART_OMAP=y
CONFIG_DEBUG_UART_BASE=0x44e09000
CONFIG_DEBUG_UART_CLOCK=48000000
CONFIG_DEBUG_UART_SHIFT=2

These enable a minimal UART backend that works before the full serial driver stack is initialized. Without this, early SPL debugging would effectively be blind. After enabling these and sprinkling debug logs throughout the SPL code, the actual early boot flow became visible:

This was probably the most fascinating part of the entire experiment because you can literally watch the SoC slowly “wake up” in real time. First the watchdog gets disabled so the board does not reset during bring-up. Then UART pinmuxing appears, clocks become active, PLLs stabilize, PMIC voltages get configured, DDR PHY calibration begins and eventually external RAM becomes usable. One thing that immediately caught my attention in this sequence was the DDR initialization path. A lot of questions started appearing naturally: – How is time even measured this early? – What exactly is a DPLL? – How are DDR clocks generated? – Where do DDR timing values come from? – Why are timings so board specific?

The BeagleBone Black starts with a very simple 24 MHz crystal oscillator physically present on the board:

But DDR memory obviously cannot operate at just 24 MHz. To generate the required high-speed clock, the AM335x uses a DPLL (Digital Phase Locked Loop), which multiplies the reference clock frequency. In the case of the BeagleBone Black DDR subsystem: 24 MHz × 16.67 ≈ 400 MHz So the DDR subsystem ultimately operates at roughly 400 MHz. This explains the DDR PLL and clock generation part. But the far more interesting topic is what happens after the clock becomes stable because having a 400 MHz clock alone is not enough. The controller still needs to know: – how long rows need to stay active – when reads are allowed – how frequently refreshes happen – how long writes take – how quickly rows may be switched – when precharge operations complete

That entire behavior is controlled through the EMIF timing registers. For the BeagleBone Black, the DDR timing configuration structure looks something like this:

These are not arbitrary hexadecimal constants copied from somewhere on the internet. Each register contains multiple tightly packed DDR timing parameters encoded into specific bitfields.

For example: sdram_tim1 = 0x0aaad4db contains fields for: – tRCD – tRP – tRAS – tWR – tRRD – and several other DDR timing constraints

One particularly interesting register is the refresh controller. DRAM cells store information as tiny electrical charges inside capacitors. Those charges naturally leak away over time, which means memory rows must periodically be refreshed or the stored data disappears. On the AM335x EMIF controller, refresh timing is configured through ref_ctrl.

The stock BeagleBone Black configuration uses `ref_ctrl = 0x00000c30`.
The programmed refresh counter value becomes:
0x0c30 = 3120 cycles
Since DDR operates at 400 MHz:
1 cycle = 2.5 ns
Therefore:
3120 × 2.5 ns = 7.8 us

meaning the EMIF performs a refresh operation roughly every 7.8 microseconds. Naturally, the next thought was what happens if I intentionally break these timings? Surprisingly, increasing the refresh interval significantly did not immediately crash the board. I pushed the refresh timing as high as 161.4 us and the system still continued operating. I stress-tested memory using both mtest and memtester, but the board remained surprisingly stable.

So I moved on to a much more critical timing parameter: tRCD. tRCD stands for RAS-to-CAS Delay and defines how long the controller must wait after activating a DRAM row before issuing a READ or WRITE command. Inside SDRAM_TIM_1, the tRCD field is encoded in bits [24:21]. From 0x0aaad4db the encoded value becomes 5. The AM335x EMIF timing fields follow this encoding rule: actual value = encoded value + 1.

Therefore:
tRCD = 5 + 1 = 6 cycles
At 400 MHz DDR:
1 / 400,000,000 = 2.5 ns
which means:
tRCD = 6 × 2.5 ns = 15 ns

So the EMIF waits roughly 15 ns after opening a DRAM row before attempting to access data from it. At first, I reduced this from 6 cycles → 5 cycles which changes the delay from 15 ns → 12.5 ns and surprisingly the board still booted successfully. But once I reduced it further 6 cycles → 3 cycles which becomes 15 ns → 7.5 ns the board finally became unstable and crashed shortly after full U-Boot started. That behavior makes perfect sense because reducing tRCD too aggressively causes the EMIF controller to issue READ or WRITE commands before the DRAM row has fully stabilized internally. At that point, memory accesses become timing-unsafe and corruption starts appearing. One interesting observation was that SPL itself still continued functioning even with broken DDR timings. The reason is fairly simple: SPL barely uses DDR compared to full U-Boot. SPL primarily executes from SRAM, uses very small buffers and performs relatively few memory accesses. Full U-Boot, however, relocates itself entirely into DDR, enables networking, loads kernels and starts stressing memory much more heavily. That increased memory pressure is what finally exposes unstable DDR timings.

Stay Tuned for More :)

By – Shaurya TW: AI Assistance has been used for image gen and some paraphrasing of texts.

Whatsapp and iMessage are a duopoly in the instant messaging space which is probably not new to anyone. Meta is known for making money from user data. Living in India means Whatsapp is the primary means of communication. (iMessage not used as much due to the abundance of SMS spams and lack of iPhone users). To my surprise on my PiHole setup one day I noticed a lot of requests being blocked to this domain – dit.whatsapp.com

Looking at the networking tab in my browser while using whatsapp web –

This kind of sent me down the rabbit hole of understanding what this exactly was and made me aware of the DIT metadata system meta engineering blog whatsapp faq post.

It's of course impressive the amount of engineering it takes to deploy such a system at scale. Also is a bit weird that this much engineering effort was put to just collect user data. Even though its anonymous as they repeatedly claim in the blog, and the intention is just to improve the working of the app and do analysis on events such as app crashes, they could change this at anytime since its not open sourced. And as mentioned in various posts such as this and this, its very clear that the anonymous claims aren't very promising either.

If the privacy angle isn't enough to migrate, I think this graph can drive another point home.

For the unaware, Whatsapp Business API is the cash cow which gave Meta multifold returns on their $19B buyout of the business back in 2014. A direct effect of this is very noticeable now after number of years of free use. This is how most people's archived chats look like (if they use this feature like me to basically shove all the businesses spamming ads).
Tired of all this I went looking into various threat models for instant messaging services and found a couple of attack vectors I had concerns over: – Identity registration service – Cloud backup service – Media upload endpoints – Messages service – Audio/Video call service – Push notification service

If one takes the case of Whatsapp or any centralized messaging apps, the claims of end to end encryption can maybe put your worries to rest when it comes to the messaging service but MITM attacks are still a concern as concluded in this white paper.

When it comes to the cloud backup system, that is a major concern due to it being unencrypted by default. In this white paper it is also discussed about some issues with the password attempt count being modifiable –

Tons of folks in the privacy space recommend Signal being the go to with the highly battle tested Signal protocol, but I did want to go full decentralized due to the elimination of any central trust anchor and see how that would work. I think Signal should be sufficient, but now that I am in this rabbit hole, might as well go full psycho.

Now there are quite a lot of great comparisons online such as this, and I was curious to try SimpleX chat out based on its move away from any user identifiers at all. One great thing about this when compared to something like Session is that I am not too fond of the idea of onion routing just because you would be adding your server as a node in the tor network. I would much rather prefer my server just being used for communication amongst my friends and family (one can argue this is less private but I would prefer it this way)

Five out of the six main issues which are mentioned earlier are all basically solved if I go with SimpleX. Why push notifs is still kind of an issue I will get into later. For the more curious folks wanting to do a deep dive into its architecture, please check out their protocol spec.

  1. Starting off with identity registration being the main problem with most apps like Signal/Whatsapp/Telegram, I never understood why phone numbers have to be involved. My idea of an instant messenger would involve sharing some sort of link/QR code with someone you met and can now keep in touch with. This idea of phone numbers being involved in any messaging app will eventually lead them to capitalize on it by sending you ads. (Since your phone number is basically linked to all other services)
  2. Cloud backups as mentioned earlier are a major issue by design because you are basically sending all your chat logs somewhere less secure. Now end to end backups are a thing, but I still would want control over where my backups get pushed to / what I can do with them. A simple export and import option would be great but whatsapp still doesn't allow an easy way to import the exported data.
  3. Support for self hosted messaging service (SMP) and media file transfer service (XFTP) is one of the biggest advantage with SimpleX and removes reliance on any centralized authority.
  4. Audio and video in most modern apps use Web-RTC based communication and in this case we again have the option to use our own.
  5. Push notification is a major problem due to Apple enforcing a closed ecosystem requiring all notifications to pass through the Apple Push Notification service (APNs), which have very strict rules. Unlike other platforms, iOS does not allow apps to maintain direct, persistent connections to custom servers to receive messages, making a custom backend to APNs bridge necessary. On Android, its very much possible thanks to UnifiedPush and other such implementations.

How I Setup SimpleX Chat with custom servers

Now that we have discussed the concerns and understood other options out there, let's set this up so people can use it with their peer groups. I want to keep the setup fairly simple like this –

If you or your peer has this setup already then all you would need is the addresses for the SMP, XFTP and the WebRTC servers and you should be good to go. If you are the first one in your group, then I'm just going to provide some links related to hosting these which you can follow - – Simplex Chat Self Hosting Documentation – Simplex as your private messenger – Coturn Docker Image – Smp-server Docker Image – XFTP-Server Docker Image

In my case, due to resource limitations in my vps, I didn't go ahead with standard docker images but instead pulled the binaries out of the docker images and just created systemd services to avoid docker overhead.

Once the servers are setup, can follow the steps below for setting up the client app -

  1. When one installs the app and has created their profile, you are given a choice to choose either SimpleX or Flux servers, can choose either for now, we would not be using these later anyway.
  2. Notification privacy options can be confusing. This is the only service in the app which is not decentralized, due to restrictions enforced by Apple as mentioned before but they do give an option to not use any push servers. I would probably go with the instant option for now since its as secure if not better than other messaging apps.
  3. Once the initial setup is done, we dive into Settings > Network & servers > Your servers and add both the custom SMP and XFTP servers. If using port other than 5224 for xftp then should be mentioned in the address. Here the format is usually: xftp://<server-identifier>@<domain-name>:<custom-port> If using port other than 5223 for smp then should be mentioned in the address. Here the format is usually: smp://<server-identifier>@<domain-name>:<custom-port> Do test out both servers and enable use for new connections
  4. After adding both servers just disable the SimpleX Chat preset server and Save the custom servers. Set the WebRTC ICE servers by adding details for your TURN and STUN servers. The format is usually -stun:<domain>:<port>turn:<username>:<password>@<domain>:<port>
    And voila you should have your custom completely private setup running. Now all you have to do is share the qr codes with friends and enjoy super private messaging! More on that here

By – Shaurya No AI involved in writing these :)