Skip to content
RetrogamingDeep Dive Published Updated 10 min readViews unavailable

Nintendo DS IPC FIFO and IPCSYNC: Reliable ARM7/ARM9 Messaging

Understand Nintendo DS ARM7/ARM9 IPCSYNC and FIFO registers, interrupt edges, backpressure, message framing, and emulator test cases.

The Nintendo DS is a dual-processor system: ARM9 application code and ARM7 code often need to exchange commands, completion notices, and small pieces of state. They can communicate through shared memory, but a shared address alone does not say when a new value is ready, who owns it, or how the other processor should be notified. The DS provides two complementary hardware mechanisms: IPCSYNC, a compact four-bit handshake with an optional interrupt, and an inter-processor communication FIFO for ordered 32-bit words.

For emulator developers, these registers are not just plumbing. Games and system software can observe queue-full and queue-empty states, error flags, interrupt timing, and the value returned by an invalid read. An implementation that only delivers the right words eventually can still boot unreliably or lose synchronization if it gets those details wrong.

Two mechanisms, two jobs

IPCSYNC at 0x04000180 is a small signaling register. Bits 8–11 are the local processor’s four-bit output, visible in bits 0–3 of the remote processor’s register. Bit 13 sends an interrupt request to the other processor when written as one; bit 14 enables receipt of that synchronization interrupt locally. This makes IPCSYNC useful as a doorbell or for a tiny state code, not as a payload transport.

The FIFO registers are at IPCFIFOCNT (0x04000184), IPCFIFOSEND (0x04000188), and IPCFIFORECV (0x04100000). Each processor has a send queue of up to 16 32-bit words, or 64 bytes. The other processor observes those same words as its receive queue. The reverse direction has its own independent queue, so ARM7-to-ARM9 traffic does not consume ARM9-to-ARM7 capacity.

Register field Meaning from the local processor’s perspective Typical use
IPCSYNC[8:11] Four-bit value sent to the peer Small handshake state or doorbell data
IPCSYNC[0:3] Four-bit value most recently sent by the peer Observe the peer’s handshake state
IPCFIFOCNT[0] / [1] Local send FIFO empty / full Avoid sending into a full queue
IPCFIFOCNT[8] / [9] Local receive FIFO empty / full Avoid reading when there is no queued word
IPCFIFOCNT[2] / [10] Enable send-empty / receive-not-empty interrupt Wake a processor on a queue transition
IPCFIFOCNT[14] FIFO error status Diagnose an empty read or full-queue write
IPCFIFOCNT[15] Enable the local send/receive FIFO interface Enable FIFO operation on each processor

The status bits are local views, not one shared global “FIFO state” value. When ARM9 writes its send FIFO, ARM9’s send-not-empty state and ARM7’s receive-not-empty state describe opposite ends of the same queued data. Logging both registers is often more useful than logging only the sender.

Safe initialization and non-blocking access

Each processor should initialize its own FIFO interface. A common low-level initialization sequence enables the FIFO and clears that processor’s outgoing queue by writing bit 15 and bit 3. Configure interrupt-enable bits deliberately afterward; a full-register assignment also writes those control bits.

The register contract rewards a simple rule: check the relevant status before performing a destructive read or a potentially overflowing write. The following pseudocode uses the classic register interface to show that rule; it is not a drop-in example for every libnds version.

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#define IPC_FIFO_SEND_FULL   (1u << 1)
#define IPC_FIFO_RECV_EMPTY  (1u << 8)
#define IPC_FIFO_ENABLE      (1u << 15)

#define REG_IPCFIFOCNT  (*(volatile uint16_t *)0x04000184u)
#define REG_IPCFIFOSEND (*(volatile uint32_t *)0x04000188u)
#define REG_IPCFIFORECV (*(volatile uint32_t *)0x04100000u)

static bool ipc_try_send(uint32_t word)
{
    const uint16_t status = REG_IPCFIFOCNT;
    if ((status & IPC_FIFO_ENABLE) == 0 ||
        (status & IPC_FIFO_SEND_FULL) != 0)
        return false;

    REG_IPCFIFOSEND = word;
    return true;
}

static bool ipc_try_receive(uint32_t *word)
{
    const uint16_t status = REG_IPCFIFOCNT;
    if (word == NULL || (status & IPC_FIFO_ENABLE) == 0 ||
        (status & IPC_FIFO_RECV_EMPTY) != 0)
        return false;

    *word = REG_IPCFIFORECV;  /* Reading removes the oldest queued word. */
    return true;
}

The sender must treat false as backpressure, not as permission to spin forever. It can retain the unsent word and retry from a main-loop service routine, or arrange a bounded retry using the application’s scheduler. A blocking loop inside an interrupt handler is especially dangerous: the peer may need the very CPU time or interrupt service that the blocked processor is preventing. Keep a single logical producer per direction, or serialize sends if both normal code and an interrupt handler can write to the same local FIFO.

There are two important disabled/empty corner cases. If the FIFO is disabled, a send write is ignored without setting the error bit, while a receive-register read returns the oldest queued word without popping it. If the enabled receive FIFO is empty, a read returns the most recently received word, or zero if there has not been one (including after a clear), and sets the error status. Do not use the returned value to detect “no message”: zero can be valid application data, and an empty read is an error, not a non-blocking poll.

The error bit also matters on overflow. A write made while the send FIFO is already full is an error; checking bit 1 before writing avoids depending on overflow behavior. The status definition describes bit 14 as an error/acknowledge field, so error handling should explicitly acknowledge it according to the target register contract rather than treating it as an ordinary software boolean.

Interrupts are edge-triggered notifications

The FIFO interrupt enables do not mean “interrupt for every word.” The send-empty interrupt condition is the enabled local send FIFO becoming empty; the receive interrupt condition is the enabled receive FIFO becoming non-empty. The interrupt request is raised on the corresponding false-to-true transition. The DS interrupt flags are IF bits 17 and 18 for send-empty and receive-not-empty respectively.

That edge behavior affects the receive loop. If three words arrive before the receiver runs, the FIFO changes from empty to non-empty once, so the receiver should drain all available words. After it drains the queue, a later arrival can create a new empty-to-non-empty transition. If the handler reads only one item and leaves the queue non-empty, it should not expect a fresh edge for each remaining item. Conversely, an empty interrupt is a useful “all submitted words have drained” signal, but not a notification for each newly available slot.

When acknowledging an interrupt, write one to the relevant IF bit as required by the interrupt controller. The FIFO condition and the pending IF flag are distinct: acknowledging the flag does not consume queue data. Design the handler to clear or otherwise resolve the condition, drain the queue as appropriate, and then return promptly. Confirm the exact interrupt-wrapper conventions in the SDK in use.

For small state changes, IPCSYNC can avoid putting a one-word notification in the FIFO. Write the local four-bit code to bits 8–11 and, when needed, strobe bit 13; on the peer, enable bit 14 and inspect bits 0–3. Treat the payload bits and the interrupt strobe as separate controls. IPCSYNC is not a substitute for a multiword protocol, and it does not make unrelated shared-memory writes atomic or automatically establish a portable memory-ordering contract in every SDK.

Define a protocol above the word stream

The FIFO transports words, not application messages. It does not provide opcodes, lengths, acknowledgments, or a guarantee that a multiword command fits in the currently free capacity. Define those properties explicitly. For example, a one-word command could dedicate fields to a message kind, a small sequence number, and an argument. A larger command can send a header containing a version and payload length, followed by exactly that many data words.

Keep the receiver as a bounded state machine: accept a valid header, check that its length is within a protocol maximum, collect no more than that many words, validate the sequence or checksum if the protocol uses one, and only then apply the command. If the FIFO fills halfway through a multiword frame, preserve the sender’s offset and resume later; do not restart from word zero. If a reset or malformed header makes both ends disagree about framing, define a resynchronization rule rather than assuming the next word is a header.

For multiword transfers, make partial delivery an ordinary state. The control register reports empty and full, not an atomic free-slot count, so hardware alone cannot reserve room for an entire frame. If a protocol needs all-or-nothing admission, add an explicit credit or outstanding-word scheme; otherwise retain the frame offset and resume as the peer drains the queue. Do not let independent producers interleave words in one direction: a header from one command followed by another producer’s payload is indistinguishable from a valid stream to hardware. Keep shared-memory buffers separate from FIFO control words, and send an explicit offset/length or completion message when shared data is involved. The receiver should never infer that a shared buffer is ready merely because it saw an unrelated FIFO word.

The FIFO and shared RAM solve different problems. FIFO words have ordering and bounded capacity, making the queue suitable for commands and small messages. Shared memory can carry larger payloads without spending 32-bit FIFO slots, but it needs an ownership and notification protocol. A common design sends a descriptor or sequence number through IPC, keeps the data in a mutually visible buffer, and has the receiver validate that the descriptor refers to a complete buffer. The SDK’s cacheability and synchronization rules still govern that buffer; a FIFO event by itself is not a universal memory barrier.

Emulator model and regression cases

Model each direction as a bounded queue of 16 words, with independent control-register views for the sending and receiving CPUs. A send updates the sender’s empty/full state and the peer’s receive empty/full state. A receive pops the oldest word and updates both views. Model the enable bit, clear operation, error status, and last-received value as explicit state; these are observable behaviors, not implementation details that can be replaced with an unbounded host queue.

Schedule interrupt edges from changes in the documented conditions, not merely from every register access. In particular, receive-not-empty should transition once when data first arrives to an empty queue, and again only after the queue becomes empty and then receives data. Send-empty is similarly tied to the transition back to empty. When executing both ARM CPUs, preserve the order of a write, queue-state update, and the peer’s ability to observe the associated interrupt. A coarse emulator scheduler can accidentally deliver the interrupt before a state change is visible or delay the peer long enough to create behavior that hardware cannot exhibit.

A useful regression matrix includes:

  • Enable and clear each processor independently; confirm the two traffic directions remain independent.
  • Send one word and verify FIFO order, sender status, peer receive status, and the first receive interrupt.
  • Fill exactly 16 slots, confirm full status at both ends, drain one word, then refill without loss or reordering.
  • Attempt a 17th write in a dedicated error test and verify the documented error behavior; ordinary software should instead stop at full.
  • Read an empty queue and a disabled queue separately; verify returned values, pop behavior, and error state match the hardware reference.
  • Queue several words before servicing the receive interrupt; check that the handler drains the available batch and that a later empty-to-non-empty transition raises a new edge.
  • Exercise IPCSYNC payload updates, remote interrupt enable, and interrupt strobe independently from FIFO traffic.
  • Save and restore with queued words, non-empty/full flags, error state, last-received data, and pending interrupt flags; compare the post-restore sequence against uninterrupted execution.

Use hardware captures, known-good homebrew traces, and more than one emulator implementation where available. The GBATEK documentation is a valuable register reference, but it is a community-maintained technical reference rather than a Nintendo-published specification. Current development libraries also evolve: devkitPro’s libnds v2 release replaces the classic FIFO APIs with PXI, so application authors should target the API version they build against. The hardware-level register model remains useful when reading old software, implementing an emulator, or bringing up a low-level ARM7/ARM9 protocol.

Related:

Sources:

Comments