Home Of Steesha

BLOG

XDU 操作系统课程设计

228
2026-01-12
XDU 操作系统课程设计

注意

本实验内容均为本人原创,禁止在不思考情况下直接将代码与详解提交至自己的作业与实验报告,本文仅分享题目思路,帮助读者修改一些偏门的BUG,节省读者的课程设计时间。

如果使用大语言模型进行代码编写或代码修改,其对操作系统内核相关代码开发问题非常多,会有很多小众函数/宏,还有一些非常固定的编码习惯,会很轻易的看出,如果你没有思考直接将代码提交或验收,基本上非常容易看出哪几个人的代码是同一个模子刻出来的,或者是完全AI写的,即使你的输入输出与标准输出吻合,你也有必要了解代码中的所有细节与原理。(如果你要验收的话)

本课程老师是李航,验收时要求较高,会递归式的问你问题,直到你无法答出(部分验收问题在文章末尾写着),建议第一次就把所有的东西搞懂后再去验收,会给你多次机会验收,可以坚持去几次刷分。

实验平台

学校提供的OpenKylin虚拟机,里面的qemu是学校提供的,加入了edu设备,不建议在其他系统上进行实验,如果不得不使用其他系统,请同步qemu。

题目一:内核API

实验内容

设计一个内核模块,并在此内核模块中创建一个内核链表以及两个内核线程。

线程 1 需要遍历进程链表并将各个进程的pid、进程名加入到内核链表中。

线程 2 中需不断从内核链表中取出节点并打印该节点的元素。

在卸载模块时停止内核线程并释放资源。

代码

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/list.h>
#include <linux/sched.h>
#include <linux/kthread.h>
#include <linux/rcupdate.h>
#include <linux/slab.h>
#include <linux/delay.h>
#include <linux/sched/signal.h>
#include <linux/string.h>

MODULE_LICENSE("GPL");

MODULE_AUTHOR("FuShengyuan");

MODULE_DESCRIPTION("A simple kernel module with a list and two threads.");

static struct list_head my_list;
static struct task_struct *thread1, *thread2;

spinlock_t lock;

// 定义链表节点结构
struct pid_node
{
    int pid;
    char comm[16];
    struct list_head list;
};

// thread1 函数体
static int thread1_func(void *data)
{
    struct task_struct *p;
    struct pid_node *tmp_pid_node;

    while(!kthread_should_stop()){
	    spin_lock(&lock);
	    for_each_process(p)
	    {
            // 代码这里是错误的,不能使用 GFP_KERNEL 而是应该使用 GFP_ATOMIC
		    tmp_pid_node = (struct pid_node*)kmalloc(sizeof(struct pid_node), GFP_KERNEL);
		    tmp_pid_node->pid = p->pid;
	            memcpy(tmp_pid_node->comm, p->comm, TASK_COMM_LEN);
		    printk(KERN_INFO "[thread1] list_add (pid=%d, name=%s)\n", p->pid, p->comm);
		    list_add(&tmp_pid_node->list, &my_list);
	    }
	    spin_unlock(&lock);
	    msleep_interruptible(1000);
    }
    return 0;
}
// thread2 函数体
static int thread2_func(void *data)
{
    struct list_head *pos, *n;
    struct pid_node *tmp_pid_node;
    while(!kthread_should_stop()){
	    spin_lock(&lock);
	    list_for_each_safe(pos, n, &my_list)
	    {
	    	tmp_pid_node = list_entry(pos, struct pid_node, list);
	    	printk(KERN_INFO "[thread2] list_for_each_safe (pid=%d, name=%s)\n", tmp_pid_node->pid, tmp_pid_node->comm);
			list_del(&tmp_pid_node->list);
            kfree(tmp_pid_node);
        }
	    spin_unlock(&lock);
	    msleep_interruptible(100);
    }
    return 0;
}

// 模块初始化函数
int kernel_module_init(void)
{
    printk(KERN_INFO "List and thread module init\n");
    // Init SpinLock
    spin_lock_init(&lock);

    // Init list 
    INIT_LIST_HEAD(&my_list);

    // Start Threads
    thread1 = kthread_create(thread1_func, NULL, "myThread1");
    thread2 = kthread_create(thread2_func, NULL, "myThread2");
    wake_up_process(thread1);
    wake_up_process(thread2);
    return 0;
}

// 模块清理函数
void kernel_module_exit(void)
{
    struct pid_node *tmp_pid_node;
    struct list_head *pos, *n;
    // 停止线程1
    kthread_stop(thread1);
    // 停止线程2
    kthread_stop(thread2);
    // 清理链表
    spin_lock(&lock);
    list_for_each_safe(pos, n, &my_list)
    {
	    // get entry.
	    tmp_pid_node = list_entry(pos, struct pid_node, list);
	    // delete it from list.
	    list_del(pos);
	    // free it from heap.
	    kfree(tmp_pid_node);
	    tmp_pid_node = NULL;
    }
    spin_unlock(&lock);
    printk(KERN_INFO "List and thread module exit\n");
}

module_init(kernel_module_init);

module_exit(kernel_module_exit);

代码详解

1.thread1_func

首先使用for_each_process来遍历内核进程链表,然后对于每个p(task_struct*),我们kmalloc一个内存区域用于装我们的struct,然后我们拷贝进程名comm和pid到我们的struct并且使用锁来保证多线程安全,在申请锁后释放锁前,我们使用list_add来将我们的struct链表头加入到我们的主链表头my_list,就完成了数据的插入。

为了保证thread1一直运行到模块结束,我们使用while(!kthread_should_stop()) 进行保证,并且在循环中使用sleep防止影响系统性能。

2.thread2_func

首先我们与thread1_func一样,使用带kthread_should_stop()的循环,并且在循环中使用锁来保证多线程安全,然后使用list_for_each_safe()来遍历链表my_list,pos是当前节点,n是下一个节点,然后使用list_entry来访问pos节点的结构体,注意不能用list_first_entry,因为在遍历的时候,pos指向的是每一个节点,而list_first_entry会拿到pos的下一个节点(因为链表头也是一个节点),然后用kprint格式化输出相关信息,在遍历结束后释放锁防止死锁,最后sleep方便我们截图也防止影响系统性能。

题目二:deferred work

实验内容

设计并实现一个内核模块,该模块旨在通过work queue和kernel thread两种不同的机制 来完成延迟工作(deferred work),并对比分析这两种实现方式的异同点。 具体实验步骤如下:

1. 分别采用work queue和kernel thread两种方式调用10个函数(函数内部打印学号后3 位依次加1的方式区分。例如函数1中打印315,函数2中打印316,以此类推),观 察并记录work queue与kernel thread在执行函数时的顺序差异。请注意,每个函数应 当对应一个独立的kernel thread,即10个函数需由10个不同的kernel thread分别执行。

2. 探究work queue中的delayed_work功能,要求在模块加载成功的5秒后打印一条预设 的信息,以验证delayed_work的延迟执行效果。

代码

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/sched.h>
#include <linux/kthread.h>
#include <linux/slab.h>
#include <linux/delay.h>
#include <linux/workqueue.h>
#include <linux/atomic.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("FuShengyuan");
MODULE_DESCRIPTION("deferred work");

/// @brief 存放work_struct与id信息
struct work_ctx
{
    struct work_struct work;
    int current_id;
};
struct delayed_work my_work;

struct work_ctx works[10];

struct task_struct *threads[10];

int numbers[10];

/// @brief kthread执行体
/// @param data 传入 *work_struct
/// @return
int kthread_handler(void *data)
{
    printk("kthread: %d\n", *(int*)data);
    // Wait for quit.
    while(!kthread_should_stop())
    {
           msleep_interruptible(1000);
    }
    return 0;
}
/// @brief work queue执行体
/// @param work
void work_queue_handler(struct work_struct *work)
{
    // obtain the pointer of work_ctx from work.
    struct work_ctx* ctx = container_of(work, struct work_ctx, work);
    printk("work queue : %d\n", ctx->current_id);
}
/// @brief delayed work执行体
/// @param work
void delayed_work_handler(struct work_struct *work)
{
    printk("delayed work!\n");
}

/// @brief 内核模块初始化
/// @param
/// @return
int deferred_work_init(void)
{
    printk(KERN_INFO "deferred work module init\n");
    int i, stu_base_num;
    struct task_struct* myKthread;

    stu_base_num = 55;
    for(i = 0;i < 10;i++)
    {
            numbers[i] = stu_base_num + i;
    }

    // Init Delayed Work.
    INIT_DELAYED_WORK(&my_work, delayed_work_handler);
    // Use msecs_to_jiffies to convert.
    if(!schedule_delayed_work(&my_work, msecs_to_jiffies(5000)))
    {
            printk(KERN_INFO "failed to schedule delayed_work\n");
    }

    // Init Work.
    for(i = 0;i < 10;i++)
    {
            works[i].current_id = numbers[i];
            INIT_WORK(&works[i].work, work_queue_handler);
            if(!schedule_work(&works[i].work))
            {
                    printk(KERN_INFO "failed to schedule work\n");
            }
    }

    // Init Kthread.
    for(i = 0;i < 10; i ++)
    {
            myKthread = kthread_create(kthread_handler, &numbers[i], "myKthread%d", i);
            if(!wake_up_process(myKthread))
            {
                    printk(KERN_INFO "myKthread is failed to wakeup.\n");
                    kthread_stop(myKthread);
                    continue;
            }
            // store myKthread handle.
            threads[i] = myKthread;
    }
    return 0;
}
/// @brief 内核模块退出
/// @param
void deferred_work_exit(void)
{
    // Stop all threads.
    int i;
    printk(KERN_INFO "Stopping all myKthread.\n");
    for(i = 0;i < 10;i++)
    {
            kthread_stop(threads[i]);
    }
    printk(KERN_INFO "deferred work module exit\n");
}

module_init(deferred_work_init);
module_exit(deferred_work_exit)

代码详解

首先我们在deferred_work_init中初始化numbers数组,因为如果传局部变量引用到kthread则不能保证在线程解引用的时候局部变量还可用,会导致空指针异常或者是无法预测的数值。

然后我们初始化并调度delayed_work,这里要注意的就是schedule_delayed_work的第二个参数是jiffies而不是msecs,经过查询我们找到msecs_to_jiffies函数,用于转换毫秒到jiffies。

然后我们初始化并调度10个work,并在work_queue_handler中使用container_of来从work_struct转换到我们的work_ctx,从而获取到参数current_id并输出。

最后初始化kthread,我注意到kthread_create的name是fmtstr,所以我采用了myKthread%d来区分线程名(貌似线程名一样也可以运行,但是线程名不能为NULL,会导致系统崩溃),然后wake_up_process来唤醒线程并保存线程句柄,在线程中打印完成参数后进入休眠,并时刻接受should_stop信号,和实验1一样,如果不接受直接return会导致线程提前退出,结构体则变得无效,在kthread_stop执行时会导致系统崩溃。

最后,在uninstall的时候,结束掉所有kthread。

题目三:edu设备驱动

实验内容

本次实验旨在让学生深入理解并实践 edu 设备驱动的开发。实验中,我们将提供 edu 设备驱动的框架代 码,学生需在此基础上完成关键代码的实现。具体实验要求如下:

1.补全框架中的 TODO 位置的缺失代码,包含 TODO 的函数如下所示:

○ edu_driver_probe

为 edu_dev_info 实例分配内存

将 BAR 的总线地址映射到系统内存的虚拟地址

○ edu_driver_remove

从设备结构体 dev 中提取 edu_dev_info 实例

补全 iounmap 函数的调用的参数

释放 edu_dev_info 实例

○ kthread_handler

将用户传入的变量交给 edv 设备进行阶乘计算,并读出结果,注意加锁。

结果放入 user_data 中的 data 数据成员中时,需要确保读写原子性

○ edu_dev_open

完成 filp 与用户进程上下文信息的绑定操作

○ edu_dev_release

释放 edu_dev_open 中分配的内存

○ edu_dev_unlocked_ioctl

用户通过 ioctl 传入要计算阶乘的数值,并读取最后阶乘的结果。计算阶乘使用内核线程, 线程代码放在 kthread_handler 中。

实现驱动程序的 ioctl 调用处理功能。该调用需接收一个整型参数。当驱动程序接收到用 户的 ioctl 调用后,需创建一个内核线程。在该内核线程中,利用edu 设备的阶乘功能对 传入的整型参数进行计算,并将计算结果存储于驱动程序中,以便用户进程后续获取。

驱动程序需具备识别不同进程调用的能力,确保将计算结果正确返回给对应的调用进程。

2. 编写 C 语言应用程序,通过调用 edu 驱动的 ioctl 接口进行操作。首先,设置参数cmd 值为0, 输入待计算的数值。等待一定时间后,将参数 cmd 值更改为1,再次调用ioctl 接口,以获取设 备计算完成的结果。

代码

#include <asm/io.h>
#include <linux/module.h>
#include <linux/pci.h>
#include <linux/delay.h>
#include <linux/pid.h>
#include <linux/kernel.h>
#include <linux/kthread.h>
#include <linux/types.h>

// 保存edu设备信息
struct edu_dev_info
{
	resource_size_t io;
	long range, flags;
	void __iomem *ioaddr;
	int irq;
};

static struct pci_device_id id_table[] = {
	{PCI_DEVICE(0x1234, 0x11e8)}, // edu设备id
	{
		0,
	} // 最后一组是0,表示结束
};

struct edu_dev_info *edu_info;
spinlock_t lock;

/// @brief edu设备发现函数
/// @param dev 
/// @param id 
/// @return 
static int edu_driver_probe(struct pci_dev *dev, const struct pci_device_id *id)
{
	int ret = 0;
	printk("executing edu driver probe function!\n");

	ret = pci_enable_device(dev);
	if (ret)
	{
		printk(KERN_ERR "IO Error.\n");
		return -EIO;
	}

	// allocate GFP_ATOMIC memory for edu_info
	edu_info = (struct edu_dev_info*)kmalloc(sizeof(struct edu_dev_info), GFP_ATOMIC);

	ret = pci_request_regions(dev, "edu_dirver"); // 申请一块驱动掌管的内存空间
	if (ret)
	{
		printk("PCI request regions err!\n");
		goto out_mypci;
	}

	//将写入BAR的总线地址映射到系统内存的虚拟地址
	edu_info->io = pci_resource_start(dev, 0);
	edu_info->range = pci_resource_len(dev, 0);
	edu_info->flags = pci_resource_flags(dev, 0);
	edu_info->irq = dev->irq;
	edu_info->ioaddr = pci_ioremap_bar(dev, 0);
	if(!edu_info->ioaddr)
	{
		printk("PCI ioremap_bar err!\n");
		goto out_regions;
	}


	pci_set_drvdata(dev, edu_info); // 设置驱动私有数据
	printk("Probe succeeds.PCIE ioport addr start at %llX, edu_info->ioaddr is 0x%p.\n", edu_info->io, edu_info->ioaddr);

	printk("id = %x\n", readl(edu_info->ioaddr + 0x00));
	writel(0xffeebbcc, edu_info->ioaddr + 0x04);
	printk("liveness = %x\n", readl(edu_info->ioaddr + 0x04));

	return 0;

out_regions:
	pci_release_regions(dev);
out_mypci:
	kfree(edu_info);
	return ret;
}

/// @brief edu设备移除函数
/// @param dev 
static void edu_driver_remove(struct pci_dev *dev)
{
	struct edu_dev_info *m_edu_info;
	// acquired the address of edu_dev_info instance
	m_edu_info = pci_get_drvdata(dev);
	if(!m_edu_info)
	{
		printk("edu_driver_remove: edu_info is NULL.\n");
		return;
	}
	// unmap pci io memory.
	iounmap(m_edu_info->ioaddr);
	pci_release_regions(dev);
	// free the instance of edu_dev_info
	kfree(m_edu_info);
	pci_disable_device(dev);
	printk("Device is removed successfully.\n");
}

MODULE_DEVICE_TABLE(pci, id_table); // 暴露驱动能发现的设备ID表单

static struct pci_driver pci_driver = {
	.name = "edu_dirver",
	.id_table = id_table,
	.probe = edu_driver_probe,
	.remove = edu_driver_remove,
};

// =============================================================================== //

#define EDU_DEV_MAJOR 200  /* 主设备号 */
#define EDU_DEV_NAME "edu" /* 设备名 */


int current_id = 0;

struct user_data
{
	int id;
	atomic64_t data;
};

struct thread_data
{
	struct user_data* user_data_ptr;
	uint32_t input_data;
};


int kthread_handler(void *data)
{
	struct thread_data* thread_data_ptr = (struct thread_data*)data;
	printk("kthread_handler thread_data_ptr = %p\n", thread_data_ptr);
	uint32_t value = thread_data_ptr->input_data;
	uint32_t state, result;
	printk("ioctl cmd 0 : factorial(%d)\n", (uint32_t)value);

	spin_lock(&lock);
	writel((unsigned int)value, edu_info->ioaddr + 0x8);
	do {
		state = readl(edu_info->ioaddr + 0x20);
		cpu_relax();
	} while(state & 0x01); // 0x01 == Computing.
	result = readl(edu_info->ioaddr + 0x8);
	printk("ioctl cmd 0 : factorial result = %d\n", result);
	spin_unlock(&lock);
	atomic64_set(&thread_data_ptr->user_data_ptr->data, result);

	kfree(data);
	return 0;
}



/// @brief open处理函数
/// @param inode 
/// @param filp 
/// @return 
static int edu_dev_open(struct inode *inode, struct file *filp)
{
	struct user_data* user_data_ptr = (struct user_data*)kmalloc(sizeof(struct user_data), GFP_KERNEL);
	if(!user_data_ptr)
	{
		printk("edu_dev_open: user_data_ptr kmalloc failed.\n");
		return -1;
	}
	user_data_ptr->id = current_id++;
	atomic64_set(&user_data_ptr->data, 0);

	// 设置filp->private_data
	filp->private_data = user_data_ptr;
	return 0;
}


/// @brief close处理函数
/// @param inode 
/// @param filp 
/// @return 
static int edu_dev_release(struct inode *inode, struct file *filp)
{
	kfree(filp->private_data);
	return 0;
}


/// @brief ioctl处理函数
/// @param pfilp_t 
/// @param cmd 
/// @param arg 
/// @return 
long edu_dev_unlocked_ioctl(struct file *pfilp_t, unsigned int cmd, unsigned long arg)
{
	struct user_data* user_data_ptr = (struct user_data*)pfilp_t->private_data;
	struct thread_data* thread_data_ptr;
	struct task_struct* handle;

	switch(cmd)
	{
		case 0: // Write
			thread_data_ptr = kmalloc(sizeof(struct thread_data), GFP_KERNEL);
			if(!thread_data_ptr)
			{
				printk("ioctl: kmalloc failed.\n");
				return -1;
			}

			thread_data_ptr->input_data = (uint32_t)arg;
			thread_data_ptr->user_data_ptr = user_data_ptr;
			handle = kthread_create(kthread_handler, thread_data_ptr, "calucate-thread-%d", user_data_ptr->id);
			if(!handle)
			{
				printk("ioctl: kthread create failed.\n");
				return -1;
			}
			wake_up_process(handle);
			return 0;
			break;
		case 1: // Read
			return atomic64_read(&user_data_ptr->data);
			break;
		default:
			return -ENOTTY; // Invalid Cmd.
	
	}
}


static struct file_operations edu_dev_fops = {
	.open = edu_dev_open,
	.release = edu_dev_release,
	.unlocked_ioctl = edu_dev_unlocked_ioctl,
};
/// @brief 驱动程序初始化
/// @param  
/// @return 
static int __init edu_dirver_init(void)
{
	printk("HELLO PCI\n");
	int ret = 0;
	// 注册字符设备
	ret = register_chrdev(EDU_DEV_MAJOR, EDU_DEV_NAME, &edu_dev_fops);
	if (0 > ret)
	{
		printk("kernel edu dev register_chrdev failure\n");
		return -1;
	}
	printk("chrdev edu dev is insmod, major_dev is 200\n");
	// 注册edu pci设备
	ret = pci_register_driver(&pci_driver);
	if (ret)
	{
		// 注销字符设备
		unregister_chrdev(EDU_DEV_MAJOR, EDU_DEV_NAME);
		printk("kernel edu dev pci_register_driver failure\n");
		return ret;
	}
	// 初始化自旋锁
	spin_lock_init(&lock);
	return 0;
}
/// @brief 驱动程序注销
/// @param  
/// @return 
static void __exit edu_dirver_exit(void)
{
	// 注销字符设备
	unregister_chrdev(EDU_DEV_MAJOR, EDU_DEV_NAME);
	// 注销edu pci设备
	pci_unregister_driver(&pci_driver);
	printk("GOODBYE PCI\n");
}

MODULE_LICENSE("GPL");

module_init(edu_dirver_init);
module_exit(edu_dirver_exit);

代码详解

首先在edu_driver_probe中,我先分配了一块空间给edu_info,我这里用的是GFP_ATOMIC,防止休眠,然后,根据edu.txt里面提供的数据,我们知道BAR0是我们需要映射的,我们使用pci_ioremap_bar将其映射到内核地址空间,随后设置驱动私有数据,设置为edu_info,方便我们在remove的时候获取并释放。最后在这个函数,我获取了设备的id,并且进行了liveness check来测试设备是否正常运行。

在edu_driver_remove里面,我使用pci_get_drvdata获取到在probe中设置的私有数据edu_info,并且对其ioaddr进行unmap,随后free掉edu_info整个结构体,然后只需要pci_release_regions和pci_disable_device即可卸载驱动。

对于kthread_handler,传入的data是thread_data,首先我对其进行解包,获取到usermode下用户ioctl传入的值,然后为了防止多线程冲突,访问物理设备内存必须上锁,随后我将其写到ioaddr+0x08的地方,用于让设备计算阶乘,然后对ioaddr+0x20的低位进行检查,当其为1的时候代表设备忙,为0的时候代表计算完毕,在不断等待中,我是用了cpu_relax来将控制权交给cpu,防止卡顿。在发现设备计算完毕后使用readl读取出结果,然后设置结果的原子变量即可,最后我们需要kfree掉data,因为这个data是在ioctl里面kmalloc的,不free就会内存泄漏。

在edu_dev_open里面,我kmalloc了一个user_data,然后为了区别他们,使用current_id++的方式来区分,并且初始化user_data_ptr->data防止未定义的行为,随后设置private_data用于到时候在ioctl里面读取。

edu_dev_release中,只需要free掉private_data(即open中malloc的user_data)即可。

然后,ioctl里面,也是程序中最重要的部分,我首先从private_data拿出user_data_ptr,随后判断用户是要write还是要read结果,如果是read则直接返回user_data的结果变量,如果是write就malloc一个thread_data并传递参数给线程(这里当时犯了个错误,传了个引用,调试了好久,毕竟thread_data直接就是指针,如果传引用就是局部变量,在thread里面的时候不光解引用错误导致kernel panic,而且这时候这个函数可能都结束了,局部变量已经不可用),然后为了区分线程,我们使用id来设置唯一的名字,随后wakeup线程。

在edu_driver_init里,我在注册edu pci设备失败的时候,注销了字符设备再返回。

题目四:内核裁剪

实验内容

在本次实验中,学生将利用 qemu 模拟器启动并运行一个虚拟机,以下是详细的实验操作步骤:

  1. 修改 Linux 内核默认编译配置,移除对 ext4文件系统的默认支持,并新增对 btrfs 文件系统的支 持,随后进行内核编译。

  1. 利用 buildroot 工具构建根文件系统(rootfs),配置目标架构为最终运行该系统的平台类型。配 置 Filesystem images 为 btrfs 格式,此操作将生成 btrfs 类型的虚拟磁盘,内含 rootfs。配置 Target packages , 向 rootfs 中添加 vim、openssh 以及 bash。配置 System configuration,将 默认 shell 设置为 bash。

  2. 通过 qemu,添加网络和 edu 设备,结合已编译的内核及虚拟磁盘,尝试启动并运行基于 btrfs 虚拟磁盘的虚拟机。

  3. 将在 qemu 虚拟机外编译好的 edu 驱动程序复制到虚拟机内,并进行安装和运行测试。

  4. 使用默认配置的内核,进入虚拟机后查询内核参数 kernel.shmmax 的值,并尝试修改该参数。 修改完毕后,再次查询 kernel.shmmax 的值,以验证修改是否成功。

详解

首先,先编译内核,先使用 make x86_64_defconfig 读取默认 x86_64 下的配置,在这个基础 上我们进行内核裁剪,然后使用 make menuconfig 打开配置菜单,确认架构是 x86 下的 x86_64,并且按照要求,在 filesystem 选项中取消对于 ext4 文件系统的支持转而对 btrfs 文 件系统进行支持,最后保存退出,使用 make bzImage -j 8 进行编译,最后 bzImage 即为编译 好的内核文件。 下来我们需要编译 rootfs(文件系统),提供除了内核外的用户态软件。首先我们必须确认 rootfs 的文件系统类型是 btrfs,确保内核能够正确对其进行加载。然后我们根据需求,添加 openssh,vim,以及 bash,需要勾选 provided by busybox 才能看见这几个,如果找不到可以 使用 menuconfig 的搜索功能进行查找。然后 make -j8 进行编译即可。 全部编译完成后,我们使用 qemu 虚拟机启动内核和 rootfs,启动参数如下:

qemu-system-x86_64 \
 -smp 2 \
 -m 2048 \
 -kernel bzImage \
 -drive file=rootfs.btrfs,if=virtio,format=raw \
 -append "console=ttyS0 root=/dev/vda rw init=/sbin/init" \
 -virtfs local,path=/,mount_tag=host0,security_model=passthrough \
 -device edu \
 -nographic

加入 device 参数是为了验证我们在实验 3 中编写的驱动程序,加入 virtfs 是为了访问 host 的文件系统。 最后我们退出时使用快捷键 Ctrl+A, X 即可强制关闭 qemu。

验收

验收时候老师会看你代码问问题,问的问题比较深入,并且会递归地问,下面我列出一些问遇到的问题或是引申出来的问题与参考的回答。

主要就是要根据你自己的代码进行回答,最好不要使用AI进行编写,除非你看懂了里面使用的API以及所有原理。

具体Q&A

题目1相关

Q: SpinLock是什么,阐述它的原理?

A: SpinLock是自旋锁,在Acquire的时候会进行忙等待,即重复地检查加锁状态,直到锁被释放。这种状态会不让CPU进行调度。

Q: SpinLock锁中为什么不能使线程进行睡眠?

A: SpinLock是“忙等锁”,会禁止CPU进行调度,持锁期间,CPU必须保持运转,不能切换到其他任务。如果Sleep就会导致

Q: SpinLock既然禁止了CPU调度,那么会不会有一种情况,线程A进入了锁的临界区,我线程B运行的时候,锁进行了忙等待,那为什么线程B拿到了CPU的时间片,不是禁止了调度么?

A: SpinLock是禁止持锁者进行调度,而非等待者进行调度。而且持锁者的时间片被分走还可能是因为中断,禁止调度不等于禁止中断,中断处理程序会直接让持锁着等待。

Q: 模块的清理函数中运行的kthread_stop后有没有一种可能性,模块先退出,线程再退出,如果这样会导致非常严重的问题,这一点你怎么看?

A: 我们要明确kthread_stop是阻塞的,它运行时首先会设置线程的退出标识,给线程发退出的信号,然后它会等待线程退出,最后清理回收线程的相关资源,所以它运行完后,线程必定是已经完全退出的。

Q: Mutex和SpinLock的区别?为什么在这里用SpinLock而不用Mutex?

A: Mutex会睡眠,会睡眠就会调度。SpinLock不会睡眠。因为这里的临界区很短、加锁很频繁,mutex 的睡眠 + 调度器切换成本远高于 spinlock 的忙等成本,所以 mutex 会让性能变得非常糟糕。

题目2相关

Q: 讲解kmalloc的第二个参数的作用。

A: kmalloc第二个参数控制获取内存资源时kmalloc内部函数的行为,比如GFP_KERNEL允许在没有内存的时候,函数会阻塞,内核将会把线程挂起,直到出现了可用的内存(当其他线程的内存被回收后),才会分配返回,所以它不能搭配自旋锁进行使用,否则会导致死锁。GFP_ATOMIC与第一个不同的是,它不允许线程休眠,这适合在自旋锁内使用,并且如果遇到了没有内存的状态,它则直接返回NULL。

Q: kmalloc和malloc的区别,内核中是否能使用malloc进行内存分配?

A: 不能。因为kmalloc分配的是内核的堆内存,这是内核的API。malloc分配的是用户态的堆内存,它们的内存空间不同,malloc是glibc提供的API,而内核是没有glibc的。

Q: 为什么在一个空的循环中,你的代码加了sleep,这有什么作用吗?

A: 因为不加休眠函数,会导致CPU调度器进行频繁的调度,浪费性能,宏观体现就是操作系统用起来很卡。使用一个休眠函数可以让CPU去调度其他的线程,从而提高性能。

Q: work_ctx中的work_struct能否放到结构体的其他位置,会有什么影响吗?

A: 可以,Linux内核中这种容器模式的结构体是位置无关的,还有线程的task_struct也是一样。对于work_struct,处理它的宏(比如说container_of)会要求你填写需要抽取内容的struct和work_struct实例的名字,做到直接访问。如果非要说有影响,应该是从C语言的内存对齐说的,这可能会略微影响性能,但是肯定不会造成错误。

题目3相关

Q: pci_device_id table是干什么用的?

A: 用于告诉操作系统内核这个驱动程序只能接受列出的几种设备,当操作系统扫描设备列表的时候,如果扫描到了这个驱动可以处理的设备号,则才会调用驱动的probe函数,这样有助于热插拔等。

Q: pci_enable_device 有什么作用?

A: 启用设备如果设备被暂停(低功耗等情况)、启用BAR,MMIO机制,设备可以申请物理内存地址,驱动可以安全读写设备的寄存器。

Q: pci_request_regions 的作用是什么?

A: 占用设备的BAR(MEM/IO)资源,防止多个驱动同时访问一个设备。

Q: 驱动为什么要设置私有数据?

A: pci_get_drvdatapci_set_drvdata一个获取私有数据,一个设置私有数据。私有数据与PCI设备绑定,允许驱动程序在处理过程中随时获取这些数据,简化代码流程,驱动程序不需要考虑对结构体的传递。如果驱动管理了多个设备,私有数据与设备的绑定将允许每个设备都有自己的数据结构,不冲突。最重要的一点是,中断处理是没有上下文的,中断处理函数只给你一个irq和void* dev_id,如果不将私有数据绑定,你无法区分你在应对哪个设备(如果你的驱动支持多个设备)。

Q: filp->private_data有什么用?

A: 与驱动私有数据的原理差不多,将数据与文件描述符关联,使内核驱动更加模块化,对于特定的文件,设置文件的私有数据可以在open/read/write操作时方便拿到这些数据,而不需要在内核模块考虑这些结构体的传递。

Q: 保存设备irq号的作用?

A: 让驱动能够注册中断回调流程,从而让设备中断时能主动通知CPU。

Q: PCI的BAR如何初始化,如何作用?

A: Linux PCI子系统在扫描到新的设备的时候,会给BAR里面写0xFFFFFFFF,然后部分位始终为0,PCI子系统回读后会得知Size和Alignment,PCI子系统为设备分配一个物理内存地址并将其写回BAR,BAR相当于一个过滤器,使得设备仅监听特定地址区间的TLP。驱动初始化的时候,会ioremap,将这个物理地址映射到虚拟地址,CPU对这些虚拟地址的读写都会转化为TLP通知设备,设备将会设置特定的寄存器值。

题目4相关

Q: 为什么实验4要选btrfs取消ext4(这个问题问的有一点问题,当时没回答上是因为没理解问题,后面问了,看回答)?

A: 不是说非得选btrfs,而是要保证rootfs的文件格式被裁剪的linux内核支持,否则linux不支持rootfs的文件系统,就无法正确进行I/O操作。

实验报告

实验1 Kernel API

本节中请至少说明:i. kmalloc, kfree与C语言的malloc,free有何不同; ii. 通过何种函数获得进程的pcb?iii. 自己所编写的两个内核线程是如何实现同步互斥的? iv.内核线程和用户空间线程有何不同。v. 使用内核链表而不是自行编写一个链表有什么好处?

代码相关解释

1.thread1_func

首先使用for_each_process来遍历内核进程链表,然后对于每个p(task_struct*),我们kmalloc一个内存区域用于装我们的struct,然后我们拷贝进程名comm和pid到我们的struct并且使用锁来保证多线程安全,在申请锁后释放锁前,我们使用list_add来将我们的struct链表头加入到我们的主链表头my_list,就完成了数据的插入。

为了保证thread1一直运行到模块结束,我们使用while(!kthread_should_stop()) 进行保证,并且在循环中使用sleep防止影响系统性能。

2.thread2_func

首先我们与thread1_func一样,使用带kthread_should_stop()的循环,并且在循环中使用锁来保证多线程安全,然后使用list_for_each_safe()来遍历链表my_list,pos是当前节点,n是下一个节点,然后使用list_entry来访问pos节点的结构体,注意不能用list_first_entry,因为在遍历的时候,pos指向的是每一个节点,而list_first_entry会拿到pos的下一个节点(因为链表头也是一个节点),然后用kprint格式化输出相关信息,在遍历结束后释放锁防止死锁,最后sleep方便我们截图也防止影响系统性能。

问题回答

i. kmalloc与kfree均是Linux内核所提供的API,用于分配与释放内核态下的堆内存,由内核管理。malloc与free是glibc所提供的API,分配用户态下的堆内存,由glibc管理。

ii. 获取PCB,其实就是获取到 进程的task_struct。一般我们通过for_each_process进行遍历进程列表来获取某个进程的PCB。

iii. 互斥:通过自旋锁spin_lock,线程1在进行关键的链表修改操作时,进入自旋锁的临界区,完成后退出临界区,交给线程2进入临界区。同步:检查链表是否为空,如果不为空才进行打印。

iv. 内核线程没有用户态地址空间,内核线程由内核管理,并通过内核提供的API进行创建。用户态线程有用户态地址空间,由每个进程所管理,使用glibc提供的API进行创建。内核线程永远在内核态运行,用户态线程在用户态下运行,可以陷入内核态。

v. 内核链表经过高度优化,经过时间的考验。其速度更快,稳定性更好,有完整的控制API或者宏(比如kthread_stop API或者是container_of宏),可以轻松进行操作,并且内核中非常多要用到链表的地方,都只支持内核链表的list_head结构体,都是为其所定制的,自己编写的链表无法与之进行匹配。

实验2 deferred work

本节中请至少说明:基于实验结果,解释workqueue与kernel thread的不同?

代码相关解释

首先我们在deferred_work_init中初始化numbers数组,因为如果传局部变量引用到kthread则不能保证在线程解引用的时候局部变量还可用,会导致空指针异常或者是无法预测的数值。

然后我们初始化并调度delayed_work,这里要注意的就是schedule_delayed_work的第二个参数是jiffies而不是msecs,经过查询我们找到msecs_to_jiffies函数,用于转换毫秒到jiffies。

然后我们初始化并调度10个work,并在work_queue_handler中使用container_of来从work_struct转换到我们的work_ctx,从而获取到参数current_id并输出。

最后初始化kthread,我注意到kthread_create的name是fmtstr,所以我采用了myKthread%d来区分线程名(貌似线程名一样也可以运行,但是线程名不能为NULL,会导致系统崩溃),然后wake_up_process来唤醒线程并保存线程句柄,在线程中打印完成参数后进入休眠,并时刻接受should_stop信号,和实验1一样,如果不接受直接return会导致线程提前退出,结构体则变得无效,在kthread_stop执行时会导致系统崩溃。

最后,在uninstall的时候,结束掉所有kthread。

问题回答

Kernel thread与其他线程一样属于并行关系,workqueue是内核所管理的一个工作队列,当有工作加入时会进入队列进行排队,等到合适时机进行运行。基于实验结果,可能无法直接感受出来,是因为工作队列中没有大量任务,宏观上是直接执行的,kthread在启动后也是立马执行,可以看到有区别的是delayed_work,这样的工作会在一定延迟后进行执行。从代码中可以看出,workqueue的生命周期是由内核进行管理的,而kthread的生命周期是由程序进行管理。

实验3 Edu驱动

本节中请至少说明:i. 解释所填写的实验代码的语义  ii. 基于实验代码,请解释如何访问PCI设备的寄存器。iii. 请解释本驱动中启用线程的好处以及带来的问题。

代码相关解释

首先在edu_driver_probe中,我先分配了一块空间给edu_info,我这里用的是GFP_ATOMIC,防止休眠,然后,根据edu.txt里面提供的数据,我们知道BAR0是我们需要映射的,我们使用pci_ioremap_bar将其映射到内核地址空间,随后设置驱动私有数据,设置为edu_info,方便我们在remove的时候获取并释放。最后在这个函数,我获取了设备的id,并且进行了liveness check来测试设备是否正常运行。

在edu_driver_remove里面,我使用pci_get_drvdata获取到在probe中设置的私有数据edu_info,并且对其ioaddr进行unmap,随后free掉edu_info整个结构体,然后只需要pci_release_regions和pci_disable_device即可卸载驱动。

对于kthread_handler,传入的data是thread_data,首先我对其进行解包,获取到usermode下用户ioctl传入的值,然后为了防止多线程冲突,访问物理设备内存必须上锁,随后我将其写到ioaddr+0x08的地方,用于让设备计算阶乘,然后对ioaddr+0x20的低位进行检查,当其为1的时候代表设备忙,为0的时候代表计算完毕,在不断等待中,我是用了cpu_relax来将控制权交给cpu,防止卡顿。在发现设备计算完毕后使用readl读取出结果,然后设置结果的原子变量即可,最后我们需要kfree掉data,因为这个data是在ioctl里面kmalloc的,不free就会内存泄漏。

在edu_dev_open里面,我kmalloc了一个user_data,然后为了区别他们,使用current_id++的方式来区分,并且初始化user_data_ptr->data防止未定义的行为,随后设置private_data用于到时候在ioctl里面读取。

edu_dev_release中,只需要free掉private_data(即open中malloc的user_data)即可。

然后,ioctl里面,也是程序中最重要的部分,我首先从private_data拿出user_data_ptr,随后判断用户是要write还是要read结果,如果是read则直接返回user_data的结果变量,如果是write就malloc一个thread_data并传递参数给线程(这里当时犯了个错误,传了个引用,调试了好久,毕竟thread_data直接就是指针,如果传引用就是局部变量,在thread里面的时候不光解引用错误导致kernel panic,而且这时候这个函数可能都结束了,局部变量已经不可用),然后为了区分线程,我们使用id来设置唯一的名字,随后wakeup线程。

在edu_driver_init里,我在注册edu pci设备失败的时候,注销了字符设备再返回。

问题回答

i. 已在上面部分详细阐述。

ii. 为了访问PCI设备的寄存器,我们首先要在probe发现函数中,对设备的BAR映射到物理内存。首先我们使用pci_request_regions 来占有设备的BAR资源,读取一些BAR中的基本信息并且通过pci_ioremap_bar将其映射到物理地址,以便我们进行读写。这时我们访问PCI设备的寄存器只需要通过pci_ioremap_bar返回的内核内存地址进行读写,系统进行映射,体现为对PCI设备的内存进行读写,然后我们只需要阅读edu.txt(设备手册),找到我们感兴趣的寄存器的偏移,即可通过readl或者writel或者其他方案进行读写访问。

iii. 使用线程,可以让用户态的ioctl不阻塞等待结果,但是带来的问题就是用户态如果读取结果过快,则设备可能还没有处理完就读取走了,这个数据就是无效的。

实验4 内核裁剪

本节中请至少说明:i. 给出编译内核的关键步骤,并解释; ii. 给出编译buildroot的关键步骤,并解释;iii. 给出如何将edu驱动文件放入根文件系统中的过程,并解释;iv. 给出修改kernel.shmmax的值的应用场景;v. 若在x86平台上编译arm平台下所使用的根文件系统,是否可行,并给出理由。

操作相关解释:

首先,先编译内核,先使用make x86_64_defconfig 读取默认x86_64下的配置,在这个基础上我们进行内核裁剪,然后使用make menuconfig打开配置菜单,确认架构是x86下的x86_64,并且按照要求,在filesystem选项中取消对于ext4文件系统的支持转而对btrfs文件系统进行支持,最后保存退出,使用make bzImage -j 8进行编译,最后bzImage即为编译好的内核文件。

下来我们需要编译rootfs(文件系统),提供除了内核外的用户态软件。首先我们必须确认rootfs的文件系统类型是btrfs,确保内核能够正确对其进行加载。然后我们根据需求,添加openssh,vim,以及bash,需要勾选provided by busybox才能看见这几个,如果找不到可以使用menuconfig的搜索功能进行查找。然后make -j8进行编译即可。

全部编译完成后,我们使用qemu虚拟机启动内核和rootfs,启动参数如下:

qemu-system-x86_64 \
 -smp 2 \
 -m 2048 \
 -kernel bzImage \
 -drive file=rootfs.btrfs,if=virtio,format=raw \
 -append "console=ttyS0 root=/dev/vda rw init=/sbin/init" \
 -virtfs local,path=/,mount_tag=host0,security_model=passthrough \
 -device edu \
 -nographic

加入device参数是为了验证我们在实验3中编写的驱动程序,加入virtfs是为了访问host的文件系统。

最后我们退出时使用快捷键Ctrl+A, X即可强制关闭qemu。

问题回答:

i. 已在上方操作相关解释阐述。

ii. 已在上方操作相关解释阐述。

iii. 我们先在HostOS编译实验3的内核模块edu_dev.ko和用户态测试程序user_space,然后通过

mount -t 9p -o trans=virtio host0 /mnt/host

将HostOS的virtfs挂载到GuestOS的/mnt/host上(注意要保持该文件夹存在才能mount)。这时GuestOS的/mnt/host 下就是HostOS的根目录,我们只需要访问

/mnt/host/home/Labmem/workspace/3-stu/code

即可找到我们的内核模块,我们可以选择直接在此处进行加载并测试,或者选择将其拷贝到rootfs中。

iv. kernel.shmmax决定了单个共享内存段允许的最大字节数,所有需要大块共享内存的系统都可能需要调大它。具体的应用场景比如说我们启动qemu的参数中的virtfs,这必然会使用shared memory进行通信缓存,并且qem给GuestOS分配的内存也是shared memory,rootfs也会被载入后成为shared memory。

v. 首先明确这个问题是完全可行的,因为rootfs其实就是一堆可执行文件与配置的集合,若rootfs中的二进制架构与编译它的操作系统的文件架构不同,则可以使用交叉编译工具链进行编译。并且业界构建 ARM 系统(如 Buildroot、Yocto)几乎全部在 x86 主机上完成,因此这是常规且推荐的做法。

个人体会

本节中请至少说明:i.此次实验活动中遇到的哪些关键问题,自己是如何发现并解决的。ii.上述问题的发现与解决对自己有哪些启发(从技术层面和学习做事方法两个角度进行说明)。