【CW32无线抄表项目】单片机SPI+DMA读写Flash(W25Q)保姆级避坑指南

天资达人 人工智能 2026-04-03 4447 0

在用 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 的哨声

wKgZO2nLz0SAPLKVAAC8kmHbP0g098.jpg

图片

找到“店名”(触发源编号 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位地址)// ... 省略 ...//