【嵌入式八股13】RTOS

一、线程间通信

进程拥有独立的地址空间,各进程之间相互隔离;而线程则共享所属进程的地址空间,这使得线程间通信在一定程度上更为便捷。线程间通信常用的方式包括信号、互斥锁、读写锁、自旋锁、条件变量和信号量等。

由于线程共享进程的全局内存区域,其中涵盖初始化数据段、未初始化数据段以及堆内存段等,所以线程之间能够方便、快速地共享信息,只需将数据复制到共享(全局或堆)变量中即可。然而,为了保证数据的一致性和正确性,必须考虑线程的同步和互斥问题,常见的技术手段如下:

  1. 信号:在 Linux 系统中,可使用 pthread_kill() 函数向线程发送信号,以此实现线程间的异步通知和简单交互。
  2. 互斥锁:其作用是确保在同一时刻仅有一个线程能够访问共享资源。当互斥锁被某个线程占用时,其他试图加锁的线程会进入阻塞状态(即释放 CPU 资源,由运行状态转变为等待状态)。当锁被释放时,哪个等待线程能够获得该锁则取决于内核的调度策略。
  3. 读写锁:读写锁具有独特的特性,当以写模式加锁且处于写状态时,任何试图加锁的线程(无论是读线程还是写线程)都会被阻塞;而当以读状态模式加锁且处于读状态时,“读”线程不会被阻塞,“写”线程则会被阻塞,即读模式下共享,写模式下互斥。
  4. 自旋锁:当线程尝试获取自旋锁受阻时,不会进入阻塞状态,而是在循环中不断轮询查看能否获得该锁。这种方式由于没有线程的切换,所以不存在切换开销,但会持续占用 CPU 资源,可能导致 CPU 资源的浪费。因此,自旋锁适用于并行结构(多个处理器)或者锁被持有时间较短且不希望因线程切换产生开销的场景。
  5. 条件变量:条件变量能够以原子的方式阻塞线程,直到某个特定条件变为真时为止。对条件的测试需要在互斥锁的保护下进行,并且条件变量总是与互斥锁配合使用,以确保数据的一致性和线程的正确执行。
  6. 信号量:信号量本质上是一个非负的整数计数器,主要用于实现对公共资源的控制。当公共资源增加时,信号量的值相应增加;当公共资源减少时,信号量的值随之减少。只有当信号量的值大于 0 时,线程才能够访问信号量所代表的公共资源。

二、条件变量(condition variable)

在 C++11 中,条件变量为线程同步提供了一种强大的机制。当条件不满足时,相关线程会一直处于阻塞状态,直到特定条件出现,这些线程才会被唤醒。

  1. 线程阻塞与唤醒的实现
    • 线程的阻塞是通过成员函数 wait()、wait_for() 和 wait_until() 来实现的。
    • 线程的唤醒则是通过函数 notify_all() 和 notify_one() 来完成的,其中 notify_all() 会唤醒所有等待该条件变量的线程,而 notify_one() 只会唤醒一个等待的线程。
  2. 虚假唤醒问题:在理想情况下,wait 类型函数只有在被唤醒或者超时时才会返回。但在实际应用中,由于操作系统的某些原因,wait 类型函数可能会在不满足条件时就返回,这种现象被称为虚假唤醒。例如:
if (不满足 xxx 条件) {
    // 没有虚假唤醒时,wait 函数可以一直等待,直到被唤醒或者超时,程序逻辑正常。
    // 但实际中存在虚假唤醒,这会导致假设不成立,wait 函数不会继续等待,而是跳出 if 语句,
    // 从而提前执行其他代码,使程序流程出现异常。
    wait();  
}
// 其他代码
...

为了避免虚假唤醒带来的问题,在实际使用中通常会采用 while 循环来检查条件,如下所示:

while (!(xxx 条件) )
{
    // 当虚假唤醒发生时,由于 while 循环的存在,会再次检查条件是否满足,
    // 如果不满足则继续等待,从而有效解决了虚假唤醒的问题。
    wait();  
}
// 其他代码
....
  1. 生产者消费者模式案例
#include 
#include 
#include 
#include 
#include 

class PCModle {
public:
    PCModle() : work_(true), max_num(30), next_index(0) {}

    void producer_thread() {
        while (work_) {
            std::this_thread::sleep_for(std::chrono::milliseconds(500));

            // 加锁,确保对共享资源的访问是互斥的
            std::unique_lock lk(cvMutex);
            // 当队列未满时,继续添加数据,wait 函数会在条件不满足时阻塞线程
            cv.wait(lk, [this]() { return this->data_deque.size() <= this->max_num; });

            next_index++;
            data_deque.push_back(next_index);
            std::cout << "producer " << next_index << ", queue size: " << data_deque.size() << std::endl;
            // 唤醒其他等待的线程
            cv.notify_all();
            // 自动释放锁,允许其他线程访问共享资源
        }
    }

    void consumer_thread() {
        while (work_) {
            // 加锁
            std::unique_lock lk(cvMutex);
            // 检测条件是否达成,即队列是否为空
            cv.wait(lk, [this] { return!this->data_deque.empty(); });

            // 互斥操作,取出数据
            int data = data_deque.front();
            data_deque.pop_front();
            std::cout << "consumer " << data << ", deque size: " << data_deque.size() << std::endl;
            // 唤醒其他线程
            cv.notify_all();
            // 自动释放锁
        }
    }

private:
    bool work_;
    std::mutex cvMutex;
    std::condition_variable cv;
    // 缓存区,用于存储生产的数据
    std::deque data_deque;
    // 缓存区最大数目
    size_t max_num;
    // 数据
    int next_index;
};

int main() {
    PCModle obj;

    std::thread ProducerThread = std::thread(&PCModle::producer_thread, &obj);
    std::thread ConsumerThread = std::thread(&PCModle::consumer_thread, &obj);

    ProducerThread.join();
    ConsumerThread.join();

    return 0;
}

三、共享内存

共享内存是一种高效的进程间通信方式。不同进程之间共享的内存通常为同一段物理内存,进程可以将同一段物理内存映射到各自的地址空间中,这样所有相关进程都能够访问共享内存中的地址。当某个进程向共享内存写入数据时,所做的改动会立即反映在共享内存中,其他能够访问同一段共享内存的进程可以立即获取到这些变化。

  1. 优点:共享内存的访问效率非常高,因为在通信过程中无需内核的介入,避免了不必要的数据复制操作,从而大大提高了数据传输的速度。
  2. 缺点:然而,共享内存没有内置的同步机制,这意味着在多个进程同时访问共享内存时,可能会出现数据竞争和不一致的问题。因此,在使用共享内存进行通信时,需要开发者手动设计和实现同步机制,以确保数据的正确性和一致性。

四、关闭中断的方式

在 Cortex-M3 和 M4 架构中,中断屏蔽寄存器主要有三种,分别是 PRIMASK、FAULTMASK 和 BASEPRI,它们各自具有不同的功能和作用:

  1. PRIMASK 寄存器:当 PRIMASK 寄存器设置为 1 后,会关闭除了 HardFault 异常外的所有中断和其他异常。此时,只有不可屏蔽中断(NMI)、复位(Reset)和 HardFault 异常可以得到响应。可以使用以下汇编指令来操作 PRIMASK 寄存器:
CPSIE I;                                        // 清除 PRIMASK(使能中断)
CPSID I;                                        // 设置 PRIMASK(禁止中断)
  1. FAULTMASK 寄存器:FAULTMASK 寄存器会将异常的优先级提升到 -1。当设置为 1 后,会关闭所有中断和异常,包括 HardFault 异常,只有 NMI 和 Reset 可以得到响应。操作 FAULTMASK 寄存器的汇编指令如下:
CPSIE F;                        // 清除 FAULTMASK
CPSID F;                        // 设置 FAULTMASK
  1. BASEPRI 寄存器:BASEPRI 寄存器可以用来屏蔽低于某一个阈值的中断。当设置为 n 后,会屏蔽所有优先级数值大于等于 n 的中断和异常。在 Cortex-M 架构中,优先级数值越大表示其优先级越低。

五、RT-Thread 关闭中断

RT-Thread 采用汇编代码实现关闭中断的功能,其方式类似于上述第一种关闭中断的方式,即屏蔽全部中断,仅响应 HardFault、NMI 和 Reset 异常。具体实现代码如下:

;/*
; * rt_base_t rt_hw_interrupt_disable();
; */
rt_hw_interrupt_disable    PROC
    EXPORT  rt_hw_interrupt_disable
    MRS     r0, PRIMASK
    CPSID   I
    BX      LR
    ENDP

六、FreeRTOS 关闭中断

在 FreeRTOS 中,通过向 basepri 寄存器中写入 configMAX_SYSCALL_INTERRUPT_PRIORITY 来实现关闭中断的功能,这表明优先级低于 configMAX_SYSCALL_INTERRUPT_PRIORITY 的中断都会被屏蔽。具体代码如下:

#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY  // 此宏用来设置 FreeRTOS 系统可管理的最大优先级,也就是 BASEPRI 寄存器中存放的阈值。
// 关中断
// 向 basepri 中写入 configMAX_SYSCALL_INTERRUPT_PRIORITY,
// 表明优先级低于 configMAX_SYSCALL_INTERRUPT_PRIORITY 的中断都会被屏蔽
static portFORCE_INLINE void vPortRaiseBASEPRI( void )
{
    uint32_t ulNewBASEPRI = configMAX_SYSCALL_INTERRUPT_PRIORITY;

    __asm
    {
        msr basepri, ulNewBASEPRI
        dsb
        isb
    }
}

七、RT-Thread rt_enter_critical() 和 rt_hw_interrupt_disable() 区别

rt_enter_critical()  // 该函数用于禁用调度器,但不会关闭中断。它支持嵌套调用,最大嵌套深度为 65535。在禁用调度器期间,线程的调度被暂停,从而保证了临界区代码的执行不会被其他线程抢占。
rt_hw_interrupt_disable()  // 此函数用于关闭中断,同样支持嵌套调用。关闭中断后,系统将不会响应任何中断请求,直到中断被重新启用。

八、FreeRTOS taskENTER_CRITICAL 和 taskDISABLE_INTERRUPTS 区别

vTaskSuspendAll()  // 该函数用于挂起调度器,不会关闭中断。它属于 FreeRTOS 层面的操作,不直接依赖具体的硬件。并且支持嵌套调用,在挂起调度器期间,线程的调度被暂停,但中断仍然可以被响应。
taskENTER_CRITICAL  // 支持嵌套调用,其底层实现为关闭部分中断,并且有引用计数。通过引用计数来跟踪临界区的嵌套深度,确保在所有嵌套的临界区都退出后,中断才会被恢复。
taskDISABLE_INTERRUPTS  // 该函数用于关闭中断,不支持嵌套调用。其实现方式为配置 BASEPRI 寄存器,通过设置合适的阈值来屏蔽某些中断。需要注意的是,如果在中断已经被禁用的情况下再次调用该函数,可能会导致不可预料的结果。

在以下例子中,调用 funcA 函数后,由于 funcB 函数中也有关闭和打开中断的操作,导致在执行完 funcB 函数后中断就会被打开,从而使得 funcC() 函数的执行无法得到有效的保护。而若使用 taskENTER_CRITICAL 和 taskEXIT_CRITICAL 则不会出现这种情况,因为它们通过引用计数等机制能够正确处理嵌套的临界区:

// 在临界区 ENTER/EXIT 内流程如下:
ENTER
  /* 中断 DISABLE */
ENTER
EXIT
 /* 此时中断仍然 DISABLE */
EXIT
 /* 释放所有的临界区,现在才会中断 ENABLE*/

// 但在中断 DISABLE 内流程则是如下:
DISABLE
/* 现在是中断 DISABLE */
DISABLE

ENABLE
/* 即使中断 DISABLE 了两次,中断现在也会重新使能 */
ENABLE

void funcA()
{
    taskDISABLE_INTERRUPT(); // 关中断
    funcB();// 调用函数 funcB
    funcC();// 调用函数 funcC
    taskENABLE_INTERRUPTS();// 开中断
}

void funcB()
{
    taskDISABLE_INTERRUPTS();// 关中断
    执行代码
    taskENABLE_INTERRUPTS();// 开中断
}

九、FreeRTOS 中断优先级设置

在 FreeRTOS 中,可以通过以下两个宏来设置系统可管理的中断优先级范围:

#define configLIBRARY_LOWEST_INTERRUPT_PRIORITY			15       // 定义中断的最低优先级,取值范围为 0 - 15
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY	5        // 定义系统可管理的最高中断优先级

这意味着高于 5 的优先级(即优先级数值小于 5 的中断)由 FreeRTOS 系统管理,而低于 5 的优先级(优先级数值大于等于 5 的中断)则不受 FreeRTOS 直接管理。

十、临界区

临界区是指访问公共资源的程序片段,它并不是一种通信方式,而是用于保证在同一时刻只有一个线程或进程能够访问共享资源,从而避免数据竞争和不一致的问题。

进入临界区通常有以下两种方式:

taskENTER_CRITICAL();
{
    .............// 临界区代码,在进入临界区时关闭中断或禁用调度器,以保护共享资源的访问
}
taskEXIT_CRITICAL();

vTaskSuspendAll();
{
    .............// 临界区代码,此方式仅关闭调度器,但仍然响应中断,适用于某些对中断响应有要求的场景
}
xTaskResumeAll();

十一、互斥锁(Mutex)、自旋锁(Spin)

当加锁操作失败时,互斥锁和自旋锁采取了不同的应对策略:

  1. 互斥锁(Mutex):互斥锁也称为独占锁,具有“谁上锁谁有权释放”的特点。当一个线程申请上锁失败后,该线程会进入阻塞状态,等待锁的释放。需要注意的是,互斥锁不能在中断处理程序中调用,因为中断处理程序的执行应该尽量简短和高效,而互斥锁的阻塞可能会导致中断处理的延迟。
  2. 自旋锁(Spinlock):当线程申请自旋锁失败后,不会进入阻塞状态,而是会一直循环判断是否能够成功上锁,这种方式会持续消耗 CPU 资源。然而,自旋锁的优势在于可以在中断中调用,并且在锁被持有时间较短的情况下,由于避免了线程切换的开销,能够提高程序的执行效率。

十二、临界区与锁的对比

互斥锁与临界区在功能上有相似之处,都是为了实现对共享资源的互斥访问,但它们也存在一些区别:

  1. 互斥锁:互斥锁(mutex)是可以命名的,这意味着它可以跨越进程使用,适用于不同进程之间的同步和互斥。由于互斥锁的功能更为强大和通用,创建互斥锁需要的资源相对更多。如果只是为了在进程内部实现线程间的互斥,使用互斥锁可能会带来一定的资源浪费。
  2. 临界区:临界区是一种轻量级的同步机制,与互斥锁和事件等内核同步对象相比,临界区是用户态下的对象,即只能在同一进程中实现线程间的互斥。由于临界区无需在用户态和核心态之间进行切换,所以其工作效率相对较高,能够减少资源占用量,在进程内部线程同步的场景中具有一定的优势。

使用场景 操作权限

临界区 一个进程下不同线程间 用户态,轻量级,执行速度快
互斥锁 进程间或线程间 内核态,涉及状态切换,相对较慢

十三、阻塞与非阻塞区别

  1. 阻塞:当线程或进程的执行条件不满足时,会进入等待状态,即阻塞态。在阻塞期间,线程或进程会暂停执行,直到条件满足后才会被唤醒并继续执行。
  2. 非阻塞:当线程或进程的执行条件不满足时,会立刻返回,不会等待条件的改变,而是继续执行其他任务。非阻塞方式能够提高程序的并发性和响应性,避免线程或进程在等待条件时的无谓等待。

十四、RTOS 为何不用 malloc 和 free

在实时操作系统(RTOS)中,通常不使用标准的 malloc 和 free 函数进行内存管理,主要原因如下:

  1. 实现复杂,占用空间较多:malloc 和 free 的实现涉及到复杂的内存管理算法和数据结构,如堆的维护等。这不仅增加了代码的复杂性,还会占用较多的内存空间,对于资源有限的嵌入式系统来说,可能会造成不必要的资源浪费。
  2. 并非线程安全操作:在多线程环境下,malloc 和 free 函数不是线程安全的。多个线程同时调用这些函数可能会导致内存管理的混乱,例如出现双重释放、悬空指针等问题,从而影响系统的稳定性和可靠性。
  3. 每次调用执行时间不确定:malloc 和 free 的执行时间会受到内存碎片、当前内存使用情况等多种因素的影响,每次调用的执行时间是不确定的。而在 RTOS 中,对于实时性要求较高的任务,这种不确定性可能会导致任务错过截止时间,影响系统的实时性能。
  4. 内存碎片化:频繁地使用 malloc 和 free 会导致内存碎片化问题。随着时间的推移,内存中会出现许多不连续的小块空闲内存,使得后续较大的内存分配请求难以满足,即使总的空闲内存量足够。这会降低内存的利用率,影响系统的性能。
  5. 不同编译器适配复杂:不同的编译器对 malloc 和 free 的实现可能会有所不同,这就需要在不同的编译环境下进行适配和调试。对于嵌入式系统来说,可能会使用多种编译器和开发工具链,这增加了开发和维护的难度。
  6. 难以调试:由于 malloc 和 free 函数的实现较为复杂,当出现内存相关的错误时,如内存泄漏、越界访问等,调试起来非常困难。在 RTOS 中,及时发现和解决内存问题对于系统的稳定性至关重要,而使用 malloc 和 free 会增加调试的复杂性和难度。

十五、FreeRTOS 内存管理算法

FreeRTOS 提供了多种内存管理算法,分别为 heap_1 到 heap_5。其中除了 heap_3 分配在堆上,其余算法均在 bss 段开辟静态空间进行管理。以下是对各算法的详细介绍:

// 定义内存堆的大小
#define configTOTAL_HEAP_SIZE (8 * 1024) // 8KB

// 全局变量 "uc_heap" 的定义
static uint8_t ucHeap[configTOTAL_HEAP_SIZE];
uint8_t *ucHeap = ucHeap;
  1. heap_1
    • 优点:内存分配的时间是确定的,因为它只进行内存分配操作,不涉及回收操作。这对于一些对时间确定性要求较高的应用场景非常重要,例如实时控制系统。
    • 缺点:只分配不回收,不合并空闲区块。随着内存的不断分配,最终会导致没有足够的内存可用,即使存在一些已分配但不再使用的内存块。适用于那些在系统运行期间内存需求相对固定,不需要释放内存的应用。
  2. heap_2
    • 优点:使用最佳拟合算法进行内存分配,能够找到最接近所需内存大小的空闲内存块进行分配,从而提高内存的利用率。
    • 缺点:虽然支持内存回收,但不合并相邻的空闲内存块,这会导致内存碎片化问题逐渐加剧。而且内存分配的时间是不确定的,因为需要在空闲内存块中寻找最佳匹配的块,这可能会随着内存使用情况的变化而有所不同。
  3. heap_3
    • 优点:使用标准库的 malloc() 和 free() 函数,这使得它与传统的 C 语言内存管理方式兼容,对于一些已经熟悉标准内存管理函数的开发者来说,使用起来相对容易。
    • 缺点:代码量大,因为需要实现完整的 malloc 和 free 功能。并且不是线程安全的,在多线程环境下使用需要额外的同步机制来保证内存管理的正确性。此外,内存分配和释放的时间也是不确定的,可能会影响系统的实时性能。heap 的大小由链接器配置定义(通常在启动文件中定义),这增加了配置的复杂性。
  4. heap_4
    • 优点:使用 first fit 算法来分配内存,即从内存块的起始位置开始查找,找到第一个足够大的空闲内存块进行分配。同时,它会合并相邻的空闲内存块,有效地减少了内存碎片化问题,提高了内存的利用率。
    • 缺点:内存分配的时间不确定,因为需要遍历空闲内存块来找到合适的块。虽然合并相邻空闲内存块有助于减少碎片化,但在某些情况下,仍然可能会出现较长的查找时间。
  5. heap_5
    • 优点:在 heap_4 的基础上,增加了从多个独立的内存空间分配内存的能力。这对于一些具有不连续内存空间的系统非常有用,例如在某些嵌入式设备中,可能存在多个不同的内存区域。
    • 缺点:同样存在内存分配时间不确定的问题,因为其内存分配算法本质上与 heap_4 类似,只是增加了对多个内存空间的管理。

十六、内存池

内存池是一种用于管理和分配内存的技术,主要用于解决频繁地申请和释放内存带来的性能问题。

  1. 传统内存管理的问题:在传统的内存管理中,当程序需要使用内存时,通常会调用内存分配函数(如 malloc)来动态申请一块内存空间。而当不再使用该内存时,则会调用相应的内存释放函数(如 free)来释放内存。然而,这种动态的内存分配和释放操作在频繁进行时,会产生很多开销。一方面,内存管理函数本身需要进行一系列的操作,如查找合适的内存块、更新内存管理数据结构等,这会增加 CPU 的负担。另一方面,频繁的分配和释放会导致内存碎片化问题,使得内存的利用率降低,后续的内存分配请求可能会因为没有足够大的连续内存块而失败。
  2. 内存池的工作原理:内存池事先申请一定大小的内存空间,并将其划分成多个固定大小的块,形成一个池子。当程序需要使用内存时,直接从内存池中分配一个可用的块,而不是频繁地调用内存分配函数。在释放内存时,将内存块归还给内存池,而不是调用内存释放函数。这样,内存池就可以重复使用这些内存块,减少了内存分配和释放的开销。
  3. 内存池的优势
    • 降低内存碎片问题:由于内存池中的内存块大小是固定的,不会出现因为分配和释放不同大小的内存块而导致的内存碎片化问题。这有助于提高内存的利用率,确保系统在长时间运行过程中仍然能够有效地分配内存。
    • 减少动态内存分配和释放的开销:通过一次性申请和释放内存块,避免了频繁调用内存分配和释放函数的开销,提高了内存分配和释放的效率,从而提升了程序的性能。
    • 提供内存分配的可预测性:内存池的内存分配时间是相对固定的,因为它只需要从池中获取一个可用的块,而不需要进行复杂的内存查找和管理操作。这对于实时操作系统(RTOS)来说非常重要,能够避免因动态内存分配造成的不确定性和性能抖动,保证任务的实时性要求。

你可能感兴趣的:(嵌入式八股,java,开发语言)