该系列文章总纲链接:专题分纲目录 LinuxC 系统编程​​​​​​​


本章节思维导图如下所示(思维导图会持续迭代):

第一层:

Linux C 系统编程(14)网络编程 基础_套接字

第二层:

Linux C 系统编程(14)网络编程 基础_客户端_02


1 套接字概念

linux使用套接字进行进程间的通信;通过套接字,其他进程的位置对于应用程序来讲是透明的;套接字代表通信的端点,必须保证2个端点各有一个套接字才可以。套接字的通信过程如下:
 

Linux C 系统编程(14)网络编程 基础_服务器_03

套接字实现了一层抽象,让用户感觉在操作文件一样。抽象过程如下:

Linux C 系统编程(14)网络编程 基础_ip地址_04


2 准备工作

2.1 字节序

在网络环境中,进程间通信是跨主机的,因此就有了字节序不统一的问题。为解决这个问题,网络协议提供一种字节序,当跨主机的两个进程进行通信时,先将需要传输的数据转换成网络字节序,待接收方接收数据后,将其转换为本机的字节序。字节序转换流程如下:

Linux C 系统编程(14)网络编程 基础_ip地址_05

linux环境下使用4个函数进行字节序的转换,函数原型如下:

#include <arpa/inet.h>
uint32_t htonl(uint32_t hostint32);
uint16_t htons(uint16_t hostint16);
uint32_t ntohl(uint32_t netint32);
uint16_t ntohs(uint16_t netint16);

详细见linux函数参考手册。网络字节序就是大端字节序,但是网络的情况是复杂的,为了保证代码的可移植性,不论在哪种字节序的主机上都要做字节转换处理。

2.2 地址格式

网络环境中每台计算机都有一个IP地址。(对于IPv4协议来讲,是一个32位无符号整数;对于IPv6协议来讲,是一个128位无符号整数)。linux中使用in_addr结构表示一个IP地址,结构定义如下:

#include <netinet/in.h>
struct in_addr{
     in_addr_t s_addr;     /*in_addr_t 被定义为无符号整型*/
}

当确定了目标机后,还需要通过端口号来确定主机中哪个进程需要通信(每个进程对应一个16位的端口号)。因此,在网络中,一个IP地址与一个端口号连在一起就可以确定一台主机的一个进程。当唯一的两点已经确认后,通信就开始了。linux中地址结构的定义如下:

#include <netinet/in.h>

struct socketaddr_in{
     sa_family_t sin_family;     /*16位的地址族,根据套接字场合不同而不同,网络通信IPv4地址族为AF_NET*/
     in_port_t sin_port;          /*16位的端口号*/
     struct in_addr sin_addr;     /*32位的IP地址*/
     unsigned char sin_zero[8];/*填充区,8个字节填0,为保证socketaddr_in与socket_addr地址结构可以随意转换*/
}

struct socketaddr{
     sa_family_t sin_family;     /*16位的地址族,根据套接字场合不同而不同,网络通信IPv4地址族为AF_NET*/
     char sa_data[14];          /*14字节的填充区,可以看成sin_port、sin_addr、sin_zero三个成员变量组成*/
}

结构socketaddr_in与socketaddr等长,所以可以很容易地相互转换。

2.3 地址形式转换

IP地址是以二进制的形式存储在地址结构中的,直接观察有些不便,用点分十进制(xxx.xxx.xxx.xxx)表示才直观。linux下提供的IP地址转换函数如下:

#include <arpa/inet.h>
const char *inet_ntop(int af, const void *src,char *dst, socklen_t size);/*将二进制数转换成点分十进制*/
int inet_pton(int af, const char *src, void *dst);/*将点分十进制转换成二进制*/

详细见linux函数参考手册。  

2.4 获得主机信息

一台主机和网络相关的信息一般存放在系统中的某个文件里(例如/etc/hosts),用户可以通过系统函数读取文件上的内容,在linux下使用gethostent函数读取和主机有关的信息:

#include <netdb.h>
struct hostent *gethostent(void);     /*读取含有主机相关信息的文件*/
void endhostent(void);               /*关闭含有主机相关信息的文件*/

详细见linux函数参考手册。其中,hostent结构体的定义如下:

struct hostent {
    char *h_name;           /*正式主机名,每个主机只有一个*/
    char **h_aliases;     /*主机别名列表,可以有多个,以二位数组形式存储*/
    int h_addrtype;           /*IP地址类型,可以选择IPv4/IPv6*/
    int h_length;           /*IP地址长度,IPv4对应4字节的地址长度*/
    char **h_addr_list;      /*IP地址列表,h_addr_list[0]为主机的IP地址*/
};

注意:调用gethostent两次,则第一次host指针指向的缓冲区内容会被冲掉。

2.5 地址映射

对于用户而言,套接字的地址结构信息是不必要的,用户只需传递一个sockaddr_in地址结构的地址,之后由系统来填充其中的内容即可。网络环境中的服务器需要提供一个唯一地址的IP和主机名(域名);对于大部分服务器来讲,客户端不知道其IP地址,但知道其域名。DNS可以将域名转换为IP地址,转换过程如下:

Linux C 系统编程(14)网络编程 基础_套接字_06

转换后的IP地址和端口号存储在addr_info信息结构中。linux下提供一个函数,即根据服务器的域名和服务名称即可得到服务器的IP地址和端口号;并将其填写到一个sockaddr_in地址结构中,该函数内部访问了DNS服务器,从而得到需要访问主机的IP号和端口号,函数原型如下:

#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
int getaddrinfo(const char *node, const char *service,const struct addrinfo *hints,struct addrinfo **res);

详细见linux函数参考手册。    


3 套接字基础编程

套接字技术对大部分通信细节做了隐藏,使得操作类似于文件,也正因为这样,所以很多文件操作函数也可以用在套接字上。(linux将设备抽象为文件的策略使得编程简单很多)

3.1 建立和销毁套接字描述符

linux环境下创建一个套接字和取消一个套接字的函数原型如下:

#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
#include <unistd.h>
int close(int fd);               /*关闭一个套接字和关闭一个文件是一样的操作*/

详细见linux函数参考手册

3.2 地址绑定

创建一个套接字以后需要绑定地址的套接字才能够进行通信。linux下使用bind函数将一个套接字绑定在一个地址上,函数原型如下:

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);

详细见linux函数参考手册注意:sockaddr_in结构中不能指定协议为IPv6,即通信域不能指定为AF_INET6。其中,第二个参数在实际当中需要先对参数进行初始化,过程如下:

struct socketaddr_in *addr;
addr = (struct sockaddr_in *)malloc(sizeof(struct sockaddr_in));
addr->sin_family = AF_INET;               /*使用IPv4协议的地址列表*/
addr->sin_port = 8888;                    /*端口号,一般大于1024,因为只有root用户才能使用024以下的端口,通常这个端口由系统指派,因为可能被别的进程占用*/
addr->sin_addr=0x60ba8c0;               /*一般通过getaddrinfo来获取,如果希望可以接收网络中任意的数据包,则将此项设置为INADDR_ANY*/
bind(fd, (struct sockaddr_in) *addr,sizeof(struct sockaddr_in));

3.3 建立一个连接

在绑定一个套接字后,客户端就可以建立一个连接,对于面向服务的套接字类型,必须指定;对于无连接服务,这一步是没有必要的。linux环境下使用connect函数建立一个主动建立一个连接,函数原型如下:

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);

详细见linux函数参考手册注意:对于网络而言,应用程序一定要能处理连接时可能发生的错误;失败原因有很多,一旦失败就要考虑重新尝试,不过尝试一般都需要有一段时间的延迟,以保证网络有时间自动恢复。

使用connect函数的机制如图所示:

Linux C 系统编程(14)网络编程 基础_#include_07

客户端建立一个连接,服务器端就要监听并接受这样一个连接,进而对其进行处理,linux下使用的listen函数监听客户端的连接请求;使用accept函数接受一个连接的请求,函数原型如下:

#include <sys/types.h>
#include <sys/socket.h>
int listen(int sockfd, int backlog);
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

详细见linux函数参考手册。注意:对于套接字描述符,不可使用lseek函数对其进行重定位。

3.4 使用文件读写函数读写套接字

在网络中使用read/write函数容易出现问题,原因如下:

  1. 延时问题:对于本地文件夹,字节流在本地传输的延时可以忽略不计,但是在网络中传输的时间可能会很长;因此会造成I/O的阻塞;解决方法只能是非阻塞/使用多路I/O。
  2. 网络应用程序要能够处理因为中断/网络连接问题造成的读写操作异常返回,但是这样会让程序变得更阿基复杂和不好控制。

注意:close函数在网络环境下出错的原因并不是文件本身的问题,而是由于“缓输出”导致了异常;write函数只是将要写入文件的内容放到缓存中,真正写到外存上是需要时间的,对于本地文件,几乎不会出错,但是在网络环境下,出错的概率就大了;因此,在网络环境下,调用write函数并不能保证文件内已经准确到达对端。

3.5 面向连接的数据传输

linux环境下用read/write函数进行网络通信很容易出问题,但是linux下有专门用于面向连接的套接字的函数,这两个函数分别是send和recv,其函数原型如下:

#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

详细见linux函数参考手册

3.6 面向连接的最简单服务器端与客户端流程

@1 服务器端执行流程(伪代码)如下:

//地址结构初始化;
fd=socket();
bind(fd,...);
listen(fd,...);
while(1){
     accept_fd=accept(fd,...);
     //与客户端交互,处理来自客户端的请求;(recv/send);
     close(accept_fd);
}
close(fd);
close函数失败的处理。

@2 客户端执行流程(伪代码)如下:

//地址结构初始化;
fd=socket();
connect(fd,...);
//与服务器交互,向服务器发出具体消息/接受来自服务器的消息;(send/recv)
close(fd);
close函数失败的处理。

注意:

  1. 在实际当中,如果不知道服务器的IP地址,可以使用getaddrinfo函数,通过DNS服务器将服务器的域名转换为服务器主机的IP;如果连域名也不知道,那就无法通信。
  2. 对于一般可间的局域网,服务器和客户端大都属于一个用户组,其IP地址是相互可见的;但是对于互联网环境中,服务器的IP往往是对客户隐藏的。

3.7 面向无连接的数据传输

用于面向无连接套接字的读写函数要复杂一点,由于没有建立一个连接,所以每次发送数据的过程都要明确指出该数据包的目的地址;在接收数据包时,接收进程可以得到发送该数据包的地址。linux环境下提供专门对无连接套接字进行读写的函数,分别是sendto和recvfrom函数,函数原型如下:

#include <sys/types.h>
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);

详细见linux函数参考手册

3.8 面向无连接的最简单服务器端与客户端流程

@1 服务器端执行流程(伪代码)如下:

//地址结构初始化;
fd=socket();
bind(fd,...);
while(1){
     //与客户端交互,处理来自客户端的请求;(recvfrom/sendto);
}
close(fd);
close函数失败的处理。

@2 客户端执行流程(伪代码)如下:

//地址结构初始化;
fd=socket();
//与服务器交互,向服务器发出具体消息/接受来自服务器的消息;(sendto/recvfrom)
close(fd);

4 非阻塞套接字

当进程需要对套接字进行读写操作,而套接字的数据尚未准备好,则进行读写套接字操作的函数将会阻塞,使进程进入休眠状态等待,其后面的操作也就无法进行了,非阻塞I/O将解决这种问题。由于套接字属于一种特殊的文件,因此,可以使用更改文件阻塞的方式来修改套接字的阻塞状态。在服务器端执行流程(伪代码)如下:

//地址结构初始化;
fd=socket();
bind(fd,...);
     //服务器端在以往的流程上添加的3个逻辑控制语句。
     flag=fcntl(fd,F_GETFL);
     flag|=O_NONBLOCK;
     fcntl(fd,F_SETFL,flag);
listen(fd,...);
while(1){
     accept_fd=accept(fd,...);
     //与客户端交互,处理来自客户端的请求;(recv/send);
     close(accept_fd);
}
close(fd);
close函数失败的处理。

非阻塞网络应用程序的客户端流程与之前的可以一致,也可以将其做成输入阻塞,以便于验证服务端的正确性。