【Linux实践系列】:进程间通信:万字详解共享内存实现通信

06-01 1456阅读

🔥 本文专栏:Linux Linux实践项目

🌸作者主页:努力努力再努力wz

【Linux实践系列】:进程间通信:万字详解共享内存实现通信

【Linux实践系列】:进程间通信:万字详解共享内存实现通信

【Linux实践系列】:进程间通信:万字详解共享内存实现通信

💪 今日博客励志语录: 人生就像一场马拉松,重要的不是起点,而是坚持到终点的勇气

★★★ 本文前置知识:

匿名管道

命名管道


前置知识大致回顾(对此十分熟悉的读者可以跳过)

那么我们知道进程之间具有通信的需求,因为某项任务需要几个进程共同来完成,那么这时候就需要进程之间协同分工合作,那么进程之间就需要知道彼此之间的完成的进度以及完成的情况,那么此时进程之间就需要通信来告知彼此,而由于进程之间具有独立性,那么进程无法直接访问对方的task_struct结构体以及页表来获取其数据,那么操作系统为了满足进程之间通信的需求又保证进程的独立性,那么采取的核心思想就是创建一份公共的内存区域,然后让通信的进程双方能够看到这份公共的内存区域,从而能够实现通信

那么对于父子进程来说,由于子进程是通过拷贝父进程的task_struct结构体得到自己的一份task_struct结构体,那么意味着子进程会拷贝父进程的文件描述表,从而子进程会继承父进程打开的文件,而操作系统让进程通信的核心思想就是创建一块公共的内存区域,那么这里对于父子进程来说,那么文件就可以作为这个公共的内存区域,来保存进程之间通信的信息,所以这里就要求父进程在创建子进程之前,先自己创建一份文件,这样再调用fork创建出子进程,这样子进程就能继承到该文件,那么双方就都持有该文件的文件描述符,然后通过文件描述符向该文件进行写入以及读取,而我们知道该文件只是用来保存进程之间通信的临时数据,而不需要刷新到磁盘中长时间保存,那么必定该文件的性质是一个内存级别的文件,那么创建一个内存级别的文件就不能在调用open接口,因为open接口是用来创建一个磁盘级文件,其次就是双方通过该文件来进行进程之间通信的时候,那么双方不能同时对该文件进行读写,因为会造成偏移量错位以及文件内容混乱的问题,所以该文件只能用来实现单向通信,也就是智能一个进程向该文件写入,然后另一个进程从该文件中进行读取,那么由于该文件单向通信的特点,并且进程双方是通过文件描述符来访问,所以该文件其没有路径名以及文件名,因此该文件被称作匿名管道文件,那么我们要创建匿名管道文件,就需要调用pipe接口,那么pipe接口的返回值就是该匿名管道文件读写端对应的file结构体的文件描述符

而对于非父子进程来说,此时他们无法再看到彼此的文件描述表,那么意味着对于非父子进程来说,那么这里只能采取建立一个普通的文件,该普通的文件作为公共区域,那么一个进程向该文件中写入,另一个进程从该文件读取,根据父子进程通信的原理,我们知道该普通文件肯定不是一般的普通文件,它一定也得是内存级别文件,其次也只能实现单向通信,而对于匿名管道来说,通信进程双方看到该匿名管道是通过文件描述符来访问到这块资源,而对于命名管道则是通过通过路径加文件名的方式来访问命名管道,那么访问的方式就是通信进程的双方各自通过open接口以只读和只写的权限分别打开该命名管道文件,获取其文件描述符,然后通信进程双方通过文件描述符然后调用write以及read接口来写入以及读取数据,而创建一个命名管道就需要我们调用mkfifo接口

那么这就是对前置知识的一个大致回顾,如果读者对于上面讲的内容感到陌生或者想要知道其中的更多细节,那么可以看我之前的博客


共享内存

那么此前我们已经学习了两种通信方式,分别是匿名管道以及命名管道来实现进程的通信,那么这期博客,我便会介绍第三种通信方式,便是共享内存,那么我会从三个维度来解析共享内存,分别是什么是共享内存以及共享内存的底层相关的原理和结合前面两个维度的理论知识,如何利用共享内存来实现进程的通信,也就是文章的末尾我们会写一个用共享内存实现通信的小项目

什么是共享内存以及共享内存的底层原理

那么我们知道进程间通信的核心思想就是通过开辟一块公共的区域,然后让进程双方能够看到这份资源从而实现通信,所以这里的共享内存其实本质就是操作系统为其通信进程双方分配的一个物理内存,那么这份物理内存就是共享内存,所以共享内存的概念其实很简单与直接

根据进程间通信的核心思想,那么这里的公共的区域已经有了,那么下一步操作系统要解决的问题便是创建好了共享内存,如何让进程双方能够看到这份共享内存资源

那么对于进程来说,按照进程的视角,那么它手头上只持有虚拟地址,那么进程访问各种数据都只能通过虚拟地址去访问,然后系统再借助页表将虚拟地址转换为物理地址从而访问到相关数据,所以要让通信进程双方看到共享内存,那么此时操作系统的任务就是提供给通信进程双方各自一个指向共享内存的虚拟地址,然后通信进程双方就可以通过该虚拟地址来向共享内存中写入以及读取数据了,那么这个时候操作系统要进行的工作,就是创建通信的进程的同时,设置好该进程对应的mm_struct结构体中的共享内存段,并且在其对应的页表添加其共享内存的虚拟地址到物理地址的映射的条目

那么知道了共享内存来实现进程双方通信的一个大致的原理,那么现在的问题就是如何请求让操作系统来为该通信进程双方创建共享内存

那么这里就要让操作系统为该其创建一份共享内存,就需要我们在代码层面上调用shmget接口,那么该接口的作用就是让内核为我们创建一份共享内存,但是在介绍这个接口如何使用之前,我们还得补充一些相关的理论基础,有了这些理论基础,我们才能够认识到shmget这些参数的意义是什么

  • shmget
  • 头文件: 和
  • 函数声明:int shmget(ket_t key,size_t size,int shmflg);
  • 返回值:调用成功返回shmid,调用失败则返回-1,并设置errno

    key/shmid

    那么这里的shmget的一个参数就是一个key,那么读者对于key的疑问无非就是这两个方面:这个key是什么?key的作用是什么?

    那么接下来的讲解会以这两个问题为核心,来为你解析这个key究竟是何方神圣

    首先我们一定要清楚的是系统中存在不只有一个共享内存,因为系统中需要通信的进程不只有一对,所以此时系统中的共享内存就不只有一个,那么系统中存在这么多的共享内存,那么每一个共享内存都会涉及到创建以及读取和写入以及最后的销毁,那么操作系统肯定就要管理存在的所有的共享内存,那么管理的方式就是我们熟悉的先描述再组织的方式来管理这些共享内存,也就是为每一个共享内存创建一个struct shm_kernel结构体,那么该结构体就记录了该共享内存的相关的属性,比如共享内存的大小以及共享内存的权限以及挂载时间等等,那么每一个共享内存都有对应的结构体,那么内核会持有这些结构体,并且会采取特定的数据结构将这些结构体组织起来,比如链表或者哈希表,那么系统中每一个共享内存肯定是不相同的,那么为了区分这些不同的共享内存,那么系统就得给这些共享内存分配一个标识符,通过标识符来区分这些共享内存

    而进程要用共享内存实现通信,那么进程首先得请求操作系统为我们该进程创建一份共享内存,然后获取到指向该共享内存的虚拟地址,而进程间的通信,涉及的进程的数量至少为两个,那么以两个进程为例子,假设进程A和进程B要进行通信,那么此时需要为这对进程提供一个共享内存,那么就需要A进程或者B进程告诉操作系统来为其创建一份共享内存

    那么这里你可以看到我将或者这两个字加粗,那么就是为了告诉读者,那么这里我们只需要一个进程来告诉内核创建一份共享内存,不需要两个进程都向操作系统发出创建共享内存的请求,所以只需要一个进程请求内核创建一份共享内存,然后另一个进程直接访问创建好的共享内存即可

    那么知道了这点之后,那么假设这里创建共享内存的任务交给了A进程,那么此时A进程请求内核创建好了一份共享内存,那么对于B进程来说,它如何知道获取到A进程创建好的共享内存呢,由于系统内存在那么多的共享内存,那么B进程怎么知道哪一个共享内存是A进程创建的,那么这个时候就需要key值,那么这个key值就是共享内存的标识符

    key就好比酒店房间的一个门牌号,那么A和B进程只需要各自持有该房间的门牌号,那么就能够找到该房间,但是这里要注意的就是这里的key值不是由内核自己生成的,而是由用户自己来生成一个key值

    那么有些读者可能就会感到疑问,那么标识符这个概念对于大部分的读者来说都不会感到陌生,早在学习进程的时候,我们就已经接触到标识符这个概念,那么对内核为了管理进程,那么会为每一个进程分配一个标识符,那么就是进程的PID,而在文件系统中,任何类型的文件都有自己对应的inode结构体,那么内核为了管理inode结构体,那么也为每一个文件对应的inode结构体分配了标识符,也就是inode编号,所以读者可能会感到疑惑:那么在这里共享内存也会存在标识符,但是这里的标识符为什么是用户来提供而不是内核来提供呢,是内核无法做到为每一个共享内存分配标识符还是说因为其他什么原因?

    那么这个疑问是理解这个key的关键,首先我要明确的就是内核肯定能够做到为每一个共享内存提供标识符,这个工作对于内核来说,并不难完成,并且事实上,内核也的确为每一个共享内存提供了标识符,那么这个标识符就是shmid

    在引入了shmid之后,可能有的读者又会产生新的疑问:按照你这么说的话,那么实际上内核为每一个创建好的共享内存分配好了标识符,但是这里还需要用户自己在创建一个标识符,那么理论上来说,岂不是一个共享内存会存在两个所谓的标识符,一个是key,另一个是shmid,而我们访问共享内存只需要一个标识符就够了,那么这里共享内存拥有两个标识符,岂不是会存在冗余的问题?并且为什么不直接使用内核的标识符来访问呢?


    那么接下来我就来依次解答读者的这些疑问,那么首先关于为什么我们进程双方为什么不直接通过shmid来访问内存

    那么我们知道内核在创建共享内存的同时会为该共享内存创建对应的struct shm_kernel结构体,那么其中就会涉及到为其分配一个唯一的shmid,而假设请求内核创建共享内存的任务是交给A进程来完成,而B进程只需要访问A进程请求操作系统创建好的共享内存,而对于B进程来说,它首先得知道哪个共享内存是提供给我们两个A个B两个进程使用的,意味着B进程就得通过共享内存的标识符得知,因为每一个共享内存对应着一个唯一且不重复的标识符,对于A进程来说,由于它来完成共享内存的创建,而shmget接口是用来创建共享内存并且返回值就是共享内存的shmid,那么此时A进程能够知道并且获取进程的shmid标识符,但是它能否将这个shmget的返回值也就是shmid告诉该B进程吗,毫无疑问,肯定是不可能的,因为进程之间就有独立性!那么如果直接使用shmid来访问共享内存,那么必然只能对于创建共享内存的那一方进程可以看到而另一个进程无法看到,那么无法看到就会让该进程不知道哪一个共享内存是用来给我们A和B进程通信的,所以这就是为什么要有key存在

    那么A和B进程双方事先会持有一个相同的key,那么A进程是创建共享内存的一方,那么它会将key传递给shmget接口,那么shmget接口获取到key,会将key作为共享内存中其中一个字段填入,最终给A进程返回一个shmid,而对于B进程来说,那么它拿着同一个key值然后也调用shmget接口,而此时对于B进程来说,它的shmget的行为则不是创建共享内存,而是内核会拿着它传递进来的key,到组织共享内存所有结构体的数据结构中依次遍历,找到匹配该key的共享内存,然后返回其shmid

    而至于为什么A和B进程都调用shmget函数,但是shmget函数有着不同的行为,对于A来说是创建,对于B来说则可以理解为是“查询”,那么这就和shmget的第三个参数有关,那么第三个参数会接受一个宏,该宏决定了shmget行为,所以A和B进程调用shmget接口传递的宏肯定是不一样的,那么我会在下文会讲解shmget接口的第三个参数,这里就先埋一个伏笔

    所以综上所述,这里的key虽然也是和shmid一样是作为标识符,但是是给用户态提供使用的,是用户态的两个进程在被创建之前的事先约定,而内核操作则是通过shmid,那么key的值没有任何的意义,所以理论上我们用户可以自己来生成任意一个无符号的整形作为key,但是要注意的就是由于这里key是用户自己生成自己决定的,那么有可能会出现这样的场景,那么就是用户自己生成的key和已经创建好的共享内存的key的值一样或者说冲突,所以这里系统为我们提供了ftok函数,那么该函数的返回值就是key值,那么我们可以不调用该函数,自己随便生成一个key值,但是造成冲突的后果就得自己承担,所以这里更推荐调用ftok函数生成一个key值

    这里推荐使用ftok函数来生成的key,不是因为ftok函数生成的key完全不会与存在的共享内存的key造成冲突,而是因为其冲突的概率相比于我们自己随手生成一个的key是很低的

    • ftok
    • 头文件: 和
    • 函数声明:key_t ftok(const char* pathname,int proj_id);
    • 返回值:调用成功返回key值,调用失败则返回-1

      那么这里ftok会接收两个参数,首先是一个文件的路径名以及文件名,那么这里注意的就是这里的文件的路径名以及文件名一定是系统中存在的文件,因为它会解析这个路径以及文件名从而获取该文件的inode编号,然后得到对应的inode结构体,从中再获取其设备编号,那么这里的proj_id的作用就是用来降低冲突的概率,因为到时候ftok函数获取到文件的inode编号以及设备号和proj_id,然后会进行位运算,得到一个32位的无符号整形,那么其位运算就是:

      ftok 通过文件系统元数据生成 key 的算法如下:

      key = (st_dev & 0xFF) 
          struct vm_area_struct *mmap;       // VMA 链表的头节点(单链表)
          struct rb_root mm_rb;               // VMA 红黑树的根节点(用于快速查找)
          // ...其他字段(如页表、内存计数器等)
      };
      struct vm_area_struct {
          // 内存范围
          unsigned long vm_start;
          unsigned long vm_end;
          // 权限与标志
          unsigned long vm_flags;
          // 文件与偏移
          struct file *vm_file;
          unsigned long vm_pgoff;
          // 操作函数
          const struct vm_operations_struct *vm_ops;
          // 链表与树结构
          struct vm_area_struct *vm_next;
          struct rb_node vm_rb;
          // 其他元数据
          struct mm_struct *vm_mm;
          // ...
      };
      
          struct ipc_perm shm_perm;   // 共享内存段的权限信息
          size_t          shm_segsz;  // 共享内存段的大小(字节)
          time_t          shm_atime;  // 最后一次附加的时间
          time_t          shm_dtime;  // 最后一次断开的时间
          time_t          shm_ctime;  // 最后一次修改的时间
          pid_t           shm_cpid;   // 创建共享内存段的进程 ID
          pid_t           shm_lpid;   // 最后一次操作的进程 ID
          shmatt_t        shm_nattch; // 当前附加到共享内存段的进程数(引用计数)
          // ... 其他字段(可能因系统而异)
      };
      /* 定义在 sys/ipc.h 中 */
      struct ipc_perm {
          key_t          __key;     /* 用于标识 IPC 对象的键值 */
          uid_t          uid;       /* 共享内存段的所有者用户 ID */
          gid_t          gid;       /* 共享内存段的所有者组 ID */
          uid_t          cuid;      /* 创建该 IPC 对象的用户 ID */
          gid_t          cgid;      /* 创建该 IPC 对象的组 ID */
          unsigned short mode;      /* 权限位(类似于文件权限) */
          unsigned short __seq;     /* 序列号,用于防止键值冲突 */
      };
      
             key=ftok(pathname.c_str(),ProjectId);
            if(key
               a.logmessage(Fatal,"ftoke调用失败");
               exit(EXIT_FAILURE);
            }
      }
      size_t CreatShm(int flag=IPC_CREAT|IPC_EXCL|0666)
      {
         CreatKey();
          int shmid=shmget(key,SHM_SIZE,flag);
            if(shmid
               a.logmessage(Fatal,"shemget fail:%s",strerror(errno));
               exit(EXIT_FAILURE);
            }
            return shmid;
      }
      size_t GetShm()
      {
           int shmid=CreatShm(IPC_CREAT|0666);
           return shmid;
      }
      
            a.logmessage(Fatal,"creat fifo fail:%s",strerror(errno));
            exit(EXIT_FAILURE);
         }
      
           a.logmessage(Fatal,"processA attach Fail:%s",strerror(errno));
           exit(EXIT_FAILURE);
         }
         a.logmessage(debug,"processA attch successfully:0x%x",Shmadder);
      
            std::cout
                 Shmadder[len-1]='\0';
            }
            char c;
            std::cout
               break;
            }
            if(n
               a.logmessage(Fatal,"write fail:%s",strerror(errno));
               exit(EXIT_FAILURE);
            }
            sleep(1);
         }
      
          a.logmessage(Fatal,"processA detach FAILER:%s",strerror(errno));
          exit(EXIT_FAILURE);
         }
         a.logmessage(debug,"processA detach successfully");
      
          a.logmessage(Fatal,"processA shmctl fail:%s",strerror(errno));
          exit(EXIT_FAILURE);
         }
         a.logmessage(info,"processA quit successfully");
      
              a.logmessage(Fatal,"attch fail:%s",strerror(errno));
              exit(EXIT_FAILURE);
          }
          a.logmessage(debug,"processB attch successfully:0x%x",Shmadder);
      
              char c;
              int n=read(fd,&c,1);
              if(c=='x')
              {
                  break;
              }else if(n==0)
              {
                  break;
              }else if(n
                  a.logmessage(Fatal," processB read fail:%s",strerror(errno));
                  exit(EXIT_FAILURE);
              }else {
              char buffer[1024]={0};
              memcpy(buffer,Shmadder,1024);
              a.logmessage(info,"processB get a message:%s",buffer);
              }
          }
      
              a.logmessage(Fatal,"processB detach fail:%s",strerror(errno));
              exit(EXIT_FAILURE);
          }
          a.logmessage(debug,"processB detach successfully");
          a.logmessage(info,"processB quit successfully");
      
             key=ftok(pathname.c_str(),ProjectId);
            if(key
               a.logmessage(Fatal,"ftoke调用失败");
               exit(EXIT_FAILURE);
            }
      }
      size_t CreatShm(int flag=IPC_CREAT|IPC_EXCL|0666)
      {
         CreatKey();
          int shmid=shmget(key,SHM_SIZE,flag);
            if(shmid
               a.logmessage(Fatal,"shemget fail:%s",strerror(errno));
               exit(EXIT_FAILURE);
            }
            return shmid;
      }
      size_t GetShm()
      {
           int shmid=CreatShm(IPC_CREAT|0666);
           return shmid;
      }
      
        info,
        debug,
        warning,
        Fatal,
      };
      class log
      {
         private:
         std::string memssage;
         int method;
         public:
         log(int _method=screen)
         :method(_method)
         {
         }
         void logmessage(int leval,char* format,...)
         {
            char* _leval;
            switch(leval)
           {
              case info:
              _leval="info";
              break;
              case debug:
              _leval= "debug";
              break;
              case warning:
              _leval="warning";
              break;
              case Fatal:
              _leval="Fatal";
              break;
           }
              char timebuffer[SIZE];
              time_t t=time(NULL);
              struct tm* localTime=localtime(&t);
              snprintf(timebuffer,SIZE,"[%d-%d-%d-%d:%d]",localTime-tm_year+1900,localTime-tm_mon+1,localTime-tm_mday,localTime-tm_hour,localTime-tm_min);
              char rightbuffer[SIZE];
              va_list arg;
              va_start(arg,format);
              vsnprintf(rightbuffer,SIZE,format,arg);
              char finalbuffer[2*SIZE];
              snprintf(finalbuffer,sizeof(finalbuffer),"[%s]%s:%s",_leval,timebuffer,rightbuffer);
              int fd;
              switch(method)
              {
                  case screen:
                  std::cout
                     write(fd,finalbuffer,sizeof(finalbuffer));
                     close(fd);           
                   }
                  break;
                  case ClassFile:
                  switch(leval)
                  {
                       case info:
                        fd=open("log/info.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
                       write(fd,finalbuffer,sizeof(finalbuffer));
                       break;
                       case debug:
                        fd=open("log/debug.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
                       write(fd,finalbuffer,sizeof(finalbuffer));
                       break;
                       case warning:
                      fd=open("log/Warning.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
                       write(fd,finalbuffer,sizeof(finalbuffer));
                       break;
                       case Fatal:
                        fd=open("log/Fatal.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
                       break;
                  }
                  if(fd0)
                  {
                     write(fd,finalbuffer,sizeof(finalbuffer));
                     close(fd);
                  }
              }
         }
      };
      
         int shmid=CreatShm();
         a.logmessage(debug,"processA creat Shm successfully:%d",shmid);
         int n=mkfifo(FIFO_FILE,0666);
         if(n
            a.logmessage(Fatal,"creat fifo fail:%s",strerror(errno));
            exit(EXIT_FAILURE);
         }
         a.logmessage(debug,"processA creat fifo successfully");
         a.logmessage(info,"processA is waiting for processB open");
         int fd=open(FIFO_FILE,O_WRONLY);
         if(fd
            a.logmessage(Fatal,"processA open fail:%s",strerror(errno));
            exit(EXIT_FAILURE);
         }
         a.logmessage(debug,"processA open fifo successfully");
         char* Shmadder=(char*)shmat(shmid,NULL,NULL);
         if(Shmadder==(void*)-1)
         {
           a.logmessage(Fatal,"processA attach Fail:%s",strerror(errno));
           exit(EXIT_FAILURE);
         }
         a.logmessage(debug,"processA attch successfully:0x%x",Shmadder);
         while(true)
         {
            std::cout
                 Shmadder[len-1]='\0';
            }
            char c;
            std::cout
               break;
            }
            if(n
               a.logmessage(Fatal,"write fail:%s",strerror(errno));
               exit(EXIT_FAILURE);
            }
            sleep(1);
         }
         close(fd);
         unlink(FIFO_FILE);
          n=shmdt(Shmadder);
         if(n
          a.logmessage(Fatal,"processA detach FAILER:%s",strerror(errno));
          exit(EXIT_FAILURE);
         }
         a.logmessage(debug,"processA detach successfully");
         n=shmctl(shmid,IPC_RMID,NULL);
         if(n
          a.logmessage(Fatal,"processA shmctl fail:%s",strerror(errno));
          exit(EXIT_FAILURE);
         }
         a.logmessage(info,"processA quit successfully");
         exit(0);
      }
      
          int shmid=GetShm();
          a.logmessage(debug,"processB get Shm successfully:%d",shmid);
          int fd=open(FIFO_FILE,O_RDONLY);
         if(fd
            a.logmessage(Fatal,"processB open fail:%s",strerror(errno));
            exit(EXIT_FAILURE);
         }
         a.logmessage(debug,"processB open fifo successfully");
          char* Shmadder=(char*)shmat(shmid,NULL,NULL);
          if(Shmadder==(void*)-1)
          {
              a.logmessage(Fatal,"attch fail:%s",strerror(errno));
              exit(EXIT_FAILURE);
          }
          a.logmessage(debug,"processB attch successfully:0x%x",Shmadder);
          while(true)
          {
              char c;
              int n=read(fd,&c,1);
              if(c=='x')
              {
                  break;
              }else if(n==0)
              {
                  break;
              }else if(n
                  a.logmessage(Fatal," processB read fail:%s",strerror(errno));
                  exit(EXIT_FAILURE);
              }else {
              char buffer[1024]={0};
              memcpy(buffer,Shmadder,1024);
              a.logmessage(info,"processB get a message:%s",buffer);
              }
          }
          close(fd);
          int n=shmdt(Shmadder);
          if(n
              a.logmessage(Fatal,"processB detach fail:%s",strerror(errno));
              exit(EXIT_FAILURE);
          }
          a.logmessage(debug,"processB detach successfully");
          a.logmessage(info,"processB quit successfully");
          exit(0);
      }
      
免责声明:我们致力于保护作者版权,注重分享,被刊用文章因无法核实真实出处,未能及时与作者取得联系,或有版权异议的,请联系管理员,我们会立即处理! 部分文章是来自自研大数据AI进行生成,内容摘自(百度百科,百度知道,头条百科,中国民法典,刑法,牛津词典,新华词典,汉语词典,国家院校,科普平台)等数据,内容仅供学习参考,不准确地方联系删除处理! 图片声明:本站部分配图来自人工智能系统AI生成,觅知网授权图片,PxHere摄影无版权图库和百度,360,搜狗等多加搜索引擎自动关键词搜索配图,如有侵权的图片,请第一时间联系我们。

相关阅读

目录[+]

取消
微信二维码
微信二维码
支付宝二维码