拓冰建站拓冰建站
首页 / 资讯中心 / 正文

MicroPython流设备与块设备深度解析:从串口到SD卡的I/O读写实践

搞嵌入式开发这些年几乎每次给新人讲 MicroPython 里的 I/O 读写都要把“流设备”和“块设备”这两个概念从头捋一遍。不是大家笨而是这两个词太像了实际用起来却完全是两条路。尤其是串口这玩意你用uart.write()写数据时它是一条流可当你插一张 SD 卡用open(/sd/test.txt, w)写文件时底层又是一个按块访问的设备。同一个系统里两种完全不同的底层逻辑混在一起不理解清楚调试时很容易一头雾水。这篇文章想解决的就是这个问题。我会从流设备和块设备的本质差异讲起把 MicroPython 的接口设计拆开看再结合串口、CH340/FTDI 驱动、ESP32-S3 上 OLED 显示这类常见场景把底层链路和实操步骤完整走一遍。适合正在学 MicroPython、或者已经在做 ESP32/树莓派 Pico 项目但被 I/O 问题卡住的朋友。学完你不仅能分清这两类设备还能自己写一个块设备驱动挂载成文件系统。1. 先从一次“串口卡死”说起流设备和块设备到底是什么1.1 一个现象引出两类设备有一次我在 ESP32-S3 上调一个数据上报程序板子通过串口把传感器数据发给上位机。代码写得很顺uart.write()一发串口调试助手一收数据就出来了。然后我为了让上报更稳定在循环里不用sleep改成用标志位去控制节奏。结果发现一个问题当上位机端的串口调试助手没有打开时板子跑一段时间就会卡住像是在uart.write()那一步死循环了一样。后来我意识到串口就是一个典型的流设备它的数据是按顺序进入一个 FIFO 缓冲区的如果缓冲区满了、对端又没人接收写操作就会一直阻塞等待。这个“卡住”不是 BUG而是流设备自身的工作方式。相比之下如果你往 SD 卡里写文件SD 卡按块访问写操作通常不会因为“对方没人读”而阻塞——因为文件系统自带缓存和数据调度机制。这就是流设备与块设备第一个显著区别流设备关注的是“能不能把字节送出去”块设备关注的是“能不能把数据放到指定位置”。这个现象之所以有代表性是因为绝大多数人第一次接触 MicroPython 时都是用串口或者 I2C 这类流设备。等到用文件系统读写 TF 卡、Flash才感受到另一种抽象。两套逻辑如果不提前理清遇到问题时会非常被动。1.2 流与块的本质差异一条管道和一本翻页书我用一个生活化的类比来理解这两类设备。流设备像一根自来水管你拧开水龙头水字节源源不断流出去水流的顺序是固定的不可能跳过中间的某一段去“指定位置”取水。串口、I2C、SPI以字节流方式操作时、socket、麦克风采集的数据流都是这个模式。它们天生是顺序访问的你只能一个字节一个字节地读出来或者写进去不支持跳读、不支持原地修改某一小段数据。块设备更像一本带页码的书或者一个带格子的储物柜。数据不是一个字节一个字节出现的而是按固定大小的“块”Block来组织常见的是 512 字节一块。你想读第 3 块就直接翻到第 3 页想写第 10 块就把第 10 格抽屉拉开放进去。SD 卡、eMMC、NAND Flash、机械硬盘都是这类设备。它们最核心的能力是随机访问和块级改写。流和块这两个词形容的其实是“数据访问的最小单位”和“访问方式”。流设备不具备位置概念块设备天然带位置概念。MicroPython 为什么要区分它们因为底层硬件就是这么运作的你没法用统一的方式去抽象操作系统只能顺势而为给流设备一套接口给块设备另一套接口。维度流设备块设备数据组织字节流顺序访问固定大小块随机访问典型设备串口、I2C、SPI、socketSD 卡、Flash、硬盘、EEPROM是否支持 seek通常不支持或支持有限天然支持块定位核心方法read / write / readintoreadblocks / writeblocks / ioctl阻塞特性缓冲区满时写阻塞写入有硬件时序和管理策略上层依赖直接读写数据通常挂载文件系统使用1.3 为什么 MicroPython 要区分这两类设备很多人疑惑Linux 里一切皆文件所有设备都能用 open/read/write 访问。MicroPython 为什么不直接搞一套统一接口答案很简单MicroPython 运行在资源受限的 MCU 上内存可能只有几百 KB没法像完整版 Python 那样为每种设备实现完整的 POSIX 语义。它只保留最核心的抽象用最小的接口集满足大多数场景。于是 MicroPython 的设计就变成底层硬件驱动分成两类一类实现流式读写方法另一类实现块设备读写方法上层再通过io模块和os模块把它们包装成你熟悉的样子。这么做有个非常实际的好处当你在 MicroPython 里调用os.mount()挂载一个文件系统时它只需要检查对象有没有实现readblocks/writeblocks/ioctl有就认为它是块设备可以往上面建文件系统。如果你拿一个串口对象去挂载直接报错。这种“接口即身份”的设计让代码逻辑非常清晰也让我们这些搞移植的人能快速判断一个驱动能不能用在文件系统层。2. MicroPython 怎么用代码“认出”设备类型stream 与 block device 的接口差异2.1 流设备的三大核心操作read / write / close在 MicroPython 中一个对象能不能当流设备用不需要看它的类名只需要看它有没有实现流接口方法。标准流设备通常有read()、readinto()、write()、close()可能还有flush()、seek()等。文件对象、machine.UART、machine.I2C、machine.SPI、socket对象都属于这一类。判断一个对象是不是流设备最直接的办法是在 REPL 里输入dir(obj)然后看方法列表。比如我经常这么干import machine u machine.UART(1, baudrate115200) print(dir(u))你会看到read、readline、readinto、write、flush等方法但绝不会出现readblocks、writeblocks、ioctl。反过来如果你得到一个 SD 卡对象用dir()看方法只有readblocks、writeblocks、ioctl、sync没有read和write。这就是区分两类的第一道分辨术。流设备的read和write语义上很像水管write(bhello)就是把 5 个字节塞进水管不管对面是谁read(10)就是尝试从水管里接最多 10 个字节。它不关心数据从哪里来、到哪里去只保证字节顺序。这种设计最大好处是简单任何外设只要把数据按字节进出就能用同一套模式读写也方便在代码里直接对接到 Python 的文件接口。2.2 块设备必须实现的四个方法readblocks / writeblocks / ioctl / sync块设备的接口在 MicroPython 里规定得很严格一个块设备对象如果要被文件系统识别必须实现以下方法readblocks(block_num, buf)从第block_num块开始读取数据填充到buf中。writeblocks(block_num, buf)将buf中的数据写入第block_num块。ioctl(op, arg)控制命令MicroPython 内部通过它获取设备信息。sync()同步数据到物理介质。这里的核心是ioctl。常见操作码有定义在os模块内部数字不是随便定的。我写驱动时最常用的两个操作码含义返回值1同步设备无4获取块数量SEC_COUNT总块数5获取块大小SEC_SIZE每块字节数其中块大小通常固定为 512 字节。为什么是 512这是历史上硬盘扇区的大小FAT 文件系统也默认 512 字节一簇MicroPython 的 FAT 文件系统实现基本沿用了这个值所以大部分块设备驱动把块大小翻成 512 就能直接用。你如果写 EEPROM 驱动内部按 16 字节一页操作完全没问题但对外暴露给VfsFat时块大小一定要是 512 的整数关系否则文件系统会疯掉。很多第一次写块设备驱动的人看到readblocks第一个参数是block_num容易以为每次只读 512 字节。实际上buf的长度可能是一整个 erase block 或者更大比如open(/sd/test.dat, rb).read()读一个几 KB 的文件时文件系统可能一次请求读 4 个块。所以驱动代码里必须根据len(buf)循环读取多个块而不是假设 buf 永远是 512 字节。2.3 文件系统其实是“块设备上的流”别把两者搞混这里有个非常容易混淆的点今天必须说透。你平时用open()操作文件拿到的文件对象是流接口因为文件对象的read/write行为确实是一条字节流它按顺序读取、写入还支持seek()跳转。但文件对象底层依赖的物理存储设备是块设备。也就是说文件系统是在块设备之上构建出来的“流式视图”。为什么需要这层解释因为文件往往不是连续存放的。一个 10 KB 的文件可能在 SD 卡上被拆成了 20 个不连续的块。文件系统需要记录这些块的映射关系当你read()文件时它自动查目录、找块、调用readblocks把数据读出来再按文件的逻辑顺序拼成字节流喂给你。等到文件系统在内存里建好缓存你对文件对象的操作看起来就和操作串口一样简单了。所以当你写open(/sd/test.txt, w).write(hello)时代码层面你看不到任何readblocks的痕迹但这背后确实在按块读写。理解这层关系你就知道为什么 SD 卡挂载失败时文件打不开、为什么某些“FLASH 盘”在 MicroPython 里必须先mkfs格式化才能用。流接口是门面块设备是地基。3. 串口为什么是流设备从引脚电平到系统调用的完整链路3.1 UART 收发一帧数据的底层过程串口也就是 UART是嵌入式世界里最常见的流设备。说它“底层”是因为它真的就是从引脚电平层面开始工作的。UART 发送一字节数据时会先在 TX 引脚上拉出一个低电平作为起始位然后是 8 个数据位按低位到高位逐位发送最后拉高电平作为停止位。没有时钟线全靠双方约定好波特率来采样。一根空闲状态为高电平的线因为起始位的下拉让接收端知道“要开始收数据了”。这里有个关键点波特率决定了每一位数据的时间宽度。比如波特率 115200意味着每秒最多传输 115200 个 bit那每一位的持续时间大约是1 / 115200 ≈ 8.68 微秒发送一字节1 起始位 8 数据位 1 停止位 10 bit需要的时间大约是10 x 8.68 ≈ 86.8 微秒所以理论上 115200 波特率下串口每秒最多能传约 11520 字节。看到这个数字你就明白为什么串口写大量数据时那么慢了。也明白为什么 MicroPython 的uart.write()在缓冲区满时会阻塞——底层还在按这个速度一位一位地往外挪挪不完你就得等着。接收端的流程则完全反过来RX 引脚检测到起始位后按约定的波特率每隔一个 bit 时间采样数据线电平凑满 8 位拼成一个字节放进接收 FIFO。CPU 再从 FIFO 里读出来。这个过程中如果发送方和接收方的波特率不一致哪怕只差一点点采样点就会逐渐偏移最终导致乱码。3.2 CH340 与 FTDIUSB 转串口芯片在链路中的角色我们平时调试 ESP32-S3电脑上打开串口调试助手看到 COM 口或/dev/ttyUSB0这背后通常是 USB 转串口芯片在干活。最常见的就是 CH340 和 FTDI 这两个系列。CH340 是国产芯片性价比高很多几十块钱的开发板都在用。但它有个特点Windows 下通常需要手动安装驱动而且某些精简版系统上容易装不上。如果你在设备管理器里看到一个未识别的设备名字都没有大概率是 CH340 驱动问题。解决办法是去官方厂家的驱动页面下载对应版本装完重启插拔一次 USB 线。如果系统开启了“驱动强制签名”CH340 的旧版驱动还可能需要临时禁用签名才能安装。FTDI 是老牌芯片兼容性出名地好但价格也更贵。FTDI 驱动在 Windows 和 macOS 上一般系统自动识别插上就能用。很多专业调试器、开发板都愿意多花几块钱用 FTDI 芯片省去驱动烦恼。在 Linux 下两者通常都不需要额外装驱动。CH340 对应内核模块ch341FTDI 对应ftdi_sio。插上设备后用dmesg查看内核输出能看到识别信息。macOS 新版系统对 FTDI 和部分 CH340 也有内置驱动。我之前在 Linux 上调试时如果ls /dev/ttyUSB*没出现设备第一步就是dmesg | tail看内核到底认没认出来。这里额外提醒一句很多 USB 转串口模块上会标 TX、RX 两个引脚。连接时要注意交叉接线设备的 TX 要接转接板的 RX设备的 RX 要接转接板的 TX。很多人第一次接反了怎么发都收不到最后发现是 RX/TX 对调一下就好了。3.3 波特率、帧格式与调试助手乱码到底是谁的错串口乱码是我在社区里看到最高频的问题。排查顺序有这么几个先查波特率。两边明明都设成了 115200但实际芯片内部的时钟误差可能导致两边有细微偏差。MicroPython 在 ESP32-S3 上通过 APB 时钟分频来做波特率选的引脚不同也可能影响时钟源导致实际波特率和理论值有偏差。这种情况下可以试试把波特率降低比如降到 9600往往就好了。因为波特率越低单位 bit 时间越长同样的绝对误差占比越小抗偏差能力越强。再查帧格式。UART 有数据位、校验位、停止位的设置。最常见的配置是 8 个数据位、无校验、1 个停止位也就是常说的 8N1。如果一端配了 8E1也就是偶校验另一端还是 8N1接收到的数据当然对不上。最后查接线和电平。串口通信一定要共地也就是设备的地和 USB 转串口模块的地要接在一起否则电平没有统一参考数据会出现随机错误。还有线不要太长尤其波特率高的时候长线加上干扰很容易丢字节。串口调试助手在这里非常重要。我推荐大家在排查阶段切换到 HEX 模式显示直接看十六进制数据比看 ASCII 乱码直观得多。如果发一个0x55收回来是0xAA那大概率是接线交叉问题如果收到的是0x55 0x55 0x55后面跟着乱码那是波特率不匹配如果整个数据完全收不到先检查驱动有没有识别到 COM 口。4. 实操在 ESP32-S3 上打通一条串口链路4.1 硬件连接与固件准备手头正好有一块 ESP32-S3 开发板和一块 CH340 USB 转串口模块我用它演示一下完整链路。先把硬件接好开发板 UART_TX 引脚 → CH340 模块的 RX 引脚开发板 UART_RX 引脚 → CH340 模块的 TX 引脚开发板 GND → CH340 模块的 GNDCH340 模块插到电脑 USB 口如果你用的是带板载 USB 转串口的开发板那基本上不需要外接 CH340 模块直接用 USB 线连电脑就行。插上后Windows 打开设备管理器看端口Linux 执行ls /dev/ttyUSB*能看到新设备就是成功了。接下来烧 MicroPython 固件。ESP32-S3 要用 esptool 烧录先擦除再写入pip install esptool esptool.py --port /dev/ttyUSB0 erase_flash esptool.py --port /dev/ttyUSB0 write_flash -z 0x0 esp32s3-20240602-v1.23.0.bin固件文件名里的版本号可能会变去 MicroPython 官网下载对应芯片型号的最新版本即可。烧录完成后打开串口调试助手选对 COM 口波特率设 115200复位开发板能看到提示符就说明 REPL 已经跑起来了。4.2 用 machine.UART 让数据跑起来进入 REPL 后第一件事就是验证machine.UART对象。注意不同板子默认引脚的 UART 编号可能不同ESP32-S3 上有多个 UART建议直接用UART(1)并显式指定 TX/RX 引脚import machine import utime uart machine.UART(1, baudrate115200, tx17, rx16) uart.init(baudrate115200, bits8, parityNone, stop1)这里的tx17, rx16是两个物理引脚编号实际接线时要把这个 17 脚连到外部设备的 RX 上。然后写一个收发循环while True: uart.write(bhello from esp32\r\n) data uart.read() if data: print(recv:, data) utime.sleep_ms(1000)把这代码通过 REPL 执行另一边打开串口调试助手同样设成 115200、8N1能看到每隔一秒收到一条 “hello from esp32”。如果往助手端的发送框里输入字符ESP32 这边 REPL 里会打印出recv: bxxx。这段代码虽然简单但已经把流设备的核心体现出来了。uart.write()就是往流里塞字节uart.read()就是从流里取字节。数据没有位置概念只管顺序进出。如果你连续写大量数据比如uart.write(bx * 4096)你会发现这条语句要执行很久因为底层 FIFO 会满满了就要等。这正是流设备和块设备最直观的区别。4.3 进阶把 REPL 重定向到串口让打印日志更省心有时候我们同时要驱动 OLED 显示又要通过串口看调试日志。而 MicroPython 默认把print()输出到板载 USB 串口的 REPL导致拔掉 USB 线后日志就没了。这时可以用os.dupterm()把 REPL 输出重定向到外部 UARTimport os, machine uart machine.UART(1, baudrate115200, tx17, rx16) os.dupterm(uart, 1) print(this goes to external uart)执行完后print()的内容就不会发到板载 USB 串口了而是从外部 UART 发出去。电脑上打开另一个串口调试助手连接 CH340 模块对应的 COM 口就能看到日志。这个技巧在调试 ESP32-S3 OLED 这类项目时特别管用因为 OLED 板子往往把 I2C 引脚都用掉了只能靠外接串口输出日志dupterm直接解决了这个问题。需要注意的是一旦dupterm了外部串口就变成 REPL 主通道所以不要同时在那个串口上做其他数据收发否则输入和日志会混在一起。另外想恢复默认 REPL重新执行os.dupterm(None, 1)即可。5. 实操进阶在 MicroPython 里挂载一块“块设备”5.1 SD 卡上板最经典的块设备接入方式串口讲完我们看看块设备在 MicroPython 里的实际用法。最典型的就是 SD 卡。ESP32-S3 上可以用 SDMMC 接口或 SPI 接口驱动 SD 卡。用 SPI 驱动不需要 SDIO 那么多引脚接线也灵活我一般优先用这种方式。import machine, os from machine import Pin, SPI spi SPI(2, baudrate20000000, sckPin(18), mosiPin(23), misoPin(19)) sd machine.SDCard(slot2, sckPin(18), misoPin(19), mosiPin(23), csPin(5)) os.mount(sd, /sd) print(os.listdir(/sd))这段代码执行后SD 卡就被挂载到了根文件系统的/sd目录。之后你就能正常使用open(/sd/test.txt, w)来写文件了。注意SD 卡内部实际上是块设备但machine.SDCard这个类已经帮我们把readblocks、writeblocks、ioctl、sync这几个方法都封装好了所以能直接被os.mount()识别。这里有个关键操作如果 SD 卡的格式不是 FAT 文件系统os.mount()会失败。新买的 SD 卡有些是 exFAT 甚至 ext4 格式MicroPython 的VfsFat不认。解决办法是用os.VfsFat.mkfs(sd)格式化但注意这个操作会清空 SD 卡所有数据执行前一定要确认没有重要文件。5.2 更底层的玩法让 EEPROM 变成 FAT 文件系统如果你手头有一颗 I2C 接口的 EEPROM比如 AT24C32理论上也可以把它变成一个 FAT 文件系统挂载到 MicroPython 里。这个操作比 SD 卡硬核得多它会逼着你理解块设备驱动的每个细节。步骤很简单第一步是用 I2C 读写 EEPROM然后实现块设备接口。import machine, os, utime from machine import Pin, I2C i2c I2C(0, sclPin(9), sdaPin(8), freq400000) class EEPROMBlock: def __init__(self, i2c, addr0x50, size32768): self.i2c i2c self.addr addr self.size size def readblocks(self, block_num, buf): start block_num * 512 for i in range(0, len(buf), 32): offset start i self.i2c.writeto(self.addr, bytes([offset 8, offset 0xff])) chunk self.i2c.readfrom(self.addr, min(32, len(buf) - i)) buf[i:i 32] chunk def writeblocks(self, block_num, buf): start block_num * 512 for i in range(0, len(buf), 16): offset start i page buf[i:i 16] self.i2c.writeto(self.addr, bytes([offset 8, offset 0xff]) page) utime.sleep_ms(5) def ioctl(self, op, arg): if op 4: # BP_IOCTL_SEC_COUNT return self.size // 512 if op 5: # BP_IOCTL_SEC_SIZE return 512 if op 1: # BP_IOCTL_SYNC return 0写入为什么按 16 字节一页因为 AT24C32 这类 EEPROM 一页最多写 16 字节超过会跨页出错。写完为什么要延时 5ms因为 EEPROM 写入有内部写周期这个周期内芯片不响应外部操作必须等它写完。这就是块设备驱动和流设备驱动最大的不同块设备要考虑物理介质本身的读写限制和时序。定义好类之后格式化和挂载dev EEPROMBlock(i2c, size32768) os.VfsFat.mkfs(dev) os.mount(dev, /eeprom) print(os.listdir(/eeprom))执行完一颗 32KB 的 EEPROM就变成了一块弹性很小的“小硬盘”你可以往里面写小配置文件了。当然FAT 文件系统本身有目录结构和管理开销所以实际能用的空间会小于 32KB。5.3 两个块设备实操常踩的坑第一个坑mkfs之前要确信块大小配对了。块大小和块数量是文件系统布局的基础如果你在ioctl里把BP_IOCTL_SEC_SIZE误写成 256而实际驱动按 512 读写文件系统会直接报错。我建议在类里加一行调试输出先把返回值打印出来确认一遍。第二个坑EEPROM 写入时跨 16 字节页边界。我的代码里按固定 16 字节切分并逐页写入这是个稳定的做法。如果你图省事把整个 512 字节writeto一次发过去很多 EEPROM 芯片只会写入前 16 字节甚至报错。所以写块设备驱动时千万别把应用层“我把数据给它就行”的思维带进来每一层都要按照物理硬件的极限去适配。6. 常见问题与排查技巧实录6.1 串口类问题速查表现象可能原因解决方案串口调试助手打不开 COM 口驱动未装好或端口被占用重装 CH340/FTDI 驱动关闭占用端口的软件数据完全收不到TX/RX 接反或未共地交叉接线确认 GND 已连接收到的数据乱码波特率不匹配两端统一波特率必要时降速到 9600 验证偶发丢字节USB 转串口质量差、线太长换短线降低波特率加延时或流控uart.write()卡住不返回发送缓冲区满对端不读检查接收端是否打开减少单次发送量设备管理器识别为未知设备CH340 驱动异常卸载驱动重装临时禁用驱动签名6.2 块设备类问题速查表现象可能原因解决方案os.mount()报错 ENODEV设备没有实现readblocks等方法确认对象方法名拼写正确mount 失败提示不支持文件系统SD 卡分区格式不对用os.VfsFat.mkfs(sd)重新格式化写入 EEPROM 后数据不对页写入跨边界按 16 字节一页循环写入mkfs后挂载直接崩溃ioctl返回值错误打印调试信息确认块大小和块数量SD 卡偶尔读写失败SPI 速度过高或供电不足降低baudrate检查供电稳定性6.3 我的几条独家调试心得调试了这么多设备和板子我发现几条特别有用的经验今天一并分享。第一多用dir()探查对象。MicroPython 里一切皆对象拿到一个不确定类型的外设对象先dir()看方法列表立刻就知道它是流设备还是块设备。这个动作成本几乎为零但能避免非常多误判。第二串口调试助手一定要备两种显示模式。平时用 ASCII 看文本排查问题时切 HEX 看原始字节。比如你发现收到的数据永远是0xFF这通常说明线没接好或者电平不对如果数据第一位永远是0x00可能是波特率偏差导致采样点偏移到了起始位。第三写块设备驱动时务必先在最小范围内测试readblocks和writeblocks再挂文件系统。我自己写 EEPROM 驱动时第一步是先写一个测试函数往地址0x0000写 16 字节再读出来对比确认无误后才敢执行mkfs。否则文件系统把所有块信息都写进去之后才发现驱动有问题排错难度会成倍增加。最后再讲一点实操中的体会这篇文章写到这里核心的流设备与块设备差异、MicroPython 接口、串口链路和块设备驱动实践都已经完整走了一遍。对我来说搞懂这两类设备的区别最大的收益不是记住了哪个接口叫什么而是调试时心态完全不同了——遇到卡住时先想是不是流设备的缓冲区问题遇到文件系统挂不上时先查块设备的ioctl返回问题定位快得多。如果后续想深入扩展可以试试把多个 EEPROM 或 Flash 芯片抽象成一个更大的块设备靠ioctl做地址映射甚至把 ESP32-S3 内部的 Flash 划分一块出来挂载 VFS。不过走之前建议先把最基本的串口收发和 SD 卡挂载跑通这两个场景覆盖了绝大部分 MicroPython 开发需求。希望这篇文章能让你少踩几个我当年踩过的坑。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门