跳转到内容

嵌入式系统/线程与同步

来自 Wikibooks,开放世界中的开放书籍

多任务和多线程是计算史上最有用的一次发展。这种技术并不总是对嵌入式系统工程师可用,但一些嵌入式系统和 RTOS 具有多线程 (MT) 能力。本节中的章节将讨论 MT 的一些用途,并将讨论与 MT 编程相关的一些常见陷阱。本页仅作为多线程编程的简要参考。

抢占式多线程

[编辑 | 编辑源代码]

当第一个多任务系统建立时,它们没有中央控制器。多任务是通过让程序自愿地放弃对系统的控制来建立的,然后系统会将控制权交给另一个进程。这种系统运行得相当好,除了任何行为不端的程序都会减慢整个系统的速度。例如,如果一个程序陷入无限循环,它永远不会放弃控制权,系统就会冻结。

解决这个问题的办法是抢占式多线程。在抢占式环境中,可以随时将控制权从一个进程转移到另一个进程。被“抢占”的进程甚至不会知道发生了任何事情,除了可能在 2 条指令之间出现比平时更大的延迟。抢占式多线程允许程序不主动放弃控制权,也允许计算机在单个进程挂起时继续运行。

与抢占式多线程相关的许多问题都源于这样一个事实,即当一个进程没有准备好放弃控制权时,控制权会被从它手中夺走。例如,如果一个进程正在写入一个内存位置,并且被抢占,下一个进程将看到半写的数据,甚至在该内存位置看到损坏的数据。或者,如果一个任务正在从一个输入端口读取数据,并且被抢占,时间就会不对,并且数据会从该行丢失。显然,这是不可接受的。

因此,解决这个问题的新方法是同步的概念。同步是由抢占式多线程操作系统提供的一系列工具,以确保避免这些问题。同步工具可以包括计时器、“临界区”和锁。计时器可以确保一个给定的进程可以被抢占,但只能在一定的时间内。临界区是代码中的命令,可以防止系统在一定时间内切换控制权。锁是命令,可以防止原子操作中的干扰。这些主题将在接下来的章节中讨论。

互斥锁

[编辑 | 编辑源代码]

术语互斥锁是“互斥”的简称,是抢占式环境中使用的一种机制类型,可以防止对当前正在使用的资源进行未经授权的访问。互斥锁遵循以下几个规则

  1. 互斥锁是系统范围内的对象,由内核维护。
  2. 互斥锁一次只能被一个进程拥有。
  3. 互斥锁可以通过要求内核将该互斥锁分配给当前任务来获取。
  4. 如果互斥锁已被分配,请求函数将被阻塞,直到互斥锁可用。

一般来说,尽快释放互斥锁被认为是良好的编程实践。互斥锁的一些问题将在死锁一章中讨论。

自旋锁

[编辑 | 编辑源代码]

自旋锁是一种快速同步方法。它以其行为命名 - 在条件为假时循环自旋。为了实现自旋锁,系统应该支持test-and-set习语或通过任何方式(屏蔽中断、锁定总线)提供对锁定线程的独占访问。

自旋锁的优点是它们非常简单。缺点是它们在循环等待时会浪费 CPU 周期。自旋锁最常见的用途是同步对对象的快速访问。在对一段代码进行自旋锁时,不建议进行长时间的计算。

临界区

[编辑 | 编辑源代码]

临界区是一系列计算机指令,如果被中断,可能会出现故障。原子操作是一系列计算机指令,不能被中断,并且可以正常运行。在实践中,这两个细微不同的定义通常会合并在一起。操作系统提供同步对象来满足这些要求,并且有些实际上将这些对象称为“临界区”、“原子操作”或“监视器”。

临界区的一个例子是从由中断填充的队列中删除数据的代码。如果临界区没有受到保护,中断可以在出队函数正在更改指针时发生,并破坏队列指针。原子操作的一个例子是 I/O 读取,其中进程必须以特定速率读取所有数据,并且在读取时不能被抢占。

一般来说,良好的编程实践是让程序尽快退出临界区,因为长时间保持临界区会导致系统中的其他进程无法获得任何时间,从而导致性能大幅下降。临界区应该谨慎使用。

优先级调度

[编辑 | 编辑源代码]

许多 RTOS 都有一个机制来区分不同任务的相对优先级。高优先级任务比低优先级任务更频繁地执行。但是,优先级调度的每个实现都会略有不同。

死锁发生在抢占式 MT 系统中的一系列同步对象被以这样一种方式持有,即任何进程都无法前进。让我们看一个例子

假设我们有 2 个线程:T1 和 T2。我们还有 2 个互斥锁,M1 和 M2。

  1. T1 请求并获取互斥锁 M1。
  2. T2 获取 M2。
  3. T1 请求 M2,系统将控制权转移到 T2,直到 T2 释放 M2。
  4. T2 请求 M1,系统陷入死锁(两个线程都不能继续,直到另一个释放它的互斥锁)。

这是一个非常难以诊断的问题,也是一个更难解决的问题。本章将提供一些关于如何防止死锁的一般性指导。

看门狗定时器

[编辑 | 编辑源代码]

参见嵌入式系统/看门狗定时器.

在没有锁的情况下读取计数器

[编辑 | 编辑源代码]

读取两次并比较

[编辑 | 编辑源代码]

也许嵌入式系统中最常见的并发算法是“读取计数器两次并比较”的乐观并发控制模式。

许多硬件计时器和硬件计数器通过 8 位总线连接到 CPU。如果计时器的低字节在开始读取计时器时恰好为 0xFF,如果计时器在两次字节读取之间递增,则简单地分别读取每个字节将得到一个损坏的值。[1] 如果先读取低字节,再读取高字节,或者反过来,我们将得到略微不同的损坏值,但无论哪种方式,它都是损坏的。

一个简单的解决方案是读取计数器两次并比较:[2][3][4][5][6][7][8][9][10][11][12]

long atomic_read_counter(volatile long *counter){
    long counter_old, counter_new;
    do{
        counter_old = *counter; // alas, not an atomic operation when the timer is connected to the CPU over an 8-bit bus.
        counter_new = *counter;
    }while( counter_old != counter_new );
    return counter_new;
}

或者

// "optimized" routine hard-wired to read a 16-bit Counter1:
// the entire routine takes 3 machine instructions on the 8051 -- see Craig Steiner, Abhishek Yadav, etc.
inline
int atomic_read_counter1(){
    byte upper, lower;
    do{
        upper = Counter1H;
        lower = Counter1L;
    }while( upper != Counter1H );
    return( (upper << 8) | lower );
}

由于没有使用锁,双重读取算法避免了死锁、优先级反转以及其他与锁相关的问题。

还有许多其他基于这种读取两次并比较算法的算法。

例如,Linux 中使用的 seqlock 使用这种读取两次并比较算法。[13][14]

例如,单读单写环形缓冲区在嵌入式系统中也很常见,读写器都可以使用类似的无锁算法实现。

递增计数器

[edit | edit source]

当使用读取两次并比较算法来同步多个进程(而不是上面提到的硬件计数器和读取计数器的进程)时,更改计数器的进程需要以对其他进程看起来原子化的方式进行更改。

许多架构都有一条指令可以原子地递增变量。可惜的是,确切的细节因架构而异,也因 C 编译器而异。一些相对可移植的方法来告诉 C 编译器原子地递增变量包括来自标准原子操作库的 std::atomic 模板类,它是 C 编程语言 C11 标准的一部分;[15] 或者 tbb::atomic 模板类;或者 boost::atomic 模板类;等等。

// rough draft -- untested code

__interrupt_handler h(){
        // ...
        // inside interrupt handler, interrupts are already turned off, so it's safe to increment the counter
        raw_counter++;
        // ...
}

// using CAS to update the counter is safe even if interrupts are turned on.
// inspired by example_atomic_inc() in
// https://www.kernel.org/doc/Documentation/atomic_ops.txt
void increment_counter(volatile long *counter){
    _Bool ret;
	do{
		long old = *counter;
		long new = old + 1;
		// requires #include <stdatomic.h>
		ret = atomic_compare_exchange_strong( counter, &old, new );
	}while(!ret);
}

// only use if interrupts are turned on, and it's not a multiprocessor machine:
void increment_counter(volatile long *counter){
    disable_interrupts();
        (*counter)++;
    enable_interrupts();
}

双重读取算法的主要问题是 ABA 问题。[16] 为了避免 ABA 问题,我们使用只向上计数的计数器,[17] 我们给计数器足够的位,以便在任何合理的时间内,线程可能在读取第一次读取的第一个字节和第二次读取的最后一个字节之间被延迟(例如,最大 1 秒),计数器足够长,以至于在最坏情况下(最高频率)计数率,它需要比这长得多的时间——例如,溢出所需的最短时间是每 10 秒一次。

例如,可以从 2 个计数器合成上下计数器,一个计数器只向上计数,另一个计数器只向下计数。

进一步阅读

[edit | edit source]
  1. 一些计时器,例如 Atmel AVR 计时器,通过硬件解决了这个问题。这些计时器有特殊的附加硬件,因此当程序读取低字节时,计时器的所有剩余字节会同时锁存到硬件临时寄存器中。之后,CPU 可以从临时锁存器中读取每个字节(而计时器本身继续计数)而不会出现损坏。Atmel。 "AVR072: Accessing 16-bit I/O Registers". 2002. 不幸的是,有时主应用程序需要读取一个使用硬件临时寄存器的 16 位寄存器,而中断例程需要读取(相同或其他)16 位寄存器,该寄存器会覆盖相同的临时寄存器。那么主应用程序偶尔会得到一个损坏的值。 "AVR Libc FAQ: Why do some 16-bit timer registers sometimes get trashed?"
  2. Epson Toyocom。 "Real Time Clock Module: Application manual". 第 21 页说“由于读取的数据没有被保存(数据可能正在改变),为了获得准确的数据,应该读取两次倒计时状态,然后比较。”
  3. Michael Silva。 "Introduction to embedded programming: Timers/Counters". “使用溢出时的潜在问题”部分。 (镜像).
  4. Nigel Jones。 "An unfortunate consequence of a 32-bit world".
  5. Opensolaris。 "clock.h: Locking strategy for high-resolution timing services".
  6. Milan Verle。 “8051 MCU 的架构和编程”。第 2 章:“8051 微控制器架构”。 2.6 节“计数器和计时器”。 “如何‘读取’计时器?”小节。 说:“……低字节被正确读取(255),但在程序计数器要读取高字节 TH0 时,发生了溢出,两个寄存器的内容都发生了改变(TH0:14→15,TL0:255→0)。这个问题有一个简单的解决方案。应该先读取高字节,然后读取低字节,最后再读取高字节。如果存储在高字节中的数字不同,则应该重复此序列。这只是一个程序中只有 3 条指令的短循环。……另一个解决方案……是……在读取时关闭计时器……并在读取完成后重新打开它。”
  7. "PICmicro Mid-range MCU family". 第 12.5.2 节:“在异步计数器模式下读取和写入计时器 1” 在 “示例 12-2:读取一个 16 位自由运行计时器”中说:“读取高字节……读取低字节……读取高字节[再次]”
  8. Nippey。 "Reading out a 16bit timer on an 8bit system without a latch for the high/low byte". Stackoverflow。 说“只要不发生在对低字节和高字节采样之间发生溢出,就一直采样”。
  9. James Rogers。 "The 8051 Timers". “动态读取计时器”部分。 “先读取高字节,再读取低字节,最后再读取高字节。如果两次读取的高字节不相同,则重复此过程。”
  10. Craig Steiner。 "The 8051/8052 Microcontroller: Architecture, Assembly Language, and Hardware Interfacing". 第 41 页。 第 8.2.6.1 节“读取计时器的值”。 说“程序先读取计时器的第一字节,然后读取低字节,最后再读取高字节。如果第二次读取的高字节与第一次读取的高字节不同,则重复此循环。在代码中,这将写成[3 条汇编语言指令]”。
  11. Abhishek Yadav。 "Microprocessor 8085, 8086". 第 463 页。 说“先读取计时器的第一字节,然后读取低字节,最后再读取高字节。如果第二次读取的高字节与第一次读取的高字节不同,则重复此循环。在代码中,这将写成[3 条汇编语言指令]”。
  12. Alka Kalra, Sanjeev Kumar Kalra。 "Architecture and Programming of 8051 Microcontroller". 2010. 第 5.5.8 节:“读取计时器的值”。 第 163 页。 “先读取计时器的第一字节,然后读取低字节,最后再读取高字节。如果第二次读取的高字节与第一次读取的高字节不同,则重复此循环。在代码中,这将写成[3 条汇编语言指令]”。
  13. 维基百科:seqlock
  14. Jonathan Corbet, Greg Kroah-Hartman, Alessandro Rubini。 “Linux 设备驱动程序”。第 "Alternatives to Locking" O'Reilly 2005 章。
  15. https://cppreference.cn/w/c/atomic
  16. 维基百科:ABA 问题
  17. 只向下计数的计数器也很好。
华夏公益教科书