该系列文章总纲链接:专题分纲目录 LinuxC 系统编程
本章节思维导图如下所示(思维导图会持续迭代):
第一层:
第二层:
1 套接字概念
linux使用套接字进行进程间的通信;通过套接字,其他进程的位置对于应用程序来讲是透明的;套接字代表通信的端点,必须保证2个端点各有一个套接字才可以。套接字的通信过程如下:
套接字实现了一层抽象,让用户感觉在操作文件一样。抽象过程如下:
2 准备工作
2.1 字节序
在网络环境中,进程间通信是跨主机的,因此就有了字节序不统一的问题。为解决这个问题,网络协议提供一种字节序,当跨主机的两个进程进行通信时,先将需要传输的数据转换成网络字节序,待接收方接收数据后,将其转换为本机的字节序。字节序转换流程如下:
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地址,转换过程如下:
转换后的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下使用的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函数容易出现问题,原因如下:
- 延时问题:对于本地文件夹,字节流在本地传输的延时可以忽略不计,但是在网络中传输的时间可能会很长;因此会造成I/O的阻塞;解决方法只能是非阻塞/使用多路I/O。
- 网络应用程序要能够处理因为中断/网络连接问题造成的读写操作异常返回,但是这样会让程序变得更阿基复杂和不好控制。
注意: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函数失败的处理。
注意:
- 在实际当中,如果不知道服务器的IP地址,可以使用getaddrinfo函数,通过DNS服务器将服务器的域名转换为服务器主机的IP;如果连域名也不知道,那就无法通信。
- 对于一般可间的局域网,服务器和客户端大都属于一个用户组,其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函数失败的处理。
非阻塞网络应用程序的客户端流程与之前的可以一致,也可以将其做成输入阻塞,以便于验证服务端的正确性。