socket的概念和常用函数介绍

1.套接字概念

Socket本身有“插座”的意思,在Linux环境下,用于表示进程间网络通信的特殊文件类型。本质为内核借助缓冲区形成的伪文件。

既然是文件,那么理所当然的,我们可以使用文件描述符引用套接字。与管道类似的,Linux系统将其封装成文件的目的是为了统一接口,使得读写套接字和读写文件的操作一致。区别是管道主要应用于本地进程间通信,而套接字多应用于网络进程间数据的传递。

套接字的内核实现较为复杂,不宜在学习初期深入学习。

在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程。“IP地址+端口号”就对应一个socket。欲建立连接的两个进程各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。因此可以用Socket来描述网络连接的一对一关系。

套接字通信原理如下图所示:

在网络通信中,套接字一定是成对出现的。一端的发送缓冲区对应对端的接收缓冲区。我们使用同一个文件描述符索发送缓冲区和接收缓冲区。一个文件描述符指向同一个套接字,该套接字内部由内核借助两个缓冲区实现。

在管道进程通信中,对于发送缓冲区和接收缓冲区(相对于不同的进程而言,都是指向管道),使用的是不同的文件描述符。

TCP/IP协议最早在BSD UNIX上实现,为TCP/IP协议设计的应用层编程接口称为socket API。本章的主要内容是socket API,主要介绍TCP协议的函数接口,最后介绍UDP协议和UNIX Domain Socket的函数接口。

2.预备知识

网络字节序

我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分。网络数据流同样有大端小端之分,那么如何定义网络数据流的地址呢?发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址。

TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节。例如上一节的UDP段格式,地址0-1是16位的源端口号,如果这个端口号是1000(0x3e8),则地址0是0x03,地址1是0xe8,也就是先发0x03,再发0xe8,这16位在发送主机的缓冲区中也应该是低地址存0x03,高地址存0xe8。但是,如果发送主机是小端字节序的,这16位被解释成0xe803,而不是1000。因此,发送主机把1000填到发送缓冲区之前需要做字节序的转换。同样地,接收主机如果是小端字节序的,接到16位的源端口号也要做字节序的转换。如果主机是大端字节序的,发送和接收都不需要做转换。同理,32位的IP地址也要考虑网络字节序和主机字节序的问题。

小端法:pc本地存储 -- 高位存高地址,低位存地址。

大端法;网络存储 -- 高位存低地址,低位存高地址。

htonl:本地小端 -htonl-> 网络大端(IP):将小端存储的IP转为大端存储的形势,防止路由分析IP的时候分析错误没法找到mac地址

htons:本地小端 -htons-> 网络大端(port)

为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换

#include <arpa/inet.h>

本地 --》 网络
uint32_t htonl(uint32_t hostlong);  //IP
uint16_t htons(uint16_t hostshort);  //port端口

网络 --> 本地
uint32_t ntohl(uint32_t netlong);   //IP
uint16_t ntohs(uint16_t netshort);   //port

h表示host,n表示network,l表示32位长整数,s表示16位短整数。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回,
如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。

192.168.1.11 --> string -atoi-> int -htonl->网络字节序

要将在内存中本质是string的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);
    只能处理IPv4的ip地址
    不可重入函数
    注意参数是struct in_addr

现在:
    #include <arpa/inet.h>
    //1. 本地字节序(string IP小端存储)  --> 网络字节序(大端存储)
    int inet_pton(int af, const char *src, void *dst);
    参数:
        af: AF_INET、AF_INET6
        src: 传入参数,IP地址(点分十进制)
        dst:传出参数,转换后的网络字节序的IP地址
    返回值:
        成功:1  
        异常:0(说明src指向的不是一个有效的ip地址)
        失败:-1

    //2. 网络字节序 --> 本地字节序
    const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
    参数:
        af: AF_INET、AF_INET6
        src: 传入参数,网络字节序IP地址
        dst:传出参数,存储转换后的本地字节序IP地址
        size:dst的大小
    返回值:
        成功:dst
        失败:NULL

    支持IPv4和IPv6
    可重入函数
    其中inet_pton和inet_ntop不仅可以转换IPv4的in_addr,还可以转换IPv6的in6_addr。
    因此函数接口是void *addrptr。

sockaddr数据结构

它是套接字不可缺少的一部分,赋值给套接字令套接字绑定上IP和端口号

strcut sockaddr 很多网络编程函数诞生早于IPv4协议,那时候都使用的是sockaddr结构体,为了向前兼容,现在sockaddr退化成了(void *)的作用,传递一个地址给函数,至于这个函数是sockaddr_in还是sockaddr_in6,由地址族确定,然后函数内部再强制类型转化为所需的地址类型。

比如bind函数中的参数const struct sockaddr *addr,虽然sockaddr类型的结构体已经不用了,但这些函数的传入参数的数据类型必须为sockaddr,因此在使用sockaddr_in或者sockaddr_in6时候要将其强转为sockaddr再作为参数传入给函数

可参看 man 7 ip。

struct sockaddr {
sa_family_t sa_family; /* address family, AF_xxx */
char sa_data[14];/* 14 bytes of protocol address */
};
//基本不用了,使用的是sockaddr精细化后的sockaddr_in(IP4)

使用 sudo grep -r "struct sockaddr_in {"  /usr 命令可查看到struct sockaddr_in结构体的定义。一般其默认的存储位置:/usr/include/linux/in.h 文件中。

struct sockaddr_in {
    __kernel_sa_family_t sin_family; //地址结构类型:AF_INET(IPv4)
    __be16 sin_port;//端口号,网络字节序类型 -- htons
    struct in_addr sin_addr;//IP地址,网络字节序类型 -- inet_pton
    /* Pad to size of `struct sockaddr'. */
    unsigned char __pad[__SOCK_SIZE__ - sizeof(short int) -
                        sizeof(unsigned short int) - sizeof(struct in_addr)];
};

struct in_addr {/* Internet address. */
    __be32 s_addr;          // IP结构体 sin_addr,32,要用到inet_pton函数转为网络字节序
};
//------------------------------------------------------------------------------------------
struct sockaddr_in6 {
    unsigned short int sin6_family; /* AF_INET6 */
    __be16 sin6_port; /* Transport layer port # */
    __be32 sin6_flowinfo; /* IPv6 flow information */
    struct in6_addr sin6_addr;/* IPv6 address */
    __u32 sin6_scope_id; /* scope id (new in RFC2553) */
};

struct in6_addr {
    union {
        __u8 u6_addr8[16];
        __be16 u6_addr16[8];
        __be32 u6_addr32[4];
    } in6_u;
    #define s6_addr in6_u.u6_addr8
    #define s6_addr16 in6_u.u6_addr16
    #define s6_addr32 in6_u.u6_addr32
};

#define UNIX_PATH_MAX 108
struct sockaddr_un {
    __kernel_sa_family_t sun_family; /* AF_UNIX */
    char sun_path[UNIX_PATH_MAX]; /* pathname */
};

Pv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位端口号和32位IP地址,IPv6地址用sockaddr_in6结构体表示,包括16位端口号、128位IP地址和一些控制字段。UNIX Domain Socket的地址格式定义在sys/un.h中,用sock-addr_un结构体表示。各种socket地址结构体的开头都是相同的,前16位表示整个结构体的长度(并不是所有UNIX的实现都有长度字段,如Linux就没有),后16位表示地址类型。IPv4、IPv6和Unix Domain Socket的地址类型分别定义为常数AF_INET、AF_INET6、AF_UNIX。这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容。因此,socket API可以接受各种类型的sockaddr结构体指针做参数,例如bind、accept、connect等函数,这些函数的参数应该设计成void *类型以便接受各种类型的指针,但是sock API的实现早于ANSI C标准化,那时还没有void *类型,因此这些函数的参数都用struct sockaddr *类型表示,在传递参数之前要强制类型转换一下,例如:

struct sockaddr_in servaddr;
bind(listen_fd, (struct sockaddr *)&servaddr, sizeof(servaddr));/* initialize servaddr */

地址初始化示例

初始化示例:
struct sockaddr_in addr;
char *IP = 19.18.1.1;
addr.sin_fimaly = AF_INET;  //地址类型,AF_INET:IPv4
addr.sin_port = htons(8808);  //端口号,利用htons转为网络字节序(大端)

//第一种:int inet_pton(int af,const char *src,void *dst);  返回值无法赋值,得用传出参数
int dst;   //传出参数
inet_pton(AF_INET,IP,(void*)&dst);   //调用
addr.sin_addr.s_addr = dst;  //传出参数 -- 赋值
//——》也可以合并:
inet_pton(AF_INET,IP,&addr.sin_addr.s_addr);


//第二种:利用宏INADDR_ANY -- 掌握
addr.sin_addr.s_addr = htonl(INADDR_ANY);  //取出当前系统中有效的任意IP地址,二进制类型
//也就是自己将IP转为二进制类型进行转为大端存储方式 -- 网络字节序

3.网络套接字函数

socket模型创建流程图

socket模型建立一共有3个套接字

socket函数

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
domain:
AF_INET 这是大多数用来产生socket的协议,使用TCP或UDP来传输,用IPv4的地址
AF_INET6 与上面类似,不过是来用IPv6的地址
AF_UNIX/AF_LOCAL 本地协议,使用在Unix和Linux系统上,一般都是当客户端和服务器在同一台及其上的时候使用
type:
SOCK_STREAM 这个协议是按照顺序的、可靠的、数据完整的基于字节流的连接。这是一个使用最多的socket类型,这个socket是使用TCP来进行传输。
SOCK_DGRAM 这个协议是无连接的、固定长度的传输调用。该协议是不可靠的,使用UDP来进行它的连接。
             //上面两种协议为常用的,分别为流式协议和报时协议
SOCK_SEQPACKET该协议是双线路的、可靠的连接,发送固定长度的数据包进行传输。必须把这个包完整的接受才能进行读取。
SOCK_RAW socket类型提供单一的网络访问,这个socket类型使用ICMP公共协议。(ping、traceroute使用该协议)
SOCK_RDM 这个类型是很少使用的,在大部分的操作系统上没有实现,它是提供给数据链路层使用,不保证数据包的顺序
protocol:
传0 表示使用默认协议。根据参2选择的协议类型默认匹配上相应的协议。
         SOCK_STREAM:TCP协议
         SOCK_DGRAM:UDP协议
返回值:
成功:返回指向新创建的socket的文件描述符,失败:返回-1,设置errno
         //要注意的是该套接字可以绑定上IP和port,不绑定则由系统默认分配

socket()打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符,应用程序可以像读写文件一样用read/write在网络上收发数据,如果socket()调用出错则返回-1。对于IPv4,domain参数指定为AF_INET。对于TCP协议,type参数指定为SOCK_STREAM,表示面向流的传输协议。如果是UDP协议,则type参数指定为SOCK_DGRAM,表示面向数据报的传输协议。protocol参数的介绍从略,指定为0即可。

bind函数

绑定IP和端口号

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd:
socket文件描述符 -- 套接字2
addr:   传入参数
创建一个sockaddr的结构体赋值好服务器的ip和端口号,作为传入参数传入
        会将其赋值给套接字2,将套接字2绑定上服务器的ip和端口
addrlen:
sizeof(addr)长度
返回值:
成功返回0,失败返回-1, 设置errno

服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接,因此服务器需要调用bind绑定一个固定的网络地址和端口号。

bind()的作用是将参数sockfd和addr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听addr所描述的地址和端口号。前面讲过,struct sockaddr *是一个通用指针类型,addr参数实际上可以接受多种协议的sockaddr结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度。如:

struct sockaddr_in servaddr;
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(6666);

首先将整个结构体清零,然后设置地址类型为AF_INET,网络地址为INADDR_ANY,这个宏表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP地址,这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个IP地址,端口号为6666。

listen函数

设置同时与服务器(监听套接字2)建立连接的上限数(同时进行3次握手党的客户端数量)

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);
sockfd:
socket文件描述符 -- 套接字2
backlog:
排队建立3次握手队列和刚刚建立3次握手队列的链接数和
         最大值是128

查看系统默认backlog
cat /proc/sys/net/ipv4/tcp_max_syn_backlog

典型的服务器程序可以同时服务于多个客户端,当有客户端发起连接时,服务器调用的accept()返回并接受这个连接,如果有大量的客户端发起连接而服务器来不及处理,尚未accept的客户端就处于连接等待状态,listen()声明sockfd处于监听状态,并且最多允许有backlog个客户端处于连接待状态,如果接收到更多的连接请求就忽略。listen()成功返回0,失败返回-1。

accept函数

阻塞监听客户端连接,成功会返回一个绑定了客户端IP和端口的socket(句柄 -- 文件描述符 -- 返回值),并且获取到对方客户端的IP和端口号(传出参数),而原先那个套接字就会被解放,用于去监听(迎接下一个客户端)。所以说socket模型创建有3个套接字。

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
sockdf:
socket文件描述符 -- 套接字2,套接字3是依托套接字2创建出来的
addr:
传出参数,返回链接客户端地址信息,含IP地址和端口号
addrlen:
传入传出参数(值-结果),传入sizeof(addr)大小,函数返回时返回真正接收到地址结构体的大小
返回值:
成功返回一个新的socket文件描述符,用于和客户端通信,失败返回-1,设置errno

三方握手完成后,服务器调用accept()接受连接,如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来。addr是一个传出参数,accept()返回时传出客户端的地址和端口号。addrlen参数是一个传入传出参数(value-result argument),传入的是调用者提供的缓冲区addr的长度以避免缓冲区溢出问题,传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。如果给addr参数传NULL,表示不关心客户端的地址。

我们的服务器程序结构是这样的:

while (1) {
cliaddr_len = sizeof(cliaddr);
connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);
n = read(connfd, buf, MAXLINE);
......
close(connfd);
}

整个是一个while死循环,每次循环处理一个客户端连接。由于cliaddr_len是传入传出参数,每次调用accept()之前应该重新赋初值。accept()的参数listenfd是先前的监听文件描述符,而accept()的返回值是另外一个文件描述符connfd,之后与客户端之间就通过这个connfd通讯,最后关闭connfd断开连接,而不关闭listenfd,再次回到循环开头listenfd仍然用作accept的参数。accept()成功返回一个文件描述符,出错返回-1。

connect函数

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockdf:
socket文件描述符 -- 套接字1
addr:
传入参数,指定服务器端地址信息,含IP地址和端口号
         定义的时候为struct sockaddr_in,传入的时候取地址后强转为sockaddr*
addrlen:
传入参数,传入sizeof(addr)大小
返回值:
成功返回0,失败返回-1,设置errno

客户端需要调用connect()连接服务器,connect和bind的参数形式一致,区别在于bind的参数是自己的地址,而connect的参数是对方的地址。connect()成功返回0,出错返回-1。

总结

套接字2绑定上服务器的IP和端口(bind),获取到句柄nfd,方便客户端connect申请连接到服务器。

bind绑定,利用listen设置排队建立3次握手队列和刚刚建立3次握手队列的链接数和(也就是同时连接的客户端数),然后套接字2等待客户端利用connect申请连接服务器(套接字2就像个迎宾人),若客户端申请,服务器accept确认接受并解除阻塞,会返回一个新的套接字3,同时获取到申请连接的客户端的IP和端口号,而套接字2会解放去继续下一轮的客户端申请连接。

套接字3用于与客户端套接字1进行数据的交互。

--> 也就是套接字2捆绑着服务器的IP和端口号等(bind),listen设置监听上限,充当监听人(迎宾人),客户端知道服务器的IP和端口后利用connect申请将自己的套接字1句柄与服务器建立连接(套接字1和服务器的IP和端口会绑定在一起),服务器accept正在阻塞接收到申请后进行连接,同时返回返回一个新的套接字3句柄负责与客户端进行数据的交换,并且会得到客户端的IP和端口号(客户端的IP和端口会和套接字3绑定在一起) -- 两个相互绑定着对方的IP和端口号的套接字2、3实现数据的交互(地址结构皆是网络字节序)

4.案例

由于篇幅原因,下个章节讲解

使用socket编程来实现一个简单的C/S模型(TCP协议)-CSDN博客

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值