前几章文件系统都是只读的。这一章实现 ext2 的写路径,让 echo hello > /tmp/a.txt && cat /tmp/a.txt 能正常工作,且重启后文件仍然存在。过程中还修了 fd 引用计数和 tty 字符设备两个问题。

ext2 磁盘结构回顾

块组 0:
  [超级块][组描述符][block bitmap][inode bitmap][inode table][数据块...]

关键字段:

  • sb->s_free_inodes_count / gd->bg_free_inodes_count:空闲 inode 计数
  • sb->s_free_blocks_count / gd->bg_free_blocks_count:空闲 block 计数
  • gd->bg_inode_bitmap:inode bitmap 所在块号
  • gd->bg_block_bitmap:block bitmap 所在块号
  • gd->bg_inode_table:inode table 起始块号

inode 号从 1 开始;前 10 个是系统保留的(root=2,lost+found=11)。

alloc_inode / alloc_block

alloc_inode

uint32_t alloc_inode(void) {
    // 1. 读 inode bitmap 块
    // 2. 找第一个为 0 的位(bit i → inode i+1)
    // 3. 置位,写回 bitmap
    // 4. 更新超级块和组描述符的 free_inodes_count
    // 5. 返回 inode 号(从 1 开始)
}

alloc_block

uint32_t alloc_block(void) {
    // 1. 读 block bitmap 块
    // 2. 找第一个为 0 的位,跳过 block 0(保留)
    // 3. 置位,写回 bitmap
    // 4. 更新超级块和组描述符的 free_blocks_count
    // 5. 清零新分配的块(避免垃圾数据)
    // 6. 返回块号
}

注意:block bitmap 的 bit 0 对应 block 0,必须跳过(block 0 不存在)。实际第一个可分配的块由文件系统布局决定(本项目中是 610)。

sync_meta

写入 inode、bitmap、超级块后必须调用 bcache_sync() 将脏页刷到磁盘,否则重启后数据丢失。

ext2_dir_add:向目录写入新条目

ext2 目录条目格式:

[inode:4][rec_len:2][name_len:1][file_type:1][name:name_len]

写入流程:

  1. 读目录的数据块
  2. 遍历所有条目,找到最后一条(inode=0pos + rec_len >= block_size
  3. 将最后一条的 rec_len 缩短到其实际占用(8 + name_len 对齐到 4 字节)
  4. 在空出的空间写新条目,新条目的 rec_len 填满剩余空间
  5. 若目录块不够,分配新块

ext2_truncate:清空文件内容

void ext2_truncate(uint32_t ino) {
    // 1. 读 inode
    // 2. 释放所有直接块(i_block[0..11]):清 block bitmap,将 i_block[i] 置 0
    // 3. 间接块暂不处理(简化实现)
    // 4. 将 inode 的 i_size = 0,i_blocks = 0
    // 5. 写回 inode,sync_meta
}

VFS fd 引用计数

问题

Shell 执行 echo hello > /tmp/a.txt 时,busybox ash 的重定向流程:

open("/tmp/a.txt", O_WRONLY|O_CREAT|O_TRUNC) → fd=3
dup2(3, 1)   ← 将 stdout 重定向到文件
close(3)     ← 关闭原始 fd
echo 写 fd=1
dup2(0, 1)   ← 用 stdin 恢复 stdout

dup2(3, 1) 之后:fd_table[1]fd_table[3] 指向同一 VFS fd。close(3) 之后:若直接释放 VFS fd,则 fd_table[1] 成了悬空引用,write(1, ...) 触发 use-after-free。

修复:refcnt

VFile 结构中加 refcnt 字段:

typedef struct {
    VNode    *vnode;
    uint32_t  offset;
    int       used;
    int       type;
    int       refcnt;   // ← 新增
} VFile;

规则:

  • alloc_fd 分配时 refcnt = 1
  • vfs_ref(fd)refcnt++,在 fd_dup2fd_copy_table(fork 继承)时调用
  • vfs_close(fd)refcnt--,只有降到 0 时才真正释放

tty chardev:让 stdin/stdout/stderr 进入 VFS

问题

之前 fd_table[0/1/2] 都是 -1(内核直接调用 tty_read/tty_write),但 dup2(3, 1) 要能把 VFS fd 写到 fd_table[1],且之后 dup2(0, 1) 要能从 fd_table[0] 复制。

修复

  1. 注册 "tty" 字符设备,backed by tty_read / tty_write
  2. proc_exec 启动 shell 时,为 fd 0/1/2 各 vfs_open_cdev("tty")
for (int tfd = 0; tfd < 3; tfd++) {
    int vfd = vfs_open_cdev("tty");
    if (vfd >= 0) p->fd_table[tfd] = vfd;
}

惰性 tty 重建

busybox ash 在启动时(job control 失败路径)会 close(0),导致 fd_table[0]=-1。之后 dup2(0, 1) 恢复 stdout 时,fd_table[0]=-1fd_dup2 失败 → EBADF 死循环。

修复:SYS_DUP2 中惰性重建

case SYS_DUP2: {
    int32_t oldfd = (int32_t)a1;
    // 若 oldfd 是 0/1/2 但已被关闭,惰性重新打开 tty
    if (oldfd >= 0 && oldfd < PROC_MAX_FD
        && current->fd_table[oldfd] < 0
        && (oldfd == 0 || oldfd == 1 || oldfd == 2)) {
        int tvfd = vfs_open_cdev("tty");
        if (tvfd >= 0) current->fd_table[oldfd] = tvfd;
    }
    int32_t r = fd_dup2(current->fd_table, oldfd, (int32_t)a2);
    return (r < 0) ? (uint64_t)(-EBADF) : (uint64_t)(int64_t)r;
}

错误返回值:-1 vs -EBADF

fd_dup2 返回 int32_t -1 表示失败。若直接 return (uint64_t)(int64_t)(-1),用户态看到的是 0xFFFFFFFFFFFFFFFFerrno = -(int64_t)ret = 1 = EPERM

必须显式映射:

return (r < 0) ? (uint64_t)(-EBADF) : (uint64_t)(int64_t)r;

最终结果

/ # echo hello > /tmp/a.txt
/ # cat /tmp/a.txt
hello

重启后:

/ # cat /tmp/a.txt
hello

文件写入正常,重启后持久化。

小结

ext2 写操作的核心是 bitmap 管理:alloc_inodealloc_block 找空闲位、置位、更新计数;ext2_dir_add 在最后一条目后插入新条目;sync_meta 确保元数据落盘。

fd 引用计数是实现 dup2 的基础,少了它就会 use-after-free。tty 进入 VFS 则是让标准输入输出能参与 dup2 重定向的前提。这些组合在一起,才能让 shell 的 I/O 重定向完整工作。