Socket的使用:从入门到把线上连接错误一次排查明白
“socket网络编程”这几个字,几乎是后端、嵌入式、桌面客户端开发者绕不开的坎。我翻了下最近大家的搜索记录,除了“python socket”“socket编程”这类入门问题,真正让大家头疼的反而是各种错误码:error 2002 (HY000)、no more data to read from socket、connection refused (10061)、socket read timed out、create socket connection failure (-70028)……其实这些都指向同一个主题:socket 本身不大,但围绕它的坑异常多。
这篇不是给你念 API 文档。我想做的事只有一件:把 socket 从“会用”到“能排错”的完整链路讲清楚。你会明白 socket 的底层模型是什么,TCP 和 UDP 怎么选,Python 怎么写出第一个能跑的通话程序,然后再把工作中最常踩的连接错误逐个拆开。最后还会聊聊 WebSocket 跨域、诡异的“补随机数”现象,以及 FreeRTOS + lwIP 这种嵌入式环境下的 socket 玩法。刚入门的新手可以只看前两章,天天跟 MySQL、连接池打交道的老手可以直接跳到第三章,那部分每一行都是实践踩出来的。
1. Socket到底是个啥——先建立正确的认知模型
1.1 用“打电话”理解Socket的本质
Socket 的中文解释是“网络通信的端点”,太抽象。我更喜欢拿打电话来打比方:两台机器要交换数据,本质就像两个人打电话。
- socket() 是给你装一部电话机
- bind() 是给这部电话机分配一个号码(IP + 端口)
- listen() 是让电话机进入待机状态,竖起耳朵等来电
- accept() 就像听到铃声后拿起话筒,这一刻才真正建立了一条能对话的线路
- connect() 是对方拨号
- send() / recv() 是张嘴说话和竖起耳朵听
- close() 就是挂断电话
把这一串 API 用这个模型串起来,比死记 hard code 高效得多。你写服务端时,不就是“装电话、配号码、待机、接听”四步吗?你写客户端时,不就是“拿电话、拨号、说话、挂断”吗?后面看任何语言的 socket 代码,本质都是这一套动作换个语法糖。
再往深一层,TCP 的三次握手其实就是“电话接通”的真实过程:客户端发一个 SYN(“喂,你在吗?”),服务端回 SYN+ACK(“在的,你能听到我吗?”),客户端再回 ACK(“能听到,我们开始聊”)。这个流程不需要你写代码,操作系统和协议栈全包了。你要关注的是连接建立成功之后,send 和 recv 怎么配合。
1.2 TCP和UDP,选哪个?先想清楚你的场景
Socket 不是只有一种。最常用的两种是 TCP 和 UDP,对应 socket 类型分别是 SOCK_STREAM 和 SOCK_DGRAM。
TCP 像打电话:有连接、有状态、有序、可靠。你说一句话,对方要么完整听到,要么没听到但有反馈。适合文件传输、数据库连接、请求响应这类不能丢数据的业务。
UDP 像对讲机:你把消息喊出去就完事了,对方是否收到、消息顺序对不对,你都不管。但换来的是低延迟、无连接开销。适合音视频流、游戏实时同步、物联网传感器上报这类“丢几帧也认了”的场景。
选型上,我个人的判断标准很简单:这条数据丢了会造成业务故障吗?会,就选 TCP;不会,且对延迟敏感,优先 UDP。绝大多数企业级应用选 TCP,别被网上乱七八糟的“高性能 UDP”忽悠了,先搞清楚自己的数据能不能丢,再谈追求性能。
1.3 从一张表看懂Socket API全家桶
不管你是用 Python、Java、C 还是 Go,底层那套 API 思想是通用的。把最核心的一套记熟,其他语言都能触类旁通:
| API | 动作 | 类比 | 说明 |
|---|---|---|---|
| socket() | 创建套接字 | 装电话机 | 指定协议族(AF_INET)、套接字类型(SOCK_STREAM/SOCK_DGRAM) |
| bind() | 绑定地址和端口 | 分配电话号码 | 服务端需要,客户端通常可省略(随机端口) |
| listen() | 进入监听状态 | 电话机待机 | 只对 TCP 服务端有意义,参数 backlog 是等待队列长度 |
| accept() | 接受连接 | 拿起话筒 | 阻塞式调用,返回新连接的 socket 和客户端地址 |
| connect() | 发起连接 | 拨号 | 客户端主动向服务端地址发起 TCP 三次握手 |
| send() / sendto() | 发送数据 | 说话 | TCP 用 send,UDP 用 sendto(要指定目标地址) |
| recv() / recvfrom() | 接收数据 | 听对方说 | TCP 用 recv,UDP 用 recvfrom(能拿到来源地址) |
| close() / shutdown() | 关闭连接 | 挂电话 | close 全关,shutdown 可单关读或写方向 |
这里特别要提一下 shutdown 和 close 的区别。close 是把整个 socket 都关掉,读和写都不能再用了;shutdown(fd, SHUT_WR) 则只关闭写方向,对方仍可以给你发数据。像 HTTP 的 Keep-Alive、一些自定义的“发完请求等响应”场景,半关闭很有用。比如客户端发完所有数据后 shutdown 写方向,告诉服务端“我说完了,你看着办”,这时候服务端 recv 会返回 0(EOF),立刻知道客户端不会再来数据了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从零写一个能用的Socket程序
2.1 服务端:四步走,把“电话座机”装好
光说不练没有意义,先用 Python 写一个最简 TCP 服务端,Python 是入门 socket 最适合的工具,因为它足够贴近底层 API,又能少写一堆模板代码。
python复制import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', 9000))
server.listen(5)
print('server listening on 9000...')
while True:
conn, addr = server.accept()
print(f'client connected from {addr}')
data = conn.recv(1024)
if data:
conn.sendall(b'hello, ' + data)
conn.close()
解释几个关键点,这些是面试也常考、线上也常踩的。
第一,setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) 是保命配置。如果不加,服务端程序退出后端口会进入 TIME_WAIT 状态,短时间内重新启动就会报“Address already in use”。加了它,端口能被快速复用。写任何服务端程序,第一行都应该有它。
第二,bind(('0.0.0.0', 9000)) 里的 0.0.0.0 表示监听本机所有网卡 IP,不管客户端连的是内网 IP 还是公网 IP 都能到达。如果写成 bind(('127.0.0.1', 9000)),那就只有本机自己能连,外部怎么都连不上。很多部署环境的“连不上”问题,根源都在这。
第三,listen(5) 里的数字是 backlog,翻译过来是:还没被 accept 处理的连接,最多在队列里排 5 个。超出的话客户端 connect 会失败或变慢。不用纠结具体值,知道它存在就够了。
accept() 默认是阻塞的。程序执行到这里会停住,直到有客户端连进来,然后返回一个新的 socket 对象 conn 和客户端地址 addr。注意这里产生了两个 socket:一个是 server 负责“接电话”,一个是 conn 负责“这次通话”。你循环 accept,就能一直接待新客户端,但一次只能处理一个连接。真实服务器想同时处理多个连接,要用多线程、select/poll/epoll 或 asyncio,那属于进阶话题,但这里的模型必须搞清楚。
conn.recv(1024) 的 1024 是缓冲区大小,意思是“最多读 1024 字节”。数据不够 1024 字节时,recv 返回实际读到的量;对端关闭连接时返回 b''。conn.sendall(b'hello, ' + data) 比 send 更可靠,它会循环发送直到数据全部发完,省得你自己处理“只发了一半”的情况。
运行服务端后,随便开个终端,可以用 nc 连上去测试:
bash复制nc 127.0.0.1 9000
输入任意内容回车,服务端应该回一句带前缀的问候。这一步能过,说明你的“电话座机”装好了。
2.2 客户端:三步连接,拨通对方的号码
客户端比服务端简单多了。创建 socket、connect、send/recv、close,齐活。
python复制import socket
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('127.0.0.1', 9000))
client.sendall(b'world')
resp = client.recv(1024)
print(resp.decode())
client.close()
connect 是同步阻塞的,它会等 TCP 三次握手完成或者超时。如果服务端没起来、端口不对、防火墙拦截,connect 会抛异常。生产代码里一定要给 connect 设置超时,别让它无限等下去。Python 里可以这样:
python复制client.settimeout(5)
Java 里的等价写法是 socket.connect(new InetSocketAddress(host, port), 5000)。设置连接超时这个习惯,能帮你避免一大半“程序卡死不动”的线上事故。
2.3 一场完整的“通话”:粘包、拆包与处理顺序
写完上面这段,很多新手会做一件至关重要但容易踩坑的事:把发送和接收的数据“对上号”。热搜里那句“为什么 socket 接收到奇数字节,后面会补一个随机数”,其实就是对不上号时的典型困惑,后面第 4.2 节我会详细展开。
这里先讲根上的问题:TCP 是字节流,不是消息流。你调用一次 sendall(b'hello'),数据到了协议栈里就是一堆连续的字节,跟下一次 send 的数据之间没有任何边界。接收方可能一次 recv 收到半条消息,也可能一次收到好几条消息拼在一起。这就是常说的粘包和拆包。
比如客户端连续发三次 sendall(b'abc'),服务端如果凑巧一次 recv 返回 b'abcabcabc',你是没法从数据里切出“三次发送”的。
解决方案只有一个:在应用层约定消息格式。最朴素也最常用的方案是“长度前缀法”——先固定发 4 字节(比如大端序)的消息长度,再发消息体。
python复制import struct
# 发送端
msg = b'hello world'
client.sendall(struct.pack('>I', len(msg)) + msg)
# 接收端
header = recv_exact(4)
msg_len = struct.unpack('>I', header)[0]
body = recv_exact(msg_len)
recv_exact 要自己写一个循环,因为 recv 一次未必读满指定长度。这个“先长度、后内容”的模式,你在 Redis RESP 协议、Minecraft 协议、各种 RPC 框架里都会见到,学会了受益终身。
3. 那些年我们踩过的Socket连接错误(排错实战)
这一章是全文的重头戏。我从最近的搜索热词里挑出最高频的四个错误,整理成一张总表,再逐个拆解。
| 错误信息 | 出现位置 | 一句话定性 | 最常见原因 |
|---|---|---|---|
| ERROR 2002 (HY000): Can't connect to local MySQL server through socket | MySQL 客户端 / JDBC 本地连接 | 本地 socket 文件不通 | 服务没启动、socket 路径不一致、权限不足 |
| No more data to read from socket | JDBC / MySQL Connector | 已连接但服务端提前关闭 | 连接空闲超时、服务端异常退出、坏连接被取出 |
| Connection refused (10061) | Windows 下各种客户端 | 端口没人监听 | 服务没起来、监听地址不对、防火墙拦截 |
| Socket read timed out | Java / Python 读取时 | 连接还在但对端不响应 | 慢查询、网络黑洞、对端僵死 |
| Create socket connection failure (-70028) | MySQL 主从/远程连接 | 连接建立阶段失败 | DNS 不可达、端口不通、网络策略问题 |
这个表先存着,下面逐条拉开讲。
3.1 ERROR 2002 (HY000):MySQL的Socket文件去哪了
搜这个错的人多半是在本地执行 mysql -u root -p 时崩溃的。完整报错一般长这样:
code复制ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock' (2)
先说原理。在 Linux/Unix 环境下,MySQL 客户端如果连接的主机是 localhost,会默认走 Unix domain socket 而不是 TCP。Unix domain socket 是同一台机器内部进程通信的机制,它的“地址”是一个文件路径,比如 /tmp/mysql.sock。这个文件是 MySQL 服务端启动时创建的,客户端就是往这个文件上读写字节。如果文件不存在、路径不对、或没有权限访问,就会报 2002。
常见原因按排序:
- MySQL 服务根本没启动。这是 90% 的情况,服务端没起来,socket 文件自然不存在。先确认:
systemctl status mysql或ps -ef | grep mysqld。 - socket 文件路径不一致。客户端默认找
/tmp/mysql.sock,但 MySQL 的 my.cnf 可能把 socket 配置到了/var/run/mysqld/mysqld.sock或别的地方。查询实际路径:bash复制然后用mysql -h 127.0.0.1 -P 3306 -u root -p SHOW VARIABLES LIKE 'socket';-S参数指定真实路径:mysql -uroot -p -S /var/run/mysqld/mysqld.sock。 - 目录权限不足。运行客户端的用户没权限访问 socket 文件所在的目录,比如目录是 700 且属于 mysql 用户。检查
/var/run/mysqld/和/tmp的权限。 - JDBC URL 的 host 写错。Java 里
jdbc:mysql://localhost:3306/db有时会尝试走本地 socket(取决于 Connector/J 的配置和系统环境),而jdbc:mysql://127.0.0.1:3306/db基本确定走 TCP。如果你的服务端没开 TCP 监听(skip-networking开启时),localhost 方式就可能失败。
我的个人习惯是:本地调试用 127.0.0.1 而不是 localhost,既绕开 socket 路径问题,又方便用 tcpdump 抓包观察。别小看这个习惯,它能帮你节省大量排查时间。
3.2 “No more data to read from socket”:服务器悄悄关了门
这句出现在 JDBC 日志里的频率相当高,完整常见形式是:
code复制java.io.EOFException: No more data to read from socket
场景通常是:客户端从连接池里拿了一条连接,执行 SQL 时才发现这条连接早被服务端悄悄关掉了。你拿着一条断掉的连接去读写,TCP 层会告诉你“没有更多数据了”——因为对端已经发来了 FIN(正常关闭)或 RST(异常重置)。
为什么会发生?核心原因是连接的空闲超时。MySQL 服务端的 wait_timeout 默认是 8 小时(有的版本是 28800 秒),连接空闲超过这个时间会被服务端主动断开。但很多连接池的 maxLifetime 又没配合好,导致池里保留了过期连接。另一个常见原因是服务端做过重启或 failover,所有旧连接都被清掉了,客户端不知道还在用。再有一个就是中间网络设备(防火墙、LB)对空闲连接有超时清理策略,比如 5 分钟没流量就悄悄回收。
这是“坏连接”问题的典型场景,我倾向于从三处下手:
- 连接池加探活。Druid 或 HikariCP 都支持配置验证语句,比如
validationQuery: SELECT 1,获取连接前先 ping 一下。 - 服务端和客户端超时时间对齐。把 MySQL 的
wait_timeout、连接池的maxLifetime都调成合理的值,比如 60 秒左右,让池里的连接在过期前就被换掉。 - 捕获异常后重试。应用层遇到
EOFException时,丢弃这条连接,从池里重新取一条执行一次。
再补充一个思路:不要忽略 read timeout 和 EOFException 之间的差异。EOF 是对端主动关闭,数据再也不会来了;timeout 是对端还没关,只是响应太慢。前者你和连接池较劲,后者你和慢 SQL 较劲。方向搞反了,排查就会越走越偏。
3.3 “Connection refused (10061)”:走了弯路,也可能是没人接
Windows 下连接被拒最常见的错误码是 10061,Linux 下通常是 Connection refused(ECONNREFUSED)。含义非常清楚:你的数据包到达了目标机器,但目标端口上没有进程在监听。就好比你拨通了电话,但对面是空号——服务器和网络通路是通的,这个目标座位没人坐。
排查五板斧,按顺序来:
- 确认服务进程是否监听目标端口。Linux 用
ss -lntp | grep 9000,Windows 用netstat -ano | findstr 9000。如果输出为空,就是服务没起来或端口起错。 - 确认监听地址。输出里如果只有
127.0.0.1:9000,说明服务端只听本机回环;外网或局域网 IP 去连肯定被拒。要让外部可连,监听地址必须是0.0.0.0或具体的外网/内网 IP。 - 确认防火墙。Linux 看
iptables -L -n或firewall-cmd --list-all,Windows 检查“高级安全 Windows Defender 防火墙”的入站规则。 - 确认 IP 和端口是否写对。连了
192.168.1.10的 8080,但服务端监听的是 9090,自然拒绝。扫一眼配置就能发现的事,别一开始就怀疑网络安全策略。 - 注意 IPv6 的坑。服务端只监听
[::]:8080(IPv6)但不监听 IPv4 的0.0.0.0:8080时,用 IPv4 地址连接会被拒。检查监听输出里是 v4 还是 v6。
这里分享一个我常用的快速定位命令:nc -vz 目标IP 端口。它专门测试端口通不通,比用业务客户端去试快得多。通的话会打印 succeeded,不通会告诉你具体错误。多一事不如少一事,先验证端口再查业务,效率翻倍。
3.4 Socket read timed out vs Created socket failure:超时与建立失败要分清
这两个极易混淆。我在线上见过太多人把“超时”当成“连不上”来排查,结果绕了一大圈。
Socket read timed out 的场景是:TCP 连接已经建立成功,你调 recv 去读,但服务端迟迟不返回任何字节,直到超出你设置的读超时时间。这说明网络通路没问题,是对端不响应。Java 里对应 java.net.SocketTimeoutException: Read timed out,触发点通常是 setSoTimeout 或 JDBC 驱动的 socketTimeout。原因常见于:数据库执行慢查询、服务端线程卡死、网络链路出现黑洞(丢包但不提示)。
Create socket connection failure (-70028) 的场景是:连接根本还没建立。这个错误号常见于 MySQL 主从复制和某些远程连接配置中。原因可能是 DNS 解析失败、路由不可达、目标端口被防火墙丢弃(DROP 而不是 REJECT,注意 REJECT 会返回 refused,DROP 会表现为超时)、或服务端 bind-address 限制导致不接受来自该客户端的连接。
我自己在实际排查时,会先看错误是发生在 connect 阶段还是 recv 阶段:
- connect 阶段失败:查端口监听、防火墙、DNS、bind-address。
- recv 阶段超时:查慢 SQL、线程池阻塞、网络丢包、防火墙空闲超时。
诊断工具上,ping 只能告诉你主机在不在,无法验证端口;telnet 或 nc 能验证端口是否可达;tcpdump 则能看到数据包到底有没有到、有没有被对端拒绝。三层工具组合,基本没有查不出来的连接问题。
4. 关于Socket的“跨域”与传输怪问题
4.1 Socket有跨域吗?先分清TCP Socket和WebSocket
这个问题搜得非常多,但问的人往往自己都没搞清楚“跨域”的定义和场景。我先把结论放在这里:
- 原生 TCP/UDP Socket 之间没有浏览器同源策略,不存在跨域问题。你用 Python 客户端连一个 C 写的服务端,或者 A 服务器连 B 服务器的端口,只要网络策略允许,没有任何“域”的限制。服务器之间更没有“跨域”这个说法。
- 浏览器里的 WebSocket 有跨域概念。浏览器出于安全考虑,会在 WebSocket 握手请求里带
Origin头。服务端如果不校验这个头,任何网页里的 JS 都能连接你的 WebSocket 服务,就可能出现 CSWSH(Cross-Site WebSocket Hijacking)之类的安全问题。
所以做 WebSocket 服务时,“允许哪些来源连接”是一个必须处理的配置。最常见的做法,是在握手阶段(beforeRequest 或拦截器)校验 Origin 或 Sec-WebSocket-Protocol,不在白名单里的直接拒绝。Spring Boot 集成 WebSocket 时也有对应的 setAllowedOrigins 或 setAllowedOriginPatterns 配置。
但注意,这不是浏览器环境下的原生 socket 需要关心的事。如果你写的是服务器之间的 socket 通信,就不要把“跨域”这套浏览器思维带进来,你要处理的是更底层的网络安全策略和认证授权问题。
4.2 为什么收完奇数字节,后面会补一个随机数?
热搜里这个表述很有意思:“为什么 socket 接收到奇数字节,后面会补一个随机数”。我直接说结论:socket 本身绝对不会补随机数。
Socket 是透明的收发管道。TCP 保证字节内容不变、顺序不变,UDP 保证单个数据报内容不变。你发送端写了什么字节,接收端收到的就是什么字节。中间不会有人心血来潮给你塞几个随机数。如果你的接收端真的看到了“奇数字节后面跟着随机数”,问题一定出在以下四个地方之一:
- 应用层协议有 padding 字段。很多协议为了保证对齐或隐藏真实数据长度,会在消息尾部补填充字节。比如某些加密协议、自定义帧格式,补齐到偶数或固定长度。填充值可能是 0,也可能是随机数,目的是防分析。
- TLS/SSL 加密层。某些密码套件会在加密记录里加入随机填充,这是协议设计,不是 socket 行为。
- 发送端缓冲区没初始化。C 语言的老场景了,
char buf[1024]没清零就send,会把栈上残留的随机数据发出去。这种 bug 很隐蔽,接收端看到的就是“正常数据后跟了一堆看不懂的字节”。 - 接收方的拆包逻辑不对。“奇数字节”很可能是某条消息的完整内容,后面“补的随机数”其实是下一条消息的开头。你没有按长度字段切分消息,把两条数据硬看成了一条。
遇到这种现象,我的建议永远是:先抓包,后下结论。tcpdump -i eth0 port 9000 -X 或者用 Wireshark 直接把服务器来回的报文存下来,看那堆“随机数”是从发送端出来的,还是中间网络设备改的。抓到包,真相立现。端口、进程、协议栈都不会骗你,只有没看清数据的猜测会骗你。
4.3 Spring Boot 集成WebSocket的yml配置
搜“spring boot 集成web socket yml 配置”的人,大概率是卡在“配置写在哪儿”上了。先明确一件事:Spring Boot 集成 WebSocket 的“主要配置生意”都在 Java 配置类里,yml 里能配的其实很少。
一个典型的集成流程:
java复制@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(myHandler(), "/ws")
.setAllowedOriginPatterns("*");
}
}
WebSocketHandler 继承 TextWebSocketHandler,重写 afterConnectionEstablished(连接建立后调用)和 handleTextMessage(收到文本消息后调用)。到这里,最核心的 WebSocket 接入已经完成了。至于 yml 配置,通常只有容器层面那几个参数比较常用:
yaml复制server:
port: 8080
tomcat:
websocket:
read-timeout: 15000
max-text-message-size: 8192
注意,Spring Boot 默认使用内嵌 Tomcat 时才支持上面的 server.tomcat.websocket.* 配置项。如果你换成了 Jetty 或 Undertow,配置属性前缀和参数都不一样,得去对应文档里查。生产环境我建议把心跳检测业务做在 WebSocketHandler 里,比如定期 ping 客户端,而不是依赖容器的 read-timeout,因为容器超时一般是兜底,业务层主动心跳才能及时发现问题。
还要区分一个容易混淆的点:Spring 的 WebSocket 和 Java 的 java.net.Socket 完全是两码事。前者是浏览器/客户端用的高级协议(基于 HTTP 升级),后者是底层 TCP 套接字。yml 里各种 server.* 配置管的都是 WebSocket 容器,而原生 Socket 的连接参数(超时、缓冲区、keepalive)都要在你的代码里设置,配置文件的触角够不着。
5. 从服务器到单片机:FreeRTOS + lwIP 里跑Socket
5.1 嵌入式环境下的Socket编程差异
热搜里出现“freertos tcpip lwip socket”,说明不少人已经在往嵌入式方向走了。lwIP(lightweight IP)是一个为嵌入式系统写的 TCP/IP 协议栈,内存占用最小化,提供了类 BSD socket API。它在 FreeRTOS 上跑的时候,很多行为跟桌面开发不一样,你得先调整心理预期。
差异主要在三点。第一,特性全靠宏开关。lwIP 编译时有一堆宏,比如 LWIP_SOCKET、LWIP_NETCONN、SO_REUSEADDR,不开对应宏,有些 API 编译不过或者直接返回错误。第二,资源池有限。协议栈的控制块、pbuf 缓冲区都是静态分配的,数量在 lwipopts.h 里配置。你同时开的 socket 数量、TCP 连接数都受这几个宏限制。第三,不能随便阻塞太久。嵌入式环境里一个任务卡死在 recv 上,可能导致整机行为异常。
5.2 lwIP socket API的典型用法
lwIP 的 socket API 和标准 socket 可以说长得一模一样,实现时直接用 lwip/sockets.h 头文件即可。因为宏映射,你写的代码跟在 Linux 上写的差不多。下面是一个最简 TCP 服务端框架:
c复制#include "lwip/sockets.h"
void tcp_server_task(void *arg) {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in local;
local.sin_family = AF_INET;
local.sin_port = htons(8080);
local.sin_addr.s_addr = INADDR_ANY;
bind(listen_fd, (struct sockaddr *)&local, sizeof(local));
listen(listen_fd, 4);
while (1) {
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &len);
if (conn_fd > 0) {
char buf[128];
int n = recv(conn_fd, buf, sizeof(buf), 0);
if (n > 0) {
send(conn_fd, buf, n, 0);
}
}
closesocket(conn_fd);
}
}
注意几个细节:hotos() 把端口号转成大端字节序,这个是网络字节序的硬要求;INADDR_ANY 相当于 0.0.0.0;lwIP 的关闭函数用 closesocket(在部分版本宏定义里等价于 close),不同版本差异要留意。还有一个关键点:lwIP 只有在你开启了 LWIP_SOCKET 并且 RTOS 环境正常运行时,上述调用才有效。调用前需要保证网卡驱动已经初始化、IP 地址已配置完成。
5.3 资源受限下的注意事项
跑一跑 demo 不难,但要在嵌入式环境稳定运行,下面几条我从实际项目中总结的经验值得参考。
- 任务栈别开太小。lwIP 的 socket 操作内部会占用较多栈空间,尤其在使用
lwip_send/lwip_recv时,栈太小容易溢出导致系统崩溃。我一般给网络任务开 2048 字节起步,具体要看你的 TCP 窗口和缓冲配置。 - 合理配置 lwipopts.h 宏。
MEMP_NUM_NETCONN决定最大 socket 数量,MEMP_NUM_TCP_PCB决定同时活跃 TCP 连接数,TCP_SND_BUF和TCP_WND决定收发窗口。开大了费内存,开小了容易丢连接。先算清楚你的最大并发连接数和单连接数据量,再倒推配置。 - 给 recv 加超时。嵌入式环境最怕任务卡死。用
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout))给接收操作加上限,超时后返回错误码,任务可以走分支处理,而不是永远阻塞。 - 重连要有退避。TCP 连接断了之后,不要疯狂重连,否则协议栈资源会被占满。我用的是简单指数退避:1s、2s、4s、8s,封顶 30s,断线重连基本稳定。
如果你在 FreeRTOS 里跑 lwIP,还要注意 DHCP 或静态 IP 的初始化时机。很多“连不上”的问题,不是 socket 代码写错,而是板子的网络接口还没拿到 IP 地址就开始 connect 了。初始化完成后加一个 netif_is_up 或 netif_is_link_up 检查,能在现场省下不少功夫。
最后,说点实际的。我从最早对着书本抄 socket 示例,到后来被 MySQL 的 2002 错误、连接池坏连接、嵌入式设备联调反复折磨,最大的体会是:socket 的 API 只占技术栈的一小部分,更多精力要花在对字节流、连接状态、超时和错误码的理解上。遇到连接报错,别急着改代码,先按“监听状态—网络路径—协议内容”的顺序排查,5 分钟内定不了位再回头看业务逻辑。留好 tcpdump 抓包的习惯,它会成为你排查网络问题最可靠的伙伴。
