4. TCP客户端编程:从零搭建一个能收发数据的客户端
TCP客户端,说白了就是主动发起连接的那一方。在Zephyr里写TCP客户端,流程其实挺固定的——创建socket、连服务器、收发数据、关闭连接。我刚开始接触Zephyr网络栈时,觉得这东西跟Linux的socket编程差不多,但实际踩了几个坑才发现,嵌入式环境下的TCP客户端有很多细节需要注意。
这一章,我们就手把手把TCP客户端的完整流程走一遍。
4.1 TCP客户端创建流程
先看整体流程,心里有个谱:
嗯,流程看着简单吧?但每个步骤都有坑。我们一个一个拆开讲。
4.2 创建socket——选对参数是关键
创建socket的API长这样:
int sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock < 0) {
printk("socket创建失败,错误码: %d\n", errno);
return -1;
}
三个参数的含义:
| 参数 | 取值 | 说明 |
|---|---|---|
| domain | AF_INET | IPv4协议族,Zephyr目前主要支持这个 |
| type | SOCK_STREAM | 流式套接字,TCP专用 |
| protocol | IPPROTO_TCP | 指定TCP协议,也可以传0让系统自动选 |
这里有个容易忽略的点——Zephyr的socket创建失败时,错误码存在全局变量errno里。我遇到过几次创建失败的情况,打印出来一看,要么是内存不够,要么是协议栈没初始化好。
4.3 连接服务器——填对地址结构体
创建完socket,下一步就是连服务器。连接前需要准备好服务器的地址信息:
struct sockaddr_in server_addr = {0};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080); // 服务器端口
inet_pton(AF_INET, "192.168.1.100", &server_addr.sin_addr);
int ret = connect(sock, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (ret < 0) {
printk("连接失败,错误码: %d\n", errno);
close(sock);
return -1;
}
这里有几个细节要注意:
- 端口一定要用htons转换——网络字节序是大端,而你的CPU可能是小端。我见过有人忘了转换,结果连了半天连不上,查了一下午才发现端口号反了。
- IP地址用inet_pton——这个函数把点分十进制字符串转成二进制。Zephyr里也可以用
inet_addr,但inet_pton更通用,IPv6也能用。 - connect是阻塞的——默认情况下,connect会一直等到连接建立或超时。Zephyr的超时时间由底层驱动决定,一般几十秒。
4.4 发送数据——别以为一次就能发完
连接建立后,就可以用send发数据了:
const char *msg = "Hello from Zephyr!";
int sent = send(sock, msg, strlen(msg), 0);
if (sent < 0) {
printk("发送失败\n");
} else {
printk("成功发送 %d 字节\n", sent);
}
但这里有个经典问题——send不一定一次发完所有数据。TCP是流协议,底层可能因为缓冲区满、窗口大小等原因只发送了部分数据。所以正确的做法是循环发送:
int send_all(int sock, const char *data, int len) {
int total_sent = 0;
while (total_sent < len) {
int ret = send(sock, data + total_sent, len - total_sent, 0);
if (ret < 0) {
printk("send出错: %d\n", errno);
return -1;
}
total_sent += ret;
}
return total_sent;
}
这个send_all函数我几乎每个项目都会写一份。你想想看,如果发一个几百字节的包,一次send就搞定了,但万一发几K的数据呢?不循环发送,数据就丢了。
4.5 接收数据——同样需要循环
接收数据用recv,和send类似,也可能只收到部分数据:
char buf[256];
int received = recv(sock, buf, sizeof(buf) - 1, 0);
if (received > 0) {
buf[received] = '\0';
printk("收到: %s\n", buf);
} else if (received == 0) {
printk("服务器关闭连接\n");
} else {
printk("recv出错: %d\n", errno);
}
recv的返回值有三种情况:
| 返回值 | 含义 | 处理方式 |
|---|---|---|
| > 0 | 收到数据,返回实际字节数 | 处理数据,可能继续recv |
| 0 | 对方优雅关闭连接 | 关闭本地socket,结束通信 |
| < 0 | 出错 | 检查errno,决定重试还是关闭 |
实际项目中,我一般会写一个recv_all函数,跟send_all对应,确保收完指定长度的数据。特别是做协议解析时,比如先收4字节的包头,再根据包头里的长度收包体,这种场景必须循环recv。
4.6 错误处理——别让程序默默死掉
错误处理是TCP客户端里最容易忽视的部分。我见过不少代码,socket创建失败就直接return,连错误码都不打印。这在调试时简直要命。
常见的错误码和处理建议:
| errno | 含义 | 处理建议 |
|---|---|---|
| EINTR | 被信号中断 | 重试当前操作 |
| EAGAIN/EWOULDBLOCK | 非阻塞模式下暂时不可操作 | 稍后重试 |
| ECONNRESET | 连接被对端重置 | 关闭socket,重新连接 |
| ETIMEDOUT | 连接超时 | 检查网络,重试连接 |
| ENOMEM | 内存不足 | 检查堆大小,释放资源 |
我的错误处理原则:
- 每个API调用后都检查返回值
- 出错时打印errno和上下文信息
- 可恢复的错误(如EINTR)重试
- 不可恢复的错误(如ECONNRESET)关闭socket
- 不要吞掉错误——让上层知道发生了什么
4.7 完整示例——一个能用的TCP客户端
把上面的知识点串起来,写一个完整的TCP客户端:
#include <zephyr/kernel.h>
#include <zephyr/net/socket.h>
#include <zephyr/printk.h>
#define SERVER_PORT 8080
#define SERVER_IP "192.168.1.100"
#define BUF_SIZE 256
int tcp_client_demo(void) {
int sock, ret;
struct sockaddr_in server_addr;
char buf[BUF_SIZE];
// 1. 创建socket
sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock < 0) {
printk("socket() failed, errno: %d\n", errno);
return -1;
}
printk("socket创建成功\n");
// 2. 准备服务器地址
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERVER_PORT);
inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr);
// 3. 连接服务器
ret = connect(sock, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (ret < 0) {
printk("connect() failed, errno: %d\n", errno);
close(sock);
return -1;
}
printk("连接服务器成功\n");
// 4. 发送数据
const char *msg = "Hello Zephyr TCP!";
ret = send(sock, msg, strlen(msg), 0);
if (ret < 0) {
printk("send() failed, errno: %d\n", errno);
close(sock);
return -1;
}
printk("发送了 %d 字节\n", ret);
// 5. 接收数据
ret = recv(sock, buf, sizeof(buf) - 1, 0);
if (ret > 0) {
buf[ret] = '\0';
printk("收到服务器回复: %s\n", buf);
} else if (ret == 0) {
printk("服务器关闭了连接\n");
} else {
printk("recv() failed, errno: %d\n", errno);
}
// 6. 关闭连接
close(sock);
printk("连接已关闭\n");
return 0;
}
4.8 避坑指南
最后总结几个我实际项目中遇到的坑:
- 忘记初始化地址结构体——struct sockaddr_in定义后一定要memset清零,否则sin_zero里的垃圾数据可能导致奇怪的问题。
- 端口号搞反字节序——记住:网络字节序是大端,x86/ARM通常是小端。htons/htonl是必须的。
- recv返回0没处理——返回0表示对方关闭连接,这时候应该close(sock)并退出,而不是继续recv。
- send/recv没循环——TCP是流协议,一次调用不一定能收发完所有数据。写个循环包装函数一劳永逸。
- 错误码没打印——errno是调试的救命稻草。每个API失败时都打印出来,能省大量排查时间。
嗯,TCP客户端编程的核心内容就这些。说白了就是socket、connect、send、recv、close这五个API,但每个API背后都有不少细节。把这些细节处理好,你的TCP客户端才能稳定可靠地工作。