目录
1. 设备驱动的作用
2. 有无操作系统时的设备驱动
2.1 无操作系统
2.1.1 硬件、驱动和应用程序的关系
2.1.2 单任务软件典型架构
2.2 有操作系统
2.2.1 硬件、驱动、操作系统和应用软件的关系
3. Linux设备分类
3.1 常规分类法
3.1.1 字符设备
3.1.2 块设备
3.1.3 网络设备
3.2 总线分类法
4. Linux设备驱动在整个软硬件系统中的位置
5. 内核空间与用户空间
5.1 硬件基础
5.2 软件使用
5.3 内核态与用户态
6. GNU C对ANSI C的常见扩展
6.1 零长度数组
6.2 case范围
6.3 语句表达式
6.4 typeof关键字
6.5 可变参数宏
6.6 当前函数名宏
6.7 特殊属性声明__attribute__
6.7.1 noreturn
6.7.2 unused
6.7.3 aligned
6.7.4 packed
6.7.5 section
6.7.6 format
6.8 内建函数
6.8.1 __builtin_constant_p
6.8.2 __builtin_expect
7. 内核编程其他主题
7.1 do {} while(0)
7.2 goto语句的使用
7.3 内核中的并发
7.4 当前进程的获取
7.4.1 before 2.6
7.4.2 from 2.6
7.5 浮点工具链
7.6 其他细节
核心:充当硬件和应用软件之间的纽带
具体任务:
① 读写设备寄存器(实现控制的方式)
② 完成设备的轮询、中断处理、DMA通信(CPU与外设通信的方式)
③ 进行物理内存向虚拟内存的映射(在开启硬件MMU的情况下)
说明1:无操作系统特点
① 驱动包含的接口函数直接与硬件功能吻合,没有任何附加功能(向下)
② 设备驱动的接口被直接提交给应用软件工程师,应用软件直接访问设备驱动的接口(向上)
说明2:无操作系统情况下,两种不合理的驱动架构
缺点:设备驱动和应用软件平等,驱动中包含了业务层面的处理,不符合高内聚,低耦合的要求
缺点:应用软件直接操作硬件寄存器,不单独设计驱动模块,代码不可复用
在一个无限循环中夹杂着对设备中断的检测或者对设备的轮询(前后台系统)
说明1:设备驱动的2个任务
① 操作硬件(向下)
② 将驱动融于内核,需要设计面向操作系统内核的接口,这些接口由操作系统定义(向上)
在有操作系统的情况下,驱动的架构由相应的操作系统定义,必须按照相应的架构设计驱动
结果:驱动成为连接硬件和内核的桥梁 !!!
说明2:操作系统通过给驱动制造麻烦来给上层应用提供便利
由于驱动都按照操作系统给出的独立于设备的接口设计,应用程序可以使用统一的系统调用接口来访问各种设备
e.g. 使用write和read函数可以访问各种字符设备和块设备,而不论设备的具体类型和工作方式
特点:
① 以串行顺序依次进行访问的设备
② 字符设备不经过系统的快速缓冲
e.g. 触摸屏、鼠标
特点:
① 可以用任意顺序进行访问,以块为单位进行操作
② 块设备经过系统的快速缓冲
③ 在块设备上可以构建文件系统
e.g. 硬盘、SD卡
说明:块设备以块为最小传输单位,不能按字节处理数据。而Linux则允许块设备传送任意数量的字节,因此块 & 字符设备的区别仅在于内核内部管理数据的方式不同,即内核和驱动之间的软件接口不同
特点:
① 网络设备面向数据包的接收和发送设计
② 网络设备不对应于文件系统的结点
说明:与字符 & 块设备一样,网络设备也可以是一个纯粹的软件设备(e.g. 回环网卡)
示例:I2C驱动 / USB驱动 / PCI驱动 / LCD驱动
这些驱动都可以归入常规分类法的3个基础类别,但由于这些设备比较复杂,Linux为其定义了各自的驱动体系结构(即内核提供了给定类型设备的附加层,我们编写的驱动是和这些附加层一起工作)
所谓给定类型设备的附加层,其实就是内核开发者实现了整个设备类型的共有特性,并提供给驱动程序实现者
e.g. USB设备由USB模块驱动,而USB模块和USB子系统一起工作。但USB设备本身在系统中可以表现为一个字符设备(e.g. USB串口)/ 块设备(e.g. USB读卡器)/ 网络设备(e.g. USB网卡)
说明1:除网络设备外,字符设备和块设备都被映射为Linux文件系统中的文件,可以通过文件系统的系统调用接口(open / close / read / write)访问
说明2:对块设备的2种访问方式
① 原始块访问(e.g. dd命令)
② 构建文件系统通过文件访问
说明3:Linux块子系统 & MTD子系统
① MTD子系统面向Nor & Nand Flash工作,在其上可建立Yaffs等文件系统
② Linux块子系统面向磁盘 & MMC/SD工作,在其上可建立FAT/EXT等文件系统
操作系统能区分为内核空间与用户空间的硬件基础是CPU支持不同的工作模式
ARM:支持usr / fiq / irq / svc / sys / und / abt 七种模式(ARM v6架构)
X86:拥有ring 0 ~ ring 3四种特权等级
Linux利用CPU的这一特性实现内核态和用户态,但他只使用两级
ARM:内核态(svc模式)、用户态(usr模式)
X86:内核态(ring 0)、用户态(ring 3) // ring 1 现在被用于实现虚拟化
说明1:ARM Linux的系统调用实现原理是采用swi软中断从用户态切换至内核态
说明2:X86是通过int 0x80中断进入内核态
内核态:可以进行任何操作
用户态:禁止对硬件的直接访问和对内存的未授权访问
说明:内核态和用户态使用不同的地址空间(即有自己的内存映射),Linux只能通过系统调用和硬件中断从用户空间进入内核空间
补充:在进入内核态时,系统调用和硬件中断的不同
① 执行系统调用的内核代码运行在进程上下文中,他代表调用进程执行操作,因此能够访问进程地址空间的所有数据
② 处理硬件中断的内核代码运行在中断上下文中,他和进程是异步的,与任何一个特定进程无关
通常,一个驱动程序模块中的某些函数作为系统调用的一部分,而其他函数负责中断处理
struct var_data
{
int len;
char data[0];
};
说明1:由于没有为data数组分配内存,因此sizeof(struct var_data) = sizeof(int)
说明2:char data[0]意味着通过var_data结构体类型变量的data[i]成员可以访问len之后的第i个地址中的内容。
e.g. 假设struct var_data的数据域就保存在struct var_data紧接着的内存区域,那么可以通过如下方式遍历这些数据。
struct var_data s;
...
for (i = 0; i < s.len; ++i) // 此时s.len中保存的就是实际的数据域字节数
printf("%x\n", s.data[i]);
典型应用场景:定义变长对象的头结构(e.g. 802.11帧头部,由于Information Elements的存在,帧长度可变~~)
补充:其实只有在数据域紧接着struct var_data分配时,零长度数组才有意义
上机测试:成员数组data的起始地址
GNU C 支持case x...y 语法
switch (ch)
{
case '0'...'9':
ch -= '0';
break;
case 'a'...'f'
ch -= 'a' - 10;
break;
case 'A'...'F':
ch -= 'A' - 10;
break;
}
其实就是花括号中的复合语句,在花括号中可以定义变量
在这个topic下主要想讨论一种避免副作用的宏定义方式,
#definf MIN(x, y) ((x) < (y) ? (x) : (y))
这种宏定义方式已经考虑得比较全面,但不能避免调用时的副作用,比如,
int x = 10;
int y = 20;
// int z = ((x++) < (y++) ? (x++) : (y++))
// 由于副作用变量错误地累加了2次
int z = MIN(x++, y++);
注意:在实际使用中要避免在调用宏时带副作用
改进方式:在复合语句中定义局部变量
#define MIN(type, x, y) \
({type _x = (x); type _y = (y); _x < _y ? _x : _y})
// int z = {int _x=x++; int _y=y++; _x < _y ? _x : _y};
int z = MIN(int, x++, y++);
typeof(x)可以获得x的类型,借助这个宏,可以重写上面的MIN 宏(内核代码中的实现)
#define MIN(x, y) ({ \
const typeof(x) _x = (x); \
const typeof(y) _y = (y); \
(void) (&_x == &_y); \
_x < _y ? _x : _y})
说明:(void)(&_x == &_y) 的作用是判断参与比较的两个值类型是否一致
_x 和_y 的地址值当然不可能相同,但是如果两个变量的类型不同,此处进行地址比较就会使得编译器警告:comparison of distinct pointer types lacks a cast
这是因为C语言中的指针包含(地址值 + 基类型)这2个属性
#define pr_debug(fmt, args...) printk(fmt, ##args)
说明:pre_debug宏中的args表示其余的参数,可以是零个或多个
pre_debug("%s:%d\n", filename, line);
// 展开为
printk("%s:%d\n", filename, line);
使用##是为了处理args参数个数为零个的情况,此时前面的逗号变得多余,使用##后,GNU C处理器会丢弃前面的逗号
pre_debug("success!\n");
// 展开为
printk("success!\n");
而不是
printk("success!\n",);
说明:##的作用
##的作用是对标记(token)进行连接,在上例中fmt和args均为token。如果token为空,则不进行连接,并且删除掉多余的逗号
此处的关键如何删除掉多余的逗号,作为验证,我们定义如下2个宏,
#define pr_debug_a(fmt, args...) printf(fmt, args)
#define pr_debug_b(fmt, args...) printf(fmt, ##args)
// 以如下相同的方式调用上述宏
pr_debug_a("hello");
pr_debug_b("hello");
使用gcc -E选项查看预处理结果如下,
验证结果在预期之中,不加##号的宏会有多余的逗号,编译时将会失败。下面来验证一下是不是##号将多余的逗号删除的,我们定义如下的宏,
#define test(args...) , ##args
// 以如下方式调用上述宏
test();
test("hello");
使用gcc -E选项查看预处理结果如下,
可见##号在可变参数args为空的情况下,确实可以删除之前多余的逗号;如果args不为空,宏也能正常工作
脑洞验证再进行一步,##号能删除多个逗号吗 ? 我们定义如下的宏
#define test(args...) ,, ##args
test();
预处理结果如下,可见##号只能删除一个多余逗号
那么##号可以删除其他符号吗 ? 经过验证,如果换成其他符号或token,预处理均会失败
#define test(args...) ; ##args // 预处理失败
#define test(args...) a ##args // 预处理失败
GNU C中使用宏__FUNCTION__ 保存函数在源代码中的名字,C99中新增了__func__ 宏表示当前函数名。
目前建议在Linux编程中使用__func__ 宏
用途:声明函数、变量和类型的特殊属性,以便进行手工的代码优化和定制代码检查的方法
语法:在需要修饰的声明后面添加__attribute__((ATTRIBUTE)),其中ATTRIBUTE为属性说明,如果存在多个属性,以逗号分隔
下面列举几个常用的属性:
用于函数,表示函数从不返回。编译器可以据此优化代码(e.g. 不为函数返回准备寄存器),并消除不必要的警告信息
用于函数和变量,表示该函数或变量可能不会被用到,可避免编译器产生的警告
用于变量、结构体或联合体,指定变量、结构体或联合体的对齐方式,以字节为单位
struct example_struct
{
char a;
int b;
long c;
}__attribute__((aligned(4))); // 以4字节对齐
下面通过一个实例来了解下aligned的作用:
aligned(4)
aligned(16)
分析:如果将__attribute__((aligned(n)))作用于一个类型(n必须为2的幂次方),那么该类型变量在分配地址空间时,其存放的地址一定按照n字节对齐;并且其占用的空间也是n的整数倍
从验证结果看,此处按16B对齐是整个结构体的起始地址,并不是结构体中每个成员
用于变量和类型,用于变量或结构体时表示使用最小可能的对齐;用于枚举、结构体或联合体类型是表示该类型使用最小的内存
说明:可见对整个结构体类型使用packed属性后,不再对齐和补齐(但实际编程时不建议使用,因为非对齐的内存访问效率较低)
用于函数或数据,表示将其链接到指定的段
__attribute__((section("section_name")))
用于函数,表示函数使用printf / scanf风格的参数,指定format属性可以让编译器根据格式串检查参数类型
该属性说明printk的第1个参数是格式串,从第2个参数开始会根据printf函数的格式串规则检查参数
GNU C提供了大量的内建函数,其中大部分是标准C库函数的GNU C编译器内建版本(如memcpy等),他们与对应的标准C库函数功能相同
不属于库函数的其他内建函数的命名通常以__builtin开始
__builtin_constant_p(EXP)用于判断一个值是否为编译时常数,如果参数EXP的值是常数该函数返回1,否则返回0
__builtin_expect(EXP, C)用于为编译器提供分支预测信息,其返回值是整数表达式EXP的值,C的值必须是编译时常数
由于代码中的分支语句会中断流水线,所以可以使用likely & unlikely宏暗示分支容易成立还是不容易成立
说明:可以使用-ansi -pedantic编译选项禁用GNU C语法
使用场景:宏定义
示例:
#define SAFE_FREE(p) do {free(p); p = NULL;} while(0) // 此处没有分号哦~~
if (p)
SAFE_FREE(p);
else
... // do something
说明:此时的宏展开不会有问题。如果仅仅使用花括号,仍可能有潜在风险
#define SAFE_FREE(p) {free(p); p = NULL;}
if (p)
SAFE_FREE(p); // 调用后添加分号,是通常的使用习惯
else
... // do something
会被展开为,
if (p)
{free(p); p = NULL;} ;
else
... // do something
由于分号的存在,{free(p); p = NULL} ; 表示2 条语句(复合语句 + 空语句),所以else 无法配对,导致编译失败。
问题根源:C 语言中规定语句以分号结束,但有一点例外,就是复合语句是以右花括号结束(})
goto语句在Linux内核源码中一般只用于错误处理
关键:在错误处理时,注销 / 释放资源的顺序和注册 / 申请资源的顺序相反
if (register_a() != 0)
goto err;
if (register_b() != 0)
goto err1;
if (register_c() != 0)
goto err2;
//错误处理书写技巧:从err 写起,逐层向上~~
err2:
unregister_b();
err1:
unregister_a();
err:
return ret;
理解关键:内核代码几乎始终不能假定在给定代码段中能够独占CPU
并发原因:
① Linux中的并发进程,可能同时要使用我们的驱动程序
② 中断处理程序
③ 其他异步事件(e.g. 内核定时器)
④ SMP
⑤ 内核抢占
对内核代码的要求:
① 必须可重入,能够同时运行在多个上下文中
② 内核数据结构要保证多个线程能分开执行
③ 访问共享数据的代码必须避免破坏共享数据
如果当前执行的内核操作由某个进程发起,那么可以通过全局项current来获得当前进程
全局项current的实现方式如下,
实现为指向struct task_struct的指针(include/sched.h)
补充:虽然before 2.6版本中的current是一个指向task_struct结构的全局变量,但是内核态在获取当前进程时,依然是通过进程内核栈计算得到,下图为Linux 2.4版本的示例
① 不再是一个全局变量
② 为支持SMP,开发了一种能找到运行在相关CPU上的当前进程的机制
③ 实现时不依赖特定架构,将指向task_struct结构的指针隐藏在内核栈中
说明1:sp变量声明方式
使用register修饰:建议使用寄存器存储该变量
asm("sp"):如果将该变量存储在寄存器中,则使用sp寄存器
该声明起到将sp寄存器的值存储到sp变量的作用
说明2:current_thread_info实现原理
首先看一下内核栈的结构,内核给每个线程分配8KB(THREAD_SIZE)的栈,虽然以联合体的方式存在,但是在栈的低地址处存放的是thread_info
因此内核栈的布局如下图所示,
当内核线程执行到current_thread_info时,其SP堆栈指针指向调用进程所对应的内核线程的栈顶,通过sp & ~(THREAD_SIZE - 1)对齐,就能到达内核栈的低地址处,进而获取thread_info结构的地址
Linux的浮点处理有3种方式,其对应的编译选项如下,
① 完全软浮点:-mfloat-abi=soft
② 与软浮点兼容,但是使用FPU硬件:-mfloat-abi=softfp
③ 完全硬浮点:-mfloat-abi=hard
说明:由于目前主流的ARM芯片都自带VFP或者NEON等浮点处理单元(FPU),所以对硬浮点的需求更加强烈。在工具链前缀中包含"hf"的为支持完全硬浮点的工具链,比如arm-linux-gnueabihf-gcc
① 内核API中以双下划线开头的函数,是接口的底层组件,应该谨慎使用
② 内核代码不能实现浮点运算(当然内核代码中也不需要~~)