引言
网络编程就是要设计一个能够通过网络和另一个进程进行通信的程序。
进程可以简单地理解为运行中的程序。
Socket 即套接字,能够唯一确定进行通信的两个进程。至于套接字是如何关联通信双方的,不必关心。套接字由系统维护。
每一个套接字都有一个唯一的编号,称为 文件描述符。程序只需要保存好这个描述符就行了。通信过程中的任何操作都需要提供这个描述符,包括结束通信。
通信双方必须有一方扮演服务器角色,而另一方扮演客户端。客户端程序主动请求连接,而服务器程序被动接受连接。
服务器程序和客户端程序的工作流程不尽相同。本文一并介绍服务器和客户端在通信过程中的各个环节。对于服务器或客户端特有的环节,会特别说明。
创建套接字
套接字需要通过调用 socket()
函数创建。如果创建成功,socket()
函数的返回值就是套接字的文件描述符,否则是 -1
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
第一个参数用于说明 地址族。通信的前提就是能够定位另一方。如果找不到另一方,通信就无从谈起。地址族取决于通信的情形,不同的通信情形对应不同的地址族。
进程间通信的情形大致可分为:本地的两个进程基于文件系统进行通信;两个进程通过 TCP/IP 体系结构的网络进行通信。其中 TCP/IP 就有 IPv4 和 IPv6 两种地址族。
domain 取值 | 含义 |
---|---|
AF_UNIX 或 AF_LOCAL
|
本地通信 |
AF_INET |
IPv4 |
AF_INET6 |
IPv6 |
AF 即 Address Family,INET 即 Internet
后面两个参数分别说明套接字的类型和协议。对于 TCP/IP,类型是和协议一一对应的。
type 取值 | 含义 |
---|---|
SOCK_STREAM |
面向字节流,用于 TCP 协议 |
SOCK_DGRAM |
面向数据报,用于 UDP 协议 |
SOCK_RAW |
原始套接字,用于 IP、ICMP 等底层协议 |
从 Linux 内核 2.6.17 起,参数
type
还可以接受上述取值与SOCK_NONBLOCK
和SOCK_CLOEXEC
按位或的值。前者表示将 Socket 设为非阻塞;后者表示子进程中关闭该 Socket
protocol 取值 | 含义 |
---|---|
IPPROTO_TCP |
TCP 协议 |
IPPROTO_UDP |
UDP 协议 |
下面代码首先定义一个整型变量 server_sockfd
用来保存套接字的文件描述符,然后调用 socket()
函数创建一个基于 IPv4 寻址、使用面向字节流的 TCP 协议的套接字。
int server_sockfd;
server_sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (server_sockfd == -1) {
perror("Faild to create socket.");
exit(-1);
}
如果要使用的是 UDP 协议,则参数应改为:
server_sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
应该在 socket()
函数之后使用一个 if
语句。这样才能在套接字创建失败时及时发现。使用 exit()
函数需要包含头文件 stdlib.h
#include <stdlib.h>
fd 即 file descriptor 文件描述符
套接字的地址
确定了套接字的地址族、类型和协议后,还要给出套接字要关联的地址。这相当于现实中,想要见面的两个人,约定见面的地方。
对于服务器程序,就是给出所在主机的 IP 地址,并指定一个端口号来代表自己。对于客户端程序,要说明服务器的 IP 地址和服务器程序使用的端口号。
地址结构
套接字的地址用一个结构体来组织。对于 TCP/IPv4,使用的结构类型如下:
// netinet/in.h
struct sockaddr_in
{
__SOCKADDR_COMMON (sin_);
in_port_t sin_port; /* Port number. */
struct in_addr sin_addr; /* Internet address. */
/* Pad to size of `struct sockaddr'. */
unsigned char sin_zero[sizeof (struct sockaddr) -
__SOCKADDR_COMMON_SIZE -
sizeof (in_port_t) -
sizeof (struct in_addr)];
};
sin 即 sockaddr_in,in 即 Internet
第一个成员是名为 sin_family
的无符号短整型,含义同 socket()
函数的第一个参数,即地址族。
// netinet/in.h > sys/socket.h > bits/socket.h > bits/sockaddr.h
typedef unsigned short int sa_family_t;
#define __SOCKADDR_COMMON(sa_prefix) \
sa_family_t sa_prefix##family
#define __SOCKADDR_COMMON_SIZE (sizeof (unsigned short int))
第二个成员是 16 位的无符号短整型,用来保存端口号。
// stdint.h
typedef unsigned short int uint16_t;
// netinet/in.h
typedef uint16_t in_port_t;
第三个成员是一个 in_addr
类型的结构,仅有一个 32 位的无符号整型成员,IP 地址就保存于此。
// stdint.h
typedef unsigned int uint32_t;
// netinet/in.h
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};
第四个成员的存在只是为了使整个结构的长度与结构 sockaddr
保持一致。本身没有实际意义,用 0
填充即可。
下面代码首先定义一个 sockaddr_in
类型的结构变量 server_addr
,使用 memset()
函数将其全部填 0
后,填写了地址族。
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(struct sockaddr_in));
server_addr.sin_family = AF_INET;
有专门的清 0 函数 bzero()
bzero(&server_addr, sizeof(struct sockaddr_in));
使用 memset()
函数或 bzero()
函数都需要包含头文件 string.h
#include <string.h>
void *memset(void *s, int c, size_t n);
void bzero(void *s, size_t n);
网络字节序
在填写 IP 地址和端口号时,需要注意字节序。下面以整型为例讨论字节序。
一般,在内存中,整型占用 4 个字节;短整型占用 2 个字节;长整型占用 8 个字节。假设一个整数包含的字节的内存地址分别是 n、n+1、n+2、n+3……,则地址 n 就是这个整数的起始端。如果定义了一个指针指向这个整数,则指针的具体指向就是起始端。
大多数 CPU 都将最低位的字节存于起始端。例如,只有两个字节的短整型 0xff00
,其低位的字节 00
存于 n,而高位的字节 ff
存于 n+1
这样的顺序称为 小端 字节序。反过来,把最高位存于起始端则为 大端 字节序。
1 个十六进制位相当于 4 个二进制位,8 个二进制位为 1 个字节。
向网络中传输字节时,是从起始端开始传输的。不过 TCP/IP 规定,传输的第一个字节是最高位,最后一个字节才是最低位。即 0xff00
在内存中应当是这样子的:
CPU 在内存中存放字节的顺序称为 主机字节序;字节在网络中的传输顺序称为 网络字节序。TCP/IP 规定的网络字节序就是大端字节序,而主机字节序是不确定的,这就需要确保字节序列在发送之前已经转换成网络字节序。
头文件 arpa/inet.h
提供了一组函数,专门对整型的字节序进行转换。
#include <arpa/inet.h>
// 从主机字节序到网络字节序
uint32_t htonl(uint32_t hostlong) // 适用于 IPv4 地址
uint16_t htons(uint16_t hostshort) // 适用于端口号
// 从网络字节序到主机字节序
uint32_t ntohl(uint32_t netlong) // 适用于 IPv4 地址
uint16_t ntohs(uint16_t netshort) // 适用于端口号
htons 即 host to network short,htons 即 host to network long
下面代码分别向结构变量 server_addr
填写了 IP 地址和端口号。
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = INADDR_ANY;
INADDR_ANY
是一个全 0 的 IP 地址常量,在头文件 netinet/in.h
中定义。
// netinet/in.h
#define INADDR_ANY ((in_addr_t) 0x00000000)
全 0 的 IP 地址表示本机拥有的任意 IP 地址。
IP 地址通常采用点分十进制表示。例如 "127.0.0.1"
这样一个字符串。对于这种形式的 IP 地址,可以使用 inet_addr()
函数进行解析。
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
下面是一些网络编程中经常需要用到的函数。
#include <arpa/inet.h>
// 获取一个点分十进制表示的 IP 地址的网络字节序,
// 结果作为返回值
in_addr_t inet_addr(const char *cp);
// 获取一个点分十进制表示的 IP 地址的网络字节序,
// 结果存储在 inp 指向的 in_addr 结构中
int inet_aton(const char *cp, struct in_addr *inp);
// 获取一个 in_addr 结构中的 IP 地址的点分十进制表示
// 结果作为返回值
char *inet_ntoa(struct in_addr in);
与描述符绑定
这一步是针对服务器程序而言,客户端程序一般不需要此步骤。
填写好地址后,需要使用 bind()
函数将地址结构与描述符绑定。
#include <sys/socket.h>
int bind(int fd, const struct sockaddr * addr, socklen_t addr_len)
-
fd
要与之绑定的描述符; -
addr
要求给出一个sockaddr
结构的地址。前面定义的结构是sockaddr_in
类型,而不是sockaddr
类型。因此,对其取址后,还需要进行强制类型转换; -
addr_len
实际上就是整型,要给出前一个参数——地址结构——的大小。
如果绑定成功,bind()
函数将返回 0
,否则返回 -1
下面代码将结构 server_addr
与描述符 server_sockfd
绑定。
if (bind(server_sockfd, (struct sockaddr *)&server_addr, sizeof (struct sockaddr_in)) == -1) {
perror("Faild to bind socket.");
exit(-1);
}
要检查 bind()
函数的返回值,这样一旦绑定失败,就能立即知道。
如果服务器在调用 bind()
函数之前,没有设置端口号,或者说设置为 0,那么在调用 bind()
函数时,内核就会从空闲的端口号中随机挑选一个,作为本机在此次通信中所使用的端口。这种情况下,通过 getsockname()
函数可以得到确切的端口号。
#include <sys/socket.h>
int getsockname(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
函数 getsockname()
会将套接字 sockfd
绑定的地址信息写入 addr
指向的地址结构。第三个参数 addrlen
指向的 socklen_t
型变量在调用前,必须初始化为地址结构的大小,函数返回时,该变量会被设为地址信息的实际大小。
如果提供的地址结构太小,地址将被截断。在这种情况下,addrlen
指向的变量会被设定为一个超出正常范围的值。
下面代码应放在 bind()
函数调用之后,对端口号进行判断,如果是 0,则调用 getsockname()
函数以获取确切的值。
if (server_addr.sin_port == 0) {
int server_addrlen = sizeof (struct sockaddr_in);
getsockname(server_sockfd, (struct sockaddr *)&server_addr, &server_addrlen);
printf("real port: %hu\n", ntohs(server_addr.sin_port));
}
函数
getsockname()
获取的是本机的 IP 地址和端口号。
建立连接
在准备好地址结构之后,如果是面向字节流的套接字,需要建立连接才能进行通信。如果是面向数据报的套接字,则不需要。
服务器接受并处理连接
对于服务器程序来说,就是调用 listen()
函数通知底层协议,可以开始接收来自客户端的请求,并与之建立连接,即开始 监听。陆陆续续建立的连接将形成一个队列,等待 accept()
函数调取。
#include <sys/socket.h>
int listen(int fd, int n)
-
fd
是要开始监听的套接字的描述符; -
n
是队列的最大长度。如果连接数达到这个值,往后的请求将被直接拒绝。比如设为 5,也可以使用常量SOMAXCONN
,这将由系统决定队列的最大长度。
如果顺利,listen()
函数将返回 0
,否则返回 -1
下面代码启动了对套接字 server_sockfd
的监听。
if (listen(server_sockfd, 5) == -1) {
perror("Faild to listen socket.");
exit(-1);
}
接下来要做的就是调用 accept()
函数。
#include <sys/socket.h>
int accept(int fd, struct sockaddr * addr, socklen_t * addr_len)
accept()
函数会等到有客户端接入时才返回。返回值是一个新的文件描述符。后续与该客户端的通信都要通过这个文件描述符进行。
accept()
函数还会将客户端的地址信息存放到第二个参数指向的地址结构,并在第三个参数指向的整型变量给出地址信息的实际长度。
调用
accept()
函数时,如果对客户端的地址信息感兴趣,必须将第三个参数指向的整型变量设为可接受的长度,也就是地址结构的长度;如果不感兴趣,可将第二、三个参数均设为NULL
下面代码调用 accept()
函数,开始处理套接字 server_sockfd
的队列。
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof (struct sockaddr_in);
int client_sockfd;
client_sockfd = accept(server_sockfd, (struct sockaddr *)&client_addr, &client_addr_len);
if (client_sockfd == -1) {
perror("Failed to accept a new connection on a socket.");
exit(1);
}
客户端请求建立连接
对于客户端来说,是调用 connect()
函数主动去连接服务器的套接字。
#include <sys/socket.h>
int connect(int fd, const struct sockaddr * addr, socklen_t addr_len);
-
fd
要求一个套接字的描述符。连接建立后,与服务器的通信都要通过这个套接字进行; -
addr
指向一个包含服务器地址信息的地址结构; -
addr_len
给出第二个参数——地址结构——的大小。
如果顺利,connect()
函数将返回 0
,否则返回 -1
下面代码调用 connect()
函数尝试与 server_addr
指定的服务器程序建立连接。
int ret = connect(server_sockfd, (struct sockaddr *)&server_addr,
sizeof (struct sockaddr_in));
if (ret == -1) {
fprintf(stderr, "Failed to connect to %s port %d: %s\n",
inet_ntoa(server_addr.sin_addr),
ntohs(server_addr.sin_port),
strerror(errno));
exit(-1);
}
调用 bind()
函数之后,客户端可调用 getsockname()
函数获取本机在此次通信中所使用的端口号,以及代表本机的 IP 地址。
struct sockaddr_in local_addr;
socklen_t local_addrlen = sizeof(struct sockaddr_in);
getsockname(server_sockfd, (struct sockaddr *)&local_addr, &local_addrlen);
printf("local address: %s\n", inet_ntoa(local_addr.sin_addr));
printf("local port: %hu\n", ntohs(local_addr.sin_port));
传输字节流
TCP 连接一旦建立,不管是服务器,还是客户端,都会有一个接收缓存和发送缓存。发送缓存里放的是即将发送给对方的数据;接收缓存里放的是来自对方的数据。
函数 recv()
从接收缓存读取指定数量的字节; send()
函数将指定数量的字节推送到发送缓存。
#include <sys/socket.h>
ssize_t recv(int fd, void *buf, size_t n, int flags);
ssize_t send(int fd, const void *buf, size_t n, int flags);
这两个函数的第一个参数 fd
都要求一个描述符,以说明通过哪个套接字收发字节。
recv()
函数从第二个参数 buf
的指向开始,写入从接收缓存读取到的字节;send()
函数从第二个参数 buf
的指向开始,读取字节到发送缓存。它们的第三个参数 n
都用于指定读写的字节数。
对于 send()
函数,默认是只有发送完所有字节才会返回。对于 recv()
函数,默认是只要接收到字节就返回,哪怕只有一个字节。它们的最后一个参数 flags
就是用来改变这种默认行为的,一般设为 0
,表示保持默认。
如果读写成功,它们的返回值都是实际读写的字节数,否则返回 -1
。如果返回的字节数是 0
,说明对方关闭连接,应该结束通信,不能再收发字节了。
下面代码,调用 send()
向套接字 server_sockfd
发送字节。第二个参数给出了要发送的字节 "123456789"
,但第三个参数指定只发送 5 个字节,因此另一方将只能接收到 "12345"
if (send(server_sockfd, "123456789", 5, 0) == -1) {
fprintf(stderr, "Failed to send bytes to socket %d: %s\n",
server_sockfd, strerror(errno));
exit(-1);
}
下面代码,在 do-while
循环中调用 recv()
函数从套接字 client_sockfd
读取字节到字符数组 buf
,读取到的字节数保存在整型变量 buflen
中。然后用 printf()
函数输出读取到的字节。
#define BUF_SIZE 140
int buflen;
char buf[BUF_SIZE];
do {
buflen = recv(client_sockfd, buf, BUF_SIZE, 0);
if (buflen > 0) {
buf[buflen] = '\0';
printf("recv: %d: %s", buflen, buff);
} else if (buflen == 0) {
printf("Connection closed by foreign host.\n");
break;
} else if (buflen == -1) {
fprintf(stderr, "Failed to receive bytes from socket %d: %s\n",
client_sockfd, strerror(errno));
}
} while (strncasecmp(buf, "quit", 4) != 0);
当接收到 "quit"
时,do-while
循环的条件就不成立,便跳出循环。
参数 flags
的取值是一些宏。这些宏可以同时起作用,只需将它们进行按位或运算即可。
flags 取值 | 含义 |
---|---|
MSG_DONTWAIT |
不要阻塞,立即返回。若没有数据可读写,仍然返回 -1 ,同时 errno 会被设置为 EAGAIN 或 EWOULDBLOCK
|
MSG_OOB |
发送或接收紧急数据 |
MSG_PEEK |
不要将本次读取到的数据从接收缓存中清除 |
MSG_WAITALL |
必须读取到指定数量的字节才返回 |
MSG_NOSIGNAL |
在进行写操作时,如果中途对端关闭读,不要引发 SIGPIPE 信号 |
收发数据报
如果是 UDP 套接字,客户端在填写服务器的地址信息后,就可以调用 sendto()
函数向服务器发送数据报;服务器在调用 bind()
函数之后,就可以调用 recvfrom()
函数开始接收来自客户端的数据报。
由于 UDP 是面向无连接的,每次发送数据报都要给出目标地址;接收数据报时也要同时获取源地址,否则无法知道收到的数据报来自哪个客户端。
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
关闭连接
不管是 TCP 套接字还是 UDP 套接字,要结束通信,必须调用 close()
函数来关闭连接。
#include <unistd.h>
int close(int fd);
实际上,close()
并不会立即把连接关闭,它只是把 fd
的引用次数减 1。只有当引用次数为 0 时,才真正关闭连接。在多进程程序中,每创建一个子进程,就会使父进程中打开的 Socket 的引用次数加 1
如果想要立即关闭连接,则应该使用下面函数。
#include <sys/socket.h>
int shutdown(int fd, int howto);
参数 howto
决定 shutdown()
的行为,取值如下:
-
SHUT_RD
不能再进行读操作,已经在接收缓存中的数据会被丢弃 -
SHUT_WR
不能再进行写操作,已经在发送缓存中的数据会在真正关闭连接之前发送出去 -
SHUT_RDWR
不能再进行读操作,也不能再进行写操作
附录 A:一个完整的服务器程序
下面是一个完整的服务器程序。启动后,监听在 0.0.0.0:8080
上,并将接收到的字节打印出来。如果收到 "quit"
则关闭连接并结束退出。
#include <stdio.h>
#include <stdlib.h> // exit()
#include <stdint.h> // uint8_t
#include <string.h> // memset(), memcpy(), strcpy(), strerror()
#include <unistd.h> // close()
#include <sys/socket.h> // socket(), bind(), listen(), accept(), recv(), send()
#include <arpa/inet.h> // htons(), htons(), inet_addr()
#include <errno.h> // errno
#define BUF_SIZE 140
int main()
{
int server_sockfd;
server_sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (server_sockfd == -1) {
perror("Faild to create socket.");
exit(1);
}
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(struct sockaddr_in));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = INADDR_ANY;
if (bind(server_sockfd, (struct sockaddr *)&server_addr, sizeof(struct sockaddr_in)) == -1) {
perror("Faild to bind socket.");
exit(1);
}
if (server_addr.sin_port == 0) {
socklen_t server_addrlen = sizeof(struct sockaddr_in);
getsockname(server_sockfd, (struct sockaddr *)&server_addr, &server_addrlen);
printf("real port: %hu\n", ntohs(server_addr.sin_port));
// %hu 用于显示无符号短整型
}
if (listen(server_sockfd, SOMAXCONN) == -1) {
perror("Faild to listen socket.");
exit(1);
}
int client_sockfd;
client_sockfd = accept(server_sockfd, NULL, NULL);
if (client_sockfd == -1) {
perror("Failed to accept a new connection on a socket.");
exit(1);
}
int buflen;
char buf[BUF_SIZE];
do {
buflen = recv(client_sockfd, buf, BUF_SIZE, 0);
if (buflen > 0) {
buf[buflen] = '\0';
printf("recv: %d: %s", buflen, buf);
} else if (buflen == 0) {
printf("Connection closed by foreign host.\n");
break;
} else if (buflen == -1) {
fprintf(stderr, "Failed to receive bytes from socket %d: %s\n",
client_sockfd, strerror(errno));
exit(1);
}
} while (strncasecmp(buf, "quit", 4) != 0);
close(client_sockfd);
close(server_sockfd);
return 0;
}
假设上面代码保存在 server.c
中,用 gcc
命令编译,输出为 server
并运行。
$ gcc server.c -o server
$ ./server
这时程序将在终端中持续运行。可以在另一个终端中用 telnet
命令与它连接。
$ telnet 127.0.0.1 8080
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
接着输入 hello world
并按下回车,可以看到前面的终端同步输出 hello world
$ ./server
hello world
输入 quit
则会断开连接。
$ telnet 127.0.0.1 8080
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
hello world
quit
Connection closed by foreign host.
附录 B:一个完整的客户端程序
下面是一个完整的客户端程序。启动后,会尝试连接到 127.0.0.1:8080
,并将终端输入的字节发送过去。如果发送的是 "quit"
则关闭连接并结束退出。
#include <stdio.h>
#include <stdlib.h> // exit()
#include <stdint.h> // uint8_t
#include <string.h> // memset(), memcpy(), strcpy(), strerror()
#include <unistd.h> // close()
#include <sys/socket.h> // socket(), bind(), listen(), accept(), recv(), send()
#include <arpa/inet.h> // htons(), htons(), inet_addr()
#include <errno.h> // errno
#define BUF_SIZE 140
int main()
{
int server_sockfd;
server_sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (server_sockfd == -1) {
perror("Faild to create socket.");
exit(1);
}
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(struct sockaddr_in));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
int ret = connect(server_sockfd, (struct sockaddr *)&server_addr,
sizeof(struct sockaddr_in));
if (ret == -1) {
fprintf(stderr, "Failed to connect to %s port %hu: %s\n",
inet_ntoa(server_addr.sin_addr),
ntohs(server_addr.sin_port),
strerror(errno));
exit(1);
}
struct sockaddr_in local_addr;
socklen_t local_addrlen = sizeof(struct sockaddr_in);
getsockname(server_sockfd, (struct sockaddr *)&local_addr, &local_addrlen);
printf("local address: %s\n", inet_ntoa(local_addr.sin_addr));
printf("local port: %hu\n", ntohs(local_addr.sin_port));
char buf[BUF_SIZE];
do {
fgets(buf, sizeof(buf), stdin);
ret = send(server_sockfd, buf, strlen(buf), 0);
if (ret > 0) {
printf("send: %d\n", ret);
} else if (ret == -1) {
fprintf(stderr, "Failed to send bytes to socket %d: %s\n",
server_sockfd, strerror(errno));
exit(1);
}
} while (strncasecmp(buf, "quit", 4) != 0);
close(server_sockfd);
return 0;
}
网友评论