[Linux环境编程学习笔记_1]:I/O-文件与目录

1. 文件系统

我们可以把一个磁盘分成一个或多个分区,每个分区包含一个文件系统,这个文件系统由很多柱面组成,而柱面中有一个非常重要的概念叫做 i 节点。

i 节点包含了文件的大部分信息,如文件类型,文件访问权限位,文件大小和指向文件数据的指针等,大多数信息都存在 st_mode成员中,有两个重要参数存放在目录中,文件名和 i 节点编号。

2. 文件属性结构体

struct stat
{
    mode_t    st_mode;    //文件类型和文件权限
    uid_t     st_uid;     //用户ID
    gid_t     st_gid;     //组ID
    nlink_t   st_nlink;   //连到该文件的硬连接数目,刚建立的文件值为1
    off_t     st_size;    //文件字节数,即文件大小,链接文件的话是所指路径名的长度
    blksize_t st_blksize; //文件系统上进行I/O操作时的最优块大小
    blkcnt_t  st_blocks;  //块数,du命令就是blocks
    time_t    st_atime;   //最后一次访问时间,access
    time_t    st_mtime;   //最后一次修改时间,modify
    time_t    st_ctime;   //最后一次文件属性改变时间,chmod
    ino_t     st_ino;     //文件inode结点号
    dev_t     st_dev;     //文件系统的设备号,该文件系统包含了文件名及对应的i节点
    dev_t     st_rdev;    //针对字符设备和块设备,实际的设备号,主/次设备号
};

您可以通过以下功能获取文件的信息结构:

[En]

You can get the information structure of the file through the following functions:

#include
int stat(const char *restrict pathname, struct stat *restrict buf);
int fstat(int fd, struct stat *buf);
int lstat(const char *restrict pathname, struct stat *restrict buf);
int fstatat(int fd, const char *restrict pathname, struct stat *restrict buf, int flag);
  • stat函数可以根据 pathname,获取对应文件的信息结构;
  • fstat函数,则可以根据文件描述符fd,获取对应文件的信息结构;
  • lstat函数,针对链接文件, lstat函数返回的是链接文件本身的信息,而不是链接文件指向的文件的信息;
  • fstatat函数,可以根据文件描述符 fd 和 pathname 来确定要获取的文件信息,如果获取的是链接文件,flag 参数用来决定获取的是链接文件本身的信息还是链接文件所指向的文件信息。

i-node 表和文件数据的映射关系如下图:

3. 文件信息

st_mode是 16位的,用二进制表示时,包含三部分信息:

文件类型  设置位  文件权限
****     ***    *** *** ***

最常见的文件类型就是 普通文件目录文件了,其次还有一些其他类型的文件:

  • 普通文件 (-);
  • 目录文件(d);
  • 符号链接(l):类似于 windows 中的快捷方式;
  • 字符特殊文件(c):这种类型的文件提供了对设备不带缓冲的访问,每次访问长度可变;
  • 块特殊文件(b):这种类型的文件提供对设备(如磁盘)带缓冲的访问,每次访问以固定长度为单位进行;
  • FIFO(f):有时也叫管道,用于进程间通信;
  • 套接字(s):用于进程间的网络通信。

您可以通过以下功能确定文件类型:

[En]

You can determine the file type by the following functions:

#include
S_ISREG();  //普通文件,是的话,返回1,参数传入st_mode
S_ISDIR();  //目录文件
S_ISLNK();  //符号链接文件
S_ISCHR();  //字符设备文件
S_ISBLK();  //块设备文件
S_ISFIFO(); //FIFO文件
S_ISSOCK(); //套接字

设置位分为三种:

设置位 功能 S_ISUID 执行时会将有效用户ID设置为用户ID S_ISGID 执行时会将有效组ID设置为组ID S_ISVTX 粘着位

粘着位只对目录有效,起限制删除的作用, shell中可以通过 chmod +t filename来设置粘着位。设置了粘着位的目录,只有当操作用户拥有该文件,或拥有该目录,或者是超级用户,且对该目录具有写权限,才能删除或重命名该目录下的文件。

文件的权限可以分为 读,写,执行三种权限,根据不同的所有者可以分为以下三类:

访问权限 说明 S_IRUSR 用户读 S_IWUSR 用户写 S_IXUSR 用户执行 S_IRGRP 组读 S_IWGRP 组写 S_IXGRP 组执行 S_IROTH 其他读 S_IWOTH 其他写 S_IXOTH 其他执行

一般用 u表示用户,用 g表示组,用 o表示其他。

检查文件权限时,将按以下顺序执行操作:

[En]

When checking file permissions, they are performed in the following order:

  • 将所有访问权限授予特权进程
    [En]

    grant all access rights to privileged processes*

  • 若进程 ID 与文件的用户 ID 相同,那么就是文件的属主权限;
  • 上述条件不满足的话,会判断进程的组 ID 和文件的组 ID 是否匹配,匹配就赋予文件的属组权限;
  • 若以上三点都不满足,内核会根据 other 权限,授予进程相应权限。

对于权限需要注意的点:

  • 对于目录来说,目录具备读权限,指的是可以获取该目录下的文件列表名;而目录具备执行权限,指的是能通过目录,所有目录下的文件,举个例子,如果要打开 /usr/include/stdio.h文件,需要对目录 /,/usr,/usr/include 有执行权限。一般目录都需要具备执行权限。
  • 如果要在目录中创建或删除文件,则该目录需要具有写入和执行权限
    [En]

    if you want to create or delete a file in a directory, the directory needs to have write and execute permissions*

  • 新文件的用户 ID 设置为进程的有效用户 ID,组 ID 可以使进程的有效组 ID ,也可以是所在目录的组 ID。

当进程创建新的文件或目录时,它必须使用文件模式来创建掩码字,即如果对应的位为1,则阻止相应的权限:

[En]

When the process creates a new file or directory, it must use the file mode to create a mask word, which means that the corresponding permission is blocked if the corresponding bit is 1:

屏蔽位 含义 0400 用户读 0200 用户写 0100 用户执行 0040 组读 0020 组写 0010 组执行 0004 其他读 0002 其他写 0001 其他执行

函数如下:

#include
mode_t umask(mode_t cmask); //返回之前的文件模式创建屏蔽字

这个参数系统默认就有的,可以通过 shell 命令 umask查看

umask
#include
int access(const char *pathname, int mode);
int faccessat(int fd, const char *pathname, int mode, int flag);
参数说明:
    mode: R_OK=测试读权限;
          W_OK=测试写权限;
          X_OK=测试执行权限;
          F_OK=有这个文件吗
返回值:
    成功=0; 失败=-1

但因为它不是原子的,所以这个函数不安全。

[En]

But because it’s not atomic, this function is not safe.

#include
int chmod(const char *pathname, mode_t mode);
int fchmod(int fd, mode_t mode);
int fchmodat(int fd, const char *pathname, mode_t mode, int flag);

该函数还可以设置执行时用户 ID 和 组 ID, mode参数如下:

mode 说明 S_ISUID 执行时设置用户 ID S_ISGID 执行时设置组 ID S_ISVTX 粘着位,对目录使用 S_IRWXU 用户读,写,执行 S_IRUSR 用户读 S_IWUSR 用户写 S_IXUSR 用户执行 S_IRWXG 组读,写,执行 S_IRGRP 组读 S_IWGRP 组写 S_IXGRP 组执行 S_IRWXO 其他读,写,执行 S_IROTH 其他读 S_IWOTH 其他写 S_IXOTH 其他执行

粘着位:用于目录,主要用于限制删除或重命名目录中的文件。

[En]

Adhesive bits: used for directories, mainly to restrict the deletion or renaming of files in a directory.

只有对目录具有写入访问权限并且满足以下条件之一的用户才能删除或重命名该目录中的文件:

[En]

Only users who have write access to the directory and meet one of the following conditions can delete or rename files in that directory:

  • 拥有此文件;
  • 拥有此目录;
  • 是超级用户

4. 文件所有者

st_uidst_gid分别指明了文件所属主和所属组。

每个文件都有有一个文件所有者和组所有者,我们把当前的所有者和组所有者分别称为 有效用户 ID有效组 ID,有的情况下可能还有个 附属组ID

一般文件新创建时,其用户 ID 就是创建它的进程的有效用户 ID,文件的组 ID 就是该进程的有效组 ID。

一般情况下,有效用户 ID 就是实际用户 ID,有效组 ID 就是实际组 ID,这两个参数保存在 st_uidst_gid中,可以通过函数 S_ISUIDS_ISGID测试。

与一个进程相关的 ID 梳理如下:

ID 种类 说明 实际用户 ID 我们实际是谁 实际组 ID 有效用户 ID 当前有效的所有者 有效组 ID 附属组 ID 保存的设置用户 ID 由 exec 函数保存 保存的设置组 ID

文件用户 ID 和组 ID 可以通过以下函数修改, chownfchownfchownatlchown

#include
int chown(const char *pathname, uid_t owner, gid_t group);
int fchown(int fd, uid_t owner, gid_t group);
int fchownat(int fd, const char *pathname, uid_t owner, gid_t group, int flag);
int lchown(const char *pathname, uid_t owner, gid_t group);
函数功能:
    改变对应文件的用户ID和组ID.

只有特权进程才能使用 chown函数来改变文件的用户 ID,对于非特权进程,如果进程的有效用户 ID 与文件的用户 ID 相匹配,那么可使用 chown函数将文件的组 ID 更换为其从属的任一属组的 ID。

参数 owner或者 group为 -1 时表明不变。

5. 文件链接

结构成员变量 st_nlink代表了文件的硬链接数。

文件链接类型分为 硬链接符号链接,两种区别在于:

  • 硬链接通常要求链接和文件位于同一文件系统中
    [En]

    hard links usually require links and files to be in the same file system*

  • 只有超级用户才能创建指向目录的硬链接(主要是为了防止在文件系统中引入循环)。
    [En]

    only superusers can create hard links to directories (mainly to prevent the introduction of loops in the file system).*

硬链接通常要求链接与文件位于同一文件夹中,并且通常不允许指向目录的链接,因为指向目录的链接可能会导致循环,例如:

[En]

Hard links usually require links to be in the same folder as files, and links to directories are generally not allowed, because linking to directories may cause loops, such as:

有文件 /temp
在 /temp 中创建一个硬链接文件 link 和普通文件 a,那么遍历文件的时候,可能会出现以下情况:
/temp/a
/temp/link/a
/temp/link/link/a

上述情况下,如果是符号链接造成的循环,可以通过 unlink函数解除,因为 unlink不跟随符号链接。

硬链接文件的创建函数如下:

[En]

The creation function of the hard link file is as follows:

#include
int link(const char *existingpath, const char *newpath);
int linkat(int efd, const char *exitingpath, int nfd, const char *newpath, int flag);
参数说明:
    existingpath: 要创建硬链接的源文件的路径,不应该是符号链接文件的路径;
    newpath:      创建的硬链接的文件路径,如果该路径已存在,会报错;
    flag:         通过该标志位控制是否解引用链接文件.

硬链接就是为文件创建一个名字,多个文件名通过相同的 inode 编号,指向同一文件,操作文件时,随便用哪一个都行。

使用符号链接的关键是看函数是否能处理符号链接。通常,符号链接的处理是处理链接文件,而不是链接文件本身。

[En]

The key to the use of symbolic links is to see whether the function can deal with symbolic links. In general, the processing of symbolic links is to deal with the linked file, not the linked file itself.

#include
int symlink(const char *pathname, const car *sympath);
int symlinkat(const char *pathname, int fd, const char *sympath);

pathname可以不存在,因为即使存在,也不能保证它不会被删除,就会使该符号链接变为悬空链接,这时,对该符号链接解引用时都会出错。

这里要注意的是 symlink 的目标文件的路径不是根据当前路径计算的,而是根据符号链接文件的路径计算的,如:

有目录dir1,dir1中有文件a.txt,现在想在dir1目录下创建a.txt文件的符号链接文件b.txt,
并不是
    symlink("./dir1/a.txt", "./dir1/b.txt");  -- 1
而是
    symlink("./a.txt", "./dir1/b.txt");       -- 2
1 中写法的话会从 ./dir1/dir1/a.txt 中找 a.txt 文件,这样明显是不正确的
2 中写法的话就是从 ./dir1/a.txt 中找 a.txt 文件

看上去会有点绕,shell 中的 ls -s,命令创建符号链接文件也是这样子的,否则会找不到。

符号链接的长度是指指向目标文件的文件名的长度。

[En]

The length of the symbolic link refers to the length of the file name that points to the target file.

常用的 open函数打开链接文件时是跟随符号链接的,所以需要一种方法打开链接文件本身,并读该链接中的名字,就有了以下函数:

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

删除链接文件必须满足以下两个条件:

[En]

The deletion of linked files must meet the following two conditions:

  • 文件的链接数为 0;
  • 当前没有打开该文件的进程。
    [En]

    there is currently no process to open the file.*

可通过以下函数删除:

#include
int unlink(const char *pathname);
int unlinkat(int fd, const char *pathname, int flag);

要注意, unlink无法删除目录文件,删除目录文件要用 rmdirremove函数。

6. 文件大小

stat结构成员 st_size表示以字节为单位的文件的长度,只对普通文件,目录文件和链接文件有效。

对于普通文件,文件长度可以是 0,在读这类文件时,将得到文件结束(end-of-file)指示;对于目录,文件长度通常是一个数的整数倍,如 16 的整数倍或 512 的整数倍;对于符号链接,文件长度是在文件名中的字节数,对于共享内存对象,该字段表示对象的大小,如:

lib 文件是个链接文件,链接到 usr/lib
那么连接文件 lib 的文件长度为 7,也就是名字长度

st_blksize是对文件 I/O 较合适的块长度,一般是 4095。

st_blocks是所分配的实际 512 字节块块数,并不是所有的 OS 都是 512 字节,这个要根据实际情况区分。

文件长度有几种特殊情况:

[En]

There are several special cases for file length:

当文件的偏移量超过文件末尾并写入一些数据时,就会导致文件空洞。

[En]

The file hole is caused when the offset of the file exceeds the end of the file and some data is written.

有时,您需要截断文件末尾的一些数据以缩短文件。在这种情况下,您需要截断该文件。相关职能如下:

[En]

Sometimes you need to truncate some data at the end of the file to shorten the file. In this case, you need to truncate the file. The relevant functions are as follows:

#include
int truncate(const char *pathname, off_t length);
int ftruncate(int fd, off_t length);

如果文件当前长度大于参数 length,将会截断;如果当前长度小于参数 length,会在文件尾部添加一系列空字节或是一个文件空洞。

du命令显示的是文件实际所占的块的大小,所以对于有空洞文件,可能会出现 st_sizest_blocks大的情况,因为 st_size 是包含空洞文件的空洞的。

7. 文件的时间

文件的计时可分为以下三类:

[En]

The timing of a file can be divided into the following three categories:

  • 文件数据最后访问时间:st_atime;
  • 文件数据最后修改时间:st_mtime;
  • 文件状态最后改变时间:st_ctime。

需要注意修改时间和文件状态改变的时间的区别,修改指的是文件内容最后一次被修改的时间,状态更改指的是该文件 i 节点最后一次被修改的时间,如文件访问权限,用户ID的更改等。

默认情况下,系统按修改时间排序,也可以使用以下命令更改排序方法:

[En]

By default, the system sorts by modification time, or you can change the sorting method with the following command:

ls -u //按访问时间排序
ls -c //按状态更改时间排序

文件的访问时间和修改时间是可以通过函数修改的,但是文件状态不行,文件状态发生变化时,会自动更新该字段数据。 utime函数调用成功后会将状态最后改变时间设置为当前时间。

#include
int utime(const char *pathname, const struct utimbuf *buf);
参数说明:
    pathname: 文件所在路径;
    buf:      要修改的时间值,为NULL的话,设置为当前时间;
返回说明:
    成功=0; 失败=-1

utimbuf结构体定义如下:

struct utimbuf {
    time_t actime;  //访问时间
    time_t modtime; //修改时间
};

Linux 还提供了源于 BSD 的 utimes系统调用,其功能类似于 utime

#include
int utimes(const char *pathname, const struct timeval tv[2]);

utimesutime的最大区别在于提供了微秒级别的时间。

还可以使用 futimes函数使用文件描述符来指定文件,对于符号链接文件,可以使用 lutimes函数来对符号链接本身做操作:

#include
int futimes(int fd, const struct timeval tv[2]);
int lutimes(const char *pathname, const struct timeval tv[2]); //不会对符号链接文件解引用

您也可以使用纳秒时间来修改函数:

[En]

You can also use nanosecond time to modify the function:

#include
int futimens(int fd, const struct timespec times[2]);
int utimensat(int fd, const char *path, const struct timespec times[2], int flag);
参数说明:
    times 数组的第一个是访问时间,第二个是修改时间

结构体 timespec定义如下:

struct timespec {
    time_t tv_sec;  //秒
    long   tv_nsec; //纳秒
};

有以下几种情况:

  • times 参数是一个空指针,那么访问时间和修改时间都设置为当前时间;
  • times 参数任意一个元素的 tv_nsec字段是 UTIME_NOW,相应的时间戳设置为当前时间(注意,不是 tv_sec字段);
  • times 参数任意一个元素的 tv_nsec字段是 UTIME_OMIT,相应的时间戳保持不变;
  • 如果既不是 UTIME_NOW,也不是 UTIME_OMIT,相应的时间戳设置为相应的 tv_sectv_nsec字段的值。

修改的条件是进程对该文件具备写权限,且进程的有效用户 ID 等于 该文件的所有者 ID,或者是超级用户。

对于 utimensat函数,如果 fd指定为 AT_FDCWD,此时对 path参数的解读与 utimes类似。 flags参数一般为 0,但是对于符号链接文件,如果想对链接本身操作,可以将 flags参数设置为 AT_SYMLINK_NOFOLLOW

8. 设备特殊文件

每个文件系统所在的存储设备都由其主、次设备号表示,可以使用 major()minor()分别访问主、次设备号。

系统中与每个文件名关联的 st_dev值是文件系统的设备号, st_ino字段则该包含文件的 i 节点。利用以上两者,可在所有文件系统中唯一标识某个文件。

只是有字符特殊文件和块特殊文件才有 st_rdev值,此值包含了实际设备的设备号。

9. 通用文件操作函数

当不更换文件系统为一个文件重命名时,该文件的实际内容并未移动,只需要构造一个指向现有 i 节点的新目录项,并删除老的目录项。链接计数并不会改变。

#include
int rename(const char *oldname, const char *newname);
int renameat(int oldfd, const char *oldname, int newfd, const char *newname);

如果 newname已存在,那么会将其覆盖,如果 oldnamenewname一样,则不发生变化。

以下几点需要注意:

  • 如果 oldname 或 newname 指代的是符号链接,那么处理的是符号链接本身,而非链接的文件;
  • 如果是对目录文件重命名,要保证 newname要么不存在,要么存在的目录文件是个空目录,因为 rename是不会移动数据的,且要注意, newname不能包含 oldname作为路径名前缀;
  • rename只能用于同一文件系统。

rmdir函数可以删除一个空目录。

#include
int rmdir(const char *pathname);

remove函数可以用来解除对一个文件或目录的链接,对于文件来说, remove功能和 unlink一样,对于目录来说, remove功能和 rmdir一样。

#include
int remove(const char *pathname);

10. 目录文件

可以使用 mkdirmkdirat函数创建目录文件:

#include
int mkdir(const char *pathname, mode_t mode);
int mkdirat(int fd, const char *pathname, mode_t mode);

目录文件的创建所指定的文件访问权限 mode 由进程的文件模式创建屏蔽字修改(umask)。

常见的错误是指定与文件相同的 mode(只具备读,写权限),但是,对于目录,通常需要执行权限,以允许访问该目录中的文件名。

可以使用 rmdir函数删除目录文件:

#include
int rmdir(const char *pathname);
#include
DIR *opendir(const char *pathname);//路径名方式打开一个目录
DIR *fopendir(int fd);             //以文件描述符的方式打开一个目录,可以将文件描述符转换为 DIR 结构
struct dirent *readdir(DIR *dp);   //读目录,一次返回一条目录
void rewinddir(DIR *dp);           //将目录流重新移动到原点
int closedir(DIR *dp);             //关闭目录
long telldir(DIR *dp);
void seekdir(DIR *dp, long loc);

opendir函数在执行时会为目录相关联的文件描述符设置 close_on_exec标志,以确保在执行 exec时自动关闭该文件描述符。

dirent定义如下:

struct dirent {
    ino_t d_ino;       //inode节点号
    char  d_name[256]; //目录文件名
};
#define _XOPEN_SOURCE 500
#include

int nftw(const char *dirpath, int (*func)(const char *pathname, const struct stat *statbuf, int typeflag, struct FTW *ftwbuf), int nopenfd, int flags);
参数说明:
    dirpath: 目录路径;
    func:    目录树中每个文件要执行的函数;
    nopenfd: 可使用的文件描述符的最大值,一层一个;
    flags:   操作标志;
函数功能:
    默认以前序遍历方式遍历文件树,并为每个文件调用一个func函数

参数 flags含义如下:

  • FTW_CHDIR:在处理目录之前会先调用 chdir函数进入该目录,主要是配合 func函数使用;
  • FTW_DEPTH:使用后序遍历;
  • FTW_MOUNT:不会越界进入另一个文件系统;
  • FTW_PHYS:对符号链接文件不会解引用。

函数 func用的是 stat函数,所以对链接文件,默认是解引用的。

typeflag参数定义如下:

  • FTW_D:目录文件;
  • FTW_DNR:是一个不能读取的目录文件;
  • FTW_DP:正在对一个目录进行后序遍历,当前项是一个目录,其所包含的文件和子目录已完成处理;
  • FTW_F:该文件的类型是除目录和符号链接以外的任何类型;
  • FTW_NS:对该文件调用 stat失败;
  • FTW_SL:是一个符号链接文件;
  • FTW_SLN:是一个悬空的符号链接文件。

参数 ftwbuf是一个 FTW结构型的,定义如下:

struct FTW {
    int base;  //文件名在fpath中的偏移地址
    int level; //目录树的层次,也就是深度
};

每次调用 func都需要返回一个整型值,如果返回 0,那么 nftw函数还会继续对树进行遍历,若返回非 0 值,则通知 nftw停止对树的遍历, nftw函数的返回值与 func的返回值相同。

nftw内部实现会动态分配内存,所以如果直接通过 longjmp函数跳转出去,至少会引起内存泄露问题。

可以通过 chdirfchddir函数来修改当前工作路径:

#include
int chdir(const char *pathname);
int fchdir(int fd);

需要注意的是工作路径是进程的一个属性,所以它只影响调用 chdir的进程本身,而不影响其他进程。

可以通过 getcwd函数来获取当前工作路径:

#include
char *getcwd(char *buf, size_t size);
参数说明:
    buf:  保存返回的当前工作路径;
    size: 缓冲区大小,超过会报错

如果 buf为 NULL 的话,且 size为 0 的话,系统会按需分配一个缓冲区,并将指向该缓冲区的指针作为函数的返回值。但是此时需要注意用完要释放,否则会造成内存泄漏。

#define _BSD_SOURCE
#include
int chroot(const char *pathname);

chroot函数可以修改当前进程的根目录,主要功能就在于限制当前进程访问其他文件,只能访问当前根目录下的文件。

但这一机制并不完全安全:

[En]

But this mechanism is not completely secure:

对于特权级程序来说,可以使用 mknod函数创建一个内存设备文件,然后可以通过这个内存设备文件访问 RAM 的内容;

对于非特权程序,您还应注意以下事项:

[En]

For non-privileged programs, you should also pay attention to the following:

  • 调用 chroot修改完根目录后,没有立即将当前工作目录更新过来,此时还是可以访问其他文件的;
  • 如果在调用 chroot之前有打开一个监禁区外的文件,那么在调用 chroot后可以利用这个文件描述符实现越狱;
  • 也可以使用域名套接字实现越狱。
    [En]

    jailbreaking can also be achieved by using domain sockets.*

#includechar *realpath(const char *pathname, char *resolved_path);参数说明:    pathname:      要解析的路径;    resolved_path: 保存解析结果;返回说明:    成功=返回指向结果的指针; 失败=NULL

会对 pathname一 一解析,最后生成绝对路径名。

#include
char *dirname(char *pathname);  //返回一个路径的目录部分
char *basename(char *pathname); //返回一个路径的文件名部分

Original: https://www.cnblogs.com/sleep-at-11/p/15756552.html
Author: Sleep-at-11
Title: [Linux环境编程学习笔记_1]:I/O-文件与目录

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/523720/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球