MicroPython 流设备与块设备:串口与 SD 卡底层原理及调试指南
2026/9/5 17:24:21 网站建设 项目流程

1. 从串口说起:为什么 MicroPython 开发者要搞懂设备类型

先用一个实际场景把话题拉开。你用 MicroPython 在 ESP32 上写串口程序,调一个传感器模块,代码大概是uart.read()读数据、uart.write()发指令,跑起来一切正常。后来你换了个思路,想在开发板上挂一个 SD 卡存日志,接口同样有读有写,底层用的却是os.listdir()open()这种文件操作。同样是 I/O,为什么一个走读写函数,一个走文件系统接口?这就是流设备(stream device)和块设备(block device)在底层设计上的差异直接导致的。

这个差异不是 MicroPython 特有的,Unix 系操作系统里早就把设备分成了“字符设备”和“块设备”两大类,串口、键盘、终端属于前者,硬盘、SSD、SD 卡属于后者。MicroPython 作为一个面向微控制器的 Python 运行时,把这些概念简化了,但核心逻辑还在。不理解这两个东西的本质区别,你在写驱动、调外设、排查通信问题的时候就会踩很多莫名其妙的坑——比如为什么串口读数据会“丢”,为什么 SD 卡拔插之后文件系统崩了,为什么同样一段代码换个设备就不工作。

这篇文章就干一件事:把流设备和块设备的底层差异讲透。不绕弯子,直接对比两者的数据模型、缓冲机制、驱动接口,再落到 MicroPython 的machine.UARTosblock device protocol这些具体对象上,最后讲讲实际调试串口时最容易遇到的那几个问题。适合刚接触 MicroPython 的嵌入式新手,也适合那些 Arduino 转过来、对底层机制没系统梳理过的同学。看完之后你不仅知道怎么用,还能说清楚“为什么它是这么设计的”。

2. 流设备与块设备:两种完全不同的数据世界观

2.1 流设备:没有“位置”概念的数据管道

流设备的核心特征就是“顺序读写,没有随机访问”。数据从一端进来,从另一端出去,你没法跳到中间的某个偏移量去读,也没法回头重新读一遍已经过去的数据。这个特性用生活里最简单的例子类比就是水管:水从水龙头出来,流到桶里,你只能按顺序接水,不可能说“我想接第 100 毫升那一段水”。数据到了应用层,就像水流进了桶,你要是没接住,它就过去了,再想找回来不可能。

串口是流设备的典型代表。UART.read()拿到的永远是“当前缓冲区里已到达的字节”,你不可能读到 10 秒前的某一段数据;UART.write()发送出去的字节也顺着线路走了,不可能“撤回”。键盘、鼠标、麦克风、网络 socket 在软件抽象层面也都是流设备,它们只保证数据按顺序到达,不保证你能随时访问历史数据。

这个特性决定了流设备驱动在底层必须做一件事:缓冲。因为数据到达的时间是不可预测的,CPU 可能正在干别的活,如果数据到了没人取,就丢了。所以流设备驱动几乎都有一块 FIFO(先进先出)缓冲区,数据到了先存进去,应用层什么时候来读,什么时候拿走。MicroPython 里machine.UART默认启用硬件 FIFO,底层还有一层软件缓冲,就是干这个用的。

2.2 块设备:按“块”寻址的存储介质

块设备的世界观完全不同。它把存储空间划分成固定大小的“块”(block),每个块有独立的地址,驱动提供的是“从第 N 块开始读 M 块”“从第 N 块开始写 M 块”这种带位置信息的操作。读和写都围绕地址进行,访问是随机的——你可以先读第 100 块,再回头读第 2 块,没问题。

SD 卡、eMMC、NOR Flash、SATA 硬盘都是块设备。这个设计的原因是物理介质的特点:Flash 的擦除和写入必须以块为单位,机械硬盘的读写也需要磁头定位到扇区。把这些差异藏在一个统一的块接口后面,上层可以做文件系统、可以做分区表、可以做磨损均衡,应用层根本不需要关心介质是什么型号、什么规格。

块设备的典型特征还有一个:数据是持久化的。你写入的数据在掉电后仍然存在,下次启动还可以读回来。这个特征和“按地址访问”结合起来,才让文件系统得以存在——文件系统本质上是管理块设备上数据布局的一层软件,它需要能随机访问任意块,才能维护文件目录、索引节点、空闲块列表这些元数据。

2.3 一张表说清核心差异

维度流设备块设备
数据模型连续字节流固定大小的块序列
访问方式顺序读写随机读写(按地址)
核心操作read / writereadblocks / writeblocks / ioctl
能否持久化通常不能(数据流过后即消失)能(掉电不丢失)
是否需要缓冲必须,否则会丢数据需要,但目的不同(缓存命中率)
典型代表串口、键盘、网络 socketSD 卡、Flash、硬盘
上层抽象直接读写文件系统、分区表
MicroPython 接口machine.UARTmachine.I2Cos挂载、littlefsfatfs

这张表是全文的主干。你记住一个核心判断标准:能不能按地址随机访问。能,就是块设备;不能,就是流设备。其他所有差异几乎都可以从这个标准推导出来。

3. MicroPython 对两类设备的抽象与实现

3.1 流设备在 MicroPython 中的接口体系

MicroPython 对流设备的抽象继承自 Python 标准库的IOBase,核心就是read()write()readinto()这几个方法。任何实现了这套方法的对象都可以被称为“流对象”,不管底层是串口、I2C 总线还是 SPI 总线。

以串口为例,ESP32 上初始化一个 UART 并读写,实际代码长这样:

from machine import UART, Pin # 初始化 UART0,波特率 115200,TX 用 GPIO1,RX 用 GPIO3 uart = UART(1, baudrate=115200, tx=Pin(1), rx=Pin(3), timeout=100) # 发送一帧数据 uart.write(b'\xAA\x55\x01\x02\x03') # 读取最多 64 字节数据 data = uart.read(64) if data: print('received:', data.hex())

这里的关键设计是:UART对象实现了流接口,所以它不仅能传给read()/write()直接用,还能传给任何接受流对象的标准库函数。比如你用json.load(uart)直接从串口读 JSON,或者用pickle.dump(data, uart)把对象序列化后发出去。Python 生态对流对象的高度统一,在 MicroPython 里也得到了保留。

MicroPython 的流对象还有一个细节:实现了上下文管理器协议(with语句)。这在实际开发中非常有用:

with UART(1, baudrate=9600) as uart: uart.write(b'AT\r\n') resp = uart.read(128)

进入with块时自动打开串口,退出时自动释放资源。在资源有限的单片机上,这个特性可以帮你避免忘记 close 导致的资源泄漏。

3.2 块设备协议:挂载文件系统的前提

MicroPython 对块设备的抽象更底层,对应的是os.AbstractBlockDev这个协议类。一个对象要想被当作块设备用,必须实现三个核心方法:

class MyBlockDev: def readblocks(self, block_num, buf): # 从 block_num 开始读取数据,填充到 buf pass def writeblocks(self, block_num, buf): # 把 buf 数据写入从 block_num 开始的块 pass def ioctl(self, cmd, arg): # 设备控制命令:获取块数量、块大小、擦除块等 pass

ioctl是块设备协议的“万能接口”。MicroPython 定义了几个标准命令:

  • IOCTL_BLOCK_COUNT:返回设备总块数
  • IOCTL_BLOCK_SIZE:返回每块字节数
  • IOCTL_ERASE_BLOCK:擦除指定块

文件系统挂载时,MicroPython 会通过ioctl获取这些参数,然后在设备上建立 FAT 或 littlefs 文件系统。这正是“文件系统是构建在块设备之上的一层软件”这句话的技术体现。

实际项目中,你很少需要自己实现块设备协议——SD 卡驱动库已经做好了,直接os.mount()就行。但在某些特殊场景下,比如把一块 SPI Flash 模拟成 U 盘、做日志循环存储、实现 OTA 固件双备份,你会需要自己封装一个 block device。这时候理解协议本身就很关键了。

3.3 为什么串口不能挂文件系统?

这是很多新手会有的疑问:我能不能把串口挂载成 U 盘,直接open('/uart/xxx')读写?答案是不能,或者说不应该。

原因直接对应 2.1 和 2.2 的差异。文件系统要求设备支持随机访问:创建文件时要写目录项,追加数据时要修改文件大小字段,删除文件时要标记空闲块。这些操作都需要“先读某个位置 → 修改 → 写回去”。串口是一个顺序数据管道,你发过去的字节被对端设备消费掉了,不可能说“我要重新写一下第 3 个字节”。即使你强行在串口协议上模拟随机访问(比如加地址帧、ACK 帧),效率也会低到无法接受。

反过来说,SD 卡不能当串口用,也是因为数据模型不匹配——块设备没有“事件驱动”的概念,你写进去的数据要等文件系统刷新才落盘,没法像串口那样按字节实时交互。

所以记住一个实际工程结论:流设备适合传输数据,块设备适合存储数据。它们是两个维度上的东西,各有分工。你在设计系统架构时,要先想清楚每个外设承担什么角色——是管道还是仓库,然后再决定怎么驱动它。

4. 实际调试经验:串口作为流设备的常见坑与排查

4.1 串口丢数据的真正原因

前面提到流设备必须要有缓冲,但这不代表有了缓冲就不会丢数据。MicroPython 在 ESP32 上跑的时候,最常见的串口丢数据场景是:高波特率下,应用层读取不及时,缓冲区溢出

用算账的方式说清楚。ESP32 的 UART 硬件 FIFO 通常只有 128 字节(具体取决于芯片版本),MicroPython 在它上面还封装了一层软件缓冲区,默认大小因移植版而异,PyBoard 上可以设置UART.initread_buf_len,ESP32 移植版对软件缓冲区的暴露程度不同。假设总有效缓冲区是 256 字节,波特率 921600,每字节 10 bit(8 数据位 + 1 起始位 + 1 停止位),意味着每秒能收 92160 字节,缓冲区 256 字节大约能扛 2.8 毫秒。如果 MicroPython 的垃圾回收在这 2.8 毫秒内开了一次大的 GC 暂停(在 ESP32 上 GC 暂停几十毫秒完全可能),数据就溢出了,丢掉的部分还不一定是尾部——内部 FIFO 一般是从旧数据开始丢(对,新数据把旧数据挤出去也是有的)。

排查方法很简单,一句代码把缓冲区状态打出来:

uart = UART(1, baudrate=921600, tx=1, rx=3) # 每次收到数据后查看 print(uart.any()) # 缓冲区中待读字节数

如果any()的返回值经常出现“缓冲区满”的迹象,或者你明显感觉到数据量大的时候才丢,基本可以断定是缓冲溢出。对策有三个方向:

  1. 降低波特率,给 CPU 更多处理时间
  2. 增大软件缓冲区(如果移植版允许配置)
  3. 用中断 +readinto()主动搬运,减少数据在硬件 FIFO 里的停留时间

其中第三个方向在 PyBoard 上用定时器中断轮询串口是常用套路,ESP32 上则建议用uasyncio配合串口读事件来做。

4.2UART.read()返回None还是空字节串?

这是 MicroPython 串口编程里最容易困惑的问题。UART.read(n)的行为是:在超时时间内读到数据,返回bytes对象;如果超时时间内一个字节都没读到,返回None;如果设置了timeout为 0,那么非阻塞模式下读不到数据时也会返回None

这个设计继承自 PyBoard 的原始实现,和 CPython 标准库的read行为不一致。CPython 的read(n)在文件结束前会阻塞等待,直到读满 n 字节或遇到 EOF;MicroPython 的read(n)是“至少返回一个字节,最多返回 n 字节”,而且超时就返回。

很多人在循环里这么写:

while True: data = uart.read(1) if data: process(data) else: # data 是 None,这里容易踩坑 pass

判断if data是正确的,因为None和空字节串b''都会判断为 False。但如果你想区分“超时了”和“确实读到 0 字节”,就得显式判断:

data = uart.read(1) if data is None: print('timeout, no data') elif len(data) == 0: print('empty data (unlikely but possible)') else: process(data)

实际项目中建议统一用is None判断,不要依赖 truthiness,这样代码意图更明确。

4.3 串口调试助手常见的“乱码”是逻辑问题

提到串口就绕不开串口调试助手。嵌入式开发几乎人手一个,Windows 上用 SSCOM 或友善串口助手,macOS 上找个 OpenSerial 之类的小工具,Linux 下直接minicompicocom。乱码问题几乎是必经之路。

排查乱码的顺序应该是:

  1. 波特率对不对。收发两端不一致是最常见的原因。9600 和 115200 的差异肉眼可见,但 115200 和 115200 的微小误差(比如晶振不准)在长帧数据下也会出现偶发错位。

  2. 数据位/停止位/校验位是否匹配。大部分场景是 8N1(8 数据位、无校验、1 停止位),但有些模块默认是 8E1,改一下设置就行。

  3. 电平逻辑是否一致。TTL 电平的串口直接连 RS232 电平的设备,信号极性反转,收到的就是乱码。这时候需要一个 MAX3232 芯片做电平转换,或者用带转换的 USB 转串口模块。

  4. 接地是否共地。串口通信是异步的,收发双方必须有共同的地参考。USB 转串口模块和板子各自用独立电源时,一定要把 GND 连到一起,否则数据线只是“参考一个浮动的地”,偶尔能通、偶尔乱码、偶尔烧口。

这四条排查完,90% 的乱码问题都能定位。剩下 10% 可能是驱动问题——CH340 和 FTDI(如 FT232)是两款最常见的 USB 转串口芯片,两者都需要装驱动。CH340 在 Windows 下偶尔出现设备识别失败,多半是驱动版本问题,卸载重装新版驱动即可;FTDI 在老版本 macOS 升级系统后也经常掉驱动,需要去官网重新拉一个兼容版本。

4.4 阻塞与超时:不要让主循环卡死在read()

流设备一个天然的风险是阻塞。uart.read(100)在没有任何数据到达时会一直等到超时,这个超时时间由timeout参数决定。如果你初始化串口时忘设timeout,某些移植版默认是 -1(永久阻塞),主循环就卡死了。

一个很隐蔽的坑是:read(n)接收到 n 字节前不会提前返回,哪怕已经等了很久。这在读取不定长数据帧时尤其坑人。实测中正确的做法是配合uart.any()先查询可读字节数,再决定读多少:

while True: if uart.any() > 0: # 先把当前可读的数据全部读走 data = uart.read(uart.any()) process(data) # 每轮循环都做点别的事,避免死等 check_sensor() update_display()

这个模式的好处是主循环不会被卡死,适合做简单轮询。如果数据量很大、要求实时性高,就用uasyncio配合uart.readinto()做异步接收。MicroPython 的uasyncio对串口的支持取决于底层是否实现了非阻塞 IO,ESP32 移植版做得还可以,实际效果比裸循环轮询稳定得多。

4.5 虚拟串口:调试和自动化的好帮手

虚拟串口这个工具值得单独说一下。它不是真实硬件,而是软件模拟出来的 COM 口。Windows 上用 com0com 这类工具可以创建一对虚拟串口,一头连你的上位机程序,一头连串口调试助手——两个程序都以为自己在跟真实硬件通信,实际是在内部互通。

这在开发上位机时非常好用。你还没拿到硬件,就可以先用虚拟串口把上位机逻辑跑通:这端发什么、那端收什么、怎么解析数据帧、怎么处理黏包,完全可以在纯软件环境里验证。等硬件到手,把代码里虚拟串口号换成真实 COM 口就行。

另一个典型的自动化测试场景:用脚本通过虚拟串口模拟一个传感器设备,向单片机发数据帧,验证单片机上的解析逻辑。这样测试可以反复跑、随时跑、自动跑,比手拿一个真实传感器捅线来回试要高效得多。

5. 块设备在 MicroPython 里的实操:SD 卡与 Flash 文件系统

5.1 最简 SD 卡挂载流程

MicroPython 挂载 SD 卡的代码极其简单。以 ESP32 为例,用官方sdcard驱动模块(移植版自带或从 MicroPython 仓库拉取),加上os.mount()

from machine import Pin, SPI import os, sdcard # 初始化 SPI 接口,用于 SD 卡通信 spi = SPI(2, baudrate=20000000, polarity=0, phase=0, sck=Pin(18), mosi=Pin(23), miso=Pin(19)) cs = Pin(5, Pin.OUT) # 创建块设备对象 sd = sdcard.SDCard(spi, cs) # 格式化(第一次使用或文件系统损坏时) # os.VfsFat.mkfs(sd) # 挂载文件系统 os.mount(sd, '/sd') # 现在可以直接进行文件操作 with open('/sd/test.txt', 'w') as f: f.write('hello from MicroPython\n') print(os.listdir('/sd'))

这段代码背后的逻辑是这样的:SDCard类内部实现了readblocks/writeblocks/ioctl三个方法,所以它是一个标准块设备;os.mount(sd, '/sd')把块设备挂载到路径,MicroPython 自动在上面创建文件系统抽象。整个过程和 Linux 下mount /dev/mmcblk0 /mnt/sd是同一个思路,只是细节藏在了固件里。

5.2 块大小与文件系统选择的影响

SD 卡物理扇区大小通常是 512 字节,SPI Flash 则可能是 4096 字节。块设备驱动会通过ioctl把块大小报告给文件系统,文件系统以这个大小为单位分配和管理空间。这就是为什么同样一张 SD 卡,FAT16 和 FAT32 的簇大小不同,存储同样数量的文件,实际占用空间不同——簇太大,小文件浪费的空间就多。

MicroPython 默认支持 FAT 和 littlefs 两种文件系统。FAT 兼容性最好,SD 卡拔下来插电脑上能直接读;littlefs 是为 Flash 设计的,自带磨损均衡和掉电保护,适合日志型应用。实际项目中,需要数据被 PC 读取就选 FAT(使用os.VfsFat),纯粹给嵌入式设备自己用就选 littlefs(os.VfsLfs2):

# 格式化为 littlefs(在块设备上新建文件系统) os.VfsLfs2.mkfs(sd) os.mount(sd, '/sd', vfs=os.VfsLfs2)

选 littlefs 有一个隐藏的好处:它在 CRC 校验上更严格,掉电时不容易整个文件系统崩掉。设备插着电、存储过程中突然拔电,FAT 有可能会出现目录链断裂、文件变成 0 字节,littlefs 大概率还能恢复出旧版本的文件结构。这就是为什么 OTA 固件包、配置持久化这类要求可靠性的场景,建议走 littlefs。

5.3 自己封装一个块设备的思路

有时候现成的块设备驱动不够用。最常见的一个需求是:把板载 SPI Flash 的某个分区做成一个独立文件系统,比如存储用户配置、蓝牙配对信息、日志缓冲。这种情况下你需要自己写一个 block device 封装。

核心思路是把原有的 SPI Flash 块设备“分片”成一个独立的逻辑块设备,只允许文件系统访问特定扇区范围:

class PartitionBlockDev: def __init__(self, flash, start_block, num_blocks): self.flash = flash # 底层 SPI Flash 块设备 self.start = start_block # 起始块号 self.count = num_blocks # 总块数 def readblocks(self, block_num, buf): return self.flash.readblocks(self.start + block_num, buf) def writeblocks(self, block_num, buf): return self.flash.writeblocks(self.start + block_num, buf) def ioctl(self, cmd, arg): if cmd == 3: # IOCTL_BLOCK_COUNT return self.count elif cmd == 4: # IOCTL_BLOCK_SIZE return self.flash.ioctl(4, arg) elif cmd == 6: # IOCTL_ERASE_BLOCK return self.flash.ioctl(6, self.start + arg)

这里的优雅之处在于:底层 Flash 本身可能是一个大块设备,你通过偏移量包了一层“分区设备”,文件系统看不到它范围之外的空间,自然就不会越权写坏其他数据。这套逻辑在 Linux 上叫分区表,在 MicroPython 里你用一个类就实现了。

我自己在实际项目中就用这个方案做过一个双分区 OTA:A 区跑当前固件,B 区存放下载的新固件,启动时根据标记位决定从哪个区启动。整个过程完全复用同一个 Flash 芯片,不需要额外硬件。

6. 流设备与块设备协同:一个完整的数据采集系统设计示例

前面的内容偏原理和单项实践,这里把两者组合起来,展示一个完整的嵌入式数据采集系统的设计过程。目标场景是:设备通过串口连接一个环境传感器(比如温湿度、PM2.5),定时读取数据,再把数据追加写入 SD 卡日志文件。需要的话,通过 I2C 接口再接一个 OLED 屏幕显示实时数值。

这个系统里,传感器是流设备,SD 卡是块设备,OLED 走 I2C 总线也可以归为流设备系列。三种设备混合使用,正好看到 MicroPython 里不同 I/O 类型各司其职。

硬件清单:

  • ESP32 / STM32 开发板(以 ESP32 为例)
  • USB 转 TTL 模块 + 传感器(串口输出)
  • SPI 接口 SD 卡模块
  • I2C OLED 屏(SSD1306,128x64)

初始化代码:

from machine import UART, SPI, Pin, I2C import os, time, sdcard from ssd1306 import SSD1306_I2C # 串口连接传感器,9600 波特率 uart = UART(2, baudrate=9600, tx=17, rx=16, timeout=50) # SPI 初始化 SD 卡 spi = SPI(2, baudrate=20000000, sck=Pin(18), mosi=Pin(23), miso=Pin(19)) sd = sdcard.SDCard(spi, Pin(5)) os.mount(sd, '/sd') # I2C 初始化 OLED i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=400000) oled = SSD1306_I2C(128, 64, i2c)

读取传感器并写入文件的循环:

def read_sensor(): """从串口读取一帧传感器数据(假设帧格式:0xAA, len, data..., 0x55)""" # 等待帧头 0xAA while True: if uart.read(1) == b'\xAA': break # 读取长度字节 length = uart.read(1)[0] # 读取数据部分 payload = uart.read(length - 2) # 去掉帧头帧尾 # 读取帧尾校验(简化处理,不做校验) uart.read(1) return payload def log_to_sd(timestamp, data): """把数据追加写入 SD 卡日志""" with open('/sd/sensor.log', 'a') as f: f.write('{}: {}\n'.format(timestamp, data)) def display_on_oled(data): oled.fill(0) oled.text('Temp: {:.1f}C'.format(data['temp']), 0, 0) oled.text('Humi: {:.1f}%'.format(data['humi']), 0, 16) oled.show()

主循环:

while True: raw = read_sensor() # 解析传感器数据(示例:温度 2 字节,湿度 2 字节) temp = (raw[0] << 8 | raw[1]) / 100 humi = (raw[2] << 8 | raw[3]) / 100 data = {'temp': temp, 'humi': humi} # 显示到 OLED display_on_oled(data) # 写入 SD 卡日志 log_to_sd(time.time(), data) # 等 10 秒再读下一次 time.sleep(10)

这个系统充分展示了流设备与块设备的协同方式:read_sensor()从串口读取数据,是流设备的典型场景——数据按顺序到达,读一帧就消费一帧;log_to_sd()把数据写成文件,是块设备的典型场景——通过文件系统接口随机写入,数据持久化保存。两个环节各有自己的核心矛盾:流设备那边是“数据来了要及时取走,否则会丢”,块设备那边是“写入要可靠,掉电文件系统不崩”。实际调试时,两者的侧重点完全不同。

如果要给这个系统加一个远程上传功能——把日志通过网口或 Wi-Fi 发给服务器——网络 socket 又是流设备,数据要实时处理,不能让 TCP 接收缓冲区溢出。整个系统的数据通路就变得清晰起来了:传感器产生流数据,处理器消费并转化,块设备负责持久化,网络把数据流送到远端。每个环节选什么设备、用什么抽象,都是有内在逻辑的。

7. 固件移植相关的几个话题:为什么有些 MicroPython 版本行为不一样

使用 MicroPython 过程中,大家应该都遇到过“同一个 API,不同开发板行为不同”的情况。这跟 MicroPython 的架构有关:核心代码(包括刚才提到的os、流协议、块设备协议)是统一的,但每个移植版(ESP32、STM32、RP2040)在实现machine.UARTmachine.I2C等外设驱动时是各写各的,硬件差异直接体现在 API 行为上。

典型例子:UART.read()timeout参数语义。PyBoard 移植版严格按照参数设定超时时间,timeout=50就是最多等 50ms;ESP32 移植版早期版本的超时精度受 FreeRTOS tick 周期影响,实际超时时间会偏长一些,你设 50ms 可能实际等了 100ms 才返回。数据量大的时候,这种差异会表现为“接收数据的节奏不同”。

记住一个原则:MicroPython 是统一语言,但不是统一实现。跨平台前,先把外设行为差异的清单列出来,逐项验证,特别是时序敏感的部分。我在 ESP32 和 RP2040 之间移植过一个数据采集应用,表现良好;在 ESP32 和 STM32 之间移植时,串口中断方式和缓冲区容量差异就明显影响了接收性能,不能假定代码不变结果就不变。

7.1 USB 转串口芯片:CH340 与 FTDI 的不同表现

聊串口驱动时,必然会碰到 USB 转串口芯片的差异。CH340 和 FTDI(FT232 等)是两种最常见的方案。CH340 便宜,国内很多开发板集成它;FTDI 贵一些,但驱动兼容性和传输稳定性在 Windows/Linux/macOS 下都更成熟。

实际操作中,两者的差异主要体现在几个方面:

  1. 驱动安装:CH340 在 Windows 上通常需要从官网下载驱动,新版系统可能自动识别但版本较旧;FTDI 驱动在主流系统上基本被内置,Linux 内核直接支持,即插即用。

  2. 兼容性:某些老的调试软件对 CH340 的识别有问题,需要先装驱动再插设备;FTDI 则几乎在所有系统上都能被直接识别为标准的串口设备。

  3. 传输稳定性:在高速率下(比如 921600 甚至 2Mbps),FTDI 芯片的数据吞吐稳定性通常优于 CH340,但这在常规调试场景下感知不明显。如果你在跑大数据量的日志采集(比如每秒几十 KB),可以留意一下。

建议:开发阶段手边备一根 CH340 的线、一根 FTDI 的线,遇到“串口打不开”“识别不了”的问题时,换线是最快的排查动作——不要浪费时间纠结是驱动问题还是板子问题,换根线一下子就定位了。

8. 更深入的底层机制:中断、DMA 与缓冲区

流设备在嵌入式环境里还有一层底层机制值得拿出来单独讲,就是中断和 DMA。它们的核心目标都一样:让 CPU 从逐字节搬运数据中解脱出来

没有中断的串口接收是什么情况?CPU 每收到一个字节都要去读寄存器、存内存、判断是否一帧结束。以 115200 波特率算,一个字节大约 87 微秒,CPU 要在这个时间内完成读走和处理,否则下一个字节来了就可能覆盖。这几乎占满了 CPU 的有效时间,主循环什么都做不了。

有了硬件 FIFO 和中断,情况就不一样了:数据到了,FIFO 先接着,攒到一定数量触发一次中断,CPU 在中断服务程序里一次性搬走一批数据。ESP32 的 UART 硬件 FIFO 通常有 128 字节,加上 DMA 可以把数据直接搬运到内存缓冲区,CPU 只需要在 DMA 传输完成时收尾。这套机制在裸机开发里是用寄存器配置的,代码写起来相对繁琐:

// STM32 裸机串口 DMA 接收初始化示例(简化) HAL_UART_Receive_DMA(&huart1, rx_buffer, MAX_LEN); // DMA 传输完成中断中处理数据

而 MicroPython 把这些细节都封装掉了,你只需要调UART.read(),底层是轮询、中断还是 DMA,移植版自行决定。这对开发效率来说是巨大解放,但也带来一个问题:你无法精确控制底层行为。比如要用 DMA + 空闲中断实现“不定长数据帧接收”,MicroPython 标准库未必开放这个门。这时你有两个选择:一是用machine.UART的 IRQ 回调机制自己实现帧解析逻辑,二是直接写一个 C 扩展模块,把性能敏感的接收逻辑下沉到固件层。

以 ESP32 的视角,用 MicroPython 实现“帧接收”的常用套路是这样的:

from machine import UART from micropython import const # 定义帧格式常量 FRAME_HEAD = const(0xAA) FRAME_TAIL = const(0x55) # 用 IRQ 回调 + 累积缓冲实现不定长帧接收 buffer = bytearray() frame_ready = False def uart_handler(uart): global frame_ready if uart.any(): data = uart.read(uart.any()) buffer.extend(data) # 判断帧头帧尾 if len(buffer) >= 2 and buffer[0] == FRAME_HEAD and buffer[-1] == FRAME_TAIL: frame_ready = True uart = UART(1, baudrate=115200, tx=1, rx=3) uart.irq(handler=uart_handler, trigger=UART.IRQ_RXIDLE)

这里IRQ_RXIDLE触发条件是接收完一帧数据后短暂空闲,正好适合不定长帧的场景。这类底层用法在文档里不一定写得很细,需要自己实验验证。

8.1 DMA 与普通中断的选择逻辑

DMA 不是“越多越好”,它也有成本。DMA 需要额外的硬件控制逻辑、需要配置传输方向和数据宽度、中断处理完后需要判断传输是否正常结束。对于小数据量、低频率的串口接收(比如每秒几条 AT 指令),用中断就够了,DMA 反而增加代码复杂度。当数据量达到每秒几十 KB 以上,或者要求 CPU 在数据搬运期间完全不被占用的时候,DMA 的价值才真正体现。

块设备也一样。SD 卡通过 SPI 或 SDMMC 接口传输,SDMMC 接口本身带 DMA 能力,4-bit 模式下读一个扇区只需几十微秒的 CPU 参与时间;SPI 模式下则完全靠 CPU 控制时钟信号,数据吞吐量低一些,但接线简单,很多开发板首选 SPI。实测下来,SPI 模式挂载 SD 卡跑 littlefs 写日志,在 20MHz SPI 时钟下大约每秒能写 100~200KB,对绝大多数日志场景足够了。如果你要连续写入速度更高,就应该切换到 SDMMC 接口并开启 DMA,这会直接提升一个数量级。

9. 工程实践中的选型建议与经验总结

写了不少底层原理,最后落地到工程选型。面对一个具体的 I/O 需求,怎么决定它应该是流设备还是块设备、该用什么接口方式?

我的建议是三步:

  1. 明确数据的生命周期。数据是临时经过、用完就扔的,还是需要留存、掉电不丢的?前者走向流设备,后者走向块设备。

  2. 明确数据的存取模式。是顺序处理还是随机访问?如果要频繁修改文件的某个片段、按索引查找记录,一定是块设备 + 文件系统;如果只是数据一个接一个到达,消费掉就行,流设备就够了。

  3. 评估系统容错要求。掉电时数据可以丢吗?丢多少可以接受?流设备天然不保证掉电持久性,块设备+文件系统在掉电时也可能损坏元数据,要不要用 littlefs 这类带掉电保护的文件系统,取决于业务容忍度。

基于这三步,大多数 I/O 选型决策都能快速收敛。

在具体接口层面,也有一套经验值可以参考:短距离、低速率、简单协议用 UART;板内设备通信用 I2C(一种低速两线总线,适合传感器这类数据量不大的设备);高速大容量数据(显示屏、SD 卡、Flash)用 SPI 或者并行接口;长距离、多节点、抗干扰场景用 RS485(本质上是 UART 的物理层差分版本)。

需要提醒的是,技术选型没有“绝对正确”,只有“在这个场景下更合适”。有人非要用 I2C 接 SD 卡也不是不行,但速度会被拉低到不可用的程度;有人非要用块设备 + 文件系统的思路去管串口数据,结果就是设计出一套荒谬的分帧寻址协议。理解每种设备的底层模型和设计初衷,选型时自然就有方向感。

10. 个人实操的心得与几个容易被忽略的细节

写到最后,分享几个我在实际项目里踩过坑之后沉淀下来的体会,都是小事,但真的很影响开发效率。

第一件事,MicroPython 里os.mount的 vfs 参数很容易被忽略。很多人直接os.mount(sd, '/sd'),用的其实是默认文件系统(通常是 FAT)。如果你之前在这张 SD 卡上格式化为 littlefs,直接挂载会报错。反之亦然。所以跨系统使用 SD 卡时,先搞清楚卡上的文件系统格式,再决定挂载方式。最省事的做法是:新卡先拿到 PC 上格式化为 FAT32,以后在 MicroPython 里就不用纠结 vfs 参数了。

第二件事,time.sleep在串口接收附近要慎用。流设备的数据不等人,你sleep(100)长时间不读,缓冲区就可能溢出。如果代码逻辑里确实需要等待,用uart.irq事件来唤醒而不是盲等,对系统整体响应性提升明显。

第三件事,调试串口的时候,调试助手显示十六进制还是文本,往往决定了你能不能看清数据。和传感器通信时,数据几乎都是二进制格式,记得切到 HEX 显示,否则你会看到一堆“乱码”字符,而这并不是真乱码。这个细节看起来毫不起眼,但确实能让人白排查老半天。

第四件事,也是最后一件,MicroPython 的 REPL 串口和外设串口一定要分开。ESP32 默认把 UART0 用作 REPL(交互命令行),很多开发板把 UART0 通过 USB 连到了电脑。你在 UART0 上接传感器、同时又要用 REPL 调试,就会遇到互相抢占的问题:REPL 输出把传感器数据冲了,传感器数据把 REPL 提示符刷掉。规范做法是:REPL 走 USB 虚拟串口,外设数据走硬件 UART1/2。这几乎是从 MicroPython 转到项目开发后的第一课。

流设备和块设备的差异不是一个需要死记硬背的概念,而是理解整个嵌入式 I/O 系统的一条主线。你拿到一个新的外设模块时,先问一句“它是流设备还是块设备”,后面的驱动接口选什么、缓冲怎么做、数据怎么存取,自然就有答案了。这也是我这篇文章最想传达的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询