Skip to content

ChibiOS 关键区与锁

liuzikai edited this page Jan 17, 2022 · 2 revisions

本文介绍 ChibiOS RT 内核概念:系统状态(state)、同步(synchronization)、关键区(critical section)等。

前置阅读:ChibiOS 线程编写

中断 Interrupt 简介

如果读者已经熟悉中断 Interrupt(ECE220、ECE391),可以跳过这一部分。

考虑这样一个场景:CPU 需要及时接受一个外部设备的信息(例如,电机发送了回传数据),一个 naive 的方法是写一个循环,反复检查是否接收到数据,不过显然这个方法非常低效,CPU 花费大量的时间在无用的检查上。如果给循环加上周期(例如使用线程,每 10ms 检查一次),则会有延迟的问题,信息无法被及时处理。

中断则是另一个解决思路:让外部设备主动通知 CPU。当 CPU 接收到消息时,立即切换到处理消息的代码,运行结束后返回先前运行的代码。通过在切换前保存系统状态(寄存器、栈等)被打断的代码对这一过程并无感知,和顺序执行并无差别。

同步(Synchronization)、关键区(Critical Section)、锁(Lock)

中断可能随时触发,中断触发称为 Interrupt Request(IRQ)。如果中断代码和正在执行的代码完全独立,则没有任何影响,但实际开发过程往往并非如此。一个简单的例子:CPU 正在执行这样一段代码:

if (now() - last_update_time > 2000) {  // 符合

    // 中断触发,last_update_time 在中断代码中增大

    // 继续执行
    int delta = now() - last_update_time;
    // 此时 delta 不一定 > 2000
}

由于正在执行的代码和中断代码有交集(last_update_time),两者相互干扰了。我们编写代码,一般都是基于顺序执行的假设,因此这种问题往往很难从代码中看出来,而且 debug 相对困难。

不仅是中断,相互干扰的问题对于线程,或多处理器,也同样存在。操作系统切换线程一般也是通过中断的方式,随时可能发生。将上述例子的中断,替换成在另一个线程中执行的代码,或者在另一个处理器上并行执行的代码,同样适用。这一类问题被称为同步(Synchronization)问题。

中断/线程切换/并行执行的”随时发生“,是在汇编层面上的,在 C/C++ 层面,可能发生在一句代码中间,例如 a = a + 1,这一句代码可能被翻译成两条汇编指令:加法和写入结果,中断/线程切换/并行执行可能在这两条指令中间发生。

由于我们控制开发不考虑多处理器场景,并行执行就不再讨论了,感兴趣的读者可以自行了解。

解决同步问题,最常见的方法是设置关键区(critical section)。关键区是一段连续代码,这段代码必须被连续执行,中间不得触发中断/线程切换:

chSysLock();
// 中断/线程切换不可触发,关键区开始 --->

if (now() - last_update_time > 2000) {
    int delta = now() - last_update_time;
}

// <--- 关键区结束
chSysLock();
// 中断/线程切换可触发

控制中断/线程切换,一般涉及系统状态(system state)、优先级(priority)。而在更广义的并行开发中,除了有关键区的概念,还有更广义的锁(lock)的概念。读写共享资源前获取锁,完成后释放锁,同样构造了一个关键区,依赖于同一个锁的关键区,任意时刻最多只能有一个在执行,而其他必须等待获取锁。锁是一种更为灵活的保护共享资源的方式,可以定义多个锁保护不同的资源,亦有互斥锁(mutex)、读写锁(read/write lock)等不同类别的锁。

我们先着重于中断/线程切换,展开介绍 ChibiOS 相关的支持。

ChibiOS 系统状态(System State)

以下内容来自 ChibiOS 官方文档:ChibiOS free embedded RTOS - Introduction to the RT Kernel,非常推荐各位阅读此篇文档,介绍了 ChibiOS 的基本架构。

The states have a strong meaning and must be understood in order to utilize the RTOS correctly.

我们着重关注以下几个状态:

  • Thread。ChibiOS RT 正在执行其中一个线程。普通(Normal)API 可以在这一状态中被调用。
  • ISR。ChibiOS RT 正在执行中断函数(Interrupt Serving Routine)。
  • S-Locked。ChibiOS 正在线程中的关键区(critical sections in thread context)。
  • I-Locked。ChibiOS 正在中断函数中的关键区(critical sections in ISR context)。

状态之间的切换显示在这张图中:

ChibiOS System States

简单的例子:

void GimbalSKD::SKDThread::main() {
    // Thread state
    setName("GimbalSKD");
    while (!shouldTerminate()) {
        // Thread state

        chSysLock();
        // S-Locked state
        chSysUnlock();
        
        // Thread state
        sleep(TIME_MS2I(SKD_THREAD_INTERVAL));
    }
}

在 Thread state 中,中断可以触发(IRQ),高优先级的线程可以打断低优先级的线程(保持 Thread state)。

在 S-Locked state 中,中断不可触发,线程不可被打断(context switch operation is always performed synchronously within a critical section)。

void Referee::uart_rx_callback(UARTDriver *uartp) {  // 裁判系统 UART 中断回调函数
    // ISR state

    chSysLockFromISR();
    // I-Locked state
    chSysUnockFromISR();

    // ISR state
}

中断触发时,系统从 Thread state 转移到 ISR state。ISR state 中,更高优先级的中断可以触发。

I-Locked state 中,中断不可触发。

正确使用 chSysLock/Unlock(FromISR)

首先,chSysLock 只能在 Thread state 中调用,chSysLockFromISR 只能在 ISR state 中调用。这也就意味着,不能连续调用两次 chSysLock 或 chSysLockFromISR。Unlock 同理。重复 lock/unlock 是造成 ChibiOS halt 的常见原因之一。

其次,lock 和 unlock必须匹配。chSysLock 后必须 chSysUnlock,chSysLockFromISR 后必须 chSysUnockFromISR,否则 ChibiOS 将保持在错误的 state 中。常见的问题是在关键区中使用了 return、break、continue,或者将 lock/unlock 放在 if 中。

再者,在不同的 state 中只能调用对应类别(class)的 ChibiOS API。主要关注以下几个类别的 ChibiOS API:

  • Normal functions。无特殊后缀,除非文档特别说明,只能在 Thread 中调用。
  • S-Class functions。函数名带 “S” 后缀,只能在 S-Locked 中调用。
  • I-Class functions。函数名带 “I” 后缀,只能在 I-Locked 中调用。
  • X-Class functions。函数名带 “X” 后缀,可在 Thread、S-Locked、I-Locked 中调用。

举个例子,我们调用 uartStartReceive 来开始下一轮 UART 接收。在线程中,启动第一次接收,调用的是无后缀的 uartStartReceive

void Referee::init() {
    // Start uart driver
    uartStart(UART_DRIVER, &UART_CONFIG);

    // Wait for starting byte
    rx_status = WAIT_STARTING_BYTE;
    robot_state.robot_id = 0;
    uartStartReceive(UART_DRIVER, FRAME_SOF_SIZE, &pak);
}

而在中断回调函数中,我们需要先进入 I-Locked,然后调用 uartStartReceiveI

void Referee::uart_rx_callback(UARTDriver *uartp) {
    
    chSysLockFromISR();  /// --- ENTER I-Locked state. DO NOT use LOG, printf, non I-Class functions or return ---

    // ...

    switch (rx_status) {
        case WAIT_STARTING_BYTE:
            uartStartReceiveI(uartp, FRAME_SOF_SIZE, pak_uint8);
            break;
        // ...
    }

    chSysUnlockFromISR();  /// --- EXIT I-Locked state ---

}

Shell 支持

Shell(包括 printf 等)需要缓冲区的支持,ChibiOS 提供的版本是基于 Thread state 设计的,例如,缓冲区满时,切换到其他线程。这也就导致了 printf、LOG 等函数只能在 Thread state 中调用

新版本 Meta-Embedded 引入了 printfI、LOG_I 等函数,这些函数可在 I-Locked 中调用。

模板与实际应用

同步是并行计算一个很重要的问题,没有容易的解决方法。关键区需要合理使用,不同步可能导致正确性问题,而过度同步可能导致性能问题(关键区内无线程调度,高优先级任务被迫推迟),因此关键区应经可能保持短小。而且,并非所有共享资源都需要保护,例如以下两个线程,同步是不需要的,因为只有 Thread 1 修改 a,而 Thread 2 只读不写。

// Thread 1
while (true) {
    a += 2;
    // ...
}

// Thread 2
while (true) {
    if (a > 42) {
        // ...
    } 
}

为了减少代码错误,推荐使用以下模板:

chSysLock();  /// --- ENTER S-Locked state. DO NOT use S/I-Class functions (LOG, printf) or return ---
{
    // Your code
}
chSysUnlock();  /// --- EXIT S-Locked state ---
chSysLockFromISR();  /// --- ENTER I-Locked state. DO NOT use non I-Class functions (LOG, printf) or return ---
{
    // Your code
}
chSysUnlockFromISR();  /// --- EXIT I-Locked state ---

使用三个 /,在 CLion 中会将注释高亮。使用额外的一组 {} 显示 critical section 的范围(同时会在 CLion 左侧增加一个折叠)。

更新历史

  • 2022.01.17 初始版本。liuzikai
Clone this wiki locally