(五)内核那些事儿:系统和程序的交互
(五)内核那些事儿:系统和程序的交互
1. 程序的交互方式
1.1 事件的生成
事件生成主要涉及硬件中断与输入控制器以及设备驱动的标准化处理两个关键环节。
- 硬件中断与输入控制器 鼠标和键盘通过诸如 USB、PS/2 等接口连接到计算机。当用户进行操作,如按下键盘按键或移动鼠标时,硬件会生成中断信号,该信号通过主板的输入控制器(例如南桥芯片)发送给 CPU。例如,键盘按下某个按键时,键盘控制器会生成对应的扫描码(Scan Code),以此标识按下的具体按键;鼠标移动时则会发送位移数据(X/Y 轴变化量)以及按键状态信息。
- 设备驱动的标准化处理 操作系统中的设备驱动程序(例如 hid.sys 这类通用驱动)负责将硬件信号转换为系统能够识别的输入事件。具体而言,键盘扫描码会被转换为 ASCII 码或 Unicode 字符,例如按下 “A” 键会转换为字符编码 0x41;鼠标位移数据会被转换为屏幕坐标偏移量,鼠标的按键动作(如左键点击)则会被转换为 WM_LBUTTONDOWN 等事件类型,从而使操作系统能够以统一的标准来处理这些输入事件。
1.2 事件如何分发到程序
事件分发到程序主要依托事件队列和缓冲区两个概念,以下详细介绍其存在形式与工作原理:
-
操作系统层面的事件队列
- 运行状态:运行在内核态。
- 功能概述:操作系统维护一个全局事件队列,负责接收来自各类硬件设备(如键盘、鼠标等)产生的事件。之后,操作系统依据事件所指向的目标窗口,将这些事件分发至相应应用程序的事件缓冲区中。
-
应用程序的事件缓冲区
- 运行状态:运行在内核态。 事件接收是操作系统层面的事情,而事件处理的才是程序的事情。也就是说接受事件和事件处理是分离的。OS会为每个程序分配一个缓冲区,这个缓冲区是os管理的,应用程序仅能对其进行消费。具体如下:
- 事件缓冲区的管理:操作系统为每个应用程序分配一个事件缓冲区,此缓冲区一般由底层窗口系统(如 WinAPI 或 X11)负责管理与写入,应用程序仅能对其进行消费。当应用程序未处于阻塞状态时,会从该缓冲区读取事件;若处于阻塞状态,操作系统则会将事件写入缓冲区。一旦缓冲区已满,可能会提示应用程序未响应。
- 操作系统与应用程序的协作:操作系统负责事件的接收与存储,其缓冲区充当了事件的暂存处。应用程序通过自身的事件循环主动从操作系统的缓冲区读取事件,并将其纳入自身独立的事件队列(如 Qt 应用程序有自己的事件队列)进行进一步管理与消费。
- 主线程阻塞时的情况:当主线程阻塞时,操作系统的缓冲区仍可写入事件,Qt 的事件队列也能够接收操作系统分发的事件。但由于主线程被阻塞,这些接收到的事件无法得到及时处理,直至主线程恢复正常运行。
- 未响应情况的触发:若操作系统的缓冲区已满,操作系统可能会选择丢弃后续事件,或者直接标记应用程序为“未响应”状态,这通常发生在事件处理长时间延迟,导致缓冲区持续积压事件的情况下。
-
应用程序层面的事件队列
- 运行状态:运行在用户态。
- 功能概述:每个应用程序都具备自身的事件队列,专门用于接收并处理从操作系统分发而来的事件。应用程序借助事件循环(Event Loop)持续从消息队列中获取事件,并对其进行相应处理。需注意,事件循环并非独立线程,一般与主线程共享。
- 阻塞与响应机制:当程序处于阻塞状态时,事件队列依旧能够接收新事件。这是因为事件的接收与处理过程相互分离。然而,当主线程发生阻塞时,事件队列便无法再接收新事件,程序就会进入未响应状态。例如,在 Qt 环境下,编写一个每输入一个字符就显示该字符,并停顿 5 秒的测试程序。当一次性输入“123456789”这些字符时,输入到“7”的时候程序就会发生未响应,不再识别之后输入的字符。而此前输入的“1234567”则会每隔 5 秒依次显示出来。
1.3 事件如何分发到指定程序?
操作系统通过多种机制来确定将事件分发给哪个窗口,主要涉及焦点窗口与激活状态、模态对话框与输入拦截、输入虚拟化与多桌面以及鼠标和键盘事件各自的定位与焦点机制。
- 焦点窗口与激活状态 只有激活(Active)的窗口才能接收键盘输入,激活状态由用户点击、程序切换(如 Alt + Tab)或系统策略(如弹窗自动激活)决定。例如,当视频播放器全屏时,其窗口处于激活状态,键盘的播放 / 暂停快捷键会被优先处理。
- 模态对话框与输入拦截 当程序弹出模态对话框时,对话框会抢占焦点,后续输入事件仅传递给该对话框,直到其关闭(如点击 “确定” 按钮)。这种机制确保了用户在与模态对话框交互时,输入不会干扰到其他窗口。
- 输入虚拟化与多桌面 在支持多桌面的系统(如 Linux 的 GNOME、Windows 10 虚拟桌面)中,输入事件仅传递给当前桌面的窗口,避免跨桌面干扰。这保证了不同桌面环境下窗口的输入独立性。
- 鼠标事件的定位 鼠标移动或点击时,系统根据当前鼠标指针的屏幕坐标,通过窗口区域检测算法(如矩形碰撞检测)确定指针下方的窗口。例如,Windows 通过 GetWindowAtPoint() 函数获取鼠标所在窗口的句柄(HWND),Linux 的 X11 则通过 XQueryPointer() 获取焦点窗口。通过这些方式,操作系统能够精准定位鼠标事件的目标窗口并进行分发。
- 键盘事件的焦点机制 键盘输入通常分配给当前具有焦点(Focus)的窗口。焦点窗口由用户交互(如点击窗口)或程序逻辑(如 SetFocus() 函数)确定。例如,当用户点击浏览器窗口时,该窗口成为焦点窗口,后续键盘输入(如打字)会被发送给浏览器。
综上所述,操作系统通过硬件与驱动协作生成事件,借助事件队列进行管理与分发,并依靠多种窗口状态判断机制,准确地将鼠标和键盘响应事件分发给对应的窗口,从而实现高效的用户交互处理。
1.4 程序接受键鼠输入的例子
程序接收鼠标和键盘输入涉及硬件层、操作系统层和应用程序层,以下以键盘输入显示字符和鼠标事件处理为例详细说明:
-
硬件层:
- 用户按下键盘上的一个键,硬件设备会生成一个中断信号
- 操作系统的中断处理程序捕获这个信号,并生成相应的事件。
- 键盘控制器检测到按键事件,并将按键的扫描码发送到计算机。
- 更进一步地,其实键盘控制器能够捕捉某一个按键的按下或者释放状态。
-
操作系统层:
- 操作系统接收到键盘控制器发送的扫描码。
- 操作系统将扫描码转换为虚拟键码(Virtual Key Code)。
- 因为不同地区的键盘可能不一样的,因此扫描码和虚拟键码需要有一个映射关系。映射关系由输入法,键盘布局(地区)等决定。
- 操作系统生成一个键盘事件(如
WM_KEYDOWN和WM_CHAR),并将其放入全局的事件队列中。 - 操作系统维护一个全局事件队列,用于存储所有输入事件和系统事件。
- 操作系统根据当前活动窗口和焦点窗口,将事件消息分发到相应的客户端程序。
-
应用程序层:
- 应用程序通过事件处理机制获取键盘事件。
- 应用程序处理键盘事件,将虚拟键码转换为字符。
- 应用程序在适当的位置显示字符。
-
键盘输入显示字符流程
- 硬件层 用户按下键盘按键,硬件设备生成中断信号,操作系统中断处理程序捕获该信号并生成相应事件。同时,键盘控制器检测按键事件,捕捉按键的按下或释放状态,并将按键扫描码发送给计算机。
- 操作系统层
- 码值转换:操作系统接收键盘控制器发送的扫描码,因不同地区键盘差异,依据输入法、键盘布局等映射关系,将扫描码转换为虚拟键码。
- 事件生成与入队:生成如
WM_KEYDOWN和WM_CHAR等键盘事件,并将其放入操作系统维护的全局事件队列,该队列存储所有输入事件和系统事件。 - 事件分发:操作系统根据当前活动窗口和焦点窗口,把事件消息分发到相应客户端程序。
- 应用程序层 应用程序通过自身事件处理机制获取键盘事件,将虚拟键码转换为字符,并在合适位置显示该字符。
-
鼠标事件处理流程
- 事件生成:用户移动鼠标或点击鼠标按钮,生成鼠标事件。
- 目标确定与分发:操作系统依据鼠标指针位置确定目标窗口,若指针位于某窗口区域内,就将鼠标事件分发给该窗口。
1.5 操作系统和 qt 是怎么处理和理解组合键的?
组合键由用户同时按下多个键构成,用于触发特定功能或操作,常见的组合方式是由 Ctrl、Shift、Alt、Fn、Meta(在 Windows 系统中为 Win 键,在 Mac 系统中为 Command 键)等修饰键与其他键组合而成。而修饰键(Modifier Key)则是计算机键盘上的一类特殊按键,本身不能单独输入字符或执行功能,需要与其他按键组合使用,以修改或增强其他按键的功能。注意,可以认为修饰键是固定的,就Ctrl、Shift那些。所谓的更改修饰键无非就是更改键盘映射。
操作系统在处理组合键时,需经过一系列步骤,之后将其传递给应用程序,应用程序再进行相应处理。
-
键盘事件基础 键盘事件一般可简化理解为按下、释放和重复这三个基本事件。然而,对于修饰键,系统有着特殊处理机制。当检测到修饰键按下时,会修改事件的修饰位状态位,使其对应按下的修饰键。值得注意的是,修饰位状态位可以表示多个修饰键同时按下的组合情况。
-
Qt 对组合键的处理 Qt 作为一个跨平台的应用程序开发框架,在接收到操作系统分发的键盘事件后,会进一步处理组合键。
- 事件接收与解析:Qt 应用程序通过其事件处理机制接收键盘事件。在事件处理函数中,Qt 会检查事件的修饰位状态位。例如,在
keyPressEvent(QKeyEvent *event)函数中,可以通过event->modifiers()方法获取当前事件的修饰键状态。 - 组合键判断与处理:Qt 应用程序根据获取到的修饰键状态和按下的其他键,判断是否为特定的组合键。例如,若检测到
event->modifiers() & Qt::ControlModifier且event->key() == Qt::Key_C,则判断用户按下了 “Ctrl + C” 组合键,应用程序可在此处编写复制操作的逻辑。
2. 钩子机制
2.1 什么是钩子机制
操作系统的钩子机制是一种强大的编程技术,它允许应用程序在系统事件的特定阶段插入自定义代码,从而对系统行为进行监视、拦截与干预。本质上,钩子就像是在系统事件流中设置的“哨卡”,当特定类型的事件经过时,与之关联的钩子函数就会被触发执行。常见的输入法和全局热键的实现都离不开钩子机制。
以全局热键为例子,实现全局热键通常依赖于键盘钩子。应用程序通过注册一个全局键盘钩子,当用户按下键盘按键时,钩子函数会被调用。在钩子函数中,应用程序可以检查按下的按键组合是否与预设的全局热键匹配。如果匹配,则执行相应的操作,如打开特定的应用程序、执行某个功能模块等。
以输入法为例子,这也离不开钩子机制,特别是键盘钩子和消息钩子。当用户按下键盘按键时,键盘钩子捕获按键事件。输入法程序通过钩子函数获取按键信息,根据当前输入法的设置和状态,将按键转换为相应的字符或符号。同时,消息钩子用于捕获窗口消息,确保输入法能够与应用程序的窗口进行交互,例如在文本输入框中正确显示输入的内容。例如,在中文输入法中,用户按下英文字母键时,输入法程序通过钩子捕获按键,将其转换为拼音,再根据拼音和用户的输入习惯转换为对应的汉字,并显示在输入框中。
2.2 钩子机制的基本原理
- 钩子函数注册:开发者编写特定的回调函数,即钩子函数,并通过操作系统提供的接口将其注册到系统中。这些钩子函数针对不同类型的系统事件,例如键盘事件、鼠标事件、窗口消息等。注册时,需要指定钩子的类型以及钩子函数的地址,操作系统会将这些信息记录下来,以便在相应事件发生时调用。
- 钩子链的形成:操作系统为每种类型的钩子维护一个钩子链。当一个钩子函数被注册时,它会被添加到相应类型钩子的钩子链中。当特定事件发生时,操作系统会沿着这个钩子链依次调用各个钩子函数。钩子链的存在使得多个应用程序可以同时对同一类型的事件进行监视和处理,不同应用程序的钩子函数按照注册顺序在事件发生时依次执行。
- 事件捕获与处理:当系统检测到特定类型的事件时,它会从钩子链的头部开始,逐个调用钩子函数,并将事件相关的信息传递给它们。钩子函数可以根据这些信息进行自定义处理,比如修改事件参数、阻止事件进一步传递或者执行额外的操作。在处理完事件后,钩子函数可以选择将事件传递给下一个钩子函数(如果存在),或者直接返回给操作系统进行默认处理。
钩子机制是 Windows 系统级编程的重要工具,但需谨慎使用。核心流程为:
- 选择钩子类型,实现对应回调函数;
- 使用
SetWindowsHookEx注册钩子(全局钩子需 DLL); - 通过
CallNextHookEx传递消息,避免阻塞系统; - 程序退出时调用
UnhookWindowsHookEx卸载钩子。
注意,考虑到钩子的影响面,也会进一步分为线程钩子、进程钩子、全局钩子。
3. 选择集
3.1 操作系统是怎么处理选择集的?怎么处理选中对象的?怎么处理多选的?
从底层视角,即硬件、硬件驱动以及鼠标事件层面而言,并不存在选择集这一概念,它属于一个抽象概念,主要由客户端软件负责实现与管理。
操作系统本身并不直接处理选择集。其主要作用是通过各类鼠标事件,如左键、右键、滚轮的点击与释放,鼠标移动,鼠标进入/离开窗口,滚轮移动,双击,拖拽开始/进行/结束等,将用户输入传递给应用程序。例如,当用户在桌面上点击某个文件图标时,鼠标的点击事件会由操作系统捕获,然后传递给负责显示桌面的应用程序。操作系统仅充当输入传递的桥梁,具体如何管理选择集则交由应用程序来决定。
选择集的管理和处理通常由应用程序来完成。以Qt应用程序开发为例,Qt本身并没有抽象出选择集概念。若开发者需要处理多选情况,一般会在Qt应用程序内维护一个用于存放被选中对象的容器。例如,在一个文件管理应用程序中,当用户通过鼠标点击或拖拽框选多个文件时,应用程序会将这些被选中文件的相关信息(如文件路径、文件名等)存储在预先定义好的容器(如QList或QSet)中。
也就是说得开发者自己处理多选情况,然后使用一个容器表示被选中的对象。
4. 基本概念
4.1 用户态和内核态的区别
在计算机系统中,为限制不同程序间的访问能力,防止程序非法获取其他程序内存数据、外围设备数据并传输到网络,CPU 划分出用户态和内核态两个权限等级。线程在 CPU 上运行状态也分为这两种,内核态较用户态享有更高级的指令权限。
用户态和内核态是操作系统的两种运行级别,二者最大区别在于特权级不同,用户态特权级最低,内核态特权级较高。运行在用户态的程序不能直接访问操作系统内核数据结构和程序。
操作系统将数据分别存放于系统空间和用户空间,系统数据存于系统空间,用户进程数据存于用户空间。这种分离存放方式,不仅便于管理,更重要的是能对两部分数据的访问进行控制,防止用户程序误操作或恶意破坏系统数据,确保系统稳定性。
4.2 用户态和内核态的数据指针访问
- 用户态不能访问内核态的指针:为实现内存保护,防止因越界访问导致受保护内存被非法修改甚至系统崩溃,直接通过传递数据指针来传递数据的方式被禁止。
- 内核态可以访问用户态的指针(有前提):必须保证用户态虚拟空间的指针(虚拟空间地址)已分配物理地址,否则指针传入内核态会因不引发缺页异常而报错。
- 内核访问用户进程地址的方式:在内核中访问用户进程的地址时,使用
copy_from_user而非memcpy直接拷贝。copy_from_user具备两个重要功能:一是对用户进程传过来的地址范围进行合法性检查;二是当用户传来的地址未分配物理地址时,定义缺页处理后的异常发生地址,保证程序顺利执行。因为对于用户进程访问虚拟地址,若未分配物理地址,会触发内核缺页异常,内核负责分配物理地址并修改映射页表,此过程对用户进程透明,但内核空间发生缺页时必须显式处理,否则会导致内核错误。而直接使用memcpy未出现异常,是因为只有用户传来的地址空间未分配对应物理地址时才会进行修复,若用户进程之前已使用过这段空间,代表已分配物理地址,自然不会发生缺页异常。
4.3 两种状态的转换
- 系统调用:用户进程主动要求切换到内核态的方式,通过系统调用申请操作系统提供的服务程序来完成工作。
- 异常:当 CPU 执行用户态程序时,若遇到某些不可预知的异常事件,会触发从当前运行进程切换到处理此异常的内核相关程序,从而进入内核态,如缺页异常。
- 外围设备中断:当外围设备完成用户请求的操作后,会向 CPU 发出中断信号,CPU 暂停执行下一条指令,转而执行中断信号处理程序。例如硬盘读写操作完成后,系统会切换到硬盘读写的中断处理程序执行后续操作。
当发生用户态到内核态的切换时(本质是从“用户程序”切换到“内核程序”),会经历以下过程:
- 通过 trap 指令将处理器设置为内核态。
- 保存当前寄存器(栈指针、程序计数器、通用寄存器)。
- 将栈指针设置指向内核栈地址,实现用户栈到内核栈的切换。
- 将程序计数器设置到事先约定的地址,该地址存放着系统调用处理程序的起始(入口)地址。
从内核态返回用户态时,也会进行类似操作。每次用户态和内核态的切换都存在一定开销。
4.4 如何避免频繁切换
用户态和内核态之间的切换存在开销,频繁切换会带来较大性能损耗,可采取以下方法减少切换:
- 减少线程切换:线程切换会导致用户态和内核态之间的切换,减少线程切换可降低这两种状态的切换频率。
- 无锁并发编程:多线程竞争锁时,加锁、释放锁会引发较多上下文切换,采用无锁并发编程可避免这种情况。
- CAS 算法:使用 CAS(Compare - And - Swap)算法可避免加锁,防止线程阻塞,进而减少上下文切换。
- 使用最少的线程:避免创建不必要的线程,从根源上减少因线程切换导致的用户态和内核态切换。
- 协程:在单线程内实现多任务调度,并维持多个任务间的切换,避免多线程带来的频繁状态切换。
- 使用用户进程缓冲区(buffer):程序读取文件时,常先申请一块内存数组作为 buffer,每次调用
read读取设定字节长度的数据写入 buffer,后续程序从 buffer 中获取数据,buffer 使用完后再进行下一次调用填充。通过这种方式减少系统调用次数,从而降低用户态与内核态切换所耗费的时间。
4.5 系统调用
- 什么是系统调用:系统调用是操作系统提供给用户编程使用的公共子程序,通常以函数或方法的形式呈现。
- 为什么要使用系统调用:操作系统为安全管理计算机软硬件资源,划分了用户态和内核态,防止程序员直接操作系统资源(如进程、内存、I/O、文件等),因为应用软件不具备对硬件所有资源的使用权限,否则可能因误操作或恶意软件导致系统破坏。用户虽无法直接操作这些资源,但可通过系统调用向操作系统请求相关资源服务,例如 I/O 请求与释放、设备启动、文件的创建读写删除、进程的创建撤销阻塞唤醒、进程间消息传递以及内存的分配回收等。
- 程序员如何使用系统调用以及操作系统如何响应:程序员在代码中先传递系统调用参数,接着通过陷入(trap)指令将用户态转换为内核态,实现从用户栈到内核栈的切换,并将返回地址压入内核栈备用。随后,CPU 执行相应的内核服务程序,完成操作后再返回用户态。
以 int 0x80 指令为例,该指令会使 CPU 陷入中断,执行对应的 0x80 中断处理函数。在 Linux 系统中,用户态和内核态使用不同的栈,各自负责相应的函数调用且互不干扰。当执行 int $0x80 时,程序需从用户态切换到内核态,当前栈要从用户栈切换到内核栈。当中断程序执行结束返回时,当前栈需从内核栈切换回用户栈。当前栈由 ESP 寄存器的值指向,若 ESP 值位于用户栈范围,则当前栈为用户栈,反之亦然。寄存器 SS 的值指向当前栈所在的页。用户栈切换到内核栈的过程如下:
- 将当前 ESP、SS 等寄存器的值保存到内核栈上。
- 将 ESP、SS 等值设置为内核栈的相应值。
从内核栈切换回用户栈时,则恢复 ESP、SS 等寄存器的值,即把保存在内核栈的原 ESP、SS 等值重新设置回对应寄存器。
Enjoy Reading This Article?
Here are some more articles you might like to read next: