RK3568驱动开发实战:从并发竞争实验理解Linux内核同步机制

发布时间:2026/5/17 0:54:33

RK3568驱动开发实战:从并发竞争实验理解Linux内核同步机制 1. 项目概述从一次“诡异”的打印说起最近在调试一块基于瑞芯微RK3568芯片的工控板时遇到了一个让我排查了半天的“灵异”现象。我写了一个简单的字符设备驱动功能是接收用户空间传来的字符串如果是“topeet”就睡眠4秒后打印如果是“itop”就睡眠2秒后打印。逻辑看起来清晰无比对吧但当我同时在后台运行两个测试程序一个传“topeet”一个传“itop”时终端打印出来的结果却让我愣住了——竟然打印了两次“itop”。那个本该在4秒后出现的“topeet”仿佛被“吞噬”了。这个现象就是Linux驱动开发中一个经典且必须直面的核心问题并发与竞争。对于嵌入式Linux开发者尤其是使用像RK3568这样多核高性能处理器的开发者来说理解并发与竞争不是选修课而是必修课。RK3568作为一款四核Cortex-A55处理器天生就支持多任务并行与并发执行。这意味着你的驱动代码随时可能被多个进程、多个线程甚至是中断处理程序同时“光顾”。如果你写的驱动还停留在“单线程、顺序执行”的思维里那么各种难以复现、随机出现的“鬼畜”Bug就会找上门来。本文将以我这次踩坑经历为引手把手带你从理论到实践彻底搞懂并发与竞争的来龙去脉并通过一个可复现的实验让你亲眼看到竞争是如何发生的为后续学习互斥锁、原子操作等保护机制打下坚实的基础。无论你是刚接触驱动的新手还是有一定经验想巩固此概念的开发者这篇文章都将为你提供一次透彻的梳理。2. 并发与竞争内核世界的“共享单车”难题在深入实验之前我们必须把几个核心概念掰开揉碎讲清楚。很多人对“并发”、“并行”、“竞争”这些词一知半解写代码时自然也就无法预见风险。2.1 并发单核CPU的“时间管理术”想象一下你只有一个厨师单核CPU却要同时照看一锅汤任务A和一口炒菜任务B。汤需要小火慢炖期间不需要一直搅拌而炒菜需要大火快炒不断翻动。聪明的厨师不会等汤炖好再炒菜也不会等菜炒好再回头管汤。他会这样做先给炒锅下油爆香执行任务B然后转身去给汤锅加点调料切换到任务A接着再回来翻炒几下菜切回任务B……如此快速切换。在旁人看来汤和菜仿佛在同时被烹制这就是并发。在早期的单核计算机中操作系统就是这位“时间管理大师”。它通过调度算法让CPU在执行一个任务的I/O等待比如等待磁盘读写、网络数据时迅速切换到另一个就绪的任务去执行。CPU的执行速度极快纳秒级任务切换的 overhead 相对很小因此宏观上我们感觉多个任务在“同时”运行。其根本目的是为了提高宝贵的CPU资源的利用率避免其因等待而空闲。2.2 并行多核CPU的“团队作战”现在厨房升级了有了两个厨师双核CPU。此时炖汤和炒菜可以真正意义上同时进行了一个厨师专门负责看汤锅另一个厨师专门负责掌炒勺。两个任务在同一时刻物理上都在被执行这种状态就是并行。多核CPU的每个核心都是一个独立的执行单元可以同时处理不同的指令流这是硬件层面带来的真正的同时性效率远高于单核的并发切换。2.3 现实世界并发与并行的交织在我们的RK3568开发板上搭载的是四核Cortex-A55 CPU。系统启动后运行着几十甚至上百个进程和线程。4个核心面对这么多任务显然无法让每个任务都独占一个核心。因此真实场景是并发与并行的混合体并行4个核心在同一时刻最多可以真正并行执行4个任务。并发对于运行在每个核心上的任务操作系统仍然会在它们之间进行快速的切换调度以实现更多任务的“同时”推进。所以一个核心上可能交替执行着线程A和线程B并发而另一个核心上正在执行线程C并行。整个系统呈现出一种多层次、交织的并发执行图景。为了方便讨论后续我们通常将这种可能引发访问冲突的“同时性”访问统称为并发访问。2.4 竞争当“共享资源”遭遇并发访问理解了并发竞争的概念就水到渠成了。假设我们小区门口有一辆共享单车共享资源。我准备扫码骑车任务A刚扫完码正在开锁的这个瞬间你也过来扫码任务B。如果后台系统处理不当可能会发生我读取了单车状态为“空闲”正准备将其改为“使用中”几乎在同一时刻你也读取了状态看到的也是“空闲”。结果我们俩都成功开锁都认为自己是这辆车的唯一使用者这就产生了冲突。在驱动编程中这个“共享单车”可以是一个全局变量比如我们实验中的char kbuf[10]一个硬件寄存器比如GPIO的状态寄存器一个动态分配的内存区域一个链表结构驱动自定义的设备状态结构体当两个或以上的执行路径进程、线程、中断在没有同步机制保护的情况下去读写同一个共享资源且至少有一个操作是“写”时最终的结果将依赖于这些操作执行的精确时序。这种不确定性就是竞争条件它导致的错误就是竞争。2.5 Linux内核中竞争产生的四大“导火索”在我们的驱动代码运行环境中竞争主要由以下四种典型的并发源引发多线程/多进程访问这是最普遍的情况。Linux是多用户多任务操作系统用户空间的多个进程可以同时打开同一个设备文件内核会为每个open调用创建独立的文件上下文但它们操作的底层驱动数据可能是同一个。中断处理程序硬件中断拥有最高的优先级之一。当驱动正在处理某个共享资源例如正在修改一个表示硬件状态的全局变量时一个中断突然到来中断服务例程ISR也试图读写同一个变量。如果处理不当驱动上下文的数据就会被中断“打断”并破坏。内核抢占现代Linux内核2.6以后是抢占式的。这意味着即使在内核态执行驱动代码也可能被另一个更高优先级的进程抢占。如果驱动正在修改共享资源时被抢占而新调度的进程或它的内核路径也访问该资源竞争就发生了。对称多处理这正是RK3568这样的多核CPU带来的“甜蜜的烦恼”。两个或多个CPU核心真正同时执行到驱动代码中访问共享资源的部分。这是最隐蔽、最难调试的一种竞争情况因为它在单核上可能永远无法复现。核心提示在单核非抢占内核上只要在访问共享资源时禁止中断就能防止大部分竞争。但在SMP多核且内核可抢占的系统如主流Linux上情况复杂得多必须使用专为SMP设计的同步机制如自旋锁。3. 实验拆解亲手制造一个竞争现场理论讲得再多不如亲手“搞一次破坏”来得印象深刻。下面我们就基于RK3568平台编写一个故意制造竞争的驱动并观察其后果。这个实验的设计精髓在于通过人为引入睡眠ssleep来放大并发访问的时序窗口让竞争结果变得确定且可观察。3.1 驱动设计思路与“陷阱”设置我们的目标是创建一个字符设备驱动它拥有一个全局字符数组kbuf作为共享资源。用户空间通过write系统调用向设备写入字符串。驱动根据写入的字符串不同执行不同的延时后打印出kbuf的内容。竞争触发逻辑驱动定义全局变量static char kbuf[10] {0};。所有write操作都读写这个变量。在write_test函数中先用copy_from_user将用户数据拷贝到kbuf。然后进行判断如果kbuf内容是”topeet”则睡眠4秒如果是”itop”则睡眠2秒。睡眠结束后打印kbuf的内容。关键陷阱判断和打印的依据都是kbuf而kbuf是全局共享的。睡眠操作ssleep会主动让出CPU这是一个极佳的“被抢占”时机。预期与非预期结果无竞争时顺序执行先执行./app /dev/device_test topeet驱动睡眠4秒后打印”topeet”再执行./app /dev/device_test itop驱动睡眠2秒后打印”itop”。输出顺序和内容都正确。存在竞争时并发执行两个app几乎同时启动。假设topeet先执行它把kbuf改为”topeet”然后开始4秒睡眠。在这4秒内itop进程的write调用得以执行它把kbuf覆盖成了”itop”然后睡眠2秒。由于itop睡眠时间短它会先醒来打印kbuf此时kbuf是”itop”。2秒后topeet醒来也打印kbuf此时kbuf早已被改成了”itop”。于是我们看到了两个”itop”的打印输出”topeet”神秘消失。3.2 驱动程序代码逐行精讲让我们结合代码看看这个“陷阱”是如何具体实现的。#include linux/init.h #include linux/module.h #include linux/fs.h #include linux/uaccess.h #include linux/cdev.h #include linux/device.h #include linux/delay.h // 需要包含此头文件以使用 ssleep static char kbuf[10] {0}; // 【共享资源/竞争源】全局缓冲区所有进程的write操作都读写它 static ssize_t write_test(struct file *file, const char __user *ubuf, size_t len, loff_t *off) { int ret; // 1. 将用户空间数据拷贝到内核共享缓冲区 ret copy_from_user(kbuf, ubuf, len); if (ret ! 0) { printk(copy_from_user is error\n); return -EFAULT; } // 2. 【危险区开始】根据缓冲区内容决定延时 // 注意此处的判断对象是 kbuf而kbuf可能被其他进程修改 if (strcmp(kbuf, topeet) 0) { // 写入topeet的进程将在此睡眠4秒 ssleep(4); // 主动放弃CPU给竞争者创造了绝佳机会 } else if (strcmp(kbuf, itop) 0) { // 写入itop的进程将在此睡眠2秒 ssleep(2); } // 3. 睡眠结束后打印缓冲区内容 // 【竞争结果显现点】此时打印的kbuf可能已经不是本进程最初写入的那个值了 printk(copy_from_user buf is %s \n, kbuf); return len; }代码中的关键风险点分析第2步的判断与睡眠strcmp(kbuf, ...)和ssleep()这两个操作组合在一起构成了一个典型的“检查-行动”非原子序列。在检查strcmp之后行动ssleep及后续操作之前共享资源kbuf是完全暴露的。ssleep函数它会使当前进程进入可中断的睡眠状态TASK_INTERRUPTIBLE并主动调用调度器切换进程。这长达数秒的窗口期对于计算机而言堪称“永恒”足以让其他进程执行成千上万条指令。数据覆盖后执行的进程通过copy_from_user直接覆盖了kbuf彻底破坏了前一个进程的上下文。实操心得为什么用ssleep而不是mdelayssleep()是秒级睡眠基于schedule_timeout实现会主动让出CPU。而mdelay()是忙等待通过空循环消耗CPU时间在延迟期间不会让出CPU。在这个实验中我们需要让出CPU以模拟真实的并发调度场景因此ssleep是更合适的选择。在实际驱动中除非极短且必须精确的延迟否则应避免使用忙等待。3.3 测试应用程序的编写测试程序app.c的作用很简单根据命令行参数向指定的设备节点写入特定的字符串。#include stdio.h #include sys/types.h #include sys/stat.h #include fcntl.h #include unistd.h #include string.h int main(int argc, char *argv[]) { int fd; if (argc ! 3) { // 检查参数数量 printf(Usage: %s device_node topeet|itop\n, argv[0]); return -1; } fd open(argv[1], O_RDWR); // 打开设备节点 if (fd 0) { perror(file open failed); return -1; } // 根据第二个参数写入不同的字符串 if (strcmp(argv[2], topeet) 0) { write(fd, topeet, 7); // 注意长度最好传实际字符串长度1‘\0’或确保驱动不越界。这里传7包含结尾‘\0’。 } else if (strcmp(argv[2], itop) 0) { write(fd, itop, 5); // 同上传5 } else { printf(Invalid second argument. Use topeet or itop.\n); } close(fd); return 0; }编译注意事项交叉编译时使用-static选项链接静态库是因为开发板的根文件系统中可能缺少相应的动态库如glibc静态编译可以避免库依赖问题让可执行文件在任何同架构环境中都能运行。命令aarch64-linux-gnu-gcc -o app app.c -static3.4 实验操作与现象记录接下来我们在RK3568开发板上进行实验亲眼见证竞争的发生。步骤一编译与加载驱动在驱动源码目录执行make生成example.ko。将example.ko和测试程序app通过adb push或scp等方式拷贝到开发板。在开发板终端加载驱动insmod example.ko。查看dmesg可以看到驱动打印出的主次设备号以及设备节点/dev/device_test被成功创建。步骤二顺序执行测试基线验证首先验证驱动基本功能是否正确。# 第一次写入 topeet ./app /dev/device_test topeet # 观察dmesg大约4秒后输出copy_from_user buf is topeet # 第二次写入 itop ./app /dev/device_test itop # 观察dmesg大约2秒后输出copy_from_user buf is itop顺序执行一切正常说明驱动逻辑在单一线程下是工作的。步骤三并发执行测试触发竞争现在我们让两个进程“同时”去写。# 在后台同时运行两个测试程序 ./app /dev/device_test topeet ./app /dev/device_test itop 符号表示将命令放到后台执行这样两个app几乎会同时启动。立即观察dmesg输出的内核日志。预期内的“异常”现象 你可能会看到类似如下的输出[ 123.456789] copy_from_user buf is itop [ 125.456789] copy_from_user buf is itop或者由于调度器的微妙差异也可能是[ 123.456789] copy_from_user buf is itop [ 127.456789] copy_from_user buf is itop现象解读第一个打印约2秒后出现这是写入”itop”的进程打印的。它写入后睡眠2秒然后打印kbuf此时kbuf的内容是”itop”。符合预期。第二个打印在第一个打印的2秒或4秒后出现关键来了这应该是写入”topeet”的进程打印的。它本应在睡眠4秒后打印”topeet”但实际打印的却是”itop”。这证明在它睡眠的4秒期间kbuf被另一个进程修改了。“topeet”去哪了它被覆盖了。进程Atopeet写入kbuf”topeet”后开始睡眠。进程Bitop在A睡眠期间执行将kbuf覆盖为”itop”。当A醒来时它读取到的kbuf已经是B写入的值因此打印错误。这个实验清晰地展示了对共享资源kbuf的非保护并发写操作导致了数据的一致性问题。进程A的上下文它认为自己写的是topeet被进程B破坏且进程A对此毫无察觉。4. 竞争条件深度剖析与问题排查指南通过上面的实验我们看到了竞争导致的结果。但在实际项目中竞争条件往往更加隐蔽引发的症状也千奇百怪。下面我们来系统性地拆解竞争条件的本质并建立一套排查思路。4.1 竞争条件的本质非原子操作与交错执行竞争条件的根源在于对于一个共享资源的操作尤其是“读-改-写”序列不是原子性的并且这些操作可能被并发执行路径交错进行。在我们的实验里“操作”序列是读/写copy_from_user(kbuf, ubuf, len)将用户数据写入kbuf读strcmp(kbuf, “topeet”)检查kbuf内容延时/调度ssleep(4)主动让出CPU读printk(“… %s \n”, kbuf)读取kbuf并打印这个序列中的第1步和第2步之间、第2步和第3步之间、第3步和第4步之间都可能被插入其他进程的write_test函数执行。一旦插入的操作也包含对kbuf的写第1步最终状态就会混乱。4.2 更隐蔽的竞争场景举例实验中的竞争因为有了sleep而被放大和固化。现实中没有sleep竞争依然存在只是更难复现计数器竞态static int global_counter 0; static ssize_t write_counter(struct file *file, ...) { global_counter; // 这行代码编译成汇编可能是LOAD - ADD - STORE printk(“Counter: %d\n”, global_counter); }global_counter不是原子指令。在多核SMP上两个核可能同时LOAD到相同的旧值比如5各自加1后STORE回去结果变成了6而不是预期的7。这种问题在单核上由于中断或抢占也可能发生。链表操作竞态// 假设在中断和进程上下文中都会操作同一个链表 list_add(new_node, global_list); // 中断中执行 // ... 进程上下文执行到此处遍历global_list如果进程在遍历链表时中断发生并修改了链表增删节点进程可能会访问到无效的指针导致内核崩溃oops。4.3 并发问题排查思路与工具当你怀疑驱动存在并发问题时可以遵循以下思路识别共享资源首先审视你的驱动代码找出所有可能被多个执行路径访问的全局变量、静态变量、硬件寄存器、分配的内存、链表等。给它们加上注释。理清并发执行路径分析哪些地方可能并发驱动是否会被多个进程打开open文件操作read/write/ioctl是否可能被同时调用驱动是否注册了中断处理函数中断中是否访问了进程上下文也会访问的资源驱动是否使用了内核定时器、工作队列、内核线程它们与进程上下文是否共享数据你的硬件平台是否是SMP如RK3568这是最需要警惕的。代码审查与加锁规划对每一个共享资源检查所有访问它的代码路径。问自己如果另一条路径在这条路径访问的中间时刻也来访问会怎样规划需要使用哪种同步原语互斥锁、自旋锁、信号量、原子变量等来保护它。使用调试工具Lockdep内核锁依赖关系检测器。在配置内核时启用CONFIG_DEBUG_LOCKDEP它能动态检测锁的获取顺序是否可能造成死锁。对于新写的驱动这是一个极其宝贵的工具。KCSAN (Kernel Concurrency Sanitizer)内核并发访问检测器。它可以检测到数据竞争data race即两个访问没有使用锁来排序且至少有一个是写操作。在RK3568的SDK内核中尝试启用并测试它能帮你发现许多潜在的竞争点。重复压力测试编写脚本在短时间内高并发地调用你的驱动接口。竞争错误通常是概率性的压力测试能提高其出现频率。内核Oops信息分析如果竞争导致了内存越界、空指针解引用内核会崩溃并打印Oops信息。仔细分析Oops的调用栈能找到出错的代码位置。4.4 从实验到解决方案的思考我们的实验暴露了问题那么如何解决呢核心思想是将“检查-行动”或“读-改-写”这个非原子序列变成一个不可分割的原子操作或者在执行这个序列时阻止其他并发路径访问同一资源。针对实验中的kbuf问题我们可以想到几种保护方案为每个打开的文件描述符分配独立缓冲区将kbuf从全局静态变量移到file结构的private_data中。这样每个open调用获得的文件描述符都有自己独立的缓冲区从根本上消除共享。但这不适用于所有场景比如真正的硬件状态寄存器就无法复制。使用互斥锁在write_test函数开头加锁在函数结尾解锁。这样同一时间只有一个执行路径能进入临界区操作kbuf。这是最通用的方法。使用自旋锁如果竞争非常激烈且临界区执行时间极短比如只是增加一个计数器可以使用自旋锁。但在临界区内睡眠如ssleep是绝对禁止的这会导致死锁。使用原子变量如果共享资源只是一个简单的整型计数器可以使用内核提供的原子操作atomic_t。在接下来的章节中将会深入探讨这些同步机制的具体用法、适用场景以及在RK3568驱动中的实践。理解本章这个竞争实验是正确使用所有同步机制的基础。你必须先清楚地看到“敌人”竞争在哪里才能有效地使用“武器”锁去防御它。5. 总结与核心教训通过这个完整的并发与竞争实验我们不仅看到了现象更理解了其背后的机理。对于RK3568这类多核处理器的驱动开发请务必牢记以下几点核心教训默认假设并发存在在编写驱动时必须时刻假设你的函数会被多个CPU核心、多个进程/线程、中断处理程序同时调用。这是一种必要的“防御性编程”思维。识别所有共享资源这是第一步也是最关键的一步。任何非局部变量、硬件状态都需要被怀疑。同步机制是成本而非可选使用锁等机制会带来性能开销和死锁风险但相比于数据损坏导致系统崩溃的灾难性后果这个成本是必须付出的。你需要做的是精细化管理用合适的锁保护合适的数据尽量缩小临界区加锁范围。避免在临界区内睡眠这是一个铁律。持有自旋锁时绝对禁止睡眠包括调用任何可能引起调度的函数如kmalloc(GFP_KERNEL)、copy_from_user等。持有互斥锁时睡眠也要非常小心需确保不会导致死锁或严重延迟。测试、测试、再测试并发Bug具有极强的不确定性。在单核、低负载下运行良好的驱动在多核、高并发压力下可能瞬间崩溃。务必进行并发压力测试并善用内核提供的调试工具。回到我们最初的实验那个打印出两个“itop”的驱动就像一个没有交通灯的路口两辆车进程都认为自己能安全通过结果导致了“事故”。在后续的章节中我们将学习如何在这个路口安装“交通灯”互斥锁、“交通警察”信号量等同步设施让数据流安全、有序地通过。掌握了这些你才能写出在RK3568这样复杂的多核SMP系统上稳定可靠的驱动程序。

相关新闻