广州大学学生实验报告
开课学院及实验室:计算机科学与教育软件学院计算机软件实验室 2020 年 11 月 06 日
计算机科学
学院
与教育软件
学院
年级/专
业/班
计科
186
姓名
许统楠
学号
1806100056
实验课
程名称
实验项
目名称
Unix/linux 操作系统分析实验
设备驱动: Linux 系统下的字符设备驱动程序编程
成绩
指导老
师
陶文正
实验四 设备驱动:Linux 系统下的字符设
备驱动程序编程
一、 实验目的
1、通过一个简单的设备驱动的实现过程。学会 Linux 中设备驱动程序的编写。
2、附加实验:学会 Linux 中添加新的系统调用。
二、 实验环境
VMware 虚拟机、Ubuntu 12.04
三、 实验内容
1、编写一个字符设备驱动,并利用对字符设备的同步操作,设计实现一个聊天
程序。可以有一个读,一个写进程共享该字符设备,进行聊天;也可以由多个读
和多个写进程共享该字符设备,进行聊天。
2、在 Linux 系统中,根据所给的文件和步骤,实现添加新的系统调用。
1
四、 实验原理
① Linux 字符设备驱动实现
(1)主要数据结构
结构体 file_operations 在头文件 linux/fs.h 中定义,用来存储驱动内核
模块提供的对设备进行各种操作的函数的指针。
该结构体的每个域都对应着驱动内核模块用来处理某个被请求的事务的函
数的地址。
设备"gobalvar”的基本入口点结构变量 gobalvar_fops
struct file_operationsglobalvar_fops=
{
/*标记化的初始化格式这种格式允许用名字对这类结构的字段进行初始
化,这就避免了因数据结构发生变化而带来的麻烦。
这种标记化的初始化处理并不是标准 C 的规范,而是对 GUN 编译器的一种(有用的)
特殊扩展*/
//用来从设备中获取数据.在这个位置的一个空指针导致 read 系统调用以
-EINVAL("Invalidargument")失败.一个非负返回值代表了成功读取的字节数
(返回值是一个“signedsize”类型,常常是目标平台本地的整数类型).
.read=globalvar_read,//发送数据给设备.如果 NULL,-EINVAL 返回给调
用 write 系统调用的程序.如果非负,返回值代表成功写的字节数.
.write=globalvar_write,””//尽管这常常是对设备文件进行的第一个操
作,不要求驱动声明一个对应的方法.如果这个项是 NULL,设备打开一直成功,
但是你的驱动不会得到通知.
.open=globalvar_open,//当最后一个打开设备的用户进程执行 close①系
统调用时,内核将调用驱动程序的 release(函数:release 函数的主要任务是清
理未结束的输入/输出操作、释放资源、用户自定义排他标志的复位等。
.release=globalvar_release,};
(2)注册字符设备函数
int
register_chrdev(unsigned
int
major,unsigned
baseminor,unsigned
file_operations*fops)
int
count,const
char*name,const
int
struct
返回值提示操作成功还是失败。负的返回值表示错误;O 或正的返回值表明
操作成功。
major 参数是被请求的主设备号 ,name 是设备的名称 ,该名称将出现在
/proc/devices 中,
fops 是指向函数指针数组的指针,这些函数是调用驱动程序的入口点,
在 2.6 的内核之后,新增了一个 register_chrdev_region 函数,它支持将同
一个主设备号下的次设备号进行分段,每一段供给一个字符设备驱动程序使用,
使得资源利用率大大提升。
(3)MKDEV()函数
宏定义:#defineMKDEV(major,minor)(((major)(MINORBITS)|(minor))
成功热行返回 dey_t 类型的设备编号,dev_t 类型是 unsigned int 类型,
32 位,用于在驱动程序中定文设备编号,
高 12 位为主设备号,低 20 位为次设备号,可以通过 MAJOR 和 MTNOR 来获得主
2
设备号和次设备号。
在 module_init 宏调用的函数中去注册字符设备驱动
major 传 0 进去表示要让内核帮我们自动分配一个合适的空白的没被使用的
主设备号
内核如果成功分配就会返回分配的主设备号;如果分配失败会返回负数
dev_tdev=MKDEV(major,0);
(4)注册驱动
file_operations 这个结构体变量,让 cdev 中的 ops 成员的值
file_operations 结构体变量的值。
这个结构体会被 cdev_add 函数想内核注册 cdev 结构体,可以用很多函数来
操作他。
如:
cdev_alloc:让内核为这个结构体分配内存的
cdey_init:将 struct cdev 类型的结构体变量和 file_operations 结构体进
行绑定的
cdev_add:向内核里面添加一个驱动,注册驱动
cdev_del:从内核中注销掉一个驱动。注销驱动
//注册字符设备驱动,设备号和 file_operations 结构体进行绑定
cdev_init(iglobalvar.devm,&tglobalvar_fops);
#define THIS_MODULE(&_this_module)是一个 struct module 变量,代表当
前模块,
与那个著名的 current 有几分相似,可以通过 THIS_MODULE 宏来引用模块的
struct module 结构,
比如使用 THIS_MODULE->state 可以获得当前模块的状态。
(5)创建 class 并将 class 注册到内核中,返回值为 class 结构指针
定义在/include/linux/device.h
创建 class 并将 class 注册到内核中,返回值为 class 结构指针
在驱动初始化的代码里调用 class_create 为该设备创建一个 class,再为
每个设备调用 device_create 创建对应的设备。
省去了利用 mknod 命令手动创建设备节点
(6)Open 函数
在大部分驱动程序中,open 应完成如下工作:
● 递增使用计数。--为了老版本的可移植性
● 检查设备特定的错误(诸如设备未就绪或类似的硬件问题)。
● 如果设备是首次打开,则对其进行初始化。
● 识别次设备号,并且如果有必要,更新 f_op 指针。
● 分配并填写被置于 filp->private_data 里的数据结构。
(7)Release 函数
release 都应该完成下面的任务:
● 释放由 open 分配的、保存在 filp->private_data 中的所有内容。
3
● 在最后一次关闭操作时关闭设备。字符设备驱动程序
● 使用计数减 1。
如果使用计数不归 0,内核就无法卸载模块。
并不是每个 close 系统调用都会引起对 release 方法的调用。
仅仅是那些真正释放设备数据结构的 close 调用才会调用这个方法,
因此名字是 release 而不是 close。内核维护一个 file 结构被使用多少
次的计数器。
无论是 fork 还是 dup 都不创建新的数据结构(仅由 open 创建),它们只
是增加已有结构中的计数。
(8)Exit()函数
static void globalvar_exit(void)
{
device_destroy(my_class, MKDEV(major, 0));
class_destroy(my_class);
cdev_del(&globalvar.devm);
/*
参数列表包括要释放的主设备号和相应的设备名。
参数中的这个设备名会被内核用来和主设备号参数所对应的已注册设备名
进行比较,如果不同,则返回 -EINVAL。
如果主设备号超出了所允许的范围,则内核同样返回 -EINVAL。
*/
unregister_chrdev_region(MKDEV(major, 0), 1);//注销设备
}
(9)Read()函数
ssize_t read(struct file *filp, char *buff,size_t count, loff_t
*offp);
参数 filp 是文件指针,参数 count 是请求传输的数据长度。
参数 buff 是指向用户空间的缓冲区,这个缓冲区或者保存将写入的数据,
或者是一个存放新读入数据的空缓冲区。
最后的 offp 是一个指向“long offset type(长偏移量类型)”对象的指针,
这个对象指明用户在文件中进行存取操作的位置。
返回值是“signed size type(有符号的尺寸类型)”
主要问题是,需要在内核地址空间和用户地址空间之间传输数据。
不能用通常的办法利用指针或 memcpy 来完成这样的操作。由于许多原因,
不能在内核空间中直接使用用户空间地址。
内核空间地址与用户空间地址之间很大的一个差异就是,用户空间的内存是
可被换出的。
当内核访问用户空间指针时,相对应的页面可能已不在内存中了,这样的话
就会产生一个页面失效
4
② 附加实验:添加新的系统调用
系统调用其实就是函数调用,只不过调用的是内核态的函数,但是我们知道,用户态是
不能随意调用内核态的函数的,所以采用软中断的方式从用户态陷入到内核态。在内核中通
过软中断 0X80,系统会跳转到一个预设好的内核空间地址,它指向了系统调用处理程序(不
要和系统调用服务例程混淆),这里指的是在 entry.S 文件中的 system_call 函数。就是说,所
有的系统调用都会统一跳转到这个地址执行 system_call 函数,那么 system_call 函数如何派
发它们到各自的服务例程呢?
我们知道每个系统调用都有一个系统调用号。同时,内核中一个有一个 system_call_table
数组,它是个函数指针数组,每个函数指针都指向了系统调用的服务例程。这个系统调用号
是 system_call_table 的下标,用来指明到底要执行哪个系统调用。当 int ox80 的软中断执行
时,系统调用号会被放进 eax 寄存器中,system_call 函数可以读取 eax 寄存器获得系统调用
号,将其乘以 4 得到偏移地址,以 sys_call_table 为基地址,基地址加上偏移地址就是应该
执行的系统调用服务例程的地址。
实验步骤:
1 Linux 字符设备驱动实现
(1)按照实验所给的源代码做好调试准备。
(2)调试步骤:
make
sudo insmod globalvar.ko
gcc read.c -o read
gcc write.c -o write
sudo ./read
sudo ./write
5
(3)可 dmesg 查看打印信息
6
7
(4)卸载:
//rm /dev/chardev0
rmmod globalvar
② 附加实验:添加新的系统调用
1.添加系统调用的两种方法
方法一:编译内核法
拿到源码之后
修改内核的系统调用库函数 /usr/include/asm-generic/unistd.h,在这里
面可以使用在 syscall_table 中没有用到的 223 号
添加系统调用号,让系统根据这个号,去找到 syscall_table 中的相应表项。在/a
rch/x86/kernel/syscall_table_32.s 文件中添加系统调用号和调用函数的
对应关系
接着就是 my_syscall 的实现了,在这里有两种方法:第一种方法是在 kernel 下
自己新建一个目录添加自己的文件,但是要编写 Makefile,而且要修改全局的 Ma
kefile。第二种比较简便的方法是,在 kernel/sys.c 中添加自己的服务函数,这样
子不用修改 Makefile.
以上准备工作做完之后,然后就要进行编译内核了,以下是我编译内核的一个过程。
1.make menuconfig (使用图形化的工具,更新.config 文件)
2.make -j3 bzImage (编译,-j3 指的是同时使用 3 个 cpu 来编译,bzImage
指的是更新 grub,以便重新引导)
3.make modules (对模块进行编译)
4.make modules_install(安装编译好的模块)
5.depmod (进行依赖关系的处理)
6.reboot (重启看到自己编译好的内核)
方法二:内核模块法
这种方法是采用系统调用拦截的一种方式,改变某一个系统调用号对应的服务程序为我们自己的
编写的程序,从而相当于添加了我们自己的系统调用。具体实现,我们来看下:
2.通过内核模块实现添加系统调用
这种方法其实是系统调用拦截的实现。系统调用服务程序的地址是放在 sys_call_table 中通过
系统调用号定位到具体的系统调用地址,那么我们通过编写内核模块来修改 sys_call_table 中
的系统调用的地址为我们自己定义的函数的地址,就可以实现系统调用的拦截。
8