系统编程学习-文件一

文件IO

目标
1. 理解文件描述符
2. 基础文件IO函数(open creat lseek close read write3. 进阶文件IO函数(dup fcntl sync)
点击查看代码
1. 文件描述符
非负整数,open或creat时返回的文件描述符,必定为最小的可用描述符

基础文件IO
open 打开文件时,设置了O_APPEND标识,即使使用lseek改变文件偏移,写入依然会追加在文件末尾,并且文件偏移为写入前文件长度 + 写入长度。
O_APPEND,似乎会将lseek(fd, 0, 2) 和 write 绑定成一个原子操作,需要验证


od -c , -c表示以字节方式, od命令查看文件的原始内容

ssize_t read(int fd, void* buff, szie_t nbyte)
ret:成功返回读取的实际字节数,失败返回-1
arg:nbyte,读取的nbyte字节数;
不会自动在buff最后添加\0;
文件结尾再调用read返回0;
实际读取字节数小于nbyte的情况:
	文件末尾,不足nbyte
	fd对应终端设备,一次读入一行
	从网络中读,网络缓冲可能小于nbyte
	fd对应管道和fifo,如果管道包含字节小于nbyte
	面向记录的设备,一次返回一条记录,小于nbyte
	read中途由于信号造成中断,此时,虽然读取的数据没有nbyte,但是指针还是会偏移到开始时的nbyte之后,(lseek,后退,重读)

一次可读入的最大字节数为,SSIZE_MAX



ssize_t write(int fd, void *buff, size_t nbyte)
ret: 实际写入字数,失败返回-1;
通常write不会失败,返回nbyte。 失败的原因可能是磁盘满了或者超出文件长度限制

ssize_t lseek(fd, offset, whence)
ret:当前文件偏移位置,失败返回-1
arg:whence可以有三个宏SEEK_SET,SEEK_CUR,SEEK_END,对应0 1 2
分别表示从文件开头,文件当前位置,文件结尾处。
强调:SEEK_END,是文件最后一个字符的下一个位置,和c++容器中end()一样


buffer设置大小和文件IO效率
在需要读取字节数非常多的情况下
当buffer设置很小,甚至是1时,效率会非常低
随着buffer增大,读取耗时会显著降低,
当buffer超过128byte时(实验场景),随着buffer增大,耗时几乎不变,这是由于文件系统的预读(read ahead)机制造成的;
当检测到正在顺序读取时,系统试图读入更多的数据并假想程序会很快读取这些数据。此时,增大buffer几乎不会影响读取效率
随着buffer继续增加,效率反而会有所降低,这是因为buffer大小比预读空间还要大,超过预读空间的部分没有收益于预读的影响


内核如何记录打开的文件:
1.进程表中记录着 打开文件描述符表 (每个表项包含:文件描述符标志, 指向文件表某一表项指针)
2.所有打开文件共有一个文件表,(每个表项包含:文件状态(读,写,非阻塞...), 当前文件偏移, 指向文件v-node的指针)
3.每个打卡文件(设备)有一个 v-node结构。(包含:文件类型,对此种文件进行各种操作的函数指针, 大多文件都有的i-node)
linux系统,只有i-node,没有v-node
每个进程私有的打开文件描述符表--》公共的文件表(n个表项)-》x个打开文件
区别文件描述符标识, 文件状态标识, 前者是作用于进程的一个文件描述符,后者作用于所有指向该文件表项的文件描述符
可能出现多个文件描述符指向同一个文件表项,比如 dup,fork

保证多进程对同一文件原子性的写入:
使用 pread 和 pwrite,(fd, buff, nbyte, offset);用法和readwrite几乎一样,相当于将lseek,和readwrite绑定成原子操作
使用 pread 和 pwrite 时无法中断,并且在函数结束后才更行文件偏移


文件IO进阶
文件描述符复制
int dup (int fd);
ret: 成功返回新文件描述符(当前最小可用文件描述符),失败返回-1
int dup2 (int fd, int fd2);
ret:成功返回新文件描述符(fd2指定的文件描述符),失败返回-1
如果fd2 已经有打开文件,则先将其关闭; 如果fd2 等于fd1,则直接返回fd

dup(fd) 等效于 fcntl(fd, F_DUPFFD, 0);
dup2(fd, fd2) 几乎等效于 close(fd2); fcntl(fd, F_DUPFD, fd2); 不同之处在于dup2是原子操作
新描述符独占一个打开文件描述符表项, 和被复制描述符公用一个文件表项(文件标志,偏移,v-node相同)

保证文件一致性(清空缓存)
大多数磁盘IO都通过缓冲进行,当需要写入文件时,先将数据拷贝进缓冲区,当缓冲满或者需要重用缓冲时,才将缓冲排入输出队列,等其到达队首时,才进行实际IO,这种方式称为延时写(delayed write)。减少了读写次数,降低了文件更新速度
void sync(void)
将所有修改过的块排入写队列,然后就返回,并不等待实际写磁盘结束
int fsync(int fd)
只对指定的文件描述符起作用,并且等待实际写入完成才返回,并且同步修改包含文件属性的修改
ret: 成功返回0, 失败返回 -1
int fdatasync(int fd)
和fsync类似,不过,只作用于数据部分,文件属性不会同步修改


修改文件属性
int fcntl(int fd, int cmd, .../*int arg*/)
ret:成功依赖于cmd, 失败返回 -1
arg:大部分cmd对应的第三个参数是一个整数,但是cmd表示记录锁时,是一个执行结构的指针

cmd 可以分为5类:
(1)复制一个现有描述符 cmd = F_DUPFD
(2)获得/设置文件描述符标记 cmd=F_GETFD F_SETFD
(3)获得/设置文件状态标识 cmd=F_GETFL F_SETFL
(4)获得/设置异步IO所有权 cmd=F_GETOWN F_SETOWN
(5)获得/设置记录锁 cmd=F_GETLK F_SETLK F_SETLKW

F_DUPFD 能复制文件描述符,结果为 所有未使用文件描述符中min(未使用描述符最小值,点三个参数)
新的文件描述符有自己的一套文件描述符标识,其中FD_CLOEXEC文件描述符标志被清除(该标志表示在exec时,关闭文件描述符)
F_GETFD F_SETFD, 当前只定义了FD_CLOEXEC,0为默认状态,exec时不关闭描述符
F_GETFL F_SETFL, 设置获取文件标志
文件标志包括,
不可设置:O_RDONLY, O_WRONLY, O_RDWR (0 1 2),由于对应012,并不是每位对应一种标志,获取后还需要判断
可设置的:O_APPEND, O_NONBLOCK, O_SYNC, O_DSYNC, O_RSYNC,O_ASYNC,O_CLOEXEC,O_NOATIME,O_NOCTTY
O_SYNC 等待写完成(数据和属性)  等于fsync()
通常数据库系统,需要设置设置等待写完成,为了保险起见,最好手动调用fsync()
O_DSYNC 等待写完成 (仅数据) 等于fdatasync()
O_RSYNC 同步读写
O_ASYNC 设置此标记后,当调用open返回的文件描述符可以进行IO操作时,产生一个信号,仅对终端,FIFOs,socket生效,Linux中,open时设置该标记无效,必须通过fcntl设置才生效
O_CLOEXEC,表示close-on-exec,一个好的习惯是,在创建(新)文件描述符时,对其设置该标志,可以避免,使用fcntl设置该标记时,某些多线程下的产生的竞争场景 fork()和fcntl()
O_NOATIME,在读文件时,不更新最近访问时间。目的在于设置该标志能显著减少磁盘活动量,省却了既要读取文件内容,又要更新i-node节点的繁琐。使用该标志需要对文件有所有权或特权
O_NOCTTY, 仅当文件是终端文件时有效,防止其成为控制终端。

open错误号(errno):
EACCES, 权限不允许
EISDIR, 设置了写标志,但是文件是目录
EMFILE, 打开文件描述符到达上限
ENFILE, 文件打开数量达到系统上限(和上面一个属于不同的表)
ENOENT, 文件不存在(文件不存在,目录不存在,链接文件目标不存在)
EROFS, 文件为只读,调用者以写的方式打开文件
ETXTBSY, 文件为可执行文件,且正在执行
#include <unistd.h>
int ioctl(int fd, int rquest, ...);
ret:失败返回-1成功返回其他值
ioctl除了函数本身的头文件外,还需要设备专用的头文件,以终端设备为例,需要<termios.h>

/dev/fd
该目录下存在形如/dev/fd/1 /dev/fd/0 的文件,对这些文件进行打卡操作,等效于复制描述符n,
新描述符拥有的权利是已打开文件的子集(打开文件的mode 交集  原有描述符权利), open("/dev/fd/0", O_RDWR), 得到的文件描述符依然只能RW, 不能写。
其主要用途用于shell中,使得命令能像处理路径一样处理 标准输入 输出。
某些命令支持特殊符号路径 - ,表示标准输入, 当存在/dev/fd之后,可以用/dev/fd/0 代替 -;


补充说明:
readwrite都在内核执行
dup本质上是创建一条文件描述符表项,而open不但会创建文件描述符表项,还会创建打卡文件表项
命令行中 digit1 > &digit2 本质上是调用dup2(digit2, F_DUPFD, digit1) 实现的
当同时存在 重定向输入 重定向输出时,顺序不同可能含义不同,通过上面两条推导

文件IO深入探究
1.原子性操作:
检测并创建文件,open时同时设置O_CREAT和O_EXCL,当文件存在时,会调用失败
向文件尾追加,使用O_APPEND保证写操作从文件末尾开始

2.在文件特定偏移处IO
<unistd.h>
ssize_t pread(fd, buf, size, offset)
ssize_t pwrite(fd, buf, size, offset)
与read和write的区别在于,这两个调用会在指定的offset处进行读写,而不是文件偏移处,并且不会改变文件偏移。
相当于原子性的执行了,暂存当前文件偏移,将偏移设置成offset,读写操作,恢复文件偏移。
主要用于多线程场景

3.分散输入和集中输出
<sys/uio.h>
ssize_t readv(fd, struct iovec *iov, int iovcnt)
ret:读取的字节总数,失败返回-1
arg:iov是一个结构体数组,iovcnt是数组长度
功能:将一次读入的数据读到多个buffer当中,每个buffer由一个struct iovec描述
struct iovec{
	void *iov_base; //buffer的首地址
	size_t iov_len; //buffer的长度
}
ssize_t writev(fd, struct iovec *iov, int iovcnt)
ret:写入的字节总数,失败返回-1
arg:iov是一个结构体数组,iovcnt是数组长度
功能:将多个buffer的内容按照iov的顺序一起写入
等价于,申请一片很大的空间,将iov的buffer拷贝进去,再调用write写
这两个调用主要是为了使用方便

linux 2.6.30版本之后还支持
<sys/uio.h>
preadv(fd, *iov, iovcnt, offset)
pwritev(fd, *iov, iovcnt, offset)


文件和目录

stat 函数和结构体
#include <sys/stat.h>
int stat(filename, struct stat *buf)
int fstat(fd, struct stat *buf)
int lstat(filename, struct stat *buf)
ret: 成功返回0,失败返回-1
arg: buf是一个出参,用于保存filename 的stat

stat和fstat的区别在用使用路径还是描述符指定文件,
lstat和stat类似,但是当文件是符号链接时,lstat返回符号链接本身的信息,而不是链接指向的目标文件

struct stat
{
    dev_t       st_dev;     /* ID of device containing file -文件所在设备的ID*/
    ino_t       st_ino;     /* inode number -inode节点号*/
    mode_t      st_mode;    /* protection -保护模式?*/
    nlink_t     st_nlink;   /* number of hard links -链向此文件的连接数(硬连接)*/
    uid_t       st_uid;     /* user ID of owner -user id*/
    gid_t       st_gid;     /* group ID of owner - group id*/
    dev_t       st_rdev;    /* device ID (if special file) -设备号,针对设备文件*/
    off_t       st_size;    /* total size, in bytes -文件大小,字节为单位*/
    blksize_t   st_blksize; /* blocksize for filesystem I/O -系统块的大小*/
    blkcnt_t    st_blocks;  /* number of blocks allocated -文件所占块数*/
    time_t      st_atime;   /* time of last access -最近存取时间*/
    time_t      st_mtime;   /* time of last modification -最近修改时间*/
    time_t      st_ctime;   /* time of last status change - */
};


文件类型及判断方法
unix文件类型:
普通文件(regular file)
目录文件
块特殊文件
字符特殊文件
FIFO(命名管道)
套接字(socket)
符号链接

<sys/stat.h> 中定义了一些判断文件类型的宏
S_ISREG(mode)
S_ISDIR(mode)
S_ISCHR(mode)
S_ISBLK(mode)
S_ISFIFO(mode)
S_ISSOCK(mode) 在linux系统下,需要定义_GNU_SOURCE才能使用该接口

IPC对象被视为文件,但是其接口有点区别,不是mode 而是指向stat的指针
S_TYPEISMQ(ptr) 是否为消息队列
S_TYPEISSEM(ptr) 是否为信号量
S_TYPEISSHM(ptr) 是否为共享内存


stat-文件访问权限
实际用户ID,有效用户ID,保存的设置用户ID,
实际组ID,有效组ID,附加组ID,保存的设置组ID

实际用户ID,即登录时输入的用户名,密码对应的用户
有效用户ID,决定了我们的文件访问权限,
保存的设置用户ID, 在程序执行时,保存的有效用户id

每个文件都有一个所有者,由stat中st_uid成员表示, st_gid表示所有组
通常进程的有效ID等于用户的实际ID,但是可以在通过设置stat中的st_mode设置一个特殊标志,改变这种行为,
设置用户ID(set-user-ID):"当执行文件时,进程的有效用户id设置成文件所有者的用户id", 对应的宏为S_ISUID
设置组ID(set-group-ID):"将执行此文件的进程的有效组id,设置成文件所有者的组ID",对应的S_ISGID
通过passwd命令,和shadow文件来理解这两个标志

文件访问权限:
所有文件都有访问权限,由三组 3bit位表示(rw- r-- r--),三bit分别代表 读 写 可执行,每组代表不同角色,表示所有者,所有组,其他用户
访问权限为:
{
<sys/stat.h>
S_IRUSR
S_IWUSR
S_IXUSR
S_IRGRP
S_IWGRP
S_IXGRP
S_IROTH
S_IWOTH
S_IXOTH
}

目录权限:
当打开文件时,对文件名字中的目录(包含隐含的当前工作目录),校验是否具有可执行权限;然后判断文件本身的是满足访问权限
引用隐含目录的例子:访问不带目录名的文件,会隐式的检查当前目录的执行权限。对于PATH变量,如果某个目录不具备可执行权限,shell不会在该目录下搜索文件
目录的读权限,允许获得目录下的文件列表; 
目录的执行权限,使用目录下的文件;
目录的写权限,在目录下创建/删除文件时,需要同时有目录的写和执行权限。 令人奇怪的是,删除文件不需要对文件本身有读写权限

权限性质:
文件所有者(st_uid、st_gid),是文件性质
进程的有效ID(有效用户ID,有效组ID),是进程性质

权限校验过程
若进程的有效ID是0(超级用户),允许访问。
若进程有效ID等于用户所有者ID,会按照打开文件的mode & 文件用户权限 获得访问权限
若进程有效组ID或附加组ID之一等于文件组ID,则会按照文件的打开mode & 文件组权限获得访问权限
若其他用户权限被设置,则可访问

新文件和目录的所有权
新文件的用户ID为进程的有效ID。
新文件的组ID可以是进程的有效组ID,或者是他所在目录的组ID,取决于操作系统(Linux中,如果父目录存在组ID,则新文件组ID于父目录相同)

#include <unistd.h>
int access(path, mode)
ret:成功返回0,失败返回-1
arg:mode常用宏 R_OK, W_OK,X_OK, F_OK
access和进程访问文件略有不同,是按照 实际用户/组ID 进行访问权限测试的,单是校验过程一样
所以,当设置了 设置用户ID 之后,可能出现access R_OK失败,但是文件能以可读方式打开

#include <sys/stat.h>
mode_t umask(mode_t cmask);
ret: 之前设置的mask值
创建文件是,mask为1的为,会屏蔽掉open,creat 中mode对应的位
创建文件前,先清0 umask值,避免创建文件影响其他进程读取,
每个进程都有一个独自的umask值(非全局唯一)
命令行中 umask -S 可以查看未屏蔽

#include <sys/stat.h>
int chmod(const char *path, mode_t mode);
int fchmod(int fd, mode_t mode);
ret: 成功返回0,失败返回-1;
要求,进程的有效用户ID 等于 文件的所有者ID,或者root用户
chmod 可以设置除了上述常规权限外,还有三个特殊权限,
S_ISUID, 设置用户id
S_ISGID, 设置组id 
S_ISVTX, 粘贴位/正文为,saved-text bit
当权限不够时,设置的特殊权限会被清除,

ls -l 时,
-rwsr-xr-x 表示SUID和所有者权限中可执行位被设置
-rwSr--r-- 表示SUID被设置,但所有者权限中可执行位没有被设置
-rwxr-sr-x 表示SGID和同组用户权限中可执行位被设置
-rw-r-Sr-- 表示SGID被设置,但同组用户权限中可执行位没有被设置
chmod 实际上是更新的是inode节点最近被修改的时间,而ls -l 显示的时间是文件内容最后被修改的时间

粘贴位,S_ISVTX
通常对目录设置粘贴位,
对目录具有写权限,并且【拥有此文件||拥有此目录||超级用户】才能 删除或者更改 目录下的文件名
典型应用场景是:/tmp 和 /var/spool/uucppublic
任何人可以在目录下创建文件,并读写,但是只能修改自己拥有的文件
三个特殊权限对应的二进制位,在原有权限位前加三个,4 2 1 对应SUID SGID sticky,和其他权限组合起来的八进制数7 777

#include <unistd.h>
int chown(path, uid_t owner, gid_t group)
int fchown(fd, uid_t owner, gid_t group)
int lchown(path, uid_t owner, git_t group)
ret: 成功返回0,失败返回-1
owner 或 group为-1时,表示不改变

_POSIX_CHOWN_RESTRICTED, 特殊的权限设置符号,如果该符号起作用,只有root用户能更改其用户ID,除非文件原owner为-1且对文件有写权限

文件长度
st_size
仅普通文件,目录,符号链接有意义,分别对应文件长度,目录项记录长度,链接中实际的字节数(不含null,usr/lib长为7)
使用st_blksize 或 st_blocks 表示文件占用的IO块数,使用du -s [file] 可以查看磁盘块的占用数

空洞文件:
将指针偏移至文件末尾之后,进行行写操作之后得到的。
如果使用cat 读取空洞文件,会将空洞内容以0填满,导致变为空洞文件长度变为实际文件长度

#include <unistd.h>
int truncate(path, off_t length)
int ftruncate(fd, off_t length)
ret: 成功返回0, 出错返回-1
将文件截断为指定长度,如果文件原长度小于length,效果因系统而异
文件系统
磁盘(分区)
分区(文件系统)
文件系统(自举块,超级块,柱面0,柱面1...柱面n)
柱面i(超级块副本,配置信息,i节点图,块位图,i节点,数据块)
i节点(i节点1,i节点2,i节点3...)数据块(空白,数据块1,空白,数据块2.空白,目录块1,空白...)

1. i节点是固定长度的记录项,每个i节点n中有指向数据块的指针,目录录块也是一段数据,不过内容是(指向i节点n的索引和文件名)
2. 一个i节点n可以指向多个数据块,多个目录中的目录项也可以指向同一个i节点(会使节点的链接计数+1),只有当链接数为0时,才会释放占用的磁盘快,这也是删除目录项被称为unlink的原因。
3. 链接计数包含在 st_nlink 中。基本类型为 nlink_t 称为硬链接, LINK_MAX指定了一个文件链接数的最大值
4. 对于符号链接: inode 中会指明文件类型是S_IFLNK, 符号链接的内容,是实际文件的路径名
5. i节点包含的信息:文件类型,文件访问权限位,文件长度,指向该文件所占用的数据块的指针
6. stat中大部分信息都取自i节点,只有两项数据取自目录项,i节点编号ino_t 和 文件名
7. 每个文件系统都对各自的i节点进行编号,这就是为什么ln命令不能跨文件系统创建
8. 在不更换文件系统的情况下为文件更换文件名时,文件的实际内容没有改动,只需要构造一个的目录项,并解除与旧目录项的链接,通常mv的工作方式就是如此
9. 每个不包含其他目录的目录,链接计数都是2 因为存在'.'目录,要是目录包含目录项,链接计数至少为3,因为除了本身和'.'外,还有子目录的'..'


#include <unistd.h>
int link(exitpath, newpath)
ret: 成功返回0,失败-1
arg:exitpath指向现有文件,newpath链接文件
为现有文件创建一个链接

#include <unistd.h>
int unlink(path)
ret:成功返回0,失败返回-1;
删除一个目录项,使文件计数减一,
当path是符号链接时,unlink会删除符号链接,而不是引用文件本身
pathname 也可以是目录,但是通常使用rmdir函数

#include <stdio.h>
int remove(path)
ret: 成功返回0, 失败 -1
对于remove,如果path是文件,相当于unlink,如果path是目录,相当于

#include<stdio.h>
int rename(oldname, newname)
ret:成功返回0,失败返回-1
如果old是文件,如果new已经存在,则删除源文件
如果old是目录,如果new已经存在,
如果old 是符号链接,直接修改其本身,而不是引用

符号链接
硬链接的限制:两个链接处于同一文件系统,只有超级用户才能创建指向目录的硬链接(应为操作不当可能在系统中出现循环,而大多数系统无法处理这种情况)
符号链接一般用于将整个文件或目录移动到系统的另一位置

#include <unistd.h>
symlink(path, sympath)
ret:成功返回0, 失败返回 -1
arg:path为目标,sympath为符号链接
创建符号链接,要注意path可以不存在

#include <unistd.h>
ssize_t readlink(path, buf, buf_size)
ret: 成功返回读到的字节数,失败返回-1
返回符号链接本身的内容,同样,不易null结尾


image

image

image

文件的时间
三个时间:
最后访问时间:st_atime, ls -u
最后修改时间:st_mtime, ls
i节点最后修改时间:st_ctime, ls -c

文件的访问和修改时间可以被更改:
#include <utime.h>
int utime(pathname, struct utimbuf *times)
ret:成功返回0,失败返回-1
struct utimbuf {
	time_t actime;
	time_t modtime;
}

如果times是空指针,则设置为当前时间
如果是非空指针,只有当进程有效id等于文件所有者用户id、或者超级用户进程时,才能修改成功


目录
创建目录
#include <sys/stat.h>
int mkdir(path, mode)
ret: 成功返回0,失败返回-1
至少要对目录设置一个执行权限

删除目录
#include <unistd.h>
int rmdir(path)
ret: 成功返回0,失败返回-1
调用此函数时,目录的连接数0,如果有进程正在访问该目录,也不会失败,等到进程退出时才真正删除目录。
但是调用后,其他进程不能再访问该目录

读取目录
#include <dirent.h>
DIR *opendir(path)
struct dirent *readdir(DIR *dp)
void rewinddir(DIR *dp)
int closedir(DIR *dp)
void seekdir(DIR *dp, long loc)

struct dirent {
	ino_t d_ino; \\i节点号
	char d_name[NAME_MAX +1]; \\max通常为255,文件名以null结尾
}

例题:递归遍历目录文件

改变工作目录
#include <unistd.h>
int chdir(path)
int fchdir(fd)
ret: 成功返回 0, 失败返回-1
当前工作目录是一个进程的一个属性,登录用户目录是登录名的一个属性

获取当前工作目录
#include <unistd.h>
char *getcwd(buf, size)
ret:成功返回buf,失败返回NULL

关于fchdir,平常我们可以,使用open打开目录,获得一个描述符,可以使用fchdir


特殊文件
设备号的数据类型是dev_t
主设备号和设备驱动程序相关,
次设备号标识特定的子设备。
通常有两个宏能够访问major(),minor()。定义在头文件 <sys/sysmacros.h> 中,而该文件被<sys/type.h>包含
每个文件都有st_dev值,表示文件系统的设备号,只有特殊文件和块特殊文件才有st_rdev值

shell 正则 tty[01] 表示 tty0 tty1
通常文件都是磁盘的主从设备号,st_dev, 设备文件使用的是设备自己的设备号st_rdev
使用mount命令可以查看 主从设备号


习题
core文件产生时 权限有可能不受mask值的影响 和系统有关



posted @   木瓜粉  阅读(102)  评论(0编辑  收藏  举报
(评论功能已被禁用)
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
点击右上角即可分享
微信分享提示