3. DMA描述符链表(LLI)深度优化

DMA的链表模式,说白了就是让DMA自己知道下一步该干什么。你不用一次次去触发它,它自己顺着链表往下走。这个机制在STM32上叫LLI(Linked List Item),用好了,CPU几乎可以彻底撒手不管数据传输。

但我见过太多人把LLI用成了“死链”——要么预分配策略不对导致内存碎片,要么中断太频繁把CPU拖垮。今天咱们就把这几个坑一个一个填上。

3.1 链表的预分配策略

LLI节点存在内存里,DMA通过硬件指针去取。这意味着:节点内存必须固定、不能移动、不能释放。动态分配?想都别想。

⚠ 我曾经见过一个项目,在RTOS任务里用malloc给LLI节点分配内存,结果运行几小时后DMA突然“失联”——因为内存碎片把节点覆盖了。从那以后,我所有LLI节点全部静态预分配。

我个人习惯用一个大数组来预分配所有LLI节点。比如你要支持8个DMA流,每个流最多32个节点:

#define LLI_POOL_SIZE     256
#define LLI_NODE_MAX      32

typedef struct {
    uint32_t  src_addr;
    uint32_t  dst_addr;
    uint32_t  next_lli;
    uint32_t  control;
} LLI_Node_t;

// 静态池,放在SRAM里,地址固定
LLI_Node_t lli_pool[LLI_POOL_SIZE] __attribute__((aligned(4)));

// 每个流占用一段连续节点
uint16_t lli_stream_offset[8];

分配策略很简单:每个流从池里拿一段连续节点,用完后归还。但注意——节点一旦分配给某个流,就不要挪作他用。你想想看,DMA正在跑链表,你突然把节点内存改了,后果就是总线错误或者数据错乱。

策略 优点 缺点 我推荐
完全静态分配 零碎片,确定性高 浪费内存 ✅ 资源充足时首选
池式预分配 灵活,利用率高 需管理分配/释放 ✅ 多数场景
动态分配 最灵活 碎片、不可预测 ❌ 绝对不要用

3.2 中断负载均衡

LLI模式下,每个节点传输完成都可能触发中断。如果你有32个节点,每个节点都进中断,CPU就光顾着响应中断了,啥正事也干不了。

为什么会这样?因为很多人把DMA的中断配置成了“每次传输完成都中断”。其实STM32的DMA控制器支持中断掩码——你可以在每个节点的控制字里选择是否产生中断。

💡 我的做法:只在关键节点上开中断。 比如一个音频采集任务,每采集256个样本算一帧。我会把256个LLI节点分成8组,每组32个节点,只在每组最后一个节点上开中断。这样中断频率降低了8倍,CPU负载从12%降到了1.5%。

具体实现:在LLI节点的控制寄存器里设置LLI_CTRL_TCIE位。

// 只在第32、64、96...个节点上开中断
for (int i = 0; i < total_nodes; i++) {
    lli_pool[i].control = DEFAULT_CTRL;
    if ((i + 1) % 32 == 0) {
        lli_pool[i].control |= LLI_CTRL_TCIE;  // 开传输完成中断
    }
}

还有一个技巧:把中断服务函数里的活儿尽量往外搬。中断里只做两件事:清标志、发信号。真正的数据处理交给任务线程。我习惯用消息队列:

void DMA_IRQHandler(void) {
    uint32_t flags = DMA->ISR;
    DMA->IFCR = flags;  // 清标志,越快越好

    if (flags & DMA_TC_FLAG) {
        osMessageQueuePut(queue_handle, &data, 0, 0);  // 发消息
    }
}

3.3 乒乓缓冲实现

乒乓缓冲是LLI最经典的应用。说白了就是:DMA在写缓冲区A的时候,CPU在读缓冲区B;下一轮交换。这样CPU和DMA永远不抢同一块内存。

用LLI实现乒乓缓冲,核心是让链表在两组缓冲区之间来回跳。我一般这样设计:

  1. 准备两个缓冲区:buf_a[1024]、buf_b[1024]
  2. 创建两个LLI节点:node_a指向buf_a,node_b指向buf_b
  3. 让node_a的next指向node_b,node_b的next指向node_a

代码实现:

#define BUF_SIZE  1024
uint8_t buf_a[BUF_SIZE];
uint8_t buf_b[BUF_SIZE];

LLI_Node_t node_a, node_b;

// 初始化节点
node_a.src_addr  = (uint32_t)periph_addr;
node_a.dst_addr  = (uint32_t)buf_a;
node_a.control   = BUF_SIZE | LLI_CTRL_TCIE;  // 开中断
node_a.next_lli  = (uint32_t)&node_b;         // 指向B

node_b.src_addr  = (uint32_t)periph_addr;
node_b.dst_addr  = (uint32_t)buf_b;
node_b.control   = BUF_SIZE | LLI_CTRL_TCIE;
node_b.next_lli  = (uint32_t)&node_a;         // 指向A,形成环

中断里只需要判断当前完成的是哪个缓冲区:

void DMA_IRQHandler(void) {
    // ...清标志...
    uint32_t cur_dst = DMA->CMAR;  // 当前DMA目标地址
    if (cur_dst == (uint32_t)buf_a) {
        // buf_a刚写完,CPU去处理buf_a
        process_buffer(buf_a);
    } else {
        process_buffer(buf_b);
    }
}
🔑 关键点:乒乓缓冲的链表必须是环形结构。最后一个节点指向第一个节点,这样DMA会永远循环下去,不需要CPU干预。你只需要在中断里处理“哪个缓冲区满了”就行。

我在一个高速数据采集项目里用过这个方案——ADC以5Msps采样,DMA用乒乓+LLI循环,CPU只在每个缓冲区满时处理一次。最终CPU占用率不到3%,数据零丢失。

嗯,这里要注意一个细节:缓冲区大小必须是DMA传输粒度的整数倍。比如DMA配置为半字传输(16位),缓冲区字节数必须是2的倍数。否则最后一个字节可能传不完整。

LLI乒乓缓冲工作流程 外设/ADC DMA控制器 缓冲区A (CPU正在处理) 缓冲区B (DMA正在写入) LLI节点A LLI节点B 数据请求 写入 指向 指向 next next(环) 💡 工作流程:DMA从外设读取数据 → 写入缓冲区B → 完成后通过LLI节点B跳转到节点A → 下一轮写入缓冲区A,同时CPU处理缓冲区B。如此循环,永不冲突。
💡 进阶技巧:如果你需要更高吞吐量,可以用三缓冲甚至四缓冲。原理一样,只是链表节点更多。我做过一个4缓冲的方案,DMA永远在写其中一个,CPU在处理另外三个——几乎零等待。

最后说一句:LLI的优化没有银弹。预分配策略、中断频率、缓冲数量,这三者需要根据你的实际场景去调。我的建议是——先跑起来,再优化。别一开始就想着完美,先把数据通路打通,然后用示波器或者逻辑分析仪看看实际的中断频率和CPU负载,再针对性地调整。