拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Linux内核模块开发实战:系统调用、文件系统扩展与设备驱动

简介操作系统课程设计综合性实践教学资料面向计算机科学专业学生及课程指导教师聚焦进程管理、内存管理、文件系统、设备驱动等核心模块的设计与实现训练。包内为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字段为myext4mount -t依赖这个名字编译时不要直接执行make全量编内核而是用内核模块方式只编这个目录make -C /lib/modules/$(uname -r)/build Mfs/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 bs1 seek268435455 dd if/dev/cdesign_memdev bs1 skip268435455 count1第二条命令读到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 个座位、多个读者进程读者需要经历注册、阅读、注销三个阶段。注册和注销都操作同一份读者名单必须保证互斥座位数量则是数量信号量的典型用途。这里建模成两个信号量信号量初值含义操作时机empty5剩余空座位注册时sem_wait(empty)注销时sem_post(empty)mutex1读者名单互斥访问注册和注销时保护共享内存结构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_getvalueint val; sem_getvalue(sem_empty, val); printf(empty %d\n, val);如果empty在注销后没有恢复为 5说明某个读者进程在sem_wait(empty)之后异常退出没有执行sem_post(sem_empty)。这种问题在程序崩溃后会出现信号量里的值因此永久丢失。调试期间执行清理命令ipcs -m ipcrm -m shmidPOSIX 信号量不会由内核自动删除用完后最好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”。最后一定要保留一个干净的原始内核快照出问题时在几分钟内恢复环境而不是重新编译二十分钟。这些准备不会增加太多工作量但能让验收过程稳定不少。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门