随笔 - 97, 文章 - 22, 评论 - 81, 引用 - 0
数据加载中……

网络编程路漫漫(一)启程

一、套接字
      1、什么是套接字(socket)
      2、创建套接字
            1) 协议族(Protocol Family)
            2) 套接字类型
            3) 协议确定
      3、分配IP和端口
            1) IP(Internet Protocol)
            2) 端口
            3) 地址信息详解
            4) 主机字节序/网络字节序
            5) 绑定IP和端口
二、基于TCP的服务器端
      1、TCP/IP协议
      2、TCP服务器主流程
      3、等待连接请求
      4、受理请求
      5、数据交换
      6、断开连接
      7、调试工具
三、基于TCP的客户端
      1、TCP客户端主流程
      2、请求连接
四、回顾主流程

 一、套接字
        1、什么是套接字(socket)
        首先,我不想阐述太多的概念,直接拿例子说话最实际,说得越多就越乱。那么让我们先来看一个概念(囧),网络编程。
        网络编程就是编写程序将两台连网的计算机实现数据交换。如何进行数据交换?首先需要物理连接,这个不是我们程序员需要关心的事情,我们需要关心的是如何编写数据传输软件。操作系统为我们提供了“套接字”(socket)模块来干这件事。
        套接字是网络数据传输用的软件设备。更加直白的解释是这样的:网络上两个程序通过一个双向的通信连接实现数据交换,这个连接的一端称为一个套接字。
        2、创建套接字
        好了,概念貌似是比较清晰了的样子,那么让我们来看看套接字到底是个什么鬼。
        创建套接字的函数如下:
                int socket(int domain, int type, int protocol);
                          成功时返回文件描述符,失败时返回-1。
        这个函数是操作系统提供的,用于创建一个套接字的内核对象(内核对象是指由操作系统创建的一系列资源,比如进程、线程、文件、套接字、信号量、互斥量等等)。返回值是一个文件描述符,可以简单的理解为一个ID。如果熟悉MFC的话,我们会发现Windows开发时创建窗口返回的是一个叫“句柄”的东西。没错了,Linux上叫文件描述符,Windows上叫句柄。
        这里有必要讲一下文件描述符和socket的关系,因为在Linux下socket操作和文件操作没有区别,即socket也是文件的一种(Windows下并非如此)。文件描述符是系统分配给文件或套接字的整数。在stdio.h头文件下有三个预定义的文件描述符:
    #define stdin  (&__iob_func()[0])
    #define stdout (&__iob_func()[1])
    #define stderr (&__iob_func()[2])
         即0代表标准输入,1代表标准输出,2代表标准错误。所以应用程序申请的socket编号从3开始。
         接下来解释下socket创建时用到的参数:
         domain 代表套接字中使用的协议族,type 代表套接字数据传输类型,protocol 代表通信协议。这样一解释,是不是本来还有点理解,现在完全懵逼了?不要紧张,一个一个来解释。
            1)协议族(Protocol Family)
         能用图的时候坚决不写字,所以就有了下面这个图。
 
图一-2-1
        IPv4是互联网协议的第四版,也是第一个被广泛使用,构成现今互联网技术的基础的协议。所以我们着重讲解PF_INET,其它的暂且可以不管了,是不是很开心?
            2)套接字类型
        套接字类型决定了数据传输方式。总共有两种:SOCK_STREAM创建面向连接的套接字、SOCK_DGRAM创建面向消息的套接字。
        SOCK_STREAM:
            面向连接,可以理解成一条传送带,只要这条传送带质量没有问题(也就是网一直连着),那么传送带上的物品就不会丢失,较晚的物品不会先到达(传送带的保序特性),并且传输的物品不存在数据边界:即发送方和接收方的发送/接收的动作并非一一对应,比如发送方在传送带起点连续多次放了一些物品,接收方可以只通过一次操作就取走所有物品。这是因为发送和接收数据有一个内部缓冲(buffer),发送方的数据通过发送方的输出缓冲存放至接收方的输入缓冲,如果接收方不取走数据,这些数据就一直在它的输入缓冲中。缓冲满了会提供数据重传机制,所以面向连接的套接字不会存在数据丢失。一句话概括:
            可靠、保序、面向连接的数据传输方式的套接字。
        SOCK_DGRAM:
            面向消息,可以理解成快递。快递运送过程有可能丢失,不同快递公司运送的速度不同,所以无法保证先寄出的快递先到达,并且发送一个快递,接收的也是一个,因此有数据边界。而且必须限制快递的大小。一句话概括:
            不可靠、不保序、以高速数据传输为目的的套接字。
            3)协议确定
        参数PF_INET指定的IPv4协议族中,指定SOCK_STREAM面向连接的传输方式,满足前两个条件的协议只有IPPROTO_TCP,因此可以如下调用创建:
        int tcp_sock = socket(PF_INET, SOCK_STREAM, 0);
        参数PF_INET指定的IPv4协议族中,指定SOCK_DGRAM面向消息的传输方式,满足前两个条件的协议只有IPPROTO_UDP,因此可以如下调用创建:
        int udp_sock = socket(PF_INET, SOCK_DGRAM, 0);
        因为两种socket都只有一种协议,所以第三个参数可以省略。
        那么什么时候用TCP,什么时候用UDP呢?来日方长。先卖个关子。这里先以TCP连接为例子进行展开。
        3、分配IP和端口
        上面讲了一大堆,竟然只讲了一个socket的创建,看来我还是太啰嗦了。那么,既然啰嗦了,就再啰嗦几句吧(囧)。创建完的套接字需要绑定一个网络地址,这样网络的两端才能进行通信。
        绑定套接字网络地址的函数如下:
                int bind(int sockfd, struct sockaddr* addr, socket_t addrlen);
                          成功时返回0,失败时返回-1。
         第一个参数就是我们创建的那个套接字ID(文件描述符),第二个参数就说来话长了,首先来看下sockaddr这个结构体的定义:
                struct sockaddr {
          unsigned short sin_family;       /* Address family */
          char sa_data[14];                /* 14 bytes of protocol address */
      };

         看完了吗?
         不说话就是看完了,那我们再来看另一个?
                struct sockaddr_in {
          unsigned short sin_family;       /* Address family */
          unsigned short sin_port;         /* Port number */
          struct in_addr sin_addr;         /* Internet address */
          unsigned char sin_zero[8];       /* Same size as struct sockaddr */
      };

         嗯,细心的你应该发现了,还有一个结构体没有定义。
                struct in_addr {
            unsigned int s_addr;            /* Internet address */
      };
         现在开始科普,1个sockaddr结构体的总字节数为16(2+14),sockaddr_in的总字节数也是16(2+2+4+8)。所以两者可以通过取地址后用指针进行强制转换(C/C++中的基础知识)。那么为什么要设计两种结构体呢?先来看下IP和端口的定义。
            1)IP(Internet Protocol)
        为了使计算机连接到网络并收发数据,必须向其分配IP地址。IP地址主要分为两类:IPv4和IPv6。IPv4是4字节的,IPv6是为了应对IP地址耗尽的问题而提出的标准,它是16字节的。目前主要应用的还是IPv4,这里也只讨论IPv4的情况。
        IPv4标准的4字节IP地址分为网络地址和主机地址,且分为A、B、C、D、E等类型,E类作为保留使用,如图一-3-1所示。
图一-3-1
        数据在互联网进行传输的时候,首先浏览网络地址,将数据传输到对应的网络,再由该网络将数据分派到对应的主机。
        并且只需要判断首字节就能清楚的知道是哪类地址。A类地址首字节范围:0~127,B类地址首字节范围:128~191,A类地址首字节范围:192~223。
            2)端口号
        IP用于区分计算机,端口号用于区分应用程序。比如你在看视频和浏览网页的时候都需要用到数据传输,那么如何区分数据是传递到那个应用程序呢,就需要给套接字指定端口号。端口号是一个2字节的无符号整型,端口号范围0-65535,其中0-1023为知名端口已经被占用(如FTP、HTTP、SMTP)。
            3)地址信息详解
        了解IP和端口后,就可以填充sockaddr_in结构了。让我们再来回顾一下sockaddr_in结构体。
                * sin_family
                        还记得创建socket时候的第一个参数吗?我们这里只讨论IPv4协议族,所以这里用PF_INET即可(有些代码里用AF_INET,它和PF_INET在Windows里是同一个宏)。
                * sin_port
                        保存16位(2字节)端口号,需要以网络字节序保存(稍后介绍)。
                * sin_addr
                        保存32位(4字节)IP地址信息,以网络字节序保存。虽然是个结构体,但是结构体下只有一个整型变量,所以可以直接理解成32位整数即可。
                * sin_zero
                        保留字段,只是为了和sockaddr字节对齐。引入sockaddr_in的原因是当协议族不是PF_INET时,情况不一样。比如IPv6的情况,IP地址是16个字节的,sin_addr明显不够用。
            4)主机字节序/网络字节序
        主机字节序主要有两种:大端序:高字节放低位地址、小端序:高字节放高位地址。
        例如,一个2字节的数字0x1234,存放的基地址为0x10。如果采用大端序,0x10存放0x12,0x11存放0x34。如果采用小端序,0x10存放0x34,0x11存放0x12。
        网络传输数据时统一采用大端序。所以必须先将数据转化成大端序格式再进行网络传输。转换函数操作系统已经给出了,总共有如下四个:htons、ntohs、htonl、ntohl。
        我去!这是什么鬼?
        不用担心,其实这四个函数很容易识别,它是由h、to、n、l、s这几个词拼出来的。h代表host,n则是network,l是long,s是short。
        主要用于对IP和端口进行主机字节序和网络字节序的转换。那么也许有人会问,那实际数据传输的时候也需要这么干吗?答案是否定的!
            5)绑定IP和端口
        实际绑定过程如下代码所示:
                #define IP "156.123.122.11"
                #define PORT 10101
                struct sockaddr_in serv_addr;

                memset(&serv_addr, 0, sizeof(serv_addr));
                serv_addr.sin_family = PF_INET;
                serv_addr.sin_addr.s_addr = inet_addr(IP);
                serv_addr.sin_port = htons(PORT);
                bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
        其中inet_addr是系统函数,将点分十进制的IP地址转换成32位大端序的整型值,失败返回 INADDR_NONE。
 二、基于TCP的服务器端
        1、TCP/IP协议
        根据数据传输方式的不同,基于网络协议的套接字一般分为TCP套接字和UDP套接字。还记得创建套接字时指定的第二个参数吗?当它为SOCK_STREAM时,即为TCP套接字。TCP是Transmission Control Protocol(传输控制协议)的简称。TCP和UDP处于网络四层协议栈的传输层(网络接口层-IP层-传输层-应用层)。之所以要分层,是为了通过标准化操作设计开放式系统,路由器用来完成IP层的交互任务,不同公司生产的路由器可以进行互相替换,因为生产商会按照IP层的标准制造。而网卡则是遵循了网络接口层的协议标准制造的。
        2、TCP服务端主流程

图二-2-1
        如图二-2-1所示,为TCP服务端的函数调用顺序。调用socket创建套接字,然后利用bind为套接字分配地址。利用listen使对应套接字进入等待连接请求状态。如果有新的请求,则调用accept接收新的客户端连接。接着使用read(接收)和write(发送)实现数据交换。数据交换完毕调用close关闭套接字。
        3、等待连接请求
        只有当服务端调用了listen函数等待连接请求,客户端才有机会接入。listen的调用比较简单。
                int listen(int sockfd, int backlog);
                          成功时返回0,失败时返回-1。
        sockfd代表了希望进入等待连接请求的文件描述符,传递完毕后该套接字成为服务器端套接字(监听套接字)。backlog为指定的连接请求等待队列的长度。
        对于一个TCP连接,服务器端与客户端需要通过三次握手来建立网络连接。当三次握手成功后,端口状态由LISTEN转变为ESTABLISHED,接着这条链路上就可以开始传送数据了。每一个处于监听(Listen)状态的端口,都有自己的监听队列。监听队列的长度与如下两方面有关:
            a. proc/sys/net/core/somaxconn
            b. listen 的第二个参数backlog
        队列大小为两者的小值,可以通过 echo 1000 > proc/sys/net/core/somaxconn 来修改前者。第二个参数backlog设置太小会导致高并发情况下,客户端connect的时候队列满了,服务器端就不会受理了,客户端继续尝试...如果还是满的...就这样恶性循环,最后导致连接超时。
        4、受理请求
        服务端受理客户端请求是由以下函数完成的:
                int accept(int sockfd, struct sockaddr* addr, socket_t* addrlen);
                          成功时返回创建的套接字文件描述符,失败时返回-1。
        函数调用成功后,操作系统将产生用于数据I/O的套接字。当客户端发起connect(稍后第三节会讲到)请求,服务端不会马上受理,而是将这些请求放在listen所对应套接字的等待队列中,accept的作用就是将队列首的请求取出来进行受理。accept的第二个参数addr就保存了客户端的地址信息,因为调用前我们并不知道是哪个客户端接入的,所以我们并不需要填充addr指向的结构。当accept返回时,自然就把地址填在*addr(注意前面的*,表示解引用,因为addr是个指针)了。
        并且如果没有特殊指定,accept是同步阻塞的,即当等待队列为空时,accept函数不会返回。
        5、数据交换
        所谓数据交换,就是我们通常所说的I/O(Input/Output)。对于TCP连接(UDP的先不介绍),主要有两个系列的函数:
        a、read/write      (Linux)
        b、recv/send       (Windows/Linux)
        限于篇幅问题,这里简单介绍下read和write,剩余内容待到日后再详细研究。
                int read(int fd, void *buf, size_t nbytes);
                          成功时返回接收到的字节数(遇到文件结尾返回0),失败时返回-1。
        read用于读取(接收)数据。fd代表数据接收对象的文件描述符,可以是文件也可以是套接字;buf保存了接收数据的首地址;nbytes代表将要接收的最大字节数。
                int write(int fd, const void *buf, size_t nbytes);
                          成功时返回发送的字节数,失败时返回-1。
        write用于写入(发送)数据。fd代表数据发送对象的文件描述符,可以是文件也可以是套接字;buf传入的是发送数据的首地址;nbytes代表发送的最大字节数。
        read和write相呼应,一端进行write,另一端进行read。这里涉及到I/O缓冲的问题,有必要解释一下。对于TCP而言,数据收发无边界,意味着服务端调用3次write函数,每次10字节(数据收发以字节为单位),客户端有可能通过一次read调用直接接收30字节的数据,反之亦然。那么这个是如何做到的呢?发送出去的字节在没有接收的情况下是寄存在哪里的?
        如图二-5-1,write函数调用后并非立即发送数据,read函数调用后也并非立即接收数据。write调用瞬间,数据将移至输出缓冲;read调用瞬间,从输入缓冲读取数据。并且在适当的时候,会将本地的输出缓冲的数据传送到对方的输入缓冲中区。
图二-5-1
        这里需要理清几个知识点:
        a.每个TCP套接字都有自己的I/O缓冲,并且在创建该套接字时自动生成(即用户不需要关心它的创建和销毁)。
        b.如果输出缓冲中还有数据,关闭套接字,这些数据还是会传送出去。
        c.如果输入缓冲中还有数据,关闭套接字,这些数据就丢失了。
        那么write函数何时返回?等到对方read之后才返回?答案是否定的。我们之前提到了输出缓冲,没错,write函数就是负责将数据移动到输出缓冲,然后就返回了。那么如果输出缓冲满了怎么办?这个又是一个值得讨论的问题。需要分阻塞socket和非阻塞socket,限于篇幅先不考虑,以后专门讲这个问题。对于read的时候缓冲区为空时,同样需要考虑阻塞和非阻塞的情况。
        6、关闭套接字
        直接给出关闭函数:
                int close(int sockfd);
                          成功时返回0,失败时返回-1。
        关闭函数还有一个叫shutdown的,属于“半关闭”。这里先介绍到这里。
        最后,附上只处理一个请求的服务端源码:https://pan.baidu.com/s/1qXGuhB2
        7、调试工具
        这里假设我们的服务器是架设在Linux上的,可以利用gcc(GNU Compiler Collection)对源文件进行编译和链接。
            gcc test.c -o test
                    将test.c预处理、汇编、编译并链接形成可执行文件test。-o选项用来指定输出文件的文件名。
            gcc -g test.c -o test
                    增加-g选项,便于用gdb进行调试。
        程序出现错误后,会生成一个core.test.30212的dump文件(其中30212为当时运行时候的进程ID),可以利用gdb进行堆栈查看。
            gdb test core.test.30212
        运行指令后,进入gdb调试界面。输入where、backtrace、info stack都可以查看到发生错误的堆栈。
        注意:写代码的时候可以在核心代码的前后打上printf用于测试代码是否正确运行到这里,注意Linux下printf最后需要加上'\n',因为printf是行缓冲。
 三、基于TCP的客户端
        1、TCP客户端主流程
        客户端的结构相对于服务端比较简单,创建socket之后调用connect进行连接,剩下的流程就一样了。
图三-1-1
        2、请求连接
        请求连接的函数如下:
                int connect(int sockfd, struct sockaddr* servaddr, socket_t addrlen);
                          成功时返回0,失败时返回-1。
        第一个参数为客户端的文件描述符,第二个参数为服务端的网络地址(同上文中的bind)的指针,第三个参数传入sizeof(sockaddr)即可。
        上文说到服务端调用listen函数后创建连接请求的等待队列,这时候客户端调用connect进行请求。connect函数的返回条件是服务端接收连接请求或者发生断网等异常情况,这里的服务端“接收连接“并不是代表服务端调用accept,其实服务端把请求记录到等待队列里。
        客户端进行本次连接的IP和端口号并不需要应用程序去分配,操作系统已经把这点做好了。
        同样附上一个简单的客户端连接源码:https://pan.baidu.com/s/1nuRV8Zf

四、回顾主流程
        最后我们来回顾下,今天学到的主要内容。用伪代码来描述下整个过程。
        服务端:
        int main() {
            server_sock = socket();
            bind(server_sock);
            listen(server_sock, 5);
            client_sock = accept(server_sock);
            read(client_sock, buf);
            write(client_sock, buf);
            close(client_sock);
            close(server_sock);
        }
        客户端:
        int main() {
            sock = socket();
            connect(sock);
            write(sock, buf);
            read(sock, buf);
            close(sock);
        }
    
        (未完待续...)

posted on 2017-12-20 20:36 英雄哪里出来 阅读(1407) 评论(0)  编辑 收藏 引用


只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理