V3s开发板用着用着,IO口总是不够用。传感器要挂在GPIO上,继电器要驱动,还要接几个编码器,板子本身引出的引脚很快就见底了。更麻烦的是有些外设对实时性有要求,靠Linux用户态去翻转GPIO,延迟经常飘到毫秒级以上。后来我把一块某国产厂商的W806单片机(180MHz主频的RISC-V核)做成V3s的协处理器,中间用SDIO链路连接,算是把这套方案彻底跑通了。这篇文章就把整个方案的设计思路、硬件连接、两端代码框架和调试中踩过的坑完整写出来。
如果你也遇到"Linux板卡IO不够用、实时性达不到要求、又不想换更高端的SoC"这种情况,这篇文章会比较对胃口。文章覆盖的内容包括:为什么选SDIO而不是UART/SPI/I2C、W806端从机协议栈怎么写、V3s端Linux下如何把自定义MMC命令封装成驱动、以及联调中几个非常容易翻车的地方。
1. 为什么选SDIO做V3s的IO扩展,而不是UART、SPI、I2C
1.1 三条候选通信链路的能力对比
做Linux板卡和MCU之间的通信,大多数人第一反应是UART。串口简单、两边都有现成驱动、调试也方便,但问题特别明显:带宽太低。V3s上的UART一般跑115200bps,换算一下也就11.5KB/s左右,极限也不过几Mbps。如果协处理器要周期性上报大量传感器数据,或者要执行一波批量IO操作,这个带宽根本不够用。我最初用UART试过一轮,几百个字节的数据就能把链路占满,更别说还要留余量给日志输出。
SPI是第二反应。SPI速率可以拉到几十MHz,Linux这边有spi-dev驱动,MCU端几乎所有单片机都带SPI外设,看起来是个好选择。实际用起来有几个问题:一是SPI是主从严格同步的,V3s作为主设备持续产生时钟,W806被动响应,如果从机处理不及时,主设备会一直等,容易把内核线程卡死;二是SPI全双工虽然可以同时收发,但Linux用户态通过spi-dev做双向交互时,每次transaction都伴随不小的调度开销;三是在V3s这种板卡上,SPI总线上往往已经挂了Flash、显示屏之类的设备,中断优先级和设备冲突处理起来很烦。
SDIO是很多人容易忽略的一条路。V3s自带SD/MMC控制器,本身就跑着MMC子系统,SDIO协议栈在Linux内核里已经是成熟代码。只要W806有SDIO从机接口(这一点很关键,不是所有MCU都支持从机模式),两边就可以直接用SDIO协议通信。SDIO的CLK只要25MHz,1-bit模式就有25Mbps,4-bit模式理论上可以到100Mbps级别,实际跑到6-8MB/s很轻松,比UART高两三个数量级。而且SDIO的物理层本身就带了CRC7命令校验和CRC16数据校验,数据传输的可靠性比裸UART高很多。
对比表放在这里,直观一点:
| 链路方案 | 典型有效带宽 | 双向实时性 | MCU端要求 | 可靠性保护 | 总成本 |
|---|---|---|---|---|---|
| UART | 0.01-0.5MB/s | 一般 | 任意MCU | 需自行加帧校验 | 最低 |
| SPI | 1-5MB/s | 主从轮流,实时性一般 | 任意MCU带SPI从机 | 需自行加帧校验 | 低 |
| SDIO 1-bit | 3-6MB/s | 好,支持双向并发 | 需SDIO从机控制器 | 协议自带CRC7/CRC16 | 中 |
| SDIO 4-bit | 6-8MB/s以上 | 好,支持双向并发 | 同上 | 协议自带 | 中 |
1.2 SDIO链路真正适合什么场景
不过SDIO并不是万金油。如果一个工程里的协处理器只是读几个按键、点几个LED,那用I2C更划算,两根线就能搞定,W806这类MCU的I2C从机实现也简单,带宽需求又不大。SDIO的复杂度主要在于:MCU端必须真有硬件SDIO从机控制器,软件初始化时序也比UART复杂,调试阶段要用到逻辑分析仪抓波形才能看清楚问题。
真正适合SDIO方案的场景,我认为有这几个特征:
第一,包量比较大。比如一块板子上接了十几个温湿度传感器,每个都周期上报几十字节数据,加上时间戳和状态字段,带宽要在几十KB/s以上,这时候SDIO的价值就出来了。
第二,需要双向高频交互。比如一边要频繁下发PWM控制参数,另一边又要快速回传编码器计数,这种读写交错频繁的场景,SDIO的CMD/DAT分离机制比SPI轮转式全双工更灵活。
第三,未来可能要跑文件系统。如果后续想把W806外挂的SD卡空间也挂到Linux上,或者想通过SDIO做固件升级通道,SDIO本身就是存储类总线的标准路径,迁移成本比UART低很多。
总结一句话:如果只是低速IO,别折腾SDIO;如果链路是瓶颈,SDIO值得认真考虑。
2. 硬件连接与从机时序:先把手脚摆对
2.1 引脚对接方案与上拉细节
W806这颗芯片的SDIO控制器支持从机模式,官方SDK里有一套配置路径,但网上文档写得比较分散。先看引脚对接。V3s这边一般用SDC0接口,引出的是SDC0_CLK、SDC0_CMD、SDC0_D0到D3共六根线。W806的SDIO从机引脚分布在PB组和PC组,具体是哪几个引脚要以对应开发板的原理图为准,但一般也就那五六根。连接方式很简单:CLK接CLK,CMD接CMD,D0-D3分别对应,两边共地。
这里有一个非常容易被忽略的细节:上拉电阻。SD总线的CMDUCT信线在空闲状态需要保持高电平,所以CMD和DAT线必须接上拉电阻。数值选10kΩ比较稳妥,V3s内部虽然也有上拉,但不同板子的配置不一样,外置上拉可以避免"时好时坏"的毛病。CLK线不用上拉,而且尽量走短一点,SDIO时钟频率只要上了25MHz,长走线加上寄生电容,波形就会明显劣化。
另外,V3s的SDIO接口通常会有一个CD(Card Detect)引脚,用于检测SD卡是否存在。我们要接的是协处理器设备,完全不需要CD检测,直接不连接这个引脚,并且在设备树里把卡检测配置关掉。如果默认开着CD检测,内核会认为总线上根本没有卡,后面的自定义MMC命令就发不出去。这个坑我后面会专门再讲,这里先记住这个结论。
还有一个电源时序问题:W806和V3s是两套独立供电系统,上电顺序不同会导致SDIO控制器在时钟未稳定时收到乱码命令。我建议给W806单独加一个MOS管控制电源,由V3s的一个空闲GPIO来给W806上电,这样V3s先启动完,再把W806拉起来,时序完全可控。
2.2 SDIO从机时序的基本逻辑
SDIO的时序本质上和SD存储卡是一样的:所有操作由Host(V3s)发起,每笔事务包含CMD阶段、可选的DATA阶段、以及CRC校验。CMD线传输48位命令,带7位CRC校验,从机收到命令后如果CRC不对可以直接忽略。DATA线在4-bit模式下,每个时钟周期传输4位数据,每块数据末尾有16位CRC。
W806作为从机,核心工作就是监听CMD线,解析Host发来的命令,然后按照命令要求通过DATA线接收或发送数据块。这个过程中,从机完全靠CLK信号同步,所以两边不需要像UART那样约定波特率,也不需要高精度时钟校准,只要CLK稳定,数据就能正确采样。
从时序图的角度看,整个流程是:V3s的MMC控制器把命令发出去,经过几微秒到几十微秒的命令响应时间,W806在DAT线上返回一个带有CRC的响应块,然后进入数据阶段。数据阶段需要把块长度和块数量事先约定好,否则从机不知道要收多久。
实际操作层面,我用的办法是让V3s把每次交互设计成"命令+固定长度数据块"的形式。命令里包含操作码和参数,参数里直接写清数据块长度。W806收到命令后先解析,再决定DMA收发长度,这样从机端逻辑简单很多,不用做复杂的连续流解析。
3. W806端裸机SDIO从机协议栈:把数据按时序收进来
3.1 中断+DMA双缓冲的收包框架
W806端的程序没有操作系统,就是一个裸机循环加中断处理。SDIO从机需要响应Host随时发来的命令,如果用轮询的方式查寄存器,很容易错过命令窗口,所以必须走中断。数据量大一点的时候,得让DMA干活,CPU只在中断里做包的解析和响应调度。
我采用的框架是双缓冲接收。给DMA准备两个接收缓冲区,一个被DMA填充数据,另一个由CPU解析处理,两边交替使用,避免"DMA还没写完CPU就开始读"导致的半包问题。缓冲区大小要根据最大协议帧来定,我这边设的是512字节,内部再划分出帧头和载荷区。
初始化W806端SDIO的核心步骤可以归纳为这么几步:
- 使能对应引脚的SDIO复用功能,配置好总线宽度(1-bit或4-bit)。
- 打开SDIO控制器时钟,配置从机模式,把DAT线方向设为输入。
- 挂SDIO中断,使能命令接收中断和数据传输结束中断。
- 设置DMA,把第一个接收缓冲区的地址交给SDIO控制器。
- 等待Host发起命令。
给一个伪代码片段参考整体思路:
#include "wm_sdio.h" static uint8_t rx_buf[2][512] __attribute__((aligned(4))); static uint8_t tx_buf[2][512] __attribute__((aligned(4))); static volatile uint8_t rx_cur = 0; void SDIO_IRQHandler(void) { uint32_t stat = SDIO->INT_STAT; if (stat & SDIO_INT_CMD_END) { /* 收到完整命令,进入命令解析流程 */ handle_sdio_command(); } if (stat & SDIO_INT_DATA_END) { /* 当前DMA缓冲接收完成,切到另一块缓冲 */ rx_cur ^= 1; SDIO_SetRxDMA(rx_buf[rx_cur]); parse_frame(rx_buf[rx_cur ^ 1]); } SDIO->INT_STAT = stat; /* 清中断标志 */ }注意一个很关键的细节:切换到下一块缓冲区必须在DMA停止的状态下进行,否则可能出现缓冲区指针被DMA改写一半的竞态。我在代码里加了关中断保护。
3.2 帧格式与状态机
SDIO只负责把字节从A搬到了B,字节本身是啥意思还得自己定义。所以要设计一套简单的协议帧,覆盖"读IO、写IO、批量上报、固件升级"这些操作。
我用的帧格式是:
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 帧头 | 2 | 固定0xA5 0x5A,用于同步和识别 |
| 命令字 | 1 | 0x01读IO状态,0x02写IO,0x03批量采集,0x04固件升级 |
| 载荷长度 | 2 | 小端序,表示后面载荷的字节数 |
| 载荷 | N | 按命令字解释的业务数据 |
| CRC16 | 2 | 从帧头开始到载荷末尾的CRC16校验 |
这个帧格式参考的是常见的Modbus风格,好处是状态机好写,排查问题也直观。所有长度字段用小端序,避免两端的字节序问题——W806的RISC-V核是小端,V3s的ARM核也是小端,两边天然对齐,但写协议的时候还是要明确标出来。
W806端收包状态机分四个阶段:找帧头、收命令字、收长度、收载荷和CRC。因为DMA已经帮我们把数据按块收好了,状态机只需要在内存里处理缓冲区的字节,逻辑不复杂:
typedef enum { ST_WAIT_HEAD, ST_WAIT_CMD, ST_WAIT_LEN, ST_WAIT_PAYLOAD, } parse_state_t; static parse_state_t pstate = ST_WAIT_HEAD; static uint32_t expect_len = 0; static uint8_t *payload_ptr; void parse_frame(uint8_t *buf) { uint32_t i = 0; while (i < RX_BLOCK_LEN) { switch (pstate) { case ST_WAIT_HEAD: if (buf[i] == 0xA5 && i + 1 < RX_BLOCK_LEN) { if (buf[i + 1] == 0x5A) { pstate = ST_WAIT_CMD; i += 2; } } i++; break; case ST_WAIT_CMD: cur_cmd = buf[i++]; pstate = ST_WAIT_LEN; break; case ST_WAIT_LEN: expect_len = buf[i] | (buf[i + 1] << 8); i += 2; payload_ptr = &buf[i]; pstate = ST_WAIT_PAYLOAD; break; case ST_WAIT_PAYLOAD: if (i + expect_len + 2 <= RX_BLOCK_LEN) { if (crc16(buf, i + expect_len) == 0) { exec_cmd(cur_cmd, payload_ptr, expect_len); } } pstate = ST_WAIT_HEAD; i += expect_len + 2; break; } } }执行命令的时候,比如写IO命令,就直接操作W806的GPIO输出寄存器;批量采集命令,就去读所有ADC通道和编码器计数,把结果打包塞进发送缓冲区,等Host下次读的时候发出去。注意不要在中断里做大量浮点运算或复杂业务逻辑,中断里只做"数据搬移+置标志位",真正算编码器、滤波这些事放到主循环里。
4. V3s端Linux侧数据通路:让SDIO从机出现在/dev下
4.1 自定义MMC命令与驱动挂载
Linux端要做的第一件事,是让SDIO总线上能识别到W806这个"设备"。SDIO协议标准里有一种叫"自定义/厂商专用命令"(SDIO CMD52/CMD53)的东西,SDIO外设通过分配好的I/O地址进行读写,我们可以把W806的帧接口抽象成一个带地址空间的IO设备。更简化一点的做法,是把自定义命令直接封装成V3s MMC控制器能发的MMC命令,通过mmc_wait_for_req往总线上发。
首先要确保内核的MMC子系统初始化好了这个host。V3s的设备树里,SDC0节点需要设置成支持SDIO的模式,并且去掉CD引脚检测:
&sdc0 { status = "okay"; bus-width = <4>; max-frequency = <25000000>; broken-cd; /* 不检测卡中断 */ non-removable; /* 视为不可移除设备 */ };"broken-cd"和"non-removable"这两个属性就是我前面说的坑的关键配置,没有它们,内核会在探测阶段认为总线上没有设备,后续发命令直接返回"媒体不存在"的错误。
驱动侧我写了一个platform驱动,挂在MMC子系统下面。基本思路是:驱动probe时通过mmc_alloc_host注册一个新的mmc_host,然后在驱动的ioctl接口里,把用户态传进来的"命令+载荷"组装成mmc_request,调用mmc_wait_for_req发给W806。整个过程在进程上下文里同步执行,内核线程负责异步收数据。
驱动骨架大概长这样:
static long w806_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { struct mmc_host *host = g_host; struct w806_req ureq; struct mmc_command mcmd = {0}; struct mmc_data mdata = {0}; struct mmc_request mrq = {0}; struct scatterlist sg; uint8_t *buf; if (copy_from_user(&ureq, (void __user *)arg, sizeof(ureq))) return -EFAULT; buf = kmalloc(ureq.len + 4, GFP_KERNEL); /* 组装帧:帧头+命令字+长度+载荷 */ // ... mcmd.opcode = MMC_SET_BLOCK_COUNT; /* 用自定义opcode也可 */ mcmd.arg = 0xA55A0000 | ureq.cmd; mcmd.flags = MMC_RSP_R1; mdata.flags = (ureq.dir == W806_READ) ? MMC_DATA_READ : MMC_DATA_WRITE; mdata.blksz = ureq.len; mdata.blocks = 1; sg_init_one(&sg, buf, ureq.len); mdata.sg = &sg; mdata.sg_len = 1; mrq.cmd = &mcmd; mrq.data = &mdata; mmc_set_data_timeout(&mdata, host); mmc_wait_for_req(host, &mrq); if (mcmd.error || mdata.error) { kfree(buf); return -EIO; } if (ureq.dir == W806_READ) copy_to_user((void __user *)ureq.buf, buf + FIXED_HEAD_SZ, ureq.len); kfree(buf); return 0; }这里注意一点:内核里直接组装自定义MMC命令时,cmd.opcode不能撞上标准固定命令的数值,尽量用保留给厂商自定义的区间。如果MMC框架不认,可以用SDIO_RW_EXTENDED这类标准命令,功能号定义成自家设备的ID,这样兼容性更好。
4.2 内核线程、等待队列与用户态ioctl
有了ioctl读写接口之后,用户态可以直接打开/dev/w806_io,每次调用ioctl来完成一次读或写帧操作。但是如果有周期性的批量采样需求,频繁从用户态发起系统调用就会引入调度延迟。更实际的做法是在驱动里开一个内核线程,专门负责数据流程。
核心机制是一个环形缓冲加等待队列。内核线程从mmc子系统的收包队列里取数据,解析帧之后放入环形缓冲区。用户态通过read接口,在没有数据时阻塞在等待队列上,内核线程每收完一包就唤醒等待队列,用户态醒来后从缓冲区取数据。
我最初以为最方便的通信方式是走TCP/IP或UART这类成熟路径,但实测下来在SDIO链路上,内核里直接用等待队列的延迟最稳定。等待队列的唤醒延迟一般在几十微秒量级,用户态再通过read拿到数据,整体往返大概120微秒左右,这个结果后面实测会提到。
用户态的调用代码反而很简单,封装成这样:
int w806_read(unsigned char cmd, void *payload, int len) { int fd = open("/dev/w806_io", O_RDWR); struct w806_req rq; rq.cmd = cmd; rq.dir = W806_READ; rq.len = len; rq.buf = payload; int ret = ioctl(fd, W806_IOC, &rq); close(fd); return ret; }这里有个设计取舍:每次调用都open/close设备文件,文件开关的开销大概几次微秒,如果采样频率很高,建议fd在程序初始化时打开一次,不要每次释放。实测高频采样下,反复open/close会明显拉高平均延迟,我从每秒100次加到每秒1000次才注意到这个问题。
5. 联调实测与三个最容易翻车的点
5.1 实测吞吐与往返延迟
整套系统联调时,我重点测了两个指标:批量数据传输的吞吐率,以及单帧命令的往返延迟。测试方法是用W806的定时器产生一个GPIO脉冲,V3s端循环读这个IO状态,每次读帧打一个时间戳,统计平均延迟。
4-bit模式、25MHz时钟下,我这块板子调到的最优结果是:写W806寄存器帧(16字节载荷)的往返延迟稳定在120微秒左右,其中内核驱动处理占了大头,剩下的才是总线和从机处理时间。批量读256字节传感器数据时,有效吞吐在6.2MB/s左右。这个数字和理论峰值有差距,但UART和SPI几乎不可能到这个量级。
如果遇到吞吐上不去的情况,优先检查DMA配置。W806端和V3s端如果有一边没有真正走DMA,而是用CPU逐字节拷贝,吞吐会掉到1MB/s以下。还有个隐蔽点:V3s的MMC控制器在做4-bit数据传输时,SG链表要求每一段缓冲区地址对齐到块边界,不满足对齐就会触发fallback,一样掉速。
5.2 坑一:SD卡检测引脚让内核以为卡不存在
这个锅我在前面无线索地埋了几次,现在是详细版本。V3s的SDC0接口默认带着CD检测,设备树里没做好配置的时候,内核扫描SDIO总线,看到没有卡插入,直接不创建SDIO client。在这种情况下,我的驱动根本拿不到host,ioctl调用返回的是ENXIO。
表现形式特别迷惑:总线上电测量,CLK和CMD波形都正常,W806端也能收到命令,但V3s端上报"卡检测失败"或"media event ERROR"。排查方式是把设备树里的sdc0节点加上"non-removable"属性,并用"broken-cd"明确告诉内核不检测CD引脚状态。
这个配置改了之后,重新编译设备树,清空mmcblk相关的自动挂载配置,再启动就能看到类似"mmc0: new SDIO card"的日志。我在另一块板子上试过,同样的W806固件,只改设备树,从完全不通变成可以正常收发,问题就出在这。
5.3 坑二:时钟未稳定前的垃圾命令导致从机状态错乱
前面提到过电源时序,现在说说它会引发什么后果。V3s启动早期,MMC控制器可能在SDIO外设还没有初始化完的时候就开始发命令,或者CLK中间不停抖动,W806的SDIO从机控制器会把这些毛刺误判成命令头。一旦进入解析了半个命令的状态,后面即使收到正常命令,也可能因为状态机错位而丢弃。
表现是:按复位键重新启动V3s之后,第一次调用ioctl总是超时,第二次就好了。这是因为第一次的命令被W806端错误状态吞了,等到超时后再发重试命令,W806才回到正常状态。
解决方案有两个我都试过,都有效:一是在V3s的设备树里把max-frequency配低一点,比如降到12.5MHz,上电初期波形质量会好很多;二是在W806端初始化SDIO中断的时候,增加一个"时钟锁存"逻辑,让W806在CLK引脚持续高电平一定时间后才打开命令中断。第二种方案更彻底,因为它是从根上屏蔽了乱序命令。
5.4 坑三:DMA缓冲对齐与Cache一致性问题
第三个坑和Linux内存的底层机制有关。mmc_wait_for_req要求SG缓冲区在ARM架构下保持Cache line对齐,如果缓冲区是普通kmalloc分配的,往往只能对齐到8字节,而DMA传输需要64字节对齐。不满足对齐条件时,内核会启用bounce buffer,数据在cache和DMA缓冲区之间反复搬运,不仅速度慢,有时还会出现数据内容偶发不一致。
我的修改方式很简单:分配缓冲区时从kmalloc换成dma_alloc_coherent或者aligned allocation,确保地址对齐到L1 Cache line大小。驱动里再加一句WARN_ON检查,如果sg缓冲区地址不是64字节对齐,就打印调试信息,防止后续改动破坏这个条件。
这个坑的表现是传输偶尔出错,CRC校验失败率大概在千分之一到百分之一之间,看起来像是信号质量问题,实际根因是cache里残留了旧数据。用dma_alloc_coherent之后这个问题就彻底消失了,CRC失败率降到了零。
最后再分享一个调试期的小技巧。SDIO链路上如果数据老是错,先不要急着改代码,用逻辑分析仪同时抓CLK、CMD和D0三根线,看看从机的响应是否在命令后规定的64个时钟周期内返回。很多看似神秘的错误,最终都归结为"响应超时"这一个原因。我这次联调最耗时间的部分,其实不是在写协议栈,而是在波形和日志之间来回找线索。把这几个点都排干净之后,整套SDIO扩展IO的方案跑得非常省心,目前已经稳定运行了很长一段时间。