本章介绍了IRQL以及和IRQL相关的一些内容,算是对我知识空缺的一个填补,自此知道了IRQL是怎么一回事
6.1 中断请求级别
硬件设备完成某些事情之后,会通过请求中断的方式通知处理器,中断会连接到中断处理器硬件上,然后将请求发往处理器去执行对应的中断处理程序 ISR(中断服务例程 Interrupt Service Routine)
每个硬件与一个优先级相关联,称为中断请求级别 IRQL,由 HAL 决定,IRQL 在每个处理器上下文都有,就像每个处理器都有一套寄存器一样
IRQL 提供的规则是,处理器执行有最高 IRQL 的代码,也就是说提高 IRQL 会临时阻止小于等于当前 IRQL 的代码的执行
当执行当前线程的时候,发生了 IRQL 等级比较高的中断的时候,会立马中断当前线程保存上下文切换去执行 ISR,书上的两个图:
所有的 ISR 都是运行在最初被中断的线程上的,当有更高级别的中断出现,就会再次中断当前的 ISR
被中断的线程不会因为中断而减少原有的时间片,中断处理结束后,就好像一切都没发生一样
用户模式编程的时候不会提到 IRQL 这个概念,因为在用户模式下 IRQL 始终为 0 且无法更改,但在内核模式下可进行修改,重要的 IRQL 等级说明如下:
- PASSIVE_LEVEL(0):CPU 的正常 IRQL,线程调度正常进行
- APC_LEVEL(1):用于特殊内核 APC,线程调度正常进行
- DISPATCH_LEVEL(2):这是发生变化的地方,调度器不会被唤醒,不允许访问分页内存,不允许在内核对象上等待,否则会系统崩溃
- 设备 IRQL:用于硬件的中断范围,在 x64/ARM/ARM64 上是 3-11,在 x86 上是 3-26,规则与 IRQL2 一样
- HIGH_LEVEL:最高级别,屏蔽一切中断,被一些进行链表操作的 API 所使用,在 x64/ARM/ARM64 上是 15,在 x86 上是 31
当 IRQL>=2 的时候,对执行的代码会有一些限制:
- 访问不在物理内存中的内存地址是一个致命错误,尽量避免使用分页池内存和用户缓冲区数据
- 等待任何调度器内核对象都会引起系统崩溃,除非将等待时间设置为 0
这些限制是因为调度程序在 IRQL=2 上运行,当 IRQL>=2 的时候,调度程序将无法在该处理器上唤醒,不会发生上下文切换
内存被交换到页面文件后,不存在于物理内存中,访问该内存会触发 page fault 中断,由此中断将相应的内存从页面文件交换回物理内存
因为 page fault 中断的级别 IRQL = 1,因此,当代码执行在 IRQL=2 的时候,不会对 page fault 进行处理,导致无法访问到目标内存地址,从而引发系统奔溃
windbg 可以使用
!irql
命令查看 IRQL 等级,可以使用!idt
查看已注册的中断
提升和降低 IRQL
在内核模式下,可以手动提高(API:KeRaiseIrql
) 和降低(API: KeLowerIrql
) IRQL
如果提高了 IRQL,要确保在同一函数内将 IRQL 等级降低回来,低入高返回,容易导致系统奔溃
线程优先级和 IRQL
IRQL 是处理器的属性,线程优先级是线程的属性,线程优先级仅在 IRQL<2 时才有意义
如果某个线程将 IRQL 提高到了 2,理论上它就有了无限的时间片,直到 IRQL 从 2 降低下来为止
任务管理器使用一个叫做系统中断的伪进程来显示花费在 IRQL>=2 时的 CPU 时间:
在 Process Explorer 上使用 Interrupts 伪进程来显示:
6.2 延迟过程调用
书中示例是 ReadFile 函数的调用过程,该函数进入内核后,通过 IO 管理器构造 IRP 派遣给对应驱动程序之后,驱动程序给硬件设备下达工作任务,自此请求线程可以继续做其他的事情,硬件的活它自己干,硬件完成任务之后,会触发硬件中断,将处理结果通知给处理器
硬件触发的中断在设备 IRQL 上进行(中断是异步到达的)
驱动处理请求完成之后,会调用 IoCompleteRequest 来完成请求返回结果,该函数只能在 IRQL<=2 时调用,ISR 不能直接调用该函数 (下一章介绍具体原因)
DPC 是一个对象,封装了一个函数,函数会在 IRQL=2 时调用,DPC 机制允许 ISR 完成之后能够尽快调用 IoCompleteRequest
注册了 ISR 的驱动程序需要预先准备好 DPC 对象(从非分页池中申请内存分配 KDPC 结构,使用 KeInitializeDpc 进行初始化),然后在 ISR 函数的退出之前会调用 KeInsertQueueDpc 将此 DPC 插入 DPC 队列
每个处理器都有自己的 DPC 队列,KeInsertQueueDpc
默认将 DPC 插入到当前处理器的 Dpc 队列,在 ISR 返回的时候,在 IRQL 降低到 0 之前,会查看在处理器队列上有没有 DPC 的存在,如果有,处理器就把 IRQL 降低到 2 去逐个处理 DPC,然后再降低回 0,再返回原来的代码处
处理器会在 IRQL 从高(>=2)到 0 的时候,检查 DPC 队列,如果有 DPC 就请求中断切换到 IRQL2 执行完 DPC 再返回到 IRQL0
再刚刚的图上后面实际上还有一部分,就是 DPC 执行
与时钟一起使用 DPC
DPC 最初是为了给 ISR 使用而创建的
内核时钟也可以使用 DPC,时钟是一个分发器对象,能够使用 KeWaitForSingleObject 函数在其上等待,内核时钟提供了用 DPC 作其回调的方式,可以在时钟到期时请求中断进行调用执行 DPC,好处是可以保证在任何用户代码和部分内核代码之前进行执行
具体使用见:"DPC 定时器的使用"
6.3 异步过程调用
APC 也是封装了函数的结构,APC 的目标是某个特定线程,只有目标线程才能调用 APC 函数,每个线程都有一个 APC 链表
APC 有三种类型:
- 用户模式 APC:在线程进入 alertable 状态时在用户模式的 IRQL PASSIVE_LEVEL 上运行。通常会通过调用如 SleepEx、WaitForSingleObjectEx、WaitForMultipleObjectsEx 以及类似的 API 来达到此目的。在警报状态下,线程会检查其 APC 队列,如果不是空的—其中的 APC 就会被执行,直到队列为空。
- 普通内核模式 APC:在内核模式下的 IRQL PASSIVE_LEVEL 中执行,能够抢占用户模式代码和用户模式 APC。
- 特殊内核 APC:在内核模式下的 IRQL APC_LEVEL(1)中执行,能够抢占用户模式代码、普通内核 APC 和用户模式 APC,这些 APC 被 I/O 系统用来完成 I/O 操作。
关键区会阻止执行用户模式和普通内核 APC(特殊内核 APC 仍可执行)。
线程通过调用 KeEnterCriticalRegion 进入关键区,调用 KeLeaveCriticalRegion 离开关键区。
警戒区阻止执行所有 APC。
线程调用 KeEnterGuardedRegion 进入警戒区,调用 KeL-eaveGuardedRegion 离开警戒区。
将 IRQL 提高到 APC_LEVEL 时会禁止所有 APC 的投递
6.4 结构化异常处理
异常是某些指令执行了会使处理器产生错误的操作而导致的事件,与中断类似,不过异常是同步到来的,中断是异步到来的
如果出现了异常,内核会将其捕获,并且在有可能的时候允许代码处理它,这个机制称为结构化异常处理 SEH,在用户模式和内核模式都可以使用
引发异常后,内核会在发生异常的函数中寻找处理函数,如果没有就在调用栈中寻找处理函数,如果还是没有就系统崩溃
微软往 C 语言里新增了几个关键字来进行异常处理:(用户模式也可以用)
使用__try/__except
此块用来当代码出现异常的时候,针对异常进行处理
用户代码是绝对不可信的,所以访问用户提供的缓冲区的时候需要用 __try/__except
包起来,使得一个错误缓冲区不会造成系统崩溃
将第四章示例处理程序进行修改得到:
case IOCTL_PRIORITY_BOOSTER_SET_PRIORITY: {
// do the work
// 判断接收到的缓冲区大小是否足够
if (stack->Parameters.DeviceIoControl.InputBufferLength < sizeof(ThreadData)) {
status = STATUS_BUFFER_TOO_SMALL;
break;
}
// 获取缓冲区参数
auto data = (PThreadData)stack->Parameters.DeviceIoControl.Type3InputBuffer;
if (data == nullptr) {
status = STATUS_INVALID_PARAMETER;
break;
}
__try {
// 参数合法性检查
if (data->Priority < 1 || data->Priority>31) {
status = STATUS_INVALID_PARAMETER;
break;
}
// 将线程id转换成句柄,然后通过API获取线程结构,该API会增加线程结构的引用计数,需要手动减少
PETHREAD pEthread;
status = PsLookupThreadByThreadId(ULongToHandle(data->ThreadId), &pEthread);
if (!NT_SUCCESS(status)) {
break;
}
// 修改优先级
KeSetPriorityThread(pEthread, data->Priority);
// 手动减少引用计数
ObDereferenceObject(pEthread);
}
__except(EXCEPTION_EXECUTE_HANDLER){ // EXCEPTION_EXECUTE_HANDLER 表示任何异常都会处理
status = STATUS_ACCESS_VIOLATION;
}
break;
}
捕获异常表达式为 EXCEPTION_EXECUTE_HANDLER
时表示任何异常都会处理,也可以通过 GetExceptionCode
API 获取异常类型,然后根据异常进行选择处理
内核提供了通用的函数 ExRaiseStatus
和 ExRaiseAccessViolation
这样特定的函数来抛出任何一个异常。
Windows 平台上其他编程语言的异常处理机制底层就是 SEH
使用__try/__finally
__try/__finally
块的使用是为了不管在代码是否有异常的情况下,都能顺利执行某些代码片段
使用示例:
void foo(){
void* p = ExAllocatePool(PagedPool, 1024);
__try{
// do something
}
__finally{
ExFreePool(p);
}
}
如果发生了异常,__finally
的代码会在从调用栈中搜索处理程序之前得到执行
使用 C++RAII 代替__try/__finally
首先从网上扒一段定义:
RAII(Resource Acquisition Is Initialization)是由 c++ 之父 Bjarne Stroustrup 提出的,中文翻译为资源获取即初始化,他说:使用局部对象来管理资源的技术称为资源获取即初始化;这里的资源主要是指操作系统中有限的东西如内存、网络套接字等等,局部对象是指存储在栈的对象,它的生命周期是由操作系统来管理的,无需人工介入;
RAII 包装器是利用 C++ 局部对象自动销毁的特性来控制资源的生命周期的,示例:内存的管理
template<typename T = void>
class kunique_ptr
{
public:
kunique_ptr(T* p = nullptr) :_p(p) {}
~kunique_ptr() {
if (_p)ExFreePool(_p);
}
T* operatior->() const {
return _p;
}
T& operatior* ()const {
return *_p;
}
private:
T* _p;
};
关于 c++ 的 RAII 包装器用法,过几天专门来琢磨琢磨是怎么用的,单独整理一篇博客,这里就暂时先跳过了
6.5 系统崩溃
BSOD 是一种保护机制,防止内核代码破坏重要系统数据
系统能够配置在崩溃时做哪些事情,在高级系统设置,高级,启动和故障恢复设置里:
转储文件捕获了崩溃时系统的状态,能够在事后获取转储文件来分析崩溃原因
在系统崩溃时,转储信息会写入到第一个页面文件中,而非目标文件,在重启系统时,内核发现页面文件里有转储信息了再将其数据复制到目标文件中去(因为此时系统不够稳定,安全起见最好的选择就是将数据写入到已经打开的页面文件中去)
转储文件的类型决定转储文件保存的信息:
- 小内存转储:仅包含基本的系统信息和引起崩溃的线程信息
- 坏处:不足以判断发生了什么,好处:文件非常小
- 内核内存转储:捕获所有内核内存,不包含用户内存
- Windows7 及以前是默认选项
- 完整内存转储:转储全部内存,包括用户内存
- 转储大小依赖于系统内存大小,以及当前使用量,可能会很大
- 自动内存转储:类似内核内存转储,内核会在启动时自动调整页面文件的大小,以保证有足够的大小容纳一个内核转储。
- 这仅在页面文件大小被指定为“系统管理”时才会进行。
- Windows8 及以后是默认选项
- 活跃内存转储(Windows 10+):类似完整内存转储,但不转储虚拟机所使用的内存
崩溃转储信息
使用 windbg 打开转储文件,并执行命令进行最基本的分析:
!analyze -v
每个崩溃的转储代码最多能有 4 个数字
分析转储文件
这一块内容等日后进行实践分析的时候再好好拿出来分析分析
转储文件是系统的一个快照,分析转储文件和内核分析是一样的,除了不能执行命令,不能下断点,其他命令都可以正常用
一些其他命令:
切换处理器:
~ns // n是处理器索引号
~1s
~2s
列出崩溃时在所有处理器上运行的线程
!running
!running -t // 加上选项-t 显示每个线程的调用栈。
列出所有线程的线程栈
!stack
!stack 0 string // 后面可以跟字符串搜索含该字符串的模块或线程函数
系统挂起
挂起的系统是没有响应或者几乎没有响应的系统。看上去是以某种方式停机了或者死锁了,系统没有崩溃
要获得系统转储可以使用 NotMyFault 工具,强行使系统崩溃并生成转储文件
NotMyFault - Windows Sysinternals | Microsoft Docs
如果系统完全失去响应,而内核调试器连上去,那就正常进行调试或者使用 .dump
命令产生一个转储文件。
如果系统失去响应,内核调试器又无法连上时,如果事先配置好了注册表(假定以某种方式等待挂起),还可以手动产生一个转储文件。
当检测到适当的键组合时,键盘驱动程序会生成一个崩溃。这里的崩溃代码为 0xe2(MANUALLY_INITIATED_CRASH)。
各种强制系统崩溃的信息具体详见微软文档吧
Forcing a System Crash - Windows drivers | Microsoft Docs
6.6 线程同步
为了防止数据竞争,需要线程同步,内核提供了一些原语来帮助达成线程同步
原语是在操作系统中调用核心层子程序的指令。它是不可中断的,而且总是作为一个基本单位出现
互锁操作
互锁函数提供了原子操作,利用硬件特性而不涉及软件对象,能用优先用
表中的函数在用户模式也能用,他们不是真正的函数而是 CPU 内联函数(CPU 特殊指令)
InterlockedCompareExchange 函数家族在无锁编程中使用,无锁编程技术在不使用软件对象的前提下执行复杂的原子操作。
分发器对象
内核提供了一组被称为分发器对象的原语,也被称为可等待对象。这些对象有两种状态:有信号和无信号,含义与对象类型有关。
线程调用等待函数切换到等待状态,在等待期间线程不会消耗 CPU 周期。
用于等待的主要函数是 KeWaitForSingleObject 和 KeWaitForMultipleObjects(从等待函数返回的值传递给 NT_SUCCESS 函数都返回真)
互斥量
互斥量是一种经典的对象,用于解决多个线程中的某个线程在任何时刻访问共享资源的标准问题。
互斥量在自由时为有信号态。一旦某个线程调用等待函数并成功,这个互斥量就变成无信号态,这个线程就称为该互斥量的拥有者。
- 如果某个线程是一个互斥量的拥有者,那么此线程是唯一能释放该互斥量的线程。
- **一个互斥量能多次被同一线程获取。**线程获取几次互斥量就要释放几次互斥量。
互斥量的使用示例:
KMUTEX myMutex;
void init() {
// 必须调用该函数一次来初始化互斥量
KeInitializeMutex(&myMutex, 0);
}
void DoWork() {
__try {
// 等待互斥量
KeWaitForSingleObject(&myMutex, Executive, KernelMode, FALSE, nullptr);
// 执行一些操作
}
__finally {
// 释放互斥量
KeReleaseMutex(&myMutex, FALSE);
}
}
互斥量包装器示例:
struct Mutex {
void init() {
// 必须调用该函数一次来初始化互斥量
KeInitializeMutex(&myMutex, 0);
}
void Lock() {
// 等待互斥量
KeWaitForSingleObject(&myMutex, Executive, KernelMode, FALSE, nullptr);
}
void UnLock() {
// 释放互斥量
KeReleaseMutex(&myMutex, FALSE);
}
private:
KMUTEX myMutex;
};
通用的具有 lock 和 unlock 函数的包装器类型:
template<typename TLock>
struct AutoLock{
AutoLock(TLock& lock) :_lock(lock) {
lock.Lock();
}
~AutoLock() {
_lock.UnLock();
}
private:
TLock& _lock;
};
使用方法:
Mutex myMutex;
// 在使用前先初始化
void init() {
myMutex.init();
}
// 某个线程函数
// 使用通用包装器获取信号量和释放信号量
// 在定义变量的时候自动等待获取,变量生命周期结束的时候自动释放
void DoWork() {
AutoLock<Mutex> locker(myMutex);
// 执行其他代码
}
快速互斥量
快速互斥量是普通互斥量的替代,只有内核模式有,比普通互斥量更快一点,特点:
- 不能被递归获取
- 快速互斥量被获取后,IRQL 会被提升到 APC_LEVEL,会阻断 APC 的传递
- 快速互斥量只能无限等待,不能设定超时值
驱动程序应该优先使用快速互斥量
从非分页池中分配 FAST_MUTEX 结构并调用 ExInitializeFastMutex 可以初始化快速互斥量。
用 ExAcquireFastMutex 或 ExAcquireFastMutexUnsafe(如果当前 IRQL 恰巧已经是 APC_LEVEL)以获取快速互斥量。
用 ExReleaseFastMutex 或 ExReleaseFastMutexUnsafe
快速互斥量包装器(和上例通用):
struct FastMutex {
void init() {
ExInitializeFastMutex(&_FastMutex);
}
void Lock() {
ExAcquireFastMutex(&_FastMutex);
}
void UnLock() {
ExReleaseFastMutex(&_FastMutex);
}
private:
FAST_MUTEX _FastMutex;
};
信号量
信号量的主要目标是用来限制某些东西,比如队列的长度。
比如一个队列最大容量是 100,把容量设置为信号量,那每个线程要往队列里插入值的时候,获取一个信号量,信号量就会减 1,当队列里的成员被移除时,信号量就加 1
信号量最大值和初始值(通常设置成最大值)的初始化通过调用 KeInitializeSemaphore 来完成。
当其内部值大于零时,信号值处于有信号态。此时调用 KeWaitForSingleObject 的线程将结束等待,同时信号量的值减一。如此继续直到其值达到零,这时信号量变成无信号态
信号量没有所有权,一个线程获取的信号量可以被另一个线程释放
事件
事件封装了一个布尔型的标志—要么真(有信号)要么假(无信号)。事件的主要目的是在某事发生时发出信号,提供执行流上的同步。
事件有两种类型:
- 通知事件(手动重置)
- 同步事件(自动重置)
事件的创建:
从非分页池中分配 KEVENT 结构,并指明事件类型(NotificationEvent 或者 SynchronizationEvent)和初始事件状态(Signaled 或者 non-singnaled),去调用 KeInitializeEvent 进行初始化。
等待某个事件则通常通过 KeWaitXxx 函数完成。调用 KeSetEvent 设置事件为有信号,调用 KeResetEvent 或者 KeClearEvent 重置该事件(无信号)(后者比前者稍快一点,因为它不用返回事件的前面状态)。
执行体资源
执行体资源是适合单写多读场景的另一种同步原语,是一种特殊对象,不是分发器对象
初始化执行体资源的方法如下:
从非分页池中分配一个 ERESOURCE 结构并调用 ExInitializeResourceLite。
初始化完成后,线程就能通过 ExAcquireResourceExclusiveLite 获取排他锁(用于写)或者调用 ExAcquireResourceSharedLite 获取共享锁。
工作完成之后,线程用 ExReleaseResourceList 释放执行体资源(不论获取的是哪种锁)。
调用获取和释放函数的先决条件是必须禁止通常的内核 APC。可以通过在调用获取函数之前先调用 KeEnterCriticalRegion 以及在调用释放函数之后调用 KeLeaveCriticalRegion 来做到这点。
代码示例:
ERESOURCE resource;
void WriteData() {
KeEnterCriticalRegion(); // 进入关键区(屏蔽APC)
ExAcquireResourceExclusiveLite(&resource, TRUE); // 获取执行体资源排他锁(写)
// ExAcquireResourceSharedLite(&resource, TRUE); // 获取执行体资源共享锁(读)
// ExEnterCriticalRegionAndAcquireResourceExclusive(&resource); // 进入关键区+获取排他锁
// ExEnterCriticalRegionAndAcquireResourceShared(&resource); // 进入关键区+获取共享锁
// do something
// ExReleaseResourceAndLeaveCriticalRegion(&resource); // 释放锁+退出关键区
ExReleaseResourceLite(&resource); // 释放锁
KeLeaveCriticalRegion(); // 退出关键区
}
释放此资源占用的内存之前,需要先调用 ExDeleteResourceLite 将资源从内核资源列表中移除
6.7 高 IRQL 同步
有一种情况是线程不能进行等待,也就是 IRQL>=2 的时候,这种时候需要另一种同步的方法
场景:IRQL0 需要访问数据,一个定时器 DPC 也会访问该数据,可能同时访问
CPU 单核心下,低 IRQL 函数只需要将 IRQL 提升到 DISPATCH_LEVEL 级别,DPC 就不会干扰函数的执行,等函数执行完再把 IRQL 降低回来
多核心情况下,该方法不适用,因为可能会有另一个核心提升 IRQL2 执行 DPC 了,需要使用自旋锁来完成。
自旋锁
自旋锁是内存中的一个简单位,通过 API 提供原子化的测试和修改操作。
当 CPU 想要获取自旋锁而它当前并不自由的话,CPU 会一直在自旋锁上自旋,等待它被另一个 CPU 释放。(CPU 级别的同步,和线程同步不太一样)
创建自旋锁需要从非分页池中分配一个 KSPIN_LOCK 结构并调用 KeInitializeSpinLock,这将把自旋锁置于未被拥有的状态。
获取自旋锁是一个两步过程:将 IRQL 提升到适当的级别,然后获取自旋锁。
操作自旋锁相关函数:
- 典型场景是给 DPC 用
- 对 ISR 和别的函数之间来用,IRQL 等级取决于最高 IRQL 访问等级
如果获取自旋锁,一定要在当前函数给释放掉,否则可能会死锁
还有一种自旋锁是排队自旋锁,也是只能用于 IRQL2 级别,可以先进先出服务 CPU
6.8 工作项目
驱动程序想要多起一个线程去运行其他的代码,可以显式的创建一个线程,内核提供了分离执行线程的函数:PsCreateSystemThread 和 IoCreateSystemThread(Windows 8 以上可用),这些函数用于长时间在后台运行的驱动程序
IoCreateSystemThread 函数运行将一个设备对象或驱动程序对象关联到线程,新增一个引用计数,确保线程执行的时候驱动不会被过早卸载掉
对于运行时间有限的操作来说,使用内核提供的线程池更好,会在系统工作线程里执行
工作项目是用来描述系统线程池中排队的函数,驱动程序初始化一个工作项目,排队到系统线程池里等待执行,和 DPC 不同的是,这种方式执行的等级是 IRQL PASSIVE_LEVEL
可以用以下两种方式之一来创建和初始化工作项目:(需要确保在工作项目执行完之前,驱动不会被卸载)
- 用 IoAllocateWorkItem 分配和初始化工作项目。此函数返回一个指向不透明 IO_WORKITEM 的指针。在用完这个工作项目之后,需要调用 IoFreeWorkItem 释放它。
- 用 IoSizeofWorkItem 提供的大小动态分配一个 IO_WORKITEM 结构,然后调用 IoInitializeWorkItem。在用完工作项目之后,调用 IoUninitializeWorkItem。
调用 IoQueueWorkItem 将工作项目进行排队。(IoQueueWorkItemEx,在工作项目函数需要在退出之前释放自身的情况下比较有用。)
总结
本章介绍了很多东西,虽然介绍具体是怎么使用的,但至少都讲明白了分别都是什么,也算是对我知识空缺的一个很棒的填补
关于同步这里,概念比较多,这里整理一下都是用于做啥的:
对象 | 对象类型 | 主要用于 |
---|---|---|
互锁函数 | 非对象 | 原子操作 |
互斥量 | 分发器对象 | 用于解决多线程同时访问资源的经典问题 |
快速互斥量 | 特殊对象 | 比互斥量快一点 |
信号量 | 分发器对象 | 用于限制某些东西,例如队列长度 |
事件 | 分发器对象 | 在某件事发生时发出信号,提供执行流上的同步 |
执行体资源 | 特殊对象 | 用于单写多读场景下的另一种同步原语 |
自旋锁 | 内存中的简单位 | 提供高 IRQL 级别的同步 |
关于工作项目,说白了就是驱动程序中的多线程操作,回头针对每一项内容,再好好整理学习总结一下