Linux驱动开发基础(定时器、mmap)
所学来自百问网
目录
1.定时器
1.1 定时器时间单位
1.2 内核函数
1.3 定时器的应用举例
2.mmap
2.1 内存映射现象与数据结构
2.2 ARM 架构内存映射简介
2.2.1 一级页表映射过程
2.2.2 二级页表映射过程
2.2.3 应用程序新建内存映射
2.2.3.1 mmap调用过程
2.2.3.2 cache和buffer
2.2.4 驱动程序
2.2.5 编程
2.2.5.1 APP编程
2.2.5.2 驱动编程
1.定时器
1.1 定时器时间单位
编译内核时,可以在内核源码根目录下用“ls -a”看到一个隐藏文件,它就是内核配置文件。打开后可以看到如下这项:
CONFIG_HZ=100
这表示内核每秒中会发生 100 次系统滴答中断(tick),这是Linux系统的心跳。每发生一次tick中断,全局变量jiffies就会累加1。 CONFIG_HZ=100 表示每个滴答是10ms。
定时器的时间就是基于jiffies的,我们修改超时时间时,一般使用这2种方法:
在add_timer之前,直接修改:
timer.expires = jiffies + xxx; // xxx 表示多少个滴答后超时,也就是xxx*10ms
timer.expires = jiffies + 2*HZ; // HZ 等于 CONFIG_HZ,2*HZ 就相当于 2秒
*在add_timer之后,使用mod_timer修改:
mod_timer(&timer, jiffies + xxx); // xxx 表示多少个滴答后超时,也就是xxx*10ms
mod_timer(&timer, jiffies + 2*HZ); // HZ 等于 CONFIG_HZ,2*HZ 就相当于 2秒
1.2 内核函数
对于4.x.x版本的Linux内核函数
-
setup_timer(timer, fn, data):设置定时器,主要是初始化timer_list结构体,设置其中的函数、参数。
-
void add_timer(struct timer_list *timer):向内核添加定时器。timer->expires表示超时时间,当超时时间到达,内核就会调用这个函数: timer->function(timer->data)。
-
int mod_timer(struct timer_list *timer, unsigned long expires): 修改定时器的超时时间它等同于:del_timer(timer); timer->expires = expires; add_timer(timer);
-
int del_timer(struct timer_list *timer):删除定时器。
1.3 定时器的应用举例
按下或松开一个按键,它的GPIO电平会反复变化,最后才稳定。一般是几十毫秒才会稳定。
如果不处理抖动的话,用户只操作一次按键,中断程序可能会上报多个数据。
处理方式
1.在按键中断程序中,可以循环判断几十亳秒,发现电平稳定之后再上报
弊端:太耗时,违背“中断要尽快处理”的原则,你的系统会很卡
2.使用定时器
核心在于:在GPIO中断中并不立刻记录按键值,而是修改定时器超时时间,10ms 后再处理。
-
如果10ms内又发生了GPIO中断,那就认为是抖动,这时再次修改超时时间为10ms。
-
只有10ms 之内再无GPIO中断发生,那么定时器的函数才会被调用。 在定时器函数中记录按键值。
2.mmap
应用程序和驱动程序之间传递数据时,可以通过read、write函数进行。这涉及在用户态buffer和内核态buffer之间传数据,如下图所示:
应用程序不能直接读写驱动程序中的 buffer,需要在用户态 buffer 和内核态buffer之间进行一次数据拷贝。这种方式在数据量比较小时没什么问题; 但是数据量比较大时效率就太低了。比如更新LCD显示时,如果每次都让APP传递一帧数据给内核,假设 LCD 采用 1024*600*32bpp 的格式,一帧数据就有 1024*600*32/8=2.3MB 左右,这无法忍受。
改进的方法就是让程序可以直接读写驱动程序中的 buffer,这可以通过 mmap 实现(memory map),把内核的buffer映射到用户态,让APP在用户态直接读写。
2.1 内存映射现象与数据结构
假设有这样的程序,名为test.c:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int a;
int main(int argc, char **argv)
{
if (argc != 2)
{
printf("Usage: %s <number>\n", argv[0]);
return -1;
}
a = strtol(argv[1], NULL, 0);
printf("a's address = 0x%lx, a's value = %d\n", &a, a);
while (1)
{
sleep(10);
}
return 0;
}
在PC上如下编译(必须静态编译):
gcc -o test test.c -staitc
分别执行test程序2次,最后执行ps,可以看到这2个程序同时存在,这2个程序里a变量的地址相同,但是值不同。如下图:
观察到这些现象:
-
2个程序同时运行,它们的变量a的地址都是一样的:0x6bc3a0;
-
2个程序同时运行,它们的变量a的值是不一样的,一个是12,另一个是123。
疑问来了:
-
这2个程序同时在内存中运行,它们的值不一样,所以变量a的地址肯定不同;
-
但是打印出来的变量a的地址却是一样的。
这里要引入虚拟地址的概念:CPU发出的地址是虚拟地址,它经过 MMU(Memory Manage Unit,内存管理单元)映射到物理地址上,对于不同进程 的同一个虚拟地址,MMU会把它们映射到不同的物理地址。如下图:
-
当前运行的是app1时,MMU会把CPU发出的虚拟地址addr映射为物理地址 paddr1,用paddr1去访问内存。
-
当前运行的是app2时,MMU会把CPU发出的虚拟地址addr映射为物理地址 paddr2,用paddr2去访问内存。
-
MMU负责把虚拟地址映射为物理地址,虚拟地址映射到哪个物理地址去?
可以执行ps命令查看进程ID,然后执行“cat /proc/325/maps”得到映射关系。
每一个APP在内核里都有一个tast_struct,这个结构体中保存有内存信息:mm_struct。而虚拟地址、物理地址的映射关系保存在页目录表中,如下图所示:
解析如下:
-
每个APP在内核中都有一个task_struct结构体,它用来描述一个进程;
-
每个APP都要占据内存,在task_struct中用mm_struct来管理进程占用的内存;
-
内存有虚拟地址、物理地址,mm_struct中用mmap来描述虚拟地址, 用pgd来描述对应的物理地址。
-
注意:pgd,Page Global Directory,页目录。
-
-
每个APP都有一系列的VMA:virtual memory
-
比如APP含有代码段、数据段、BSS段、栈等等,还有共享库。这些单元会保存在内存里,它们的地址空间不同,权限不同(代码段是只读的可运行的、数据段可读可写),内核用一系列的vm_area_struct来描述它们。
-
vm_area_struct 中的 vm_start、vm_end是虚拟地址。
-
-
vm_area_struct 中虚拟地址如何映射到物理地址去?
-
每一个APP的虚拟地址可能相同,物理地址不相同,这些对应关系保存在pgd中。
-
2.2 ARM 架构内存映射简介
ARM 架构支持一级页表映射,也就是说MMU根据CPU发来的虚拟地址可以找到第1个页表,从第1个页表里就可以知道这个虚拟地址对应的物理地址。一级页表里地址映射的最小单位是1M。
ARM 架构还支持二级页表映射,也就是说MMU根据CPU发来的虚拟地址先找到第1个页表,从第1个页表里就可以知道第2级页表在哪里;再取出第2级页表,从第2个页表里才能确定这个虚拟地址对应的物理地址。二级页表地址映射 的最小单位有4K、1K,Linux使用4K。
一级页表项里的内容,决定了它是指向一块物理内存,还是指问二级页表, 如下图:
2.2.1 一级页表映射过程
一线页表中每一个表项用来设置1M的空间,对于32位的系统,虚拟地址空间有4G,4G/1M=4096。所以一级页表要映射整个4G空间的话,需要4096个页表项。
第0个页表项用来表示虚拟地址第0个1M(虚拟地址为0~0xFFFFF)对应哪一块物理内存,并且有一些权限设置;
第1个页表项用来表示虚拟地址第1个1M(虚拟地址为 0x100000~ 0x1FFFFF)对应哪一块物理内存,并且有一些权限设置;
依次类推。
使用一级页表时,先在内存里设置好各个页表项,然后把页表基地址告诉MMU, 就可以启动MMU了。
以下图为例介绍地址映射过程:
a) CPU发出虚拟地址vaddr,假设为0x12345678
b) MMU根据vaddr[31:20]找到一级页表项:
◆ 虚拟地址0x12345678是虚拟地址空间里第0x123个1M,所以找到页表里第0x123项,根据此项内容知道它是一个段页表项。
段内偏移是0x45678。
c) 从这个表项里取出物理基地址:Section Base Address,假设是 0x81000000
d) 物理基地址加上段内偏移得到:0x81045678 所以CPU要访问虚拟地址0x12345678时,实际上访问的是0x81045678的物理地址。
2.2.2 二级页表映射过程
首先设置好一级页表、二级页表,并且把一级页表的首地址告诉MMU。
以下图为例介绍地址映射过程:
-
CPU发出虚拟地址vaddr,假设为0x12345678
-
MMU根据vaddr[31:20]找到一级页表项:
虚拟地址0x12345678 是虚拟地址空间里第0x123个1M,所以找到页表里第0x123项。根据此项内容知道它是一个二级页表项。
-
从这个表项里取出地址,假设是address,这表示的是二级页表项的物理地址;
-
vaddr[19:12]表示的是二级页表项中的索引index即0x45,在二级页表项中找到第0x45项;
-
二级页表项格式如下:
里面含有这4K或1K物理空间的基地址page base addr,假设是 0x81889000: 它跟vaddr[11:0]组合得到物理地址:0x81889000 + 0x678 = 0x81889678。 所以CPU要访问虚拟地址0x12345678时,实际上访问的是0x81889678的物理地址
2.2.3 应用程序新建内存映射
2.2.3.1 mmap调用过程
要给APP新开劈一块虚拟内存,并且让它指向某块内核buffer,具体操作:
1.得到一个vm_area_struct,它表示APP的一块虚拟内存空间;APP 调用 mmap 系统函数时,内核就帮我们构造了一个 vm_area_stuct 结构体。里面含有虚拟地址的地址范围、权限。
2.确定物理地址:你想映射某个内核buffer,你需要得到它的物理地址
3.给vm_area_struct和物理地址建立映射关系:内核提供有相关函数。
APP 里调用mmap时,导致的内核相关函数调用过程如下:
2.2.3.2 cache和buffer
使用mmap时,需要有cache、buffer的知识。下图是CPU 和内存之间的 关系,有cache、buffer(写缓冲器)。Cache是一块高速内存;写缓冲器相当于 一个FIFO,可以把多个写操作集合起来一次写入内存。
程序运行时有“局部性原理”,这又分为时间局部性、空间局部性。
-
时间局部性: 在某个时间点访问了存储器的特定位置,很可能在一小段时间里,会反复地访问这个位置。
-
空间局部性: 访问了存储器的特定位置,很可能在不久的将来访问它附近的位置。
-
而CPU的速度非常快,内存的速度相对来说很慢。CPU要读写比较慢的内存时,怎样可以加快速度?根据“局部性原理”,可以引入cache。
1.读取内存addr处的数据时:
◼ 先看看cache中有没有addr的数据,如果有就直接从cache里返回数据:这被称为cache命中。
◼ 如果cache中没有addr 的数据,则从内存里把数据读入,注意:它不是仅仅读入一个数据,而是读入一行数据(cache line)。
◼ 而CPU很可能会再次用到这个addr的数据,或是会用到它附近的数据,这时就可以快速地从cache中获得数据。
2.写数据:
◼ CPU要写数据时,可以直接写内存,这很慢;也可以先把数据写入cache, 这很快。
◼ 但是cache中的数据终究是要写入内存的啊,这有2种写策略:
a) 写通(write through):
◆ 数据要同时写入cache和内存,所以cache和内存中的数据保持一致,但是它的效率很低。能改进吗?可以!使用“写缓冲器”: cache 大哥,你把数据给我就可以了,我来慢慢写,保证帮你写完。
◆ 有些写缓冲器有“写合并”的功能,比如CPU执行了4条写指令: 写第0、1、2、3个字节,每次写1字节;写缓冲器会把这4个写操作合并成一个写操作:写word。对于内存来说,这没什么差别, 但是对于硬件寄存器,这就有可能导致问题。
◆ 所以对于寄存器操作,不会启动buffer功能;对于内存操作,比如LCD的显存,可以启用buffer功能。
b) 写回(write back):
◆ 新数据只是写入cache,不会立刻写入内存,cache和内存中的数据并不一致。
◆ 新数据写入cache 时,这一行cache 被标为“脏”(dirty);当 cache 不够用时,才需要把脏的数据写入内存。
使用写回功能,可以大幅提高效率。但是要注意cache和内存中的数据很可 能不一致。这在很多时间要小心处理:比如CPU产生了新数据,DMA把数据从内 存搬到网卡,这时候就要CPU执行命令先把新数据从cache刷到内存。反过来 也是一样的,DMA从网卡得过了新数据存在内存里,CPU读数据之前先把cache 中的数据丢弃。
是否使用 cache、是否使用 buffer,就有 4 种组合(Linux 内核文件 arch\arm\include\asm\pgtable-2level.h):
上面4种组合对应下表中的各项,一一对应(下表来自s3c2410芯片手册,高架构的cache、buffer更复杂,但是这些基础知识没变):
-
第1种是不使用cache也不使用buffer,读写时都直达硬件,这适合寄存器的读写。
-
第2种是不使用cache但是使用buffer,写数据时会用buffer进行优化,可能会有“写合并”,这适合显存的操作。因为对显存很少有读 操作,基本都是写操作,而写操作即使被“合并”也没有关系。
-
第3种是使用cache不使用buffer,就是“write through”,适用于只读设备:在读数据时用cache加速,基本不需要写。
-
第4种是既使用cache又使用buffer,适合一般的内存读写。
2.2.4 驱动程序
驱动程序要做的事情有3点:
1.确定物理地址
2.确定属性:是否使用cache、buffer
3.建立映射关系
参考Linux源文件,示例代码如下:
还有一个更简单的函数:
2.2.5 编程
在驱动程序中申请一个8K的buffer,让APP通过mmap能直接访问。
2.2.5.1 APP编程
open驱动、buf=mmap(……)映射内存,直接读写buf就可以了, 代码如下:
22 /* 1. 打开文件 */
23 fd = open("/dev/hello", O_RDWR);
24 if (fd == -1)
25 {
26 printf("can not open file /dev/hello\n");
27 return -1;
28 }
29
30 /* 2. mmap
31 * MAP_SHARED : 多个APP都调用mmap映射同一块内存时, 对内存的修改大家都可以看到。
32 * 就是说多个APP、驱动程序实际上访问的都是同一块内存
33 * MAP_PRIVATE : 创建一个copy on write的私有映射。
34 * 当APP对该内存进行修改时,其他程序是看不到这些修改的。
35 * 就是当APP写内存时, 内核会先创建一个拷贝给这个APP,
36 * 这个拷贝是这个APP私有的, 其他APP、驱动无法访问。
37 */
38 buf = mmap(NULL, 1024*8, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
39 if (buf == MAP_FAILED)
40 {
41 printf("can not mmap file /dev/hello\n");
42 return -1;
43 }
最难理解的是mmap函数MAP_SHARED、MAP_PRIVATE参数。使用 MAP_PRIVATE映射时,在没有发生写操作时,APP、驱动访问的都是同一块内存; 当APP发起写操作时,就会触发“copy on write”,即内核会先创建该内存块的拷贝,APP的写操作在这个新内存块上进行,这个新内存块是APP私有的,别的APP、驱动看不到。
仅用MAP_SHARED参数时,多个APP、驱动读、写时,操作的都是同一个内存块,“共享”。
MAP_PRIVATE映射是很有用的,Linux中多个APP都会使用同一个动态库,在没有写操作之前大家都使用内存中唯一一份代码。当APP1发起写操作时,内核会为它复制一份代码,再执行写操作,APP1就有了专享的、私有的动态库,在里面做的修改只会影响到APP1。其他程序仍然共享原先的、未修改的代码。
有了这些知识后,下面的代码就容易理解了,请看代码中的注释:
45 printf("mmap address = 0x%x\n", buf);
46 printf("buf origin data = %s\n", buf); /* old */
47
48 /* 3. write */
49 strcpy(buf, "new");
50
51 /* 4. read & compare */
52 /* 对于MAP_SHARED映射: str = "new"
53 * 对于MAP_PRIVATE映射: str = "old"
54 */
55 read(fd, str, 1024);
56 if (strcmp(buf, str) == 0)
57 {
58 /* 对于MAP_SHARED映射,APP写的数据驱动可见
59 * APP和驱动访问的是同一个内存块
60 */
61 printf("compare ok!\n");
62 }
63 else
64 {
65 /* 对于MAP_PRIVATE映射,APP写数据时, 是写入另一个内存块(是原内存块的"拷贝")
66 */
67 printf("compare err!\n");
68 printf("str = %s!\n", str); /* old */
69 printf("buf = %s!\n", buf); /* new */
70 }
执行测试程序后,查看到它的进程号PID,执行这样的命令查看这个程序的内存使用情况:
[root@100ask:~]# cat /proc/PIC/maps
2.2.5.2 驱动编程
1.分配一块8K的内存
我们应该使用kmalloc或kzalloc,这样得到的内存物理地址是连续的, 在mmap时后APP才可以使用同一个基地址去访问这块内存。(如果物理地址不连续,就要执行多次mmap了)。
2.提供mmap函数
关键在于mmap函数,代码如下:
要注意的是,remap_pfn_range中,pfn的意思是“Page Frame Number”。 在Linux中,整个物理地址空间可以分为第0页、第1页、第2页,诸如此类, 这就是pfn。假设每页大小是4K,那么给定物理地址phy,它的pfn = phy / 4096 = phy >> 12。内核的page一般是4K,但是也可以配置内核修改page 的大小。所以为了通用,pfn = phy >> PAGE_SHIFT。
APP 调用mmap后,会导致驱动程序的mmap函数被调用,最终APP的虚拟地址和驱动程序中的物理地址就建立了映射关系。APP可以直接访问驱动程序的 buffer。