标签 内核学习 下的文章
字符设备的驱动程序开发步骤大致都是差不多的,这里绘制了一张图来形象的反应字符设备驱动程序的关键步骤:我们创建一个字符设备的时候,首先要得到一个设备号,分配设备号的途径有静态分配和动态分配;拿到设备的唯一 ID,我们需要实现 file_operation 并保存到 cdev 中,实现 cdev 的初始化;然后我们需要将我们所做的工作告诉内核,使用 cdev_add() 注册 cdev;最后我们还需要创建设备节点,以便我们后面调用 file_operation接口。注销设备时我们需释放内核中的 cdev,归还申请的设备号,删除创建的设备节点。1.字符设备的定义Linux 内核提供了两种方式来定义字符设备:第一种方式,就是我们常见的变量定义;第二种方式,是内核提供的动态分配方式,调用该函数之后,会返回一个 struct cdev 类型的指针,用于描述字符设备。第二种方式定义的字符设备,可以通过cdev_del函数来释放占用的内存。2.设备号的申请和归还2.1.设备号的静态申请register_chrdev_region 函数用于静态地为一个字符设备申请一个或多个设备编号。int register_chrdev_region(dev_t from, unsigned count, const char *name);参数:from:dev_t 类型的变量,用于指定字符设备的起始设备号,如果要注册的设备号已经被其他的设备注册了,那么就会导致注册失败;count:指定要申请的设备号个数, count 的值不可以太大,否则会与下一个主设备号重叠;name:用于指定该设备的名称,我们可以在/proc/devices 中看到该设备。返回值:返回 0 表示申请成功,失败则返回错误码。2.2.设备号的动态申请使用 register_chrdev_region 函数时,都需要去查阅内核源码的 Documentation/devices.txt 文件,这就十分不方便。因此,内核又为我们提供了一种能够动态分配设备编号的方式: alloc_chrdev_region。调用 alloc_chrdev_region 函数,内核会自动给我们分配一个尚未使用的主设备号。我们可以通过命令“cat /proc/devices”查询内核分配的主设备号。int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);参数:dev:指向 dev_t 类型数据的指针变量,用于存放分配到的设备编号的起始值;baseminor:次设备号的起始值,通常情况下,设置为 0;count:指定要申请的设备号个数, count 的值不可以太大,否则会与下一个主设备号重叠;name:用于指定该设备的名称,我们可以在/proc/devices 中看到该设备。返回值:返回 0 表示申请成功,失败则返回错误码。2.3.设备号的申请(静态和动态都支持)除了register_chrdev_region函数能够静态申请设备号,alloc_chrdev_region函数能够动态申请设备号之外,内核还提供了register_chrdev 函数用于分配设备号。该函数是一个内联函数,它不仅支持静态申请设备号,也支持动态申请设备号,并将主设备号返回。register_chrdev 函数原型如下:参数:major:用于指定要申请的字符设备的主设备号,等价于 register_chrdev_region 函数,当设置为 0 时,内核会自动分配一个未使用的主设备号;name:用于指定字符设备的名称;fops:用于操作该设备的函数接口指针。返回值:主设备号。我们从以上代码中可以看到,使用 register_chrdev 函数向内核申请设备号,同一类字符设备(即主设备号相同),会在内核中申请了256 个,通常情况下,我们不需要用到这么多个设备,这就造成了极大的资源浪费。因此通常情况下,并不使用该函数。2.4.设备号的归还当我们删除字符设备时候,我们需要把分配的设备编号交还给内核,对于使用 register_chrdev_region 函数以及 alloc_chrdev_region 函数分配得到的设备编号,可以使用 unregister_chrdev_region 函数将分配得到的设备号归还给内核。void unregister_chrdev_region(dev_t from, unsigned count);参数:from:指定需要注销的字符设备的设备编号起始值,我们一般将定义的 dev_t 变量作为实参;count:指定需要注销的字符设备编号的个数,该值应与申请函数的 count 值相等,通常采用宏定义进行管理。3.字符设备的初始化cdev_init()函数主要将file_operations结构体和我们的字符设备结构体相关联。void cdev_init(struct cdev *cdev, const struct file_operations *fops);参数:cdev:struct cdev 类型的指针变量,指向需要关联的字符设备结构体;fops:file_operations 类型的结构体指针变量,一般将实现操作该设备的结构体 file_operations 结构体作为实参。4.字符设备的注册和移除4.1.字符设备的注册cdev_add 函数用于向内核的 cdev_map 散列表添加一个新的字符设备。int cdev_add(struct cdev *p, dev_t dev, unsigned count);参数:p:struct cdev 类型的指针,用于指定需要添加的字符设备;dev:dev_t 类型变量,用于指定设备的起始编号;count:指定注册多少个设备。返回值:0或者错误码。4.2.字符设备的移除从内核中移除某个字符设备,则需要调用 cdev_del 函数。从系统中删除 cdev, cdev 设备将无法再打开,但任何已经打开的 cdev 将保持不变,即使在 cdev_del 返回后,它们的 fops 仍然可以调用。void cdev_del(struct cdev *p);参数:p:将已经注册的字符设备结构体的地址作为实参传递进去,就可以从内核中移除该字符设备了。5.设备节点的创建和销毁5.1.设备节点的创建可以在代码中使用device_create函数创建设备节点。参数:class:指向这个设备应该注册到的 struct 类的指针;parent:指向此新设备的父结构设备(如果有)的指针;devt:要添加的字符设备的设备号;drvdata:要添加到设备进行回调的数据;fmt:输入设备名称。返回值:成功时返回 struct device 结构体指针, 错误时返回 ERR_PTR()。5.2.设备节点的销毁可以使用device_destroy函数来删除 device_create 函数创建的设备节点。void device_destroy(struct class *class, dev_t devt);参数:class:指向注册此设备的 struct 类的指针;devt:以前注册设备时,使用的设备号。5.3.使用mknod命令创建设备节点除了使用代码创建设备节点,还可以使用 mknod 命令创建设备节点。用法: mknod 设备名 设备类型 主设备号 次设备号当类型为”p”时可不指定主设备号和次设备号,否则它们是必须指定的。如果主设备号和次设备号以”0x”或”0X”开头,它们会被视作十六进制数来解析;如果以”0”开头,则被视作八进制数;其余情况下被视作十进制数。可用的设备类型包括:b:创建 (有缓冲的) 区块特殊文件;c,u:创建 (没有缓冲的) 字符特殊文件;p:创建先进先出 (FIFO) 特殊文件。如: mkmod /dev/test c 2 0创建一个字符设备/dev/test,其主设备号为 2,次设备号为 0。当我们使用上述命令,创建了一个字符设备文件时,实际上就是创建了一个设备节点 inode 结构体,并且将该设备的设备编号记录在成员i_rdev,将成员 f_op 指针指向了 def_chr_fops 结构体。这就是 mknod 负责的工作内容。inode 上的 file_operation 并不是自己构造的 file_operation,而是字符设备通用的 def_chr_fops,那么自己构建的 file_operation 等在应用程序调用 open 函数之后,才会绑定在文件上。
1.Linux设备分类按照读写存储数据方式,我们可以把Linux设备分为以下几种:字符设备、块设备和网络设备。字符设备: 指应用程序按字节/字符来读写数据的设备。它通常不支持随机存取数据。字符设备在实现时,大多不使用缓存器,系统直接从设备读取/写入每一个字符。块设备: 通常支持随机存取和寻址,并使用缓存器。它与字符设备不同之处就是,是否支持随机存储。字符型是流形式,逐一存储。数据的读写只能以块的倍数进行。网络设备: 是一种特殊设备,它并不存在于/dev 下面,主要用于网络数据的收发。Linux 内核中处处体现面向对象的设计思想,为了统一形形色色的设备, Linux 系统将设备分别抽象为 struct cdev, struct block_device,struct net_devce 三个对象,具体的设备都可以包含着三种对象从而继承和三种对象属性和操作,并通过各自的对象添加到相应的驱动模型中,从而进行统一的管理和操作。2.字符设备抽象Linux 内核中将字符设备抽象成一个具体的数据结构 (struct cdev), 我们可以理解为字符设备对象,cdev 记录了字符设备的相关信息(设备号、内核对象),字符设备的打开、读写、关闭等操作接口(file_operations)。在我们想要添加一个字符设备时,就是将这个对象注册到内核中,通过创建一个文件(设备节点)绑定对象的cdev,当我们对这个文件进行读写操作时,就可以通过虚拟文件系统,在内核中找到这个对象及其操作接口,从而控制设备。C 语言中没有面向对象语言的继承的语法,但是我们可以通过结构体的包含来实现继承,这种抽象提取了设备的共性,为上层提供了统一接口,使得管理和操作设备变得很容易。在硬件层,我们可以通过查看硬件的原理图、芯片的数据手册,确定底层需要配置的寄存器,这类似于裸机开发。将对底层寄存器的配置,读写操作放在文件操作接口里面,也就是实现 file_operations 结构体。其次在驱动层,我们将文件操作接口注册到内核,内核通过内部散列表来登记记录主次设备号。在文件系统层,新建一个文件绑定该文件操作接口,应用程序通过操作指定文件的文件操作接口来设置底层寄存器。3.相关概念及数据结构在 linux 中,我们使用设备编号来表示设备,主设备号区分设备类别,次设备号标识具体的设备。字符设备的cdev 结构体记录了设备号,在使用设备时,我们通常会打开设备节点,通过设备节点的 inode 结构体、 file 结构体最终找到 file_operations 结构体,并从file_operations 结构体中得到操作设备的具体方法。3.1.设备号Linux对于设备的访问是通过文件系统的名称进行的,这些名称被称为特殊文件、设备文件,或者简单称为文件系统树的节点, Linux 根目录下有/dev 这个文件夹,专门用来存放设备中的驱动程序。通过执行命令:ls -l /dev,可以查看/dev目录下每个文件的详细信息,每一行的第一个字符表示设备的类型,'c'用来标识字符设备,'b'用来标识块设备。一般来说,主设备号指向设备的驱动程序,次设备号指向某个具体的设备。如上图,I2C-0,I2C-1 属于不同设备但是共用一套驱动程序。在内核中,dev_t 用来表示设备编号,dev_t 是一个 32 位的数,其中,高 12 位表示主设备号,低 20 位表示次设备号。理论上主设备号取值范围:0~2^12-1,次设备号 0~2^20-1。实际上在内核源码中 __register_chrdev_region() 函数中,major 被限定在0~CHRDEV_MAJOR_MAX, CHRDEV_MAJOR_MAX 是一个宏,值是 512。宏定义 MAJOR 和 MINOR,可以根据设备的设备号来获取设备的主设备号和次设备号。宏定义MKDEV,用于将主设备号和次设备号合成一个设备号,主设备号可以通过查阅内核源码的 Documentation/devices.txt 文件,而次设备号通常是从编号 0 开始。内核通过一个散列表 (哈希表) 来记录设备编号。哈希表由数组和链表组成,吸收数组查找快,链表增删效率高,容易拓展等优点。以主设备号为 cdev_map 编号,使用哈希函数 f(major)=major%255 来计算组数下标 (使用哈希函数是为了链表节点尽量平均分布在各个数组元素中,提高查询效率);主设备号冲突, 则以次设备号为比较值来排序链表节点。内核用 struct cdev 结构体来描述一个字符设备,并通过 struct kobj_map 类型的散列表cdev_map 来管理当前系统中的所有字符设备。3.2.设备节点设备节点(设备文件): Linux 中设备节点是通过“mknod”命令来创建的。一个设备节点其实就是一个文件,Linux 中称为设备文件。设备节点被创建在/dev下,是连接内核与用户层的枢纽,就是设备是接到对应哪种接口的哪个 ID 上。设备节点是Linux内核对设备的抽象,一个设备节点就是一个文件。应用程序通过一组标准化的调用执行访问设备,这些调用独立于任何特定的驱动程序。而驱动程序负责将这些标准调用映射到实际硬件的特有操作。3.3.数据结构在驱动开发过程中,不可避免要涉及到三个重要的的内核数据结构分别包括文件操作方式(file_operations),文件描述结构体(struct file)以及inode 结构体。file_operation 就是把系统调用和驱动程序关联起来的关键数据结构。内核中用 file 结构体来表示每个打开的文件,每打开一个文件,内核会创建一个结构体,并将对该文件上的操作函数传递给该结构体的成员变量f_op,当文件所有实例被关闭后,内核会释放这个结构体。在file 结构体中,我们需要关心的数据成员有f_op和private_data:const struct file_operations *f_op:存放与文件操作相关的一系列函数指针,如 open、 read、 wirte 等函数。void *private_data:该指针变量只会用于设备驱动程序中,内核并不会对该成员进行操作。因此,在驱动程序中,通常用于指向描述设备的结构体。inode 结构体是 Linux 管理文件系统的最基本单位,也是文件系统连接任何子目录、文件的桥梁。内核使用 inode 结构体在内核内部表示一个文件。因此,它与表示一个已经打开的文件描述符的结构体 (即 file 文件结构) 是不同的,我们可以使用多个 file 文件结构表示同一个文件的多个文件描述符,但此时,所有的这些 file 文件结构全部都必须只能指向一个 inode 结构体。在inode 结构体中,我们需要关心的数据成员有i_rdev和i_cdev:dev_t i_rdev:表示设备文件的结点,这个域实际上包含了设备号。struct cdev *i_cdev:struct cdev 是内核的一个内部结构,它是用来表示字符设备的,当 inode 结点指向一个字符设备文件时,此域为一个指向 inode 结构的指针。
1.内核模块传参内核模块作为一个可拓展的动态模块,为 Linux 内核提供了灵活性,但是有时我们需要根据不同的应用场景给内核模块传递不同的参数,例如在程序中开启调试模式、设置详细输出模式以及制定与具体模块相关的选项,为了满足这种需求,内核允许对内核模块指定参数,而这些参数可以在装载内核模块时改变。Linux内核提供一个宏module_param来实现模块的参数传递,module_param宏定义在include/linux/moduleparam.h文件中。module_param宏的定义形式如下:#define module_param(name, type, perm)module_param需要三个参数:name:变量的名称;type:变量的类型,目前内核支持的类型有 byte, short, ushort, int, uint, long, ulong, charp, bool,invbool。其中 charp 表示的是字符指针, bool 是布尔类型,其值只能为 0 或者是 1; invbool 是反布尔类型,其值也是只能取 0 或者是 1,但是 true 值表示 0, false 表示 1。变量是 char 类型时,传参只能是 byte,char * 时只能是 charp;perm:sysfs入口项的访问许可掩码,可选的值:S_IRUSR,S_IWUSR,S_IRGRP,S_IWGRP,S_IROTH,S_IXOTH,S_IRUGO,S_IWUGO(这些值可以通过或的方式进行组合,比如S_IRUSR | S_IWUSR表示用户拥有读写权限)。对于这些宏的定义可以参考文件:include/linux/stat.h。上述文件权限唯独没有关于可执行权限的设置,请注意,该文件不允许它具有可执行权限。如果强行给该参数赋予表示可执行权限的参数值 S_IXUGO,那么最终生成的内核模块在加载时会提示错误。这里列举一个简单的内核模块参数传递的例子,module_param.c文件内容:Makefile文件内容:编译module_param内核模块,将生成的module_param.ko文件通过nfs或者scp拷贝到开发板,然后在开发板中执行命令:sudo insmod module_param.ko int_type=12 bool_type=1 char_type=201 str_type="hello!"我们定义的四个模块参数,会在'/sys/module/模块名/parameters'下存在以模块参数为名的文件。由于 int_type 和 char_type 的权限是 0,所以我们没有权限查看该参数。2.符号共享符号共享是指内核模块能够使用其他内核模块导出的符号,或者内核模块将自己模块内的符号导出给其他内核模块使用。这里的符号指的是内核模块中导出的函数或者变量,在加载模块时被记录在公共内核符号表中,以供其他模块调用。这个机制,允许我们使用分层的思想解决一些复杂的模块设计。我们在编写一个驱动的时候,可以把驱动按照功能分成几个内核模块,借助符号共享去实现模块与模块之间的接口调用,变量共享。通常情况下我们无需导出任何符号,但是如果其他模块想要从我们这个模块中获取某些符号的时候,就可以考虑导出符号为其提供服务,这被称为模块层叠技术。例如 msdos 文件系统依赖于由 fat 模块导出的符号;USB 输入设备模块层叠在 usbcore 和 input 模块之上。也就是我们可以将模块分为多个层,通过简化每一层来实现复杂的项目。如果一个模块需要向其他模块导出符号,则应该使用下面的宏:符号必须在模块文件的全局部分导出,不能在函数中使用,EXPORT_SYMBOL_GPL使得导出的模块只能被 GPL 许可的模块使用。这里使用内核模块传参小节的module_param.c文件为基础进行修改,作为一个导出内核模块参数的内核模块,module_param.c文件的内容如下:module_param.h文件内容:symbol_share.c文件内容:Makefile文件内容:编译module_param内核模块和symbol_share内核模块,将生成的module_param.ko和symbol_share.ko文件通过nfs或者scp拷贝到开发板,然后在开发板中执行命令:注意,要先加载module_param内核模块,再加载symbol_share内核模块,因为symbol_share内核模块会依赖module_param内核模块导出的符号,如果先加载symbol_share内核模块,symbol_share内核模块将会加载失败。查看module_param内核模块导出的符号:
1.内核模块的概念因为Linux 操作系统采用了宏内核结构,宏内核的优点是执行效率非常高,但缺点也是十分明显的,一旦我们想要修改、增加内核某个功能时(如增加设备驱动程序)都需要重新编译一遍内核。为了解决这一缺点,Linux 中引入了内核模块这一机制。内核模块就是实现了某个功能的一段内核代码,在内核运行过程,可以加载这部分代码到内核中,从而动态地增加了内核的功能。基于这种特性,我们进行设备驱动开发时,以内核模块的形式编写设备驱动,只需要编译相关的驱动代码即可,无需对整个内核进行编译。内核模块的引入不仅提高了系统的灵活性,对于开发人员来说更是提供了极大的方便。内核模块定义:内核模块全称 Loadable Kernel Module(LKM),是一种在内核运行时加载一组目标代码来实现某个特定功能的机制。内核模块特点:(1).模块本身不被编译入内核映像,这控制了内核的大小;(2).模块一旦被加载,它就和内核中的其它部分完全一样。我们编写的内核模块,经过编译,最终形成以.ko为后缀的文件。ko 文件在数据组织形式上是 ELF(Excutable And Linking Format) 格式,是一种普通的可重定位目标文件。2.编写Hello内核模块对于程序入门学习来说,Hello World程序是经典的例子,这里我们也实现一个简单的Hello内核模块用于了解内核模块编程的基本框架。hello_module.c文件的内容如下所示:2.1.Hello内核模块代码框架分析Linux 内核模块的代码框架通常由下面几个部分组成:(1).模块加载函数 (必须):当通过 insmod 或 modprobe 命令加载内核模块时,模块的加载函数就会自动被内核执行,完成本模块相关的初始化工作。(2).模块卸载函数 (必须):当执行 rmmod 命令卸载模块时,模块卸载函数就会自动被内核自动执行,完成相关清理工作。(3).模块许可证声明 (必须):许可证声明描述内核模块的许可权限,如果模块不声明,模块被加载时,将会有内核被污染的警告。(4).模块参数:模块参数是模块被加载时,可以传值给模块中的参数。(5).模块导出符号:模块可以导出准备好的变量或函数作为符号,以便其他内核模块调用。(6).模块的其他相关信息:可以声明模块作者等信息。2.2.内核模块头文件Hello内核模块中,使用3个头文件,下面说说这3个头文件具体提供的信息:(1).#include <linux/module.h>:包含内核模块信息声明的相关函数;(2).#include <linux/init.h>:包含了 module_init() 和 module_exit() 函数的声明;(3).#include <linux/kernel.h>: 包含内核提供的各种函数,如 printk。2.3.内核模块加载/卸载函数module_init():声明内核模块加载函数,加载内核模块的时候会调用声明的内核模块加载函数,模块加载成功,会在/sys/module下新建一个以模块名为名的目录。module_exit():声明内核模块卸载函数,卸载内核模块的时候会调用声明的内核模块卸载函数。__init 用于修饰函数, __initdata 用于修饰变量。带有 __init 的修饰符,表示将该函数放到可执行文件的 __init 节区中,该节区的内容只能用于模块的初始化阶段,初始化阶段执行完毕之后,这部分的内容就会被释放掉,真可谓是“针尖也要削点铁”。__exit 用于修饰函数,__exitdata 用于修饰变量。带有__exit的修饰符,表示将该函数放到可执行文件的__exit节区,当执行完模块卸载阶段之后,就会自动释放该区域的空间。注意:hello_module_init()函数的返回值是int,hello_module_exit()的返回值是void,并且这两个函数都使用static进行修饰,表示函数只能在本文件进行调用,不能被其他文件调用。2.4.内核打印函数-printkprintk函数的打印等级:printk函数可以指定打印等级,当不指定打印等级的时候,会使用默认的打印等级。查看当前系统 printk 打印等级: cat /proc/sys/kernel/printk,从左到右依次对应控制台日志级别、默认消息日志级别、最小的控制台日志级别、默认控制台日志级别。控制台日志级别:优先级高于该值得消息将被打印到到控制台;默认消息日志级别:将用该优先级来打印没有指定优先级的消息;最小的控制台日志级别:控制台日志级别可被设置的最小值(最高优先级);默认控制台日志级别:控制台日志级别的缺省值。以上的数值设置,数值越小,优先级越高。假设你想让hello_module_init()或者hello_module_exit()函数中,没有指定打印等级的printk的内容输出到控制台,那么你可以将"默认消息日志级别"设置为小于4,可以设置为3(只需要数值小于控制台日志级别即可),执行的命令如下:sudo sh -c "echo '4 3 1 7' > /proc/sys/kernel/printk"然后执行加载或者卸载模块,就可以看到未指定打印等级的消息输出到控制台了。查看内核所有打印信息: dmesg,注意内核 log 缓冲区大小有限制,缓冲区数据可能被覆盖掉。3.内核模块的makefile对于内核模块而言,它是属于内核的一段代码,只不过它并不在内核源码中。为此,我们在编译时需要到内核源码目录下进行编译。编译内核模块使用的 Makefile 文件,和我们前面编译 C 代码使用的 Makefile 大致相同,这得益于编译 Linux 内核所采用的 Kbuild 系统,因此在编译内核模块时,我们也需要指定环境变量 ARCH 和CROSS_COMPILE 的值。编译Hello内核模块使用的Makefile文件内容如下:在内核模块的目录中,执行make命令编译内核模块,生成hello_module.ko文件,将hello_module.ko文件通过nfs或者scp拷贝到开发板,即可加载该内核模块。4.内核模块常用命令4.1.lsmodlsmod 列出当前内核中的所有模块,格式化显示在终端,其原理就是将/proc/module 中的信息调整一下格式输出。 lsmod 输出列表有一列 Used by,它表明此模块正在被其他模块使用,显示了模块之间的依赖关系。4.2.insmod如果要将一个模块加载到内核中, insmod 是最简单的办法, insmod+模块完整路径就能达到目的,前提是你的模块不依赖其他模块,还要注意需要 sudo 权限。如果你不确定是否使用到其他模块的符号,你也可以尝试modprobe,后面会有它的详细用法。4.3.rmmodrmod 工具仅仅是将内核中运行的模块删除,只需要传给它路径就能实现。rmmod 不会卸载一个模块所依赖的模块,需要依次卸载,当然用 modprobe -r 可以一键卸载。4.4.modprobemodprobe 和 insmod 具备同样的功能,同样可以将模块加载到内核中,除此以外 modprobe 还能检查模块之间的依赖关系,并且按照顺序加载这些依赖,可以理解为按照顺序多次执行 insmod。4.5.depmodmodprobe 是怎么知道一个给定模块所依赖的其他的模块呢?在这个过程中, depend 起到了决定性作用,当执行 modprobe 时,它会在模块的安装目录下搜索 module.dep 文件,这是 depmod 创建的模块依赖关系的文件。4.6.modinfomodinfo 用来显示内核模块一些信息。比如:modinfo hello_module.ko5.系统自动加载内核模块我们自己编写了一个模块,或者说怎样让它在板子开机自动加载呢?这里就需要用到上述的 depmod 和 modprobe 工具了。首先需要将我们想要自动加载的模块统一放到”/lib/modules/内核版本”目录下,内核版本使用 'uname -r'查询;其次使用 depmod 建立模块之间的依赖关系,命令’ depmod -a’;这个时候我们就可以在 modules.dep 中看到模块依赖关系。最后在/etc/modules 加上我们自己的模块,注意在该配置文件中,模块不写成.ko 形式代表该模块与内核紧耦合,有些是系统必须要跟内核紧耦合,比如 mm 子系统,一般写成.ko 形式比较好,如果出现错误不会导致内核出现 panic 错误,如果集成到内核,出错了就会出现panic。
最近这段时间一直在学习minix 文件系统1.0的内容,想着在Windows上通过自己编写的软件直接将文件传输到Linux 0.11的硬盘上(功能类似于FTP工具),这样就不需要自己搭一个虚拟机这么麻烦了,而且文件交互更方便,目前完成了基础功能,等完全弄好之后,会在我的博客上进行发布的,敬请关注。下面进入本文的内容。Makefile(或makefile)文件是make工具程序的配置文件。make工具程序的主要用途是能自动的决定一个含有很多源程序文件的大型程序中哪个文件需要被重新编译。Makefile的使用比较复杂,这里只作简单的介绍,详细说明请参考GNU make使用手册。Makefile用于告诉make要做些什么工作。make的执行过程分为两个不同的阶段。在第一个阶段,它读取所有的Makefile文件以及包含的Makefile文件等,记录所有的变量及其值、隐式的或显式的规则,并构造出所有目标对象及其先决条件的一幅全景图。在第二阶段期间,make就使用这些内部结构来确定哪个目标对象需要被重建,并且使用相应的规则来操作。当make重新编译程序时,每个修改过的C代码文件必须被重新编译。如果一个头文件被修改过了,那么为了确保正确,每一个包含该头文件的C代码程序都将被重新编译。每次编译操作都产生一个与源程序对应的目标文件。最终,如果任何源代码文件被编译过了,那么所有的目标文件不管是刚编译完的还是以前就编译好的必须连接在一起以生成新的可执行文件。简单的Makefile文件含有一些规则,这些规则具有如下形式:目标(target)…:先决条件(prerequisites)… 命令(command) ... ...其中’目标’对象通常是程序生成的一个文件的名称,例如是一个可执行文件或目标文件。目标也可以是所要采取活动的名字,比如’清除’(clean)。’先决条件’是一个或多个文件名,是用作产生目标的输入条件。通常一个目标依赖几个文件。而’命令’是make需要执行的操作。一个规则可以有多个命令,每一个命令自成一行。注意:你需要在每个命令行之前键入一个制表符(也就是tab)!自动变量是一种在命令行上根据具体情况能被自动替换的变量。自动变量的值是基于目标对象及其先决条件而在命令执行前设置的。这里列举一些比较常用的自动变量:$^:表示规则的所有先决条件,包括它们所处目录的名称;$<:表示规则中的第一个先决条件;$@:表示目标对象。
为了生成内核代码文件,Linux 0.11使用了两种编译器。第一种是汇编编译器as86和相应的链接程序(或称为链接器)ld86。它们专门用于编译和链接,运行在实地址模式下的16位内核引导扇区程序bootsect.s和设置程序setup.s。第二种是GNU的汇编器as(gas)和C语言编译器gcc以及相应的链接程序ld。编译器用于为源程序文件产生对应的二进制代码和数据目标文件。链接程序用于对相关的所有目标文件进行组合处理,形成一个可被内核加载执行的目标文件,即可执行文件。1、目标文件格式在Linux 0.11系统中,GNU gcc或as编译输出的目标模块文件和链接程序所生成的可执行文件都使用了UNIX传统的a.out格式。目标文件格式示意图如下:a.out格式7个区的基本定义和用途如下所述:执行头部分(exec header):该部分中含有一些参数(exec结构),是有关目标文件的整体结构信息。例如代码和数据区的长度、未初始化数据区的长度、对应源程序文件名以及目标文件创建时间等。内核使用这些参数把执行文件加载到内存中并执行,而链接程序ld使用这些参数将一些模块文件组合成一个可执行文件。这是目标文件唯一必要的组成部分。代码区(text segment):由编译器或汇编器生成的二进制指令代码和数据信息,含有程序执行时被加载到内存中的指令代码和相关数据。可以以只读形式被加载。数据区(data segment):由编译器或汇编器生成的二进制指令代码和数据信息,这部分含有已经初始化过的数据,数据区总是被加载到可读写的内存中。代码重定位部分(text relocations):这部分含有供链接程序使用的记录数据。在组合目标模块文件时,用于定位代码段中的指针或地址。当链接程序需要改变目标代码的地址时,就需要修正和维护这些地方。数据重定位部分(data relocations):类似于代码重定位部分的作用,但是用于数据段中指针的重定位。符号表部分(symbol table):这部分同样含有供链接程序使用的记录数据。这些记录数据保存着模块文件中定义的全局符号以及需要从其他模块文件中输入的符号,或者是由链接器定义的符号,用于在模块文件之间对命名的变量和函数(符号)进行交叉引用。字符串表部分(string table):该部分含有与符号名相对应的字符串。用于调试程序,调试目标代码,与链接程序无关。这些信息可包含源程序代码和行号、局部符号以及数据结构描述信息等。目标文件的文件头中含有一个长度为32字节的exec数据结构,通常称为文件头结构或者执行头结构。有关a.out结构的详细信息请参见include/a.out.h文件后的介绍。文件头结构定义如下:根据a.out文件中头结构魔数字段的值,我们又可把a.out格式的文件分成几种类型。Linux 0.11系统使用了其中两种类型:模块目标文件使用了OMAGIC(Old Magic)类型的a.out格式,它指明文件是目标文件或者是不纯的可执行文件,其魔数是0x107(八进制0407)。而执行文件使用了ZMAGIC类型的a.out格式,它指明文件为需求分页处理(demang-paging,即需求加载load on demand)的可执行文件,其魔数是0x10b(八进制0413)。这两种格式的主要区别在于它们对各个部分的存储分配方式上。虽然该结构的总长度只有32字节,但是对于一个ZMAGIC类型的执行文件来说,其文件开始部分却需要专门留出1024字节的空间给头结构使用。除头结构占用的32个字节以外,其余部分均为0。从1024字节之后才开始放置程序的正文段和数据段等信息。而对于一个OMAGIC类型的.o模块文件来说,文件开始部分的32字节头结构后面紧接着就是代码区和数据区。a_entry字段指定了程序代码开始执行的地址。而a_syms、a_trsize和a_drsize字段则分别说明了数据段后符号表、代码和数据段重定位信息的大小。对于可执行文件来说并不需要符号表和重定位信息,因此除非链接程序为了调试目的而包括了符号信息,执行文件中的这几个字段的值通常为0。Linux 0.11系统的模块文件和执行文件都是a.out格式的目标文件,但是只有编译器生成的模块文件中包含用于链接程序的重定位信息。代码段和数据段的重定位信息均由重定位记录项构成,每个记录的长度为8字节,其结构如下:重定位项的功能有两个。一是当代码段被重定位到一个不同的基地址处时,重定位项则用于指出需要修改的地方。二是在模块文件中存在对未定义符号引用时,当此未定义符号最终被定义时链接程序就可以使用相应重定位项对符号的值进行修正。目标文件的最后部分是符号表和相关的字符串表。符号表记录项的结构如下:2、Linux 0.11中的目标文件格式磁盘上a.out执行文件的各区在进程逻辑地址空间中的对应关系如下图所示:Linux 0.11系统中进程的逻辑地址空间大小是64MB。对于ZMAGIC类型的a.out执行文件,它的代码区的长度是内存页面的整数倍。由于Linux 0.11内核使用需求页(Demand-paging)技术,即在一页代码实际要使用的时候才被加载到物理内存页面中,而在进行加载操作的fs/execve()函数中仅仅为其设置了分页机制的页目录项和页表项,因此需求页技术可以加快程序的加载速度。图中bss是进程的未初始化数据区,用于存放静态的未初始化数据。在开始执行程序时,bss的第一页内存会被设置为全0。图中heap是堆空间区,用于分配进程在执行过程中动态申请的内存空间。3、链接程序输出链接程序对输入的一个或多个模块文件以及相关的库函数模块进行处理,最终生成相应的二进制执行文件或者是一个所有模块组合而成的大模块文件。对于a.out格式的模块文件来说,由于段类型是预先知道的,因此链接程序对a.out格式的模块文件进行存储分配比较容易。例如,对于具有两个输入模块文件和需要链接一个库函数模块的情况,其存储分配情况如下图所示:4、链接程序预定义变量在链接过程中,链接器ld和ld86会使用变量记录下执行程序中每个段的逻辑地址。因此在程序中可以通过访问这几个外部变量来获得程序中段的位置。链接器预定义的外部变量通常至少有etext、_etext、edata、_edata、end和_end。变量名etext和_etext的地址是程序正文段结束后的第1个地址;edata和_edata的地址是初始化数据区后面的第1个地址;end和_end的地址是未初始化数据区(bss)后的第1个地址位置。带下划线’_’前缀的名称等同于不带下划线的对应名称,它们之间的唯一区别在于ANSI、POSIX等标准中没有定义符号etext、edata和end。5、System.map文件在编译内核时,linux/Makefile文件产生的System.map文件就用于存放内核符号表信息。当内核运行出错时,通过System.map文件中的符号表解析,就可以查到一个地址值对应的变量名,或反之。尽管内核本身实际上不使用System.map,但其他程序,像klogd、lsof、ps以及其他像dosemu等许多软件都需要一个正确的System.map文件。利用该文件,这些程序就可以根据已知的内存地址查找出对应的内核变量名称,便于对内核的调试工作。
1、C函数调用机制函数调用操作包括从一块代码到另一块代码之间的双向数据传递和执行控制转移。数据传递通过函数参数和返回值来进行。另外,我们还需要在进入函数时为函数的局部变量分配存储空间,并且在退出函数时收回这部分空间。Intel 80x86 CPU为控制传递提供了简单的指令,而数据的传递和局部变量存储空间的分配与回收则通过栈操作来实现。(1)、栈帧结构和控制转移权方式大多数CPU上的程序实现使用栈来支持函数调用操作。栈被用来传递函数参数、存储返回信息、临时保存寄存器原有值以备恢复以及用来存储局部数据。单个函数调用操作所使用的栈部分被称为栈帧(Stack frame)结构,结构如图3-4所示。栈帧结构的两端由两个指针来指定。寄存器ebp通常用作帧指针(frame pointer),而esp则用作栈指针(stack pointer)。在函数执行过程中,栈指针esp会随着数据的入栈和出栈而移动,因此函数中对大部分数据的访问都基于帧指针ebp进行。对于函数A调用函数B的情况,传递给B的参数包含在A的栈帧中。当A调用B时,函数A的返回地址(调用返回后继续执行的指令地址)被压入栈中,栈中该位置也明确指明了A栈帧的结束处。而B的栈帧则从随后的栈部分开始,即图中保存栈指针(ebp)的地方开始。再随后则用于存放任何保存的寄存器值以及函数的临时值。栈是往低(小)地址方向扩展的,而esp指向当前栈顶处的元素。通过使用push和pop指令我们可以把数据压入栈中或者从栈中弹出。对于没有指定初始值的数据所需要的存储空间,我们可以通过把栈指针递减适当的值来做到。类似的,通过增加栈指针值我们可以回收栈中已分配的空间。指令CALL和RET用于处理函数调用和返回操作。调用指令CALL的作用是把返回地址压入栈中并且跳转到被调用函数开始处执行。返回地址是程序中紧随调用指令CALL后面一条指令的地址。因此当被调函数返回时就会从该位置继续执行。返回指令RET用于弹出栈顶处的地址并跳转到该地址处。在使用该指令之前,应该先正确处理栈中内容,使得当前栈指针所指位置内容正是先前CALL指令保存的返回地址。另外,若返回值是一个整数或者一个指针,那么寄存器eax将被默认用来传递返回值。尽管某一时刻只有一个函数在执行,但我们还是需要确定在一个函数(调用者)调用其他函数(被调用者)时,被调用者不会修改或覆盖掉调用者今后要用到的寄存器内容。因此Intel CPU采用了所有函数必须遵守的寄存器用法统一惯例。该惯例指明,寄存器eax、edx和ecx的内容必须由调用者自己负责保存。当函数B被A调用时,函数B可以在不用保存这些寄存器内容的情况下任意使用它们而不会毁坏函数A所需要的任何数据。另外,寄存器ebx、esi和edi的内容则必须由被调用者B来保护。当被调用者需要使用这些寄存器中的任何一个时,必须首先在栈中保存其内容,并在退出时恢复这些寄存器的内容。因为调用者A(或者一些更高层的函数)并不负责保存这些寄存器的内容,但可能在以后的操作中还需要用到原先的值。还有寄存器ebp和esp也必须遵守第二个惯例用法,即由被调用者来保护。(2)、函数调用举例我们观察下面C程序exch.c中函数调用的处理过程。该程序交换两个变量中的值,并返回它们的差值:其中函数swap()用于交换两个变量的值。C程序中的主程序main()也是一个函数(将在本章的后面进行说明),它在调用了swap()之后返回交换的结果。这两个函数的栈帧结构如图3-5所示。可以看出,函数swap()从调用者(main())的栈帧中获取其参数。图中的位置信息相对于寄存器ebp中的帧指针。栈帧左边的数字指出了相对于帧指针的地址偏移值。在象gdb这样的调试器中,这些数值都用2的补码表示,例如,’-4’被表示成’0xfffffffc’,’-12’被表示成’0xfffffff4’。调用者main()的栈帧结构中,包括局部变量a和b的存储空间,相对于帧指针位于-4和-8偏移处。由于我们需要为这两个局部变量生成地址,因此它们必须保存在栈中而非简单的存放在寄存器中。使用命令”gcc -Wall -S -o exch.s exch.c”可以生成该C语言程序的汇编程序exch.s代码,见如下所示(删除了一些无关的代码):.text_swap: pushl %ebp #保存原ebp值,设置当前函数的帧指针 movl %esp, %ebp subl $4, %esp #为局部变量c在栈内分配空间 movl 8(%ebp), %eax #取函数第1个参数,该参数是一个整数类型值的指针 movl (%eax),%ecx #取该指针所指位置的内容,并保存到局部变量c中 movl %ecx, -4(%ebp) movl 8(%ebp), %eax #再次取第1个参数,然后取第2个参数 movl 12(%ebp), %edx movl (%edx), %ecx #把第2个参数所指内容放到第1个参数所指的位置 movl %ecx, (%eax) movl 12(%ebp), %eax #再次取第2个参数 movl -4(%ebp), %ecx #然后把局部变量c中的内容放到这个指针所指位置处 movl %ecx, (%eax) leave #恢复原ebp、esp值(即movl %ebp,%esp; popl %ebp) ret_main: pushl %ebp #保存原ebp值,设置当前函数的帧指针 movl %esp, %ebp subl $8, %esp #为整型局部变量a和b在栈中分配空间 movl $16, -4(%ebp) #为局部变量赋初值(a=16,b=32) movl $32, -8(%ebp) leal -8(%ebp), %eax #为调用swap()函数作准备,取局部变量b的地址 pushl %eax #作为调用的参数并压入栈中,即先压入第2个参数 leal -4(%ebp), %eax #再取局部变量a的地址,作为第1个参数入栈 pushl %eax call _swap #再调用函数swap() movl -4(%ebp), %eax #取第一个局部变量a的值,减去第二个变量b的值 subl -8(%ebp), %eax leave #恢复原ebp、esp值(即movl %ebp,%esp; popl %ebp) ret这两个函数均可以划分为3个部分:”设置”,初始化栈帧结构;”主体”,执行函数的实际计算操作;”结束”,恢复栈状态并从函数中返回。leave指令用于处理栈内容以准备返回,它的作用等价于下面两个指令:movl %ebp, %esp#恢复原esp的值(指向栈帧开始处)popl %ebp#恢复原ebp的值(通常是调用者的帧指针)变量地址压入栈中的顺序正好与函数声明的参数顺序相反。即函数最后一个参数首先压入栈中,而函数的第1个参数则是最后一个在调用函数指令call之前压入栈中的。(3)、main也是一个函数上面提到C程序的主程序main()也是一个函数,这是因为在编译链接时它将会作为crt0.s汇编程序的函数被调用。crt0.s是一个桩(stub)程序,名称中的”crt”是”C run-time”的缩写。该程序的目标文件将被链接在每个用户执行程序的开始部分,主要用于设置一些初始化全局变量等。Linux 0.11中crt0.s汇编程序如下所示,其中建立并初始化全局变量_environ供程序中其他模块使用。.text.globl _environ #声明全局变量_environ(对应C程序中的environ变量)__entry:#代码入口标号 movl 8(%esp), %eax #取程序的环境变量指针envp并保存在_environ中 movl %eax, _environ #envp是execve()函数在加载执行文件时设置的 call _main #调用我们的主程序,其返回状态值在eax寄存器中 pushl %eax #压入返回值作为exit()函数的参数并调用该函数1:call _exit jmp 1b #控制应该不会到达这里,若到达这里则继续执行exit().data_environ: #定义变量_environ,为其分配一个长字空间 .long 0在通常的编译过程中,我们无需特别指定stub模块crt0.o,但是若想从上面给出的汇编程序手工使用ld从exch.o模块链接产生可执行文件exch,那么我们就需要在命令行上特别指明crt0.o这个模块,并且链接的顺序应该是”crt0.o、所有程序模块、库文件”。为了使用ELF格式的目标文件以及建立共享库模块文件,现在的gcc编译器(2.x)已经把这个crt0扩展成几个模块:crt1.0、crti.o、crtbegin.o、crtend.o和crtn.o。这些模块的链接顺序为“crt1.0、crti.o、crtbegin.o(crtbeginS.o)、所有程序模块、crtend.o(crtendS.o)、crtn.o、库模块文件”。gcc的配置文件specfile指定了这种链接顺序。其中crt1.o、crti.o和crtn.o由C库提供,是C程序的“启动”模块;crtbegin.o和crtend.o是C++语言的启动模块,由编译器gcc提供;而crt1.o则与crt0.o的作用类似,主要用于在调用main()之前做一些初始化工作,全局符号_start就定义在这个模块中。crtbegin.o和crtend.o主要用于C++语言在.ctors和.dtors区中执行全局构造器(constructor)和析构器(destructor)函数。crtbeginS.o和crtendS.o的作用与前两者类似,但用于创建共享模块中。crti.o用于在.init区中执行初始化函数init()。.init区中包含进程的初始化代码,即当程序开始执行时,系统会在调用main()之前先执行.init中的代码。crtn.o则用于在.fini区中执行进程终止退出处理函数fini()函数,即当程序正常退出时(main()返回之后),系统会安排执行.fini中的代码。boot/head.s程序中第136-140行就是用于为跳转到init/main.c中的main()函数作准备工作。第139行上的指令在栈中压入了返回地址,而第140行则压入了main()函数代码的地址。当head.s最后在第218行上执行ret指令时就会弹出main()的地址,并把控制权转移到init/main.c程序中。2、在汇编程序中调用C函数在汇编程序调用一个C函数时,程序需要首先按照逆向顺序把函数参数压入栈中,即函数最后(最右边的)一个参数先入栈,而最左边的第1个参数在最后调用指令之前入栈,如图3-6所示。然后执行CALL指令去执行被调用的函数。在调用函数返回后,程序需要再把先前压入栈中的函数参数清除掉。在执行CALL指令时,CPU会把CALL指令下一条指令的地址压入栈中(见图中EIP)。如果调用还涉及到代码特权级变化,那么CPU还会进行堆栈切换,并且把当前堆栈指针、段描述符和调用参数压入新堆栈中。由于Linux内核中只使用中断门和陷阱门方式处理特权级变化时的调用情况,并没有使用CALL指令来处理特权级变化的情况,因此这里对特权级变化时的CALL指令使用方式不再进行说明。汇编中调用C函数比较“自由”。只要是在栈中适当位置的内容就都可以作为参数供C函数使用。这里以图3-6中具有3个参数的函数调用为例,如果我们没有专门为调用函数func()压入参数就直接调用它的话,那么func()函数仍然会把存放EIP位置以上的栈中其他内容作为自己的参数调用。如果我们为调用func()而仅仅明确的压入了第1、第2个参数,那么func()函数的第3个参数p3就会直接使用p2前的栈中内容。另外,我们说汇编程序调用C函数比较自由的另一个原因是我们可以根本不用CALL指令而采用JMP指令来同样达到调用函数的目的。方法是在参数入栈后人工把下一条要执行的指令地址压入栈中,然后直接使用JMP指令跳转到被调用函数开始地址处去执行函数。此后,当函数执行完成时就会执行RET指令把我们人工压入栈中的下一条指令地址弹出,作为函数返回的地址。Linux内核中也有多处用到了这种函数调用方法,例如kernel/asm.s程序第62行调用执行traps.c中的do_int3()函数的情况。3、在C程序中调用汇编函数从C程序中调用汇编程序函数的方法与汇编程序中调用C函数的原理相同,但Linux内核程序中不常使用。调用方法的着重点仍然是对函数参数在栈中位置的确定上。当然,如果调用的汇编语言程序比较短,那么可以直接在C程序中使用前面介绍的内联汇编语句来实现。下面是一个示例,包含两个函数的汇编程序callee.s如下所示:#/*# *By:Ailson Jack# *Date:2018.08.28# *Blog:www.only2fire.com# *Des:本汇编程序利用系统调用sys_write()实现显示函数int mywrite(int fd, char *buf, int count).# * 函数int myadd(int a, int b, int *res)用于执行a+b=res运算.若函数返回0,则说明溢出.# *注意:如果在现在的Linux系统(例如RedHat9)下编译,则请去掉函数名前的下划线'_'.# *编译:as -o callee.o callee.s#*/#.code32 #让as汇编器切换为32位代码汇编方式,否则在64位系统下编译时,编译的时候可能会出现"invalid instruction suffix for push"的问题SYSWRITE = 4 #sys_write()系统调用号.globl _mywrite, _myadd.text_mywrite: pushl%ebp movl%esp, %ebp pushl%ebx movl8(%ebp), %ebx #取调用者的第1个参数:文件描述符 fd movl12(%ebp), %ecx #取第2个参数:缓冲区指针 movl16(%ebp), %edx #取第3个参数:显示字符数 movl$SYSWRITE, %eax # %eax中放入系统调用号4 int $0x80 #执行系统调用 popl%ebx movl%ebp, %esp popl%ebp ret_myadd: pushl%ebp movl%esp, %ebp movl8(%ebp), %eax #取第1个参数:a movl12(%ebp), %edx #取第2个参数:b xorl%ecx, %ecx # %ecx为0表示计算溢出 addl%eax, %edx #执行加法运算 jo 1f #若溢出 则跳转 movl16(%ebp), %eax #取出第3个参数的指针 movl%edx, (%eax) #把计算结果放入指针所指位置处 incl%ecx #没有发生溢出,于是设置无溢出返回值1:movl%ecx, %eax # %eax中是函数返回值 movl%ebp, %esp popl%ebp ret该汇编文件中的第1个函数mywrite()利用系统中断0x80调用系统调用sys_write(int fd, char *buf, int count)实现在屏幕上显示信息。对应的系统调用功能号是4(参见include/unistd.h中的__NR_write),三个参数分别为文件描述符、显示缓冲区指针和显示字符数。在执行int 0x80之前,寄存器%eax中需要放入调用功能号(4),寄存器%ebx、%ecx和%edx要按调用规定分别存放fd、buf和count。函数mywrite()的调用参数个数和用途与sys_write()完全一样。第2个函数myadd(int a, int b, int *res)执行加法运算,其中参数res是运算的结果。函数返回值用于判断是否发生溢出。如果返回值为0表示计算已经发生溢出,结果不可用。否则计算结果将通过res返回给调用者。注意:如果在现在的Linux系统(例如RedHat 9)下编译callee.s程序,则请去掉函数名前的下划线’_’。调用这两个函数的C程序caller.c如下所示:该函数首先利用汇编函数mywrite()在屏幕上显示开始计算的信息“Calculating…”,然后调用加法计算汇编函数myadd()对a和b两个数进行运算,并在第3个参数res中返回计算结果。最后再利用mywrite()函数把格式化过的结果信息字符串显示在屏幕上。如果函数myadd()返回0,则表示加函数发生溢出,计算结果无效。这两个文件的编译和运行结果如下所示:# as -o callee.o callee.s# gcc -o caller caller.c callee.o# ./caller注意:上述汇编程序和C程序在我提供的Linux 0.11系统和Ubuntu 14.04的32位系统均可以编译和运行成功;在Ubuntu 14.04的64位系统中可以编译,但是运行不成功。在其他的32位版本Linux系统中应该也可以正常的编译和运行,64位系统可能不行。 上述代码例子我已经上传到云盘,大家可以自行下载研究,下载地址:点此下载 密钥:9gdm。
本小节给出内核中经常用到的一些gcc扩充语句的说明。1、C程序编译和链接使用gcc编译器编译C语言程序时,通常会经过4个处理阶段,即预处理阶段、编译阶段、汇编阶段和链接阶段,如下图所示:gcc的命令行格式如下:gcc [选项] [-o outfile] infile…infile是输入的C语言文件,outfile是编译产生的输出文件。对于某次编译过程,并非一定要全部执行这4个阶段,使用命令行选项可以让gcc编译过程在某个处理阶段后就停止执行。例如,使用’-S’选项可以让gcc在输出了C程序对应的汇编语言程序之后就停止运行;使用’-c’选项可以让gcc只生成目标文件(.o后缀的文件)而不执行链接处理。2、嵌入汇编本节介绍内核C语言程序中接触到的嵌入式汇编(内联汇编)语句。具有输入和输出参数的嵌入式汇编语句的基本格式如下:asm(“汇编语句” : 输出寄存器 : 输入寄存器 : 会被修改的寄存器);除第1行以外,后面带冒号的行若不使用就都可以省略。其中,”asm”是内联汇编语句的关键词;”汇编语句”是你写汇编指令的地方。”输出寄存器”表示当这段嵌入式汇编执行完之后,哪些寄存器用来存放输出数据,这些寄存器会分别对应一C语言表达式值或一个内存地址。”输入寄存器”表示在开始执行汇编代码时,这里指定的一些寄存器中应存放的输入值,它们也分别对应着一C变量或常数值。”会被修改的寄存器”表示你已对其中列出的寄存器中的值进行了改动,gcc编译器不能再依赖于它原先对这些寄存器加载的值。如果必要的话,gcc需要重新加载这些寄存器。因此我们需要把那些没有在输出/输入寄存器部分列出,但是在汇编语句中明确使用到或隐含使用到的寄存器名列在这部分。下面列出了kernel/traps.c文件中第22行开始的一段代码作为例子来进行说明:#define get_seg_byte(seg,addr) \({ \register char __res; \ //定义了一个寄存器变量__res__asm__("push %%fs; \ //首先保存了fs寄存器原值(段选择符) mov %%ax,%%fs; \ //然后用seg设置fs movb %%fs:%2,%%al; \ //取seg:addr处1字节内容到al寄存器中 pop %%fs" \ //恢复fs寄存器原内容 :"=a" (__res) \ //输出寄存器列表 :"0" (seg),"m" (*(addr))); \ //输入寄存器列表__res;})这段10行代码定义了一个嵌入式汇编语言宏函数。通常使用汇编语句最方便的方式是把它们放在一个宏内。用圆括号括住的组合语句(花括号中的语句):”({})”可以作为表达式使用,其中最后一行上的变量__res(第10行)是该表达式的输出值,见下一节说明。因为宏语句需要在一行上,因此这里使用反斜杠’\’将这些语句连接成一行。这条宏定义将被替换到程序中引用该宏名称的地方。第1行定义了宏名称,也即是宏函数名称get_seg_byte(seg,addr)。第3行定义了一个寄存器变量__res,该变量将被保存在一个寄存器中,以便于快速访问和操作。如果想指定寄存器(例如eax),那么我们可以把该句写成”register char __res asm(“ax”);”,其中’asm’也可以写成’__asm__’。第4行上的’__asm__’表示嵌入汇编语句的开始。从第4行到第7行的4条语句是AT&T格式的汇编语句。另外,为了让gcc编译产生的汇编语言程序中寄存器名称前有一个百分号”%”,在嵌入式汇编语句寄存器名称前就必须写上两个百分号”%%”。第8行即是输出寄存器,这句的含义是在这段代码运行结束后将eax所代表的寄存器的值放入__res变量中,作为本函数的输出值。”=a”中的”a”称为加载代码,”=”表示这是输出寄存器,并且其中的值将被输出值替代。第9行表示在这段代码开始运行时,将seg放到eax寄存器中,”0”表示使用与上面同个位置的输出相同的寄存器。而(*(addr))表示一个内存偏移地址值。为了在上面汇编语句中使用该地址值,嵌入汇编程序规定把输出和输入寄存器统一按顺序编号,顺序是从输出寄存器序列从左到右从上到下以”%0”开始,分别记为%0、%1、…、%9。因此,输出寄存器的编号是%0(这里只有一个输出寄存器),输入寄存器前一部分(“0”(seg))的编号是%1,而后部分的编号是%2。上面第6行上的%2即代表(*(addr))这个内存偏移量。该宏函数的功能是从指定段和偏移值的内存地址处取一个字节。下面在看一个例子:asm(“cld\n\t” “rep\n\t” “stol” : /*没有输出寄存器*/ : “c”(count-1), “a”(fill_value), “D”(dest) : “%ecx”, “edi%”);第4行说明这段嵌入汇编程序没有用到输出寄存器。第5行的含义是:将count-1的值加载到ecx寄存器中(加载代码是”c”),fill_value加载到eax中,dest放到edi中。表3-4中是一些你可能会用到的寄存器加载代码及其具体的含义:下面的例子不是让你自己指定哪个变量使用哪个寄存器,而是让gcc为你选择。asm(“leal (%1,%1,4), %0” : “=r”(y) : “0”(x));“leal (r1, r2, 4), r3”语句表示:(r1+r2*4) -> r3。如果gcc将r指定为eax的话,那么上面汇编语句的含义为:“leal (eax,eax,4), eax”注意:在执行代码时,如果不希望汇编语句被gcc优化而作修改,就需要在asm符号后面添加关键词volatile,下面列出了两种声明方式:asm volatile (...);__asm__ __volatile__ (...); //建议使用这种方式,兼容性更好关键词volatile也可以放在函数名前来修饰函数,用来通知gcc编译器该函数不会返回,即该函数不会返回到调用者代码中(例如do_exit()函数)。下面看一个比较长的例子,该代码是从include/string.h文件中摘取的,是strncmp()字符串比较函数的一种实现。其中每行中的”\n\t”是用于gcc预处理程序输出列表好看而设置的。//字符串1和字符串2的前count个字符进行比较//参数: cs:字符串1, ct:字符串2, count:比较的字符数//%0:eax(__res)返回值, %1:edi(cs)串1指针, %2:esi(ct)串2指针, %3:ecx(count)//返回:如果串1>串2,则返回1; 串1==串2,则返回0; 串1<串2,则返回-1extern inline int strncmp(const char * cs,const char * ct,int count){register int __res __asm__("ax"); //__res是寄存器变量,该变量保存在寄存器eax中__asm__("cld\n" //清方向位 "1:\tdecl %3\n\t" //count-- "js 2f\n\t" //如果count<0,则向前跳转到标号2 "lodsb\n\t" //取串2的字符ds:[esi]->al,并且esi++ "scasb\n\t" //比较al与串1的字符es:[edi],并且edi++ "jne 3f\n\t" //如果不相等,则向前跳转到标号3 "testb %%al,%%al\n\t" //该字符是NULL字符吗? "jne 1b\n" //不是,则向后跳转到标号1,继续比较 "2:\txorl %%eax,%%eax\n\t" //是NULL字符,则eax清零(返回值) "jmp 4f\n" //向前跳转到标号4,结束 "3:\tmovl $1,%%eax\n\t" //eax中置1 "jl 4f\n\t" //如果前面比较中,串2字符<串1字符,则返回1,结束 "negl %%eax\n" //否则eax=-eax,返回负值,结束 "4:" :"=a" (__res):"D" (cs),"S" (ct),"c" (count):"si","di","cx");return __res; //返回比较结果}3、圆括号中的组合语句花括号对”{...}”用于把变量声明和语句组合成一个复合语句(组合语句)或一个语句块,这样在语义上这些语句就等同于一条语句,组合语句的右花括号后面不需要使用分号。圆括号中的组合语句,即形如”({...})”的语句,可以在GNU C中用作一个表达式使用。这样就可以在表达式中使用loop、switch语句和局部变量,因此这种形式的语句通常称为语句表达式。语句表达式具有如下示例的形式:({int y = foo(); int z; if(y > 0) z = y; else z = -y; 3 + z;})其中组合语句中最后一条语句必须是后面跟随一个分号的表达式。这个表达式(“3+z”)的值即用作整个圆括号括住语句的值。如果最后一条语句不是表达式,那么整个语句表达式就具有void类型,因此没有值。这种表达式中语句声明的任何局部变量都会在整个语句结束后失效。4、寄存器变量GNU 对C语言的另一个扩充是允许我们把一些变量值放到CPU寄存器中,即所谓的寄存器变量。寄存器变量可以分为2种:全局寄存器变量和局部寄存器变量。在GNU C程序中我们可以在函数中用如下形式定义一个局部寄存器变量:register int res __asm__(“ax”);这里ax是变量res所希望使用的寄存器。5、内联函数在程序中,通过把一个函数声明为内联(inline)函数,就可以让gcc把函数的代码集成到调用该函数的代码中去。这样处理可以去掉函数调用时进入/退出时间开销,从而肯定能够加快执行速度。因此把一个函数声明为内联函数的主要目的就是能够尽量快速的执行函数体。内联函数嵌入调用者代码中的操作是一种优化操作,因此只有进行优化编译时才会执行代码嵌入处理。若编译过程中没有使用优化选项”- 0”,那么内联函数的代码就不会被真正的嵌入到调用者代码中,而是只作为普通函数调用来处理。把一个函数声明为内联函数的方法是在函数声明中使用关键词’inline’,例如内核文件fs/inode.c中的如下函数:函数中的某些语句用法可能会使得内联函数的替换操作无法正常进行,或者不适合进行替换操作。例如使用了可变参数、内存分配函数malloca()、可变长度数据类型变量、非局部goto语句、以及递归函数。编译时可以使用选项-Winline让gcc对标志成inline但不能被替换的函数给出警告信息以及不能替换的原因。ISO标准C99的内联函数语义定义等同于使用组合关键词inline和static的定义,即省略了关键词”static”。若在程序中需要使用C99标准的语义,那么就需要使用编译选项-std=gnu99。不过为了兼容起见,在这种情况下还是最好使用inline和static组合。关键词inline和extern组合在一起的作用几乎类同一个宏定义。使用这种组合方式就是把带有组合关键词的一个函数定义放在.h头文件中,并且把不含关键词的另一个相同函数定义放在一个库文件中。此时头文件中的定义会让绝大数对该函数的调用被替换嵌入。如果还有没有被替换的对该函数的调用,那么就会使用程序文件中或库中的拷贝。Linux 0.1.x内核源代码中文件include/string.h、lib/strings.c就是这种使用方式的一个例子。例如,string.h中定义了如下函数:而在内核函数库目录中,lib/strings.c文件把关键词inline和extern都定义为空,如下所示。因此实际上就在内核函数库中又包含了string.h文件所有这类函数的一个拷贝,即又对这些函数重新定义了一次,并且”消除”了两个关键词的作用。此时库函数中重新定义的上述strcpy()函数变成如下形式:
- 1
本站信息
目前本站共被浏览 165583 次
目前本站已经运行 3577 天
目前本站共有 165 篇文章
目前本站共有 6 条评论信息
目前本站共有 104 个标签
目前本站共有 0 条留言信息
网站创建时间: 2015年03月01日
最近更新时间: 2023年11月26日
目前本站已经运行 3577 天
目前本站共有 165 篇文章
目前本站共有 6 条评论信息
目前本站共有 104 个标签
目前本站共有 0 条留言信息
网站创建时间: 2015年03月01日
最近更新时间: 2023年11月26日
JLink V9掉固件修复(灯不亮) 3Zephyr笔记2 - 在STM32F429上运行HelloWorld 2计算NandFlash要传入的行地址和列地址 1Linux MMC子系统 - 6.eMMC 5.1工作模式-设备识别模式 0Linux MMC子系统 - 5.eMMC 5.1工作模式-引导模式 0Linux MMC子系统 - 4.eMMC 5.1常用命令说明(2) 0
最新评论
标签云
Linux嵌入式实用技巧ARM内核学习问题集合CC++编程语言阅读笔记汇编Linux内核完全注释Windows驱动开发计算机基础ARM11ARMv7-ASTM32IDESublimeLinux内核学习eMMCMMC子系统Ubuntu操作系统OfficeVMWareAPUEgccRTOS中断漫游世界随笔感悟开发工具软件应用编程VsCodearmccarmclang编译器ZephyrSPIJLink网卡驱动安装各种芯片库函数NFSμCOS内核sambaFlashUnix命令与脚本输入法Linux内核设计与实现gitRIFFWAVJATGFTPar8161安装centos有线上网μCGUI字库工程建立右键菜单网络文件系统Firefox百度NTFS文件系统CodeBlocksCentOS数据结构算法PhotoShop51KeilQTUltraEditscanfglibc宏定义UIDGID优先级娱乐天地SourceInsight磁盘扇区总线I2CPDFBComparePythonI2SFPUMakefileSWDCPUARP软件推荐FileZilla