1. 单核处理器上的线程冲突,到底在冲突什么
先说一个很多人容易绕进去的误区:一提到线程冲突,脑子里浮现的画面往往是“多个线程在多个核上同时跑,抢同一块数据”。但单核处理器上压根就没有“同时执行”这回事——CPU 在一个时刻只能跑一条指令,所谓的多线程,不过是操作系统把时间切成很多片,轮流分给不同的线程去执行。
那问题来了:既然同一时刻只有一个线程在跑,为什么还会产生线程冲突?
答案在于“抢占”和“交错”。线程 A 执行到一半,时间片到了,操作系统把它挂起,把寄存器、程序计数器这些现场保存好,然后切换线程 B 上来跑。如果线程 B 恰好也要访问同一个变量、同一个外设寄存器、同一个缓冲区,麻烦就来了。比如线程 A 刚刚把某个变量的值从内存读到了寄存器里,还没来得及写回去,就被切走了;线程 B 跑过来把那个变量改了;等线程 A 再被切换回来,它寄存器里存的还是旧值,直接拿这个旧值去计算、去写内存,结果就是数据被覆盖、逻辑错乱。
这就是单核处理器上线程冲突最典型的场景:不是同时争抢,而是交错访问导致的不一致。
把这个问题放到开发板上,就更值得重视了。开发板跟 PC 不一样,它上面跑的是嵌入式系统,资源紧张、实时性要求高、外设操作频繁。你以为的“多线程并行处理”,实际上是系统在背后疯狂地做上下文切换。一旦临界区没保护到位,轻则数据错乱,重则系统崩溃、外设死锁。像 T113、ESP32S3、STM32MP157 这类常见开发板,I2C 通信、RS485 收发、GPIO 控制、串口打印,哪一块不是线程冲突的高发区?
所以这篇文章就围绕“单核处理器开发板上的线程冲突”这个话题,从原理到实操,把抢占式调度、临界区、原子操作、互斥锁、自旋锁、关中断这套东西讲透。不是泛泛而谈概念,而是落到开发板上能直接用的代码和排查方法。
适合谁看?正在用开发板做嵌入式开发、写 RTOS 或 Linux 驱动、被“莫名其妙的数据错乱”折腾得头秃的人。看完你至少能回答三个问题:单核上为什么会冲突、用什么手段能避免冲突、冲突已经发生了怎么查。
2. 冲突的根源:抢占式调度与共享资源的三角关系
2.1 时间片轮转与上下文切换的真正代价
单核处理器上,操作系统实现“多线程”的核心机制是时间片轮转(Round-Robin)加优先级抢占。每个线程被分配一个时间片,比如 10 毫秒,时间一到,定时器触发中断,操作系统内核在中断上下文里完成线程切换。
这个切换过程叫上下文切换(Context Switch)。它要干的事比你想象的多:保存当前线程的寄存器组、栈指针、程序计数器、浮点寄存器状态,然后加载下一个线程的整套状态。这些操作本身不产生任何业务价值,纯粹是开销。更关键的是,切换的时机完全不可预测——你没法精确知道线程 A 执行到哪条指令时会被切走。
这就引出了单核并发的第一个本质:线程的执行是离散的、可中断的,而你对共享资源的操作往往需要连续的、不可分割的。比如“读取变量-修改变量-写回变量”这三步,在高级语言里是一行代码,但编译成汇编可能是三条指令。三条指令之间,线程随时可能被切走。
我举个开发板上特别常见的例子。ESP32 上用两个任务同时调用printf往串口打印日志:
// 任务1 void task1(void *arg) { while (1) { printf("Task1: count = %d\n", count++); vTaskDelay(10); } } // 任务2 void task2(void *arg) { while (1) { printf("Task2: count = %d\n", count--); vTaskDelay(10); } }表面上看没什么问题,但实际跑起来你会发现串口输出经常变成乱码,比如Task1: count = Task2: count = 10099挤在一行。原因就是printf内部要往串口 FIFO 写数据,写到一半任务被切换,另一个任务也往同一个串口写,两组字符在 FIFO 里交错在一起。
这只是打印日志,问题还不算严重。如果是两个线程同时操作一个全局结构体、一个 DMA 缓冲区、一个 Flash 的写地址,那后果就不是乱码了,是数据永久损坏。
2.2 临界区与竞态条件:三条指令之间的致命窗口
先定义两个核心概念,后面所有内容都围绕它们展开。
临界区(Critical Section):访问共享资源的代码段,比如上面count++那一段。这段代码在任意时刻只允许一个线程进入。
竞态条件(Race Condition):多个线程的执行结果依赖于它们之间执行的相对顺序,而这个顺序不受控制。
以count++为例,在 ARM Cortex-A7 这类单核 CPU 上,编译后的指令大致是:
LDR R0, [count_addr] ; 把 count 的值加载到寄存器 R0 ADD R0, R0, #1 ; R0 加 1 STR R0, [count_addr] ; 把新值写回内存如果线程 A 执行完LDR和ADD,还没来得及STR,就被切走了。此时线程 B 进来,把 count 从 100 改成 50。等线程 A 恢复,继续执行STR,把 101 写回去——线程 B 的修改彻底丢失。执行了两次递增,结果只加了 1。
这就是教科书上说的“读-改-写”非原子性。问题不在于“两个线程同时在改”,而在于“两个线程交叉着改,互相不知道对方改了什么”。
提示:即便是最简单的
i++,高级语言一行,底层也不是原子的。千万不要觉得“就一条语句,怎么可能出错”。
2.3 为什么单核上仍然需要同步机制:实时系统的三个典型场景
有人可能会想:既然同一时刻只有一个线程执行,那我只要在临界区里不发生任务切换,不就行了吗?
想法是对的,这也确实是单核上解决线程冲突的根本思路。但问题在于:什么情况下会发生任务切换?至少三种:
第一,时间片耗尽。这是最被动的一种。任务在临界区里还没执行完,时间片到了,定时器中断来了,任务被强制切换。所以,临界区代码不能长,一定要短小精悍,否则就算有锁也挡不住时间片抢占。
第二,更高优先级任务就绪。嵌入式实时系统里普遍使用优先级抢占调度。假设任务 A 的优先级是 5,正在临界区里跑,任务 B 的优先级是 9(数字越大优先级越高),B 在等待的某个事件发生了(比如串口收到数据触发中断),调度器立刻把 CPU 从 A 切给 B。A 被挂起在临界区中间,B 又去访问同一个资源——冲突。
第三,中断打断。这里要特别强调,在单核处理器上,中断服务程序(ISR)和普通任务之间也会产生冲突。中断可以打断任何任务的执行,包括正在临界区里的任务。如果中断处理程序里也去访问任务正在操作的共享变量,那就是任务上下文和中断上下文之间的竞态,这种问题比任务间冲突更隐蔽、更难查。
所以,单核处理器不是不需要同步,而是同步手段和策略跟多核不一样。多核要考虑“两个核同时真的在跑”,要用自旋锁、原子指令;单核上,很多情况下用“关中断”或者“关调度”就能解决问题,代价小得多。
我见过不少从 PC 端转来做嵌入式开发的程序员,一上来就在裸机或者 RTOS 上搬 pthread 那套互斥锁,结果发现锁的开销比业务本身还大,而且很容易死锁。这就是没理解单核冲突的本质——你要防的不是“同时执行”,而是“交错执行”。
3. 开发板线程冲突的四大高发场景
开发板上的线程冲突,跟 PC 上写业务系统还不太一样,它离硬件更近,很多冲突点藏在底层外设操作里。我按实战中踩坑的频率整理了四个最典型的场景。
3.1 控制台与日志输出:最容易被忽视的临界区
串口打印是线程冲突的重灾区,因为串口本身就是个慢速外设。以 STM32F407 为例,串口波特率 115200,每秒最多传输约 11520 字节,也就是 11.5KB/s 左右。相比 CPU 动辄上百 MHz 的主频,慢了好几个数量级。往串口写数据的操作,需要不断查询发送数据寄存器是否为空,这个过程中任务随时可能被切换。
更重要的是,串口打印往往分布在系统各处——错误处理里打、状态切换里打、数据处理里打。你为了调试方便加的日志,反而可能引入一堆诡异的现象:输出乱码、行内容交错、甚至程序跑飞。
解决思路有几个层次:
- 轻量方案:所有日志输出经过一个全局字符缓冲区的互斥保护,或者直接用一个串口驱动层的互斥锁;
- 更优方案:日志系统采用“生产者-消费者”模型,各任务只把日志消息丢进环形缓冲区,由一个专门的日志任务统一输出;
- 终极方案:日志级别分级,正式发布版本把调试日志关掉。
我在用 ESP32 开发时踩过一次印象很深的坑。两个任务高频打印,跑一段时间后整个系统重启,查崩溃日志发现是栈溢出。后来定位到原因:printf的重入导致内部缓冲区状态被破坏,最终把栈搞爆了。从那以后,凡是涉及串口的工程,我一定会在驱动层加锁,绝不裸调。
3.2 总线与外设共享:I2C、RS485、SPI 的多线程访问
开发板上的外设总线,尤其是 I2C 和 RS485,天生就是多设备共享的通信介质。而多线程环境下,最典型的问题就是:多个线程同时向同一条总线发起传输。
举个例子,一个用 T113 做的数据采集板,I2C 总线上挂了温湿度传感器和 RTC 时钟芯片。线程 1 每隔 1 秒读一次温湿度,线程 2 每隔 1 分钟同步一次 RTC 时间。两个线程都走 I2C 总线,如果不加保护,大概率会出现:
- I2C 通信时序被打断,设备无响应;
- 读到错误数据,而且随机出现;
- 总线控制器状态错乱,必须复位整个 I2C 外设才能恢复。
RS485 的情况更特殊,它是半双工通信,同一时刻只能有一个方向在发送。发送和接收的切换需要控制方向引脚(通常是 DE/RE 引脚),这个切换过程如果被其他线程打断,总线直接冲突。
针对这套场景,我的做法是给每条总线维护一个“总线锁”,所有访问该总线的操作都通过统一的 API 加锁执行,锁粒度为“整个事务”,而不是“单个字节”。比如读温湿度传感器,从发起起始信号到接收最后一位,整个过程必须在一个临界区内完成,中途不能被其他线程插进来。
3.3 全局数据缓冲:生产者、消费者与共享状态
这也是开发板上非常经典的冲突场景:一个线程采集数据(生产者),一个线程处理数据(消费者),中间共享一块缓冲区。
跑裸机或者 RTOS 的时候,很多人图省事,直接用全局数组加一个全局索引,简单粗暴:
uint8_t buffer[1024]; uint16_t data_count = 0; // 生产者 void sensor_task(void) { buffer[data_count] = read_sensor(); data_count++; } // 消费者 void processing_task(void) { if (data_count > 0) { uint16_t idx = data_count - 1; process_data(buffer[idx]); data_count--; } }这个代码问题极多。data_count同时被两个任务读写,又是一个典型的“读-改-写”竞态。假设data_count当前是 5,生产者刚把数据写到buffer[5],还没来得及把data_count改成 6;此时消费者线程运行,发现data_count还是 5,于是去读buffer[4]——这个位置的数据可能还没准备好,读到的是旧值或者垃圾值。
更稳妥的做法是用无锁环形缓冲区(Ring Buffer),通过读指针和写指针配合,利用单生产者单消费者模型天然避免冲突。但即便用环形缓冲区,也要注意读写指针的更新必须要用原子操作,比如关中断后更新。
3.4 中断上下文与任务上下文之间的隐形冲突
这类冲突最隐蔽,因为它不像任务间冲突那样有线程切换的痕迹,查起来极其痛苦。
典型场景:外部中断服务程序里修改了一个标志位,主循环或任务里读取这个标志位做逻辑判断。看起来只是读一个全局变量,能有什么问题?但如果你在中断里做了更复杂的操作,比如:
volatile uint32_t event_flags = 0; void EXTI_IRQHandler(void) { event_flags |= EVENT_BUTTON_PRESSED; // 其他处理 } void main_task(void) { if (event_flags & EVENT_BUTTON_PRESSED) { // 处理按键事件 event_flags &= ~EVENT_BUTTON_PRESSED; } }event_flags |= EVENT_BUTTON_PRESSED在汇编层面是“读-或-写”三步。如果主任务恰好同时也在改event_flags(比如清除某个标志位),就可能出现:中断读到旧值,做或运算,写回去时把主任务刚置位的其他标志位覆盖掉了。
虽然用volatile能让编译器每次都从内存读取而不是用寄存器里的缓存值,但volatile只解决了“可见性”,没解决“原子性”。解决中断上下文和任务上下文冲突的标准手段是:中断里尽量只做“标记”操作,具体业务逻辑放到任务里做;如果必须在中断里修改复杂的共享数据,那就需要暂停中断或者使用临界区保护。
4. 单核上解决线程冲突的四种手段,从轻到重排个序
很多人一上来就想着上锁,其实单核上的同步手段有一个从“穷”到“富”的谱系,越轻的代价越小,适用范围也越窄。选型的原则很简单:你的冲突场景是什么,就用对应的手段,不要动不动就上重量级武器。
4.1 最轻量:原子操作与 volatile 的正确使用
原子操作是指“不可被中断”的操作。在单核处理器上,如果操作本身是一条汇编指令就能完成的,这天然就是原子的,不需要额外保护。
比如 ARM 架构下,LDR/STR这种单条访存指令本来就是原子的(至少对于对齐访问来说)。所以,单核上修改一个 32 位整数,如果编译器生成的代码恰好是单条 STR 指令,理论上不需要锁。
但问题在于,高级语言里的操作往往需要多条指令。比如count++,就需要 LDR、ADD、STR 三条指令。这时候再想让操作原子化,有两条路:
- 使用编译器提供的原子内置函数,比如 GCC 的
__sync_*系列或者 C11 的stdatomic.h; - 直接使用硬件特性,比如关中断后再操作。
另外要强调的是volatile的作用。它告诉编译器:这个变量的值可能被外部因素改变(比如中断),所以每次使用都必须从内存重新读取,不能优化到寄存器里缓存。但volatile不能解决“读-改-写”的竞态,这是两个层面的事。
我在代码里常见的组合是:标志位、计数器这类单变量操作,用volatile + 原子操作就足够;涉及结构体、缓冲区这类复合数据,才需要上锁。
4.2 关中断:单核处理器的“终极武器”与代价
关中断,也叫进入临界区(注意,这里的临界区是 CPU 层面的“关中断临界区”,对应的是库函数编码调用,跟前面说的高层临界区不是一回事),是单核处理器上最直接、最彻底的互斥手段。因为所有抢占式调度的基础就是定时器中断,一旦你把中断关了,调度器就不会触发,当前任务可以一直执行下去,不会被切换走。
在裸机开发中,关中断是唯一可靠的互斥方式。以 STM32 为例:
// 保存当前中断状态并关闭全局中断 uint32_t primask = __get_PRIMASK(); __disable_irq(); // 临界区代码 shared_var++; write_to_flash(addr, data); // 恢复中断状态 __set_PRIMASK(primask);注意,关中断的代码里有一个铁律:临界区必须尽可能短,而且在临界区内绝对不能调用任何可能导致任务阻塞或延时的函数。比如不能调用delay_ms(),不能让线程在里面对某个信号量进行等待,否则整个系统的实时性就废了。
代价也要说清楚。关中断期间,所有中断都被挂起,包括对实时性要求极高的定时器中断、串口接收中断。如果临界区太长,后果就是:
- 系统响应外部事件的时间变长;
- 串口数据可能因为 FIFO 溢出而丢失;
- 看门狗定时器可能因为得不到及时喂狗而复位系统。
所以在 RTOS 里,比如 FreeRTOS,关中断通常只在很短很短的代码段里使用,像操作一个链表节点、更新一个计数器的场景。超过几十条指令的操作,宁愿用互斥量。
4.3 互斥锁与信号量:RTOS 里的标准选择
在 FreeRTOS、RT-Thread 这类 RTOS 环境里,互斥锁(Mutex)和信号量(Semaphore)是处理线程冲突的标准工具。
互斥锁的特点是“谁持有,谁释放”。它带有优先级继承机制:假设低优先级任务持有锁,高优先级任务在等待这个锁,系统会临时把低优先级任务的优先级提升到高优先级任务的级别,防止出现优先级反转。
信号量的本质是一个计数器。二值信号量可以用来做互斥,但要注意二值信号量没有优先级继承机制,存在优先级反转的风险。计数信号量则更适合“资源可用数量”的管理,比如一个缓冲池有 N 个缓冲区。
在 FreeRTOS 中使用互斥锁:
SemaphoreHandle_t mutex; void app_main(void) { mutex = xSemaphoreCreateMutex(); xTaskCreate(task_a, "A", 2048, NULL, 5, NULL); xTaskCreate(task_b, "B", 2048, NULL, 5, NULL); } // 任务 A void task_a(void *arg) { while (1) { xSemaphoreTake(mutex, portMAX_DELAY); // 临界区:操作共享资源 shared_counter++; xSemaphoreGive(mutex); vTaskDelay(10); } }使用互斥锁时,最容易犯的错有两个。
第一,忘记释放锁。某条分支路径上xSemaphoreGive没执行,其他任务就永远拿不到锁,表现为系统“卡死”。排查方法是在锁 API 的外部包一层日志或者使用带超时的xSemaphoreTake,超时时间设一个合理值,不要一上来就portMAX_DELAY死等。
第二,在持锁期间调用了阻塞操作。比如持锁时用vTaskDelay或者等待其他信号量,这是死锁的标准配方。一对线程互相持有对方要的锁,两个人就卡死在原地了。
4.4 免锁设计:环形缓冲区与单生产者单消费者模型
有时候,最好的同步是“不需要同步”。单核处理器上,有一种极其经典的无锁方案:环形缓冲区配合单生产者单消费者模型。
原理很巧妙:只有一个生产者和一个消费者,二者各自维护一个索引——生产者维护写索引,消费者维护读索引。关键规则是:
- 生产者只能根据读索引判断缓冲区是否满,然后写入数据并更新自己的写索引;
- 消费者只能根据写索引判断缓冲区是否有数据,然后读取数据并更新自己的读索引。
因为双方各自修改属于自己的索引,理论上不会产生写写冲突。唯一要注意的是索引更新操作的原子性。在单核处理器上,如果索引是 16 位或 32 位整数,且我们保证“更新索引”这一步不被打断,整个模型就是安全的。
怎么保证不被打断?非常简单:在更新索引前后关闭中断即可。因为缓冲区数据的读写操作本身不涉及竞态(生产和消费的位置天然错开),真正的竞态只存在于索引更新那几条指令。
环形缓冲区在嵌入式系统里应用极广,串口接收中断往缓冲区写、应用程序从缓冲区读,本质上就是单生产者(中断)单消费者(主循环)模型。用好了这个模式,你能省略掉很多繁琐的锁操作,系统实时性和稳定性都会上一个台阶。
5. 从原理到实战:基于开发板的线程冲突定位与修复全过程
光讲理论不落地,等于白讲。下面我带大家一起走一遍“问题复现—定位分析—修复验证”的完整过程。开发板用 T113(ARM Cortex-A7 单核,Linux 系统)举例,但思路完全适用于其他开发板。
5.1 复现一个典型的线程冲突 Bug
假设我在 T113 开发板上跑了两个用户态线程,一个线程往全局数组写数据,一个线程读数据并做校验,两个线程通过一个全局变量g_count协调写入位置:
// 全局状态 #define BUFFER_SIZE 1000 uint32_t g_buffer[BUFFER_SIZE]; int g_count = 0; // 写线程 void *writer_thread(void *arg) { uint32_t i = 0; while (1) { if (g_count < BUFFER_SIZE) { g_buffer[g_count] = i++; g_count++; } usleep(100); } return NULL; } // 读线程(校验每个元素是否为递增序列) void *reader_thread(void *arg) { while (1) { if (g_count > 0) { int idx = g_count - 1; // 检查最后一个元素 if (idx > 0 && g_buffer[idx] != g_buffer[idx - 1] + 1) { printf("数据不一致! idx=%d, val=%d, prev=%d\n", idx, g_buffer[idx], g_buffer[idx - 1]); } } usleep(50); } return NULL; }这段代码看起来人畜无害,但跑一段时间后,数据不一致的打印就会出现。
5.2 三个最有效的排查手段
手段一:加打印,缩小怀疑范围。
在g_count++和读g_count的地方分别加打印,确认数据不一致时两个线程各自看到的值。这一步能确定冲突是否发生在g_count的读写上。
手段二:用性能工具辅助定位。
Linux 环境下可以打开 ThreadSanitizer 这类数据竞争检测工具。编译时加上-fsanitize=thread,运行时代码里对共享数据的访问会被插桩,一旦检测到数据竞争,编译器会直接报出详细的位置信息。这是最省事的方式,比自己一行行读代码高效太多。
编译命令示例:
gcc -fsanitize=thread -g -o test test.c -lpthread手段三:代码走查。
人工检查竞态条件,重点关注:
- 哪个变量被多个线程访问?
- 这些访问是读还是写?
- 读写操作是否安全地被同一把锁保护?
- 有没有可能在读写中间发生线程切换?
这三个手段配合起来,基本能覆盖 90% 以上的开发板线程冲突问题。
5.3 修复方案与验证测试
对照上面的问题代码,直接用互斥锁修复:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; void *writer_thread(void *arg) { uint32_t i = 0; while (1) { pthread_mutex_lock(&lock); if (g_count < BUFFER_SIZE) { g_buffer[g_count] = i++; g_count++; } pthread_mutex_unlock(&lock); usleep(100); } return NULL; } void *reader_thread(void *arg) { while (1) { pthread_mutex_lock(&lock); if (g_count > 0) { int idx = g_count - 1; if (idx > 0 && g_buffer[idx] != g_buffer[idx - 1] + 1) { printf("数据不一致! idx=%d, val=%d, prev=%d\n", idx, g_buffer[idx], g_buffer[idx - 1]); } } pthread_mutex_unlock(&lock); usleep(50); } return NULL; }修复之后跑 24 小时,没有再出现数据不一致。这就验证了问题确实出在g_count的非原子访问上。
提示:这个场景用互斥锁是合理的,因为临界区很短。但如果临界区很长,锁的粒度就要重新设计,可以拆分成更小的临界区,或者改用上一节讲的免锁方案。
5.4 一个更隐蔽的坑:volatile 不是万能的
很多人看到g_count出错,第一反应是给变量加volatile修饰。加上之后编译再跑,问题真的很可能“消失”了——但这不是因为问题解决了,而是因为运气好,编译器恰好没有做某些优化。
volatile只能保证每次访问都从内存读取,它不能保证读写过程是原子的。在单核上g_count++依然是“读-改-写”多步操作,线程切换依然可能发生在任意两条指令之间。如果某次编译器把三种操作优化成了不同的指令序列,冲突会以完全不同的方式复现,甚至更难查。
正确的心态是:volatile是给编译器看的,不是给线程同步用的。它适合告诉编译器“这个变量会被中断修改”,防止访问被优化掉;但绝不能用它替代锁或原子操作。
6. 常用开发板线程冲突排查工具与问题速查
不同开发板、不同系统环境,排查工具和方法有很大差异。我把常用开发板的工具链情况整理成一个速查表,方便对照。
| 开发板/环境 | 常用系统 | 排查工具与手段 | 高频冲突点 |
|---|---|---|---|
| T113 (Cortex-A7) | Linux | ThreadSanitizer、GDB、strace、/proc 接口 | 多线程共享缓冲区、外设驱动并发 |
| ESP32 (Xtensa 双核) | FreeRTOS | 任务看门狗、Tracealyzer、FreeRTOS 内核统计 | 串口打印、I2C/SPI 总线共享、全局变量 |
| STM32F407 (Cortex-M4) | 裸机/RT-Thread/FreeRTOS | Keil/STM32CubeIDE 调试器、RT-Thread 信号量检查 | 中断与主循环冲突、DMA 缓冲、串口接收 |
| STM32MP157 (Cortex-A7+M4) | Linux + RTOS | 双核调试、OpenOCD、内核 trace | 双核共享内存通信、外设资源分配 |
| 正点原子 RK3588 (4×A76+4×A55) | Linux/Android | perf、bpftrace、ThreadSanitizer | 异构多核共享数据、GPU/VPU 驱动并发、内存屏障 |
这套表格不是让大家按图索骥,而是强调一个思路:不同硬件平台的冲突对抗手段差异巨大。Cortex-M 这类 MCU 上跑裸机或轻量 RTOS,最有效的手段往往是关中断和互斥锁;而 Cortex-A 这类应用处理器上跑 Linux,你的武器库就丰富多了,除了锁之外还有 RCU、原子操作、无锁队列、各种 sanitizer。
6.1 常见问题速查表
| 现象 | 可能的根因 | 排查方法 | 修复方向 |
|---|---|---|---|
| 串口输出乱码/交错 | printf 重入,缓冲区竞争 | 增加调试打印确认交错点 | 日志系统加锁;日志走环形缓冲区+独立任务 |
| 系统偶发死机/看门狗复位 | 临界区过长导致中断丢失,或死锁 | 查看复位原因寄存器,抓取崩溃栈 | 缩短临界区;检查锁的获取/释放配对 |
| 外设数据偶发错误 | I2C/SPI 总线被多线程并发访问 | 用逻辑分析仪抓总线时序 | 总线级互斥锁,事务级加锁 |
| 程序卡死不动 | 死锁:锁的获取顺序不一致或持锁阻塞 | GDB 查看各线程栈,确认锁等待关系 | 规范化锁获取顺序;持锁期间禁止阻塞操作 |
| 数据值随机变化 | 全局变量在多个上下文(任务+中断)被修改 | 静态检查 + 断点监控变量写操作 | 中断上下文与任务上下文用关中断保护 |
| 性能下降明显 | 锁粒度太粗,大量时间浪费在等待锁上 | 性能分析工具查看线程阻塞时间 | 细化锁粒度;改用无锁方案(环形缓冲区等) |
6.2 我自己积累的三条查 Bug 经验
第一,先判断冲突发生的层次。同样的“数据错误”现象,可能是应用层的竞态,也可能是驱动层的中断竞争,还可能是硬件本身的问题。定位层次错了,越查越偏。我习惯先看重启原因寄存器、打印线程调用栈、检查中断频率,先确定大概是哪个层级,再往下钻。
第二,善用“最小复现”。把怀疑的模块单独拎出来,构造一个只有两个线程、一个共享变量的小程序,反复跑几百次,确认冲突必然发生。很多时候,复杂系统里查不到的 Bug,把它拆小了之后几分钟就现原形。
第三,改动一次只动一个变量。修复竞态问题时,最忌讳东改一处西改一处。每次只改一个点,然后跑测试看现象变化,这样你才能确定哪个修改真正解决了问题。如果一口气加了锁、改了数据结构、调了线程优先级,最后 Bug 消失了,你也不确定是哪一步起了作用,下次同样的坑你还会踩进去。
7. 一个值得反复体会的设计思路:把单核当单核用
聊了这么多原理和代码,最后想聊一个稍微“形而上”的问题,同时也是我自己做嵌入式开发多年最深的感受。
很多人写开发板程序,习惯性地把 PC 上那套多线程编程思维搬过来。线程池、消息队列、锁、条件变量,一套组合拳打得飞起。但在单核处理器上,这套架构往往不是最优解,甚至会带来不必要的复杂度和性能损耗。
单核处理器的本质,是“同一时刻只能做一件事”。既然如此,很多时候你可以把程序重新组织成“顺序执行多个任务”,而不是“并发执行多个线程”。比如:
- 用状态机替代阻塞等待。传感器采集、数据上报、按键响应,拆成一个个状态,主循环轮询处理,天然没有并发问题;
- 用事件驱动替代线程模型。外部事件触发时置位标志位,主循环统一处理,中断里只做最少的标记工作;
- 专职线程+消息队列。一个线程干一件事,线程之间通过消息队列通信,避免共享内存带来的复杂性。
但我也不是让大家完全放弃多线程。实时性要求高的场景、多个彼此独立的任务确实需要并发时,多线程依然是最合理的选择。关键在于:你要清楚自己用多线程是为了什么,而不是因为大家都在用。
单核上多线程并发,本质上是在一个“只能做一件事”的 CPU 上,用时间片模拟出“同时干多件事”的效果。它解决的是“任务调度的合理性”,而不是“真正的并行”。理解了这一层,你设计程序的时候就会更克制——什么时候该上锁,什么时候该换架构,心里自然有数。
踩过几次线程冲突的坑之后,我的习惯变成了:写代码之前先画一张“资源访问图”,把共享变量、外设、缓冲区都标出来,每条访问路径写清楚在哪个上下文执行(任务1、任务2、中断)。上下文不同的路径,访问同一个资源,就必须有同步手段。这张图画完,哪里需要锁、哪里需要关中断、哪里可以用免锁设计,一目了然。
这个方法推荐给所有做嵌入式开发的朋友,简单有效,能帮你省下大量的调试时间。