
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.UART、os、block 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.UART、machine.I2C等os挂载、littlefs、fatfs这张表是全文的主干。你记住一个核心判断标准能不能按地址随机访问。能就是块设备不能就是流设备。其他所有差异几乎都可以从这个标准推导出来。3. MicroPython 对两类设备的抽象与实现3.1 流设备在 MicroPython 中的接口体系MicroPython 对流设备的抽象继承自 Python 标准库的IOBase核心就是read()、write()、readinto()这几个方法。任何实现了这套方法的对象都可以被称为“流对象”不管底层是串口、I2C 总线还是 SPI 总线。以串口为例ESP32 上初始化一个 UART 并读写实际代码长这样from machine import UART, Pin # 初始化 UART0波特率 115200TX 用 GPIO1RX 用 GPIO3 uart UART(1, baudrate115200, txPin(1), rxPin(3), timeout100) # 发送一帧数据 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, baudrate9600) as uart: uart.write(bAT\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): # 设备控制命令获取块数量、块大小、擦除块等 passioctl是块设备协议的“万能接口”。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.init的read_buf_lenESP32 移植版对软件缓冲区的暴露程度不同。假设总有效缓冲区是 256 字节波特率 921600每字节 10 bit8 数据位 1 起始位 1 停止位意味着每秒能收 92160 字节缓冲区 256 字节大约能扛 2.8 毫秒。如果 MicroPython 的垃圾回收在这 2.8 毫秒内开了一次大的 GC 暂停在 ESP32 上 GC 暂停几十毫秒完全可能数据就溢出了丢掉的部分还不一定是尾部——内部 FIFO 一般是从旧数据开始丢对新数据把旧数据挤出去也是有的。排查方法很简单一句代码把缓冲区状态打出来uart UART(1, baudrate921600, tx1, rx3) # 每次收到数据后查看 print(uart.any()) # 缓冲区中待读字节数如果any()的返回值经常出现“缓冲区满”的迹象或者你明显感觉到数据量大的时候才丢基本可以断定是缓冲溢出。对策有三个方向降低波特率给 CPU 更多处理时间增大软件缓冲区如果移植版允许配置用中断 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 字节或遇到 EOFMicroPython 的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 下直接minicom或picocom。乱码问题几乎是必经之路。排查乱码的顺序应该是波特率对不对。收发两端不一致是最常见的原因。9600 和 115200 的差异肉眼可见但 115200 和 115200 的微小误差比如晶振不准在长帧数据下也会出现偶发错位。数据位/停止位/校验位是否匹配。大部分场景是 8N18 数据位、无校验、1 停止位但有些模块默认是 8E1改一下设置就行。电平逻辑是否一致。TTL 电平的串口直接连 RS232 电平的设备信号极性反转收到的就是乱码。这时候需要一个 MAX3232 芯片做电平转换或者用带转换的 USB 转串口模块。接地是否共地。串口通信是异步的收发双方必须有共同的地参考。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对串口的支持取决于底层是否实现了非阻塞 IOESP32 移植版做得还可以实际效果比裸循环轮询稳定得多。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, baudrate20000000, polarity0, phase0, sckPin(18), mosiPin(23), misoPin(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纯粹给嵌入式设备自己用就选 littlefsos.VfsLfs2# 格式化为 littlefs在块设备上新建文件系统 os.VfsLfs2.mkfs(sd) os.mount(sd, /sd, vfsos.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 里你用一个类就实现了。我自己在实际项目中就用这个方案做过一个双分区 OTAA 区跑当前固件B 区存放下载的新固件启动时根据标记位决定从哪个区启动。整个过程完全复用同一个 Flash 芯片不需要额外硬件。6. 流设备与块设备协同一个完整的数据采集系统设计示例前面的内容偏原理和单项实践这里把两者组合起来展示一个完整的嵌入式数据采集系统的设计过程。目标场景是设备通过串口连接一个环境传感器比如温湿度、PM2.5定时读取数据再把数据追加写入 SD 卡日志文件。需要的话通过 I2C 接口再接一个 OLED 屏幕显示实时数值。这个系统里传感器是流设备SD 卡是块设备OLED 走 I2C 总线也可以归为流设备系列。三种设备混合使用正好看到 MicroPython 里不同 I/O 类型各司其职。硬件清单ESP32 / STM32 开发板以 ESP32 为例USB 转 TTL 模块 传感器串口输出SPI 接口 SD 卡模块I2C OLED 屏SSD1306128x64初始化代码from machine import UART, SPI, Pin, I2C import os, time, sdcard from ssd1306 import SSD1306_I2C # 串口连接传感器9600 波特率 uart UART(2, baudrate9600, tx17, rx16, timeout50) # SPI 初始化 SD 卡 spi SPI(2, baudrate20000000, sckPin(18), mosiPin(23), misoPin(19)) sd sdcard.SDCard(spi, Pin(5)) os.mount(sd, /sd) # I2C 初始化 OLED i2c I2C(0, sclPin(22), sdaPin(21), freq400000) 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.UART、machine.I2C等外设驱动时是各写各的硬件差异直接体现在 API 行为上。典型例子UART.read()的timeout参数语义。PyBoard 移植版严格按照参数设定超时时间timeout50就是最多等 50msESP32 移植版早期版本的超时精度受 FreeRTOS tick 周期影响实际超时时间会偏长一些你设 50ms 可能实际等了 100ms 才返回。数据量大的时候这种差异会表现为“接收数据的节奏不同”。记住一个原则MicroPython 是统一语言但不是统一实现。跨平台前先把外设行为差异的清单列出来逐项验证特别是时序敏感的部分。我在 ESP32 和 RP2040 之间移植过一个数据采集应用表现良好在 ESP32 和 STM32 之间移植时串口中断方式和缓冲区容量差异就明显影响了接收性能不能假定代码不变结果就不变。7.1 USB 转串口芯片CH340 与 FTDI 的不同表现聊串口驱动时必然会碰到 USB 转串口芯片的差异。CH340 和 FTDIFT232 等是两种最常见的方案。CH340 便宜国内很多开发板集成它FTDI 贵一些但驱动兼容性和传输稳定性在 Windows/Linux/macOS 下都更成熟。实际操作中两者的差异主要体现在几个方面驱动安装CH340 在 Windows 上通常需要从官网下载驱动新版系统可能自动识别但版本较旧FTDI 驱动在主流系统上基本被内置Linux 内核直接支持即插即用。兼容性某些老的调试软件对 CH340 的识别有问题需要先装驱动再插设备FTDI 则几乎在所有系统上都能被直接识别为标准的串口设备。传输稳定性在高速率下比如 921600 甚至 2MbpsFTDI 芯片的数据吞吐稳定性通常优于 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, baudrate115200, tx1, rx3) uart.irq(handleruart_handler, triggerUART.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 需求怎么决定它应该是流设备还是块设备、该用什么接口方式我的建议是三步明确数据的生命周期。数据是临时经过、用完就扔的还是需要留存、掉电不丢的前者走向流设备后者走向块设备。明确数据的存取模式。是顺序处理还是随机访问如果要频繁修改文件的某个片段、按索引查找记录一定是块设备 文件系统如果只是数据一个接一个到达消费掉就行流设备就够了。评估系统容错要求。掉电时数据可以丢吗丢多少可以接受流设备天然不保证掉电持久性块设备文件系统在掉电时也可能损坏元数据要不要用 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 系统的一条主线。你拿到一个新的外设模块时先问一句“它是流设备还是块设备”后面的驱动接口选什么、缓冲怎么做、数据怎么存取自然就有答案了。这也是我这篇文章最想传达的东西。