news 2026/9/5 5:28:39

MicroPython流设备与块设备:从UART到Flash的底层原理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MicroPython流设备与块设备:从UART到Flash的底层原理

1. 流设备与块设备的分界线在哪里

先从一个我实际经历过的小场景说起。有次我拿一块 ESP32 开发板做一个温湿度采集器,代码里打开文件写日志,写完顺手把同一个函数拿去操作串口,结果发现串口打印正常,但文件写入却时不时报错,而且报错的时机毫无规律。排查了一下午才意识到,我一直在用“读写文件”那一套心智去对待串口,但串口和文件在底层根本不是一回事。这个困惑,正是 MicroPython 里“流设备”和“块设备”这两个概念最容易让人踩坑的地方。

在 MicroPython 的世界里,几乎所有的 I/O 操作最终都可以归结为两类抽象:一类是流设备(stream device),一类是块设备(block device)。如果你查官方文档,会看到io模块、os模块、machine.UARTmachine.I2C这些对象被混在一起讲,初学者很容易以为它们都是“打开之后读一读写一写”,差别不大。但事实上,两者从设计目标、底层协议到使用方式都完全不同,理解这层差异,是写稳嵌入式代码的前提。

流设备的核心特征是顺序访问。你可以把它想象成一根水管:数据从一端流进,从另一端流出,你读取到的字节顺序,就是数据进入的顺序。读取操作没有跳转能力,你不能说“我想读第 100 个字节”,只能一个字节一个字节地往下读。串口就是最典型的流设备。你从传感器收到一帧数据0xAA 0x01 0x02 0x55,用uart.read()读回来,拿到的就是这一串原始顺序字节,中间没有索引,没有随机访问。

块设备则完全不同。块设备的核心特征是随机访问,数据按固定大小的“块”存储,每次读写至少是一个块,而且你完全可以指定从第几个块开始读、读多少块。最常见的块设备就是 microSD 卡、Flash 芯片、EEPROM。你在电脑上看到的 C 盘、D 盘,本质上都是块设备加上一层文件系统之后呈现出来的样子。文件系统做的事,就是把块设备提供的“按块读写”能力,封装成“按文件读写”的友好接口。

所以,如果你在 MicroPython 环境里操作一个文件,比如:

with open('data.txt', 'w') as f: f.write('hello')

表面上看,你面对的是一个文件对象,但这个文件对象底层可能挂在一个块设备上(比如 SD 卡),也可能挂在一个流设备上(比如某些虚拟文件系统)。而如果你直接操作machine.UART,你面对的就是一个纯粹的流设备,没有任何文件系统层的包装。

到这里,你可能已经意识到,“文件”和“流”并不是一回事,而“块设备”和“文件系统”也不是一回事。MicroPython 的巧妙之处在于,它让这两类设备都尽可能贴近“文件式操作”的感觉,从而降低学习成本。但这种“贴近”也带来了一个副作用:很多人把read()write()用熟了,却没搞明白背后的设备类型差异,一旦遇到readinto()seek()ioctl这些进阶接口就开始犯迷糊。

接下来的内容,我会从 MicroPython 的源码和实际硬件行为两个角度,把流设备、块设备的具体实现拆开讲清楚,并结合串口、SD 卡、Flash、蓝牙等真实场景,聊聊怎么判断一个设备该用哪种抽象,以及常见的坑都埋在哪里。

2. 流设备的核心:从 read/write 到 readinto 的层层细节

2.1 流设备在 MicroPython 中的底层形态

MicroPython 中的流设备,严格来说并不是一个独立的类,而是一组协议约定。任何对象,只要实现了read()write()readline()等方法中的一部分,就可以被视为流设备。官方文档里把这些方法统称为“流协议”(Stream Protocol)。

从底层 C 代码的角度看,MicroPython 运行时定义了一个mp_stream_p_t结构体,里面包含readwriteioctl三个核心回调函数指针,外加一些辅助字段。Python 层的sys.stdinsys.stdoutos.urandom的底层、machine.UARTmachine.I2Cnetwork.SSL等,最终都通过这套流协议暴露给用户。

这意味着,流设备不一定是硬件设备。比如socket对象也是流设备,uart是流设备,甚至一个普通的内存字节串,只要用io.BytesIO包一下,也是流设备。反过来,machine.Pin不是流设备,因为它没有实现这些读写方法。

搞清楚这一点,对理解“串口等 I/O 设备的底层差异”特别重要。因为当你用uart.read()的时候,你调用的并不只是“UART 驱动里的一个函数”,而是在调用一套通用的、由 MicroPython 运行时管理的流协议。这套协议会帮你处理超时、错误码、部分读写等细节。

2.2 为什么流设备推荐用 readinto 而不是 read

很多从 Arduino 转过来的朋友,用 MicroPython 操作串口时,最喜欢写这样的代码:

data = uart.read(10)

这行代码的意思很直白:读 10 个字节,返回一个 bytes 对象。但对于追求性能和稳定性的场景,我强烈建议改成readinto

buf = bytearray(10) n = uart.readinto(buf)

两种写法返回的结果一样,但背后开销差别很大。read()内部必须创建一个新的 bytes 对象来承接数据,这就涉及堆内存分配。在内存只有几百 KB 的嵌入式设备上,频繁的堆分配会导致碎片化,时间一长就容易触发MemoryError

readinto()则是把数据直接填进你预先分配好的bytearray,不产生新的对象分配,内存零额外开销。这个方法在设计上就是为流设备批量读取准备的。我第一次意识到这个问题,是在一个持续跑 72 小时的采集任务里,用read()逐包读取串口数据,跑到第三天程序突然崩了,打印出来的异常正是内存分配失败。改成readinto()之后,再也没出现过这个问题。

从“理解设备差异”的角度看,这个习惯还帮你建立了一个直觉:流设备的数据是“流动”的,它需要你准备好容器去接,而不是让系统临时找容器。块设备则不是这样,你读一个块,系统知道块的大小、位置,数据是稳定可寻址的,临时分配问题不大。但串口这类流设备,数据如果你不加紧接住,就会被后来的数据覆盖,所以“预先准备容器”才是正确的合作方式。

2.3 流设备的阻塞与非阻塞模式

流设备另一个容易被忽略的底层差异是阻塞模式。UART.read()在没有数据可读时,默认会阻塞住,直到超时或收到数据。MicroPython 提供了timeout参数来控制这个行为,单位是毫秒:

uart = machine.UART(1, baudrate=115200, tx=17, rx=16, timeout=50) data = uart.read()

上面这个配置表示:如果 50 毫秒内没有数据,read()返回None。这个机制看起来简单,但在实际项目中,很多人纠结过一个问题:“为什么我的read()有时候返回None,有时候返回空字符串b''?”原因是,超时返回None,而数据读取到了 0 字节(比如刚好读到了流结束)才返回b''。在不同设备上,行为可能还不一样,因此严谨的写法是先判None再判空。

所有流设备都遵循类似的阻塞/超时语义,包括 UART、I2C、socket。但你需要注意,不同设备对“超时”的默认值不同。machine.UART在未指定 timeout 时默认可能是不超时,而某些网络 socket 默认超时时间短得惊人。这不算 bug,而是流设备协议设计中“尽量通用”带来的副作用。用之前,翻一下对应驱动的文档总没错。

2.4 一个容易混淆的点:文件对象是流设备吗

很多人在 MicroPython 中用过类似代码:

with open('log.txt', 'r') as f: line = f.readline()

然后看到readline()这个方法在UART对象上也有,就以为“文件对象就是流设备”。这种理解只对了一半。

MicroPython 的文件对象确实实现了流协议的一部分(readwritereadline),所以它可以被当成流设备使用。但文件对象同时还实现了seek()tell()flush()等方法,这些方法在纯流设备上不存在或没有意义。seek()的存在,意味着文件底层很可能是一个块设备,支持随机访问;而UART没有seek(),因为串口数据一旦被读走就没了,不可能回退。

这个差异刚好可以用一句话概括:文件对象是“可寻址的流”,而 UART 是“不可寻址的流”。前者底层是块存储媒介,后者是线性的物理传输通道。理解这一点,很多代码设计上的疑问就都迎刃而解了,比如为什么不能对串口用seek(0)来重新读一帧数据。

3. 串口的真实身份:为什么 UART 是最典型的流设备

3.1 串口背后的硬件行为

串口(UART)可能是嵌入式开发里接触最多的流设备了,它的底层机制非常适合用来理解“流”的含义。硬件层面,UART 由发送线(TX)和接收线(RX)组成,数据按位逐字节发送,每一位的持续时间由波特率决定。比如 115200 波特率,意味着每秒最多传输 115200 位,约 14400 字节。

数据从外部设备进入 UART 外设后,会先存进硬件接收缓冲区(FIFO),MicroPython 的 UART 驱动再把这个 FIFO 中的数据搬到软件缓冲区,最后等待 Python 层的read()来取。这个过程完全符合“水流入管、水管出水”的画面。

有意思的是,如果你在逻辑分析仪上看串口波形,会看到空闲时 TX 线保持高电平,起始位拉低一个位宽,然后是 8 个数据位、可选校验位、停止位。这些时序细节,普通应用不需要关心,但当你遇到通信不稳定时,就该知道问题出在“流”的哪个环节了。比如数据偶尔乱码,常见原因是波特率不匹配或 GND 没共地;数据丢字节,常见原因是接收缓冲区溢出,也就是“水管出水口太小,水池满了”。

3.2 MicroPython 的 UART 对象如何映射流协议

machine.UART在 MicroPython 内部就是按流设备实现的。它实现了以下流协议方法:

  • read(n=-1):读取最多 n 个字节,不指定则读取所有可读数据。
  • readline():读取到换行符为止。
  • readinto(buf, nbytes=0):把数据读进缓冲区。
  • write(buf):发送数据。
  • any():返回接收缓冲区中可读字节数(这是 UART 特有的非流协议方法,但非常实用)。

这里要特别说明any(),流协议标准里其实没有这个方法,MicroPython 为了方便串口轮询而加上。它不阻塞,能直接告诉你缓冲区里有多少数据,非常适合在非阻塞任务中使用:

if uart.any(): data = uart.read(uart.any())

有人会担心,read(uart.any())在读取间隙又有新数据进入,会不会丢?实际上,读到的数据量取决于调用时刻的缓冲区状态,多读进来的一点数据不会丢,留在缓冲区里下次再读即可。这个写法是安全的,且效率比read()不加参数更高,因为它减少了无谓的等数据时间。

3.3 串口调试与驱动层面的常见困扰

标题的热搜词里有一堆与串口相关的内容:串口调试助手、CH340 串口驱动、CH341 驱动、FTDI 驱动、虚拟串口、SSCOM、Minicom、RS485、MODBUS 等等。这些词背后,其实都指向一个共同点:大家在使用串口时,真正关心的是“把数据从 A 点挪到 B 点,别丢、别乱、别阻塞”。

关于驱动,CH340、CH341、FTDI 这类 USB 转串口芯片,本质上是把 USB 协议转换成 UART 协议。你在电脑上看到的 COM 口,就是一个虚拟串口,它由驱动提供,行为上和物理串口几乎没有差别。在 Windows 下,最常见的坑有两个:

  • 驱动版本过旧,导致设备管理器里识别为未知设备,解决办法是去芯片厂商官网下载最新驱动,而不是用系统自动匹配的驱动。
  • 多个 USB 转串口同时插入,COM 口号漂移。这时建议在设备管理器里手动指定固定 COM 号,避免代码里写死了串口号却找不到设备。

如果你在 Linux 下用 Minicom 调试 ESP32,常常会遇到“ttyACM0 is locked”的提示。这个问题的本质是:Minicom 默认会创建一个锁文件来判断串口是否被占用,如果上次程序没有正常退出,锁文件残留,就会导致下次无法打开。解决办法很简单:

sudo rm /var/lock/LCK..ttyACM0

或者检查是否有其他程序占用串口,用lsof /dev/ttyACM0查看。

3.4 流设备上的经典排错场景:串口收不到数据

这里分享一个排查链路,你在串口调试中大概率会遇到。现象很简单:板子上电后,上位机串口助手收不到任何数据。

第一步,确认硬件连接。TXD 是否接到了对端的 RXD?GND 是否共地?波特率是否一致?这看起来像废话,但实际上有一半以上的“收不到数据”是 TXD/RXD 接反了。

第二步,确认代码里的串口引脚。ESP32 的 UART 可以使用任意 GPIO 作为 TX/RX,但不同开发板的默认引脚可能不同。如果你的开发板丝印没标清楚,最好在代码里显式指定:

uart = machine.UART(1, baudrate=115200, tx=17, rx=16)

第三步,确认流协议层是否正常。用一个最简单的回环测试:把 TX 和 RX 用杜邦线短接,然后执行uart.write()后再uart.read()。如果数据能收回来,说明 UART 外设没问题,问题出在外接设备或线路。

第四步,检查是不是缓冲区溢出或丢数据。如果上位机能收到数据,但内容不完整,可以先把波特率降低到 9600,看看是否改善。如果降低波特率后问题消失,说明是高频传输下的数据丢失,需要从 DMA、缓冲区和接线质量入手排查。

这套排查链路,本质上是“从物理层到协议层再到应用层”逐层剥离问题。因为流设备传输链路长,任何一个环节断裂都会导致数据缺失,所以排查时必须分层,不能只盯着一端看。

4. 块设备的世界:readblocks、writeblocks 与 ioctl

4.1 块设备在 MicroPython 中长什么样

如果说流设备是水管,那块设备就像一栋楼的储物柜。每个储物格(块)大小固定、有编号,你想存取哪个格子,直接告诉管理员格子号就行,管理员不会在意你是按顺序还是跳着存取。这种设计天然适合存储介质,因为 Flash 芯片、SD 卡内部本身就是按扇区、按块组织的。

MicroPython 中,一个块设备对象需要实现以下方法:

  • readblocks(block_num, buf):从指定块号开始读数据,读入长度为len(buf)的字节。
  • writeblocks(block_num, buf):从指定块号开始写数据。
  • ioctl(op, arg):控制设备参数,比如查询块数量、块大小、擦除设备等。

重点在ioctl。它定义一个操作码常量,常用的有:

  • ioctl(MP_BLOCKDEV_IOCTL_BLOCK_COUNT, 0):返回块总数。
  • ioctl(MP_BLOCKDEV_IOCTL_BLOCK_SIZE, 0):返回块大小(字节)。
  • ioctl(MP_BLOCKDEV_IOCTL_SYNC, 0):同步缓存到物理介质。
  • ioctl(MP_BLOCKDEV_IOCTL_ERASE, arg):擦除指定块。

如果你在 MicroPython 里写过自定义块设备驱动,一定对这套接口不陌生。这跟流设备的read/write接口风格完全不同——流设备不需要关心“块号”,它只有“流位置”和“数据”;块设备则把存储空间切成一个个可寻址单元,接口天然带有“块号 + 缓冲区”的维度。

4.2 为什么文件系统需要块大小对齐

在块设备上创建文件系统时,有一个容易忽略的关键参数:块大小。比如你用os.VfsFat挂载一个自定义块设备,需要指定sec_size(扇区大小),而设备返回的块大小必须跟文件系统期望的扇区大小匹配,否则挂载会失败或者读写异常。

我在一个 DIY 数据记录仪项目里,用了一颗 SPI Flash(W25Q128),扇区大小是 4096 字节。第一次写块设备驱动时,我在ioctl里返回块大小 4096,挂载VfsFat时报错,提示filesystem mount failed。后来发现,VfsFat期望的扇区大小是 512 字节,而 Flash 物理擦除单位是 4096 字节,两者不一样。

解决思路不是去硬改ioctl返回值,而是加一层“扇区模拟”:让逻辑块大小保持 512 字节,内部再把多个 512 字节映射到一个物理 4096 字节块上。这个设计是块设备驱动开发中非常实用的一招,也能解释为什么市面上的 Flash 转 U 盘方案都有一个“磨损均衡 + 映射层”的中间件存在。

4.3 块设备与流设备的边界:异常处理逻辑完全不同

块设备和流设备的异常处理思路也有本质区别。流设备的数据具有时效性,读取超时不会报错,而是返回None或超时标志;块设备的数据是持久化的,读取一个不存在的块,会直接抛出OSError

这个差异在使用上很重要。比如,从串口读取数据,你只能做“有没有数据”的判断,而不能做“这数据对不对”的判断(数据对不对取决于应用层协议);但从块设备读取数据,你可以通过返回的字节数判断是否读满,没读满就意味着设备异常。

MicroPython 官方文档里还有一句提示:块设备的writeblocks并不保证数据已经写进物理介质,必须在调用ioctl(MP_BLOCKDEV_IOCTL_SYNC, 0)之后才算真正落盘。这一点非常容易忽略。在流设备上,write()把数据交给驱动就算完成了;在块设备上,writeblocks()之后还有一层缓存同步的隐式流程。如果你写了一个块设备驱动,但忘了实现SYNC操作,可能会导致文件系统挂载正常、写入也正常,但掉电后数据全部丢失。

5. 当“流”与“块”混在一起:文件系统、虚拟设备和特殊对象

5.1 文件系统也可以是流设备

很多人以为文件系统只能构建在块设备之上,其实不一定。MicroPython 的os模块支持挂载“伪文件系统”(fake filesystem),底层可以是一个流设备或一个普通内存对象。最典型的例子是esp32NVS(非易失存储)和littefs。NVS 不提供块设备接口,而是通过键值对的形式读写数据,但 MicroPython 仍然可以把它包装成文件系统使用。

在这个场景下,你对“文件”的操作,底层其实是键值存储,不再有“块号”的概念。也就是说,文件系统这个抽象层,既能构建在块设备上,也能构建在流设备或其他存储抽象上。理解了这一点,再回去看open()函数返回的文件对象,就不会简单地把它等同于“块设备”了。

5.2 自定义一个虚拟块设备:从零实现 RAM Disk

为了让你更清楚块设备的实现流程,我给你展示一个最简单但可用的虚拟块设备:在内存里模拟一块 8KB 的存储。

import os from micropython import const _BLOCK_COUNT = const(16) _BLOCK_SIZE = const(512) class RAMBlockDev: def __init__(self): self.data = bytearray(_BLOCK_COUNT * _BLOCK_SIZE) def readblocks(self, block_num, buf): offset = block_num * _BLOCK_SIZE buf[:] = self.data[offset:offset + len(buf)] def writeblocks(self, block_num, buf): offset = block_num * _BLOCK_SIZE self.data[offset:offset + len(buf)] = buf def ioctl(self, op, arg): if op == 4: # MP_BLOCKDEV_IOCTL_BLOCK_COUNT return _BLOCK_COUNT elif op == 5: # MP_BLOCKDEV_IOCTL_BLOCK_SIZE return _BLOCK_SIZE elif op == 6: # MP_BLOCKDEV_IOCTL_SYNC return 0

然后挂载文件系统:

dev = RAMBlockDev() os.VfsFat.mkfs(dev) os.mount(dev, '/ramdisk')

这样你就得到了一个 8KB 的 RAM 磁盘,可以像普通文件系统一样创建文件。这个例子的价值在于:帮你把readblocks/writeblocks/ioctl协议和文件系统之间的协作关系直观地理解清楚。如果你把RAMBlockDev换成真正的 Flash 或 SD 卡驱动,思路是一样的。

5.3 网络连接对象算流设备吗

标题里没有直接提到网络,但 MicroPython 中常用的socket对象其实也是流设备。你可能在代码里见过:

import socket s = socket.socket() s.connect(('192.168.1.100', 8080)) s.send(b'hello') data = s.recv(1024)

sendrecv就是流协议的读写操作,只不过名字不同。如果你用usocketmakefile()方法拿到一个文件对象,那它的行为就和 UART 更像了,支持readline()readinto()等。所以,网络套接字也是流设备,它具备“顺序、不可寻址、可能阻塞或超时”的典型流特征。

这个认识在写跨场景代码时特别有用。比如你写了一个上位机通信模块,底层既可以是串口,也可以是 TCP socket,两者的流特性高度相似,代码可以复用同一个处理逻辑,只要把read()替换成recv()write()替换成send()

6. 从热搜词汇看串口开发者的真实痛点

6.1 为什么有这么多人搜“串口驱动”

看一下热搜词列表,关于串口驱动的搜索量非常大:CH340、CH341、FTDI、虚拟串口、串口调试助手。这些词背后暴露出的问题,其实不是芯片本身难用,而是很多人第一次接触 USB 转串口时,被“驱动安装”这件事卡住了。

CH340 和 CH341 都是南京沁恒的 USB 转串口芯片,性价比高,国内开发板普遍使用。FTDI 的芯片(比如 FT232)更贵,但驱动兼容性更好,某些场景下更稳定。如果你的设备管理器里出现“USB-SERIAL CH340”且带黄色感叹号,大概率是驱动没装好。建议去沁恒官网下载最新驱动,装完重启电脑。如果还是不行,看看是不是 USB 线的问题——有些 USB 线只供电不传数据,这种情况我遇到过不下三次。

虚拟串口则是另一类常用工具。Windows 下的com0comVirtual Serial Port Driver,可以创建成对的虚拟串口,让两个程序互相通信。这在调试上位机软件时非常好用:你不需要真实硬件,就可以模拟两个串口之间的数据收发。

6.2 从“串口关闭”“串口被占用”聊到资源管理

热搜词中还有“串口关闭”“linux 设置 ttyS1 为调试串口”这类内容。这说明很多人踩过串口资源管理的坑。在 Windows 下,串口被占用时会提示“端口已被打开”或“另一个程序正在使用此端口”。原因通常是串口调试助手没关掉、后台程序残留,或者驱动层没有释放端口。

在 Linux 下,情况类似。如果你用 Python 的pyserial打开串口后程序异常退出,串口可能不会被自动释放,需要手动关闭。一个稳妥的写法是with语法:

import serial with serial.Serial('/dev/ttyUSB0', 115200, timeout=1) as ser: ser.write(b'AT\r\n') resp = ser.read(100)

这样即使发生异常,串口也会被自动关闭。如果你确实遇到了串口被占用且找不到是谁在用,可以用lsof /dev/ttyUSB*fuser -k /dev/ttyUSB0强制释放。

6.3 串口通讯协议与换行问题

热搜词里有“串口怎么换行”“串口通信协议”这些,说明很多人都卡在串口协议最基本的格式上。串口本身是字节流的传输通道,它不关心你的数据结构是换行分隔、固定帧头、还是 MODBUS 格式。换行只是上位机和下位机之间约定的一个“分帧”标记。

最常见的方式是每帧数据以\n(LF)或\r\n(CRLF)结尾,下位机用readline()读取一整行。但要注意,MicroPython 的readline()默认只认\n,如果对端发的是\r\n,你可能会收到带\r的脏数据。稳妥做法是:

line = uart.readline() line = line.strip()

或者干脆用自定义的帧解析,指定帧头、帧尾、长度字段,这样协议更健壮,不受换行符困扰。比如:

_FRAME_HEAD = b'\xAA\x55' def parse_frame(data): if data.startswith(_FRAME_HEAD) and len(data) >= 6: length = data[4] if len(data) >= 5 + length: return data[5:5 + length] return None

6.4 RS485、MODBUS 与流设备的关系

热搜词里有很多 RS485 与 MODBUS 的内容。MODBUS 是应用层协议,RS485 是物理层总线标准,而 UART 在中间扮演着“物理层传输载体”的角色。在 MicroPython 中实现 MODBUS RTU,本质还是通过 UART 这个流设备收发字节流,只是需要在收发之间切换 RS485 的方向控制引脚。

很多 STM32 开发者会问,为什么 MODBUS 接收数据不使用 DMA 会丢数据?核心原因是,MODBUS RTU 要求帧间隔不超过 1.5 个字符时间,如果 CPU 处理不及时,数据会被新数据覆盖。解决办法是使用 UART 的空闲中断(IDLE interrupt)来判断一帧数据是否接收完毕,或者使用 DMA + 空闲中断的组合。这在流设备语境下,对应的是“流式数据的分帧处理”问题——你无法通过seek来回到帧头,所以只能在接收过程中就完成帧边界的识别。

7. 实际选型和设计建议:串口、Flash、SD 卡应该怎么选

7.1 按数据特性选择设备类型

回到最根本的问题:什么情况下用流设备,什么情况下用块设备?

我的判断标准很简单:如果你的数据是“生成的、实时的、有序的”,选流设备;如果你的数据是“存储的、持久化的、可索引的”,选块设备。

举个例子,温度传感器数据采集。传感器每秒钟产生一个温度值,你需要实时读取,这个环节用流设备(UART 或 I2C)最合适。数据读回来后,如果要存到 SD 卡上长期保留,写入环节就用块设备加文件系统。这两个环节的设备类型不同,你处理的方式也应区分:读取传感器时,代码要处理超时和部分读取;写入 SD 卡时,代码要处理“落盘”和文件系统同步。

很多项目出问题的根源,就是用流设备的方式操作块设备,或者反过来。比如,有人试图用seek()来跳过串口收到的不需要的字节,结果发现根本不行;还有人试图从 SD 卡上像流一样持续读取一个不断增长的日志文件,数据却被文件系统缓冲在内存里,迟迟没写入物理卡。

7.2 从流设备到块设备的桥接:一个实用设计模式

有些场景需要两者配合。最简单的例子:用 AT 指令从 4G 模组通过串口接收一段 Base64 编码的固件升级包,然后写入外部 Flash。流程是:

  1. 用 UART(流设备)接收数据。
  2. 按块大小切分数据,写入 SPI Flash(块设备)。
  3. 每次写完一个块,调用SYNC确保落盘。

这就是从流到块的桥接设计。关键不是写代码,而是设计“分帧 + 对齐”的转换逻辑。因为流设备的字节没有边界,而块设备要求按块写入,如果接收到的数据不是块大小的整数倍,就需要缓冲不足一字节的部分在内存里。

我在一个 OTA 项目中实现过类似逻辑,核心代码如下:

block_size = 4096 pending = bytearray() while True: chunk = uart.read(256) if not chunk: continue pending.extend(chunk) while len(pending) >= block_size: flash.writeblocks(current_block, pending[:block_size]) current_block += 1 del pending[:block_size]

数据最后不足一个块时,用0xFF填充后再写。这么做是为了符合 Flash 的编程特性——未写入的 Flash 字节读出来通常是0xFF,把填充字节也设为0xFF有助于校验通过。

7.3 关于 MicroPython 官方文档没明说的小事

最后聊几个官方文档里着墨不多、但实际开发中特别影响体验的小细节。

第一,ioctl的操作码在不同 MicroPython 版本、不同移植版本中可能是不同的。官方文档推荐用microPython 常量或者port模块中定义好的常量,而不是自己写死数字。我上面 RAMDisk 示例里直接用4/5/6是为了简化展示,真实项目中建议用from micropython import const并正确定义,或者直接参考对应移植版源码中的定义。

第二,流设备的write()方法返回值不一定是写入的字节数。虽然大部分时候write()返回len(data),但在某些非阻塞模式下,它可能返回一个小于len(data)的值。严谨的代码应该用循环确保所有数据都写入:

def uart_write_all(uart, data): while data: n = uart.write(data) if n is None: continue data = data[n:]

这个函数在写 RS485 或 MODBUS 时特别有用,因为半双工总线上数据没有完整发送出去就切换方向,会导致帧被截断。

第三,块设备的readblocks传入的bufbytearray,但在littlefs文件系统下,要求缓冲区必须对齐到 4 字节。如果你在块设备驱动里使用了直接内存访问(比如esp32的 SPI DMA),对齐要求会更严格。忽略对齐会得到不可预期的OSError,排查起来很费劲。

8. 从底层差异到编程心智的转变

花了这么多篇幅讲流设备和块设备,归根结底想表达的核心观点是:设备类型决定了你能对它做什么、不能对它做什么,而不是 API 表面上看起来有多像

所有流设备的共同点是“顺序性”:读 UART 的顺序就是数据到达的顺序,读 socket 的顺序就是数据包到达的顺序,读BytesIO的顺序就是你当初写入的顺序。所有块设备的共同点是“可寻址性”:你可以精确地告诉它“读第 5 块”“擦除第 2 块”“把第 7 块写满 512 字节”,它可以稳定地执行,不依赖于数据的到达时序。

这套心智一旦建立起来,你再去看 MicroPython 的osiomachine模块,就不会觉得 API 混乱了。看到read()你会想:“这是流操作,数据可能只是部分到达。”看到readblocks()你会想:“这是块操作,缓冲区必须足够大,块号必须有效。”看到ioctl()你会想:“这是在和设备的元信息打交道,返回值比数据本身更重要。”

我个人在实际项目中还有一个体会:调试流设备和块设备问题的难度差异很大。流设备的问题往往具有“时隐时现”的特点,因为数据时序稍纵即逝;块设备的问题则相对稳定,因为存储内容持久化,复现概率高。遇到间歇式串口丢数据,不要急着改代码,先用while uart.any(): ...把数据全部读出来打日志看看;遇到 SD 卡挂载失败,先用逻辑分析仪或示波器确认 SPI/I2C 时序和电平转换是否正常。这些做法,都是“先定位到具体层,再做修改”的思路。

最后分享一个我常用的调试技巧:在串口通信的代码里,适当加入类似“帧计数”的机制,每收到一帧数据,就在帧尾追加一个自增序号。这样上位机一旦发现序号不连续,就能立刻意识到底层丢了帧,而不是盲目怀疑校验算法或协议解析。这个方法同样适用于块设备的写入验证——你在每个块末尾写入一个块号,读取时校验,就能快速定位坏块和映射错误。这种朴实但有效的验证手段,比依赖任何调试器都好用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/5 5:28:06

串口通信效率提升三板斧:空闲中断+DMA、硬件流控与波特率误差预算

我敢打赌,你电脑里那个串口调试助手已经用了不下三年,但你手里的串口通信,从来没跑满过。串口这东西,看着简单,实际上坑藏得比想象中深。波特率设对了、校验位选对了、数据能收发,很多人就觉得“串口通信已…

作者头像 李华
网站建设 2026/9/5 5:27:45

CLIP STUDIO PAINT 颜色助手 1.2.0:从色彩理论到高效工作流

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 5:27:33

零成本搭建本地AI全栈:旧主机上手把手教程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 5:26:45

AI大模型工程师核心技能:从RAG到Agent的工程实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 5:26:22

变电站SCADA安全防护实战:电力调度数字证书与IEC61850安全通信落地

某 220kV 变电站调试期抓包,站控层网段上一台后台监控主机正在和 IED 通信,报文直接明文可见:遥测值、断路器位置、甚至控制命令的字段名都读得出来。更吓人的是,安全审计翻日志发现有一台「来历不明」的装置曾短暂接入站控层网络…

作者头像 李华
网站建设 2026/9/5 5:24:56

IAR原生跨平台IDE:嵌入式开发在Linux与Windows间无缝迁移

IAR落下两步棋:原生跨平台IDE让Linux和Windows站上同一起跑线做嵌入式开发这么多年,IAR Embedded Workbench一直是个让我又爱又恨的存在。爱的是它的编译器优化效果确实顶尖,代码密度和执行效率在ARM、RISC-V这些架构上表现都属第一梯队&…

作者头像 李华