[自制操作系统] JOS文件系统详解&支持工作路径&MSH

来源:互联网 发布:日语网络 编辑:程序博客网 时间:2024/05/16 15:19

这里写图片描述
本文分为两部分:
第一部分将详细分析JOS的文件系统及文件描述符的实现方法。
第二部分将实现工作路径,提供新的系统调用,完善用户空间工具。
本文中支持的新特性:

  • 支持进程工作目录 提供getcwdchdir

  • 新的syscall

    • SYS_env_set_workpath 修改工作路径
  • 新的用户程序
    • ls 功能完善
    • pwd 输出当前工作目录
    • cat 接入工作目录
    • touch 由于文件属性没啥可改的,用于创建文件
    • mkdir 创建目录文件
    • msh 更高级的shell 还未完全完工 支持cd 支持默认二进制路径为 bin
  • 调整目标磁盘生成工具

Github:https://github.com/He11oLiu/MOS

JOS文件系统详解

文件系统总结

      Regular env           FS env   +---------------+   +---------------+   |      read     |   |   file_read   |   |   (lib/fd.c)  |   |   (fs/fs.c)   |...|.......|.......|...|.......^.......|...............   |       v       |   |       |       | RPC mechanism   |  devfile_read |   |  serve_read   |   |  (lib/file.c) |   |  (fs/serv.c)  |   |       |       |   |       ^       |   |       v       |   |       |       |   |     fsipc     |   |     serve     |   |  (lib/file.c) |   |  (fs/serv.c)  |   |       |       |   |       ^       |   |       v       |   |       |       |   |   ipc_send    |   |   ipc_recv    |   |       |       |   |       ^       |   +-------|-------+   +-------|-------+           |                   |           +-------------------+
  • 底层与磁盘有关的丢给ide_xx来实现,因为要用到IO中断,要给对应的权限
  • 通过bc_pgfault来实现缺页自己映射,利用flush_block来回写磁盘
  • 然后通过分block利用block cache实现对于磁盘的数据读入内存或者写回磁盘
  • 再上面一层的file_readfile_write,均是对于blk的操作。
  • 再上面就是文件系统服务器,通过调用file_read实现功能了。
  • 客户端通过对需求打包,发送IPC给文件系统服务器,即可实现读/写文件的功能。

文件系统&文件描述符 Overview

JOS文件系统是直接映射到内存空间DISKMAPDISKMAP + DISKSIZE这块空间。故其支持的文件系统最大为3GB.

IDE ide.c

文件系统底层PIO驱动放在ide.c中。注意在IDE中,是以硬件的角度来看待硬盘,其基本单位是sector,不是block

  • bool ide_probe_disk1(void) 用于检测disk1是否存在。
  • voidide_set_disk(int diskno) 用于设置目标磁盘。
  • ide_read ide_write 用于磁盘读写。

block cache bc.c

文件系统在内存中的映射是基于block cache的。以一个block为单位在内存中为其分配单元。注意在bc中,是以操作系统的角度来看待硬盘,其基本单位是block,不是sector

  • void *diskaddr(uint32_t blockno) 用于查找blockno在地址空间中的地址。

  • blockno = ((uint32_t)addr - DISKMAP) / BLKSIZE 用于查找addr对应文件系统中的blockno

  • static void bc_pgfault(struct UTrapframe *utf) 用于处理读取不在内存中而出现page fault的情况。这时需要从file system通过PIO读取到block cache(也就是内存中新分配的一页)中,并做好映射。

  • void flush_block(void *addr) 用于写回硬盘,写回时清理PTE_D标记。

file system fs.c

文件系统是基于刚才的block cache和底层ide驱动的。

bitmap 相关

bitmap每一位代表着一个block的状态,用位操作检查/设置block状态即可。

  • bool block_is_free(uint32_t blockno) 用于check给定的blockno是否是空闲的。

  • void free_block(uint32_t blockno) 设置对应位为0

  • int alloc_block(void) 设置对应位为1

文件系统操作

  • void fs_init(void) 初始化文件系统。检测disk1是否存在,检测super blockbitmap block

  • static int file_block_walk(struct File *f, uint32_t filebno, uint32_t **ppdiskbno, bool alloc) 用于找到文件ffilenoblockblocknoalloc用于控制当f_indirect不存在的时候,是否需要新申请一个block

  • int file_get_block(struct File *f, uint32_t filebno, char **blk) 用于找到文件ffilenoblock的地址。

  • static int dir_lookup(struct File *dir, const char *name, struct File **file) 用于在dir下查找name这个文件。其遍历读取dir这个文件,并逐个判断其目录下每一个文件的名字是否相等。

  • static int dir_alloc_file(struct File *dir, struct File **file)dir下新申请一个file。同样也是遍历所有的dir下的文件。找到第一个名字为空的文件,并把新的文件存在这里。

  • static int walk_path(const char *path, struct File **pdir, struct File **pf, char *lastelem) 用于从根目录获取path的文件,文件放在pf中,路径放在pdir中。如果找到了路径没有找到文件。最后的路径名放在lastelem中,最后的路径放在pdir中。

文件操作

  • int file_create(const char *path, struct File **pf) 用于创建文件。

  • int file_open(const char *path, struct File **pf) 打开文件。

  • ssize_t file_read(struct File *f, void *buf, size_t count, off_t offset)foffset读取countbytes的数据放入buf中。

  • int file_write(struct File *f, const void *buf, size_t count, off_t offset) 与上面的类似。

  • static int file_free_block(struct File *f, uint32_t filebno) 删除文件中的filebno

  • static void file_truncate_blocks(struct File *f, off_t newsize) 缩短文件大小。

  • int file_set_size(struct File *f, off_t newsize) 修改文件大小。

  • void file_flush(struct File *f) 将文件写回硬盘

  • void fs_sync(void) 将所有的文件写回硬盘

文件系统服务器 serv.c

  • 服务器主要逻辑umain: 初始化文件系统,初始化服务器,开始接收请求。

  • 服务器具体函数见上面实现。

  • int openfile_alloc(struct OpenFile **o)用于服务器分配一个openfile结构体

文件描述符 fd.c

  • struct fd 结构体

    struct Fd {int fd_dev_id;off_t fd_offset;int fd_omode;union {    // File server files    struct FdFile fd_file;};};

    其中fd_file用于发送的时候传入服务器对应的fileid

    包括了fd_id 文件读取的offset,读取模式以及FdFile

  • int fd2num(struct Fd *fd)fd获取其编号

  • char* fd2data(struct Fd *fd)fd获取文件内容

  • int fd_alloc(struct Fd **fd_store) 查找到第一个空闲的fd,并分配出去。

  • int fd_lookup(int fdnum, struct Fd **fd_store) 为查找fdnum的fd,并放在fd_store中。

  • int fd_close(struct Fd *fd, bool must_exist) 用于关闭并free一个fd

  • int dev_lookup(int dev_id, struct Dev **dev) 获取不同的Device

  • int close(int fdnum) 关闭fd

  • void close_all(void) 关闭全部

  • int dup(int oldfdnum, int newfdnum) dup不是简单的复制,而是要将两个fd的内容完全同步,其是通过虚拟内存映射做到的。

  • read(int fdnum, void *buf, size_t n) 后面的与这个类似

    • 获取fdfd_dev_id并根据其获取dev
    • 调用dev对应的function
  • int seek(int fdnum, off_t offset) 用于设置fdoffset

  • int fstat(int fdnum, struct Stat *stat) 获取文件状态。

    struct Stat{char st_name[MAXNAMELEN];off_t st_size;int st_isdir;struct Dev *st_dev;};
  • int stat(const char *path, struct Stat *stat) 获取路径状态。

具体关于文件描述符的设计见下图。

这里写图片描述

下面就来详细看现有的三个device

文件系统读写 file.c

  • 之前已经分析过devfile_xx的函数

  • static int fsipc(unsigned type, void *dstva)用于给文件系统服务器发送IPC

  • 这里是实例化了一个用于文件读取的dev

    struct Dev devfile ={.dev_id = 'f',.dev_name = "file",.dev_read = devfile_read,.dev_close = devfile_flush,.dev_stat = devfile_stat,.dev_write = devfile_write,.dev_trunc = devfile_trunc};

管道 pipe.c

关于pipe

管道是一种把两个进程之间的标准输入和标准输出连接起来的机制,从而提供一种让多个进程间通信的方法,当进程创建管道时,每次都需要提供两个文件描述符来操作管道。其中一个对管道进行写操作,另一个对管道进行读操作。对管道的读写与一般的IO系统函数一致,使用write()函数写入数据,使用read()读出数据。

同刚才的file的操作类似,这里是对于pipe的操作。

struct Dev devpipe =    {        .dev_id = 'p',        .dev_name = "pipe",        .dev_read = devpipe_read,        .dev_write = devpipe_write,        .dev_close = devpipe_close,        .dev_stat = devpipe_stat,};

pipe 的结构体如下

struct Pipe{    off_t p_rpos;              // read position    off_t p_wpos;              // write position    uint8_t p_buf[PIPEBUFSIZ]; // data buffer};
  • int pipe(int pfd[2]) 申请两个新的fd,映射到同一个虚拟地址上,一边Read_only 一边Write_only即可。

  • static ssize_t devpipe_read(struct Fd *fd, void *vbuf, size_t n) 其从fd对应的data获取pipep = (struct Pipe *)fd2data(fd);然后从pipe->buf中读取内容。维护p_rpos

  • static ssize_t devpipe_write(struct Fd *fd, const void *vbuf, size_t n) 其从fd对应的data获取pipep = (struct Pipe *)fd2data(fd);然后向pipe->buf中写入内容。维护p_wpos

屏幕输入输出 console.c

struct Dev devcons =    {        .dev_id = 'c',        .dev_name = "cons",        .dev_read = devcons_read,        .dev_write = devcons_write,        .dev_close = devcons_close,        .dev_stat = devcons_stat};

实现直接调用syscall即可,和之前实现的putchar类似。

支持工作路径以及更完整的工具

本本分将主要关注用户空间程序,并补全内核功能(支持工作路径)。
本部分主要包括以下用户应用程序:

ls      list directory contentspwd     return working directory namemkdir   make directoriestouch   change file access and modification times(we only support create file)cat     concatenate and print filesshell

list directory contents

读文件

由于写到这里第一次在用户空间读取文件,简要记录一下读取文件的过程。

首先是文件结构,在lab5中设计文件系统的时候设计的,保存在struct File中,用户可以根据此结构体偏移来找具体的信息。

再是fsformat中提供的与文件系统相关的接口。这里用到了readn。其只是对于read的一层包装。

功能实现

回到ls本身的逻辑上。ls 主要是读取path文件,并将其下所有的文件名全部打印出来。

return working directory name

由于之前写的JOS中每个进程没有写工作目录。这里再加上工作目录。

struct env中加入工作目录,添加后env如下:

struct Env {    struct Trapframe env_tf;    // Saved registers    struct Env *env_link;       // Next free Env    envid_t env_id;         // Unique environment identifier    envid_t env_parent_id;      // env_id of this env's parent    enum EnvType env_type;      // Indicates special system environments    unsigned env_status;        // Status of the environment    uint32_t env_runs;      // Number of times environment has run    int env_cpunum;         // The CPU that the env is running on    // Address space    pde_t *env_pgdir;       // Kernel virtual address of page dir    // Exception handling    void *env_pgfault_upcall;   // Page fault upcall entry point    // IPC    bool env_ipc_recving;       // Env is blocked receiving    void *env_ipc_dstva;        // VA at which to map received page    uint32_t env_ipc_value;     // Data value sent to us    envid_t env_ipc_from;       // envid of the sender    int env_ipc_perm;       // Perm of page mapping received    // work path    char workpath[MAXPATH];};

由于env对于用户是不可以写的,所以要添加新的syscall,进入内核态改。

enum {    SYS_cputs = 0,    SYS_cgetc,    SYS_getenvid,    SYS_env_destroy,    SYS_page_alloc,    SYS_page_map,    SYS_page_unmap,    SYS_exofork,    SYS_env_set_status,    SYS_env_set_trapframe,    SYS_env_set_pgfault_upcall,    SYS_yield,    SYS_ipc_try_send,    SYS_ipc_recv,    SYS_getcwd,    SYS_chdir,    NSYSCALLS};

由于JOS中用户其实可以读env中的内容,所以getcwd就不陷入内核态了,直接读取就好。

新建dir.c用于存放与目录有关的函数,实现getcwd

char *getcwd(char *buffer, int maxlen){    if(!buffer || maxlen < 0)        return NULL;    return strncpy((char *)buffer,(const char*)thisenv->workpath,maxlen);}

而对于修改目录,必须要陷入内核态了,新加syscall

int sys_chdir(const char *path){    return syscall(SYS_chdir, 0, (uint32_t)path, 0, 0, 0, 0);}

刚才的dir.c中加入用户接口

// change work path// Return 0 on success, // Return < 0 on error. Errors are://  -E_INVAL *path not exist or not a pathint chdir(const char *path){    int r;    struct Stat st;    if ((r = stat(path, &st)) < 0)        return r;    if(!st.st_isdir)        return -E_INVAL;    return sys_chdir(path);}

然后去内核添加功能

// change work path// return 0 on success.static intsys_chdir(const char * path){    strcpy((char *)curenv->workpath,path);    return 0;}

最后实现pwd

#include <inc/lib.h>void umain(int argc, char **argv){    char path[200];    if(argc > 1)        printf("%s : too many arguments\n",argv[0]);    else        printf("%s\n",getcwd(path,200));}

make directories

发现JOS给我们预留了标识位O_MKDIR,由于与普通的file_create不一样,当有同名的文件存在的时候,但其不是目录的情况下,我们仍然可以创建,所以新写了函数

int dir_create(const char *path, struct File **pf){    char name[MAXNAMELEN];    int r;    struct File *dir, *f;    if (((r = walk_path(path, &dir, &f, name)) == 0) &&        f->f_type == FTYPE_DIR)        return -E_FILE_EXISTS;    if (r != -E_NOT_FOUND || dir == 0)        return r;    if ((r = dir_alloc_file(dir, &f)) < 0)        return r;    // fill struct file    strcpy(f->f_name, name);    f->f_type = FTYPE_DIR;    *pf = f;    file_flush(dir);    return 0;}

然后在serve_open下建立新的分支

    // create dir    else if (req->req_omode & O_MKDIR)    {        if ((r = dir_create(path, &f)) < 0)        {            if (!(req->req_omode & O_EXCL) && r == -E_FILE_EXISTS)                goto try_open;            if (debug)                cprintf("file_create failed: %e", r);            return r;        }    }

dir.c下提供mkdir函数

// make directory// Return 0 on success,// Return < 0 on error. Errors are://  -E_FILE_EXISTS directory already existint mkdir(const char *dirname){    char cur_path[MAXPATH];    int r;    getcwd(cur_path, MAXPATH);    strcat(cur_path, dirname);    if ((r = open(cur_path, O_MKDIR)) < 0)        return r;    close(r);    return 0;}

最后提供用户程序

#include <inc/lib.h>#define MAXPATH 200void umain(int argc, char **argv){    int r;    if (argc != 2)    {        printf("usage: mkdir directory\n");        return;    }    if((r = mkdir(argv[1])) < 0)        printf("%s error : %e\n",argv[0],r);}

Create file

创建文件直接利用open中的O_CREAT选项即可。

#include <inc/lib.h>#define MAXPATH 200void umain(int argc, char **argv){    int r;    char *filename;    char pathbuf[MAXPATH];    if (argc != 2)    {        printf("usage: touch filename\n");        return;    }    filename = argv[1];    if (*filename != '/')        getcwd(pathbuf, MAXPATH);    strcat(pathbuf, filename);    if ((r = open(pathbuf, O_CREAT)) < 0)        printf("%s error : %e\n", argv[0], r);    close(r);}

cat

这个只需要修改好支持工作路径即可

#include <inc/lib.h>char buf[8192];void cat(int f, char *s){    long n;    int r;    while ((n = read(f, buf, (long)sizeof(buf))) > 0)        if ((r = write(1, buf, n)) != n)            panic("write error copying %s: %e", s, r);    if (n < 0)        panic("error reading %s: %e", s, n);}void umain(int argc, char **argv){    int f, i;    char *filename;    char pathbuf[MAXPATH];    binaryname = "cat";    if (argc == 1)        cat(0, "<stdin>");    else        for (i = 1; i < argc; i++)        {            filename = argv[1];            if (*filename != '/')                getcwd(pathbuf, MAXPATH);            strcat(pathbuf, filename);            f = open(pathbuf, O_RDONLY);            if (f < 0)                printf("can't open %s: %e\n", argv[i], f);            else            {                cat(f, argv[i]);                close(f);            }        }}

SHELL

Shell的时候发现问题:之前没有解决fork以及spawn时候的子进程的工作路径的问题。所有再一次修改了系统调用,将系统调用sys_chdir修改为能够设定指定进程的工作目录的系统调用。

int sys_env_set_workpath(envid_t envid, const char *path);

修改对应的内核处理:

// change work path// return 0 on success.static intsys_env_set_workpath(envid_t envid, const char *path){    struct Env *e;    int ret = envid2env(envid, &e, 1);    if (ret != 0)        return ret;    strcpy((char *)e->workpath, path);    return 0;}

这样就会fork出来的子进程继承父亲的工作路径。

shell中加入built-in功能,为未来扩展shell功能提供基础

int builtin_cmd(char *cmdline){    int ret;    int i;    char cmd[20];    for (i = 0; cmdline[i] != ' ' && cmdline[i] != '\0'; i++)        cmd[i] = cmdline[i];    cmd[i] = '\0';    if (!strcmp(cmd, "quit") || !strcmp(cmd, "exit"))        exit();    if (!strcmp(cmd, "cd"))    {        ret = do_cd(cmdline);        return 1;    }    return 0;}int do_cd(char *cmdline){    char pathbuf[BUFSIZ];    int r;    pathbuf[0] = '\0';    cmdline += 2;    while (*cmdline == ' ')        cmdline++;    if (*cmdline == '\0')        return 0;    if (*cmdline != '/')    {        getcwd(pathbuf, BUFSIZ);    }    strcat(pathbuf, cmdline);    if ((r = chdir(pathbuf)) < 0)        printf("cd error : %e\n", r);    return 0;}

修改<> 支持当前工作路径

case '<': // Input redirection            // Grab the filename from the argument list            if (gettoken(0, &t) != 'w')            {                cprintf("syntax error: < not followed by word\n");                exit();            }            // Open 't' for reading as file descriptor 0            // (which environments use as standard input).            // We can't open a file onto a particular descriptor,            // so open the file as 'fd',            // then check whether 'fd' is 0.            // If not, dup 'fd' onto file descriptor 0,            // then close the original 'fd'.            if (t[0] != '/')                getcwd(argv0buf, MAXPATH);            strcat(argv0buf, t);            if ((fd = open(argv0buf, O_RDONLY)) < 0)            {                cprintf("Error open %s fail: %e", argv0buf, fd);                exit();            }            if (fd != 0)            {                dup(fd, 0);                close(fd);            }            break;        case '>': // Output redirection            // Grab the filename from the argument list            if (gettoken(0, &t) != 'w')            {                cprintf("syntax error: > not followed by word\n");                exit();            }            if (t[0] != '/')                getcwd(argv0buf, MAXPATH);            strcat(argv0buf, t);            if ((fd = open(argv0buf, O_WRONLY | O_CREAT | O_TRUNC)) < 0)            {                cprintf("open %s for write: %e", argv0buf, fd);                exit();            }            if (fd != 1)            {                dup(fd, 1);                close(fd);            }            break;

创建硬盘镜像

  • 利用mmap映射到内存,对内存读写。

    if ((diskmap = mmap(NULL, nblocks * BLKSIZE, PROT_READ | PROT_WRITE,                    MAP_SHARED, diskfd, 0)) == MAP_FAILED)    panic("mmap %s: %s", name, strerror(errno));

    diskmap开始,大小为nblocks * BLKSIZE

  • alloc用于分配空间,移动diskpos

void *alloc(uint32_t bytes){    void *start = diskpos;    diskpos += ROUNDUP(bytes, BLKSIZE);    if (blockof(diskpos) >= nblocks)        panic("out of disk blocks");    return start;}
  • 块 123 在初始化的时候分配

    alloc(BLKSIZE);super = alloc(BLKSIZE);super->s_magic = FS_MAGIC;super->s_nblocks = nblocks;super->s_root.f_type = FTYPE_DIR;strcpy(super->s_root.f_name, "/");nbitblocks = (nblocks + BLKBITSIZE - 1) / BLKBITSIZE;bitmap = alloc(nbitblocks * BLKSIZE);memset(bitmap, 0xFF, nbitblocks * BLKSIZE);
  • writefile用于申请空间,写入磁盘

    void writefile(struct Dir *dir, const char *name){int r, fd;struct File *f;struct stat st;const char *last;char *start;if ((fd = open(name, O_RDONLY)) < 0)    panic("open %s: %s", name, strerror(errno));if ((r = fstat(fd, &st)) < 0)    panic("stat %s: %s", name, strerror(errno));if (!S_ISREG(st.st_mode))    panic("%s is not a regular file", name);if (st.st_size >= MAXFILESIZE)    panic("%s too large", name);last = strrchr(name, '/');if (last)    last++;else    last = name;// 获取目录中的一个空位f = diradd(dir, FTYPE_REG, last);// 获取文件存放地址,分配空间start = alloc(st.st_size);// 将文件读如到磁盘中刚刚分配的地址readn(fd, start, st.st_size);// 完成文件信息finishfile(f, blockof(start), st.st_size);close(fd);}void finishfile(struct File *f, uint32_t start, uint32_t len){int i;// 这个是刚才目录下传过来的地址,直接修改目录下的相应项f->f_size = len;len = ROUNDUP(len, BLKSIZE);for (i = 0; i < len / BLKSIZE && i < NDIRECT; ++i)    f->f_direct[i] = start + i;if (i == NDIRECT){    uint32_t *ind = alloc(BLKSIZE);    f->f_indirect = blockof(ind);    for (; i < len / BLKSIZE; ++i)        ind[i - NDIRECT] = start + i;}}
  • 目录结构体与何时将目录写入

    void startdir(struct File *f, struct Dir *dout){dout->f = f;dout->ents = malloc(MAX_DIR_ENTS * sizeof *dout->ents);dout->n = 0;}void finishdir(struct Dir *d){// 目录文件的大小int size = d->n * sizeof(struct File);// 申请目录文件存放空间struct File *start = alloc(size);    // 将目录的文件内容放进去memmove(start, d->ents, size);  // 补全目录在磁盘当中的信息finishfile(d->f, blockof(start), ROUNDUP(size, BLKSIZE));free(d->ents);d->ents = NULL;}
  • 添加bin路径,并在shell中类似path环境变量默认读取bin下的可执行文件

    opendisk(argv[1]);startdir(&super->s_root, &root);f = diradd(&root, FTYPE_DIR, "bin");startdir(f,&bin);for (i = 3; i < argc; i++)    writefile(&bin, argv[i]);finishdir(&bin);finishdir(&root);finishdisk();

获取时间

又新增一个syscall,这里不再累述,利用mc146818_read获取cmos时间即可。

int gettime(struct tm *tm){    unsigned datas, datam, datah;    int i;    tm->tm_sec = BCD_TO_BIN(mc146818_read(0));    tm->tm_min = BCD_TO_BIN(mc146818_read(2));    tm->tm_hour = BCD_TO_BIN(mc146818_read(4)) + TIMEZONE;    tm->tm_wday = BCD_TO_BIN(mc146818_read(6));    tm->tm_mday = BCD_TO_BIN(mc146818_read(7));    tm->tm_mon = BCD_TO_BIN(mc146818_read(8));    tm->tm_year = BCD_TO_BIN(mc146818_read(9));    return 0;}

实机运行输出

check_page_free_list() succeeded!check_page_alloc() succeeded!check_page() succeeded!check_kern_pgdir() succeeded!check_page_free_list() succeeded!check_page_installed_pgdir() succeeded!====Graph mode on====   scrnx = 1024   scrny = 768MMIO VRAM = 0xef803000=====================SMP: CPU 0 found 1 CPU(s)enabled interrupts: 1 2 4FS is runningFS can do I/ODevice 1 presence: 1block cache is goodsuperblock is goodbitmap is good# msh in / [12: 4:28]$ cd documents# msh in /documents/ [12: 4:35]$ echo hello liu > hello# msh in /documents/ [12: 4:45]$ cat hellohello liu# msh in /documents/ [12: 4:49]$ cd /bin# msh in /bin/ [12: 4:54]$ ls -l -F-          37 newmotd-          92 motd-         447 lorem-         132 script-        2916 testshell.key-         113 testshell.sh-       20308 cat-       20076 echo-       20508 ls-       20332 lsfd-       25060 sh-       20076 hello-       20276 pwd-       20276 mkdir-       20280 touch-       29208 msh# msh in /bin/ [12: 4:57]$ 
原创粉丝点击