单核开发板线程冲突:原理、场景与防护方法全解析
前阵子一位做设备的朋友给我发来一段代码用的是一块 STM32F407VE 开发板板子上跑着 FreeRTOS两个任务轮流往同一个全局计数器里累加数据。他告诉我这个计数器跑几分钟就会丢几个数最开始怀疑是芯片有问题后来换了板子、换了电源、查了晶振最后才发现问题出在代码本身。这个现象就是典型的单核处理器开发板上的线程冲突。很多人有一个直觉单核处理器同一时刻只能执行一条指令怎么会有线程冲突CPU 又没有真正并行两个任务又不是真的同时在跑为什么会“打架”如果你也这样想那今天这篇必须看完。我会结合裸机中断、RTOS 任务、Linux 多线程三种场景把单核开发板上的线程冲突原理、典型场景、防护手段和排查方法一次性讲透。不管你是刚学会点灯的新手还是正在调试复杂系统的老手这个坑几乎所有人都踩过。1. 单核处理器开发板上线程冲突到底怎么发生的1.1 先纠正一个直觉单核不是“一个跑完再跑另一个”单核处理器上的多任务靠的是调度器在时间上把 CPU 切成很多小片轮流分配给不同任务。从宏观上看好像多个任务同时在进行从微观上看任意一个瞬间只有一个任务在执行。这种模式叫并发concurrency不是并行parallelism。问题恰恰出在这里。你写代码的时候心里想的是“任务 A 执行完了任务 B 再执行”或者“这个函数从头跑到尾不可能被插一脚”。但实际运行中调度器可以在任意一条指令之后打断当前任务保存现场切换到另一个任务。最常见的打断源有两个周期性 tick 中断。比如 FreeRTOS 的 tick 默认 1ms 一次每次 tick 都可能触发任务切换。高优先级任务就绪。当一个更高优先级的任务等待的事件发生即使当前任务运行得好好的也会被立刻抢占。所以在单核上两个线程确实不会“同时”访问同一块内存但它们会在时间上交错访问。只要访问的顺序不对数据就会出现逻辑错误。线程冲突的根源不在于“同时”而在于“交错”。1.2 从一条指令看非原子操作冲突的物理根因那“交错”为什么会造成错误关键在于我们以为的“一步操作”在 CPU 看来其实是好几步。比如代码里最简单的一句g_counter g_counter 1;这句话看起来是一次但在 ARM Cortex-M 上它会被编译成三条汇编指令LDR R0, [R1] ; 把 g_counter 从内存读到寄存器 ADD R0, R0, #1 ; 寄存器里加 1 STR R0, [R1] ; 把结果写回内存问题来了如果任务 A 在执行完第一条 LDR 之后、还没执行 STR 之前被 tick 中断切走然后任务 B 也执行了同样的三条指令会发生什么我们用一个时间线来还原任务 A 读到 g_counter 100保存到寄存器准备加 1。tick 中断到达任务 A 被切换出去。任务 B 运行读到 g_counter 100加 1写回 101。调度器切回任务 A任务 A 的寄存器里保存的仍然是 100它加 1 后写回 101。两次累加结果却只加了 1。这就是“丢数据”的本质原因。整个读-改-写过程如果中间被切走就会用旧值覆盖新值。这类复合操作在计算机领域有个专门的名字叫非原子操作。一个操作要成为原子的必须一口气执行完中间不能被任何东西打断。这个道理放到生活里也很好理解。假设你和同事共用一本账本登记流水你现在登记到第 100 条刚写下“100”还没写“1101”电话响了你去接。同事过来看到账本最后一行是 100他接着写了 101。你回来以后不记得自己写到哪看着手头的 100又补了一行 101。最终账本上只有一条 101但实际业务发生了两笔。这就是交错执行导致的更新丢失。2. 三个必须认识的冲突触发源2.1 读-改-写复合操作上面计数器的例子就是最典型的读-改-写操作。在嵌入式开发里这种操作多到数不过来计数器累加比如统计报文数量、错误次数。修改结构体的某一个字段比如dev.status | 0x01。更新一个全局变量的值而这个值依赖其它任务刚写入的结果。只要这个“读-改-写”过程不是原子的在单核上就存在理论上的冲突窗口。窗口越大出问题的概率越高。注意这里说的概率不是玄学而是确定性事件。你的 tick 中断是固定频率的任务切换是确定会发生的只是切换点具有随机性。只要运行时间足够长总有一次切换会落在读和写之间。这就是为什么这类 bug 往往“跑几分钟才出现一次”特别难复现。2.2 多字节变量的撕裂读写读-改-写是复合操作还有一个更容易被忽略的坑多字节变量的读写本身就不是原子的。看硬件规格STM32F407 是 32 位处理器内部寄存器和数据总线是 32 位。它读一个 32 位的 int 变量一条 LDR 指令就能完成写一个 32 位 int一条 STR 指令也能完成。但如果变量是 64 位比如uint64_t或者double32 位处理器必须分两次读写一次读低 32 位一次读高 32 位。同样如果你定义一个很大的结构体一次性赋值给另一个结构体变量编译器会生成一批拷贝指令中间也能被打断。这种撕裂读写带来的后果非常隐蔽。假设一个任务正在更新一个 64 位时间戳变量刚写完低 32 位还没写高 32 位被切走了。另一个任务这时来读这个变量就会拿到一个“高 32 位是旧值、低 32 位是新值”的混合数据。这个数据既不是旧值也不是新值完全是一个不存在的数字。程序拿到这种脏数据去计算、去比较结果自然乱七八糟。嵌入式场景里常见的撕裂读对象包括float32位下本来没问题但如果你在 8 位或 16 位单片机上一个 float 就要拆好几次、double、uint64_t时间戳、以及各种结构体。在 FreeRTOS 这些 RTOS 上很多人只给简单变量加锁却忽视了结构体。这是最要命的结构体字段越多、越大撕裂窗口就越大。2.3 编译器优化和 DMA 带来的隐性问题除了调度切换还有两个容易被人忽略的“隐形杀手”。第一个是编译器。C 语言标准里编译器默认假设变量只在当前执行流中被修改。如果一个全局变量在某个任务里被频繁使用编译器可能把它优化到寄存器里而不是每次都从内存读取。如果另一个任务在中断里修改了这个变量前一个任务却一直在用寄存器里的“旧缓存”就会读取到过期数据。这种情况靠看代码很难发现因为代码逻辑完全正常问题出在编译优化。解决办法是使用volatile关键字告诉编译器“这个变量可能在别处被修改请每次都去内存读取”。但注意volatile只能解决“编译器不知道别人会改这个变量”的问题解决不了读-改-写的非原子冲突。很多人以为加上 volatile 就万事大吉实际上它管不了调度交错。第二个是 DMA。严格来说DMA 不属于线程但它是一个独立于 CPU 的“主设备”可以直接访问内存。如果 DMA 和 CPU 同时访问同一块缓冲区虽然没有线程切换但存在真正的硬件并行访问。比如你用 DMA 接收串口数据同时主循环里在处理上一次收到的数据处理到一半 DMA 又写入了新数据那你处理的可能就是一半旧数据、一半新数据的混合物。解决思路要么用双缓冲让 DMA 写一块、CPU 读另一块要么用信号量通知“数据已处理完可以写入下一批”。3. 四种典型冲突场景与修复前后对照3.1 场景一两个任务抢同一个计数器这是最经典的场景也是我开头提到的案例。复现代码大概长这样volatile uint32_t g_counter 0; void Task_Producer(void *arg) { for (;;) { g_counter g_counter 1; vTaskDelay(pdMS_TO_TICKS(10)); } } void Task_Consumer(void *arg) { for (;;) { g_counter g_counter 1; vTaskDelay(pdMS_TO_TICKS(10)); } }两个任务优先级相同开了时间片轮转调度tick 为 1ms。各跑一段时间后g_counter 的实际值会明显小于理论累加值。修复方案很简单要么把累加点包进临界区void Task_Producer(void *arg) { for (;;) { taskENTER_CRITICAL(); g_counter g_counter 1; taskEXIT_CRITICAL(); vTaskDelay(pdMS_TO_TICKS(10)); } }要么用互斥量static SemaphoreHandle_t xMutex; xMutex xSemaphoreCreateMutex(); void Task_Producer(void *arg) { for (;;) { xSemaphoreTake(xMutex, portMAX_DELAY); g_counter g_counter 1; xSemaphoreGive(xMutex); vTaskDelay(pdMS_TO_TICKS(10)); } }修复后累加操作要么一口气执行完要么在没拿到锁的时候等别人用完不会再出现覆盖的情况。3.2 场景二采集任务和发送任务共享缓冲区很多开发板项目都有这样的结构一个任务负责采集传感器数据另一个任务负责把数据通过串口或者网络发出去。如果直接用共享结构体代码如下typedef struct { float ax; float ay; float az; uint32_t timestamp; } imu_data_t; imu_data_t g_imu;采集任务每 10ms 更新一次 g_imu发送任务随时读取并组帧。由于结构体包含 4 个字段在 ARM 上整个赋值需要多条指令。发送任务完全可能在采集任务刚写完 ax、还没写完 ay 的时候插进来于是发出去的数据帧里ax 是新的、ay 和 az 是旧的时间戳也可能对不上。这种数据帧发到上位机你会看到曲线偶尔出现一个毛刺或者姿态偶尔跳一下。它不频繁但极难排查。改造方案最推荐用消息队列把“共享内存”改成“寄信”QueueHandle_t xImuQueue; xImuQueue xQueueCreate(4, sizeof(imu_data_t)); // 采集任务 imu_data_t data; data.ax read_ax(); data.ay read_ay(); data.az read_az(); data.timestamp now(); xQueueSend(xImuQueue, data, pdMS_TO_TICKS(10)); // 发送任务 imu_data_t rx_data; if (xQueueReceive(xImuQueue, rx_data, portMAX_DELAY) pdPASS) { send_frame(rx_data); }队列内部已经用临界区保护好了读写操作。采集任务写完整个结构体之后才发送发送任务从队列里拿到的永远是一个完整的数据拷贝不会出现半新半旧。用队列还有一个额外好处天然解决了“发送任务处理慢、采集任务覆盖数据”的问题——如果队列已满采集任务可以选择丢弃或者阻塞等待而不是直接覆盖旧数据。3.3 场景三中断置标志主循环查询即使不用 RTOS裸机开发也一样会遇到线程冲突。裸机里的“线程”就是主循环和中断服务函数。举个最常见的例子volatile uint8_t g_event_flag 0; void EXTI_IRQHandler(void) { g_event_flag 1; // 按键中断里置位 } int main(void) { while (1) { if (g_event_flag) { g_event_flag 0; handle_event(); } } }这个代码看起来没什么问题。但有一个隐蔽的坑如果中断在主循环检测到 g_event_flag 为 1、准备清除之前又触发了一次新事件会被直接清掉。也就是说“检测和清除”之间存在一个非原子窗口。正确做法是在临界区里清除标志while (1) { uint8_t flag_snapshot 0; __disable_irq(); if (g_event_flag) { g_event_flag 0; flag_snapshot 1; } __enable_irq(); if (flag_snapshot) { handle_event(); } }把检测和清除变成一个不可打断的整体。还有一种更简单的思路中断里只负责给事件“记账”把事件放入一个环形队列主循环慢慢处理。这样事件既不会丢也不会重复处理中断服务函数也变得更短小。3.4 场景四多字段状态结构体加 volatile 也救不回来再举一个更隐蔽的例子。一个设备状态结构体typedef struct { uint8_t mode; uint16_t value; } device_state_t; volatile device_state_t g_state;任务 A 负责更新状态g_state.mode 2; g_state.value 500;任务 B 负责读取并依据 mode 解释 valueif (g_state.mode 2) { use_value(g_state.value); }这里即使加上了 volatile仍然有问题。任务 A 先更新 mode再更新 value如果任务 B 恰好在两次更新之间读取它看到的可能是 mode 已经是 2、value 还是旧值然后拿着旧的 value 去执行“模式 2”的逻辑得到错误结果。这种“多字段组合一致性”问题单靠 volatile 解决不了必须从整体上加锁。一个简单办法是把整个状态封装成临界区保护的读写函数void set_state(uint8_t mode, uint16_t value) { taskENTER_CRITICAL(); g_state.mode mode; g_state.value value; taskEXIT_CRITICAL(); } void get_state(device_state_t *out) { taskENTER_CRITICAL(); *out g_state; taskEXIT_CRITICAL(); }或者用序列号机制每次更新结构体时附带一个自增的版本号。读取方先读版本号再读数据再读一次版本号如果两次版本号不一致就重读。这是无锁编程里 seqlock 的简化思路。4. 单核下的保护机制怎么选4.1 关中断和临界区杀伤力最大的原始武器在单核处理器上最直接的互斥手段就是关中断。为什么关中断有用因为单核上的任务切换完全依赖中断tick 中断或者其它唤醒中断来触发。把中断一关调度器就进不来当前任务就可以安心执行一段完整代码。在裸机里__disable_irq()和__enable_irq()就是最简单的临界区。在 FreeRTOS 里推荐用taskENTER_CRITICAL()和taskEXIT_CRITICAL()。它内部会关中断同时用一个嵌套计数器记录进入了几次退出时必须完全匹配。这个 API 的优点是很轻量适合保护几十条指令以内的短临界区。但它有几个限制必须记住临界区里不能调用任何可能阻塞的 API。比如vTaskDelay、xQueueReceive一旦调用系统直接卡死或者断言报错。关中断的时间不能太长。如果你把一个耗时 100ms 的计算放进去整个系统的实时性就毁了。中断被屏蔽期间串口数据可能溢出、电机控制可能抖动。关中断关的是所有可屏蔽中断。如果你只想屏蔽低优先级中断、保留高优先级中断应该用 FreeRTOS 提供的taskENTER_CRITICAL_FROM_ISR系列或者用 Cortex-M 的 BASEPRI 寄存器按优先级屏蔽。我的经验是临界区只用来保护“几条指令就能完成”的操作比如变量赋值、表项插入、标志位置位。需要长时间持锁的场景换互斥量。4.2 互斥量与优先级反转为什么单核也需要锁既然关中断能做到互斥那为什么还需要互斥量因为关中断有三个做不到的事它不能“等”。如果你的共享资源需要长时间占用比如一个外设初始化流程要好几毫秒你不能一直关着中断等。它不能“排队”。多个任务抢一个资源时关中断没法实现公平调度。它不能解决“当前任务还没执行完、资源就被别的任务改掉”这种需要阻塞等待的场景。互斥量Mutex就是为这种场景设计的。它本质上是一个“锁”谁拿到锁谁就能进入临界区没拿到的任务会被挂起阻塞等锁释放后再继续。在 FreeRTOS 中创建互斥量的代码是SemaphoreHandle_t xLock xSemaphoreCreateMutex();使用的时候xSemaphoreTake(xLock, pdMS_TO_TICKS(100)); // 最多等 100ms // 临界区代码... xSemaphoreGive(xLock);注意xSemaphoreTake的第二个参数是等待时间。设置为portMAX_DELAY表示永久等待但要小心死锁——如果两个任务互相等对方手里的锁就会永久卡住。互斥量还有一个非常重要的特性优先级继承。嵌入式课程里都会讲到优先级反转问题低优先级任务持锁中优先级任务抢占 CPU导致高优先级任务虽然就绪却拿不到锁响应被无限期拖慢。FreeRTOS 的互斥量在创建时会启用优先级继承如果高优先级任务正在等一把锁而锁被低优先级任务持有系统会临时把低优先级任务的优先级提到和等高优先级任务相同让持有锁的任务尽快跑完并释放锁减少反转时间。这里有个硬性规范互斥量不能在中断服务函数里使用。因为中断里不允许阻塞等待如果锁被占用xSemaphoreTake在中断里无法挂起调用会直接断言。中断里要同步应该用带 FromISR 后缀的 API或者只做标志位/队列操作。4.3 用队列换思维别共享内存把数据“寄过去”在 RTOS 里最推荐的跨任务通信方式不是共享内存而是消息队列。队列最大的好处是数据是拷贝式传递的。发送方把数据复制到队列里接收方从队列里复制出去。队列内部的所有读写操作已经由内核用临界区保护好了你不需要自己加锁。这个设计理念很值得琢磨。它强制你想清楚数据的“归属权”每个数据在某一时刻只属于一个执行流。发送方写完就放手接收方拿到就是自己的不存在两个任务同时改同一块内存的问题。队列使用也很简单QueueHandle_t xQueue xQueueCreate(10, sizeof(uint32_t)); // 发送任务 uint32_t value read_sensor(); xQueueSend(xQueue, value, pdMS_TO_TICKS(10)); // 接收任务 uint32_t received; xQueueReceive(xQueue, received, portMAX_DELAY);如果你要传递的数据比较大比如一个 1KB 的音频帧每次拷贝开销很大。这时可以退一步队列里只传递指针指向一个预先分配好的内存池。但这样又回到了共享内存的老路需要你自己加额外的同步。所以一般来说几百字节以内的结构体直接用队列最省心。4.4 原子操作和无锁环形队列在一些性能敏感的路径上锁太贵了关中断又影响实时性。这时候可以考虑原子操作。在单核上一条指令本身就是原子的。比如给一个 32 位变量赋新值一条 STR 指令就完成不需要加锁。C 语言和编译器也提供了一些原子内建函数比如 GCC 的__atomic_add_fetch(g_counter, 1, __ATOMIC_SEQ_CST);这个函数在 ARM Cortex-M 上会生成 LDREX/STREX 指令序列即使在执行过程中被中断打断重新执行一遍就能保证最终结果正确。还有一个非常实用的无锁设计单生产者、单消费者的环形队列。只要遵守规则生产者只修改写指针消费者只修改读指针而且每个指针的更新用原子操作保证就可以做到无需加锁、且互不干扰。我在很多串口驱动和日志系统里都见过这种设计性能极高逻辑也清晰。4.5 延伸单核 Linux 开发板上的 pthread 同步前面讲的都是 MCU 上的 RTOS 或裸机场景。但很多开发板跑的是 Linux比如 i.MX6ULL 这类单核 Cortex-A7 开发板。这里同样存在线程冲突而且原理完全一致Linux 内核的调度器会在任意一个调度点切换线程counter照样不是原子的。用 pthread 写多线程时标准做法是加互斥锁int shared_counter 0; pthread_mutex_t mutex PTHREAD_MUTEX_INITIALIZER; void *thread_func(void *arg) { for (int i 0; i 10000; i) { pthread_mutex_lock(mutex); shared_counter; pthread_mutex_unlock(mutex); } return NULL; }如果你不加锁跑两个线程各累加一万次最终结果大概率不是 20000而是小一些的数。这个实验在很多开发板上都能复现。Linux 下比 MCU 更复杂的一点是编译器优化更激进甚至会出现指令重排。你在代码里写的顺序经过编译器和 CPU 优化后实际执行的顺序可能不一样。所以生产代码应该尽量用 C11 的stdatomic.h原子库或者干脆用内核/库提供的同步原语不要自己抠裸变量。5. 调试与排查实录5.1 用 GPIO 和逻辑分析仪给冲突“拍照”线程冲突这类偶发问题最难的不是修而是定位。我之前调试一个 T113 开发板上的 RTOS 项目时一个共享标志总是偶发失效怎么加日志都抓不到。后来想了一个笨办法在可疑代码段的入口和出口各翻转一个空闲 GPIO用逻辑分析仪以高采样率抓取这个引脚的波形。如果这段代码从头到尾没被抢占那入口拉高、出口拉低波形应该是一个干净的矩形。但如果代码执行到一半被其他任务抢占了逻辑分析仪上就能看到一个“毛刺”——电平在任务切换时发生跳变。通过测量毛刺的位置和频率我很快定位到了被抢占的具体代码行。这个方法在裸机和 RTOS 下都通用成本极低效果却奇好。还有一个经验排查线程冲突时尽量少用调试器的断点。单核系统里断点本身会暂停整个 CPU相当于把所有任务都冻结了冲突的时序完全被改变。很多偶发问题一进调试器就消失退出调试器又出现就是断点改变了时序。GPIO 探针不会改变时序比断点可靠得多。5.2 内核自带的排查工具与关键指标FreeRTOS 提供了一些统计信息里面藏着很多线索。比如uxTaskGetStackHighWaterMark()检查任务栈余量。如果某个任务的栈高水位很低说明栈快溢出了栈溢出会踩坏相邻内存导致共享变量被“神秘修改”。vTaskList()或uxTaskGetSystemState()查看每个任务的状态、优先级、运行时间。如果某个任务一直处于 Running 状态说明它可能在临界区里死循环或者优先级设置有问题。FreeRTOS 的configCHECK_FOR_STACK_OVERFLOW宏开启栈溢出检测能在溢出发生的瞬间触发钩子函数方便记录现场。Linux 开发板上的话gdb 加thread apply all bt可以看到所有线程的调用栈定位死锁非常简单。更重量级的工具有 Valgrind 的 helgrind 模块以及 GCC 的 ThreadSanitizer编译时加-fsanitizethread它们能自动检测数据竞争直接告诉你哪一行代码访问的哪一块内存存在冲突。我在单核 Linux 板子上用 ThreadSanitizer 查过一个共享链表的问题编译后跑一遍压力测试报告直接指到了问题代码省了很多事。5.3 常见冲突问题速查表现象可能原因排查手段计数器偶发少计读-改-写操作被任务切换打断反汇编看指令加临界区或互斥量串口数据帧偶发乱码多字节结构体撕裂读写改用队列传递整包数据高优先级任务响应延迟优先级反转低优先级任务持锁换带优先级继承的互斥量任务卡死、不再运行死锁两个任务互相等待对方锁给xSemaphoreTake加超时开超时统计全局变量“凭空”变值任务栈溢出或数组越界踩到相邻变量开栈溢出检测加金丝雀变量变量总是不更新编译器优化寄存器缓存了旧值加volatile或使用原子操作中断里调用xSemaphoreTake导致崩溃中断里不能阻塞等待改用GiveFromISR系列或队列再加一个实战技巧给可疑共享变量加“金丝雀值”。在变量前后各放一个已知的固定值比如0xA5A5A5A5跑一段时间后检查金丝雀是否被改写。如果被改写说明发生了越界访问或者栈溢出。这个方法在嵌入式里便宜好用几乎每个老工程师都用过。5.4 那些“看起来像线程冲突其实不是”的坑排查线程冲突时容易陷入一个思维定式只要数据不对就觉得是任务之间打架。实际上很多问题根本不是线程冲突。数组越界写。比如一个uint8_t buffer[10]你写入了第 11 个元素。它可能正好落在共享变量所在的内存地址上把变量改得面目全非。表面上看是“共享变量被无缘无故修改”实际上是你自己用错了索引。任务栈溢出。FreeRTOS 默认不会检查栈溢出任务栈溢出后数据会写到任务控制块或者其它全局区域现象千奇百怪。开栈溢出检测或者把任务栈调大一点试试排除这个因素。DMA 和 CPU 的竞争。前面提过DMA 是独立于 CPU 的主设备哪怕只有一个核DMA 和 CPU 也是真正的并行。这种问题用线程锁保护不了需要用双缓冲或者专门的同步机制。浮点运算的精度问题。有些数据跳变并非被抢占而是浮点精度或者滤波算法本身造成的。先把嫌疑代码“关掉”看看数据是否正常再继续排查。这听起来很简单但很多人一上来就查锁和临界区忽略了这个基本步骤。排查顺序我建议这样先排除硬件干扰和内存越界再确认是不是多执行流交错最后才考虑加锁。上来就乱加锁反而会把问题搞得更难查。我个人在实际项目里有一个习惯从写第一版代码开始就给每个跨任务访问的变量建一张表明确记录“谁在写、谁在读、用什么同步方式”。这张表不一定要写进文档写在代码注释里也行。等到出问题时直接查表定位基本上几分钟就能确定哪些变量没有防护。线程冲突这类 bug最尴尬的点在于它不是每次必现而是看运气。如果一开始就不留防护缺口运气差的风险就被提前排除了。最后再分享一个小技巧如果你确定某段代码存在冲突但修好之后想验证“确实修好了”不要只跑一遍。想办法提高冲突概率——把两个任务的执行频率调高、把 tick 调快、把操作的数据变大、让临界区执行时间变长——用压力测量的方式去复现。能稳定复现才能稳定验证。偶发问题一旦能稳定复现离解决就只差一步了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →