ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

嵌入式通信入门:从GPIO到UART/I2C/SPI的MicroPython实战

嵌入式通信入门:从GPIO到UART/I2C/SPI的MicroPython实战

1. 从标题拆解嵌入式通信的完整学习路径

“Reading Pinouts, Toggling GPIOs, and Talking Serial in Mic”这个标题,第一次看到的时候我就觉得它特别接地气。它没有一上来就抛什么高深概念,而是把嵌入式开发最核心的三件事按顺序摆了出来:先看懂引脚定义,再学会控制引脚电平,最后让设备之间能通过串行协议对话。这三步走下来,基本上就把微控制器和外部世界打交道的底层逻辑摸透了。

我做了十多年嵌入式项目,带过不少新人,发现一个很普遍的现象:很多人拿到一块开发板,第一反应是找现成的例程烧进去跑,跑通了就觉得自己会了。但一旦换个传感器、换块屏幕,或者通信出了问题,就完全不知道从哪里下手排查。根子就在于对GPIO的工作模式、UART/I2C/SPI的时序逻辑、以及引脚复用关系没有建立起清晰的认知。这个标题所指向的内容,恰恰是解决这个问题的钥匙。

这篇文章面向的读者很明确:刚接触MicroPython和ESP32系列开发板的朋友,或者从Arduino转过来想深入理解底层通信的开发者。我会围绕GPIO、UART、I2C、SPI这几个核心关键词,把引脚阅读、电平翻转、串行通信这三件事从头到尾讲清楚。不只是告诉你调哪个函数,更要说明白为什么这么调、参数怎么算、出了问题怎么查。读完之后,你应该能独立完成一个多设备通信的小项目,遇到总线不通、数据错乱这类问题也有自己的排查思路。

2. 核心概念拆解:GPIO、UART、I2C、SPI到底在做什么

2.1 GPIO的八种工作模式与选型逻辑

GPIO是General Purpose Input Output的缩写,翻译过来就是通用输入输出。它是微控制器上最基础的外设,没有之一。你可以把它理解成一扇门,门可以往外推(输出),也可以往里拉(输入),门上还可以装不同的锁(上拉、下拉、开漏等)。STM32的GPIO有八种工作模式,ESP32虽然寄存器设计不同,但逻辑上是相通的。

先把这八种模式列出来,然后我逐个解释什么时候用哪个:

模式英文标识典型场景
浮空输入Input Floating外部已有上下拉电阻的信号输入
上拉输入Input Pull-up按键检测(按下接地)
下拉输入Input Pull-down按键检测(按下接VCC)
模拟输入AnalogADC采样、低功耗唤醒
开漏输出Output Open-drainI2C总线、电平转换
推挽输出Output Push-pullLED驱动、普通数字输出
复用开漏AF Open-drainI2C外设引脚
复用推挽AF Push-pullSPI、UART外设引脚

选型的核心逻辑其实就两条:第一,这个引脚是用来读信号还是写信号?读就选输入类,写就选输出类。第二,外部电路有没有上下拉?如果有,就选浮空;如果没有,而你又需要一个确定的默认电平,就选内部上下拉。

我见过太多人在这里踩坑。比如用ESP32读一个机械按键,按键一端接GPIO,另一端接GND。如果你把GPIO配成浮空输入,按键没按下的时候引脚电平是不确定的,读出来的值会随机跳变。正确做法是配成上拉输入,这样没按下时读到高电平,按下时读到低电平,干净利落。

再比如I2C的SDA和SDA线,必须配成开漏输出加上拉电阻。为什么?因为I2C是多主多从的总线结构,任何设备都可以拉低总线,但不能推高总线——推高会导致总线冲突时两个设备一个输出高一个输出低,直接短路。开漏输出的特点是只能主动拉低,释放时靠外部上拉电阻把电平拉高,这样就实现了“线与”逻辑,谁都可以拉低,但没人会强行推高。

注意:ESP32的GPIO12在启动时会影响闪存电压,GPIO0影响启动模式,GPIO2和GPIO15在启动时有特殊要求。选引脚的时候一定要先看开发板的引脚图,避开这些启动敏感引脚,否则会出现上电不启动或者随机重启的问题。

2.2 UART通信协议的核心要点

UART是全双工异步串行通信,全双工意味着收发可以同时进行,异步意味着没有时钟线,靠双方约定的波特率来同步。它只需要两根线:TX(发送)和RX(接收)。连接的时候要交叉接,A的TX接B的RX,A的RX接B的TX,这是最基本的常识,但我确实见过有人直连然后纳闷为什么收不到数据。

UART的数据帧格式是这样的:空闲时线路为高电平,起始位是一个比特时间的低电平,然后是5到9个数据位(通常是8位),可选的校验位,最后是1到2个停止位(高电平)。接收方在起始位的下降沿开始采样,按照波特率的节奏逐位读取。

波特率的计算是很多人关心的问题。假设波特率是115200,意味着每秒传输115200个比特,每个比特的时间是1/115200秒,约等于8.68微秒。如果时钟源是80MHz,那么每个比特需要80,000,000/115200≈694个时钟周期。实际的分频系数计算还要考虑过采样率,ESP32的UART默认用16倍过采样,所以分频系数是694/16≈43.4,取整后会有微小误差,但只要误差在2%以内,通信就能正常进行。

UART最常用的场景就是调试输出和模块通信。比如你烧录MicroPython固件后,通过USB转UART芯片连接电脑,在串口终端里看到的REPL交互界面,走的就是UART协议。再比如GPS模块、蓝牙模块、某些传感器模块,都是通过UART输出数据的。

2.3 I2C总线的时序与寻址机制

I2C是半双工同步串行总线,只需要两根线:SCL(时钟)和SDA(数据)。它的特点是支持多主多从,每个从设备有唯一的7位地址(也有10位地址的扩展格式,但用得少)。主设备发起通信,通过地址选中从设备,然后进行读写操作。

I2C的时序有几个关键点必须掌握。起始条件:SCL为高时,SDA从高变低。停止条件:SCL为高时,SDA从低变高。数据有效性:SCL为高期间,SDA必须保持稳定,数据的变化只能发生在SCL为低的期间。应答位:每传输完8个比特,接收方在第9个时钟周期把SDA拉低表示ACK,保持高表示NACK。

读写流程是这样的:主设备发送起始条件,然后发送7位从机地址加1位读写标志(0表示写,1表示读)。从机如果存在且地址匹配,会回复ACK。接着主设备发送寄存器地址(如果是写操作),然后发送数据字节,每字节都等待ACK。读操作稍微复杂一点:先写寄存器地址(告诉从机要读哪个寄存器),然后发送重复起始条件,再发送地址加读标志,从机回复ACK后开始输出数据,主设备每接收一个字节要回复ACK,最后一个字节回复NACK表示不再继续读,最后发送停止条件。

我调试I2C的时候最常遇到的问题就是设备不响应。排查步骤一般是这样的:先用逻辑分析仪或者示波器看SCL和SDA有没有波形,如果没有波形,说明主设备根本没发起通信,检查代码里有没有调用初始化、引脚配置对不对。如果有波形但从机不回复ACK,检查从机地址对不对——很多传感器的数据手册上写的是8位地址,但实际传输时用的是7位地址,需要右移一位。还要检查上拉电阻有没有接,I2C总线必须接上拉电阻,典型值是4.7kΩ,阻值太大会导致上升沿变缓,阻值太小会增加功耗。

2.4 SPI协议的高速全双工特性

SPI是高速全双工同步串行总线,需要四根线:SCK(时钟)、MOSI(主出从入)、MISO(主入从出)、CS(片选)。它的速度比I2C快得多,ESP32的SPI时钟可以跑到80MHz,而I2C标准模式只有100kHz,快速模式400kHz,高速模式3.4MHz。SPI没有地址机制,靠片选线来选中从设备,每个从设备需要一根独立的CS线。

SPI有四种工作模式,由CPOL(时钟极性)和CPHA(时钟相位)组合而成。CPOL决定空闲时SCK是高还是低,CPHA决定数据在SCK的哪个边沿采样。模式0是CPOL=0、CPHA=0,空闲低电平,上升沿采样;模式1是CPOL=0、CPHA=1,空闲低电平,下降沿采样;模式2是CPOL=1、CPHA=0,空闲高电平,下降沿采样;模式3是CPOL=1、CPHA=1,空闲高电平,上升沿采样。

选哪个模式取决于从设备的数据手册。比如常见的W25Q系列SPI Flash支持模式0和模式3,SD卡在SPI模式下用模式0,很多ADC芯片用模式1或模式3。如果模式选错了,读出来的数据会错位,表现为读到的值总是实际值的两倍或者一半,或者完全乱码。

SPI的片选有硬件片选和软件片选两种方式。硬件片选是外设自动控制CS引脚,在数据传输前后自动拉低和拉高。软件片选是用普通GPIO手动控制,灵活性更高,适合需要在一帧数据中间切换片选的场景。MicroPython的SPI对象默认不控制CS,需要你自己用Pin对象来控制。

3. MicroPython环境下的实操全流程

3.1 开发板选型与固件烧录

ESP32-S3是目前玩MicroPython比较合适的选择,双核240MHz,自带USB OTG,512KB SRAM,8MB PSRAM(视模组而定),价格也便宜。我手头用的是ESP32-S3-DevKitC-1,板载USB转串口芯片,插上电脑就能识别。

固件烧录的步骤不复杂,但有几个细节容易出问题。首先去MicroPython官网下载对应板子的固件文件,注意要选对型号,ESP32-S3的固件和ESP32的不通用。然后用esptool擦除闪存:

esptool.py --chip esp32s3 --port /dev/ttyUSB0 erase_flash

烧录固件:

esptool.py --chip esp32s3 --port /dev/ttyUSB0 --baud 460800 write_flash -z 0x0 ESP32_S3_GENERIC-20240602-v1.23.0.bin

烧录完成后,用串口终端工具连接,波特率115200,就能看到MicroPython的REPL界面了。Windows下可以用PuTTY或者MobaXterm,Linux和macOS直接用screen或者minicom。

实操心得:如果烧录后看不到REPL,先检查波特率是不是115200,再检查USB线是不是只供电不传数据的那种。我遇到过好几次因为用了劣质USB线导致只能供电不能通信的情况,换根线就好了。

3.2 GPIO读写与LED闪烁实战

进入REPL后,先来点最简单的——点亮一颗LED。ESP32-S3-DevKitC-1板载的RGB LED接在GPIO48上,但那个是WS2812可寻址LED,需要专用驱动。为了演示普通GPIO操作,我外接一个LED到GPIO4,串联一个220Ω限流电阻到GND。

from machine import Pin import time led = Pin(4, Pin.OUT) while True: led.value(1) time.sleep(0.5) led.value(0) time.sleep(0.5)

这段代码做了三件事:把GPIO4配置为推挽输出模式,然后循环设置高电平和低电平,中间延时500毫秒。推挽输出的驱动能力比较强,ESP32的GPIO可以输出约40mA的电流,驱动一颗LED绰绰有余。

接下来试试按键输入。找一个轻触按键,一端接GPIO5,另一端接GND。因为按键按下时把引脚拉到GND,所以GPIO要配置成上拉输入:

from machine import Pin import time button = Pin(5, Pin.IN, Pin.PULL_UP) led = Pin(4, Pin.OUT) while True: if button.value() == 0: # 按下时读到低电平 led.value(1) else: led.value(0) time.sleep(0.01)

这里有个细节值得说:为什么加10毫秒的延时?因为机械按键在按下和释放的瞬间会有抖动,电平会在高低之间快速跳变几次,如果不加延时,程序可能会把一次按下识别成多次。10毫秒的延时可以跳过抖动期,这叫软件消抖。更优雅的做法是用中断加定时器消抖,但对于入门来说,轮询加延时已经够用了。

3.3 UART串口通信配置与调试

MicroPython的UART使用起来很直接。ESP32-S3有两个硬件UART,UART0默认用于REPL,UART1可以自由使用。我接一个USB转UART模块到GPIO17(TX)和GPIO18(RX),和电脑上的另一个串口终端通信。

from machine import UART, Pin uart = UART(1, baudrate=9600, tx=Pin(17), rx=Pin(18), bits=8, parity=None, stop=1) # 发送数据 uart.write('Hello from ESP32!\n') # 接收数据 if uart.any(): data = uart.read() print('Received:', data)

参数说明:baudrate是波特率,双方必须一致;bits是数据位,通常8位;parity是校验位,None表示无校验;stop是停止位,通常1位。这几个参数加起来就是常说的“8N1”格式。

如果要接收不定长数据,可以用uart.read()配合uart.any()来判断缓冲区有没有数据。但更可靠的方式是用中断或者超时机制。MicroPython的UART支持timeout参数,设置读取超时时间,避免程序卡死在读取操作上。

注意:ESP32-S3的UART引脚是可以映射到任意GPIO的,这叫IO矩阵功能。但有些引脚有默认的UART功能,用默认引脚性能更稳定。如果非要用其他引脚,在初始化时指定tx和rx参数即可。

3.4 I2C设备扫描与传感器读取

I2C的初始化需要指定SCL和SDA引脚,以及频率。ESP32-S3默认的I2C引脚是GPIO8(SDA)和GPIO9(SCL),但我习惯用GPIO21和GPIO22,这两个引脚在ESP32系列上比较通用。

from machine import I2C, Pin i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=400000) # 扫描总线上的设备 devices = i2c.scan() print('Found devices:', [hex(d) for d in devices])

i2c.scan()会遍历所有可能的7位地址,发送起始条件和地址字节,如果收到ACK就认为该地址有设备。扫描结果用十六进制打印出来,比如['0x76']表示地址0x76有一个设备,通常是BME280环境传感器。

读取BME280的数据需要先写寄存器地址,再读数据:

# BME280的I2C地址是0x76或0x77 BME280_ADDR = 0x76 # 读取芯片ID寄存器(地址0xD0) chip_id = i2c.readfrom_mem(BME280_ADDR, 0xD0, 1) print('Chip ID:', hex(chip_id[0])) # 应该输出0x60 # 读取温度数据(简化版,实际需要补偿计算) data = i2c.readfrom_mem(BME280_ADDR, 0xFA, 3) raw_temp = (data[0] << 12) | (data[1] << 4) | (data[2] >> 4) print('Raw temperature:', raw_temp)

readfrom_mem是MicroPython提供的便捷方法,封装了“写寄存器地址+重复起始+读数据”的完整流程。如果手动实现的话,需要先writeto发送寄存器地址,再readfrom读取数据。

3.5 SPI驱动显示屏或Flash

SPI的初始化参数比I2C多一些,需要指定波特率、极性、相位、数据位和引脚:

from machine import SPI, Pin spi = SPI(1, baudrate=10000000, polarity=0, phase=0, sck=Pin(12), mosi=Pin(11), miso=Pin(13)) cs = Pin(10, Pin.OUT) cs.value(1) # 默认拉高,不选中 # 向SPI Flash发送读ID命令(0x9F) cs.value(0) spi.write(b'\x9F') response = spi.read(3) cs.value(1) print('Flash ID:', [hex(b) for b in response])

这里polarity=0、phase=0对应SPI模式0。W25Q系列Flash支持模式0和模式3,用模式0没问题。读ID命令0x9F会返回三个字节:制造商ID、存储类型、容量。比如Winbond的W25Q128会返回0xEF 0x40 0x18。

如果要驱动SPI接口的TFT显示屏,比如ST7735或ILI9341,除了SPI本身,还需要控制DC(数据/命令选择)和RST(复位)引脚。DC引脚为低时表示发送的是命令,为高时表示发送的是数据。初始化流程一般是:硬件复位、发送初始化命令序列、设置显示区域、开始写入像素数据。

4. 常见问题排查与避坑指南

4.1 GPIO不响应或电平异常

最常见的原因是引脚配置错了模式。比如你想输出高电平驱动LED,但配成了开漏输出,又没有接上拉电阻,那引脚永远输出不了高电平,LED自然不亮。还有一种情况是引脚被其他外设占用了,比如你用了UART1的默认引脚GPIO17和GPIO18,然后又想把GPIO17当普通输出用,那肯定冲突。

排查方法很简单:先用万用表量引脚电压,输出高时应该是3.3V左右,输出低时应该是0V。如果电压不对,检查模式配置。如果电压对但外设不工作,检查外部电路连接。

ESP32还有一个特殊问题:GPIO6到GPIO11通常连接内部SPI Flash,不建议用作普通GPIO。虽然ESP32-S3的IO矩阵允许映射,但操作不当会导致程序崩溃。我一般避开这些引脚。

4.2 UART乱码或收不到数据

乱码九成以上是波特率不匹配。发送方和接收方的波特率必须完全一致,哪怕差一点点,累积误差也会导致采样错位。比如发送方115200,接收方115200,但发送方的时钟源精度不够,实际波特率是114000,那接收方在数据帧的后半部分就会采样错误。

其次是接线问题。TX和RX必须交叉接,TX接RX,RX接TX。如果直连TX到TX,双方都在发送,没人接收,自然收不到数据。还有GND必须共地,不共地的话电平参考不一致,通信也会失败。

数据位、校验位、停止位的配置也要匹配。如果发送方用8N1,接收方用8E1,那校验位的位置会错位,导致数据解析错误。

4.3 I2C总线锁死或设备不响应

I2C总线锁死是一个经典问题。现象是SCL被某个设备一直拉低,主设备无法发起新的通信。原因通常是在通信过程中主设备复位了,但从设备还在等待时钟继续,把SDA拉低不放。解决方法是在初始化I2C之前,手动发送9个时钟脉冲,让从设备完成当前字节的传输并释放总线。

# I2C总线恢复:手动翻转SCL 9次 scl = Pin(22, Pin.OUT) sda = Pin(21, Pin.IN) for _ in range(9): scl.value(0) time.sleep_us(10) scl.value(1) time.sleep_us(10) # 然后发送停止条件 sda.value(0) time.sleep_us(10) scl.value(1) time.sleep_us(10) sda.value(1)

设备不响应还有可能是地址错了。很多传感器的数据手册给出的是8位地址,比如0xEC,但I2C实际传输的是7位地址,需要右移一位变成0x76。MicroPython的i2c.scan()返回的是7位地址,可以直接用。

上拉电阻也是常见问题。I2C总线必须接上拉电阻,典型值4.7kΩ。有些模块板载了上拉电阻,有些没有。如果没接,总线空闲时电平不确定,通信会随机失败。

4.4 SPI数据错位或全为0xFF

SPI读出来全是0xFF,通常意味着MISO线没有正确连接,或者从设备没有被选中。检查CS引脚有没有拉低,MISO有没有接到从设备的MISO。如果读出来的数据错位,比如应该是0x12 0x34,读出来是0x24 0x68,那说明采样边沿错了,需要切换CPOL和CPHA。

SPI的时钟频率也不能太高。虽然ESP32的SPI可以跑到80MHz,但很多从设备支持不了这么高的速度。W25Q Flash通常支持50MHz,SD卡在SPI模式下建议不超过25MHz,一些低速ADC可能只支持10MHz。如果通信不稳定,先把频率降下来试试。

问题现象可能原因排查步骤
GPIO输出无变化模式配置错误检查Pin.OUT/Open-drain设置
UART乱码波特率不匹配确认双方波特率一致
I2C扫描不到设备上拉电阻缺失测量SCL/SDA空闲电平
SPI读全0xFFMISO未连接检查接线和CS信号
设备随机重启引脚冲突避开GPIO6-11和启动敏感引脚

避坑技巧:每次接线前先断电,接好后再上电。热插拔I2C或SPI设备容易导致总线锁死或者引脚损坏。我烧过好几个传感器就是因为带电插拔。

5. 从单点实验到多设备协同的进阶思路

把GPIO、UART、I2C、SPI都跑通之后,下一步就是让它们协同工作。我最近做的一个小项目是环境监测节点:BME280通过I2C读取温湿度和气压,W25Q128通过SPI存储历史数据,GPS模块通过UART输出位置信息,状态指示灯用GPIO控制。四个外设同时工作,互不干扰。

这里的关键是引脚分配要提前规划好,避免冲突。我的分配方案是:I2C用GPIO21/22,SPI用GPIO10-13,UART1用GPIO17/18,LED用GPIO4,按键用GPIO5。每个外设的引脚都错开,而且避开了启动敏感引脚。

MicroPython的REPL在调试多设备时非常方便,可以逐条命令测试每个外设是否正常,确认后再写进脚本。我习惯先在REPL里把初始化代码跑一遍,用scan()、readfrom_mem()、write()这些方法验证硬件连接,然后再组织成完整的程序。

性能方面,ESP32-S3的双核可以一个核跑通信任务,另一个核跑数据处理。MicroPython的_thread模块支持多线程,但GIL的存在使得多线程对计算密集型任务帮助不大。更实用的做法是用uasyncio做异步IO,把UART接收、I2C轮询、SPI写入这些IO操作并发起来,避免阻塞等待。

内存管理也要注意。MicroPython的堆空间有限,频繁创建大对象会触发垃圾回收,导致通信时序抖动。我的经验是提前分配好缓冲区,用bytearray而不是bytes,避免在中断里做内存分配。

最后分享一个调试技巧:用逻辑分析仪抓时序。I2C和SPI的问题,看波形比看代码快得多。一个几十块钱的USB逻辑分析仪配合开源软件,能解码I2C、SPI、UART协议,直接告诉你哪个字节错了、哪个ACK丢了。我调试BME280的时候,就是靠逻辑分析仪发现重复起始条件的时序不对,调整了readfrom_mem的调用方式才解决。

这套东西说到底就是熟能生巧。先把GPIO点灯和按键读明白,再把UART调通,然后I2C扫设备,最后SPI读写数据。每一步都亲手做一遍,遇到问题用逻辑分析仪和万用表查,比看十篇文章都管用。

返回列表