上一章 ls 能运行了,但打印出来的目录内容是空的——因为还没有实现 getdents64。这一章把目录列表、cd、pwd 全部补齐,同时碰到了一个 -O2 下内存乱序导致的调度器死循环 Bug。
SYS_GETDENTS64
Linux 接口
int getdents64(int fd, struct linux_dirent64 *dirp, unsigned int count);
每个条目的结构:
struct linux_dirent64 {
uint64_t d_ino; // inode 号
int64_t d_off; // 到下一条目的偏移(可用递增序号代替)
uint16_t d_reclen; // 本条目总字节数(含填充,8 字节对齐)
uint8_t d_type; // 文件类型(4=目录,8=普通文件)
char d_name[]; // 文件名(null 结尾)
};
d_reclen 必须 8 字节对齐,计算方式:
uint16_t reclen = (uint16_t)((19 + namelen + 1 + 7) & ~7);
// ↑固定头部 ↑名字 ↑null ↑对齐
内核实现
在 VFS 层实现 vfs_getdents,遍历 ext2 目录的所有条目,逐个填写 linux_dirent64 并写入用户缓冲区:
int vfs_getdents(int fd, uint64_t uva, uint32_t count) {
// 1. 检查 fd 是否为目录
// 2. 从 offset 开始遍历 ext2 目录
// 3. 跳过 "." 和 ".."(busybox ls 不显示它们)
// 4. 每个条目:填 d_ino、d_type、d_name、d_reclen
// 5. 通过 copy_to_user 写入用户空间
// 6. 更新 file->offset,返回写入的总字节数
}
关键:返回 0 表示目录已读完(EOF),busybox ls 据此停止调用。
ext2 目录遍历
ext2 目录条目格式(ext2_dirent):
[inode:4][rec_len:2][name_len:1][file_type:1][name:name_len]
遍历时 pos += de->rec_len 跳到下一条目,直到 pos >= block_size。
SYS_CHDIR
进程 cwd 字段
在 process_t 中增加 cwd[256] 字段存储当前目录路径,初始化为 "/":
typedef struct process {
// ...
char cwd[256];
} process_t;
fork 时子进程继承父进程的 cwd(*child = *parent 已自动复制)。exec 时保留 cwd,不重置(POSIX 规定 execve 不改变工作目录)。
实现
case SYS_CHDIR: {
char path[256];
copy_str_from_user(path, a1, sizeof(path));
// 验证路径存在且是目录
int ino = vfs_path_lookup(path, current->cwd);
if (ino < 0) return (uint64_t)(-ENOENT);
// 规范化并更新 cwd
if (path[0] == '/') {
strncpy(current->cwd, path, 255);
} else {
// 拼接:cwd + "/" + path,再做 ".." 处理
path_join(current->cwd, path);
}
return 0;
}
SYS_GETCWD
case SYS_GETCWD: {
uint32_t len = (uint32_t)a2;
uint32_t cwdlen = strlen(current->cwd) + 1;
if (cwdlen > len) return (uint64_t)(-ERANGE);
copy_to_user(a1, (uint8_t *)current->cwd, cwdlen);
return a1; // 返回用户缓冲区地址(Linux 约定)
}
VFS 相对路径支持
之前 vfs_path_lookup 只支持绝对路径(从 / 出发)。为了让 cd bin(相对路径)和 openat(AT_FDCWD, "bin/ls") 正常工作,需要传入起始 inode:
int vfs_path_lookup_from(const char *path, int start_ino);
- 路径以
/开头:从根 inode 出发 - 路径不以
/开头:从start_ino出发
syscall 层的 openat、newfstatat、getdents 等收到 AT_FDCWD(-100)时,传入 vfs_lookup_inode(current->cwd) 作为起始 inode。
Bug:proc_exit 后 kresume 死循环
现象
ls 打印完目录内容后调用 exit_group(0),但串口日志显示调度器一直在保存和恢复 pid=2(ls):
[sc2] nr=0xe7 (exit_group) a1=0
[sched] save ksp pid=2 ksp=0x100f48
[sched] ->kresume pid=2
[sched] save ksp pid=2 ksp=0x100f48
[sched] ->kresume pid=2
...(无限循环)
原因:-O2 内存乱序
proc_exit 的 C 代码逻辑是:
current->state = PROC_ZOMBIE; // 1. 标记为 ZOMBIE
current = NULL; // 2. 清空 current 指针
current_proc = -1; // 3. 清空 current_proc
__asm__ volatile(
"mov %0, %%rsp\n\t"
"sti\n\t" // 4. 开中断
"1: hlt\n\t"
"jmp 1b"
:: "r"(kstack), "r"(cr3val)
);
用 -O2 编译时,GCC 可能将步骤 1/2/3 的内存写延迟到 inline asm 之后(编译器认为 asm 不依赖这些变量)。
结果:PIT 在 sti 之后触发,此时:
current_proc仍为 2(没有被提前写)procs[2].state仍为PROC_RUNNING(ZOMBIE 写被推迟)- 调度器条件满足,把 ksp 保存到 pid=2,然后
kresumepid=2 - 进入死循环
修复:内存屏障
在 inline asm 前加 __asm__ volatile("" ::: "memory"),强制编译器在屏障前提交所有内存写:
current->state = PROC_ZOMBIE;
current = NULL;
current_proc = -1;
__asm__ volatile("" ::: "memory"); // ← 内存屏障,确保以上写操作已提交
__asm__ volatile(
"mov %0, %%rsp\n\t"
"sti\n\t"
"1: hlt\n\t"
"jmp 1b"
:: "r"(kstack), "r"(cr3val)
);
经验:内核中的 inline asm 前后,凡是有语义依赖的内存写,必须加 "memory" clobber 或显式屏障。-O2 对非 volatile 变量的乱序是合法的优化,不加屏障是 UB。
关键:QEMU 启动参数
用 if=floppy / if=ide 方式在新版 QEMU(10.x)下 ATA 会卡死(DRQ 永远不置位)。必须用原始参数:
qemu-system-x86_64 \
-m 256M \
-drive format=raw,file=myos.img \
-drive format=raw,file=ext2.img \
-nographic -serial mon:stdio
两个 -drive 不指定 if=,QEMU 自动把第二个挂到 IDE bus 0,ATA PIO 能正常工作。
最终结果
/ # ls
bin etc lost+found
/ # cd bin
/bin # pwd
/bin
/bin # ls
busybox cat echo ls sh
/bin #
ls 正确列出目录内容,cd / pwd 正常工作,shell 提示符动态更新。
小结
getdents64 的实现主要是格式转换:把 ext2 的 dirent 格式翻译成 Linux 的 linux_dirent64 格式,注意 d_reclen 的 8 字节对齐。相对路径支持需要把 AT_FDCWD 语义下沉到 VFS 层。
内存屏障 Bug 是这一章最有价值的教训:内核代码里的 inline asm 和周围 C 代码的内存操作顺序,在 -O2 下不能想当然地认为按源码顺序执行,凡是有跨越 asm 边界的语义依赖,必须显式加屏障。