一、套接字
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、什么是套接字(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的,属于“半关闭”。这里先介绍到这里。
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和端口号并不需要应用程序去分配,操作系统已经把这点做好了。
四、回顾主流程
最后我们来回顾下,今天学到的主要内容。用伪代码来描述下整个过程。
服务端:
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);
}
(未完待续...)