树莓派Pico MicroPython HTTP客户端实战:urequests原理、踩坑与舵机控制联动
如果你玩过树莓派Pico应该对MicroPython里那个迷你的HTTP客户端库urequests不陌生。我第一次在Pico上做数据上报项目时第一件事就是研究怎么用这个库实现一个稳定、可复现的HTTP客户端。说实话当时以为和电脑上Python的requests差不多结果一写才发现坑比想象中多内存不够、超时无响应、证书校验失败、中文编码乱码每一个都能耗掉半天时间。这篇文章我就把自己验证过的方案、踩过的坑、以及底层原理一起整理出来聊透Pico上的HTTP客户端到底该怎么写。本文适合正在用树莓派Pico做IoT数据采集、远程控制、API调用的小伙抄作业也适合刚接触MicroPython、想弄明白嵌入式端HTTP请求原理的入门玩家。会用代码和实际操作说话除了告诉你“怎么调”更会解释“为什么必须这么调”。1. 为什么Pico上要选urequests1.1 urequests是什么它和requests差在哪urequests是MicroPython官方提供的一个极简HTTP客户端库可以理解成Python标准库requests在嵌入式环境下的精简移植版。它保留了requests最常用的接口get、post、put、delete、head、request以及响应的status_code、text、json()等方法但内部实现完全是围绕MicroPython的运行环境重新设计的。之所以不能直接使用标准Python的requests核心原因是Pico这块芯片的资源约束。树莓派Pico的RP2040只有264KB RAM而MicroPython运行时要占用一部分内存留给用户堆的空间通常只有100KB左右。完整版requests库光是导入就要占几十KB再加上SSL解析、连接池管理、cookie持久化这些重量级功能分分钟触发MemoryError。urequests只保留HTTP请求最核心的逻辑代码量小内存占用低能跑在资源极其受限的单片机上。我在实际测试中还发现一个容易被忽略的差异urequests默认不会对HTTPS证书做严格校验而requests在标准环境下会使用系统证书库。这既是嵌入式设计的妥协也是一个安全隐患。后面讲SSL问题时我会把细节展开这里先记住一个结论在Pico上做公网HTTPS请求证书问题一定要单独处理。1.2 硬件与固件前提不是所有Pico都能联网先说一个很多人一开始就搞错的问题普通树莓派PicoRP2040本身没有无线网卡它是不能直接连WiFi的。真正能直接联网的是Pico W它额外集成了英飞凌CYW43439无线芯片支持2.4GHz WiFi。如果你手里是普通Pico想走HTTP客户端这条路通常有两条替代方案一是外接ESP-01这类串口WiFi模块通过AT指令转发TCP数据二是使用WIZnet W5500等以太网扩展板MicroPython有wiznet5k驱动支持有线网络。这两种方案我在项目里都试过稳定性尚可但配置复杂度会高不少。今天这篇文章以Pico W为基准展开普通Pico加模块的思路是相通的只是网络层初始化方式不同。这一点直接关系到你后面写网络代码时的第一个选择到底是直接用network.WLAN还是先跑通AT指令。如果用了普通Pico却没有无线模块就算把urequests代码写好也无法做任何实际请求。1.3 MicroPython固件版本决定你踩多少坑我见过太多人卡在“import urequests失败”上最后发现是固件版本太老或者定制固件里根本没预装这个模块。MicroPython官方发布的Pico W固件通常默认包含urequests但第三方定制固件、精简固件、或者较老的稳定版本不一定带。我习惯的检查方式是在REPL里执行import urequests print(urequests.__name__)如果提示ImportError不要急着怀疑自己先确认固件版本和模块列表。MicroPython官方在文档中提供了modules查询方法import sys print(sys.version) print(sys.implementation) help(modules)如果确定固件缺少urequests最简单的办法是去官方GitHub仓库的micropython/lib/urequests.py目录下找到源码通过Thonny的文件管理功能上传到Pico的/lib/urequests.py位置之后就能正常导入了。这个手动补库的方法是我最早期的保底方案至今仍在一些测试环境里用。2. 烧录固件与WiFi联网准备2.1 固件选择和烧录细节进入Pico W的HTTP客户端开发前先把基础环境搭好。推荐直接下载MicroPython官方针对Raspberry Pi Pico W的固件文件名一般为rp2-pico-w-xxxxxx.uf2。注意别下载成不带w的版本否则固件里没有WiFi驱动。烧录方式很多人已经熟悉按住Pico W上的BOOTSEL按钮再用USB线连接电脑此时会弹出一个名为RPI-RP2的U盘将uf2固件文件直接拖入即可。拖完之后设备会自动重启并出现一个串口设备Thonny等IDE就能识别到了。这里有一个实操建议烧完固件后用Thonny的“配置解释器”确认MicroPython版本号。因为后续urequests的timeout参数、ssl模块行为、json()实现都跟版本相关。我在1.20.0版本上测过timeout可以正常工作但更早的版本对timeout的支持就很有限需要自己用socket.setblocking和select实现超时很麻烦。看到版本低于1.19建议直接升级固件。2.2 WLAN连接和基础网络测试Pico W连接WiFi的代码比较固定核心是MicroPython内置的network模块import network import time wlan network.WLAN(network.STA_IF) wlan.active(True) if not wlan.isconnected(): print(connecting...) wlan.connect(你的SSID, 你的密码) for _ in range(20): if wlan.isconnected(): break time.sleep(0.5) print(connected:, wlan.ifconfig())这段代码我几乎每次新建工程都会复用。几个关键点说明一下wlan.active(True)必须放在connect()之前否则连接不会生效。用循环轮询isconnected()而不是无限等待这样能避免设备启动后卡死在网络初始化的状态里。ifconfig()返回的元组里第一个元素就是IP地址可以用来快速确认DHCP是否成功。实际项目里WiFi断线重连是绕不开的问题。MicroPython的WLAN没有自动重连机制路由器重启、信号波动都会让连接静默断开。我后来养成一个习惯每次发起HTTP请求前先检查wlan.isconnected()如果断开就重新走一遍连接流程。这个防御性检查看起来简单却能把很多偶发故障消灭在请求之前。3. urequests核心方法与参数3.1 支持的方法和基础用法urequests的方法集虽然精简但覆盖了日常开发大多数场景。最常用的有import urequests # GET resp urequests.get(http://httpbin.org/get) print(resp.status_code) print(resp.text) # POST resp urequests.post(http://httpbin.org/post, datahello) # POST JSON resp urequests.post(http://httpbin.org/post, json{key: value}) # PUT resp urequests.put(http://httpbin.org/put, dataupdate) # DELETE resp urequests.delete(http://httpbin.org/delete)从代码量来看这些调用方式几乎和电脑上的requests完全一样这也是urequests最大的优势降低了从原型到嵌入式设备的迁移成本。我可以直接把一个Python脚本里的API请求部分复制到Pico上只要把超时、内存这些参数重新设计一下基本能跑。不过底层差别很大。requests会帮你维护连接池、自动处理重定向、管理cookie而urequests每次请求都是现场新建socket、发请求、收响应、立即关闭没有任何连接复用。也就是说频繁短请求时TCP握手开销会非常明显。如果同一个服务端你需要连续发几十条请求建议用我后面的“长连接方案说明”里讲到的底层socket保持方式或者做简单的请求合并。3.2 关键参数解析与坑点先看一个带完整参数的例子import urequests headers { Content-Type: application/json, User-Agent: pico-http-client/1.0, } data {temperature: 25.6, humidity: 60.2} try: resp urequests.request( POST, http://192.168.1.10:8080/api/sensor, jsondata, headersheaders, timeout5, ) print(resp.status_code) print(resp.json()) resp.close() except Exception as e: print(request error:, e)这里有几个坑需要单独提第一个坑是timeout参数。它在较新的MicroPython固件中才被完整支持单位是秒表示从建立连接到读取响应的整体超时时间。早期urequests源码中request函数根本没有timeout参数你传进去也不会报错但行为不会改变。解决方法是确保固件版本够新或者自己包装一层异常保护。第二个坑是json参数和data参数的区别。如果使用jsonurequests会自动帮你把字典转成JSON字符串并设置Content-Type: application/json。如果使用data默认按表单或原始字符串发送。很多新手把JSON字符串直接传给data,服务端解析时就会因为Content-Type不对而失败。第三个坑是请求结束后一定要手动调用resp.close()。这个几乎是嵌入式HTTP必须养成的习惯。如果你不主动关闭底层socket和文件对象不会立刻释放连续请求几次后就会出现内存泄漏最终请求失败甚至系统崩溃。我在项目里是统一用try-finally结构包裹确保任何时候都关闭响应。第四个坑是resp.text在Pico上不是真正的“文本解码”它本质上返回的是字节串经过UTF-8解码后的结果。如果服务器返回GBK编码内容resp.text会乱码。处理办法我放到后面编码问题里详说。4. 三个可直接抄的HTTP客户端实现4.1 GET请求读取公开API假设我们要从Open-Meteo这类天气API读取温度数据来模拟一个最典型的IoT数据采集场景。代码可写为import network import urequests import time def connect_wifi(ssid, password): wlan network.WLAN(network.STA_IF) wlan.active(True) if not wlan.isconnected(): wlan.connect(ssid, password) for _ in range(20): if wlan.isconnected(): break time.sleep(0.5) return wlan.isconnected() def get_temperature(): url https://api.open-meteo.com/v1/forecast?latitude31.23longitude121.47current_weathertrue try: resp urequests.get(url, timeout10) print(status:, resp.status_code) if resp.status_code 200: data resp.json() current data.get(current_weather, {}) temp current.get(temperature) print(temperature:, temp) return temp except Exception as e: print(get error:, e) finally: try: resp.close() except Exception: pass return None if connect_wifi(your_ssid, your_password): print(temp:, get_temperature())这个例子里有几个实际项目中最常用的处理细节状态码先判断再解析、异常全局捕获、finally里强制关闭响应。为什么建议状态码判断因为urequests不会像高级语言库那样帮你抛HTTP错误异常它只是把状态码放在resp.status_code里。服务端返回500时resp.text可能是一段错误页直接调json()会抛ValueError导致整个请求流程被误判成网络故障。4.2 POST JSON上报数据到本地服务IoT设备最常做的就是周期上报。我以一个本地测试服务为例演示POST JSON的上报写法import urequests import json def report_sensor(temp, humidity): payload { device_id: pico-w-001, temp: temp, humidity: humidity, } headers { Content-Type: application/json, Authorization: Bearer YOUR_TOKEN, } try: resp urequests.post( http://192.168.1.10:8080/api/report, jsonpayload, headersheaders, timeout5, ) if resp.status_code in (200, 201): print(report ok) else: print(server error:, resp.status_code, resp.text[:200]) except Exception as e: print(report failed:, e) finally: try: resp.close() except Exception: pass这里我在headers里加了Authorization字段模拟带鉴权的上报接口。实际开发中很多云平台接口要求Bearer Token写在代码里虽然方便但注意这是明文存储Pico上的固件文件谁都能读涉及生产环境一定要用安全存储方案或考虑硬件加密。另外注意resp.text[:200]这种截断处理。嵌入式设备屏幕上不显示完整日志串口终端缓冲区也有限打印长响应文本会拖慢运行节奏甚至因为中文乱码刷屏影响调试。只输出前几百字节足够定位问题了。4.3 带鉴权和流式响应的完整客户端有些接口返回内容很大比如OTA固件包、图片文件、日志批处理等。直接在Pico上调用resp.text或resp.content会把整个响应一次性加载进内存几十KB的文件可能直接触发MemoryError。正确做法是使用流式读取响应的raw对象支持按块读取import urequests url http://192.168.1.10:8080/files/latest.bin headers {Authorization: Bearer YOUR_TOKEN} try: resp urequests.get(url, headersheaders, timeout15, streamTrue) if resp.status_code 200: total 0 # 按128字节分块读取边读边写 while True: chunk resp.raw.read(128) if not chunk: break # 这里可以直接写入文件或转发到其他设备 total len(chunk) print(downloaded bytes:, total) else: print(download failed:, resp.status_code) except Exception as e: print(stream error:, e) finally: try: resp.close() except Exception: pass注意我传了streamTrue这会让urequests在收到响应头后就返回而不是等整个响应体全部收完才开始处理。配合resp.raw.read(128)可以控制在任意时刻只占用128字节的缓冲区内存开销几乎恒定。这个模式我在做OTA升级时非常受用。Pico W上跑HTTP下载固件,如果一次性read()整个文件,大概率内存不足;但分块读取后,几MB的文件也能顺利转存到外部Flash。5. 深入HTTP原理与底层实现5.1 HTTP请求的构成我们在上层调urequests.get时,它内部做的事情其实非常原始:建立TCP连接,按HTTP协议格式发送文本请求,再按协议解析返回内容。一个最简单的GET请求报文长这样:GET /api/temp HTTP/1.1 Host: 192.168.1.10:8080 Connection: close一个POST请求则会多出Content-Type、Content-Length和请求体:POST /api/report HTTP/1.1 Host: 192.168.1.10:8080 Content-Type: application/json Content-Length: 23 Connection: close {temp: 25.6, humidity: 60.2}理解了这一点,你就能解释很多urequests的“奇怪行为”。例如,为什么json参数能自动帮我设置Content-Type?因为在源码里,当你传json参数时,它会先调用json.dumps把字典转成文本,再计算字符串长度填充进Content-Length头,并自动添加Content-Type: application/json。如果你手写了headers里的Content-Type,又同时传了json参数,后者的设置可能会覆盖你的自定义值,这种隐形冲突在实际项目里经常让人摸不着头脑。响应的解析也一样。服务器返回的报文是文本头加二进制体,urequests拿到后会解析状态行提取状态码,再根据Content-Length或Transfer-Encoding读取body。由于MicroPython内存小,它并不会把所有body都提前加载,大量内容都是延迟到resp.text或resp.content访问时才真正读取。这也是为什么一个简单的resp.json()可能读取整个body并解码,大响应下会变得很慢且吃内存。5.2 用usocket手写一个迷你HTTP客户端为了让你彻底看清urequests做了什么,我建议在项目中单独用usocket实现一个极简GET请求。这并不浪费,当你需要精细控制socket、实现长连接、或者使用urequests不支持的HTTP特性时,这个“低保底”方案非常管用。import socket def raw_http_get(host, port, path): s socket.socket() s.settimeout(5) try: s.connect((host, port)) req GET {} HTTP/1.1\r\nHost: {}:{}\r\nConnection: close\r\n\r\n.format( path, host, port ) s.send(req.encode()) buf b while True: chunk s.recv(512) if not chunk: break buf chunk return buf.decode(utf-8, ignore) finally: s.close() print(raw_http_get(192.168.1.10, 8080, /api/temp))这段代码和urequests核心逻辑已经非常接近。它先创建socket,设置超时防止永久阻塞,然后发送一个符合HTTP 1.1规格的请求头,再循环接收所有返回字节。为什么Connection: close很重要?因为它告诉服务器返回完body后主动关闭连接,这样客户端读到EOF就知道内容结束了,不用去解析Content-Length。urequests内部很多实现也是依赖这种模式。如果需要保持长连接,这套逻辑就要复杂得多,要解析Content-Length、分块传输编码、处理半包粘包,这些在Pico上做起来成本很高,所以默认库选择“用完即断”是合理的工程取舍。5.3 urequests源码要点有空的话,建议打开一下urequests.py源码,内容不长,一百多行。你会发现几个关键实现:def request(method, url, dataNone, jsonNone, headers{}, streamNone, timeoutNone): ... urlparts url.split(/, 3) protocol urlparts[0] host urlparts[2] ...它先解析URL,把协议、域名、路径分开。这里可以看到,urequests对URL解析非常朴素,直接字符串切割,所以遇到复杂URL、带特殊字符的查询参数、或者非标准端口时,可能出现解析偏差。这提醒我们不要传太花哨的URL,查询参数尽量先做urlencode。另外一个细节是response类内部用self.raw保存底层socket文件对象,text属性是这个文件对象读取后的解码结果,而json()是对text再执行json.loads。所以一旦resp被close,再访问text/like很多会报错或返回空。调试时经常发现打印顺序不对就看不到内容,原因就在这里。6. 常见问题速查与调试6.1 七类典型异常与处理我把实际项目里遇到过的典型问题整理成了一个表格,方便快速对照定位:现象可能原因解决办法ImportError: no module named urequests固件精简或版本过老手动上传urequests.py到/lib目录或升级固件OSError: [Errno 110] ETIMEDOUT网络不通、服务端无响应、超时设置太短检查WiFi连接,增加timeout,先用ping或socket测试DNS解析失败路由器DNS异常、域名拼写错误尝试直接用IP地址请求,检查WLAN DNS配置MemoryError响应体过大或同时加载了多个大对象开启streamTrue分块读取,及时close,减少大字典缓存ValueError: invalid JSON服务器返回非JSON或编码错误先打印resp.text前200字节确认内容SSL相关错误HTTPS证书校验失败、固件ssl实现不完整换HTTP测试、管理证书、或评估关闭校验风险返回中文乱码服务端使用GBK等非UTF-8编码用resp.content.decode(gbk, ignore)处理这几类问题中,我最想细说的是SSL。Pico W支持ssl模块,但MicroPython通常没有CA证书库。官方固件为了简化,往往不会默认校验服务器证书,这在公网HTTPS请求时存在中间人攻击风险。如果服务端证书链不完整或过期,请求可能直接失败。我的建议是:内网设备用HTTP足够时尽量用HTTP;公网必须用HTTPS时,到官方文档下载cacert.pem等证书文件上传到设备,再通过ssl模块加载。import ussl import socket s socket.socket() s.connect((api.example.com, 443)) s ussl.wrap_socket(s, server_hostnameapi.example.com)注意server_hostname参数在较新版本才有,它用于SNI扩展。如果你发现HTTPS连接异常但同时访问HTTPS网站都没问题,多半是SNI没传导致服务器返回了默认证书。6.2 网络排查工具与手段Pico上跑HTTP客户端出问题时,别急着改代码,先用最基础的手段缩小范围。第一,检查WiFi连接状态,执行wlan.status()和wlan.ifconfig(),确认IP是否正常。IP显示0.0.0.0就说明DHCP失败。第二,用socket写一个最简TCP连接测试,看看目标端口通不通:import socket s socket.socket() s.settimeout(3) try: s.connect((192.168.1.10, 8080)) print(tcp ok) except Exception as e: print(tcp fail:, e) finally: s.close()这一步能快速区分问题是网络不通还是HTTP层异常。第三,在PC上用同样的接口做对照测试,确认服务端、请求参数是否正常。如果PC上直接返回500,那就不是Pico的问题。很多开发者在服务端调接口时默认对方会带某些Header或鉴权字段,而嵌入式端总是忘记加,两台设备一对比问题立刻显形。6.3 编码与JSON解析的避坑技巧中文数据在Pico上非常容易出问题。最典型的是服务器返回UTF-8正常,但打印到串口终端时显示乱码。这种情况先确认Terminal编码设置为UTF-8,一般是Thonny和串口工具的默认值,不用改动。如果服务端用的是GBK编码,比如一些老旧的内部接口,处理方式如下:resp urequests.get(http://192.168.1.10:8080/api/cn) # resp.text是UTF-8 decode无法正常处理GBK text resp.content.decode(gbk, ignore) print(text)这里用resp.content拿到原始字节,再手动指定GBK解码。在Pico上,resp.content比resp.text更可靠,因为它不掺杂编码假设。我写接口对接代码时,只要发现响应里可能有中文,一律直接用resp.content自己解码,避免被库的默认行为坑。发送端也要注意。如果你把中文放进jsondata里,MicroPython的json模块默认输出Unicode转义,服务端拿到后解析是正常的。但如果你手动拼JSON字符串发送,可能出现utf-8编码后Content-Length计算不一致的问题。我的做法是优先用json参数,让库自动处理headers和长度计算。7. 扩展:通过HTTP远程控制舵机7.1 舵机控制原理既然题目里的热搜词提到了“树莓派pico控制舵机”,我就把HTTP客户端再往外扩展一步:做一个通过HTTP指令控制舵机的联动方案。这个场景非常典型:设备端作为HTTP客户端定期请求服务端指令,服务端返回目标角度,设备端解析后控制舵机转动。舵机控制的原理是PWM脉宽调制。普通9g舵机工作在50Hz频率,也就是周期20ms,需要控制的是高电平脉冲宽度。0.5ms对应0度,2.5ms对应180度,中间线性映射。MicroPython中用machine.PWM控制:from machine import Pin, PWM servo PWM(Pin(0)) servo.freq(50) def set_angle(angle): pulse_min 500 # 0.5ms, 单位us pulse_max 2500 # 2.5ms pulse int(pulse_min (pulse_max - pulse_min) * angle / 180) # duty_ns 是MicroPython 1.20支持的接口单位纳秒 servo.duty_ns(pulse * 1000)注意,老版本MicroPython里用duty()接收0到1023的值,容易让新手困惑;新版本推荐duty_ns,直接给纳秒数值,语义清晰。如果你固件版本支持duty_ns,优先用它。7.2 整合HTTP轮询指令现在把HTTP客户端和舵机控制叠加。设备每隔几秒请求一次指令接口,拿到角度值后转动舵机:import urequests import time def fetch_angle(): try: resp urequests.get(http://192.168.1.10:8080/api/angle, timeout3) if resp.status_code 200: data resp.json() angle int(data.get(angle, 0)) return angle except Exception as e: print(fetch error:, e) finally: try: resp.close() except Exception: pass return None while True: angle fetch_angle() if angle is not None: set_angle(max(0, min(180, angle))) time.sleep(2)这里有两个细节值得注意。一是角度做了限幅max(0, min(180, angle)),防止外部接口输入异常值导致舵机堵转;二是轮询周期设置为2秒,既保证了实时性,又不会因为请求太频繁把Pico的内存或者路由器连接表打爆。如果要做更实时、更高效的控制,可以把方向反过来:在Pico上直接跑一个HTTP server,让控制端主动调用Pico的IP地址。MicroPython内置了microWebSrv等库,或者用socket手写简单服务器。不过那种场景HTTP客户端就不是主角了,这里点到为止。HTTP轮询方案的优点是对服务端压力小、实现简单、不容易被NAT卡住,适合大多数IoT定时控制场景。7.3 从客户端到设备联动的几点体会走了这么一圈,你会发现MicroPython urequests库虽然小,但背后牵扯的是嵌入式网络编程的整个知识体系。写HTTP客户端本身不复杂,难的是在资源受限环境里做工程取舍:内存不够要流式读取,响应慢要设计超时,证书校验缺失要评估安全边界,断网要主动重连,编码不同要逐字节处理。我在实际项目中总会提醒自己,嵌入式HTTP客户端不是把Python端的代码搬过来就完事,而是要根据Pico的内存、功耗、稳定性重新设计一套简化协议。有些接口字段能省则省,有些响应能定位到足够信息就不读取完整body,有些重试策略宁可少一点也不能让设备卡死。这个库真正的好处是它替你屏蔽了socket编程的大部分裸细节,让你把精力集中在业务逻辑上。但也正因为屏蔽得少,你才有机会随时打开源码看到底层发生了什么。这种“足够小、足够透明”的特质,是我在Pico上依旧偏爱urequests的原因。