在用 SPI 读写 Flash(比如 W25Q 系列)时,往往会觉得用 CPU 一个字节一个字节地收发太慢了。于是大家都会想到用 DMA(直接内存访问) 这个“搬运工”来代劳。
但是!当你满怀信心地配置好 DMA,一跑程序,往往会绝望地卡死在 while(dma_done == 0); 里面。今天,我们就用一段极简的测试代码(往 Flash 里写一个 "kunkun" 并读出来),手把手教你如何完美打通 SPI 和 DMA 的任督二脉!
核心思维预警:SPI 和 DMA 是怎么配合的?
SPI 的全双工脾气:SPI 就像一个双向传送带。你发一个字节出去,必然会同时收一个字节回来。必须有发才有收。
DMA 的搬运工角色:我们通常需要雇佣两个 DMA 搬运工。一个叫 TX(发送通道),负责把内存里的数据疯狂塞给 SPI;另一个叫 RX(接收通道),负责把 SPI 收到的数据搬回内存。
第一步:准备好你的“停车场”(内存对齐)
// 【关键】:定义真正的内存空间,并强制 4 字节对齐 __attribute__((aligned(4))) uint8_t CW_DMA_TxBuf1[256] = "kunkun"; __attribute__((aligned(4))) uint8_t CW_DMA_RxBuf1[256];
注意: DMA 搬运数据速度极快,但它有个小怪癖——喜欢整齐的地址。加上 attribute((aligned(4))) 就是告诉编译器:“请把这两个数组放在能被 4 整除的内存地址上”。如果不加,有时候硬件在寻址时可能会报错或者发生数据偏移。
C 语言中内存对齐(结构体)
struct MyData {
int a; // 4 字节
int b; // 4 字节
char c; // 1 字节
};
它的内存布局就像这样:
第 0-3 字节:放 int a,完美填满一排。
第 4-7 字节:放 int b,完美填满第二排。
第 8 字节:放 char c,它只占了第三排的第一个座位。
第 9-11 字节: CPU 是个“强迫症”,它要求下一个结构体(如果你定义一个数组的话)必须从新的一排(4 的倍数地址)开始。为了保证这种整齐,它在 char c 后面塞了 3 个字节的废话(Padding)。
所以:9 (有效)+ 3 (垫片) = 12 字节。
#pragma pack(1)
struct MyData {
int a;
int b;
char c;
};
#pragma pack() // 用完记得关掉,否则会影响后面的代码
//缺点:CPU 访问 a 和 b 可能会变慢一点点,
//因为地址可能不再是 4 的倍数,CPU 甚至需要分两次读取再拼接(这叫非对齐访问)。
第二步:配置 DMA 搬运工的“打卡机”(中断配置)
void NVIC_Configuration(void){
__disable_irq();
NVIC_ClearPendingIRQ(DMACH23_IRQn);
NVIC_SetPriority(DMACH23_IRQn, 1); // 建议设个优先级
NVIC_EnableIRQ(DMACH23_IRQn);
__enable_irq();
}
/* 定义一个全局标志位,告诉主程序:搬完了! */
volatile uint8_t g_dma_done = 0; // 全局标志位
void DMACH23_IRQHandler(void)
{
// 检查通道 2 (RX) 是否完成(通常以 RX 完成为准,因为 RX 结束代表总线时钟已全部跑完)
if (DMA_GetITStatus(DMA_IT_TC2))
{
DMA_ClearITPendingBit(DMA_IT_TC2);
g_dma_done = 1; // 竖起旗子
}
// 清理通道 3 (TX) 标志位
if (DMA_GetITStatus(DMA_IT_TC3))
{
DMA_ClearITPendingBit(DMA_IT_TC3);
}
// 错误处理
if (DMA_GetITStatus(DMA_IT_TE2) || DMA_GetITStatus(DMA_IT_TE3))
{
DMA_ClearITPendingBit(DMA_IT_TE2 | DMA_IT_TE3);
Error_Handle();
}
}
注意:搬运工(DMA)干完活总得跟老板(CPU)汇报一下吧?这段代码就是给系统注册了一个“微信提示音”。当 DMA 搬完 6 个字节的 "kunkun" 时,它会触发中断,把我们代码里的 g_dma_done 标志位置为 1,这样我们的 while 死循环就能冲过去了。
第三步:重头戏!初始化 SPI 和 DMA
这段 SPI2_DMA_Init 初始化代码里,藏着几个最容易让人抓狂的致命地雷,我们已经全部扫清了:
void SPI2_DMA_Init(void){
// ... (变量声明省略) ...// 避坑 1:一定要给外设通电!
__RCC_SPI2_CLK_ENABLE();
RCC_AHBPeriphClk_Enable(RCC_AHB_PERIPH_DMA, ENABLE);
如果不打开 SPI 的时钟,SPI 就等于没插电,你后面写的所有寄存器配置都会像扔进黑洞一样毫无反应。
// 避坑 2:找对收发货的“物理地址”// 【RX 接收通道配置】
DMA_InitStruct.DMA_SrcAddress = (uint32_t)&CW_SPI2->DR; // 收货地:SPI 的数据寄存器
DMA_InitStruct.DMA_DstAddress = (uint32_t)CW_DMA_RxBuf1; // 卸货地:我们的内存数组
DMA_InitStruct.DMA_DstInc = DMA_DstAddress_Increase; // 卸货时地址要递增,依次排好排满
DMA_InitStruct.HardTrigSource = 33; // 告诉搬运工,听 SPI2_RX 的哨声// 【TX 发送通道配置】
DMA_InitStruct.DMA_SrcAddress = (uint32_t)CW_DMA_TxBuf1; // 收货地:我们的 "kunkun" 数组
DMA_InitStruct.DMA_SrcInc = DMA_SrcAddress_Increase; // 拿货时挨个字母拿
DMA_InitStruct.DMA_DstAddress = (uint32_t)&CW_SPI2->DR; // 卸货地:SPI 的数据寄存器
DMA_InitStruct.HardTrigSource = 37; // 告诉搬运工,听 SPI2_TX 的哨声


找到“店名”(触发源编号 Index)
看你第一张图:
001000:这是二进制的 8。手册规定,这是 SPI2 接收店的“店号”。
001001:这是二进制的 9。手册规定,这是 SPI2 发送店的“店号”。
找到“打卡方式”(位域分配)
看你第二张图(DMA 触发寄存器位域描述):
第 0 位 (TYPE):设置为 1 才能开启“硬件触发模式”。如果是 0,搬运工就不听 SPI 的哨声了。
第 5 ~ 2 位 (HARDSRC):手册规定,这 4 位是用来填“店号”的。
现场算账(公式推导)
因为“店号”要填在从 第 2 位 开始的地方,所以我们需要把店号 左移 2 位(相当于乘以 4),然后把 第 0 位 设为 1。
对于 SPI2_RX (接收):
店号:8(二进制 1000)。
填位:把 1000 往左挪两位,变成 1000xx。
加上开关:最后一位(TYPE)填 1,变成 100001。
转换:二进制 100001 就是十进制的 33!
$$8 times 4 + 1 = 33$$
对于 SPI2_TX (发送):
店号:9(二进制 1001)。
填位:把 1001 往左挪两位,变成 1001xx。
加上开关:最后一位(TYPE)填 1,变成 100101。
转换:二进制 100101 就是十进制的 37!
$$9 times 4 + 1 = 37$$
| 信号名称 | 原始编号 (Index) | 寄存器填法 (二进制) | 最终数值 |
| SPI2_RX | 8 (1000) | 10 0001 | 33 |
| SPI2_TX | 9 (1001) | 10 0101 | 37 |
很多朋友喜欢自己手算地址,比如写个 0x4000380C。一旦算错哪怕一个字节,DMA 就会把数据搬到错误的地方导致崩溃。用 &CW_SPI2->DR 让编译器去抓取绝对正确的地址,最稳妥!
// 避坑 3:安全地拨动开关// 先关闭 SPI (SPE=0),确保寄存器可写,防止被硬件锁死
CW_SPI2->CR1 &= ~(uint32_t)(1 < < 6);
CW_SPI2- >CR1 |= (uint32_t)(0x03 < < 16); // 告诉 SPI:允许你呼叫 DMA!
CW_SPI2- >CR1 |= (uint32_t)(1 < < 6); // 重新开启 SPI (SPE=1)
}
SPE=0(熄火):你必须先按下停止键,让机器停下来。否则,为了安全,机器的换挡杆(寄存器)是锁死拔不动的。
设置 DMA(换挡):机器停稳后,你才能把档位拨到“全自动模式(DMA模式)”。
SPE=1(重新启动):接好线、换好挡后,再次合上电源。这时候,机器就会按照你设定的“全自动模式”狂奔了。
如果你跳过第一步直接改,表面上代码写进去了,但实际上机器内部的档位根本没动,这就是为什么很多人程序卡死在 while 里的“灵异”原因。
第四步:写入数据,千万别忘了“清肠胃”!
看 W25Q_DMA_Write_Kunkun 这个写函数,注意中间那段极其特殊的代码:
// 1. CPU 手动发送指令和地址 (比如 0x02, 还有 24位地址)// ... 省略 ...//







