0%

Linux Device Drivers笔记之设备驱动简介

本系列文章是对《Linux Device Drivers》一书的个人笔记,作为学习过程的记录,供日后参考回顾用,不保证可读性和条理性。

设备驱动程序简介

机制和策略

区分机制和策略是Unix的设计哲学之一。

  • 机制:需要提供什么功能
  • 策略:如何使用这些功能

举例一:TCP/IP网络,位于下层的操作系统负责提供套接字抽象,但在所传输的数据上没有附加任何策略;上层的服务器则分别提供不同的服务。

举例二:ftpd服务器提供文件传输机制,用户可以使用任何自己喜欢的客户端传输文件,例如命令行和图形客户端。

驱动程序应当不带策略:编写访问硬件的内核代码时,不要给用户强加任何特定策略。不同的用户有不同的需求,驱动程序应该处理如何使用硬件可用的问题,而降怎样使用硬件的问题留给上层应用程序。因此,当驱动程序只提供了放文件硬件的功能而没有附加任何限制是,这个驱动程序就比较灵活。

不带策略的驱动程序包括一些典型特征:

  • 支持同步和异步操作
  • 驱动程序能够被多次打开、充分利用硬件特性
  • 不具备用来简化任务的软件层
  • 不具备提供与策略相关的软件层

内核功能划分

  • 进程管理:创建和销毁进程,处理进程的输入输出,进程间通信,进程调度器
  • 内存管理:虚拟内存,分配释放内存
  • 文件系统:Unix中一切对象皆文件
  • 设备控制:驱动物理设备
  • 网络功能:应用程序和网络接口间传递数据包,路由和地址解析

img

设备和模块的分类

可装载模块,在运行时添加和移除模块:

  • insmod
  • rmmod

设备分类:

  • 字符设备
    • 能够像字节流(类似文件)一样被访问的设备
    • 设备节点,比如 /dev/console, /dev/tyys0
    • 与普通文件的差别在于字符设备是一个只能顺序访问的数据通道,不能前后移动访问位置
  • 块设备
    • 和字符设备类似,通过 /dev 目录下的文件系统节点来访问
    • 块设备上容纳文件系统
    • IO操作时块设备每次只能传输一个或多个完整的块,每个块包含512(或其他)字节
  • 网络接口
    • 网络接口不是面向流的设备,没有映射到文件系统中的节点
    • 分配唯一的名字,比如eth0
    • 内核和网络设备驱动之间的通信是数据包传输相关的函数

安全问题

  • 尽量避免在驱动程序代码中实现安全策略,应该在系统管理员的控制下,由内核的高层来实现
  • 对有可能会影响整个系统的行为,进行必要的安全检查,由驱动程序来完成
  • 避免代码引入的缺陷,比如缓冲区溢出
  • 对任何用户的输入进行严格的验证后使用
  • 对任何内存都进行初始化
  • 将某些敏感操作限于特权用户

用户空间和内核空间

OS的作用是位应用程序提供一个对计算机硬件的一致视图。OS必须负责程序的独立操作并保护资源不受非法访问。这个重要任务只有在CPU能够保护系统软件不受应用程序的破坏时才能完成。

CPU中实现不同的操作模式,不同级别具有不同的功能,在较低级别中将禁止某些操作。Unix使用了这样的级别,当CPU存在多个级别时,Unix使用最高级别和最低级别

  • 内核运行在最高级别,可以进行所有的操作;
  • 用户程序运行在最低级别,CPU控制着对硬件的直接访问以及对内存的非授权访问。

通常将运行模式称作内核空间和用户空间。说明两种模式具有不同的优先权等级,每个模式都有自己的内存映射,即自己的地址空间。

应用程序执行系统调用或者被硬件中断挂起时,Unix切换到内核空间:

  • 执行系统调用的内核代码运行在进程上下文中,它代表调用进程执行操作,能够访问进程地址空间的所有数据。
  • 处理硬件中断的内核代码和进程是异步的,与任何一个特定进程无关。

一个驱动程序要执行两类任务:

  • 模块中的某些函数作为系统调用的一部分执行;
  • 其他函数负责处理中断;

构造和运行模块

在编写内核模块之前,需要配置好编译和测试环境;关键是设置与运行的内核版本一致的内核源码树,并完成内核的编译。

Hello World模块

  • 示例代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    #include <linux/init.h>
    #include <linux/module.h>

    MODULE_LICENSE("Dual BSD/GPL");
    MODULE_AUTHOR("Your Name");
    MODULE_DESCRIPTION("A Simple Module");
    MODULE_VERSION("1.0.0");
    //MODULE_DEVICE_TABLE(table_info);
    //MODUEL_ALIAS(alternate_name);

    static int hello_init(void)
    {
    printk(KERN_ALERT "Hello, world\n");
    return 0;
    }

    static void hello_exit(void)
    {
    printk(KERN_ALERT "Goodbye, cruel world\n");
    }

    module_init(hello_init);
    module_exit(hello_exit);
  • Makefile

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    obj-m := hello_module.o
    KERNELDIR := ~/Workspace/linux-rpi-4.19.y
    PWD := $(shell pwd)

    CROSS_TOOL := arm-linux-gnueabihf-
    TARGET_ARCH := arm
    # 指定交叉编译工具链和目标平台架构

    all:
    make -C $(KERNELDIR) M=$(PWD) modules CROSS_COMPILE=$(CROSS_TOOL) ARCH=$(TARGET_ARCH)
    # 首先改变目录到 -C 指定的内核源代码目录,其中有内核的顶层makefile文件;
    # M= 选项让make在构造modules目标之前返回到模块源代码目录;
    # modules 目标指向 obj-m 变量中定义的模块;
    # 其他选项指定交叉编译工具链和目标硬件平台架构;
  • 安装运行:sudo insmod ./hello.ko

    • modprobe与insmod类似,但是它考虑要装载的模块是否引用了一些当前内核中不存在的符号;如果有,modprobe会在当前模块搜索路径查找定义了该符号的其他模块,并进行加载。
    • lsmod 列出当前装载到内核中的所有模块;lsmod通过读取 /proc/modules 虚拟文件系统来获得这些信息。
    • 有关当前已加载模块的信息也可以在 sysfs 虚拟文件系统的 /sys/module 下找到。
  • 查看log:dmesgcat /var/log/messages

内核中的并发

Linux内核代码包括驱动程序代码必须是可重入的,它必须能够同时运行在多个上下文中。要仔细编写代码,处理并发问题,防止竞态发生。

当前进程

前面提到,内核模块会运行在进程上下文中。内核代码可以通过访问全局项 current 来获得当前进程。current定义在 中,是一个指向 struct task_struct 的指针,task_struct结构定义在 中。定义如下:

1
struct task_struct *current;

current指针指向当前正在运行的进程。在open,read等系统调用的执行过程中,当前进程指的是调用这些系统调用的进程。

对current的引用会频繁发生,不依赖于特定架构的实现机制通常是将指向task_struct结构的指针隐藏在内核栈中。

下面的代码通过访问某些成员打印当前进程的ID和命令名:

1
2
printk(KERN_INFO "The process is \"%s\" (pid %i)\n",
current->comm, current->pid);

内核编程的限制

  • 内核栈非常小,通常大小只有一个页4096字节;不要声明大的自动变量;
  • 具有2个下划线(__)前缀的函数通常是接口的底层组件,应谨慎使用。双下划线告诉程序员,谨慎调用,否则后果自负;
  • 内核代码不能实现浮点运算;

内核版本宏

内核版本相关的宏定义在 <linux/version.h> 中,该头文件自动包含于 <linux/module.h> 中。

  • UTS_RELEASE:描述内核版本的字符串,比如 “2.6.10”
  • LINUX_VERSION_CODE:内核版本的整数表示,比如2.6.10对应的是 132618
  • KERNEL_VERSION(major,minor,release):根据输入的三个参数,创建整数版本号;比如 KERNEL_VERSION(2,6,10)扩展为 132618

内核符号表

模块被装入内核后,所导出的任何符合都会变成内核符号表的一部分;这些导出的符号可以为其他模块所用。

模块使用下面的宏导出符号:

1
2
EXPORT_SYMBOL(name);
EXPORT_SYMBOL_GPL(name);

符合只能在模块文件的全局部分导出,上述宏被扩展为特殊变量声明,该变量必须是全局,并保存在可执行文件的一个特殊ELF段中。

初始化和关闭

初始化函数的定义如下:

1
2
3
4
5
static int __init initialization_function(void)
{
/* code goes here */
}
module_init(initialization_function);
  • __init标记表示该函数尽在初始化期间使用;在模块被装载后,模块装载器会将初始化函数扔掉,释放部分内存。
  • __initdata作用类似,用于标记数据结构
  • 注意,不要在结束初始化后仍要使用的函数或数据结构上使用这两个标记
  • __devinit/__devinitdata在内核未被配置为支持热插拔设备的情况下,这两个标记才会被翻译为对应的上述两个标记

卸载函数的定义如下:

1
2
3
4
5
static void __exit cleanup_function(void)
{
/* cleanup code goes here */
}
module_exit(cleanup_function);
  • __exit/__exitdata标记表明该代码仅用于卸载模块;
  • 被标记为__exit的函数只能在模块被卸载或者系统关闭时被调用,其他任何用法都是错误的
  • 如果一个模块未定义卸载函数,则内核不允许卸载该模块

初始化错误处理

模块注册可能会失败,因为需要内存分配,而所需内存可能无法获得。因此,模块代码必须始终检查返回值,确保所请求的操作已真正成功。

内核错误编码定义在 <linux/errno.h> 中的负整数,比如 -ENODEV/-ENOMEM;程序可以通过 perror 函数将错误编码转换为错误文字信息。

如果注册过程中发生了严重错误,则要讲出错之前的任何注册工作撤销掉。

  • 错误处理可以使用goto语句(可能是唯一使用goto的情况),内核经常使用goto来处理错误。
  • 另外的方法,是发生错误时从初始化函数中调用清楚函数,清除函数在撤销每项设施之前必须检查它的状态。
  • 需要注意的是,因为卸载函数被非退出代码使用,不能将其标记为__exit

模块装载竞争

  • 在初始化函数还在运行的时候,内核完全可能会调用我们的模块。因此在首次注册完成后,代码就应该准备被内核其他部分调用;在用来支持某个设施的内部初始化完成前,不要注册任何设施。
  • 如果 初始化失败,则应该仔细处理内核其他部分正在进行的操作,并且要等待这些操作的完成。

模块参数

使用insmod或modprobe命令装载模块时传递参数,例子如下:

1
insmod hello.ko howmany=10 whom="Bob"

模块必须让参数(howmang,whom)对insmod命令可见。

参数使用宏module_param来声明,并且放在任何函数之外,通常是在源文件的头部。使用如下代码声明参数并使之对insmod可见:

1
2
3
4
5
6
7
8
9
// 定义在 moduleparam.h 中
#include <linux/moduleparam.h>

static char *whom = "world";
static int howmany = 1;

// 三个参数分别为:变量的名称、类型、用于sysfs入口项的访问许可掩码
module_param(howmany, int S_IRUGO);
module_param(whom, charp, S_IRUGO);

注意,所有的模块参数都必须给一个默认值。

内核支持的模块参数类型如下:

type remarks
bool, invbool invbool类型反转其值,true变为false,false变为true
charp 字符指针值,内核位用户提供的字符串分配内存,设置相应指针
int, long, short, uint, ulong, ushort 不同长度的基本整数值

支持数组参数,提供数组值时用逗号划分各数组成员;声明数组参数使用下面的宏:

1
2
module_param_array(name, type, num, perm);
// name是数组名,type是数组元素类型,num是一个整数变量,perm是访问许可值

module_param中最后一个参数是访问许可值,应使用<linux/stat.h>中定义的值。这个值用来控制谁能够访问sysfs中对模块参数的表述。如果perm被设置为0,就不会有对应的sysfs入口项;否则,模块参数会在/sys/module中出现,并设置为给定的访问许可。

  • S_IRUGO:任何人可读取该参数,但不能修改;
  • S_IRUGO|S_IWUSR:允许root用户修改该参数;
  • 注意,如果参数通过sysfs被修改,如同模块修改了该参数的值一样,但是内核不会以任何方式通知模块;
  • 大多数情况下,模块参数不可写;

To be continued…