做嵌入式久了,总会碰到“数据怎么拿出来”的问题。设备跑了好几天,攒了一堆日志、采集数据,总不能每次都用调试器连电脑去读Flash吧。以前我第一反应是插SD卡,挂FatFs,但SD卡的座子、热插拔、供电问题也很烦。后来在项目里换成了CH376S这颗USB接口芯片,往U盘里写文件这件事一下就轻了——STM32这边走SPI发几条命令,芯片自己就把USB设备的枚举、SCSI/UFI命令、FAT表更新全处理掉,通电验证十分钟不到就跑通了一次完整读写。这篇文章就把我这次STM32F103C8T6 + CH376S的实战过程完整记录下来,硬件接线、底层SPI驱动、创建文件、写入、读取的代码都会贴出来。想给设备加数据记录、离线导出配置,或者纯粹想在嵌入式项目里打通U盘文件读写,这篇可以直接照抄。
1. 为什么选CH376S而不是硬刚USB Host协议栈
1.1 让STM32老老实实当“主机”,工程量比想象中大
很多人第一反应是:现在的MCU不是都有USB外设吗?比如STM32F407、H7都带USB OTG,直接把U盘当大容量存储设备来枚举不就行了?理论上可以,实际操作会撞上一堆协议栈的墙。
要把U盘识别成一个块设备,MCU要经历USB总线复位、设备枚举、读设备描述符、配置接口、BOT传输协议(Bulk-Only Transport)、SCSI命令集(INQUIRY、READ CAPACITY、READ10/WRITE10)这一长串流程。这还没完,拿到扇区数据之后,你还得在MCU上移植FatFs并自己解析MBR、DBR、FAT表、目录项。ST官方的HAL库确实提供了USB Host库,但它对HAL版本、堆栈大小、U盘主控兼容性都挺敏感,稍微换个U盘就可能枚举失败。而且F103这种经典芯片根本没有USB Host功能,只有设备模式,你想做Host还得换芯片。
所以问题不是“能不能”,而是“划不划算”。如果你的需求只是“把一小包数据存成文件,或者把配置文件读出来”,为一个USB Host协议栈投入一周甚至几周的时间,太浪费了。
1.2 CH376S相当于“USB Host + 文件系统”二合一
CH376S是沁恒出品的一颗USB接口控制芯片,它最大的特点是把USB Host控制器和FAT文件系统固件都集成在了芯片里。MCU只需要用SPI、UART或者8位并口给它发命令,它会自己完成U盘的枚举、SCSI指令、FAT表的读写和文件目录管理。对单片机来说,操作U盘文件跟操作一块“智能外设”差不多,你发个“创建文件”命令,它就把文件建好了;发个“写入数据”命令,它就把数据写进当前文件。
芯片内部固件分两种模式。内置固件模式是默认状态,芯片自带的固件帮你处理好了USB和FAT,实战中绝大多数场景都用这个模式。另一种是外部固件模式,把CH376S当纯USB Host控制器用,文件系统完全由MCU自己实现,这个基本没人用,除非你要做特殊协议。
需要说明的是,内置固件对文件系统的支持是FAT12、FAT16和FAT32,文件名字符串只支持8.3格式的大写英文名,和当年DOS时代的命名规则一样。长文件名、中文名、NTFS、exFAT这些不要指望它,这是这套方案最大的边界,后面内容都是在这个边界里玩。
1.3 SPI、UART、并口三种接口怎么挑
CH376S对外提供了三种通信接口。8位并口速度最快,适合吞吐量大的场景,但占用IO太多,小单片机一般不推荐。UART接口最简单,全双工异步串口,几乎不挑MCU,但波特率一般建议设置在9600到115200之间,传文件速度偏慢。SPI接口是各方面都比较折中的选择,速度足够用,占IO少,常见工程里也最多人用。
这次实战我用SPI模式。STM32F103C8T6的硬件SPI1直接接CH376S,时钟速率我放在1MHz左右,总线约2.25Mbps(视分频而定),对文件读写来说完全够。第一次调建议先在CubeMX里把SPI速率降下来,等通信稳定了再提速度,排查问题会轻松很多。
2. 硬件接线:SPI模式下的最小系统
2.1 引脚对照与接线表
CH376S模块的核心引脚不多,接到STM32上就是6根线:片选、时钟、SPI收发、中断、复位,再加电源和地。我用的是STM32F103C8T6最小系统板加一个成品CH376S模块,接线如下:
| CH376S 引脚 | 功能 | STM32F103C8T6 | 说明 |
|---|---|---|---|
| SCS | SPI片选,低有效 | PA4 | GPIO推挽输出 |
| SCK | SPI时钟 | PA5 | SPI1_SCK |
| SDI | SPI数据输入(MCU发出的MOSI) | PA7 | SPI1_MOSI |
| SDO | SPI数据输出(发给MCU的MISO) | PA6 | SPI1_MISO |
| INT | 中断输出,低有效 | PA3 | GPIO输入,上拉 |
| RST | 复位,低有效 | PA2 | GPIO推挽输出 |
| VCC | 模块供电 | 3.3V或5V(见说明) | 以模块要求为准 |
| GND | 地 | GND | 共地 |
如果你自己画的板子上CH376S不是模块形式,注意确认芯片的V3引脚、晶振电路、去耦电容这些外围器件,别漏了。模块上一般已经把这些都做好了,省心很多。
2.2 供电、复位和中断引脚的注意事项
供电是这颗芯片最容易翻车的地方。CH376S作为USB Host时,需要给U盘提供5V电源,这个5V不是从芯片里变出来的,需要外部供电。很多模块会带一个5V输入引脚,板上再用LDO转出3.3V给芯片逻辑。如果你的板子只给CH376S的VCC供了3.3V,U盘可能偶尔能识别、读写一两次就掉线,甚至完全没反应。U盘工作电流一般在100mA到300mA,峰值可能更高,最好用能提供500mA以上的5V电源给U盘供电。
复位引脚RST是低有效,上电后需要拉低至少几十毫秒再拉高,让芯片完成内部初始化。我实测下来上电后给个200毫秒左右的延时最稳,太着急发命令容易吃闭门羹。
INT引脚是开漏输出,低电平表示有事件需要MCU处理。开漏输出不能输出高电平,所以必须在外部接上拉电阻,或者用MCU内部上拉。我用的STM32F103GPIO配置成输入上拉,不需要额外加电阻,实测没问题。
提示:如果在系统里看到INT引脚一直是低电平,那是因为它没被拉高,可能是GPIO没配置上拉,或者模块上INT线虚焊。INT被卡死会导致后续所有命令都以为有事件,整个流程就会乱掉。
2.3 买模块还是自己画板
第一次接触CH376S,我的建议是直接买成品模块。好处是晶振、LDO、USB母座、通信接口电平转换都给你画好了,你只需要接几根杜邦线到STM32,最多十分钟就能把硬件搭起来。模块上一般会标注SCS、SCK、SDI、SDO这些引脚,照着接线表对号入座即可。
等验证完功能,再考虑把CH376S画进自己的板子里。自己画的时候需要注意:CH376S需要12MHz晶振,V3引脚需要接一个0.1uF电容到地,USB Host口的D+和D-走线尽量短、等长,U盘电源的5V输入处加一个100uF左右的电解电容和0.1uF小电容,避免U盘主控瞬间拉低电压把整个系统搞复位。
3. 代码实现:从底层驱动到U盘文件读写
这一节我直接把核心代码贴出来,逻辑是自底向上的:先做SPI底层通信,再做CH376S命令交互,最后封装文件读写接口。CubeMX配置那里我用的是HAL库,SPI1配置成全双工主机、8位数据、MSB先行、Mode 0,速率分频到约1MHz,别的默认就行。
3.1 通信测试:先确认SPI链路通不通
任何通信芯片第一步都是确认链路通,CH376S有一个专门的命令叫 CHECK_EXIST(0x06)。发送任意一个字节,芯片会把你发的字节取反后回传。比如你发0x55,正常会读回0xAA。用这个命令可以快速排查接线、SPI配置问题。
/* ch376s.h 关键定义,命令码以CH376S数据手册为准 */ #define CMD_CHECK_EXIST 0x06 #define CMD_SET_USB_MODE 0x15 #define CMD_GET_STATUS 0x22 #define CMD_RD_USB_DATA0 0x27 #define CMD_DISK_MOUNT 0x31 #define CMD_FILE_OPEN 0x32 #define CMD_FILE_CREATE 0x34 #define CMD_FILE_ERASE 0x35 #define CMD_FILE_WRITE 0x36 #define CMD_FILE_READ 0x37 #define CMD_FILE_CLOSE 0x3C #define USB_INT_SUCCESS 0x14 #define USB_INT_DISK_READ 0x20 #define USB_INT_DISK_ERR 0x1D #define USB_INT_FILE_ERR 0x1B #define ERR_MISS_FILE 0x42 #define ERR_EXIST_NAME 0x47static uint8_t SPI_Xfer(uint8_t byte) { uint8_t rx = 0; HAL_SPI_TransmitReceive(&hspi1, &byte, &rx, 1, 10); return rx; } static void WriteCmd(uint8_t cmd) { CS_LOW(); SPI_Xfer(cmd); CS_HIGH(); } static void WriteData(uint8_t data) { CS_LOW(); SPI_Xfer(data); CS_HIGH(); } static uint8_t ReadData(void) { uint8_t data; CS_LOW(); data = SPI_Xfer(0xFF); CS_HIGH(); return data; }通信测试代码放到初始化函数里:
void CH376_Init(void) { RST_LOW(); HAL_Delay(20); RST_HIGH(); HAL_Delay(200); WriteCmd(CMD_CHECK_EXIST); WriteData(0x55); uint8_t r = ReadData(); if (r != 0xAA) { /* 到这里说明SPI没通,先查接线、SPI模式、片选 */ Error_Handler(); } }如果程序死在Error_Handler,不要急着往下走,先用逻辑分析仪或者示波器看SCK和MOSI上的波形,确认命令字节真的发出去了,再查MISO上的回传。
3.2 切换USB主机模式并挂载U盘
SPI通了之后,要让CH376S进入USB Host模式,发送命令 SET_USB_MODE(0x15),参数是0x06(内置固件主机模式)。这个命令不需要等中断,直接读回一个状态字节,正常返回0x51。如果返回值不对,基本可以判定是芯片没进入预期状态。
uint8_t CH376_SetUSBMode(uint8_t mode) { WriteCmd(CMD_SET_USB_MODE); WriteData(mode); return ReadData(); /* 0x51 = 成功 */ }切成主机模式后,插入U盘,用 DISK_MOUNT(0x31)命令挂载磁盘。挂载内部会完成U盘枚举、读文件系统引导区的动作,返回状态一般就是USB_INT_SUCCESS(0x14)。挂载失败时常见返回是USB_INT_DISK_ERR(0x1D)或者USB_INT_FILE_ERR(0x1B)。
等待中断的函数我直接轮询INT引脚:
static uint8_t WaitINT(void) { uint32_t timeout = 0xFFFFFF; while (INT_IS_LOW() == 0) { if (--timeout == 0) return 0xFF; } WriteCmd(CMD_GET_STATUS); return ReadData(); }就是等INT变低,然后用GET_STATUS命令读取状态码。状态码会同时清除CH376S的中断标志,所以每次事件处理完,INT会重新变高,等着下一个事件来。
3.3 写文件:创建、写入、关闭的三板斧
写文件看起来是三个动作,实际上是四步:删除旧文件、创建文件、按块写入数据、关闭文件并更新长度。CH376S的CMD_FILE_CREATE在文件已存在时会返回ERR_EXIST_NAME(0x47),所以我在创建前先执行一次删除操作,忽略“文件不存在”的错误。
uint8_t CH376_DeleteFile(char *name) { WriteCmd(CMD_FILE_ERASE); WriteString(name); return WaitINT(); } uint8_t CH376_WriteFile(char *name, uint8_t *buf, uint16_t len) { uint8_t s; s = CH376_DeleteFile(name); if (s != USB_INT_SUCCESS && s != ERR_MISS_FILE) return s; WriteCmd(CMD_FILE_CREATE); WriteString(name); s = WaitINT(); if (s != USB_INT_SUCCESS) return s; uint16_t offset = 0; while (offset < len) { uint8_t block = (len - offset) > 64 ? 64 : (len - offset); WriteCmd(CMD_FILE_WRITE); WriteData(block); for (uint8_t i = 0; i < block; i++) WriteData(buf[offset + i]); s = WaitINT(); if (s != USB_INT_SUCCESS) return s; offset += block; } WriteCmd(CMD_FILE_CLOSE); WriteData(0x01); /* 参数1表示关闭并更新文件长度 */ s = WaitINT(); return s; }这里有几个关键点。第一,文件名必须以0结尾的ASCII字符串,格式必须是8.3大写,比如“TEST.TXT”、“LOG001.CSV”,不要写“test.txt”或者“我的文件.txt”。第二,每次写入的数据块不要超过64字节,这是CH376S内部缓冲区的基本尺寸,超出后芯片处理不过来,老老实实分批写。第三,写完文件一定要调用CMD_FILE_CLOSE并且参数传1,这个参数会告诉芯片把文件的真实长度更新进FAT目录项。如果漏了这步,U盘上确实会出现这个文件,但大小是0字节,内容全丢了。
3.4 读文件:打开、循环读取、结束判断
读取流程相对于写入更简单一点,核心是打开文件后反复调用CMD_FILE_READ,每次有数据时状态码是USB_INT_DISK_READ(0x20),全部读完时返回USB_INT_SUCCESS。读取到的数据要先发CMD_RD_USB_DATA0命令,从芯片缓冲区里取出本次的长度和数据。
uint8_t CH376_ReadFile(char *name, uint8_t *buf, uint16_t maxLen, uint16_t *readLen) { uint8_t s; *readLen = 0; WriteCmd(CMD_FILE_OPEN); WriteString(name); s = WaitINT(); if (s != USB_INT_SUCCESS) return s; while (1) { WriteCmd(CMD_FILE_READ); s = WaitINT(); if (s == USB_INT_SUCCESS) break; /* 文件结束 */ if (s != USB_INT_DISK_READ) return s; WriteCmd(CMD_RD_USB_DATA0); uint8_t cnt = ReadData(); for (uint8_t i = 0; i < cnt; i++) { uint8_t d = ReadData(); if (*readLen < maxLen) buf[(*readLen)++] = d; } } return USB_INT_SUCCESS; }第一次写这个循环的时候,我犯过一个低级错误:每次读完数据没有继续发CMD_FILE_READ,结果只读到一块数据就结束了。一定要记住,CMD_FILE_READ是“继续读取下一块”的意思,要读到USB_INT_SUCCESS才算完。
3.5 完整代码工程与CubeMX配置要点
把上面这些函数组合起来,配合串口打印,主程序可以这样跑:
uint8_t wbuf[] = "Hello CH376S!\r\nSTM32 -> CH376S -> U盘 读写测试\r\n"; uint8_t rbuf[256]; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); MX_USART1_UART_Init(); CH376_Init(); uint8_t mode = CH376_SetUSBMode(0x06); printf("SetUSBMode: 0x%02X\r\n", mode); uint8_t st; do { HAL_Delay(200); st = CH376_DiskMount(); } while (st != USB_INT_SUCCESS); printf("Disk Mount OK\r\n"); st = CH376_WriteFile("TEST.TXT", wbuf, sizeof(wbuf) - 1); printf("WriteFile: 0x%02X\r\n", st); uint16_t rlen = 0; st = CH376_ReadFile("TEST.TXT", rbuf, sizeof(rbuf), &rlen); printf("ReadFile: 0x%02X, len=%d\r\n", st, rlen); if (st == USB_INT_SUCCESS) printf("Content: %.*s\r\n", rlen, rbuf); while (1) { HAL_Delay(1000); } }CubeMX里SPI1的配置我建议这样填:全双工主机模式,8位数据,MSB先行,时钟极性CPOL=0、时钟相位CPHA=0,预分频先给64,总线时钟72MHz下就是约1.125MHz的SPI时钟。所有GPIO里,PA4、PA2配成推挽输出,PA3配成上拉输入,PA5、PA6、PA7由SPI外设接管,不用手动配置模式。
提示:如果读完一遍还不理解命令交互,建议先把CH376S的数据手册里“命令详解”那章翻一遍。芯片文档写得很工程化,每个命令都有时序图,比在网上找零碎博客高效得多。
4. 常见问题与排障实录
这部分是我在实际调试中遇到最多、也是最值得写下来的问题。如果你按上面的代码跑不通,大概率是下面这几类情况之一。
4.1 U盘插上没反应/挂载失败,八成是电源和格式
“插上U盘但程序一直挂载不成功”是最常见的问题。我排查这个问题的顺序一般是:先看供电,再看通信,最后看U盘格式。
供电问题前面提过:Host模式下U盘必须要有5V电源。用万用表量一下USB母座VBUS和GND之间的电压,如果低于4.7V就要怀疑电源能力。很多开发板是从USB口取电的,再带一个U盘,电流就可能不够了。我踩过的具体坑是用了带灯的U盘扩展坞,还插了一根一米多长的USB延长线,结果电压掉到4.2V,U盘怎么都枚举不上,换短线直插就好了。
格式问题也很好排除。把U盘用Windows格式化工具重新格式化成FAT32,分配单元大小选默认,快速格式化即可。NTFS和exFAT在这颗芯片上基本没戏,别浪费时间。如果你是老手,可能会想用DiskGenius把U盘分区并做成FAT32,那是另一种玩法,但普通调试不需要。
如果这些都没问题,再回头查SPI链路。用CHECK_EXIST命令确认通信正常,用SET_USB_MODE看返回是否0x51,这两步过了,硬件基本没问题。
4.2 文件写不进/写完是0字节
最典型的原因就是我前面反复强调的:写完数据没有调用CMD_FILE_CLOSE,或者调用了但参数传成了0。CH376S只有在关闭文件并传参1的时候,才会把内容长度更新到FAT目录项里。如果你中途掉电、复位,没走到这句话,那U盘上就留下一个0字节的文件。
第二个原因是文件已经存在。直接执行CMD_FILE_CREATE成功不了,会返回0x47。所以我写文件前先删除同名文件,把“文件已存在”这个坑提前填掉。如果你想让文件内容覆盖而不是追加,这个流程就是标准姿势。
第三个原因是文件名不符合8.3格式。比如“DATA123456.TXT”,主文件名已经9个字符了,超了;再比如“传感器.CSV”,中文和编码都不行。文件名就按“8个字符以内的大写ASCII + 点 + 3个字符以内的扩展名”来定,简单。
4.3 读出来的数据乱码或者不全
读出来的数据乱码,多数不是CH376S的问题,而是数据本身的编码问题。比如你在电脑上用记事本保存了一个UTF-8的文本,在单片机上按字节读出来,再通过串口按ASCII打印,中文部分肯定乱,因为UTF-8一个汉字占3字节,你按单字节处理并一个个打印,自然对不上。解决办法是按原始字节流处理,不要对内容做任何编码假设;如果要在Excel里看中文,就按后面5.2节说的,写入UTF-8 BOM。
读不全的情况,要先检查读取循环是否在收到USB_INT_SUCCESS之前就break了。另外,如果文件数据量比你的buf大,我的代码是“读到maxLen就停止存,但还会继续把剩余数据读完”,这样不会破坏读取循环状态。
4.4 SPI通信不稳定的几个隐藏雷点
第一是片选CS的控制。如果CubeMX里SPI的NSS配置成了硬件自动模式,芯片可能会自己拉低NSS,和软件控制的CS打架。建议把NSS配置成Disable硬件管理,CS引脚完全用GPIO手动控制。
第二是SPI模式配置错误。CH376S手册支持模式0和模式3,我用的是模式0。如果你在别的工程里看到模式3,也有道理,但一定要和发送接收端匹配。前几年我做过一次LPC1768的移植,就是SPI模式没对齐,收发数据全是0xFF,查了一下午。
第三是等待中断的延时问题。CH376S操作U盘有时需要几十毫秒甚至上百毫秒,比如格式化、大量写数据。我的WaitINT里有一个很大的超时计数,但如果你的系统有其它任务需要及时响应,建议把超时逻辑改成基于HAL的毫秒计数,避免死等。
5. 扩展:把U盘读写做成数据记录器
跑通读写只是第一步。工程上真正有用的往往是“反复写日志、不断追加记录数据”。这一节我给出几个可以直接落地的扩展思路。
5.1 追加日志不覆盖老数据
最稳的追加方式不是去定位文件末尾,而是用带编号的文件名。比如每次开机创建一个新文件,文件名是“LOG001.TXT”、“LOG002.TXT”,每次写入前用CMD_FILE_OPEN去探测下一个编号是否存在,从1开始往后找,直到找到一个不存在编号再创建。这样既避开了CH376S的定位命令,也天然形成了日志归档。缺点是文件多,但你可以做成固定数量,比如只保留最近10个文件,超过就删掉最旧的编号。
如果设备有RTC,更实用的是直接用日期时间当文件后缀,比如“26012513.TXT”表示2026年1月25日13时,但注意8.3格式里主文件名字符最多8个,这种“YMMDDHH”类的编码需要自己算好长度,比如“262513.TXT”就只用了6个字符。
5.2 直接生成Excel能打开的UTF-8 CSV
记录传感器数据时,CSV格式最省事。要让Excel直接识别UTF-8编码的中文,写入CSV内容之前,先向文件写入3个字节的BOM:0xEF、0xBB、0xBF。之后每行数据用逗号分隔,最后以“\r\n”结束。
比如写入“时间,温度,湿度\r\n”这种表头,再逐行追加数据,保存成“LOG001.CSV”,拿到电脑上直接用Excel打开就不会乱码。注意CSV文件本身的文件名依然得符合8.3格式,内容编码和文件命名规则是两回事。
5.3 写入频率、掉电保护与U盘寿命
U盘的Flash是有擦写寿命的,别把它当成RAM随便写。设计数据记录器时,建议攒够一批数据再写入一次,比如每512字节写一次,而不是每收到一个传感器点就写一次。每次写完都关闭文件,既能更新长度,也能降低FAT表损坏的概率。
掉电保护是个绕不开的话题。如果设备写文件写到一半断电,文件长度更新不了,这次的数据大概率丢,甚至可能导致FAT表异常。工程上的简单做法是双文件轮流写,比如A文件满了写B文件,B文件写完校验又回到A,保证总有一份数据是完整的。更稳妥的方案是加一个超级电容或小容量电池,检测到掉电后延迟给系统断电几百毫秒,利用这点时间把当前文件正常关闭。后者虽然多了一点成本,但对数据可靠性要求高的场景非常值得。
我在实际调试中体会最深的一件事是:这类带固件的接口芯片,只要通信链路通了,后续的命令交互基本都是套路,最难查的反而是供电、U盘格式、文件关闭这些“看起来不起眼”的环节。正式做产品之前,我强烈建议你把同一套代码多换几个U盘、几根USB线测一测,因为U盘主控的兼容性差异是真实存在的。CH376S这套方案肯定不完美,8.3文件名、无长目录这些限制摆在那,但如果你只要“把数据稳定存进U盘、能方便导出来”,它已经是性价比很高的选择了。后面我还会把手上的代码封装成支持多文件管理、循环覆盖的通用组件,到时候再整理成文章跟各位交流。