扫码加入

  • 正文
  • 相关推荐
申请入驻 产业图谱

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

03/31 10:43
319
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

在用 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位地址)// ... 省略 ...//  避坑 4:放 DMA 进场前,必须清空垃圾!while (CW_SPI2->ISR & (1 << 1))     {        volatile uint8_t dummy = CW_SPI2->DR;     }    CW_SPI2->ICR = 0xFFFFFFFF; // 彻底清除 SPI 的溢出错误标志位

Dummy:

1.当你用 CPU 发送 4 个字节的指令和地址时,根据 SPI 全双工的特性,Flash 也会同时给你回传 4 个“垃圾数据”。如果你不把这 4 个垃圾从 SPI 肚子里拿出来,接着就让 TX DMA 疯狂往里塞新数据,SPI 瞬间就会被撑死(发生 Overrun 溢出错误)。一旦溢出,SPI 就会死机罢工,你的程序也就永远卡在 while 里了!

2.当你下一次开启 DMA 准备接收  Flash 数据时,DMA 会发现缓冲区里已经有一个数了(其实是上次剩下的垃圾数据),它会兴奋地先把这个垃圾数搬进你的 RAM。结果就是:你收到的所有数据都会往后错位一个字节

    // 2. 巧妙的目的地设计static uint8_t trash_bin;    CW_DMACHANNEL2->DSTADDR = (uint32_t)&trash_bin;    CW_DMACHANNEL2->CSR_f.DSTINC = 0; // 目的地不递增

数据时,我们也会收到 Flash 传回来的无用信号。为了不浪费内存,我们弄了一个 trash_bin(垃圾桶),并把地址递增关掉(DSTINC = 0),让所有收到的垃圾都覆盖扔在同一个地方。

第五步:把数据读回来

// 巧妙的源地址设计,用来“骗”出时钟信号    static uint8_t dummy = 0xFF;    CW_DMACHANNEL3->SRCADDR = (uint32_t)&dummy;    CW_DMACHANNEL3->CSR_f.SRCINC = 0; // 发送地址固定

数据时,要想让 SPI 产生时钟信号去 Flash 拿货,TX 搬运工就必须不停地往 SPI 里发东西。我们就搞一个无意义的 dummy 变量(通常是 0xFF),并关闭发送地址递增(SRCINC = 0),让 TX DMA 一直发这个假数据,从而把真数据从 Flash 里“骗”出来存进接收数组里。

完整程序

#include "cw32f030_dma.h"#include "dma.h"#include "cw32f030_rcc.h"/* 在 dma.c 的顶部,所有函数之外 */extern volatile uint8_t g_dma_done;#include "cw32_eval_spi_flash.h"// 【关键】:定义真正的内存空间,并强制 4 字节对齐__attribute__((aligned(4))) uint8_t CW_DMA_TxBuf1[256] = "kunkun"; __attribute__((aligned(4))) uint8_t CW_DMA_RxBuf1[256];void NVIC_Configuration(void){    __disable_irq();     // 【修改点】:换成 2 和 3 的共用中断向量    NVIC_ClearPendingIRQ(DMACH23_IRQn);    NVIC_SetPriority(DMACH23_IRQn, 1); // 建议设个优先级    NVIC_EnableIRQ(DMACH23_IRQn);     __enable_irq();  }void Error_Handle(){  while(1);}/** * @brief SPI2 底层字节交换函数 (DMA 启动指令需要用到它) */uint8_t SPI_ReadWriteByte(uint8_t dat){    // 等待发送缓冲区空    while (SPI_GetFlagStatus(CW_SPI2, SPI_FLAG_TXE) == RESET);    // 发送数据    CW_SPI2->DR = dat;    // 等待接收缓冲区非空    while (SPI_GetFlagStatus(CW_SPI2, SPI_FLAG_RXNE) == RESET);    // 返回收到的数据    return CW_SPI2->DR;}/** * @brief 等待 W25Q 内部擦写完成 */void W25Q_WaitForWriteEnd(void){    uint8_t status = 0;    FLASH_SPI_CS_LOW();    SPI_ReadWriteByte(0x05); // 发送读状态寄存器指令    do {        status = SPI_ReadWriteByte(0xFF); // 持续读取状态    } while ((status & 0x01) == 0x01);    // 只要 BUSY 位为 1 就继续等    FLASH_SPI_CS_HIGH();}void SPI2_DMA_Init(void){    DMA_InitTypeDef DMA_InitStruct;    // 1. 【核心修复】:开启 SPI2 外设时钟    __RCC_SPI2_CLK_ENABLE();
    // 2. 使能 DMA 外设时钟    RCC_AHBPeriphClk_Enable(RCC_AHB_PERIPH_DMA, ENABLE);    DMA_StructInit(&DMA_InitStruct);    // ==========================================================    // 【配置通道 2:SPI2_RX (接收)】    // ==========================================================    DMA_InitStruct.DMA_Mode = DMA_MODE_BLOCK;    DMA_InitStruct.DMA_TransferWidth = DMA_TRANSFER_WIDTH_8BIT; 
    // 【核心修复】:绝对正确的 DR 寄存器地址    DMA_InitStruct.DMA_SrcAddress = (uint32_t)&CW_SPI2->DR; // 从 SPI 拿货    DMA_InitStruct.DMA_SrcInc = DMA_SrcAddress_Fix;    DMA_InitStruct.DMA_DstAddress = (uint32_t)CW_DMA_RxBuf1;    DMA_InitStruct.DMA_DstInc = DMA_DstAddress_Increase;    // 接收地址必须递增
    DMA_InitStruct.TrigMode = DMA_HardTrig;    DMA_InitStruct.HardTrigSource = 33; // SPI2_RX (8*4+1)
    DMA_Init(CW_DMACHANNEL2, &DMA_InitStruct);    // ==========================================================    // 【配置通道 3:SPI2_TX (发送)】    // ==========================================================    DMA_InitStruct.DMA_SrcAddress = (uint32_t)CW_DMA_TxBuf1;     DMA_InitStruct.DMA_SrcInc = DMA_SrcAddress_Increase;    // 发送地址必须递增    // 【核心修复】:绝对正确的 DR 寄存器地址    DMA_InitStruct.DMA_DstAddress = (uint32_t)&CW_SPI2->DR; // 往 SPI 送货    DMA_InitStruct.DMA_DstInc = DMA_DstAddress_Fix;
    DMA_InitStruct.HardTrigSource = 37; // SPI2_TX (9*4+1)
    DMA_Init(CW_DMACHANNEL3, &DMA_InitStruct);    // 3. 中断配置    DMA_ClearITPendingBit(DMA_IT_ALL);    DMA_ITConfig(CW_DMACHANNEL2, DMA_IT_TC | DMA_IT_TE, ENABLE);    DMA_ITConfig(CW_DMACHANNEL3, DMA_IT_TC | DMA_IT_TE, ENABLE);    // 4. NVIC 配置    __disable_irq();     NVIC_ClearPendingIRQ(DMACH23_IRQn);    NVIC_SetPriority(DMACH23_IRQn, 1);    NVIC_EnableIRQ(DMACH23_IRQn);    __enable_irq();    // 5. 【核心修复】:安全地开启 SPI2 端对 DMA 的请求使能    CW_SPI2->CR1 &= ~(uint32_t)(1 << 6);       // 先关闭 SPI (SPE=0),确保寄存器可写    CW_SPI2->CR1 |= (uint32_t)(0x03 << 16);    // 设置 DMARX 和 DMATX 使能位    CW_SPI2->CR1 |= (uint32_t)(1 << 6);        // 重新开启 SPI (SPE=1)}void W25Q_DMA_Write_Kunkun(uint32_t Addr){    // 1. 发送写使能指令 (CPU 手动发)    FLASH_SPI_CS_LOW();    SPI_ReadWriteByte(0x06);      FLASH_SPI_CS_HIGH();    // 2. 发送页写指令和地址 (CPU 手动发)    FLASH_SPI_CS_LOW();    SPI_ReadWriteByte(0x02);      SPI_ReadWriteByte((Addr >> 16) & 0xFF);    SPI_ReadWriteByte((Addr >> 8) & 0xFF);    SPI_ReadWriteByte(Addr & 0xFF);    // ==========================================================    // 【核心新增:清肠胃逻辑】    // 扫除刚才发指令和地址时,SPI 自动收回来的 4 个垃圾字节    // ==========================================================    // 只要接收缓冲区非空 (RXNE, 第 1 位),就一直读出来扔掉    while (CW_SPI2->ISR & (1 << 1))     {        volatile uint8_t dummy = CW_SPI2->DR;     }    // 彻底清除 SPI 的所有错误标志位 (特别是 Overrun 溢出位)    CW_SPI2->ICR = 0xFFFFFFFF; 
    // 3. 配置并启动 DMA (搬运真正的数据)    g_dma_done = 0;
    // 先关掉,防止之前任务没跑完导致干扰    DMA_Cmd(CW_DMACHANNEL2, DISABLE);    DMA_Cmd(CW_DMACHANNEL3, DISABLE);    // 清除 DMA 之前的残留中断标志    DMA_ClearITPendingBit(DMA_IT_ALL);    // 设置搬运数量 (6个字节) 记得带上 REPEAT 位 (1 << 16)    CW_DMACHANNEL2->CNT = (1 << 16) | 6;      CW_DMACHANNEL3->CNT = (1 << 16) | 6;    // 通道 2 (RX) 目的地设为“垃圾桶”,写操作不需要收回来的数据    static uint8_t trash_bin;    CW_DMACHANNEL2->DSTADDR = (uint32_t)&trash_bin;    CW_DMACHANNEL2->CSR_f.DSTINC = 0; // 目的地不递增    // 通道 3 (TX) 源地址设为我们的 kunkun 数组    CW_DMACHANNEL3->SRCADDR = (uint32_t)CW_DMA_TxBuf1;    CW_DMACHANNEL3->CSR_f.SRCINC = 1; // 源地址递增    // 【重要技巧】:先让收货员到位 (RX),再让发货员开工 (TX)    DMA_Cmd(CW_DMACHANNEL2, ENABLE);    DMA_Cmd(CW_DMACHANNEL3, ENABLE);    // 4. 等待中断标志位    while(g_dma_done == 0);    FLASH_SPI_CS_HIGH();    W25Q_WaitForWriteEnd(); // 等待 Flash 内部写完}void W25Q_DMA_Read_Back(uint32_t Addr){    FLASH_SPI_CS_LOW();    SPI_ReadWriteByte(0x03); // 读指令    SPI_ReadWriteByte((Addr >> 16) & 0xFF);    SPI_ReadWriteByte((Addr >> 8) & 0xFF);    SPI_ReadWriteByte(Addr & 0xFF);    g_dma_done = 0;    DMA_Cmd(CW_DMACHANNEL2, DISABLE);    DMA_Cmd(CW_DMACHANNEL3, DISABLE);    CW_DMACHANNEL2->CNT = (1 << 16) | 6;    CW_DMACHANNEL3->CNT = (1 << 16) | 6;    // 通道 2 (RX) 目的地设为我们的接收数组    CW_DMACHANNEL2->DSTADDR = (uint32_t)CW_DMA_RxBuf1;    CW_DMACHANNEL2->CSR_f.DSTINC = 1; // 接收地址要递增    // 通道 3 (TX) 源地址设为 0xFF,产生时钟信号    static uint8_t dummy = 0xFF;    CW_DMACHANNEL3->SRCADDR = (uint32_t)&dummy;    CW_DMACHANNEL3->CSR_f.SRCINC = 0; // 发送地址固定    DMA_Cmd(CW_DMACHANNEL2, ENABLE);    DMA_Cmd(CW_DMACHANNEL3, ENABLE);    while(g_dma_done == 0);    FLASH_SPI_CS_HIGH();}
#include "stdio.h"/* 在 dma.h 中添加 */#include "stdint.h"// extern 告诉编译器:这个变量的户口在别处,这里只是打个招呼extern volatile uint8_t g_dma_done;uint8_t SPI_ReadWriteByte(uint8_t dat);void W25Q_WaitForWriteEnd(void);/* 在 dma.h 中添加 */extern uint8_t CW_DMA_TxBuf1[];extern uint8_t CW_DMA_RxBuf1[];void Error_Handle();
#include "main.h"#include "cw32f030_gpio.h"#include "cw32f030_rcc.h"#include "init.h"#include "buffer.h"#include "fun.h"#include "radio.h"#include "delay.h"#include "flashhoufun.h" #include "cw32_eval_spi_flash.h"#include "dma.h"// 全局中断标志 (fun.c 也要用)volatile uint8_t g_bIrqTriggered = 0; void System_Init_Config(void);//int32_t main(void)//{   //    // 1. 硬件初始化//    System_Init_Config();//    //    // 2. 射频初始化//    if (rf_init() != OK) //    {//        while(1); // 失败报警//    }//    rf_set_default_para();//    // 3. 初始状态设置 (编译时决定)//    #ifdef SLAVE_MODE//        // [从机] 上电必须开启接收,否则听不到第一句//        rf_enter_single_timeout_rx(15000);//    #endif//    //    // [主机] 不需要预先接收,它会主动发送//    while (1)//    {//        // === 1. 优先处理中断 (公共逻辑) ===//        if (g_bIrqTriggered)//        {//            g_bIrqTriggered = 0;//            rf_irq_process(); // SPI 读取状态//        }//        // === 2. 业务逻辑 (编译时二选一) ===//        #ifdef MASTER_MODE//            OnMaster();//        #endif//        #ifdef SLAVE_MODE//            OnSlave();//        #endif//    }//                //}//int32_t main(void)//{   //    System_Init_Config();//    //    SPI_FLASH_Init();//    //    flash_fun();//    while (1)//    {//    }//}int32_t main(void){    System_Init_Config();      // 初始化时钟和串口    SPI_FLASH_Init();   // 初始化 SPI 硬件    SPI2_DMA_Init();    // 初始化 DMA 配置    printf("开始 DMA 验证...rn");    // 第一步:写入 kunkun    W25Q_DMA_Write_Kunkun(0x0000); 


    // 第二步:读取回来    W25Q_DMA_Read_Back(0x0000);    // 第三步:验证    if (strcmp((char*)CW_DMA_RxBuf1, "kunkun") == 0) {        printf("验证通过!收到了:%srn", CW_DMA_RxBuf1);    } else {        printf("验证失败,收到了垃圾数据。rn");    }    while(1);}void System_Init_Config(void){    RCC_Configuration();     GPIO_Configuration();    SPI_Configuration();    EXTI_Configuration();          ADC_Configuration();}

我们通过调试来观看效果

我们在验证通过处打了一个断点,直接运行程序,发现,kunkun已经收到了,且验证通过

证明我们通过DMA成功地向W25Q中写入了数据,且能够正常从中读取。

调试过程(每一次寄存器值变化都有截图并配上了说明)

如果你发现代码左侧打不上红点,或者单步调试时箭头乱跳,请记住‘重启大法’的进阶版:点击全编译(Rebuild All),它能强制刷新调试器的大脑,让它重新认清你的每一行代码。

刚开始什么也没有,我们把SPI2也加进去。

查看当前的寄存器值

1.CR1 = 0x00001C04 (控制寄存器 1 - 核心大脑)

这是最关键的寄存器,决定了 SPI 怎么工作。我们把它拆成二进制看: 0001 1100 0000 0100

Bit 2 (MSTR) = 1主机模式。代表单片机是“老板”,控制着时钟信号。

Bit 10~13 (WIDTH) = 01118 位数据长度。代表每次搬运 8 个比特(1 个字节)。

Bit 6 (SPE) = 0SPI 关闭。这是目前的重点!因为这一位是 0,说明 SPI 还没“点火”,所以它现在不会有任何动作。

Bit 16~17 (DMA) = 0DMA 请求关闭。说明此时还没运行到开启 DMA 的那行代码,或者还没写进去。

2. ISR = 0x00000201 (状态寄存器 - 仪表盘)

它告诉你现在硬件里发生了什么。

Bit 0 (BSY) = 1忙碌标志。这很有趣,虽然 SPI 没使能,但硬件可能还残留着之前的忙碌状态。

Bit 9 (MATCH) = 1匹配标志(具体取决于芯片定义)。通常代表数据比较或某些特定状态已达成。

Bit 1 & 2 (RXNE & TXE) = 0:代表现在收货篮和发货篮都是空的。

3. SSI = 0x00000001 (从机选择寄存器 - 手刹)

值 = 1:在软件管理模式下,这相当于把手刹松开了。它告诉 SPI:“虽然现在没选具体的从机,但你可以随时准备干活。”

4. ICR = 0x000000FF (中断清除寄存器 - 橡皮擦)

这是一个“只写”寄存器,显示 0xFF 通常代表它准备好了清除所有的错误标志位(比如溢出、频率错误等)。

到达FLASH_Init()函数

SPI2的寄存器值

寄存器值发生变化。

大家看,现在的 CR1 寄存器里,CPOLCPHABR 这些‘属性’都已经设好了,这就像是赛车手已经调好了座椅、系好了安全带(Init完毕)。但是,代表发动机点火的 EN 位 还是熄灭的。没有这一位,SPI 引脚上永远不会产生波形。

BR从 0 变到 2,意味着你把 SPI 的通讯速度“降了两档”,变得更慢但更稳了。

BR 寄存器值 二进制 分频系数 实际速度(假设 PCLK=64MHz)
0 0 f_PCLK / 2 32 MHz (极速)
1 1 f_PCLK / 4 16 MHz
2 10 f_PCLK / 8 8 MHz (稳健)

再次观察

EN 那个框应该会自动勾选

CR1 的值应该会从 0x1E17 变成 0x1E57(因为加了 1 << 6)。

看 ISR 寄存器:一旦 EN 变红(变 1),ISR 里的 TXE(发送缓冲空)指示灯应该会瞬间亮起。

该SPI初始化运行结束

SRCINC 被勾选:因为我们要发送 "kunkun",DMA 搬完 'k' 之后,必须把地址往后挪一位去搬 'u'。

DSTINC 没勾选:因为目的地是 SPI 的数据寄存器(DR),它就像是一个固定的“投递口”,地址不能变。

SRCADDR (源地址)0x20000004。这就是你的 CW_DMA_TxBuf1 数组在内存里的首地址。

DSTADDR (目的地址):数据的终点。

这里的地址规律:

以 0x20 开头:代表内存(SRAM),通常是我们的 uint8_t 数组。

以 0x40 开头:代表外设(Peripherals),在这里就是 SPI 的数据寄存器 DR

外置 Flash(W25Q)对于单片机来说是“编外人员”,它不在单片机的 内存版图里。它们之间唯一的联系通道,就是 SPI 数据寄存器(DR)

通道 3 (TX):发送链条

起点 (SRCADDR)0x20000004 (内存中的 CW_DMA_TxBuf1)。

终点 (DSTADDR)0x40003810 (SPI2 的数据寄存器 DR)。

后续(硬件自动完成):数据进入 SPI2_DR 后,SPI 硬件模块会通过引脚(MOSI),把数据一个位一个位地“挤”到 外置 Flash 里去。

 通道 2 (RX):接收链条

起点 (SRCADDR)0x40003810 (SPI2 的数据寄存器 DR)。

后续(硬件自动完成):外置 Flash 收到指令后,通过引脚(MISO)把数据传回 SPI2_DR。

终点 (DSTADDR)0x20000110 (内存中的 CW_DMA_RxBuf1)。

通道 3 (TX):负责把货从‘内陆仓库 A (TxBuf)’搬到‘港口 (SPI_DR)’。货到了港口,就会自动装船运往‘海外岛屿 (Flash)’。

通道 2 (RX):负责在‘港口 (SPI_DR)’守着,一旦有‘海外岛屿 (Flash)’运回来的货,就立刻把它搬回‘内陆仓库 B (RxBuf)’。

TRIG (触发寄存器):值是 0x25(十进制 37)

这就是我们之前算的:0x09 (SPI2_TX) *4 + 1 = 37。

TYPE 被勾选:代表“硬件触发”,即只有 SPI 喊“我渴了(发送缓冲空)”,DMA 才会搬货。

CNT (计数器寄存器):决定“搬多少”

这是配送单上的数量栏

它的构成:在 CW32 中,CNT 是一个 32 位的寄存器。

低 16 位 (0-15位):真正的计数。比如你设为 6,它就搬 6 个字节。

高位 (特别是第 16 位 REPEAT):这是“自动续单”开关。如果设为 1,搬完这 6 个,它会自动变回 6 准备下一轮。

搬运前:显示你设置的总数(如 6)。

搬运中:你会看到这个值在递减(6-> 5 ->4...)。

搬运后:变为 0(如果不开启 REPEAT)。

寄存器值变化

CSR 寄存器为什么变了?

对比之前的截图,你会发现 CSR 的值从 0x18(或 0x28)变成了 0x1E(或 0x2E)。

消失的“魔法数字”:0x1E 的由来

我们来拆解一下这个十六进制:

之前的 0x18:二进制是 11000(即 TRANS=1SRCINC=1)。

现在的 0x1E:二进制是 11110

多了 Bit 1 (TCIE):传输完成中断使能。

多了 Bit 2 (TEIE):传输错误中断使能。

结论:这说明 DMA 搬运工现在不仅领到了任务,还被交待了:“干完活(TC)或者干砸了(TE),记得打个报告(发中断信号)。”

2. __disable_irq() 到底干了什么?

疑问:“既然我们开启了中断使能(TCIE),为什么后面紧接着要关闭中断(disable_irq)呢?”

这是一个非常有用的安全套路

DMA_ITConfig:是开启了 DMA 的“发言权”。

__disable_irq():是 CPU 告诉全校:“大家先别说话,听我说完!”

目的:我们在配置 NVIC(中断向量控制器)时,为了防止配置到一半突然跳出一个中断把程序搞乱,我们会先全局禁言,等 NVIC 所有的优先级、通道都配好了,再用 __enable_irq() 恢复。

我这里将断点打在了while里面,直接运行发现跳出了刚才的函数。直接到下一个DMA_Read_Back了

为什么断点没停住?

你在 volatile uint8_t dummy = CW_SPI2->DR; 这一行打断点,逻辑如下:

前提条件:只有 while 循环的条件 (CW_SPI2->ISR & (1 << 1))(即 RX 缓冲区有数据)成立时,程序才会进入循环。

真相:如果你在执行到这一行之前,SPI 的接收缓冲区恰好是空的,程序就会直接跳过 while 循环

结果:因为没进循环,你打在循环内部的断点自然就不会停。程序会以极快的速度执行完后面的 DMA 启动代码,并一直运行到 main 函数中的下一行 W25Q_DMA_Read_Back(0x0000);

赋值瞬间(现在这一步):你在代码里写了 CW_DMACHANNEL2->CNT = (1 << 16) | 6;。由于你刚才点击了单步运行,这一行代码生效了。此时的 6 代表“任务定额”,即:我已经告诉 DMA 待会儿要搬 6 个字节。

等待阶段:因为你还没执行 DMA_Cmd(ENABLE),搬运工还没出发。所以它手里的计数器 CNT 依然纹丝不动地停在 6。

DSTADDR = &trash_bin 这一行是在干什么?

这是“垃圾回收”设计。

背景知识:SPI 是“全双工”的。你发 6 个字节出去,Flash 就会回传 6 个字节。

遇到的问题:我们在“写操作”时,并不需要 Flash 回传的任何数据。但根据 SPI 的脾气,它非要传回来不可。

操作

你定义了一个叫 trash_bin(垃圾桶)的变量。

你把 DMA 接收通道(Channel 2)的目的地址指向了这个“垃圾桶”。

并且你设置了 DSTINC = 0(地址不递增)。

最终效果:无论 Flash 回传多少个字节,DMA 都会把它们重叠地扔进同一个 trash_bin 变量里

为什么 CNT 是 6?(kunkun就是6啊)

因为 "kunkun" 包含 6 个英文字母,在 C 语言中,每个标准英文字符占据 1 个字节(Byte)。

'k'(1) + 'u'(1) + 'n'(1) + 'k'(1) + 'u'(1) + 'n'(1) = 6 字节。 所以你的 CNT 设为 6,搬运工刚好搬完这串字符。

真相是:SPI 既不认识英文,也不认识中文,它只认识“字节”。

SPI 就像一根水管,它不管流的是可乐(英文)还是奶茶(中文),它只负责把这桶水(数据)从一头挤到另一头。

// 1. 定义你的内容__attribute__((aligned(4))) uint8_t CW_DMA_TxBuf1[256] = "背带裤"; // 2. 自动计算长度 (不要数数,让编译器算)// strlen 会计算字符串的实际字节数(不含结尾的 )uint16_t data_len = strlen((char*)CW_DMA_TxBuf1); // 3. 赋值给 DMA 计数器CW_DMACHANNEL3->CNT = (1 << 16) | data_len; CW_DMACHANNEL2->CNT = (1 << 16) | data_len;

千万不要手动去数 CNT 应该是几,万一以后想发“唱、跳、rap、篮球”呢?数不过来的。

我们重新调试,查看while之后的程序运行效果,我们发现了寄存器的值产生了变化。

DSTADDR 的“身份大变身”:0x20000214

之前:在读函数里,这个地址指向的是 RxBuf1(比如 0x20000110),那是为了把货搬回家。

现在:你执行了 DSTADDR = &trash_bin,地址变成了 0x20000214

解读:这就是你的 static uint8_t trash_bin; 变量在内存里的真实“门牌号”。

虽然我们要往 Flash 写数据,但由于 SPI 的特性,Flash 也会回传垃圾信息。我们现在把接收终点设为 0x20000214,就是给这些垃圾找了个专门的“处理厂”。

运行了DAM_Cmd后。

1. STATUS = 0x05 —— 胜利的信号灯

这是两张图中最重要的发现!

含义:在 CW32 的 DMA 控制寄存器中,0x05 代表 TCIF(传输完成) 和 TEIF(传输错误) 标志位的集合。

解读:重点看 TCIF。这个 5 告诉我们:搬运工已经完成了 6 个字节的指标,并向 CPU 打了报告说:“活儿干完了!”

教程点:如果这个值是 0,说明搬运工还在半路上或者根本没动。

2. CNT = 0 —— 任务清零

现象:无论通道 2 还是通道 3,CNT 都变成了 0

解读:这证明了搬运过程没有卡死。6 个字节被一个不剩地搬完了。

教程点:这就是“倒计时”结束的标志。

3. SRCADDR / DSTADDR —— 路径轨迹

这是最实锤的证据:

通道 3 (TX)SRCADDR 从 0x20000004 变成了 0x2000000A

计算:增加量 = 10 - 4 = 6。说明它精准地从数组里拿走了 6 个字节。

通道 2 (RX)DSTADDR依然是 0x20000214

计算:增加量 = 0。

为什么? 因为你在代码里设了 DSTINC = 0(目的地不递增)。这证明了 6 个垃圾字节确实全部重叠着扔进了同一个“垃圾桶”变量里,没有弄脏别的内存空间。

我们进入第二个函数。

寄存器值发生了变化。CNT值变为6

在修改 DMA 的配置(如搬运数量 CNT、源地址 SRCADDR、目的地址 DSTADDR)之前,必须先关闭该 DMA 通道。这就像是在给行驶中的货车更换路线和货物之前,必须先让它“熄火停车”。

通道 3 (TX) 的变化:产生时钟信号

当前值SRCADDR 为 0x2000000A

分析:这证实了上一步“写”操作非常成功。它从 0x20000004 开始,搬运了 6 个字节,刚好停在 0xA 的位置。

代码动作CW_DMACHANNEL3->SRCADDR = (uint32_t)&dummy;

分析:现在我们要“读”数据。SPI 必须有发送动作才能产生时钟。所以我们要把源地址改到一个固定的 dummy 变量(0xFF)上,并设置 SRCINC = 0(不递增)。

结果:通道 3 将不再从数组拿货,而是盯着这一个 0xFF 猛发,目的是把 Flash 里的数据“骗”出来。

通道 2 (RX) 的变化:重新指向仓库

当前值DSTADDR 为 0x20000214

分析:这说明上一步它确实把回传的垃圾数据扔进了“垃圾桶”(trash_bin)。

代码动作CW_DMACHANNEL2->DSTADDR = (uint32_t)CW_DMA_RxBuf1;

分析:这是最关键的一步变化!我们把接收目的地从“垃圾桶”改回了正式的仓库 CW_DMA_RxBuf1

结果:这一次,Flash 回传的真正数据(比如刚才存入的 "kunkun")将会被整齐地存入我们的接收数组中。

寄存器值变化。

1. DSTADDR 的变迁:从“垃圾桶”搬回“仓库”

现象:你看 DMACHANNEL2 的 DSTADDR 已经变成了 0x20000110

逻辑:对比上一节“写函数”时它指向的是 trash_bin(垃圾桶),现在执行完第 214 行,它重新指向了你的正式接收数组 CW_DMA_RxBuf1

要点:这时候搬运工已经接到了新指令:“接下来的货不准扔了,全部给我搬进这个正式仓库里存好。”

2. DSTINC = 1 的预备动作(第 215 行)

现状:目前截图里 DSTINC 的勾还没打上,因为箭头正停在这一行,还没执行。

逻辑:一旦执行这一行,DSTINC 就会变红打勾。

要点:这是读操作的灵魂。因为我们要存入 6 个连续的字节,所以每搬一个,仓库的货架位置(地址)就得往后挪一位。如果这里忘了设为 1,那所有的货都会堆在数组的第一个位置。

3. SRCADDR = 0x40003818(SPI2 的数据口)

观察:注意看 Channel 2 的源地址。它是固定不变的 0x40...

逻辑:因为不管读还是写,货始终都是从 SPI 这个“港口”出来的。

最后CNT变为0

1. CNT = 0 —— 任务圆满完成

现象:你看 DMACHANNEL3 的 CNT 变成了 0

解读:这证明 DMA 已经成功发出了 6 个 dummy(0xFF)字节。既然发出了 6 个,根据 SPI 全双工原理,它也必然已经收回了 6 个字节并存入了你的 CW_DMA_RxBuf1

搬运工已经把 6 块“诱饵砖头”扔出去了,现在的任务量已经归零。

2. SRCADDR = 0x20000108 —— 固定的“诱饵投递点”

现象:注意看 SRCADDR 的值。这个 0x20000108 就是你定义的 static uint8_t dummy 变量在内存里的地址。

对比变化

写操作时:地址会从 ...004 变到 ...00A(因为地址要递增去拿不同的字母)。

读操作时:由于你设置了 SRCINC = 0(看图中 SRCINC 的框确实没勾选),地址始终锁死在 0x20000108

这就是我们说的“定点投币”。搬运工守着这一个 0xFF 不停地发,所以它的足迹(地址)没有移动。

3. STATUS 标志位(虽然被遮挡但可推断)

推断:此时该通道的传输完成中断标志(TCIF)已经置起了。它正在大喊:“我干完活了!”

当前卡点:程序停在 while 循环,是因为 CPU 还没来得及跳进中断服务函数(ISR)去执行 g_dma_done = 1

验证成功

发送数据时:产生的“接收垃圾”必须用 dummy 变量处理掉。

接收数据时:必须准备一个 0xFF 当作“敲门砖”发出去。

垃圾桶(dummy变量):是为了清空收件箱,防止旧数据干扰新数据。

Dummy Byte(0xFF等):是为了给硬件提供“时钟动力”,让对方能把数据传回来。

扩展(后话)

当单片机‘大脑’(RAM)装不下 500 个人的信息时,最好的办法是在旁边盖一间‘简易仓库’(W25Q Flash)。我们用 DMA 这个‘自动搬运工’,把收到的每一个节点数据通过 SPI 接口送进仓库;等 4G 信号满格时,再用 DMA 把数据从仓库提出来塞进串口发出去。这样即使 CPU 内存再小,也能处理成千上万条信息。”

在只有 8K RAM 的情况下,这种“存储-转发(Store-and-Forward)”模式是最稳妥的。

这种“RAM 做水管,Flash 做水库”的架构,可以说是解决低资源单片机(8K RAM)处理海量数据的标准答案。

对于农村无线抄表系统的基站来说,各个 PAN3031 节点上报数据的时间固定一段时间,比如9点到10点,而 4G 模块开机联网又非常耗电且需要时间。这套机制能让基站平时保持低功耗:一收到 PAN3031 的数据,就迅速用 SPI+DMA 丢进 W25Q 存起来;等攒够了批次,再唤醒 4G 模块,用 UART+DMA 一次性打包上报。

核心设计思路:数据定长化(空间换时间)

Flash 的擦写有页(256字节)和扇区(4K字节)的概念。为了方便管理,我们最好把每个节点的数据规定为一个固定长度,比如 32 个字节

"Node 101, usage 0.3Trn" 实际大约 22 个字节。

剩下的用 0x00 补齐,凑够 32 字节。

好处:计算 Flash 地址极其简单!第 10 个节点的地址就是 10 * 32 = 320 (0x0140)

第一部分:模拟接收 10 个节点并存入 Flash

我们沿用已经调通的 SPI DMA 逻辑,把它封装成一个可以接收动态数据的函数:

#include <stdio.h>#include <string.h>// 定义一个标准的数据包大小,方便 Flash 寻址(比如 32 字节)#define NODE_DATA_SIZE 32  // 发送和接收的缓存(必须 4 字节对齐)__attribute__((aligned(4))) uint8_t CW_DMA_TxBuf1[NODE_DATA_SIZE]; __attribute__((aligned(4))) uint8_t CW_DMA_RxBuf1[NODE_DATA_SIZE];/** * @brief 改进版:将指定长度的数组通过 DMA 写入 W25Q * @param Addr Flash 内部的目标物理地址 * @param pData 要写入的数据指针 * @param len 数据长度 */void W25Q_DMA_Write_Buffer(uint32_t Addr, uint8_t* pData, uint16_t len){    // 1. 发送写使能指令 (CPU 手动发)    FLASH_SPI_CS_LOW();    SPI_ReadWriteByte(0x06);      FLASH_SPI_CS_HIGH();    // 2. 发送页写指令 0x02 和 24位地址 (CPU 手动发)    FLASH_SPI_CS_LOW();    SPI_ReadWriteByte(0x02);      SPI_ReadWriteByte((Addr >> 16) & 0xFF);    SPI_ReadWriteByte((Addr >> 8) & 0xFF);    SPI_ReadWriteByte(Addr & 0xFF);    // ==========================================================    // 【清肠胃逻辑】扫除发指令和地址时 SPI 自动收回的 4 个垃圾字节    // ==========================================================    while (CW_SPI2->ISR & (1 << 1))     {        volatile uint8_t dummy = CW_SPI2->DR;     }    CW_SPI2->ICR = 0xFFFFFFFF; // 彻底清除错误标志位    // 3. 配置并启动 DMA (这里用你的原代码逻辑)    g_dma_done = 0;    DMA_Cmd(CW_DMACHANNEL2, DISABLE);    DMA_Cmd(CW_DMACHANNEL3, DISABLE);    DMA_ClearITPendingBit(DMA_IT_ALL);    // 【动态赋值】将传入的长度赋给 CNT 寄存器    CW_DMACHANNEL2->CNT = (1 << 16) | len;      CW_DMACHANNEL3->CNT = (1 << 16) | len;    // 通道 2 (RX) 目的地设为“垃圾桶” (定点清除)    static uint8_t trash_bin;    CW_DMACHANNEL2->DSTADDR = (uint32_t)&trash_bin;    CW_DMACHANNEL2->CSR_f.DSTINC = 0; // 不递增    // 通道 3 (TX) 源地址设为传入的数据指针 pData    CW_DMACHANNEL3->SRCADDR = (uint32_t)pData;    CW_DMACHANNEL3->CSR_f.SRCINC = 1; // 递增    // 启动搬运工    DMA_Cmd(CW_DMACHANNEL2, ENABLE);    DMA_Cmd(CW_DMACHANNEL3, ENABLE);    // 等待搬运完成    while(g_dma_done == 0);    FLASH_SPI_CS_HIGH();    W25Q_WaitForWriteEnd(); // 等待 Flash 内部把数据刻录完毕}

第二部分:业务逻辑层(伪代码与调用流程)

这一步展示如何在收到数据后存入 Flash,然后统一上报。

 

// 假设我们在一个特定的 Flash 扇区开始存数据uint32_t Base_Flash_Addr = 0x000000; void Meter_Reading_System_Demo(void){    // ==========================================================    // 阶段一:收集 PAN3031 节点数据,存入水库 (W25Q)    // ==========================================================    printf("--- 开始收集 10 个节点的数据 ---rn");    for(int i = 0; i < 10; i++)    {        // 1. 清空发送缓冲区        memset(CW_DMA_TxBuf1, 0, NODE_DATA_SIZE);
        // 2. 模拟从 PAN3031 收到数据,格式化成字符串        // 真实的场景中,这些变量来自无线数据包解码        int node_id = 101 + i;        float usage = 0.3 + (i * 0.1);         sprintf((char*)CW_DMA_TxBuf1, "Node %d, usage %.1fTrn", node_id, usage);
        // 3. 计算这个节点在 Flash 里的物理地址        uint32_t target_addr = Base_Flash_Addr + (i * NODE_DATA_SIZE);
        // 4. 召唤 DMA 搬运工,把数据刻录进 Flash        W25Q_DMA_Write_Buffer(target_addr, CW_DMA_TxBuf1, NODE_DATA_SIZE);
        printf("节点 %d 数据已安全存入 Flash 地址: 0x%06Xrn", node_id, target_addr);    }
    // ... 此时 CPU 可以去休眠,或者等待 4G 模块连上基站 ...    // ==========================================================    // 阶段二:4G 模块就绪,从水库捞数据上报    // ==========================================================    printf("--- 4G 模块就绪,准备通过 UART 发送上报 ---rn");
    // 假设 4G 模块的串口发送 DMA 我们分配为 DMACHANNEL4    // (这里给出伪代码逻辑,UART-DMA 的配置和 SPI 类似,只是终点是 UART->TDR    for(int i = 0; i < 10; i++)    {        uint32_t read_addr = Base_Flash_Addr + (i * NODE_DATA_SIZE);
        // 1. 用 SPI-DMA 把数据从 Flash 读回 RAM 里的 CW_DMA_RxBuf1        // (调用你已经写好的 W25Q_DMA_Read_Back,修改使其支持动态长度)        W25Q_DMA_Read_Buffer(read_addr, CW_DMA_RxBuf1, NODE_DATA_SIZE);
        // 2. (伪代码)开启 UART-DMA,把刚才读回来的数组发给 4G 模块        // UART_DMA_Send_Buffer(CW_UART1, CW_DMA_RxBuf1, NODE_DATA_SIZE);
        // 等待 UART 发送完成        // while(uart_dma_done == 0); 
        printf("已通过串口上报: %s", CW_DMA_RxBuf1);    }}

 

扫码加入QQ群3群| 610403240

相关推荐

登录即可解锁
  • 海量技术文章
  • 设计资源下载
  • 产业链客户资源
  • 写文章/发需求
立即登录

以开放、共享、互助为理念,致力于构建武汉芯源半导体CW32系列MCU生态社区。无论是嵌入式MCU小自还是想要攻破技术难题的工程师,亦或是需求解决方案的产品经理都可在CW32生态社区汲取营养、共同成长。