Linux下的tcpip编程

Posted by JIN Blog on May 27, 2018

Linux下的TCP/IP编程—-基础篇

端口与进程

​ TCP 的包是不包含 IP 地址信息的,那是 IP 层上的事,但是有源端口和目的端口。 就是说,端口这一东西,是属于 TCP 知识范畴的。

​ 我们知道两个进程,在计算机内部进行通信,可以有管道、内存共享、信号量、消息队列等方法。 而两个进程如果需要进行通讯最基本的一个前提是能够唯一的标识一个进程,在本地进程通讯中我们可以使用 「PID(进程标识符)」 来唯一标识一个进程。 但 PID 只在本地唯一,如果把两个进程放到了不同的两台计算机,然后他们要通信的话,PID 就不够用了,这样就需要另外一种手段了。

​ 解决这个问题的方法就是在运输层使用 「协议端口号 (protocol port number)」,简称 「端口 (port)」. 我们知道 IP 层的 ip 地址可以唯一标识主机,而 TCP 层协议和端口号可以唯一标识主机的一个进程,这样我们可以利用:「ip地址+协议+端口号」唯一标示网络中的一个进程。 在一些场合,也把这种唯一标识的模式称为「套接字 (Socket)」。

这就是说,虽然通信的重点是应用进程,但我们只要把要传送的报文交到目的主机的某一个合适的端口,剩下的工作就由 TCP 来完成了。

​ TCP 用一个 16 位端口号来标识一个端口,可允许有 65536 ( 2的16次方) 个不同的端口号,范围在 0 ~ 65535 之间。端口号根据服务器使用还是客户端使用,以及常见不常见的维度来区分,主要有以下类别:

  • 服务器端使用的端口号
  • 熟知的端口号
  • 登记端口号
  • 客户端使用的端口号

端口号的分类

服务器端使用的端口号

熟知端口号: 取值范围:0 ~ 1023。 服务器机器一接通电源,服务器程序就运行起来,为了让因特网上所有的客户程序都能找到服务器程序,服务器程序所使用的端口就必须是固定的,并且总所众所周知的。

一些常见的端口号:

应用程序 FTP TELNET SMTP DNS TFTP HTTP HTTPS SNMP
熟知端口号 21 23 25 53 69 80 443 161

登记端口号:

取值范围:1024 ~ 49151。 这类端口没有熟知的应用程序使用,但是需要登记,以防重复

客户端使用的端口号

取值范围:49152 ~ 65535。 这类端口仅在客户端进程运行时才动态选择。 又叫 短暂端口号,表示这种端口的存在时间是短暂的,客户进程并不在意操作系统给它分配的是哪一个端口号,因为客户进程之所以必须有一个端口号,是为了让传输层的实体能够找到自己。

PS:在/etc/services文件中可以查看所有知名服务使用的端口。

socket编程

​ Socket  网络上的两个程序通过一个双向的通信连接实现数据的交换,这个连接的一端称为一个socket。  Socket的英文原义是“孔”或“插座”。作为BSD UNIX的进程通信机制,取后一种意思。通常也称作”套接字”,用于描述IP地址和端口,是一个通信链的句柄,可以用来实现不同虚拟机或不同计算机之间的通信。在Internet上的主机一般运行了多个服务软件,同时提供几种服务。每种服务都打开一个Socket,并绑定到一个端口上,不同的端口对应于不同的服务。Socket正如其英文原意那样,像一个多孔插座。一台主机犹如布满各种插座的房间,每个插座有一个编号,有的插座提供220伏交流电, 有的提供110伏交流电,有的则提供有线电视节目。 客户软件将插头插到不同编号的插座,就可以得到不同的服务 。

​ Socket的链接步骤:

###服务端:

创建套接字—-绑定端口地址—-监听链接请求—-接受链接请求——进行通讯

1. int socket(int domain,int type, int protocol):创建socket套接字

  • domain (协议族):该参数决定socket使用哪种协议进行通讯

    PF_INET——IPv4协议族 PF_INET6——-IPv6协议族 PF_LOCAL——本地通讯的Unix协议族 PF_PACKET——底层套接字协议族 PF_IPX——IPX Novell协议族

  • type (套接字类型):该参数决定了socket在协议族的限定下,使用那种方式进行连接

    SOCK_STREAM(面向连接的): 这种连接方式是要在两个socket端建立一个长连接,在通许期间是不断开的,始终保持连接。所以其在传输过程中数据不会消失,所有的数据都是按照顺序传输,而且传输的数据没有边界限定。

    SOCK_DGRAM(面向消息的): 这种连接方式在通讯时不需要始终维持一个连接,只需要在进行通讯时将数据进行包裹后向目的socket发出即可。这种连接方式并不是按照数据的顺序依次进行传输,而是把数据分割包装在包裹中,所以数据的可能会发生丢失或者是损毁,而且每次传输限制数据的大小(不能超过每个数据包的大小),所以每次传输是有边界的。

  • protocol (协议):该参数决定了socket在协议族和连接方式的限定下具体使用哪种协议进行通讯

    根据具体选择的协议族和连接方式进行选择,当所选的协议族和连接类型的限定下只有一种协议时,可以传入0作为默认值。

socket()函数成功时返回文件描述符,失败时返回-1

2. int bind(int sockfd, struct sockaddr *myaddr, socklen_t addrlen):为socket分配(绑定)地址

  • sockfd(socket的文件描述符):服务端的socket描述符
  • myaddr(socket地址):服务端的socket地址
  • addrlen(socket地址长度):服务端的socket地址长度

若成功绑定则返回0,失败则返回-1

3.int listen(int sockfd, int backlog):监听连接请求

  • sockfd(socket的文件描述符):服务端的socket描述符
  • backlog(连接请求队列的长度):表示最多可进入队列的连接请求数量。

若进入监听状态成功则返回0,失败时返回-1

4. int accept(int sockfd, struct sockaddr addr, socklen_t addrlen):接受连接请求,进行通讯

  • sockfd(socket的文件描述符):服务器端描述符
  • addr (socket地址):保存发起连接请求的socket客户端地址信息
  • addrlen(socket地址长度) :客户端socket地址长度

若成功则返回文件描述符(即将要通讯的客户端的socket描述符),失败则返回-1

5. 至此就完成了服务端的建立,在当客户端进行连接请求时就可以接受连接,进行和通讯。

###客户端:

创建套接字—-请求链接服务器—–进行通讯

1.int connect(int sockfd, struct sockaddr * serv_addr, socklen_t addrlen ):客户端请求连接

  • sockfd(socket文件描述符):要连接的服务端 socket描述符
  • serv_addr(socket地址):要连接的服务端的地址
  • addrlen(socket地址长度):服务端的socket地址长度

若成功则返回0,失败则返回-1

2. 至此就完成了客户端的建立,只要在请求连接成功之后便可以和服务端进行通讯

###客户端/服务端的通讯:

因为在Linux中所有的一切都被视为是文件,所以socket之间的读写也被认为是文件读写,所以就需要了解Linux中的文件操作。

分配给系统的标准输入/输出/错误文件描述符

文件描述符 对象
0 标准输入:Standard Input
1 标准输出:Standard Output
2 标准错误:Standard Error

int open(const *path, int flag):打开文件

  • path(文件名的字符串地址):要打开的文件名的字符串地址

  • flag(文件打开模式信息):文件的打开方式

    O_CREAT——必要时创建文件

    O_TRUNC——删除全部现有文件

    O_APPEND——维持现有文件,保存到其后面

    O_RDONLY——只读打开

    O_WRONLY——只写打开

    O_RDWR——读写打开 成功时返回文件描述符,失败时返回-1

int close(int fd):关闭文件

  • fd(文件描述符):要关闭的文件描述符

成功时返回0,失败时返回-1

ssize_t write(int fd, const void *buf, size_t nbytes):向文件中写入数据

  • fd(文件描述符):要写入的对象的文件描述符
  • buf(缓冲指针):保存要写入数据的缓存地址的指针
  • nbytes(缓冲数据的字节数):要传送数据的字节数

成功则返回写入的字节数,失败则返回-1

ssize_t read(int fd, const void *buf, size_t nbytes):从文件中读取数据

  • fd(文件描述符):要读入的对象的文件描述符
  • buf(缓冲指针):保存要读入数据的缓存地址的指针
  • nbytes(读入数据的字节数):要接受数据的最大字节数

成功时返回读取的字节数(若遇到文件末尾则返回0),失败时返回-1

ssize_t send(int fd, const void *buf, size_t nbytes,int flag):写出数据

  • fd(文件描述符):要写入的对象的套接字文件描述符
  • buf(缓冲指针):保存要写入数据的缓存地址的指针
  • nbytes(缓冲数据的字节数):要传送数据的字节数
  • flag(标志位):传输数据时指定的可选信息项

成功时返回发送的字节数,失败时返回-1

ssize_t recv(int fd, const void *buf, size_t nbytes):从文件中读取数据

  • fd(文件描述符):要读入的对象的套接字文件描述符
  • buf(缓冲指针):保存要读入数据的缓存地址的指针
  • nbytes(读入数据的字节数):可接收的最大字节数
  • flag(标志位):接收数据时的可选信息项 成功时返回接受的字节数(收到EOF时返回 0),失败时返回-1

ssize_t readv(int fds, const struct iovec* iov, int iovcnt):从文件中读取数据

  • fds(文件描述符):表示数据传输对象的套接字文件描述符,也可以像write函数一样传入文件或者是标准输入描述符
  • iov(结构体数据指针):iovec结构体数组的地址,结构体中包含发送数据的位置和大小信息。
  • iovcnt(数组大小):向第二个参数传递的数字长度

ssize_t writev(int fds, const struct iovec* iov, int iovcnt):向文件中写入数据

  • fds(文件描述符):表示数据传输对象的套接字文件描述符,也可以像read函数一样传入文件或者是标准输出描述符
  • iov(结构体数据指针):iovec结构体数组的地址,结构体中包含发送数据的位置和大小信息。
  • iovcnt(数组大小):向第二个参数传递的数字长度

###read&write, send&recv,readv&writev异同:

相同点:都可以进行基本的数据的读写操作,只从数据的传输结果来看没有太大的区别。

不同点:

read&write函数是标准的文件操作函数,只能进行文件的读写,并根据函数的返回值来判断读写的情况,其使用简单方便,但是只能满足基本的读写需求。

send&recv函数则在在read&write函数的基础上进行了扩充,多添加了一个标志位,使得在传输数据的同事能传递一些控制信息,增强了文件读写的可控制性。

readv&writev函数则是可以对数据进行整合来进行能够传输和发送,通过将多个缓冲区的数据进行整合发送,减少I/O函数调用的次数,提高了传输的效率。

###地址族与数据序列:

我们都知道在网络中是通过IP地址来确定一个主机在网络中的位置,因而可以想到在网络通讯中也是在这个基础之上进行的。准确的来说,socket通信中主要是通过IP地址+端口号的方式来进行程序间的通讯:通过IP地址来唯一确定网络上的一台主机,通过端口号来唯一确定主机上的一个通讯程序。

IP地址: IP地址分为IPv4和IPv6,目前只涉及到IPv4。两者的主要区别在于用于表示地址的字节长度不同,IPv4用4字节(32bit)来表示一个地址,IPv6用16字节(128bit)来表示一个地址。

IPv4地址分类:

A类:0~127 B类:128~191 C类:192~223

端口号: 端口号用于区分在 同一主机三个的多个应用进程,由16bit来表示,所以范围是0~65535,但是0~1023是常用的熟知端口(如web服务是80端口,FTP服务是21端口),所以仅可以使用剩下的端口号。

网络字节序与主机字节序

主机字节序就是我们平常说的大端和小端模式:不同的CPU有不同的字节序类型,这些字节序是指整数在内存中保存的顺序,这个叫做主机序。引用标准的Big-Endian和Little-Endian的定义如下:

  a) Little-Endian就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。

  b) Big-Endian就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。

网络字节序:4个字节的32 bit值以下面的次序传输:首先是0~7bit,其次8~15bit,然后16~23bit,最后是24~31bit。这种传输次序称作大端字节序。由于TCP/IP首部中所有的二进制整数在网络中传输时都要求以这种次序,因此它又称作网络字节序。字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序,一个字节的数据没有顺序的问题了。

所以:在将一个地址绑定到socket的时候,请先将主机字节序转换成为网络字节序,而不要假定主机字节序跟网络字节序一样使用的是Big-Endian。由于这个问题曾引发过血案!公司项目代码中由于存在这个问题,导致了很多莫名其妙的问题,所以请谨记对主机字节序不要做任何假定,务必将其转化为网络字节序再赋给socket。

数据序列:

由于在不同的cpu中对于数字的保存方式不同,例如对于4字节整数型值1,有的cpu保存方式为 00000000 00000000 00000000 0000000100000000 00000000 00000000 00000001,而有的cpu则是倒序保存 00000000 00000001 00000000 00000000 00000000 00000001 00000000 00000000这也就是我们所说的大端模式(高位字节存放在地位地址)和小端模式(高位字节存放在高位地址),该处的地址称为主机字节序。

为了在网络通讯时方便,统一规定网络字节序为大端模式,所以在进行地址的设定时要先进行地址的转换。

unsigned short htons(unsigned short):主机字节序转换成网络字节序,用于转换端口号

  • short(要转换的数据):要转换的数据 返回网络字节序

unsigned short ntohs(unsigned short):网络字节序转换成主机字节序,用于转换端口号

  • short(要转换的数据):要转换的数据 返回网络字节序

unsigned long htonl(unsigned long):主机字节序转换成网络字节序,用于转换IP地址

  • long(要转换的数据):要转换的数据 返回网络字节序

注:s代表short,l代表long,h代表主机字节序,n代表网络字节序。

unsigned long ntohl(unsigned long):网络字节序转换成主机字节序,用于转换IP地址

  • long(要转换的数据):要转换的数据 返回网络字节序

in_addr_t inet_addr(const char * string):将传入的IP地址转换成32位整型数,并转换成网络字节序

  • string (字符串地址):要转换的点分十进制的IP地址字符串,例如192.168.10.1

成功时返回32位打断序整数型值,失败时返回INADDR_NONE

在网络通讯中要使用socket_addr这个结构体来保存socket地址,因此需要对这个结构体有所了解。

struct sockaddr
{
 sa_family_t       sin_family;//地址族
 char              sa_data[14];
}

struct sockaddr_in
{
 sa_family_t      sin_family;//地址族
 uint16_t         sin_port;//16位的TCP/IP端口号(网络字节序)
 struct in_addr   sin_addr;//32位的IPv4地址(网络字节序)
 char             sin_zero[8]//不使用(必须填充位0,只是为了和sockaddr结构保持一致)
 }

struct in_addr
{
 in_addr_t           s_addr;//32位IPv4地址
}