Understanding GPIO DMA on Teensy 4.1

DMA is one of those features that sounds simple until you actually try to use it.

The basic idea behind Direct Memory Access is straightforward: instead of having the CPU repeatedly move data between a peripheral and memory, configure the DMA hardware to do the transfers for you. That frees the processor to work on something else and, more importantly for high-speed acquisition, avoids having to execute an interrupt service routine for every sample.

Paul Stoffregen

Paul's Deep Dives

Hello SparkFans! Paul here from PJRC. I spend a lot of time on the PJRC forum helping people solve all kinds of tricky problems. Some of those discussions offer especially useful insights into how things really work.

Paul’s Deep Dives are written by SparkFun, drawing from Paul’s original forum posts and technical guidance. Each article revisits a discussion from the PJRC forum, adding context and organizing the material into a deeper technical walkthrough while preserving Paul’s original engineering insights.

On Teensy 4.1, the hardware is certainly capable of some impressive DMA tricks. The difficult part is figuring out how all the pieces fit together.

There isn't really an easy DMA tutorial for the i.MX RT1062. The comments in DMAChannel.h are probably the closest thing we have to an introduction, and existing Teensy libraries are useful examples. But GPIO DMA involves several parts of the chip at once: GPIO, IOMUX, DMA, DMAMUX, XBAR, and usually a timer or external clock source.

There are also a couple of important details that aren't obvious from reading NXP's enormous reference manual.

So rather than trying to explain every feature the DMA controller can perform, I want to concentrate on one practical problem:

How do we capture parallel GPIO data into memory at a fixed sample rate without making the CPU handle every sample?

That's a good problem for understanding how DMA on Teensy 4 actually works.

Why DMA in the First Place?

The original problem that started this discussion was sampling 28 GPIO states at 2 million samples per second.

Doing that from an interrupt can work surprisingly well for a while. Conceptually, the code looks something like this:

void myISR()
{
    buffer[position] = GPIO6_DR;
    position++;

    if (position == BUFFER_SIZE) {
        position = 0;
        // switch buffers
    }
}

But at 2 MHz, that interrupt runs two million times every second.

Now imagine the rest of the application also needs to package those samples into UDP packets and send them over Ethernet. Suddenly the processor is trying to service a very frequent interrupt while the networking stack is doing work of its own.

That is exactly the sort of situation where DMA starts to make sense.

Instead of this:

External clock
      |
      v
    CPU ISR
      |
      v
 Read GPIO
      |
      v
 Write RAM

we want the hardware to do this:

External clock
      |
      v
     XBAR
      |
      v
    DMAMUX
      |
      v
     DMA
   /     \
GPIO      RAM

The CPU doesn't need to participate in every sample. It only needs to hear from DMA occasionally—typically when half or all of a buffer has been filled.

That changes the problem dramatically.

Start Slow

One of the most useful pieces of advice I can give when developing DMA code is this:

Don't start at 2 MHz.

Start ridiculously slow.

DMA debugging is difficult because when the configuration is wrong, the hardware usually doesn't give you a helpful error message. Often the only symptom is that nothing happens, the wrong memory changes, or everything happens much faster than you expected.

If your trigger runs very slowly, you can print memory locations and actually watch the DMA transfer progress.

Make any memory that DMA changes volatile where appropriate so the compiler doesn't assume that memory cannot change spontaneously.

Once the whole path works at a few Hertz, changing the trigger frequency to something much faster is easy.

Getting the plumbing right is the hard part.

The First Teensy 4 GPIO Trap: GPIO1 versus GPIO6

Before configuring DMA, there's an important detail about GPIO on the i.MX RT1062.

Each GPIO port effectively has two ways it can be accessed.

GPIO1 through GPIO4 live on the normal peripheral bus. GPIO6 through GPIO9 provide fast access to corresponding pins. Teensy normally configures pins to use those fast GPIO registers because they're much better when the ARM processor is manipulating GPIO directly.

That's why you'll commonly see code like:

GPIO6_DR

for fast direct GPIO access.

Unfortunately, DMA can't access those fast GPIO registers.

This is one of the details that's especially frustrating because it isn't clearly called out in the documentation.

For DMA, we need to use the normal GPIO registers—GPIO1 through GPIO4.

Individual pins can be switched between the fast and normal GPIO mappings using the IOMUXC GPR registers. For a group of pins belonging to GPIO1, for example, you'll see code along these lines:

GPIO1_GDIR &= ~(0x03FC0000u);
IOMUXC_GPR_GPR26 &= ~(0x03FC0000u);

The first line configures those GPIO bits as inputs.

The second switches those pins away from the fast GPIO6 mapping so they can be accessed through GPIO1.

That's essential. If you point DMA at GPIO6 and wonder why nothing useful happens, you can spend a very long time debugging the DMA configuration when the real problem is the bus the GPIO registers live on.

Think of GPIO as a 32-Bit Memory Location

Once the pins are routed to GPIO1, the actual DMA transfer is conceptually simple.

All 32 bits of a GPIO port are represented by a memory-mapped register. Reading that register gives us the state of the port.

So instead of having the CPU execute:

sample = GPIO1_DR;

we can tell DMA:

Every time you receive a request, read GPIO1_DR and put the resulting 32-bit value into the next location in this buffer.

That's exactly the kind of repetitive operation DMA handles well.

Understanding the DMA Transfer

The DMA controller uses a Transfer Control Descriptor, or TCD, to describe a transfer.

The TCD can look intimidating because the hardware supports a huge number of possibilities. For this application, though, we only need a small subset of them.

We want the equivalent of:

buffer[0] = GPIO1_DR;
buffer[1] = GPIO1_DR;
buffer[2] = GPIO1_DR;
buffer[3] = GPIO1_DR;
// ...

except each assignment happens when a hardware trigger arrives.

That tells us most of the DMA configuration immediately.

The source address always stays the same:

Source = GPIO1_DR
Source offset = 0

The destination moves forward one 32-bit word after each transfer:

Destination = buffer
Destination offset = 4 bytes

And because the GPIO registers require 32-bit access, each transfer is 4 bytes.

Using DMAChannel, much of that setup can be expressed quite simply:

DMAChannel dma;

dma.begin();
dma.source(GPIO1_DR);
dma.destinationBuffer(dmaBuffer, sizeof(dmaBuffer));

That's a much friendlier starting point than manually programming every TCD field.

DMAChannel also dynamically allocates a DMA channel. That's useful because Teensy libraries which use DMA generally use the same mechanism, reducing the chance that two libraries accidentally try to own the same hardware DMA channel.

Minor Loops and Major Loops

There are two DMA terms worth understanding because you'll encounter them constantly in the reference manual: minor loop and major loop.

For our GPIO capture, think of one minor loop as one sample.

A hardware event occurs:

clock edge
    |
    v
DMA request
    |
    v
read GPIO1_DR
    |
    v
write one uint32_t

Then the destination pointer advances four bytes.

The major loop is the collection of all those individual transfers needed to fill the buffer.

For example, with:

#define DMABUFFER_SIZE 4096

uint32_t dmaBuffer[DMABUFFER_SIZE];

we can configure DMA to perform 4096 minor transfers.

When that major loop completes, DMA can generate an interrupt.

dma.interruptAtCompletion();
dma.attachInterrupt(dmaInterrupt);

Now instead of interrupting the CPU for every sample, we interrupt it once after thousands of samples.

That's the real payoff.

The Harder Part: Where Does the DMA Request Come From?

Moving data from GPIO into RAM isn't actually the most difficult part.

Generating exactly one DMA request per sample is where things become interesting.

If we have an external ADC clock, we'd like every rising or falling edge of that clock to cause one DMA transfer.

But GPIO itself isn't one of the normal DMA request sources.

This is where the i.MX RT crossbar—XBAR—becomes useful.

XBAR is essentially a programmable routing fabric inside the chip. Signals from different peripherals and I/O pins can be connected to other internal peripherals.

For an external sampling clock, we can build this route:

External clock pin
       |
       v
     IOMUX
       |
       v
      XBAR
       |
       v
 DMA request generator
       |
       v
     DMAMUX
       |
       v
      DMA

It looks complicated because it is several separate peripherals, but each block is doing one fairly simple job.

Routing an External Clock Through XBAR

Suppose Teensy pin 4 carries our external sampling clock.

That pin can be routed to an XBAR input.

First we configure the pin's mux:

IOMUXC_SW_MUX_CTL_PAD_GPIO_EMC_06 = 3;

Then make sure that XBAR signal is configured as an input:

IOMUXC_GPR_GPR6 &=
    ~(IOMUXC_GPR_GPR6_IOMUXC_XBAR_DIR_SEL_8);

There's also a daisy-chain selection because this XBAR input can come from more than one physical pad:

IOMUXC_XBAR1_IN08_SELECT_INPUT = 0;

Then connect that XBAR input to one of the DMA request outputs:

xbar_connect(
    XBARA1_IN_IOMUX_XBAR_INOUT08,
    XBARA1_OUT_DMA_CH_MUX_REQ30
);

We also need to configure the XBAR output to generate a DMA request on the edge we care about.

For a rising edge:

XBARA1_CTRL0 =
    XBARA_CTRL_STS0 |
    XBARA_CTRL_EDGE0(1) |
    XBARA_CTRL_DEN0;

Finally, tell our DMA channel which hardware event to use:

dma.triggerAtHardwareEvent(DMAMUX_SOURCE_XBAR1_0);

Now every selected clock edge can cause one GPIO sample to be transferred into RAM.

There is one more easily missed detail.

The XBAR peripheral needs its clock enabled:

CCM_CCGR2 |= CCM_CCGR2_XBAR1(CCM_CCGR_ON);

Do this before configuring XBAR.

Otherwise you can write perfectly reasonable-looking XBAR configuration code and spend a lot of time wondering why none of it works.

Why Not Trigger Directly From a Timer?

If the sampling clock is generated internally rather than externally, using a timer sounds like the obvious solution.

There's an important catch.

A timer can assert a DMA request, but depending on how the timer and DMA are configured, the timer may not receive the acknowledgement it needs when DMA services that request. The request can remain asserted.

DMA then sees what amounts to:

REQUEST REQUEST REQUEST REQUEST REQUEST...

instead of:

request
   |
wait for next timer event
   |
request

The result can be a DMA channel that runs continuously and transfers the entire buffer as fast as the hardware allows.

This acknowledgement behavior is one of the important pieces that's very difficult to understand from NXP's documentation alone.

Routing the timer pulse through one of XBAR's DMA request generators is usually a much cleaner solution because those request generators automatically acknowledge the DMA service.

There is another solution involving two DMA channels: one performs the real transfer, and another performs a dummy operation that acknowledges or clears the timer condition. The first DMA channel can trigger the second.

That works, but it consumes another DMA channel and is more complicated.

Unless there's a good reason to do otherwise, I'd start with XBAR.

A Minimal GPIO-to-Memory Setup

Stripped down to the important pieces, the configuration looks approximately like this:

#include <DMAChannel.h>

DMAChannel dma;

#define BUFFER_SIZE 4096
uint32_t dmaBuffer[BUFFER_SIZE];

void dmaInterrupt()
{
    dma.clearInterrupt();
    asm("DSB");

    // Tell the main program a buffer is ready.
}

void setup()
{
    // GPIO1 bits used for parallel input
    GPIO1_GDIR &= ~(0x03FC0000u);

    // Move these pins from fast GPIO6 to DMA-accessible GPIO1
    IOMUXC_GPR_GPR26 &= ~(0x03FC0000u);

    // DMA: GPIO -> memory
    dma.begin();
    dma.source(GPIO1_DR);
    dma.destinationBuffer(dmaBuffer, sizeof(dmaBuffer));

    dma.interruptAtCompletion();
    dma.attachInterrupt(dmaInterrupt);

    // Enable XBAR
    CCM_CCGR2 |= CCM_CCGR2_XBAR1(CCM_CCGR_ON);

    // Route Teensy pin 4 into XBAR
    IOMUXC_SW_MUX_CTL_PAD_GPIO_EMC_06 = 3;

    IOMUXC_GPR_GPR6 &=
        ~(IOMUXC_GPR_GPR6_IOMUXC_XBAR_DIR_SEL_8);

    IOMUXC_XBAR1_IN08_SELECT_INPUT = 0;

    // Rising edge generates DMA request
    XBARA1_CTRL0 =
        XBARA_CTRL_STS0 |
        XBARA_CTRL_EDGE0(1) |
        XBARA_CTRL_DEN0;

    xbar_connect(
        XBARA1_IN_IOMUX_XBAR_INOUT08,
        XBARA1_OUT_DMA_CH_MUX_REQ30
    );

    dma.triggerAtHardwareEvent(DMAMUX_SOURCE_XBAR1_0);

    dma.enable();
}

This isn't meant as a universal copy-and-paste DMA library. The pin mapping and masks have to match the hardware you're actually using.

But it shows the basic architecture without all the complexity found in something like OctoWS2811.

Don't Start by Copying OctoWS2811's TCD

OctoWS2811 is a useful reference because it demonstrates that GPIO DMA works on Teensy 4, but I wouldn't recommend learning DMA by trying to understand its entire transfer configuration.

It's doing something substantially more complicated.

OctoWS2811 generates waveform data dynamically in chunks and uses multiple DMA operations. Its TCD configuration takes advantage of capabilities that simply aren't necessary for straightforward data acquisition.

For continuous input, a better mental model is the Teensy Audio library.

The audio code commonly lets DMA run continuously through a buffer. An interrupt occurs when part of the buffer has been filled, software consumes that part, and DMA continues filling another part.

Conceptually:

DMA ---> [ HALF A | HALF B ]
           ^          ^
           |          |
        process     filling

Then:

DMA ---> [ HALF A | HALF B ]
           ^          ^
           |          |
        filling     process

That's often exactly what we want for an ADC or parallel digital acquisition system.

Configure the DMA once, let it run continuously, and respond only when enough data has accumulated to justify involving the CPU.

Circular Buffers Make This Much Easier

For continuous acquisition, the destination address can automatically wrap back to the beginning of the buffer after reaching the end.

At the raw TCD level, the important setting is the final destination adjustment—often discussed as DLAST.

The DMA increments the destination by four bytes after each GPIO sample:

buffer + 0
buffer + 4
buffer + 8
buffer + 12
...

When the major loop finishes, DLAST adjusts the destination pointer back to the beginning.

Then DMA can simply continue.

That means we don't have to stop the acquisition, manually reset the pointer, and restart everything for every buffer.

DMAChannel provides helpers for common configurations, and I'd use those whenever possible rather than manually filling in every TCD register.

Don't Forget the Interrupt Cleanup

A typical DMA interrupt handler should clear the DMA interrupt:

void dmaInterrupt()
{
    dma.clearInterrupt();
    asm("DSB");

    bufferReady = true;
}

The DSB—Data Synchronization Barrier—is important on Cortex-M7 because writes to peripheral registers can be buffered.

Without the barrier, it's possible for the processor to logically leave the ISR before the peripheral write clearing the interrupt has fully taken effect.

That's the kind of tiny detail that can produce extremely confusing behavior in otherwise correct-looking code.

DMA and Cache Coherency

There's another issue that becomes important depending on where the buffer lives.

A simple global array such as:

uint32_t dmaBuffer[4096];

normally lives in Teensy's RAM1/TCM region, which isn't cached in the same way as RAM2.

But consider:

DMAMEM uint32_t dmaBuffer[4096];

or memory allocated with malloc(). Those normally reside in RAM2. External PSRAM on Teensy 4.1 is also cached.

DMA doesn't know anything about the Cortex-M7 cache.

DMA reads and writes physical memory. The CPU may be reading or writing cached copies of that memory.

That can produce a nasty situation:

CPU sees:       old cached data
DMA wrote:      new physical data

Both pieces of hardware are behaving correctly. They're simply looking at different copies.

Teensy provides cache maintenance functions for dealing with this.

Before DMA sends data from memory to a peripheral, flush modified CPU cache contents to physical memory:

arm_dcache_flush(buffer, size);

For DMA writing from a peripheral into memory, invalidate the relevant cache so the CPU subsequently reloads the DMA-written data:

arm_dcache_delete(buffer, size);

There's also:

arm_dcache_flush_delete(buffer, size);

The exact operation depends on which direction the data is moving.

DMA buffers in cached memory should also generally be aligned appropriately. You'll often see code like:

DMAMEM uint32_t dmaBuffer[4096]
    __attribute__((aligned(32)));

Thirty-two-byte alignment matches the Cortex-M7 cache-line size and avoids several unnecessary headaches.

How Fast Can GPIO DMA Actually Go?

This is where it's important not to confuse the 600 MHz ARM clock with the speed of every peripheral inside the chip.

The normal GPIO registers used by DMA are on the peripheral side of the device. They aren't equivalent to the fast GPIO6 access the CPU gets.

Experiments in the forum showed the difference quite clearly.

Direct CPU polling from GPIO6 can be considerably faster than equivalent access through GPIO1. Tests with heavily unrolled loops reached roughly 66 million GPIO6 reads per second at a 600 MHz CPU clock, while comparable GPIO1 testing was around 21 million reads per second.

Those numbers aren't specifications for DMA throughput, but they demonstrate an important architectural limitation: the DMA-accessible GPIO path is slower than the CPU's fast GPIO path.

Another GPIO DMA experiment using an externally clocked counter reported reliable operation around 10 MHz, with missed samples appearing above that point in that particular setup.

I wouldn't treat 10 MHz as a universal hard limit. Wiring, signal integrity, DMA contention, peripheral clocks, and the exact transfer configuration all matter.

But I also wouldn't assume that a 600 MHz processor means GPIO DMA can sample at anything remotely approaching 600 MHz.

For the original 2 MHz application, we're in much friendlier territory.

For something like a 50 MHz parallel ADC, I'd start thinking seriously about whether GPIO DMA is the right interface at all. An external FIFO, FlexIO, or another hardware interface designed for synchronous parallel data may be a better architecture.

Be Careful Trying to "Fix" This by Overclocking the Peripheral Bus

One experiment in the thread increased the peripheral/IPG clock by changing its divider and observed GPIO DMA approaching 20 MHz.

That's interesting as an experiment, but I wouldn't recommend treating it as the normal solution.

The IPG peripheral clock is intended to operate within its specified limits. Raising it beyond those limits can make peripherals unreliable, and changing the clock divider can also break software which assumes Teensy's normal F_BUS_ACTUAL configuration.

If an application only works by pushing the peripheral bus substantially beyond specification, that's a good sign to reconsider the architecture rather than depend on the overclock.

The Practical Architecture for a 2 MHz ADC

Going back to the original problem, we already have a 2 MHz clock driving the external ADC.

That's actually convenient.

Instead of generating another timer, I'd use that external sampling clock as the DMA request source.

The architecture becomes:

                +----------------+
2 MHz ADC clock | Teensy clock pin|
--------------->|     IOMUX      |
                +-------+--------+
                        |
                        v
                      XBAR
                        |
                        v
                     DMAMUX
                        |
                        v
Parallel ADC -------> GPIO1
data                    |
                        v
                       DMA
                        |
                        v
               +-----------------+
               | acquisition RAM |
               +-----------------+
                        |
                 buffer interrupt
                        |
                        v
                       CPU
                        |
                        v
                  UDP / Ethernet

This separates two jobs that were previously competing with each other.

DMA handles the time-critical acquisition.

The CPU handles networking.

That's exactly the sort of separation DMA is intended to provide.

Double Buffering

For streaming data, I'd usually arrange things so the CPU never processes memory that DMA is currently modifying.

That can be done with two buffers:

DMA -> Buffer A
CPU -> Buffer B

then

DMA -> Buffer B
CPU -> Buffer A

or with one circular buffer where interrupts occur at half and full completion.

The important rule is the same:

DMA owns one region while the CPU owns another.

That eliminates the race which can happen when networking code is reading data while an interrupt or DMA transfer is simultaneously changing it.

For the original UDP application, this is a much better model than trying to disable interrupts around:

Udp.beginPacket(...);
Udp.write(...);
Udp.endPacket();

Networking is allowed to take however long it needs, provided it finishes processing one buffer before DMA comes around and needs that memory again.

If it doesn't, then we have a throughput problem rather than an interrupt-latency problem—and that's a much easier problem to reason about.

DMA Doesn't Have to Be Mysterious

The i.MX RT1062 DMA controller has a huge number of capabilities. You can chain channels, scatter and gather, modify addresses, trigger other DMA operations, generate interrupts partway through buffers, and construct some very elaborate hardware pipelines.

You don't need any of that to get started.

For GPIO acquisition, keep the model simple:

1. Put the pins on DMA-accessible GPIO1-4.

2. Configure DMA:
      source      = GPIO register
      destination = RAM buffer
      source step = 0
      destination step = 4 bytes

3. Route a sampling event through XBAR.

4. Use that event as the DMA request.

5. Let DMA fill the buffer.

6. Interrupt the CPU only when useful amounts
   of data are ready.

Once that works slowly, increase the sample rate.

Once a single buffer works, make it circular or double-buffered.

Only after that should you start worrying about more elaborate TCD configurations.

That's generally how I approach this hardware. Don't begin by trying to understand every register in a 3,000-page reference manual. Find the smallest hardware path that accomplishes the job, get each piece working, and then add complexity only when the application actually needs it.

DMA on Teensy 4 isn't especially friendly when you're staring at the registers for the first time. But once you reduce it to event → request → transfer → buffer, the architecture starts to make a lot more sense.


Suggested Diagrams for the Published Version

Diagram 1: GPIO DMA acquisition path
External ADC Clock → IOMUX → XBAR → DMAMUX → DMA, with Parallel ADC Data → GPIO1 → DMA → RAM joining the same DMA block.

Diagram 2: Interrupt-driven versus DMA-driven sampling
Compare invoking the Cortex-M7 for every sample against DMA collecting an entire block of samples before interrupting the processor.

Diagram 3: GPIO1 versus GPIO6
Show the same physical pins switchable between fast CPU-accessible GPIO6 and slower DMA-accessible GPIO1.

Footnote

This article is based on the PJRC forum thread Teensy 4.1 How to start using DMA? and the discussion that followed between forum members.