1.裸机程序设计模式
1.1 轮询模式
在 main 函数中是一个 while 循环
1.2 前后台
所谓“前后台”就是使用中断程序。前台影响后台,后台不影响前台。函数相互之间有影响。
1.3 定时器驱动
定时器驱动模式,是前后台模式的一种,可以按照不同的频率执行各种函数。比如需要每2分钟给小孩喂一口饭,需要每5分钟给同事回复信息。函数相互之间有影响。
1.4 基于状态机
将一个任务拆分为多个任务,任务一的第一部分执行完后就去执行任务二的第一部分,以此类推。但是很多场景里,函数 A、B 并不容易拆分为多个状态,并且这些状态执行的时间并不好控制。
2.多任务系统
2.1 多任务模式
多任务系统会依次给这些任务分配时间:你执行一会,我执行一会,如此循环。只要切换的间隔足够短,用户会“感觉这些任务在同时运行”
2.2 互斥操作
互斥操作(Mutual Exclusion,简称Mutex)用于防止多个任务(或线程)同时访问共享资源,从而避免资源冲突和数据不一致的情况。它确保在同一时刻只有一个任务能够访问特定的资源。互斥操作用于控制共享资源的访问,防止多个任务同时访问资源。互斥操作主要解决资源访问冲突的问题
2.2.1互斥锁
互斥锁(Mutex)是用于在并发编程中控制对共享资源的访问的一种同步机制。它的核心目的是确保在某一时刻,只有一个任务(线程)能够访问特定的共享资源,避免多个任务同时操作同一资源而导致的竞态条件和数据不一致。
2.2.2互斥锁的工作流程
尝试获取锁:当一个任务需要访问共享资源时,它会首先尝试获取互斥锁。
如果锁是未锁定的,任务就可以获取锁,进入临界区,执行资源操作。
如果锁是已锁定的,任务就会被阻塞,直到锁被释放。
进入临界区:当任务获取到互斥锁后,它进入临界区(Critical Section),即访问共享资源的区域。只有持有锁的任务才能进入临界区。
释放锁:当任务完成对共享资源的操作后,它会释放互斥锁,允许其他任务获取锁并进入临界区。
2.2.3互斥锁的优点
防止竞态条件:互斥锁可以防止多个任务同时访问共享资源,避免因并发修改造成的数据混乱。
保证数据一致性:使用互斥锁确保了资源访问的顺序性,使得数据保持一致。
任务同步:通过互斥锁,不同任务可以有效地同步操作,避免资源争用。
2.2.4互斥锁的缺点
性能开销:互斥锁的使用可能导致任务阻塞和上下文切换,这可能增加系统的开销。
任务阻塞:
当一个任务(或线程)请求访问某个资源时,如果该资源的互斥锁已经被其他任务占用,那么请求锁的任务就会被阻塞。
阻塞会导致任务停止执行,并且等待锁的释放。任务阻塞时无法执行其他操作,从而浪费了CPU时间。
上下文切换:
当任务阻塞时,操作系统的调度器会选择其他任务执行,这时就发生了上下文切换。
上下文切换是操作系统保存当前任务的状态并加载新任务的状态的过程。每次上下文切换都有一定的开销,包括保存和恢复寄存器、程序计数器等信息。
频繁的上下文切换会导致系统效率降低,特别是在高并发的场景中。
死锁:如果两个任务相互持有对方需要的锁,并且都无法释放锁,就会发生死锁。为了避免死锁,开发者需要小心设计锁的获取和释放顺序。
死锁是一种程序中常见的并发问题,它发生在两个或多个任务相互等待对方持有的锁,并且都无法释放已持有的锁,导致系统中的任务永远不能继续执行。
死锁的发生:
死锁通常出现在多个任务(或线程)同时持有一个或多个互斥锁,并且它们按照不同的顺序请求锁时。例如:
任务A持有锁1,等待锁2。
任务B持有锁2,等待锁1。
此时,任务A和任务B互相等待对方释放锁,导致两个任务都无法继续执行,系统进入死锁状态。
死锁的四个必要条件:
互斥:至少有一个资源必须是被排他的,即同一时刻只能有一个任务占用该资源。
请求与保持:任务在持有资源的同时,要求其他任务占有某个资源。
不剥夺:已经获得的资源,在任务完成之前,不能被强制剥夺。
循环等待:在多个任务之间存在一个环形的资源等待关系,即任务A等待任务B持有的资源,任务B等待任务A持有的资源,最终形成环。
如果这四个条件同时满足,就可能发生死锁。
如何避免死锁:
开发者需要小心设计任务和锁的获取顺序,避免出现死锁的情况。常见的避免死锁的策略包括:
锁的顺序:确保所有任务在获取多个锁时,都按照相同的顺序请求锁,这样可以避免循环等待。例如,任务A和任务B都按照“锁1 → 锁2”顺序请求锁,而不是反过来。
超时机制:为每个请求锁的操作设置一个超时时间。如果在指定的时间内未能获取到锁,任务将放弃并释放已占有的锁,从而避免死锁。
避免持有锁过长时间:尽量缩短持有锁的时间,不要在临界区内执行复杂的操作。这样可以减少死锁发生的概率。
死锁检测与恢复:有些操作系统提供死锁检测机制,通过检查任务间的资源等待图来判断是否发生死锁。如果发现死锁,系统可以通过恢复策略(如撤销某些任务的操作)来解除死锁。
2.3任务状态
在RTOS中,任务通常有几种状态:就绪(Ready)、运行(Running)、阻塞(Blocked)、挂起(Suspended)等。
阻塞:当一个任务需要访问某个共享资源时,如果该资源当前被其他任务占用(比如一个锁或者资源被占用),该任务就会被阻塞,直到资源被释放。
在多任务操作系统中,任务A如果一直处于阻塞状态(例如,等待某个互斥锁或者信号量),那么任务A是通过某种机制自动检查锁是否已经被释放的。操作系统(RTOS)通常会在任务A被阻塞时,把它从就绪队列中移出,直到它等待的资源(例如锁、信号量或事件)可用时,操作系统会将任务A重新放入就绪队列,并通知任务A继续执行。
具体流程:
1.任务A在等待锁时被阻塞:当任务A试图获取锁时,操作系统会检查锁的状态。如果锁已经被其他任务(如任务B)持有,任务A就会被阻塞。任务A调用xSemaphoreTake(xMutex, portMAX_DELAY)等函数来请求锁,如果锁未被释放,任务A就会被阻塞,直到锁可用。
2.任务B释放锁:当任务B完成对共享资源的操作后,它会释放锁(通过xSemaphoreGive(xMutex))。释放锁后,操作系统会检查是否有任务正在等待该锁。如果有,操作系统会将一个等待任务(如任务A)唤醒,并将它放回就绪队列中。
3.任务A被唤醒:当任务A被唤醒时,它会重新进入就绪队列,操作系统会调度任务A重新执行。
就绪队列(Ready Queue)是操作系统中用于管理可执行任务(或线程)的一种数据结构。在多任务操作系统中,任务或线程会根据其状态被划分到不同的队列中,而就绪队列专门用于存储那些已准备好执行、但当前还没有获得CPU时间的任务或线程。
挂起(Suspended)是操作系统中的一种任务状态,指的是任务被操作系统临时暂停执行,并且无法被调度,直到它被显式地恢复。挂起的任务并不执行任何操作,并且它也不会占用CPU资源。暂停执行、无法调度、不会阻塞资源
优点:
节省资源:通过挂起不必要的任务,可以节省系统资源,特别是在资源有限的嵌入式系统中。
提高效率:挂起不需要执行的任务,有助于提高系统中其他任务的响应速度和执行效率。
缺点:
任务恢复控制复杂:任务被挂起后,恢复的时机需要特别小心控制,避免出现逻辑错误或不必要的延迟。
挂起任务的管理:如果挂起任务过多,可能会使任务管理变得复杂,特别是在需要频繁挂起和恢复任务时,可能会增加开发的难度。
挂起 vs 阻塞:阻塞(Blocked)状态是任务由于某些原因(如等待I/O或同步操作)暂时停止执行,但阻塞的任务仍然处于调度系统中,操作系统会在条件满足时将其恢复执行。而挂起的任务则完全不参与调度,直到显式地被恢复。
2.3 同步操作(Synchronization Operation)
同步操作用于协调多个任务之间的执行顺序,确保它们按预定的顺序或条件运行。这种操作通常用于实现任务之间的协作,例如任务A必须在任务B完成后才能继续执行。同步操作用于控制任务的执行顺序或协调任务间的关系。同步操作主要解决任务间的执行顺序问题.
· 工作原理:同步机制通常依赖于事件标志、信号量或消息队列等方式,让一个任务等待另一个任务的信号或完成标志,才能继续执行。
· 应用场景:例如,一个任务需要等待另一个任务完成数据处理,然后再基于这些数据进行下一步操作,或者多个任务必须在同一时间点执行某个操作。
3.CPU内部的寄存器
低速寄存器R0-R7,高速寄存器R8-R12,R13-R15为特殊寄存器
R13:SP(stack pointer)寄存器
R14:LR(link register)寄存器
R15:PC(program counter),程序计数器
4.常用汇编指令
LDR(load register):加载字到寄存器,读取4字节(32位)的数据
LDRB(byte):加载字节到寄存器,读取1字节(8位)的数据
LDRH(half bit):加载半字到寄存器,读取2字节(16位)的数据
LDR R0, [R1] ; 从内存地址R1指向的位置加载4字节(32位)数据到R0
LDR R0, [R1, #4] 从R1 + 4这个地址处加载4字节的数据到寄存器R0,R1是一个基地址,#4是偏移量,偏移量是以字节为单位的。假设R1的值是0x20000000,偏移量#4加到R1上,新的地址是0x20000004,指令会从地址0x20000004处读取4字节的数据,并将它们存储到寄存器R0中。
同样意思,写指令:
STR(store register):存储寄存器中的字
STRB:存储寄存器中的字节
STRH:存储寄存器中的半字
加(add)减(subtraction)法:
ADD R0,R1,R2,将R1+R2的值赋给R0
ADD R1,R1,#1,将R1+1的值赋给R1
SUB R0,R1,R2,将R1-R2的值赋给R0
SUB R1,R1,#1,将R1-1的值赋给R1
CMP R0,R1; 比较的结果保存在PSR(程序状态寄存器)
B 无条件转移
BL 转移并连接(呼叫子程序)
B main; Branch,直接跳转
BL main ;Branch and Link,先把返回地址保存在LR寄存器里再跳转
5.为什么每个任务都有自己的栈
1. 任务独立性
每个任务在 RTOS 中是一个独立的执行单元,任务通常会执行不同的代码,并且有各自的局部变量和函数调用。为了保持每个任务的执行独立性,必须为每个任务提供独立的栈空间。如果多个任务共享同一个栈,它们的局部变量和函数调用可能会相互覆盖或冲突,导致数据错误和程序崩溃。
2. 保存任务的上下文
每个任务在执行时,都会使用栈来存储函数调用时的局部变量、返回地址、寄存器状态等。这些信息对于任务的恢复和切换非常重要。当任务执行被中断或切换时,操作系统需要保存当前任务的上下文(如寄存器值、栈指针等),并在任务重新调度时恢复这些上下文。每个任务有自己的栈,保证了任务的执行上下文不会互相干扰。
保存和恢复:在任务切换时,操作系统会将当前任务的栈指针(SP)保存到任务的控制块中(通常称为任务控制块 TCB),然后切换到另一个任务的栈指针。每个任务的栈保持了该任务在运行时的状态,确保任务能够从正确的位置恢复。
3. 避免栈溢出和栈冲突
如果多个任务共享同一个栈,任何一个任务的栈溢出(比如超出了栈的分配范围)都会影响到其他任务,导致不可预知的行为。而为每个任务分配独立栈空间,可以避免任务之间的栈溢出或数据污染,从而提高程序的稳定性。
4. 任务切换(上下文切换)的支持
RTOS 中的任务切换需要将当前任务的状态(上下文)保存起来,并恢复下一个任务的状态。栈是保存任务状态的核心部分,因此每个任务必须有独立的栈,以保证任务在切换后能正确恢复运行。
上下文切换:上下文切换时,RTOS 会保存当前任务的栈指针,存储所有寄存器状态,然后切换到另一个任务。每个任务的栈有其独立的栈指针,确保任务切换时恢复正确的上下文。
5. 局部变量的隔离
栈用于存储局部变量,而这些变量通常是在函数调用时分配的。如果不同的任务共享同一个栈,那么它们的局部变量可能会相互覆盖和污染,导致意外的结果。每个任务拥有自己的栈,保证了任务中的局部变量是独立的,不会相互影响。
6. 实时性和资源管理
RTOS 的设计要求任务能够实时响应外部事件,因此每个任务通常会具有不同的优先级和资源需求。独立的栈可以帮助 RTOS 更好地管理任务的资源使用和内存分配,确保高优先级任务能快速且独立地执行,而不受到低优先级任务栈溢出或资源浪费的影响。
7. 简化任务管理
通过为每个任务分配独立的栈,RTOS 可以更简洁地管理每个任务的执行状态和资源。操作系统只需维护每个任务的栈指针,而不需要处理共享栈的复杂性。这使得任务的创建、调度和切换更加高效和可靠。
总结:
每个任务都有自己的栈,是为了保证任务执行的独立性,避免任务间相互干扰,并支持任务上下文的保存和恢复。这样可以确保任务能够在上下文切换时正确恢复,不会出现栈数据冲突或溢出的情况,同时提高系统的稳定性和可靠性。在实时操作系统中,独立的栈空间对于任务调度、资源管理和实时响应非常重要。
6.堆和栈
堆:
堆是一种动态分配的内存区域,用于存储在程序运行时需要动态分配的内存数据。堆的分配和释放通常是由程序员(或运行时系统)手动控制的。
堆的作用:
动态内存分配:当程序需要在运行时分配内存时,堆提供了一个区域用于存储这些动态分配的对象或数据。堆内存可以在程序执行时按需增加或减少,适用于不确定大小的数据结构,如动态数组、链表等。
长生命周期的数据:堆内存通常用于存储生命周期较长的数据(如跨函数调用的数据)。与栈不同,堆中的数据不随函数的退出而消失,程序可以在整个运行期间访问这些数据。
灵活性:堆允许程序在运行时灵活地申请和释放内存。对于一些需要动态管理内存的复杂数据结构(如大对象、动态数组等),堆提供了必要的支持。
特点:
灵活性高:堆内存可以动态分配,大小不固定,适用于不确定大小的数据存储。
内存分配慢:堆的内存分配和释放速度相对较慢,需要通过管理系统来跟踪空闲的内存块。
需要手动管理:堆的内存需要程序员显式地进行分配(如 malloc 或 new)和释放(如 free 或 delete)。如果没有正确释放,可能会导致内存泄漏。
容易发生内存碎片化:由于堆内存的分配和释放是动态的,容易造成内存碎片,即内存空间不连续,可能影响性能。
栈:
栈是一种后进先出(LIFO)的数据结构,通常用于管理函数调用、局部变量以多任务系统里保存现场。栈有着固定的增长方向(通常是向下增长,高地址到低地址)。
作用:
函数调用管理:栈用于存储函数调用时的返回地址、局部变量、参数等。每当函数被调用时,函数的返回地址和局部变量会被压入栈中;当函数执行完毕时,它的局部数据会从栈中弹出,控制权返回到调用者。
局部变量存储:函数内部的局部变量通常存储在栈中。栈分配和释放局部变量的速度非常快,因为它只需要调整栈指针。
调用者保存的上下文:栈可以保存寄存器的值(如程序计数器 PC 和链接寄存器 LR),以便函数调用时能恢复上下文。
栈的特点:
速度快:栈的分配和释放非常迅速,只需要简单的指针操作。
有限大小:栈的大小通常有限(受操作系统限制),如果栈空间不足,可能会发生栈溢出(stack overflow)。
自动管理:栈的内存分配和释放是由编译器和操作系统自动管理的。
栈主要用于函数调用、局部变量存储和快速的内存管理,它的内存分配和释放非常高效,但有大小限制。
堆主要用于动态内存分配,适用于需要在程序运行时决定大小的数据结构,它的内存管理灵活但可能导致内存碎片,并且分配和释放速度相对较慢。
7.简述FreeRTOS本质
FreeRTOS 在某种程度上可以理解为实现了一种 时间片轮转(Round-Robin)调度的实时操作系统。它会根据任务的优先级和时间片来调度不同任务的执行。下面我将详细解释这一过程以及 FreeRTOS 中任务调度的机制。
1、FreeRTOS 的任务调度
任务切换和时间片轮转: FreeRTOS 调度器确实实现了类似于时间片轮转的机制,尤其是当系统中有多个任务具有相同优先级时。时间片轮转指的是每个任务在 CPU 上执行一个固定的时间段(时间片),执行完该时间片后,任务被中断,操作系统会切换到下一个任务。具体来说:
任务调度:当任务的时间片用完,或是任务的执行被中断时,FreeRTOS 会切换到下一个任务。如果任务没有完成,它会在下次被调度时继续执行,通常是通过保存和恢复任务的上下文来完成的。
上下文保存和恢复:每当任务被切换时,FreeRTOS 会保存当前任务的“现场”(即任务的寄存器、堆栈、程序计数器等信息),然后恢复下一个任务的“现场”,这样就能让任务从它被挂起的位置继续执行。
2、优先级调度:
FreeRTOS 并不仅仅依赖时间片轮转来调度任务,还考虑了任务的优先级。任务的优先级在任务创建时设置,调度器根据任务的优先级来决定哪个任务应当优先执行:
高优先级任务会先执行,甚至会抢占低优先级的任务。
低优先级任务只有在没有高优先级任务运行时才会执行。
因此,如果任务有不同的优先级,FreeRTOS 会优先执行优先级高的任务,而优先级相同的任务则会使用时间片轮转。
3、时间片轮转的工作原理:
当多个任务具有相同优先级时,FreeRTOS 会实现时间片轮转机制,确保每个任务都有机会运行。具体步骤如下:
每个任务会被分配一个时间片(可以通过 configTICK_RATE_HZ 或 vTaskDelay() 控制)。
当任务的时间片到期时,FreeRTOS 会触发任务切换,将当前任务的现场保存,恢复下一个任务的现场,并让下一个任务执行。
这种机制会一直循环,确保多个任务交替执行。
4、中断和任务切换:
在 FreeRTOS 中,任务切换不仅由时间片到期触发,还可以通过外部事件(如中断)触发。中断服务例程(ISR)可以通过信号量、队列等机制与任务交互,从而引发任务的调度和切换。
5、任务的状态:
FreeRTOS 中的任务有多种状态,包括:
就绪状态:任务准备好运行,但 CPU 当前未分配给它。
运行状态:任务正在执行。
阻塞状态:任务等待某些事件或资源(例如等待信号量、等待消息队列数据等)。
挂起状态:任务被挂起,不能执行,直到恢复。
当任务从一个状态切换到另一个状态时,FreeRTOS 会自动进行任务调度,确保系统按预期执行。
总结:
FreeRTOS 可以理解为实现了 时间片轮转(Round-Robin)调度,尤其是在任务具有相同优先级时,系统会在不同任务之间进行轮转切换。每个任务在其时间片内执行,时间片到期后,操作系统会保护当前任务的现场并恢复下一个任务的现场,确保任务可以交替执行。除了时间片轮转,FreeRTOS 还考虑了任务的优先级来进行任务调度。
8.FreeRTOS源码概述
入口函数:
TickType_t和BaseType_t:
FreeRTOS 配置了一个周期性的时钟中断:Tick Interrupt。每发生一次中断,中断次数累加,这被称为 tick count,tick count 这个变量的类型就是 TickType_t。对于32位架构,建议把 TickType_t 配置为uint32_t。
BaseType_t这是该架构最高效的数据类型,32 位架构中,它就是 uint32_t。
变量前缀名:
c指char;s指short;l指long,即int32_t;x一般指结构体;u指无符号;p指指针;v指void,无返回值。
9.内存管理
与Heap_4相比,Heap_2不会合并相邻的空闲内存,所以Heap_2会导致严重的"碎片化" 问题。但是,如果申请、分配内存时大小总是相同的,这类场景下Heap_2没有碎片化的问题。
所以它适合这种场景:频繁地创建、删除任务,但是任务的栈大小都是相同的(创建任务时,需要分配TCB和栈,TCB总是一样的)。
Heap_4会把相邻空闲内存合并为一个大的空闲内存,可以较少内存的碎片化问题。适用于这种场景:频繁地分配、释放不同大小的内存。
Heap_5分配内存、释放内存的算法跟 Heap_4 是一样的。相比于Heap_4,Heap_5并不局限于管理一个大数组:它可以管理多块、分隔开的内存。在嵌入式系统中,内存的地址可能并不连续(多个内存),这种场景下可以使用Heap_5。
合并机制:在释放内存时,vPortFree() 会尝试将相邻的空闲内存块合并成一个大块,减少内存碎片。prvInsertBlockIntoFreeList() 函数通过遍历链表,检查待插入的内存块是否与前后相邻的空闲块可以合并。如果相邻,合并后更新链表。
合并的条件是:待插入块的末尾与相邻块的起始位置对齐。通过合并机制,系统能够避免大量的小块空闲内存,从而减少内存碎片,提高内存的使用效率。
10.钩子函数
钩子函数(Hook Function)是一种设计模式,允许外部代码在特定的事件或操作发生时插入自定义的代码。钩子函数本质上是一种回调机制,可以在不修改原始代码的情况下,实现特定的功能扩展或定制化。
工作原理:
钩子函数是预先定义的接口或函数指针,用户可以在应用程序中提供自定义的函数来“挂钩”到这些接口。当某个特定事件发生时,系统会调用这些挂钩的函数,并且在函数内部执行用户定义的代码。
钩子函数:预定义的、在特定时刻可以被替代或扩展的函数。
回调机制:钩子函数是通过回调的方式来扩展功能的。也就是说,系统会在合适的时候调用由用户定义的钩子函数。
作用:
钩子函数的主要作用是让程序的行为更灵活和可定制。它提供了一个接口,允许用户在某些特定时刻执行自定义代码,而不需要修改底层的实现代码。这在许多场景中非常有用,例如:在某个事件发生时执行自定义的清理操作、在系统启动或停止时执行自定义的初始化或销毁任务、在任务调度前后执行用户定义的操作。
总结:
钩子函数是一种回调机制,允许在特定事件或操作发生时执行用户定义的代码。在 FreeRTOS 中,钩子函数可以帮助开发者定制系统行为,如空闲任务、内存分配失败、栈溢出等事件的处理。通过使用钩子函数,开发者可以提高系统的灵活性和可定制性,而无需修改 FreeRTOS 内核代码。