简介:操作系统课程设计综合性实践教学资料,面向计算机科学专业学生及课程指导教师,聚焦进程管理、内存管理、文件系统、设备驱动等核心模块的设计与实现训练。包内为1个PDF文档,文件体量约255KB,内容覆盖课程目标、对应毕业要求指标点、可选题目、设计任务、考核权重与组织方式等完整环节。其中可选题目从虚拟机安装、Linux内核重编译等基础任务,延伸到新增系统调用、自定义文件系统、驱动开发与缺页统计等进阶实验,适合不同层级的学生按需选用。资源目前已有107人浏览学习。通过该文档,读者可以快速了解操作系统课程设计的考核标准与实施流程,明确各题目需要掌握的内核机制、模块编写思路及报告与答辩要求,有助于制定实验方案、分配团队角色并规范完成设计报告,为完整经历一次高质量的课程设计提供清晰参考。
1. 环境选型与内核编译的前置决策
组里五个人分别抽到系统调用、文件系统、驱动、缺页统计和进程通信,看起来各做各的,实际上只有把内核编环境和模块编译链打通,才能保证后面几个题不互相拖累。很多组第一周就花在“从 kernel.org 拉源码然后全量编译”上,结果发现虚拟机磁盘不够、编译到一半报错,或者 insmod 时提示版本 magic 不一致。比较省事的方案是装 CentOS 或 SUSE,安装时勾选 Development Tools,再用 yum/dnf 单独安装 kernel-devel 和 kernel-headers,这样/lib/modules/$(uname -r)/build这个符号链接才真正指向可用的内核树。后续重编内核时,源码版本必须和 uname -r 对应,否则模块加载那一步会出现 Invalid module format。先把这步做实,后面题目涉及的 Makefile、Kconfig 和 /proc 接口都会好处理很多。
2. 新增系统调用与改造ext4:内核树里的两次动手
2.1 内核树准备与编译参数选择
修改系统调用和文件系统都需要对内核源码做改动,再把新内核安装到虚拟机里。最稳妥的做法是先拿到当前内核对应的源码包,而不是重新从 kernel.org 下载一个完全不相关的版本。以 CentOS 为例,uname -r显示的是带发行版后缀的版本号,直接去 kernel.org 找同名源码经常会编译出带-xxx后缀的版本,导致头文件路径不一致。
配置阶段我一般执行make menuconfig,在 General setup 里打开Kernel .config support,方便后面组件查询配置项。编译命令分三步:
make -j$(nproc) sudo make modules_install sudo make installmake -j的并发数不要超过虚拟机分配的 CPU 核心数,否则容易 OOM。安装完成后reboot,用uname -r确认启动的是新内核。如果只是做模块编译,不需要每次都全量编内核,但新增系统调用和修改 ext4 源码这两个题必须走完整流程,因为最终要验证的功能只在自编译的内核里生效。
2.2 新增系统调用:从函数体到系统调用表
扩展系统调用的标准做法是三步:写函数体、声明原型、注册到系统调用表。以“计算一个数字的三次方并打印”为例,在内核源码kernel/sys.c末尾追加:
SYSCALL_DEFINE1(my_cube, long, x) { long result = x * x * x; printk(KERN_INFO "my_cube: %ld^3 = %ld\n", x, result); return result; }SYSCALL_DEFINE1是必须的包装宏,它负责把用户态参数按寄存器规则传递到内核态,并生成名为sys_my_cube的导出符号。函数体内用printk把计算结果写入内核日志,便于后续用dmesg验证调用是否真正落到了新内核。
接着修改系统调用表,x86_64 下对应的文件是arch/x86/entry/syscalls/syscall_64.tbl。找一个尚未占用的系统调用号,追加一行:
548 common my_cube sys_my_cube系统调用号不是随便选,必须比__NR_syscalls小,否则内核启动时数组越界。建议先grep -n "common" syscall_64.tbl | tail看当前最大编号。最后在include/linux/syscalls.h中添加用户态可见的函数原型:
asmlinkage long sys_my_cube(long x);编译安装后,写一个用户态小程序测试:
#include <unistd.h> #include <sys/syscall.h> #include <stdio.h> int main(void) { long ret; ret = syscall(548, 3); printf("syscall returned %ld\n", ret); return 0; }syscall(548, 3)直接绕过 libc,把编号 548 传给rax寄存器,参数 3 传入rdi。运行后若dmesg出现my_cube: 3^3 = 27,说明用户态到内核态的完整链路已经走通。常见问题有两个:一是syscall_64.tbl里编号写成了已有编号,导致新调用被旧函数覆盖;二是忘记在syscalls.h声明,编译时提示隐式函数声明。
2.3 改造ext4为“新文件系统”:模块化编译与动态加载
文件系统题的核心是把 ext3/ext4 的源代码复制一份,改掉名字,再让内核以模块方式加载这个“新文件系统”。直接改 ext4 源码里的函数名也不现实,课程设计的验收重点是能看到mount -t myext4动态挂载成功,并且文件写操作能打印信息。
先复制源码树,并全局替换前缀,避免和内核里已有的 ext4 符号冲突:
cp -r fs/ext4 fs/myext4 cd fs/myext4 sed -i 's/ext4/myext4/g' *.c *.h只改字符串替换还不够,还需要处理几个关键文件。下表是常见做法里必须改动的位置:
| 文件 | 需要修改的内容 | 说明 |
|---|---|---|
fs/myext4/Makefile | 将obj-$(CONFIG_EXT4_FS)改为指向本目录的模块变量 | 让新目录参与独立编译 |
fs/myext4/Kconfig | 在原来 CONFIG 项的基础上复制一个MYEXT4_FS配置项 | 为make menuconfig提供开关 |
fs/myext4/ext4.h及子模块 | 将文件名、超级块 magic、错误识别串全部改为MYEXT4 | 避免和内核内置 ext4 冲突 |
fs/myext4/super.c | 修改struct file_system_type的name字段为myext4 | mount -t依赖这个名字 |
编译时不要直接执行make全量编内核,而是用内核模块方式只编这个目录:
make -C /lib/modules/$(uname -r)/build M=fs/myext4 modules-C指定内核 build 目录,M=指定外部模块源码位置。编译成功后生成myext4.ko,用下面命令加载和挂载:
insmod myext4.ko mkdir -p /mnt/myext4 mount -t myext4 /dev/sdb1 /mnt/myext4挂载前需要有一个真实分区或文件镜像,我用dd创建一个 128MB 的文件,再 format 成新文件系统。mount成功后dmesg会输出该文件系统自己的标识。如果 insmod 报Unknown symbol,多半是随内核编译时没有把原来的 ext4 一起改为模块,需要确认内核里原来的 ext4 仍是内置,否则两个文件系统同时归类为同一个 fs type 会冲突。
2.4 在文件写操作链路上埋打印信息
题目要求“至少能对文件写操作向系统后台打印出信息”,这里我选择在ext4_file_write_iter这个入口函数动手。该函数是 ext4 写操作的顶层入口,所有普通写请求都会经过它。改造后的代码可以在文件写入量大于 0 时记录一次完整日志:
static ssize_t myext4_file_write_iter(struct kiocb *iocb, struct iov_iter *from) { ssize_t ret; if (iov_iter_count(from) > 0) printk(KERN_INFO "myext4: write to %s, %zu bytes\n", file_dentry(iocb->ki_filp)->d_name.name, iov_iter_count(from)); ret = ext4_file_write_iter(iocb, from); return ret; }iov_iter_count返回当前请求的字节数,file_dentry(...)->d_name.name取文件名。更实用的做法是再增加一个struct file_operations中的地址重定向,把这个自定义函数挂到新文件系统对应的fops上。由于经过全局替换后原有ext4_file_write_iter已经被识别为普通函数,这里直接用原名调用即可,不会构成递归。
验证时在/mnt/myext4下随便写一个文件,然后查看内核日志:
echo "hello" > /mnt/myext4/hello.txt dmesg | tail -1如果只打印了挂载信息而没有写入日志,第一排查点是函数是否在编译时被内联优化掉,第二排查点是fs/myext4/file.c中的file_operations是否真的指向了新函数,而不是还挂在旧的 ext4 符号上。课程设计验收时并不要求文件系统性能达标,只要写操作有可见日志并保持数据完整性就算通过。
3. 内存模拟驱动与缺页统计:设备驱动和proc节点实现
3.1 用miscdevice实现256MB内存模拟设备
驱动题的常见做法是用miscdevice字符设备框架,因为它的注册方式比register_chrdev_region更简洁,自动分配主设备号,并且能支持多个实例。用kmalloc分配 256MB 连续内存基本都会失败,所以使用vmalloc分配非连续物理页面,再配合copy_to_user/copy_from_user做数据搬运。
驱动骨架如下:
#include <linux/module.h> #include <linux/miscdevice.h> #include <linux/vmalloc.h> static char *mem_buf; static ssize_t mem_read(struct file *file, char __user *buf, size_t count, loff_t *ppos) { if (*ppos >= 256 * 1024 * 1024) return 0; if (count > 256 * 1024 * 1024 - *ppos) count = 256 * 1024 * 1024 - *ppos; if (copy_to_user(buf, mem_buf + *ppos, count)) return -EFAULT; *ppos += count; return count; } static ssize_t mem_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos) { if (*ppos >= 256 * 1024 * 1024) return -ENOSPC; if (count > 256 * 1024 * 1024 - *ppos) count = 256 * 1024 * 1024 - *ppos; if (copy_from_user(mem_buf + *ppos, buf, count)) return -EFAULT; *ppos += count; return count; } static struct file_operations mem_fops = { .owner = THIS_MODULE, .read = mem_read, .write = mem_write, .llseek = generic_file_llseek, }; static struct miscdevice mem_dev = { .minor = MISC_DYNAMIC_MINOR, .name = "cdesign_memdev", .fops = &mem_fops, }; static int __init memdrv_init(void) { mem_buf = vmalloc(256 * 1024 * 1024); if (!mem_buf) return -ENOMEM; return misc_register(&mem_dev); } static void __exit memdrv_exit(void) { misc_deregister(&mem_dev); vfree(mem_buf); } module_init(memdrv_init); module_exit(memdrv_exit); MODULE_LICENSE("GPL");vmalloc在内核里分配的是虚拟地址连续的内存,物理页面可以不连续,但用户态读写时我们是按整体偏移量访问的,所以对使用者没有感知。misc_register会在/dev下自动创建设备节点,默认权限只有 root 能访问,测试时用普通用户写dd会报权限错误,可以临时chmod 666 /dev/cdesign_memdev。
编译模块时内核树必须已经安装了上一章提到的kernel-devel,然后:
make -C /lib/modules/$(uname -r)/build M=$PWD modules insmod cdesign_mem.ko ls -l /dev/cdesign_memdev验证 256MB 读写能力,我直接用dd在两个偏移处写入特征数据:
echo -n "A" | dd of=/dev/cdesign_memdev bs=1 seek=268435455 dd if=/dev/cdesign_memdev bs=1 skip=268435455 count=1第二条命令读到A,说明模块的偏移量定位和边界判断正确。这里的seek和skip会调用llseek,所以llseek不能省略。
3.2 缺页统计:从handle_mm_fault到/proc输出
统计系统缺页次数,课程设计的原始思路是在内核里维护一个计数器,再通过/proc暴露给用户态。常见改法是直接在mm/memory.c的handle_mm_fault函数入口增加计数:
#include <linux/atomic.h> static atomic_t cdesign_pgfault_counter = ATOMIC_INIT(0); int handle_mm_fault(struct vm_fault *vmf, unsigned long flags) { atomic_inc(&cdesign_pgfault_counter); ... }这里用atomic_t避免多 CPU 并发加 1 造成计数丢失。handle_mm_fault在缺页异常时都会被调用,所以这个计数覆盖了所有类型缺页。
把计数暴露给用户态,推荐使用proc_ops新接口,比老式file_operations少了 llseek 的坑。在fs/proc/cdesign_mem.c里注册:
static int pgfault_show(struct seq_file *m, void *v) { seq_printf(m, "%u\n", atomic_read(&cdesign_pgfault_counter)); return 0; } static int pgfault_open(struct inode *inode, struct file *file) { return single_open(file, pgfault_show, NULL); } static const struct proc_ops pgfault_fops = { .proc_open = pgfault_open, .proc_read = seq_read, .proc_release = single_release, };proc_ops是 Linux 5.6 之后的标准写法,老版本内核还叫file_operations,字段名同理。将.proc_open指向single_open,把内存页面和输出函数绑定在一起,.proc_read直接复用内核自带的seq_read,这样用户态cat /proc/pgfaults就能拿到计数。
3.3 用户态验证与数据解读
新内核编译安装后,加载模块并用脚本观察:
cat /proc/cdesign_pgfaults find /usr/src -type f | head -100 > /dev/null cat /proc/cdesign_pgfaults两次读出的计数差异表示find遍历目录时触发了多少次缺页。缺页次数并不是越高越差,程序冷启动阶段缺页是正常行为。比较有信息量的验证是比较mincore前后的计数变化:先mlockall锁定内存,再读一次计数,计数几乎不变。
调试时常见问题是在/proc节点里读到的计数恒为 0。若是模块方式导出计数,那么计数变量必须来自同一份内核符号,但模块内不能直接访问mm/memory.c里的 static 变量,所以这个题实际上更适合把计数代码直接编进内核,再用/proc模块读取。或者使用kprobe函数级探针挂到handle_mm_fault上,避免重编内核,不过课程设计要求的“编译并安装新内核”流程会丢失这部分得分点。我一般保留内核补丁法和 kprobe 两套实验数据,再在报告里对比两者开销。
4. 阅览室问题:信号量与共享内存的进程同步设计
4.1 信号量建模:座位数与读者身份区
阅览室问题的边界是 5 个座位、多个读者进程,读者需要经历注册、阅读、注销三个阶段。注册和注销都操作同一份读者名单,必须保证互斥;座位数量则是数量信号量的典型用途。这里建模成两个信号量:
| 信号量 | 初值 | 含义 | 操作时机 |
|---|---|---|---|
empty | 5 | 剩余空座位 | 注册时sem_wait(empty),注销时sem_post(empty) |
mutex | 1 | 读者名单互斥访问 | 注册和注销时保护共享内存结构 |
empty控制同时进入阅览室的最大人数,mutex只保护临界区,不直接控制座位。如果把empty初值改成 1,就变成阅览室只允许一个读者进入,与题目不符。
需要记录每位读者的手机号或身份信息,所以共享内存里放数组:
struct reader_info { char id[16]; char phone[12]; }; struct shared_data { struct reader_info seats[5]; int count; };count表示当前正在阅读的读者数量,方便程序在注册时找空位。
4.2 注册与注销的代码实现
这里使用 POSIX 信号量和共享内存,因为接口比 System V 更简单,资源回收也更不容易遗漏。创建共享内存:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <fcntl.h> #include <semaphore.h> #include <sys/mman.h> #include <sys/stat.h> #define SEATS 5 static struct shared_data *shm; static void register_reader(const char *id, const char *phone) { int i; sem_wait(sem_empty); sem_wait(sem_mutex); for (i = 0; i < SEATS; i++) { if (shm->seats[i].id[0] == '\0') { strncpy(shm->seats[i].id, id, sizeof(shm->seats[i].id) - 1); strncpy(shm->seats[i].phone, phone, sizeof(shm->seats[i].phone) - 1); shm->count++; printf("reader %s registered\n", id); break; } } sem_post(sem_mutex); }同理注销时先sem_wait(sem_mutex),找到匹配id的槽位后清空并递减count,然后sem_post(sem_mutex),最后sem_post(sem_empty)。信号量的创建代码如下:
sem_empty = sem_open("/sem_empty", O_CREAT, 0644, SEATS); sem_mutex = sem_open("/sem_mutex", O_CREAT, 0644, 1);sem_open的第一个参数是命名信号量,O_CREAT表示不存在时创建,最后一个参数是初值。每个读者进程必须都调用sem_open,并且指向同一名字,才能达成互斥。如果只在父进程创建子进程前sem_init一次,子进程会继承信号量对象,但这要求父子进程共享同一地址空间,和“多个读者进程”的标准场景不吻合。我推荐命名信号量,就是因为不同exec出来的进程之间也能访问同一文件系统路径上的信号量。
阅读阶段用sleep模拟占用座位。若想体现并发,可以让每个读者进程休眠时间不同,然后观察共享内存里同时存在的读者数量最大不超过 5。
共享内存初始化:
int fd = shm_open("/reader_shm", O_CREAT | O_RDWR, 0644); ftruncate(fd, sizeof(struct shared_data)); shm = mmap(NULL, sizeof(struct shared_data), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);ftruncate把共享内存对象大小设置为结构体大小,mmap的MAP_SHARED保证一个进程的修改对另一个进程可见。
4.3 多进程调试与残留下处理
并发程序最容易出现的问题是信号量初值不对,导致第二批读者全部阻塞。验证方式是在注册前后打印sem_getvalue:
int val; sem_getvalue(sem_empty, &val); printf("empty = %d\n", val);如果empty在注销后没有恢复为 5,说明某个读者进程在sem_wait(empty)之后异常退出,没有执行sem_post(sem_empty)。这种问题在程序崩溃后会出现,信号量里的值因此永久丢失。调试期间执行清理命令:
ipcs -m ipcrm -m <shmid>POSIX 信号量不会由内核自动删除,用完后最好sem_unlink("/sem_empty")。另外注意sem_wait必须检查返回值,若被信号中断会返回-1且errno为EINTR,一个健壮的实现需要循环重试。这项检查也是答辩时老师常问的细节:为什么不加SA_RESTART时同一个进程会随机卡死。
5. 从测试到答辩:验收手段与文档组织技巧
答辩前最怕的不是功能没过,而是现场环境里模块加载失败后不知道问题出在哪。我在每个题目目录里都放了一个verify.sh,把编译、加载、功能验证、卸载四步全部串起来。以驱动题为例,脚本会检查/dev/cdesign_memdev是否存在、写读 256MB 边界数据是否一致,然后把dmesg里的错误信息输出到verify.log。答辩时只需要跑一遍脚本再现场打开日志,比现场手打命令更紧凑。
文档方面,建议把设计思想单独写成“从需求到模块划分”的段落,不要用大篇幅贴代码。老师更关心你如何在handle_mm_fault这个入口插入计数器、为什么选择miscdevice而不是register_chrdev_region,以及信号量初值怎么被推导出来。报告里的流程图不用画得过于复杂,把读者注册/注销的时序画清楚即可。
一个实用的包装技巧是给所有内核改动做成 diff 文件,并在报告附录里列出每个 diff 对应的测试命令。这样现场答辩即使只给了十分钟,也可以快速定位到某个函数并回答“为什么这里用atomic_inc”。最后一定要保留一个干净的原始内核快照,出问题时在几分钟内恢复环境,而不是重新编译二十分钟。这些准备不会增加太多工作量,但能让验收过程稳定不少。
本文还有配套的精品资源,点击获取