分析FreeRTOS内核对象API的源码实现(八)

内存管理:

1.内存管理的基本概念

在计算系统中,变量、中间数据一般存放在系统存储空间中,只有在实际使用时才将它们从存储空间调入到中央处理器内部进行运算。通常存储空间可以分为两种:内部存储空间和外部存储空间。内部存储空间访问速度比较快,能够按照变量地址随机地访问,也就是我们通常所说的 RAM(随机存储器),或电脑的内存;而外部存储空间内所保存的内容相对来说比较固定,即使掉电后数据也不会丢失,可以把它理解为电脑的硬盘。在这一章中我们主要讨论内部存储空间(RAM)的管理------内存管理。

FreeRTOS 操作系统将内核与内存管理分开实现,操作系统内核仅规定了必要的内存管理函数原型,而不关心这些内存管理函数是如何实现的,所以在 FreeRTOS 中提供了多种内存分配算法(分配策略),但是上层接口(API)却是统一的。这样做可以增加系统的灵活性:用户可以选择对自己更有利的内存管理策略,在不同的应用场合使用不同的内存分配策略。

在嵌入式程序设计中内存分配应该是根据所设计系统的特点来决定选择使用动态内存分配还是静态内存分配算法,一些可靠性要求非常高的系统应选择使用静态的,而普通的业务系统可以使用动态来提高内存使用效率。静态可以保证设备的可靠性但是需要考虑内存上限,内存使用效率低,而动态则是相反。

FreeRTOS 内存管理模块管理用于系统中内存资源,它是操作系统的核心模块之一。主要包括内存的初始化、分配以及释放。

很多人会有疑问,什么不直接使用 C 标准库中的内存管理函数呢?在电脑中我们可以用 malloc()和 free()这两个函数动态的分配内存和释放内存。但是,在嵌入式实时操作系统中,调用 malloc()和 free()却是危险的,原因有以下几点:

 这些函数在小型嵌入式系统中并不总是可用的,小型嵌入式设备中的 RAM 不足。

 它们的实现可能非常的大,占据了相当大的一块代码空间。

 他们几乎都不是安全的。

 它们并不是确定的,每次调用这些函数执行的时间可能都不一样。

 它们有可能产生碎片。

 这两个函数会使得链接器配置得复杂。

 如果允许堆空间的生长方向覆盖其他变量占据的内存,它们会成为 debug 的灾难。

在一般的实时嵌入式系统中,由于实时性的要求,很少使用虚拟内存机制。所有的内存都需要用户参与分配,直接操作物理内存,所分配的内存不能超过系统的物理内存,所有的系统堆栈的管理,都由用户自己管理。

同时,在嵌入式实时操作系统中,对内存的分配时间要求更为苛刻,分配内存的时间必须是确定的。一般内存管理算法是根据需要存储的数据的长度在内存中去寻找一个与这段数据相适应的空闲内存块,然后将数据存储在里面。而寻找这样一个空闲内存块所耗费的时间是不确定的,因此对于实时系统来说,这就是不可接受的,实时系统必须要保证内存块的分配过程在可预测的确定时间内完成,否则实时任务对外部事件的响应也将变得不可确定。

而在嵌入式系统中,内存是十分有限而且是十分珍贵的,用一块内存就少了一块内存,而在分配中随着内存不断被分配和释放,整个系统内存区域会产生越来越多的碎片,因为在使用过程中,申请了一些内存,其中一些释放了,导致内存空间中存在一些小的内存块,它们地址不连续,不能够作为一整块的大内存分配出去,所以一定会在某个时间,系统已经无法分配到合适的内存了,导致系统瘫痪。其实系统中实际是还有内存的,但是因为小块的内存的地址不连续,导致无法分配成功,所以我们需要一个优良的内存分配算法来避免这种情况的出现。

不同的嵌入式系统具有不同的内存配置和时间要求。所以单一的内存分配算法只可能适合部分应用程序。因此,FreeRTOS 将内存分配作为可移植层面(相对于基本的内核代码部分而言),FreeRTOS 有针对性的提供了不同的内存分配管理算法,这使得应用于不同场景的设备可以选择适合自身内存算法。

FreeRTOS 对内存管理做了很多事情,FreeRTOS 的 V9.0.0 版本为我们提供了 5 种内存管理算法,分别是 heap_1.c、heap_2.c、heap_3.c、heap_4.c、heap_5.c,源文件存放于FreeRTOS\Source\portable\MemMang 路径下,在使用的时候选择其中一个添加到我们的工程中去即可。(本节只介绍1、2、4、5)

FreeRTOS 的内存管理模块通过对内存的申请、释放操作,来管理用户和系统对内存的使用,使内存的利用率和使用效率达到最优,同时最大限度地解决系统可能产生的内存碎片问题。

2.内存管理的应用场景

首先,在使用内存分配前,必须明白自己在做什么,这样做与其他的方法有什么不同,特别是会产生哪些负面影响,在自己的产品面前,应当选择哪种分配策略。

内存管理的主要工作是动态划分并管理用户分配好的内存区间,主要是在用户需要使用大小不等的内存块的场景中使用,当用户需要分配内存时,可以通过操作系统的内存申请函数索取指定大小内存块,一旦使用完毕,通过动态内存释放函数归还所占用内存,使之可以重复使用(heap_1.c 的内存管理除外)。

例如我们需要定义一个 float 型数组:floatArr\[\];

但是,在使用数组的时候,总有一个问题困扰着我们:数组应该有多大?在很多的情况下,你并不能确定要使用多大的数组,可能为了避免发生错误你就需要把数组定义得足够大。即使你知道想利用的空间大小,但是如果因为某种特殊原因空间利用的大小有增加或者减少,你又必须重新去修改程序,扩大数组的存储范围。这种分配固定大小的内存分配方法称之为静态内存分配。这种内存分配的方法存在比较严重的缺陷,在大多数情况下会浪费大量的内存空间,在少数情况下,当你定义的数组不够大时,可能引起下标越界错误,甚至导致严重后果。

我们用动态内存分配就可以解决上面的问题。所谓动态内存分配就是指在程序执行的过程中动态地分配或者回收存储空间的分配内存的方法。动态内存分配不象数组等静态内存分配方法那样需要预先分配存储空间,而是由系统根据程序的需要即时分配,且分配的大小就是程序要求的大小。

例如我们在学习创建任务的时候,给任务栈和TCB分配内存就是用动态分配,如下:

3.内存管理方案详解

在分析这些内存管理方案之前我们先看看它们的区别:

FreeRTOS 规定了内存管理的函数接口,具体见,但是不管其内部的内存管理方案是怎么实现的,所以,FreeRTOS 可以提供多个内存管理方案,下面,就一起看看各个内存管理方案的区别。

c 复制代码
void *pvPortMalloc( size_t xSize ); //内存申请函数 
void vPortFree( void *pv ); //内存释放函数 
void vPortInitialiseBlocks( void ); //初始化内存堆函数 
size_t xPortGetFreeHeapSize( void ); //获取当前未分配的内存堆大小 
size_t xPortGetMinimumEverFreeHeapSize( void ); //获取未分配的内存堆历史最小值

FreeRTOS 提供的内存管理都是从内存堆中分配内存的。从前面学习的过程中,我们也知道,创建任务、消息队列、事件等操作都使用到分配内存的函数,这是系统中默认使用内存管理函数从内存堆中分配内存给系统核心组件使用。

对于 heap_1.c、heap_2.c 和 heap_4.c 这三种内存管理方案,内存堆实际上是一个很大的 数 组 , 定 义 为 static uint8_t ucHeap configTOTAL_HEAP_SIZE , 而 宏 定 义configTOTAL_HEAP_SIZE 则表示系统管理内存大小,单位为字,在 FreeRTOSConfig.h 中

由用户设定。

对于 heap_3.c 这种内存管理方案,它封装了 C 标准库中的 malloc()和 free()函数,封装后的 malloc()和 free()函数具备保护,可以安全在嵌入式系统中执行。因此,用户需要通过编译器或者启动文件设置堆空间。

heap_5.c 方案允许用户使用多个非连续内存堆空间,每个内存堆的起始地址和大小由用户定义。这种应用其实还是很大的,比如做图形显示、GUI 等,可能芯片内部的 RAM是不够用户使用的,需要外部 SDRAM,那这种内存管理方案则比较合适。

(1)heap_1.c

heap_1.c 管理方案是 FreeRTOS 提供所有内存管理方案中最简单的一个,它只能申请内存而不能进行内存释放,并且申请内存的时间是一个常量,这样子对于要求安全的嵌入式设备来说是最好的,因为不允许内存释放,就不会产生内存碎片而导致系统崩溃,但是也有缺点,那就是内存利用率不高,某段内存只能用于内存申请的地方,即使该内存只使用一次,也无法让系统回收重新利用。

实际上,大多数的嵌入式系统并不会经常动态申请与释放内存,一般都是在系统完成的时候,就一直使用下去,永不删除,所以这个内存管理方案实现简洁、安全可靠,使用的非常广泛。

heap1.c 方案具有以下特点:

1、 用于从不删除任务、队列、信号量、互斥量等的应用程序(实际上大多数使用FreeRTOS 的应用程序都符合这个条件)。

2、 函数的执行时间是确定的并且不会产生内存碎片。 heap_1.c 管理方案使用两个静态变量对系统管理的内存进行跟踪内存分配,具体见

1.内存申请函数 pvPortMalloc()

c 复制代码
static size_t xNextFreeByte = ( size_t ) 0;

static uint8_t *pucAlignedHeap = NULL;

对静态变量的了解:

在了解pvPortMalloc() 函数源码前,我们先了解这两个静态变量。我们知道,静态变量保存在 RAM 区的固定地址,程序运行期间全程不释放,每次读取该变量都会访问同一个地址,所以能够保存上一次的值。这与普通变量不同:普通变量在函数调用结束后,其占用的内存会被释放或覆盖,值随之丢失。例如,调用函数时,系统会在该任务栈上临时开辟一块空间,用来保存普通变量运行后的结果;当函数 return 后栈指针回退,这块内存会被后续函数调用复用,值自然也就丢失了。

变量 xNextFreeByte: 变量 xNextFreeByte 用来定位下一个空闲的内存堆位置。真正的运作过程是记录已经被分配的内存大小,在每次申请内存成功后,都会增加申请内存的字节数目。因为内存堆实际上是一个大数组,我们只需要知道已分配内存的大小,就可以用它作为偏移量找到未分配内存的起始地址。

变量 pucAlignedHeap: 是一个指向对齐后的内存堆起始地址,我们使用一个数组作为堆内存,但是数组的起始地址并不一定是对齐的内存地址,所以我们需要得到FreeRTOS 管理的内存空间对齐后的起始地址,并且保存在静态变量 pucAlignedHeap 中。为什么要对齐?如下:

  • Cortex-M3/M4/M4F 的 FreeRTOS port 在 portmacro.h 里定义 portBYTE_ALIGNMENT 8;
  • 8 字节是 AAPCS 对栈/公共接口的对齐要求;
  • uint64_t、double 需要 8 字节对齐;
  • Cortex-M4F 的 FPU/异常帧也涉及 8 字节;
  • 性能只是附带好处。

内存申请函数就是用于申请一块用户指定大小的内存空间,当系统管理的内存空间满足用户需要的大小的时候,就能申请成功,并且返回内存空间的起始地址,源码如下:

c 复制代码
void *pvPortMalloc( size_t xWantedSize )
{
void *pvReturn = NULL;
static uint8_t *pucAlignedHeap = NULL;

	/* Ensure that blocks are always aligned to the required number of bytes. */
	#if( portBYTE_ALIGNMENT != 1 )
	{
		if( xWantedSize & portBYTE_ALIGNMENT_MASK )
		{
			/* Byte alignment required. */
			xWantedSize += ( portBYTE_ALIGNMENT - ( xWantedSize & portBYTE_ALIGNMENT_MASK ) );
		}
	}
	#endif

	vTaskSuspendAll();
	{
		if( pucAlignedHeap == NULL )
		{
			/* Ensure the heap starts on a correctly aligned boundary. */
			pucAlignedHeap = ( uint8_t * ) ( ( ( portPOINTER_SIZE_TYPE ) &ucHeap[ portBYTE_ALIGNMENT ] ) & ( ~( ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK ) ) );
		}

		/* Check there is enough room left for the allocation. */
		if( ( ( xNextFreeByte + xWantedSize ) < configADJUSTED_HEAP_SIZE ) &&
			( ( xNextFreeByte + xWantedSize ) > xNextFreeByte )	)/* Check for overflow. */
		{
			/* Return the next free byte then increment the index past this
			block. */
			pvReturn = pucAlignedHeap + xNextFreeByte;
			xNextFreeByte += xWantedSize;
		}

		traceMALLOC( pvReturn, xWantedSize );
	}
	( void ) xTaskResumeAll();

	#if( configUSE_MALLOC_FAILED_HOOK == 1 )
	{
		if( pvReturn == NULL )
		{
			extern void vApplicationMallocFailedHook( void );
			vApplicationMallocFailedHook();
		}
	}
	#endif

	return pvReturn;
}

(1)pvPortMalloc() 返回最低地址,并解释任务栈与 TCB 的地址关系

在 pvPortMalloc() 函数成功分配一块空间后,返回的指针 pvReturn 指向这块空间的起始地址,也就是最低地址。因为源码中执行的是:

c 复制代码
pvReturn = pucAlignedHeap + xNextFreeByte;
xNextFreeByte += xWantedSize;

而内存堆的分配方向是从低地址向高地址增长,每次申请成功后 xNextFreeByte 都会增加,所以新分配内存块的起始地址就是当前空闲区域的低地址端。

这一点也能解释为什么在创建任务函数中,计算栈顶 pxTopOfStack 时要写成:

c 复制代码
pxTopOfStack = pxStack + ( ulStackDepth - ( uint32_t ) 1 );

任务栈是从低地址向高地址分配的一块连续空间,因此要用栈底地址 pxStack 加上 ulStackDepth - 1,才能得到任务栈的最高地址。(注意ulStackDepth单位是 StackType_t 元素个数,不是字节数)

这里还要注意一个关键区别:内存分配是从低地址向高地址增长,而栈的使用方向是从高地址向低地址伸展 。因此在创建任务时,FreeRTOS 会先分配任务栈空间,再分配任务 TCB,这样任务 TCB 位于比任务栈最高地址还要高的位置。当任务栈溢出时,栈向低地址方向"侵蚀",不会向上破坏位于更高地址的任务 TCB。(这在heap_1.c 成立,在heap_4.c不一定,因为heap_1.c 会释放空间,可能导致空间是随机的 )

(2)指向对齐后的内存堆起始地址,这里初始化为NULL

(3)如果系统要求内存对齐的字节不是按 1 字节对齐,那么就把要申请的内存大小 xWantedSize 按照portBYTE_ALIGNMENT定义的字节大小对齐,该宏定义如下:

c 复制代码
#define portBYTE_ALIGNMENT			8

#if portBYTE_ALIGNMENT == 8
	#define portBYTE_ALIGNMENT_MASK ( 0x0007 )
#endif

也就是说我们会将 xWantedSize向上取整,按照8字节对齐,怎么理解呢?如下:

假设我们先申请一块 100 字节大小的内存空间,按 8 字节对齐计算的过程如下:

text 复制代码
( xWantedSize & portBYTE_ALIGNMENT_MASK )
= 0110 0100 & 0000 0111
= 0000 0100
= 4

这里得到的 4,实际上就是 100 除以 8 的余数,表示 100 字节距离 8 字节对齐还差:

text 复制代码
portBYTE_ALIGNMENT - ( xWantedSize & portBYTE_ALIGNMENT_MASK )
= 8 - 4
= 4

因此原代码中的核心计算就是:

text 复制代码
xWantedSize += ( portBYTE_ALIGNMENT - ( xWantedSize & portBYTE_ALIGNMENT_MASK ) )
= 100 + ( 8 - 4 )
= 100 + 4
= 104

对齐后,实际参与申请的内存大小从 100 字节调整为 104 字节。所以第(3)处的含义是:计算你申请的空间还差多少字节才能满足 portBYTE_ALIGNMENT 字节对齐,然后把这个差额加到 xWantedSize 上,也就是把申请大小向上取整为 8 字节的整数倍。

(4)挂起调度器,该任务在调用pvPortMalloc() 函数分配空间的时候,不允许切换到其它任务也来分配空间

(5)如果指向对齐后的内存堆起始地址为NULL,那就需要初始化指向这块堆的首地址,前面说过我们要8字节对齐,所以为了避免这块堆的首地址不是8字节对齐,这里我们也需要计算一下,该计算流程如下图:

假设这块堆的起始地址是 0x2000 0001,那么:

text 复制代码
&ucHeap[ portBYTE_ALIGNMENT ]
= &ucHeap[8]
= 0x2000 0001 + 8
= 0x2000 0009

这里取的是 ucHeap[8] 的地址,而不是 ucHeap[0] 的地址,是为了预留出向下取整的空间,避免对齐计算后跑出数组首地址。

再看掩码部分:

text 复制代码
portBYTE_ALIGNMENT_MASK = 0x0007
~( ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK )
= ~0x0007
= 0xFFFF FFF8

如果只看低 4 位,0xFFFF FFF8 的低 4 位就是 1000,所以原文中的计算可以简化为:

text 复制代码
0x2000 0009 & ~0x0007

只看地址低 4 位:

text 复制代码
地址低 4 位:  1001
掩码低 4 位:  1000
按位与结果:  1000

将结果 1000 代入原来地址后,完整结果就是:

text 复制代码
0x2000 0009 & 0xFFFF FFF8
= 0x2000 2008

所以这块堆的起始地址由 0x2000 0001 对齐为 0x2000 0008,低 3 位被固定为 000,刚好满足 8 字节对齐。

为什么要先加 8:

  • 如果直接拿 &ucHeap0 去掩码,而数组起始地址是 0x2000 0001,掩码后会得到 0x2000 0000------跑到数组外面去了;
  • 先加 8 变成 0x2000 0009,再掩码得到 0x2000 0008,这个地址一定落在 \&ucHeap\[0, &ucHeap0+8] 范围内;
  • 最多浪费 8 字节,所以:

(6)这一步是防止分配的空间超出了这块堆的最大内存

(7)第一次分配的时候,pucAlignedHeap指向这块堆的起始地址,xNextFreeByte==NULL,所以第一次分配空间的时候,返回的就是这块堆的起始地址

(8)将xNextFreeByte赋值为分配好内存的大小,下次再分配空间就会从堆的起始地址+这块内存大小的地址开始分配

(9)恢复调度器

(10)返回分配好的内存起始地址(低地址)

(2)heap_2.c

heap_2.c 方案与 heap_1.c 方案采用的内存管理算法不一样,它采用一种最佳匹配算法(best fit algorithm),比如我们申请 100 字节的内存,而可申请内存中有三块对应大小 200 字节, 500 字节和 1000 字节大小的内存块,按照算法的最佳匹配,这时候系统会把 200 字节大小的内存块进行分割并返回申请内存的起始地址,剩余的内存则插回链表留待下次申请。Heap_2.c 方案支持释放申请的内存,但是它不能把相邻的两个小的内存块合成一个大的内存块,对于每次申请内存大小都比较固定的,这个方式是没有问题的,而对于每次申请并不是固定内存大小的则会造成内存碎片,后面要讲解的 heap_4.c 方案采用的内存管理算法能解决内存碎片的问题,可以把这些释放的相邻的小的内存块合并成一个大的内存块。 同样的 ,内存分配时需 要的总的内存堆 空间由文 件 FreeRTOSConfig.h 中的 宏configTOTAL_HEAP_SIZE 配置,单位为字。通过调用函数 xPortGetFreeHeapSize() 我们可

以知道还剩下多少内存没有使用,但是并不包括内存碎片,这样一来我们可以实时的调整和优化 configTOTAL_HEAP_SIZE 的大小。

heap_2.c 方案具有以下特点:

  1. 可以用在那些反复的删除任务、队列、信号量、等内核对象且不担心内存碎片的应用程序。
  2. 如果我们的应用程序中的队列、任务、信号量、等工作在一个不可预料的顺序,这样子也有可能会导致内存碎片。
  3. 具有不确定性,但是效率比标准 C 库中的 malloc 函数高得多
  4. 不能用于那些内存分配和释放是随机大小的应用程序。

heap_2.c 方案与 heap_1 方案在内存堆初始化的时候操作都是一样的,在内存中开辟了一个静态数组作为堆的空间,大小由用户定义,然后进行字节对齐处理。 heap_2.c 方案采用链表的数据结构记录空闲内存块,将所有的空闲内存块组成一个空闲内存块链表,FreeRTOS 采用 2 个 BlockLink_t 类型的局部静态变量 xStart、xEnd 来标识空闲内存块链表的起始位置与结束位置,如下:

也就是说对于heap_2.c的管理方案:调用pvPortMalloc()函数除了分配我们需要的内存外还会分配一块BlockLink_t类型结构的内存,该结构体定义如下:

c 复制代码
typedef struct A_BLOCK_LINK
{
	struct A_BLOCK_LINK *pxNextFreeBlock;	/*<< The next free block in the list. */
	size_t xBlockSize;						/*<< The size of the free block. */
} BlockLink_t;

成员一:pxNextFreeBlock

它是一个指针,指向空闲内存块链表中的下一个空闲内存块。更准确地说,它指向的是下一个 BlockLink_t 结构体的首地址,也就是下一个空闲内存块的"块头"。整个空闲链表就是由一个个 BlockLink_t 节点单向串联起来的,所以这个成员相当于单链表里的"下一个节点指针",只能单向地访问下一个节点。

成员二:xBlockSize

它保存当前内存块的大小。有了这个大小信息,申请内存时就可以判断当前空闲内存块是否满足我们要分配的大小;释放内存时,由于 BlockLink_t 结构体的大小是固定的,只要把传入的地址(我们pvPortMalloc()函数返回的是直接能用的内存,不包括"块头"和vPortFree()函数传入的也是能用的内存,关于"块头"的处理,那是这两个函数内部的事,我们应用层不用理会 )向前偏移一个 BlockLink_t 结构体的大小,就能从"数据区首地址"回退到当前内存块的"块头",从而访问到这个结构体中的 xBlockSize 成员,最后就可以知道需要释放多少内存空间(这个xBlockSize大小 = 该内存的"块头"+能用的内存)。

在申请内存成功的同时系统还会判断当前这块内存是否有剩余(大于一个链表节点所需内存空间),这样子就表示剩下的内存块还是能存放东西的,也要将其利用起来。如果有剩余的内存空间,系统会将内存块进行分割,在剩余的内存块头部添加一个内存节点,并且完善该空闲内存块的信息,然后将其按内存块大小插入内存块空闲链表中,供下次分配使用,其中 prvInsertBlockIntoFreeList() 这个函数就是把节点按大小插入到链表中。

具体流程如下:

(补充:链表头的xBlockSize为0,而链表尾的xBlockSize为该堆的内存大小,是因为这里的链表排序是按从小到大排序的,想想也很合理,你不可能一开始就拿最大的内存块比较你需要的内存块吧?)

pvPortMalloc()函数源码如下:

c 复制代码
void *pvPortMalloc( size_t xWantedSize )
{
BlockLink_t *pxBlock, *pxPreviousBlock, *pxNewBlockLink;
static BaseType_t xHeapHasBeenInitialised = pdFALSE;
void *pvReturn = NULL;

	vTaskSuspendAll();
	{
		/* If this is the first call to malloc then the heap will require
		initialisation to setup the list of free blocks. */
		if( xHeapHasBeenInitialised == pdFALSE )
		{
			prvHeapInit();
			xHeapHasBeenInitialised = pdTRUE;
		}

		/* The wanted size is increased so it can contain a BlockLink_t
		structure in addition to the requested amount of bytes. */
		if( xWantedSize > 0 )
		{
			xWantedSize += heapSTRUCT_SIZE;

			/* Ensure that blocks are always aligned to the required number of bytes. */
			if( ( xWantedSize & portBYTE_ALIGNMENT_MASK ) != 0 )
			{
				/* Byte alignment required. */
				xWantedSize += ( portBYTE_ALIGNMENT - ( xWantedSize & portBYTE_ALIGNMENT_MASK ) );
			}
		}

		if( ( xWantedSize > 0 ) && ( xWantedSize < configADJUSTED_HEAP_SIZE ) )
		{
			/* Blocks are stored in byte order - traverse the list from the start
			(smallest) block until one of adequate size is found. */
			pxPreviousBlock = &xStart;
			pxBlock = xStart.pxNextFreeBlock;
			while( ( pxBlock->xBlockSize < xWantedSize ) && ( pxBlock->pxNextFreeBlock != NULL ) )
			{
				pxPreviousBlock = pxBlock;
				pxBlock = pxBlock->pxNextFreeBlock;
			}

			/* If we found the end marker then a block of adequate size was not found. */
			if( pxBlock != &xEnd )
			{
				/* Return the memory space - jumping over the BlockLink_t structure
				at its start. */
				pvReturn = ( void * ) ( ( ( uint8_t * ) pxPreviousBlock->pxNextFreeBlock ) + heapSTRUCT_SIZE );

				/* This block is being returned for use so must be taken out of the
				list of free blocks. */
				pxPreviousBlock->pxNextFreeBlock = pxBlock->pxNextFreeBlock;

				/* If the block is larger than required it can be split into two. */
				if( ( pxBlock->xBlockSize - xWantedSize ) > heapMINIMUM_BLOCK_SIZE )
				{
					/* This block is to be split into two.  Create a new block
					following the number of bytes requested. The void cast is
					used to prevent byte alignment warnings from the compiler. */
					pxNewBlockLink = ( void * ) ( ( ( uint8_t * ) pxBlock ) + xWantedSize );

					/* Calculate the sizes of two blocks split from the single
					block. */
					pxNewBlockLink->xBlockSize = pxBlock->xBlockSize - xWantedSize;
					pxBlock->xBlockSize = xWantedSize;

					/* Insert the new block into the list of free blocks. */
					prvInsertBlockIntoFreeList( ( pxNewBlockLink ) );
				}

				xFreeBytesRemaining -= pxBlock->xBlockSize;
			}
		}

		traceMALLOC( pvReturn, xWantedSize );
	}
	( void ) xTaskResumeAll();

	#if( configUSE_MALLOC_FAILED_HOOK == 1 )
	{
		if( pvReturn == NULL )
		{
			extern void vApplicationMallocFailedHook( void );
			vApplicationMallocFailedHook();
		}
	}
	#endif

	return pvReturn;
}

具体流程就不讲了,我们主要研究的是 heap_4.c

(3)heap_4.c

中断管理的运作机制

1.异常与中断的基本概念

异常是导致处理器脱离正常运行转向执行特殊代码的任何事件,如果不及时进行处理,轻则系统出错,重则会导致系统毁灭性瘫痪。所以正确地处理异常,避免错误的发生是提高软件鲁棒性(稳定性)非常重要的一环,对于实时系统更是如此。

异常是指任何打断处理器正常执行,并且迫使处理器进入一个由有特权的特殊指令执行的事件。异常通常可以分成两类:同步异常和异步异常。由内部事件(像处理器指令运行产生的事件)引起的异常称为同步异常,例如造成被零除的算术运算引发一个异常,又如在某些处理器体系结构中,对于确定的数据尺寸必须从内存的偶数地址进行读和写操作。从一个奇数内存地址的读或写操作将引起存储器存取一个错误事件并引起一个异常(称为校准异常)。

异步异常主要是指由于外部异常源产生的异常,是一个由外部硬件装置产生的事件引起的异步异常。同步异常不同于异步异常的地方是事件的来源,同步异常事件是由于执行某些指令而从处理器内部产生的,而异步异常事件的来源是外部硬件装置。例如按下设备某个按钮产生的事件。同步异常与异步异常的区别还在于,同步异常触发后,系统必须立刻进行处理而不能够依然执行原有的程序指令步骤;而异步异常则可以延缓处理甚至是忽略,例如按键中断异常,虽然中断异常触发了,但是系统可以忽略它继续运行(同样也忽略了相应的按键事件)。

中断,中断属于异步异常。所谓中断是指中央处理器 CPU 正在处理某件事的时候,外部发生了某一事件,请求 CPU 迅速处理,CPU暂时中断当前的工作,转入处理所发生的事件,处理完后,再回到原来被中断的地方,继续原来的工作,这样的过程称为中断。 中断能打断任务的运行,无论该任务具有什么样的优先级,因此中断一般用于处理比较紧急的事件,而且只做简单处理,例如标记该事件,在使用 FreeRTOS 系统时,一般建议使用信号量、消息或事件标志组等标志中断的发生,将这些内核对象发布给处理任务,处理任务再做具体处理。

通过中断机制,在外设不需要 CPU 介入时,CPU 可以执行其他任务,而当外设需要CPU 时通过产生中断信号使 CPU 立即停止当前任务转而来响应中断请求。这样可以使CPU 避免把大量时间耗费在等待、查询外设状态的操作上,因此将大大提高系统实时性以及执行效率。

在这里要知道,FreeRTOS 源码中有许多处临界段的地方,临界段虽然保护了关键代码的执行不被打断,但也会影响系统的实时,任何使用了操作系统的中断响应都不会比裸机快。比如,某个时候有一个任务在运行中,并且该任务部分程序将中断屏蔽掉,也就是进入临界段中,这个时候如果有一个紧急的中断事件被触发,这个中断就会被挂起,不能得到及时响应,必须等到中断开启才可以得到响应,如果屏蔽中断时间超过了紧急中断能够容忍的限度,危害是可想而知的。所以,操作系统的中断在某些时候会有适当的中断延迟,因此调用中断屏蔽函数进入临界段的时候,也需快进快出。当然 FreeRTOS 也能允许一些高优先级的中断不被屏蔽掉,能够及时做出响应,不过这些中断就不受系统管理,也不允许调用 FreeRTOS 中与中断相关的任何 API 函数接口。

FreeRTOS 的中断管理支持:

 开/关中断。

 恢复中断。

 中断使能。

 中断屏蔽。

 可选择系统管理的中断优先级。

2.中断的介绍

与中断相关的硬件可以划分为三类:外设、中断控制器、CPU 本身。

外设:当外设需要请求 CPU 时,产生一个中断信号,该信号连接至中断控制器。

中断控制器:中断控制器是 CPU 众多外设中的一个,它一方面接收其他外设中断信号的输入,另一方面,它会发出中断信号给 CPU。可以通过对中断控制器编程实现对中断源的优先级、触发方式、打开和关闭源等设置操作。在 Cortex-M 系列控制器中常用的中断控制器是 NVIC(内嵌向量中断控制器 Nested Vectored Interrupt Controller)。

CPU:CPU 会响应中断源的请求,中断当前正在执行的任务,转而执行中断处理程序。NVIC 最多支持 240个中断,每个中断最多 256 个优先级。

3.和中断相关的名词解释

中断号:每个中断请求信号都会有特定的标志,使得计算机能够判断是哪个设备提出的中断请求,这个标志就是中断号。

中断请求:"紧急事件"需向 CPU 提出申请,要求 CPU 暂停当前执行的任务,转而处理该"紧急事件",这一申请过程称为中断请求。

中断优先级:为使系统能够及时响应并处理所有中断,系统根据中断时间的重要性和紧迫程度,将中断源分为若干个级别,称作中断优先级。

中断处理程序:当外设产生中断请求后,CPU 暂停当前的任务,转而响应中断申请,即执行中断处理程序。

中断触发:中断源发出并送给 CPU 控制信号,将中断触发器置"1",表明该中断源产生了中断,要求 CPU 去响应该中断,CPU 暂停当前任务,执行相应的中断处理程序。 中断触发类型:外部中断申请通过一个物理信号发送到 NVIC,可以是电平触发或边沿触发。

中断向量:中断服务程序的入口地址。

中断向量表:存储中断向量的存储区,中断向量与中断号对应,中断向量在中断向量表中按照中断号顺序存储。

临界段:代码的临界段也称为临界区,一旦这部分代码开始执行,则不允许任何中断打断。为确保临界段代码的执行不被中断,在进入临界段之前须关中断,而临界段代码执行完毕后,要立即开中断。

4.中断管理的运作机制

当中断产生时,处理机将按如下的顺序执行:

  1. 保存当前处理机状态信息
  2. 载入异常或中断处理函数到 PC寄存器
  3. 把控制权转交给处理函数并开始执行
  4. 当处理函数执行完成时,恢复处理器状态信息
  5. 从异常或中断中返回到前一个程序执行点

中断使得 CPU 可以在事件发生时才给予处理,而不必让 CPU 连续不断地查询是否有相应的事件发生。通过两条特殊指令:关中断和开中断可以让处理器不响应或响应中断,在关闭中断期间,通常处理器会把新产生的中断挂起,当中断打开时立刻进行响应,所以会有适当的延时响应中断,故用户在进入临界区的时候应快进快出。 中断发生的环境有两种情况:在任务的上下文中,在中断服务函数处理上下文中。

 任务在工作的时候,如果此时发生了一个中断,无论中断的优先级是多大,都会打断当前任务的执行,从而转到对应的中断服务函数中执行。如下图:

(1)、(3):在任务运行的时候发生了中断,那么中断会打断任务的运行,那么操作系统将先保存当前任务的上下文环境,转而去处理中断服务函数。

(2)、(4):当且仅当中断服务函数处理完的时候才恢复任务的上下文环境,继续运行任务。

在执行中断服务例程的过程中,如果有更高优先级别的中断源触发中断,由于当前处于中断处理上下文环境中,根据不同的处理器构架可能有不同的处理方式,比如新的中断等待挂起直到当前中断处理离开后再行响应;或新的高优先级中断打断当前中断处理过程,而去直接响应这个更高优先级的新中断源。后面这种情况,称之为中断嵌套。在硬实时环境中,前一种情况是不允许发生的,不能使响应中断的时间尽量的短。而在软件处理(软实时环境)上,FreeRTOS 允许中断嵌套,即在一个中断服务例程期间,处理器可以响应另外一个优先级更高的中断,如下图:

(1):当中断 1 的服务函数在处理的时候发生了中断 2,由于中断 2 的优先级比中断 1 更高,所以发生了中断嵌套,那么操作系统将先保存当前中断服务函数的上下文环境,并且转向处理中断 2,当且仅当中断 2 执行完的时候图 24-2(2),才能继续执行中断 1。

5.中断延迟的概念

即使操作系统的响应很快了,但对于中断的处理仍然存在着中断延迟响应的问题,我们称之为中断延迟(Interrupt Latency) 。

中断延迟是指从硬件中断发生到开始执行中断处理程序第一条指令之间的这段时间。也就是:系统接收到中断信号到操作系统作出响应,并完成换到转入中断服务程序的时间。也可以简单地理解为:(外部)硬件(设备)发生中断,到系统执行中断服务子程序(ISR)的第一条指令的时间。

中断的处理过程是:外界硬件发生了中断后,CPU 到中断处理器读取中断向量,并且查找中断向量表,找到对应的中断服务子程序(ISR)的首地址,然后跳转到对应的 ISR去做相应处理。这部分时间,我称之为:识别中断时间。

在允许中断嵌套的实时操作系统中,中断也是基于优先级的,允许高优先级中断抢断正在处理的低优先级中断,所以,如果当前正在处理更高优先级的中断,即使此时有低优先级的中断,也系统不会立刻响应,而是等到高优先级的中断处理完之后,才会响应。而即使在不支持中断嵌套,即中断是没有优先级的,中断是不允许被中断的,所以,如果当前系统正在处理一个中断,而此时另一个中断到来了,系统也是不会立即响应的,而只是等处理完当前的中断之后,才会处理后来的中断。此部分时间,我称其为:等待中断打开时间。

在操作系统中,很多时候我们会主动进入临界段,系统不允许当前状态被中断打断,故而在临界区发生的中断会被挂起,直到退出临界段时候打开中断。此部分时间,我称其为:关闭中断时间。

中断延迟可以定义为,从中断开始的时刻到中断服务例程开始执行的时刻之间的时间段。中断延迟 = 识别中断时间 + 等待中断打开时间 + 关闭中断时间。 注意:" "的时间是不一定都存在的,此处为最大可能的中断延迟时间。

6.中断管理的应用场景

中断在嵌入式处理器中应用非常之多,没有中断的系统不是一个好系统,因为有中断,才能启动或者停止某件事情,从而转去做另一间事情。我们可以举一个日常生活中的例子来说明,假如你正在给朋友写信,电话铃响了,这时你放下手中的笔去接电话,通话完毕再继续写信。这个例子就表现了中断及其处理的过程:电话铃声使你暂时中止当前的工作,而去处理更为急需处理的事情------接电话,当把急需处理的事情处理完毕之后,再回过头来继续原来的事情。在这个例子中,电话铃声就可以称为"中断请求",而你暂停写信去接电话就叫作"中断响应",那么接电话的过程就是"中断处理"。由此我们可以看出,在计算机执行程序的过程中,由于出现某个特殊情况(或称为"特殊事件"),使得系统暂时中止现行程序,而转去执行处理这一特殊事件的程序,处理完毕之后再回到原来程序的中断点继续向下执行。

为什么说吗没有中断的系统不是好系统呢?我们可以再举一个例子来说明中断的作用。假设有一个朋友来拜访你,但是由于不知何时到达,你只能在门口等待,于是什么事情也干不了;但如果在门口装一个门铃,你就不必在门口等待而可以在家里去做其他的工作,朋友来了按门铃通知你,这时你才中断手中的工作去开门,这就避免了不必要的等待。CPU 也是一样,如果时间都浪费在查询的事情上,那这个 CPU啥也干不了,要他何用。在嵌入式系统中合理利用中断,能更好利用 CPU 的资源。

7.中断管理讲解

ARM Cortex-M 系列内核的中断是由硬件管理的,而 FreeRTOS 是软件,它并不接管由硬件管理的相关中断(接管简单来说就是,所有的中断都由 RTOS 的软件管理,硬件来了中断时,由软件决定是否响应,可以挂起中断,延迟响应或者不响应),只支持简单的开关中断等,所以 FreeRTOS 中的中断使用其实跟裸机差不多的,需要我们自己配置中断,并且使能中断,编写中断服务函数,在中断服务函数中使用内核 IPC 通信机制,一般建议使用信号量、消息或事件标志组等标志事件的发生,将事件发布给处理任务,等退出中断后再由相关处理任务具体处理中断。

用 户 可 以 自 定 义 配 置 系 统 可 管 理 的 最 高 中 断 优 先 级 的 宏 定 义configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY , 它 是 用 于 配 置 内 核 中 的basepri 寄存器的,当 basepri 设置为某个值的时候,NVIC 不会响应比该优先级低的中断,而优先级比之更高的中断则不受影响。

就是说当这个宏定义配置为 5 的时候,中断优先级数值在 0、1、2、3、4 的这些中断是不受 FreeRTOS 屏蔽的,也就是说即使在系统进入临界段的时候,这些中断也能被触发而不是等到退出临界段的时候才被触发,当然,这些中断服务函数中也不能调用 FreeRTOS 提供的 API 函数接口,而中断优先级在 5 到 15 的这些中断是可以被屏蔽的,也能安全调用 FreeRTOS 提供的 API 函数接口。(我们在任务上下文开启中断屏蔽就是为了在操作内核的时候防止中断中会调用API函数也操作内核,所以如果该中断不受屏蔽,自然不能调用API函数)

ARM Cortex-M NVIC 支持中断嵌套功能:当一个中断触发并且系统进行响应时,处理器硬件会将当前运行的部分上下文寄存器自动压入中断栈中,这部分的寄存器包括 PSR,R0,R1,R2,R3 以及 R12 寄存器。当系统正在服务一个中断时,如果有一个更高优先级的中断触发,那么处理器同样的会打断当前运行的中断服务例程,然后把老的中断服务例程上下文的 PSR,R0,R1,R2,R3 和 R12 寄存器自动保存到中断栈中。这些部分上下文寄存器保存到中断栈的行为完全是硬件行为,这一点是与其他 ARM 处理器最大的区别(以往都需要依赖于软件保存上下文)。

另外,在 ARM Cortex-M 系列处理器上,所有中断都采用中断向量表的方式进行处理,即当一个中断触发时,处理器将直接判定是哪个中断源,然后直接跳转到相应的固定位置进行处理。而在 ARM7、ARM9 中,一般是先跳转进入 IRQ 入口,然后再由软件进行判断是哪个中断源触发,获得了相对应的中断服务例程入口地址后,再进行后续的中断处理。ARM7、ARM9 的好处在于,所有中断它们都有统一的入口地址,便于 OS 的统一管理。而ARM Cortex-M 系列处理器则恰恰相反,每个中断服务例程必须排列在一起放在统一的地址上(这个地址必须要设置到 NVIC 的中断向量偏移寄存器中)。中断向量表一般由一个数组定义(或在起始代码中给出),在 STM32 上,默认采用起始代码给出,代码如下:

c 复制代码
__Vectors DCD __initial_sp ; Top of Stack 
DCD Reset_Handler ; Reset Handler 
DCD NMI_Handler ; NMI Handler 
DCD HardFault_Handler ; Hard Fault Handler 
DCD MemManage_Handler ; MPU Fault Handler 
DCD BusFault_Handler ; Bus Fault Handler 
DCD UsageFault_Handler ; Usage Fault Handler 
DCD 0 ; Reserved 
DCD 0 ; Reserved 
DCD 0 ; Reserved 
DCD 0 ; Reserved 
DCD SVC_Handler ; SVCall Handler 
DCD DebugMon_Handler ; Debug Monitor Handler 
DCD 0 ; Reserved 
DCD PendSV_Handler ; PendSV Handler 
DCD SysTick_Handler ; SysTick Handler

; External Interrupts
DCD WWDG_IRQHandler ; Window Watchdog
DCD PVD_IRQHandler ; PVD through EXTI Line detect
DCD TAMPER_IRQHandler ; Tamper
DCD RTC_IRQHandler ; RTC
DCD FLASH_IRQHandler ; Flash
DCD RCC_IRQHandler ; RCC
DCD EXTI0_IRQHandler ; EXTI Line 0
DCD EXTI1_IRQHandler ; EXTI Line 1
DCD EXTI2_IRQHandler ; EXTI Line 2
DCD EXTI3_IRQHandler ; EXTI Line 3
DCD EXTI4_IRQHandler ; EXTI Line 4
DCD DMA1_Channel1_IRQHandler ; DMA1 Channel 1
DCD DMA1_Channel2_IRQHandler ; DMA1 Channel 2
DCD DMA1_Channel3_IRQHandler ; DMA1 Channel 3
DCD DMA1_Channel4_IRQHandler ; DMA1 Channel 4
DCD DMA1_Channel5_IRQHandler ; DMA1 Channel 5
DCD DMA1_Channel6_IRQHandler ; DMA1 Channel 6
DCD DMA1_Channel7_IRQHandler ; DMA1 Channel 7

.........

FreeRTOS 在 Cortex-M 系列处理器上也遵循与裸机中断一致的方法,当用户需要使用自定义的中断服务例程时,只需要定义相同名称的函数覆盖弱化符号即可。所以, FreeRTOS 在 Cortex-M 系列处理器的中断控制其实与裸机没什么差别。

CPU / 中断 / 任务切换 主线总结(重点)

(1) 一句话主线

中断响应:NVIC 选中断 → 向量表取入口 → 硬件压 8 字异常帧到当前栈 → Handler 用 MSP 执行 ISR → bx lr 按 EXC_RETURN 返回。

任务切换:PendSV 把当前任务的 R4~R11 + EXC_RETURN 压入任务栈,把新栈顶存入 TCB → 选下一个任务 → 从它的 TCB 取栈顶、恢复 R4~R11 + EXC_RETURN → 硬件弹异常帧,任务继续跑。

细节顺序:

text 复制代码
asm
; 保存当前任务
mrs r0, psp                    ; r0 = PSP(指向硬件异常帧)
stmdb r0!, {r4-r11, r14}       ; 压 R4~R11 + EXC_RETURN;r0 减 36
str r0, [TCB]                  ; TCB->pxTopOfStack = r0(新栈顶)

; 恢复下一个任务
ldr r0, [nextTCB]              ; r0 = 保存的栈顶
ldmia r0!, {r4-r11, r14}       ; 恢复 R4~R11 + EXC_RETURN;r0 前进到硬件帧
msr psp, r0                    ; PSP = 硬件异常帧地址
bx r14                         ; 用 EXC_RETURN 返回
  • stmdb/ldmia 更新的是 r0,不是 PSP;
  • PSP 只在 msr psp, r0 时变;
  • ldmia 必须先把 r0 推进到硬件帧,再 msr psp;顺序反了,硬件会弹错栈。

MSP/PSP 是寄存器(栈指针),主栈/任务栈是内存;EXC_RETURN 决定回哪个模式、用哪个栈。(可以理解为MSP/PSP是两套独立的寄存器,而R13是分组寄存器)

(2)两个模式、两条栈

模式 谁在跑 R13 用哪个栈
Thread 任务 PSP(FreeRTOS 任务启动后)
Handler ISR、PendSV、SysTick、SVC MSP(架构规定)
  • MSP:主栈指针,Handler 模式使用;启动文件里的 Stack_Size 决定大小。
  • PSP:任务栈指针,任务在 Thread 模式使用;每个任务有自己的栈。
  • MSP/PSP 是寄存器;主栈/任务栈是它们指向的内存。
  • FreeRTOS 任务启动前(复位/启动代码)也用 MSP;第一次任务启动后切到 PSP。
  • 异常入口压帧时,硬件用的是被打断上下文所用的栈:任务 → PSP;Handler → MSP。

(3)寄存器与异常帧

寄存器 角色 谁保存
R0~R3 前 4 个函数参数、返回值、caller-saved 临时 硬件(异常帧)
R4~R11 callee-saved:跨函数调用保留的局部变量/长期值 PendSV(任务切换时)
R12 IP / 过程间调用临时(caller-saved) 硬件(异常帧)
R13 SP 栈指针,MSP/PSP banked TCB 保存 PSP 值
R14 LR 函数返回地址;异常入口后变成 EXC_RETURN 硬件(任务 LR);PendSV 保存 EXC_RETURN
R15 PC 下一条指令地址 硬件(异常帧)
xPSR N/Z/C/V、Q、GE、Thumb 位、IPSR 硬件(异常帧)
S0~S31/FPSCR 浮点上下文(如使用 FPU) 硬件/port(视配置)

硬件异常帧(8 字,低 → 高):

R0、R1、R2、R3、R12、LR、PC、xPSR

PendSV 额外保存(9 字,低 → 高):

R4、R5、R6、R7、R8、R9、R10、R11、EXC_RETURN

所以基本任务上下文是 17 字 = 68 字节(不含 FPU):

  • 硬件 8 字;
  • 软件 9 字(R4~R11 + EXC_RETURN)。

两个 LR 要分清:

  • 硬件帧里的 LR:任务被打断时的返回地址(任务恢复后要用);
  • PendSV 软件帧里的 r14:EXC_RETURN(异常返回控制字)。
  • R14 只有一个,这是它在两个不同时刻的两个快照,必须存在两个不同槽位(也就是说R14两个时刻压入栈的值是保存到栈不同的地址)如下:
text 复制代码
地址        内容
P-36       R4          ← TCB->pxTopOfStack 指向这里
P-32       R5
P-28       R6
P-24       R7
P-20       R8
P-16       R9
P-12       R10
P-8        R11
P-4        EXC_RETURN  ← PendSV 保存的 R14(值 B)
P+0        R0          ← 硬件异常帧起点(PSP)
P+4        R1
P+8        R2
P+12       R3
P+16       R12
P+20       LR          ← 硬件保存的任务返回地址(值 A)
P+24       PC
P+28       xPSR

为什么 EXC_RETURN 要存到任务栈?

它是 CPU 寄存器里的值,但任务切换不是马上返回同一个人:

  • PendSV 要切到另一个任务,当前 LR 很快会被别的代码覆盖;
  • 以后这个任务再被恢复时,需要用它自己的 EXC_RETURN 来 bx lr;
  • 所以 PendSV 把 LR(EXC_RETURN)和 R4~R11 一起存进任务栈,作为该任务上下文的一部分。

就像 R4~R11 平时也在寄存器里,切换时也要存到栈------EXC_RETURN 同理。寄存器是"当前现场",栈是"持久存档"。

带 FPU 时还有个实际原因:EXC_RETURN 的 bit4 表示"这个任务是否用了 FPU",port 用 tst r14, #0x10 决定要不要保存 S16~S31。所以它不只是返回模式,还带上下文信息。

(4) CPU 如何知道中断入口:向量表

下标/异常号 内容
0 初始 MSP(__initial_sp)
1 Reset
2 NMI
3 HardFault
4 MemManage
5 BusFault
6 UsageFault
11 SVCall
12 DebugMonitor
14 PendSV
15 SysTick
16 IRQ0
17... IRQ1...
  • 向量表基址由 SCB->VTOR 决定;
  • 入口地址 = VTOR + 4 × 异常号;
  • 外部中断 IRQn 的异常号 = 16 + IRQn;
  • 关于 VTOR :VTOR 是向量表偏移寄存器,也是内核里系统控制块(SCB)的核心寄存器,作用是告诉 CPU"中断向量表现在放在内存的哪个起始地址"。内核上电时,硬件会自动把 VTOR 置为 0;STM32 依靠 BOOT 引脚的存储器映射机制,把 0x0000 0000 映射到 Flash 起始地址 0x0800 0000(M4 的向量表在 Flash 中默认起始地址就是 0x0800 0000),所以 CPU 从 VTOR 取向量表时,实际访问的就是 Flash 起始地址 0x0800 0000。
  • STM32F4 通常把向量表放在 Flash 起始 0x08000000,启动时映射到 0x00000000;Bootloader 跳转 App 时,可以把 VTOR 改到 App 的向量表。例如,假设 App 起始地址设为 0x0810 0000,就需要设置 SCB->VTOR = 0x0800 0000 | 0x0010 0000。
  • NVIC 选中当前最高优先级的 pending 中断后,CPU 按异常号查向量表,把入口地址装进 PC,同时把 LR 设为 EXC_RETURN,并压异常帧。

CPU 不是"知道变量地址",而是按异常号查向量表,拿到 ISR 入口。

(5)异常进入与 EXC_RETURN

异常进入时硬件做三件事:

  1. 把 R0~R3、R12、LR、PC、xPSR 压到被打断上下文所用的栈:
  • 任务是 Thread + PSP → 压到任务栈;
  • Handler 是 MSP → 压到主栈;
  1. 把 LR 设为 EXC_RETURN;
  2. 切换模式/栈,跳转到向量表里的 ISR 入口。

EXC_RETURN 常见值:

EXC_RETURN 值 含义
0xFFFFFFF1 返回 Handler 模式,使用 MSP
0xFFFFFFF9 返回 Thread 模式,使用 MSP
0xFFFFFFFD 返回 Thread 模式,使用 PSP
  • bit 2(0x4)为 1 表示用 PSP;
  • 带 FPU 时还有扩展值(如 0xFFFFFFED),表示需要恢复浮点上下文;
  • ISR 末尾 bx lr,硬件按 EXC_RETURN 选栈、弹帧、返回;
  • 任务自己的 LR(返回地址)在异常帧里;Handler 的 LR 是 EXC_RETURN,两者不要混。

(异常帧 = 硬件在栈上拍的"现场快照"(R0~R3、R12、LR、PC、xPSR);帧里的 LR 是任务的返回地址,Handler 手里的 LR 是 EXC_RETURN;返回时硬件靠 EXC_RETURN 选栈/模式,再把快照弹回去)

(6)MSP/PSP 的切换

  • 任务运行:R13 = PSP,指向任务栈顶;
  • 异常进入:硬件用当前 SP(R13、PSP)压 8 字帧;Handler 里 R13 = MSP;
  • Handler 自己的局部变量、嵌套 ISR 都在 MSP;
  • PendSV 要用 PSP 时,通过 MRS/MSR:
text 复制代码
asm
; ---------- 保存当前任务 ----------
mrs r0, psp
; r0 = PSP;此时 PSP 指向硬件异常帧。
; 如果是尾链,这个帧由最初的 IRQ 压好,PendSV 不会重复压。

stmdb r0!, {r4-r11, r14}
; 以 r0 为基址,把 R4~R11 和 EXC_RETURN 压到任务栈。
; r0 减 36(9 字);PSP 本身不变。

str r0, [TCB]
; 把递减后的 r0(新栈顶,指向 R4)存入 TCB->pxTopOfStack。
; 这是任务恢复时要装回 PSP 的值,不是当前 PSP。

; ---------- 恢复下一个任务 ----------
ldr r0, [nextTCB]
; r0 = 下一个任务的 pxTopOfStack(指向它的 R4)

ldmia r0!, {r4-r11, r14}
; 恢复 R4~R11 + EXC_RETURN;r0 增加 36,前进到硬件帧

msr psp, r0
; PSP = 硬件异常帧地址

bx r14
; 用 EXC_RETURN 返回

关键:

  • PSP 是寄存器,任务栈是内存;切换时换的是 PSP 的值,不是搬栈内容。
  • stmdb/ldmia 更新的是 r0,不是 PSP;PSP 只在 msr psp 时变。
  • ldmia 必须先把 r0 推到硬件帧,再 msr psp;顺序反了,硬件会把 R4~R11 当成异常帧弹。
  • 任务栈里两份 LR:硬件帧的 LR = 任务返回地址;软件帧的 r14 = EXC_RETURN。

(7)从任务运行到 PendSV:中断响应 + 置位

场景:任务正在跑,外设中断或 SysTick 到来,ISR 需要请求一次任务切换。

1. 任务运行

  • 状态:Thread / PSP,任务在跑自己的代码。

2. 中断到来

  • 外设中断 / SysTick 到来,请求一次任务切换。

3. NVIC 选中断

  • NVIC 选出当前最高优先级的 pending 中断。

4. CPU 查向量表

  • CPU 按异常号查向量表,取出 ISR 入口。

5. 硬件自动保存现场

  1. 把 R0~R3、R12、LR、PC、xPSR 压到任务 PSP 栈(PSP -= 32);
  2. LR = EXC_RETURN(如 0xFFFFFFFD);
  3. 进入 Handler 模式,R13 = MSP。

6. ISR 执行(MSP)

  • 处理外设:清标志、读数据、xQueueSendFromISR / vTaskNotifyGiveFromISR ...;
  • FromISR API 发现唤醒了更高优先级任务:
    • *pxHigherPriorityTaskWoken = pdTRUE
    • (SysTick 则是 xTaskIncrementTick() 返回 pdTRUE)
  • 末尾 portYIELD_FROM_ISR(x):
    • x 为真 → 写 ICSR.PENDSVSET = 1
    • 只挂起 PendSV,不立即切换。

7. 汇编细节:ISR 返回与 PendSV

A. 任务运行 → 中断入口

任务运行:

R4 = 100, R5 = 200, ...(任务的局部变量)

R13 = PSP,指向任务栈顶

中断到来,硬件自动:

text 复制代码
asm
; 硬件行为(不是指令,是异常入口机制)
; 1. 把 R0~R3, R12, LR, PC, xPSR 压到 PSP 栈
; 2. LR = EXC_RETURN(如 0xFFFFFFFD)
; 3. 进入 Handler 模式,R13 = MSP

此时:

  • 硬件异常帧在 PSP 上;
  • R4~R11 没有被硬件保存,仍然是任务的 100、200......;
  • R13 已经变成 MSP。

B. ISR prologue:临时保存 R4~R11 到 MSP(主栈)

C 编译器生成的 ISR 开头(示意,实际只压用到的寄存器):

text 复制代码
asm
push {r4-r11, lr}
; 把任务的 R4~R11 和 EXC_RETURN 保存到 MSP(主栈)
; 注意:保存到 MSP,不是 PSP

为什么要压:

  • ISR 是 C 函数,按 AAPCS 要用 R4~R11 就必须先保存调用者的值;
  • 这里"调用者"就是被打断的任务;
  • 保存到 MSP,因为 Handler 模式 R13 = MSP。

C. ISR 执行 + portYIELD_FROM_ISR

ISR 里正常处理外设;需要切换时:

text 复制代码
BaseType_t woken = pdFALSE;
xQueueSendFromISR(..., &woken);
portYIELD_FROM_ISR(woken);

Cortex-M 上大致是:

text 复制代码
asm
cmp  r0, #0
beq  done
ldr  r1, =0xE000ED04      ; SCB->ICSR
ldr  r2, =0x10000000      ; PENDSVSET
str  r2, [r1]             ; 只置 PendSV pending 位
dsb
isb
done:

这里不切换任务,只是挂起 PendSV。

D. ISR epilogue:恢复 R4~R11,再 bx lr

asm
pop {r4-r11, lr}
; R4~R11 = 任务原来的 100、200......
; LR = EXC_RETURN
bx lr
; 触发异常返回

这里是最关键的一步:ISR 自己用的 999 被丢弃,R4~R11 恢复成任务的原值。

E. 两种返回路径

没有 PendSV:

text 复制代码
asm
bx lr

硬件按 EXC_RETURN:

  • 选 Thread 模式、PSP 栈;
  • 从 PSP 弹 8 字异常帧(R0~R3、R12、LR、PC、xPSR);
  • 任务继续跑,R4~R11 就是刚才恢复的原值。

没有任务切换,只有一次中断进出。

有 PendSV

bx lr 时 PendSV pending 位已置位,CPU 可能尾链到 PendSV:

  • 不弹任务异常帧;
  • 硬件异常帧继续留在 PSP 上;
  • R4~R11 此时已经是任务的原值(ISR epilogue 刚恢复的)。

F. PendSV:保存任务上下文

text 复制代码
asm
mrs r0, psp
; r0 = PSP,指向任务栈上的硬件异常帧

stmdb r0!, {r4-r11, r14}
; 把任务的原值 R4~R11 + EXC_RETURN 压到任务栈
; r0 减 36;PSP 本身不变

str r0, [TCB]
; TCB->pxTopOfStack = 新栈顶(指向 R4)

注意:这里存进任务栈的 R4~R11,是 ISR epilogue 恢复后的任务原值,不是 ISR 执行时的临时值。

G. PendSV:选下一个任务并恢复

text 复制代码
asm
vTaskSwitchContext()          ; 选最高优先级就绪任务

ldr r0, [nextTCB]
; r0 = 下一个任务的 pxTopOfStack

ldmia r0!, {r4-r11, r14}
; 恢复下一个任务的 R4~R11 + EXC_RETURN
; r0 前进到它的硬件异常帧

msr psp, r0
; PSP = 下一个任务的硬件帧地址

bx r14
; 按 EXC_RETURN 返回 Thread/PSP

硬件随后从新 PSP 弹 8 字异常帧,下一个任务开始跑。

H. R4~R11 的完整轨迹

阶段 R4 的值 说明
任务运行 100 任务自己的局部变量
中断入口 100 硬件不保存 R4~R11
ISR prologue 100 → MSP 任务值暂存到 MSP,R4 仍是 100
ISR 执行 999 ISR 自己用
ISR epilogue 100 从 MSP 恢复任务原值
没有 PendSV 100 任务继续跑
有 PendSV 100 PendSV 把 100 压到任务 PSP
下一个任务 自己的值 从它的 PSP 恢复

8. 恢复下一个任务

  • 硬件从新 PSP 弹 8 字异常帧;
  • 下一个任务运行(Thread / PSP)。

各阶段用哪个栈

阶段 模式 / R13 栈上发生什么
任务运行 Thread / PSP 任务自己的调用栈
中断入口 Handler / MSP 硬件把 8 字异常帧压到 PSP(任务栈)
ISR 执行 Handler / MSP ISR 的局部变量、保存的 R4~R11 在 MSP(主栈)
ISR 置位 Handler / MSP 只写 ICSR.PENDSVSET,不切栈、不切换
PendSV Handler / MSP 在 PSP 上再压 R4~R11 + EXC_RETURN
下一个任务 Thread / PSP 硬件从新 PSP 弹 8 字异常帧

几个关键点

  1. portYIELD_FROM_ISR() 只是"挂起 PendSV"
  • 它不会在 ISR 里立即切换;
  • 真正的切换在 ISR 退出后,由最低优先级的 PendSV 执行;
  • 多个中断都置位,PendSV pending 位只有一位,最终只切一次。
  1. ISR 自己的现场在 MSP,任务现场在 PSP
  • 硬件异常帧压在任务栈(PSP);
  • ISR 的局部变量、嵌套调用在 MSP;
  • ISR 的 prologue/epilogue 在 MSP 上临时保存/恢复 R4~R11,所以 PendSV 运行时,R4~R11 已经是任务的原值。
  1. 没有 PendSV 就不切换
  • 只是普通中断:硬件压帧 → ISR → 硬件弹帧 → 回原任务;
  • 只有 portYIELD_FROM_ISR(x) 的 x 为真(或 SysTick 需要切换)时,才置 PendSV。
  1. PendSV 是最低优先级
  • 如果还有嵌套 ISR,PendSV 会等它们全部退出;
  • 所以它拿到的任务上下文是完整、稳定的,不会在 ISR 中间切换。
  1. PendSV 里的顺序
  • mrs r0, psp → stmdb 保存 → str 存 TCB;
  • ldr 取下一个任务栈顶 → ldmia 恢复到硬件帧 → msr psp → bx r14;
  • stmdb/ldmia 动的是 r0,PSP 只在 msr psp 时变。
  1. 任务切换完整流程(PendSV)

触发源:

  • SysTick:xTaskIncrementTick() 发现需要切换(时间片/延时任务到期)→ 返回 pdTRUE → SysTick 处理函数置 PendSV;

  • ISR:portYIELD_FROM_ISR(x),当 x 为真时置 PendSV;

  • 任务主动让出:taskYIELD() 等。

    执行:

    PendSV(FreeRTOS 配成最低优先级,等所有活动异常退出后运行)

    ├─ 硬件异常帧已在当前任务 PSP 上

    │ (尾链时沿用最初 IRQ 压的帧;从 Thread 直接进 PendSV 时由硬件新压)

    ├─ mrs r0, psp

    ├─ stmdb r0!, {r4-r11, r14} ; 压 R4~R11 + EXC_RETURN 到任务栈

    ├─ str r0, TCB ; 保存新栈顶到 pxTopOfStack

    ├─ vTaskSwitchContext() ; 选最高优先级就绪任务

    ├─ ldr r0, nextTCB ; 取下一个任务的 pxTopOfStack

    ├─ ldmia r0!, {r4-r11, r14} ; 恢复 R4~R11 + EXC_RETURN;r0 到硬件帧

    ├─ msr psp, r0 ; PSP = 硬件帧地址

    └─ bx r14 ; EXC_RETURN 返回

    ↓

    硬件从新 PSP 弹 8 字异常帧

    ↓

    下一个任务(Thread/PSP)继续跑

  • pxTopOfStack 是 TCB 里的字段,不是 R13;

  • PendSV 是最低优先级异常,所以不会在 ISR 中间切换;

  • PendSV pending 位只有一位,多个请求会合并成一次;

  • 任务栈和 TCB 通常从 FreeRTOS 堆里分配;PSP 指向任务栈的某一段;

  • 第一次任务启动也类似:SVC 恢复 pxPortInitialiseStack 伪造的帧,msr psp 后 bx lr。

  1. 易错点速查
易错点 正确说法
"PSP 被 stmdb 更新" stmdb r0! 更新的是 r0;PSP 只在 msr psp 时变
"先 msr psp 再 ldmia" 必须先 ldmia 把 r0 推到硬件帧,再 msr psp
"任务返回地址和 EXC_RETURN 同一个槽" 两个不同槽:硬件帧 LR、软件帧 r14
"EXC_RETURN 不会被覆盖" R14 会被覆盖;覆盖前硬件已把任务 LR 存入异常帧
"pxTopOfStack 是 R13" 是 TCB 字段,保存任务被切走时的栈顶值
"PendSV 立即切换" 只置 pending 位,等 ISR 退出后执行
"中断响应就是任务切换" 中断响应保存/恢复被打断任务;PendSV 才换任务

(8)第一次任务启动:SVC + 假帧

  • pxPortInitialiseStack() 在任务栈上伪造一个异常帧:
    xPSR、PC=任务入口、LR=退出处理、R0=参数、R12、R4~R11、EXC_RETURN
  • vTaskStartScheduler() 创建 Idle/Timer 任务后,prvStartFirstTask():
  • 从向量表取初始 MSP,设置 MSP;
  • svc 0;
  • SVC Handler:
  • 取出 pxCurrentTCB->pxTopOfStack;
  • 恢复 R4~R11 和 EXC_RETURN;
  • msr psp, r0;
  • bx lr;
  • 硬件从新 PSP 弹出假帧,任务从入口开始跑。

所以"任务第一次运行"也是靠一次伪造的异常返回实现的。

总结:MSP/PSP 不只是"任务/中断之间压栈弹栈"用的。 它们是当前栈指针:函数调用、局部变量、保存寄存器、异常帧、上下文切换,全都走它们。RTOS 只是把分工固定成"任务用 PSP、中断用 MSP"(也就是在任务上下文R13=PSP,在中断R13=MSP)

(9)CPU是如何处理变量的

1.变量不是"从任务栈取出来运行"。 CPU 只对寄存器做运算;变量要么被编译器分配在寄存器里,要么在内存里(栈/全局/堆)。需要时用 load/store 指令把内存值搬到寄存器,算完再存回去。

2.Flash 里的指令确实包含"怎么找变量"的信息,但分三种情况。

三种变量的访问方式

局部变量:sp, #偏移

text 复制代码
 int f(int a)
 {
     int x = a + 1;
     return x * 2;
 }

如果 x 被编译器放到栈上,反汇编大概是:

text 复制代码
asm
str r0, [sp, #4]   ; a 存到 SP+4
ldr r3, [sp, #4]   ; 取 a
adds r3, #1
str r3, [sp, #0]   ; x 存到 SP+0
ldr r3, [sp, #0]   ; 取 x
lsls r3, r3, #1

关键在 sp, #4:

  • 指令里编码的是偏移量 4;
  • 运行时地址 = 当前 SP + 4;
  • 所以同一个函数在不同任务里跑,SP 不同,变量的实际地址也不同;
  • 编译器知道每个局部变量在栈帧里的偏移,指令里写死偏移即可。

如果开了优化,x 可能一直待在寄存器里,根本不进栈------这也是为什么"看 C 代码猜汇编"经常猜不准。

全局/静态变量:链接器固定地址

text 复制代码
asm
ldr r3, =xNextFreeByte   ; 从文字池取地址
ldr r2, [r3]             ; 取值

xNextFreeByte 的地址在链接时就定了,存在 Flash 的文字池里;CPU 把地址装进寄存器,再 ldr 取值。和 PSP 无关。

堆变量:运行时指针

text 复制代码
int *p = pvPortMalloc(80);
*p = 5;

p 里存的是 pvPortMalloc 返回的地址(可能在寄存器或栈上),*p = 5 变成:

text 复制代码
asm
str r1, [r0]   ; r0 = p 指向的堆地址

地址是运行时才有的,指令用的是"指针寄存器 + 偏移"的寻址方式。

所以,CPU 到底怎么"运行变量"?

一句话:

CPU 不运行变量,它运行指令。指令从 Flash 取出来,操作数要么在寄存器里,要么由"基址寄存器 + 偏移"算出来。变量只是编译器给内存/寄存器起的名字。

  • 局部变量:基址是 SP(PSP/MSP),偏移在指令里;
  • 全局变量:基址是链接器给的固定地址,地址在文字池里;
  • 堆变量:基址是运行时指针,存在寄存器/内存里。

(10)中断管理要点

概念 要点
NVIC 优先级分组 你的工程 NVIC_PriorityGroup_4:4 位抢占优先级,0 位子优先级;数值越小越紧急
FreeRTOS 最高系统调用优先级 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY(如 5);数值 ≥ 5 的中断才能调 ...FromISR
高于系统调用优先级的中断 数值 0~4 的中断不能调用 FreeRTOS API;否则 portASSERT_IF_INTERRUPT_PRIORITY_INVALID 会报
BASEPRI taskENTER_CRITICAL() 把 BASEPRI 抬到 configMAX_SYSCALL_INTERRUPT_PRIORITY,屏蔽数值 ≥ 它的中断;更高优先级(数值更小)仍可运行
PRIMASK 屏蔽除 NMI/HardFault 外的所有中断;FreeRTOS 临界段一般用 BASEPRI,不用 PRIMASK
vTaskSuspendAll 只阻止任务切换,不关中断;ISR 仍可运行
中断嵌套 高优先级(数值小)ISR 可打断低优先级 ISR;Handler 都用 MSP
尾链/晚到 连续异常可以尾链,减少返回开销;晚到的更高优先级中断可以插队
portYIELD_FROM_ISR 只置 PendSV,不立即切换;PendSV 等 ISR 退出后执行

中断响应 ≠ 任务切换:中断进入只是保存/恢复被打断的任务;只有请求了 PendSV,才会切到另一个任务。

(11)CPU 使用率统计

  • 需要:
  • configGENERATE_RUN_TIME_STATS = 1
  • configUSE_STATS_FORMATTING_FUNCTIONS = 1
  • portCONFIGURE_TIMER_FOR_RUN_TIME_STATS():配置一个高频定时器
  • portGET_RUN_TIME_COUNTER_VALUE():返回计数器值
  • 原理:
  • 任务切换时记录时间差,累加到任务的运行时间;
  • vTaskGetRunTimeStats() 打印每个任务的运行时间/百分比;
  • 计数器频率应远高于 tick(例如 10~100 倍),否则分辨率不够;
  • 注意:
  • 统计本身有开销;
  • vTaskGetRunTimeStats() 内部用 sprintf,输出最好走打印任务/互斥,避免和别的 printf 交错;
  • vTaskList() 需要 configUSE_TRACE_FACILITY = 1。

(12) 易错点 / 面试自测

  1. MSP/PSP 是寄存器还是内存?(寄存器;主栈/任务栈才是内存)
  2. pxTopOfStack 在哪?(TCB 字段,保存切换时的 PSP 值)
  3. 硬件异常帧保存哪些寄存器?为什么没有 R4~R11?
  4. R4~R11 为什么存两次?(MSP 上临时保存给 ISR 用;PSP 上持久保存给任务切换用;push 是拷贝不是转移)
  5. EXC_RETURN 是什么?bx lr 怎么用它?
  6. CPU 怎么找到 ISR 入口?(VTOR + 4 × 异常号,查向量表)
  7. 为什么 PendSV 要最低优先级?(等所有 ISR 退出,安全统一地切换)
  8. 为什么不能在 ISR 里直接切任务?(嵌套异常/EXC_RETURN/MSP/PSP 会乱)
  9. portYIELD_FROM_ISR 在中途置位可以吗?(硬件上可以,反正要等 ISR 退出;关键是条件性,末尾统一是软件约定)
  10. FromISR API 的中断优先级要求?(数值 ≥ configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY)
  11. BASEPRI 和 PRIMASK 的区别?
  12. 第一次任务怎么启动?(SVC + pxPortInitialiseStack 伪造帧)
  13. 中断响应和任务切换的区别?(前者保存/恢复被打断任务;后者由 PendSV 换任务)
  14. 静态/全局/局部/堆变量分别怎么访问?(固定地址 / SP+偏移 / 运行时指针)

① 复位启动

  • 硬件从向量表取初始值:
    • MSP = 向量表[0](__initial_sp)
    • PC = Reset_Handler
  • 进入 main:外设初始化 → 创建任务/队列/定时器 → vTaskStartScheduler

② 启动第一个任务:SVC

  • prvStartFirstTask:设置 MSP = 向量表[0],触发 svc 0
  • SVC 处理:
    • 恢复第一个任务的 R4~R11 + EXC_RETURN
    • PSP = 任务栈
    • bx lr 异常返回
  • 硬件从 PSP 弹出伪造异常帧,第一个任务开始运行(Thread / PSP)

③ 任务运行中,中断到来(SysTick / 外设 IRQ)

  • NVIC 按优先级选中当前中断
  • CPU 按 VTOR + 4 × 异常号 从向量表取 ISR 入口
  • 硬件把 8 字异常帧压入当前任务的 PSP(任务栈)
  • 进入 Handler 模式,用 MSP 执行 ISR
  • 需要切换时:portYIELD_FROM_ISR → 置 PendSV pending

④ ISR 返回,PendSV 执行任务切换

  • ISR 退出后尾链到最低优先级 PendSV:
    • 把当前任务的 R4~R11 压入它的 PSP;新栈顶存入 TCB
    • 选中下一个任务;将 TCB->pxTopOfStack 写入 PSP
    • 恢复 R4~R11,bx lr
    • 硬件从新 PSP 弹出 8 字异常帧
  • 下一个任务(Thread / PSP)继续运行

CPU 使用率统计

(1)CPU 利用率的基本概念

CPU 使用率其实就是系统运行的程序占用的 CPU 资源,表示机器在某段时间程序运行的情况,如果这段时间中,程序一直在占用 CPU 的使用权,那么可以人为 CPU 的利用率是 100%。CPU 的利用率越高,说明机器在这个时间上运行了很多程序,反之较少。利用率的高低与 CPU 强弱有直接关系,就像一段一模一样的程序,如果使用运算速度很慢的CPU,它可能要运行 1000ms,而使用很运算速度很快的 CPU 可能只需要 10ms,那么在1000ms 这段时间中,前者的 CPU 利用率就是 100%,而后者的 CPU 利用率只有 1%,因为1000ms 内前者都在使用 CPU 做运算,而后者只使用 10ms 的时间做运算,剩下的时间CPU 可以做其他事情。

FreeRTOS 是多任务操作系统,对 CPU 都是分时使用的:比如 A 任务占用 10ms,然后 B 任务占用 30ms,然后空闲 60ms,再又是 A任务占 10ms,B 任务占 30ms,空闲 60ms;如果在一段时间内都是如此,那么这段时间内的利用率为 40%,因为整个系统中只有 40%的时间是 CPU 处理数据的时间。

(2)CPU 利用率的作用

一个系统设计的好坏,可以使用 CPU 使用率来衡量,一个好的系统必然是能完美响应急需的处理,并且系统的资源不会过于浪费(性价比高)。举个例子,假设一个系统的CPU 利用率经常在 90%~100%徘徊,那么系统就很少有空闲的时候,这时候突然有一些事情急需 CPU 的处理,但是此时 CPU 都很可能被其他任务在占用了,那么这个紧急事件就有可能无法被相应,即使能被相应,那么占用 CPU 的任务又处于等待状态,这种系统就是不够完美的,因为资源处理得太过于紧迫;反过来,假如 CPU 的利用率在 1%以下,那么我们就可以认为这种产品的资源过于浪费,搞一个那么好的 CPU 去干着没啥意义的活(大部分时间处于空闲状态),使用,作为产品的设计,既不能让资源过于浪费,也不能让资源过于紧迫,这种设计才是完美的,在需要的时候能及时处理完突发事件,而且资源也不会过剩,性价比更高。

(3)CPU 利用率统计

FreeRTOS 是一个很完善很稳定的操作系统,当然也给我们提供测量各个任务占用CPU 时间的函数接口,我们可以知道系统中的每个任务占用 CPU 的时间,从而得知系统设计的是否合理,出于性能方面的考虑,有的时候,我们希望知道 CPU 的使用率为多少,进而判断此 CPU 的负载情况和对于当前运行环境是否能够"胜任工作"。所以,在调试的时候很有必要得到当前系统的 CPU 利用率相关信息,但是在产品发布的时候,就可以把CPU 利用率统计这个功能去掉,因为使用任何功能的时候,都是需要消耗系统资源的,FreeRTOS 是使用一个外部的变量进行统计时间的,并且消耗一个高精度的定时器,其用于定时的精度是系统时钟节拍的 10-20 倍,比如当前系统时钟节拍是 1000HZ,那么定时器的计数节拍就要是 10000-20000HZ。而且 FreeRTOS 进行 CPU 利用率统计的时候,也有一定缺陷,因为它没有对进行 CPU 利用率统计时间的变量做溢出保护,我们使用的是 32 位变量来系统运行的时间计数值,而按 20000HZ 的中断频率计算,每进入一中断就是 50us,变量加一,最大支持计数时间:2^32 * 50us / 3600s =59.6 分钟,运行时间超过了 59.6 分钟后统计的结果将不准确,除此之外整个系统一直响应定时器 50us 一次的中断会比较影响系统的性能。

用 户 想 要 使 用 使 用 CPU 利 用 率统 计 的 话 , 需 要 自 定 义 配 置 一 下 ,首 先 在FreeRTOSConfig.h 配置与系统运行时间和任务状态收集有关的配置选项,并且实现portCONFIGURE_TIMER_FOR_RUN_TIME_STATS() 与portGET_RUN_TIME_COUNTER_VALUE()这两个宏定义,如下:

c 复制代码
//启用运行时间统计功能 
#define configGENERATE_RUN_TIME_STATS 1 
//启用可视化跟踪调试 
#define configUSE_TRACE_FACILITY 1 
/* 与宏 configUSE_TRACE_FACILITY 同时为 1 时会编译下面 3 个函数 
* prvWriteNameToBuffer() 
* vTaskList(), 
* vTaskGetRunTimeStats() 
*/ 
#define configUSE_STATS_FORMATTING_FUNCTIONS 1 

extern volatile uint32_t CPU_RunTime; 

#define portCONFIGURE_TIMER_FOR_RUN_TIME_STATS() (CPU_RunTime = 0ul) 
#define portGET_RUN_TIME_COUNTER_VALUE() CPU_RunTime 

然后需要实现一个中断频率为 20000HZ 定时器,用于系统运行时间统计,其实很简单,只需将 CPU_RunTime 变量自加即可,这个变量是用于记录系统运行时间的,中断服务函数具体如下:

c 复制代码
 /* 用于统计运行时间 */ 
volatile uint32_t CPU_RunTime = 0UL; 

void BASIC_TIM_IRQHandler (void) 
{ 
	if ( TIM_GetITStatus( BASIC_TIM, TIM_IT_Update) != RESET ) { 
	CPU_RunTime++; 
	TIM_ClearITPendingBit(BASIC_TIM , TIM_FLAG_Update); 
	} 
}

然后我们就可以在任务中调用 vTaskGetRunTimeStats()和 vTaskList()函数获得任务的相关信息与 CPU 使用率的相关信息,然后打印出来即可。关于 vTaskGetRunTimeStats()和 vTaskList()函数的具体实现过程就不讲解了,有兴趣可以看看源码。

c 复制代码
memset(CPU_RunInfo,0,400); //信息缓冲区清零 

vTaskList((char *)&CPU_RunInfo); //获取任务运行时间信息 

printf("---------------------------------------------\r\n"); 
printf("任务名 任务状态 优先级 剩余栈 任务序号\r\n"); 
printf("%s", CPU_RunInfo); 
printf("---------------------------------------------\r\n"); 

memset(CPU_RunInfo,0,400); //信息缓冲区清零 

vTaskGetRunTimeStats((char *)&CPU_RunInfo); 

printf("任务名 运行计数 使用率\r\n"); 
printf("%s", CPU_RunInfo); 
printf("---------------------------------------------\r\n\n");

实验总结

FreeRTOS的源码到这就结束了,最后我们用一个小实验对我们的学习做个总结:

本次实验将创建三个任务:

任务A是控制任务,目的:根据传来的指令实现对LED灯和软件定时器的控制(涉及API函数有:队列、任务通知、软件定时器)

任务B:接收按键传来的消息,并将消息发送给任务A(涉及的API函数有:任务通知、队列)

任务C:在其它任务进入阻塞状态时,打印"等待接收消息"

除此之外,我们还需用到三个中断服务函数:

USART接收服务函数:将电脑打印的消息打包处理,然后发送给USART队列

两个EXTI中断服务函数:根据两个引脚的电平变化(高电平触发)通过任务通知,告诉任务B接收到哪个按键按下

框架大致如下(略有改动,以代码为准):

指令对应的代码逻辑:

main文件如下:

c 复制代码
#include "stm32f4xx.h"
#include "./led/bsp_led.h"
#include "bsp_debug_usart.h"
#include "key.h"
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "semphr.h"
#include "event_groups.h"
#include <string.h>

TaskHandle_t APP_TaskHandel=NULL;
TaskHandle_t TaskA_Handle=NULL;
TaskHandle_t TaskB_Handle=NULL;
TaskHandle_t TaskC_Handle=NULL;


QueueHandle_t USARTxQueue=NULL;
QueueHandle_t KEYxQueue=NULL;

SemaphoreHandle_t Semaphore_Text1Handel=NULL;
SemaphoreHandle_t Semaphore_Text2Handel=NULL;
/*创建互斥量句柄*/
SemaphoreHandle_t Semaphore_MutexHandel=NULL;

/*创建事件组句柄*/
EventGroupHandle_t EventGroup_Text=NULL;

TimerHandle_t TimerA_Text=NULL;
TimerHandle_t TimerB_Text=NULL;

#define Event_Key1		(1<<0)
#define Event_Key2		(1<<1)
#define Event_USART		(1<<2)

static void Hadware_Init(void);
void APP_CreatTask(void* parameter);
void TaskA(void* parameter);
void TaskB(void* parameter);
void TaskC(void* parameter);

/*定时器回调函数*/
void TimerA_CallBack(TimerHandle_t xTimer);
// void TimerB_CallBack(TimerHandle_t xTimer);


void Delay(uint32_t time);

uint8_t	Flag1;
uint8_t	Flag2;

int main(void)
{
  Hadware_Init();

  /*创建应用任务*/
  xTaskCreate(APP_CreatTask,"APP_Task",128,NULL,8,&APP_TaskHandel);

  if(APP_TaskHandel!=NULL)
  {
	printf("任务创建成功\r\n");
  }
  else
  {
	printf("任务创建失败\r\n");
  }

  /*开启任务调度*/
  vTaskStartScheduler();

  while(1)
  {

  }
}

static void Hadware_Init(void)
{
	/*
	 * STM32中断优先级分组为4,即4bit都用来表示抢占优先级,范围为:0~15
	 * 优先级分组只需要分组一次即可,以后如果有其他的任务需要用到中断,
	 * 都统一用这个优先级分组,千万不要再分组,切忌。
	 */
	NVIC_PriorityGroupConfig( NVIC_PriorityGroup_4 );
	
	/* LED 初始化 */
	LED_GPIO_Config();

	/* 串口初始化	*/
	Debug_USART_Config();

	Key_Init();
  
}

/*创建应用任务*/
void APP_CreatTask(void* parameter)
{
	xTaskCreate(TaskA,"TaskA",256,NULL,3,&TaskA_Handle);
	xTaskCreate(TaskB,"TaskB",256,NULL,4,&TaskB_Handle);
	xTaskCreate(TaskC,"TaskC",256,NULL,1,&TaskC_Handle);
	// xTaskCreate(TaskD,"TaskD",128,NULL,1,&Task_Handle);


	USARTxQueue=xQueueCreate(10,100);
	KEYxQueue=xQueueCreate(100,4);

	Semaphore_Text1Handel=xSemaphoreCreateBinary();
	/*创建完二值信号量,信号量默认为0,所以要释放一个*/
	xSemaphoreGive(Semaphore_Text1Handel);

	/*创建计数型信号量*/
	Semaphore_Text2Handel=xSemaphoreCreateCounting(2,2);

	/*创建普通互斥量*/
	Semaphore_MutexHandel=xSemaphoreCreateMutex();

	/*创建事件组*/
	EventGroup_Text=xEventGroupCreate();

	/*创建软件定时器*/
	TimerA_Text=xTimerCreate("TimerA",1000,pdTRUE,(void *)1,TimerA_CallBack);

	vTaskDelete(APP_TaskHandel);
}

/*创建任务A*/
void TaskA(void* parameter)
{
	char rx_datas[100];
	uint32_t key_data=0;
	while(1)
	{
		while(xQueueReceive(USARTxQueue,rx_datas,0)==pdPASS)
		{
			if(strcmp(rx_datas,"LED ON")==0)
			{
				LED1_ON;
				printf("OK LED ON\r\n");
			}
			else if(strcmp(rx_datas,"LED OFF")==0)
			{
				LED1_OFF;
				printf("OK LED OFF\r\n");
			}
			else if(strcmp(rx_datas,"BLINK")==0)
			{
				xTimerStart(TimerA_Text,0);
			}
			else if(strcmp(rx_datas,"STOP BLINK")==0)
			{
				xTimerStop(TimerA_Text,0);
			}

		}

		while(xQueueReceive(KEYxQueue,&key_data,0)==pdPASS)
		{
			if(key_data==0x12)
			{
				LED1_ON;
				printf("OK LED ON\r\n");
			}
			else if(key_data==0x24)
			{
				LED1_OFF;
				printf("OK LED OFF\r\n");
			}
		}
		/*每1s查看一次*/
		vTaskDelay(1000);
	}
}

/*创建任务B*/
void TaskB(void* parameter)
{
	uint32_t data=0;
	while(1)
	{
		xTaskNotifyWait(NULL,0xFF,&data,portMAX_DELAY);

		xQueueSend(KEYxQueue,&data,0);

		if(data==0x24)
		{
			printf("按键1按下\r\n");
		}

		if(data==0x12)
		{
			printf("按键2按下\r\n");
		}
	}
}

void TaskC(void* parameter)
{
	while(1)
	{

		/*获取二值信号量*/
		xSemaphoreTake(Semaphore_Text1Handel,portMAX_DELAY);

		printf("等待接收消息\r\n");

		/*释放二值信号量*/
		xSemaphoreGive(Semaphore_Text1Handel);

		vTaskDelay(500);
	}

}

void TimerA_CallBack(TimerHandle_t xTimer)
{
	LED1_TOGGLE;
	printf("OK BLINK");
}

stm32f4xx_it.c文件如下:

c 复制代码
extern QueueHandle_t USARTxQueue;

/*创建事件组句柄*/
extern EventGroupHandle_t EventGroup_Text;
void USART1_IRQHandler(void)
{
  uint8_t rx_datas[100];
  uint8_t ch=0;
  static uint8_t i=0;
  BaseType_t queueYIELD=pdFALSE;
  if(USART_GetFlagStatus(USART1,USART_FLAG_RXNE)==SET)
  {
    ch=USART_ReceiveData(USART1);

    /* 收到回车或换行都认为一行结束,兼容 \r\n、\r、\n 三种发送方式 */
    if(ch=='\r' || ch=='\n')
    {
      /* i==0 说明是空行(例如 \r\n 里的 \n),直接忽略 */
      if(i>0)
      {
        rx_datas[i]='\0';

        /* 中断在调度器启动前就被使能了,队列可能还没创建 */
        if(USARTxQueue!=NULL)
        {
          xQueueSendFromISR(USARTxQueue,rx_datas,&queueYIELD);
        }
        i=0;
      }
    }
    /* 防止超长数据把数组写越界 */
    else if(i<sizeof(rx_datas)-1)
    {
      rx_datas[i++]=ch;
    }

    portYIELD_FROM_ISR(queueYIELD);
  }
}

extern TaskHandle_t TaskB_Handle;
void EXTI15_10_IRQHandler(void)
{
  BaseType_t key_YIELD = pdFALSE;
  if(EXTI_GetITStatus(EXTI_Line13)==SET)
  {
    /*防止在没创建任务B时,就发生EXTI中断,这时的任务B句柄为NULL*/
    if(TaskB_Handle!=NULL)
    {
      xTaskGenericNotifyFromISR(TaskB_Handle,0x12,eSetValueWithOverwrite,NULL,&key_YIELD);
    }

    EXTI_ClearFlag(EXTI_Line13);

    portYIELD_FROM_ISR(key_YIELD);
  }
}

void EXTI0_IRQHandler(void)
{
  BaseType_t key_YIELD = pdFALSE;
  if(EXTI_GetITStatus(EXTI_Line0)==SET)
  {

    if(TaskB_Handle!=NULL)
    {
      xTaskGenericNotifyFromISR(TaskB_Handle,0x24,eSetValueWithOverwrite,NULL,&key_YIELD);
    }
    EXTI_ClearFlag(EXTI_Line0);

    portYIELD_FROM_ISR(key_YIELD);
  }
}

对代码的分析:

USART 接收流程:

USART1 中断逐字节接收,遇到 \r/\n 认为一行结束,把缓冲区以 100 字节的项拷贝到 USARTxQueue。如果此时有任务阻塞在该队列上,xQueueSendFromISR 会把它唤醒并置xHigherPriorityTaskWoken;本代码的 TaskA 多数时间在 vTaskDelay(1000),不在队列等待链上,所以命令只是入队,等 TaskA 醒来后再处理,最坏延迟约 1 秒;队列满时命令会被丢弃。

EXTI 中断流程

EXTI 中断调用 xTaskGenericNotifyFromISR,把按键值写入 TaskB 的通知值并唤醒 TaskB。若 TaskB 优先级(4)高于被中断的任务(TaskA 3、TaskC 1、Idle 0),pxHigherPriorityTaskWoken 为真,ISR 末尾 portYIELD_FROM_ISR 置 PendSV,等所有 ISR 退出后切到 TaskB;若被中断的是 Tmr Svc(31),TaskB 不高于它,不会立即切换。TaskB 被唤醒后把按键值转发到 KEYxQueue,TaskA 下一次轮询时处理。

PendSV:portYIELD_FROM_ISR 只置 PendSV pending 位,不立即切换;PendSV 在所有活动异常退出后执行,保存当前任务上下文、选出最高优先级就绪任务、恢复下一个任务的上下文,再通过EXC_RETURN 返回 Thread/PSP。

printf 重入问题:TaskA、TaskB、TaskC、TimerA 回调都直接调用了 printf,而 printf 不可重入,多个上下文并发调用时输出会交错。提高 TaskB 的优先级并不能保证串口的独占输出------它只能保证 TaskB 自身的 printf 不会被优先级更低的任务打断;相反,由于 TaskB 优先级高于 TaskA,按键中断唤醒 TaskB 后更容易抢占正在打印的 TaskA,反而增大了输出交错的风险。正确的做法是单独建立一个打印任务 + 打印队列,所有任务和中断上下文只把字符串投递到打印队列,由打印任务统一输出到串口。本节实验为了简单实现基本功能,没有采用这种方案,所以把 TaskB 设为最高优先级只能保证 TaskB 的 printf 不被 TaskA、TaskC 打断;若要保证 TaskA 的 printf 不被 TaskB 打断,就需要在 TaskA 处理完本次接收的消息后,再按下按键发送下一条命令。

相关推荐
努力努力再努力wz1 小时前
【C++高阶系列】C++20 协程底层原理:函数为什么能暂停?从状态机到 Task,彻底串起挂起、恢复与协程调度
开发语言·c++·c++20·1024程序员节
咩咩啃树皮1 小时前
JavaScript 中 this 指向为什么总在变?
开发语言·前端·javascript
程序员清风1 小时前
算法题中的边界条件、测试与调试
开发语言·算法·kotlin
砚底藏山河1 小时前
python量化实操:冷热分层存储
java·开发语言·python·金融
十五年专注C++开发1 小时前
pybind11:一个跨C++和Python平台的编程库
开发语言·c++·python·pybind11
艾络迅Acceleronix2 小时前
环境监测终端:STM32+EG800Z AT+ 艾络迅平台实战|多传感器采集联动全流程
c语言·stm32·单片机·嵌入式硬件·物联网·1024程序员节
CodeYp20192 小时前
第11讲:stack 和 queue
开发语言·c++·queue·stack
涂山苏苏⁠2 小时前
stm32复习总结
stm32·单片机·嵌入式硬件
Zwawa2 小时前
一只按键不只“按下”:短按、长按和连按如何共存
c语言·单片机·嵌入式