libuv 网络编程实战:TCP、UDP、DNS 解析与网卡信息获取
【免费下载链接】libuvCross-platform asynchronous I/O项目地址: https://gitcode.com/gh_mirrors/li/libuv
libuv 是一套跨平台异步 I/O 库,其网络编程能力建立在 BSD socket 概念之上:与直接使用 socket 接口相比,所有操作均是非阻塞的,并额外提供了地址转换、DNS 查询、socket 参数调节等工具函数,把繁琐的底层细节抽象掉。本文以 libuv 官方指南的网络章节为骨架,结合本仓库的示例代码(docs/code)与头文件定义(include/uv.h),完整演示如何用uv_tcp_t、uv_udp_t写出 TCP 回显服务器/客户端、UDP 广播收发、异步 DNS 解析,并枚举系统网络接口。
说明:本文的示例代码主要用于演示 libuv API 的用法,并非高质量工程代码——它们会泄漏内存、也不总是正确关闭连接,请勿直接用于生产环境。
TCP:基于流(Stream)的面向连接通信
TCP 是面向连接的流式协议,因此它在 libuv 中直接建立在流(stream)基础设施之上。核心句柄是uv_tcp_t,它同时服务于服务器端与客户端。
服务器:init → bind → listen → accept
服务器端 socket 的建立遵循四个步骤:
- 用
uv_tcp_init初始化 TCP 句柄; - 用
uv_tcp_bind绑定地址与端口; - 调用
uv_listen注册回调,每当客户端发起新连接时回调被触发; - 在回调中用
uv_accept接受连接; - 之后通过[流操作]与客户端通信(读/写)。
下面的监听 socket 初始化代码来自 docs/code/tcp-echo-server/main.c:
uv_tcp_t server; uv_tcp_init(loop, &server); uv_ip4_addr("0.0.0.0", DEFAULT_PORT, &addr); uv_tcp_bind(&server, (const struct sockaddr*)&addr, 0); int r = uv_listen((uv_stream_t*) &server, DEFAULT_BACKLOG, on_new_connection); if (r) { fprintf(stderr, "Listen error %s\n", uv_strerror(r)); return 1; } return uv_run(loop, UV_RUN_DEFAULT);其中uv_ip4_addr("0.0.0.0", DEFAULT_PORT, &addr)把"人类可读的 IP + 端口"转换为 BSD socket API 需要的struct sockaddr_in,这是最常用的工具函数,定义于 include/uv.h。反向转换(从sockaddr_in得到字符串 IP)用uv_ip4_name;IPv6 对应地有uv_ip6_addr/uv_ip6_name两个变体。
大部分"搭建"类函数(init、bind)是同步的,因为它们属于 CPU 密集型的本地操作。真正回到 libuv 回调风格的是uv_listen:其第二个参数是 backlog 队列长度,即内核中排队等待 accept 的连接最大数目,示例中取DEFAULT_BACKLOG = 128。
当客户端发起连接时,on_new_connection回调被调用(见 docs/code/tcp-echo-server/main.c):
void on_new_connection(uv_stream_t *server, int status) { if (status < 0) { fprintf(stderr, "New connection error %s\n", uv_strerror(status)); return; } uv_tcp_t *client = (uv_tcp_t*) malloc(sizeof(uv_tcp_t)); uv_tcp_init(loop, client); if (uv_accept(server, (uv_stream_t*) client) == 0) { uv_read_start((uv_stream_t*) client, alloc_buffer, echo_read); } else { uv_close((uv_handle_t*) client, on_close); } }要点:
- 回调必须在每个新连接上新建一个客户端句柄,并通过
uv_accept与服务器句柄关联; uv_accept返回 0 表示成功,此时即可调用uv_read_start建立对该流的读兴趣;- 若不想接受该连接(如业务上拒绝),可以直接在
uv_listen回调里对客户端句柄调用uv_close关闭; - 后续的读回调(
uv_read_start的 alloc/read 回调)与写请求(uv_write)与通用流示例完全一致,区别只在句柄类型。回显逻辑见echo_read/echo_write,注意写请求需要自行管理uv_write_t及其缓冲区的生命周期(示例中用write_req_t把请求与uv_buf_t打包在一起)。
客户端:一步 uv_tcp_connect
与服务器端的 bind/listen/accept 流程相对,客户端只需调用uv_tcp_connect,并使用与uv_listen相同风格的uv_connect_cb回调:
uv_tcp_t* socket = (uv_tcp_t*)malloc(sizeof(uv_tcp_t)); uv_tcp_init(loop, socket); uv_connect_t* connect = (uv_connect_t*)malloc(sizeof(uv_connect_t)); struct sockaddr_in dest; uv_ip4_addr("127.0.0.1", 80, &dest); uv_tcp_connect(connect, socket, (const struct sockaddr*)&dest, on_connect);连接建立后on_connect被调用(uv_tcp_connect与回调签名见 include/uv.h)。回调接收的uv_connect_t结构体含有一个.handle成员,指向发起连接的 socket,随后即可对它执行uv_read_start等流操作。完整的客户端示例(连接irc.libera.chat)见下文 DNS 一节。
UDP:无连接、不可靠的数据报通信
UDP 提供无连接、不可靠的网络通信,因此 libuv 不为其提供流接口,而是通过uv_udp_t句柄(负责接收)和uv_udp_send_t请求(负责发送)及相关函数实现非阻塞 UDP 支持。虽然底层模型不同,但读/写的 API 风格与普通流读非常接近。
本仓库的 docs/code/udp-dhcp/main.c 演示了从 DHCP 服务器获取 IP 地址的第一阶段——发送 DHCP Discover 广播包。
注意:
udp-dhcp示例需要以root身份运行,因为它使用了低于 1024 的知名端口。
发送与接收 socket 的搭建
uv_udp_init(loop, &recv_socket); struct sockaddr_in recv_addr; uv_ip4_addr("0.0.0.0", 68, &recv_addr); uv_udp_bind(&recv_socket, (const struct sockaddr *)&recv_addr, UV_UDP_REUSEADDR); uv_udp_recv_start(&recv_socket, alloc_buffer, on_read); uv_udp_init(loop, &send_socket); struct sockaddr_in broadcast_addr; uv_ip4_addr("0.0.0.0", 0, &broadcast_addr); uv_udp_bind(&send_socket, (const struct sockaddr *)&broadcast_addr, 0); uv_udp_set_broadcast(&send_socket, 1); uv_udp_send_t send_req; uv_buf_t discover_msg = make_discover_msg(); struct sockaddr_in send_addr; uv_ip4_addr("255.255.255.255", 67, &send_addr); uv_udp_send(&send_req, &send_socket, &discover_msg, 1, (const struct sockaddr *)&send_addr, on_send);代码中几个地址语义需要分清:
0.0.0.0表示绑定到所有网卡接口;255.255.255.255是广播地址,意味着数据报会发送到子网内的所有接口;- 端口
0表示由操作系统随机分配一个端口。
接收 socket 绑定在 68 端口(DHCP 客户端端口)并对所有接口监听,用于读回任何 DHCP 服务器的响应;绑定参数UV_UDP_REUSEADDR(值见 include/uv.h)让本程序能与机器上其他系统 DHCP 客户端共享同一端口。发送 socket 则用uv_udp_send向 67 端口(DHCP 服务器端口)发送广播报文。
广播标志是必须设置的:如果不调用uv_udp_set_broadcast(&send_socket, 1),发送广播时会得到EACCES错误。与流 I/O 一样,读/写回调都会在出错时收到小于 0 的 status 码。示例中具体发送的 DHCP 报文内容(BOOTREQUEST、XID、CHADDR、magic cookie、DHCPDISCOVER 选项等)与本书主题无关,可自行阅读 docs/code/udp-dhcp/main.c 的make_discover_msg。
读回调:数据报来源与截断标志
由于 UDP socket 不与特定对端建立连接,读回调比流读回调多一个发送者地址参数:
void on_read(uv_udp_t *req, ssize_t nread, const uv_buf_t *buf, const struct sockaddr *addr, unsigned flags) { if (nread < 0) { fprintf(stderr, "Read error %s\n", uv_err_name(nread)); uv_close((uv_handle_t*) req, NULL); free(buf->base); return; } char sender[17] = { 0 }; uv_ip4_name((const struct sockaddr_in*) addr, sender, 16); fprintf(stderr, "Recv from %s\n", sender); // ... 解析 DHCP 响应 ... free(buf->base); uv_udp_recv_stop(req); }读回调各参数的含义:
nread:读取到的字节数;为 0 表示没有更多数据可读;addr:数据报的发送者地址;若为NULL表示当前没有可读内容(回调此时不应做任何处理),若不为NULL则表示收到来自该主机的一个空数据报;flags:当 alloc 回调提供的缓冲区不足以容纳全部数据时,该参数为UV_UDP_PARTIAL,此时放不下的数据会被操作系统直接丢弃(这正是 UDP 不可靠的体现)。该标志定义于 include/uv.h。
UDP 选项
uv_udp_t支持调节多项 socket 参数(全部声明于 include/uv.h):
生存时间(TTL)
用uv_udp_set_ttl(uv_udp_t* handle, int ttl)修改 socket 上发出的数据报的 TTL。
仅限 IPv6 栈
IPv6 socket 默认可以同时承载 IPv4 与 IPv6 通信(双栈模式)。若希望把 socket 限制为只处理 IPv6,在uv_udp_bind时传入UV_UDP_IPV6ONLY标志即可(对应enum uv_udp_flags中的UV_UDP_IPV6ONLY = 1)。
多播(Multicast)
socket 可以订阅/退订多播组:
int uv_udp_set_membership(uv_udp_t* handle, const char* multicast_addr, const char* interface_addr, uv_membership membership);其中membership取UV_JOIN_GROUP(加入)或UV_LEAVE_GROUP(退出)。此外:
- 多播数据包的本地回环默认是开启的,可用
uv_udp_set_multicast_loop关闭; - 多播数据包的 TTL 用
uv_udp_set_multicast_ttl修改; - 若接口上存在多个地址,可用
uv_udp_set_multicast_interface指定发往多播组的源接口地址; - 需要携带源地址过滤时,还有
uv_udp_set_source_membership(SSM 模型)。
异步 DNS 解析:uv_getaddrinfo 与 uv_getnameinfo
libuv 提供自己的getaddrinfo替代实现,实现异步 DNS 解析:在回调中即可对解析出的地址执行正常的 socket 操作,无需阻塞事件循环。
解析并连接:以 irc.libera.chat 为例
docs/code/dns/main.c 演示了完整的"解析 → 连接 → 读取"流程:
struct addrinfo hints; hints.ai_family = PF_INET; hints.ai_socktype = SOCK_STREAM; hints.ai_protocol = IPPROTO_TCP; hints.ai_flags = 0; uv_getaddrinfo_t resolver; fprintf(stderr, "irc.libera.chat is... "); int r = uv_getaddrinfo(loop, &resolver, on_resolved, "irc.libera.chat", "6667", &hints); if (r) { fprintf(stderr, "getaddrinfo call error %s\n", uv_err_name(r)); return 1; } return uv_run(loop, UV_RUN_DEFAULT);使用要点(函数签名见 include/uv.h):
- 若
uv_getaddrinfo返回非零值,说明参数设置阶段就出了问题,回调将完全不会被调用; hostname、servname与hints的结构语义与 POSIXgetaddrinfo(3)一致(ai_family、ai_socktype、ai_protocol、ai_flags等);- 所有参数(字符串与 hints)在
uv_getaddrinfo返回后即可安全释放,libuv 内部已做拷贝; - 若回调传
NULL,函数会同步执行解析; - 解析失败时错误码通过回调的
status返回(负值),可用uv_err_name打印。
在回调中连接并释放结果
解析回调里可以从struct addrinfo链表中任选一个地址发起连接,同时演示了客户端uv_tcp_connect的完整用法(docs/code/dns/main.c):
void on_resolved(uv_getaddrinfo_t *resolver, int status, struct addrinfo *res) { if (status < 0) { fprintf(stderr, "getaddrinfo callback error %s\n", uv_err_name(status)); return; } char addr[17] = {'\0'}; uv_ip4_name((struct sockaddr_in*) res->ai_addr, addr, 16); fprintf(stderr, "%s\n", addr); uv_connect_t *connect_req = (uv_connect_t*) malloc(sizeof(uv_connect_t)); uv_tcp_t *socket = (uv_tcp_t*) malloc(sizeof(uv_tcp_t)); uv_tcp_init(loop, socket); uv_tcp_connect(connect_req, socket, (const struct sockaddr*) res->ai_addr, on_connect); uv_freeaddrinfo(res); }必须调用uv_freeaddrinfo(res)释放 libuv 内部 malloc 的地址链表,否则会泄漏内存。连接成功后on_connect通过req->handle拿到 socket,即可uv_read_start开始读数据。
底层实现:线程池中的系统 getaddrinfo
从源码看,libuv 的异步解析是把系统调用搬到线程池里执行:src/unix/getaddrinfo.c 中当传入回调时调用uv__work_submit(loop, &req->work_req, UV__WORK_SLOW_IO, uv__getaddrinfo_work, uv__getaddrinfo_done),即通过 libuv 线程池异步执行系统getaddrinfo,完成后把结果投递回事件循环,在uv__getaddrinfo_done中调用用户回调。v0.9.0 之前的旧版本曾内置 c-ares 作为备选解析器,但已被移除,如今统一使用系统getaddrinfo(v0.8.0 及更早版本同时携带 c-ares 备选方案)。
反向解析:uv_getnameinfo
libuv 还提供与解析相反的操作uv_getnameinfo(声明见 include/uv.h):给定struct sockaddr*地址,在回调中返回主机名与端口服务名,适用于日志记录、反向 DNS 查询等场景。
枚举网络接口:uv_interface_addresses
服务启动时往往需要绑定到本机的具体 IP 地址,libuv 用uv_interface_addresses提供系统网络接口信息。docs/code/interfaces/main.c 打印全部接口详情:
char buf[512]; uv_interface_address_t *info; int count, i; uv_interface_addresses(&info, &count); i = count; printf("Number of interfaces: %d\n", count); while (i--) { uv_interface_address_t interface_a = info[i]; printf("Name: %s\n", interface_a.name); printf("Internal? %s\n", interface_a.is_internal ? "Yes" : "No"); if (interface_a.address.address4.sin_family == AF_INET) { uv_ip4_name(&interface_a.address.address4, buf, sizeof(buf)); printf("IPv4 address: %s\n", buf); } else if (interface_a.address.address4.sin_family == AF_INET6) { uv_ip6_name(&interface_a.address.address6, buf, sizeof(buf)); printf("IPv6 address: %s\n", buf); } printf("\n"); } uv_free_interface_addresses(info, count);字段说明(结构体定义见 include/uv.h):
name:接口名(如eth0、lo);phys_addr[6]:MAC 物理地址;is_internal:是否为回环(loopback)等内部接口;address:联合体,存放struct sockaddr_in(IPv4)或struct sockaddr_in6(IPv6),按sin_family区分;netmask:对应的子网掩码,同样按地址族联合存放。
两个注意点:
- 若一个物理接口上配了多个 IPv4/IPv6 地址,该接口的
name会出现多次,每次对应其中一个地址; - 使用完毕后必须调用
uv_free_interface_addresses(info, count)释放由 libuv 分配的数组内存(对应声明见 include/uv.h)。
小结
libuv 的网络编程保持了与 BSD socket 一致的心智模型,但把非阻塞、地址转换、DNS 解析等细节全部封装进事件循环:TCP 走 init/bind/listen/accept(服务端)或一步uv_tcp_connect(客户端)的流模型;UDP 通过uv_udp_t+uv_udp_send_t收发数据报,并支持 TTL、IPv6-only、多播等 socket 选项;uv_getaddrinfo/uv_getnameinfo把系统 DNS 解析搬到线程池实现异步化;uv_interface_addresses则让服务在启动时就能感知本机网卡与地址。结合 docs/code 下tcp-echo-server、udp-dhcp、dns、interfaces四个完整示例以及 include/uv.h 中的 API 声明,即可快速搭建自己的跨平台异步网络程序。更多进阶用法(句柄/请求模型、缓冲区与流操作、事件循环机制)可继续阅读 docs/src/guide 下的其他章节。
【免费下载链接】libuvCross-platform asynchronous I/O项目地址: https://gitcode.com/gh_mirrors/li/libuv
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考