【Linux】网络编程套接字

时间:2023-02-13 10:22:07

1、想要了解套接字,想让我们了解一些关于套接字的一些基础知识。
(1)先来认识一下什么是IP地址?
IP地址一共有两个分类,IPV4和IPV6,一般情况下无特殊说明的都是IPV4协议。
IP地址的概念:
IP地址是在IP协议中,用来标识网络中不同主机的地址。
对于IPV4来说,IP地址是一个4字节,32位的整数。
对于IPV6来说,IP地址是一个16字节,128位的整数。
我们通常也是用“点分十进制”的字符串标识IP地址,比如192.168.0.1(一般情况下最后一个点后是1的表示路由器的地址),用点分割的每一个数字表示一个字节,范围是0-255
(2)再来理解一下什么是源IP地址和目的IP地址?
在IP数据包中,有两个IP地址,一个叫做源IP地址一个叫做目的IP地址,源IP也就是发送方的地址,目的IP的值也就是接收方的地址。
(3)再来认识一下什么是端口号?
端口号是传输层协议的内容:
【1】端口号是一个2字节16位的整数
【2】端口号用来标识一个进程,告诉操作系统当前数据要交给哪一个进程去处理
【3】IP地址+端口号(套接字)能表示网络上某一台主机上的某个进程
【4】一个端口号只能被一个进程占用
我们学习系统编程时知道PID是唯一标识进程的,在这里我们又学到端口号是唯一标识进程的,这两个到底有什么区别呢?
端口号是指计算机与计算机之间进行通信的时候来标识不同计算机上的进程的,是由通信协议决定的,端口号一般是固定不变的,PID是操作系统内核进行分配管理的,是为了区分本主机下的不同进程的,PID也是随机产生的。
【5】理解源端口号和目的端口号?
传输层协议(TCP\UDP)的数据段中有两个端口号,分别叫做源端口号和目的端口号,就是在描述数据是谁发的,要发给谁
(4)认识一下TCP、IP协议?
TCP是一个传输层协议,它是一个有连接的、可靠的、面向字节流的通信协议。
UDP也是一个传输层协议,它是一个无连接的、不可靠的、面向数据报的通信协议。
(5)网络字节序列
内存中的多字节数据相对于内存地址有大小端之分,磁盘中的多字节数据相对于文件中的偏移地址也有大小端之分,网络数据流同样有大小端之分,那么如何定义网络字节流的地址呢?
(1)发送主机通常把发送缓冲区中的数据按内存地址从低到高的顺序发出。
(2)接收主机把从网络上接到的字节依次保存到接收缓冲区中,也是按照内存地址从低到高的顺序存储。
(3)因此网络数据流的地址应该这样规定:先发出的数据是低地址,后发出的数据是高地址
(4)TCP/IP协议规定,网络字节序列应采用大端字节序,即低地址高字节。
(5)不管这台机器是大端机还是小端机,都会按照这个TCP/IP协议规定的网络字节序列来发送和接收数据。
(6)如果当前发送主机是小端,就需要先将数据转化成大端,否则就忽略直接发送即可。
为使网络程序具有可移植性,是同样的c代码同样在大端机器和小端机器上都能运行,可以调用以下库函数作网络字节序列和主机字节序类的转化。

#include<arpa/inet.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);

【1】这些函数名其实很好记,h表示host,n表示network,l表示32位长整形,s表示16位短整数
【2】例如htonl表示将32位的长整数从主机字节序列转换成网络字节序,例如将IP地址转换后准备发送。
【3】如果主机是小端字节序, 这些函数将参数作相应的大小端转换之后返回。
【4】如果主机是大端字节序,这些函数不做转换,直接将参数原封不动返回。
2、下来认识一下socket(套接字)编程接口
socket常见API

//创建socket文件描述符(TCP/UDP,客户端+服务器)
int socket(int domain,int type, int protocol);
//绑定端口号(TCP/IP,服务器)
int bind(it socket, const struct sockaddr* address,socklen_t address_len);
//开始监听(TCP,服务器)
int listen(int socket, int backlog);
//接收请求(TCP,服务器)
int accept(int socket,const struct sockaddr* address,socklen_t address_len);
//建立连接
int connect(int sockfd,const struct sockaddr* address,socklen_t address_len);

sockaddr结构
socket API 是一层抽象的网络编程接口,适用于各种底层网络协议,如IPV4,IPV6,以及Domain Socket,然而各种网络协议的地址格式并不相同。
【Linux】网络编程套接字
【1】IPV4和IPV6的地址格式定义在netinet/in.h中,IPV4地址使用sockaddr_in结构体表示,包括16位地址类型,16位端口号和32位IP地址,
【2】IPV4、IPV6地址类型分别定义为常数AF_INET、AF_INET6,这样只要取得某种sockaddr的首地址,不需要知道哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容。
【3】socket API可以都用struct sockaddr* 类型来表示,在使用的时候需要强制转化成·sockaddr_in,这样的好处是程序的通用性,可以接收IPV4,IPV6以及UNIX Domain Socket各种类型的sockaddr结构体指针做参数。
sockaddr结构:
【Linux】网络编程套接字
sockaddr_in结构
【Linux】网络编程套接字
虽然socket api的接口是sockaddr,但是我们真正在基于IPV4编程时,使用的数据结构是sockaddr_in;那个结构里主要有三部分信息:地址类型,端口号,IP地址
inaddr结构
【Linux】网络编程套接字
in_addr表示一个IPV4的IP地址,其实就是一个32位整数
简单的UDP网络程序:
服务器代码:
server.c

#include<unistd.h>
#include<stdio.h>
#include<arpa/inet.h>
#include<sys/socket.h>
#include<string.h>
int main(int argc, char* argv[])
{
    int sock = socket(AF_INET,SOCK_DGRAM,0);
    if(sock<0)
    {
        perror("socket\n");
        return 2;
    }
   struct sockaddr_in local;
    local.sin_family = AF_INET;
    local.sin_port = htons(atoi(argv[2]));
    local.sin_addr.s_addr = inet_addr(argv[1]);
    if(bind(sock, (struct sockaddr*)&local,sizeof(local))<0)
    {

        perror("bind");
        return 3;
    }
    char buf[1024];
    struct sockaddr_in client;
    while(1)
    {
        socklen_t len = sizeof(client);
        ssize_t s = recvfrom(sock, buf, sizeof(buf)-1,0,(struct sockaddr*)&client,&len);
        if(s>0)
        {
            buf[s] = 0;
            printf("[%s:%d]:%s\n",inet_ntoa(client.sin_addr),ntohs(client.sin_port),buf);
            sendto(sock, buf, strlen(buf), 0, (struct sockaddr*)&client, sizeof(client));

        }

    }

}

解释说明:
socket的参数使用SOCK_DGRAM表示UDP
bind之后就可以直接通信了
使用sendto和recvfrom来进行数据读写
客户端代码:

#include<unistd.h>
#include<stdio.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<string.h>
int main(int argc, char* argv[])
{
    int sock = socket(AF_INET, SOCK_DGRAM,0);
    if(sock<0)
    {
        perror("socket");
        return 2;
    }
    struct sockaddr_in server;
    server.sin_family = AF_INET;
    server.sin_port = htons(atoi(argv[2]));
    server.sin_addr.s_addr = inet_addr(argv[1]);
    char buf[1024];
    struct sockaddr_in peer;
    while(1)
    {
        socklen_t len = sizeof(peer);
        printf("Please Enter#:");
        fflush(stdout);
        ssize_t s = read(0, buf, sizeof(buf)-1);
        if(s>0)
        {
            buf[s-1] = 0;
            sendto(sock, buf, strlen(buf),0,(struct sockaddr*)&server, sizeof(server));
            ssize_t _s = recvfrom(sock, buf, sizeof(buf)-1,0,(struct sockaddr*)&peer,&len);
            if(_s>0)
            {
                buf[_s]=0;
                printf("server echo# %s\n", buf);

            }
        }
    }

}

解释说明:
sock的参数使用SOCK_DGRAM表示UDP
使用sendto和recvfrom表示数据读写。
运行结果:
【Linux】网络编程套接字
在运行时不能直接运行,必须加上命令行参数,ip地址和端口号

3、下来来认识下地址转换函数
这里只介绍IPV4的socket网络编程,sockaddr_in中的成员struct in_addr sin_addr表示32位的ip地址,但是我们常用点分十进制来表示IP地址,以下函数可以在字符串表示和in_addr表示之间转换
字符串转in_addr的函数

#include<arpa/inet.h>
int inet_aton(const char* strptr,struct in_addr* addrptr);
in_addr_t inet_addr(const char* strptr);
int inet_pton(int family,const char* strptr, void * addrptr);

in_addr转字符串的函数:

char* inet_ntoa(struct in_addr inaddr);
const char* inet_ntop(int family, const void* addrptr, char* strptr);

其中inet_pton和inet_ntop不仅可以转换IPV4的in_addr,还可以转换IPV6的in6_addr,因此函数接口是void* addrptr。
代码实例:

#include<stdio.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
int main()
{
    struct sockaddr_in addr;
    inet_aton("127.0.0.1",&addr.sin_addr);
    uint32_t * ptr = (uint32_t*)(&addr.sin_addr);
    printf("addr: %x\n",*ptr);
    printf("addr_str: %s\n",inet_ntoa(addr.sin_addr));
    return 0;

}

关于inet_ntoa
inet_ntoa这个函数返回了一个char*,很显然是这个函数自己在内部申请了一块内存来保存ip的结果。那么是否需要调用者手动释放呢?
man手册上说,inet_ntoa函数,是把这个返回结果放到了静态存储区,这个时候不需要我们手动释放,多次调用这个函数会出现什么结果呢?
测试代码如下:

#include<stdio.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
int main()
{
    struct sockaddr_in addr1;
    struct sockaddr_in addr2;
    addr1.sin_addr.s_addr = 0;
    addr2.sin_addr.s_addr = 0xffffffff;
    char* ptr1 = inet_ntoa(addr1.sin_addr);
    char* ptr2 = inet_ntoa(addr2.sin_addr);
    printf("ptr1:%s,ptr2:%s\n",ptr1,ptr2);
    return 0;

}

运行结果:
【Linux】网络编程套接字
因为inet_ntoa把结果放在自己内部的一个静态存储区,这样第二次调用的结果,会覆盖掉上一次的结果。
(1)思考:如果有多个线程调用inet_ntoa,是否会出现异常状况呢?
(2)在 APUE中,明确提出inet_ntoa不是线程安全的函数
(3)但是在centos7上测试,并没有出现问题,可能内部的实现加了互斥锁
(4)在多线程环境下建议使用inet_ntop,这个函数由使用者提供一个缓冲区来保存结果,可以规避线程安全问题
多线程调用inet_ntoa代码实例如下:

#include<stdio.h>
#include<unistd.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<pthread.h>
void * Func1(void* p)
{
    struct sockaddr_in* addr = (struct sockaddr_in*)p;
    while(1)
    {
        char* ptr = inet_ntoa(addr->sin_addr);
        printf("addr1:%s\n", ptr);
    }
    return NULL;
}
void * Func2(void* p)
{
    struct sockaddr_in* addr = (struct sockaddr_in*)p;
    while(1)
    {
        char* ptr = inet_ntoa(addr->sin_addr);
        printf("addr2:%s\n", ptr);
    }
    return NULL;
}
int main()
{
    pthread_t tid1 = 0;
    struct sockaddr_in addr1;
    struct sockaddr_in addr2;
    addr1.sin_addr.s_addr = 0;
    addr2.sin_addr.s_addr=0xffffffff;
    pthread_create(&tid1,NULL,Func1,&addr1);
    pthread_t tid2 = 0;
    pthread_create(&tid2, NULL,Func2,&addr2);
    pthread_join(tid1,NULL);
    pthread_join(tid2, NULL);
    return 0;
}

运行结果:
【Linux】网络编程套接字
也会被覆盖。
3、简单的TCP网络程序
下面通过最简单的客户端/服务器程序的实例来学习socket API.实现一个简单的阻塞式的网络聊天工具。
TCP服务器:
server.c

#include<stdio.h>
#include<unistd.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<errno.h>
#include<string.h>
#include<sys/types.h>
#define _PORT_ 9999
#define _BACKLOG_ 10
int main()
{
    int sock = socket(AF_INET,SOCK_STREAM,0);
    if(sock<0)
    {
        printf("create socket error,error is:%d,errstring is:%s\n",errno,strerror(errno));
    }
    struct sockaddr_in server_socket;
    struct sockaddr_in client_socket;
    bzero(&server_socket,sizeof(server_socket));
    server_socket.sin_family=AF_INET;
    server_socket.sin_addr.s_addr = htonl(INADDR_ANY);
    server_socket.sin_port = htons(_PORT_);
    if(bind(sock,(struct sockaddr*)&server_socket,sizeof(struct sockaddr_in))<0)
    {
        printf("bind error,error code is:%d,error string is :%s\n",errno,strerror(errno));
        close(sock);
        return 1;
    }
    if(listen(sock,_BACKLOG_)<0)
    {
        printf("listen error,error code is:%d,errstring is : %s\n",errno, strerror(errno));
        close(sock);
        return 2;
    }
    printf("bind and listen success,wait accept..\n");
    for(;;)
    {
        socklen_t len = 0;
        int client_sock = accept(sock,(struct sockaddr*)&client_sock,&len);
        if(client_sock<0)
        {
            printf("accept error,errno id: %d,errstring id:%s\n",errno,strerror(errno));
            close(sock);
            return 3;
        }
    char buf_ip[INET_ADDRSTRLEN];
    memset(buf_ip,'\0',sizeof(buf_ip));
    inet_ntop(AF_INET,client_socket.sin_addr,buf_ip,sizeof(buf_ip));
    printf("get connet,ip is : %s port is:%d\n",buf_ip,ntohs(client_socket.sin_port));
    while(1)
    {
        char buf[1024];
        memset(buf,'\0',sizeof(buf));
        read(client_sock,buf,sizeof(buf));
        printf("client:#%s\n",buf);
        printf("server :$");
        memset(buf,'\0',sizeof(buf));
        fgets(buf,sizeof(buf),stdin);
        buf[strlen(buf)-1]='\0';
        write(client_sock,buf,strlen(buf)+1);
        printf("please wait...\n");
    }
    }
close(sock);
return 0;
}

下面介绍用到的socket API,这些函数都在sys/socket.h中
socket()
函数原型:
【Linux】网络编程套接字
(1)socket()打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符
(2)应用程序可以像读写文件一样用read和write在网络上收发数据
(3)如果socket调用出错则返回-1
(4)对于IPV4,family参数指定为AF_INET;
(5)对于TCP协议,type参数指定为SOCK_STREAM,表示面向流的传输协议
(6)protocol参数一般指定为0即可、
bind()
函数原型:
【Linux】网络编程套接字
(1)服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器的地址和端口号后就可以向服务器发起连接,服务器需要调用bind绑定一个固定的网络地址和端口号
(2)bind()成功返回0,失败返回-1
(3)bind()的功能是将参数sockfd和myaddr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听myaddr所描述的地址和端口号。
(4)前面说过,struct sockaddr*是一个通用指针类型,myaddr参数实际上可以接受多种协议的sockaddr结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度
我们的程序中对myaddr参数是这样初始化的

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

各函数功能:
1、将整个结构体清零
2、设置地质类型为AF_INET
3、网络地址为INADDR_ANY,这个宏表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP地址,这样设置可以在所有的IP地址上监听,直到与某个客户建立了连接才确定下来到底使用哪个IP地址
4、端口号为SERV_PORT,我们定义为9999
listen()
函数原型:
【Linux】网络编程套接字
(1)listen()声明sockfd处于监听状态,并且最多允许有backlog个客户端处于连接准备状态,如果接收到更多的连接请求就忽略,这里设置不会太大(一般是5)
(2)listen()成功返回0,失败返回-1
accept()
函数原型
【Linux】网络编程套接字
(1)三次握手完后,服务器调用accept()函数接收连接
(2)如果服务器调用accept函数之后还没有客户端的连接请求,就阻塞等待直到有客户端连接上来。
(3)addr是一个传出参数,accept()返回时传出客户端的地址和端口号。
(4)如果给addr参数传NULL,表示不关心客户端的地址和端口号。
(5)addrlen是一个传入传出参数,传入的是调用者提供的,缓冲区addr的长度以避免缓冲区溢出问题,传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。
我们的服务器程序结构是这样的:

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

TCP客户端例子:

#include<stdio.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<unistd.h>
#include<sys/types.h>
#include<arpa/inet.h>
#include<errno.h>
#include<string.h>
#define SERVER_PORT 9999
#define SERVER_IP "192.168.0.111"
int main(int argc, char* argv[])
{
    if(argc != 2)
    {
        printf("Usage :client: IP\n");
        return 1;

    }
    char* str = argv[1];
    char buf[1024];
    memset(buf, '\0',sizeof(buf));
    struct sockaddr_in server_sock;
    int sock = socket(AF_INET,SOCK_STREAM,0);
    bzero(&server_sock,sizeof(server_sock));
    server_sock.sin_family = AF_INET;
    inet_pton(AF_INET,SERVER_IP,&server_sock.sin_addr);
    server_sock.sin_port = htons(SERVER_PORT);
    int ret = connect(sock,(struct sockaddr*)&server_sock,sizeof(server_sock));
    if(ret<0)
    {
        printf("connect failed...,errno is:%d,errstring is :%s\n",errno,strerror(errno));
        return 1;
    }
    printf("connect success...\n");
    while(1)
    {
        printf("client:#");
        fgets(buf,sizeof(buf),stdin);
        buf[strlen(buf)-1]= '\0';
        write(sock,buf,sizeof(buf));
        if(strncasecmp(buf,"quit",4)==0)
        {
            printf("quit!\n");
            break;
        }
        printf("please wait...\n");
        read(sock,buf,sizeof(buf));
        printf("server:$%s\n",buf);

    }
    close(sock);
    return 0;
}

由于客户端不需要固定的端口号,因此不用调用bind(),客户端的端口号由内核自动分配。
注意:
(1)客户端不是不允许调用bind(),只是没有必要调用bind()固定一个端口号。否则如果在同一台机器上启动多个客户端,就会出现端口号被占用导致不能正确建立连接
(2)服务器也不是必须调用bind(),但如果服务器不调用bind(),内核会自动给服务器分配监听端口,每次启动服务器时端口号都不一样,客户端连接服务器时就会遇到麻烦。
运行结果:
【Linux】网络编程套接字
connect()函数:
【Linux】网络编程套接字
(1)客户端需要调用connect()函数去连接服务器,
(2)connect和bind的参数形式一致,区别在于bind()的参数都是自己的地址,而connect的参数是对面的地址
(3)connect()成功返回0,出错返回-1
我们可以看一下,服务器的监听状态。
【Linux】网络编程套接字
再启动一个客户端,尝试连接服务器,发现第二个客户端,不能正确的和服务器进行通信。
分析原因,是因为我们accept了一个请求之后,就在一直while尝试read,没有继续调用到accept,导致不能接受新的请求。
所以说我们这个Tcp只能处理一个请求。
TCP通信协议过程:
【Linux】网络编程套接字

具体过程:
(1)服务器初始化
【1】调用socket,创建文件描述符
【2】调用bind,将当前文件描述符和ip/port绑定在一起,如果这个端口已经被其他进程占用了,就会bind失败
【3】调用listen,声明当前这个文件描述符作为一个服务器的文件描述符,为后面的accept做好准备
【4】调用accept,并阻塞,等待客户端连接过来
(2)建立连接的过程
【1】调用socket创建文件描述符
【2】调用connect,像服务器 发起连接请求
【3】connect会发出SYN段并阻塞等待服务器应答(第一次)
【4】connect会发出SYN,会应答一个SYN-ACK段表示”同意建立连接”;(第二次)
【5】客户端收到SYN-ACK后会从connect返回,同时应答一个 ACK段(第三次)
这个建立连接的过程,通常称为三次握手
数据传输的过程:
【1】建立连接后,TCP协议提供全双工的通信服务;所谓全双工的意思是,在同一个连接中,同一时刻,通信双方可以同时写数据,相对的概念就是半双工,同一连接在同一时刻,只能由一方写数据
【2】服务器从accept()返回后立刻调用read(),读socket()就像读管道一样,如果没有数据到达就会阻塞等待
【3】这时客户端调用write()发送请求给服务器,服务器收到后 从read()返回,对客户端的请求进行处理,在此期间客户端调用read()阻塞等待服务器的应答
【4】服务器调用write()将处理结果发回给客户端,再次调用 read()阻塞等待下一条请求
【5】客户端收到后从read()返回,发送下一条请求,如此循环下去
(3)断开连接过程
【1】如果客户端没有更多请求了,就调用close()关闭连接,客户端会向客户端发送FIN段(第一次)
【2】此时服务器收到FIN后,会回应一个ACK,同时read会返回0(第二次)
【3】read返回之后,服务器就知道客户端关闭了连接,也调用close关闭连接,这个时候服务器会向客户端发送一个FIN(第三次)
【4】客户端收到FIN,再返回一个ACK给服务器(第四次)
这个断开连接的过程叫做四次挥手
在学习socket API时要注意应用程序和TCP协议层是如何交互的
【1】应用程序调用某个socket函数时TCP协议层完成什么动作,比如调用connect()会发出SYN段
【2】应用程序如何知道TCP协议层的状态变化,比如从某个阻塞的socket函数返回就表明TCP协议收到了某些段,再比如read(0返回0就表明收到了FIN段
TCP和UDP作比较
可靠传输vs不可靠传输
有连接vs无连接
字节流vs数据报