3、DMA传输类型:内存到内存、内存到外设、外设到内存、外设到外设

好,咱们今天来聊聊DMA的四种传输类型。说实话,这四种类型是DMA的核心玩法,搞懂了它们,你基本就掌握了DMA的精髓。我个人习惯把这四种类型分成两类:一类是内存参与的,一类是纯外设之间的。咱们一个一个来看。

3.1 内存到内存传输(Memory-to-Memory)

这种传输,说白了就是把数据从一块内存搬到另一块内存。你可能会问:“这跟CPU用memcpy有啥区别?”嗯,区别大了去了。

CPU做内存拷贝,需要CPU亲自把数据读进来,再写出去。这期间CPU被完全占用,干不了别的。而DMA做内存拷贝,CPU只需要告诉DMA控制器:“源地址在这儿,目标地址在那儿,长度是这么多,你开始干吧。”然后CPU就可以去处理其他任务了。

关键点:内存到内存传输通常需要DMA控制器支持“双地址”模式,即同时管理源地址和目标地址的递增。很多低端MCU的DMA不支持这种模式,选型时要留意。

我在项目中遇到过这样一个场景:一个图像处理系统需要把摄像头采集的原始数据从缓冲区A拷贝到缓冲区B进行算法处理。数据量很大,每帧几兆字节。如果用CPU拷贝,帧率直接掉一半。后来改用DMA内存到内存传输,CPU几乎零负担,帧率稳稳的。

避坑指南:我曾经在STM32F4上做内存到内存DMA传输,发现数据总是错位。查了半天,原来是源地址和目标地址的字节对齐问题。DMA通常要求地址按字(4字节)对齐,否则会触发错误。所以,分配缓冲区时一定要用对齐属性

// 示例:Linux内核中配置内存到内存DMA传输
struct dma_chan *chan;
struct dma_slave_config config;
struct dma_async_tx_descriptor *tx;
dma_cookie_t cookie;

// 请求DMA通道
chan = dma_request_chan(dev, "memcpy");
if (IS_ERR(chan)) {
    pr_err("Failed to request DMA channel\n");
    return PTR_ERR(chan);
}

// 配置传输参数
memset(&config, 0, sizeof(config));
config.src_addr_width = DMA_SLAVE_BUSWIDTH_4_BYTES;
config.dst_addr_width = DMA_SLAVE_BUSWIDTH_4_BYTES;
config.src_maxburst = 4;
config.dst_maxburst = 4;

dmaengine_slave_config(chan, &config);

// 准备传输描述符
tx = dmaengine_prep_dma_memcpy(chan, dst_phys, src_phys, size, DMA_CTRL_ACK);
if (!tx) {
    pr_err("Failed to prepare DMA memcpy\n");
    dma_release_channel(chan);
    return -ENOMEM;
}

// 提交并启动传输
cookie = dmaengine_submit(tx);
dma_async_issue_pending(chan);

3.2 内存到外设传输(Memory-to-Peripheral)

这种类型太常见了。比如你要通过SPI发送一批数据给外部DAC,或者通过UART发送一串字符串。传统做法是CPU一个字节一个字节地往数据寄存器里写,效率极低。

用DMA的话,你只需要把数据放在内存里,告诉DMA:“从这块内存取数据,写到外设的数据寄存器里。”DMA就会自动完成搬运,每次传输完还会触发中断通知CPU。

我个人习惯把这种模式叫做“推模式”——DMA把数据从内存“推”到外设。你想想看,如果外设是SPI、I2C这类带FIFO的接口,DMA配合FIFO简直是绝配,几乎可以做到零CPU干预。

注意:内存到外设传输时,外设地址通常是固定的(比如SPI_DR寄存器地址),不会递增。而内存地址需要递增。所以配置DMA时,要设置源地址递增、目标地址固定。搞反了的话,数据会写到奇怪的地方去。

// 示例:配置DMA从内存到UART外设
struct dma_slave_config config = {
    .direction = DMA_MEM_TO_DEV,
    .src_addr = (dma_addr_t)tx_buffer,      // 内存源地址
    .dst_addr = uart_base + UART_DR_OFFSET, // 外设目标地址
    .src_addr_width = DMA_SLAVE_BUSWIDTH_1_BYTE,
    .dst_addr_width = DMA_SLAVE_BUSWIDTH_1_BYTE,
    .src_maxburst = 1,
    .dst_maxburst = 1,
};

dmaengine_slave_config(chan, &config);

// 注意:这里源地址递增,目标地址固定
tx = dmaengine_prep_slave_sg(chan, &sgl, 1, DMA_MEM_TO_DEV, DMA_PREP_INTERRUPT);

3.3 外设到内存传输(Peripheral-to-Memory)

这个跟上面正好相反。外设产生数据,DMA负责把数据从外设的数据寄存器搬到内存里。典型的应用场景:ADC采样、UART接收、I2S音频采集。

我做过一个多通道同步数据采集系统,8路ADC同时采样,每路采样率1MHz。如果用中断方式,CPU每收到一个中断就要读一次数据,8路×1MHz=8M次中断/秒,CPU直接瘫痪。后来改用DMA,每路ADC配一个DMA通道,数据直接搬到内存环形缓冲区里,CPU只需要每隔一段时间来处理一次批量数据。

经验之谈:外设到内存传输中,环形缓冲区(Ring Buffer)是标配。DMA不断往缓冲区里写数据,CPU从缓冲区里读数据。只要DMA的写入速度不超过CPU的处理速度,系统就能稳定运行。我曾经因为缓冲区开得太小,导致DMA覆盖了未处理的数据,那叫一个惨烈。

这里有个细节要注意:外设到内存传输时,外设地址固定,内存地址递增。跟内存到外设正好相反。很多新手在这里搞混,配置反了导致数据全部写到同一个地址。

传输类型 源地址 目标地址 典型场景
内存→内存 递增 递增 数据拷贝、缓冲区搬运
内存→外设 递增 固定 SPI发送、UART发送、DAC输出
外设→内存 固定 递增 ADC采集、UART接收、I2S录音
外设→外设 固定 固定 SPI→GPIO、定时器→PWM

3.4 外设到外设传输(Peripheral-to-Peripheral)

这种类型用得相对少一些,但某些场景下非常有用。外设到外设传输,数据不经过内存,直接从源外设的数据寄存器搬到目标外设的数据寄存器。

举个例子:一个SPI从设备接收数据,然后立即通过GPIO输出。传统做法是:SPI接收中断→CPU读SPI数据→CPU写GPIO寄存器。用DMA外设到外设传输,可以直接把SPI的数据寄存器映射到GPIO的输出寄存器,数据一来就自动输出,延迟极低。

实用技巧:外设到外设传输对DMA控制器的要求比较高。很多DMA控制器只支持内存参与的双地址传输,不支持外设到外设。选型时一定要看数据手册里有没有提到“Peripheral-to-Peripheral”或“Memory-to-Memory”支持。我踩过这个坑,选了一款号称有DMA的MCU,结果只支持内存到外设和外设到内存,外设到外设不支持,项目差点延期。

为什么会需要外设到外设传输?说白了就是追求极致的低延迟。比如在高速数据采集系统中,ADC采样结果需要立即触发DAC输出,中间不能有CPU参与。用DMA外设到外设传输,延迟可以控制在几个时钟周期内。

// 外设到外设传输的伪代码配置
// 源:SPI数据寄存器(地址固定)
// 目标:GPIO输出寄存器(地址固定)
struct dma_slave_config config = {
    .direction = DMA_DEV_TO_DEV,
    .src_addr = SPI_DR_ADDR,      // 源外设地址
    .dst_addr = GPIO_ODR_ADDR,    // 目标外设地址
    .src_addr_width = DMA_SLAVE_BUSWIDTH_2_BYTES,
    .dst_addr_width = DMA_SLAVE_BUSWIDTH_2_BYTES,
    .src_maxburst = 1,
    .dst_maxburst = 1,
};

// 注意:源地址和目标地址都固定,不递增
tx = dmaengine_prep_slave_sg(chan, &sgl, 1, DMA_DEV_TO_DEV, DMA_PREP_INTERRUPT);

3.5 四种类型的对比与选择

好了,四种类型都讲完了。咱们来总结一下怎么选:

  • 内存到内存:适合大数据量的内存拷贝,比如图像缓冲区搬运、协议栈数据包拷贝。CPU能解放出来做其他事。
  • 内存到外设:适合需要批量输出数据的场景,比如音频播放、波形输出、批量发送。配合FIFO效果更佳。
  • 外设到内存:适合需要批量采集数据的场景,比如多通道ADC、高速UART接收。环形缓冲区是标配。
  • 外设到外设:适合需要极低延迟的数据转发场景,比如ADC→DAC直通、SPI→GPIO直通。硬件支持是关键。

最后提醒一句:不管用哪种类型,DMA传输完成中断一定要处理好。我曾经在中断服务函数里做了太多操作,导致下一次DMA传输已经开始,但缓冲区还没准备好,数据全乱了。正确的做法是:中断里只设置标志位,真正的数据处理放到主循环或任务里做。

嗯,这四种类型就讲到这里。下一章咱们聊聊DMA的链式传输和循环传输模式,那又是另一番天地了。