AD16N_1.4.1__TF卡和Flash共用引脚会导致程序在PC_mode下死机
TF卡和Flash共用SPI:怎么接电脑当U盘
项目里用了杰理AD16N(一颗跑音频+蓝牙的小芯片,SOP16封装只有16个脚),T卡和外挂Flash得共用同一组SPI引脚。插上USB线进PC模式,电脑要同时看到T卡和Flash两个盘。三根线、两个设备、一条USB——其实跟独木桥一个道理:桥就一条,两辆车得轮着过,谁过桥谁就得把对面拦住。下面就按这个思路捋一遍。 |
整体思路
Plain Text // TF卡+Flash共用SPI → USB接电脑的完整链路
板级配置(PA09/PA10/PA11) │ ├── 注册设备:sd0(T卡) + ext_flsh(Flash) │ ├── 进PC模式 → usb_slave_app() │ ├── dev_open("ext_flsh") → SPI仲裁 → Flash初始化 │ ├── 预热读(sector 0、1) │ └── usb_start() → USB枚举 │ ├── 电脑发SCSI命令(READ_10 / WRITE_10) │ ├── MSD层拿到LUN号 → 找到对应设备 │ ├── IOCTL_CMD_RESUME → SPI仲裁抢桥 │ ├── Flash读写(带4KB cache) │ └── IOCTL_CMD_SUSPEND → SPI仲裁还桥 │ └── 退出PC模式 → dev_close() → 释放SPI |
翻译:芯片上电先把两个设备都注册好,插USB进PC模式后电脑看到两个盘,每次读写Flash前先把T卡的SPI线挂起来(拦桥),读写完再还回去(放行),来来回回就这么切。
一、硬件怎么共的——三根线两个人用
AD16N的SOP16封装脚不够用,T卡和Flash只能共用PA09、PA10、PA11这三根线。两个设备看这三根线的角色不一样:
引脚 | T卡视角(SD模式) | Flash视角(SPI模式) |
PA09 | CLK(时钟) | CS(片选) |
PA10 | DAT0(数据) | CLK(时钟) |
PA11 | CMD(命令) | DO/DI(数据出入) |
Plain Text // 共线拓扑——独木桥结构
PA09 PA10 PA11 │ │ │ ┌─────┴────────┴────────┴─────┐ │ SPI / SD 总线 │ └─────┬──────────────┬────────┘ │ │ ┌─────┴─────┐ ┌─────┴────────┐ │ T卡(SD) │ │ Flash(SPI) │ │ CLK/DAT │ │ CS/CLK/DO │ │ /CMD │ │ /DI │ └───────────┘ └──────────────┘ |
三根线就是那条独木桥——同一时刻只能有一个设备在上面跑数据,另一个得让路。
整体架构放一张图:
看一下SDK里怎么配的:
Plain Text // app_config.h —— SPI引脚和复用开关
#define TFG_SPI_UNIDIR_MODE_EN ENABLE //外挂Flash单线模式 #define TFG_SPI_WORK_MODE SPI_MODE_UNIDIR_1BIT
#define TFG_SPI_CS_PORT_SEL IO_PORTA_09 #define TFG_SPI_CLK_PORT_SEL IO_PORTA_10 #define TFG_SPI_DO_PORT_SEL IO_PORTA_11 #define TFG_SPI_DI_PORT_SEL IO_PORTA_11 //单线模式DO和DI共用PA11
#define SPI_SD_IO_REUSE TFG_SD_EN //SD启用时才开IO复用协调 |
再看T卡那边的引脚定义:
Plain Text // sd_port.h —— T卡引脚(和Flash完全重叠)
#define SDMMC_CMD_IO IO_PORTA_11 //CMD → PA11 #define SDMMC_CLK_IO IO_PORTA_09 //CLK → PA09 #define SDMMC_DAT_IO IO_PORTA_10 //DAT → PA10 |
两段配置对着看:PA09/PA10/PA11一个不差,Flash的SPI和T卡的SD用的就是同一组脚。SPI_SD_IO_REUSE这个宏就是"独木桥交通灯"的总开关——T卡启用了,复用协调才会生效。
二、软件怎么仲裁的——独木桥上的交通灯
硬件共线了,软件得管好"谁在用桥"。SDK里搞了一套信号量+SPI切换的机制,每次Flash要用SPI之前,先把T卡的IO挂起来,用完再还回去。
抢桥:norflash_reuse_enter()
Plain Text // norflash.c —— Flash抢占SPI总线
static int norflash_reuse_enter() { if (norflash_reuse_keep) { //已经占着桥了,直接过 return 0; } if (sd_io_reuse_suspend() != 0) { //叫T卡让路,让不了就失败 return -1; } spi_busy = 1; //标记:桥上有人了 spi_flash_io_resume(); //把SPI控制器重新配好给Flash用 return 0; } |
还桥:norflash_reuse_exit()
Plain Text // norflash.c —— Flash归还SPI总线
static void norflash_reuse_exit() { if (norflash_reuse_keep) { //有"霸桥"标记,不还 return; } if (!spi_busy) { //本来就没占,不用还 return; } spi_flash_io_suspend(); //SPI引脚释放(IO设高阻) spi_busy = 0; //标记清除 sd_io_reuse_resume(); //把T卡的IO还回去 } |
T卡怎么让路:sd_io_reuse_suspend()
这是最底层的仲裁——逐线挂起T卡的三个IO(CMD、CLK、DAT),每根线对应一把信号量:
Plain Text // norflash.c —— 逐线挂起T卡IO
int sd_io_reuse_suspend() { u8 sd_io_suspend_status = 0; u16 retry_cnt = 0; _retry: OS_ENTER_CRITICAL(); //关中断,进临界区 if (0 != sd_io_suspend(0, 0)) { //挂CMD线 goto _exit; } sd_io_suspend_status |= BIT(0); if (0 != sd_io_suspend(0, 1)) { //挂CLK线 goto _exit; } sd_io_suspend_status |= BIT(1); if (0 != sd_io_suspend(0, 2)) { //挂DAT线 goto _exit; } OS_EXIT_CRITICAL(); return 0; //三根线全挂起,成功
_exit: //中途失败,已挂的线得还回去 if (sd_io_suspend_status & BIT(0)) sd_io_resume(0, 0); if (sd_io_suspend_status & BIT(1)) sd_io_resume(0, 1); OS_EXIT_CRITICAL();
if (retry_cnt++ < 10000) { //最多重试10000次,每次等10μs udelay(10); goto _retry; } return -1; //100ms还没拿到,彻底失败 } |
独木桥上的交通灯工作流程:Flash要过桥 → 先按住T卡三根信号线的"红灯"(CMD→CLK→DAT逐个锁) → Flash过桥干活 → 干完把红灯放开 → T卡可以走了。整个过程在临界区里做,中断都关了,不会被打断。
SPI底层切换做了什么
"挂起"和"恢复"不只是改个标志位,SPI控制器得真正切换:
Plain Text // spi.c —— SPI挂起(释放IO给T卡)
void spi_suspend(hw_spi_dev spi) { u8 *port = spix_p_data_cache[spi].port; spi_io_port_uninit(port[0]); //CLK → 高阻 spi_io_port_uninit(port[1]); //DO → 高阻 spi_io_port_uninit(port[2]); //DI → 高阻 spi_io_crossbar_uninit(spi); //断开IO矩阵映射 spi_disable(spi_regs[spi]); //关SPI外设 } |
Plain Text // spi.c —— SPI恢复(IO重新配给Flash)
void spi_resume(hw_spi_dev spi) { spi_set_bit_mode(spi, spi_info->mode); //重配单线/双线模式 spi_cs_dis(spi_regs[spi]); //CS手动控制 spi_clk_idle_sel(...); //时钟极性 spi_set_baud(spi, spi_info->clk); //波特率 spi_enable(spi_regs[spi]); //开SPI外设 } |
挂起=把IO全放成高阻+关SPI外设+断IO矩阵,恢复=重配寄存器+重连IO矩阵+开SPI。不是软件层面的"暂停",是硬件层面的"拆线重接"。
整个仲裁时序放一张图,看起来更直观:

拿一张表汇总一下整个仲裁流程的状态变化:
阶段 | spi_busy | T卡IO状态 | SPI控制器 | Flash可访问 |
空闲 | 0 | 正常(T卡用) | 关闭 | ❌ |
Flash抢桥中 | 0→1 | 逐线挂起 | 恢复 | ❌→✅ |
Flash使用中 | 1 | 挂起 | 开启 | ✅ |
Flash还桥中 | 1→0 | 逐线恢复 | 关闭 | ✅→❌ |
三、怎么接电脑的——USB MSD把Flash当U盘
独木桥的仲裁搞定了,接下来看怎么让电脑认出这两个盘。USB Mass Storage用的是Bulk-Only Transport协议——电脑发SCSI命令,芯片收到后读写对应的存储设备,再把结果传回去。
注册两个盘
SDK里把T卡和Flash分别注册成USB MSD的两个LUN(逻辑单元号):
Plain Text // msd.c —— 注册T卡和Flash为两个U盘
_msd_var.max_lun = 2; _msd_var.inquiry[0] = SCSIInquiryData; //LUN0: T卡 _msd_var.inquiry[1] = EX_SCSIInquiryData; //LUN1: Flash
msd_register_disk("sd0", NULL); //T卡 msd_register_disk("ext_flsh", NULL); //外挂Flash |
Plain Text // device_list.c —— 设备注册表
//U盘模式用norflash_dev_ops(512字节块单位,自带擦写管理) {.name = "ext_flsh", .ops = &norflash_dev_ops, .priv_data = &norflash_data}, //T卡用sd_dev_ops {.name = "sd0", .ops = &sd_dev_ops, .priv_data = &sd0_data}, |
电脑插上USB看到两个盘:一个是T卡(LUN0),一个是Flash(LUN1)。电脑不知道它们共用SPI,它只管发SCSI命令,芯片内部自己协调。
进PC模式的完整流程
Plain Text // usb_slave_mode.c —— PC模式入口
void usb_slave_app(void) { usb_device_mode(0, 0); //配置USB为从机模式
void *device = dev_open("ext_flsh", 0); //打开Flash设备 if (device != NULL) { //开启4KB读缓存 + 关闭缓存同步的临界区保护 dev_ioctl(device, IOCTL_SET_READ_USE_CACHE, 1); dev_ioctl(device, IOCTL_SET_CACHE_SYNC_ISR_EN, 0);
//预热:读sector 0和1,把Flash叫醒+填充cache memset(usb_slave_flash_warmup_buf, 0, sizeof(usb_slave_flash_warmup_buf)); dev_bulk_read(device, usb_slave_flash_warmup_buf, 0, 1); dev_bulk_read(device, usb_slave_flash_warmup_buf, 1, 1); }
usb_start(); //启动USB枚举,电脑开始识别 //... 消息循环处理SCSI命令 ... } |
进PC模式就四步:配USB从机 → 打开Flash(触发一次SPI仲裁) → 预热读两个扇区 → 启动USB。预热是为了把Flash从休眠里叫醒,顺便填好cache。
电脑读写Flash的数据通路
电脑发一个SCSI READ_10命令,芯片这边的处理路径(同步模式):
Plain Text // msd.c —— SCSI READ_10处理(同步路径)
static void read_10(const struct usb_device_t *usb_device) { u32 lba = read_32(msd_var->cbw.lba); //起始扇区号 u16 lba_num = (cbw.LengthH << 8) | cbw.LengthL; //扇区数
while (lba_num) { num = lba_num > MSD_BLOCK_SIZE ? MSD_BLOCK_SIZE : lba_num;
dev_ioctl(dev_fd, IOCTL_CMD_RESUME, 0); //抢桥 err = dev_bulk_read(dev_fd, msd_buf, lba, num); //读Flash dev_ioctl(dev_fd, IOCTL_CMD_SUSPEND, 0); //还桥
msd_mcu2usb(usb_device, msd_buf, num * 512); //数据传给电脑 lba += num; lba_num -= num; } } |
写操作(WRITE_10)也是同样的套路,多了一个flush:
Plain Text // msd.c —— SCSI WRITE_10处理(同步路径,简化)
while (lba_num) { msd_usb2mcu_64byte_fast(..., msd_buf, num * 512); //从USB收数据
dev_ioctl(dev_fd, IOCTL_CMD_RESUME, 0); //抢桥 dev_bulk_write(dev_fd, msd_buf, lba, num); //写Flash dev_ioctl(dev_fd, IOCTL_CMD_SUSPEND, 0); //还桥 } if (need_flush) { dev_ioctl(dev_fd, IOCTL_CMD_RESUME, 0); dev_ioctl(dev_fd, IOCTL_FLUSH, 0); //把cache里的脏数据刷到Flash dev_ioctl(dev_fd, IOCTL_CMD_SUSPEND, 0); } |
每次读写Flash的套路都是"抢桥→干活→还桥"三板斧。写完还得flush一下,把cache里攒着的脏数据真正写进Flash。电脑那边发 SYNCHRONIZE_CACHE命令时也会触发flush。
四、Flash cache怎么工作的——桥上的临时仓库
Flash写入有个特点:得先擦后写,而且擦除是按4KB扇区整块来的。如果每写512字节就擦一次4KB,速度慢得离谱,Flash寿命也撑不住。SDK的做法是在RAM里开一个4KB缓冲区当"临时仓库"——数据先往仓库里攒,攒满一个扇区或者被催了(flush)才真正擦写一次。
cache的三个状态变量
Plain Text // norflash.c —— Flash cache核心变量
static u8 *flash_cache_buf; //4KB缓冲区(RAM里) static u32 flash_cache_addr; //当前缓存的是哪个4KB扇区 static u8 flash_cache_is_dirty; //脏标记:缓冲区内容和Flash上不一样了 static u8 flash_cache_timer; //脏计时:攒了多久没写 |
写入流程:攒着写
Plain Text // norflash.c —— _norflash_write() 写入逻辑(简化)
//1. 算出写入地址落在哪个4KB扇区 u32 align_addr = addr / 4096 * 4096;
//2. 如果和当前缓存的扇区不一样,先把旧脏数据刷掉 if (align_addr != flash_cache_addr) { if (flash_cache_is_dirty) { _norflash_eraser(FLASH_SECTOR_ERASER, flash_cache_addr); //擦旧扇区 _norflash_write_pages(flash_cache_addr, flash_cache_buf, 4096); //写回 flash_cache_is_dirty = 0; } _norflash_read(align_addr, flash_cache_buf, 4096, 0); //读新扇区到缓存 flash_cache_addr = align_addr; }
//3. 在缓冲区里改数据 memcpy(flash_cache_buf + (addr - align_addr), w_buf, align_len);
//4. 判断是否凑满一整个扇区 if ((addr + align_len) % 4096) { flash_cache_is_dirty = 1; //没满,标脏,先不写 flash_cache_timer = 1; //开始计时 } else { //满了,立刻擦写 _norflash_eraser(FLASH_SECTOR_ERASER, align_addr); _norflash_write_pages(align_addr, flash_cache_buf, 4096); flash_cache_is_dirty = 0; } |
写入逻辑就是"先攒后刷":数据塞进RAM缓冲区,凑满4KB整扇区就立刻擦写,没凑满就标个脏等着。跟你往快递箱里塞包裹一样——塞满了叫快递员来收,没满就先攒着。
完整的写入流程放一张图:

定时同步:别攒太久
如果一直没凑满怎么办?SDK有个定时器 _norflash_cache_sync_timer(),隔一段时间检查一次——有脏数据就强制刷盘:
Plain Text // norflash.c —— 定时同步脏缓存(简化)
void _norflash_cache_sync_timer(u32 sync_step) { if (flash_cache_is_dirty) { //同步也得抢桥! if (norflash_reuse_enter() == 0) { _norflash_eraser(FLASH_SECTOR_ERASER, flash_cache_addr); _norflash_write_pages(flash_cache_addr, flash_cache_buf, 4096); flash_cache_is_dirty = 0; norflash_reuse_exit(); //用完还桥 } } } |
定时同步也得走独木桥仲裁——抢桥→擦写→还桥。所以就算Flash自己在后台刷缓存,T卡的SPI也会被短暂挂起。
五、会出什么问题——桥上堵车的几种情况
独木桥方案能跑,但有几个容易出问题的地方。
1:仲裁超时死机
sd_io_reuse_suspend()重试10000次×10μs = 100ms。T卡如果在做大块读写,100ms内还没释放IO信号量,Flash这边就返回-1。上层拿到-1后的处理方式各不相同——有的直接返回0(读写失败但不崩),有的地方没做错误处理就直接空指针炸了。
查了半天发现根因有两层:
• T卡在做连续多扇区读写时,一个命令周期可能超过100ms
• Flash驱动的 norflash_bulk_read拿到-1后返回0,MSD层以为读了0个扇区,后续指针计算就错了
修法:
1. 在 sd_io_reuse_suspend()里把重试上限从10000调到更大的值(或者改成带超时的信号量等待)
2. 在 norflash_bulk_read/norflash_bulk_write失败路径加明确的错误码返回,MSD层收到错误码后回 MEDIUM_ERROR给电脑,不继续读写
Plain Text // norflash.c —— 仲裁失败时的日志(排查用)
//抢桥失败会打印: log_error("nf reuse enter fail keep=%d busy=%d", norflash_reuse_keep, spi_busy);
//等Flash BUSY超时会打印: log_error("norflash_wait_ok timeout cmd=%x off=%x len=%x wr=%d sr1=%x busy=%d keep=%d", norflash_dbg_last_cmd, norflash_dbg_last_offset, norflash_dbg_last_len, norflash_dbg_last_is_write, reg_1, spi_busy, norflash_reuse_keep); |
看到这两条log就知道是独木桥堵车了——要么T卡太忙让不了路,要么Flash操作太慢桥上堵住了。
2:异步MSD时序冲突
SDK里 msd.c有个 USB_MSD_BULK_DEV_USE_ASYNC宏控制异步写入。异步模式下USB收数据和Flash写数据是重叠的(双缓冲乒乓),但SPI仲裁不是线程安全的——如果USB中断里触发了T卡检测,同时Flash又在写,两边同时操作SPI就会数据错乱。
Plain Text // msd.c —— 异步开关
#define USB_MSD_BULK_DEV_USE_ASYNC 0 //关闭异步MSD,走同步读写路径 |
这个宏设成0就是走同步路径:收完USB数据再写Flash,写完再收下一包。慢一点但安全。共用SPI的场景下别开异步,时序冲突太难排查。
3:写入慢(flush太频繁)
电脑往Flash盘拷文件时,每写几个扇区Windows就发一次 SYNCHRONIZE_CACHE命令。每次flush都要走"抢桥→擦写4KB→还桥",而Flash擦除一个4KB扇区要几十ms。如果flush太频繁,写入速度就被擦除时间拖慢。
调了一下发现:
• 不设缓存,每512字节都擦写一次:写入极慢,Flash寿命也撑不住
• 缓存开了但 CACHE_SYNC_ISR_EN没关,定时器在中断里也会抢桥flush:写入时被打断,速度波动大
• IOCTL_SET_CACHE_SYNC_ISR_EN设成0(关闭中断内同步),只在SCSI的 SYNCHRONIZE_CACHE和手动flush时才刷缓存:写入稳定,速度刚好
Plain Text // usb_slave_mode.c —— PC模式下的缓存策略
dev_ioctl(device, IOCTL_SET_READ_USE_CACHE, 1); //读缓存开 dev_ioctl(device, IOCTL_SET_CACHE_SYNC_ISR_EN, 0); //关中断内自动同步 |
PC模式下得关掉中断内自动同步,不然后台定时器会抢桥flush,和前台的SCSI读写撞在一起。让flush只在SCSI命令触发时走,独木桥的通行顺序就可控了。
六、串联回顾——完整走一遍
从板子上电到电脑看到两个盘并读写Flash,整条链路用树枝图串起来:
Plain Text // 完整数据链路——从配置到读写
板级配置 ├── PA09/PA10/PA11 三脚共用(独木桥) ├── SPI_SD_IO_REUSE = 1(交通灯开关) │ 设备注册 ├── "sd0" → sd_dev_ops(T卡驱动) ├── "ext_flsh" → norflash_dev_ops(Flash驱动,512B块模式+cache) │ 进PC模式 usb_slave_app() ├── ① USB配从机模式 ├── ② dev_open("ext_flsh") → norflash_reuse_enter()(第一次抢桥) │ └── sd_io_reuse_suspend() → spi_flash_io_resume() ├── ③ 预热读sector 0、1(Flash叫醒+cache填充) ├── ④ usb_start()(USB枚举,电脑识别到两个盘) │ 电脑发SCSI命令 ├── READ_10 / WRITE_10 │ ├── IOCTL_CMD_RESUME → norflash_reuse_enter()(抢桥) │ ├── dev_bulk_read/write → SPI读写Flash(桥上干活) │ │ └── 写入走4KB cache:攒满擦写 / 标脏等flush │ └── IOCTL_CMD_SUSPEND → norflash_reuse_exit()(还桥) ├── SYNCHRONIZE_CACHE → IOCTL_FLUSH → 强制刷脏缓存 │ 退出PC模式 └── dev_close("ext_flsh") → norflash_reuse_exit()(最后一次还桥) |
回扣一下独木桥的类比:
现实中 | 代码里 |
独木桥(一条路) | PA09/PA10/PA11(三根共用线) |
交通灯(红绿灯) | sd_io_reuse_suspend() / resume() |
车辆通行证 | spi_busy + norflash_reuse_keep |
桥上施工(读写Flash) | spi_flash_io_resume() + SPI命令 |
临时仓库(桥头堆货) | flash_cache_buf(4KB RAM缓冲) |
催收员(定时来收货) | _norflash_cache_sync_timer() |
收费站(电脑USB口) | USB MSD Bulk-Only Transport |
整条链路就这些——配置共脚、注册设备、仲裁抢桥、cache攒着写、USB传数据。搞明白"独木桥怎么轮着走"这一件事,剩下的都是工程细节。
感受
SPI共线本身不复杂,跟I2C多从机一个思路,分时复用嘛。SDK把仲裁封装成 norflash_reuse_enter/exit也算清晰,抢桥还桥看代码就能明白。USB MSD协议更不用说了,SCSI命令集翻一翻就上手。
卡住我比较久的是仲裁时序——T卡和Flash的SPI切换牵扯IO矩阵、信号量、临界区,出了问题条件都凑不齐,很难复现。cache flush策略也绕了一阵,什么时候刷、在哪个上下文刷、会不会和前台SCSI命令撞车,翻了好几遍代码才理顺。最意外的是MSD异步双缓冲,宏定义看着挺美,放到共用SPI场景下直接没法用,这块的问题还是得等待杰理的AD小组来完善
共脚、仲裁、cache、MSD——搞明白这四个词,这套方案就算摸透了。