【Linux】详解套接字编程

news2024/9/20 9:29:09

文章目录

    • 网络套接字
      • 1.端口号
        • 1.1认识端口号
        • 1.2端口号VS PID
      • 2.TCP与UDP协议
      • 3.网络字节序
      • 4.socket编程
        • 4.1常用接口
        • 4.2sockaddr结构
        • 4.3.socket接口的底层工作
        • 4.4字符串IP VS 整形IP
        • 4.5 bind与INADDR_ANY
      • 5.UDP聊天服务器
        • 5.1va_start和va_end
        • 5.2vsnprintf函数
        • 5.3自定义日志类
        • 5.4UDP服务端
        • 5.5UDP客户端
      • 6.TCP服务器
        • 6.1TCP客户端
        • 6.2TCP服务端
        • 6.3以TCP聊天服务器为例
      • 7.多线程服务器
        • 7.1多线程版本
        • 7.2线程池版本
      • 8.源代码地址

网络套接字

1.端口号

两台主机之间通信的目的不仅仅是为了将数据发送给对端主机,而是为了访问对端主机上的某个服务。端口就是找到这个服务的钥匙,标识主机上的一个进程。

网络通信的本质:本质上就是一种进程间通信

通过IP地址和MAC地址能够将数据发送到对端主机了,但实际我们是想将数据发送给对端主机上的某个服务进程。

此外,数据的发送者也不是主机,而是主机上的某个进程,比如当我们用浏览器访问数据时,实际就是浏览器进程向对端服务进程发起的请求。

socket通信的本质就是一种进程间通信。

1.1认识端口号

端口号(port)是传输层协议的内容.

  • 端口号是一个2字节16位的整数;
  • 端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理;
  • IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;
  • 一个端口号只能被一个进程占用

因为端口号是隶属于某台主机的,所以端口号可以在两台不同的主机当中重复,但是在同一台主机上进行网络通信的进程的端口号不能重复。此外,一个进程可以绑定多个端口号,但是一个端口号不能被多个进程同时绑定。

1.2端口号VS PID

端口号和进程ID都可以标识一个进程。为什么网络通信不直接使用进程ID?

进程ID(PID)是用来标识系统内所有进程的唯一性的,它是属于系统级的概念;而端口号(port)是用来标识需要对外进行网络数据请求的进程的唯一性的,它是属于网络的概念。

一台机器上可能会有大量的进程,但并不是所有的进程都要进行网络通信,可能有很大一部分的进程是不需要进行网络通信的本地进程,此时PID虽然也可以标识这些网络进程的唯一性,但在该场景下就不太合适了。

Port和进程ID反映了一个进程使用的不同场景,在同一主机下使用进程ID标识一个进程,在网络通信中使用Port标识一个进程

OS如何通过Port找到进程ID

实际底层采用了Hash方式,建立了进程ID和Port的映射关系。当底层拿到Port后可以通过Hash映射直接找到对应的进程。

2.TCP与UDP协议

TCP协议

TCP协议叫做传输控制协议(Transmission Control Protocol),TCP协议是一种面向连接的、可靠的、基于字节流的传输层通信协议。

TCP协议是面向连接的,如果两台主机之间想要进行数据传输,那么必须要先建立连接,当连接建立成功后才能进行数据传输。其次,TCP协议是保证可靠的协议,数据在传输过程中如果出现了丢包、乱序等情况,TCP协议都有对应的解决方法。

特点

  • 传输层协议
  • 有连接
  • 可靠传输
  • 面向字节流

UDP协议

UDP协议叫做用户数据报协议(User Datagram Protocol),UDP协议是一种无需建立连接的、不可靠的、面向数据报的传输层通信协议。

使用UDP协议进行通信时无需建立连接,如果两台主机之间想要进行数据传输,那么直接将数据发送给对端主机就行了,但这也就意味着UDP协议是不可靠的,数据在传输过程中如果出现了丢包、乱序等情况,UDP协议本身是不知道的。

特点

  • 传输层协议
  • 无连接
  • 不可靠传输
  • 面向数据报

3.网络字节序

内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分。

  • 大端存储:低字节存放在高地址,高字节存放在低地址。
  • 小端存储:低字节存放在低地址,高字节存放在高地址。

image-20221128011401162

只在本地机器上运行,那么是不需要考虑大小端问题的。不同的主机存放的方式可能不同,如果不同一标准,那么两台主机通信数据就是错误混乱的。

网络数据流地址

  • 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
  • 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
  • 因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
  • TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.
  • 不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据;
  • 如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可

网络字节序与主机字节序之间的转换

#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
主机字节序转换为网络字节序【大端】----转换4字节的IP地址
uint16_t htons(uint16_t hostshort);
主机字节序转换为网络字节序----转换2字节的端口号
uint32_t ntohl(uint32_t netlong);
网络字节序【大端】转换为主机字节序 -----转换4字节的IP地址
uint16_t ntohs(uint16_t netshort);
网络字节序【大端】转换为主机字节序 -----转换2字节的端口号
  • 这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
  • 例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
  • 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
  • 如果主机是大端字节序,这些 函数不做转换,将参数原封不动地返回。

4.socket编程

4.1常用接口

常见接口

创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)

int socket(int domain, int type, int protocol);

参数说明:
	domain:IP地址类型
			常用:AF_INET,AF_INET6
        
	type:套接字类型
			SOCK_STREAM:它提供基于字节流的有序、可靠、双向连接
可以支持带外数据传输机制。【流式套接字------用于TCP通信】
			SOCK_DGRAM:支持数据报(固定最大值的无连接、不可靠消息长度)。【报文套接字----UDP通信】
			SOCK_SEQPACKET
                
	protocol:默认0

返回值:
        成功:返回文件描述符
        失败:-1

绑定端口号 (TCP/UDP, 服务器)

int bind(int socket, const struct sockaddr *address,socklen_t addrlen);

参数:
    sockfd:套接字
        
    address:ip套接字结构体地址
        
    addrlen:结构体大小
         
返回值:
      成功返回0
      失败返回-1

开始监听socket (TCP, 服务器)

int listen(int socket, int backlog);

参数:
    socket:套接字
        
    backlog:已完成连接队列和未完成连接队列数之和的最大值 128。一般这个参数为5

接收请求 (TCP, 服务器)

int accept(int socket, struct sockaddr* address,socklen_t* address_len);

参数:
    socket:套接字
        
    address:传入类型参数,获取客户端的IP和端口信息
        
    address_len:结构体大小的地址
返回值:
      新的已连接套接字的文件描述符

建立连接 (TCP, 客户端)

int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);

参数
    sockfd:sockfd套接字【文件描述符】
        
    addr:套接字结构体的地址
        
    addrlen:结构体的长度

4.2sockaddr结构

套接字不仅支持跨网络的进程间通信,还支持本地的进程间通信(域间套接字)。在进行跨网络通信时我们需要传递的端口号和IP地址,而本地通信则不需要,因此套接字提供了sockaddr_in结构体和sockaddr_un结构体,其中sockaddr_in结构体是用于跨网络通信的,而sockaddr_un结构体是用于本地通信的。

socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6 ,然而, 各种网络协议的地址格式并不相同,为了让套接字编程具有同一的接口,于是出现了sockaddr结构体。

image-20221128013621941

  • IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位地址类型, 16位端口号和32位IP地址.
    IPv4、IPv6地址类型分别定义为常数AF_INET、AF_INET6. 这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容.
  • socket API可以都用struct sockaddr *类型表示, 在使用的时候需要强制转化成sockaddr_in; 这样的好处是程序的通用性, 可以接收IPv4, IPv6, 以及UNIX Domain Socket各种类型的sockaddr结构体指针做为参数;

4.3.socket接口的底层工作

创建套接字操作系统做了什么?

int socket(int domain, int type, int protocol);

当我们调用socket函数创建一个套接字时,实际上相当于我们打开了一个网络文件。打开文件后,在内核层面上,在内核管理文件结构体的FCB链表中链入了一个struct file结构体。并将结构体地址填入到对应的fd_array[ ]中,返回对应的数组下标。这个数组下标也就是socket的返回值

image-20221128110351020

下面验证上面的说法:我们关闭标准错误,然后再创建套接字,观察返回值是否为2

int main(){
    close(2);
    int sockfd=socket(AF_INET,SOCK_DGRAM,0);
    if(sockfd<0){
        std::cout<<"socket err "<<strerror(errno)<<std::endl;
    }
    std::cout<<"sockfd : "<<sockfd<<std::endl; 
}

image-20221128111746175

每一个struct file结构体包含了对应打开文件的各种信息,比如文件属性、操作方法以及文件缓冲区等。

  • 其中文件属性是由内核当中的struct node结构体维护。
  • 而文化对应的操作方法则通过函数指针来实现多态。由struct file_operations来维护
  • 对于一般的文件,缓冲区一般是磁盘文件;而对于网络文件,缓冲区则是网卡。

image-20221128112622063

如何理解bind?

在创建套接字后,OS如何确定对于的文件描述符sockfd对应的是一个磁盘文件还是网络文化。

在bind()接口中,struct sockaddr *address 参数包含了IP和端口号信息。而bind,需要将IP地址和port端口号告诉对应的网络文件;

此时可以改变网络文件当中,struct file_operations当中文件操作函数的指向,把对应的操作函数改为对应网卡的操作函数。

所以bind就是将文件和网络联系起来。(通过修改strucqt file_operations中函数指针的指向)

4.4字符串IP VS 整形IP

IP地址的表现形式有两种:

  • 字符串IP:类似于192.168.233.123这种字符串形式的IP地址,叫做基于字符串的点分十进制IP地址。
  • 整数IP:IP地址在进行网络传输时所用的形式,用一个32位的整数来表示IP地址。

关于字符串IP和整形IP的转化,在下面的博客中由详细的说明:

【网络编程】套接字_影中人lx的博客-CSDN博客

4.5 bind与INADDR_ANY

如果要想写的服务器在网络上允许,需要绑定服务器的IP地址。由于云服务的IP地址是由厂商提供,这个IP地址并不一定是真正的公网IP。如果需要让外网访问,因此需要绑定0。

系统中提供了一个INADDR_ANY的宏,对应的值为0。

bind() INADDR_ANY的好处

一个服务器的带宽足够大,那么一台服务器的数据接收能力就约束了机器的IO能力。因此一台机器的底层可能装有多个网卡,此时这台服务器可能就有多个IP地址。

image-20221128120253180

但是接收数据的服务器的端口8081只有一个,当网络中有数据时,这台服务器的多张网卡底层都收到了数据。如果服务器只绑定其中的一个IP,那么服务器只能从对应的网卡接收数据。

image-20221128121009872

如果服务器绑定的是INADDR_ANY,那么网卡接收到的数据,服务器端口都可以接收,极大的提高了效率。

image-20221128121200447

5.UDP聊天服务器

5.1va_start和va_end

在C中,当无法列出传递函数的所有实参的类型和数目时,可以用省略号指定参数表。例如:

void foo(...);
void foo(int level,char* format...);

函数传参原理

函数参数是以栈的形式,从右到左入栈。

参数的内存存放格式:参数存放在内存的堆栈段中,在执行函数的时候,从最后一个参数开始入栈。因此栈底高地址,栈顶低地址

void func(int x, float y, char z);

那么,调用函数的时候,实参 char z 先进栈,然后是 float y,最后是 int x,因此在内存中变量的存放次序是 x->y->z。

相关接口

typedef char* va_list;

void va_start (va_list ap, prev_param ); /* ANSI version */
type va_arg ( va_list ap, type ); 
void va_end ( va_list ap ); 
  • va_list:一个字符指针,可以理解为指向当前参数的一个指针,取参必须通过这个指针进行。
  • va_start:对ap进行初始化,让ap指向可变参数表里面的第一个参数。第一个参数是 ap 本身,第二个参数是在变参表前面紧挨着的一个变量,即“…”之前的那个参数;
  • va_arg: 获取参数。它的第一个参数是ap,第二个参数是要获取的参数的指定类型。按照指定类型获取当前参数,返回这个指定类型的值,然后把 ap 的位置指向变参表中下一个变量的位置;
  • **va_end:释放指针,将输入的参数 ap 置为 NULL。通常va_start和va_end是成对出现。 **
int demo(const char *msg, ...)
{
    /*定义保存函数参数的结构*/
    va_list argp;
    int argno = 0;
    char *para;
    /*argp指向传入的第一个可选参数,msg是最后一个确定的参数*/
    va_start(argp, msg);
    while (1)
    {
        para = va_arg(argp, char *);
        if (strcmp(para, "") == 0)
            break;
        printf("Parameter #%d is: %s\n", argno, para);
        argno++;
    }
    va_end(argp);
    /*将argp置为NULL*/
    return 0;
}
int main(void)
{
    demo("DEMO", "This", "is", "a", "demo!", "");
    return 0;
}

image-20221128124835556

5.2vsnprintf函数

#include <stdarg.h>
int vsnprintf(char *str, size_t size, const char *format, va_list ap);

参数:
	str:保存输出字符数组的存储区。
    size:存储区的大小。
    format:包含格式字符串的C字符串,其格式字符串与printf中的格式相同
    arg:变量参数列表,用va_list 定义。第一个参数必须是format

按照一定的格式打印参数,这也是printf函数的底层实现。

int demo(const char *format, ...)
{
    /*定义保存函数参数的结构*/
    va_list argp;
    va_start(argp,format);
    char buff[128];
    vsnprintf(buff,sizeof(buff)-1,format,argp);
    va_end(argp);
    std::cout<<"buff : "<<buff<<std::endl;
    return 0;
}
int main(void)
{
    const char* format="Demo :%s,int:%d,double:%f,char:%c";
    demo(format,"DEMO",2022,11.11,'a');
    return 0;
}

image-20221128130044074

5.3自定义日志类

//日志等级
#define DEBUG 0
#define NOTICE 1
#define WARINING 2
#define FATAL 3

const char *log_level[]={"DEBUG", "NOTICE", "WARINING", "FATAL"};
void logMessage(int level,const char*format,...){
    assert(level>=DEBUG);
    assert(level<=FATAL);
    char* name=getenv("USER");
    char loginfo[1024];
    
    va_list ap;
    va_start(ap,format);
    vsnprintf(loginfo,sizeof(loginfo)-1,format,ap);
    va_end(ap);

    //如果是FATAL错误,输出到标准错误中
    FILE* out=(level==FATAL)?stderr:stdout;
    fprintf(out, "%s | %u | %s | %s\n", \
        log_level[level], \
        (unsigned int)time(nullptr),\
        name == nullptr ? "unknow":name,\
        loginfo);                                                             
}

5.4UDP服务端

static void Usage(const std::string porc)
{
    std::cout << "Usage:\n\t" << porc << " port [ip]" << std::endl;
}

class Udpserver
{
public:
    Udpserver(int port,std::string ip="")
        :port_((uint16_t)port),ip_(ip),sockfd_(-1)
    {

    }
    ~Udpserver(){

    }
    //初始化接口
    void init(){
        sockfd_=socket(AF_INET,SOCK_DGRAM,0);
        if(sockfd_<0){
            logMessage(FATAL, "socket:%s:%d", strerror(errno), sockfd_);
            exit(1);
        }
        logMessage(DEBUG,"create socket sucess: %d",sockfd_);
        //绑定端口
        struct sockaddr_in local;
        bzero(&local,sizeof(local));
        local.sin_family=AF_INET;
        local.sin_port=htons(port_);
        // 服务器都必须具有IP地址,"xx.yy.zz.aaa",字符串风格点分十进制 -> 4字节IP -> uint32_t ip
        // INADDR_ANY(0): 程序员不关心会bind到哪一个ip, 任意地址bind,强烈推荐的做法,所有服务器一般的做法
        // inet_addr: 指定填充确定的IP,特殊用途,或者测试时使用,除了做转化,还会自动给我们进行 h—>n
        //绑定任意端口
        local.sin_addr.s_addr=htonl(INADDR_ANY);
        int ret=bind(sockfd_,(const struct sockaddr*)&local,sizeof(local));
        if(ret<0){
            logMessage(FATAL,"bind:%s:%d",strerror(errno),sockfd_);
            exit(2);
        }
        logMessage(DEBUG,"bind sucess:%d",sockfd_);
    }



    void checkOnlineUser(std::string& clientip,uint32_t clientport,struct sockaddr_in& client){
        std::string key = clientip;
        key += ":";
        key += std::to_string(clientport);
        auto iter=users.find(key);
        if(iter==users.end()){
            users.insert({key, client});
        }
        else{

            //do nothing
        }
    }
    //接收消息,实现广播
    void start(){
        //发送缓存和接收缓存
        char recvbuff[1024]={0};
        char sendbuff[1024]={0};
        while(true){
            struct sockaddr_in client;
            socklen_t len=sizeof(client);
            ssize_t s=recvfrom(sockfd_,recvbuff,sizeof(recvbuff)-1,0,(struct sockaddr*)&client,&len);
            if(s>0){
                recvbuff[s]=0;
            }
            else if(s==-1)
            {
                logMessage(WARINING,"recvform:%s:%d",strerror(errno),sockfd_);
                continue;
            }
            // 读取成功的,除了读取到对方的数据,还要读取到对方的网络地址[ip:port]
            std::string clientIp = inet_ntoa(client.sin_addr);       //拿到了对方的IP
            uint32_t clientPort = ntohs(client.sin_port); // 拿到了对方的port

            checkOnlineUser(clientIp, clientPort, client); //如果存在,什么都不做,如果不存在,就添加
            // 打印出来客户端给服务器发送过来的消息
            logMessage(NOTICE, "[%s:%d]# %s", clientIp.c_str(), clientPort, recvbuff);

            //实现广播
            messageRoute(clientIp,clientPort,recvbuff);
        }
    }
    void messageRoute(std::string ip, uint32_t port, std::string info)
    {

        std::string message = "[";
        message += ip;
        message += ":";
        message += std::to_string(port);
        message += "]# ";
        message += info;
        for(auto &user : users)
        {
            sendto(sockfd_, message.c_str(), message.size(), 0, (struct sockaddr*)&(user.second), sizeof(user.second));
        }
    }
private:
    int sockfd_; //套接字
    uint16_t port_;
    std::string ip_;
    std::unordered_map<std::string, struct sockaddr_in> users; //记录在线用户
};

5.5UDP客户端

//多线程客户端,一个线程用于用户IO,一个线程用于接收广播消息

struct sockaddr_in server;  //服务器信息

static void Usage(std::string name){
    std::cout << "Usage:\n\t" << name << " server_ip server_port" << std::endl;
}


void* recv_rounite(void* arg){
    while (true)
    {
        int sockfd=*(int*)arg;
        char buff[1024];
        struct sockaddr_in client;
        socklen_t len=sizeof(client);
        ssize_t s=recvfrom(sockfd,buff,sizeof(buff),0,(struct sockaddr*)&client,&len);
        if (s > 0)
        {
            buff[s] = 0;
            std::cout << "server echo# " << buff << std::endl;
        }
        
    }
    
}
int main(int argc,char*argv[]){
    if(argc!=3){
        Usage(argv[0]);
        exit(1);
    }
    std::string server_ip=argv[1];
    uint16_t server_port=atoi(argv[2]);  

    int sockfd=socket(AF_INET,SOCK_DGRAM,0);
    assert(sockfd>0);
    
    bzero(&server,sizeof(server));
    server.sin_family=AF_INET;
    server.sin_port=htons(server_port);
    server.sin_addr.s_addr=inet_addr(server_ip.c_str());
    //接收消息
    pthread_t rev;
    pthread_create(&rev,nullptr,recv_rounite,(void*)&sockfd);
    std::string buffer;

    //用户进行IO操作
    while(true){
        std::cerr<<"please Enter#  ";
        std::getline(std::cin,buffer);
        sendto(sockfd,buffer.c_str(),buffer.size(),0,(const struct sockaddr*)&server,sizeof(server));
    }
    close(sockfd);

    return 0;
}

为什么在UDP中客户端中,用户不需要进行bind?

其实需要bind。但是不是由用户进行bind,而是OS自动进行bind。

**严重不推荐自己在客户端绑定:**client可能有很多,不能给客户端bind指定的port,port可能被别的client使用了,如果再进行bind就会发生崩溃。

结果展示

image-20221129143338643

这个简单的服务器也支持多个用户同时在线。

6.TCP服务器

6.1TCP客户端

客户端主要实现连接客户端。与用户IO交互,接收用户的命令发送给客户端。并接收客户端发生的信息。

///多线程客户端,一个线程用于用户IO,一个线程用于接收广播消息

struct sockaddr_in server;  //服务器信息
volatile bool quit=false;	//判断用户是否需要退出

static void Usage(std::string name){
    std::cout << "Usage:\n\t" << name << " server_ip server_port" << std::endl;
}


void* recv_rounite(void* arg){
    while (true)
    {
        int sockfd=*(int*)arg;
        char buff[1024];
        struct sockaddr_in client;
        socklen_t len=sizeof(client);
        ssize_t s=read(sockfd,buff, sizeof(buff) - 1);
        if (s > 0)
        {
            buff[s] = 0;
            std::cout << "server echo# " << buff << std::endl;
        }
    }
    
}
int main(int argc,char*argv[]){
    if(argc!=3){
        Usage(argv[0]);
        exit(1);
    }

    std::string server_ip=argv[1];
    uint16_t server_port=atoi(argv[2]);  

    int sockfd=socket(AF_INET,SOCK_STREAM,0);
    if (sockfd< 0){
        std::cerr << "socket: " << strerror(errno) << std::endl;
        exit(SOCKET_ERR);
    }
    bzero(&server,sizeof(server));
    server.sin_family=AF_INET;
    server.sin_port=htons(server_port);
    server.sin_addr.s_addr=inet_addr(server_ip.c_str());
    socklen_t len=sizeof(server);
    int ret=connect(sockfd,(const sockaddr*)&server,len);
    //接收消息
    pthread_t rev;
    pthread_create(&rev,nullptr,recv_rounite,(void*)&sockfd);

    string buffer;
    //用户进行IO操作
    while(true){
        std::cerr<<"please Enter#  ";
        std::getline(std::cin,buffer);
        //判断用户是否需要退出
        if(strcasecmp(buffer.c_str(),"quit")==0){
            quit=true;
        }
        ssize_t s=write(sockfd,buffer.c_str(),sizeof(buffer.c_str())-1);
        if(s>0){
            continue;
        }
        else if(s<=0){
            break;
        }
    }
    close(sockfd);

    return 0;
}

6.2TCP服务端

TCP服务端和UDP服务端的写法不同,TCP是有连接的。需要经过下面的步骤:

  • 创建监听套接字
  • 将监听套接字于IP和端口绑定(bind())
  • 监听listen(),等待请求。
  • 提取链接accept(),得到一个新的通信套接字
  • 通信
  • 关闭通信套接字

服务器框架

class Tcpserver
{
public:
    Tcpserver(uint16_t port, const std::string ip = "")
        : port_((uint16_t)port), ip_(ip), listen_sockfd_(-1){
       pthread_mutex_init(&_mutex,nullptr);
    }
    ~Tcpserver(){
    }
    void init(){
        //创建监听套接字
        listen_sockfd_=socket(PF_INET, SOCK_STREAM, 0);
        /*
        	socket日志信息
        */
        //绑定端口
        bind(listen_sockfd_, (const struct sockaddr *)&local, sizeof(local));
        /*
        	bind日志信息
        */
        //监听套接字,至于为什么是5在后面的章节解释
        listen(listen_sockfd_, 5);
        /*
        	listen日志信息
        */
        //循环提取连接
        while (true)
        {
            int sock = accept(listen_sockfd_, (sockaddr *)&client, &len);
            /*
            	accept日志信息
            */
            //处理事务
        }
        /*
        	事务处理
        */
    }
private:
    int listen_sockfd_; //套接字
    uint16_t port_;
    std::string ip_;
    std::unordered_map<std::string, struct sockaddr_in> users; //记录在线用户
    std::unordered_map<std::string,int>users_sockfd;//用户信息与套接字的映射关系。
    pthread_mutex_t _mutex;	//访问临界资源的锁
};

6.3以TCP聊天服务器为例

服务端单执行流处理方式

如果服务端采用单执行流的方式提取连接,并与客户端进行网络通信。由于服务端存在读取客户端信息的行为,且该行为是一个阻塞行为;因此服务器无法达到一次处理多个客户请求的需求。

多执行流的处理方式

6.1多进程方式+信号捕捉SIGCHLD

采用多进程的方式处理多个执行流,存在回收子进程的问题;如果使用waitpid等待子进程,则会发生阻塞,无法做到同时与多个客户通信。

根据信号一节的知识我们知道,子进程在退出后会向父进程发送SIGCHLD信号,所以考虑以捕捉信号的方式回收子进程。

static void Usage(const std::string porc)
{
    std::cout << "Usage:\n\t" << porc << " port [ip]" << std::endl;
}

class Tcpserver
{
public:
    Tcpserver(uint16_t port, const std::string ip = "")
        : port_((uint16_t)port), ip_(ip), listen_sockfd_(-1)
    {
       pthread_mutex_init(&_mutex,nullptr);
    }
    ~Tcpserver(){
    }
    //初始化接口
    void init(){
        listen_sockfd_ = socket(PF_INET, SOCK_STREAM, 0);
        if (listen_sockfd_ < 0){
            logMessage(FATAL, "socket:%s:%d", strerror(errno), listen_sockfd_);
            exit(1);
        }
        logMessage(DEBUG, "create socket sucess: %d", listen_sockfd_);
        //绑定端口
        struct sockaddr_in local;
        bzero(&local, sizeof(local));
        local.sin_family = PF_INET;
        local.sin_port = htons(port_);
        // 服务器都必须具有IP地址,"xx.yy.zz.aaa",字符串风格点分十进制 -> 4字节IP -> uint32_t ip
        // INADDR_ANY(0): 程序员不关心会bind到哪一个ip, 任意地址bind,强烈推荐的做法,所有服务器一般的做法
        // inet_addr: 指定填充确定的IP,特殊用途,或者测试时使用,除了做转化,还会自动给我们进行 h—>n
        //绑定任意端口
        ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr));
        int ret = bind(listen_sockfd_, (const struct sockaddr *)&local, sizeof(local));
        if (ret < 0){
            logMessage(FATAL, "bind:%s", strerror(errno));
            exit(2);
        }
        logMessage(DEBUG, "bind sucess:%d", listen_sockfd_);
        //监听
        int res = listen(listen_sockfd_, 5);
        if (res < 0){
            logMessage(FATAL, "listen:%s", strerror(errno));
            exit(3);
        }
        logMessage(DEBUG, "listen sucess:%d", listen_sockfd_);
    }

    //检测用户是否已经被添加
    void checkOnlineUser(std::string &clientip, uint32_t clientport, struct sockaddr_in &client)
    {
        std::string key = clientip;
        key += ":";
        key += std::to_string(clientport);
        auto iter = users.find(key);
        if (iter == users.end()){
            users.insert({key, client});
        }
        else{

            // do nothing
        }
    }
    //接收消息,实现广播
    void start()
    {
        //注册信号捕捉
        signal(SIGCHLD,SIG_IGN);
        //发送缓存和接收缓存
        char recvbuff[1024] = {0};
        char sendbuff[1024] = {0};
        while (true){
            //提取连接
            struct sockaddr_in client;
            socklen_t len = sizeof(client);
            int sock = accept(listen_sockfd_, (sockaddr *)&client, &len);
            if(sock<0){
                logMessage(WARINING,"accept:%s[%d]",strerror(errno),sock);
            }
            logMessage(DEBUG,"accept success: [%d]",sock);

            //获取客户端的IP和PORT信息
            std::string clientIp = inet_ntoa(client.sin_addr);
            uint32_t clientport=ntohs(client.sin_port);

            //添加客户与套接字中间的映射
            users_sockfd[clientIp]=sock;

            pid_t pid=fork();
            if(pid==0){ //子进程与客户端进行通信
                //子进程再添加一次,父子进程会发生写时拷贝
                users_sockfd[clientIp]=sock;
                close(listen_sockfd_);
                checkOnlineUser(clientIp,clientport,client);
                //与客户端进行通信
                info_to_client(clientIp,clientport,sock);
                exit(0);
            }
            else{   //父进程需要关闭子进程通信的套接字,继续提取链接
                close(sock);
                continue;
            }
        }
    }
    //实现广播
    void messageRoute(std::string ip, uint32_t port, std::string info){

        std::string message = "[";
        message += ip;
        message += ":";
        message += std::to_string(port);
        message += "]# ";
        message += info;
        for(auto& sockfd:users_sockfd)
        {
            write(sockfd.second,message.c_str(),strlen(message.c_str()));
        }
    }

    void info_to_client(std::string& clientip,uint32_t clientport,int sock){
        assert(sock>=0);
        assert(!clientip.empty());
        assert(clientport>=1024);
        char buff[1024];    
        logMessage(DEBUG,"begin..................%s[%d]",clientip.c_str(),sock);
        while (true)
        {
            ssize_t s=read(sock,buff,sizeof(buff)-1);
            if(s>0){
                //判断用户是否需要退出
                buff[s]='\0';
                if(strcasecmp(buff,"quit")==0){
                    logMessage(DEBUG,"client quit---%s[%d]",clientip.c_str(),sock);
                    break;
                }
                logMessage(DEBUG,"%s[%d] information  :%s",clientip.c_str(),sock,buff);
                //实现广播
                messageRoute(clientip,clientport,buff);
            }
            else if(s==0){
                // pipe: 读端一直在读,写端不写了,并且关闭了写端,读端返回s == 0,代表对端关闭
                // s == 0: 代表对方关闭,client 退出
                logMessage(DEBUG,"client quit-----%s[%d]",clientip.c_str(),sock);
                break;
            }
            else{
                 logMessage(DEBUG, "%s[%d] - read: %s", clientip.c_str(), clientport, strerror(errno));
                 break;
            }
        }
        logMessage(DEBUG,"end..................%s[%d]",clientip.c_str(),sock);
        close(sock);
        logMessage(DEBUG, "server close %d done", sock);
    }
private:
    int listen_sockfd_; //套接字
    uint16_t port_;
    std::string ip_;
    std::unordered_map<std::string, struct sockaddr_in> users; //记录在线用户
    std::unordered_map<std::string,int>users_sockfd;
    pthread_mutex_t _mutex;
};

int main(int argc, char *argv[])
{
    if (argc != 2 && argc != 3)
    {
        Usage(argv[0]);
        exit(3);
    }
    uint16_t port = atoi(argv[1]);
    std::string ip;
    if (argc == 3)
    {
        ip = argv[2];
    }
    Tcpserver svr(port, ip);
    svr.init();
    svr.start();
    return 0;
}

接下来用两个客户端向服务器发送信息。

image-20221203171112403

我们明明实现了广播的功能,为什么客户端之间无法接收到对方的消息?

  • **父子进程满足写时拷贝,**先创建的进程找不到后面进程的套接字。后面创建进程的套接字可以找到前面进程的套接字。

6.2.多进程方式+阻塞等待:子进程再创建子进程

孤儿进程会被系统进程领养,回收的问题就交给了系统来回收。因此上面的程序只有start()函数需要修改,其他不变。

void start()
{
    //注册信号捕捉
    signal(SIGCHLD, SIG_IGN);
    //发送缓存和接收缓存
    char recvbuff[1024] = {0};
    char sendbuff[1024] = {0};
    while (true)
    {
        //提取连接
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        int sock = accept(listen_sockfd_, (sockaddr *)&client, &len);
        if (sock < 0)
        {
            logMessage(WARINING, "accept:%s[%d]", strerror(errno), sock);
        }
        logMessage(DEBUG, "accept success: [%d]", sock);

        //获取客户端的IP和PORT信息
        std::string clientIp = inet_ntoa(client.sin_addr);
        uint32_t clientport = ntohs(client.sin_port);

        //添加客户与套接字中间的映射
        users_sockfd[clientIp] = sock;

        pid_t pid = fork();
        if (pid == 0)
        { //子进程
            close(listen_sockfd_);
            //孙子进程实现业务逻辑,子进程负责创建孙子进程
            //子进程
            if (fork() > 0)
            {
                exit(0);
            }
            //孙子进程,与客户端进行通信
            info_to_client(clientIp, clientport, sock);
            exit(0);
        }
        //父进程回收子进程
        close(sock);
        pid_t ret = waitpid(pid, nullptr, 0); //阻塞方式回收子进程
    }
}

7.多线程服务器

7.1多线程版本

各个线程共享同一个文件描述符:所以当主线程accept到一个文件描述符时,其他线程可以直接访问到这个文件描述符的。

虽然其他线程可以直接访问文件描述符,但是其他线程不知道它所服务的客户端对应的时哪个文件描述符,因此主线程创建次线程后需要告诉新线程对应应该访问的文件描述符的值。

//线程的参数列表
class arglist
{
public:
    struct sockaddr_in *_addr;
    int _sockfd;
    Tcpserver *_svr;
    //构造函数
    arglist(int sockfd, Tcpserver *svr, struct sockaddr_in *addr)
        : _sockfd(sockfd), _svr(svr), _addr(addr)
    {
    }
};

void start()
{
    //注册信号捕捉
    signal(SIGCHLD, SIG_IGN);
    //发送缓存和接收缓存
    char recvbuff[1024] = {0};
    char sendbuff[1024] = {0};
    while (true)
    {
        //提取连接
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        int sock = accept(listen_sockfd_, (sockaddr *)&client, &len);
        if (sock < 0)
        {
            logMessage(WARINING, "accept:%s[%d]", strerror(errno), sock);
        }
        logMessage(DEBUG, "accept success: [%d]", sock);

        //获取客户端的IP和PORT信息
        std::string clientIp = inet_ntoa(client.sin_addr);
        uint32_t clientport = ntohs(client.sin_port);

        //添加客户与套接字中间的映射
        users_sockfd[clientIp] = sock;
        //多线程版本
        arglist *arg = new arglist(sock, this, &client);
        users_sockfd[clientIp] = sock;
        //创建一个线程,用于对该套接字的运行
        pthread_t pid;
        pthread_create(&pid, nullptr, pthread_run, (void *)arg);
    }
}

//静态函数内部,必须是某个对象调用具体的成员函数;
static void *pthread_run(void *arg)
{
    pthread_detach(pthread_self());
    arglist *arl = (arglist *)arg;
    //注册用户
    struct sockaddr_in *client = arl->_addr;
    socklen_t len = sizeof(client);
    // 读取成功的,除了读取到对方的数据,还要读取到对方的网络地址[ip:port]
    std::string clientIp = inet_ntoa((*client).sin_addr); //拿到了对方的IP
    uint32_t clientPort = ntohs((*client).sin_port);      // 拿到了对方的port
    arl->_svr->lock();
    arl->_svr->checkOnlineUser(clientIp, clientPort, *client); //如果存在,什么都不做,如果不存在,就添加
    arl->_svr->unlock();
    //循环接收数据
    char recvbuff[1024];
    while (true)
    {
        bzero(&recvbuff, sizeof(recvbuff));
        //接收消息
        ssize_t s = read(arl->_sockfd, recvbuff, sizeof(recvbuff) - 1);
        // 打印出来客户端给服务器发送过来的消息
        logMessage(NOTICE, "[%s:%d]# %s", clientIp.c_str(), clientPort, recvbuff);
        //实现广播
        arl->_svr->messageRoute(clientIp, clientPort, recvbuff);
    }
}

线程之间共享进程的数据,因此多线程可以实现广播的功能,下面以两个客户端为例。

image-20221203183055686

7.2线程池版本

来一个连接就创建一个线程,断开一个连接就释放一个线程,这样频繁地创建和释放线程资源,对OS来说是一种负担。线程池可以避免但时间内大量的链接请求,此外还能保证内核被充分利用,防止过分调度。

task.hpp

task类主要负责生产任务,内部定义一个回调函数指针。

class task
{
public:
    using callback_t = std::function<void (std::string, uint32_t,int)>;
    //构造函数
    task(std::string clientip, uint32_t clientport,int sock,callback_t func)
        :clientip_(clientip),clientport_(clientport),sock_(sock),func_(func)
    {}
    task():sock_(-1), clientport_(-1)
    {}
    void operator()()
    {
        logMessage(DEBUG, "线程ID[%p]处理%s:%d的请求 开始...",pthread_self(), clientip_.c_str(), clientport_);
        func_(clientip_,clientport_,sock_);
        logMessage(DEBUG, "线程ID[%p]处理%s:%d的请求 结束...",pthread_self(), clientip_.c_str(), clientport_);
    }
 
private:
    int sock_;
    std::string clientip_;
    uint32_t clientport_;
    callback_t func_;
};

线程池定义

线程池的实现在另一篇文章中有详细介绍,下面是实现线程池的链接。

线程实现链接:[线程池的实现]((599条消息) 【Linux】线程池_影中人lx的博客-CSDN博客)

tcpserver.cpp

该文件的改动较大,实现广播的函数和实现通信的函数需要定义在类外。定义在类内,默认的第一个参数是this。

image-20221203214903738

服务器运行程序负责不断的生产任务,并向任务队列中添加任务。

//类外定义
std::unordered_map<std::string, int> users_sockfd;
//实现广播
void messageRoute(std::string ip, uint32_t port, std::string info)
{

    std::string message = "[";
    message += ip;
    message += ":";
    message += std::to_string(port);
    message += "]# ";
    message += info;
    for (auto &sockfd : users_sockfd)
    {
        write(sockfd.second, message.c_str(), strlen(message.c_str()));
    }
}
void info_to_client(const std::string clientip, uint32_t clientport, int sock)
{
    assert(sock >= 0);
    assert(!clientip.empty());
    assert(clientport >= 1024);
    char buff[1024];
    logMessage(DEBUG, "begin..................%s[%d]", clientip.c_str(), sock);
    while (true)
    {
        ssize_t s = read(sock, buff, sizeof(buff) - 1);
        if (s > 0)
        {
            //判断用户是否需要退出
            buff[s] = '\0';
            if (strcasecmp(buff, "quit") == 0)
            {
                logMessage(DEBUG, "client quit---%s[%d]", clientip.c_str(), sock);
                break;
            }
            logMessage(DEBUG, "%s[%d] information  :%s", clientip.c_str(), sock, buff);
            //实现广播
            messageRoute(clientip, clientport, buff);
        }
        else if (s == 0)
        {
            // pipe: 读端一直在读,写端不写了,并且关闭了写端,读端返回s == 0,代表对端关闭
            // s == 0: 代表对方关闭,client 退出
            logMessage(DEBUG, "client quit-----%s[%d]", clientip.c_str(), sock);
            break;
        }
        else
        {
            logMessage(DEBUG, "%s[%d] - read: %s", clientip.c_str(), clientport, strerror(errno));
            break;
        }
    }
    logMessage(DEBUG, "end..................%s[%d]", clientip.c_str(), sock);
    close(sock);
    logMessage(DEBUG, "server close %d done", sock);
}
//类外定义

class Tcpserver
{
    void start()
    {
        //注册信号捕捉
        // signal(SIGCHLD, SIG_IGN);
        //创建线程池
        pool_ = threadpool<task>::getInstance();
        //发送缓存和接收缓存
        char recvbuff[1024] = {0};
        char sendbuff[1024] = {0};
        pool_->start();
        while (true)
        {
            //提取连接
            struct sockaddr_in client;
            socklen_t len = sizeof(client);
            int sock = accept(listen_sockfd_, (sockaddr *)&client, &len);
            if (sock < 0)
            {
                logMessage(WARINING, "accept:%s[%d]", strerror(errno), sock);
            }
            logMessage(DEBUG, "accept success: [%d]", sock);

            //获取客户端的IP和PORT信息
            std::string clientIp = inet_ntoa(client.sin_addr);
            uint32_t clientport = ntohs(client.sin_port);

            //添加客户与套接字中间的映射
            users_sockfd[clientIp] = sock;
            //创建任务
            task t(clientIp, clientport, sock, info_to_client);
            pool_->push(t);
        }
}

服务器实现结果

image-20221203215850323

8.源代码地址

源代码地址

socket · 影中人/test - 码云 - 开源中国 (gitee.com)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/58511.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【RTS】杜金房大神FreeSwitch分享笔记

技术万变不离其宗不管如何实现原理都是一样的。杜金房大神 RTS 高可用 一台机器上俩fs,公用同一个ip用户连接的是一个ip,不知道切了fs。两台主备数据同步

ARM 汇编编写 LED 灯

一、一步步点亮LED 1. 硬件工作原理及原理图查阅 LED 本身有 2 个接线点&#xff0c;一个是 LED 的正极&#xff0c;一个是 LED 的负极。LED 这个硬件的功能就是点亮或者不亮&#xff0c;物理上想要点亮一颗 LED 只需要给他的正负极上加正电压即可&#xff0c;要熄灭一颗 LED…

Linpack安装测试流程记录

软件背景 虽然很早就接触了HPC&#xff0c;也参与过一些项目&#xff0c;诸如电影动画渲染集群以及某博导老师的基因分析计算集群&#xff0c;但是对于跑超算的linpack&#xff0c;一直没时间上手玩。 Linpack是超算必测项目,也是考验优化能力的套件,很有意思&#xff0c;记录…

软件测试工程师到底要不要转行开发? 2022测试生涯该如何转型升级?

测试工程师到底是干啥的&#xff1f; 测试工程师转开发有多大希望&#xff1f; 为了能够解除大家心中的疑惑&#xff0c;我决定从以下几个方面来补充回答&#xff1a; 测试工程师到底是干什么的&#xff1f; 测试工程师转开发有多大希望&#xff1f; 测试工程师一定要转开发吗…

2023秋招,Java岗最全面试攻略,吃透25个技术栈Offer拿到手软!

我分享的这份春招 Java 后端开发面试总结包含了 JavaOOP、Java 集合容器、Java 异常、并发编程、Java 反射、Java 序列化、JVM、Redis、Spring MVC、MyBatis、MySQL 数据库、消息中间件 MQ、Dubbo、Linux、ZooKeeper、 分布式 &数据结构与算法等 25 个专题技术点&#xff0…

腾讯云服务器2核4G、4核8G、8核16G、16核32G配置报价表出炉

现在腾讯云服务器2核2G、2核4G、4核8G、8核16G、16核32G配置价格表已经出来了&#xff0c;大家可以参考一下。腾讯云轻量应用服务器为轻量级的云服务器&#xff0c;使用门槛低&#xff0c;按套餐形式购买&#xff0c;轻量应用服务器套餐自带的公网带宽较大&#xff0c;4M、6M、…

rocketmq安装、启动

1、下载 >wget http://mirror.bit.edu.cn/apache/rocketmq/4.4.0/rocketmq-all-4.4.0-source-release.zip >unzip rocketmq-all-4.4.0-source-release.zip > cd rocketmq-all-4.4.0/ > mvn -Prelease-all -DskipTests clean install -U > cd distribution/targ…

[附源码]计算机毕业设计学生宿舍管理系统Springboot程序

项目运行 环境配置&#xff1a; Jdk1.8 Tomcat7.0 Mysql HBuilderX&#xff08;Webstorm也行&#xff09; Eclispe&#xff08;IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持&#xff09;。 项目技术&#xff1a; SSM mybatis Maven Vue 等等组成&#xff0c;B/S模式 M…

5.函数与递归

一、函数 1.基本介绍 此前我们使用了很多库函数&#xff0c;现在我们可以定义自己的函数来帮助我们完成一些特定的任务。 函数返回值类型 函数名(变量1,变量2,...,变量n) {...return; }函数返回值类型有很多类&#xff1a; 可以为char,int,double,long long,string等基础数…

【servelt原理_13_状态管理】

状态管理 1.现有问题 Http是无状态的&#xff0c;不能保存每次提交的信息如果用户发来一个新的请求&#xff0c;服务器无法知道它是否与上次请求是否有联系.对于那么需要提交多次信息才能完成的操作&#xff0c;比如购物&#xff0c;就很有问题 2.概念 将浏览器和web服务器之…

npm vue 路由之一级路由(npm默认已经集成了vue)

npm vue 路由之一级路由&#xff08;npm默认已经集成了vue&#xff09; 文档https://v3.router.vuejs.org/zh/installation.html npm install vue-router3.5.2 --save 1.在App.vue上面添加 <router-view></router-view>2.在main.js上面添加 import VueRouter fro…

【计算机网络】计算机网络复习总结 ----- 计算机网络概述

计算机网络 内容管理计算机网络概述计算机网络定义计算机网络、互联网、因特网计算机网络产生和发展 &#xff08;略&#xff09;计算机网络分类按照网络作用范围分类&#xff1a;按照拓扑结构分类&#xff1a;按照交换技术分类按照应用模式分类&#xff1a;按照工作方式分类相…

opencv c++ 轮廓匹配

1、几何矩和Hu矩 1.1几何矩 a&#xff09;几何计算公式&#xff1a; p、q为阶数&#xff0c;当pq 1时&#xff0c;几何矩为一阶矩&#xff0c;pq 2&#xff0c;几何矩为二阶矩&#xff0c;依次类推。。 因此&#xff0c;对于二值图像有&#xff1a; 所有前景像素的x坐标之…

Spring对AOP的实现

Spring对AOP实现的模式分为2种&#xff0c;一种是代理&#xff0c;一种是AspectJ&#xff0c;这种区分方式是直接使用实现方式区分的。 二、Spring对动态代理的设计 动态代理我们都知道在Spring中分为JDK动态代理和cglib动态代理&#xff0c;JDK动态代理自不用说&#xff0c;…

没有几年经验你真学不会这份SpringCloud实战演练文档

前言 时间飞逝&#xff0c;转眼间毕业七年多&#xff0c;从事 Java 开发也六年了。我在想&#xff0c;也是时候将自己的 Java 整理成一套体系。 这一次的知识体系面试题涉及到 Java 知识部分、性能优化、微服务、并发编程、开源框架、分布式等多个方面的知识点。 写这一套 Ja…

[附源码]Python计算机毕业设计Django共享汽车系统

项目运行 环境配置&#xff1a; Pychram社区版 python3.7.7 Mysql5.7 HBuilderXlist pipNavicat11Djangonodejs。 项目技术&#xff1a; django python Vue 等等组成&#xff0c;B/S模式 pychram管理等等。 环境需要 1.运行环境&#xff1a;最好是python3.7.7&#xff0c;…

Unity 3D 碰撞体(Collider)|| Unity 3D 触发器(Trigger)

在游戏制作过程中&#xff0c;游戏对象要根据游戏的需要进行物理属性的交互。 因此&#xff0c;Unity 3D 的物理组件为游戏开发者提供了碰撞体组件。碰撞体是物理组件的一类&#xff0c;它与刚体一起促使碰撞发生。 碰撞体是简单形状&#xff0c;如方块、球形或者胶囊形&…

零基础入门数据挖掘——二手车交易价格预测:baseline

零基础入门数据挖掘 - 二手车交易价格预测 赛题理解 比赛要求参赛选手根据给定的数据集&#xff0c;建立模型&#xff0c;二手汽车的交易价格。 赛题以预测二手车的交易价格为任务&#xff0c;数据集报名后可见并可下载&#xff0c;该数据来自某交易平台的二手车交易记录&am…

四旋翼无人机学习第12节--跨页连接符的标号设置、DRC、PDF导出

文章目录1 跨页连接符的标号设置2 DRC与原理图检查3 PDF导出1 跨页连接符的标号设置 1、在设置跨页连接符的标号之前&#xff0c;需要去修改原理图的页码。 2、按照下图所示的操作步骤依次点击。 3、接着会弹出annotate的对话框&#xff0c;按照下图进行选择&#xff0c;如果…