3. 开发环境搭建

Socket编程,说白了就是让两个设备通过网络互相通信。在Zephyr里,它用的是标准的BSD Socket API——这套接口在Linux、FreeBSD上都能见到。我刚开始接触Zephyr时,发现它居然也支持这套API,心里踏实了不少。毕竟,会了这套接口,你在嵌入式网络开发里就算入了门。

这一节,我们就把socket()、bind()、listen()、accept()、connect()这几个核心函数掰开揉碎,再聊聊阻塞与非阻塞模式。嗯,都是实战里天天用的东西。

3.1 Zephyr BSD Socket API 概览

Zephyr的Socket API定义在 zephyr/net/socket.h 头文件中。它基本遵循POSIX标准,但做了一些嵌入式场景下的裁剪。我个人习惯把它的核心函数分成三类:

分类 函数 作用
创建与关闭 socket(), close() 创建/销毁套接字描述符
地址绑定与连接 bind(), connect(), listen() 绑定地址、发起连接、监听
数据传输 send(), recv(), sendto(), recvfrom() 发送和接收数据

你想想看,TCP通信就像打电话,UDP通信就像寄明信片。TCP需要先建立连接(拨号),然后可靠地传输数据;UDP则直接丢出去,不管对方收没收到。在Zephyr里,这两者的API用法基本一致,只是参数稍有不同。

关键点:Zephyr的Socket API默认是阻塞模式。这意味着如果你调用recv()但对方没发数据,线程会一直等在那里。后面我们会讲怎么改成非阻塞。

3.2 socket():创建套接字

所有网络通信的第一步,都是调用socket()创建一个套接字描述符。它的原型很简单:

int socket(int family, int type, int protocol);

三个参数分别指定:

  • family:地址族。IPv4用 AF_INET,IPv6用 AF_INET6。我建议你优先用IPv4,调试起来方便。
  • type:套接字类型。TCP用 SOCK_STREAM,UDP用 SOCK_DGRAM。
  • protocol:协议。一般填0,让系统自动选择。

举个例子,创建一个TCP套接字:

int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) {
    printk("socket创建失败,错误码:%d\n", errno);
    return -1;
}

我在项目中遇到过一个问题:Zephyr的socket()返回的是非负整数,但如果你同时开了太多套接字,它会返回-1并设置errno为ENFILE(系统文件表满)。嗯,嵌入式设备资源有限,记得及时关闭不再用的套接字。

小技巧:Zephyr的socket描述符是递增分配的。如果你发现描述符数字很大,说明前面有套接字没关。可以用 net_context_foreach() 遍历所有活跃连接来排查。

3.3 bind():绑定地址与端口

bind()的作用是把套接字和一个本地地址绑定起来。对于服务器来说,这一步是必须的——你得告诉系统,我要在哪个IP和端口上监听。对于客户端,通常可以跳过bind(),让系统自动分配一个临时端口。

int bind(int sock, const struct sockaddr *addr, socklen_t addrlen);

这里有个容易踩的坑:地址结构体的类型。IPv4用的是 struct sockaddr_in,IPv6用的是 struct sockaddr_in6。但bind()的参数类型是 struct sockaddr *,所以需要强制转换。

看一个完整的绑定示例:

struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(1883);  // MQTT默认端口
server_addr.sin_addr.s_addr = INADDR_ANY;  // 监听所有网络接口

int ret = bind(sock, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (ret < 0) {
    printk("bind失败,错误码:%d\n", errno);
    close(sock);
    return -1;
}

注意那个 htons() 函数。网络字节序是大端模式,而你的CPU可能是小端(比如ARM Cortex-M)。htons() 就是把主机字节序转换成网络字节序。我曾经忘记做这个转换,结果端口号死活对不上,排查了半天才发现是字节序的问题。

避坑指南:bind()时如果端口被占用,会返回-1并设置errno为EADDRINUSE。Zephyr默认不启用SO_REUSEADDR选项,所以你需要等一会儿才能重用同一个端口。调试时我经常遇到这个问题,重启设备后等几秒再试就好了。

3.4 listen()与accept():TCP服务器的核心

对于TCP服务器,bind()之后需要调用listen()进入监听状态,然后循环调用accept()接受客户端连接。

listen():开始监听

int listen(int sock, int backlog);

backlog参数指定连接请求队列的最大长度。Zephyr里这个值通常设得比较小,比如2或3。为什么?因为嵌入式设备内存有限,队列太长反而浪费资源。

if (listen(sock, 2) < 0) {
    printk("listen失败\n");
    close(sock);
    return -1;
}

accept():接受连接

accept()会阻塞等待,直到有客户端连接进来。它返回一个新的套接字描述符,用于和这个客户端通信。原来的监听套接字继续等待其他连接。

struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);

int client_sock = accept(sock, (struct sockaddr *)&client_addr, &client_len);
if (client_sock < 0) {
    printk("accept失败\n");
    continue;
}

printk("收到来自 %s:%d 的连接\n",
       inet_ntoa(client_addr.sin_addr),
       ntohs(client_addr.sin_port));

这里有个细节:accept()返回的client_sock和原来的sock是两个不同的描述符。记得在通信结束后close(client_sock),但不要close(sock)——除非你不想再接受新连接了。

3.5 connect():客户端发起连接

客户端的流程简单得多:创建套接字后,直接调用connect()连接服务器。

int connect(int sock, const struct sockaddr *addr, socklen_t addrlen);

示例:

struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(1883);
inet_pton(AF_INET, "192.168.1.100", &server_addr.sin_addr);

if (connect(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
    printk("连接失败,错误码:%d\n", errno);
    close(sock);
    return -1;
}

printk("连接成功!\n");

connect()在阻塞模式下会一直等到连接建立或超时。Zephyr的默认超时时间比较长,我建议你在应用层自己加一个超时机制,比如用定时器或k_sleep()轮询。

3.6 阻塞与非阻塞模式

这是很多初学者容易搞混的地方。说白了,阻塞模式就是函数调用会一直等,直到操作完成;非阻塞模式则是立即返回,告诉你“还没好,你过会儿再来问”。

特性 阻塞模式 非阻塞模式
recv()行为 一直等,直到收到数据 立即返回,没数据则返回-1,errno=EAGAIN
connect()行为 等到连接建立或失败 立即返回,连接在后台进行
CPU占用 低(线程挂起) 高(需要轮询)
适用场景 简单应用、单线程 多连接、事件驱动

在Zephyr里,设置非阻塞模式有两种方法:

方法一:创建时指定

int sock = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);

方法二:运行时设置

int flags = fcntl(sock, F_GETFL, 0);
fcntl(sock, F_SETFL, flags | O_NONBLOCK);

我个人习惯用第一种,简单直接。但要注意,非阻塞模式下的connect()返回0不代表连接成功,它可能只是表示连接正在建立。你需要用poll()或select()去检查连接状态。

实战建议:在Zephyr上做MQTT客户端时,我通常用非阻塞模式配合poll()。这样可以在等待数据的同时处理其他任务,比如按键响应、传感器读取。纯阻塞模式在简单场景下够用,但一旦需要同时处理多个连接,就捉襟见肘了。

3.7 知识结构图:Socket编程核心流程

下面这张图总结了TCP服务器和客户端的完整流程,以及阻塞/非阻塞的切换点。建议你对照着代码看,思路会更清晰。

Socket编程核心流程 Socket编程核心流程(TCP) 服务器 客户端 socket() 创建套接字 bind() 绑定地址端口 listen() 开始监听 accept() 等待连接 recv()/send() 通信 close() 关闭连接 socket() 创建套接字 connect() 连接服务器 send()/recv() 通信 close() 关闭连接 TCP三次握手 阻塞模式:函数等待操作完成才返回。非阻塞模式:函数立即返回,通过poll()/select()检查状态。 设置方法:socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0) 或 fcntl()

3.8 实战中的几个建议

最后,分享几个我在Zephyr上做Socket编程时积累的经验:

  • 错误处理别偷懒:每次系统调用后都检查返回值。嵌入式环境里,网络不稳定是常态,errno能帮你快速定位问题。
  • 注意内存占用:Zephyr的Socket实现会占用RAM,每个连接大约需要几百字节到几KB。别一口气开太多,根据你的设备内存量力而行。
  • 非阻塞+轮询是黄金组合:在单线程MCU上,非阻塞模式配合poll()可以同时处理多个连接,而且不会阻塞其他任务。我做的几个项目都是这个模式。
  • 调试时多用打印:在关键位置加printk()打印套接字状态、错误码和IP地址。等调通了再删掉,能省不少时间。