原文地址:https://mp.weixin.qq.com/s/3nSgjPKSUNDL-RnUM-Zgfw
你是否对嵌入式 Linux 充满热情,渴望在这个领域中大展身手?如果是,那么这场面试题的考验你绝对不能错过。嵌入式 Linux 作为一种强大而灵活的技术,对专业人才的需求与日俱增。通过这些精心挑选的面试题,你将有机会展示自己的实力,同时也能发现自己的不足之处,为进一步提升自己的技能指明方向。现在,让我们一同深入嵌入式 Linux 的世界,看看50道八股文你能答对多少道。
常见的Linux发行版包括Ubuntu、Debian、CentOS、Fedora、Red Hat、Arch Linux等。这些发行版有着不同的特点和适用场景,例如,Ubuntu和Debian适合桌面和服务器应用,CentOS和Red Hat适合企业级应用,Arch Linux适合高级用户和定制化需求。
在Debian/Ubuntu系统中,可以使用以下命令查看已安装的软件包:
dpkg --list
在CentOS/RHEL系统中,可以使用以下命令查看已安装的软件包:
yum list installed
可以使用以下命令查看Linux系统的CPU占用率:
top
该命令会打印出系统正在运行的进程和相应的资源占用情况,包括CPU占用率、内存占用率等。
可以使用以下命令查看Linux系统的磁盘使用情况:
df
该命令会打印出系统中每个文件系统的使用情况,包括已用空间、剩余空间等。
可以使用以下命令查看Linux系统的网络连接情况:
netstat
该命令会打印出系统中当前的网络连接状态,包括建立的连接、监听的端口等。
在Debian/Ubuntu系统中,可以使用以下命令安装软件包:
apt-get install <package_name>
在CentOS/RHEL系统中,可以使用以下命令安装软件包:
yum install <package_name>
可以使用以下命令查看Linux系统的系统日志:
tail /var/log/syslog
该命令会打印出系统的系统日志,包括系统重要事件、错误信息等。
可以使用以下命令在Linux系统中创建临时文件:
mktemp
该命令会创建一个唯一的临时文件,并打印出该文件的名称。
可以使用以下命令在Linux系统中修改文件权限:
chmod
该命令可以修改文件的读、写、执行权限,例如,将某个文件设置为只读:
chmod 400 <filename>
- 主机加电自检,加载 BIOS 硬件信息。
- 读取 MBR 的引导文件(GRUB、LILO)。
- 引导 Linux 内核。
- 运行第一个进程init(进程号永远为 1 )。
- 进入相应的运行级别。
- 运行终端,输入用户名和密码。
关机。单机用户模式。字符界面的多用户模式(不支持网络)。字符界面的多用户模式。未分配使用。图形界面的多用户模式。重启。
- 管道(pipe)、流管道(s_pipe)、有名管道(FIFO)
- 信号(signal)
- 消息队列
- 共享内存
- 信号量
- 套接字(socket)
比较重要的是/var/log/messages日志文件。该日志文件是许多进程日志文件的汇总,从该文件可以看出任何入侵企图或成功的入侵。另外,如果胖友的系统里有 ELK 日志集中收集,它也会被收集进去。
交换空间是Linux使用的一定空间,用于临时保存一些并发运行的程序。当RAM没有足够的内存来容纳正在执行的所有程序时,就会发生这种情况。
root帐户就像一个系统管理员帐户,允许你完全控制系统。你可以在此处创建和维护用户帐户,为每个帐户分配不同的权限。每次安装Linux时都是默认帐户。
LILO是Linux的引导加载程序。它主要用于将Linux操作系统加载到主内存中,以便它可以开始运行。
BASH是Bourne Again SHell的缩写。它由Steve Bourne编写,作为原始Bourne Shell(由/ bin / sh表示)的替代品。它结合了原始版本的Bourne Shell的所有功能,以及其他功能,使其更容易使用。从那以后,它已被改编为运行Linux的大多数系统的默认shell。
命令行界面(英语**:command-line interface**,缩写]:CLI)是在图形用户界面得到普及之前使用最为广泛的用户界面,它通常不支持鼠标,用户通过键盘输入指令,计算机接收到指令后,予以执行。也有人称之为字符用户界面(CUI)。通常认为,命令行界面(CLI)没有图形用户界面(GUI)那么方便用户操作。因为,命令行界面的软件通常需要用户记忆操作的命令,但是,由于其本身的特点,命令行界面要较图形用户界面节约计算机系统的资源。在熟记命令的前提下,使用命令行界面往往要较使用图形用户界面的操作速度要快。所以,图形用户界面的操作系统中,都保留着可选的命令行界面。
开源允许你将软件(包括源代码)免费分发给任何感兴趣的人。然后,人们可以添加功能,甚至可以调试和更正源代码中的错误。它们甚至可以让它运行得更好,然后再次自由地重新分配这些增强的源代码。这最终使社区中的每个人受益。
这种所谓的自由软件运动具有多种优势,例如可以自由地运行程序以及根据你的需要自由学习和修改程序。它还允许你将软件副本重新分发给其他人,以及自由改进软件并将其发布给公众。
一般来说,面试不会问 inode 。但是 inode 是一个重要概念,是理解 Unix/Linux 文件系统和硬盘储存的基础。理解inode,要从文件储存说起。文件储存在硬盘上,硬盘的最小存储单位叫做"扇区"(Sector)。每个扇区储存512字节(相当于0.5KB)。操作系统读取硬盘的时候,不会一个个扇区地读取,这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个"块"(block)。
这种由多个扇区组成的"块",是文件存取的最小单位。"块"的大小,最常见的是4KB,即连续八个 sector组成一个 block。文件数据都储存在"块"中,那么很显然,我们还必须找到一个地方储存文件的元信息,比如文件的创建者、文件的创建日期、文件的大小等等。这种储存文件元信息的区域就叫做inode,中文译名为"索引节点"。每一个文件都有对应的inode,里面包含了与该文件有关的一些信息。简述 Linux 文件系统通过 i 节点把文件的逻辑结构和物理结构转换的工作过程?一般来说,面试官不太会问这个题目。
Linux 通过 inode 节点表将文件的逻辑结构和物理结构进行转换。inode 节点是一个 64 字节长的表,表中包含了文件的相关信息,其中有文件的大小、文件所有者、文件的存取许可方式以及文件的类型等重要信息。在 inode 节点表中最重要的内容是磁盘地址表。在磁盘地址表中有 13 个块号,文件将以块号在磁盘地址表中出现的顺序依次读取相应的块。
Linux 文件系统通过把 inode 节点和文件名进行连接,当需要读取该文件时,文件系统在当前目录表中查找该文件名对应的项,由此得到该文件相对应的 inode 节点号,通过该 inode 节点的磁盘地址表把分散存放的文件物理块连接成文件的逻辑结构。
1)硬链接 由于 Linux 下的文件是通过索引节点(inode)来识别文件,硬链接可以认为是一个指针,指向文件索引节点的指针,系统并不为它重新分配 inode 。每添加一个一个硬链接,文件的链接数就加 1 。不足:1)不可以在不同文件系统的文件间建立链接;2)只有超级用户才可以为目录创建硬链接。2)软链接 软链接克服了硬链接的不足,没有任何文件系统的限制,任何用户可以创建指向目录的符号链接。因而现在更为广泛使用,它具有更大的灵活性,甚至可以跨越不同机器、不同网络对文件进行链接。
不足:因为链接文件包含有原文件的路径信息,所以当原文件从一个目录下移到其他目录中,再访问链接文件,系统就找不到了,而硬链接就没有这个缺陷,你想怎么移就怎么移;还有它要系统分配额外的空间用于建立新的索引节点和保存原文件的路径。实际场景下,基本是使用软链接。总结区别如下:· 硬链接不可以跨分区,软件链可以跨分区。· 硬链接指向一个 inode 节点,而软链接则是创建一个新的 inode 节点。· 删除硬链接文件,不会删除原文件,删除软链接文件,会把原文件删除。
RAID 全称为独立磁盘冗余阵列(Redundant Array of Independent Disks),基本思想就是把多个相对便宜的硬盘组合起来,成为一个硬盘阵列组,使性能达到甚至超过一个价格昂贵、 容量巨大的硬盘。RAID 通常被用在服务器电脑上,使用完全相同的硬盘组成一个逻辑扇区,因此操作系统只会把它当做一个硬盘。RAID 分为不同的等级,各个不同的等级均在数据可靠性及读写性能上做了不同的权衡。在实际应用中,可以依据自己的实际需求选择不同的 RAID 方案。当然,因为很多公司都使用云服务,大家很难接触到 RAID 这个概念,更多的可能是普通云盘、SSD 云盘酱紫的概念。
- 添加普通用户登陆,禁止 root 用户登陆,更改 SSH 端口号。修改 SSH 端口不一定绝对哈。当然,如果要暴露在外网,建议改下。
- 服务器使用密钥登陆,禁止密码登陆。
- 开启防火墙,关闭 SElinux ,根据业务需求设置相应的防火墙规则。
- 装 fail2ban 这种防止 SSH 暴力破击的软件。
- 设置只允许公司办公网出口 IP 能登陆服务器(看公司实际需要),也可以安装 VPN 等软件,只允许连接 VPN 到服务器上。
- 修改历史命令记录的条数为 10 条。
- 只允许有需要的服务器可以访问外网,其它全部禁止。
- 做好软件层面的防护。
CC 攻击,主要是用来攻击页面的,模拟多个用户不停的对你的页面进行访问,从而使你的系统资源消耗殆尽。DDOS 攻击,中文名叫分布式拒绝服务攻击,指借助服务器技术将多个计算机联合起来作为攻击平台,来对一个或多个目标发动 DDOS攻击。攻击,即是通过大量合法的请求占用大量网络资源,以达到瘫痪网络的目的。
怎么预防 CC 攻击和DDOS攻击?防 CC、DDOS攻击,这些只能是用硬件防火墙做流量清洗,将攻击流量引入黑洞。流量清洗这一块,主要是买 ISP服务商的防攻击的服务就可以,机房一般有空余流量,我们一般是买服务,毕竟攻击不会是持续长时间。
由于程序员的水平及经验参差不齐,大部分程序员在编写代码的时候,没有对用户输入数据的合法性进行判断。
应用程序存在安全隐患。用户可以提交一段数据库查询代码,根据程序返回的结果,获得某些他想得知的数据,这就是所谓的 SQL 注入。
SQL注入,是从正常的 WWW端口访问,而且表面看起来跟一般的 Web 页面访问没什么区别,如果管理员没查看日志的习惯,可能被入侵很长时间都不会发觉。如何过滤与预防?数据库网页端注入这种,可以考虑使用 nginx_waf做过滤与预防。
系统调用(System call)是程序向系统内核请求服务的方式。可以包括硬件相关的服务(例如, 访问硬盘等),或者创建新进程,调度其他进程等。系统调用是程序和操作系统之间的重要接 口。
库函数:把一些常用的函数编写完放到一个文件里,编写应用程序时调用,这是由第三方提供 的,发生在用户地址空间。
在移植性方面,不同操作系统的系统调用一般是不同的,移植性差;而在所有的ANSI C编译器 版本中,C库函数是相同的。
在调用开销方面,系统调用需要在用户空间和内核环境间切换,开销较大;而库函数调用属于 “过程调用”,开销较小。
Linux虚拟内存技术通过将进程的虚拟地址空间映射到物理内存中,实现对系统内存资源的高效管理和使用。虚拟内存技术实现过程:虚拟地址空间划分-->页表映射-->页面置换-->内存回收-->内存映射文件-->内核模式和用户模式切换。
insmod-->init(负责初始化驱动模块并且注册相关设备、分配资源等操作),rmmod-->exit(负责释放驱动模块所占用的资源、注销设备等操作)。比如:设计驱动模块时注意:初始化和清理工作,错误处理及依赖关系、冲突解决。
- 查看 Oops 信息:首先要获取 Oops 的完整信息,它通常会显示在控制台或日志中,包括出错的代码地址、调用栈、进程信息等。
- 分析调用栈:根据 Oops 输出的调用栈信息,找到出错的函数和相关上下文。这有助于确定问题发生的位置和原因。
- 查找代码问题:确认指针是否为 NULL 或未初始化。检查数组越界、内存访问违规等常见错误。查看数据结构是否正确,确保在使用前进行了适当的初始化。
- 使用调试工具:使用 gdb 调试内核或模块,可以加载符号文件以便更好地理解程序执行状态。使用 printk() 打印调试信息,以帮助确认程序执行流程和变量状态。
- 重现问题:如果可能,尝试重现 Oops 以进一步分析,确保可以稳定复现后进行调试。
- 修复并测试:找到根本原因后,进行相应修改,并通过测试验证问题是否得到解决。
- 查阅资料:有时可以在网上查找其他开发者是否遇到类似问题,并参考他们的解决方案。
ioctl是一个系统调用,用于设备输入 / 输出控制。它提供了一种在用户空间和内核空间之间传递控制信息的机制,主要用于对设备驱动进行各种操作,这些操作不能简单地归类为读或写操作。例如,对设备进行特殊的配置、获取设备的特定状态信息、发送控制命令等。
它的函数原型通常是int ioctl(int fd, unsigned long request,...);,其中fd是文件描述符,指向要操作的设备文件;request是一个命令码,用于指定要执行的操作;省略号表示可以传递的参数,其类型和含义取决于request命令码。
unlock_ioctl是ioctl的一个变体。在 Linux 内核中,在某些情况下,为了防止死锁等并发问题,当从文件系统层调用ioctl时,会先获取文件系统的锁。而unlock_ioctl提供了一种在不需要获取这些锁的情况下执行ioctl - 类似功能的机制。
⑴并发处理方面
当ioctl被调用时,它可能会受到文件系统锁机制的影响。如果在文件系统层已经获取了相关的锁,那么ioctl操作会在这些锁的保护下进行。这在多线程或者多进程访问同一个设备文件并且可能存在并发冲突的情况下是很重要的。例如,在一个多进程的环境中,多个进程可能会通过ioctl对同一个设备进行配置操作,ioctl的锁机制有助于确保这些操作的顺序性和原子性,防止数据不一致等问题。
unlock_ioctl的设计初衷是为了在不需要锁保护的情况下执行操作。这可能是因为调用者已经确保了操作的安全性(例如,已经在外部实现了适当的同步机制),或者操作本身不会对共享数据造成冲突。它提供了一种更灵活的方式来执行类似于ioctl的控制操作,尤其是在那些对性能要求较高且能够确保不会出现并发冲突的场景中。
⑵使用场景方面
ioctl适用于大多数常规的设备控制操作,特别是当需要与文件系统的锁机制协同工作以确保设备操作的安全性和一致性时。例如,对块设备进行格式化操作,这个操作可能涉及到文件系统相关的底层设置,通过ioctl可以在锁的保护下安全地进行。或者对字符设备进行波特率设置等操作,ioctl可以确保在多个用户空间程序访问设备时,设置操作按照正确的顺序和方式进行。
unlock_ioctl通常用于一些特殊的、自定义的设备控制场景,这些场景下调用者对设备操作的并发情况有自己的控制机制,或者操作本身比较简单且不会影响其他正在进行的操作。比如,对于一个简单的自定义传感器设备,其unlock_ioctl操作可能只是用于获取设备的内部温度传感器的当前温度读数,并且这个操作被设计为可以在不需要文件系统锁的情况下快速执行,因为它不会对设备的其他功能(如校准操作等)产生干扰。
⑶在内核实现方面
在传统的设备驱动实现中,ioctl函数会通过switch语句或者其他方式来处理不同的request命令码。它通常会与设备驱动的内部数据结构和操作紧密结合。例如,在一个简单的字符设备驱动中,ioctl函数可能会根据不同的命令码来读取或修改设备的寄存器值,代码可能如下:
long my_device_ioctl(struct file *filp, unsigned long cmd, unsigned long arg) {
struct my_device_data *dev_data = filp->private_data;
switch (cmd) {
case MY_DEVICE_CMD_READ_REGISTER:
// 从设备寄存器读取数据并返回给用户空间
return put_user(dev_data->register_value, (unsigned long __user *)arg);
case MY_DEVICE_CMD_WRITE_REGISTER:
// 从用户空间获取数据并写入设备寄存器
return get_user(dev_data->register_value, (unsigned long __user *)arg);
default:
return -ENOTTY;
}
}
unlock_ioctl的实现通常也类似于ioctl,但在处理过程中会跳过与文件系统锁相关的部分。它的实现可能更侧重于快速执行特定的操作,而不需要考虑文件系统层的锁机制。不过,在实际应用中,需要谨慎使用unlock_ioctl,因为如果使用不当,可能会导致并发问题。例如,在一个网络设备驱动中,如果unlock_ioctl用于修改网络接口的某些配置参数,而没有适当的并发控制,可能会导致网络通信出现混乱。
⑴内存访问方式的差异
内核的虚拟地址空间访问要求:Linux 内核运行在虚拟地址空间中,这是一种对物理内存的抽象。内核只能通过虚拟地址来访问内存,无法直接使用物理地址进行访问。虚拟地址经过内存管理单元(MMU)的转换,才会映射到相应的物理地址。这种机制为内核提供了内存保护、多进程并发等重要功能,确保了系统的稳定性和安全性。
外设的物理地址特性:外设的寄存器或其他需要访问的物理内存区域具有固定的物理地址,这些地址是由硬件设计决定的。与内核的虚拟地址空间相互独立,内核无法直接通过常规的内存访问方式来操作这些物理地址。
⑵硬件与软件的兼容性和可移植性
不同硬件架构的差异:不同的 CPU 架构对内存和外设的编址方式可能不同。例如,x86 处理器有专门的 I/O 地址空间,需要使用特定的 I/O 指令来访问;而 RISC 指令系统的 CPU(如 ARM、PowerPC 等)通常将外设 I/O 端口作为内存的一部分进行访问1。ioremap 函数为不同架构的硬件提供了一种统一的接口,使得驱动程序可以在不同的硬件平台上运行,而不需要针对特定的硬件架构进行大量的修改。
驱动程序的可移植性需求:为了使驱动程序能够在不同的 Linux 系统版本和硬件平台上通用,需要使用一种与硬件无关的方式来访问外设。ioremap 函数将物理地址映射到内核虚拟地址空间,使得驱动程序可以使用统一的虚拟地址来操作外设,提高了驱动程序的可移植性。
⑶方便内核对内存的管理和保护
内存管理的一致性:通过 ioremap 将外设的物理地址映射到内核虚拟地址空间后,内核可以将对外设的访问与对普通内存的访问统一管理。这样可以方便地使用内核中的各种内存管理机制,如页表管理、内存分配和回收等,确保内存的正确使用和管理。
访问权限的控制:内核可以通过虚拟地址空间的访问权限设置,对驱动程序访问外设的行为进行控制。例如,可以设置某些虚拟地址区域为只读或只写,防止驱动程序对设备进行不恰当的操作,从而提高系统的稳定性和安全性。
⑴设备驱动模型的三个重要成员
设备(device):代表物理设备或者虚拟设备,它包含了设备的物理属性(如设备地址、中断号等)和逻辑属性(如设备名称、设备功能等)。设备结构体(struct device)用于在内核中描述设备信息。例如,对于一个 USB 设备,设备结构体中会记录 USB 设备的 Vendor ID、Product ID、端点信息等物理属性,以及设备是存储设备还是通信设备等逻辑属性。
驱动(driver):用于控制设备的软件模块,包含了操作设备的函数,如设备的初始化函数、读写函数、中断处理函数等。驱动结构体(struct driver)定义了这些操作函数以及驱动的名称、所属模块等信息。以字符设备驱动为例,驱动结构体中的read、write函数用于实现用户空间和内核空间的数据交互,probe函数用于在设备和驱动匹配时进行初始化操作。
总线(bus):作为设备和驱动之间的通信桥梁,负责设备和驱动的匹配、枚举等工作。不同类型的设备连接在不同的总线上,如 PCI 总线用于连接 PCI 设备,USB 总线用于连接 USB 设备。总线结构体(struct bus_type)定义了总线的名称、设备和驱动的匹配函数、设备枚举函数等。
⑵platform 总线的匹配规则
基于设备树(Device Tree)的匹配:在设备树中,每个设备节点都有一个兼容属性(compatible),用于指定设备与驱动的匹配信息。当注册一个设备到平台总线时,总线会遍历已注册的驱动,查看驱动是否支持该设备。驱动也有对应的of_device_id结构体数组,其中包含了驱动支持的设备兼容属性列表。如果设备树中的设备兼容属性与驱动的of_device_id中的某一项匹配成功,就认为设备和驱动匹配。例如,一个设备树节点的兼容属性为 “my - company, my - device”,而驱动的of_device_id中有一个项为 {.compatible = “my - company, my - device”},则两者匹配。
基于 ID 表的匹配除了设备树匹配外,还有传统的基于 ID 表的匹配方式。驱动结构体中有一个id_table,它是一个struct platform_device_id结构体数组,列出了驱动支持的设备 ID。当注册一个设备时,平台总线会检查设备的id是否与驱动的id_table中的某一项匹配。如果匹配成功,就将设备和驱动关联起来。
⑶设备和驱动注册顺序
在 Linux 设备驱动模型中,没有严格要求一定要先注册驱动再注册设备,或者先注册设备再注册驱动。这两种方式在不同的场景下都可以使用。
先注册设备后注册驱动这种方式比较符合设备先存在于系统中的实际情况。例如,在系统启动过程中,硬件设备已经物理连接到系统中,内核首先会通过设备初始化代码来注册设备。当相应的驱动模块加载并注册时,总线会发现已经存在匹配的设备,从而调用驱动的probe函数来进行设备和驱动的初始化连接。这种方式的优点是可以更好地反映设备的实际存在状态,并且在驱动开发过程中,可以先确定设备的属性,然后编写与之匹配的驱动。
先注册驱动后注册设备这种方式在一些动态设备添加的场景中比较有用。例如,在热插拔设备的情况下,驱动可能已经预先加载并注册到总线中。当设备插入时,系统注册设备,总线会立即找到匹配的驱动并进行连接。另外,在一些测试场景或者模块开发过程中,先注册驱动可以方便地等待设备的到来,一旦设备注册成功,就可以快速进行测试和验证。
⑴内核空间与用户空间的区别
▼访问权限
内核空间:拥有最高的权限,可以访问系统的所有硬件资源,包括 CPU 寄存器、物理内存、I/O 端口等。这是因为内核需要对硬件进行管理和控制,如设备驱动程序需要直接操作硬件寄存器来实现设备的初始化、数据传输等功能。例如,在处理网络数据包的接收时,内核中的网络驱动需要访问网卡的寄存器来读取数据包,这种操作只有内核空间具有权限执行。
用户空间:权限受到严格限制,不能直接访问硬件资源。用户进程只能通过系统调用请求内核来间接访问硬件。这样设计的目的是为了保护系统的稳定性和安全性,防止用户进程错误地操作硬件导致系统崩溃。例如,一个普通的用户应用程序(如文本编辑器)不能直接访问磁盘的扇区来读写文件,必须通过内核提供的文件系统接口(如read、write系统调用)来进行文件操作。
⑵内存管理
内核空间:有独立的内存区域,其内存布局是由内核自己管理的。内核可以访问所有物理内存,并且可以使用一些特殊的内存分配机制,如kmalloc(用于分配小块连续内存)、vmalloc(用于分配非连续内存)等。内核空间的内存分配和回收通常是为了满足系统运行的需求,如为设备驱动分配缓冲区、为内核数据结构分配内存等。例如,在加载一个设备驱动模块时,内核会使用内存分配函数为驱动的全局变量、设备结构体等分配内存空间。
用户空间:每个用户进程都有自己独立的虚拟地址空间,这个空间是由内核通过内存管理单元(MMU)为其分配的。用户进程看到的内存地址是虚拟地址,经过 MMU 的转换才能访问到实际的物理内存。用户空间的内存分配主要通过系统调用(如brk或mmap)来请求内核分配内存,如malloc库函数最终也是通过系统调用向内核请求内存。不同用户进程的虚拟地址空间是相互隔离的,一个进程不能直接访问另一个进程的内存,这保证了进程的独立性和安全性。
⑶运行模式和稳定性要求
内核空间:一直处于内核模式运行,主要负责系统的管理和维护工作,如进程调度、内存管理、设备驱动等。内核的稳定性至关重要,因为一旦内核出现错误(如空指针引用、非法内存访问等),很可能导致整个系统崩溃,即出现 “内核恐慌(Kernel Panic)” 的情况。所以内核代码在开发过程中经过了严格的测试和验证。
用户空间:运行在用户模式,主要用于运行各种应用程序。用户进程的崩溃一般不会影响到系统的其他部分和内核的正常运行。例如,一个用户应用程序(如游戏软件)出现内存泄漏或者段错误,只会导致该应用程序自身退出,而不会导致整个操作系统无法运行。
⑸用户空间与内核通信方式
▼系统调用(System Call)
原理和机制:这是用户空间与内核空间通信的最基本方式。用户进程通过执行一条特殊的指令(如在 x86 架构下是int 0x80或syscall指令)来触发一个系统调用。这个指令会使 CPU 从用户模式切换到内核模式,并将控制权交给内核中的系统调用处理程序。系统调用处理程序会根据系统调用号(每个系统调用都有一个唯一的编号)来执行相应的内核函数。例如,write系统调用用于将数据写入文件,当用户进程执行write系统调用时,内核会执行相应的文件写入操作,如将数据从用户空间的缓冲区复制到内核缓冲区,再通过文件系统驱动将数据写入磁盘。
应用场景和示例:广泛应用于文件操作、进程管理、网络通信等各个方面。以open系统调用为例,用户程序想要打开一个文件时,会调用open函数,这个函数内部会执行系统调用,请求内核打开指定的文件。内核会根据文件路径找到对应的文件节点,进行权限检查等操作,然后返回一个文件描述符给用户进程,用户进程就可以使用这个文件描述符进行后续的读、写等操作。
▼信号(Signal)
原理和机制:信号是一种异步的通信机制,用于通知进程发生了某个事件。信号可以由内核发送给用户进程,也可以由用户进程发送给其他用户进程。当内核检测到一个事件(如进程收到一个非法指令、定时器到期等)时,会向相关的用户进程发送一个信号。用户进程可以预先注册信号处理函数,当接收到信号时,会暂停当前的执行流程,转而执行信号处理函数。信号有多种类型,如SIGINT(通常由用户按下 Ctrl - C 产生,用于中断进程)、SIGKILL(强制终止进程)等。
应用场景和示例:在进程控制和错误处理方面非常有用。例如,当一个后台运行的服务器进程接收到SIGTERM信号(通常用于请求进程正常终止)时,它可以在信号处理函数中进行一些清理工作,如关闭文件描述符、释放内存等,然后正常退出,避免数据丢失和资源浪费。
▼设备文件(Device File)
原理和机制:在 Linux 系统中,设备被抽象为文件,位于/dev目录下。用户进程可以像操作普通文件一样对设备文件进行打开、读、写等操作。当用户进程打开一个设备文件时,内核会根据设备文件对应的设备驱动程序来处理后续的操作。例如,对于一个字符设备(如终端设备),用户进程打开/dev/tty后,可以通过read和write系统调用从终端读取用户输入的数据或者向终端输出数据。
应用场景和示例:用于与硬件设备进行交互。以打印机设备为例,用户可以通过向对应的打印机设备文件(如/dev/lp0)写入数据来发送打印任务。内核会将用户写入的数据传递给打印机设备驱动,由驱动控制打印机进行打印操作。
▼共享内存(Shared Memory)
原理和机制:这是一种高效的通信方式,通过在内存中开辟一块共享区域,让用户空间和内核空间都能够访问。在内核空间,使用shmget等函数来创建共享内存段,获取共享内存的标识符,并将其映射到内核的虚拟地址空间。在用户空间,通过系统调用(如mmap)将共享内存段映射到用户进程的虚拟地址空间。这样,用户进程和内核就可以通过读写这块共享内存来交换信息。不过,为了避免并发访问时的冲突,通常需要使用同步机制,如信号量或互斥锁。
应用场景和示例:在高性能计算、多媒体处理等对数据传输效率要求较高的场景中应用较多。例如,在一个视频播放应用中,内核中的视频驱动可能将解码后的视频帧数据放置在共享内存中,用户空间的播放程序可以直接从共享内存中读取视频帧进行播放,减少了数据复制的过程,提高了播放效率。
(1)Linux 中内存划分及使用
①内存划分
内核空间(Kernel Space):这是 Linux 操作系统内核运行的内存区域,通常位于内存的高端部分。它用于存储内核代码、内核数据结构(如进程控制块 PCB、文件系统缓存等)以及设备驱动程序等。内核空间的大小在系统启动时就确定了,并且是操作系统的核心部分,拥有最高的权限,可以直接访问系统中的所有硬件资源。
用户空间(User Space):是供应用程序使用的内存区域,每个用户进程都有自己独立的用户空间。用户空间用于存储应用程序的代码、数据(如全局变量、堆内存、栈内存)等。用户进程只能访问自己的用户空间,不能直接访问其他进程的空间或者内核空间,这是为了保护系统的安全性和稳定性。如果用户进程需要访问硬件资源或者执行一些特权操作,必须通过系统调用请求内核来完成。
②内存使用方式
▼内核空间使用
- 内核代码段:存放内核的二进制指令代码,这些代码在系统启动时加载到内存中,并且在整个系统运行过程中是只读的,用于执行系统的核心功能,如进程调度、内存管理、文件系统操作等。
- 内核数据段:包含各种内核数据结构,如进程描述符(用于管理进程状态)、内存管理相关的数据结构(如页表)、设备驱动程序的全局变量等。这些数据结构是内核正常运行所必需的,并且根据系统的运行状态会动态变化。
- 内核缓冲区(Buffer)和缓存(Cache):用于提高系统性能。例如,磁盘缓存(Page Cache)用于缓存磁盘上读取和写入的数据,减少磁盘 I/O 操作次数;网络缓冲区用于暂存网络数据包,提高网络传输效率。
▼用户空间使用
- 代码段:存储用户应用程序的可执行代码,这部分内存是只读的,用于执行应用程序的功能逻辑。
- 数据段:包括已初始化的全局变量和静态变量,这些变量在程序启动时就分配了内存空间并且初始化。
- BSS 段(Block Started by Symbol):用于存储未初始化的全局变量和静态变量。在程序加载时,这部分内存会被清零。
- 堆(Heap):用于动态内存分配,应用程序可以通过malloc、calloc等函数在堆内存中申请空间,用于存储程序运行过程中动态生成的数据,如链表节点、动态数组等。堆内存的大小可以在程序运行过程中动态增长和收缩。
- 栈(Stack):用于存储函数调用的信息,包括局部变量、函数参数、返回地址等。栈的内存分配是从高地址向低地址进行的,并且遵循后进先出(LIFO)的原则。当一个函数被调用时,会在栈上为其分配空间,函数返回时,这些空间会被释放。
(2)虚拟地址、物理地址的概念及转换
虚拟地址(Virtual Address):是 CPU 在运行用户进程或内核代码时使用的地址。对于每个进程来说,它看到的是一个独立的虚拟地址空间,这个空间从 0 开始,大小通常是由 CPU 的寻址能力决定的(如 32 位 CPU 的虚拟地址空间大小为 2^32 字节,即 4GB)。虚拟地址的优点是提供了内存保护和内存管理的灵活性。每个进程都认为自己拥有整个地址空间,并且不同进程的虚拟地址空间是相互隔离的,这样可以防止一个进程错误地访问另一个进程的内存。
物理地址(Physical Address):是内存芯片上实际的存储单元地址,它是真实存在于硬件中的地址。物理内存是计算机系统中实际安装的内存芯片的集合,物理地址用于在内存芯片上定位数据的存储位置。例如,当数据要被写入内存或者从内存读取时,内存控制器会根据物理地址找到对应的存储单元进行操作。
▼转换机制
内存管理单元(MMU):是实现虚拟地址到物理地址转换的关键部件。MMU 使用页表(Page Table)来进行地址转换。页表是一种数据结构,它将虚拟地址空间划分为一个个固定大小的页面(Page),通常页面大小为 4KB(在一些系统中也可能是其他值),同时将物理内存也划分为同样大小的页框(Page Frame)。每个虚拟页面在页表中有一个对应的表项,这个表项记录了该虚拟页面对应的物理页框的地址以及一些访问权限信息(如可读、可写、可执行等)。
地址转换过程:当 CPU 发出一个虚拟地址访问请求时,MMU 首先将虚拟地址分解为页号(Page Number)和页内偏移(Page Offset)。然后,根据页号在页表中查找对应的物理页框地址,再将物理页框地址与页内偏移组合起来,就得到了实际的物理地址,从而实现了虚拟地址到物理地址的转换。例如,对于一个 32 位的虚拟地址,假设页面大小为 4KB(2^12 字节),那么高 20 位表示页号,低 12 位表示页内偏移。MMU 通过查找页表将高 20 位的页号转换为物理页框号,再与低 12 位的页内偏移组合,就得到了 32 位的物理地址。
(3)高端内存概念及与其他地址的关系
在 32 位 Linux 系统中,由于虚拟地址空间只有 4GB,内核通常会将一部分虚拟地址空间划分给用户进程使用,另一部分留给自己。内核空间的大小一般是固定的,比如 1GB。但是,内核可能需要访问超过其初始分配的物理内存范围的内存,这部分超出常规内核空间可直接映射范围的内存就称为高端内存(High Memory)。高端内存的存在是为了在 32 位系统中能够访问更多的物理内存,因为 32 位系统的虚拟地址空间有限,无法直接映射所有的物理内存。
▼与物理地址、逻辑地址、线性地址的关系
物理地址:高端内存本身就是物理内存的一部分,它有对应的物理地址。当内核需要访问高端内存时,需要通过一些特殊的机制将高端内存映射到内核的虚拟地址空间。
逻辑地址:逻辑地址是在分段机制下产生的地址概念。在早期的 x86 系统中,使用分段和分页两种机制来管理内存。逻辑地址由段选择子(Segment Selector)和段内偏移组成。在 Linux 系统中,通常已经将分段机制进行了简化,使得逻辑地址和线性地址基本等同。
线性地址:线性地址是在分页机制之前的一种中间地址形式。在没有开启分页机制时,线性地址和物理地址是相同的。当开启分页机制后,线性地址通过 MMU 的分页机制转换为物理地址。对于高端内存,在被正确映射到内核虚拟地址空间后,这个虚拟地址(可以看作是一种线性地址)通过 MMU 的转换最终可以访问到对应的高端物理内存。也就是说,高端内存的访问需要先将其映射为内核可以访问的线性地址(虚拟地址),然后再通过 MMU 转换为物理地址来实现实际的访问。
(1)Linux 中断实现机制
中断请求(IRQ)的产生:硬件设备(如网卡、硬盘控制器等)在需要 CPU 处理某些事件时,会向 CPU 发送一个中断请求信号。这个信号通过中断控制器(如在 x86 系统中的 8259A 或 APIC 等)传递给 CPU。每个中断请求都有一个对应的中断号,用于唯一标识该中断。例如,当网卡接收到一个新的数据包时,它会产生一个中断请求,中断号可能是由硬件设计或者系统初始化时确定的。
▼中断处理流程
中断向量表和中断处理程序入口:CPU 收到中断请求后,会根据中断号查找中断向量表(Interrupt Vector Table),中断向量表中存放着各个中断号对应的中断处理程序(Interrupt Handler)的入口地址。CPU 会暂停当前正在执行的任务,跳转到对应的中断处理程序入口开始执行中断处理。
中断上下文(Interrupt Context)保存和恢复:在跳转到中断处理程序之前,CPU 会自动保存当前任务的一些关键信息,如程序计数器(PC)、寄存器状态等,这个过程称为中断上下文保存。这些信息被保存在内核栈中。当中断处理完成后,CPU 会从内核栈中恢复之前保存的上下文信息,继续执行被中断的任务。
中断嵌套(Interrupt Nesting):在一些情况下,CPU 可能在处理一个中断的过程中又收到一个新的中断请求。如果新中断的优先级高于当前中断,并且系统允许中断嵌套,那么 CPU 会暂停当前中断处理,转而处理新的中断。当新中断处理完成后,再返回继续处理之前被中断的中断。不过,中断嵌套会增加系统的复杂性和处理开销,需要谨慎设计。
▼中断服务例程(ISR,Interrupt Service Routine)
这是中断处理的核心部分,也就是实际的中断处理程序。它负责处理硬件设备产生的具体事件。例如,在网卡的中断服务例程中,会读取网卡接收缓冲区中的数据包,然后将数据包传递给网络协议栈进行后续处理。ISR 应该尽可能地短小精悍,因为它是在中断上下文中执行的,而中断上下文是一个比较特殊的执行环境,不能进行一些可能导致阻塞的操作(如等待 I/O 操作完成、获取互斥锁等),否则会影响系统的响应性能和稳定性。
(2)Tasklet 与 Workqueue 的区别及底层实现区别
▼Tasklet特点
- 轻量级:Tasklet 是一种轻量级的软中断处理机制,主要用于在中断处理的下半部执行一些相对简单、执行时间较短的任务。它在软件中断(Softirq)上下文执行,运行速度较快。
- 不可睡眠:由于是在软中断上下文运行,Tasklet 不能进行可能导致睡眠的操作,如调用可能会阻塞的系统调用。否则会导致系统崩溃,因为软中断上下文没有像进程上下文那样的睡眠和唤醒机制。
- 顺序执行:对于同一个 CPU,Tasklet 是按照一定的顺序依次执行的。如果多个 Tasklet 被调度,它们会排队等待执行,保证了一定程度的执行顺序。
底层实现:Tasklet 是基于软中断机制实现的。Linux 内核中有一组软中断向量,Tasklet 被分配到特定的软中断向量上。当 Tasklet 被调度时,它会被添加到对应的软中断队列中。在适当的时候(如从中断返回或者在特定的内核调度点),内核会检查软中断队列,如果有 Tasklet 等待执行,就会调用相应的 Tasklet 处理函数。Tasklet 的实现利用了内核的软中断基础设施,并且通过一些原子操作来保证其在多核环境下的正确调度和执行。
▼Workqueue特点
- 可睡眠操作支持:Workqueue 提供了一种在进程上下文执行任务的机制,这使得它可以执行一些可能会导致睡眠的操作,如等待 I/O 完成、分配大块内存等。这是它与 Tasklet 的主要区别之一,因为进程上下文有完整的睡眠和唤醒机制,所以可以进行这些复杂的操作。
- 延迟执行和异步性:Workqueue 可以用于延迟执行一些任务,或者将任务异步地分配给后台进程执行。例如,可以将一些不紧急的设备初始化任务或者数据清理任务放到 Workqueue 中执行,这样不会影响到中断处理的及时性和系统的响应性能。
- 多线程执行(可选):可以通过配置使 Workqueue 中的任务由多个线程来执行,这在处理多个耗时任务或者需要并行处理的任务时非常有用。不过,多线程执行也会带来一些并发管理的问题,需要注意线程间的同步和资源共享。
底层实现:Workqueue 的底层实现是基于内核线程(Kernel Thread)的。当一个任务被添加到 Workqueue 中时,内核会创建一个或多个内核线程来执行这些任务。这些内核线程有自己的进程上下文,包括内核栈、进程控制块等。工作队列中的任务会被排队,每个内核线程会从队列中获取任务并执行。为了管理工作队列和任务的调度,内核中有一系列的结构体和函数,用于跟踪任务状态、分配任务给线程、处理线程的创建和销毁等操作。
(3)区分上半部和下半部的原因
▼中断响应及时性要求
上半部(Top - Half)目的:中断上半部主要用于处理对时间敏感、需要立即响应的硬件事件。例如,当网卡收到一个数据包时,中断上半部需要尽快地读取网卡的状态寄存器,确认中断原因,并且将数据包从网卡接收缓冲区复制到内核缓冲区的一个临时位置。这些操作必须在最短的时间内完成,以确保硬件设备能够继续正常工作,并且不会丢失数据或者错过下一个中断请求。如果中断上半部执行时间过长,会导致硬件设备长时间等待,影响系统的整体性能和实时性。
▼避免长时间阻塞中断上下文
下半部(Bottom - Half)必要性:中断上下文是一个特殊的执行环境,它不允许进行一些可能导致阻塞的操作,如等待 I/O 完成、获取互斥锁等。然而,很多中断处理任务可能会涉及到这些复杂的操作。例如,在处理接收到的网络数据包时,可能需要对数据包进行复杂的协议解析,这个过程可能需要访问磁盘上的配置文件或者进行网络查询等操作,这些操作都可能会导致阻塞。将这些复杂的、可能导致阻塞的任务放到中断下半部,可以让中断上半部快速返回,使得 CPU 能够及时响应其他的中断请求,同时保证了系统的稳定性和可靠性。通过这种方式,中断处理被分成了两个部分,上半部快速响应硬件,下半部在合适的环境(如进程上下文或者软中断上下文)中完成复杂的后续处理。
(1)Linux 中断响应执行流程
硬件中断请求:当硬件设备(如网卡、键盘、定时器等)需要 CPU 处理事件时,会向中断控制器发送一个中断请求信号。这个信号包含了中断的标识信息,如中断号。例如,当键盘上有按键按下时,键盘控制器会产生一个中断请求,中断号由系统初始化时分配给键盘设备。
CPU 接收中断:CPU 在执行当前指令周期结束后,会检查中断引脚信号。如果检测到有中断请求,它会暂停当前正在执行的任务(无论是用户进程还是内核任务),并开始处理中断。首先,CPU 会根据中断号在中断向量表(Interrupt Vector Table)中查找对应的中断处理程序入口地址。中断向量表是一个存储中断处理程序入口指针的数据结构,它的每个元素对应一个特定的中断号。
保存中断上下文:在跳转到中断处理程序之前,CPU 会自动保存当前执行任务的一些关键信息,这些信息被称为中断上下文。包括程序计数器(PC),它指向被中断的指令地址,以便在中断处理完成后能继续执行原来的任务;还有通用寄存器的值,这些寄存器可能保存着任务的中间计算结果等。这些信息被保存在内核栈(Kernel Stack)中,内核栈是为每个 CPU 分配的用于存储内核执行环境相关信息的内存区域。
执行中断处理程序(上半部):找到中断处理程序入口后,CPU 开始执行中断处理程序的上半部。这部分主要负责快速处理与硬件紧密相关的紧急任务。例如,对于网卡中断,上半部可能会读取网卡接收缓冲区的数据包头部,确认数据包的合法性,并将数据包从网卡的缓冲区搬运到内核的临时缓冲区。上半部的处理应该尽可能地快速,因为它在中断上下文中执行,这个环境不能进行可能导致阻塞的操作(如等待 I/O 完成、获取互斥锁等),否则会影响系统的响应性能和稳定性。
判断是否有下半部任务及调度(如果有):许多中断事件需要进行一些复杂的、可能会导致阻塞的后续处理。在这种情况下,中断处理程序的上半部会触发中断下半部的调度。中断下半部可以使用多种机制来实现,如 tasklet、workqueue 等。如果有下半部任务需要执行,会将相应的任务添加到对应的执行队列中,等待合适的时机执行。例如,对于网络数据包的处理,上半部只是简单搬运了数据包,下半部可能会进行复杂的协议解析、将数据传递给应用程序等操作,这些操作可能涉及到内存分配、磁盘 I/O 等可能导致阻塞的操作,所以安排在中断下半部。
恢复中断上下文并返回:当中断处理程序的上半部(如果没有下半部或者下半部已经成功调度)完成后,CPU 会从内核栈中恢复之前保存的中断上下文信息。这包括恢复程序计数器和通用寄存器的值,然后继续执行被中断的任务,就好像中断没有发生过一样。如果有下半部任务,在合适的时机(如在中断返回后、系统处于空闲状态或者特定的内核调度点),下半部任务会被执行。
(2)中断的申请及执行时间
中断申请:在设备驱动开发中,要使用中断首先需要进行中断申请。通常使用request_irq函数(在 Linux 内核中)来申请中断。这个函数的主要参数包括中断号(irq)、中断处理函数(handler)、中断触发方式(如上升沿触发、下降沿触发等)以及一些标志位(如共享中断标志等)。例如,一个简单的字符设备驱动中,申请中断可能如下所示:
static irqreturn_t my_device_interrupt(int irq, void *dev_id) {
// 中断处理逻辑
return IRQ_HANDLED;
}
int init_module(void) {
int ret = request_irq(my_device_irq_num, my_device_interrupt, IRQF_TRIGGER_RISING, "my_device", NULL);
if (ret) {
printk(KERN_ERR "Failed to request interrupt\n");
return ret;
}
return 0;
}
在内核启动过程中或者设备驱动初始化阶段,驱动程序会调用request_irq函数向内核申请中断。内核会将这个请求记录在内部的数据结构中,并且将中断号和对应的中断处理函数关联起来。同时,还会对中断的触发方式等参数进行配置,使得当硬件设备产生符合条件的中断请求时,能够正确地调用对应的中断处理函数。
中断处理函数执行时间:当硬件设备产生的中断请求信号经过中断控制器传递到 CPU,并且 CPU 识别出该中断请求(通过中断号查找中断向量表找到对应的中断处理函数入口)后,就会执行中断处理函数。具体来说,只要满足硬件中断触发条件,并且系统没有屏蔽该中断(例如,没有通过disable_irq等函数禁止该中断),中断处理函数就会在 CPU 收到中断请求后的下一个指令周期开始执行。中断处理函数执行的时机是由硬件中断事件的发生来触发的,并且在 CPU 的调度控制下,优先于普通的用户进程和内核任务执行,以确保对硬件事件的及时响应。不过,如前面所述,为了避免长时间阻塞中断上下文,复杂的、可能导致阻塞的任务通常会通过中断下半部机制来延迟执行。
①Linux 中的同步机制概述
同步机制的重要性:在 Linux 操作系统中,由于多任务和多线程的存在,多个执行单元(如进程或线程)可能会同时访问共享资源。如果没有适当的同步机制,就会导致数据不一致、竞态条件(Race Condition)等问题。例如,两个线程同时对一个全局变量进行写操作,就可能导致最终结果不符合预期。同步机制的目的就是要协调多个执行单元对共享资源的访问,确保系统的正确性和稳定性。
▼主要的同步机制类型
原子操作(Atomic Operation):原子操作是一种不可分割的操作,在执行过程中不会被其他操作中断。例如,在某些 CPU 架构上,有专门的原子指令用于对整数进行加、减、比较和交换等操作。原子操作可以用于简单的计数器等共享资源的保护,它的优点是速度快,缺点是功能相对有限。
自旋锁(Spinlock):这是一种忙等待(Busy - Waiting)的锁机制。当一个执行单元试图获取一个已经被占用的自旋锁时,它会不断地循环检查锁是否被释放,而不是进入睡眠状态。自旋锁适用于锁被占用的时间很短的情况,因为如果长时间等待,循环检查会浪费 CPU 资源。
信号量(Semaphore):信号量是一种用于控制多个执行单元对共享资源访问的计数器。它可以用于实现互斥访问(类似于互斥锁),也可以用于实现资源的计数访问。当一个执行单元要访问共享资源时,需要先获取信号量,如果信号量的值大于零,就可以获取成功并将信号量的值减一;如果信号量的值为零,则执行单元可能会进入睡眠状态等待信号量的值大于零。
互斥锁(Mutex):互斥锁用于实现互斥访问共享资源,它与信号量类似,但更侧重于互斥访问。当一个线程获取了互斥锁后,其他线程就不能获取该锁,直到锁被释放。互斥锁通常会在获取失败时让线程进入睡眠状态,而不是像自旋锁那样忙等待。
条件变量(Condition Variable):条件变量通常与互斥锁一起使用,用于实现线程间的同步等待。当一个线程需要等待某个条件满足时,它可以在持有互斥锁的情况下等待条件变量。当另一个线程改变了条件并通知条件变量时,等待的线程可以被唤醒并重新检查条件是否满足。
②自旋锁(Spinlock)与信号量的区别
⑴等待机制
自旋锁:采用忙等待的方式。当一个线程或进程试图获取一个被其他线程占用的自旋锁时,它会一直循环检查锁的状态,直到锁被释放。这种方式在等待期间会一直占用 CPU 资源,就像一个人在门口不停地转动门把手,看看门是否打开一样。例如,在一个多处理器系统中,一个 CPU 核心上的执行单元在等待自旋锁时,它会不断地执行循环检查指令,这会消耗一定的 CPU 时间,但不会导致线程上下文切换(Thread Context Switch)。
信号量:采用阻塞等待的方式。当执行单元发现信号量的值为零时(表示资源不可用),它会进入睡眠状态,将 CPU 资源让出,直到信号量的值大于零(表示资源可用)被其他执行单元唤醒。这就好比一个人来到资源有限的房间前,如果房间满了(信号量为零),他就会去睡觉,等到有人离开房间(信号量增加)并通知他时,他才会醒来进入房间。
⑵适用场景
自旋锁:适用于锁被占用的时间很短的情况。因为如果等待时间过长,不断循环检查锁状态会浪费大量的 CPU 资源。例如,在单处理器系统中,如果中断处理程序和进程上下文都可能访问的共享数据结构,并且访问时间很短,就可以使用自旋锁。在多处理器系统中,自旋锁可以用于保护那些访问时间极短的共享高速缓存行(Cache Line)等资源。
信号量:适用于锁可能被长时间占用或者资源计数访问的场景。例如,在一个生产者 - 消费者模型中,多个生产者和消费者线程共享一个缓冲区。可以使用信号量来控制缓冲区的空槽数量(供生产者使用)和满槽数量(供消费者使用)。当缓冲区满时,生产者线程会等待(通过信号量阻塞),当缓冲区空时,消费者线程会等待。由于线程在等待信号量时会让出 CPU 资源,所以即使等待时间较长,也不会像自旋锁那样浪费大量的 CPU 资源。
⑶性能特点
自旋锁:在锁被快速释放的情况下,自旋锁的性能较好,因为它避免了线程上下文切换的开销。但是,如果锁被长时间占用,自旋锁会导致 CPU 资源的浪费,因为执行单元一直在循环检查,而不能进行其他有用的工作。在单处理器系统中,使用自旋锁时要特别小心,因为如果一个执行单元在自旋等待锁,而持有锁的执行单元无法被调度执行(因为只有一个处理器),就会导致死锁。
信号量:信号量的性能在等待时间较长时相对较好,因为等待的执行单元会让出 CPU 资源。但是,信号量的获取和释放操作涉及到更多的内核机制,如睡眠和唤醒操作,这会带来一定的开销。在频繁获取和释放信号量的场景下,这种开销可能会影响系统性能。
RCU 是一种用于高效地实现共享数据的读写操作的同步机制,主要关注在多处理器系统中对共享数据的读取和更新操作。其核心思想是在更新数据时,允许同时进行读取操作,并且尽可能地减少锁的使用,以提高系统的并发性能。
⑴RCU 的原理机制
▼读取操作(Read - Side)
在 RCU 机制下,读取共享数据的操作基本是无锁的。当一个读者(Reader)访问共享数据时,它直接读取数据,不需要获取任何锁。这是因为 RCU 保证在读者访问数据的过程中,数据不会被释放或被破坏。为了实现这一点,RCU 利用了内存屏障(Memory Barrier)和编译器优化屏障(Compiler Optimization Barrier)等技术。
内存屏障用于确保在屏障之前的内存操作(如读取数据)一定在屏障之后的内存操作之前完成。编译器优化屏障则防止编译器对代码进行可能改变内存访问顺序的优化。例如,在一个多处理器系统中,当一个 CPU 核心上的读者读取共享数据时,通过这些屏障保证它读取到的数据是完整和正确的。
▼更新操作(Update - Side)
副本创建(Copy):当需要更新共享数据时,首先会创建一个数据的副本。这个副本包含了更新后的数据。例如,在更新一个链表节点时,会创建一个新的链表节点,这个新节点包含了更新后的内容。
更新同步(Update):在副本创建完成后,更新操作会使用一种机制来确保所有正在访问旧数据的读者都完成访问后,再用新数据替换旧数据。这是通过一个称为 “宽限期(Grace Period)” 的概念来实现的。在宽限期内,旧数据仍然可以被读者访问,而新数据已经准备好替换旧数据。
宽限期(Grace Period):宽限期是 RCU 机制中的一个关键概念。它是一个时间段,从更新操作开始创建副本,到最后一个可能访问旧数据的读者完成访问的时间间隔。在宽限期内,内核会跟踪正在运行的读者任务(如进程或线程),确保没有读者在访问旧数据。当宽限期结束后,就可以安全地释放旧数据并使用新数据。
数据发布(Publish):在宽限期结束后,新数据会被发布,也就是替换旧数据。这个过程可能涉及到修改指针或者其他数据结构的引用,使得后续的读者能够访问到更新后的数据。例如,在更新链表时,在宽限期结束后,会将链表的指针指向新创建的节点,这样新的读者就会访问到更新后的链表。
⑵RCU 的优势和应用场景
▼优势
高并发性能:由于读取操作基本无锁,大量的读者可以同时访问共享数据,不会因为锁的竞争而降低性能。这在高并发的读取场景中非常有用,如在多处理器系统的缓存系统或者高性能的网络服务器中,能够显著提高系统的吞吐量。
低延迟:相比于传统的锁机制,RCU 避免了读者获取锁和等待锁释放的过程,从而降低了读取操作的延迟。这对于对延迟敏感的系统(如实时系统)是非常重要的。
▼应用场景
数据结构更新频繁且读取操作占主导的情况:例如,在操作系统的文件系统缓存管理中,数据结构(如缓存的文件索引节点)经常需要更新,但同时又有大量的读取操作(如文件系统的读操作)。RCU 可以很好地平衡更新和读取的需求,提高系统的整体性能。
动态数据结构的维护:在动态链表、树等数据结构的更新场景中,RCU 可以用于在不影响大量读取操作的情况下进行节点的插入、删除等操作。例如,在网络协议栈中的路由表更新,既需要更新路由信息,又要保证正在进行的数据包转发操作(读取路由表)不受太大影响,RCU 是一种合适的解决方案。
⑴软中断的基本概念
软中断(Softirq)是 Linux 内核中用于异步处理任务的一种机制。与硬中断(由硬件设备触发的中断)不同,软中断是由软件触发的,它主要用于处理一些对时间不那么敏感,但又需要相对及时处理的任务,如网络数据包处理、定时器事件等。软中断运行在中断上下文,但它比硬中断处理程序更加灵活,可以执行一些相对复杂的操作,但也有一定的限制,不能像进程一样随意睡眠或等待资源。
⑵软中断的实现原理
▼软中断向量表(Softirq Vector Table)
内核维护了一个软中断向量表,类似于硬中断向量表。这个表中定义了一系列软中断处理程序的入口地址,每个软中断都有一个对应的编号(在 Linux 中通常有 10 个软中断,编号从 0 到 9),通过这个编号可以在向量表中找到对应的处理程序。例如,NET_RX_SOFTIRQ是用于处理网络接收数据包的软中断,其编号是一个固定的值,内核通过这个编号可以定位到对应的网络数据包接收处理程序。
▼软中断标记(Softirq Flag)
当一个事件发生需要触发软中断时,内核会设置相应的软中断标记。例如,当网卡接收到一个数据包后,会通过底层的硬件驱动程序设置NET_RX_SOFTIRQ标记,表示有网络接收软中断需要处理。这些标记存储在内核的一个特定的位字段中,每个位对应一个软中断。通过检查这些标记,内核可以知道哪些软中断需要被处理。
▼软中断触发与调度(Trigger and Scheduling)
软中断的触发可以发生在多个场景下。最常见的是在硬中断处理程序的下半部(Bottom - Half)触发软中断。当硬中断处理完硬件设备的紧急事件后,对于一些需要进一步处理的任务,如复杂的数据包解析或设备状态更新,会触发相应的软中断。另外,在某些系统调用完成后或者内核定时器到期时,也可能会触发软中断。
软中断的调度主要是在合适的时机检查软中断标记并执行相应的软中断处理程序。这个时机通常是在硬中断返回时、系统处于空闲状态或者在特定的内核调度点。例如,当 CPU 从一个硬中断处理程序返回时,会检查是否有软中断标记被设置,如果有,则会调用do_softirq函数来执行软中断处理程序。do_softirq函数会遍历软中断向量表,根据标记来执行对应的软中断处理程序。
▼软中断处理程序执行(Execution of Softirq Handlers)
软中断处理程序的执行是在中断上下文进行的,这意味着它有一定的限制。它不能像进程一样进行可能导致睡眠的操作,如等待 I/O 完成、获取互斥锁(在可能导致睡眠的情况下)等。因为软中断处理程序是在内核的中断处理流程中运行,如果它睡眠,会导致整个内核的中断处理流程被阻塞,影响系统的响应性能和稳定性。
软中断处理程序的任务通常是对一些事件进行相对复杂的后续处理。以网络接收软中断为例,在NET_RX_SOFTIRQ处理程序中,会对网卡接收的数据包进行进一步的解析,如检查数据包的协议类型(是 TCP、UDP 还是其他协议),将数据包传递给相应的网络协议栈上层进行处理,可能还会更新一些网络相关的统计信息等。
⑶软中断的并发处理与限制
▼并发处理
在多核系统中,软中断可以在多个 CPU 核心上同时被触发和处理。每个 CPU 都有自己独立的软中断标记和处理机制。这意味着不同的 CPU 可以同时处理不同的软中断任务,提高了系统的并发处理能力。例如,一个 CPU 可以处理网络接收软中断,而另一个 CPU 可以处理定时器软中断。
▼限制
由于软中断是在中断上下文执行,所以对其操作有严格的限制。除了不能进行可能导致睡眠的操作外,软中断处理程序还应该尽量短小精悍,以避免长时间占用 CPU 资源,影响其他硬中断和软中断的及时处理。另外,软中断处理程序之间也需要注意相互的干扰和数据一致性问题,因为它们可能会同时访问一些共享的内核数据结构。
⑴原子操作的基本概念
原子操作是指在执行过程中不会被其他操作中断的操作。在 Linux 系统中,原子操作主要用于在多处理器环境下对共享数据进行安全的访问,以避免数据竞争和不一致的情况。原子操作保证了操作的完整性和不可分割性,要么全部执行成功,要么全部不执行。
⑵实现原子操作的方法
▼硬件支持的原子指令
处理器特定指令集:许多现代处理器都提供了原子操作指令。例如,在 x86 架构中有 “lock” 前缀指令。当一条指令加上 “lock” 前缀时,它会保证在执行该指令期间,处理器的其他核心不会同时访问该指令所涉及的内存区域。比如 “lock addl” 指令用于原子地增加一个 32 位整数的值。在多核环境下,一个核心执行带有 “lock” 前缀的指令时,会通过处理器的总线锁定机制或者缓存一致性协议来阻止其他核心对相同内存位置的并发访问。
原子交换指令(如 xchg):xchg 指令用于原子地交换一个寄存器和一个内存位置的值。这在实现一些简单的同步原语(如自旋锁)时非常有用。例如,在实现一个简单的互斥锁时,可以使用 xchg 指令来原子地交换锁变量的值和一个已知的值,通过检查交换后的结果来判断锁是否被获取成功。
▼内核提供的原子操作函数
①整数原子操作:
原子加法(atomic_add)和减法(atomic_sub):用于原子地对整数进行加法和减法操作。这些函数会保证在多核环境下,多个执行单元对同一个整数进行加或减操作时不会出现数据竞争。例如,在实现一个计数器,用于统计系统中某个事件发生的次数时,就可以使用 atomic_add 函数。其内部实现通常会利用处理器的原子指令来保证操作的原子性。
原子比较并交换(atomic_cmpxchg):这个函数用于原子地比较一个内存位置的值与一个给定的值,如果相等,则将该内存位置的值替换为另一个给定的值。它在实现更复杂的同步机制(如无锁数据结构)时经常被使用。例如,在一个无锁链表的插入操作中,可以使用 atomic_cmpxchg 来原子地更新链表节点的指针。
②位原子操作:
原子位设置(atomic_set_bit)和清除(atomic_clear_bit):用于原子地设置或清除一个整数中的某一位。这些操作在处理标志位(flags)时非常有用。例如,在设备驱动中,有一个表示设备状态的整数,其中的不同位可能代表设备是否忙碌、是否有错误等状态。可以使用 atomic_set_bit 和 atomic_clear_bit 来安全地修改这些状态位,而不用担心被其他执行单元同时修改。
原子位测试并设置(atomic_test_and_set_bit)和测试并清除(atomic_test_and_clear_bit):这两个函数结合了测试和设置 / 清除操作。它们先测试指定的位是否为某个值,然后根据测试结果进行设置或清除操作,并且整个过程是原子的。这种操作在实现一些基于位标志的同步和互斥机制时很有用。
⑶使用原子操作的场景示例
计数器更新:在一个多线程的网络服务器中,需要统计每个客户端连接的数据包发送数量。可以使用原子加法操作来更新每个客户端对应的计数器,这样多个线程(例如,处理不同客户端请求的线程)在更新计数器时就不会出现数据竞争,保证了统计数据的准确性。
自旋锁实现:自旋锁的简单实现可以基于原子交换指令。例如,定义一个自旋锁变量为一个整数(0 表示锁未被占用,1 表示锁被占用)。当一个线程试图获取自旋锁时,它使用原子交换指令将锁变量的值与 1 进行交换,如果交换后的值为 0,则表示获取锁成功;如果为 1,则表示锁已被占用,线程需要自旋等待。在释放自旋锁时,将锁变量的值原子地设置为 0。
⑴MIPS CPU 空间地址划分
用户空间和内核空间划分:在 MIPS 架构中,通常像其他体系结构一样,将虚拟地址空间划分为用户空间和内核空间。对于 32 - bit 的 MIPS 系统,虚拟地址空间大小为 4GB。一般会将高地址部分(如 0xC0000000 - 0xFFFFFFFF)分配给内核空间,用于运行操作系统内核代码、访问内核数据结构以及与硬件设备交互等操作。而低地址部分(如 0x00000000 - 0xBFFFFFFF)分配给用户空间,用于运行用户应用程序。这种划分有助于保护系统的安全性和稳定性,防止用户程序随意访问内核资源。
内存映射 I/O 区域(MMIO):MIPS 系统也有内存映射 I/O 区域,这部分地址空间用于访问硬件设备的寄存器。硬件设备的寄存器被映射到特定的虚拟地址范围,通过对这些虚拟地址的读写操作,就可以实现对设备寄存器的访问。例如,一个简单的 UART 设备可能被映射到一个特定的地址范围(假设是 0x18000000 - 0x180000FF),通过向这个地址范围内的特定偏移地址写入数据,可以配置 UART 的波特率、数据位、停止位等参数,从这个范围读取数据可以获取 UART 的状态信息(如是否有数据接收、发送缓冲区是否为空等)。
⑵在 U - Boot 中操作设备特定寄存器
▼寄存器地址映射(ioremap - like 功能)
在 U - Boot 中,要操作设备寄存器,首先需要将设备寄存器的物理地址映射到虚拟地址空间,类似于 Linux 内核中的ioremap功能。不过,U - Boot 有自己的内存映射机制。它可能会使用一些特定的函数(具体函数因 U - Boot 版本和硬件平台而异)来实现这个映射。例如,通过设置一些内存管理单元(MMU)相关的寄存器和配置信息,将物理的设备寄存器地址映射到一个可以在 U - Boot 代码中访问的虚拟地址。
▼访问寄存器的函数
读寄存器:U - Boot 通常会提供一些函数来读取设备寄存器的值。这些函数可能会接收已经映射后的虚拟地址(或者是相对于某个基地址的偏移量)作为参数,然后通过指针类型转换和内存读取操作来获取寄存器的值。例如,对于一个简单的函数read_reg,其实现可能如下:
unsigned int read_reg(void *reg_addr) {
return *(unsigned int *)reg_addr;
}
写寄存器:同样,也会有函数用于向设备寄存器写入数据。这些函数会将要写入的值作为参数,通过指针操作将数据写入到指定的虚拟地址对应的寄存器中。例如,write_reg函数可以这样实现:
void write_reg(void *reg_addr, unsigned int value) {
*(unsigned int *)reg_addr = value;
}
▼设备初始化示例(以 UART 为例)
假设要在 U - Boot 中初始化一个 UART 设备用于调试输出。首先,需要找到 UART 设备寄存器在物理地址空间中的位置,假设其基地址为UART_BASE_PHYS_ADDR。然后通过 U - Boot 的地址映射机制将其映射到虚拟地址空间,得到UART_BASE_VIRT_ADDR。
接着,可以通过写寄存器函数来配置 UART 的参数。比如,要设置波特率,假设波特率寄存器的偏移量为UART_BAUD_OFFSET,可以这样操作:
#define UART_BAUD_RATE_VALUE 115200
void init_uart(void) {
void *uart_base = map_uart_registers(UART_BASE_PHYS_ADDR); // 假设的映射函数
unsigned int baud_divisor = calculate_baud_divisor(UART_BAUD_RATE_VALUE); // 根据波特率计算除数
write_reg(uart_base + UART_BAUD_OFFSET, baud_divisor);
// 可以继续配置其他参数,如数据位、停止位等
}
这里的calculate_baud_divisor函数用于根据期望的波特率计算出写入寄存器的除数,map_uart_registers是假设的用于将 UART 物理地址映射到虚拟地址的函数,实际的 U - Boot 实现中会根据硬件平台和内存管理机制来具体定义这些函数。
系统调用是用户空间程序与内核空间进行交互的接口。它允许应用程序请求内核提供的服务,如文件读写、进程管理、网络通信等。系统调用是一种特殊的函数调用,它会引起用户模式到内核模式的切换,使 CPU 能够执行内核中的代码来完成相应的功能。
read()系统调用的执行过程(从用户空间到内核空间)
▼用户空间调用准备
当应用程序调用read()函数时,它首先会在用户程序的库函数中查找read()的定义。在 C 语言库(如 glibc)中,read()函数实际上是对系统调用的包装。它会将系统调用号(read系统调用有一个唯一的系统调用号)和相关的参数(如文件描述符、缓冲区地址、读取长度等)按照特定的约定进行组织。
例如,在 x86 架构下,系统调用号可能会被放入eax寄存器,文件描述符放入ebx寄存器,缓冲区地址放入ecx寄存器,读取长度放入edx寄存器。这是一种基于寄存器传递参数的方式,不同的架构可能有不同的参数传递方式。
▼用户模式到内核模式的切换
应用程序通过执行一条特殊的指令来触发系统调用,在 x86 架构下,传统的方式是使用int 0x80指令(在现代系统中也可以使用syscall指令)。这条指令会导致 CPU 从用户模式切换到内核模式。
在切换过程中,CPU 会保存当前用户进程的上下文,包括程序计数器(PC)、寄存器状态等信息。这些信息被保存在内核栈中,以便在系统调用完成后能够恢复用户进程的执行。
▼内核空间的系统调用处理
进入内核模式后,CPU 会根据系统调用号查找系统调用表。系统调用表是一个存储了所有系统调用处理函数入口地址的数据结构。找到read系统调用对应的处理函数后,就开始执行内核中的read函数。
在内核的read函数中,首先会对文件描述符进行合法性检查。文件描述符是用户空间传递进来的,它指向一个打开的文件或设备。内核会验证这个文件描述符是否在有效的范围内,并且对应的文件结构是否存在。
然后,内核会根据文件描述符找到对应的文件对象(struct file),这个文件对象包含了文件的各种信息,如文件的操作函数指针(read、write等操作的具体实现函数)、文件的当前偏移量等。
接着,内核会调用文件对象中的read操作函数来执行实际的读取操作。如果是读取普通文件,这个操作函数可能会涉及到文件系统的逻辑,如通过文件系统的索引节点(inode)找到文件数据在磁盘上的存储位置,然后通过块设备驱动将数据从磁盘读取到内核缓冲区。
如果是读取设备文件(如终端设备或网络设备),则会调用相应设备驱动中的read函数。例如,对于终端设备,设备驱动会从设备的输入缓冲区读取用户输入的数据;对于网络设备,会从网络接收缓冲区读取网络数据包。
▼数据传输和返回准备
当数据从文件或设备读取到内核缓冲区后,内核会将数据从内核缓冲区复制到用户空间指定的缓冲区。这个过程涉及到内存的拷贝操作,并且需要确保数据的完整性和安全性。
在完成数据传输后,内核会准备返回值。read系统调用的返回值通常表示实际读取的字节数。如果返回值为 0,表示已经到达文件末尾;如果返回值为负数,表示出现了错误,并且错误码会被设置在一个全局变量中(如errno)。
▼内核模式到用户模式的恢复
最后,内核会恢复之前保存的用户进程上下文,包括程序计数器和寄存器状态等信息。然后通过执行特殊的指令(如iret指令),CPU 从内核模式切换回用户模式,继续执行用户进程中read函数之后的代码,此时read函数的返回值已经被传递回用户空间,应用程序可以根据返回值来判断读取操作的结果。
⑴引导加载程序阶段(Boot Loader)
启动流程起始:当计算机电源开启或者复位后,BIOS(基本输入输出系统)或者 UEFI(统一可扩展固件接口)会首先运行。它们会进行硬件的初始化和自检工作,例如检测内存、硬盘、键盘等硬件设备是否正常工作。然后,BIOS/UEFI 会从预先设定的启动设备(如硬盘、USB 设备、网络等)中加载引导加载程序(如 GRUB)。
引导加载程序的任务:以 GRUB 为例,它会读取自身的配置文件,这个配置文件包含了启动选项,如启动哪个内核镜像、传递给内核的参数等信息。GRUB 会根据配置文件找到内核镜像文件(通常位于 /boot 目录下),并将其加载到内存中的特定位置。同时,它还可能加载初始内存磁盘(initramfs),这是一个临时的根文件系统,包含了一些必要的驱动程序和工具,用于在真正的根文件系统挂载之前,帮助内核完成一些基本的初始化工作。
⑵内核解压与初步设置(Start_kernel)
内核解压(如果是压缩的):当内核镜像被加载到内存后,如果是压缩的内核(通常是为了节省空间),会首先进行解压操作。解压后的内核代码才能够被 CPU 正确执行。这个解压过程是由引导加载程序或者内核自身的一小部分自解压代码完成的。
内核入口点(start_kernel):解压后的内核代码从start_kernel函数开始执行,这个函数定义在init/main.c文件中,它是内核启动过程中的关键函数。在start_kernel函数中,会进行一系列的初始化操作,包括设置中断向量表、初始化内核数据结构、配置系统时钟等。
▼早期的内核初始化操作
初始化控制台(console):通过调用setup_console函数来建立内核的控制台。这是非常重要的一步,因为在后续的启动过程中,内核可以通过控制台输出启动信息、调试信息等。例如,在setup_console函数中,会检测并初始化串口控制台或者 VGA 控制台等设备,以便内核能够向外界输出信息。
内存管理初始化(mm_init):内存管理的初始化是一个复杂的过程。首先会初始化内存管理的数据结构,如页表(Page Table)。通过paging_init函数,内核会建立起虚拟地址空间和物理地址空间的映射关系。同时,会对内存区域进行划分,确定内核空间和用户空间的范围。例如,在 32 位系统中,通常会将高 1GB 的虚拟地址空间分配给内核空间,低 3GB 分配给用户空间。内存管理的初始化还包括对内存分配器(如kmalloc等函数的初始化),以便后续内核能够方便地分配和管理内存。
调度器初始化(sched_init):内核调度器负责管理系统中的多个进程或线程,决定哪个任务能够获得 CPU 资源。在sched_init函数中,会初始化调度器的数据结构,如进程队列(包括就绪队列、阻塞队列等)。同时,会设置调度策略(如时间片轮转、优先级调度等)和调度参数。例如,对于时间片轮转调度策略,会确定每个进程的时间片长度。调度器的初始化是为了后续能够合理地分配 CPU 资源,保证系统的高效运行
⑶设备驱动初始化与根文件系统挂载
设备驱动初始化(driver_init):内核会调用driver_init函数来初始化设备驱动程序。这个过程会遍历内核中的设备驱动列表,对每个设备驱动进行初始化。设备驱动的初始化包括注册设备驱动到内核的设备模型中,分配设备资源(如中断号、I/O 端口等),以及初始化设备的硬件寄存器等操作。例如,对于一个网络设备驱动,会在这个阶段初始化网卡的寄存器,注册中断处理函数,以便能够接收和发送网络数据包。
根文件系统挂载(mount_root):在设备驱动初始化完成后,内核需要挂载根文件系统。根文件系统是整个文件系统树的根部,包含了系统运行所需的基本文件和目录,如 /bin、/sdev、/etc 等。在挂载根文件系统之前,可能会先通过initramfs中的工具来加载一些必要的文件系统驱动(如果不是已经内置在核内)。然后,通过mount_root函数,内核会根据预先配置的文件系统类型(如 ext4、btrfs 等)和设备(如硬盘分区)来挂载根文件系统。这个过程涉及到文件系统驱动与磁盘设备驱动之间的交互,以及对文件系统元数据(如超级块、inode 等)的读取和解析。
⑷用户空间初始化与启动第一个用户进程
用户空间初始化(init_post):在根文件系统挂载成功后,内核会进入init_post阶段,开始为用户空间的运行做准备。这包括启动用户空间的第一个进程,通常是init进程(在 systemd 系统中是systemd进程)。init进程是所有用户进程的祖先,它的进程号(PID)为 1。内核会通过kernel_execve函数来启动init进程,将控制权从内核空间传递到用户空间。
init进程的任务:init进程在启动后,会读取配置文件(如/etc/inittab或者systemd的配置文件),根据配置来启动其他系统服务和用户应用程序。例如,它会启动系统日志服务(syslogd)来记录系统事件,启动网络服务(networkd或者network - manager)来配置和管理网络连接等。同时,init进程还负责回收孤儿进程,维护系统的运行状态等任务。从init进程开始,系统逐渐进入完整的运行状态,用户空间的各种应用程序和服务开始正常工作。
调度是操作系统内核的一个关键功能,它的主要任务是决定在多任务环境下哪个进程或线程能够在 CPU 上运行。Linux 是一个多任务操作系统,可以同时运行多个进程和线程。调度器需要平衡各个任务之间的资源分配,以提高系统的整体性能、响应速度和资源利用率。
Linux 调度器的主要组成部分和原理
⑴进程状态与调度队列
进程状态分类:Linux 中的进程有多种状态,包括就绪(Ready)、运行(Running)、阻塞(Blocked)、睡眠(Sleeping)等。就绪状态的进程是指已经准备好可以在 CPU 上运行,但暂时还没有获得 CPU 资源的进程;运行状态的进程则是正在 CPU 上执行的进程;阻塞状态的进程通常是因为等待某些事件(如 I/O 操作完成、获取锁等)而暂时无法继续执行的进程;睡眠状态类似于阻塞状态,但睡眠的原因可能是主动的(如通过系统调用让进程睡眠一段时间)。
调度队列管理:内核维护了不同的调度队列来管理处于不同状态的进程。例如,就绪队列用于存放所有就绪状态的进程。当一个进程被创建或者从阻塞状态恢复为就绪状态时,会被放入就绪队列。调度器会从就绪队列中选择一个进程来运行。对于不同的调度策略,可能会有多个就绪队列,比如基于优先级的调度策略可能会有多个按照优先级划分的就绪队列。
⑵调度策略
▼时间片轮转调度(Round - Robin Scheduling)
原理:这是一种基本的公平调度策略。每个进程会被分配一个固定的时间片(Time - slice),当一个进程在 CPU 上运行的时间达到时间片长度后,调度器会暂停该进程的运行,将 CPU 资源分配给下一个就绪进程。这样可以保证每个就绪进程都有机会在 CPU 上运行,避免某个进程长时间独占 CPU。例如,假设时间片长度为 100 毫秒,如果一个进程已经运行了 100 毫秒,即使它还没有完成任务,调度器也会将 CPU 分配给下一个就绪进程。
应用场景和优势:适用于对公平性要求较高的场景,如多用户分时系统。它能够让多个用户进程都能及时获得 CPU 资源,提供良好的交互性能。例如,在一个多用户的服务器环境中,多个用户通过终端登录系统并运行命令,时间片轮转调度可以确保每个用户的命令都能得到及时处理。
▼优先级调度(Priority Scheduling)
原理:每个进程都被赋予一个优先级,调度器会优先选择优先级高的进程来运行。优先级可以是静态的(在进程创建时就确定),也可以是动态的(根据进程的运行状态、资源需求等因素动态调整)。例如,系统中的实时进程通常会被赋予较高的优先级,因为它们对响应时间有严格的要求;而一些后台的批处理进程可能会被赋予较低的优先级。
应用场景和优势:适用于对任务的紧急程度和重要性有区分的场景。比如在一个多媒体处理系统中,音频和视频播放进程可能会被赋予较高的优先级,以确保播放的流畅性;而一些文件索引更新的进程可以被赋予较低的优先级,因为它们对及时性的要求相对较低。优先级调度可以更好地满足不同类型任务的需求,提高系统对关键任务的响应能力。
▼完全公平调度(CFS,Completely Fair Scheduling)
原理:CFS 是 Linux 内核采用的主要调度策略。它的基本思想是让每个进程在一段时间内都能获得公平的 CPU 时间份额。CFS 通过虚拟运行时间(Virtual Runtime)来衡量每个进程应该获得的 CPU 时间。虚拟运行时间是基于进程的实际运行时间和优先级等因素计算得到的。调度器会选择虚拟运行时间最小的进程来运行,这样可以保证每个进程都能按照其权重(由优先级等因素决定)公平地获得 CPU 资源。例如,一个高优先级的进程的虚拟运行时间增长速度会比低优先级的进程慢,从而在调度时更有优势,但同时也会保证低优先级的进程能够获得足够的 CPU 时间。
应用场景和优势:CFS 在各种场景下都能提供较好的性能和公平性。无论是处理交互式任务还是后台任务,都能有效地分配 CPU 资源。它能够自适应地根据系统的负载和进程的特性进行调度,使得系统在高负载和低负载情况下都能保持良好的性能。
▼调度时机
进程状态转换时:当一个进程从运行状态转换为阻塞状态(如等待 I/O 操作)或者睡眠状态时,调度器会被触发。此时,CPU 资源需要重新分配,调度器会从就绪队列中选择一个新的进程来运行。例如,一个进程执行了一个读取磁盘文件的系统调用,在等待磁盘数据读取完成的过程中进入阻塞状态,调度器会立即调度其他就绪进程。
时间片用完时:在采用时间片轮转调度策略的情况下,当一个进程的时间片用完,调度器会暂停该进程的运行,将 CPU 分配给下一个就绪进程。这可以保证多个进程能够轮流使用 CPU 资源。
新进程创建或唤醒时:当一个新进程被创建并且进入就绪状态,或者一个阻塞或睡眠的进程被唤醒进入就绪状态时,调度器会考虑是否需要重新调度。如果新进入就绪状态的进程的优先级较高或者符合其他调度条件,调度器可能会将 CPU 资源分配给这个新进程。
▼调度器的实现细节
数据结构支持:调度器依赖多种数据结构来实现高效的调度。例如,红黑树(Red - Black Tree)用于存储就绪进程。在 CFS 调度策略中,进程按照虚拟运行时间在红黑树中排序。红黑树的特性使得查找虚拟运行时间最小的进程(即下一个应该运行的进程)的操作非常高效,时间复杂度为 O (log n),其中 n 是就绪进程的数量。
上下文切换(Context Switch):当调度器决定将 CPU 从一个进程切换到另一个进程时,需要进行上下文切换。上下文切换包括保存当前运行进程的 CPU 寄存器状态、程序计数器等信息,以及恢复下一个运行进程的这些信息。这个过程需要一定的时间开销,因此调度器需要尽量减少不必要的上下文切换。例如,在 x86 架构下,通过iret指令来恢复进程的上下文,通过push和pop等指令来保存和恢复寄存器状态。同时,内核还需要处理一些与内存管理相关的问题,如更新页表等,以确保新进程能够正确地访问其内存空间。
⑴分层架构
Linux 网络子系统采用分层的架构设计,主要包括网络协议栈(Protocol Stack)、设备驱动层(Device Drivers)和网络接口层(Network Interfaces)。这种分层结构使得网络功能的实现更加模块化和易于维护。
网络协议栈:是网络子系统的核心部分,它实现了各种网络协议,如 TCP/IP 协议族(包括 IP、TCP、UDP 等协议)、ICMP 协议等。协议栈负责处理网络数据包的封装、解封、路由选择以及上层协议和下层协议之间的交互。例如,当应用程序通过 TCP 协议发送数据时,数据会在协议栈中依次经过 TCP 层的分段、添加 TCP 首部,IP 层的添加 IP 首部,最后在链路层根据不同的网络接口类型(如以太网)添加链路层首部后发送出去。
设备驱动层:提供了与物理网络设备(如网卡、无线网卡等)通信的接口。设备驱动负责控制物理设备的操作,包括设备的初始化、数据的发送和接收、中断处理等。每个物理网络设备都有对应的驱动程序,这些驱动程序需要遵循 Linux 内核的设备驱动模型进行编写。例如,当网卡接收到一个数据包时,网卡驱动会将数据包从硬件缓冲区搬运到内核缓冲区,并通知协议栈有新数据包到达。
网络接口层:作为协议栈和设备驱动之间的桥梁,提供了统一的接口来访问不同类型的网络设备。它隐藏了设备驱动的具体细节,使得协议栈可以以一种统一的方式与各种网络设备进行交互。例如,无论底层是以太网设备还是无线设备,网络接口层都提供了诸如netif_rx(用于接收数据包)和dev_queue_xmit(用于发送数据包)等函数来处理数据包的传递。
⑵网络数据包的处理流程
▼发送过程
应用层数据准备:当应用程序(如 Web 浏览器、FTP 客户端等)需要发送数据时,数据首先从用户空间通过系统调用(如send或write)传递到内核空间的套接字缓冲区(Socket Buffer)。这个过程涉及到用户空间和内核空间之间的数据复制。
协议栈处理:在协议栈中,数据会根据使用的协议进行处理。如果是 TCP 协议,数据会被分割成合适大小的 TCP 段,添加 TCP 首部(包括源端口、目的端口、序列号、确认号等信息)。然后,TCP 段会被传递给 IP 层,IP 层会添加 IP 首部(包括源 IP 地址、目的 IP 地址、协议类型等信息),形成 IP 数据包。最后,根据目标地址和网络配置,数据包可能会经过路由选择,确定从哪个网络接口发送出去。
设备驱动传输:经过协议栈处理后的数据包会被传递到网络接口层,再由对应的设备驱动程序将数据包发送到物理网络设备。设备驱动会将数据包从内核缓冲区复制到设备的硬件发送缓冲区,然后启动设备的发送操作。例如,对于以太网设备,驱动会将数据包按照以太网帧的格式进行封装,添加源 MAC 地址、目的 MAC 地址和帧校验序列等信息,然后通过物理线路发送出去。
▼接收过程
设备接收与中断通知:物理网络设备(如网卡)接收到数据包后,会通过中断通知设备驱动。网卡会将数据包从物理线路接收并存储到硬件接收缓冲区,然后触发一个中断。设备驱动收到中断后,会将数据包从硬件接收缓冲区搬运到内核缓冲区。
网络接口层处理:在网络接口层,通过netif_rx函数等将数据包传递给协议栈。这个过程可能会涉及到一些简单的预处理,如检查数据包的合法性、更新网络接口的统计信息(如接收字节数、接收数据包数等)。
协议栈解析:协议栈会从链路层开始对数据包进行解析。首先检查链路层首部的正确性,然后根据协议类型(如 IP、ARP 等)将数据包传递给相应的协议层进行处理。如果是 IP 数据包,会检查 IP 首部的合法性,根据目的 IP 地址进行路由查找,确定是本地接收还是需要转发。如果是本地接收,会根据 IP 首部中的协议类型(如 TCP、UDP)将数据包传递给相应的上层协议层进行进一步的解析和处理,最后将数据传递给相应的应用程序(通过套接字接口)。
⑶网络子系统中的重要组件和概念
套接字(Socket):套接字是应用程序与网络协议栈之间的接口。它提供了一种统一的方式来进行网络通信,无论是基于 TCP 还是 UDP 协议。应用程序通过创建套接字,绑定地址和端口,然后使用send、recv等函数进行数据的发送和接收。套接字在 Linux 中是通过struct socket结构体来表示的,它包含了各种与网络通信相关的信息,如协议类型、本地和远程的地址和端口、套接字状态等。
路由表(Routing Table):路由表用于确定网络数据包的转发路径。当一个 IP 数据包需要发送到其他网络时,协议栈会根据路由表中的信息来选择合适的网络接口和下一跳地址。路由表可以通过静态配置(如手动添加路由条目)或者动态路由协议(如 RIP、OSPF 等)来更新。路由表中的每个条目包含了目的网络地址、子网掩码、网关(下一跳地址)、网络接口等信息。
网络设备抽象(Network Device Abstraction):Linux 通过网络设备抽象将不同类型的物理网络设备统一管理。每个网络设备在系统中都被视为一个struct net_device对象,这个对象包含了设备的各种属性(如设备名称、MAC 地址、MTU 等)和操作函数(如设备初始化函数、发送和接收函数等)。这种抽象使得协议栈可以方便地与各种设备进行交互,并且易于添加新的网络设备类型。
⑴kmalloc 函数功能与特点
kmalloc主要用于在内核空间分配连续的物理内存块。它分配的内存大小相对比较灵活,可以从几个字节到较大的内存块(具体大小因内核配置和硬件平台而异)。分配的内存是物理连续的,这对于一些需要连续内存的设备驱动和内核模块非常重要。例如,在直接操作硬件设备寄存器时,可能需要连续的内存空间来进行数据的缓冲和传输。
它返回的内存是已经清零的,即新分配内存中的内容初始化为 0。这可以防止旧数据对新分配内存的干扰,在一定程度上提高了系统的安全性和稳定性。
使用场景示例:在设备驱动开发中,当需要为设备分配一个用于存储数据的缓冲区时,如果对内存连续性有要求,就可以使用kmalloc。比如,为一个简单的字符设备驱动分配一个缓冲区来存储从设备读取的数据,代码可能如下:
char *buffer;
buffer = kmalloc(1024, GFP_KERNEL);
if (!buffer) {
printk(KERN_ERR "Failed to allocate buffer\n");
return -ENOMEM;
}
// 使用buffer进行数据存储等操作
⑵vmalloc 函数功能与特点
vmalloc用于分配虚拟地址连续但物理地址不一定连续的内存空间。它可以分配较大块的内存,相比kmalloc,其可分配的内存上限更高。这是因为它不要求物理地址连续,通过虚拟地址映射机制来提供连续的内存视图给内核使用。
由于物理地址不连续,对vmalloc分配的内存访问速度可能会比kmalloc分配的内存慢。因为每次访问可能涉及到更多的内存管理单元(MMU)操作,如页表的查找和转换。
使用场景示例:、当需要分配较大的内存空间,并且对物理连续性没有严格要求时可以使用vmalloc。例如,在加载一个大型的内核模块,并且模块中的数据结构不需要物理连续的内存时,就可以使用vmalloc来分配内存空间。不过,在对性能要求较高的内存访问场景,如实时系统中的高速数据采集和处理,应谨慎使用vmalloc。
⑶kmem_cache_alloc 函数(基于 slab 分配器)功能与特点
这是基于 slab 分配器的内存分配函数。slab 分配器是一种为了减少内存碎片和提高内存分配效率而设计的内存管理机制。kmem_cache_alloc从预先分配好的高速缓存(cache)中分配内存。这些高速缓存是针对特定大小和类型的数据结构定制的。
它的优势在于对于频繁分配和释放相同类型和大小的数据结构(如进程描述符、文件描述符等)能够提供高效的内存管理。因为 slab 分配器会对这些数据结构进行预初始化,减少了每次分配后的初始化开销。
使用场景示例:在内核中大量使用的数据结构的分配场景下非常有用。例如,在频繁创建和销毁进程的场景下,用于分配进程控制块(PCB)。因为进程控制块的大小相对固定,通过kmem_cache_alloc从进程控制块对应的 slab 缓存中分配内存,可以提高分配效率并且减少内存碎片。代码可能如下:
struct task_struct *new_task;
struct kmem_cache *task_cache = kmem_cache_create("task_struct_cache", sizeof(struct task_struct), 0, SLAB_HWCACHE_ALIGN, NULL);
new_task = kmem_cache_alloc(task_cache, GFP_KERNEL);
if (!new_task) {
printk(KERN_ERR "Failed to allocate task_struct\n");
return -ENOMEM;
}
// 使用new_task进行进程相关操作
⑷get_free_pages 函数功能与特点
get_free_pages主要用于分配以页(Page)为单位的物理内存。在 Linux 系统中,页是内存管理的基本单位,通常一页的大小为 4KB(在某些配置下可能不同)。它直接从物理内存中获取空闲的页面,分配的内存是物理连续的。
这种分配方式比较底层,需要对内存页的管理有一定的了解。它返回的是物理页的线性地址,适合用于需要直接访问物理内存页面的场景,如一些对内存布局和性能要求较高的设备驱动。
使用场景示例:在开发一些底层的设备驱动,如磁盘驱动或者高性能网络驱动时,可能需要直接操作物理内存页面。例如,在磁盘驱动的缓存管理中,为了提高磁盘 I/O 性能,需要分配物理连续的内存页面来作为磁盘缓存,就可以使用get_free_pages函数。假设要分配一个包含 4 个页面的缓存区,代码可能如下:
unsigned long buffer_addr;
buffer_addr = get_free_pages(GFP_KERNEL, 2); // 2表示分配2的2次方页,即4页
if (!buffer_addr) {
printk(KERN_ERR "Failed to allocate pages\n");
return -ENOMEM;
}
// 使用buffer_addr指向的内存页面进行磁盘缓存相关操作
⑸对比总结
连续性要求:kmalloc和get_free_pages分配的是物理连续的内存,vmalloc分配的是虚拟连续但物理不一定连续的内存,kmem_cache_alloc基于 slab 分配器,物理连续性取决于 slab 的管理方式,但主要关注特定数据结构的高效分配。
分配大小灵活性:kmalloc适用于分配大小相对灵活的小到中等大小的内存块,vmalloc用于分配较大的内存空间,kmem_cache_alloc侧重于特定大小的数据结构分配,get_free_pages以页为单位分配内存,大小相对固定(取决于页的大小)。
性能方面:kmalloc和kmem_cache_alloc(对于其适用的数据结构)通常在性能上表现较好,因为它们可以利用缓存和预初始化等机制。get_free_pages在需要物理连续页面的场景下性能较好,但使用相对复杂。vmalloc由于物理地址不连续,可能在访问速度上稍慢,尤其是在频繁访问内存的场景下。
- IRQ(Interrupt Request):即中断请求,是一种通用的中断机制。它用于处理各种设备的中断请求,如外部设备(如网卡、硬盘控制器等)向 CPU 发送中断信号,告知 CPU 需要处理某些事件。IRQ 可以有多个中断源共享,当多个设备同时产生中断请求时,通过中断优先级等机制来确定中断处理的顺序。
- FIQ(Fast Interrupt Request):即快速中断请求,是一种具有更高优先级的特殊中断机制。它主要用于对时间要求极为苛刻的中断处理场景,需要能够快速响应并且尽快处理完成。
▼优先级
FIQ:具有比 IRQ 更高的优先级。当 FIQ 和 IRQ 同时产生时,CPU 会优先处理 FIQ。这是因为 FIQ 中断处理程序被设计用来处理对系统实时性和响应速度要求极高的事件,如一些高速数据采集设备或者紧急的系统故障处理。
IRQ:优先级低于 FIQ,通常用于处理一般性的设备中断,如普通的 I/O 设备操作。当没有 FIQ 请求时,IRQ 按照预设的优先级或者中断请求的顺序进行处理。
▼处理模式
FIQ:进入 FIQ 模式后,CPU 会执行专门的 FIQ 中断处理程序。FIQ 模式下,CPU 会自动屏蔽其他中断(包括 IRQ 和 FIQ),直到 FIQ 中断处理程序执行完毕。这种方式保证了 FIQ 中断处理过程不会被其他中断干扰,从而能够快速、独占式地完成紧急任务。
IRQ:当进入 IRQ 模式处理中断时,根据 CPU 的设置,可能会允许其他更高优先级的中断(如 FIQ)打断当前的 IRQ 处理过程。而且,在一些系统中,IRQ 可以嵌套,即一个 IRQ 处理程序可以被另一个更高优先级的 IRQ 中断,待高优先级 IRQ 处理完成后再返回继续处理被中断的 IRQ。
▼中断向量表入口和处理程序
FIQ:在 CPU 的中断向量表中,FIQ 有单独的入口地址。这使得 CPU 能够快速跳转到 FIQ 中断处理程序。通常,FIQ 中断处理程序的地址是固定的,并且由于其高优先级和特殊处理方式,其处理程序相对比较精简,以实现快速响应。
IRQ:IRQ 在中断向量表中有多个入口(具体数量取决于系统的中断源数量和设计),用于处理不同设备的中断请求。这些入口地址指向相应的 IRQ 中断处理程序,这些程序通常会根据设备的不同而有所不同,可能需要进行一些复杂的设备相关操作,如读取设备状态、搬运数据等。
▼在 CPU 中的实现机制
⑴硬件层面
中断引脚和控制器:CPU 有专门的中断引脚用于接收 IRQ 和 FIQ 信号。对于 IRQ,通常会有多个引脚或者通过中断控制器(如在一些复杂的系统芯片中)来管理多个 IRQ 源。当外部设备产生中断请求时,会通过这些引脚或者中断控制器将中断信号传递给 CPU。而 FIQ 信号通常有单独的、具有更高优先级的引脚接入 CPU。
中断响应电路:当 CPU 接收到中断信号(IRQ 或 FIQ)后,其内部的中断响应电路会根据中断类型(通过判断是 IRQ 引脚还是 FIQ 引脚的信号)来确定中断优先级。对于 FIQ,电路会立即暂停当前正在执行的指令(如果有),将 CPU 状态切换到 FIQ 模式,并且跳转到 FIQ 中断向量表指定的地址开始执行中断处理程序。对于 IRQ,会根据当前的中断状态(如是否允许中断、中断优先级等)来决定是否立即响应。如果允许响应,会暂停当前任务,切换到 IRQ 模式,然后跳转到对应的 IRQ 中断向量表入口地址执行处理程序。
⑵软件层面
中断处理程序编写:在软件方面,开发人员需要为 FIQ 和 IRQ 分别编写中断处理程序。FIQ 中断处理程序通常会被优化,使其尽可能地简洁和高效,以减少中断响应时间。例如,可能会将一些复杂的操作推迟到后续的非紧急阶段或者使用其他机制来处理。对于 IRQ 处理程序,由于可能会涉及多个设备的中断处理,需要通过设备驱动等软件来正确识别中断源,并进行相应的设备操作。
中断嵌套和优先级处理:在操作系统或者内核的中断处理机制中,需要考虑中断嵌套的情况,尤其是对于 IRQ。软件会维护一个中断优先级队列或者状态标志,用于管理不同 IRQ 之间的优先级关系。当一个高优先级的 IRQ 中断了正在处理的低优先级 IRQ 时,软件会保存低优先级 IRQ 的上下文,然后处理高优先级 IRQ,处理完成后再恢复低优先级 IRQ 的处理。对于 FIQ,由于其独占式的处理方式,软件通常不需要考虑 FIQ 的嵌套问题,但需要确保 FIQ 处理完成后系统能够正确恢复到之前的状态。
⑴中断分为上半部分和下半部分的原因
保证硬件的及时响应:中断上半部分主要用于对硬件设备的快速响应。当硬件设备产生中断请求时,需要尽快确认中断来源并进行一些紧急的、对时间敏感的操作。例如,当网卡接收到一个数据包时,中断上半部分需要尽快地将数据包从网卡的硬件缓冲区搬运到内核的临时缓冲区。如果不及时处理这些操作,可能会导致硬件设备的缓冲区溢出,进而丢失数据。所以,中断上半部分的处理必须足够迅速,以满足硬件设备的实时需求。
避免长时间阻塞中断上下文:中断上下文是一个特殊的执行环境,在这个环境中不能进行一些可能导致阻塞的操作,如等待 I/O 完成、获取互斥锁(在可能导致睡眠的情况下)等。然而,许多中断处理任务包含复杂的操作,这些操作可能会涉及到阻塞。例如,在处理接收到的网络数据包时,可能需要对数据包进行复杂的协议解析,这个过程可能需要访问磁盘上的配置文件或者进行网络查询等操作,这些操作都可能会导致阻塞。将这些复杂的、可能导致阻塞的任务放到中断下半部分,可以让中断上半部分快速返回,使得 CPU 能够及时响应其他的中断请求,同时保证了系统的稳定性和可靠性。
⑵中断上下半部分的实现方式
▼上半部分实现
注册中断处理函数:在设备驱动开发中,首先需要通过request_irq函数(在 Linux 内核中)等方式注册中断处理函数。这个函数的主要参数包括中断号(irq)、中断处理函数(handler)、中断触发方式(如上升沿触发、下降沿触发等)以及一些标志位(如共享中断标志等)。例如,一个简单的字符设备驱动中,申请中断可能如下所示:
static irqreturn_t my_device_interrupt(int irq, void *dev_id) {
// 中断上半部分处理逻辑,如读取设备状态寄存器,将数据搬运到内核缓冲区等
return IRQ_HANDLED;
}
int init_module(void) {
int ret = request_irq(my_device_irq_num, my_device_interrupt, IRQF_TRIGGER_RISING, "my_device", NULL);
if (ret) {
printk(KERN_ERR "Failed to request interrupt\n");
return ret;
}
return 0;
}
快速处理硬件相关操作:在中断处理函数(上半部分)中,主要执行与硬件紧密相关的紧急操作。这些操作通常是简单的、能够快速完成的,如读取设备的状态寄存器以确定中断原因,将设备接收缓冲区中的数据复制到内核缓冲区等。处理完成后,根据是否有需要进一步处理的复杂任务,决定是否触发中断下半部分的处理。
▼下半部分实现
①使用 tasklet 机制
定义 tasklet 处理函数:tasklet 是一种轻量级的软中断处理机制,用于实现中断下半部分。首先需要定义一个 tasklet 处理函数,这个函数包含了中断下半部分需要完成的复杂任务。例如:
void my_tasklet_function(unsigned long data) {
// 中断下半部分处理逻辑,如对搬运到内核缓冲区的数据进行复杂的协议解析等
}
DECLARE_TASKLET(my_tasklet, my_tasklet_function, 0);
在中断上半部分触发 tasklet:在中断处理函数(上半部分)中,当完成硬件相关的紧急操作后,如果有需要进行复杂的后续处理,可以通过tasklet_schedule函数触发 tasklet。例如:
static irqreturn_t my_device_interrupt(int irq, void *dev_id) {
// 中断上半部分处理逻辑
tasklet_schedule(&my_tasklet);
return IRQ_HANDLED;
}
执行时机:tasklet 会在适当的时候被执行,通常是在中断返回后或者系统处于空闲状态时,由内核的软中断处理机制来执行。tasklet 在软件中断(Softirq)上下文执行,运行速度较快,但不能进行可能导致睡眠的操作。
②使用 workqueue 机制
定义 workqueue 处理函数和工作项:workqueue 提供了一种在进程上下文执行任务的机制,用于中断下半部分处理。首先需要定义一个工作函数,这个函数包含了复杂的任务处理逻辑。例如:
void my_work_function(struct work_struct *work) {
// 中断下半部分处理逻辑,如可能涉及睡眠操作的复杂数据处理等
}
DECLARE_WORK(my_work, my_work_function);
在中断上半部分调度 workqueue 工作项:在中断处理函数(上半部分)中,通过schedule_work函数调度工作项。例如:
static irqreturn_t my_device_interrupt(int irq, void *dev_id) {
// 中断上半部分处理逻辑
schedule_work(&my_work);
return IRQ_HANDLED;
}
执行时机和特点:工作项会被添加到工作队列中,内核会创建一个或多个内核线程来执行这些工作项。由于是在进程上下文执行,所以可以进行可能导致睡眠的操作,如等待 I/O 完成、分配大块内存等。工作项会在这些内核线程有空闲时间时被执行,这种方式更适合处理那些耗时较长、可能会阻塞的中断下半部分任务。
段式管理(Segmentation):段式管理是一种将程序的地址空间划分为多个段(Segment)的内存管理方式。每个段都有自己的名称、长度和起始地址,并且具有不同的用途,如代码段(存放程序的指令)、数据段(存放程序的数据)、堆栈段等。段式管理通过段表(Segment Table)来实现地址转换,段表中记录了每个段的起始地址、长度和访问权限等信息。当程序访问一个内存地址时,CPU 会根据段号查找段表,得到该段的起始地址,再加上段内偏移量,就可以得到实际的物理地址。
页式管理(Paging):页式管理将内存空间和程序的虚拟地址空间划分为固定大小的页面(Page)。在虚拟地址空间中,页面是连续编号的;在物理内存中,对应的存储单元称为页框(Page Frame)。页式管理通过页表(Page Table)来进行地址转换,页表中每个表项记录了一个虚拟页面对应的物理页框地址以及访问权限等信息。当 CPU 发出一个虚拟地址访问请求时,会将虚拟地址分解为页号和页内偏移,通过页号查找页表得到物理页框地址,再与页内偏移组合,就可以得到实际的物理地址。
▼结合了段式和页式的优点
段式优点的继承
逻辑清晰的程序组织:段式管理按照程序的逻辑结构将地址空间划分为不同的段,这使得程序和数据的组织更加符合逻辑。例如,代码段、数据段和堆栈段的划分符合程序员对程序结构的理解,方便程序的编写、编译和调试。在段页式管理中,依然保留了这种逻辑结构,程序员可以方便地对不同的段进行管理和保护。
方便的程序共享和保护:段可以方便地实现程序和数据的共享和保护。不同的程序可以共享一个代码段,只要它们具有相同的段号和访问权限。同时,可以通过设置段的访问权限(如只读、可读可写等)来保护程序和数据。在段页式管理中,这种共享和保护机制仍然可以有效地实现,通过段表中的访问权限字段以及页表中的访问权限字段共同作用,可以更精细地控制对内存的访问。
页式优点的继承
高效的内存利用:页式管理通过将内存划分为固定大小的页面和页框,有效地解决了内存碎片的问题。在内存分配和回收过程中,系统以页面为单位进行操作,使得内存的利用更加高效。段页式管理也继承了这一优点,当段内的页面分配和回收时,不会产生像段式管理那样的外部碎片,提高了内存的整体利用率。
支持虚拟内存实现:页式管理是实现虚拟内存的基础。通过页表机制,系统可以将部分暂时不使用的页面换出到磁盘等外部存储设备,当需要时再换入内存。段页式管理同样可以方便地实现虚拟内存,每个段内的页面可以根据需要在内存和磁盘之间进行交换,这样可以运行比物理内存大得多的程序。
▼地址空间管理的灵活性和高效性
灵活的地址映射:段页式管理在地址映射方面具有很大的灵活性。它先将虚拟地址按照段号和段内偏移进行划分,然后在段内又按照页号和页内偏移进行映射。这种两级映射方式可以根据程序的实际需求来灵活配置段和页的大小。例如,对于代码段和数据段等不同的段,可以根据其大小和访问特性来选择合适的页面大小,从而优化内存访问性能。
高效的地址转换:虽然段页式管理涉及两级地址转换(段表和页表),但现代计算机系统通过硬件支持(如高速缓存(Cache)技术)可以有效地提高地址转换的速度。通常会在 CPU 内部设置高速缓存来存储段表和页表的部分常用表项,当进行地址转换时,首先会在高速缓存中查找,大大减少了内存访问次数,提高了地址转换的效率。
▼适应不同类型程序的需求
大型复杂程序的管理:对于大型的、复杂的程序,段页式管理能够很好地适应其内存管理需求。大型程序可能包含多个模块和大量的数据,通过段式划分可以将不同的模块和数据分开管理,便于程序的开发和维护。同时,页式管理可以保证这些模块和数据在内存中的高效存储和访问,使得大型程序能够在系统中高效运行。
多任务系统中的应用:在多任务操作系统中,每个任务都有自己的地址空间。段页式管理可以为每个任务分配独立的段和页,方便任务之间的隔离和保护。不同任务的段表和页表可以分别进行管理,这样可以有效地防止一个任务对另一个任务的内存空间进行非法访问,提高了系统的安全性和稳定性。