网络编程:开发UDP服务端

上一小节,我们通过一个简单的时间查询应用,演示了 UDP 协议的通信过程。那么,像这样的 UDP 网络应用是怎么一步步开发出来的呢?这一切离不开套接字和网络编程。

为掌握套接字和 UDP 网络编程技术,我们将以 Linux 为例,用 C 语言从零开发同款时间查询应用。UDP 编程系列分为两小节,本节介绍 UDP 服务端编程,下一小节介绍 UDP 客户端编程。

套接字概述

开始之前,我们先来了解一下:什么是套接字?

套接字( socket )是操作系统提供的一种软件实体,它充当通信端点,负责在网络中收发数据。套接字一般由进程申请创建,它的内部结构和属性由网络协议栈编程接口决定。

不同的网络协议栈,都有自己的套接字。随着互联网的发展,TCP/IP 协议栈成了网络通信的标准。因此,我们提到套接字,不加特殊说明,都是指 TCP/IP 协议栈下的套接字。

那么,一个 TCP/IP 套接字都包含哪些要素呢?下面,我们罗列出其中最为关键的几个:

  • 传输层协议,即该套接字使用的协议,比如:UDP;
  • IP地址,即该套接字使用的网络层地址;
  • 端口,即该套接字使用的传输层端口号;
  • 发送缓冲区,当网络拥塞时,应用程序发送的数据可以暂存在该缓冲区( UDP 套接字没有发送缓冲区);
  • 接收缓冲区,当应用进程繁忙时,从网络中收到的数据可以暂存在该缓冲区;

实际上,一个 UDP 套接字可以由 传输层协议IP地址端口 这个三元组唯一确定。

那么,如何创建并使用套接字进行网络通信呢?套接字一般通过系统调用进行操作,以 Linux 为例:

系统调用 功能
socket 创建一个套接字(UDP套接字类型为:SOCK_DGRAM
bind 将套接字与指定地址端口对进行绑定
sendto 使用套接字发送数据
recvfrom 使用套接字接收数据
close 关闭套接字

系统调用( syscall )是操作系统提供给应用程序的编程接口。应用进程需要请求操作系统服务时,将发起系统调用,然后陷入内核态,由操作系统执行处理逻辑。

UDP 服务端编程步骤

我们只讲解关键步骤相关代码,程序的完整代码可从 GitHub 上获取。

创建套接字

UDP 服务端应用首先要执行 socket 系统调用,创建一个套接字:

1
2
3
4
5
int s = socket(PF_INET, SOCK_DGRAM, 0);
if (s == -1) {
    perror("Failed to create socket");
    return -1;
}
  • family 协议族应该是 PF_INET ,表示 IPv4 ;
  • type 类型应该是 SOCK_DGRAM ,表示无连接数据报式( UDP );
  • protocol 指定协议,每个协议族下同一类型的协议通常只有一种,因此这个参数一般传 0 即可;

socket 系统调用执行成功,将返回一个整数,这是套接字的文件描述符。后续,程序可以通过这个描述符对套接字进行操作。

Linux 一切皆文件,每个进程都有一个 打开文件表 ,通过指针保存进程打开的文件、套接字或者管道等等。文件描述符是一个整数,它可以理解成一个下标,通过该下标即可找到对应的对象。

套接字创建成功后,进程可以通过文件描述符对它进行操作,包括 bind 绑定地址、recvfrom 接收数据以及 sendto 发送数据等等。注意到,套接字刚创建好时,三元组中只有传输层协议是确定的。

绑定地址端口

接下来,UDP 服务端必须指定监听端口,例如 12345 端口。那具体怎么做呢?

首先,应用程序需要先准备一个 sockaddr_in 类型结构体 ,并填充好监听地址和端口:

1
2
3
4
5
6
struct sockaddr_in bind_addr;
bzero(&bind_addr, sizeof(bind_addr));

bind_addr.sin_family = AF_INET;
bind_addr.sin_addr.s_addr = INADDR_ANY;
bind_addr.sin_port = htons(arguments->port);

注意到,我们在填充端口号前,必须先将它的字节序转换成网络序。

如果一台主机有多个 IP 地址,那么可以指定套接字只接收发往某个 IP 的 UDP 报文。如果想接收全部 UDP 报文,不限目的 IP ,可以将套接字绑定到通配地址 0.0.0.0 ,即 INADDR_ANY

sockaddr_in 结构体准备好后,即可执行 bind 系统调用,将套接字和给定地址端口进行绑定:

1
2
3
4
5
// bind socket with given port
if (bind(s, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) == -1) {
    perror("Failed to bind address");
    return -1;
}
  • sockfd ,套接字的文件描述符;
  • addr ,sockaddr_in 地址结构体的地址;
  • addrlen ,sockaddr_in 地址结构体的长度;

bind 系统调用执行成功,套接字三元组中的 IP 地址和端口就都确定了。此后,发往这个 IP 和端口的 UDP 报文,将提交给该套接字。

接收请求报文

UDP 套接字绑定 IP 地址和端口后,即可执行 recvfrom 系统调用,来接收其他主机发过来的报文。

开始之前,需要准备一个用于接收数据的缓冲区。由于时间查询请求由 time_request 结构体表示,我们需要定义一个 time_request 结构体变量:

1
2
// buffer for storing a request
struct time_request request;

为确定数据是谁发的,我们还需要定义一个 sockaddr_in 结构体,来保存请求者的 IP 地址和端口:

1
2
3
// buffer for storing peer address
struct sockaddr_in peer_addr;
int addr_len = sizeof(peer_addr);

准备好后即可调用 recvfrom 接收其他主机发来的 UDP 数据,只需将套接字描述符、数据缓冲区、来源地址缓冲区传给 recvfrom 即可:

1
2
// receive request from client
int bytes = recvfrom(s, &request, sizeof(request), 0, (struct sockaddr *)&peer_addr, &addr_len);
  • sockfd ,套接字的文件描述符;
  • buf ,用于保存数据的缓冲区地址;
  • len ,数据缓冲区长度;
  • flags ,一些标志位,这里传 0 即可,先不展开介绍;
  • src_addr ,用于保存源地址、端口的缓冲区地址;
  • addr_len ,地址缓冲区长度;

在默认的阻塞模式下,recvfrom 将一直等待,直到套接字上有 UDP 数据到达。

那么,UDP 数据报从到达主机,到最终被应用进程读取,整个流程大概是怎样的呢?

  1. 网卡收到一个数据链路层帧,比如以太网帧;
  2. 帧数据中搭载着一个 IP 包,操作系统将 IP 包取出;
  3. 协议栈进一步检查 IP 包中的 UDP 数据报,根据传输层协议、目的地址、目的端口找到对应的套接字,并将新包写入接收缓冲区;
  4. 应用程序执行 recvfrom 系统调用,接收 UDP 数据报,操作系统从接收缓冲区中取出一个,并将其返回;
  5. 在 recvfrom 系统调用执行期间,操作系统将 UDP 数据,从内核空间拷贝到用户进程准备好的缓冲区中;
  6. 同时,操作系统将源地址和源端口,从内核空间拷贝到用户进程准备好的缓冲区中;
  7. 至此,进程成功收到 UDP 数据,并获悉发送者的地址和端口;

发送应答报文

服务端接到请求后,对请求进行处理,并组织应答。时间查询应答由 time_reply 结构体表示,因此我们需要定义一个 time_reply 结构体变量,并将处理结果填充进去:

1
2
3
4
5
6
// buffer for storing reply
struct time_reply reply;
bzero(&reply);

// fill results
// ...

应答数据准备完毕,即可执行 sendto 系统调用,将应答通过 UDP 数据报发送出去。注意到,我们需要将套接字描述符、应答数据缓冲区、以及接收方地址端口传给 sendto :

1
2
3
4
5
// send reply back to client
if (sendto(s, &reply, reply_len, 0, (struct sockaddr *)&peer_addr, sizeof(peer_addr)) == -1) {
    perror("Failed to send");
    return -1;
}
  • sockfd ,套接字的文件描述符;
  • buf ,待发送数据的地址;
  • len ,数据长度;
  • flags ,一些标志位,这里传 0 即可,先不展开介绍;
  • src_addr ,保存目的地址结构体的指针;
  • addr_len ,目的地址结构体长度;

其中,应答接收方其实就是请求的发起方。服务端接收请求时,recvfrom 将请求方 IP 地址和端口返回给我们。

那么,从应用进程发起 sendto 系统调用,到 UDP 数据报最终被发送出去,整个流程又是怎样的呢?

  1. 应用进程发起 sendto 系统调用,通知套接字发送数据,并将目的地址端口和数据告诉内核;
  2. 操作系统根据套接字和 sendto 系统调用参数,封装 IP 包;
    1. 协议栈先封装 UDP 数据报,目的端口和数据来自 sendto 参数,源端口来自套接字本端三要素;
    2. 协议栈为 UDP 数据报打上 IP 头部,借助 IP 协议将其送至目标主机,目的地址来自 sendto 参数,源地址来自套接字本端三要素;
    3. 如果套接字本端绑定的是通配地址 0.0.0.0 ,内核将根据路由规则,选择一个出口 IP 作为源地址;
  3. IP 包需要借助链路层的传输能力,发送到下一个节点,因此协议栈为它打上帧头,才能发送出去;
    1. 根据路由规则,IP包可能可以直接发给目标主机(本地网),也可能需要发给中间路由;
    2. 不管怎样,协议栈都需要根据 IP 地址,找出下一跳的 MAC 地址,这是 ARP 协议负责的范畴;
    3. 目的 MAC 地址确定后,即可完成以太网帧的封装,源地址是发送网卡的地址;
  4. 协议栈调用网卡驱动,将以太网帧发送出去;

循环处理请求

UDP 服务器的主体执行逻辑一般是一个 无限循环 ( infinite loop )。每次循环时,服务端先接收请求,然后处理请求,最后组织并发送应答,伪代码大致如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
for (;;) {
    // receiving request
    struct time_request request;

    struct sockaddr_in peer_addr;
    int addr_len = sizeof(peer_addr);

    recvfrom(s, &request, sizeof(request), 0, (struct sockaddr *)&peer_addr, &addr_len);

    // processing request
    // ...

    // sending reply
    struct time_reply reply;
    bzero(&reply);

    sendto(s, &reply, reply_len, 0, (struct sockaddr *)&peer_addr, sizeof(peer_addr)) == -1);
}

编译程序

上面只介绍了时间查询服务端的关键步骤,完整代码可从 GitHub 上获取。程序的逻辑并不复杂,结合注释即可轻松理解,这里不再赘述。

代码目录中还有一个源文件 argparse.c ,包含命令行选项解析逻辑。

另外,还有一个 Makefile ,用来构建程序。服务端可以这样构建:

1
make server

如果你想自己编译程序,可以执行 gcc 命令:

1
gcc -o server server.c argparse.c

扩展阅读

小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注:

【小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注: