3. DMA描述符链表(LLI)深度优化
DMA的链表模式,说白了就是让DMA自己知道下一步该干什么。你不用一次次去触发它,它自己顺着链表往下走。这个机制在STM32上叫LLI(Linked List Item),用好了,CPU几乎可以彻底撒手不管数据传输。
但我见过太多人把LLI用成了“死链”——要么预分配策略不对导致内存碎片,要么中断太频繁把CPU拖垮。今天咱们就把这几个坑一个一个填上。
3.1 链表的预分配策略
LLI节点存在内存里,DMA通过硬件指针去取。这意味着:节点内存必须固定、不能移动、不能释放。动态分配?想都别想。
我个人习惯用一个大数组来预分配所有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控制器支持中断掩码——你可以在每个节点的控制字里选择是否产生中断。
具体实现:在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实现乒乓缓冲,核心是让链表在两组缓冲区之间来回跳。我一般这样设计:
- 准备两个缓冲区:buf_a[1024]、buf_b[1024]
- 创建两个LLI节点:node_a指向buf_a,node_b指向buf_b
- 让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);
}
}
我在一个高速数据采集项目里用过这个方案——ADC以5Msps采样,DMA用乒乓+LLI循环,CPU只在每个缓冲区满时处理一次。最终CPU占用率不到3%,数据零丢失。
嗯,这里要注意一个细节:缓冲区大小必须是DMA传输粒度的整数倍。比如DMA配置为半字传输(16位),缓冲区字节数必须是2的倍数。否则最后一个字节可能传不完整。
最后说一句:LLI的优化没有银弹。预分配策略、中断频率、缓冲数量,这三者需要根据你的实际场景去调。我的建议是——先跑起来,再优化。别一开始就想着完美,先把数据通路打通,然后用示波器或者逻辑分析仪看看实际的中断频率和CPU负载,再针对性地调整。