AVR单片机教程——走向高层
Posted jerry-fuyi
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了AVR单片机教程——走向高层相关的知识,希望对你有一定的参考价值。
本文隶属于AVR单片机教程系列。
?
在系列教程的最后一篇中,我将向你推荐3个可以深造的方向:RTOS、C++、事件驱动。掌握这些技术可以帮助你更快、更好地开发更大的项目。
本文涉及到许多概念性的内容,如果你有不同意见,欢迎讨论。
关于高层
这一篇教程叫作“走向高层”。什么是高层?
我认为,如果寥寥几行代码就能实现一个复杂功能,或者一行代码可以对应到几百句汇编,那么你就站在高层。高层与底层是相对的概念,没有绝对的界限。
站得高,看得远,这同样适用于编程,我们要走向高层。高层是对底层的封装,是对现实的抽象,高层相比于底层更加贴近应用。站在高层,你可以看到很多底层看不到的东西,主要有编程工具和思路。合理利用工具,可以简化代码,降低工作量;用合适的思路编程,更可以事半功倍。
但是,掌握高层并不意味着忽视甚至鄙视底层,高层建立在底层基础之上。其一,有些高层出现的诡异现象可以追溯到底层,这样的debug任务只有通晓底层与高层的开发者才能胜任;其二,为了让高层实现复杂功能的同时获得可接受的运行效率,底层必须设计地更加精致,这就对底层提出了更高的要求。
相信你经过一期和二期的教程,已经相当熟悉AVR编程的底层了。跟我一起走上高层吧!
RTOS
实时操作系统(RTOS)是一类操作系统。带有操作系统的计算机系统相比不带有的,最显著的特点是支持多任务。我们之前写的程序,在监控按键的同时,开了一个定时器中断用于数码管动态扫描,两个任务同时进行,是多任务吗?不完全是。监控按键与动态扫描两个任务只有一个可以占据main
函数,另一个必须放在中断里,中断里的任务不能执行太长时间,否则就会干扰main
函数的运行。而操作系统中的任务调度器可以给每个任务分配一定的运行时间,CPU一会执行这个,一会执行那个,每个任务都好像独占了CPU连续执行一样。
RTOS与其他操作系统的主要区别在于任务调度器的设计。在RTOS中,所有任务都有优先级,优先级高的被调度器保证优先执行,以获得最短的响应时间。在与现实世界打交道的嵌入式系统中,这样的功能往往是必要的。
操作系统通常需要中档的硬件,8位的AVR稍差了一点,主频和存储容量达不到一些操作系统的要求,不过还是有可选项的。我们来试着在开发板上运行FreeRTOS。FreeRTOS是一个免费的、为单片机设计的RTOS,是目前嵌入式市场占有率第二的操作系统,仅次于Linux。
首先去官网下载代码。下载的是一个.zip
压缩包,找到FreeRTOS
文件夹,目录下Demo
和Source
中的部分代码是需要使用的。作为一个跨平台的系统,大多数代码平台无关,只存一份,其他平台相关的代码,每个平台都有独立的实现,源码是demo都是如此,这使得代码组织有些复杂,你可以参考官方文档。
官方提供了ATmega323单片机的demo,为了在开发板上运行,需要做一些修改。demo基于WinAVR平台,它与Atmel Studio一样,都是基于avr-gcc的。如果你有WinAVR的话,直接用makefile
就可以编译;Atmel Studio虽然也提供了make
,但有些微区别,没法直接用makefile
,因此我们自己建立项目来编译。
新建项目,然后在Solution Explorer中建3个文件夹:
source
、port
和demo
。拷贝一些文件到这些目录下:
source
:Sourceinclude
所有文件、Source
下的tasks.c
、queue.c
、list.c
和croutine.c
;port
:SourceportableGCCATmega323
所有文件和SourceportableMemMang`下的heap_1.c
;demo
:DemoCommoninclude
所有文件、DemoCommonMinimal
下的crflash.c
、integer.c
、PollQ.c
和comtest.c
、DemoAVR_ATMega323_WinAVR
除makefile
以外的所有文件,再把ParTest.c
和serial.c
拎出来,main.c
拎到外面。
我是怎么知道的呢?我参考了官方文档和
makefile
文件。在Solution Explorer中Add Existing Item,在项目属性->Toolchain->AVR/GNU C Compiler->Directories中添加这三个目录。
修改代码,使之适用于我们的开发板:
修改的理由有以下几种:
ATmega323和ATmega324的寄存器略有不同;
WinAVR和Atmel Studio提供的工具链中的一些定义方式不同;
硬件配置与连接不同。
所以需要做以下修改:
port.c
中:TIMSK
改为TIMSK1
;SIG_OUTPUT_COMPARE1A
改为TIMER1_COMPA_vect
;54行改为0x02
;FreeRTOSConfig.h
中:48行改为25000000
;serial.c
中:UDR
、UCSRB
、UCSRC
、UBRRL
、UBRRH
分别改为UDR0
、UCSR0B
、UCSR0C
、UBRR0L
、UBRR0H
;67行改为0x00
;188行改为ISR(USART0_RX_vect)
;207行改为ISR(USART0_UDRE_vect)
;comtest.c
中:71行改为4
;72行改为2
;ParTest.c
中:DDRB
改为DDRC
;PORTB
改为PORTC
;49行改为0x00
;50行改为3
;72和99行把uxLED
改为(4 + uxLED)
;76行把if
和else
的大括号中的语句对调;main.c
中:删除81和84行;111行改为0
;117行改为3
;127行改为2
;153行返回类型改为int
。
不出意外的话,现在代码可以通过编译了(我这里有3个warning)。下载到单片机上,连接TX
和RX
,你会发现红灯和黄灯分别以300ms和400ms为周期闪烁,绿灯和串口黄灯一起闪烁,蓝灯不亮。
实际上,程序创建了1个整数计算、2个串口收发、2个队列收发、2个寄存器测试、1个错误检查和1个空闲共9个任务,以及2个LED闪烁协程。每过一毫秒,定时器产生一次中断,任务调度器暂停当前任务,换一个任务开始运行。为了理解这个过程,我们先介绍上下文这个概念。
一个任务在执行的过程中,需要一些临时变量,它们有的保存在栈上(栈是内存中的一块区域,寄存器SP
指向栈顶),有的在寄存器中;此外,条件分支语句还要用到寄存器SREG
中的位,这些位在之前的语句中被置位或清零;还有记录当前程序执行到哪的程序计数器。这些一起构成了任务执行的上下文:寄存器r0
到r31
、SREG
、SP
和PC
。不同任务的上下文是不共享的,但它们却要占用相同的位置,为此,在切换任务时需要把前一个上下文保存起来,并恢复要切换到的任务的上下文,这个过程称为上下文切换,然后才能继续这个任务。
我们来结合代码分析一下这个过程。
void TIMER1_COMPA_vect( void ) __attribute__ ( ( signal, naked ) );
void TIMER1_COMPA_vect( void )
{
vPortYieldFromTick();
asm volatile ( "reti" );
}
void vPortYieldFromTick( void ) __attribute__ ( ( naked ) );
void vPortYieldFromTick( void )
{
portSAVE_CONTEXT();
if( xTaskIncrementTick() != pdFALSE )
{
vTaskSwitchContext();
}
portRESTORE_CONTEXT();
asm volatile ( "ret" );
}
typedef void TCB_t;
extern volatile TCB_t * volatile pxCurrentTCB;
#define portSAVE_CONTEXT() asm volatile ( "push r0
" "in r0, __SREG__
" "cli
" "push r0
" "push r1
" "clr r1
" "push r2
" "push r3
" "push r4
" "push r5
" "push r6
" "push r7
" "push r8
" "push r9
" "push r10
" "push r11
" "push r12
" "push r13
" "push r14
" "push r15
" "push r16
" "push r17
" "push r18
" "push r19
" "push r20
" "push r21
" "push r22
" "push r23
" "push r24
" "push r25
" "push r26
" "push r27
" "push r28
" "push r29
" "push r30
" "push r31
" "lds r26, pxCurrentTCB
" "lds r27, pxCurrentTCB + 1
" "in r0, 0x3d
" "st x+, r0
" "in r0, 0x3e
" "st x+, r0
" );
#define portRESTORE_CONTEXT() asm volatile ( "lds r26, pxCurrentTCB
" "lds r27, pxCurrentTCB + 1
" "ld r28, x+
" "out __SP_L__, r28
" "ld r29, x+
" "out __SP_H__, r29
" "pop r31
" "pop r30
" "pop r29
" "pop r28
" "pop r27
" "pop r26
" "pop r25
" "pop r24
" "pop r23
" "pop r22
" "pop r21
" "pop r20
" "pop r19
" "pop r18
" "pop r17
" "pop r16
" "pop r15
" "pop r14
" "pop r13
" "pop r12
" "pop r11
" "pop r10
" "pop r9
" "pop r8
" "pop r7
" "pop r6
" "pop r5
" "pop r4
" "pop r3
" "pop r2
" "pop r1
" "pop r0
" "out __SREG__, r0
" "pop r0
" );
在定时器中断TIMER1_COMPA_vect
中,vPortYieldFromTick
被调用,其中依次调用portSAVE_CONTEXT
、xTaskIncrementTick
、vTaskSwitchContext
(可能不调用)和portRESTORE_CONTEXT
,执行汇编语句ret
;最后执行reti
。
在介绍中断的时候,我们提到过编译器添加的额外代码,把用到的寄存器都push进栈。但是,编译器只会保护该中断用到的寄存器,而上下文包括所有寄存器,需要手动地编写代码,那么也就无需编译器添加多余的代码了。函数TIMER1_COMPA_vect
被添加attributenaked
,表示无需添加任何代码,把用户编写的原原本本地编进去就够了。
进入中断时,PC
被push进栈(这是硬件做的),PC
内容变为TIMER1_COMPA_vect
的地址,随后开始执行,PC
再次push进栈(没有在图片中表示出来),开始执行portSAVE_CONTEXT
保存上下文。由于它是宏,就没有PC
进栈的过程。
然后,r0
、SREG
、r1
到r31
依次进栈,上下文的内容保存完成,其位置还需要另存。SP
指向栈顶,代表着上下文的位置,它被复制到pxCurrentTCB
所指的位置中。pxCurrentTCB
实际上是结构体TCB_t
指针,该结构体保存着当前执行的任务的信息,前两个字节保存栈指针。这样,上下文就保存完成了。
xTaskIncrementTick
把软件计数器加1,并检查是否需要任务切换。为了讲解,我们假定它需要,那么vTaskSwitchContext
就会被调用,pxCurrentTCB
指向另一个TCB_t
变量,那里保存着另一个任务的上下文,我们要恢复它。
恢复过程是,先用pxCurrentTCB
取出SP
,再按相反的顺序出栈,上下文中就只剩PC
没有恢复了(ret
和vPortYieldFromTick
的调用抵消,一起忽略)。最后执行reti
,该汇编语句从栈顶取两个字节放进PC
,并跳转到其位置继续执行。此时,PC
的内容就是该任务之前被中断时执行到的位置,现在从PC
开始继续执行,也就是继续执行该任务。上下文切换完成。
在对FreeRTOS稍有了解后,我们动手写一个基于FreeRTOS的程序。在学习数码管的时候,你很可能考虑过,在后台创建一个任务,执行数码管的扫描。现在,FreeRTOS给了你这个机会。我们创建两个任务,一个每一毫秒显示数码管的一位,另一个每200毫秒更新显示的数字。
#include <stdlib.h>
#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"
#include <ee2/segment.h>
SemaphoreHandle_t mutex;
portTASK_FUNCTION(segment_scan, pvParameters)
{
while (1)
{
static uint8_t digit = 0;
xSemaphoreTake(mutex, 1000);
segment_display(digit);
xSemaphoreGive(mutex);
if (++digit == 2)
digit = 0;
vTaskDelay(1);
}
}
portTASK_FUNCTION(segment_set, pvParameters)
{
while (1)
{
static uint8_t number = 0;
xSemaphoreTake(mutex, 1000);
segment_dec(number);
xSemaphoreGive(mutex);
if (++number == 100)
number = 0;
vTaskDelay(200);
}
}
int main()
{
segment_init(PIN_8, PIN_9);
mutex = xSemaphoreCreateMutex();
xTaskCreate(segment_scan, "scan", configMINIMAL_STACK_SIZE, NULL, 1, NULL);
xTaskCreate(segment_set, "set", configMINIMAL_STACK_SIZE, NULL, 2, NULL);
vTaskStartScheduler();
return 0;
}
两个任务都需要使用数码管这一资源。如果一个任务正在调用segment_dec
,还没返回时,定时器中断发生,切换到另一个任务,其中调用了segment_display
,就会发生冲突。我们用一个互斥量mutex
来解决。当一个任务调用了xSemaphoreTake
后,在它调用xSemaphoreGive
前,mutex
会进入锁定状态,如果另一个任务试图调用xSemaphoreTake
,则会阻塞住,切换到另一个任务。这样就保证两个任务不会冲突。资源共享是并行程序要着重处理的问题之一。
FreeRTOS还有很多功能等待你去发掘,RTOS就更多了。最后,我们来谈谈RTOS的长处和短处。
RTOS是多任务的,这是对代码顺序执行的编程模型的颠覆,使程序可以实现更多功能,比如两个连续的(不调用delay
之类的函数的)任务同时执行。即使是大多数情况下中断可以解决的问题,RTOS的引入也能让你更快地实现相同功能,这既体现在编程思路的改进,还有现成API可供使用,提高开发效率。如果涉及到程序在平台间的移植,RTOS能提供的帮助就更多了。
RTOS是事件驱动的,尽管表面上不太看得出来。这也能带来一些收益,我们将在本文最后一节进行分析。
然而,RTOS的运行负担较大,包括时间和空间,比如在AVR平台上,一次任务调度至少需要100多个指令周期。在应用本身不太复杂的情况下,这一点尤为严重,需要根据应用决定是否使用。我把RTOS安排到了最后一篇,显然是建议在AVR单片机开发中,尽可能不要使用RTOS。
最后,RTOS对个人发展是有好处的。Linux尽管不是RTOS,作为安装量最大的操作系统内核,是嵌入式开发者必须精通的。各种RTOS与Linux一样都是操作系统,无非是调度策略不同(Linux也有实时的),很多内容都是相通的。学习RTOS对学习Linux有很大帮助,这对你的嵌入式道路是有益无害的。
C++
未完待续……
以上是关于AVR单片机教程——走向高层的主要内容,如果未能解决你的问题,请参考以下文章