This example demonstrates how to build a 4-bit R-2R Resistor Ladder Digital-to-Analog Converter (DAC) and drive it using a cooperative, split-core real-time architecture inside the ICSSG0 subsystem.
In high-speed, real-time signal generation, execution timing must be absolutely predictable. If a single core tries to generate a high-speed wave and process asynchronous communication (like incoming RPMsg mailboxes), the timing loop will suffer from interrupt latency jitter whenever a message arrives.
To solve this, we divide labor between two separate cores in the same ICSSG0 cluster:
- PRU0 Core (
remoteproc0/pru0-dds-fw): Runs a tight, branchless output loop that reads a delay parameter from a specific memory address and drives the output pins. It is completely dedicated to timing and is never interrupted. - RTU0 Core (
remoteproc1/rtu0-rpmsg-fw): Manages the VirtIO RPMsg framework, listens for incoming frequency tuning words from Linux, parses them, and updates the delay parameter at the shared memory address.
Both cores have concurrent, zero-latency access to the subsystem's 64 KB Local Shared RAM located at offset 0x10000 in the PRU/RTU local address space. This memory acts as a lock-free, zero-overhead bridge:
- RTU0 writes the value:
*shared_frequency = val; - PRU0 reads the value:
delay_cycles = *shared_frequency;
An R-2R Resistor Ladder is a simple, highly effective circuit that implements digital-to-analog conversion using only two values of resistors:
[P8_16 (D3 - MSB)] ────[ 2R ]────┐
├───[ R ]───┐
[P8_15 (D2)] ──────────[ 2R ]────┘ ├───[ R ]───┐
│ ├───[ R ]───┐
[P8_11 (D1)] ──────────[ 2R ]────────────────┘ │ │
│ ├───► [ Analog Output ]
[P8_12 (D0 - LSB)] ────[ 2R ]─────────────────────────────┘ │
[ 2R ]
│
[P8_01 (GND)] ───────────────────────────────────────────────────────┴───► [ GND Output ]
At any node in the ladder, looking back toward the inputs, the Thévenin equivalent resistance is exactly
-
The input pins act as voltage sources (
$0\text{ V}$ or$3.3\text{ V}$ ). -
The binary weights are achieved because the current splits equally at each node (half going to the right, and half going down).
-
The final output voltage equation is:
$$V_{out} = V_{ref} \times \frac{D_3 \cdot 2^3 + D_2 \cdot 2^2 + D_1 \cdot 2^1 + D_0 \cdot 2^0}{16}$$ With
$V_{ref} = 3.3\text{ V}$ (the PRU high output level), this yields 16 discrete analog steps ranging from$0\text{ V}$ (digital0000) up to$3.09\text{ V}$ (digital1111or$\frac{15}{16} \cdot 3.3\text{ V}$ ).
-
Typical Values: Choose
$R = 1\text{ k}\Omega$ and$2R = 2\text{ k}\Omega$ (or$10\text{ k}\Omega$ and$20\text{ k}\Omega$ ). Using 1% tolerance metal film resistors is recommended to prevent step size non-linearity. -
Output Impedance: The Thévenin output impedance of an R-2R ladder is always equal to
$R$ , regardless of the digital input pattern. - Loading Effects: If you connect a low-impedance load (like a speaker or headphones) directly to the output, the load will draw current, distorting the divider network and causing the voltage steps to collapse. In professional circuits, an Op-Amp Voltage Follower (Buffer) is connected to the output to provide high input impedance and low output impedance.
Unlike standard application binaries, microcontroller and coprocessor development requires strict management of physical memory.
The RTU0 core has access to two Local Data RAM sections: RTU0_DMEM_0 (2 KB) and RTU0_DMEM_1 (2 KB). To prevent stack overflows (a common source of silent crashes in embedded systems), we partition our memory sections in J721E_RTU0.cmd:
.stacksection: Placed inRTU0_DMEM_1(PAGE 1), giving the call stack a dedicated 2 KB region..bss,.data, and.ciosections: Placed inRTU0_DMEM_0(PAGE 1), keeping static data isolated from the active stack pointer.
To enable RemoteProc interrupts (which Linux uses to notify the RTU core that it has sent an RPMsg), we define the interrupt controller resource map in our code.
However, this structure my_irq_rsc is parsed by the Linux kernel directly from the ELF headers on the host's filesystem, and it should not be loaded into the PRU's physical data memory.
In the linker script, we use the (COPY) attribute:
.pru_irq_map (COPY) :
{
*(.pru_irq_map)
}(COPY) flags this segment as non-loadable in the ELF program headers. If you omit (COPY), the RemoteProc loader will attempt to write the IRQ table bytes into the RTU's data memory during boot, which will fail with a kernel memory copy error (PRU memory copy failed for da 0x1250).
The __delay_cycles(const) intrinsic is a compiler-specific macro that inserts a precise sequence of static instructions. It requires a compile-time constant argument.
Because the delay cycles in our program are adjusted dynamically at runtime via RPMsg, we must write a variable loop in C:
volatile uint32_t temp = delay_cycles;
while (temp > 0) {
temp--;
__asm(" NOP");
}- Optimizer Preservation: The
__asm(" NOP")instruction is crucial. Sincetempis a local variable whose final value is never read, a standard compiler optimizer would see this loop as a "no-op" and completely remove it. The inline assembly NOP statement forces the compiler to retain and execute the loop structure. - Cycle Predictability: Each iteration of this loop compiles to a subtract (
sub), a NOP (nop), and a quick conditional branch (qbne), taking roughly 3 to 4 clock cycles per loop iteration. At 200 MHz, this translates to 15-20 ns per increment.
TI's clpru compiler enforces C89/C90 standard constraints by default. One of the most important rules of C89 is that all variables must be declared at the beginning of a block scope before any executable code statements.
- Incorrect (C99 style):
for (uint16_t i = 0; i < len; i++) { ... }
- Correct (C89 style):
uint16_t i; for (i = 0; i < len; i++) { ... }
RPMsg (Remote Processor Messaging) is a standard virtio-based messaging bus that allows the Linux host and the coprocessor cores to communicate over shared memory buffers:
- Vrings Allocation: The Linux kernel allocates shared ring buffers (vring0 and vring1) in host DDR memory.
- Kick Notification:
- When the host writes to
/dev/rpmsg_pru30, it places the message in vring0 and triggers a hardware mailbox kick. - This kick raises system event 21 on the ICSSG interrupt controller, which triggers Host Interrupt 10.
- The RTU0 core detects this by checking bit 30 of register R31 (
__R31 & (1 << 30)), clears the interrupt, and reads the buffer usingpru_rpmsg_receive().
- When the host writes to
- Response: The RTU0 core processes the buffer, writes the response to vring1, and calls
pru_rpmsg_send(), which kicks the host back to trigger a Linux read interrupt.
On your Host PC, run the build script:
./build.sh example-05-pru-dds-rtuThis compiles the sawtooth generator, the RPMsg receiver, and the DTBO.
Deploy the DTBO overlay to set the expansion pins to pruout Mode 0:
./example-05-pru-dds-rtu/enable_overlay.sh debian@192.168.1.151Reboot the BeagleBone board to apply:
ssh debian@192.168.1.151 sudo rebootRun the deployment script to load and initialize both cores:
./example-05-pru-dds-rtu/deploy_and_test.sh debian@192.168.1.151This script starts the RTU0 core first (to announce the "rpmsg-pru" channel) and then PRU0. It will output SUCCESS: /dev/rpmsg_pru30 is available. if successful.
Copy and run the Python sweeping script on the BeagleBone board to vary the delay dynamically:
# Copy python script to the board
scp example-05-pru-dds-rtu/host_control.py debian@192.168.1.151:/tmp/
# Run the sweeper script
ssh -t debian@192.168.1.151 "sudo python3 /tmp/host_control.py"You will see the dynamic delay updates printed to the console. If you connect an oscilloscope or multimeter to the R-2R ladder output, you will see the staircase sawtooth waveform frequency scale dynamically in response to your host commands!