命名管道 | 共享内存

🪐🪐🪐欢迎来到程序员餐厅💫💫💫

          主厨:邪王真眼

主厨的主页:Chef‘s blog  

所属专栏:青果大战linux

总有光环在陨落,总有新星在闪烁

期待三十五岁退休那一天,然后幸福人生五十年,诶嘿


命名管道

概念:

  • 命名管道(Named Pipe),也称为 FIFO(First - In - First - Out)特殊文件,是一种用于进程间通信(IPC)的机制。它在文件系统中有一个名字,就像普通文件一样,但它的行为和普通文件不同,主要用于无亲缘关系(非父子进程等关联关系)的进程之间的数据通信。

与匿名管道比较

区别:

  • 命名管道是确实存在于磁盘的一个文件
  • 可以用于无亲缘关系进程的IPC。

相同:

  • 我们只使用命名文件的内核缓冲区,即内存部分,不会把进程IO的数据刷新到磁盘(这样做可以提高IO效率)
  • 通信也是单向的。

创建命名管道的方法

mkfifo指令

mkfifo是一个在 Unix/Linux 系统下用于创建命名管道(FIFO - First - In - First - Out)的命令。

  • 基本语法:mkfifo [选项] 文件名

  • 在大多数情况下,常用的是-m选项,用于指定命名管道的权限模式。例如,mkfifo -m 0644 my_fifo会创建一个名为my_fifo的命名管道,权限的表示方式和普通文件的权限表示方式相同,是一个八进制数,其中读权限为 4,写权限为 2,执行权限为 1。

代码演示

mkfifo -m 0666 t

可以看到确实是生成了一个文件,他的文件名是黑色背景,而且他的信息中第一个字母是p,表示这是一个命名管道。

mkfifo函数

mkfifo是一个系统调用(在 Unix/Linux 系统中),用于创建一个命名管道

  • 参数说明
    • pathname:这是一个字符串,表示要创建的文件的路径, 如:"/tmp/my_fifo",其中/tmp是目录路径,my_fifo是命名管道的名字。这个路径名必须是合法的,并且调用进程需要有在指定目录创建文件的权限。
    • mode:这是一个用于指定命名管道权限的参数,它和创建普通文件时的权限设置类似,是一个八进制数。不过,实际创建后的命名管道权限还会受到系统umask值的影响。
  • 返回值
    • 成功时,mkfifo返回 0,表示命名管道创建成功。
    • 失败时,返回 - 1,并设置errno变量来指示错误原因。

假如要让进程AB通过命名管道通信,那么这个命名管道到底是A来创建还是B来创建无所谓,都可以。

那怎么删了他呢,直接rm当然可以,也可以用函数。

unlink函数

unlink是一个系统调用函数,用于删除文件系统中的一个文件。这里的 “文件” 包括普通文件、目录(如果目录为空)、符号链接、套接字等多种类型的文件。

  •  pathname:。这是一个字符串,用于指定要删除的文件的路径,这个路径可以是绝对路径,也可以是相对路径。如果路径指向一个符号链接(symlink),那么unlink函数会删除这个符号链接本身,而不是它所指向的目标文件。
  • 当文件成功删除时,unlink函数返回 0。如果出现错误,函数返回 - 1,并设置errno变量来指示错误的类型。

命名管道实际运用

在了解了这些的基础上,我们来写一份代码,通过命名管道实现进程AB的通信

makefile

对于这个不理解的可以看我上一篇写的博客,有详解。

CLIENT=client
SERVER=server
CC=g++
SERVER_SRC=Server.cc
CLIENT_SRC=Client.cc
.PHONY:ALL
ALL:$(SERVER) $(CLIENT)
$(SERVER):$(SERVER_SRC)
	$(CC) -o $@ $^ -std=c++11
$(CLIENT):$(CLIENT_SRC)
	$(CC) -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -rf $(CLIENT) $(SERVER) 

Common.hpp

表示服务端和客户端的公共部分,注释打好了,相信聪明的你一定能理解。

#pragma once
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<string>
#include <fcntl.h>

using namespace std;
const string PipeFile="./PipeFile";//表示要创建的命名管道名称
const mode_t pri=0600;//要创建的命名管道的权限
const int defaultfd=-1;//fd初始值
const int Size=1024;//服务端接收信号的缓冲区大小
class Init{
    public:
Init(){
    umask(0);
    int n=mkfifo(PipeFile.c_str(),pri);//打开命名管道
    if(n<0)
    {
        cerr<<"mkfifo Error"<<endl;
        return;
    }
   
}
 ~Init(){
    int n=unlink(PipeFile.c_str());//进程正常结束时销毁命名管道
       if(n<0)
    {
        cerr<<"unlink Error"<<endl;
        return;
    }
    cout<<"unlink success"<<endl;
 }
}init;

 Client.hpp

对客户端属性和方法的描述

#pragma once
#include "Common.hpp"
class Client
{
        public:
    Client()
        : _fd(defaultfd) {}
    bool OpenPipe()//以只写的方式打开命名文件
    {
        _fd = open(PipeFile.c_str(), O_WRONLY);
        if (_fd < 0)
        {
            cerr << " Client open Error" << endl;
            return false;
        }
        return true;
    }
    int SentPipe(string &in)//发送信息
    {
        return write(_fd, in.c_str(), in.size());
    }
    void ClosePipe()//关闭文件
    {
        if (_fd >= 0)
            close(_fd);
    }
    ~Client()
    {
    }

private:
    int _fd;//打开的文件fd
};

 Server.hpp

对服务端属性和方法的描述

#pragma once
#include "Common.hpp"
class Server
{
    public:
    Server()
        : _fd(defaultfd) {}
    bool OpenPipe()//以只读方式打开文件
    {
        _fd = open(PipeFile.c_str(), O_RDONLY);
        if (_fd < 0)
        {
            cerr << " Client open Error" << endl;
            return false;
        }
        return true;
    }
    int ReceivePipe(string *out)//接受信息
    {
        char *buf[Size];//用于存储获取的信息
        ssize_t n = read(_fd, buf, Size - 1);//细节Size-1,要给最后的'\0'留一个位置
        if (n > 0)
            buf[n] = '\0'; // 细节加上斜杠0结尾,因为它是是字符串
        return n;
    }
    void ClosePipe()//关闭文件
    {
        if (_fd >= 0)
            close(_fd);
    }
    ~Server()
    {}

private:
    int _fd;//打开的文件fd
};

Client.cc

#include"Client.hpp"
int main(){
    ;
    Client client;
    client.OpenPipe();
    string message;
    while(true){
        cout<<"请输入信息"<<endl;
        getline(cin,message);
        client.SentPipe(message);
    }
}

Server.cc

#include"Server.hpp"
int main(){
    ;
    Server server;
    cout<<3<<endl;;
    server.OpenPipe();
    string message;
    while(true){
        if(server.ReceivePipe(&message))
        cout<<"server 接受成功"<<endl;
        else
        break;
    }
}

这里要注意:

命名管道在进行打开申请(即open函数打开文件)时,如果读端还没打开,写端的打开申请就会被卡住,反之一样。这说明对于命名管道,读端写端的打开是互相等待的。

验证一下:

我们在server的OpenPipe中的open前后各加一个cout输出

   

    

可以看到,只打印出“1”,这说明到open函数就卡住了,得证。 


SYStem V之共享内存

理解

  • 共享内存是一种进程间通信(IPC)机制,它允许多个进程访问同一块物理内存区域。这些进程可以将这块共享内存区域映射到自己的虚拟地址空间,就好像这块内存是自己进程的一部分一样,从而可以直接读写其中的数据。

我们说过IPC的本质就是让不同的进程看到同一份资源,显然共享内存做到了这点 

我们称共享内存到虚拟地址的映射这个步骤为“把共享内存挂接到进程的地址空间中”

删除共享内存本质就是删掉页表中的共享内存的物理地址和虚拟地址的映射,这一步叫做去关联

显然共享内存是可以被大量创建出来的,于是乎,这么多的共享内存,OS当然要把他们管理起来

显然这份共享内存不会存在于进程A或B中,他是OS创建的,位于OS内核区。


函数讲解 

shmget 

 

 shmget是 Unix/Linux 系统中用于创建或获取共享内存段的系统调用函数。 

  函数参数  

  • key:这是一个键值,用于唯一地标识共享内存段。这个键值用于操作系统查找或创建共享内存段。
  • size:表示要创建或获取的共享内存段的大小,以字节为单位。
  • shmflg:一个标志位,用于指定操作的类型。它可以是以下标志位的组合(用按位或组合)
  • IPC_CREAT:如果共享内存段不存在,则创建一个新的共享内存段,若存在则直接获取
  • IPC_EXCL:单独使用无意义,当和IPC_CREAT一起使用时,如果共享内存段已经存在,则shmget函数返回 - 1,并且设置errnoEEXIST,如果不存在则创建一个新的共享内存。用于确保创建的是一个新的、唯一的共享内存段。
  • 权限标志(如0666:类似于文件的权限设置,用于指定共享内存段的访问权限。   
  • 返回值

    • 成功时,shmget函数返回一个非负整数,这个整数就是共享内存段的标识符(shmid),后续的共享内存操作(如shmat等函数)会使用这个标识符来引用该共享内存段。

    • 失败时,返回 - 1,并设置errno变量来指示错误原因。例如,可能的错误原因包括权限不足(EACCES)、内存不足(ENOMEM)或者键值错误(EINVAL)等。


ftok

ftok是一个在 Unix 和类 Unix 系统(如 Linux)中通过传入的两个参数生成一个唯一的键(key_t)的函数。

  • 参数说明
    • pathname:这是一个指向字符数组的指针,用于指定一个已存在的文件路径。这个文件必须是可以被调用ftok函数的进程访问到的。文件系统通过这个文件的相关信息(如 inode 编号等)来帮助生成唯一的键。
    • proj_id:这是一个整数标识符,用于在基于pathname生成键的基础上进一步区分不同的键。它通常是一个 0 - 255 之间的整数,或者是一个字符(其 ASCII 码值在 0 - 255 之间)。
  • 返回值
    • 如果成功生成一个有效的key_t值,ftok函数返回这个key_t值。失败返回-1

注意:只要两个参数一样,那么你生成的key就会一样。

问题一:不是说shmget的key参数是让用户自己设置吗,咋又冒出来个ftok

因为我们要保证每个共享内存的key值都不一样,让用户自己设置可能出问题,比如我现在给一个共享内存设置key为2,然后我继续写代码,过了半个小时我又要给另一个共享内存设置key,但是我忘了我用了2了,也把他的key设置为2.那这不就出错了吗。所以ftok是辅助你的,你要对自己有信息可以不用啊,但我还是建议你稳妥一些。

问题二:为什么key要让用户生成,而不是OS直接自己生成

既然你说用户可能出错,那我干脆让OS来不就好了,然OS调用ftok,不是很保险吗?

那么请问现在进程A创建了共享内存,OS给他设置了key,那么请问进程B如何获取?用类似于getpid的方法?别忘了getpid是获取的它所在进程的pid,而不是随便一个进程都可以获取!所以你让OS创建,那别的进程就拿不到key了。

但是对于用户,所以我们可以把Key写在一个文件中,AB都include该文件,在编码层面上,AB就都能拿到key值了。

至此,两个毫不相关的进程就可以依靠key值,进而看到同一份资源了

我们直接拿他练个手

#include<sys/types.h>
#include<sys/ipc.h>
#include<iostream>
#include<sys/shm.h>
using namespace std;
int main(){
int flag=shmget(1,100,IPC_CREAT|IPC_EXCL);
if(flag<0)cerr<<"Error"<<endl;
else
cout<<"Success"<<endl;
return 0;
}

可以看到,第一次成功了,第二次却失败了

使用指令:ipcs -m  ,查看当前的共享内存的信息,可以看到共享内存已经存在了

因为我们之前的共享内存没有释放,所以再拿同样的key值去创建shm发现目标已存在,就报错了。

但是我们之前自己malloc、new出来的空间都会随着进程结束而被释放啊

因为这片空间是属于OS的!函数接口确实是用户调用的,但是别忘了他的物理空间在OS内核区,所以这片空间的主人是OS,因此即是进程结束他也不会被释放

结论:共享内存的生命周期随内核  


ipcrm -m shmid

用于手动释放shmid所指向的共享内存

为什么这里我们用的是shmid,而不是key值,因为OS规定要操作共享内存,OS使用key值,用户使用shmid,即对于OSkey是共享内存的唯一标识符;对于用户shmid是共享内存的唯一标识符

指令也属于用户层,所以要用shmid


shmctl

  • shmctl是一个用于控制共享内存段的系统调用函数。它可以对共享内存段进行多种操作,包括获取共享内存段的状态信息、设置共享内存段的属性以及标记共享内存段要被删除等。

今天我们重点关注他的删除功能

参数

  • shmid是共享内存标识符,这个标识符是通过shmget系统调用创建共享内存段时返回的,用于唯一标识一个共享内存段。

  • cmd是要执行的命令,它决定了shmctl函数的具体操作。常见的命令有:

    • IPC_STAT:获取共享内存段的状态信息,并将其存储到buf指向的struct shmid_ds结构体中。这个结构体包含了共享内存段的大小、连接到该共享内存段的进程数等信息。

    • IPC_SET:使用buf指向的struct shmid_ds结构体中的信息来设置共享内存段的属性。不过,只有进程的有效用户 ID 等于共享内存段的创建者用户 ID 或者进程具有超级用户权限时,这个命令才能成功执行。

    • IPC_RMID:标记共享内存段要被删除。当这个命令被执行后,共享内存段会被标记为要删除,系统会在所有连接到这个共享内存段的进程都与它分离(通过shmdt系统调用)后,才会真正删除这个共享内存段。

  • buf是一个指向struct shmid_ds结构体的指针。这个结构体用于存储或提供共享内存段的相关信息,具体用法取决于cmd命令。当cmdIPC_STAT时,用于接收共享内存段的状态信息;当cmdIPC_SET时,用于提供要设置的共享内存段的属性信息。当cmdIPC_RMID时可以直接设置为NULL。

返回值

  • 成功时,shmctl返回 0。

  • 失败时,它返回 - 1,并设置errno来指示错误类型。


shmat

将shmid对应的共享内存挂接到指定的虚拟地址中,并设置改空间的权限(只读、只写、读写等等)

参数:  

  • shmid:用于唯一标识一个共享内存段。每个共享内存段都会有一个不同的shmid,这样shmat就能准确地知道要将哪个共享内存段连接到进程的地址空间。
  • shmadddr用于指定共享内存段连接到进程虚拟地址空间的起始地址。当设为NULL时,操作系统会自动选择一个合适的、未被占用的虚拟地址来连接共享内存段(也建议设置为NULL,别自己瞎搞)
  • shmflg用于控制共享内存段的连接方式
    • 默认0,表示是可读可写,

    • SHM_RND:将地址向下取整为某个整数倍。

    • SHM_RDONLY:只读连接

返回值

  • 成功情况

    • 返回一个指向连接后的共享内存段起始地址的指针。这个指针的类型是void*,它可以被强制转换为适当的数据类型(例如,如果共享内存用于存储字符数组,就可以转换为char*),以便进程能够像访问普通内存一样访问共享内存中的数据。

  • 失败情况

    • 会返回(void *)-1。同时,系统会设置errno变量来指示错误类型。

细节一:权限

#include<sys/types.h>
#include<sys/ipc.h>
#include<iostream>
#include<sys/shm.h>
using namespace std;
int main(){
int flag=shmget(1,100,IPC_CREAT|IPC_EXCL);
void* b=shmat(flag,NULL,0);
if((long long)b==-1)
cout<<"Error"<<endl;
else
cout<<"Success"<<endl;
return 0;
}

挂接失败了,这是因为权限问题,我们在设置共享内存时没有设置合理的权限

  int shmid = shmget(l, shmsize, IPC_CREAT | IPC_EXCL|0666);//如此方可

细节二: 

操作系统申请空间的时候,是以一个固定大小为单位来处理的,一般是以4kb为单位,这意味着即是你在申请共享内存的要求申请了4097个字节,他也会申请8KB

可以看到从结果上看她貌似之申请了4097个字节,其实不是的,OS申请了8KB,但是它只让你用了4097个字节,这意味着设下接近4kb的空间就这么被浪费了。


shmdt

  • shmdt是一个系统调用函数,用于将之前通过shmat函数连接到进程地址空间的共享内存段分离。即去关联
  • shmaddr参数是一个指向共享内存段在进程地址空间中的起始地址的指针。这个地址是之前shmat函数成功连接共享内存段后返回的地址。

  • 返回值:成功情况:它返回 0,失败返回 - 1,并且会设置errno变量来指示错误类型。例如:


代码展示

我们对共享内存的使用是没有使用系统调用(如write、read)

写入

#include "ShareMemory.hpp"
#include "time.hpp"
using namespace std;
struct data
{
    char status[32];     // 当前正在传递数据的状态
    char lasttime[48];   // 最新写入消息的时间
    char messsage[1024]; // 要传入的消息
};
int main()
{
   // 进行通信
   data *da = (data *)shm.GetAddr();
   while (true)
   {
      strcpy(da->status, "newest");
      strcpy(da->lasttime, GetTime().c_str());
      strcpy(da->messsage, "010100101010");
      sleep(2);
   }
   return 0;
}

读取

    while(true)
    {
        struct data *da = (struct data *)shm.GetAddr();
    printf("%s\n%s\n%s\n", da->status, da->lasttime, da->messsage);
    }

优点

因为共享内存被挂接到了共享区,而共享区是属于用户空间的。所以用户对他的使用和对栈区、堆区没有区别。又因为使用共享区的速度快于用户空间和OS内核区做IO的速度

因此共享内存是所有IPC中通信速度最快的

缺点

管道读端读一次,管道数据就会被清理一部分;管道为空是读端被阻塞等等特性,共享内存都没有,共享内存没有加任何保护,他的保护机制由用户自己设置


以命名管道实现对shm的保护

我们今天用利用管道自己的保护机制来保护shm

这里引入概念

  • 被保护的共享资源叫做临界资源

  • 访问临界资源的代码叫做临界区

思路如下:

进程A负责向shm写入,我们写入代码在他后面先加write再加read函数分别去调用管道AB,进程B先read函数,再从shm读取,最后加write函数

过程如下:

  1. 进程A先写入,此时 进程B的read函数会被阻塞(因为管道A为空)

  2. A向shm写完接着向管道A写入一个整型1,然后会被read函数卡住(因为管道B为空)

  3. 进程B的read从管道A读取,接着执行下一行代码——向shm读取

  4. B从shm读取完,向管道B写入一个整型1,然后循环,继续被read卡住

  5. A的read读取成功,然后循环继续向shm写入

使用命名管道+shm对比只使用管道的优势

管道只需要传递极少量的数据,所以他的时间开销可以忽略,于是我们就在保证了拥有共享内存的高速IPC优势之下,还使其拥有了保护机制 


 makefile 

CLIENT=client
SERVER=server
CC=g++
SERVER_SRC=Server.cc
CLIENT_SRC=Client.cc
.PHONY:ALL
ALL:$(SERVER) $(CLIENT)

$(SERVER):$(SERVER_SRC)
	$(CC) -o $@ $^ -std=c++11
$(CLIENT):$(CLIENT_SRC)
	$(CC) -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -rf $(CLIENT) $(SERVER) 

 PipeName.hpp

我们把上面的命名管道写的代码偷过来一部分,修改内容在注释中写了。

#pragma once
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<string>
#include <fcntl.h>
using namespace std;
string PipeFile="./PipeFile";
const mode_t pri=0600;
const int defaultfd=-1;
const int Size=1024;
class Fifo{
    public:
Fifo()
:_num(1)
 ,_fd(-1)
{
     umask(0);
    int n=mkfifo(PipeFile.c_str(),pri);
    if(n<0)
    {
        cerr<<"mkfifo Error"<<endl;
        return;
    }
}
 bool OpenPipeForWrite()
    {
        PipeFile+=to_string(_num);我们修改了PipeFile,因为我们需要生成两个命名管道,所以每次调用OpenPipeForWrite时的PipeFile不能相同
        _fd = open(PipeFile.c_str(), O_WRONLY);
        _num++;
        if (_fd < 0)
        {
            cerr << " Client open Error" << endl;
            return false;
        }
        return true;
    }
  bool OpenPipeForRead()
    {
        _fd = open(PipeFile.c_str(), O_RDONLY);
        if (_fd < 0)
        {
            cerr << " Client open Error" << endl;
            return false;
        }
        return true;
    }
    void Write(){
        int n=1;
        write(_fd,&n,sizeof n);
    }
    void Read(){
        int n=0;
        read(_fd,&n,sizeof n);
    }
~Fifo(){ int n=unlink(PipeFile.c_str());
       if(n<0)
    {
        cerr<<"unlink Error"<<endl;
        return;
    }
    cout<<"unlink success"<<endl;}
private:
int _fd;
int _num;
};
Fifo ff1,ff2;

ShareMemory.hpp 

#pragma once
#include <iostream>
#include <string>
#include <time.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#include<cstring>
using namespace std;
const string path = "/home/qingguo/project27_sharedmem";
const int project = 0x1234;
const int shmsize = 4096;
const mode_t gmode = 0600;
string Hex(int x)
{
    char arr[100];
    snprintf(arr, 100, "0x%x", x);
    return arr;
}
class ShareMemory
{
private:
    void CreatShmHelper(int shmflg)
    {
        _key = ftok(path.c_str(), project);
        cout<<Hex(_key)<<endl;//以十六进制打印key值,方便debug
        if (_key < 0)
        {
            cerr << "ftok error" << endl;
            return;
        }
        _shmid = shmget(_key, shmsize, shmflg);
        cout<<_shmid<<endl;
        if (_shmid < 0)
        {
            cerr << "shmget:" <<Hex(_key)<< endl;
            return;
        }
        return;
    }

public:
    ShareMemory()
        : _shmid(-1), _key(-1), addr(nullptr)
    {
    }
    ~ShareMemory() {}
    void CreatShm()
    {
        CreatShmHelper(IPC_CREAT | IPC_EXCL | 0666);
    }
    void GetShm()
    {
        CreatShmHelper(IPC_CREAT);
    }
    void deleteShm()
    {
        shmctl(_shmid, IPC_RMID, nullptr);
    }
    void AttachShm()
    {
        addr = shmat(_shmid, NULL, 0);
        if (-1 == (long long)addr)//细节强转没有用int,因为我的云服务器是64位的,所以指针大小是8个字节,转成四个字节的int会error
            cout << "AttchShm Error" << endl;
        return;
    }
    void DetachShm()
    {
        if (addr != nullptr) // 说明挂接成功
            shmdt(addr);
    }
    int GetShmid()
    {
        return _shmid;
    }
    void *GetAddr()
    {
        return addr;
    }
    key_t GetKey()
    {
        return _key;
    }

private:
    int _shmid;
    key_t _key;
    void *addr;
};
ShareMemory shm;
struct data
{
    char status[32];     // 当前正在传递数据的状态
    char lasttime[48];   // 最新写入消息的时间
    char messsage[1024]; // 要传入的消息
};

Server.cc

#include "ShareMemory.hpp"
#include "time.hpp"
#include "PipeName.hpp"
using namespace std;
int main()
{

    shm.GetShm();
    shm.AttachShm();
    ff1.OpenPipeForRead();
    ff2.OpenPipeForWrite();
    // 进行通信
    while (true)
    {
        ff1.Read();
        struct data *da = (struct data *)shm.GetAddr();
        printf("%s\n%s\n%s\n", da->status, da->lasttime, da->messsage);
        ff2.Write();
        strcpy(da->status, "old");
        sleep(2);
    }
    shm.DetachShm();
    shm.deleteShm();
    return 0;
}

Client.cc

#include "ShareMemory.hpp"
#include "time.hpp"
#include"PipeName.hpp"
using namespace std;
int main()
{
   shm.CreatShm();
   shm.AttachShm();
   ff1.OpenPipeForWrite();
   ff2.OpenPipeForRead();
   // 进行通信
   data *da = (data *)shm.GetAddr();
   while (true)
   {
      strcpy(da->status, "newest");
      strcpy(da->lasttime, GetTime().c_str());
      strcpy(da->messsage, "010100101010");
      sleep(3);
      ff1.Write();
      ff2.Read();
      sleep(2);
   }
   shm.DetachShm();
   shm.deleteShm();
   return 0;
}

牢骚:

最近事情好多,烦死了,一堆莫名其妙的事占用我的时间,还有些别的糟心的事,唉,来安慰我一下亚托莉酱

评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值