4. TCP客户端编程:从零搭建一个能收发数据的客户端

TCP客户端,说白了就是主动发起连接的那一方。在Zephyr里写TCP客户端,流程其实挺固定的——创建socket、连服务器、收发数据、关闭连接。我刚开始接触Zephyr网络栈时,觉得这东西跟Linux的socket编程差不多,但实际踩了几个坑才发现,嵌入式环境下的TCP客户端有很多细节需要注意。

这一章,我们就手把手把TCP客户端的完整流程走一遍。

4.1 TCP客户端创建流程

先看整体流程,心里有个谱:

TCP客户端核心流程 1. socket() 创建套接字 2. connect() 连接服务器 3. send() 发送数据 4. recv() 接收数据 5. close() 关闭连接 关键点 • socket类型选 SOCK_STREAM • 协议族用 AF_INET • 连接前先配好 sockaddr_in 注意 • send/recv 可能部分收发 • 需要循环处理返回值 收尾 • 记得 close 释放资源

嗯,流程看着简单吧?但每个步骤都有坑。我们一个一个拆开讲。

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让系统自动选
我的习惯:第三个参数我一般写IPPROTO_TCP,不偷懒传0。虽然效果一样,但代码可读性更好,别人一看就知道你要用TCP。

这里有个容易忽略的点——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的超时时间由底层驱动决定,一般几十秒。
我曾经踩过的坑:有一次我在一个低功耗项目里用TCP客户端,connect之后没检查返回值就直接发数据。结果服务器没开机,connect返回-1,程序继续往下走,send也失败,整个逻辑全乱了。记住:每次API调用都要检查返回值,别偷懒。

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;
}
补充一点:这个例子是同步阻塞模式,适合简单的请求-响应场景。如果要做更复杂的并发客户端,可以用非阻塞模式加poll/select,或者用Zephyr的socket offload。这些后面章节会讲到。

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客户端才能稳定可靠地工作。