欢迎转载,请保留作者信息
bill@华中科技大学
http://billstone.cublog.cn 

第五篇 网络通信篇

  IPC对象只能实现在一台主机中的进程相互通信, 网罗通信对象则打破了这个限制, 它如同电话和邮件, 可以帮助不通屋檐下的人们相互交流.

  套接字(Socket)是网络通信的一种机制, 它已经被广泛认可并成为事实上的工业标准.

第十五章 基于TCP的通信程序

  TCP是一种面向连接的网络传输控制协议. 它每发送一个数据, 都要对方确认, 如果没有接收到对方的确认, 就将自动重新发送数据, 直到多次重发失败后,才放弃发送.

  套接字的完整协议地址信息包括协议, 本地地址, 本地端口, 远程地址和远程端口等内容, 不同的协议采用不同的结构存储套接字的协议地址信息.

  Socket是进程间的一个连接, 我们可以采用协议, 地址和端口的形式描述它:

  { 协议, 本地地址, 本地端口, 远程地址, 远程端口 }

  当前有三种常见的套接字类型, 分别是流套接字(SOCK_STREAM), 数据报套接字(SOCK_DGRAM)和原始套接字(SOCK_RAW):

  1) 流套接字. 提供双向的, 可靠的, 顺序的, 不重复的, 面向连接的通信数据流. 它使用了TCP协议保真了数据传输的正确性.

  2) 数据报套接字. 提供一种独立的, 无序的, 不保证可靠的无连接服务. 它使用了UDP协议, 该协议不维护一个连接, 它只把数据打成一个包, 再把远程的IP贴上去, 然后就把这个包发送出去.

  3) 原始套接字. 主要应用于底层协议的开发, 进行底层的操作.

  TCP协议的基础编程模型

  TCP是面向连接的通信协议, 采用客户机-服务器模式, 套接字的全部工作流程如下:

  首先, 服务器端启动进程, 调用socket创建一个基于TCP协议的流套接字描述符.

  其次, 服务进程调用bind命名套接字, 将套接字描述符绑定到本地地址和本地端口上, 至此socket的半相关描述----{协议, 本地地址, 本地端口}----完成.

  再次, 服务器端调用listen, 开始侦听客户端的socket连接请求.

  接下来, 客户端创建套接字描述符, 并且调用connect向服务器提交连接请求. 服务器端接收到客户端连接请求后, 调用accept接受, 并创建一个新的套接字描述符与客户端建立连接, 然后原套接字描述符继续侦听客户端的连接请求.

  客户端与服务器端的新套接字进行数据传送, 调用write或send向对方发送数据, 调用read或recv接收数据.

  在数据交流完毕后, 双方调用close或shutdown关闭套接字.

  (1) Socket的创建

  在UNIX中使用函数socket创建套接字描述符, 原型如下:

代码:
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
  其中, 参数domain指定发送通信的域, 有两种选择: AF_UNIX, 本地主机通信, 功能和IPC对象类似; AF_INET, Internet地址IPV4协议. 在实际编程中, 我们只使用AF_INET协议, 如果需要与本地主机进程建立连接, 只需把远程地址设定为'127.0.0.1'即可.

  参数type指定了通信类型: SOCK_STREAM, SOCK_DGRAM和SOCK_RAW. 协议AF_INET支持以上三种类型, 而协议AF_UNIX不支持原始套接字.

  (2) Socket的命名

  函数bind命名一个套接字, 它为该套接字描述符分配一个半相关属性, 原型如下:
代码:
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, struct sockaddr *my_addr, socklen_t addrlen);
  参数s指定了套接字描述符, 该值由函数socket返回, 指针name指向通用套接字的协议地址结构, namelen参数指定了该协议地址结构的长度.

  结构sockaddr描述了通用套接字的相关属性, 结构如下
代码:
typedef unsigned short int sa_family_t;
#define __SOCKADDR_COMMON(sa_prefix)  sa_family_t sa_prefix##family
struct sockaddr{
    __SOCKADDR_COMMON (sa_);    /* Common data: address family and length.  */
    char sa_data[14];           /* Address data.  */
};
  不同的协议有不同的地址描述方式, 为了便于编码处理, 每种协议族都定义了自给的套接字地址属性结构, 协议族AF_INET使用结构sockaddr_in描述套接字地址信息, 结构如下:
代码:
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)];
};
typedef uint32_t in_addr_t;
struct in_addr{
    in_addr_t s_addr;
};
  这里有两点需要注意:

  a. IP地址转换

  在套接字的协议地址信息结构中, 有一个描述IP地址的整型成员. 我们习惯使用点分方式描述IP地址, 所以需要将其转化为整型数据, 下列函数完成此任务
代码:
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
char *inet_ntoa(struct in_addr in);
  函数inet_addr将参数ptr指向的字符串形式IP地址转换为4字节的整型数据. 函数inet_aton同样完成此功能. 函数inet_ntoa的功能则恰好相反.

  b. 字节顺序转换

  网络通信常常跨主机, 跨平台, 跨操作系统, 跨硬件设备, 但不同的CPU硬件设备, 不同的操作系统对内存数据的组织结构不尽相同. 在网络通信中, 不同的主机可能采取了不同的记录顺序, 如果不做处理, 通信双方对相同的数据会有不同的解释. 所以需要函数实现主机字节顺序和网络字节顺序的转换
代码:
#include <netinet/in.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
  函数htons, htonl分别将16位和32位的整数从主机字节顺序转换为网络字节顺序.

  函数ntohs, ntohl分别将16位和32位的整数从网络字节顺序转换为主机字节顺序.

  (3) Socket的侦听

  TCP的服务器端必须调用listen才能使套接字进入侦听状态, 原型如下
代码:
#include <sys/socket.h>
int listen(int s, int backlog);
  参数s是调用socket创建的套接字. 参数backlog则确定了套接字s接收连接的最大数目.

  在TCP通信模型中, 服务器端进程需要完成创建套接字, 命名套接字和侦听接收等一系列操作才能接收客户端连接请求. 下面设计了一个封装了以上三个操作的函数, 代码如下
代码:
int CreateSock(int *pSock, int nPort, int nMax){
        struct sockaddr_in addrin;
        struct sockaddr *paddr = (struct sockaddr *)&addrin;
        int ret = 0;        // 保存错误信息
 
        if(!((pSock != NULL) && (nPort > 0) && (nMax > 0))){
                 printf("input parameter error");
                 ret = 1;
        }
        memset(&addrin, 0, sizeof(addrin));
       
        addrin.sin_family = AF_INET;
        addrin.sin_addr.s_addr = htonl(INADDR_ANY);
        addrin.sin_port = htons(nPort);
                          // 创建socket, 在我本机上是5
        if((ret == 0) && (*pSock = socket(AF_INET, SOCK_STREAM, 0)) <= 0){
                 printf("invoke socket error\n");
                 ret = 1;
        }
                              // 绑定本地地址
        if((ret == 0) && bind(*pSock, paddr, sizeof(addrin)) != 0){
                 printf("invoke bind error\n");
                 ret = 1;
        }
                
        if((ret == 0) && listen(*pSock, nMax) != 0){
                 printf("invoke listen error\n");
                 ret = 1;
        }
       
        close(*pSock);
        return(ret);
}
  (4) Socket的连接处理

服务器端套接字在进入侦听状态后, 通过accept接收客户端的连接请求
代码:
#include <sys/types.h>
#include <sys/socket.h>
int accept(int s, struct sockaddr *addr, socklen_t *addrlen);
  函数accept一旦调用成功, 系统将创建一个属性与套接字s相同的新的套接字描述符与客户进程通信, 并返回该新套接字的描述符编号, 而原套接字s仍然用于套接字侦听. 参数addr回传连接成功的客户端地址结构, 指针addrlen回传该结构占用的字节空间大小.

  下面封装了系统调用accept, 代码如下
代码:
#include <fcntl.h>
int AcceptSock(int *pSock, int nSock){
        struct sockaddr_in addrin;
        int lSize, flags;
       
        if((pSock == NULL) || (nSock <= 0)){
                 printf("input parameter error!\n");
                 return 2;
        }
        flags = fcntl(nSock, F_GETFL, 0);          // 通过fcntl函数确保nSock处于阻塞方式
        fcntl(nSock, F_SETFL, flags & ~O_NONBLOCK);
        while(1){
                 lSize = sizeof(addrin);
                 memset(&addrin, 0, sizeof(addrin));       // 通过调试, 问题应该出在accept函数
                 if((*pSock = accept(nSock, (struct sockaddr *)&addrin, &lSize)) > 0)
                          return 0;
                 else if(errno == EINTR)
                          continue;
                 else{
                          fprintf(stderr, "Error received! No: %d\n", errno);
                          return 1;
                 }
        }
}
  (5) Socket的关闭

 套接字可以调用close函数关闭, 也可以调用下面函数
代码:
#include <sys/socket.h>
int shutdown(int s, int how);
  函数shutdown是强制性地关闭所有套接字连接, 而函数close只将套接字访问计数减1, 当且仅当计数器值为0时, 系统才真正的关闭套接字通信.

  (6) Socket的连接申请

  TCP客户端调用connect函数向TCP服务器端发起连接请求, 原型如下
代码:
#include <sys/types.h>
#include <sys/socket.h>
int  connect(int  sockfd,  const  struct sockaddr *serv_addr, socklen_t addrlen);
  其中, serv_addr指针指定了对方的套接字地址结构.

  (7) TCP数据的发送和接收

  套接字一旦连接上, 就可以发送和接收数据. 原型如下
代码:
#include <sys/types.h>
#include <sys/socket.h>
int send(int s, const void *msg, size_t len, int flags);
int recv(int s, void *buf, size_t len, int flags);
  函数send(recv)应用于TCP协议的套接字通信中, s是与远程地址连接的套接字描述符, 指针msg指向待发送的数据信息(或接收数据的缓冲区), 此信息共len个字节(或最大可接收len个字节).

  如果函数send一次性发送的信息过长, 超过底层协议的最大容量, 就必须分开调用send发送, 否则内核将不予发送信息并且置EMSGSIZE错误.

  简单服务器端程序

  这里设计了一个TCP服务器端程序的实例, 它创建Socket侦听端口, 与客户端建立连接, 然后接收并打印客户端发送的数据,代码如下
代码:
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
#include <errno.h>
 
#define VerifyErr(a, b) \
        if (a) { fprintf(stderr, "%s failed.\n", (b)); return 0; } \
        else fprintf(stderr, "%s success.\n", (b));
 
int main(void)
{
        int nSock, nSock1;
        char buf[2048];
 
        //CreateSock(&nSock, 9001, 9);
        nSock = nSock1 = 0;               // 这里只是为了调试所用
        VerifyErr(CreateSock(&nSock, 9001, 9) != 0, "Create Listen SOCKET");
        //VerifyErr(AcceptSock(&nSock1, nSock) != 0, "Link");
        AcceptSock(&nSock1, nSock);
        memset(buf, 0, sizeof(buf));
        recv(nSock1, buf, sizeof(buf), 0);
        fprintf(stderr, buf);
        close(nSock1);
        close(nSock);
 
        return 0;
}
  运行程序, 并在在浏览器中输入http://127.0.0.1:9001/, 你将得到一条来自客户端的http报文. 但是在这里却出现了问题, 问题如下:
代码:
[bill@billstone Unix_study]$ make tcp1
cc     tcp1.c   -o tcp1
[bill@billstone Unix_study]$ ./tcp1
Create Listen SOCKET success.
Error received! No: 9                         // 出现错误, 为'bad file number'错误
[bill@billstone Unix_study]$
  请大家帮忙查找一下原因!