[单刷APUE系列]第四章——文件和目录[2]

PS:原先写好文章了,结果备份Boom了,结果只好重写了一遍,文件系统部分本来是准备了图片的,但是现在没了。各位将就着看。

目录

[单刷APUE系列]第一章——Unix基础知识[1]
[单刷APUE系列]第一章——Unix基础知识[2]
[单刷APUE系列]第二章——Unix标准及实现
[单刷APUE系列]第三章——文件I/O
[单刷APUE系列]第四章——文件和目录[1]
[单刷APUE系列]第四章——文件和目录[2]
[单刷APUE系列]第五章——标准I/O库
[单刷APUE系列]第六章——系统数据文件和信息
[单刷APUE系列]第七章——进程环境
[单刷APUE系列]第八章——进程控制[1]
[单刷APUE系列]第八章——进程控制[2]
[单刷APUE系列]第九章——进程关系
[单刷APUE系列]第十章——信号[1]

黏着位(S_ISVTX)

The ISVTX (the sticky bit) indicates to the system which executable files are shareable (the default) and the system maintains the program text of the files in the swap area. The sticky bit may only be set by the super user on shareable executable files.

If mode ISVTX (the `sticky bit') is set on a directory, an unprivileged user may not delete or rename files of other users in that directory. The sticky bit may be set by any user on a directory which the user owns or has appropriate permissions.  For more details of the properties of the sticky bit, see sticky(8).

黏着位是一个很有意思的参数,S_ISVTX实际上是save text bit的缩写,我们知道,一个二进制程序实际上是由以下几部分组成

  1. 正文段

  2. 数据段

  3. bss段

正文段包含了程序几乎大部分的内容,而且由于正文段的特殊性,它是只读的,那么在早期Unix环境资源非常稀少的情况下,为了保证快速加载程序,就出现了保存正文位这一个特殊bit,如果一个程序的stx位被设置了,当程序第一次载入执行,在其终止时,程序正文段依旧会有一个副本被保存在交换区。在现在看来实际上是一种缓存技术,Node.JS的模块加载机制就与此类似。但是由于现在的Unix系统虚拟内存技术、预存取技术的存在,这个机制已经被废弃了。
正如上面Unix系统手册上所说,当黏着位设置在目录上的时候,没有特权的用户就不能删除或者重命名其他用户在这个目录中的文件。很典型的案例就是/tmp/var/tmp目录,tmp文件夹一般都是rwxrwxrwt的权限,由root用户拥有,根据规定,只有拥有文件、拥有目录、超级用户的三者之一才能删除或更名文件,这样就能保证了不会对其他用户的文件误操作。
man 8 sticky的手册页中我们也能看到关于黏着位的描述

The sticky bit has no effect on executable files. All optimization on whether text images remain resident in memory is handled by the kernel's virtual memory system.

黏着位已经对二进制文件没有任何作用了,虚拟内存机制取代了一切。

更改拥有者函数族

int chown(const char *path, uid_t owner, gid_t group);
int fchown(int fildes, uid_t owner, gid_t group);
int lchown(const char *path, uid_t owner, gid_t group);
int fchownat(int fd, const char *path, uid_t owner, gid_t group, int flag);

DESCRIPTION
The owner ID and group ID of the file named by path or referenced by fildes is changed as specified by the arguments owner and group.  The owner of a file may change the group to a group of which he or she is a member, but the change owner capability is restricted to the super-user.

The chown() system call clears the set-user-id and set-group-id bits on the file to prevent accidental or mischievous creation of set-user-id and set-group-id programs if not executed by the super-user.  The chown() system call follows symbolic links to operate on the target of the link rather than the link
itself.

The fchown() system call is particularly useful when used in conjunction with the file locking primitives (see flock(2)).

The lchown() system call is similar to chown() but does not follow symbolic links.

The fchownat() system call is equivalent to the chown() and lchown() except in the case where path specifies a relative path.  In this case the file to be changed is determined relative to the directory associated with the file descriptor fd instead of the current working directory.

Values for flag are constructed by a bitwise-inclusive OR of flags from the following list, defined in <fcntl.h>:

AT_SYMLINK_NOFOLLOW
If path names a symbolic link, ownership of the symbolic link is changed.

If fchownat() is passed the special value AT_FDCWD in the fd parameter, the current working directory is used and the behavior is identical to a call to chown() or lchown() respectively, depending on whether or not the AT_SYMLINK_NOFOLLOW bit is set in the flag argument.

One of the owner or group id's may be left unchanged by specifying it as -1.

上面是Mac OS X系统用户手册关于chown函数的介绍,从它和原著内容的对照,可以发现,实际上原著上面提到的内容,都可以从系统手册上找到,笔者个人非常推崇Unix自带的系统手册,除了方便快捷,内容也非常的详细,可以说,原著就是讲系统手册内容组合起来,然后加上了作者自己的理解。
这个函数族也非常的简单,除了fchownat函数多了一个flag参数以外,其他的参数都是一样的,从手册最后一句可以知道,ownergroup两个参数任意一个为-1,就代表对应的ID不变。当文件是符号链接的时候,lchownflag参数为AT_SYMLINK_NOFOLLOW的fchownat是一样的行为,都是符号链接本身的所有者。
从手册中了解到,文件拥有者可以将组改为拥有者的其他附属组,但是只有root用户才能改变文件拥有者,原著中提到,基于BSD的系统一直规定只有超级用户才能更改文件所有者,Mac OS X系统也是BSD系的,所以有这规定不足为奇,而原著后面也提到了这个限制在FreeBSD8.0、Linux3.2.0和Mac OS X 10.6.8中一直存在,可是笔者在基于Linux2.6系列内核的CentOS6.x中发现,CentOS6.x实际上也有此限制,并且在用户手册上写到

Only a privileged process (Linux: one with the CAP_CHOWN capability) may change the owner of a file.  The owner of a file may change the group of the file to any group of which that owner is a member.  A privileged process (Linux: with CAP_CHOWN) may change the group arbitrarily.

这点非常让人疑惑,由于笔者并没有测试其他2.6.x内核的Linux发行版,所以不知道这个限制是Linux2.6就有的,还是说是RedHat公司打的补丁。如果有朋友知道可以指正一下。
chown系统调用还会清除设置用户ID和设置组ID位,如果函数不是被root权限调用。

文件长度

stat结构体中有一个成员变量st_size用于表示以字节为单位的文件长度,我们知道,Unix有7种文件

  1. 普通文件

  2. 目录文件

  3. 块特殊文件

  4. 字符特殊文件

  5. FIFO

  6. 套接字

  7. 符号链接

很容易就能想到这个字段只对普通文件、目录文件和符号链接才有意义,但是请注意,由于FIFO的特殊性,文件长度的存在是能让进程通信更加便利,所以在在现代Unix系统中,FIFO也有这一属性。
大家应该还记得stat结构体中的st_blksizest_blocks属性,这两个属性就是关于文件在文件系统中占据空间的属性,第一个是最优文件系统块大小,第二个是分配给该文件的磁盘块数。
在前面的例程中,提到了普通文件存在文件空洞,也解释了文件空洞存在的原因,当文件长度小于所占用的磁盘块大小,就说明存在着文件空洞。当我们使用cat命令复制存在空洞的文件,新的文件就不存在空洞了,所有的空洞都会被填满。更有意思的是,使用od命令去查看这两个文件的二进制内容,可以发现空洞和新文件被填满的部分都是以null的形式(0字节)存在,

文件截断

在很多时候,开发者需要将文件截断一部分用于缩短文件。一个很典型的案例就是将文件截断为0,也就是使用O_TRUNC参数打开文件,但是系统也提供了两个函数用于文件的截断

int truncate(const char *path, off_t length);
int ftruncate(int fildes, off_t length);

这两个函数可以将文件截断也可以扩展,如果length大于文件长度,那么就会扩展当前的文件,并且扩展的空间将以文件空洞的形式存在。
而且注意,这两个函数不会修改当前文件的偏移量,如果不注意这一点,很有可能造成偏移量超出了新的文件长度,从而形成空洞。

文件系统

文件系统是一个非常庞大的模型,笔者在这里讲自己的理解,如果有需要更深一步了解的朋友,可以自行观看原著或者《鸟哥的Linux私房菜》中有关文件系统的部分。
目前Unix系统几乎都有很多自己的文件系统实现,每种文件系统都有其自己的特殊性,但是对于大部分文件系统来说,都是基于同一个模型建立的,所以实际上很相似。
文件系统有很多部分组成,普遍的实现中,都是将实际文件数据和文件属性分开,分别存放。这就是数据块和索引块,通常也叫inode块,还有包含了整个文件系统属性的超级块,为了方便管理,一些文件系统还将块进行分组,形成块组,但是这个概念实际上无关紧要。对于开发者来说重要的是索引块和数据块。对于用户来说,使用ls命令查看当前目录下的所有文件,通常可以看到如下内容

drwxr-xr-x   9 uid  gid   306  2  3 18:14 .
drwxr-xr-x  11 uid  gid   374  2  3 18:12 ..
-rw-r--r--   1 uid  gid  2290  2  3 18:13 error.c
-rw-r--r--   1 uid  gid  2229  2  3 18:13 errorlog.c
drwxr-xr-x   4 uid  gid   136  2  3 18:12 include

有文件有目录,还有两个...特殊文件,实际上ls命令并没有检索真正的数据块,这些项目就是索引块体现的,每个文件都对应了一个独一无二的索引块,但是并不是每个文件都有独一无二的数据块。索引块中记录了文件的基本信息,包括权限、大小、类型等等,上面第二列是项目的链接计数,也就是stat结构体中的st_nlink成员。我们知道,Unix的链接分为两种,硬链接和软链接(符号链接),每个inode块都有链接计数,只有当链接计数为0的时候,文件系统才真正的删除数据块,这一点和C++中的引用计数非常相似。
软链接实际上是一种文件,它存在着自己的索引块和数据块,而且软链接的存在不会让索引计数改变,只是数据块内容是另一个文件的位置,这点和C语言中的指针非常相似。
当使用mkdir创建目录的时候,可以发现新目录的引用必然是2,因为目录下默认就有两个特殊文件...,目录本身指向自身,.文件也指向自身,所以新目录索引计数必然为2.
从上面的信息可以知道,由于硬链接是文件系统inode块产生的,那么硬链接就必然无法跨越文件系统,而软链接是实实在在的文件,所以软链接可以跨文件系统。并且,这就是为什么系统提供的是unlink函数而不是delete函数的原因。

链接函数族

int link(const char *path1, const char *path2);
int linkat(int fd1, const char *name1, int fd2, const char *name2, int flag);

int unlink(const char *path);
int unlinkat(int fd, const char *path, int flag);

系统提供了上面四个函数用于链接的创建和释放,具体的介绍则在上一节讲述了。经过了这么多函数的介绍,想必大家也对Unix函数的风格有所了解了。和其他的Unix函数一样,文件描述符可以用AT_FDCWD来代替,这代表着路径将以当前工作目录来计算。
linkat函数的flag参数只有AT_SYMLINK_FOLLOW可用,这和之前我们所遇到的函数都不大一样,当这个参数存在时,函数将创建指向链接目标的链接,否则创建指向符号链接本身的链接。
unlinkat的flag则只有AT_REMOVEDIR可用,当此参数被传入时,unlinkat将和rmdir一样删除目录。

#include "include/apue.h"
#include <fcntl.h>

int main(int argc, char *argv[])
{
    if (open("tempfile", O_RDWR) < 0)
        err_sys("open error");
    if (unlink("tempfile") < 0)
        err_sys("unlink error");
    printf("file unlinked\n");
    sleep(15);
    printf("done\n");
    exit(0);
}

The unlink() function removes the link......and no process has the file open这是Unix手册中的一句话,当文件被进程打开时,unlink函数不会删除链接,所以这个特性在实际开发中十分实用,可以用来保证进程崩溃时,删除临时文件。
而且在ISO C标准函数库中,有一个remove函数,同样也是用于解除链接。

重命名函数

int rename(const char *old, const char *new);
int renameat(int fromfd, const char *from, int tofd, const char *to);

DESCRIPTION
The rename() system call causes the link named old to be renamed as new.  If new exists, it is first removed.  Both old and new must be of the same type(that is, both must be either directories or non-directories) and must reside on the same file system.

The rename() system call guarantees that an instance of new will always exist, even if the system should crash in the middle of the operation.

If the final component of old is a symbolic link, the symbolic link is renamed, not the file or directory to which it points.

The renameat() system call is equivalent to rename() except in the case where either from or to specifies a relative path.  If from is a relative path, the
file to be renamed is located relative to the directory associated with the file descriptor fromfd instead of the current working directory.  If the to is a
relative path, the same happens only relative to the directory associated with tofd.  If the renameat() is passed the special value AT_FDCWD in the fromfd or tofd parameter, the current working directory is used in the determination of the file for the respective path parameter.

由于这个函数原著已经讲得足够详细,所以这里只补充以下几点

  1. 新项目存在时,将先删除,然后在将老项目转移为新的名称

  2. 如果文件是符号链接,则只对符号链接本身起作用而不是其对应的文件

符号链接

符号链接,即通常说的软链接,是对一个文件的间接指针。硬链接是直接指向索引块,而软链接实际上是一个文件,其实从上面大家应该看到了硬链接的局限和危险,直接操作硬链接后果很差,所以引入符号链接就是为了解决硬链接存在的问题。
由于符号链接和普通文件几乎一样,所以任何涉及文件的函数,都需要注意是否处理符号链接,一般来说都是以flag参数的形式来改变函数的行为,从前面这么多的函数应该了解到了。
Unix系统也提供了创建符号链接的函数

int symlink(const char *path1, const char *path2);
int symlinkat(const char *name1, int fd, const char *name2);

函数很简单,两个函数就只有相对路径和绝对路径的区别。由于符号链接本身是一个文件,所以也应该可以读写,但是open函数是跟随符号链接的,所以Unix系统也提供了打开符号链接的函数

ssize_t readlink(const char *restrict path, char *restrict buf, size_t bufsize);
ssize_t readlinkat(int fd, const char *restrict path, char *restrict buf, size_t bufsize);

这两个函数实际上就是一个相对路径和绝对路径的区别,而且这两个函数都整合了open、read、close的操作

文件的时间

在前面的文章里讲到了stat结构体,并且可以看到,stat结构体中时间字段有秒和纳秒两种精度,但是从上面讲的文件系统可以知道,inode块记录了文件的详细信息,自然也包括了文件的时间信息,而inode块的实现是根据文件系统来确定的,这意味着精度是根据文件系统的不同而不同。
在学习Unix系统的时候大家应当知道了文件有三个时间

字段说明例子ls(1)选项
st_atime文件的最后访问时间read-u
st_mtime文件的最后修改时间write默认
st_ctimeinode块状态的最后更改时间chmod、chown-c

从上面我们知道,现在大多数的文件系统索引块和数据块是分别存放的,也就是说,修改文件信息和修改文件内容完全是两码事,当执行影响inode块的命令的时候就会修改st_ctime,当执行内容修改的时候就会修改st_mtime
我们知道,目录也是一种文件,增加、删除或修改目录项会影响到它所在的目录的相关的三个时间,一般有以下几点规律

  1. 任何修改目录内容的操作也必定会导致目录的inode块被修改,即mtime修改同时ctime修改

  2. 任何创建子目录的行为都会导致父目录被更改,这点很好理解,因为子目录必定会导致父目录索引数目增加

  3. 目录下项目数目的增加都会导致目录被更改

  4. 重命名一个文件实际就是修改了inode块,必定会导致目录修改

其实最关键的就是理解文件系统索引块和数据块分离的事实,这样就很容易理解上面的概念了。
顺便提一句,atime是access time、mtime是modification time、ctime是change time,有一些Linux教程书里面将ctime认为是文件内容更改时间,是错误的。

文件时间修改函数

int utimes(const char *path, const struct timeval times[2]);
int futimes(int fildes, const struct timeval times[2]);

为了管理文件时间,Unix系统也提供了文件时间的管理函数,在原著中,有三个函数提供,但是非常可惜的是,Mac OS X系统只提供了以上两个函数,utimensat函数并未提供,并且futimensfutimes替代了,而Linux系统则是提供utimensat函数的。

int utimensat(int dirfd, const char *pathname, const struct timespec times[2], int flags);

If times is NULL, the access and modification times are set to the current time.  The caller must be the owner of the file, have permission to write the file, or be the super-user.

If times is non-NULL, it is assumed to point to an array of two timeval structures.  The access time is set to the value of the first element, and the modi-fication time is set to the value of the second element.  The caller must be the owner of the file or be the super-user.

In either case, the inode-change-time of the file is set to the current time.

上面的是CentOS6.x系统提供的函数原型,通过对比之前那些函数族,可以发现,实际上系统应当提供这一功能强大的方法,具体原因并不清楚,如果有朋友了解可以指点一二。
这两个参数都是传入一个timeval结构体数组,timeval实际上是以下结构体

_STRUCT_TIMEVAL
{
        __darwin_time_t         tv_sec;         /* seconds */
        __darwin_suseconds_t    tv_usec;        /* and microseconds */
};

实际上这个和timespec结构体是一样样的,笔者也不知道为什么已经有了timespec还要再搞一个结构体出来。这个参数的两个时间都是日历时间,也就是俗称的Unix时间戳,一个是acess time一个是modification time,具体规则上面实际上已经有了。

  1. times参数为空,两个时间设置为当前时间

  2. 如果非空,两个时间分别被设置为第一个元素和第二个元素

  3. 任何情况下,change time都被设置为当前时间,其实这点很好理解,修改两个时间实际上就是修改了索引块

原著中实际上写了很多,但是很可惜,系统手册上只有这么点东西,笔者这里就只按照系统手册来写,如果有朋友想要了解原著内容请查看原著。
然后下面是原著中的一个例程,这个程序实际上是没法跑的,所以在这里笔者对其修改了。

#define _DARWIN_C_SOURCE 1
#include "include/apue.h"
#include <sys/time.h>
#include <fcntl.h>

int main(int argc, char *argv[])
{
    int i, fd;
    struct stat statbuf;
    struct timeval times[2];
    
    for (i = 1; i < argc; ++i) {
        if (stat(argv[i], &statbuf) < 0) {
            err_ret("%s: stat error", argv[i]);
            continue;
        }
        if ((fd = open(argv[i], O_RDWR | O_TRUNC)) < 0) {
            err_ret("%s: open error", argv[i]);
            continue;
        }
        times[0].tv_sec = statbuf.st_atimespec.tv_sec;
        times[0].tv_usec = statbuf.st_atimespec.tv_nsec;
        times[1].tv_sec = statbuf.st_mtimespec.tv_sec;
        times[1].tv_usec = statbuf.st_mtimespec.tv_nsec;
        if (futimes(fd, times) < 0)
            err_ret("%s: futimens error", argv[i]);
        close(fd);
    }
    exit(0);
}

苹果系统内的futimesutimes函数是归类给标准C库的,但是确实放在BSD系统调用中的,不要问我为什么,因为我也不知道为什么,而且可以通过查看<sys/stat.h>头文件发现,程序所需要的结构体定义是放在#if !defined(_POSIX_C_SOURCE) || defined(_DARWIN_C_SOURCE),由于APUE头文件已经定义了_POSIX_C_SOURCE,所以必须要增加_DARWIN_C_SOURCE宏定义,不增加的后果自行尝试,而且由于标准C库自己一套数据结构,必须在这里手工转换timespec->timeval类型。实际上两个是一样的。由于系统提供精确到纳秒的时间,而标准C库提供的是精确到毫秒的时间,所以需要单位转换。
上面说了那么多,实际上并没有什么卵用,这个程序只是为了证明修改文件信息会导致st_ctime的时间改变罢了。

创建删除目录函数族

int mkdir(const char *path, mode_t mode);
int mkdirat(int fd, const char *path, mode_t mode);

没什么好说的,由于前面说过的BSD系列的特点,新目录的拥有者ID是进程的有效拥有者ID,组ID则是父目录的组ID,而且还有一点需要注意,mkdir函数对mode超出低九位的行为是没有反应的,所以最好在mkdir后使用chmod确认权限。

int rmdir(const char *path);

前面说过,目录是一个文件,所以它也具有文件的特点,例如有进程打开时,删除会执行,但是不会释放实际目录,这个函数只能用来删除空目录

读目录

对于用户来说,目录的读写是透明的,但是实际上只有内核才能写目录,这样就不会出现文件系统的混乱,POSIX标准定义了一套有关目录的函数

DIR *opendir(const char *filename);
DIR *fdopendir(int fd);

struct dirent *readdir(DIR *dirp);

long telldir(DIR *dirp);

void seekdir(DIR *dirp, long loc);

void rewinddir(DIR *dirp);
int closedir(DIR *dirp);

这里只讨论原著中的函数原型,头两个很简单,就是打开一个目录返回DIR *
在第一篇文章中有一个ls程序的实现,苹果系统下的实现如下

struct dirent {
        ino_t d_ino;                    /* file number of entry */
        __uint16_t d_reclen;            /* length of this record */
        __uint8_t  d_type;              /* file type, see below */
        __uint8_t  d_namlen;            /* length of string in d_name */
        char d_name[__DARWIN_MAXNAMLEN + 1];    /* name must be no longer than this */
};

Unix规范规定,至少必须包含d_inod_name实现,DIR结构体的实现如下

typedef struct {
        int     __dd_fd;        /* file descriptor associated with directory */
        long    __dd_loc;       /* offset in current buffer */
        long    __dd_size;      /* amount of data returned */
        char    *__dd_buf;      /* data buffer */
        int     __dd_len;       /* size of data buffer */
        long    __dd_seek;      /* magic cookie returned */
        long    __dd_rewind;    /* magic cookie for rewinding */
        int     __dd_flags;     /* flags for readdir */
        __darwin_pthread_mutex_t __dd_lock; /* for thread locking */
        struct _telldir *__dd_td; /* telldir position recording */
} DIR;

是不是和FILE结构体很像,这个结构体实际上才是保存了目录信息。打开的两个函数返回的DIR最终是被其他函数所使用,而且我们看到,目录实际上也有偏移量一说。
readdir函数返回下一个目录项的指针,直到最终到底部返回null。
telldir是查询偏移量,seekdir是设置偏移量,rewinddir是将偏移量放到最开始,closedir就是关闭目录。
原著还写了一个例程用于遍历目录层次,但是笔者认为并没有什么必要,如果还有不明白的完全可以查询Unix用户手册,所以这里就不再赘述。

工作目录函数族

每个进程实际上都有一个当前工作目录,这个目录在前面很多函数中都非常有用,所有的相对路径都是以此计算,当前工作目录是进程一个属性,Unix系统也提供了系统函数。

int chdir(const char *path);
int fchdir(int fildes);

我们知道,shell实际上也是一个进程,它在启动时读取/etc/passwd中有关登录用户的家目录的字段,然后将自身工作目录改变到家目录。
笔者在这里强调,工作目录是进程一个属性,它和其他进程无关,这样我们就理解了为何cd命令是shell内建命令。
而且我们知道,子进程会继承父进程的属性,所以shell打开一个程序,它的初始工作目录就是shell的工作目录。
讲到这里,可能有些朋友已经觉得奇怪,工作目录既然是一个属性,为什么没有获取当前工作目录完整路径的函数,实际上是这样,由于内核的实现中,内核只保存了当前工作目录的一个指针,并没有保存完整的路径名,所以没有直接得到当前工作目录路径的函数。
但是由于内核持有当前工作目录的指针,那么,是不是可以通过反向向上查找,一级级反推,最终组装出当前工作目录呢。答案是可以的,而且系统还将其封装好了。

char *getcwd(char *buf, size_t size);
char *getwd(char *buf);

这两个是标准C函数,第二个函数实际上是一个兼容性函数,不需要提供缓冲区大小指示。

#include "include/apue.h"

int main(int argc, char *argv[])
{
    char *ptr;
    size_t size;
    if (chdir("/var") < 0)
        err_sys("chdir error");
    ptr = path_alloc(&size);
    if (getcwd(ptr, size) == NULL)
        err_sys("getcwd failed");
    printf("cwd = %s\n", ptr);
    exit(0);
}

这是原著一个例程,在这里有一个path_alloc函数,也是需要我们手动将其编译为静态库,然后链接到程序中。为了保证Mac OS X系统能运行,这里对目录做了修改,将其指定为/var

> ./a.out
cwd = /private/var
> ls -l /var
lrwxr-xr-x 1 root  wheel  11  2  2 03:17 /var -> private/var

所以可以知道chdir是跟随符号链接的,但是当getcwd沿着目录向上时,还是会根据实际路径计算。
getcwd说明中,系统还教了一种简便方法

A much faster and less error-prone method of accomplishing this is to open the current directory (`.') and use the fchdir(2) function to return.

在更换到其他目录前,直接使用open打开当前工作目录(.),然后保存文件描述符,当希望回到原工作目录时,只需要将其传递给fchdir就行了。

设备特殊文件

原著讲的很详细了,而且这个并不是非常有用,所以这里就不提及了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值