1、使用中断
在CM3中,NVIC为我们搞定了使用中断时的很多例行任务,如优先级检查、入栈/出栈、取向量等。不过在NVIC能行使职能之前,还需要我们做好如下的初始化工作:建立堆栈、建立向量表、分配各中断的优先级、使能中断。
1.1 建立堆栈
当开发的程序比较简单时,可以从头到尾都只使用MSP。这时,只需要保证开出一个容量够大的堆栈,再把MSP初始化到其顶即可——这也是单片机开发最常见的做法。
堆栈用穿是非常致命的错误,必须非常严肃地计算安全容量。由于CM3中的堆栈是以“向下生长的满栈”来操作SP的。在简单的场合中,经常可以把SP初始化为SRAM的末尾,这么一来就使所有的空闲内存都能为堆栈所用。
简单程序中典型的存储器分配
然而,对于比较大型的或者是有高性能指标的嵌入式系统,往往需要两个堆栈配合使用。必须保证各堆栈都有足够的容量,尤其是主堆栈,最容易栽在它上面。要注意的是,进程堆栈除了要满足本进程的最大需求量,还需要额外留出8个字,用于容纳第一级中断时被保护的寄存器。
事实上,准确计算主堆栈需求往往是不可能的任务,在调试阶段时,最好先选用内存更大点的器件,然后开出足够大的内存给主堆栈。然后在调试程序时,允许随时把主堆栈曾经的最大用量输出(通过调试串口或仿真器等),这样时间长了就能估算对主堆栈的需求。
1.2 建立向量表
如果在程序执行的从头到尾,都只给每个中断提供固定的中断服务例程(这也是目前单片机开发的绝大多数情况),则可以把向量表放到ROM中。在这种情况下不需要运行时重建向量表。然而,如果想让自己的设备能随机应变地对付各种复杂情况,就常常需要动态地改变中断服务例程,更新向量表就是必需的了。此时,向量表必须被转移到可读写存储器中(如内存)。
在把向量表重定位之前,往往要把现有的向量表往新的位置复制一份。需要拷贝的向量主要是系统异常的服务例程,如各种fault的、NMI的以及SVC的等等。如果没有建立好这些向量就启用了新的向量表,就可能会在响应异常时把不可预料的地址取出,程序极有可能跑飞。当我们把所有必要的向量都填好后,就可以启用了新的向量表了。
1.3 建立中断优先级
在复位后,对于所有优先级可编程的异常,其优先级都被初始化为0。而对于NMI和硬fault,由于它们要在危难之际挺身而出,所以把它们的优先级定死为-2和-1(高于任何其它异常)。
不要忘记为系统异常(包括faults)建立优先级。如果程序中有非常紧急的外部中断,它们甚至需要比系统异常还紧急,可是却因故不能连接到NMI上,就要把系统异常的优先级调低,才能保证紧急的中断能够抢占系统异常,从而不被延误。
1.4 使能中断
在向量表与优先级都建立好后,就到了最后一步:开中断的时候了。然而,在打开中断之前,可能还有两个步骤不能省略:
1、如果把向量表重定位到了RAM中,且这块RAM所在的存储器区域是写缓冲的,向量更新就可能被延迟。为了以防万一,必须在建立完所有向量后追加一条“数据同步隔离(DSB)”指令,以等待缓冲写入后再继续,从而确保所有数据都已落实。
2、开中断前可能已经有中断悬起,或者请求信号有效了,这往往是不可预料的。比如,在上电期间,信号线上有发生过毛刺,就可能会被意外地判定成一次中断请求脉冲。另外,在某些外设,如UART,在串口连接瞬间的一些噪音也可以被误判为接收到的数据,从而使中断被悬起。
在NVIC中进行中断的使能与除能时,都是使用各自的寄存器阵列(SETENA/CLRENA)来完成的:通过往适当的位写1来发出命令,而写0则不会有任何效果。这就让每个中断都可以自顾地使能和除能,而不必担心会破坏其它中断的设置。这改变了以前必须“读-改-写”的三步曲,从而在根本上消灭了在此地产生紊乱危象的可能;否则,必须使用互斥访问等机制来完成修改。
2、异常/中断服务例程
在CM3中,中断服务例程可以纯用C来写。
只要检测到过曾经出现的中断请求,NVIC就会记住它,因此硬件只需给一个脉冲,无需再一直保持请求电平,持续的电平反而成为一种讨厌的事了。而且当其服务例程得到执行时,NVIC自动把悬起状态清除。对于这种情况,就不必在ISR中软件清除请求信号了。
3 、软件触发中断
触发中断有多种方法:外部中断输入、设置NVIC的悬起寄存器中设置相关的位、使用NVIC的软件触发中断寄存器(STIR)
系统中总是会有一些中断没有用到,此时就可以当作软件中断来使用。软件中断的功用与SVC类似,两者都能用于让任务进入特权级下,以获取系统服务。不过,若要使用软件中断,必须在初始化时把NVIC配置与控制寄存器的USERSETMPEND位置位,否则是不允许用户级下访问STIR。
但是软件中断没有SVC专业:比如,它们是不精确的,也就是说,抢占行为不一定会立即发生,即使当时它没有被掩蔽,也没有被其它ISR阻塞,也不能保证马上响应。这也是写缓冲造成的,会影响到与操作NVIC STIR相临的后一条指令:如果它需要根据中断服务的结果来决定如何工作(如条件跳转),则该指令可能会误动作——这也可以算是紊乱危象的一种表现形式。为解决这个问题,必须使用一条DSB指令。
如果欲触发的软件中断被除能了,或者执行软件中断的程序自己也是个异常服务程序,软件中断就有可能无法响应。因此,必须在使用前检查这个中断已经在响应中了。为达到此目的,可以让软件中断服务程序在入口处设置一个标志。这种方式属于卡bug级别的,慎用,需要软件中断,还是用SVC比较好。
4、使用SVC
SVC是用于呼叫OS所提供API的正道。用户程序只需知道传递给OS的参数,而不必知道各API函数的地址。SVC指令带一个8位的立即数,可以视为是它的参数,被封装在指令本身中,如:
SVC 3; 呼叫3号系统服务
则3被封装在这个SVC指令中。因此在SVC服务例程中,需要读取本次触发SVC异常的SVC指令,并提取出8位立即数所在的位段,来判断系统调用号。
一旦获取了调用号,就可以用它来调用系统服务函数了。有理由相信,OS应该使用TBB/TBH查表跳转指令来加速定位正确的服务函数。然而,如果你是设计OS的人,必须检查这个参数的合法性,以免因数字超出跳转表的范围而跳飞。因为不能在SVC服务例程中嵌套使用SVC,所以如果有需要,就要直接调用SVC函数,例如,使用BL指令。
5、在C中使用SVC
因为晚到中断的关系,SVC中不能再使用寄存器来传递参数,而是必须使用堆栈。因此,需要使用一段汇编代码来给SVC函数传参数。如果SVC服务例程的主部由C来写,则必须在前面伴随一个汇编写的封皮,用于把堆栈中的参数提取到寄存器中。下面给出一段代码来演示这个工作。
//汇编封皮,用于提出堆栈帧的起始位置,并放到R0中,然后跳转至实际的SVC服务例程中。
__asm void svc_handler_wrapper(void)
{
IMPORT svc_handler
TST LR, #4
ITE EQ
MRSEQ R0, MSP
MRSNE R0, PSP
B svc_handler
}
//不必写下BX LR来返回,而是由svc_handler来做决定
接下来的SVC服务例程的主体就可以由C来写了,它使用R0作为输入参数(这也是堆栈帧的起始位置),用于进一步提取服务代号,并且传递参数(通过堆栈中的R0-R3)。
//使用C写成的SVC服务例程,接受一个指针参数(pwdSF):堆栈栈的起始地址。
//pwdSF[0] = R0 ,pwdSF[1] = R1
//pwdSF[2] = R2 ,pwdSF[3] = R3
//pwdSF[4] = R12,pwdSF[5] = LR
// pwdSF[6] =返回地址(入栈的PC)
// pwdSF[7] = xPSR
unsigned longsvc_handler(unsigned int* pwdSF)
{
unsigned int svc_number;
unsigned int svc_r0;
unsigned int svc_r1;
unsigned int svc_r2;
unsigned int svc_r3;
int retVal;//用于存储返回值
svc_number = ((char *) pwdSF[6])[-2]; //写的太抽象了
svc_r0 = ((unsigned long) pwdSF[0]);
svc_r1 = ((unsigned long) pwdSF[1]);
svc_r2 = ((unsigned long) pwdSF[2]);
svc_r3 = ((unsigned long) pwdSF[3]);
printf (“SVC number = %xn”, svc_number);
printf (“SVC parameter 0 = %x\n”, svc_r0);
printf (“SVC parameter1 = %x\n”, svc_r1);
printf (“SVC parameter 2 = %x\n”, svc_r2);
printf (“SVC parameter 3 = %x\n”, svc_r3);//做一些工作,并且把返回值存储到retVal中
pwdSF[0]=retVal;
return 0;
}
注意,这个函数返回的其实不是0。return 0;只是用于骗过编译器,不用给出警告,实际的返回值是retVal。
SVC服务例程不能像普通的C函数那样——通过把原型声明为”unsigned int func()”,再在末尾来一句”return xx;”来返回。因为这种常规的作法在所有的ARM中其实是把返回值放到R0里。但是别忘了,这个函数可是异常服务例程,它的返回可是享受“异常返回”的待遇的——伴随着一个硬件控制的自动出栈行为,这会从堆栈中重建R0的值,从而覆盖“return”指定的值。因此,它必须把返回值写到堆栈中R0的位置,才能借自动出栈之机返回自己的值(pwdSF[0]=retVal)。
虽然内部暗流汹涌,但是从应用程序的表面上看还是风平浪静——对于系统服务函数来说,这种独特的返回方式与普通的return xx效果是相同的,依然可以用普通的形式接收返回值。其实,在写系统软件时,这根本算不上耍狠,只不过是寻常的基本功罢了,要不然怎么说C是“低级高级语言”呢。而病毒/木马所采用的“堆栈/缓冲区溢出攻击”,那才算真正的狠招呢,但是它们原理是一脉相承的。可见,对底层理解得深刻,能让我们写出更好,更强大的程序来。
在RVDS和KeilRVMDK中,为了方便我们放参数,提供了”__ svc”编译器指示字。举例来说,如果需要在3号服务请求中传递4个参数,则可以类似下例写:
unsigned long __svc(0x03) CallSvc3(unsigned long svc_r0, unsigned longsvc_r1,
unsigned long svc_r2, unsigned long svc_r3);
当C程序调用这种函数时,则编译器会自动生成SVC指令,如下所示:
int Func(void)
{
unsigned long p0, p1, p2, p3; //传递给SVC服务例程的4个函数
unsigned long svcRet; //系统服务的返回值
//呼叫3号系统服务,并且传递4个参数,依次为:p1,p2,p3,p4,再接收返回值到svcRet中(别忘了,这个返回 //值的来历不寻常)
svcRet = CallSvc3(p0, p1, p2, p3);
. . .
return 0;
}
如欲获知__svc的官方说明,可以查阅《RVCT 3.0 Compiler and Library Guide(Ref6)》。
如果使用的是GNU的工具链,里面没有svc关键字。但是GCC支持内联汇编,可以实现此功能。例如,如果需要呼叫3号系统服务,同时传递一个参数,还接收一个返回值(两者都通过R0),则可以使用如下的内联汇编来呼叫SVC:
int MyDataIn=0x123;asmvolatile ("mov R0, %0\n"
"svc 3\n" : "": "r" (MyDataIn) );
上段内联汇编码中,两个“:”后面分别对应输入数据——由r(MyDataIn)指定,以及输出数据——即上段代码中是””,语法模式如下所示:
__asm ( assembler_code : output_list : input_list )