在用 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");// 第一步:写入 kunkunW25Q_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) = 0111:8 位数据长度。代表每次搬运 8 个比特(1 个字节)。
Bit 6 (SPE) = 0:SPI 关闭。这是目前的重点!因为这一位是 0,说明 SPI 还没“点火”,所以它现在不会有任何动作。
Bit 16~17 (DMA) = 0:DMA 请求关闭。说明此时还没运行到开启 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 寄存器里,CPOL、CPHA、BR 这些‘属性’都已经设好了,这就像是赛车手已经调好了座椅、系好了安全带(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=1, SRCINC=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) 源地址设为传入的数据指针 pDataCW_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 搬运工,把数据刻录进 FlashW25Q_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
319