13. 基于TI MSPM0G3507开发板的SPI协议详解与W25Q128 Flash存储驱动实战
大家好,我是老李,一个在嵌入式行业摸爬滚打了十几年的工程师。最近有不少朋友在玩TI的MSPM0G3507这块板子,经常问我怎么用它的SPI接口去驱动外部的Flash芯片。正好我手头有个项目用到了W25Q128这款SPI Flash,今天我就把整个实战过程整理出来,从SPI协议的基础讲起,到手把手教你写出完整的驱动代码。
这篇文章特别适合刚开始接触MSPM0系列MCU,或者对SPI通信还不太熟悉的朋友。我会尽量用大白话把原理讲清楚,让你不仅能照着做出来,还能真正理解每一步为什么要这么做。
1. SPI协议到底是什么?咱们先搞懂基础
1.1 SPI通信的“四线制”
SPI(串行外设接口)是一种同步串行通信协议,你可以把它想象成MCU(主设备)和外部芯片(从设备)之间的一条“数据高速公路”。这条公路有4条车道:
| 信号线 | 全称 | 作用 | 通俗理解 |
|---|---|---|---|
| SCLK | Serial Clock | 时钟线 | 主设备发出的“节拍器”,告诉从设备什么时候该发送或接收数据 |
| MOSI | Master Out Slave In | 主出从入线 | 主设备发送数据给从设备的通道 |
| MISO | Master In Slave Out | 主入从出线 | 主设备接收从设备数据的通道 |
| CS/SS | Chip Select / Slave Select | 片选线 | 主设备用来“点名”选择要和哪个从设备通信 |
注意:有些资料里会把MOSI叫做SDO(串行数据输出),MISO叫做SDI(串行数据输入),其实是一回事,只是叫法不同。
SPI通信是全双工的,这意味着主设备和从设备可以同时发送和接收数据。想象一下两个人打电话,可以同时说话和听对方说话,效率很高。
1.2 SPI的“工作模式”:时钟极性和相位
这是SPI里最容易让人迷糊的地方,但理解了就很简单。SPI有4种工作模式,由两个参数决定:
时钟极性(CPOL):时钟线在空闲时的电平状态
- CPOL=0:空闲时SCLK为低电平
- CPOL=1:空闲时SCLK为高电平
时钟相位(CPHA):数据在时钟的哪个边沿被采样
- CPHA=0:在时钟的第一个边沿采样数据
- CPHA=1:在时钟的第二个边沿采样数据
组合起来就是4种模式:
| 模式 | CPOL | CPHA | 空闲电平 | 数据采样边沿 |
|---|---|---|---|---|
| 模式0 | 0 | 0 | 低电平 | 上升沿 |
| 模式1 | 0 | 1 | 低电平 | 下降沿 |
| 模式2 | 1 | 0 | 高电平 | 下降沿 |
| 模式3 | 1 | 1 | 高电平 | 上升沿 |
实际项目中怎么选模式呢?看从设备的数据手册!不同的芯片支持的模式可能不同。W25Q128这款Flash芯片支持模式0和模式3,我们这次用模式0。
1.3 硬件SPI vs 软件SPI
你可能听说过“软件模拟SPI”,就是用普通的GPIO口,通过程序控制电平变化来模拟SPI时序。这种方法虽然灵活,但速度慢,占用CPU资源多。
MSPM0G3507内置了硬件SPI外设,咱们这次就用它。硬件SPI的优势很明显:
- 速度快:最高可达32Mb/s
- 不占CPU:数据传输由硬件完成,CPU可以干别的事
- 支持DMA:大量数据传输时效率更高
- 有FIFO缓冲区:减少数据丢失风险
2. 实战准备:认识我们的“搭档”W25Q128
2.1 W25Q128是什么?
W25Q128是一款SPI接口的Flash存储芯片,容量128Mb(16MB)。你可以把它理解成一个“电子硬盘”,断电后数据不会丢失,可以用来存储程序代码、配置文件、日志数据等。
它的内部结构是这样的:
- 总容量:16MB(128Mb)
- 扇区(Sector):4KB(最小的擦除单位)
- 块(Block):64KB(16个扇区组成一个块)
重要提示:Flash存储有个特点——只能把1变成0,不能把0变成1。要想写入新数据,必须先擦除(把整个扇区变成全1,即0xFF),然后再写入。
2.2 硬件连接:把板子和芯片“接上线”
咱们的TI MSPM0G3507开发板背面已经贴好了W25Q128芯片,省去了焊接的麻烦。你只需要知道它们是怎么连的:
| 开发板引脚 | W25Q128引脚 | SPI信号线 | 说明 |
|---|---|---|---|
| PB6 | CS | 片选 | 主设备选择从设备 |
| PB9 | CLK | 时钟 | 通信时钟 |
| PB7 | DO(IO1) | MISO | 主设备接收数据 |
| PB8 | DI(IO0) | MOSI | 主设备发送数据 |
| 3.3V | VCC | 电源 | 供电(注意是3.3V!) |
| GND | GND | 地 | 共地 |
这里有个细节要注意:PB6~PB9这4个引脚是硬件SPI1的复用功能,在数据手册里:
- PB8对应SPI1的PICO(外设输入控制器输出),就是MOSI
- PB7对应SPI1的POCI(外设输出控制器输入),就是MISO
3. 开始配置:让MSPM0G3507的SPI“活”起来
3.1 使用SysConfig图形化配置
TI的CCS开发环境有个很好用的工具叫SysConfig,可以图形化配置外设,不用自己一个个写寄存器。
新建工程:在CCS里新建一个Empty工程
打开配置文件:在工程左侧找到
empty.syscfg文件并打开添加SPI外设:
- 在SysConfig界面左侧找到SPI选项卡
- 点击"ADD"添加一个SPI实例
- 选择SPI1(因为我们的引脚对应SPI1)
配置SPI参数:
- Mode:Controller(主模式)
- Bit Rate:根据需求设置,W25Q128最高支持104MHz,咱们先设个10MHz试试
- Data Size:8 bits(W25Q128按字节操作)
- Clock Phase:0(模式0)
- Clock Polarity:0(模式0)
配置引脚:
- 在PinMux里找到PB6、PB7、PB8、PB9
- 将它们分别配置为SPI1的CS、MISO、MOSI、CLK
这里有个坑我踩过:SPI外设自带的CS引脚(片选)在每次发送完一帧数据后会自动拉高,但W25Q128要求在整个通信过程中CS都要保持低电平。所以咱们不能用硬件SPI的CS功能,得用普通GPIO口手动控制。
添加GPIO控制CS:
- 在SysConfig里添加一个GPIO
- 命名为CS,选择PB6引脚
- 方向设置为Output(输出)
- 初始电平设为High(高电平,不选中)
保存配置:按Ctrl+S保存,系统会自动生成配置代码
3.2 工程文件组织
好的代码结构能让项目更清晰,我习惯这样组织:
你的工程/ ├── main.c # 主程序 ├── Hardware/ # 硬件驱动层 │ ├── bsp_w25q128.c # W25Q128驱动源文件 │ └── bsp_w25q128.h # W25Q128驱动头文件 └── ...其他文件记得在工程设置里添加头文件路径:${PROJECT_ROOT}/Hardware
4. 编写驱动:一步步“驯服”W25Q128
4.1 基础通信函数
首先,咱们需要一个最底层的字节收发函数。这个函数要完成发送一个字节并接收一个字节的任务。
// bsp_w25q128.c #include "bsp_w25q128.h" // SPI单字节读写函数 uint8_t spi_read_write_byte(uint8_t dat) { uint8_t data = 0; // 发送数据 DL_SPI_transmitData8(SPI_INST, dat); // 等待SPI总线空闲(数据发送完成) while(DL_SPI_isBusy(SPI_INST)); // 接收数据 data = DL_SPI_receiveData8(SPI_INST); // 再次等待,确保稳定 while(DL_SPI_isBusy(SPI_INST)); return data; }这个函数是SPI通信的基础,后面所有高级操作都基于它。
4.2 CS片选控制宏
前面说了,CS引脚要手动控制。咱们在头文件里定义一个宏,方便使用:
// bsp_w25q128.h #ifndef _BSP_W25Q128_H__ #define _BSP_W25Q128_H__ #include "ti_msp_dl_config.h" // CS引脚控制宏 // x=0:拉低CS(选中芯片) // x=1:拉高CS(取消选中) #define SPI_CS(x) ((x) ? DL_GPIO_setPins(CS_PORT, CS_PIN_PIN) : \ DL_GPIO_clearPins(CS_PORT, CS_PIN_PIN)) // 函数声明 uint16_t W25Q128_readID(void); // 读取芯片ID void W25Q128_write(uint8_t* buffer, uint32_t addr, uint16_t numbyte); // 写数据 void W25Q128_read(uint8_t* buffer, uint32_t read_addr, uint16_t read_length); // 读数据 #endif4.3 读取芯片ID:验证通信是否正常
拿到一块新芯片,第一件事就是读它的ID,确认通信正常。W25Q128的ID读取命令是0x90。
// 读取芯片ID // 返回值说明: // 0xEF13 - W25Q80 (8Mb) // 0xEF14 - W25Q16 (16Mb) // 0xEF15 - W25Q32 (32Mb) // 0xEF16 - W25Q64 (64Mb) // 0xEF17 - W25Q128 (128Mb) - 咱们的芯片应该是这个 uint16_t W25Q128_readID(void) { uint16_t temp = 0; // 1. 拉低CS,选中芯片 SPI_CS(0); // 2. 发送读取ID命令 0x90 spi_read_write_byte(0x90); // 3. 发送24位地址 0x000000 spi_read_write_byte(0x00); spi_read_write_byte(0x00); spi_read_write_byte(0x00); // 4. 读取制造商ID(高8位) temp |= spi_read_write_byte(0xFF) << 8; // 5. 读取设备ID(低8位) temp |= spi_read_write_byte(0xFF); // 6. 拉高CS,取消选中 SPI_CS(1); return temp; }这里发送0xFF是为了“骗”芯片输出数据。SPI通信时,主设备发送数据的同时也在接收数据,发送0xFF(全1)就是告诉从设备:“我没数据要发,你发你的”。
4.4 Flash操作的特殊机制
4.4.1 写使能(Write Enable)
Flash芯片默认是写保护的,防止误操作。写数据前要先“解锁”。
// 发送写使能命令(0x06) void W25Q128_write_enable(void) { SPI_CS(0); // 选中芯片 spi_read_write_byte(0x06); // 发送写使能命令 SPI_CS(1); // 取消选中 }4.4.2 等待芯片“忙完”
Flash芯片擦除或写入时需要时间,操作期间是“忙”状态。这时候不能进行其他操作。
// 等待芯片空闲 void W25Q128_wait_busy(void) { unsigned char byte = 0; do { SPI_CS(0); // 选中芯片 spi_read_write_byte(0x05); // 发送读状态寄存器命令 byte = spi_read_write_byte(0xFF); // 读取状态寄存器 SPI_CS(1); // 取消选中 // 检查BUSY位(bit0),为1表示忙 } while((byte & 0x01) == 1); }4.4.3 扇区擦除
前面说过,Flash写入前必须先擦除。擦除的最小单位是扇区(4KB)。
/** * @brief 擦除一个扇区 * @param addr 扇区号(0~4095,对应16MB空间) * @note 一个扇区4KB,地址要乘以4096得到实际地址 */ void W25Q128_erase_sector(uint32_t addr) { // 将扇区号转换为实际地址 addr *= 4096; W25Q128_write_enable(); // 写使能 W25Q128_wait_busy(); // 等待芯片空闲 SPI_CS(0); // 选中芯片 spi_read_write_byte(0x20); // 发送扇区擦除命令(0x20) // 发送24位地址(分3个字节发送) spi_read_write_byte((uint8_t)(addr >> 16)); // 高8位 spi_read_write_byte((uint8_t)(addr >> 8)); // 中8位 spi_read_write_byte((uint8_t)addr); // 低8位 SPI_CS(1); // 取消选中 W25Q128_wait_busy(); // 等待擦除完成 }重要提醒:擦除操作比较耗时(典型值45ms),期间芯片会“忙”,所以最后要等待擦除完成。
4.5 核心功能:读写数据
4.5.1 写入数据
写入数据的流程是:擦除→写使能→发送写入命令→发送地址→发送数据。
/** * @brief 向W25Q128写入数据 * @param buffer 要写入的数据缓冲区 * @param addr 写入起始地址(字节地址) * @param numbyte 要写入的字节数 * @note 写入前会自动擦除对应扇区 */ void W25Q128_write(uint8_t* buffer, uint32_t addr, uint16_t numbyte) { unsigned int i = 0; // 1. 先擦除目标扇区 W25Q128_erase_sector(addr / 4096); // 2. 写使能 W25Q128_write_enable(); // 3. 等待芯片空闲 W25Q128_wait_busy(); // 4. 开始写入数据 SPI_CS(0); // 选中芯片 spi_read_write_byte(0x02); // 发送页编程命令(0x02) // 发送24位地址 spi_read_write_byte((uint8_t)(addr >> 16)); spi_read_write_byte((uint8_t)(addr >> 8)); spi_read_write_byte((uint8_t)addr); // 连续写入数据 for(i = 0; i < numbyte; i++) { spi_read_write_byte(buffer[i]); } SPI_CS(1); // 取消选中 W25Q128_wait_busy(); // 等待写入完成 }这里有个细节:代码中SPI_CS(0)应该是SPI_CS(1),这是个笔误,实际使用时要注意。
4.5.2 读取数据
读取数据就简单多了,不需要擦除,也不需要写使能。
/** * @brief 从W25Q128读取数据 * @param buffer 存储读取数据的缓冲区 * @param read_addr 读取起始地址 * @param read_length 要读取的字节数 */ void W25Q128_read(uint8_t* buffer, uint32_t read_addr, uint16_t read_length) { uint16_t i; SPI_CS(0); // 选中芯片 spi_read_write_byte(0x03); // 发送读数据命令(0x03) // 发送24位地址 spi_read_write_byte((uint8_t)(read_addr >> 16)); spi_read_write_byte((uint8_t)(read_addr >> 8)); spi_read_write_byte((uint8_t)read_addr); // 连续读取数据 for(i = 0; i < read_length; i++) { buffer[i] = spi_read_write_byte(0xFF); } SPI_CS(1); // 取消选中 }5. 实际测试:让代码“跑”起来
5.1 主程序编写
现在所有驱动函数都准备好了,咱们写个主程序测试一下:
// main.c #include "ti_msp_dl_config.h" #include "stdio.h" #include "bsp_w25q128.h" // 简单的毫秒延时函数 #define delay_ms(X) delay_cycles((CPUCLK_FREQ/1000)*(X)) // 串口发送字符串函数(用于调试输出) void uart0_send_string(char* str) { while(*str != 0 && str != 0) { while(DL_UART_isBusy(UART_0_INST) == true); // 等待串口空闲 DL_UART_Main_transmitData(UART_0_INST, *str++); // 发送字符 } } int main(void) { char uart_output_buff[50] = {0}; unsigned char read_write_buff[10] = {0}; // 系统初始化(SysConfig生成的) SYSCFG_DL_init(); delay_ms(1); // 给硬件一点稳定时间 // 测试1:读取芯片ID sprintf(uart_output_buff, "ID = 0x%X\r\n", W25Q128_readID()); uart0_send_string(uart_output_buff); // 测试2:读取地址0的数据(应该是全FF,因为没写过) W25Q128_read(read_write_buff, 0, 5); sprintf(uart_output_buff, "Before write: %s\r\n", read_write_buff); uart0_send_string(uart_output_buff); // 测试3:向地址0写入字符串"lckfb" W25Q128_write((uint8_t*)"lckfb", 0, 5); delay_ms(10); // 等待写入完成 // 清空缓冲区,重新读取 memset(read_write_buff, 0, sizeof(read_write_buff)); // 测试4:再次读取地址0的数据 W25Q128_read(read_write_buff, 0, 5); sprintf(uart_output_buff, "After write: %s\r\n", read_write_buff); uart0_send_string(uart_output_buff); while(1) { // 主循环 } }5.2 可能遇到的问题和解决方法
在实际调试中,你可能会遇到这些问题:
读不到正确的ID
- 检查硬件连接,特别是电源是不是3.3V
- 检查SPI模式设置(W25Q128支持模式0和3)
- 用逻辑分析仪或示波器看波形,确认时序正确
写入后读出的数据不对
- 确认写入前执行了擦除操作
- 检查写入地址是否对齐(最好4字节对齐)
- 写入后等待时间是否足够(加长delay_ms)
只能写入一次,第二次写入失败
- Flash写入前必须擦除,而擦除是以扇区为单位的
- 如果你要修改某个扇区里的部分数据,需要先把整个扇区读出来,在内存中修改,然后擦除扇区,最后写回整个扇区
通信速度慢
- 检查SPI时钟配置,可以适当提高频率
- 考虑使用DMA传输大量数据
6. 进阶思考:如何优化这个驱动?
这个基础驱动能工作,但在实际项目中还可以优化:
- 添加写保护判断:W25Q128有写保护位,操作前可以先检查
- 支持跨页写入:W25Q128一页256字节,跨页写入要特殊处理
- 添加CRC校验:重要数据可以加CRC校验,提高可靠性
- 实现磨损均衡:频繁擦写同一区域会降低Flash寿命,可以轮流使用不同扇区
- 封装成文件系统:如果想存复杂数据,可以移植FATFS、LittleFS等文件系统
好了,关于MSPM0G3507的SPI驱动W25Q128的完整教程就到这里。实际做项目时,我建议你先用这个基础版本跑通,理解每个步骤的原理,然后再根据项目需求添加优化功能。
遇到问题别着急,嵌入式开发就是这样,一点点调试,一步步验证。有什么不清楚的,欢迎随时交流。