1. CW32单片机SPI+DMA读写W25Q Flash实战指南
作为一名嵌入式开发工程师,我最近在无线抄表项目中遇到了一个经典问题:如何高效地通过SPI接口读写外部Flash存储器。传统的一个字节一个字节搬运的方式效率太低,于是我决定采用DMA(直接内存访问)技术来优化这个过程。但在实际调试中,我发现SPI+DMA的组合存在不少"坑",今天就把我的实战经验分享给大家。
1.1 项目背景与核心需求
在智能水表等物联网终端设备中,CW32系列单片机因其低功耗和高性价比被广泛应用。这类项目通常需要存储大量计量数据,而片内Flash容量有限,因此外扩W25Q系列SPI Flash成为常见方案。W25Q128FV容量达16MB,但直接使用CPU搬运数据会导致两个问题:
- 效率低下:每个字节都需要CPU参与,占用大量计算资源
- 实时性差:长时间阻塞会影响其他任务执行
DMA技术可以完美解决这些问题,它就像个智能搬运工,能在不打扰CPU的情况下自动完成数据传输。但SPI协议的全双工特性与DMA配合时,会产生一些独特的挑战。
2. 硬件架构与工作原理
2.1 SPI全双工通信机制
SPI总线就像一条双向传送带,具有以下特点:
- 主从架构:单片机作为主机控制时钟信号
- 全双工:发送和接收同时进行
- 同步传输:依赖时钟边沿采样数据
当主机发送一个字节时,从机(如W25Q)会同时返回一个字节。这意味着即使我们只想写入数据,也会收到"垃圾"数据;同样,读取数据时需要发送"哑元"字节来产生时钟信号。
2.2 DMA工作流程
DMA控制器是独立于CPU的外设,其主要功能包括:
- 自动搬运数据:在内存与外设间传输数据
- 多种触发模式:硬件触发(如SPI事件)或软件触发
- 灵活配置:支持地址递增/固定、循环模式等
在我们的场景中,需要配置两个DMA通道:
- TX通道:负责将内存数据发送到SPI数据寄存器
- RX通道:负责将SPI接收的数据保存到内存
3. 关键实现步骤与避坑指南
3.1 内存对齐问题处理
DMA对内存地址有对齐要求,不当的对齐会导致数据错位或硬件错误。以下是解决方案:
c复制// 强制4字节对齐的内存定义
__attribute__((aligned(4))) uint8_t CW_DMA_TxBuf1[256] = "kunkun";
__attribute__((aligned(4))) uint8_t CW_DMA_RxBuf1[256];
注意:
__attribute__((aligned(4)))告诉编译器将这些数组放在4字节对齐的地址上。如果不加这个修饰,在某些情况下可能导致DMA传输失败。
内存对齐原理
现代32位CPU通常以4字节为单位访问内存。非对齐访问会导致:
- 性能下降:需要多次内存访问
- 硬件异常:在某些架构上会触发错误
结构体对齐示例:
c复制struct MyData {
int a; // 4字节
int b; // 4字节
char c; // 1字节
// 编译器自动插入3字节填充(padding)
};
3.2 DMA中断配置
DMA完成传输后需要通过中断通知CPU,配置不当会导致程序卡死:
c复制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) {
if (DMA_GetITStatus(DMA_IT_TC2)) { // 检查RX通道完成
DMA_ClearITPendingBit(DMA_IT_TC2);
g_dma_done = 1; // 设置完成标志
}
if (DMA_GetITStatus(DMA_IT_TC3)) { // 清理TX标志
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();
}
}
经验:RX通道的完成中断更可靠,因为它表示SPI时钟已全部完成。仅监控TX通道可能导致提前退出等待循环。
3.3 SPI与DMA初始化关键点
完整的初始化函数包含多个易错点:
c复制void SPI2_DMA_Init(void) {
DMA_InitTypeDef DMA_InitStruct;
// 1. 开启外设时钟(常被遗忘!)
__RCC_SPI2_CLK_ENABLE();
RCC_AHBPeriphClk_Enable(RCC_AHB_PERIPH_DMA, ENABLE);
// 2. 配置RX通道(SPI2接收)
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触发源计算值
DMA_Init(CW_DMACHANNEL2, &DMA_InitStruct);
// 3. 配置TX通道(SPI2发送)
DMA_InitStruct.DMA_SrcAddress = (uint32_t)CW_DMA_TxBuf1;
DMA_InitStruct.DMA_DstAddress = (uint32_t)&CW_SPI2->DR;
DMA_InitStruct.DMA_SrcInc = DMA_SrcAddress_Increase;
DMA_InitStruct.HardTrigSource = 37; // SPI2_TX触发源计算值
DMA_Init(CW_DMACHANNEL3, &DMA_InitStruct);
// 4. 安全开启SPI的DMA请求
CW_SPI2->CR1 &= ~(uint32_t)(1 << 6); // 先关闭SPI(SPE=0)
CW_SPI2->CR1 |= (uint32_t)(0x03 << 16); // 使能DMA请求
CW_SPI2->CR1 |= (uint32_t)(1 << 6); // 重新开启SPI
}
触发源计算详解
CW32的DMA触发源需要特殊计算:
- SPI2_RX: 8 (二进制1000)
- 计算:8×4 + 1 = 33
- SPI2_TX: 9 (二进制1001)
- 计算:9×4 + 1 = 37
这个计算基于DMA触发寄存器的位域设计:
- 低2位:触发模式(1表示硬件触发)
- 高4位:外设触发源编号(左移2位)
3.4 数据写入Flash的关键技巧
写入操作需要特别注意SPI的"垃圾数据"问题:
c复制void W25Q_DMA_Write_Kunkun(uint32_t Addr) {
// 1. 发送写使能指令(CPU手动)
FLASH_SPI_CS_LOW();
SPI_ReadWriteByte(0x06); // WREN指令
FLASH_SPI_CS_HIGH();
// 2. 发送页写指令和地址(CPU手动)
FLASH_SPI_CS_LOW();
SPI_ReadWriteByte(0x02); // Page Program指令
SPI_ReadWriteByte((Addr >> 16) & 0xFF);
SPI_ReadWriteByte((Addr >> 8) & 0xFF);
SPI_ReadWriteByte(Addr & 0xFF);
// 3. 清空SPI接收缓冲区(关键!)
while (CW_SPI2->ISR & (1 << 1)) {
volatile uint8_t dummy = CW_SPI2->DR;
}
CW_SPI2->ICR = 0xFFFFFFFF; // 清除所有错误标志
// 4. 配置DMA发送实际数据
g_dma_done = 0;
DMA_Cmd(CW_DMACHANNEL2, DISABLE);
DMA_Cmd(CW_DMACHANNEL3, DISABLE);
// 设置传输长度(带REPEAT位)
CW_DMACHANNEL2->CNT = (1 << 16) | 6;
CW_DMACHANNEL3->CNT = (1 << 16) | 6;
// RX通道指向"垃圾桶"(地址不递增)
static uint8_t trash_bin;
CW_DMACHANNEL2->DSTADDR = (uint32_t)&trash_bin;
CW_DMACHANNEL2->CSR_f.DSTINC = 0;
// TX通道指向源数据
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);
while(g_dma_done == 0); // 等待完成
FLASH_SPI_CS_HIGH();
W25Q_WaitForWriteEnd(); // 等待Flash内部写入完成
}
避坑指南:发送指令和地址时,SPI会同时收到4个垃圾字节。如果不清理,后续DMA传输会导致SPI溢出错误(Overrun)。这就是为什么需要在启动DMA前清空接收缓冲区。
3.5 从Flash读取数据的实现
读取操作需要发送哑元字节来产生时钟:
c复制void W25Q_DMA_Read_Back(uint32_t Addr) {
FLASH_SPI_CS_LOW();
// 发送读指令和地址(CPU手动)
SPI_ReadWriteByte(0x03); // Read指令
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;
// RX通道指向接收缓冲区
CW_DMACHANNEL2->DSTADDR = (uint32_t)CW_DMA_RxBuf1;
CW_DMACHANNEL2->CSR_f.DSTINC = 1; // 地址递增
// 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();
}
技巧:读取时需要持续发送数据来产生时钟,这里我们设置TX通道固定发送0xFF,同时RX通道将数据存入目标缓冲区。这种"假发送,真接收"的方式是SPI读取的标准做法。
4. 调试过程与问题排查
4.1 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 卡死在while循环 | DMA未正确触发 | 检查触发源计算和SPI的DMA使能位 |
| 数据错位 | 内存未对齐或未清空缓冲区 | 确保4字节对齐,清空SPI缓冲区 |
| 只有部分数据传输 | CNT寄存器配置错误 | 检查传输长度设置,包括REPEAT位 |
| 随机数据错误 | SPI时钟配置不当 | 降低SPI时钟频率,检查CPOL/CPHA |
4.2 寄存器调试技巧
通过调试器观察关键寄存器可以快速定位问题:
-
SPI控制寄存器(CR1):
- SPE位:SPI使能位必须为1
- DMAR/DMAT位:DMA请求使能位
-
DMA状态寄存器:
- CNT值:应该随着传输递减
- SRCADDR/DSTADDR:检查地址是否正确递增
-
SPI状态寄存器(ISR):
- RXNE/TXE:标志传输状态
- OVR:溢出错误标志
4.3 实际调试案例
在调试过程中,我发现程序偶尔会卡死,通过以下步骤解决了问题:
- 检查DMA触发源:发现SPI2_TX的触发源计算错误(误用十进制而非二进制)
- 验证内存对齐:添加
__attribute__((aligned(4)))后数据错位问题消失 - 优化SPI时钟:将分频系数从2调整为8(16MHz→4MHz)提高稳定性
5. 项目扩展与优化建议
5.1 动态长度数据传输
原始示例使用固定长度传输,实际项目可以扩展为动态长度:
c复制void W25Q_DMA_Write_Buffer(uint32_t Addr, uint8_t* pData, uint16_t len) {
// ... 前导指令部分相同 ...
// 动态设置传输长度
CW_DMACHANNEL2->CNT = (1 << 16) | len;
CW_DMACHANNEL3->CNT = (1 << 16) | len;
// 动态设置数据指针
CW_DMACHANNEL3->SRCADDR = (uint32_t)pData;
// ... 其余部分相同 ...
}
5.2 Flash数据管理策略
对于无线抄表系统,建议采用以下策略:
- 定长数据包:如每个节点使用32字节固定长度
- 地址计算:节点n的地址 = 基地址 + n × 32
- 批量操作:积累一定数据量后统一上报
5.3 低功耗优化
结合DMA的特性可以实现高效的低功耗设计:
- 快速存储:收到数据后立即通过DMA存入Flash
- CPU休眠:数据传输期间CPU可进入低功耗模式
- 定时上报:积累足够数据后唤醒4G模块批量发送
6. 核心经验总结
经过这个项目的实战,我总结了以下几点关键经验:
- 内存对齐是基础:DMA对内存地址敏感,务必保证4字节对齐
- 清空缓冲区是关键:SPI全双工特性会产生垃圾数据,必须及时清理
- 触发源计算要精确:硬件触发需要严格按照手册计算参数
- 调试寄存器是捷径:遇到问题时,直接观察寄存器值往往最快定位原因
- DMA与CPU协作:合理利用中断机制实现高效协作
这个方案在CW32+W25Q的硬件平台上经过验证,稳定传输了大量计量数据,为无线抄表系统提供了可靠的数据存储方案。希望我的这些经验能帮助大家少走弯路,顺利实现自己的SPI+DMA应用。
