news 2026/6/16 15:11:14

多线程编程核心:从数据竞争到线程安全队列的实践指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多线程编程核心:从数据竞争到线程安全队列的实践指南

1. 项目概述:为什么“多线程”是程序员必须跨越的一道坎

“第4关:编写一个多线程程序”,这个标题听起来像是一个编程挑战或学习路径中的关键节点。确实,对于任何希望深入理解现代软件如何高效运行的开发者而言,多线程编程都是一道绕不开的关卡。它不仅仅是让程序“跑得更快”的魔法,更是一种全新的、以并发视角来组织代码逻辑的思维方式。在单核CPU时代,多线程更多地是为了实现程序的响应性,比如让UI界面在后台执行耗时任务时依然能响应用户操作。而今天,在多核处理器成为标配的背景下,多线程的核心价值在于榨干硬件性能,将计算任务并行化,实现真正的加速。

然而,与巨大的性能红利相伴的,是极高的复杂性和风险。线程同步、数据竞争、死锁、伪共享……这些术语背后是一个个让程序行为诡异、崩溃甚至“卡死”的深坑。很多开发者初次接触多线程时,往往只学会了创建线程的API,却对背后共享数据的状态变幻莫测感到困惑。因此,这个“关卡”的真正内涵,远不止于调用pthread_createThread.start(),而在于建立起一套完整的并发心智模型,理解数据如何在多个执行流之间安全、高效地流动。接下来,我将以一个从业者的视角,拆解编写一个健壮、高效多线程程序所需的核心知识、实践步骤以及那些容易踩坑的细节。

2. 核心概念与心智模型:超越API的底层理解

在动手写代码之前,我们必须先夯实理论基础。多线程编程的难点,一半在于对底层机制理解不清。

2.1 线程的本质:执行上下文(Context of Execution)

很多人把线程理解成“轻量级进程”。这个说法没错,但更本质的理解,应该像Linux内核创始人Linus Torvalds所言:线程和进程都是“执行上下文”(COE)。一个COE包含了CPU寄存器状态、内存映射、权限、打开的文件描述符等所有让一段代码得以持续运行所需的信息。

当你创建一个线程时,操作系统内核就是在创建一个新的COE,并让其与父COE(主线程)共享大部分资源,尤其是整个地址空间。这意味着,同一个进程内的所有线程,看到的是同一份全局变量、堆内存。这种共享是高效通信的基础,也是所有数据竞争问题的根源。

2.2 硬件视角:逻辑线程与物理核心的映射

我们编写的线程是“逻辑线程”,它代表一个独立的执行流。而CPU核心(包括超线程技术提供的逻辑处理器)是“硬件线程”,是执行流的物理载体。操作系统调度器的核心工作,就是动态地将大量的逻辑线程映射到有限的硬件线程上执行。

这里有一个关键点:多线程程序在单核CPU上也能正确运行。操作系统通过时间片轮转,让多个逻辑线程在一个核心上交替执行,由于切换速度极快(毫秒级),给人一种“同时运行”的错觉(并发)。而在多核CPU上,多个线程才可能真正地同时执行(并行)。我们追求多线程,终极目标是为了实现并行,从而提升吞吐量。

2.3 为什么需要同步?从三个经典例子看数据竞争

所有同步的需求,都源于对共享数据的并发访问。我们来看几个简化但本质的例子:

例子A:非原子操作

int counter = 0; // 共享变量 void increment() { counter++; // 这不是原子操作! }

counter++在底层通常对应三条指令:1. 从内存加载counter值到寄存器;2. 寄存器值加1;3. 将新值写回内存。如果两个线程几乎同时执行,可能发生:线程1刚加载完值(0),线程2也加载了值(0);两者分别加1后写回,最终counter是1而不是2。这就是典型的数据竞争

例子B:不变量被破坏想象一个双向链表节点,有prevnext指针。线程A正在删除此节点,需要执行两个步骤:1. 让前驱节点的next指向后继节点;2. 让后继节点的prev指向前驱节点。如果在线程A执行完步骤1但未执行步骤2时,线程B遍历链表,它会看到一个next指针已更新但prev指针仍指向旧节点的“半成品”状态,可能导致访问错误内存。这里需要保护的不是某个变量,而是一个数据结构的不变量(即“节点前后指针必须一致”)。

例子C:检查后行动(Check-Then-Act)

if (!queue.isEmpty()) { // 检查 Item item = queue.pop(); // 行动 }

如果两个线程同时执行这段代码,都可能通过isEmpty()检查,然后相继调用pop(),可能导致第二个线程尝试从空队列弹出元素而出错。检查和行动之间的间隙,就是竞态条件发生的窗口。

理解这些场景,就能明白同步的目的:将可能导致数据不一致的多个操作(临界区)包装成一个不可分割的原子操作,或者确保线程间操作的可见性与有序性

3. 同步原语详解:从锁到无锁编程

掌握了“为什么需要同步”,接下来就是“如何同步”。工具很多,各有适用场景。

3.1 互斥锁(Mutex):最基础的守护者

互斥锁提供了最基本的排他性访问。你可以把它想象成一个房间的钥匙,只有拿到钥匙的线程才能进入房间(临界区)操作共享数据。

使用模式(C++11为例):

std::mutex mtx; std::vector<int> shared_vec; void safe_push(int val) { std::lock_guard<std::mutex> lock(mtx); // 构造时加锁 shared_vec.push_back(val); // lock_guard析构时自动解锁 }

std::lock_guard是RAII(资源获取即初始化)技术的典型应用,它保证即使在push_back抛出异常的情况下,锁也能被正确释放,避免死锁。这是必须养成的习惯。

注意事项与陷阱:

  1. 锁粒度:锁住的范围要尽可能小。如果锁住整个函数,而函数里有一半代码不访问共享数据,就会严重降低并发度。好的做法是只锁住访问共享数据的代码块。
  2. 死锁:最常见的死锁是“ABBA”锁。线程1持有锁A,申请锁B;线程2持有锁B,申请锁A。双方互相等待,程序卡死。
    • 解决方案1:固定锁顺序。所有线程都按相同的顺序(如先A后B)申请锁。
    • 解决方案2:使用std::lock。C++11提供了std::lock(mtx1, mtx2, ...),可以一次性锁住多个互斥量,且保证不会死锁。配合std::adopt_lock使用。
    std::lock(mtx1, mtx2); // 同时锁住,避免死锁 std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock); std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
  3. 性能开销:加锁/解锁涉及从用户态到内核态的切换,对于极高频的细粒度操作,可能成为性能瓶颈。此时需考虑更轻量的同步方式。

3.2 读写锁(Read-Write Lock):读多写少的优化

当共享数据读操作远多于写操作时,互斥锁会限制性能,因为读操作之间本不冲突。读写锁(如std::shared_mutex)允许多个读线程同时持有锁,但写线程独占锁。

std::shared_mutex rw_mutex; ConfigData global_config; std::string read_config() { std::shared_lock<std::shared_mutex> lock(rw_mutex); // 共享锁(读锁) return global_config.get_value(); } void update_config(const std::string& val) { std::unique_lock<std::shared_mutex> lock(rw_mutex); // 独占锁(写锁) global_config.set_value(val); }

注意:要警惕“写线程饥饿”问题。如果读线程源源不断,写线程可能永远抢不到锁。一些实现会给予写线程优先权,但作为使用者,在设计时需评估读写比例。

3.3 条件变量(Condition Variable):线程间的“等待-通知”机制

互斥锁解决了互斥访问,但解决不了“等待某个条件成立”的问题。轮询(不断检查条件)会浪费CPU。条件变量让线程可以主动等待,并在条件可能满足时被通知。

典型生产者-消费者模式:

std::queue<Data> task_queue; std::mutex queue_mtx; std::condition_variable queue_cv; // 生产者 void producer() { Data data = produce_data(); { std::lock_guard<std::mutex> lock(queue_mtx); task_queue.push(data); } queue_cv.notify_one(); // 通知一个等待的消费者 } // 消费者 void consumer() { while (true) { std::unique_lock<std::mutex> lock(queue_mtx); // 等待条件:队列非空。防止虚假唤醒,必须用while循环判断条件 queue_cv.wait(lock, []{ return !task_queue.empty(); }); Data data = task_queue.front(); task_queue.pop(); lock.unlock(); // 尽早释放锁,让其他线程操作队列 process_data(data); } }

关键点

  • wait调用时,会原子地释放锁并使线程阻塞。被唤醒后,会重新获取锁。
  • 必须使用循环判断条件queue_cv.wait(lock, predicate)中的lambda就是循环判断),因为可能存在“虚假唤醒”(spurious wakeup),即线程没有收到notify也可能被唤醒。
  • notify_one()唤醒一个等待线程,notify_all()唤醒所有等待线程。根据场景选择,避免不必要的唤醒风暴。

3.4 原子操作与内存序:无需锁的同步基石

对于简单的计数器、标志位,使用锁是大材小用。C++11提供了std::atomic模板,保证了对特定类型(整型、指针等)操作的原子性。

std::atomic<int> counter{0}; void safe_increment() { counter.fetch_add(1, std::memory_order_relaxed); // 原子自增 }

原子操作的核心是不可分割。但原子操作带来的不仅仅是原子性,更重要的是它定义了内存序,解决了现代CPU乱序执行带来的可见性问题。

内存序(Memory Order)详解: 这是多线程编程中最晦涩也最重要的部分之一。考虑以下代码:

// 线程1 data = 42; // (1) ready.store(true, std::memory_order_release); // (2) // 线程2 if (ready.load(std::memory_order_acquire)) { // (3) assert(data == 42); // (4) 这个断言能保证成立吗? }

如果没有恰当的内存序,由于编译器和CPU的指令重排,线程1中(1)和(2)的执行顺序可能对线程2不可见。也就是说,线程2可能看到了ready == true,但data还是旧值(比如0),导致断言失败。

  • std::memory_order_release(释放):保证在该操作之前的所有内存写操作(包括非原子的),在该操作完成后,对其它执行了acquire操作的线程可见。
  • std::memory_order_acquire(获取):保证在该操作之后的所有内存读/写操作,不会重排到该操作之前。并且能看到最近一个release操作之前的所有写入。

在上例中,(2)的release与(3)的acquire构成了一个同步关系,确保了如果线程2看到了ready == true,那么它也一定能看到data == 42

选择建议

  • 默认使用std::memory_order_seq_cst:顺序一致性,最强保证,但性能开销最大。在不确定时用它最安全。
  • 在性能关键路径上审慎使用更宽松的序:如relaxed(只保证原子性,不提供同步)、release/acquire。这需要对数据依赖有清晰理解。
  • 避免自己发明轮子:除非你是底层库开发者,否则应优先使用高级同步工具(如互斥锁、条件变量),它们内部已经处理好了内存序问题。

3.5 Lock-free编程:挑战性能极限

Lock-free(无锁)是一种非阻塞同步的算法属性。它保证在多线程竞争时,系统整体始终有进展,即不会因为某个线程挂起而导致整个系统卡死。注意,Lock-free不等于不用锁(lock-less),它是一种更高级的并发设计模式。

核心原语:CAS(Compare-And-Swap)几乎所有Lock-free算法都基于CAS操作。C++中对应compare_exchange_strong/weak

template<typename T> class lock_free_stack { struct node { T data; node* next; }; std::atomic<node*> head; public: void push(const T& data) { node* new_node = new node{data, nullptr}; new_node->next = head.load(std::memory_order_relaxed); // CAS循环:如果head等于new_node->next(即未被其他线程修改),则将其设为new_node while(!head.compare_exchange_weak(new_node->next, new_node, std::memory_order_release, std::memory_order_relaxed)); } };

Lock-free的优缺点

  • 优点:避免了锁带来的阻塞、死锁、优先级反转等问题;在高竞争下可能性能更好。
  • 缺点:实现极其复杂,正确性难以证明;可能引发“ABA问题”(一个值从A变B再变回A,CAS无法察觉中间变化);对内存序要求苛刻。

给新手的建议:除非你是在编写高性能基础库(如并发队列、内存分配器),否则应优先使用基于锁的线程安全数据结构。很多语言的标准库或Boost库都提供了现成的并发容器。

4. 实战:设计并实现一个线程安全的任务队列

理论说再多,不如动手写一个。我们将实现一个支持多生产者、多消费者的阻塞任务队列,这是线程池等并发组件的核心。

4.1 接口设计

我们设计一个模板类ThreadSafeQueue,提供以下接口:

  • void push(T value):添加任务到队尾(阻塞直到成功)。
  • bool try_pop(T& value):尝试从队头取出任务,非阻塞,立即返回成功与否。
  • void wait_and_pop(T& value):从队头取出任务,如果队列为空则阻塞等待。
  • bool empty() const:判断队列是否为空(注意,这个状态瞬间万变,仅供参考)。

4.2 数据结构与同步方案选择

底层使用std::queuestd::deque。同步方案选择:

  • 一个互斥锁(std::mutex:保护整个队列的读写。
  • 一个条件变量(std::condition_variable:用于消费者在队列空时等待。
  • 使用RAII管理锁:确保异常安全。

4.3 完整实现(C++17)

#include <queue> #include <mutex> #include <condition_variable> #include <optional> template<typename T> class ThreadSafeQueue { private: mutable std::mutex mtx_; // mutable允许在const成员函数中加锁 std::queue<T> queue_; std::condition_variable cv_; public: ThreadSafeQueue() = default; // 禁止拷贝和赋值 ThreadSafeQueue(const ThreadSafeQueue&) = delete; ThreadSafeQueue& operator=(const ThreadSafeQueue&) = delete; void push(T value) { { std::lock_guard<std::mutex> lock(mtx_); queue_.push(std::move(value)); } // 锁在通知前释放,避免唤醒的线程立刻阻塞在锁上 cv_.notify_one(); // 通知一个等待的消费者 } // 非阻塞尝试弹出 bool try_pop(T& value) { std::lock_guard<std::mutex> lock(mtx_); if (queue_.empty()) { return false; } value = std::move(queue_.front()); queue_.pop(); return true; } // 阻塞等待并弹出 void wait_and_pop(T& value) { std::unique_lock<std::mutex> lock(mtx_); // 使用条件变量的wait方法,避免虚假唤醒 cv_.wait(lock, [this]{ return !queue_.empty(); }); value = std::move(queue_.front()); queue_.pop(); } // 返回一个optional,更现代的接口 std::optional<T> try_pop() { std::lock_guard<std::mutex> lock(mtx_); if (queue_.empty()) { return std::nullopt; } std::optional<T> res{ std::move(queue_.front()) }; queue_.pop(); return res; } std::optional<T> wait_and_pop() { std::unique_lock<std::mutex> lock(mtx_); cv_.wait(lock, [this]{ return !queue_.empty(); }); std::optional<T> res{ std::move(queue_.front()) }; queue_.pop(); return res; } bool empty() const { std::lock_guard<std::mutex> lock(mtx_); return queue_.empty(); } };

4.4 实现要点与避坑指南

  1. 异常安全push操作中,queue_.push可能因内存不足抛出std::bad_alloc。由于我们在锁内,如果抛出异常,锁会被正常释放(lock_guard析构),不会造成死锁。这是RAII的巨大优势。
  2. 移动语义:使用std::move传递数据,避免不必要的拷贝,提高性能。
  3. 通知的时机cv_.notify_one()放在锁作用域之外。如果放在锁内,被唤醒的线程会立即尝试获取锁,而此时锁还未释放,导致它再次阻塞,增加了无谓的上下文切换。
  4. empty()函数的局限性:这个函数返回时队列状态可能已改变。它主要用于调试或非关键判断,不能用于决定是否调用wait_and_pop
  5. 使用std::optionaltry_pop返回optional是更现代和安全的做法,避免了需要先构造一个默认T对象传入的开销和可能的问题。

5. 高级议题与性能陷阱

当你掌握了基础同步后,会遇到更隐蔽的问题。

5.1 伪共享(False Sharing):看不见的性能杀手

这是多线程程序性能调优中一个经典且容易被忽略的问题。现代CPU的缓存是以缓存行(Cache Line,通常64字节)为单位加载的。如果两个频繁写的、逻辑上独立的变量(比如两个线程各自的计数器)恰好位于同一个缓存行,就会导致伪共享

问题现象:线程A修改变量X,导致整个缓存行失效。线程B的变量Y虽然没被A修改,但因为和X在同一缓存行,导致B的缓存也失效,必须从更慢的内存或上级缓存重新加载。这种不必要的缓存同步会极大拖慢速度。

示例与诊断

struct Counter { volatile long long a; // volatile防止编译器过度优化 volatile long long b; } counter; // 线程1写a void thread1() { for(int i=0; i<1e9; ++i) counter.a++; } // 线程2写b void thread2() { for(int i=0; i<1e9; ++i) counter.b++; }

两个线程分别修改ab,但由于它们大概率在同一个缓存行,性能会非常差。用性能分析工具(如perf)会观察到极高的缓存失效率。

解决方案:缓存行对齐填充

#include <new> // for std::hardware_destructive_interference_size (C++17) struct alignas(64) Counter { // 64字节对齐,通常是缓存行大小 volatile long long a; char padding[64 - sizeof(long long)]; // 填充剩余字节 }; struct alignas(64) CounterB { volatile long long b; }; // 或者使用C++17标准 struct Counter { alignas(std::hardware_destructive_interference_size) volatile long long a; };

通过alignas或手动填充,确保ab位于不同的缓存行。

5.2 锁竞争与扩展性:如何让程序随核心数增长

当线程数增多时,锁可能成为瓶颈。所有线程都竞争同一把锁(粗粒度锁),并行度上不去。

优化策略

  1. 锁分解(Lock Splitting):将一个大锁保护的大数据结构,拆分成多个小锁保护的小部分。例如,将一个全局哈希表拆分成多个桶,每个桶一把锁。
  2. 锁分段(Lock Striping):这是锁分解的一种通用形式。例如,维护一个固定数量(如16)的锁数组。对数据项key,根据hash(key) % N决定使用哪把锁。这减少了竞争概率。
  3. 无锁数据结构:如前所述,在极高竞争场景下考虑。
  4. 使用线程局部存储(Thread-Local Storage, TLS):如果可能,完全避免共享。每个线程操作自己的数据副本,最后再合并。例如,多线程统计词频,每个线程统计自己的局部Map,最后合并所有局部Map。

5.3 线程池模式:管理线程的生命周期

频繁创建销毁线程开销很大。线程池预先创建一组线程,并维护一个任务队列。提交任务到队列,空闲线程从队列获取并执行。

简易线程池核心逻辑

class ThreadPool { std::vector<std::thread> workers; ThreadSafeQueue<std::function<void()>> tasks; std::atomic<bool> stop{false}; public: ThreadPool(size_t num_threads = std::thread::hardware_concurrency()) { for(size_t i=0; i<num_threads; ++i) { workers.emplace_back([this] { while(!stop) { auto task = tasks.wait_and_pop(); if (task.has_value()) { (*task)(); // 执行任务 } } }); } } ~ThreadPool() { stop = true; // 可能需要通知所有线程醒来以退出 for(auto& w : workers) { if(w.joinable()) w.join(); } } template<typename F> void submit(F&& f) { tasks.push(std::forward<F>(f)); } };

注意:线程池的优雅关闭是个复杂问题,需要小心处理队列中剩余的任务和线程退出。

6. 调试与排查:当多线程程序行为异常时

多线程Bug往往难以复现,依赖于特定的时序。以下是一些实用技巧:

  1. 使用工具

    • ThreadSanitizer (TSan):Clang/GCC内置的数据竞争检测器。编译时添加-fsanitize=thread,运行时能精准定位数据竞争的位置。
    • Helgrind / DRD:Valgrind工具套件中的线程错误检测工具。
    • 锁分析器:如vtune可以分析锁竞争热点。
  2. 代码审查与设计原则

    • 最小化共享:尽可能设计不共享数据的架构。
    • 共享不可变数据:如果数据只读,则无需同步。
    • 使用高级并发抽象:如任务并行库(Intel TBB)、并行算法(C++17std::for_each+ 执行策略)、std::async等,它们封装了复杂的同步细节。
  3. 日志与断言

    • 在关键同步点添加日志,但注意日志输出本身也可能成为同步瓶颈或改变时序。
    • 使用断言检查不变量,在调试版本中尽早暴露问题。
  4. 压力测试与随机休眠

    • 在高并发下长时间运行测试。
    • 在锁操作前后、任务提交点等位置随机插入微小休眠(std::this_thread::sleep_for),可以放大并发问题,使其更容易暴露。

7. 语言与平台特性拾遗

不同语言和平台对多线程的支持各有侧重。

  • C++:自C++11起,将多线程支持纳入标准库(<thread>,<mutex>,<atomic>,<condition_variable>等),实现了跨平台。std::jthread(C++20)提供了自动连接的线程。
  • Java:内置丰富的并发包(java.util.concurrent),提供了高性能的并发容器(ConcurrentHashMap)、线程池(ExecutorService)、高级同步器(CountDownLatch,CyclicBarrier)等,生态成熟。
  • Python:由于GIL(全局解释器锁)的存在,CPython的多线程无法实现CPU密集型任务的并行加速,更适合I/O密集型任务。CPU并行需使用multiprocessing模块或concurrent.futures.ProcessPoolExecutor
  • Go:基于CSP模型的goroutine和channel是语言核心,其“不要通过共享内存来通信,而应通过通信来共享内存”的理念,提供了一种不同的、更高级的并发编程范式,极大地简化了并发程序的设计。

穿越“编写一个多线程程序”这一关,真正的收获不是记住了几个API,而是建立起对并发世界的深刻直觉:时刻警惕共享数据,清晰地定义线程间的协作协议,并学会利用工具来验证和保障程序的正确性。这条路没有终点,每一次对性能极限的冲击,都可能将你带入更深的底层细节。但万变不离其宗,理解数据流、控制流以及硬件如何执行你的代码,是应对一切复杂性的不二法门。从今天起,在你写下每一行可能被多个线程访问的代码时,不妨多问自己一句:这里需要同步吗?我用的工具是最合适的吗?

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/16 15:11:01

终极指南:5分钟掌握英雄联盟国服免费换肤神器R3nzSkin

终极指南&#xff1a;5分钟掌握英雄联盟国服免费换肤神器R3nzSkin 【免费下载链接】R3nzSkin-For-China-Server Skin changer for League of Legends (LOL) 项目地址: https://gitcode.com/gh_mirrors/r3/R3nzSkin-For-China-Server 还在为英雄联盟国服昂贵的皮肤而烦恼…

作者头像 李华
网站建设 2026/6/16 15:10:13

3PEAK思瑞浦 TPA1287U-VS1R MSOP8 仪表放大器

特性 卓越的直流规格-小电压偏移:土40240/GV(最大值)-小电压偏移漂移:0.20.3/GV/C(最大值) -小输入偏置电流:1.5nA(最大) 优秀的交流电规格-共模抑制比:在G1时最小为80分贝 -小输入噪声:15nV/vHzG10 -输入噪声(0.1赫兹至10赫兹):1伏峰值 --3dB带宽:1.2 MHz -峰值瞬态响应率:1.6…

作者头像 李华
网站建设 2026/6/16 15:09:00

1024J冲击能量+三种放电模式:DLG-1高压发生器覆盖电缆故障全场景

⭐ 风电集电线路&#xff1a;高阻接地故障的快速定位35kV集电线路跨越山脊、农田和河谷&#xff0c;常因施工损伤或绝缘老化出现高阻接地故障。传统设备在远端检测点上难以有效激发故障点闪络&#xff0c;而DLG-1的0-32kV连续可调冲击电压和最大2048J输出能量&#xff0c;可针对…

作者头像 李华
网站建设 2026/6/16 15:07:50

Xournal++:当数字笔记遇见专业绘图,你的全能学习伙伴

Xournal&#xff1a;当数字笔记遇见专业绘图&#xff0c;你的全能学习伙伴 【免费下载链接】xournalpp Xournal is a handwriting notetaking software with PDF annotation support. Written in C with GTK3, supporting Linux (e.g. Ubuntu, Debian, Arch, SUSE), macOS and …

作者头像 李华
网站建设 2026/6/16 14:59:24

Linux网络驱动之Fixed-Link(33)

接前一篇文章&#xff1a;Linux网络驱动之Fixed-Link&#xff08;32&#xff09; 本文内容参考&#xff1a; RK3588TRL8367s 四网口千兆交换机配置与性能优化实战-CSDN博客 特此致谢&#xff01; 上一回开始结合dts文件&#xff0c;讲解Linux内核&#xff08;5.10版本&#x…

作者头像 李华