编写一个UNIX文件系统

编写一个UNIX文件系统

  1410人阅读  评论(1)  收藏  举报
近日有人求助,要写一个UNIX文件系统作为暑假作业。这种事情基本是学操作系统的必须要做的或者是做过的,毕竟文件系统是操作系统课程的一个重要组成部分。要实现这个UNIX文件系统,很多人就扎进了UNIX V6的的系统源码,以及《莱昂氏UNIX源代码分析》和《返璞归真:UNIX技术内幕》这两本书,很多人出来了,很多人在里面迷失了...最终忘了自己只是要实现一个UNIX文件系统而已。
        为何会迷失,因为代码不是自己写的,而且年代久远,编程理念不同了,作者为何那样写不一定就能理解,实际上对于任何别人写的代码,总是会有一些不易理解的地方,当然,如果作者水平超级高,那么代码也就相对容易理解。因此,写代码永远比读代码要容易!既然是要写一个文件系统,为何要用现成的UNIX V6代码呢?如果理解了UNIX文件的布局和结构,自己从零开始不参考任何现有的代码做一个也不是什么难事,最根本的是UNIX文件系统本身,至于说代码,仅仅是一个实现与用户操作的一个接口而已。如果代码是自己一点一点写的,那么你肯定能彻底明白每一行的每一个语句的精确含义,至于为何这么写,你当然及其明了!
        本文留下我仓促间几个小时写的一个类UNIX文件系统的代码,不是让别人看的,是为了自己留档,因为本文已经说了,看别人的代吗只能学习经验,不能理解本质,更何况,你看的还得是超级一流的代码,而我写的,则是超级垃圾的代码。我只想说,理解问题的本质要比代码重要得多,代码,码,并不是很多人想象中的那般重要!本文的实现基于Linux系统,即在Linux系统上编写一个用户态程序,实现UNIX文件的IO接口以及操作。
        我一向坚持的原则,那就是任何东西的根本性的,本质上的原理以及背后的思想都是及其简单的,所谓的复杂性都是优化与策略化的扩展带来的,正如TCP一样,UNIX的文件系统也不例外!我们必须知道,什么是最根本的,什么是次要的。对于UNIX文件系统,最根本的就是其布局以及其系统调用接口,一个处在最低层,一个在最上层开放给用户,如下所示:
系统调用接口:open,write,read,close...
文件系统布局:引导快,超级块,inode区表,数据块区

所有的在二者中间的部分都是次要的,也就是说那些东西不要也行,比如高速缓冲,高速缓存,VFS层,块层...因此在我的实现代码中,并没有这些东西,我所做到的,仅仅是UNIX文件系统所要求必须做到的最小集,那就是:
面对一个按照UNIX文件系统标准布局的“块设备”,可以使用open,read,write等接口进行IO操作。
在实现中,我用一个标准的Linux大文件来模拟磁盘块,这样块的操作基本都映射到了Linux标准的write,read等系统调用了。
首先定义必要的结构体:
[plain]  view plain copy
  1. //超级块结构  
  2. struct filesys {  
  3.         unsigned int s_size;        //总大小                                             
  4.         unsigned int s_itsize;        //inode表大小                                           
  5.         unsigned int s_freeinodesize;    //空闲i节点的数量                             
  6.         unsigned int s_nextfreeinode;    //下一个空闲i节点  
  7.         unsigned int s_freeinode[NUM];     //空闲i节点数组  
  8.         unsigned int s_freeblocksize;    //空闲块的数量            
  9.         unsigned int s_nextfreeblock;    //下一个空闲块  
  10.         unsigned int s_freeblock[NUM];    //空闲块数组  
  11.         unsigned int s_pad[];        //填充到512字节     
  12. };  
  13. //磁盘inode结构  
  14. struct finode {  
  15.         int fi_mode;            //类型:文件/目录  
  16.         int fi_uid_unused;        //uid,由于和进程无关联,仅仅是模拟一个FS,未使用,下同  
  17.         int fi_gid_unused;  
  18.         int fi_nlink;            //链接数,当链接数为0,意味着被删除  
  19.         long int fi_size;        //文件大小  
  20.         long int fi_addr[BNUM];        //文件块一级指针,并未实现多级指针  
  21.         time_t  fi_atime_unused;    //未实现  
  22.         time_t  fi_mtime_unused;  
  23. };  
  24. //内存inode结构  
  25. struct inode {  
  26.         struct finode   i_finode;  
  27.         struct inode    *i_parent;    //所属的目录i节点  
  28.         int     i_ino;            //i节点号  
  29.         int     i_users;        //引用计数  
  30. };  
  31. //目录项结构(非Linux内核的目录项)  
  32. struct direct  
  33. {  
  34.         char d_name[MAXLEN];        //文件或者目录的名字  
  35.         unsigned short d_ino;        //文件或者目录的i节点号  
  36. };  
  37. //目录结构  
  38. struct dir  
  39. {  
  40.         struct direct direct[DIRNUM];    //包含的目录项数组  
  41.         unsigned short size;        //包含的目录项大小       
  42. };  
  43. //抽象的文件结构  
  44. struct file {  
  45.         struct inode *f_inode;        //文件的i节点  
  46.         int f_curpos;            //文件的当前读写指针  
  47. };  

之所以叫做类UNIX文件系统,是因为我并没有去精确确认当时的UNIX文件系统的超级块以及inode表的结构,只是大致的模仿其布局,比如超级块中字段,以及字段的顺序可能和标准的UNIX文件系统并不完全一致。但是不管怎么说,当时的UNIX文件系统基本就是这个一个样子。另外,可以看到file结构体内容及其少,因为本质上,我只是想表示“一个inode节点相对于一个读写者来说,就是一个file”,仅此而已。接下来就是具体的实现了,我的方式是自下而上的,这样做的好处在于便于今后的扩展。那么首先要完成的就是i节点的分配和释放了,我的实现中,是将文件i节点映射到了内存i节点,这样或许违背了我的初衷,我不是说过不要那么多“额外”的东西来扰乱视听的吗?是的,然而比起那些所谓的额外的优化,我更不喜欢频繁的调用read和write。反正,只要自己能控制住局面即可。
        在实现中,还有一个大事就是内存的分配与释放,这些也不是本质的,记住,要实现的仅仅是一个UNIX文件系统,其它的能绕开则绕开!显然malloc,free等也是我们要绕开的,于是我基本都使用预分配空间的东西-全局数组。以下是全局变量:
[plain]  view plain copy
  1. //内存i节点数组,NUM为该文件系统容纳的文件数  
  2. struct inode g_usedinode[NUM];  
  3. //ROOT的内存i节点  
  4. struct inode *g_root;  
  5. //已经打开文件的数组  
  6. struct file* g_opened[OPENNUM];  
  7. //超级块  
  8. struct filesys *g_super;  
  9. //模拟二级文件系统的Linux大文件的文件描述符  
  10. int g_fake_disk = -1;  

在给出实现代码之前,要说明的是,在删除文件的时候,我并没有实现文件块区以及i节点的清除操作,众所周知,那样很耗时,和很多实现一样,我只是记录了一些信息,表示这个文件块或者inode字段是可以随时覆盖的。
[plain]  view plain copy
  1. //同步i节点,将其写入“磁盘”  
  2. void syncinode(struct inode *inode)  
  3. {  
  4.         int ino = -1, ipos = -1;  
  5.         ino = inode->i_ino;  
  6.     //ipos为inode节点表在文件系统块中的偏移  
  7.         ipos = IBPOS + ino*sizeof(struct finode);  
  8.     //从模拟块的指定偏移位置读取inode信息  
  9.         lseek(g_fake_disk, ipos, SEEK_SET);  
  10.         write(g_fake_disk, (void *)&inode->i_finode, sizeof(struct finode));  
  11. }  
  12. //同步超级块信息  
  13. int syncsuper(struct filesys *super)  
  14. {  
  15.         int pos = -1, size = -1;  
  16.         struct dir dir = {0};  
  17.         pos = BOOTBSIZE;  
  18.         size = SUPERBSIZE;  
  19.         lseek(g_fake_disk, pos, SEEK_SET);  
  20.         write(g_fake_disk, (void *)super, size);  
  21.         syncinode(g_root);  
  22.         breadwrite(g_root->i_finode.fi_addr[0], (char *)&dir, sizeof(struct dir), 0, 1);  
  23.         breadwrite(g_root->i_finode.fi_addr[0], (char *)&dir, sizeof(struct dir), 0, 0);  
  24. }  
  25. //关键的将路径名转换为i节点的函数,暂不支持相对路径  
  26. struct inode *namei(char *filepath, char flag, int *match, char *ret_name)  
  27. {  
  28.         int in = 0;  
  29.         int repeat = 0;  
  30.         char *name = NULL;  
  31.         char *path = calloc(1, MAXLEN*10);  
  32.         char *back = path;  
  33.   
  34.         struct inode *root = iget(0);  
  35.         struct inode *parent = root;  
  36.         struct dir dir = {0};  
  37.         strncpy(path, filepath, MAXLEN*10);  
  38.         if (path[0] != '/')  
  39.                 return NULL;  
  40.         breadwrite(root->i_finode.fi_addr[0], &dir, sizeof(struct dir), 0, 1);  
  41.         while((name=strtok(path, "/")) != NULL) {  
  42.                 int i = 0;  
  43.                 repeat = 0;  
  44.                 *match = 0;  
  45.                 path = NULL;  
  46.                 if (ret_name) {  
  47.                         strcpy(ret_name, name);  
  48.                 }  
  49.                 for (; i<dir.size; i++) {  
  50.                         if (!strncmp(name, dir.direct[i].d_name, strlen(name))) {  
  51.                                 parent = root;  
  52.                                 iput(root);  
  53.                                 root = iget(dir.direct[i].d_ino);  
  54.                                 root->i_parent = parent;  
  55.                                 *match = 1;  
  56.                                 if (root->i_finode.fi_mode == MODE_DIR) {  
  57.                                         memset(&dir, 0, sizeof(struct dir));  
  58.                                         breadwrite(root->i_finode.fi_addr[0], &dir, sizeof(struct dir), 0, 1);  
  59.                                 } else {  
  60.                                         free(back);  
  61.                                         return root;  
  62.                                 }  
  63.                                 repeat = 1;  
  64.                         }  
  65.                 }  
  66.                 if (repeat == 0) {  
  67.                         break;  
  68.                 }  
  69.         }  
  70.         if (*match != 1) {  
  71.                 *match = 0;  
  72.         }  
  73.         if (*match == 0) {  
  74.                 if (ret_name) {  
  75.                         strcpy(ret_name, name);  
  76.                 }  
  77.         }  
  78.         free(back);  
  79.         return root;  
  80. }  
  81. //通过i节点号获取内存i节点的函数  
  82. struct inode *iget(int ino)  
  83. {  
  84.         int ibpos = 0;  
  85.         int ipos = 0;  
  86.         int ret = 0;  
  87.     //倾向于直接从内存i节点获取  
  88.         if (g_usedinode[ino].i_users) {  
  89.                 g_usedinode[ino].i_users ++;  
  90.                 return &g_usedinode[ino];  
  91.         }  
  92.         if (g_fake_disk < 0) {  
  93.                 return NULL;  
  94.         }  
  95.     //实在不行则从模拟磁盘块读入  
  96.         ipos = IBPOS + ino*sizeof(struct finode);  
  97.         lseek(g_fake_disk, ipos, SEEK_SET);  
  98.         ret = read(g_fake_disk, &g_usedinode[ino], sizeof(struct finode));  
  99.         if (ret == -1) {  
  100.                 return NULL;  
  101.         }  
  102.         if (g_super->s_freeinode[ino] == 0) {  
  103.                 return NULL;  
  104.         }  
  105.     //如果是一个已经被删除的文件或者从未被分配过的i节点,则初始化其link值以及size值  
  106.         if (g_usedinode[ino].i_finode.fi_nlink == 0) {  
  107.                 g_usedinode[ino].i_finode.fi_nlink ++;  
  108.                 g_usedinode[ino].i_finode.fi_size = 0;  
  109.                 syncinode(&g_usedinode[ino]);  
  110.         }  
  111.         g_usedinode[ino].i_users ++;  
  112.         g_usedinode[ino].i_ino = ino;  
  113.         return &g_usedinode[ino];  
  114.   
  115. }  
  116. //释放一个占有的内存i节点  
  117. void iput(struct inode *ip)  
  118. {  
  119.         if (ip->i_users > 0)  
  120.                 ip->i_users --;  
  121. }  
  122. //分配一个未使用的i节点。注意,我并没有使用超级块的s_freeinodesize字段,  
  123. //因为还会有一个更好更快的分配算法  
  124. struct inode* ialloc()  
  125. {  
  126.         int ino = -1, nowpos = -1;  
  127.         ino = g_super->s_nextfreeinode;  
  128.         if (ino == -1) {  
  129.                 return NULL;  
  130.         }  
  131.         nowpos = ino + 1;  
  132.         g_super->s_nextfreeinode = -1;  
  133.     //寻找下一个空闲i节点,正如上述,这个算法并不好  
  134.         for (; nowpos < NUM; nowpos++) {  
  135.                 if (g_super->s_freeinode[nowpos] == 0) {  
  136.                         g_super->s_nextfreeinode = nowpos;  
  137.                         break;  
  138.                 }  
  139.         }  
  140.         g_super->s_freeinode[ino] = 1;  
  141.         return iget(ino);  
  142. }  
  143. //试图删除一个文件i节点  
  144. int itrunc(struct inode *ip)  
  145. {  
  146.         iput(ip);  
  147.         if (ip->i_users == 0 && g_super) {  
  148.                 syncinode(ip);  
  149.                 g_super->s_freeinode[ip->i_ino] = 0;  
  150.                 g_super->s_nextfreeinode = ip->i_ino;  
  151.                 return 0;  
  152.         }  
  153.         return ERR_BUSY;  
  154. }  
  155. //分配一个未使用的磁盘块  
  156. int balloc()  
  157. {  
  158.         int bno = -1, nowpos = -1;  
  159.         bno = g_super->s_nextfreeblock;  
  160.         if (bno == -1) {  
  161.                 return bno;  
  162.         }  
  163.         nowpos = bno + 1;  
  164.         g_super->s_nextfreeblock = -1;  
  165.         for (; nowpos < NUM; nowpos++) {  
  166.                 if (g_super->s_freeblock[nowpos] == 0) {  
  167.                         g_super->s_nextfreeblock = nowpos;  
  168.                         break;  
  169.                 }  
  170.         }  
  171.         g_super->s_freeblock[bno] = 1;  
  172.         return bno;  
  173. }  
  174. //读写操作  
  175. int breadwrite(int bno, char *buf, int size, int offset, int type)  
  176. {  
  177.         int pos = BOOTBSIZE+SUPERBSIZE+g_super->s_itsize + bno*BSIZE;  
  178.         int rs = -1;  
  179.         if (offset + size > BSIZE) {  
  180.                 return ERR_EXCEED;  
  181.         }  
  182.         lseek(g_fake_disk, pos + offset, SEEK_SET);  
  183.         rs = type ? read(g_fake_disk, buf, size):write(g_fake_disk, buf, size);  
  184.         return rs;  
  185. }  
  186. //IO读接口  
  187. int mfread(int fd, char *buf, int length)  
  188. {  
  189.         struct file *fs = g_opened[fd];  
  190.         struct inode *inode = fs->f_inode;  
  191.         int baddr = fs->f_curpos;  
  192.         int bondary = baddr%BSIZE;  
  193.         int max_block = (baddr+length)/BSIZE;  
  194.         int size = 0;  
  195.         int i = inode->i_finode.fi_addr[baddr/BSIZE+1];  
  196.         for (; i < max_block+1; i ++,bondary = size%BSIZE) {  
  197.                 size += breadwrite(inode->i_finode.fi_addr[i], buf+size, (length-size)%BSIZE, bondary, 1);  
  198.         }  
  199.         return size;  
  200. }  
  201. //IO写接口  
  202. int mfwrite(int fd, char *buf, int length)  
  203. {  
  204.         struct file *fs = g_opened[fd];  
  205.         struct inode *inode = fs->f_inode;  
  206.         int baddr = fs->f_curpos;  
  207.         int bondary = baddr%BSIZE;  
  208.         int max_block = (baddr+length)/BSIZE;  
  209.         int curr_blocks = inode->i_finode.fi_size/BSIZE;  
  210.         int size = 0;  
  211.         int sync = 0;  
  212.         int i = inode->i_finode.fi_addr[baddr/BSIZE+1];  
  213.     //如果第一次写,先分配一个块  
  214.         if (inode->i_finode.fi_size == 0) {  
  215.         int nbno = balloc();  
  216.                 if (nbno == -1) {  
  217.                         return -1;  
  218.                 }  
  219.                 inode->i_finode.fi_addr[0] = nbno;  
  220.                 sync = 1;  
  221.         }  
  222.     //如果必须扩展,则再分配块,可以和上面的合并优化  
  223.         if (max_block > curr_blocks) {  
  224.                 int j = curr_blocks + 1;  
  225.                 for (; j < max_block; j++) {  
  226.             int nbno = balloc();  
  227.                     if (nbno == -1) {  
  228.                             return -1;  
  229.                     }  
  230.                         inode->i_finode.fi_addr[j] = nbno;  
  231.                 }  
  232.                 sync = 1;  
  233.         }  
  234.         for (; i < max_block+1; i ++,bondary = size%BSIZE) {  
  235.                 size += breadwrite(inode->i_finode.fi_addr[i], buf+size, (length-size)%BSIZE, bondary, 0);  
  236.         }  
  237.         if (size) {  
  238.                 inode->i_finode.fi_size += size;  
  239.                 sync = 1;  
  240.         }  
  241.         if (sync) {  
  242.                 syncinode(inode);  
  243.         }  
  244.         return size;  
  245. }  
  246. //IO的seek接口  
  247. int mflseek(int fd, int pos)  
  248. {  
  249.         struct file *fs = g_opened[fd];  
  250.         fs->f_curpos = pos;  
  251.         return pos;  
  252. }  
  253. //IO打开接口  
  254. int mfopen(char *path, int mode)  
  255. {  
  256.         struct inode *inode = NULL;  
  257.         struct file *file = NULL;  
  258.         int match = 0;  
  259.         inode = namei(path, 0, &match, NULL);  
  260.         if (match == 0) {  
  261.                 return ERR_NOEXIST;  
  262.         }  
  263.         file = (struct file*)calloc(1, sizeof(struct file));  
  264.         file->f_inode = inode;  
  265.         file->f_curpos = 0;  
  266.         g_opened[g_fd] = file;  
  267.         g_fd++;  
  268.         return g_fd-1;  
  269. }  
  270. //IO关闭接口  
  271. void mfclose(int fd)  
  272. {  
  273.         struct inode *inode = NULL;  
  274.         struct file *file = NULL;  
  275.         file = g_opened[fd];  
  276.         inode = file->f_inode;  
  277.         iput(inode);  
  278.         free(file);  
  279. }  
  280. //IO创建接口  
  281. int mfcreat(char *path, int mode)  
  282. {  
  283.         int match = 0;  
  284.         struct dir dir;  
  285.         struct inode *new = NULL;  
  286.         char name[MAXLEN] = {0};;  
  287.         struct inode *inode = namei(path, 0, &match, name);  
  288.         if (match == 1) {  
  289.                 return ERR_EXIST;  
  290.         }  
  291.         breadwrite(inode->i_finode.fi_addr[0], (char *)&dir, sizeof(struct dir), 0, 1);  
  292.         strcpy(dir.direct[dir.size].d_name, name);  
  293.         new = ialloc();  
  294.         if (new == NULL) {  
  295.                 return -1;  
  296.         }  
  297.         dir.direct[dir.size].d_ino = new->i_ino;  
  298.         new->i_finode.fi_mode = mode;  
  299.         if (mode == MODE_DIR) {  
  300.         //不允许延迟分配目录项  
  301.                 int nbno = balloc();  
  302.                 if (nbno == -1) {  
  303.                         return -1;  
  304.                 }  
  305.                 new->i_finode.fi_addr[0] = nbno;  
  306.         }  
  307.         new->i_parent = inode;  
  308.         syncinode(new);  
  309.         dir.size ++;  
  310.         breadwrite(inode->i_finode.fi_addr[0], (char *)&dir, sizeof(struct dir), 0, 0);  
  311.         syncinode(inode);  
  312.         iput(inode);  
  313.         syncinode(new);  
  314.         iput(new);  
  315.         return ERR_OK;  
  316. }  
  317. //IO删除接口  
  318. int mfdelete(char *path)  
  319. {  
  320.         int match = 0;  
  321.         struct dir dir;  
  322.         struct inode *del = NULL;  
  323.         struct inode *parent = NULL;  
  324.         char name[MAXLEN];  
  325.         int i = 0;  
  326.         struct inode *inode = namei(path, 0, &match, name);  
  327.         if (match == 0 || inode->i_ino == 0) {  
  328.                 return ERR_NOEXIST;  
  329.         }  
  330.         match = -1;  
  331.         parent = inode->i_parent;  
  332.         breadwrite(parent->i_finode.fi_addr[0], (char *)&dir, sizeof(struct dir), 0, 1);  
  333.         for (; i < dir.size; i++) {  
  334.                 if (!strncmp(name, dir.direct[i].d_name, strlen(name))) {  
  335.                         del = iget(dir.direct[i].d_ino);  
  336.                         iput(del);  
  337.                         if (itrunc(del) == 0) {  
  338.                                 memset(dir.direct[i].d_name, 0, strlen(dir.direct[i].d_name));  
  339.                                 match = i;  
  340.                                 break;  
  341.                         } else {  
  342.                                 return ERR_BUSY;  
  343.                         }  
  344.                 }  
  345.         }  
  346.         for (i = match; i < dir.size - 1 && match != -1; i++) {  
  347.                 strcpy(dir.direct[i].d_name, dir.direct[i+1].d_name);  
  348.         }  
  349.         dir.size--;  
  350.         breadwrite(parent->i_finode.fi_addr[0], (char *)&dir, sizeof(struct dir), 0, 0);  
  351.         return ERR_OK;  
  352. }  
  353. //序列初始化接口,从模拟块设备初始化内存结构  
  354. int initialize(char *fake_disk_path)  
  355. {  
  356.         g_fake_disk = open(fake_disk_path, O_RDWR);  
  357.         if (g_fake_disk == -1) {  
  358.                 return ERR_NOEXIST;  
  359.         }  
  360.         g_super = (struct filesys*)calloc(1, sizeof(struct filesys));  
  361.         lseek(g_fake_disk, BOOTBSIZE, SEEK_SET);  
  362.         read(g_fake_disk, g_super, sizeof(struct filesys));  
  363.         g_super->s_size = 1024*1024;  
  364.         g_super->s_itsize = INODEBSIZE;  
  365.         g_super->s_freeinodesize = NUM;  
  366.         g_super->s_freeblocksize = (g_super->s_size - (BOOTBSIZE+SUPERBSIZE+INODEBSIZE))/BSIZE;  
  367.         g_root =  iget(0);  
  368.     //第一次的话要分配ROOT  
  369.         if (g_root == NULL) {  
  370.                 g_root = ialloc();  
  371.                 g_root->i_finode.fi_addr[0] = balloc();  
  372.         }  
  373.         return ERR_OK;  
  374. }  
  375. 下面是一个测试程序:  
  376. int main()  
  377. {  
  378.         int fd = -1,ws = -1;  
  379.         char buf[16] = {0};  
  380.         initialize("bigdisk");  
  381.         mfcreat("/aa", MODE_FILE);  
  382.         fd = mfopen("/aa", 0);  
  383.         ws = mfwrite(fd, "abcde", 5);  
  384.         mfread(fd, buf, 5);  
  385.         mfcreat("/bb", MODE_DIR);  
  386.         mfcreat("/bb/cc", MODE_FILE);  
  387.         fd = mfopen("/bb/cc", 0);  
  388.         ws = mfwrite(fd, "ABCDEFG", 6);  
  389.         mfread(fd, buf, 5);  
  390.         mflseek(0, 4);  
  391.         ws = mfwrite(0, "ABCDEFG", 6);  
  392.         mflseek(0, 0);  
  393.         mfread(0, buf, 10);  
  394.         mfclose(0);  
  395.         mfdelete("/aa");  
  396.         fd = mfopen("/aa", 0);  
  397.         mfcreat("/aa", MODE_FILE);  
  398.         fd = mfopen("/aa", 0);  
  399.         syncsuper(g_super);  
  400. }  

这个文件系统实现得超级简单,除去了很多额外的非本质的东西,并且也绕开了烦人的内存管理问题!于是,我的这个实现也就显示了UNIX文件系统的本质。那么再看一下,还有什么东西虽然是额外的,但是却是必不可少或者起码说是很有意思的?答案很显然,那就是空闲块或者空闲inode的组织以及分配算法,然而这个算法可以单独抽象出来。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值