• [Linux打怪升级之路]-管道


    前言

    作者小蜗牛向前冲

    名言我可以接受失败,但我不能接受放弃

      如果觉的博主的文章还不错的话,还请点赞,收藏,关注👀支持博主。如果发现有问题的地方欢迎❀大家在评论区指正

    本期学习目标:理解什么是管道,学会使用匿名管道和命名管道进行通信

    在学习管道之前,我们要明白在Llinux下,什么是通信

    一、通信

    我们都知道进程具有独立性,也就是说在进程A的执行的信息是不会被B知晓的,但是在以一下场景需要进程间信息:

    • 数据传输:一个进程需要将它的数据发送给另一个进程
    • 资源共享:多个进程之间共享同样的资源。
    • 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止
    • 时要通知父进程)。
    • 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

    那为什么要有通信呢? 

    进程通信的目的来说,我们大体可以理解为通信的目标其实就是让一个进程的消息被另外一个进程所知晓。 

    比如多进程协同的:

    1. cat file | grep "hello world"
    2. //cat将file文件中的内容打印到屏幕上
    3. // | 管道
    4. // gerp匹配并输出满足指定模式条件的行

    这个命令要完成需要二个进程共同协作,也就是说有一些信息双方都要知道,我们就通过管道(|) ,将信息共享了。

    那我们又该如何理解通信的问题:

    既然进程具有独立性,也就是说双方是不可能直接通信的,就必须有一个第三方的介入,这个第三方就要由操作系统来完成。

    • OS需要直接或者间接给通信双方的进程提供 "大家都可以看到一块内存空间"。
    • 要通信的进程,必须必须看到一份公共资源。

    那么通信操作系统是怎么完成的呢?

    所以在操作系统这一般有三种通信手段:

    • System v进程间通信(聚焦在本地通信)
    • POSIX进程间通信(让通信过程可以跨主机)
    • 管道

    下面我们将重点为大家讲述,管道是如何进行通信的。

    二、管道

    1、什么是管道

    • 管道是Unix中最古老的进程间通信的形式。
    • 我们把从一个进程连接到另一个进程的一个数据流称为一个“管道

    下面我们通过一张图来理解一下管道 

    •  who 是 Linux 中的一个命令,用于显示当前登录系统的用户信息
    •  wc -l 是 Linux 中的一个命令,用于统计文件中的行数。wc 是 word count(词频统计)的缩写,-l 选项表示只统计行数。
    • who | wc -l 是 Linux 中的一个命令,通过管道符 | 结合 whowc 两个命令,实现统计当前登录系统的用户数量。

    对于管道我们又分为二类:

    •  匿名管道pipe
    • 命名管道

    2、匿名管道pipe

    匿名管道他的概念顾明思意:创建一无名的管道

    原型:

    int pipe(int fd[2]);
    参数
    fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端
    返回值:成功返回0,失败返回错误代码

    为了方便记忆,我们可以把0想象成嘴巴,所以是读端, 把1想象成笔,所以是写端。

     

     注意:

    • 匿名管道只能用于有亲缘关系的进程间通信,也就是说,只能用于父子进程或者兄弟进程之间。
    • 匿名管道是由内核中的一块缓冲区实现的,该缓冲区分别由两个文件描述符指向,一个用于读取数据,一个用于写入数据。
    • 因为是匿名的,所以无法通过文件系统访问它们

    3、对匿名管道的理解 

    匿名管道是如何实现通信的呢?

    首先父进程创建管道

    其次父进创建子进程 

    最后父进程关闭fd[0](读端)子进程关闭fd[1](写端) 

    这样就完成管道的单向通信。

     为验证管道探究管道单向通信读写的特点,完成以下代码:

    由父进程创建管道,fork出子进程,在让子进程关闭读端,让子进程写入信息道管道中,父进程关闭写端,从管道中读取信息。

    1. #include
    2. #include
    3. #include
    4. #include
    5. #include
    6. #include
    7. #include
    8. #include
    9. #include
    10. using namespace std;
    11. int main()
    12. {
    13. //创建管道,打开读写端
    14. int fds[2];
    15. int n = pipe(fds);
    16. assert(n == 0 );
    17. //创建子进程
    18. pid_t id = fork();
    19. assert(id>=0);
    20. if(id == 0)
    21. {
    22. //子进程写入
    23. close(fds[0]);
    24. const char *s = "hellow parent";
    25. int cnt = 0;
    26. while(true)
    27. {
    28. cnt++;
    29. char buffer[1024];//子进程才可以看到的数组
    30. //向buffer数组这写入数组
    31. snprintf(buffer,sizeof buffer,"child->parent say: %s[%d][%d]",s,cnt,getpid());
    32. write(fds[1],buffer,strlen(buffer));
    33. sleep(1);//每隔1秒写入
    34. //当父进程不读,子进程的管道空间是有限的会满
    35. // cout<< cnt <
    36. }
    37. //子进程关闭写端
    38. close(fds[1]);
    39. cout<<"子进程关闭写端"<
    40. exit(0);
    41. }
    42. //父进程读取
    43. close(fds[1]);
    44. while(true)
    45. {
    46. char buffer[1024];
    47. ssize_t s = read(fds[0],buffer,sizeof(buffer)-1);
    48. if(s > 0)
    49. {
    50. buffer[s] = 0;
    51. cout<<"Get Message#"<" | my pid: "<<getpid()<
    52. }
    53. else if(s == 0)
    54. {
    55. //读到文件结尾
    56. cout<<"read: "<
    57. break;
    58. }
    59. }
    60. close(fds[0]);
    61. cout<<"父进程关闭了读端"<
    62. n = waitpid(id,nullptr,0);
    63. assert(n == id);
    64. return 0;
    65. }

    下面的探究都是具居于上面代码的简单修改 

    探究1、子进程写的快,父进程读的慢 

     我们发现管道会被写满,写端写满的时候,在写会阻塞,等对方进行读取

    结论:管道的空间是有限的会被写满

     探究2、子进程写的慢,父进程读的快 

     这里我们是让子进程每隔5秒才写入,而父进程在读取前打印A,读取后打印B

    这里我们发现在子进程没有写入时,父进程会阻塞等待子进程写入。 

      探究3、子进程直接将写端关闭

     这时候我们发现父进程会读到0,从而结束读取。

      探究4、子进程写,但是父进程不读

        cout <<"pid->"<< n << " : "<< (status & 0x7F) << endl;

    status 是一个整型变量,它用于存储进程的退出状态信息。在 Linux 中,进程终止时,会向其父进程发送一个信号,该信号包含了进程退出的原因以及退出状态码。通过位运算 status & 0x7F,可以得到进程退出的原因,即低 7 位二进制数对应的十进制数,该数字通常被称为“终止信号” 

    这里打印的是进程的pid和 退出状态信息

    那进程退出13号退出信息又代表什么意思呢? 

    我们kill-l一下

    也就是说当读端关闭了,操作系统会 给进程发13) SIGPIPE的信息,从而终止写端。

    根据以上场景的探究,下面我们归纳一下匿名管道的特点:

    • 只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
    • 管道提供流式服务
    • 一般而言,进程退出,管道释放,所以管道的生命周期随进程
    • 一般而言,内核会对管道操作进行同步与互斥
    • 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管

    三、基于匿名管道的进程池设计 

    为了更好的理解管道的应用,下面将带领大家设计一个基于管道的进程池设计。

    实现现象:通过一个父进程,随意的控制一个子进程完成我们的相关操作。

    1、main函数主体

    在函数主体中我们,要完成程序整体框架的搭建。首先,建立子进程并建立和子进程的通信的信道,其次,父进程,控制子进程,向子进程发命令码,最后,回收进程。

    1. int main()
    2. {
    3. MakeSeed();//生产随机数的种子
    4. //1.建立子进程并建立和子进程的通信的信道
    5. //1.1加载
    6. std::vector<func_t> funcMap;//用于存放子进程执行函数的地址
    7. loadTaskFunc(&funcMap);
    8. //1.2 创建子进程,并且维护好父子进程的通道
    9. std::vector subs;
    10. createSubProcess(&subs,funcMap);
    11. //父进程,控制子进程,向子进程发命令码
    12. int taskCnt = 3;//3表示子进程执行3次
    13. loadBlanceContrl(subs,funcMap,taskCnt);
    14. //回收进程
    15. waitProcces(subs);
    16. return 0;
    17. }

    2、各项函数功能的实现 

    2.1、建立子进程并建立和子进程的通信的信道

    void createSubProcess(std::vector *subs, std::vector &funcMap) 

    在创建子进程的函数中,我们传了二个参数 ,*subs和&funcMap。

    subs指针指向的是父进程管理子进程各项信息的类:

    1. class subEp//父进程的一些控制信息
    2. {
    3. public:
    4. subEp(pid_t subId,int writeFd )
    5. : _subId(subId),_writeFd(writeFd)
    6. {
    7. char nameBuffer[1024];
    8. snprintf(nameBuffer,sizeof(nameBuffer),"process-%d[pid(%d)-fd(&d)]", _num++, _subId, _writeFd);
    9. _name = nameBuffer;
    10. }
    11. public:
    12. static int _num;
    13. std::string _name;
    14. pid_t _subId;
    15. int _writeFd;
    16. };
    17. int subEp::_num = 0;

     funcMap是子进程要执行的任务:

    1. std::vector funcMap;//用于存放子进程执行函数的地址
    2. loadTaskFunc(&funcMap);

    其中的funcMap是一个数组,数组中存放的是指向父进程要子进程执行的任务函数指针。 

    1. //函数指针
    2. typedef void (*func_t)();
    3. void downLodeTask()
    4. {
    5. std::cout<<getpid()<<"下载任务\n"
    6. <
    7. sleep(1);
    8. }
    9. void ioTask()
    10. {
    11. std::cout<<getpid()<<"io任务\n"
    12. <
    13. sleep(1);
    14. }
    15. void flushTask()
    16. {
    17. std::cout<<getpid()<<"刷新任务\n"
    18. <
    19. sleep(1);
    20. }
    21. void loadTaskFunc(std::vector<func_t> *out)
    22. {
    23. assert(out);
    24. out->push_back(downLodeTask);
    25. out->push_back(ioTask);
    26. out->push_back(flushTask);
    27. }

     子进程接受任务函数

    1. int recvTask(int readFd)
    2. {
    3. int code = 0;
    4. ssize_t s = read(readFd,&code,sizeof(code));
    5. if(s == 4) return code;
    6. else if(s <= 0) return -1;
    7. else return 0;
    8. }

    下面是创建子进程代码的完整实现 

    1. void createSubProcess(std::vector *subs, std::vector<func_t> &funcMap)
    2. {
    3. std::vector<int> deleteFd;
    4. for(int i = 0; i < PROCESS_NIM;i++)
    5. {
    6. int fds[2];
    7. int n = pipe(fds);
    8. assert(n == 0);
    9. //因为assert是Dug版本显示,reserve版本不显示,其中的n是pipe的返回值
    10. //在reserve版本下,编译器可能认为n一个函数的返回值没有被使用,可能会有warn
    11. (void)n;
    12. pid_t id = fork();
    13. if(id == 0)
    14. {
    15. for(int i = 0; i < deleteFd.size(); i++) close(deleteFd[i]);
    16. //子进程处理任务
    17. close(fds[1]);
    18. while(true)
    19. {
    20. //获取命令码
    21. int commandCode = recvTask(fds[0]);
    22. //完成任务
    23. if(commandCode >= 0 && commandCode size())
    24. {
    25. funcMap[commandCode]();
    26. }
    27. else if(commandCode ==-1)
    28. {
    29. break;
    30. }
    31. }
    32. exit(0);
    33. }
    34. close(fds[0]);
    35. subEp sub(id,fds[1]);
    36. subs->push_back(sub);
    37. deleteFd.push_back(fds[1]);
    38. }
    39. }

     2.2、父进程控制子进程,向子进程发命令码

    1. int taskCnt = 3;//3表示子进程执行3次
    2. loadBlanceContrl(subs,funcMap,taskCnt);

    其中父进程控制子进程函数的第三个参数是我们要子进程执行几个任务。

    父进程发送给子进程任务的控制函数

    1. void sendTask(const subEp &process,int taskNum)
    2. {
    3. std::cout<<"send task num: "<< taskNum << " send to -> "<< process._name <
    4. int n = write(process._writeFd,&taskNum,sizeof(taskNum));
    5. assert(n == sizeof(int));
    6. (void)n;
    7. }

     父进程控制子进程的代码:

    1. void loadBlanceContrl(const std::vector &subs,const std::vector<func_t> &funcMap,int count)
    2. {
    3. int processnum = subs.size();
    4. int tasknum = funcMap.size();
    5. bool forever = (count == 0 ? true : false);
    6. while(true)
    7. {
    8. //选择一个子进程 --》std::vector ->index
    9. int subIdx = rand() % processnum;
    10. //选择一个任务
    11. int taskIdx = rand() % tasknum;
    12. //将任务发送给选项的进程
    13. sendTask(subs[subIdx],taskIdx);
    14. sleep(1);
    15. //控制子进程的执行任务的次数
    16. if(!forever)
    17. {
    18. count--;
    19. if(count == 0) break;
    20. }
    21. }
    22. for(int i = 0; iclose(subs[i]._writeFd);
    23. }

     2.3、回收进程

     waitProcces(subs);
    1. void waitProcces(std::vector processes)
    2. {
    3. int processnum = processes.size();
    4. for(int i = 0; i < processnum; i++)
    5. {
    6. waitpid(processes[i]._subId,nullptr,0);
    7. std::cout<<"wait sub process success ..." << processes[i]._subId << std::endl;
    8. }
    9. }

    2.4、代码的完整实现 

    1. #include
    2. #include
    3. #include
    4. #include
    5. #include
    6. #include
    7. #include
    8. #include
    9. #include
    10. #define PROCESS_NIM 5
    11. #define MakeSeed() srand((unsigned long)time(nullptr)^getpid()^0x324124^rand()%1234)
    12. //下面代码是子进程要完成的任务//
    13. //函数指针
    14. typedef void (*func_t)();
    15. void downLodeTask()
    16. {
    17. std::cout<<getpid()<<"下载任务\n"
    18. <
    19. sleep(1);
    20. }
    21. void ioTask()
    22. {
    23. std::cout<<getpid()<<"io任务\n"
    24. <
    25. sleep(1);
    26. }
    27. void flushTask()
    28. {
    29. std::cout<<getpid()<<"刷新任务\n"
    30. <
    31. sleep(1);
    32. }
    33. void loadTaskFunc(std::vector<func_t> *out)
    34. {
    35. assert(out);
    36. out->push_back(downLodeTask);
    37. out->push_back(ioTask);
    38. out->push_back(flushTask);
    39. }
    40. //下面代码是多进程的程序///
    41. class subEp//父进程的一些控制信息
    42. {
    43. public:
    44. subEp(pid_t subId,int writeFd )
    45. : _subId(subId),_writeFd(writeFd)
    46. {
    47. char nameBuffer[1024];
    48. snprintf(nameBuffer,sizeof(nameBuffer),"process-%d[pid(%d)-fd(&d)]", _num++, _subId, _writeFd);
    49. _name = nameBuffer;
    50. }
    51. public:
    52. static int _num;
    53. std::string _name;
    54. pid_t _subId;
    55. int _writeFd;
    56. };
    57. int subEp::_num = 0;
    58. int recvTask(int readFd)
    59. {
    60. int code = 0;
    61. ssize_t s = read(readFd,&code,sizeof(code));
    62. if(s == 4) return code;
    63. else if(s <= 0) return -1;
    64. else return 0;
    65. }
    66. void createSubProcess(std::vector *subs, std::vector<func_t> &funcMap)
    67. {
    68. std::vector<int> deleteFd;
    69. for(int i = 0; i < PROCESS_NIM;i++)
    70. {
    71. int fds[2];
    72. int n = pipe(fds);
    73. assert(n == 0);
    74. //因为assert是Dug版本显示,reserve版本不显示,其中的n是pipe的返回值
    75. //在reserve版本下,编译器可能认为n一个函数的返回值没有被使用,可能会有warn
    76. (void)n;
    77. pid_t id = fork();
    78. if(id == 0)
    79. {
    80. for(int i = 0; i < deleteFd.size(); i++) close(deleteFd[i]);
    81. //子进程处理任务
    82. close(fds[1]);
    83. while(true)
    84. {
    85. //获取命令码
    86. int commandCode = recvTask(fds[0]);
    87. //完成任务
    88. if(commandCode >= 0 && commandCode size())
    89. {
    90. funcMap[commandCode]();
    91. }
    92. else if(commandCode ==-1)
    93. {
    94. break;
    95. }
    96. }
    97. exit(0);
    98. }
    99. close(fds[0]);
    100. subEp sub(id,fds[1]);
    101. subs->push_back(sub);
    102. deleteFd.push_back(fds[1]);
    103. }
    104. }
    105. void sendTask(const subEp &process,int taskNum)
    106. {
    107. std::cout<<"send task num: "<< taskNum << " send to -> "<< process._name <
    108. int n = write(process._writeFd,&taskNum,sizeof(taskNum));
    109. assert(n == sizeof(int));
    110. (void)n;
    111. }
    112. void loadBlanceContrl(const std::vector &subs,const std::vector<func_t> &funcMap,int count)
    113. {
    114. int processnum = subs.size();
    115. int tasknum = funcMap.size();
    116. bool forever = (count == 0 ? true : false);
    117. while(true)
    118. {
    119. //选择一个子进程 --》std::vector ->index
    120. int subIdx = rand() % processnum;
    121. //选择一个任务
    122. int taskIdx = rand() % tasknum;
    123. //将任务发送给选项的进程
    124. sendTask(subs[subIdx],taskIdx);
    125. sleep(1);
    126. //控制子进程的执行任务的次数
    127. if(!forever)
    128. {
    129. count--;
    130. if(count == 0) break;
    131. }
    132. }
    133. for(int i = 0; iclose(subs[i]._writeFd);
    134. }
    135. void waitProcces(std::vector processes)
    136. {
    137. int processnum = processes.size();
    138. for(int i = 0; i < processnum; i++)
    139. {
    140. waitpid(processes[i]._subId,nullptr,0);
    141. std::cout<<"wait sub process success ..." << processes[i]._subId << std::endl;
    142. }
    143. }
    144. int main()
    145. {
    146. MakeSeed();
    147. //1.建立子进程并建立和子进程的通信的信道
    148. //1.1加载
    149. std::vector<func_t> funcMap;//用于存放子进程执行函数的地址
    150. loadTaskFunc(&funcMap);
    151. //1.2 创建子进程,并且维护好父子进程的通道
    152. std::vector subs;
    153. createSubProcess(&subs,funcMap);
    154. //父进程,控制子进程,向子进程发命令码
    155. int taskCnt = 3;//3表示子进程执行3次
    156. loadBlanceContrl(subs,funcMap,taskCnt);
    157. //回收子进程
    158. waitProcces(subs);
    159. return 0;
    160. }

     3、实验现象

    当我们用父进程控制三个子进程,让他们分别执行任务:

    四、命名管道 

    什么我们用匿名管道进行了进程间的通信,但是匿名管道有限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
    如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,那我们就要用到命名管道

    1、命名管道的相关知识

    首先我们要清楚命名管道是一种特殊类型的文件

    命名管道可以从命令行上创建,命令行方法是使用下面这个命令:

     mkfifo filename

    命名管道也可以从程序里创建,相关函数有 

    nt mkfifo(const char *filename,mode_t mode)

    命名管道的打开规则

     如果当前打开操作是为读而打开FIFO时

     O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO
    O_NONBLOCK enable:立刻返回成功

    如果当前打开操作是为写而打开FIFO时

     O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO
    O_NONBLOCK enable:立刻返回失败,错误码为ENXIO

    匿名管道与命名管道的区别 

    • 匿名管道由pipe函数创建并打开。
    • 命名管道由mkfifo函数创建,打开用open
    • FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义

    2、用命令管道实现二个进程通信

    简述:这里我们写二个文件server和linent,让他在命令管道实现进程通信。

    首先我们用makefile来管理我们的多文件

    makefile

    1. .PHONY:all
    2. all:server client
    3. server:server.cc
    4. g++ -o $@ $^ -std=c++11 -g
    5. client:client.cc
    6. g++ -o $@ $^ -std=c++11 -g
    7. .PHONY:clean
    8. clean:
    9. rm -f server client

     其次我们要创建命名管道,这里我们定义为comm.hpp,里面包含了server和client二个进程所需要的头文件。当然我们不仅仅写了建立命名管道的函数还有移除管道的函数。

    1. pragma once
    2. #include
    3. #include
    4. #include
    5. #include
    6. #include
    7. #include
    8. #include
    9. #include
    10. #include
    11. #define NAME_PIPE "/tmp/mypipe.me"
    12. bool createFifo(const std::string &path)
    13. {
    14. umask(0);
    15. int n = mkfifo(path.c_str(),0600);
    16. if(n == 0)
    17. return true;
    18. else
    19. {
    20. std::cout<<"errno: " << errno << " err string: "<< strerror(errno) << std::endl;
    21. return false;
    22. }
    23. }
    24. void removeFifo(const std::string &path)
    25. {
    26. int n = unlink(path.c_str());
    27. assert(n == 0);
    28. (void)n;
    29. }

    最后就是我们server和client二个通信文件编写:

    server.cc

    在这个文件中,我们要创建命名管道,打开文件(命名管道),从里面读取我们需要的信息(clicent发送过来)

    1. #include"comm.hpp"
    2. int main()
    3. {
    4. bool r = createFifo(NAME_PIPE);
    5. assert(r);
    6. (void)r;
    7. std::cout<< "server begin" << std::endl;
    8. int rfd = open(NAME_PIPE,O_RDONLY);
    9. std::cout<< "server end" << std::endl;
    10. if(rfd<0) exit(1);
    11. char buffer[1024];
    12. while(true)
    13. {
    14. ssize_t s = read(rfd,buffer,sizeof(buffer)-1);
    15. if(s > 0)
    16. {
    17. buffer[s] = 0;
    18. std::cout << "client->server# " << buffer << std::endl;
    19. }
    20. else if(s == 0)
    21. {
    22. std::cout << "client quit, me too !" <
    23. break;
    24. }
    25. else
    26. {
    27. std::cout << "err string " << strerror(errno) << std::endl;
    28. break;
    29. }
    30. }
    31. close(rfd);
    32. sleep(10);
    33. removeFifo(NAME_PIPE);
    34. return 0;
    35. }

     clicent.cc

    这个文件中我们只有打开刚刚server创建的文件,然后写入信息,server就可以接受到了

    1. #include"comm.hpp"
    2. int main()
    3. {
    4. std:: cout <<" clinent begin " << std::endl;
    5. int wfd = open(NAME_PIPE,O_WRONLY);
    6. std::cout << " client end " << std::endl;
    7. if(wfd < 0) exit(1);
    8. char buffer[1024];
    9. while(true)
    10. {
    11. std::cout << "Please Say# ";
    12. fgets(buffer,sizeof(buffer),stdin);
    13. if(strlen(buffer) > 0) buffer[strlen(buffer)-1] = 0;
    14. ssize_t n = write(wfd,buffer ,strlen(buffer));
    15. assert( n == strlen(buffer));
    16. (void)n;
    17. }
    18. close(wfd);
    19. return 0;
    20. }

    实验现象:

     这里我们让 clicen进程输入文字就可以在rerver文件中接收到

  • 相关阅读:
    [python]常用配置读取方法
    Mip-NeRF:抗混叠的多尺度神经辐射场ICCV2021
    jolt语法
    c++ day 4
    如何做好数据全生命周期管理,从哪几个方面做?_光点科技
    ArrayList和LinkedList对比,ArrayList使用注意事项
    android端MifareClassicTool
    ​构建“顶流”合作圈,这家车联网企业已提前入局“半决赛”
    vue2 顶象 安全 验证码的使用
    废液收集系统物联网远程监控解决方案
  • 原文地址:https://blog.csdn.net/qq_61552595/article/details/133754183