☰
Python Socket实现PC与树莓派双向通信:TCP/UDP选型与实战
2026/10/12 12:10:04 网站建设 项目流程

1. 从一个真实需求说起:为什么PC和树莓派之间需要一条"看不见的线"

很多人手里都有一块树莓派,可能是做家庭自动化控制、可能是跑一个小型数据采集节点、也可能是当作学习Linux和嵌入式的练手平台。但真正用起来之后,一个绕不开的问题很快就冒出来了:树莓派通常放在角落里默默跑着,屏幕、键盘、鼠标都不接,我怎么在PC上实时看到它的状态?怎么给它下发指令?怎么把采集到的数据传回来?

有人会说,用SSH不就行了。SSH确实能远程登录,但它是交互式的,适合手动敲命令。如果你想让PC上的一个Python程序自动跟树莓派交换数据,比如PC端每隔一秒把新的控制参数推给树莓派,树莓派把传感器读数实时回传,SSH就显得笨重了。这时候,Socket通信就是最直接、最轻量的方案。

Socket本质上就是两台设备之间的一条"看不见的线"。你可以把它想象成一根水管:一端往里灌水,另一端就能接到水。PC和树莓派各自有一个IP地址,只要它们在同一局域网内,就能通过TCP或UDP协议建立连接,然后像读写文件一样收发数据。Python标准库自带的socket模块把底层TCP/IP的复杂性封装得很好,几十行代码就能跑通一个可用的通信链路。

这篇文章面向的是有基础Python语法知识、手里有树莓派、想让两者真正"对话"起来的开发者。我会从通信模型的选择讲起,把服务端和客户端的代码逐段拆开,补充实际调试中会遇到的各种坑,最后给出一个可以直接复用的双向通信框架。不管你是做物联网原型、实验室数据采集,还是单纯想学网络编程,这套东西都能直接拿去改。

2. TCP还是UDP:先搞清楚你的数据怕不怕丢

2.1 两种协议的本质差异

在动手写代码之前,必须先做一个决定:用TCP还是UDP。这个选择不是拍脑袋定的,它直接决定了你后面代码的结构和整个系统的可靠性。

TCP是面向连接的协议。通信之前,客户端和服务端要先"握手"建立连接,之后所有数据按顺序到达,丢了会重传,错了会校验。你可以把它理解成打电话:拨通了才能说话,对方没听清会让你重复。UDP是面向无连接的协议,发送方直接把数据包扔出去,不保证到达、不保证顺序。它更像寄明信片:你投进邮筒就不管了,对方收没收到、什么时候收到,你都不知道。

对于PC和树莓派的互动场景,大部分情况下我推荐TCP。原因很简单:控制指令和数据采集通常要求可靠传输。你不想出现"PC发了停止指令但树莓派没收到"这种情况。UDP的优势在于低延迟和广播能力,适合高频传感器数据流或者一对多场景,但代价是你得自己在应用层处理丢包和乱序。

2.2 用一张表把选型逻辑说清楚

对比维度TCPUDP
连接方式需要三次握手建立连接无连接,直接发送
可靠性保证数据到达且有序不保证到达,不保证顺序
传输速度相对较慢(有确认机制)相对较快
适用场景控制指令、文件传输、配置下发高频传感器流、视频流、广播
Python实现复杂度低,socket库直接支持低,但需自行处理丢包
典型代码行数服务端约20行,客户端约15行各约10行

我自己的经验是:如果你做的是"PC下发指令、树莓派执行并回传结果"这种请求-响应模式,无脑选TCP。如果你做的是"树莓派以100Hz频率持续上报加速度数据",那可以考虑UDP,但要在应用层加序号和时间戳来检测丢包。

2.3 一个容易忽略的点:端口选择

不管选哪种协议,都要指定端口号。端口号的范围是0到65535,其中0到1023是系统保留端口,需要管理员权限才能绑定。实际开发中建议选1024以上的端口,比如8888、9999、12345这类。但要注意,有些端口可能被系统或其他服务占用了,绑定的时候会报"Address already in use"。我一般习惯在代码里把端口号定义成常量,方便统一修改,也避免在多个地方硬编码导致不一致。

提示:树莓派上如果开了某些常见服务(比如Web服务占80端口),选端口时避开这些。可以用ss -tlnp命令查看当前已监听的端口列表。

3. 服务端放在哪一端:树莓派做Server还是PC做Server

3.1 两种架构的取舍

这个问题看起来简单,但实际项目中选错了会带来不少麻烦。服务端(Server)的角色是绑定端口、等待连接、接受请求。客户端(Client)的角色是主动发起连接。

把树莓派作为服务端是最常见的做法。原因有几个:树莓派通常固定放在某个位置,IP地址相对稳定(可以在路由器里绑定静态IP),它开机后就启动服务端程序一直监听,PC随时连上来都行。而且树莓派作为数据采集端,天然适合"等待被查询"或者"接受指令"的角色。

反过来,PC作为服务端也有它的场景。比如PC上跑着一个计算密集型的模型,树莓派采集到原始数据后主动推送给PC去处理,处理完再回传结果。这种架构下,PC的IP可能变化(比如笔记本换网络),树莓派就需要处理"连不上"的情况,代码里得加重试逻辑。

3.2 树莓派做服务端的完整代码拆解

先看服务端代码,我把它拆成几个关键步骤:

import socket import threading HOST = '0.0.0.0' PORT = 8888 def handle_client(conn, addr): print(f'[新连接] {addr} 已接入') try: while True: data = conn.recv(1024) if not data: break msg = data.decode('utf-8') print(f'[收到] {msg}') reply = f'树莓派已收到: {msg}' conn.sendall(reply.encode('utf-8')) except ConnectionResetError: print(f'[断开] {addr} 异常断开') finally: conn.close() print(f'[关闭] {addr} 连接已释放') def start_server(): server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((HOST, PORT)) server.listen(5) print(f'[启动] 服务端监听 {HOST}:{PORT}') while True: conn, addr = server.accept() t = threading.Thread(target=handle_client, args=(conn, addr)) t.daemon = True t.start() if __name__ == '__main__': start_server()

逐段解释一下。HOST = '0.0.0.0'表示监听本机所有网络接口,这样局域网内任何设备都能连过来。如果你只想让特定网段访问,可以改成具体的IP。SO_REUSEADDR这个选项很重要,它允许端口在关闭后立即被重新绑定,否则你调试时频繁重启程序会遇到"端口被占用"的报错。

server.listen(5)中的5是等待队列长度,表示最多允许5个未处理的连接排队。对于PC和树莓派一对一的场景,这个值够用了。accept()会阻塞在那里,直到有客户端连进来,返回一个新的socket对象和客户端地址。

我用了一个线程来处理每个客户端连接,这样服务端可以同时接受多个连接。虽然PC和树莓派通常是一对一,但调试时你可能同时开多个客户端测试,多线程处理更灵活。t.daemon = True让线程随主线程退出而结束,避免程序关不掉。

3.3 客户端代码与连接逻辑

PC端的客户端代码:

import socket SERVER_IP = '192.168.1.100' SERVER_PORT = 8888 def start_client(): client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.settimeout(5) try: client.connect((SERVER_IP, SERVER_PORT)) print(f'[已连接] {SERVER_IP}:{SERVER_PORT}') except socket.timeout: print('[超时] 连接服务端超时,请检查IP和网络') return except ConnectionRefusedError: print('[拒绝] 服务端未启动或端口未开放') return while True: msg = input('请输入要发送的内容(输入quit退出): ') if msg == 'quit': break client.sendall(msg.encode('utf-8')) reply = client.recv(1024) print(f'[回复] {reply.decode("utf-8")}') client.close() print('[关闭] 连接已断开') if __name__ == '__main__': start_client()

settimeout(5)设置了5秒超时,避免网络不通时程序卡死。connect()发起连接,如果树莓派的服务端没启动,会立刻抛出ConnectionRefusedError;如果IP不对或者网络不通,会等到超时。这两种异常分开处理,方便定位问题。

sendall()和send()的区别值得说一下。send()返回实际发送的字节数,可能小于你传入的数据长度,需要循环发送。sendall()会一直发送直到全部数据发完,代码更简洁,推荐使用。

4. 让通信真正"双向实时":从请求-响应到全双工

4.1 请求-响应模式的局限

上面那个例子是典型的请求-响应模式:PC发一句,树莓派回一句。但实际项目中,你往往需要树莓派主动上报数据,比如每隔一秒把温度读数推给PC,同时PC也能随时下发控制指令。这就不是简单的"一问一答"了。

如果只用单线程,recv()会阻塞,导致你没法同时处理发送和接收。解决办法有两种:一是用多线程,一个线程专门收数据,一个线程专门发数据;二是用select或selectors模块做I/O多路复用。对于PC和树莓派这种场景,多线程更直观,代码也更好维护。

4.2 双线程全双工通信框架

先看树莓派服务端的改造:

import socket import threading import time import random HOST = '0.0.0.0' PORT = 8888 def receive_loop(conn): while True: try: data = conn.recv(1024) if not data: break print(f'[PC指令] {data.decode("utf-8")}') except Exception as e: print(f'[接收异常] {e}') break def send_loop(conn): while True: temp = round(20 + random.uniform(-2, 2), 2) msg = f'TEMP:{temp}' try: conn.sendall(msg.encode('utf-8')) except Exception as e: print(f'[发送异常] {e}') break time.sleep(1) def start_server(): server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((HOST, PORT)) server.listen(1) print(f'[启动] 等待PC连接...') conn, addr = server.accept() print(f'[已连接] {addr}') t_recv = threading.Thread(target=receive_loop, args=(conn,)) t_send = threading.Thread(target=send_loop, args=(conn,)) t_recv.daemon = True t_send.daemon = True t_recv.start() t_send.start() t_recv.join() t_send.join() if __name__ == '__main__': start_server()

这里用两个线程分别负责收和发。receive_loop阻塞在recv()上等待PC的指令,send_loop每隔一秒模拟采集一次温度并发送。两个线程共享同一个conn对象,TCP是全双工的,同时读写没有问题。

PC端的客户端也做类似改造:

import socket import threading SERVER_IP = '192.168.1.100' SERVER_PORT = 8888 def receive_loop(client): while True: try: data = client.recv(1024) if not data: break print(f'\n[树莓派上报] {data.decode("utf-8")}') except Exception: break def start_client(): client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect((SERVER_IP, SERVER_PORT)) print('[已连接]') t = threading.Thread(target=receive_loop, args=(client,)) t.daemon = True t.start() while True: msg = input('') if msg == 'quit': break client.sendall(msg.encode('utf-8')) client.close() if __name__ == '__main__': start_client()

4.3 粘包问题:为什么你的消息会"粘"在一起

TCP是字节流协议,它不保留消息边界。也就是说,你调了两次sendall(),对方可能一次recv()就把两条消息都收到了;或者你发了一条很长的消息,对方分两次才收完。这就是所谓的"粘包"和"拆包"问题。

在小数据量、低频率的场景下,这个问题可能不明显。但一旦数据量上来,或者发送频率变高,就一定会遇到。解决办法是在应用层定义消息边界。常见方案有三种:

第一种是固定长度消息,每条消息长度固定,接收方按固定长度读取。简单但浪费带宽,适合定长指令。第二种是用分隔符,比如每条消息以\n结尾,接收方按行读取。Python的socket.makefile()可以很方便地实现按行读取。第三种是在消息头部加长度字段,比如前4个字节表示消息体长度,接收方先读4字节,再根据长度读消息体。这是最通用的方案。

我一般推荐分隔符方案,因为实现简单且可读性好。改造后的接收代码:

def receive_loop(conn): buffer = '' while True: data = conn.recv(1024) if not data: break buffer += data.decode('utf-8') while '\n' in buffer: line, buffer = buffer.split('\n', 1) if line: print(f'[收到] {line}')

发送时每条消息末尾加\n:

conn.sendall((msg + '\n').encode('utf-8'))

注意:分隔符方案要求消息内容本身不能包含分隔符。如果你的数据里可能有换行符,要么先做转义,要么改用长度字段方案。

5. 调试路上一定会遇到的几个坑

5.1 连不上:从网络层到应用层逐层排查

"Connection refused" 和 "Connection timed out" 是两个最常见的报错,但原因完全不同。前者说明网络是通的,但目标端口没有服务在监听——要么树莓派上的服务端程序没启动,要么绑定的IP不对。后者说明数据包根本没到达目标,通常是IP写错了、两台设备不在同一网段、或者防火墙拦了。

排查顺序我一般是这样的:先在PC上ping 树莓派IP,确认网络层通不通。通了之后,在树莓派上ss -tlnp | grep 8888确认端口在监听。如果监听地址是127.0.0.1:8888而不是0.0.0.0:8888,那只有本机能连,外部连不上,需要把HOST改成0.0.0.0。最后检查树莓派的防火墙规则,有些系统默认开了防火墙会拦截入站连接。

5.2 树莓派IP变了怎么办

家用路由器分配的IP通常是动态的,树莓派重启后IP可能变。解决办法有两个:一是在路由器管理界面里给树莓派的MAC地址绑定一个固定IP;二是在树莓派上装一个mDNS服务(比如avahi),然后PC端用hostname.local来代替IP地址。第二种方案更灵活,不依赖路由器配置,Python的socket也能直接解析主机名。

5.3 程序异常退出后端口被占用

调试时频繁重启服务端,经常会遇到OSError: [Errno 98] Address already in use。这是因为TCP连接关闭后,操作系统会保持一段时间TIME_WAIT状态。解决办法就是前面代码里提到的setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1),加上这一行基本就能解决。

5.4 中文乱码问题

Python 3的字符串默认是Unicode,encode('utf-8')和decode('utf-8')要成对使用。如果一端用utf-8编码,另一端用gbk解码,中文就会乱码。统一用utf-8是最省心的做法。另外要注意,recv()返回的是bytes,必须先decode再处理。

6. 从能跑到好用:几个值得加的工程化改进

6.1 心跳机制检测连接状态

TCP连接建立后,如果一方断电或网络中断,另一方可能不会立刻感知到。recv()会一直阻塞,直到TCP超时(可能长达几分钟)。加一个心跳机制可以快速检测连接是否还活着。做法很简单:双方每隔几秒发送一个特殊的心跳包,接收方收到后更新"最后活跃时间",如果超过一定时间没收到任何数据,就主动关闭连接并尝试重连。

6.2 断线自动重连

客户端代码里加一个重连循环,连接断开后等待几秒再尝试重新连接。这在树莓派偶尔重启或者网络波动的场景下非常实用。服务端也可以在accept()循环里持续等待新连接,而不是处理完一个就退出。

6.3 用JSON格式化消息

纯文本消息在简单场景够用,但一旦消息类型多了,解析起来就麻烦。用JSON格式可以让消息自带结构:

import json msg = {'type': 'sensor', 'name': 'temperature', 'value': 23.5} conn.sendall((json.dumps(msg) + '\n').encode('utf-8'))

接收方json.loads()一下就能拿到字典,根据type字段分发处理。这样扩展新消息类型时不用改解析逻辑。

6.4 日志记录代替print

调试阶段用print没问题,但长期运行的程序应该用logging模块。可以同时输出到控制台和文件,方便事后排查问题。日志级别也能控制,生产环境只记录warning以上,调试时开debug。

import logging logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', handlers=[ logging.FileHandler('comm.log'), logging.StreamHandler() ] )

这套Socket通信框架我从最简单的请求-响应版本开始,逐步加到双线程全双工、粘包处理、心跳重连,前后在好几个项目里用过。最深的体会是:网络编程的代码量不大,但边界情况特别多。把超时、异常、重连这些处理好了,程序才能真正稳定跑起来,而不是演示五分钟就崩。另外,如果你后续想扩展到多个树莓派节点,服务端用selectors模块做I/O多路复用会比多线程更高效,那是另一个话题了,等有需要再展开。

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

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

立即咨询