作者:宋宝华 email:author@linuxdriver.cn
在过去这些年,Linux已经成功应用于服务器和桌面系统,而近年来,随着嵌入式系统应用的持续升温,Linux也开始广泛应用于嵌入式领域,逐步成为通信、工业控制、消费电子等领域的主流操作系统。Linux正以其独特的优势极大地吸引电子设计工程师,很多工程师从自己编写的或专用的RTOS转移到 Linux,Linux在嵌入式系统中的占有率与日俱增。全世界有无数的嵌入式产品正使用Linux作为其操作系统,在这些采用Linux作为操作系统的设备中,无一例外都包含着多个Linux设备驱动,没有这些设备驱动,用户便无法享受Linux上诸多精彩纷呈的应用。
1.Linux设备驱动开发的基础 Linux设备驱动的开发需要牢固的硬件基础,并需要对驱动中所涉及的Linux内核知识有良好的掌握,具体表现在:
(1)驱动直接与硬件打交道,在编写某类硬件设备的驱动时,我们必须对该驱动涉及到的硬件的工作原理和接口有清楚的掌握,因为许多时候,我们需要直接操作寄存器、控制中断和DMA。
(2)编写Linux设备驱动涉及到许多Linux内核的API,会大量使用自旋锁、信号量、等待队列、tasklet、内存与I/O访问,如果对内核中的相关API了解不够充分,很难写出高质量的驱动。
在Linux设备驱动开发中,自旋锁和信号量是两种最常用的用于并发控制的手段,几乎所有的设备驱动中都使用了自旋锁或信号量。自旋锁和信号量控制临界区的方法相似:
spin_lock (&lock) ; //获取自旋锁,保护临界区
critical section //临界区
spin_unlock (&lock) ; //释放自旋锁
down(&mount_sem);//获取信号量,保护临界区
critical section //临界区
up(&mount_sem);//释放信号量
自旋锁或信号量的区别在于:信号量是进程级的,用于多个进程之间对资源的互斥,虽然也是在内核中,但是该内核执行路径是以进程的身份,代表进程来争夺资源的。如果竞争失败,会发生进程上下文切换(当前进程进入睡眠状态,CPU运行其它进程)。当所要保护的临界区访问时间比较短时,用自旋锁是非常方便的,因为它节省上下文切换的时间。自旋锁锁定期间不允许阻塞,因此要求锁定的临界区小。
阻塞和非阻塞I/O是设备访问的两种不同模式,阻塞操作意味着在执行设备操作时,若不能获得资源,则挂起进程直到满足可操作的条件后再进行操作,被挂起的进程进入休眠状态,被从调度器的运行队列移走,直到等待的条件被满足。而非阻塞操作的进程在不能进行设备操作时,并不挂起,它或者放弃,或者不停地查询,直至可以进行操作为止。应用程序多以阻塞方式访问设备,在Linux驱动程序中,经常使用等待队列(wait queue)来实现进程的阻塞与唤醒控制,一个典型的流程如下所示:
1 static ssize_t xxx_write(struct file *file, const char *buffer, size_t count,
2 loff_t *ppos)
3 {
4 ...
5 DECLARE_WAITQUEUE(wait, current); //定义等待队列
6 add_wait_queue(&xxx_wait, &wait); //添加等待队列
7
8 ret = count;
9 /* 等待设备缓冲区可写 */
10 do
11 {
12 avail = device_writable(...);
13 if (avail < 0)
14 __set_current_state(TASK_INTERRUPTIBLE);//改变进程状态
15
16 if (avail < 0)
17 {
18 if (file->f_flags &O_NONBLOCK) //非阻塞
19 {
20 if (!ret)
21 ret = - EAGAIN;
22 goto out;
23 }
24 schedule(); //调度其他进程执行
25 if (signal_pending(current))//如果是因为信号唤醒
26 {
27 if (!ret)
28 ret = - ERESTARTSYS;
29 goto out;
30 }
31 }
32 }while (avail < 0);
33
34 /* 写设备缓冲区 */
35 device_write(...)
36 out:
37 remove_wait_queue(&xxx_wait, &wait);//将等待队列移出等待队列头
38 set_current_state(TASK_RUNNING);//设置进程状态为TASK_RUNNING
39 return ret;
40 }
上述流程中,当设备暂时不可写时,驱动主动通过schedule()调度其他进程执行本身进入睡眠状态,由于进程进入被加入了等待队列,它可以被中断或其他执行路径唤醒。
大多数外设都包含一个以上的中断,Linux将中断分成了2个半部,即顶半部和底半部,顶半部完成尽可能少的比较紧急的功能,它往往只是简单地读取寄存器中的中断状态并清除中断标志后就进行“登记中断”的工作。“登记中断”意味着将底半部处理程序挂到该设备的底半部执行队列中去。这样,顶半部执行的速度就会很快,可以服务更多的中断请求,而中断处理工作的重心就落在了底半部的头上,它来完成中断事件的绝大多数任务。底半部可以被新的中断打断,这也是底半部和顶半部的最大不同。tasklet、work-queue是Linux内核中常用的用于调度底半部执行的机制,调度底半部的典型方法如下:
1 /*定义tasklet和底半部函数并关联*/
2 void xxx_do_tasklet(unsigned long);
3 DECLARE_TASKLET(xxx_tasklet, xxx_do_tasklet, 0);
4
5 /*中断处理底半部*/
6 void xxx_do_tasklet(unsigned long)
7 {
8 ...
9 }
10
11 /*中断处理顶半部*/
12 irqreturn_t xxx_interrupt(int irq, void *dev_id, struct pt_regs *regs)
13 {
14 ...
15 tasklet_schedule(&xxx_tasklet); //调度底半部执行
16 ...
17 }
高性能处理器一般会提供一个内存管理单元(MMU),该单元辅助操作系统进行内存管理,提供虚拟地址和物理地址的映射、内存访问权限保护和CACHE缓存控制等硬件支持。Linux的每个进程可以访问4GB的内存,0~3GB位于用户空间,对所有进程单独控制,3GB~4GB位于内核空间,被所有进程共享。在Linux内核空间申请内存涉及到的函数主要包括kmalloc()、__get_free_pages()和vmalloc()等。 kmalloc()和__get_free_pages()申请的内存在物理上也是连续的,它们与真实的物理地址只有一个固定的偏移,因此存在较简单的转换关系。而vmalloc() 在虚拟内存空间给出一块连续的内存区,实质上,这片连续的虚拟内存在物理内存中并不一定连续,而vmalloc()申请的虚拟内存和物理内存之间也没有简单的换算关系。
在驱动中,对于外设的寄存器,不能直接访问物理地址,需访问经过映射后的虚拟地址。外设的寄存器可以用2种方式被映射到虚拟地址,一是静态映射,二是通过 ioremap()动态映射。静态映射的方法是在将Linux移植到特定平台时建立一个 map_desc数组,通过 MACHINE_START和MACHINE_END宏之间的.map_io成员函数建立页面。
2.Linux设备驱动的架构 近年来内核在驱动方面更偏向于提供设备驱动的架构(Framework)而非单个设备驱动,考虑到框架更强的兼容性,字符设备、块设备、网络设备、MTD 设备、TTY设备、I2C设备、LCD设备、音频设备、摄像头、USB设备、PCI设备等驱动的体系结构都变得愈发复杂。
Linux设备驱动首先作为一个内核模块而存在,模块可以直接编译进内核或编译为.ko文件通过insmod、modprobe动态加载。
Linux字符设备驱动的核心是file_operations结构体,驱动的主体是实现其中的read()、write()、ioctl()等成员函数,如:
1 struct file_operations xxx_fops =
2 {
3 .owner = THIS_MODULE,
4 .read = xxx_read,
5 .write = xxx_write,
6 .ioctl = xxx_ioctl,
7 ...
8 };
Linux块设备驱动并不直接实现file_operations成员函数,其主体变成处理实现block_device_operations成员函数以及处理上层下达的I/O请求,处理I/O请求典型流程如下:
1 static void xxx_request(request_queue_t *q)
2 {
3 struct request *req;
4 while ((req = elv_next_request(q)) != NULL)
5 {
6 struct xxx_dev *dev = req->rq_disk->private_data;
7 if (!blk_fs_request(req)) //不是文件系统请求
8 {
9 printk(KERN_NOTICE "Skip non-fs request\n");
10 end_request(req, 0);//通知请求处理失败
11 continue;
12 }
13 xxx_transfer(dev, req->sector, req->current_nr_sectors, req->buffer,
14 rq_data_dir(req)); //处理这个请求
15 end_request(req, 1); //通知成功完成这个请求
16 }
Linux网络设备驱动的结构如上图所示,其中设备驱动功能层各函数是网络设备接口层net_device 数据结构的具体成员,是驱使网络设备硬件完成相应动作的程序,它通过hard_start_xmit()函数启动发送操作,并通过网络设备上的中断触发接收操作。sk_buff 结构体用于表示描述网络包,它定义了对应于传输层TCP/UDP(及ICMP 和IGMP)、网络层 和和链路层协议的协议头。
Linux下编写网络设备驱动的主体工作是完成net_device结构体的填充以及成员函数的实现,底层最核心的工作是:发送数据包和接收数据包,接收数据包是由中断触发的。发送数据包函数的典型结构如下:
1 int xxx_tx(struct sk_buff *skb, struct net_device *dev)
2 {
3 int len;
4 char *data, shortpkt[ETH_ZLEN];
5 /* 获得有效数据指针和长度 */
6 data = skb->data;
7 len = skb->len;
8 if (len < ETH_ZLEN)
9 {
10 /* 如果帧长小于以太帧最小长度,补0 */
11 memset(shortpkt, 0, ETH_ZLEN);
12 memcpy(shortpkt, skb->data, skb->len);
13 len = ETH_ZLEN;
14 data = shortpkt;
15 }
16
17 dev->trans_start = jiffies; /* 记录发送时间戳 */
18
19 /* 设置硬件寄存器让硬件把数据包发送出去 */
20 xxx_hw_tx(data, len, dev);
21 ...
22 }
接收数据包的典型结构是:
1 static void xxx_interrupt(int irq, void *dev_id, struct pt_regs *regs)
2 {
3 ...
4 switch (status &ISQ_EVENT_MASK)
5 {
6 case ISQ_RECEIVER_EVENT:
7 /* 获取数据包 */
8 xxx_rx(dev);
9 break;
10 /* 其他类型的中断 */
11 }
12 }
13 static void xxx_rx(struct xxx_device *dev)
14 {
15 ...
16 length = get_rev_len (...);
17 /* 分配新的套接字缓冲区 */
18 skb = dev_alloc_skb(length + 2);
19
20 skb_reserve(skb, 2); /* 对齐 */
21 skb->dev = dev;
22
23 /* 读取硬件上接收到的数据 */
24 insw(ioaddr + RX_FRAME_PORT, skb_put(skb, length), length >> 1);
25 if (length &1)
26 skb->data[length - 1] = inw(ioaddr + RX_FRAME_PORT);
27
28 /* 获取上层协议类型 */
29 skb->protocol = eth_type_trans(skb, dev);
30
31 /*把数据包交给上层 */
32 netif_rx(skb);
33
34 /* 记录接收时间戳 */
35 dev->last_rx = jiffies;
36 ...
37 }
对于其他如MTD设备、TTY设备、I2C设备、LCD设备、音频设备、摄像头、USB设备、PCI设备等,Linux都定义了类似于网络设备驱动的复杂的层次结构,如TTY设备驱动的层次如下:
这些复杂结构的定义,加大了Linux驱动的开发门槛,同时也使得开发Linux驱动甚至具有了类似于使用VC++开发MFC程序的特点。
3.总结 简言之,可以得出如下等式:Linux设备驱动开发=硬件控制+Linux内核API(用于并发/同步控制、阻塞/唤醒、中断底半部调度、内存和I/O访问等)+驱动框架。等式右边的3个要素缺一不可,开发高质量的Linux驱动也势必要求工程师对这些知识有良好的掌握,拙著《Linux设备驱动开发详解》一书对这些知识都进行了深入讲解。