块设备层分析

IO无论是经过EXT3文件系统还是块设备文件,最终都要通过writeback机制将数据刷新到磁盘,除非用户在对文件进行读写的时候采用了Direct IO的方式。为了提高性能,文件系统或者是裸设备都会采用Linux的cache机制对数据读写性能进行优化,因此都会采用writeback回写机制将数据写入磁盘。

writeback机制会调用不同底层设备的address_space_operations函数将数据刷新到设备。例如,EXT3文件系统会调用blkdev_writepage函数将radix tree中的page页写入设备。

在确认需要将一个page页写入设备时,最终都需要调用submit_bh()函数,该函数描述如下:

int submit_bh(int rw, struct buffer_head * bh)
{
	struct bio *bio;
	int ret = 0;
	BUG_ON(!buffer_locked(bh));
	BUG_ON(!buffer_mapped(bh));
	BUG_ON(!bh‐>b_end_io);
	BUG_ON(buffer_delay(bh));
	BUG_ON(buffer_unwritten(bh));
	/*
	* Only clear out a write error when rewriting
	*/
	if (test_set_buffer_req(bh) && (rw & WRITE))
	clear_buffer_write_io_error(bh);
	/*
	* from here on down, it's all bio ‐‐ do the initial mapping,
	* submit_bio ‐> generic_make_request may further map this bio around
	*/
	bio = bio_alloc(GFP_NOIO, 1);
	/* 封装BIO */
	bio‐>bi_sector = bh‐>b_blocknr * (bh‐>b_size >> 9); /* 起始地址 */
	bio‐>bi_bdev = bh‐>b_bdev; /* 访问设备 */
	bio‐>bi_io_vec[0].bv_page = bh‐>b_page; /* 数据buffer地址 */
	bio‐>bi_io_vec[0].bv_len = bh‐>b_size; /* 数据段大小 */
	bio‐>bi_io_vec[0].bv_offset = bh_offset(bh); /* 数据在buffer中的offset */
	bio‐>bi_vcnt = 1;
	bio‐>bi_idx = 0;
	bio‐>bi_size = bh‐>b_size;
	/* 设定回调函数 */
	bio‐>bi_end_io = end_bio_bh_io_sync;
	bio‐>bi_private = bh;
	bio_get(bio);
	/* 提交BIO至对应设备,让该设备对应的驱动程序进行进一步处理 */
	submit_bio(rw, bio);
	if (bio_flagged(bio, BIO_EOPNOTSUPP))
	ret = ‐EOPNOTSUPP;
	bio_put(bio);
	return ret;
}

Submit_bh()函数的主要任务是为page页分配一个bio对象,并且对其进行初始化,然后将bio提交给对应的块设备对象。提交给块设备的行为其实就是让对应的块设备驱动程序对其进行处理。在Linux中,每个块设备在内核中都会采用bdev(block_device)对象进行描述。通过bdev对象可以获取块设备的所有所需资源,包括如何处理发送到该设备的IO方法。因此,在初始化bio的时候,需要设备目标bdev,在Linux的请求转发层需要用到bdev对象对bio进行转发处理。

在通用块设备层,提供了一个非常重要的bio处理函数generic_make_request,通过这个函数实现bio的转发处理,该函数的实现如下:

void generic_make_request(struct bio *bio)
{
	struct bio_list bio_list_on_stack;

	if (!generic_make_request_checks(bio))
	return;
	/*
	* We only want one ‐>make_request_fn to be active at a time, else
	* stack usage with stacked devices could be a problem. So use
	* current‐>bio_list to keep a list of requests submited by a
	* make_request_fn function. current‐>bio_list is also used as a
	* flag to say if generic_make_request is currently active in this
	* task or not. If it is NULL, then no make_request is active. If
	* it is non‐NULL, then a make_request is active, and new requests
	* should be added at the tail
	*/
	if (current‐>bio_list) {
	bio_list_add(current‐>bio_list, bio);
	return;
	}/
	* following loop may be a bit non‐obvious, and so deserves some
	* explanation.
	* Before entering the loop, bio‐>bi_next is NULL (as all callers
	* ensure that) so we have a list with a single bio.
	* We pretend that we have just taken it off a longer list, so
	* we assign bio_list to a pointer to the bio_list_on_stack,
	* thus initialising the bio_list of new bios to be
	* added. ‐>make_request() may indeed add some more bios
	* through a recursive call to generic_make_request. If it
	* did, we find a non‐NULL value in bio_list and re‐enter the loop
	* from the top. In this case we really did just take the bio
	* of the top of the list (no pretending) and so remove it from
	* bio_list, and call into ‐>make_request() again.
	*/
	BUG_ON(bio‐>bi_next);
	bio_list_init(&bio_list_on_stack);
	current‐>bio_list = &bio_list_on_stack;
	do {
	/* 获取块设备的请求队列 */
	struct request_queue *q = bdev_get_queue(bio‐>bi_bdev);
	/* 调用对应驱动程序的处理函数 */
	q‐>make_request_fn(q, bio);
	bio = bio_list_pop(current‐>bio_list);
	} while (bio);
	current‐>bio_list = NULL; /* deactivate */
}

generic_make_request()函数中,最主要的操作是获取请求队列,然后调用make_request_fn()方法处理bio。在Linux中一个块设备驱动通常可以分成两大类:有queue和无queue。有queue的块设备就是驱动程序提供了一个请求队列,make_request_fn()方法会将bio放入请求队列中进行调度处理,调度处理的方法有CFQ、Deadline和Noop之分。设置请求队列的目的是考虑了磁盘介质的特性,普通磁盘介质一个最大的问题是随机读写性能很差。为了提高性能,通常的做法是聚合IO,因此在块设备层设置请求队列,对IO进行聚合操作,从而提高读写性能。关于IO scheduler的具体算法分析请见后续文章。

在Linux的通用块层,提供了一个通用的请求队列压栈方法:blk_queue_bio(),在老版本的Linux中为__make_request()。在初始化一个有queue块设备驱动的时候,最终都会调用blk_init_allocated_queue()函数对请求队列进行初始化,初始化的时候会将blk_queue_bio方法注册到q->make_request_fn。在generic_make_request()转发bio请求的时候会调用q‐>make_request_fn(),从而可以将bio压入请求队列进行IO调度。一旦bio进入请求队列之后,可以好好的休息一番,直到unplug机制对bio进行进一步处理。另一类块设备是无queue的。无queue的块设备我们通常可以认为是一种块设备过滤驱动,这类驱动程序可以自己实现请求队列,绝大多数是没有请求队列的,直接对bio进行转发处理。这类驱动程序一个很重要的特征是需要自己实现q‐>make_request_fn()方法。这类驱动的make_request_fn()方法通常可以分成如下几个步骤:

  • 根据一定规则切分bio,不同的块设备可能存在不同的块边界,因此,需要对请求bio进行边界对齐操作。
  • 找到需要转发的底层块设备对象。
  • 直接调用generic_make_request函数转发bio至目标设备。

因此,无queue的块设备处理过程很直观。其最重要的作用是转发bio。在Linux中,device_mapper机制就是用来转发bio的一种框架,如果需要开发bio转发处理的驱动程序,可以在device_mapper框架下开发一个target,从而快速实现一个块设备驱动。

通过上述描述,我们知道,IO通过writeback或者DirectIO的方式可以抵达块设备层。到了块设备层之后遇到了两类块设备处理方法。如果遇到无queue块设备类型,bio马上被转发到其他底层设备;如果遇到了有queue块设备类型,bio会被压入请求队列,进行合并处理,等待unplug机制的调度处理。IO曾经在page cache游玩了很长时间,所有的请求在page cache受到得待遇是相同的,大家都会比较公平得被调度走,继续下面的旅程。但是,在块设备层情况就变的复杂了,不同IO受到的待遇会有所不同,这就需要看请求队列中的io scheduler具体算法了。因此,IO旅程在块设备这一站,最为重要的核心就是io scheduler。

一直想将在计算所的工作总结一下,但是一直给自己借口说没时间,最近一直在搞 Mesos/Mesosphere, 突然发现之前的工作快被我忘光了,终于不能再给自己任何借口,下了决心将自己在计算所的工作总结一下。

首先从Driver开始,其中可能涉及到Linux其他的方面,随性而写吧。

##Linux Driver##

要说Linux Driver的话,Linux IO 是必不可少的,我们首先来看一下Linux IO stack(Kernel version 3.3),如下图所示:

Linux_IO_Stack.png

这里将SSD相关的内容也添加进来了,因为在之后的章节中我们也会详细介绍SSD FTL(Flash Translation Layer)。

OK,Here we going!

文件的基本操作

如果你想保存word资料到本地磁盘,就会触发一个文件系统的写操作,如果你想将一个文件从本地磁盘拷贝到U盘时,就会触发一个文件系统的读写过程。众所周知,为了简化用户对文件的管理,Linux提供了文件系统对数据进行管理,文件系统是操作系统最为重要的组成部分。一旦你想往文件系统写入数据时,一个新的IO请求就会才用户态诞生,但是其绝大部分的处理流程都发生的内核空间。对于不同的应用类型,IO请求的属性会大相径庭。除了文件本身应该具备的基本属性(读写权限等)之外,还需要考虑文件的访问模式:一步IO还是同步IO?对文件系统的Cache是如何控制的?应用程序和内核程序之间是如何交互的?所以,在创建一个IO时,需要考虑很多这样的因素。

在Linux中,当进行文件操作的时候,5个API是必不可少的:create() open() close() write() read() 实现了对文件的所有操作。create()函数用来打开一个文件,如果该文件不存在就在磁盘上创建该文件;open()函数用来打开一个指定的文件,如果在open()中指定了O_CREATE标记,那么open()函数同样可以实现create()函数的功能;close()函数用于释放文件句柄;write() read()函数用于实现文件的读写过程。

举个例子:如果用户需要对一个文件进行写操作,那么首先调用open()函数打开想要操作的文件,该函数返回要操作文件的句柄,然后调用write()将数据写入文件,最后采用close()释放文件句柄,结束文件写入过程。但是在这个过程中,整个系统到底发生了哪些事情呢?

打开操作

用户态的API通过系统调用,触发INT80中断陷入内核。

open()调用了内核sys_open()例程,该函数的主要职责是查找指定文件的inode,然后在内核中生成对应的文件对象。在Linux中,sys_open()函数调用do_sys_open()函数完成具体的功能。在do_sys_open()中通过do_filp_open()完成文件名解析,inode对象查找,然后创建file对象,最后执行特定文件对应的file->open()函数。

do_filp_open()过程中的核心处理函数是link_path_walk(),该函数完成了基本文件的解析功能,是名字字符串解析处理实现的核心。该函数的实现基于分级解析处理的思想。例如:当需要解析/dev/mapper/map0字符串时,其首先需要判断从何处开始解析,是根目录还是当前目录?这个例子是从根目录开始解析的,那么首先获取根目录的dentry的对象并开始分析后继字符串。处理过程是以/字符为界按序提取字符串。根据规则,首先可以提取dev字符串,并计算该字符串的hash值,通过hash值查找dentry下的inode hash表,就可以很快找到/dev/目录下的inode对象。Hash值得计算是比较简单的,把所有字符对应的值累加起来就可以获得一个Hash值。根据规则,以此类推,最后解析到/dev/mapper/目录的inode对象以及文件名字串map0。到此,link_path_walk()函数完成使命,最后通过do_last()函数获取或者创建文件inode。如果用户态程序设置了O_CREATE标志,那么如果系统找不到指定的文件inode,do_last()会创建一个新的文件inode,并且把这些信息以元数据的形式写入磁盘。当指定的文件inode找到之后,另一件很重要的事情就是初始化file文件对象。初始化文件对象通过__dentry_open()函数来实现,文件对象通过inode参数进行初始化,并且把inode的操作方法函数集告诉file对象,一旦file对象初始化成功之后,调用文件对象的open()函数执行进一步的初始化工作。

写操作

当一个文件被打开之后,用户态程序就可以得到一个文件对象,即文件句柄。一旦获取文件句柄之后就可以对其进行读写操作了。用户态的读写函数write()对应内核空间的sys_write()例程,通过系统调用陷入sys_write()sys_write()函数在VFS层做的工作有限,其会调用文件对象中指定的操作函数file->f_op_write()。对于不同的文件系统,file->f_op_write()指向的操作函数是不同的。

如果文件设备是一个USB设备,并且采用的是字符设备的接口,那么在初始化文件inode的时候会调用init_special_inode()初始化这些特殊的文件。对于字符设备会采用默认的def_chr_fops()方法集,对于块设备会采用def_blk_fops()方法集,不同的文件类型会调用各自的方法集。

def_chr_fops()方法集其实就只定义了open()方法,其他的方法都没有定义。其实字符设备的操作方法都需要字符设备驱动程序自己定义,每个设备驱动程序都需要定义自己的write() read() open() close()方法,这些方法保存在字符设备对象中。当用户调用文件系统接口open()函数打开指定字符设备文件时,VFS会通过上述的sys_open()函数找到设备文件inode中保存的def_chr_fopens()方法,并执行该方法中open()函数(chrde_open()),chrdev_open()函数完成一个重要的功能就是将文件对象file中采用的方法替换成驱动程序设定的设备操作方法。完成这个偷梁换柱的代码是:

file->f_op = fops_get(p->ops)

一旦完成这个过程,后继用户程序通过文件系统的write方法都会调用字符设备驱动程序设定的write方法。即对于字符设备而言,在VFS的sys_write()函数将直接调用字符设备程序的write()方法。所以,对字符设备驱动程序而言,整个过程很简单,用户态可以直接通过系统调用执行字符设备驱动程序的代码。而对于块设备和普通文件,这个过程将会复杂的多。

在用户程序发起写请求的时候,通常会考虑如下三个问题:

  • 第一个问题是用户态数据如何高效传递给内核?
  • 第二个问题是采用同步或者异步的方式执行IO请求?
  • 第三个问题是如何执行普通文件操作,需不需要文件cache?

第一个问题是数据拷贝的问题。对于普通文件,如果采用了page cache机制,那么这种拷贝合并的开销在很大程度上是 避免不了的。但是对于网卡设备之类的设备,在读写数据的时候,就需要避免这样的数据拷贝,否则数据传输效率将会变得很低。例如,一块PCI数据采集卡。在PCI采集卡上集成了4KB的FIFO,数据采集电路会将数据不断的压入FIFO,当FIFO半满的时候会对PCI主控芯片产生一个中断信号,通知PCI主控器将FIFO中的2KB数据DMA到主机内存。CPU接收到这个中断信号之后,分配DMA内存,初始化DMA控制器,并启动DMA操作,将2KB数据传输到Host内存。并且当DMA完成操作之后,会对CPU产生一个中断。板卡的设备驱动程序在接收到这个中断请求之后,面临一个重要的问题:如何将内核空间DMA过来的数据传输给用户空间?通常有两种方案:

  • 一种是直接将内核内存映射给用户程序
  • 另一种是进行数据拷贝的方式

对于PCI数据采集卡而言,一个很重要的特性是实时数据采集,在板卡硬件FIFO很小的情况下,如果主机端的数据传输,处理耗费太多的时间,那么整条IO流水线将无法运转,导致FIFO溢出,数据采集就会出现漏点的情况。所以,为了避免这样的情况,在这些很严格应用的场合只能采用内存映射的方法,从而实现数据在操作系统层面的零拷贝,在Linux中,可以采用memory map的方法将内核空间内存映射给用户程序,从而实现用户对内核内存的直接访问。在Windows操作系统中,这种内核空间的和用户空间的数据交互方式定义成两种:Map IODirect IOMap IO就是采用内存拷贝的方式,Direct IO就是采用MDL内存映射的方式。在编写WDM Windows设备驱动程序的时候经常会用到这两种数据传输模式。值得注意的是,Windows中的Direct IO 和 Linux 中的Direct IO是完全不同的两个概念。在Linux中Direct IO是指写穿page cache的一种IO方法。

第二个问题是异步IO和同步IO的问题。对于普通文件而言,为了提高效率,Linux通常会采用page cache对文件数据在内存进行缓存。cache 虽然提高了效率,但是对有些应用来说,一旦发出写请求并且执行完之后,其期望的是将数据写入磁盘,而不是内存(page cache)。例如:有些应用会有一些元数据操作,在元数据操作的过程中,通常期望将数据写入磁盘,而不是cache在内存中。这就提出了同步IO的需求。为了达到这个效果,可以在打开文件的时候设置O_SYNV标记。当数据在page cache中聚合之后,如果发现O_SYNC标记被设置,那么就会将page cache中的数据强制刷新到磁盘。对于EXT3文件系统,该过程在ext3_file_write()函数中实现的。

第三个问题是普通文件的cache问题。对于普通文件,由于磁盘性能比较低,为了提高读写性能,通常会采用内存作为磁盘的cache。文件系统会采用预读等机制对文件读写性能进行优化,避免磁盘随机IO性能过低对文件读写性能造成影响。

但是,page cache虽然提高了性能,但是也会对文件系统的可靠性造成一定影响。例如:当数据已经被写入内存之后,系统Crash,内存中的磁盘数据将会遭到破会。为了避免这种情况,Linux文件系统提供了Direct IO方式,该方式就是让一次IO过程绕过page cache机制,直接将文件内容刷新到磁盘。与上面的同步IO相比,Direct IO达到的效果有点类似。其实,同步IO是一种write through的cache机制,而Direct IO是完全把cache抛弃了,同步IO的数据在内存还是有镜像的,而Direct IO是没有的,这是两者的区别。在Linux中的__generic_file_aio_nolock()函数中,会判断O_DIRECT标记是否被设置,如果该标记被设置,那么调用generic_file_direct_write()函数完成数据磁盘写入过程,如果该标记不存在,那么调用generic_file_buffered_write()函数将数据写入page chache

Linux Direct I/O

Direct I/O In Linux

Introduction

To the traditional operating system, common I/O operations will normally be cached in the kernel call Buffer I/O. In this paper, the file access mechanism we will describe doesn’t pass through the cache of Linux kernel. Data transfer directly to disk from the address space of application. So it calls Direct I/O offered by Linux.

Motivation of Direct I/O

What is the Buffer I/O

Buffer I/O is also known as the standard I/O which as the default I/O operation offered by most file systems. In the cache mechanism of Linux OS will cache the I/O data in the page cache, that is to say, data will be copied to the buffer in the kernel space, and then, copied to the application space in the user space from kernel space. Buffer I/O have some advantages below:

  • Buffer I/O use the kernel space buffer, separate the application space and the physical devices to some extent.
  • Buffer I/O can improve the R/W performance by reducing the time of R/W the disks.

When applications attempt to read the data on the disk, if the data have already stored in the page cache, the data can be immediately returned to the applications without R/W the physical disk. Of course, if the data don’t exist in the page cache before the applications occur reading, that need to read the data from disk to the page cache.

To the write operation, application will also write the data to the page cache first, beside, whether the data is written to the disk immediately depend on the write operation mechanism adopted by application: if user use the synchronous write, the data will be written to the disk immediately, the applications will wait until the data translating over; If it’s the deferred write mechanism, application will not wait, and return when data is written into the page cache immediately. In the case of deferred write, OS will flush the data in page cache to the disk regularly. Different from asynchronous write, deferred write don’t notify the application when the data translation is over. Asynchronous write will return to applications when the data translation is over. So, deferred write can case a risk of data loss, the asynchronous write can’t.

Disadvantage Of Buffer I/O

In the Buffer I/O, the DMA can translate data from disk to page cache directly, or write back the data from page cache to disk directly. But DMA can not translate from applications address space to disk, in this case, while in transit data need to make multiple copies between applications address space and the page cache. The overhead of the data copy operation between the CPU and memory is very large.

For some special applications, avoiding the kernel buffer of operation system and directly translating from application address space to disk will get better performance than using the kernel buffer. The self-caching application is one of the special applications.

Self-caching Applications

To some application, it would have its own data cache mechanism. For example, it will cache the data in its application address space. Such applications do not need to use the kernel buffer and they are called self-caching applications. Database Management System is a representative of this type of applications. Self-caching applications use the logical expression of data rather than physical expression. When the memory of system is low, self-caching application will make the logical cache of data to be swapped out, rather than the the actual data in the disk. The self-caching application know the semantics of the data want to work with, so it can use a more efficient cache replacement algorithm. Self-caching application may be shared a block of memory between multiple hosts, thus self-caching applications need to offer a mechanism which can make the data cached in the application address space invalid to ensure consistency of the data cached in the application address space.

To the self-caching applications, buffer I/O is not a good chance obviously. The Direct I/O in Linux is good for self-caching applications, the data translate from applications address space to disk directly which makes self-caching applications can omit the complicated structure of the system and implement programs to R/W your own custom data management to reduce the impact of system-level management for application to access data.

Direct I/O In Linux 2.6

Several File Access Modes in Linux 2.6

All of the I/O operations access the file by R/W. Here is the list of Linux 2.6 supports file access methods.

The Standard Way To Access The File

In Linux, this way to access files is implemented with two system call: read() write().

ARM Interrupt Process

ARM处理器工作模式

ARM处理器工作模式一共有 7 种:

  • USR 模式

    正常用户模式,程序正常执行模式

  • FIQ模式(Fast Interrupt Request)

    处理快速中断,支持高速数据传送或通道处理

  • IRQ模式

    处理普通中断

  • SVC模式(Supervisor)

    操作系统保护模式,处理软件中断swi reset

  • ABT中止(Abort mode){数据、指令}

    处理存储器故障、实现虚拟存储器和存储器保护

  • UND 未定义(Undefined

    处理未定义的指令陷阱,支持硬件协处理器的软件仿真

  • SYS 系统模式(基本上=USR)(System

    运行特权操作系统任务

用户模式和特权模式

除了用户模式之外的其他6种处理器模式称为特权模式

特权模式下,程序可以访问所有的系统资源,也可以任意地进行处理器模式的切换。

特权模式中,除系统模式外,其他5种模式又称为异常模式。

大多数的用户程序运行在用户模式下,此时,应用程序不能够访问一些受操作系统保护的系统资源,应用程序也不能直接进行处理器模式的切换。

用户模式下,当需要进行处理器模式切换时,应用程序可以产生异常处理,在异常处理中进行处理器模式的切换。

ARM异常中断处理概述

  • 中断的概念

中断是一个过程,是CPU在执行当前程序的过程中因硬件或软件的原因插入了另一段程序运行的过程。因硬件原因引起的中断过程的出现是不可预测的,即随机的,而软中断是事先安排的。

  • 中断源的概念

我们把可以引起中断的信号源称之为中断源

  • 中断优先级的概念

ARM处理器中有7种类型的异常,按优先级从高到低的排列如下:

  • 复位异常(Reset)
  • 数据异常(Data Abort)
  • 快速中断异常(FIQ)
  • 外部中断异常(IRQ)
  • 预取异常(Prefetch Abort)
  • 软件中断(SWI)
  • 未定义指令异常(Undefined instruction)

ARM体系异常种类细说

1.复位异常

当处理器的复位引脚有效时,系统产生复位异常中断,程序跳转到复位异常中断处理程序处执行。 复位异常中断通常用在下面两种情况下。

  • 系统上电
  • 统复位

当复位异常时,系统执行下列伪操作:

   R14_svc = UNPREDICTABLE value    //任意值
   SPSR_svc = UNPREDICTABLE value  //任意值
   CPSR[4∶0] = 0b10011  /*进入管理模式*/ 
   CPSR[5] = 0    /*处理器进入ARM状态*/ 
   CPSR[6] = 1    /*禁止快速中断*/ 
   CPSR[7] = 1    /*禁止外设中断*/ 
   If high vectors configured then 
     PC = 0xffff0000 
       Else 
     PC = 0x00000000

复位异常中断处理程序的主要功能:

  • 设置异常中断向量表。
  • 初始化数据栈和寄存器。
  • 初始化存储系统,如系统中的MMU等。
  • 初始化关键的I/O设备。
  • 使能中断。
  • 处理器切换到合适的模式。
  • 初始化C变量,跳转到应用程序执行。

2.未定义指令异常

当ARM处理器执行协处理器指令时,它必须等待一个外部协处理器应答后,才能真正执行这条指令。若协处理器没有响应,则发生未定义指令异常。

3.软中断SWI

软中断异常发生时,处理器进入特权模式,执行一些特权模式下的操作系统功能。

4.预取指令异常

预取指令异常是由系统存储器报告的。当处理器试图去取一条被标记为预取无效的指令时,发生预取异常。

5.数据访问中止异常

数据访问中止异常是由存储器发出数据中止信号,它由存储器访问指令Load/Store产生。当数据访问指令的目标地址不存在或者该地址不允许当前指令访问时,处理器产生数据访问中止异常。

6.外部中断IRQ

当处理器的外部中断请求引脚有效,而且CPSR寄存器的I控制位被清除时,处理器产生外部中断IRQ异常。系统中各外部设备通常通过该异常中断请求处理器服务。

7.快速中断FIQ

当处理器的快速中断请求引脚有效且CPSR寄存器的F控制位被清除时,处理器产生快速中断请求FIQ异常。

当异常发生时,处理器会把PC设置为一个特定的存储器地址。这一地址放在被称为向量表(vector table)的特定地址范围内。向量表的入口是一些跳转指令,跳转到专门处理某个异常或中断的子程序。

进入异常

在异常发生后,ARM内核会作以下工作:

  • 在LR中保存下一条指令的地址(即返回地址);
  • 将CPSR复制到适当的SPSR中;
  • 将CPSR模式位强制设置为与异常类型相对应的值;
  • 原来无论是ARM状态或THUMB状态,都进入ARM状态;
  • 屏蔽快速中断和外部中断。
  • 强制PC从相关的异常向量处取指。

进入异常过程

  1. 程序在系统模式下运行用户程序,假定当前处理器状态为Thumb状态、允许IRQ中断;

  2. 用户程序运行时发生IRQ中断,硬件完成以下动作:

  • 置位I位(禁止IRQ中断)
  • 清零T位(进入ARM状态)
  • 设置MOD位,切换处理器模式至IRQ模式
  • 将下一条指令的地址存入IRQ模式的LR寄存器
  • 将CPSR寄存器内容存入IRQ模式的SPSR寄存器
  • 将跳转地址存入PC,实现跳转

中断响应步骤

  • 保护断点。
  • 寻找中断入口,根据不同的中断源所产生的中断,查找不同的入口地址。
  • 执行中断处理程序。
  • 中断返回。

具体实现如下:

1.判断处理器状态

当异常发生时,处理器自动切换到ARM状态,所以在异常处理函数中要判断在异常发生前处理器是ARM状态还是Thumb状态。这可以通过检测SPSR的T位来判断。

2.向量表

每一个异常发生时总是从异常向量表开始跳转。最简单的一种情况是向量表里面的每一条指令直接跳向对应的异常处理函数。

异常处理向量表如下:

Interrupt_Table.png

利用跳转指令B建立异常向量表

  ENTRY			;汇编程序开始 
  b reset		;跳入reset处理程序 
  b HandleUndef		;跳到未定义处理程序          
  b HandSWI		;跳到软中断处理程序       
  b HandPrefetchAbt 	;跳到预取指令处理程序       
  b HandDataAbt		;跳回数据访问中止处理程序      
  b HandNoUsed		;保留 
  b HandleIRQ		;跳入中断处理程序  
  b HandleFIQ		;跳回快速中断处理标签 

注意: 跳转指令B的跳转范围为±32MB,但很多情况下不能保证所有的异常处理函数都定位在向量的32MB范围内,需要更大范围的跳转,而且由于向量表空间的限制,只能由一条指令完成。

具体实现方法有下面两种:

  • MOV PC,#imme_value 这种办法将目标地址直接赋值给PC。但这种方法受格式限制不能处理任意立即数。这个立即数由一个8位数值循环右移偶数位得到。

  • LDR PC,[PC+offset] 把目标地址先存储在某一个合适的地址空间,然后把这个存储器单元的32位数据传送给PC来实现跳转。这种方法对目标地址值没有要求。但是存储目标地址的存储器单元必须在当前指令的±4KB空间范围内。

退出异常

当异常结束时,异常处理程序必须:

  • 将LR中的值减去偏移量后存入PC,偏移量根据异常的类型而有所不同;
  • 将SPSR的值复制回CPSR;
  • 清零在入口置位的中断禁止标志。

注: 恢复CPSR的动作会将T、F和I位自动恢复为异常发生前的值。

在异常处理结束后,异常处理程序完成以下动作:

  • 将SPSR寄存器的值复制回CPSR寄存器;
  • 将LR寄存的值减去一个常量后复制到PC寄存器,跳转到被中断的用户程序。

从异常处理程序中返回

当一个ARM异常处理返回时,一共有3件事情需要处理

  • 通用寄存器的恢复
  • 状态寄存器的恢复
  • PC指针的恢复

具体操作如下:

1.恢复被中断程序的处理器状态

PC和CPSR的恢复可以通过一条指令来实现,下面是3个例子。

  MOVS  PC,LR 
  SUBS  PC,LR,#4 
  LDMFD  SP!,{PC}^ 

这几条指令是普通的数据处理指令,特殊之处在于它们把程序计数器寄存器PC作为目标寄存器,并且带了特殊的后缀S^。其中S^的作用就是使指令在执行时,同时完成从SPSR到CPSR的拷贝,达到恢复状态寄存器的目的。

2.异常的返回地址

注意: 异常返回时,另一个非常重要的问题就是返回地址的确定。前面提到过,处理器进入异常时会有一个保存LR的动作,但是该保持值并不一定是正确中断的返回地址。最简单的方法使用关键字__irq封装,ARM会自动给你处理这些事情。

总结IRQ中断处理过程

Interrupt_Table.png

测试实例:


/******************************************************************************
 * Filehead.S
 * 功能:初始化,设置中断模式、管理模式的栈,设置好中断处理函数
*****************************************************************************/ 
.extern        main
.text
.global        _start
_start:
/****************************************************************************** 
* 中断向量,本程序中,除ResetHandleIRQ外,其它异常都没有使用
******************************************************************************/
    b    Reset
; 0x04: 未定义指令中止模式的向量地址
HandleUndef:
    b    HandleUndef    
@ 0x08: 管理模式的向量地址,通过SWI指令进入此模式
handleSWI:
    b    handleSWI
; 0x0c: 指令预取终止导致的异常的向量地址
HandlePrefetchAbort:
    b    HandlePrefetchAbort  
; 0x10: 数据访问终止导致的异常的向量地址
HandleDataAbort:
    b    HandleDataAbort
; 0x14: 保留
HandleNotUsed:
    b    HandleNotUsed  
; 0x18: 中断模式的向量地址
    b    HandleIRQ  
; 0x1C: 快中断模式的向量地址
HandleFIQ:
    b    HandleFIQ
; CPU刚上电或是复位之后,系统处于 arm 状态,管理(SVC)模式。
Reset:
    ldr    sp,        =4096    ;设置栈指针,以下都是C函数,调用前需要设好栈
    bl    disable_watch_dog    ;关闭 WATCHDOG,否则CPU会不断重启
    
    /****************************************************************
     * arm汇编中,唯一能更改cpsr的指令就是msr, cpsr_cCPSR的低8位, 
     * 0xd2 = (110 10010)b,可参见CPSRbit的定义,
     * 可知CPSR的低5位是工作模式位,如果定义为10010就是进入IRQ(中断模式), 
     * 于是这条指令的意义就是进入中断模式 
     * **************************************************************/
    msr    cpsr_c,    #0xd2  ; 进入 中断(IRQ) 模式
    ldr    sp,        =3072  ; 设置中断模式的栈指针,中断模式是有自己的堆栈寄存器的,就是r13
    
    msr    cpsr_c,    #0xd3        ; 重新进入 管理(SVC) 模式。
    ldr    sp,        =4096        ; 设置 管理(SVC 模式下的栈指针
                            ; 其实复位之后,CPU就处于管理模式,
                            ; 前面的“ldr sp, =4096”完成同样的功能,此句可省略

    bl    init_led            ; 初始化 LED GPIO管脚 
    bl    init_irq            ; arm中中断使能前的初始化配置
    
    msr    cpsr_c,    #0x5f        ; 进入 系统(sys)模式,使能 IRQ 中断
    
    ldr    lr,        =halt_loop   ; 设置返回地址
    ldr    pc,        =main        ; 调用 main 函数
halt_loop:
    b    halt_loop
    
HandleIRQ:
    sub    lr,    lr,    #4 ;计算返回地址,irq 模式,lr-4,指向发送异常是正在执行的指令
    stmdb        {r0-r12,lr}        ; 保存使用到的寄存器,现场保护
                            ; 注意,此时的sp是中断模式的sp
                            ; 初始值是上面设置的3072
                                    
    ldr    lr, =int_return         ; 设置调用ISREINT_Handle函数后的返回地址 
    ldr    pc,    =EINT_Handle     ; 调用中断服务函数,在interrupt.c
    
int_return:
    ldmia        {r0-r12,pc}^    ; 中断返回,现场恢复, ^表示将spsr的值复制到cpsr

IRQ初始化函数:


void init_irq(void)
{
    // 设置GPIO为中断
    // S2,S3对应的2根引脚设为中断引脚 EINT0,ENT2
    GPFCON &= ~(GPF0_msk | GPF2_msk);
    GPFCON |= GPF0_eint | GPF2_eint;

    // S4对应的引脚设为中断引脚EINT11
    GPGCON &= ~GPG3_msk;
    GPGCON |= GPG3_eint;

    // 设置外部中断属性,子中断属性
    // 对于EINT11,需要在EINTMASK寄存器中使能它
    EINTMASK &= ~(1<<11);

    // 设置优先级

    // 使能中断
    // EINT0、EINT2、EINT8_23使能
    INTMSK &= (~(1<<0)) & (~(1<<2)) & (~(1<<5));
}

中断处理函数:


void EINT_Handle(void)
{
    unsigned long oft = INTOFFSET;    //获取中断偏移,即那个中断被触发

    switch( oft )
    {
        // S2被按下
    case 0: 
        { 
            GPFDAT |= (0x7<<4); // 所有LED熄灭
            GPFDAT &= ~(1<<4); // LED1点亮
            break;
        }

        // S3被按下
    case 2:
        { 
            GPFDAT |= (0x7<<4); // 所有LED熄灭
            GPFDAT &= ~(1<<5); // LED2点亮
            break;
        }

        // K4被按下
    case 5:
        { 
            GPFDAT |= (0x7<<4); // 所有LED熄灭
            GPFDAT &= ~(1<<6); // LED4点亮 
            break;
        }

    default:
        break;
    }

    //清中断
    if( oft == 5 ) 
        EINTPEND = (1<<11); // EINT8_23合用IRQ5,写“1”清零此位 
    SRCPND = 1<<oft;
    INTPND = 1<<oft;
}

ARM Interrupt

CPSR & SPSR

CPSR: Current program status register(当前程序状态寄存器),在任何处理器模式下被访问。它包含了条件标志位、中断禁止位、当前处理器模式标志以及其他的一些控制和状态位。CPSR在用户级编程时用于存储条件码。

SPSR: Saved program status register(程序状态保存寄存器),每一种处理器模式下都有一个状态寄存器SPSR,SPSR用于保存CPSR的状态,以便异常返回后恢复异常发生时的工作状态。当特定的异常中断发生时,SPSR用于存放当前程序状态寄存器(CPSR)的内容。在异常中断退出时,可以用SPSR来恢复CPSR。

除了用户模式和系统模式,其余模式下都有一个私有SPSR保存状态寄存器,用来保存切换模式之前的执行状态,之所以用户模式和系统模式没有SPSR是因为,通常CPU大部分时间执行在用户模式下,当产生异常或系统调用时会分别进入另外几种模式,保存用户模式下的状态,当切换回原来模式时,直接恢复SPSR的值到CPSR就可以了,因此,用户模式和系统模式下不需要SPSR。当用户在用户模式或系统模式下访问SPSR,将产生不可预知的后果。

CPSR & SPSR访问指令

ARM处理器支持程序状态寄存器访问指令,用于在程序状态寄存器和通用寄存器之间传输数据,程序状态寄存器访问指令包括以下两个条件:

  • MRS: Move to Register from State register (程序状态寄存器到通用寄存器的数据传输指令)
  • MSR: Move to State register from Register (通用寄存器到程序状态寄存器的数据传输指令)

MRS 指令用于将程序状态寄存器的内容传送到通用寄存器中。该指令一般用在以下几种情况:

  • 当需要改变程序状态寄存器的内容时,可用MRS 将程序状态寄存器的内容读入通用寄存器,修改后再写回程序状态寄存器。
  • 当在异常处理或进程切换时,需要保存程序状态寄存器的值,可先用该指令读出程序状态寄存器的值,然后保存。

指令示例:

  • MRS R0,CPSR ;传送CPSR 的内容到R0
  • MRS R0,SPSR ;传送SPSR 的内容到R0

MSR指令可以对状态寄存器CPSRSPSR进行写操作。与MRS配合使用,可以实现对CPSR或SPSR寄存器的读-修改-写操作,可以切换处理器模式、或者允许/禁止IRQ/FIQ中断等。 由于xPSR寄存器代表了CPU的状态,其每个位有特殊意义,在执行对xPSR状态寄存器写入时(读取时不存在该用法),为了防止误操作和方便记忆,将xPSR里32位分成四个区域,每个区域用小写字母表示:

c  控制域屏蔽 psr[7..0]
x  扩展域屏蔽 psr[15..8]
s  状态域屏蔽 psr[23..16]
f  标志域屏蔽 psr[31..24]
注意:区域名必须为小写字母

向对应区域进行执行写入时,使用xPSR_x可以指定写入区域,而不影响状态寄存器其它位,如:

使能IRQ中断:

ENABLE_IRQ
    MRS    R0, CPSR	      ; 将CPSR寄存器内容读出到R0
    BIC    R0, R0,#0x80     ; 清掉CPSR中的I控制位
    MSR    CPSR_c,R0         ; 将修改后的值写回 CPSR寄存器的对应控制域
    MOV    PC,LR             ; 返回上一层函数

禁用IRQ中断:

DISABLE_IRQ
    MRS    R0, CPSR	            ; 将CPSR寄存器内容读出到R0
    ORR    R0, R0,#0x80           ; 设置CPSR中的I控制位
    MSR    CPSR_c,R0               ; 将修改后的值写回 CPSR寄存器的对应控制域
    MOV    PC,LR                   ; 返回上一层函数

BIC & ORR

BIC指令的格式为:

BIC{条件}{S}  目的寄存器,操作数1,操作数2

BIC指令用于清除操作数1的某些位,并把结果放置到目的寄存器中。操作数1应是一个寄存器, 操作数2可以是一个寄存器、被移位的寄存器、或一个立即数。操作数2为32位的掩码,如果在 掩码中置了某一位1,则清除这一位。未设置的掩码位保持不变。 指令示例:

bic r0, r0, #0x1f

0x1f = 11111b 其含义:清除 r0 的bit[4:0]位。

ORR指令的格式为:

ORR{条件}{S}  目的寄存器,操作数1,操作数2

ORR指令用于在两个操作数上进行逻辑或运算,并把结果放置到目的寄存器中。操作数1应该是一 个寄存器,操作数2可以是一个寄存器,被移位的寄存器,或一个立即数。该指令常用于设置操 作数1的某些位。 指令示例:

ORR R0,R0,#3          ;  该指令设置R0的0、1位,其余位保持不变。
orr r0, r0, #0xd3		; 0xd3 = 1101 0011
				; 将r0与0xd3作算数或运算,然后将结果返还给r0,
				; 即把r0的bit[7:6]和bit[4]和bit[2:0]置为1。