简介
该项目最初为写名校 lab 过程中留下了的记录,最初只是单纯想记录实现的心路历程,所踩过的坑,但是因为学术诚信的缘故,公开解决方案甚至代码是不合适的。但是直接删了有点可惜,后来想尝试换一门语言重写 lab/proj/hw 等内容,所以逐渐演化为用其他语言重写,造轮子过程中的笔记。例如从零实现 OS/Compiler/DB/ld 等,此外和课程答案相关的内容确实会逐渐删去。
本地运行
git clone https://github.com/weijiew/everystep.git
cd everystep && mdbook serve --open
贡献
欢迎任何意义上能够优化项目的贡献。
这个系列本质上是学习 MIT 6.828 JOS 2018 lab 过程中的副产物,即在研究 JOS 代码的过程遇到的问题,感觉删了可惜,于是整理下来形成了这个系列。
个人认为 JOS 的学习过程比较陡峭,如果学习过程中代码写不出来很大程度上是概念理解不正确,那么此时最好从头开始一个概念一个概念的去查询,结合代码确保自己理解正确。如果依旧无法通过测试那么需要参考测试进一步矫正,加深对概念的认识。千万不要浮躁,戒骄戒躁,一个概念一个概念啃下来收获还是非常大的。总之结合具体代码来学习 OS 可以消除很多困惑,单纯背概念很难搞清楚所有细节。
附录包含了如何配置环境,如果想要运行代码可以参考,一定程度上能够节省你的时间。
OS 启动
第一部分主要结合具体的代码讲解 BIOS 和 Boot Loader 的实现细节,即 OS 内核进入内存之前所做的全部工作。
计算机启动的时候要先经过 BIOS(基本输入输出系统),BIOS 是计算机启动时运行的第一个程序,它负责初始化和测试系统硬件组件,然后加载并启动操作系统。
随后讲解 BIOS 如何将控制权交给 Boot Loader,以及 Boot Loader 的汇编实现细节。Boot Loader 是在 BIOS 之后运行的程序,它负责加载操作系统内核到内存中。
Boot Loader 还有一部分是 C 语言实现,负责将 OS 从磁盘写入内存中。最后讲解一些 OS 的基本概念,例如内存地址空间的演化历程。实模式和保护模式的概念和区别。这两种模式是 CPU 运行模式的基本类型,它们决定了程序如何访问内存和硬件。分段和分页的内存管理技术。这两种技术都是为了更有效地管理和保护内存资源。
OS 启动的第一步是 BIOS ,BIOS(Basic Input/Output System,基本输入输出系统)是计算机系统中的一个非常关键的组件,其主要职责可以概括为系统的启动和基础硬件管理。
BIOS 的工作流程和功能
BIOS 的工作流程和功能可以分为以下几个主要方面:
-
开机自检:电脑开机时,BIOS 首先检查电脑的基本硬件是否正常工作,比如内存、键盘等。
-
准备硬件:BIOS 设置并准备好电脑的主要硬件,比如 CPU 和硬盘,为启动操作系统做好准备。
-
引导过程:BIOS 确定从哪个设备(硬盘、U 盘等)启动电脑,并开始加载操作系统。
-
系统设置:BIOS 提供一个界面,让用户可以设置或更改电脑的硬件配置。
总的来说,BIOS 就像是电脑启动时的指挥官,负责检查硬件、准备系统,并引导操作系统的启动。
第一条指令
IBM PC 的启动过程是一个经典的计算机启动流程,我们可以通过它来理解计算机如何从零开始加载操作系统。
首先,我们要了解的是 IBM PC 的内存布局。在 IBM PC 中,当电脑开机时,CPU 开始从一个固定的物理地址执行代码,这个地址就是0x000ffff0
。这个地址位于 ROM BIOS 的最顶端,占据了 64KB 的内存空间。ROM BIOS 是只读存储器中的基本输入输出系统,负责在计算机启动时进行硬件检测、初始化,并加载操作系统。
+------------------+ <- 0x00100000 (1MB)
| BIOS ROM |
+------------------+ <- 0x000F0000 (960KB)
| 16-bit devices, |
| expansion ROMs |
+------------------+ <- 0x000C0000 (768KB)
| VGA Display |
+------------------+ <- 0x000A0000 (640KB)
| |
| Low Memory |
| |
+------------------+ <- 0x00000000
接下来解释地址 0x000ffff0
由 CS = 0xf000
和IP = 0xfff0
组成。在这里,CS(Code Segment,代码段寄存器)和 IP(Instruction Pointer,指令指针寄存器)是 x86 架构中用于指定指令地址的寄存器。在 IBM PC 开机时,CS 的值被设置为0xf000
,而 IP 的值被设置为0xfff0
。这意味着 CPU 从物理地址0x000ffff0
(等于0xf000 * 16 + 0xfff0
)开始执行指令。
最后,我们来看看第一条指令是什么。在0x000ffff0
这个地址上,存放的是一个跳转(jmp)指令。这条指令告诉 CPU 跳转到一个新的地址,开始执行那里的代码。在这个例子中,跳转的目标地址是 CS = 0xf000
,IP = 0xe05b
。这意味着跳转后的物理地址是0xfe05b
(等于0xf000 * 16 + 0xe05b
)。跳转到这个地址后,CPU 开始执行 BIOS 中的代码,进行进一步的初始化和启动过程。
结合以上细节,我们可以理解 IBM PC 是如何从一个固定的物理地址开始,通过设置特定的寄存器和执行跳转指令,进而开始整个的启动过程的。
设置中断描述符表(Interrupt Descriptor Table, IDT)
IDT 是一个数据结构,它告诉 CPU 当特定的中断发生时应该执行哪些代码。例如,假设你的电脑有一个键盘。当你按下键盘上的一个键时,键盘会向 CPU 发送一个信号,这个信号被称为“中断”。BIOS 在 IDT 中为这种类型的中断配置了一个特定的入口(或“描述符”)。这个入口定义了当按键中断发生时应该执行的函数的地址。
当中断发生时(比如你按下了键盘上的一个键),CPU 会暂停当前正在执行的任务,并根据 IDT 中的信息跳转到相应的中断处理函数。这个中断处理函数将执行必要的操作来响应中断。在按键的例子中,中断处理程序可能会读取按下的键的信息,并将其传递给操作系统。一旦中断处理函数完成其任务,它会通知 CPU 中断处理已经完成,CPU 随后会恢复之前被中断的任务。
中断描述符表(IDT)是一个关键的计算机结构,它用于管理中断处理程序的地址。为了更直观地理解 IDT,我们可以将其想象成一个表格,每一行代表一个中断向量,其中包含了处理该中断所需的信息。下面是一个简化的图形化表示:
+------------------+------------------+------------------+------------------+
| 中断向量号(Index) | 偏移地址(Offset) | 段选择器(Selector) | 属性(Attributes) |
+------------------+------------------+------------------+------------------+
| 0 | 0x0000FFFF | 0x08 | 0x8E |
| 1 | 0x0000FFEF | 0x08 | 0x8E |
| 2 | 0x0000FFDF | 0x08 | 0x8E |
| ... | ... | ... | ... |
| N | 0x0000FXXX | 0x08 | 0x8E |
+------------------+------------------+------------------+------------------+
-
中断向量号(Index):这是中断的唯一标识符。例如,键盘中断、时钟中断等都有唯一的向量号。
-
偏移地址(Offset):这是处理该中断的函数在内存中的地址。当中断发生时,CPU 会跳转到这个地址执行相应的处理程序。
-
段选择器(Selector):这是一个指向处理程序所在段的指针。在大多数现代操作系统中,这通常指向一个代码段。
-
属性(Attributes):这定义了中断门的类型和权限,例如,它可能指明这是一个 32 位的中断门,以及是否允许用户模式下的程序触发这个中断。
请注意,这只是一个简化的表示。实际的 IDT 在现代操作系统中可能更复杂,包含更多的细节。但是,上面的表格给出了一个基本的视图,展示了 IDT 如何为每个中断提供必要的信息。
初始化各种设备
BIOS 接着会初始化连接到计算机的各种硬件设备。例如,VGA 显示器(一种标准的图形显示系统),用于显示启动过程中的信息。
BIOS 然后会初始化 PCI 总线。PCI(外围组件互连标准)是一种连接主板和外围设备的总线标准。它还会初始化它所知道的所有重要设备,比如网络卡、声卡等。
寻找可启动设备
BIOS 完成基本的硬件设置后,接下来的任务是寻找一个可启动的设备。它会按照一定的顺序(通常可以在 BIOS 设置中调整)检查软盘、硬盘、CD-ROM 等设备。当 BIOS 找到一个可启动设备(例如,一个有启动扇区的硬盘)时,它会读取这个设备的引导扇区。引导扇区是存储在存储设备上的一个特殊区域,包含了启动计算机所需的代码。
BIOS 从找到的引导设备中读取引导加载程序(boot loader),并将计算机的控制权转交给它。引导加载程序接下来的任务是加载操作系统。例如,它可能会从硬盘上加载 Windows 或 Linux,并将控制权转交给操作系统。
在这个例子中,BIOS 的角色就像是一个中介,它启动计算机,检查和准备硬件,然后找到并启动引导加载程序,引导加载程序再接着加载整个操作系统。这个过程在每次计算机启动时都会发生,无论是在真实的硬件上,还是在像 QEMU 这样的模拟器中。
BIOS 启动计算机,检查和准备硬件后,接下来会将控制权传递给 Boot Loader。Boot Loader 负责将操作系统从磁盘加载到内存中。接下来讲解 Boot Loader 的实现细节。
在 JOS 中 Boot Loader 分为了汇编和 C 语言两部分来实现的,这章节讲解汇编部分 boot/boot.S
,下一章讲解 C 语言部分boot/main.c
。
为什么拆分为两部分?
简单来说无法全部用 C 语言来实现,例如将处理器从实模式切换到保护模式。这个过程需要直接操作处理器的控制寄存器,这些操作在高级语言中通常是不支持的,因此需要使用汇编语言来完成。
此外在系统启动时,硬件处于一个未知的状态,需要进行初始化。这部分工作通常包括设置中断,初始化内存,检测硬件设备等,这些工作需要直接操作硬件,而汇编语言可以直接操作硬件,因此通常使用汇编语言来完成。
当处理器切换到保护模式后,引导加载器需要加载内核到内存,并跳转到内核的入口点开始执行。这部分工作可以使用高级语言(如 C 语言)来完成,因为高级语言更易于编写和维护,同时也可以利用高级语言的各种特性,如函数、结构体等。
总的来说,使用汇编和 C 语言两部分来实现引导加载器,既可以利用汇编语言直接操作硬件的优势,又可以利用高级语言易于编写和维护的优势。
主引导记录(MBR)
主引导记录(Master Boot Record,MBR)是位于硬盘驱动器的第一个扇区(即硬盘的第一个物理扇区,通常是第 0 柱面,第 0 磁头,第 1 扇区)的一段引导代码。它的大小为 512 字节,主要包含以下两部分内容:
-
引导加载器(Boot Loader):这是一段小程序,它的任务是加载并执行操作系统的引导程序。引导加载器的大小通常非常小,因为它需要适应 MBR 的大小限制。在引导加载器中,通常会包含一些基本的硬件初始化代码,以及加载操作系统引导程序的代码。
-
分区表(Partition Table):分区表是硬盘分区信息的列表。它描述了硬盘上的各个分区的位置和大小。分区表通常位于 MBR 的最后,占用 64 字节,可以描述最多 4 个分区。
当计算机启动时,BIOS 会首先读取硬盘的 MBR,并执行其中的引导加载器代码。引导加载器会根据分区表的信息,找到操作系统的引导程序所在的位置,然后加载并执行它,从而启动操作系统。
需要注意的是,由于 MBR 的大小限制,引导加载器通常只能完成最基本的加载任务。对于一些复杂的操作系统,可能需要使用更复杂的引导加载器。在这种情况下,MBR 中的引导加载器的任务就是加载这个更复杂的引导加载器,这个更复杂的引导加载器通常被存储在硬盘的其他位置。
此外,MBR 也有一些局限性。例如,它只能支持最大 2TB 的硬盘,只能创建最多 4 个主分区等。为了解决这些问题,现代的计算机系统通常使用 GUID 分区表(GPT)来代替 MBR。
JOS 中 BIOS 最后会将 512 字节的引导扇区加载到物理地址 0x7c00
到 0x7dff
的内存中,其中包含了 Boot Loader 程序。随后使用指令 jmp 将 CS:IP
设置为 0000:7c00
并将控制权传递给 Boot Loader,最后 Boot Loader 负责将 OS 从磁盘加载到内存中。
为什么是 512 字节?
这个大小是由历史原因决定的,因为在早期的硬盘中,512 字节是一个很合适的大小,既可以满足存储引导加载器的需要,又不会浪费太多的空间。
PC 是以扇区位单位进行读取的数据的,一个扇区是 512 字节。为什么 PC 是以扇区为单位从磁盘中读取数据,主要有两个原因:
-
兼容性:由于历史原因,很多操作系统和硬件设备都假设硬盘的扇区大小为 512 字节。如果改变这个大小,可能会导致一些软件和硬件设备无法正常工作。
-
效率:硬盘的读写操作有一定的开销,如果每次只读写很小的数据,这个开销就会变得很大。因此,硬盘通常会一次读写一个扇区的数据,这样可以提高数据的读写效率。
在现代的硬盘中,扇区的大小可能会大于 512 字节,例如 4096 字节。但是为了兼容老的软件和硬件设备,这些硬盘通常会提供一个 512 字节扇区的模拟模式。
启用 A20
Boot Loader 拿到控制权后会先执行启用 A20 线,使得可以访问超过 1MB 的内存地址。
下面这段代码是用于启用 A20 线的。A20 线是计算机内存的一个地址线,它可以访问的内存地址超过 1MB。在早期的 PC 机中,为了保持向后兼容性,物理地址线 20 默认被拉低,这意味着所有超过 1MB 的内存地址都会回绕到零,也就是说,它们会被映射到内存的开始位置。
"拉低"在硬件设计中通常指的是将某个电平设置为低电平(通常是 0V),这在数字逻辑中通常表示逻辑"0"。"A20 线被拉低"意味着 A20 地址线被禁用,这限制了 CPU 访问的物理内存地址范围。
在早期的 PC 机中,为了保持与 IBM PC 和 IBM PC/XT 的向后兼容性,A20 线在启动时默认被禁用(或者说被"拉低")。这意味着,尽管 CPU 的地址总线有 21 根线(A0-A20),可以寻址 2^21(即 2MB)的内存空间,但由于 A20 线被禁用,实际上只能访问到 1MB 的内存空间。
启用 A20 线(或者说"拉高"A20 线)可以让 CPU 访问超过 1MB 的内存空间。这在运行需要大量内存的现代操作系统时是必要的。
然而,随着计算机硬件的发展,内存容量已经远远超过了 1MB,因此需要解除这一限制,以便能够访问更多的内存。这就是为什么需要启用 A20 线。
seta20.1:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.1
movb $0xd1,%al # 0xd1 -> port 0x64
outb %al,$0x64
seta20.2:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.2
movb $0xdf,%al # 0xdf -> port 0x60
outb %al,$0x60
这段代码的目的就是解除这一限制,启用 A20 线,使得可以访问超过 1MB 的内存地址。具体来说,代码首先等待键盘控制器(端口 0x64)不忙,然后向键盘控制器发送命令 0xd1,这个命令的作用是告诉键盘控制器接下来要写入的数据是一个命令字节。然后代码再次等待键盘控制器不忙,最后向键盘控制器的数据端口(端口 0x60)写入命令 0xdf,这个命令的作用是启用 A20 线。
在早期的 IBM PC 兼容机中,键盘控制器(通常是 Intel 8042 芯片)不仅负责处理键盘输入,还负责处理一些系统级别的功能,其中之一就是控制 A20 线的状态。这是因为在设计这些系统时,人们需要找到一种方法来禁用 A20 线以保持与旧的 8086 处理器的兼容性,而键盘控制器恰好有一些未使用的输出线,所以人们选择了使用键盘控制器来控制 A20 线。
在这段代码中,首先通过读取端口 0x64 的状态,等待键盘控制器不忙。然后向端口 0x64 写入 0xd1,这是一个特殊的命令,告诉键盘控制器接下来要写入的数据是一个命令字节,而不是普通的数据。然后再次等待键盘控制器不忙,最后向数据端口 0x60 写入 0xdf,这个命令的作用是启用 A20 线。
这样做的原因是,键盘控制器的某个输出线被连接到了 A20 线的门控,当这个输出线被设置为高电平时,A20 线就会被启用,允许 CPU 访问超过 1MB 的内存地址。而 0xdf 这个命令就是用来设置这个输出线为高电平的。
16 位实模式切换到 32 位保护模式
A20 线开启后接下来将 16 位实模式切换到 32 位保护模式,在 JOS 操作系统中,处理器从 16 位模式切换到 32 位模式的过程发生在引导加载器(boot loader)的早期阶段。这个过程通常在引导加载器的汇编语言部分中完成。
切换到 32 位模式的关键步骤是设置和启用保护模式。这是通过设置控制寄存器 CR0 的 PE(保护使能)位来实现的。当 PE 位被设置为 1 时,处理器就会进入保护模式,此时可以执行 32 位代码。
以上就是处理器从 16 位模式切换到 32 位模式的过程。下面是具体的切换代码。
# 通过使用引导GDT(全局描述符表)和段翻译,
# 从实模式切换到保护模式,使虚拟地址与物理地址相同,
# 以确保在切换期间内存映射保持不变。
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
# 跳转到下一条指令,但在32位代码段中执行。
# 这将使处理器切换到32位模式。
ljmp $PROT_MODE_CSEG, $protcseg
这段代码描述的是从 16 位实模式切换到 32 位保护模式的过程。在 x86 架构的早期,处理器在启动时会处于 16 位的实模式,这种模式下,处理器可以直接访问物理内存,但是只能访问到 1MB 的内存空间。为了能够访问更多的内存和提供更好的内存保护机制,处理器需要切换到 32 位的保护模式。
切换到保护模式的过程如下:
-
lgdt gdtdesc
:这条指令用于加载全局描述符表(GDT)。GDT 是一种数据结构,它定义了各种不同的内存段,包括它们的基地址、限制和访问权限等信息。在切换到保护模式之前,需要先设置好 GDT。 -
movl %cr0, %eax
和orl $CR0_PE_ON, %eax
:这两条指令用于修改控制寄存器 CR0 的 PE(保护使能)位,将其设置为 1。CR0 是一个 32 位的寄存器,它的第 0 位是 PE 位,当 PE 位被设置为 1 时,处理器会切换到保护模式。 -
movl %eax, %cr0
:这条指令将修改后的 CR0 值写回 CR0 寄存器,完成模式切换。 -
ljmp $PROT_MODE_CSEG, $protcseg
:这条指令执行一个长跳转,跳转到标签protcseg
指向的地址,并且在跳转后,处理器会开始在 32 位代码段中执行代码。这条指令的执行会导致处理器更新代码段寄存器 CS,从而使处理器开始执行 32 位代码。
这样,处理器就从 16 位实模式切换到了 32 位保护模式。
设置堆栈指针并调用 C 语言函数。
下面这段代码是在设置保护模式下的数据段寄存器,并设置堆栈指针,然后调用 C 语言函数bootmain
。
protcseg:
# 设置保护模式下的数据段寄存器
movw $PROT_MODE_DSEG, %ax # Our data segment selector
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: Stack Segment
# 设置堆栈指针并调用C语言函数。
movl $start, %esp
call bootmain
# 如果 bootmain 返回(它不应该返回),则进入循环。
spin:
jmp spin
首先,movw $PROT_MODE_DSEG, %ax
将PROT_MODE_DSEG
(保护模式下的数据段选择器)的值移动到寄存器%ax
中。然后,movw %ax, %ds
等指令将%ax
中的值复制到各个段寄存器(DS、ES、FS、GS 和 SS)中。这样做是为了在保护模式下设置正确的数据和堆栈段。
在 x86 架构中,DS、ES、FS、GS、SS 是段寄存器,它们在保护模式下用于存储段选择器,用于指定内存访问的段。
- DS(Data Segment):数据段寄存器,通常用于存储操作数和结果的内存段的段选择器。
- ES(Extra Segment):附加段寄存器,通常用于字符串和其他数据块操作的目标地址的段选择器。
- FS、GS:这是在 80386 中新增的两个段寄存器,主要用于操作系统,可以用于存储任何段选择器。
- SS(Stack Segment):堆栈段寄存器,用于存储堆栈的段选择器。
在这段代码中,所有这些段寄存器都被设置为同一个值(PROT_MODE_DSEG
),这是因为在这个特定的上下文中,所有的内存访问都应该在同一个内存段中进行。这样做可以简化内存管理,因为处理器不需要在不同的内存段之间切换。
接着,movl $start, %esp
将堆栈指针%esp
设置为start
的地址。这是为了在调用bootmain
函数之前设置正确的堆栈。
在 x86 架构中,%esp
寄存器是堆栈指针寄存器(Stack Pointer Register)。它的主要作用是指向当前的栈顶。当我们在程序中调用函数、保存临时变量或者保存 CPU 的状态时,这些信息通常会被压入栈中,而%esp
寄存器就是用来追踪当前栈顶位置的。
例如,当我们调用一个函数时,返回地址通常会被压入栈中,然后%esp
寄存器的值会减小(在 x86 架构中,栈是向下增长的),以指向新的栈顶。当函数返回时,返回地址会从栈中弹出,%esp
寄存器的值会增大,以指向新的栈顶。
因此,%esp
寄存器在函数调用、异常处理以及任务切换等操作中都起着非常重要的作用。
然后,call bootmain
调用bootmain
函数。这个函数应该包含了引导加载器的主要逻辑,例如加载内核到内存,然后跳转到内核的入口点。
最后,如果bootmain
函数返回(实际上它不应该返回),代码会进入一个无限循环spin
。这是一个安全措施,防止执行未定义的指令。如果bootmain
函数意外返回,CPU 将会在这个无限循环中停止,而不是继续执行可能存在的随机指令。
全局描述表 GDT
下面这段代码定义了一个全局描述符表(Global Descriptor Table,简称 GDT)。GDT 是 x86 架构中用于实现内存保护和分段内存管理的重要数据结构。每个段描述符定义了一个段的属性,如基地址、限制和访问权限等。
# Bootstrap GDT
.p2align 2 # force 4 byte alignment
gdt:
SEG_NULL # null seg
SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg
SEG(STA_W, 0x0, 0xffffffff) # data seg
gdtdesc:
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt
在这段代码中,定义了一个包含三个段描述符的 GDT:
-
第一个描述符是一个空描述符(
SEG_NULL
),在 x86 架构中,第一个描述符必须是空描述符。 -
第二个描述符定义了一个代码段(
SEG(STA_X|STA_R, 0x0, 0xffffffff)
)。STA_X
和STA_R
表示这个段是可读的并且可执行的。0x0
和0xffffffff
分别是这个段的基地址和限制,表示这个段覆盖了整个 4GB 的地址空间。 -
第三个描述符定义了一个数据段(
SEG(STA_W, 0x0, 0xffffffff)
)。STA_W
表示这个段是可写的。同样,这个段也覆盖了整个 4GB 的地址空间。
gdtdesc
定义了一个 GDT 描述符,它包含了 GDT 的大小和地址。在切换到保护模式之前,处理器需要知道 GDT 的位置和大小,这就是通过加载这个 GDT 描述符来实现的。因为在保护模式下,处理器通过段选择器和偏移量来访问内存,其中段选择器是一个 16 位的值,它的高 13 位是索引,用于在 GDT 中查找对应的段描述符。在这里,0x17
是 GDT 的大小减 1(因为 GDT 的大小是以字节为单位,所以需要减 1),gdt
是 GDT 的地址。
+------------------------+
| GDT Header |
+------------------------+
| Segment Descriptor 1 | +----------------------+
+------------------------+ | Segment Descriptor 2 |
| Segment Descriptor 2 | +----------------------+
+------------------------+ | Segment Descriptor 3 |
| Segment Descriptor 3 | +----------------------+
| ... | | ... |
+------------------------+ +----------------------+
| Segment Descriptor n | | Segment Descriptor n |
+------------------------+ +----------------------+
上面的文本图形化展示了全局描述符表(GDT)的基本结构。GDT 是用于在 x86 架构中进行内存分段和保护的重要数据结构。以下是各个部分的解释:
-
GDT Header: GDT 的开头包含一个简单的头部,其中包括 GDT 的大小等信息。
-
Segment Descriptor 1, 2, 3, ..., n: GDT 包含一系列段描述符,每个描述符对应一个内存段。每个段描述符包含了关于该段的信息,如基地址、段限制、访问权限等。这些描述符以数组的形式存在,可以根据需要添加更多的描述符。
每个段描述符的结构大致如下:
+------------------------+------------------------+
| Base Address | Segment Limit | <-- 64 bits
+------------------------+------------------------+
| G | D/B | 0 | AVL | P | Limit | AVL | S | Type | <-- 32 bits
+------------------------+--------+-------+-------+
-
Base Address: 段的基地址,指示段在内存中的起始位置。
-
Segment Limit: 段的限制,指示段的大小。
-
G (Granularity): 指示段限制的单位,如果为 1,表示以 4KB 为单位;如果为 0,表示以字节为单位。
-
D/B (Default/Big): 当为 1 时表示 32 位操作模式,当为 0 时表示 16 位操作模式。
-
AVL (Available): 可由系统或程序自由使用的位。
-
P (Present): 表示段是否在内存中存在。
-
S (System/Segment): 如果为 0,表示是系统段;如果为 1,表示是代码或数据段。
-
Type: 指示段的类型,如代码段、数据段等。
以上图示仅为简化的表示,实际 GDT 可能会更复杂,包括特权级、段的类型、权限等更多信息。这里的图示主要用于概述 GDT 的基本结构。
保护模式下的寻址方式
在 x86 架构中,当处理器运行在保护模式下时,内存访问不再是直接通过物理地址,而是通过一个叫做"段选择器"的值加上一个偏移量来完成的。这种方式提供了更好的内存保护和更大的内存访问范围。
段选择器是一个 16 位的值,它的高 13 位是索引,用于在全局描述符表(GDT)中查找对应的段描述符,剩余的 3 位就被用来表示请求者的特权级别和选择使用的表,为访问控制和隔离提供了更多的灵活性。段描述符包含了段的基地址、限制和访问权限等信息。
假设我们有一个段选择器,它的值为0x1234
,那么它的二进制表示为0001 0010 0011 0100
。其中,高 13 位(0001 0010 0011 0
)是索引,用于在 GDT 中查找对应的段描述符。
在 GDT 中,每个段描述符占用 8 个字节,所以我们可以通过索引乘以 8 来计算段描述符在 GDT 中的偏移量。在这个例子中,索引的值为0x123
(十进制的 291),所以段描述符在 GDT 中的偏移量为0x123 * 8 = 0x918
。
然后,处理器会将这个偏移量加上 GDT 的基地址,得到段描述符在内存中的物理地址。处理器会从这个地址处读取 8 个字节的数据,得到段描述符的内容。
最后,处理器会根据段描述符的内容和偏移量来访问内存。例如,如果偏移量为0x5678
,那么处理器会访问的物理地址就是段的基地址加上0x5678
。
总结
整体来说,Boot Loader 是在启动过程中的关键步骤,它设置了 CPU 的运行环境,从实模式切换到保护模式,并准备好跳转到高级语言编写的引导程序。
接下来是 Boot Loader 的 C 语言部分,此时处理器切换到保护模式,Boot Loader 需要加载内核到内存,并跳转到内核的入口点开始执行。
接下来需要弄清楚几个问题,内核加载到内存后存放在哪里?内核本身是如何组织的?C 语言读取内核到内存的细节。
内核加载到内存后存放在哪里?
下面这段代码定义了一个宏ELFHDR
,它将一个内存地址(0x10000)转换为一个Elf
结构体的指针。这个地址通常被称为暂存区(scratch space),在引导加载程序中,它被用于临时存储和处理从硬盘读取的内核映像,直到内核映像被加载到其最终目标位置。
// 暂存区(scratch space),用于加载和存储 ELF(Executable and Linkable Format)格式的内核映像。
// 在引导加载程序中,这个地址用于临时存储和处理从硬盘读取的内核映像,直到它被加载到其最终目标位置。
#define ELFHDR ((struct Elf *) 0x10000)
Elf
是 Executable and Linkable Format 的缩写,它是一种常见的文件格式,用于存储程序或者其他类型的可执行文件。在这个上下文中,Elf
可能是一个 C 结构体,用于表示 ELF 文件的头部(header)。
这个宏的主要用途是提供一个方便的方式来访问这个暂存区中的 ELF 头部。例如,你可以使用ELFHDR->e_entry
来访问 ELF 头部中的e_entry
字段,这个字段通常包含程序的入口点。
ELF
ELF(Executable and Linkable Format)是一种常见的文件格式,用于存储程序或者其他类型的可执行文件。下面是 JOS 中定义 ELF 对应的结构体字段。 这个结构体的主要用途是解析 ELF 文件,通过读取并解析这些字段,我们可以获取到 ELF 文件的各种重要信息,如程序入口点、程序头表和节头表的位置等。
struct Elf {
uint32_t e_magic; // 必须等于ELF_MAGIC,是一个魔数,用于验证文件是否为ELF格式
uint8_t e_elf[12]; // ELF标识,包含了文件类别、数据编码方式等信息
uint16_t e_type; // 文件类型,指明是可执行文件、可重定位文件还是共享对象文件等
uint16_t e_machine; // 目标机器类型,指明了文件是为哪种处理器设计的
uint32_t e_version; // ELF版本信息
uint32_t e_entry; // 程序入口的虚拟地址,如果没有入口点则为0
uint32_t e_phoff; // 程序头表的文件偏移量,如果没有程序头表则为0
uint32_t e_shoff; // 节头表的文件偏移量,如果没有节头表则为0
uint32_t e_flags; // 与处理器相关的标志
uint16_t e_ehsize; // ELF头的大小,字节为单位
uint16_t e_phentsize;// 程序头表中每个条目的大小,字节为单位
uint16_t e_phnum; // 程序头表的条目数
uint16_t e_shentsize;// 节头表中每个条目的大小,字节为单位
uint16_t e_shnum; // 节头表的条目数
uint16_t e_shstrndx; // 节头字符串表在节头表中的索引,用于定位每个节的名称
};
下面是展示了一个简化的 ELF 文件在内存中的布局。
+------------------------+
| ELF Header |
+------------------------+
| Program Header |
| Table (PH) |
+------------------------+
| Section |
| Header |
| Table (SH) |
+------------------------+
| .text Section |
| (Code) |
+------------------------+
| .data Section |
| (Initialized Data)|
+------------------------+
| .bss Section |
| (Uninitialized Data)|
+------------------------+
| Other Sections |
| ... |
+------------------------+
以下是各个部分的简要解释:
-
ELF Header: 包含了 ELF 文件的基本信息,如文件类型、目标机器等。
-
Program Header Table (PH): 包含了可执行文件在内存中的加载信息,每个条目描述了一个段的大小、在文件中的偏移以及在内存中的位置等。
-
Section Header Table (SH): 包含了各个节的信息,每个条目描述了一个节的大小、在文件中的偏移、在内存中的位置等。
-
Sections: 包含了实际的代码和数据,如:
- .text Section: 存储可执行代码和指令。
- .data Section: 存储已初始化的数据。
- .bss Section: 存储未初始化的数据,占用的空间在运行时被初始化为零。
- Other Sections: 其他可能存在的节,包括符号表、字符串表等。
这个布局展示了 ELF 文件在加载到内存中时的整体结构,每个部分的内容在执行过程中会根据程序的需要被加载和执行。
引导加载程序如何确定它必须读取多少个扇区才能从磁盘中获取整个内核?它在哪里可以找到这些信息?
在 JOS 中,引导加载器(boot loader)通过读取 ELF(Executable and Linkable Format)格式的内核映像文件头部来确定需要读取多少个扇区以获取整个内核。ELF 文件头部包含了一些元数据,其中就包括了内核的大小。
这个过程在 JOS 的源代码中的 boot/main.c
文件中有具体的实现。
内核的第一条指令在哪里?
在 JOS 操作系统中,内核的第一条指令位于内核映像的入口点。这个入口点是在链接内核时由链接器确定的,通常在链接脚本中指定。链接脚本定义了各个段(如代码段、数据段等)在内核映像中的布局和顺序。
在 JOS 的链接脚本(通常是一个名为 kernel.ld
的文件)中,有一行类似于 ENTRY(start)
的代码,这里的 start
就是内核的入口点,也就是内核的第一条指令的位置。
当引导加载器加载内核到内存并跳转到内核入口点时,就会开始执行内核的第一条指令。这个过程在 JOS 的引导加载器的源代码中有具体的实现。
执行的引导加载程序的最后一条指令是什么,它刚刚加载的内核的第一条指令是什么?
在 JOS 操作系统中,引导加载器(boot loader)的最后一条指令通常是一个跳转指令,用于跳转到内核的入口点开始执行。这个跳转指令通常是 jmp
或者 call
,具体取决于引导加载器的实现。
在 JOS 的引导加载器的源代码中,你可能会看到类似于以下的代码:
jmp *%eax
这条指令的意思是跳转到 %eax
寄存器中的地址开始执行。在这个上下文中,%eax
寄存器中存储的就是内核的入口点地址。
至于内核的第一条指令,它位于内核映像的入口点。这个入口点是在链接内核时由链接器确定的,通常在链接脚本中指定。在 JOS 的链接脚本(通常是一个名为 kernel.ld
的文件)中,有一行类似于 ENTRY(start)
的代码,这里的 start
就是内核的入口点,也就是内核的第一条指令的位置。
当引导加载器加载内核到内存并跳转到内核入口点时,就会开始执行内核的第一条指令。这个过程在 JOS 的引导加载器的源代码中有具体的实现。
bootmain
下面是 Boot Loader 中的 C 语言代码,这段代码从磁盘读取 ELF 格式的可执行文件,并将其加载到内存中,然后跳转到程序的入口点开始执行。如果在读取或加载过程中发现文件不是有效的 ELF 文件,或者其他任何错误,它将通过向特定的 I/O 端口发送特定的数据来表示错误,然后进入一个无限循环,停止执行。
void
bootmain(void)
{
struct Proghdr *ph, *eph;
// 从磁盘读取第一页
// ELFHDR是指向ELF头的指针,SECTSIZE*8定义了要读取的扇区数量
// 从磁盘的0位置开始,读取SECTSIZE*8个扇区的数据到ELFHDR指向的内存位置
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
// 检查这是否是有效的ELF文件
// ELF_MAGIC是ELF文件的魔数,用于验证文件格式
// 如果ELFHDR指向的内存位置的e_magic字段不等于ELF_MAGIC,说明这不是一个有效的ELF文件
if (ELFHDR->e_magic != ELF_MAGIC) {
goto bad; // 如果不是有效的ELF文件,则跳转到错误处理
}
// 加载每个程序段(忽略ph标志)
// ph是指向程序头的指针,e_phoff是程序头表的偏移量
// 通过ELFHDR和e_phoff计算得到程序头表的起始位置
ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
// eph是指向程序头表末尾的指针,通过程序头数量e_phnum计算得到
eph = ph + ELFHDR->e_phnum;
for (; ph < eph; ph++) {
// p_pa 是该段的加载地址(物理地址)
// p_memsz 是段在内存中的大小
// p_offset 是段在文件中的偏移量
// 从磁盘读取数据并加载到指定地址
// 从磁盘的p_offset位置开始,读取p_memsz个字节的数据到p_pa指向的内存位置
readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
}
// 调用ELF头中的入口点
// e_entry是程序入口点的地址
// 注意:该函数不会返回!
// 将控制权转交给ELF文件指定的入口点,开始执行程序
((void (*)(void)) (ELFHDR->e_entry))();
bad:
// 错误处理
// 发送两个字(word)的数据到I/O端口0x8A00
// 通过outw函数,向0x8A00端口写入0x8A00,表示出现错误
outw(0x8A00, 0x8A00);
// 再次发送两个字的数据到I/O端口0x8A00
// 通过outw函数,向0x8A00端口写入0x8E00,表示出现错误
outw(0x8A00, 0x8E00);
// 进入无限循环,什么也不做,用于处理错误情况
// 如果出现错误,程序将停在这里,不再继续执行
while (1)
/* do nothing */;
}
这段代码是在引导加载程序中执行的,其目的是加载 ELF(Executable and Linkable Format)格式的内核映像的头部信息。ELF 头部包含了一些重要的元数据,如程序入口点、程序头表的位置和大小等,这些信息对于后续的内核加载和执行至关重要。
在这个过程中,readseg
函数被用来执行实际的磁盘读取操作。它的参数分别是目标内存地址、要读取的字节数以及磁盘上的偏移量。
readseg
// 从内核的'offset'位置开始,读取'count'字节数据到物理地址'pa'。
// 可能会读取比请求的更多数据
void readseg(uint32_t pa, uint32_t count, uint32_t offset) {
uint32_t end_pa;
end_pa = pa + count; // 计算结束地址
// 将物理地址向下舍入到扇区边界
// SECTSIZE是扇区大小,这里通过位运算清除低位,实现向下舍入
pa &= ~(SECTSIZE - 1);
// 将字节偏移量转换为扇区偏移量,内核从第1个扇区开始
// 这里假设offset是以字节为单位的,先除以SECTSIZE得到扇区数,再加1得到实际的扇区偏移
offset = (offset / SECTSIZE) + 1;
// 如果这种一次读取一个扇区的方式太慢,我们可以一次读取多个扇区。
// 这样做会向内存写入比请求的更多的数据,但这并不重要——
// 因为我们是按顺序加载的。
while (pa < end_pa) {
// 由于我们还没有启用分页,并且正在使用恒等段映射(见boot.S),
// 我们可以直接使用物理地址。一旦JOS启用了MMU(内存管理单元),情况就不再是这样。
readsect((uint8_t*) pa, offset);
pa += SECTSIZE; // 移动到下一个扇区
offset++; // 扇区偏移量增加
}
}
这段代码是一个名为 readseg
的函数,它从磁盘的特定位置(由 offset
参数指定)读取一定数量的数据(由 count
参数指定),并将这些数据加载到物理内存的特定位置(由 pa
参数指定)。
函数首先计算出数据读取的结束地址 end_pa
,这是通过将开始地址 pa
加上要读取的字节数 count
来得到的。
然后,函数将开始地址 pa
向下舍入到最近的扇区边界。这是通过将 pa
与扇区大小 SECTSIZE
减 1 的按位取反结果进行按位与操作来实现的。这样做的目的是确保我们总是从一个完整的扇区开始读取数据。
接着,函数将字节偏移量 offset
转换为扇区偏移量。这是通过将 offset
除以扇区大小 SECTSIZE
,然后加 1 来实现的。这样做的目的是将字节偏移量转换为扇区偏移量,因为磁盘读取操作通常是以扇区为单位进行的。
然后,函数进入一个循环,从开始地址 pa
读取数据,直到达到结束地址 end_pa
。在每次循环中,函数都会调用 readsect
函数从磁盘的 offset
扇区读取一个扇区的数据,并将这些数据加载到地址 pa
指向的内存位置。然后,函数将 pa
和 offset
都增加 SECTSIZE
,以便在下一次循环中读取下一个扇区的数据。
readsect
readseg 中调用了 readsect ,下面是该函数的函数签名,它从硬盘的指定扇区读取数据。函数接受两个参数:一个指向目标缓冲区的指针dst
,以及一个表示扇区偏移量的 32 位整数offset
。
// 从磁盘读取一个扇区的数据到指定内存地址
// 参数:
// - dst: 目标内存地址,用于存储读取的数据
// - sec: 扇区号,指定要读取的扇区
void readsect(void*, uint32_t);
下面是 readsect 的具体的实现细节,函数首先调用waitdisk
函数等待磁盘准备好。然后,通过向 I/O 端口写入数据,设置要读取的扇区和数量。这里,端口0x1F2
用于设置读取扇区的数量,端口0x1F3
到0x1F6
用于设置扇区偏移,端口0x1F7
用于发送读取扇区的命令。
设置完毕后,函数再次调用waitdisk
等待磁盘准备好。最后,函数使用insl
指令从端口0x1F0
读取一个扇区的数据到dst
指向的地址。这里,SECTSIZE/4
是因为insl
一次读取 4 个字节,所以要读取SECTSIZE/4
次。
void readsect(void *dst, uint32_t offset) {
// 等待磁盘准备好
waitdisk();
// 设置要读取的扇区
outb(0x1F2, 1); // 设置读取扇区的数量为1
outb(0x1F3, offset); // 设置扇区偏移的低8位
outb(0x1F4, offset >> 8); // 设置扇区偏移的第9到16位
outb(0x1F5, offset >> 16); // 设置扇区偏移的第17到24位
outb(0x1F6, (offset >> 24) | 0xE0); // 设置扇区偏移的高8位,并设置驱动器号和其他必要的位
outb(0x1F7, 0x20); // 发送读取扇区的命令(0x20)
// 再次等待磁盘准备好
waitdisk();
// 从端口0x1F0读取一个扇区的数据到dst指向的地址
// SECTSIZE/4是因为insl一次读取4个字节,所以要读取SECTSIZE/4次
insl(0x1F0, dst, SECTSIZE/4);
}
waitdisk
下面这段代码定义了一个名为waitdisk
的函数,它的作用是等待磁盘准备好。函数中使用了一个 while 循环,循环的条件是通过inb
函数读取磁盘控制器的状态(端口 0x1F7),并检查状态寄存器的第 6 位(0x40)是否被设置,同时第 7 位(0x80)是否被清除。只有当这两个条件都满足时,循环才会结束,表示磁盘已经准备好。
void waitdisk(void) {
// 等待磁盘准备好
// 使用inb函数读取磁盘控制器的状态(端口0x1F7),并等待磁盘准备好。
// 磁盘准备好的标志是状态寄存器的第6位(0x40)被设置,同时第7位(0x80)被清除。
while ((inb(0x1F7) & 0xC0) != 0x40)
/* 什么也不做 */;
}
inb
函数是一个输入函数,用于从指定的 I/O 端口读取一个字节的数据。在这里,它读取的是磁盘控制器的状态。
这段代码中的0x1F7
是磁盘控制器状态寄存器的端口号,0xC0
是一个掩码,用于检查状态寄存器的第 6 位和第 7 位,0x40
表示磁盘已经准备好。
如果磁盘还没有准备好,那么这个 while 循环会一直执行,直到磁盘准备好为止。在循环体中没有任何操作,这是一种常见的忙等待(busy waiting)技术,也就是说,如果磁盘没有准备好,CPU 会一直空转,直到磁盘准备好为止。
加载每个程序段
接下来讲解 bootmain 中将 ELF 文件中加载每个程序段到内存中的细节。ELF 文件的程序头表包含了每个程序段的信息,如在文件中的偏移量、在内存中的位置和大小等。
// 加载每个程序段(忽略ph标志)
// ph是指向程序头的指针,e_phoff是程序头表的偏移量
// 通过ELFHDR和e_phoff计算得到程序头表的起始位置
ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
// eph是指向程序头表末尾的指针,通过程序头数量e_phnum计算得到
eph = ph + ELFHDR->e_phnum;
for (; ph < eph; ph++) {
// p_pa 是该段的加载地址(物理地址)
// p_memsz 是段在内存中的大小
// p_offset 是段在文件中的偏移量
// 从磁盘读取数据并加载到指定地址
// 从磁盘的p_offset位置开始,读取p_memsz个字节的数据到p_pa指向的内存位置
readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
}
首先,通过ELFHDR->e_phoff
获取程序头表在 ELF 文件中的偏移量,然后将其与ELFHDR
相加,得到程序头表在内存中的位置,将其赋值给ph
。
然后,通过ELFHDR->e_phnum
获取程序头表中的条目数量,将其与ph
相加,得到程序头表在内存中的结束位置,将其赋值给eph
。
接下来,使用一个 for 循环遍历程序头表中的每个条目。对于每个条目,它从磁盘的ph->p_offset
位置开始,读取ph->p_memsz
个字节的数据,然后将这些数据加载到ph->p_pa
指向的内存位置。这个过程是通过调用readseg
函数完成的。
这样,每个程序段都被正确地加载到了内存中的指定位置。
Boot Loader 结束
下面的代码将控制权最终交给了内核,随后就是执行的内核代码了。
// 调用ELF头中的入口点
// e_entry是程序入口点的地址
// 注意:该函数不会返回!
// 将控制权转交给ELF文件指定的入口点,开始执行程序
((void (*)(void)) (ELFHDR->e_entry))();
这段代码是在调用 ELF 文件的入口点,也就是程序的起始地址。这个地址是在 ELF 文件的头部中指定的,通常是程序的主函数或者其他类似的起始点。
这里的代码((void (*)(void)) (ELFHDR->e_entry))();
做了以下几件事情:
-
ELFHDR->e_entry
获取了 ELF 头部中的e_entry
字段,这个字段包含了程序入口点的地址。 -
(void (*)(void))
是一个函数指针的类型转换,它将e_entry
字段的值转换为一个没有参数也没有返回值的函数指针。 -
()
是函数调用操作符,它调用了转换后的函数指针,也就是说,它跳转到了程序的入口点并开始执行。
这段代码之所以会将控制权交给内核,是因为在这个过程中,CPU 的指令指针被设置为了内核的入口点,从而开始执行内核的代码。这个过程通常发生在系统引导的过程中,当引导加载程序(bootloader)加载并启动内核时。
总结
至此,Boot Loader 的任务完成了,接下来控制权交给内核,后续会讲解跳转到内核后的内容。
接下来讲解 PC 内存地址空间的演化历程,随后引入如何将 OS 代码从硬盘加载到内存中。
最初的 PC:1MB 内存
- 第一代 PC:
- 处理器: 早期 PC 基于 16 位的 Intel 8088 处理器。
- 内存限制: 这些计算机最多只能寻址 1MB 的物理内存。
- 地址空间布局:
0x00000000 - 0x0009FFFF
(640KB): 用于常规随机存取内存 (RAM)。0x000A0000 - 0x000FFFFF
(384KB): 保留区域,用于视频显示缓冲区和固件。
这是计算机系统中物理内存布局的一个简化图示,特别是针对于传统的 x86 架构。以下是该图示的中文注释和解释:
+------------------+ <- 0x00100000 (1MB)
| BIOS ROM |
+------------------+ <- 0x000F0000 (960KB)
| 16-bit devices, |
| expansion ROMs |
+------------------+ <- 0x000C0000 (768KB)
| VGA Display |
+------------------+ <- 0x000A0000 (640KB)
| |
| Low Memory |
| |
+------------------+ <- 0x00000000
-
低内存区域 (
0x00000000
-0x000A0000
,640KB): 这部分内存是最初的个人电脑兼容机中可用的主内存区域。它通常被操作系统和应用程序用于存储数据和代码。 -
VGA 显示内存 (
0x000A0000
-0x000C0000
,128KB): 这块区域通常被用于视频图形阵列(VGA)的显示数据。它是用来存放图形数据的内存区域,使得视频卡可以访问并显示图像。 -
16 位设备,扩展 ROM (
0x000C0000
-0x000F0000
,192KB): 这部分内存用于存放各种 16 位设备的固件和扩展只读存储器(ROM)内容。这些设备可能包括早期的网络卡、视频卡等。 -
BIOS ROM (
0x000F0000
-0x00100000
,64KB): 这是系统的基本输入输出系统(BIOS)存放的位置。BIOS 是在计算机启动时运行的第一个软件,它负责初始化和测试系统硬件,并从启动设备加载操作系统。
这个内存布局图展示了传统 x86 架构下的典型内存分布。由于历史原因和兼容性的需求,这种布局在很多现代计算机系统中仍然保持不变。了解这种布局对于操作系统的开发者和底层软件的开发者来说是非常重要的。
扩展内存与兼容性问题
-
突破 1MB 限制:
- 处理器升级: 随着 80286 和 80386 处理器的出现,内存寻址能力分别提升到了 16MB 和 4GB。
- 兼容性维护: 尽管内存寻址能力增强,为了保持与旧软件的兼容性,PC 架构师们保留了最低 1MB 的物理地址空间布局。
-
内存分割:
- "低内存"与"扩展内存": 这导致物理内存从
0x000A0000
到0x00100000
形成了一个"空洞",将 RAM 分为"低内存"(前 640KB)和"扩展内存"(其他部分)。
- "低内存"与"扩展内存": 这导致物理内存从
这个空洞的存在反映了 PC 计算机早期设计的遗留问题。在最初的 PC 设计中,只有 640KB 的内存是留给操作系统和应用程序的(称为基本内存),而更高地址的内存被用于其他目的(如视频显示和 BIOS)。随着时间的推移,尽管计算机的内存容量大大增加,这些约定为了保持与旧软件和硬件的兼容性而得以保留。
在现代计算机系统中,这个空洞区域通常不会被操作系统用于常规内存存储,因为它被特殊用途和兼容性需求占据了。不过,随着新技术和标准的发展,这个空洞的实际影响已经大大减少。
+------------------+ <- 0xFFFFFFFF (4GB)
| 32-bit |
| memory mapped |
| devices |
| |
/\/\/\/\/\/\/\/\/\/\
/\/\/\/\/\/\/\/\/\/\
| |
| Unused |
| |
+------------------+ <- depends on amount of RAM
| |
| |
| Extended Memory |
| |
| |
+------------------+ <- 0x00100000 (1MB)
| BIOS ROM |
+------------------+ <- 0x000F0000 (960KB)
| 16-bit devices, |
| expansion ROMs |
+------------------+ <- 0x000C0000 (768KB)
| VGA Display |
+------------------+ <- 0x000A0000 (640KB)
| |
| Low Memory |
| |
+------------------+ <- 0x00000000
这是对 32 位系统中的典型物理内存布局的描述。在 32 位系统中,物理地址空间被限制为 4GB。以下是对这些部分的作用的总结:
-
32-bit Memory Mapped Devices (
0xFFFFFFFF
- 约depends on amount of RAM
):- 这部分内存区域通常被用于内存映射的 I/O 设备。
- 在 32 位系统中,硬件设备(如图形卡、网络适配器等)通过内存映射 I/O(MMIO)与操作系统进行交互。
- 这些设备占据的地址通常位于物理内存的顶部(接近 4GB),这意味着这部分内存不能用于常规的 RAM 存储。
- 由于这部分内存被用于设备映射,所以实际可用于操作系统和应用程序的物理内存可能小于 4GB。
-
Unused Area (约
depends on amount of RAM
- ?):- 这个区域通常是未被使用的,其具体大小取决于系统中安装的 RAM 数量和物理内存的映射方式。
- 在某些系统中,当物理 RAM 少于 4GB 时,这个区域可能会很大。
-
Extended Memory (低于
depends on amount of RAM
):- 扩展内存指的是超过 1MB 之上的物理内存区域。
- 在早期的计算机系统中,最初的 640KB 被称为基本内存,而超过这个范围的内存称为扩展内存。
- 在现代计算机系统中,扩展内存通常指的是可用于操作系统和应用程序的主要内存区域。
这种内存布局体现了 32 位系统的物理地址空间限制和硬件设计的影响。随着 32 位架构逐渐被 64 位架构所取代,这种布局和限制在新的系统中不再适用。在 64 位系统中,物理地址空间被显著扩大,允许直接寻址更多的内存和设备。
现代 PC 和 32 位物理地址空间
- 32 位地址空间:
- 地址空间拓展: 现代 PC 通常有 32 位物理地址空间,支持高达 4GB 的内存。
- 顶部预留空间: 在 32 位物理地址空间的顶部,高于所有物理 RAM,通常由 BIOS 保留用于 32 位 PCI 设备。
超越 4GB 的内存和新挑战
-
超过 4GB 的物理 RAM:
- 处理器升级: 最新的 x86 处理器支持超过 4GB 的物理 RAM。
- 第二个"空洞": 在 32 位可寻址区域的顶部,BIOS 必须留出第二个"空洞",为这些 32 位设备映射留出空间。
-
地址空间复杂性:
- OS 开发挑战: 处理复杂的物理地址空间和其他多年演化的硬件组织问题,成为操作系统开发的重要实际挑战之一。
这一系列演化展示了计算机硬件和软件如何相互影响,以及为了兼容性和性能所做的折衷。每一次技术突破和扩展都带来了新的设计挑战和解决方案。
操作系统的实模式和保护模式是处理器操作模式的概念,与操作系统的发展密切相关。它们代表了计算机硬件和软件发展的不同阶段,并解决了特定历史时期的技术问题。
最初的处理器(如 Intel 8080)只有 16 根地址线,因此只能寻址高达 64KB 的内存。随后,Intel 8086 和 8088 处理器引入了实模式,并设计为具有 20 根地址线,从而提供了 1MB 的寻址能力。随着计算机技术的发展,这种寻址能力变得不够用。因此,Intel 在 80286 和更高级的处理器中引入了保护模式,它在 80386 中由于 32 根地址线而支持高达 4GB 的内存寻址。为了保持向后兼容性,新处理器在启动时进入实模式,作为操作系统启动过程的一部分。
实模式(Real Mode)
实模式(Real Mode)是 Intel x86 架构处理器的一种运行模式。它是最早的 CPU 运行模式,也是计算机开机后处理器的默认模式。
在实模式(Real Mode)出现之前,计算机系统使用的是更为基础的操作方式,这些方式通常与特定的硬件体系结构紧密相关。实模式的出现和普及与个人电脑(PC)和 Intel x86 架构的发展密切相关。
实模式出现之前
在实模式出现之前,早期的处理器(如 Intel 8080 和 Zilog Z80)直接操作物理地址空间,它们通常能够直接寻址较小的内存空间(如 64KB)。因为处理器的寻址能力直接受限于其地址总线的宽度。例如,8080 和 Z80 都有 16 位的地址总线,这意味着它们可以生成 2^16(即 65536)个不同的地址,从而直接寻址 64KB 的内存空间。增加地址线数量会显著增加处理器的复杂性和成本。
此外那时还没有复杂的内存管理机制如分段或分页。程序直接与物理内存交互,没有虚拟内存的概念。以及由于技术限制,早期的计算机系统通常内存较小,硬件资源有限,因此它们的操作系统和应用程序比现代系统更简单。
实模式解决的问题:
-
扩展内存寻址能力:
- 实模式为 Intel 8086 和 8088 处理器提供了 1MB 的物理地址空间,这是相对于之前的 64KB 的显著提升。
- 实模式使用段基址(Segment)和偏移地址(Offset)的组合来形成物理地址。具体的地址计算公式为:物理地址 = 段基址 * 16 + 偏移地址。比如,若段基址为
0x1000
,偏移地址为0x0050
,则物理地址为0x10000 + 0x0050 = 0x10050
。
-
向后兼容性:
- Intel 8086/8088 处理器在实模式下能够兼容运行旧的 8080 程序,有助于平滑技术过渡。
实模式是个人电脑发展早期的一个重要里程碑,它为当时的计算需求提供了充分的支持,并为后续保护模式和现代操作系统的发展奠定了基础。随着计算机技术的发展,尤其是内存容量的增加和多任务需求的出现,后续的保护模式成为了必然的技术发展方向。
段基址和偏移地址的设计
-
解决寻址限制:
- 通过将内存分成多个段(每个段最大 64KB),并在这些段内使用偏移量,8086 能够访问超过 64KB 的内存空间。
-
物理地址计算:
- 物理地址是通过将段基址乘以 16(或向左移位 4 位)然后加上偏移量来计算。这种方法允许处理器利用 16 位寄存器在 1MB 的内存范围内有效地寻址。
-
兼容性和扩展性:
- 这种设计使得新的 16 位处理器能够运行为旧 8 位处理器编写的软件,并且提供了一种相对简单的方式来扩展寻址能力。
实模式存在哪些问题
-
内存寻址限制:
- 实模式下,处理器只能访问最多 1MB 的内存。这是因为实模式下地址线被限制为 20 位。
-
无保护模式的特性:
- 实模式不提供现代操作系统所需的特性,如虚拟内存、内存保护、多任务等。
- 所有程序都有完全的硬件访问权限,可以直接操作内存和硬件设备。这使得系统容易受到恶意软件的影响。
- 这种设计在当时是一个创新的解决方案,有效地扩展了处理器的寻址能力,同时保持了向后兼容性。
- 然而,这也导致了内存寻址变得复杂,且内存管理效率不高。特别是,由于段的重叠和内存碎片问题,开发人员和编译器需要更小心地管理内存。
- 随着技术的发展,特别是处理器和操作系统的进步,这种寻址方式逐渐被更高效的模式(如保护模式和长模式)所取代。
总之,实模式下的段基址和偏移地址组合是一种针对当时技术限制的解决方案,它在扩展寻址空间的同时保持了向后兼容性,但也带来了一定的复杂性和局限性。
保护模式(Protected Mode)
保护模式(Protected Mode)是 Intel x86 架构处理器的一种运行模式,它提供了对内存、执行特权等的硬件级别保护。保护模式首次出现在 1982 年推出的 Intel 80286 处理器中,并在随后的 x86 系列处理器中得到了扩展和完善。保护模式的引入标志着从简单、有限的实模式(Real Mode)向更复杂、功能丰富的操作模式的转变。
发展脉络与出现原因:
- 保护模式首次在 1982 年的 Intel 80286 处理器中引入,并在 1985 年的 Intel 80386 处理器中得到完全实现。
- 随着计算机应用的复杂性增加和多任务需求的出现,实模式的内存限制和缺乏保护机制成为了瓶颈。
- 保护模式提供了更高的内存寻址能力(最初是 16MB,80386 扩展到 4GB),以及内存保护、多任务支持等特性。
解决的问题:
保护模式(Protected Mode)是为了解决实模式(Real Mode)存在的问题和局限性而引入的。实模式的主要局限性包括有限的内存寻址能力(只有 1MB)、缺乏有效的内存保护机制、以及无法高效地支持多任务处理等。保护模式通过以下几种方式解决了这些问题:
1. 扩展内存寻址能力
- 更高的内存限制:保护模式扩展了内存寻址能力,初期如在 80286 中能够寻址高达 16MB 的内存,而在 80386 及以后的处理器中能夠寻址高达 4GB 的内存。
- 分段和分页:保护模式引入了更加复杂的分段和分页内存管理机制。分段机制允许定义不同的内存段,每个段有自己的基址、大小和访问权限。分页机制允许将物理内存分割成固定大小的页面,实现虚拟内存。
2. 引入内存保护
- 防止非法内存访问:在保护模式下,每个内存段都有相应的访问权限,操作系统可以限制程序只能访问授权的内存区域,防止了程序间的相互干扰和非法内存访问。
- 防止操作系统崩溃:保护模式增强了操作系统的稳定性,因为用户程序无法访问操作系统内核和其他程序的内存区域。
3. 支持多任务处理
- 任务切换:保护模式支持硬件级别的任务切换机制,使得操作系统能够更有效地管理和切换多个同时运行的程序。
- 提高系统效率:多任务处理的支持使得计算机系统能够同时执行多个应用程序,提高了计算机系统的使用效率和响应速度。
4. 实现用户和内核空间的分离
- 特权级别:保护模式引入了四个特权级(Ring 0 到 Ring 3),操作系统内核通常运行在最高特权级(Ring 0),而用户程序运行在较低的特权级别,从而实现了用户空间和内核空间的有效隔离。
总的来说,保护模式通过提供更高级的内存管理、内存保护、多任务支持以及特权级别机制,解决了实模式存在的许多限制和问题。这些改进为现代操作系统的发展提供了必要的硬件支持,使得计算机系统能够运行更加复杂、功能丰富的操作系统和应用程序。
总结
实模式和保护模式的发展反映了计算机硬件和操作系统从简单到复杂的演变过程。实模式适用于早期简单的计算机系统,而保护模式则满足了现代计算机系统对高效率、多任务处理和系统稳定性的需求。随着技术的发展,现代操作系统(如 Windows、Linux 等)几乎都在保护模式下运行,以充分利用现代处理器提供的高级功能和更大的内存空间。
x86 架构中的分段(Segmentation)和分页(Paging)是内存管理的两个重要特性。这些特性共同支持虚拟内存,允许操作系统有效地管理内存资源,并为每个进程提供独立的地址空间。让我们通过具体的例子来中文讲解这两个特性。
1. 分段(Segmentation)
在 x86 架构的早期版本中,分段是内存管理的主要方式。它通过使用段寄存器和偏移量来定位内存地址。分段将内存划分为逻辑上的“段”(segment)。这些段可以是程序的不同部分,如代码段、数据段或堆栈段。为了更好地理解分段,让我们通过一个文本图形化的方式来描绘它。
假设的内存布局
假设我们有一个简化的内存模型,如下所示:
+------------------------+
| 0x0000 |
| |
| [操作系统] |
| |
| 0x1000 |
|------------------------|
| [代码段] |
| |
| 0x2000 |
|------------------------|
| [数据段] |
| |
| 0x3000 |
|------------------------|
| [堆栈段] |
| |
| 0xFFFF |
+------------------------+
在这个模型中,我们的内存被分为几个段。每个段都有一个起始地址和一个结束地址。
分段寻址
在分段系统中,地址由两部分组成:段选择器(Segment Selector)和偏移量(Offset)。例如,如果要访问代码段中的特定位置,我们可能会使用类似于下面的地址格式:
+-----------------+-----------------+
| 段选择器(如代码段) | 偏移量(如0x0050) |
+-----------------+-----------------+
内存访问示例
假设一个程序想要访问代码段中偏移量为0x0050
的位置,其地址可能表示如下:
+-----------------+-----------------+
| 0x1000 | 0x0050 |
+-----------------+-----------------+
这表示程序想要访问从代码段起始地址0x1000
开始,向后偏移0x0050
字节的位置。
段基址和物理地址
实际上,操作系统会将段选择器映射到一个段基址(Base Address)。在我们的例子中,代码段的基址是0x1000
。因此,实际访问的物理地址是基址加上偏移量:
物理地址 = 段基址 + 偏移量
= 0x1000 + 0x0050
= 0x1050
示例
假设有如下指令:
mov ax, [ds:0x1234]
在这个例子中,ds
(数据段寄存器)和偏移量0x1234
结合起来确定数据的物理地址。物理地址的计算方式为:
物理地址 = 段基址 * 16 + 偏移量
如果ds
的值为0x1000
,那么物理地址为0x10000 + 0x1234 = 0x11234
。
结论
分段提供了一种方式来组织和保护内存,它允许操作系统和程序更容易地管理和访问内存。每个段都可以有不同的属性和访问权限,例如只读或可执行,这有助于提高程序的安全性和稳定性。
2. 分页(Paging)
分页是现代操作系统中使用的内存管理技术,它允许操作系统将物理内存划分为固定大小的页,并将这些页映射到虚拟地址空间。
分页的内存模型
假设我们有一个物理内存和一个虚拟内存空间,它们被划分成等大的页。
物理内存: 虚拟内存:
+----------+ +----------+
| 页帧 0 | <--映射---> | 页面 0 |
+----------+ +----------+
| 页帧 1 | <--映射---> | 页面 1 |
+----------+ +----------+
| 页帧 2 | | 页面 2 |
+----------+ +----------+
| ... | | ... |
+----------+ +----------+
| 页帧 N | <--映射---> | 页面 M |
+----------+ +----------+
在这个模型中,物理内存由多个页帧(Page Frame)组成,而虚拟内存由多个页面(Page)组成。每个页面可以映射到任意的页帧,也可以不映射到物理内存(未分配)。
分页寻址
在分页系统中,虚拟地址由两部分组成:页号(Page Number)和页内偏移(Offset)。例如,一个虚拟地址可能被分解为:
+-----------------+-----------------+
| 页号 | 页内偏移 |
+-----------------+-----------------+
内存访问示例
假设一个程序想要访问虚拟地址0x1234
,并且我们的页大小是0x1000
(4096 字节)。
虚拟地址 0x1234:
+-----------------+-----------------+
| 0x1 | 0x234 |
+-----------------+-----------------+
| 页号 | 页内偏移 |
这里,页号是0x1
,页内偏移是0x234
。
页表查找
操作系统会使用页表(Page Table)来查找页号对应的页帧。假设页号0x1
映射到页帧0x3
。
页表:
+-------+-------+
| 页号 | 页帧 |
+-------+-------+
| ... | ... |
| 1 | 3 |
| ... | ... |
+-------+-------+
计算物理地址
最后,物理地址由页帧号和页内偏移组成:
物理地址 = 页帧号 * 页大小 + 页内偏移
= 0x3 * 0x1000 + 0x234
= 0x3234
分页是现代操作系统用于内存管理的一种关键技术。它允许将虚拟地址空间映射到物理内存的不同部分,从而实现有效的内存隔离和优化。每个进程都有自己的虚拟地址空间,通过页表将虚拟地址转换为物理地址。这种机制有助于保护进程之间的内存不被相互干扰,并支持虚拟内存的技术,如交换(Swapping)和分页存储(Paged Memory)。
段页式
段页式内存管理是一种结合了分段(Segmentation)和分页(Paging)两种技术的内存管理方法。它的目的是为了更有效地利用内存并提高系统的安全性和灵活性。简而言之,就是先把内存分成几块大的“段”,然后再把每个“段”划分成许多小的“页”。
-
分段:首先,系统把程序的不同部分(如代码、数据、堆栈等)分成几个大块,这些大块就叫做“段”。每个段都有自己的地址范围和访问权限,比如代码段可能是只读的,数据段可以读写。分段主要是为了更好地组织程序和保护内存。
-
分页:其次,在每个段内部,系统又会把段划分成许多固定大小的小块,这些小块叫做“页”。分页的目的是为了让操作系统更有效地管理内存,比如可以把不常用的页暂时存到硬盘上,需要时再调回来(这就是所谓的虚拟内存技术)。
总的来说,段页式内存管理通过先分段再分页的方式,结合了分段和分页各自的优点,既保证了内存的安全和程序的组织结构,又提高了内存的利用率和管理效率。
结论
分段和分页是 x86 架构中内存管理的重要组成部分。分段主要用于早期的 x86 架构,用于定义不同类型的内存区域(如代码段、数据段等)。而分页是现代操作系统中普遍使用的技术,它支持虚拟内存,允许更灵活和安全的内存访问。通过分页,操作系统能够有效地隔离不同进程的地址空间,提供内存保护,并支持更高效的内存管理策略。
OS 内核篇
这部分内容讲解 OS 内核写入内存,初始化,建立虚拟内存的细节。
这部分开始讲解内核部分的代码,内核代码依旧由汇编和 C 语言两部分组成,分为两部分依旧是 C 语言无法实现一些高级特性,需要汇编来实现。
其中汇编代码是操作系统内核的启动代码,主要完成了多引导协议头部的定义,内核入口点的设置,页目录的建立,分页的启用,以及栈的初始化等操作。接下来逐段讲解内核中的汇编代码的实现细节,代码位于 kern/entry.S
中。
多引导协议头部
下面代码定义了多引导协议(Multiboot)的头部。多引导协议是一种规定,它允许引导加载器(bootloader)在不了解内核细节的情况下加载操作系统内核。
// 定义多引导协议的魔数、标志和校验和
#define MULTIBOOT_HEADER_MAGIC (0x1BADB002)
#define MULTIBOOT_HEADER_FLAGS (0)
#define CHECKSUM (-(MULTIBOOT_HEADER_MAGIC + MULTIBOOT_HEADER_FLAGS))
###################################################################
# entry point
###################################################################
.text
// 定义多引导协议头部
.align 4
.long MULTIBOOT_HEADER_MAGIC
.long MULTIBOOT_HEADER_FLAGS
.long CHECKSUM
-
MULTIBOOT_HEADER_MAGIC
是多引导协议的魔数(magic number),它是一个特定的值,用于标识遵循多引导协议的内核。引导加载器会查找这个魔数,以确定内核是否支持多引导协议。 -
MULTIBOOT_HEADER_FLAGS
是多引导协议的标志位,用于指定内核需要的特定功能。在这里,它被设置为 0,表示内核不需要任何特定的功能。 -
CHECKSUM
是校验和,它的值是魔数和标志位的负和。引导加载器会计算头部中所有字段的和,如果结果为 0,那么头部就是有效的。
在 .text
段中,使用 .align 4
指令将多引导协议头部对齐到 4 字节边界,然后使用 .long
指令定义了头部的三个字段:魔数、标志位和校验和。这样,当引导加载器加载内核时,就可以找到并识别这个头部,从而正确地加载和启动内核。
内核的入口点(entry)
下面这段代码主要是用于设置操作系统内核的启动入口点,并进行一些初始化操作。
// 内核链接地址到物理内存地址的转换
#define RELOC(x) ((x) - KERNBASE)
// 定义ELF入口点
.globl _start
_start = RELOC(entry)
.globl entry
entry:
movw $0x1234,0x472 # warm boot
首先,定义了一个宏RELOC(x)
,用于将内核链接地址转换为物理内存地址。这是因为在操作系统内核被加载到内存时,它通常被加载到一个高地址,此处是 0xF0000000
,即 KERNBASE
的定义为 #define KERNBASE 0xF0000000
这是内核的链接地址。但是在早期的引导阶段,CPU 还在实模式下运行,此时 CPU 还不能访问这么高的地址。因此,需要将内核的链接地址转换为物理地址,这就是RELOC(x)
宏的作用。
然后,定义了全局符号_start
,并将其设置为entry
的物理地址。_start
是 ELF(可执行与可链接格式)的入口点,当操作系统被加载并执行时,CPU 会跳转到这个地址开始执行。因为此时 CPU 还在实模式下,所以这里需要的是entry
的物理地址,而不是链接地址。
接下来,定义了全局符号entry
,并在其后面定义了一个标签entry:
,这是内核的入口点。当 CPU 跳转到这个地址后,就会开始执行后面的代码。
最后,执行了一条movw
指令,将0x1234
写入到地址0x472
。这是一个传统的技巧,用于触发所谓的"warm boot"。在 PC 架构中,地址0x472
是一个特殊的地址,BIOS 会在启动时检查这个地址,如果其值为0x1234
,那么 BIOS 会执行一个"warm boot",也就是重新启动计算机,但不会关闭电源。这通常用于在系统配置更改或软件问题发生时快速重启计算机。
设置了一个简单的页目录
在 JOS 中,内核被链接和运行在非常高的虚拟地址(例如 0xf0100000
)上。这样做的好处是,内核的虚拟地址空间和用户程序的虚拟地址空间不会重叠,这样就可以避免内核和用户程序之间的地址冲突。同时,由于内核的虚拟地址是固定的,所以内核可以在任何位置的物理内存中加载和运行,这就解决了位置依赖性的问题。
具体来说,JOS 使用处理器的内存管理硬件将虚拟地址 0xf0100000
映射到物理地址 0x00100000
。这样,虽然内核的虚拟地址是 0xf0100000
,但是它实际上是在物理地址 0x00100000
的位置上执行。
但此时操作系统还没有建立起完整的虚拟内存系统,也就是说,还没有建立起页表来将虚拟地址映射到物理地址。所以为了让内核能在高地址运行,我们需要建立一个简单的页表,将高地址映射到物理内存的实际位置。即需要手动维护一张页目录,这个页目录将虚拟地址 [KERNBASE, KERNBASE+4MB)
转换为物理地址 [0, 4MB)
这个简单的页表只能支持 4MB 的地址空间,但这足够我们在设置完整的页表之前使用。在后续的操作系统初始化过程中,我们会在mem_init
函数中建立完整的页表,支持更大的地址空间和更复杂的内存管理功能。
下面是设置页表对应的汇编代码
movl $(RELOC(entry_pgdir)), %eax
movl %eax, %cr3
这段汇编代码的主要目的是设置虚拟内存。它首先将entry_pgdir
的物理地址加载到eax
寄存器中,然后将eax
寄存器的值存入cr3
寄存器。
entry_pgdir
是在entrypgdir.c
文件中定义的页目录的物理地址。在 x86 架构中,cr3
寄存器用于存储当前活动的页目录的物理地址。当 CPU 需要转换虚拟地址到物理地址时,它会使用cr3
寄存器中的地址作为页目录的基址。
RELOC
上面已经提及了,用于将链接地址转换为物理地址。在这里,它将entry_pgdir
的链接地址转换为物理地址。
movl
是一个汇编指令,用于将一个值从源操作数移动到目标操作数。在这里,它首先将entry_pgdir
的物理地址移动到eax
寄存器,然后将eax
寄存器的值移动到cr3
寄存器。
总的来说,这段代码的作用是将页目录的物理地址加载到cr3
寄存器,从而设置虚拟内存。
entry_pgdir
接下来结合具体的代码讲解 entry_pgdir 是如何实现这段页表的映射。
pte_t entry_pgtable[NPTENTRIES];
__attribute__((__aligned__(PGSIZE)))
pde_t entry_pgdir[NPDENTRIES] = {
[0]
= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P,
[KERNBASE>>PDXSHIFT]
= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P + PTE_W
};
// 页表的第0个条目映射到物理页0,第1个条目映射到物理页1,依此类推。
__attribute__((__aligned__(PGSIZE)))
pte_t entry_pgtable[NPTENTRIES] = {
0x000000 | PTE_P | PTE_W,
0x001000 | PTE_P | PTE_W,
....
0x3ff000 | PTE_P | PTE_W,
};
首先,定义了一个页目录entry_pgdir
,它是一个数组,包含NPDENTRIES
个页目录项。每个页目录项都是一个指向页表的指针。这个页目录将虚拟地址[0, 4MB)
和[KERNBASE, KERNBASE+4MB)
映射到物理地址[0, 4MB)
。PTE_P
和PTE_W
是页表项的标志,分别表示页存在和可写。
__attribute__((__aligned__(PGSIZE)))
是一个 GCC 特性,用于确保entry_pgdir
的地址是PGSIZE
的倍数。这是因为在 x86 架构中,页目录和页表的地址必须是页大小(通常是 4KB)的倍数。
[0] = ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P
这行代码将虚拟地址[0, 4MB)
映射到物理地址[0, 4MB)
。entry_pgtable
是页表的地址,KERNBASE
是内核的起始虚拟地址,所以entry_pgtable - KERNBASE
就是页表的物理地址。PTE_P
是页表项的标志,表示页存在。
[KERNBASE>>PDXSHIFT] = ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P + PTE_W
这行代码将虚拟地址[KERNBASE, KERNBASE+4MB)
映射到物理地址[0, 4MB)
。KERNBASE>>PDXSHIFT
是页目录项的索引,PTE_W
是页表项的标志,表示页可写。
然后,定义了一个页表entry_pgtable
,它是一个数组,包含NPTENTRIES
个页表项。每个页表项都是一个物理页的地址。这个页表的每个条目都将一个虚拟页映射到一个物理页,例如,第 0 个条目将虚拟页 0 映射到物理页 0,第 1 个条目将虚拟页 1 映射到物理页 1,依此类推。
这段代码的目的是在操作系统的早期阶段建立一个简单的虚拟内存环境,使得内核可以在高地址运行。在后续的操作系统初始化过程中,我们会建立完整的页表,支持更大的地址空间和更复杂的内存管理功能。
启用分页
接下来先从代码层面讲解如何启用分页,随后讲解分页机制的理论。下面这段代码用于启用分页机制的。在 x86 架构中,分页机制是通过控制寄存器 CR0 来控制的。
movl %cr0, %eax
orl $(CR0_PE|CR0_PG|CR0_WP), %eax
movl %eax, %cr0
首先,movl %cr0, %eax
这行代码将 CR0 寄存器的值加载到 EAX 寄存器中。
然后,orl $(CR0_PE|CR0_PG|CR0_WP), %eax
这行代码将 EAX 寄存器的值与CR0_PE
、CR0_PG
和CR0_WP
这三个标志位进行或运算。这三个标志位的含义如下:
CR0_PE
:保护使能,当设置为 1 时,启用保护模式。CR0_PG
:分页使能,当设置为 1 时,启用分页机制。CR0_WP
:写保护,当设置为 1 时,禁止超级用户程序向用户只读页面写入。
最后,movl %eax, %cr0
这行代码将 EAX 寄存器的值存回 CR0 寄存器,从而启用分页机制。
总的来说,这段代码的作用是启用分页机制,保护模式和写保护。
启用分页机制是指在计算机系统中开启一种内存管理的方式,即分页(Paging)。分页是一种内存管理策略,它将计算机的虚拟内存划分为一系列固定大小的页,每一页都有一个独立的地址。当程序需要访问内存时,它会指定页号和页内偏移量,然后硬件会自动将这个虚拟地址转换为实际的物理内存地址。
在 x86 架构中,分页机制是通过控制寄存器 CR0 来控制的。CR0 寄存器中的某一位(PG 位)用于控制分页机制是否开启。当 PG 位被设置为 1 时,分页机制就被启用了。
启用分页机制后,操作系统可以更有效地管理内存,例如防止程序间的内存冲突,提高内存利用率,实现虚拟内存等。
当程序需要使用内存时,操作系统会为其分配一个或多个页框。这些页框可能在物理内存中并不连续,但对于程序来说,它们看起来是连续的,这就是虚拟内存的概念。
分页机制的主要优点是简化了内存管理。由于所有的页和页框都是同样大小的,所以操作系统可以用简单的数据结构(如数组)来跟踪哪些页框正在被使用,哪些页框是空闲的。此外,分页机制还可以提供内存保护,因为每个页都有自己的访问权限(如只读、可写等)。
然而,分页机制也有一些缺点。例如,如果程序需要使用的内存大小不是页大小的整数倍,那么就会有一部分页框被浪费,这被称为“内部碎片”。此外,由于页框在物理内存中可能不连续,所以可能会增加数据访问的延迟。
栈的初始化
启用分页后,下面的代码将程序的执行流程从低地址空间跳转到高地址空间。
mov $relocated, %eax
jmp *%eax
relocated:
// 清除帧指针寄存器(EBP)
movl $0x0,%ebp
// 设置栈指针
movl $(bootstacktop),%esp
// 跳转到C语言初始化函数
call i386_init
// 无限循环,实际上永远不应该到达这里
spin: jmp spin
.data
###################################################################
# boot stack
###################################################################
// 定义启动栈
.p2align PGSHIFT # force page alignment
.globl bootstack
bootstack:
.space KSTKSIZE
.globl bootstacktop
bootstacktop:
这段代码主要是在启用分页后,将程序的执行流程从低地址空间跳转到高地址空间,并进行一些初始化操作。
首先,mov $relocated, %eax
这行代码将 relocated
的地址值加载到寄存器 %eax
中。这里的 relocated
是一个标签,通常表示程序在高地址空间的入口点。
然后,jmp *%eax
这行代码执行一个间接跳转,跳转到 %eax
寄存器中存储的地址处执行。因为 %eax
中存储的是 relocated
的地址,所以这行代码的效果就是跳转到 relocated
处执行。
接下来,relocated:
是一个标签,表示跳转的目标地址。在这个地址上,程序进行了一些初始化操作:
-
movl $0x0,%ebp
这行代码将寄存器%ebp
(帧指针寄存器)清零。这是因为在新的地址空间中,我们不需要保留旧的帧指针。 -
movl $(bootstacktop),%esp
这行代码将bootstacktop
的值(一个地址)加载到寄存器%esp
(栈指针寄存器)中。这是设置新的栈顶地址。 -
call i386_init
这行代码调用i386_init
函数。这个函数通常用于进行一些硬件和操作系统的初始化操作。
最后,spin: jmp spin
是一个无限循环。这是因为 i386_init
函数在完成所有初始化操作后,应该直接跳转到操作系统的主循环,而不应该返回。如果程序执行到了这里,那么说明有错误发生。
在 .data
段中,定义了启动栈 bootstack
,并且设置了栈的大小为 KSTKSIZE
。bootstacktop
是栈顶的地址,它被用在上面的代码中,来设置新的栈顶地址。
总结
上面完成了多引导协议头部的定义,内核入口点的设置,页目录的建立,分页的启用,以及栈的初始化等操作。接下来会跳转到 i386_init
函数中,即 C 语言部分,下一章节会详细讲解。
进入内核后,上篇内容已经将汇编部分讲完了,接下来讲解 C 语言部分,即 i386_init,在这段代码中会初始化控制台和内存,随后进入一个无限循环,等待用户的命令。例如用户输入 help 会触发相应的函数,控制台输出相应的内容。
i386_init
下面是 i386_init 对应的代码,随后是对这段代码的详细解释:
void
i386_init(void)
{
extern char edata[], end[];
// 在执行其他任何操作之前,完成ELF加载过程。
// 清除程序的未初始化全局数据(BSS)段。
// 这确保了所有静态/全局变量都从零开始。
memset(edata, 0, end - edata);
// 初始化控制台。
// 在执行这个操作之前,不能调用cprintf函数!
cons_init();
mem_init();
// 进入内核监视器。
// 无限循环,等待监视器的命令。
while (1) {
monitor(NULL);
}
}
-
extern char edata[], end[];
:这两个变量是由链接器提供的,表示程序的数据段(包括初始化的全局变量)和 BSS 段(包括未初始化的全局变量)的结束地址。edata
指向数据段的结束地址,end
指向 BSS 段的结束地址。 -
memset(edata, 0, end - edata);
:这行代码将 BSS 段的所有字节设置为 0。这是因为 C 语言规定,未初始化的全局变量和静态变量应该被初始化为 0。这里的end - edata
计算的是 BSS 段的大小。
下面是 edata 和 end 这两个变量在链接器中的定义:
.bss : {
PROVIDE(edata = .);
*(.bss)
PROVIDE(end = .);
BYTE(0)
}
这段代码定义了一个名为 .bss
的段,这个段用于存储程序的未初始化的全局变量和静态变量。
PROVIDE(edata = .);
这行代码定义了一个符号 edata
,并将其设置为当前位置(.
表示当前位置)。在链接器脚本中,.
代表当前输出段的位置计数器,也就是当前已经输出到的地址。因此,edata
的值就是 .bss
段的开始地址。
*(.bss)
这行代码将所有输入文件中的 .bss
段合并到输出文件的 .bss
段中。*
是通配符,表示所有的文件,.bss
表示 .bss
段。
PROVIDE(end = .);
这行代码定义了一个符号 end
,并将其设置为当前位置。因为这行代码在 *(.bss)
之后,所以 end
的值就是 .bss
段的结束地址。
BYTE(0)
这行代码在 .bss
段的末尾添加了一个字节的空间,并将其初始化为 0。这是为了确保 .bss
段在内存中的实际大小至少为一个字节,即使在源代码中没有任何未初始化的全局变量或静态变量。
总的来说,这段代码的作用是设置 .bss
段的开始和结束地址,并将所有输入文件中的 .bss
段合并到一起。
-
cons_init();
:这个函数用于初始化控制台。在这个函数被调用之前,不能使用cprintf
函数,因为cprintf
函数依赖于控制台的初始化。 -
mem_init();
:这个函数用于初始化内存管理系统。在这个函数被调用之后,操作系统就可以正常地分配和释放内存了。下一章回详细讲解这部分实现细节。 -
while (1) { monitor(NULL); }
:这是一个无限循环,它会不断地调用monitor
函数。monitor
函数是一个简单的命令行界面,它会等待用户输入命令,然后执行相应的操作。这个无限循环保证了,即使monitor
函数返回,操作系统也不会退出,而是继续等待下一个命令。
内存布局
下面展现了一个进程在内存中的布局,结合内存布局讲解 BSS 段和数据段。
高地址 ---> .----------------------.
| Environment |
|----------------------|
| | 在栈上声明的函数和变量
| STACK | 栈上的空间被基指针(base pointer)指示。
base pointer -> | - - - - - - - - - - -|
| | |
| v |
: :
. . 栈向未使用的空间方向增长,而堆向上增长。
. 空 .
. .
. . (这里可能会发生其他内存映射,如动态库,不同的内存分配方式)
. .
: :
| ^ |
| | |
brk point -> | - - - - - - - - - - -| 堆上声明动态内存
| HEAP |
| |
|----------------------|
| BSS | 未初始化数据 (BSS)
|----------------------|
| Data | 初始化数据 (DS)
|----------------------|
| Text | 二进制代码
低地址 ----> '----------------------'
其中数据段和 BSS 段是程序内存布局的两个重要部分。
-
数据段:数据段主要用于存储程序的全局变量和静态变量。这些变量在程序编译时就已经确定了初始值。例如,如果你在程序中定义了一个全局变量
int g_var = 10;
,那么这个变量就会被存储在数据段中,其初始值为 10。 -
BSS 段:BSS 段用于存储未初始化的全局变量和静态变量。在 C 语言中,如果全局变量和静态变量在声明时没有显式初始化,那么它们会被自动初始化为 0。这些变量就会被存储在 BSS 段中。例如,如果你在程序中定义了一个全局变量
int g_var;
,那么这个变量就会被存储在 BSS 段中,其初始值为 0。
这两个段的主要区别在于,数据段中的变量有初始值,而 BSS 段中的变量没有初始值。在程序加载到内存时,操作系统会为数据段和 BSS 段分配内存空间,并将数据段的变量初始化为预设的值,将 BSS 段的变量初始化为 0。
堆栈的布局
在 x86 架构中,esp(堆栈指针寄存器)和 ebp(基指针寄存器)是两个非常重要的寄存器,它们在函数调用和堆栈操作中起着关键的作用。
-
esp(堆栈指针寄存器):esp 寄存器指向当前堆栈的顶部。当我们向堆栈中压入数据时,esp 的值会减小(因为在 x86 架构中,堆栈是向下增长的),当我们从堆栈中弹出数据时,esp 的值会增大。因此,esp 总是指向当前堆栈帧的顶部。
-
ebp(基指针寄存器):ebp 寄存器通常用作帧指针,指向当前堆栈帧的底部。在函数调用时,ebp 的值会被压入堆栈,然后 esp 的当前值会被复制到 ebp,这样 ebp 就指向了新的堆栈帧的底部。在函数返回时,ebp 的值会被恢复,指向上一个堆栈帧的底部。
这两个寄存器的使用使得我们可以在堆栈中有效地定位和访问数据。例如,我们可以通过 ebp 来访问函数的参数和局部变量(它们都存储在堆栈中),而 esp 则用于管理堆栈的增长和缩小。
下面表示在 x86 架构中函数调用时,堆栈的布局。
+---------------------+
| Function Call |
|---------------------|
| ... |
| Previous Frame | <- ebp points here (bottom of the current stack frame)
| Local Variables |
| ... |
| |
|---------------------|
| Return Address |
|---------------------|
| Parameters |
| ... |
| |
|---------------------|
| Old EBP |
|---------------------|
| Local Data |
| ... |
| |
|---------------------|
| ... |
| |
|---------------------|
| ... |
| |
|---------------------|
| New Stack Frame |
| |
|---------------------|
| ... |
| Previous ESP Value | <- esp points here (top of the current stack frame)
+---------------------+
这样的结构使得函数调用和返回可以方便地在堆栈上进行,以及访问局部变量和控制程序的流程。
在 x86 架构中,当一个函数被调用时,它会将当前的 ebp 寄存器的值(也就是上一个函数的 ebp 值)保存(push)到堆栈中,然后将 esp(堆栈指针)寄存器的值复制到 ebp 寄存器,这样 ebp 就指向了新的堆栈帧的底部。因此,堆栈中保存的 ebp 值实际上形成了一个链表,每一个 ebp 值都指向了上一个函数的堆栈帧。 因此,我们可以通过从当前的 ebp 值开始,逐个追踪保存在堆栈中的 ebp 值,来回溯整个堆栈,这就是所谓的"通过跟踪保存的 ebp 指针链回溯堆栈"。这种技术常常被用于调试和错误诊断,即 backtrace 。例如,当程序崩溃时,我们可以通过回溯堆栈来找出导致崩溃的函数调用序列。
ebp(基指针寄存器)本身不是一个链表。然而,在函数调用过程中,ebp 寄存器的值会被保存在堆栈中,这样就形成了一个链式结构,我们可以通过这个链式结构回溯函数调用栈。这就是为什么有时候我们会说"ebp 指针链"或者"ebp 链"。但实际上,这是一种比喻,用来描述 ebp 在函数调用和返回过程中的行为。
总结
接下来讲解内存管理,即上面提及的 mem_init 部分。
在调试程序时,backtrace 是一个非常有用的工具,它可以显示函数调用的堆栈轨迹。这对于理解程序的执行流程,以及定位错误发生的位置非常有帮助。这篇文章讲解如何实现 backtrace 。
backtrace 使用示例
当使用 GDB(GNU Debugger)调试程序时,可以使用backtrace
命令获取函数调用链信息,从而定位程序崩溃或异常的原因。以下是一个简单的示例,演示如何使用 GDB 调试并查看 backtrace 信息。
假设有一个简单的 C++程序,文件名为my_program.cpp
:
#include <iostream>
void func3() {
int* invalidPointer = nullptr;
*invalidPointer = 42; // 这里将导致段错误
}
void func2() {
func3();
}
void func1() {
func2();
}
int main() {
func1();
return 0;
}
首先,使用以下命令编译程序并包含调试信息:
g++ -g -o my_program my_program.cpp
编译完成后,使用 GDB 运行程序:
gdb ./my_program
这将启动 GDB。现在,在 GDB 中运行程序:
run
程序将崩溃,而 GDB 会捕获这个崩溃。当崩溃发生时,使用backtrace
命令获取 backtrace 信息:
bt
这将显示 backtrace 信息,显示函数调用序列以及崩溃发生的行号。输出可能如下所示:
#0 0x0000000000401174 in func3 () at my_program.cpp:6
#1 0x0000000000401186 in func2 () at my_program.cpp:11
#2 0x0000000000401192 in func1 () at my_program.cpp:16
#3 0x00000000004011a1 in main () at my_program.cpp:21
这个 backtrace 显示崩溃发生在func3
的第 6 行,而func3
又被func2
调用(位于第 11 行),依此类推。
你可以使用 GDB 命令检查变量的值、设置断点以及执行各种调试任务。完成后,可以退出 GDB:
quit
这个示例演示了如何使用 GDB 分析 backtrace,并找出程序崩溃的源头。
OS 是如何进入到命令行的?
OS 启动后通过 i386_init
函数完成一些初始化工作后,随后会进入一个无限循环,不断地调用 monitor(NULL)
函数。这个函数就是内核监视器,它提供了一个命令行界面,允许用户输入命令来控制内核和探索系统。
void
i386_init(void)
{
extern char edata[], end[];
memset(edata, 0, end - edata);
cons_init();
// Drop into the kernel monitor.
while (1)
monitor(NULL);
}
monitor(NULL)
函数的定义在 kern/monitor.c
文件中。这个函数首先打印一些欢迎信息,然后进入一个无限循环,不断地读取用户输入的命令,并尝试执行这些命令。
在命令行中是如何解析命令的?
在 monitor
函数的无限循环中,每次循环都会调用 readline
函数来读取用户输入的一行命令,然后调用 runcmd
函数来解析和执行这个命令。runcmd
函数会查找命令列表中的命令,如果找到匹配的命令,就调用相应的函数来执行这个命令。
因此,当在内核监视器中输入一个命令并按下回车键时,内核就会执行相应的函数,然后返回到内核监视器,等待输入下一个命令。
下面是具体的使用示例,输入 help 后会输出对应的信息。
Welcome to the JOS kernel monitor!
Type 'help' for a list of commands.
K> help
help - Display this list of commands
kerninfo - Display information about the kernel
backtrace - Stack backtrace
下面是具体的实现代码:
void
monitor(struct Trapframe *tf)
{
char *buf;
cprintf("Welcome to the JOS kernel monitor!\n");
cprintf("Type 'help' for a list of commands.\n");
while (1) {
buf = readline("K> ");
if (buf != NULL)
if (runcmd(buf, tf) < 0)
break;
}
}
命令是如何跳转到对应函数上的?
一个命令对应一个名为 Command
的结构体,Command
结构体包含三个成员:
struct Command {
const char *name; // 一个指向常量字符的指针,表示命令的名称。
const char *desc; // 一个指向常量字符的指针,表示命令的描述。
// return -1 to force monitor to exit
int (*func)(int argc, char** argv, struct Trapframe* tf);
};
其中 func
表示一个函数指针,指向的函数接受三个参数(一个整数,一个字符指针的指针,以及一个指向 Trapframe
结构体的指针),并返回一个整数。这个函数指针表示执行命令时应调用的函数。
此外维护了一个全局数组,commands
数组包含了三个 Command
结构体的实例,分别对应三个不同的命令:help
,kerninfo
和 backtrace
。每个命令都有一个名称,一个描述,以及一个对应的函数。
help
命令的函数是mon_help
,它会显示所有可用的命令及其描述。kerninfo
命令的函数是mon_kerninfo
,它会显示关于内核的信息。backtrace
命令的函数是mon_backtrace
,它会显示函数调用的堆栈轨迹。
下面是具体代码的实现细节。
static struct Command commands[] = {
{ "help", "Display this list of commands", mon_help },
{ "kerninfo", "Display information about the kernel", mon_kerninfo },
{ "backtrace", "Stack backtrace", mon_backtrace},
};
当中断读取到命令时会遍历这个数组判断是否一致,若一致则会跳转到对应的函数上。
backtrace 是如何实现的?
mon_backtrace
函数实现了一个简单的 backtrace 功能。这个函数的工作原理是,它从当前的 EBP(Extended Base Pointer)寄存器开始,沿着堆栈向上回溯,打印出每个堆栈帧的信息。这个过程的代码如下:
int
mon_backtrace(int argc, char **argv, struct Trapframe *tf) {
cprintf("Stack backtrace:\n");
uint32_t *ebp;
int valid;
struct Eipdebuginfo ei;
ebp = (uint32_t *) read_ebp();
while (ebp != 0) {
cprintf(" ebp %08x", ebp);
cprintf(" eip %08x args", *(ebp + 1));
valid = debuginfo_eip(*(ebp + 1), &ei);
for (int i = 2; i < 7; ++i) {
cprintf(" %08x", *(ebp + i));
}
cprintf("\n");
if (valid == 0) {
cprintf(" %s:%d: %.*s+%d\n",
ei.eip_file, ei.eip_line, ei.eip_fn_namelen,
ei.eip_fn_name, *(ebp + 1) - ei.eip_fn_addr);
}
ebp = (uint32_t *) *ebp;
}
return 0;
}
在这段代码中,read_ebp()
函数用于读取当前的 EBP 寄存器的值。然后,通过 *(ebp+1)
可以获取到当前函数的返回地址(EIP),这个地址就是调用当前函数的函数的地址。然后,通过 debuginfo_eip
函数,可以获取到这个地址对应的源代码行号和函数名。最后,通过 *(ebp+i)
(其中 i 从 2 到 6)可以获取到当前函数的参数。
这样,通过打印出每个堆栈帧的 EBP、EIP、源代码行号、函数名和参数,就可以得到一个详细的函数调用堆栈轨迹。
内存布局
下面突出了 backtrace
中 ebp
、eip
等信息在内存中的布局:
+-------------------------+
| Stack Top |
| |
| ... |
| |
| Function Arguments | <- ebp + 2
| |
| ... |
| |
| Function Arguments | <- ebp + 6
| |
|-------------------------|
| Previous EBP | <- ebp
|-------------------------|
| Return Address (eip) | <- ebp + 1
|-------------------------|
| Local Variables |
| and Temporary Data |
| |
| ... |
| |
|-------------------------|
| Previous EBP |
|-------------------------|
| Return Address (eip) |
|-------------------------|
| Local Variables |
| and Temporary Data |
| |
| ... |
| |
+-------------------------+
解释:
- 栈从高地址向低地址生长,即栈顶在上方,栈底在下方。
- 每个堆栈帧的起始地址是
ebp
的值。 ebp
存储了上一级函数的ebp
,通过*ebp
可以访问上一级函数的堆栈帧。*(ebp + 1)
存储了当前函数的返回地址 (eip
),即调用当前函数的函数的地址。*(ebp + i)
(其中 i 从 2 到 6)存储了当前函数的参数。debuginfo_eip
函数用于获取eip
对应的源代码行号和函数名。
如何获取源代码行号?
之前代码中通过调用 debuginfo_eip
来进一步获取源代码行号和函数名等相关信息,接下来讲解 debuginfo_eip
这个函数是如何获取这些信息的。
debuginfo_eip
函数的主要目的是填充一个 Eipdebuginfo
结构体,该结构体包含了关于指定指令地址(addr
)的信息。函数返回 0 表示找到了信息,返回负数表示没有找到信息。即使返回负数,它也会将一些信息存储到 *info
中。
函数首先初始化 info
结构体的各个字段,然后找到相关的 stab 集(stab 是一种用于调试信息的数据格式)。
接着,函数使用二分查找(stab_binsearch
)在 stab 集中查找源文件(类型为 N_SO
的 stab)。如果找不到源文件,函数返回 -1。
然后,函数在找到的文件的 stab 中查找函数定义(类型为 N_FUN
的 stab)。如果找到了函数定义,函数就会设置 info
结构体的 eip_fn_name
和 eip_fn_addr
字段,并在函数定义中查找行号。如果没有找到函数定义,函数就会在整个文件中查找行号。
接下来,函数在行号的 stab 中查找文件名 stab。因为内联函数可能会插入来自不同文件的代码,所以不能只使用 lfile
stab,而需要向后查找到相关的文件名 stab。
最后,函数设置 eip_fn_narg
字段为函数接受的参数数量,如果没有包含函数,则设置为 0。
这就是 debuginfo_eip
函数的基本工作原理。
stab 是什么?
"stab" 是一种用于存储调试信息的数据格式,它通常存储在程序的二进制文件中,例如 ELF(Executable and Linkable Format)文件。它通常用于存储源代码文件、函数、变量、行号等信息,以便在调试时可以从执行的机器代码中恢复出源代码的结构。
在程序加载到内存时,这些信息也会被加载到内存中。在代码中,__STAB_BEGIN__
和 __STAB_END__
分别表示 stab 表的开始和结束,这些都是内存地址。debuginfo_eip
函数就是通过这些 stab 和字符串表来获取指定地址的调试信息。
在代码中,stab
是一个结构体,定义在 inc/stab.h
文件中。这个结构体包含了一些字段,如 n_type
、n_value
和 n_strx
,这些字段分别用于存储 stab 类型、地址值和字符串索引。
例如,N_SO
类型的 stab 用于标记源文件,N_FUN
类型的 stab 用于标记函数。n_value
字段存储的是源文件或函数的地址,n_strx
字段是一个索引,指向一个字符串表,这个字符串表存储了源文件名或函数名。
stab 中的信息是在编译阶段生成的。当你使用 gcc 或其他编译器编译源代码时,如果你开启了调试选项(例如使用 gcc 的 -g
选项),编译器就会生成 stab 信息并将其存储在生成的二进制文件中。这些信息包括源代码文件名、函数名、变量名、行号等,这些都是在调试时非常有用的信息。
总结
文章强调了在调试程序时使用 backtrace 的重要性,并通过 GDB 的 backtrace 命令展示了如何获取函数调用链信息以定位程序崩溃或异常的原因。同时,文章解释了内核监视器的实现,包括如何解析用户输入的命令并调用相应的函数。文章还通过mon_backtrace
函数展示了如何从当前的 EBP 寄存器开始,沿着堆栈向上回溯,打印每个堆栈帧的信息,并讲解了如何通过debuginfo_eip
函数和 stab 信息获取源代码行号的过程。stab 是一种存储调试信息的数据格式,通常存储在程序的二进制文件中,用于在调试时恢复出源代码的结构,这些信息是在编译阶段生成的,需要开启调试选项才会包含在生成的二进制文件中。
这篇文章主要讲解内存初始化,即 mem_init 部分。进入内核之后需要初始化物理内存,随后建立物理内存和虚拟内存之间的映射。初始化物理内存的过程分为物理内存的检测,页表的初始化。这篇文章如何初始化物理内存,下篇文章讲解如何建立虚拟内存和物理内存之间的映射。
检测物理内存
使用i386_detect_memory()
函数检测机器上的物理内存数量。在操作系统中,物理内存的检测是非常重要的一步。这是因为操作系统需要知道有多少可用的物理内存,以便于后续的内存管理和分配。例如,操作系统需要知道有多少内存可以用于创建页表,分配给进程,或者用于文件系统的缓存等。如果没有正确地检测物理内存,操作系统可能会尝试使用不存在的内存,这将导致错误。因此,物理内存的检测通常是操作系统启动和初始化过程的一部分。
通过 i386_detect_memory()
来获取一些基本的内存信息,即npages
和npages_basemem
,这两个变量都是用来存储物理内存页面数量的,但是它们的用途有所不同。
npages
:这个变量用来存储系统中所有的物理内存页面的数量。这包括了所有可用的物理内存,无论它们是否正在被使用。
npages_basemem
:这个变量用来存储基本内存的页面数量。基本内存通常指的是系统启动时可用的,例如有些内存会被 BIOS,设备驱动程序和操作系统内核使用。
npages
和npages_basemem
都是全局变量,下面是获取这两个变量的具体代码:
static void
i386_detect_memory(void)
{
size_t basemem, extmem, ext16mem, totalmem;
// 使用 CMOS 调用来测量可用的基本内存和扩展内存。
// (CMOS 调用返回以千字节为单位的结果。)
basemem = nvram_read(NVRAM_BASELO);
extmem = nvram_read(NVRAM_EXTLO);
ext16mem = nvram_read(NVRAM_EXT16LO) * 64;
// 计算基本内存和扩展内存中可用的物理页面数量。
if (ext16mem)
totalmem = 16 * 1024 + ext16mem;
else if (extmem)
totalmem = 1 * 1024 + extmem;
else
totalmem = basemem;
npages = totalmem / (PGSIZE / 1024);
npages_basemem = basemem / (PGSIZE / 1024);
cprintf("Physical memory: %uK available, base = %uK, extended = %uK\n",
totalmem, basemem, totalmem - basemem);
}
这段代码的主要目的是检测和计算系统中可用的物理内存数量。
首先,它使用 CMOS 调用来读取基本内存(basemem
)和扩展内存(extmem
和ext16mem
)。
extmem
表示的是在 1MB 以上,16MB 以下的扩展内存大小,单位是 KB。这部分内存通常被称为"传统"的扩展内存。这部分内存通常被操作系统和一些大型的应用程序使用,例如数据库管理系统或者图形处理软件。
ext16mem
表示的是在 16MB 以上的扩展内存大小,单位是 64KB。这部分内存通常被称为"高端"的扩展内存。这部分内存通常被操作系统用来存储内核数据结构,例如页表、文件系统缓存等。
在操作系统启动的时候,会读取这两个变量的值,然后根据这些值来初始化内存管理子系统,包括设置物理内存的布局,初始化页表,以及设置内存分配器等。
CMOS 是计算机上的一种小型存储设备,用于存储系统的基本设置,包括系统时间和系统内存大小等信息。在这里,nvram_read
函数被用来读取 CMOS 中的内存信息。这些信息通常以 KB(千字节)为单位。
然后,它根据读取到的内存信息来计算总的物理内存数量(totalmem
)。如果ext16mem
存在,那么总内存就是 16MB 加上ext16mem
。如果ext16mem
不存在但extmem
存在,那么总内存就是 1MB 加上extmem
。如果两者都不存在,那么总内存就是basemem
。
这段代码的结果是计算出系统中可用的物理内存总量,以便于后续的内存管理和分配。
初始页目录
接下来创建一个初始的页目录来管理虚拟内存到物理内存的映射。
kern_pgdir = (pde_t *) boot_alloc(PGSIZE);
memset(kern_pgdir, 0, PGSIZE);
其中 boot_alloc
函数是一个简单的物理内存分配器,仅在系统启动时用于分配物理内存。后续会讲解这个函数的实现细节。
函数接受一个参数n
,表示需要分配的字节数。此处表示要分配一个 PGSIZE ,作为内核的页目录(kern_pgdir),PGSIZE 即 4096 字节。页目录是一个包含页表条目(PTE)的数组,每个条目都指向一个页表。页表又是一个数组,包含了实际的物理页帧地址以及一些权限和状态位。
可以将页目录想象成一个数组,其中每个元素都是一个页表条目(PTE)。每个 PTE 都指向一个页表。下面是一个简化描述。
+---------------+
| Page Table |
| Directory |
+---------------+
| PTE[0] (Page |
| Table Entry) |
+---------------+
| PTE[1] |
+---------------+
| PTE[2] |
+---------------+
| ... |
+---------------+
| PTE[N] |
+---------------+
在这个数组中,每个 PTE 都是一个指向页表的指针。接下来,让我们深入了解页表,将其表示为另一个数组,其中包含了实际的物理页帧地址以及一些权限和状态位。这可以通过以下方式进行图形化表示:
+------------------------+
| Page Table |
+------------------------+
| Entry 0 | Frame: 0xABC | <-- Physical Page Frame Address
| | Permissions | <-- Read/Write/Execute permissions
| | Status Bits | <-- Page status bits
+------------------------+
| Entry 1 | Frame: 0xDEF |
| | Permissions |
| | Status Bits |
+------------------------+
| Entry 2 | Frame: ... |
| | Permissions |
| | Status Bits |
+------------------------+
| ... |
+------------------------+
| Entry N | Frame: ... |
| | Permissions |
| | Status Bits |
+------------------------+
在这个数组中,每个条目表示一个物理页帧,其中包含了该页的实际地址、权限(读/写/执行)和状态位。整个结构形成了一个层次化的页面管理系统,其中页目录引导到页表,而页表则映射到实际的物理页帧。
boot_alloc
接下来详细讲解 boot_alloc 是如何申请空间的。通过 boot_alloc 申请足够的空间用作内核的页目录(kern_pgdir)。如果系统没有足够的内存来满足分配请求,boot_alloc
函数会触发 panic,表示系统出现了无法恢复的错误。
static void *
boot_alloc(uint32_t n)
{
static char *nextfree;
char *result;
if (!nextfree) {
extern char end[];
nextfree = ROUNDUP((char *) end, PGSIZE);
}
// 将 'n' 对齐到 PGSIZE 的倍数
if (n > 0) {
n = ROUNDUP(n, PGSIZE);
}
// 检查是否有足够的剩余内存进行分配。
if (PADDR(nextfree) + n > npages * PGSIZE) {
panic("boot_alloc: Out of memory!");
return NULL;
}
// 通过调整 'nextfree' 来分配内存。
result = nextfree;
if (n > 0) {
nextfree += n;
}
return result;
}
需要注意的是,这个函数只能在系统初始化期间使用,也就是在设置page_free_list
列表之前。page_free_list
是一个链表,用于跟踪系统中所有的空闲内存页。一旦这个列表被设置,系统就会开始使用page_alloc
函数来分配内存,而不再使用boot_alloc
函数。
nextfree 为什么指向 end ?
nextfree 是一个指向下一个可用内存的指针。在 boot_alloc 函数中,如果 nextfree 为 NULL(也就是第一次调用 boot_alloc 函数),nextfree 会被初始化为内核 bss 段结束后的第一个地址。bss 段接下来是堆,即堆的起始地址。
if (!nextfree) {
extern char end[];
nextfree = ROUNDUP((char *) end, PGSIZE);
}
关于程序的内存布局在上一篇文章中已经提及,下面是一个局部图。这样做的目的是让 nextfree 指向内核使用的内存之后的第一个可用页。
..........
. .
: :
| ^ |
| | |
brk point -> | - - - - - - - - - - -| 堆上声明动态内存
| HEAP |
| |
|----------------------|
| BSS | 未初始化数据 (BSS)
|----------------------|
| Data | 初始化数据 (DS)
|----------------------|
| Text | 二进制代码
低地址 ----> '----------------------'
检查内存是否足够
在分配内存之前要检查是否有足够的可用内存,下面是具体的判断代码,后续会详细讲解。
if (PADDR(nextfree) + n > npages * PGSIZE) {
panic("Out of memory!");
}
PADDR(nextfree)
是将nextfree
(下一个空闲内存字节的虚拟地址)转换为物理地址。n
是请求的内存大小,npages * PGSIZE
是系统中总的物理内存大小。
如果PADDR(nextfree) + n
大于npages * PGSIZE
,那么说明请求的内存大小加上已经分配的内存大小超过了系统的总物理内存大小,这意味着系统没有足够的物理内存来满足这次的内存分配请求,因此会触发 panic,表示系统出现了无法恢复的错误。
设置 UVPT
接下来要在页目录中递归地插入自身,以在虚拟地址 UVPT 处形成一个虚拟页表。
kern_pgdir[PDX(UVPT)] = PADDR(kern_pgdir) | PTE_U | PTE_P;
在这里,kern_pgdir[PDX(UVPT)]
是获取 UVPT 的页目录条目。PADDR(kern_pgdir)
是获取页目录的物理地址。PTE_U
和 PTE_P
是页表条目的权限位,分别表示用户级别可以访问和页存在。
所以,这行代码的含义是将页目录的物理地址与权限位进行或运算后,设置到 UVPT 的页目录条目中。这样做的结果是在虚拟地址 UVPT 处形成了一个虚拟页表,这个页表实际上就是页目录自身。这样做的好处是,操作系统可以通过 UVPT 这个虚拟地址来访问和修改页表。
UVPT
是用户级别的页表的虚拟地址。在 x86 架构中,每个进程都有自己的页表,用于将虚拟地址映射到物理地址。UVPT
是这个页表在用户空间的虚拟地址。这个地址是在用户空间的高地址部分,这样设计的目的是为了避免与用户程序的地址空间冲突。在这个地址处,用户程序可以读取但不能写入,因此它可以查看但不能修改页表。
在 kern/pmap.c
文件的 mem_init
函数中,UVPT
被设置为页目录自身的地址,这样在用户空间就可以访问到页表了:这行代码的含义是将页目录的物理地址与权限位进行或运算后,设置到 UVPT
的页目录条目中。这样做的结果是在虚拟地址 UVPT
处形成了一个虚拟页表,这个页表实际上就是页目录自身。
分配并初始化页表数组
接下来需要为每个物理页面分配一个 struct PageInfo
结构体,并将其存储在 pages
数组中。可以使用 boot_alloc
函数来分配所需的内存,然后使用 memset
函数将所有字段初始化为 0。以下是实现这个功能的具体代码:
// 计算需要的内存大小
size_t pages_size = sizeof(struct PageInfo) * npages;
// 使用 boot_alloc 分配内存
pages = (struct PageInfo *) boot_alloc(pages_size);
// 使用 memset 将所有字段初始化为 0
memset(pages, 0, pages_size);
这段代码首先计算了需要分配的内存大小,然后使用 boot_alloc
函数分配了相应的内存,并将返回的地址赋值给 pages
。最后,使用 memset
函数将所有字段初始化为 0。
接下来讲解和 page 相关的三个函数,其中 page_init 用于初始化物理页面的空闲列表,page_alloc 用于申请一个 page ,而 page_free 则用与释放 page 。
初始化空闲页面列表
在分配了初始的内核数据结构之后,设置空闲物理页面的列表。这样,所有后续的内存管理都将通过page_*
函数进行。
page_init
函数的主要目的是初始化物理页面的空闲列表。这个函数的主要任务是遍历所有的物理页面,并将未使用的页面添加到空闲列表中。在这个过程中,我们需要考虑到一些特殊的内存区域,比如物理页面 0(通常被 BIOS 使用),IO hole(被 IO 设备使用的内存区域),以及已经被内核使用的内存区域。
void
page_init(void)
{
pages[0].pp_ref = 1;
pages[0].pp_link = NULL;
// 从物理页面 1 到 npages_basemem - 1 是基本内存,可以被分配。
for (size_t i = 1; i < npages_basemem; i++) {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}
// 处理 I/O 空隙。这些页面从 IOPHYSMEM 到 EXTPHYSMEM 不能被分配。
size_t io_hole_start = IOPHYSMEM / PGSIZE;
size_t io_hole_end = EXTPHYSMEM / PGSIZE;
for (size_t i = io_hole_start; i < io_hole_end; i++) {
pages[i].pp_ref = 1;
}
// 处理内核已使用的扩展内存。从 EXTPHYSMEM 到 boot_alloc(0) 返回的地址是内核使用的。
size_t kernel_end = PADDR((char *)boot_alloc(0)) / PGSIZE;
for (size_t i = io_hole_end; i < kernel_end; i++) {
pages[i].pp_ref = 1;
}
// 处理剩余的扩展内存。这些页面可以被分配。
for (size_t i = kernel_end; i < npages; i++) {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}
}
函数首先将物理页面 0 标记为已使用,然后将基础内存的页面添加到空闲列表中。接着,函数将 IO hole 的页面标记为已使用。然后,函数检查扩展内存中的页面,其中一部分已经被内核使用,一部分是空闲的。对于已经被内核使用的页面,函数将它们标记为已使用。对于空闲的页面,函数将它们添加到空闲列表中。
这个函数的主要作用是在系统启动时,对物理内存进行初始化和管理,以便后续的内存分配和回收操作。
Page
接下来详细讲解如何管理物理页面,以及如何实现获取 Page page_alloc
和释放 Page page_free
对应的功能。对于物理页面需要设计一个对应的结构体PageInfo
,用于存储关于物理页面的元数据。这个结构体并不是物理页面本身,但是每个物理页面和一个PageInfo
结构体一一对应。
struct PageInfo {
struct PageInfo *pp_link;
uint16_t pp_ref;
};
PageInfo
结构体包含两个成员:
-
pp_link
:这是一个指向下一个PageInfo
的指针,用于链接空闲页面列表。 -
pp_ref
:这是一个指向此页面的指针计数(通常在页面表条目中)。对于使用page_alloc
分配的页面,这个字段是有效的。但是对于在引导时使用pmap.c
的boot_alloc
分配的页面,这个字段是无效的。
这个结构体通常映射在UPAGES
地址,对内核是可读写的,对用户程序是只读的。
page_alloc
page_alloc 函数用来获取一个 page ,以下是实现这个函数的代码:
struct PageInfo *
page_alloc(int alloc_flags)
{
// 检查是否有空闲的物理页面
if (!page_free_list)
return NULL;
// 从空闲列表中取出一个页面
struct PageInfo *page = page_free_list;
// 更新空闲列表
page_free_list = page_free_list->pp_link;
// 防止双重释放
page->pp_link = NULL;
// 如果需要,将页面内容清零
if (alloc_flags & ALLOC_ZERO)
memset(page2kva(page), 0, PGSIZE);
return page;
}
这段代码首先检查 page_free_list
是否为空。如果为空,说明没有空闲的物理页面,函数返回 NULL。否则,函数从 page_free_list
中取出一个页面,并更新 page_free_list
。然后,函数将取出的页面的 pp_link
字段设置为 NULL,以防止在释放页面时出现双重释放的错误。最后,如果 alloc_flags
参数中包含 ALLOC_ZERO
标志,函数将整个页面填充为 0。
page_free
page_free
函数用于释放一个 Page ,以下是实现这个函数的代码:
void
page_free(struct PageInfo *pp)
{
// 如果 pp->pp_ref 不为0,调用 panic 函数报错
if (pp->pp_ref != 0)
panic("pp->pp_ref is nonzero!");
// 如果 pp->pp_link 不为 NULL,调用 panic 函数报错
if (pp->pp_link != NULL)
panic("pp->pp_link is not NULL!");
// 将页面添加到空闲列表中
pp->pp_link = page_free_list;
page_free_list = pp;
}
这段代码首先检查 pp->pp_ref
是否为 0,如果不为 0,说明这个页面还在被引用,不能被释放,函数调用 panic 函数报错。然后,函数检查 pp->pp_link
是否为 NULL,如果不为 NULL,说明这个页面已经在空闲列表中,函数调用 panic 函数报错。如果以上两个条件都满足,函数将这个页面添加到 page_free_list
中。
总结
至此,详细讲解了物理页面管理,接下来讲解虚拟内存。
上篇文章已经将物理内存初始化设置完毕了,接下来讲解如何建立虚拟内存到物理内存之间的映射。在讲解映射之前要先讲解虚拟内存,虚拟内存将内核和用户软件使用的虚拟地址映射到物理内存的地址。
虚拟地址、线性地址和物理地址三者的区别
在计算机系统中,我们通常会遇到三种类型的地址:虚拟地址(Virtual Address)、线性地址(Linear Address)和物理地址(Physical Address)。这三种地址在内存管理中各有其特定的用途和含义。
-
虚拟地址(Virtual Address):虚拟地址是由 CPU 生成的,程序在运行时使用的地址。这些地址是虚拟的,也就是说它们并不直接对应实际的物理内存地址。虚拟地址空间的大小由 CPU 的架构决定,例如在 32 位系统中,虚拟地址空间的大小为 4GB。
-
线性地址(Linear Address):线性地址是虚拟地址经过分段(Segmentation)转换后得到的地址。在分段转换过程中,虚拟地址会加上一个段基址得到线性地址。在许多现代操作系统中,由于使用了平坦模型(Flat Model),分段转换通常不会改变地址,因此虚拟地址和线性地址往往是相同的。
-
物理地址(Physical Address):物理地址是实际存在于 RAM 中的地址。线性地址经过分页(Paging)转换后得到物理地址。分页转换是通过页表(Page Table)完成的,页表将线性地址空间分割成固定大小的页(Page),并将每个页映射到物理内存中的具体位置。
总的来说,虚拟地址是程序直接使用的地址,线性地址是虚拟地址经过分段转换后的地址,物理地址是实际对应到 RAM 中的地址。这三种地址的转换过程是由 CPU 的内存管理单元(MMU)自动完成的。
内存管理单元(MMU)
内存管理单元(MMU)是计算机系统中的一个关键组件,它负责处理虚拟内存和物理内存之间的映射。MMU 的工作过程可以分为以下几个步骤:
-
当 CPU 需要访问内存时,它会生成一个虚拟地址。这个虚拟地址是由程序生成的,可以被视为一个抽象的概念,它并不直接对应物理内存中的实际位置。
-
这个虚拟地址会被送到 MMU 进行处理。MMU 会使用一种叫做页表的数据结构来存储虚拟地址到物理地址的映射信息。
-
MMU 会查找页表,找到虚拟地址对应的物理地址。这个过程叫做地址转换或地址映射。
-
一旦找到了对应的物理地址,MMU 就会将这个物理地址返回给 CPU,CPU 就可以直接访问物理内存了。
-
如果在页表中找不到虚拟地址对应的物理地址,那么 MMU 就会触发一个页面错误(Page Fault)。这通常意味着程序试图访问一个并未分配的内存区域,或者试图进行一种不被允许的操作,例如写入一个只读的内存区域。
-
在处理页面错误时,操作系统会介入,它可能会分配新的内存,或者终止发生错误的程序。
在这个过程中,MMU 起到了一个关键的角色,它使得程序可以使用虚拟地址来访问内存,而不需要关心物理内存的实际布局。这大大简化了内存管理的复杂性,同时也提供了一种有效的内存保护机制。
地址转换
此前在 boot/boot.S
中,已经设置了一个全局描述符表(GDT),其中将所有段基地址设置为 0,限制为 0xffffffff
,有效地禁用了段转换。所以"Selector" 没有产生效果,故线性地址总是等于虚拟地址的偏移量。所以在后续的内容中虚拟地址等同于线性地址。可以忽略分段,仅专注于页面转换。
Selector +--------------+ +-----------+
---------->| | | |
| Segmentation | | Paging |
Software | |-------->| |----------> RAM
Offset | Mechanism | | Mechanism |
---------->| | | |
+--------------+ +-----------+
Virtual Linear Physical
线性地址组成
接下来讲解虚拟地址的组成组成部分,一个线性地址('la')被分解为三个部分:
- 页目录索引(Page Directory Index)
- 页表索引(Page Table Index)
- 页内偏移(Offset within Page)
+--------10------+-------10-------+---------12----------+
| Page Directory | Page Table | Offset within Page |
| Index | Index | |
+----------------+----------------+---------------------+
\--- PDX(la) --/ \--- PTX(la) --/ \---- PGOFF(la) ----/
\---------- PGNUM(la) ----------/
这里的 10 和 12 是位数,表示每个部分占用的位数。页目录索引和页表索引各占 10 位,页内偏移占 12 位。
PDX(la)
、PTX(la)
、PGOFF(la)
和PGNUM(la)
这四个宏用于分解线性地址。例如,PDX(la)
用于获取线性地址la
的页目录索引。
如果你有页目录索引、页表索引和页内偏移,你可以使用PGADDR(PDX(la), PTX(la), PGOFF(la))
这个宏来构造一个线性地址。
下面是这几个宏对应的实现细节:
// page number field of address
#define PGNUM(la) (((uintptr_t) (la)) >> PTXSHIFT)
// page directory index
#define PDX(la) ((((uintptr_t) (la)) >> PDXSHIFT) & 0x3FF)
// page table index
#define PTX(la) ((((uintptr_t) (la)) >> PTXSHIFT) & 0x3FF)
// offset in page
#define PGOFF(la) (((uintptr_t) (la)) & 0xFFF)
// construct linear address from indexes and offset
#define PGADDR(d, t, o) ((void*) ((d) << PDXSHIFT | (t) << PTXSHIFT | (o)))
页目录和页表
Page Directory 可以理解为数组,其中每一个元素分别是 Page Directory Entry (PDE) 。每个 PDE 指向一个 Page Table。下面是一个简化的 Page Directory 的文本图形化表示:
+-----------+-----------+-----------+-----------+-----------+
| PDE 0 | PDE 1 | PDE 2 | PDE 3 | ... |
+-----------+-----------+-----------+-----------+-----------+
| ... | ... | ... | ... | ... |
+-----------+-----------+-----------+-----------+-----------+
Page Table 也是一个数组,一个位置对应一个 Page Table Entry (PTE)。每个 PTE 指向一个物理页框。下面是一个简化的 Page Table 的文本图形化表示:
+-----------+-----------+-----------+-----------+-----------+
| PTE 0 | PTE 1 | PTE 2 | PTE 3 | ... |
+-----------+-----------+-----------+-----------+-----------+
| ... | ... | ... | ... | ... |
+-----------+-----------+-----------+-----------+-----------+
每个 PTE 包含一些信息,例如指向对应物理页框的地址等。在这个图中,每个格子代表一个 PTE。
Page Directory 和 Page Table 之间的关系如下:
Page Directory Page Table
+-----------+ +-----------+
| PDE 0 |-------->| PTE 0 |
+-----------+ +-----------+
| PDE 1 |-------->| PTE 1 |
+-----------+ +-----------+
| PDE 2 |-------->| PTE 2 |
+-----------+ +-----------+
| PDE 3 |-------->| PTE 3 |
+-----------+ +-----------+
这里,箭头表示指向关系。每个 Page Directory Entry 指向一个对应的 Page Table,而每个 Page Table Entry 指向一个物理页框。这种结构允许通过两级查找,从虚拟地址找到物理地址。
建立映射的过程
接下来结合具体的代码讲解页目录和页表,进一步具像化,了解在代码中是如何使用的。函数pgdir_walk
的作用是在给定的页目录pgdir
中查找虚拟地址va
对应的页表项。如果页表项不存在,它可以选择创建一个新的页表页。
pte_t *
pgdir_walk(pde_t *pgdir, const void *va, int create)
{
pde_t *pde;
pte_t *pte;
struct PageInfo *pp;
// 获取页目录项
pde = &pgdir[PDX(va)];
if (*pde & PTE_P) {
// 页目录项存在,获取页表
pte = (pte_t*) KADDR(PTE_ADDR(*pde));
} else {
if (!create || (pp = page_alloc(ALLOC_ZERO)) == NULL) {
// 页目录项不存在,且不创建新的页表页,或者创建失败
return NULL;
}
// 设置新页表页的物理地址到页目录项中
*pde = page2pa(pp) | PTE_U | PTE_W | PTE_P;
// 增加引用计数
pp->pp_ref++;
// 获取页表
pte = (pte_t*) page2kva(pp);
}
// 返回页表项
return &pte[PTX(va)];
}
pgdir
是一个页目录,它是一个数组,每个元素都是一个页目录项(Page Directory Entry,简称 PDE)。每个页目录项都包含一个页表的物理地址和一些权限位。
通过PDX(va)
宏获取虚拟地址va
的页目录索引,并使用该索引从页目录pgdir
中获取对应的页目录项pde
。pde_t *pde
是一个指向页目录项的指针。pte_t *pte
是一个指向页表项的指针。
然后,函数检查页目录项pde
是否存在。如果存在(即*pde & PTE_P
为真),则说明对应的页表已经存在。随后通过 pte = (pte_t*) KADDR(PTE_ADDR(*pde));
获取页表的虚拟地址。
-
其中
PTE_ADDR
,它用于获取页表项(Page Table Entry,简称 PTE)中的物理地址。之前已经提及,在 x86 架构的分页内存管理中,页表项中存储了物理页的物理地址和一些权限位。这个宏通过与操作& ~0xFFF
将页表项pte
的低 12 位清零,从而获取到物理地址。这是因为在 x86 架构中,物理地址的低 12 位用于存储权限位和其他信息,而高位存储的才是实际的物理地址。 -
通过
KADDR(PTE_ADDR(*pde))
将页目录项pde
中存储的物理地址转换为内核虚拟地址,并将其视为页表pte
。
如果页目录项pde
不存在,函数会根据create
参数决定是否创建新的页表页。如果create
为假或者创建新的页表页失败(即page_alloc(ALLOC_ZERO)
返回 NULL),函数会返回 NULL 表示失败。
如果创建新的页表页成功,函数会将新页表页的物理地址和一些权限位设置到页目录项pde
中,并增加新页表页的引用计数。然后,函数将新页表页的物理地址转换为内核虚拟地址,并将其视为页表pte
。
最后,函数通过PTX(va)
宏获取虚拟地址va
的页表索引,并返回页表pte
中对应的页表项的地址。
这个函数是虚拟内存管理的关键部分,它实现了虚拟地址到物理地址的映射。
范围映射
通过 pgdir_walk
实现了虚拟地址到物理地址的映射。接下来在这个函数的基础上增加其他功能,例如给出起始地址和长度,建立多个地址的映射。
boot_map_region
将虚拟地址空间[va, va+size)
映射到物理地址[pa, pa+size)
、通过 page_lookup
返回虚拟地址'va'映射的页,通过 page_remove
取消映射虚拟地址'va'的物理页,通过 page_insert
将物理页'pp'映射到虚拟地址'va'。
研究如何使用 boot_map_region ,下面是一个具体的使用示例。boot_map_region 实现后,就需要用到下面的代码,其中将虚拟地址 UPAGES 处映射 'pages' 数组。此前已经讲过 pages 数组了,其中包含所有物理页面信息的数组,每个元素是一个struct PageInfo
结构体,表示一个物理页面的状态。
boot_map_region(kern_pgdir, UPAGES, pages_size, PADDR(pages), PTE_U | PTE_P);
所以,这个函数调用的作用是将虚拟地址UPAGES
到UPAGES + pages_size
的范围映射到物理地址PADDR(pages)
到PADDR(pages) + pages_size
的范围,权限设置为用户可读且页面存在。这样,内核就可以通过访问虚拟地址UPAGES
来访问和管理pages
数组了。
接下来讲解如何实现 boot_map_region ,下面是实现代码,后续讲解每行代码的含义。
static void
boot_map_region(pde_t *pgdir, uintptr_t va, size_t size, physaddr_t pa, int perm)
{
// 计算需要映射的页数
size_t num_pages = size / PGSIZE;
for (size_t i = 0; i < num_pages; i++) {
// 计算当前页的虚拟地址和物理地址
uintptr_t cur_va = va + i * PGSIZE;
physaddr_t cur_pa = pa + i * PGSIZE;
// 获取当前页的页表项
pte_t *pte = pgdir_walk(pgdir, (void *)cur_va, 1);
// 如果获取页表项失败(例如,内存不足),则退出函数
if (!pte) {
return;
}
// 设置页表项的值为物理地址和权限位的组合
*pte = cur_pa | perm | PTE_P;
}
}
之前已经讲解过一遍boot_map_region
参数的含义了,接下来再重复一遍。即在给定的页目录pgdir
中,将虚拟地址va
到va + size
的范围映射到物理地址pa
到pa + size
的范围。size
是以字节为单位的大小,是PGSIZE
的倍数,va
和pa
都应该是页对齐的。
-
计算需要映射的页数,这是通过将
size
除以PGSIZE
得到的。 -
对于每一页,计算当前页的虚拟地址和物理地址。这是通过将
i * PGSIZE
加到va
和pa
上得到的。 -
获取当前页的页表项。这是通过调用
pgdir_walk
函数得到的,该函数返回一个指向页表项的指针。如果页表项不存在,pgdir_walk
会创建一个新的页表页。 -
如果获取页表项失败(例如,内存不足),则退出函数。
-
设置页表项的值为物理地址和权限位的组合。这是通过将
cur_pa
、perm
和PTE_P
进行位或操作得到的。PTE_P
是一个标志位,表示页表项有效。
这段代码的目的是在页目录中设置虚拟地址到物理地址的映射,这是建立虚拟内存系统的一个重要步骤。
查找虚拟地址对应的物理页面
接下来 page_lookup
的函数,它在给定的页目录 pgdir
中查找虚拟地址 va
映射的物理页面。
struct PageInfo *
page_lookup(pde_t *pgdir, void *va, pte_t **pte_store)
{
// 使用 pgdir_walk 获取虚拟地址 va 的页表项
pte_t *pte = pgdir_walk(pgdir, va, 0);
if (!pte)
return NULL; // 如果页表项不存在,返回 NULL
// 如果 pte_store 不为零,存储页表项的地址
if (pte_store)
*pte_store = pte;
// 使用 pa2page 将页表项中的物理地址转换为页面信息结构
return pa2page(PTE_ADDR(*pte));
}
函数的参数包括:
pgdir
:页目录的指针,它是一个数组,每个元素都是一个页目录项(Page Directory Entry,简称 PDE)。va
:需要查找的虚拟地址。pte_store
:一个指向页表项(Page Table Entry,简称 PTE)指针的指针。如果pte_store
不为零,那么函数会在其中存储找到的页表项的地址。
函数的返回值是一个指向 PageInfo
结构的指针,这个结构包含了物理页面的信息。如果虚拟地址 va
没有映射的物理页面,函数会返回 NULL
。
函数的主要步骤如下:
- 使用
pgdir_walk
函数获取虚拟地址va
的页表项。如果页表项不存在,函数返回NULL
。 - 如果
pte_store
不为零,那么在pte_store
指向的位置存储页表项的地址。 - 使用
pa2page
函数将页表项中的物理地址转换为PageInfo
结构,然后返回这个结构的指针。
这个函数主要用于查找虚拟地址映射的物理页面,以及获取虚拟地址对应的页表项。
删除虚拟地址和物理页面之间的映射关系
这个函数主要用于取消虚拟地址映射的物理页面,以及释放不再使用的物理页面。
void
page_remove(pde_t *pgdir, void *va)
{
pte_t *pte;
struct PageInfo *pp;
// 使用 page_lookup 获取虚拟地址 va 映射的页
pp = page_lookup(pgdir, va, &pte);
if (!pp)
return; // 如果没有物理页,什么都不做
// 减少物理页的引用计数
pp->pp_ref--;
// 如果引用计数达到 0,释放物理页
if (pp->pp_ref == 0)
page_free(pp);
// 将页表项设置为 0
*pte = 0;
// 使 TLB 无效
tlb_invalidate(pgdir, va);
}
函数的参数包括:
pgdir
:页目录的指针,它是一个数组,每个元素都是一个页目录项(Page Directory Entry,简称 PDE)。va
:需要取消映射的虚拟地址。
函数的主要步骤如下:
- 使用
page_lookup
函数获取虚拟地址va
映射的物理页面。如果没有映射的物理页面,函数直接返回。 - 减少物理页面的引用计数。如果引用计数达到 0,释放物理页面。
- 将虚拟地址
va
对应的页表项设置为 0,即取消映射。 - 使用
tlb_invalidate
函数使 TLB(Translation Lookaside Buffer,快表)无效。因为页表项已经改变,所以需要使 TLB 无效,以防止 CPU 使用过时的映射信息。
page_insert
这段代码的作用是将物理页面 pp
映射到虚拟地址 va
。
int
page_insert(pde_t *pgdir, struct PageInfo *pp, void *va, int perm)
{
pte_t *pte;
// 使用 pgdir_walk 获取虚拟地址 va 的页表项
pte = pgdir_walk(pgdir, va, 1);
if (!pte)
return -E_NO_MEM; // 如果获取页表项失败(例如,内存不足),返回 -E_NO_MEM
// 如果页表项存在,使用 page_remove 取消映射
if (*pte & PTE_P) {
if (PTE_ADDR(*pte) == page2pa(pp)) {
// 如果重复插入同一个页面,只需要更新权限
*pte = page2pa(pp) | perm | PTE_P;
return 0;
}
page_remove(pgdir, va);
}
// 将物理页面 pp 映射到虚拟地址 va
*pte = page2pa(pp) | perm | PTE_P;
// 增加引用计数
pp->pp_ref++;
return 0;
}
函数的参数包括:
pgdir
:页目录的指针,它是一个数组,每个元素都是一个页目录项(Page Directory Entry,简称 PDE)。pp
:需要映射的物理页面,它是一个PageInfo
结构的指针,这个结构包含了物理页面的信息。va
:需要映射的虚拟地址。perm
:映射的权限位。
函数的主要步骤如下:
- 使用
pgdir_walk
函数获取虚拟地址va
的页表项。如果页表项不存在,函数返回NULL
。 - 如果页表项存在,那么使用
page_remove
函数取消映射。如果重复插入同一个页面,只需要更新权限。 - 将物理页面
pp
映射到虚拟地址va
,并设置权限位。 - 增加物理页面
pp
的引用计数。
这个函数主要用于在虚拟地址空间中映射物理页面。
总结
本文结合具体的代码讲解了虚拟内存的映射过程,接下来将会讲解内核如何调用这些代码进一步建立映射。
此前设置了一个简单的页表,使得内核可以在其链接地址 0xf0100000
运行,尽管它实际上是在 ROM BIOS 上方的物理内存 0x00100000
处加载的。这个页表只映射了 4MB 的内存。接下来会扩展这个映射,使得从虚拟地址 0xf0000000
开始的前 256MB 物理内存和虚拟地址空间的其他一些区域都被映射。先讲解内核地址空间的布局细节,随后增加映射。
内核地址空间
下面是虚拟内存对应的图示,其中定义了内核和用户模式软件都需要的内存管理相关的定义。
/*
* 虚拟内存映射: 权限
* 内核/用户
*
* 4 Gig --------> +------------------------------+ 这是4GB的虚拟内存空间的顶部。在32位系统中,虚拟内存地址范围是0到4GB。
* | | RW/--
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* : . :
* : . :
* : . :
* |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~| RW/--
* | | RW/--
* | 重新映射的物理内存 | RW/--
* | | RW/--
* KERNBASE, ----> +------------------------------+ 0xf0000000 --+ 内核空间的开始,它在虚拟地址空间的高端。0xf0000000 意味着内核空间是1GB。
* KSTACKTOP | CPU0的内核栈 | RW/-- KSTKSIZE | KSTACKTOP是内核栈的顶部,每个CPU都有自己的内核栈。
* | - - - - - - - - - - - - - - -| |
* | 无效内存 (*) | --/-- KSTKGAP | 一个保护页,用于防止内核栈溢出。
* +------------------------------+ |
* | CPU1的内核栈 | RW/-- KSTKSIZE |
* | - - - - - - - - - - - - - - -| PTSIZE
* | 无效内存 (*) | --/-- KSTKGAP |
* +------------------------------+ |
* : . : |
* : . : |
* MMIOLIM ------> +------------------------------+ 0xefc00000 --+ 内存映射I/O的上限。
* | 内存映射的I/O | RW/-- PTSIZE
* ULIM, MMIOBASE --> +------------------------------+ 0xef800000 用户空间的上限和内存映射I/O的基址。
* | 当前页表 (用户 R-) | R-/R- PTSIZE
* UVPT ----> +------------------------------+ 0xef400000 用户虚拟页表的地址。
* | RO PAGES | R-/R- PTSIZE
* UPAGES ----> +------------------------------+ 0xef000000 页结构的只读副本的地址。
* | RO ENVS | R-/R- PTSIZE
* UTOP,UENVS ------> +------------------------------+ 0xeec00000 用户可访问的虚拟内存的顶部和环境结构的只读副本的地址。
* UXSTACKTOP -/ | 用户异常栈 | RW/RW PGSIZE 用户异常栈的顶部。
* +------------------------------+ 0xeebff000
* | 空内存 (*) | --/-- PGSIZE
* USTACKTOP ---> +------------------------------+ 0xeebfe000 普通用户栈的顶部。
* | 正常用户栈 | RW/RW PGSIZE
* +------------------------------+ 0xeebfd000
* | |
* | |
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* . .
* . .
* . .
* |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
* | 程序数据和堆 |
* UTEXT --------> +------------------------------+ 0x00800000 用户程序开始的地方。
* PFTEMP -------> | 空内存 (*) | PTSIZE
* | |
* UTEMP --------> +------------------------------+ 0x00400000 --+ 临时页映射的地址。
* | 空内存 (*) | |
* | - - - - - - - - - - - - - - -| |
* | 用户STAB数据 (可选) | PTSIZE
* USTABDATA ----> +------------------------------+ 0x00200000 | 用户级STAB数据的地址。
* | 空内存 (*) | |
* 0 ------------> +------------------------------+ --+ 虚拟内存空间的底部,地址为0。
*
*/
这个虚拟内存映射图示提供了操作系统如何管理和使用虚拟内存的视图,包括内核空间、用户空间、内存映射 I/O 等等。
内核空间的映射
接下来建立三个映射,首先要在虚拟地址 UPAGES 处映射 'pages' 数组。
boot_map_region(kern_pgdir, UPAGES, pages_size, PADDR(pages), PTE_U | PTE_P);
这段代码的目的是在虚拟地址 UPAGES 处映射'pages'数组。'pages'数组是一个包含所有物理页面信息的数组,每个元素是一个struct PageInfo
结构体,表示一个物理页面的状态。
boot_map_region
函数实现细节上一篇文章已经讲过,用于在虚拟地址和物理地址之间建立映射。这里,它将虚拟地址 UPAGES 映射到'pages'数组的物理地址(通过PADDR(pages)
获取)。映射的大小是pages_size
,权限设置为PTE_U | PTE_P
,表示用户和内核都可以读取,但不能写入。这样,操作系统就可以通过访问虚拟地址 UPAGES 来访问和管理所有的物理页面信息了。
随后为内核栈分配物理内存。内核栈从虚拟地址 KSTACKTOP 向下增长。整个范围[KSTACKTOP-PTSIZE, KSTACKTOP)
都是内核栈,但将其分为两部分:
boot_map_region(kern_pgdir, KSTACKTOP - KSTKSIZE, KSTKSIZE, PADDR(bootstack), PTE_W);
[KSTACKTOP-KSTKSIZE, KSTACKTOP)
:由物理内存支持。[KSTACKTOP-PTSIZE, KSTACKTOP-KSTKSIZE)
:不由物理内存支持;因此,如果内核溢出其栈,它将出错而不是覆盖内存。这被称为"保护页"。
即分别对应下图中的两部分,其中 Invalid Memory 用作保护。
KERNBASE, ----> +------------------------------+ 0xf0000000 --+
KSTACKTOP | CPU0's Kernel Stack | RW/-- KSTKSIZE |
| - - - - - - - - - - - - - - -| |
| Invalid Memory (*) | --/-- KSTKGAP |
+------------------------------+ |
权限设置为:内核可读写,用户无权限。
上述代码将虚拟地址[KSTACKTOP-KSTKSIZE, KSTACKTOP)
映射到'bootstack'所指向的物理内存。这样,内核就可以使用这段内存作为其栈空间。
这种设计是为了防止内核栈溢出导致的内存覆盖问题。当内核栈溢出时,由于保护页没有物理内存支持,所以会触发一个错误,而不是覆盖其他内存区域。这样可以提高系统的稳定性和安全性。
boot_map_region(kern_pgdir, KERNBASE, 0xffffffff - KERNBASE, 0, PTE_W);
在这个例子中,虚拟地址范围[KERNBASE, 2^32)
被映射到物理地址范围[0, 2^32 - KERNBASE)
。虽然我们可能没有2^32 - KERNBASE
字节的物理内存,但我们仍然设置了映射。这是因为,即使物理内存不足,操作系统也需要能够访问整个虚拟地址空间。
这种设计可以简化内存管理,并提高系统的效率和安全性。因为内核可以直接通过虚拟地址访问任何物理内存,而无需进行复杂的地址转换。同时,由于用户进程不能直接访问物理内存,因此可以防止用户进程误操作或恶意操作导致的系统崩溃或数据泄露。
OS 内核篇
这部分内容讲解 OS 内核写入内存,初始化,建立虚拟内存的细节。
这篇文章结合具体的代码讲解什么是进程,在内核中是如何表示一个进程的。
什么是进程?
进程是计算机中的程序关于某个数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位。具体来说,进程是一个具有一定独立功能的程序关于一个数据集合的一次动态执行过程。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
下面这段代码定义了一个名为Env
的结构体,它代表了一个进程(在这个上下文中,可以理解为一个进程)。Env
结构体中包含了以下字段:
此外,代码还定义了一个名为EnvType
的枚举类型,用于表示特殊的进程类型。目前只定义了一个值ENV_TYPE_USER
,表示用户类型的进程。
struct Env {
struct Trapframe env_tf; // 保存的寄存器状态
struct Env *env_link; // 指向下一个空闲的`Env`的指针
envid_t env_id; // 进程的唯一标识符
envid_t env_parent_id; // 父进程的唯一标识符
enum EnvType env_type; // 表示特殊系统进程的类型,例如区分用户进程或内核进程
unsigned env_status; // 进程的状态
uint32_t env_runs; // 进程运行的次数
// Address space
pde_t *env_pgdir; // 页目录的内核虚拟地址
};
如何区分不同的进程?
通过进程 id 来区分不同的进程,即envid_t
是一个 32 位的整数,用于唯一标识一个进程。envid_t ID 的结构如下:
- 最高位(第 32 位)是符号位,如果小于零表示错误,大于零表示其他进程,等于零表示当前进程。
- 中间的 21 位(第 11 位到第 31 位)是唯一标识符(Uniqueifier),用于区分在不同时间创建但共享相同进程索引的进程。
- 最低的 10 位(第 1 位到第 10 位)是进程索引(Environment Index),等于进程在
envs[]
数组中的索引。
+1+---------------21-----------------+--------10--------+
|0| Uniqueifier | Environment |
| | | Index |
+------------------------------------+------------------+
\--- ENVX(eid) --/
这种设计使得进程 ID 既包含了进程的唯一标识,又包含了进程在进程数组中的位置,从而方便了进程的管理和查找。
进程状态有哪些?
下面这段代码定义了一个枚举类型,用于表示Env
结构体中的env_status
字段的状态。env_status
字段表示该进程的运行状态。枚举类型中的每个值代表一种可能的状态:
ENV_FREE
:进程是空闲的,没有运行任何进程。ENV_DYING
:进程正在结束,进程正在被销毁。ENV_RUNNABLE
:进程中的进程可以运行,但当前没有运行。ENV_RUNNING
:进程中的进程正在运行。ENV_NOT_RUNNABLE
:进程中的进程不能运行。
这种设计使得操作系统可以通过检查env_status
字段来快速确定一个进程的状态,从而决定如何管理和调度进程中的进程。
进程初始化
在 OS 中,每个进程都有自己的地址空间,这个地址空间是由操作系统管理的。OS 会将进程数组 'envs' 映射到 UENVS 处,使得用户程序可以读取但不能写入这个数组。这样做的目的是为了让用户程序能够获取到进程的信息,但是不能修改这些信息,以保证系统的稳定性和安全性。
'envs' 数组存储了系统中所有进程的信息,每个元素是一个 'struct Env' 结构体,包含了进程的状态、进程的页目录、进程的运行时间等信息。通过将 'envs' 数组映射到用户空间,用户程序可以读取这些信息,了解系统的运行状态,例如可以查看其他进程的状态,或者计算自己的 CPU 使用率等。
下面是具体的申请内存,将 'envs' 数组映射到用户空间的具体代码,和此前 Page 的映射过程类似。
// 申请内存
size_t envs_size = ROUNDUP(NENV * sizeof(struct Env), PGSIZE);
envs = (struct Env *) boot_alloc(envs_size);
memset(envs, 0, envs_size);
// ...
// 在虚拟地址 UPAGES 处映射 'pages' 数组
boot_map_region(kern_pgdir, UENVS, envs_size, PADDR(envs), PTE_U | PTE_P);
总的来说,将 'envs' 数组映射到 UENVS 处,是为了让用户程序能够访问到进程的信息,同时防止用户程序修改这些信息,保证系统的稳定性和安全性。
下面是 UENVS 在虚拟内存中的具体位置。
UVPT ----> +------------------------------+ 0xef400000
| RO PAGES | R-/R- PTSIZE
UPAGES ----> +------------------------------+ 0xef000000
| RO ENVS | R-/R- PTSIZE
UTOP,UENVS ------> +------------------------------+ 0xeec00000
UXSTACKTOP -/ | User Exception Stack | RW/RW PGSIZE
+------------------------------+ 0xeebff000
| Empty Memory (*) | --/-- PGSIZE
USTACKTOP ---> +------------------------------+ 0xeebfe000
UXSTACKTOP
:这是用户异常栈的顶部。当用户模式的代码触发异常时,处理器会切换到这个栈。
这些地址的定义对于操作系统来说非常重要,因为它们定义了用户空间的内存布局和权限。例如,UVPT
、UPAGES
和 UENVS
都是只读的,这意味着用户程序不能修改这些区域的内容,但可以读取它们。这有助于保护操作系统的关键数据结构不被恶意或错误的用户程序修改。
初始化进程
接下来会初始化进程数组 'envs' 中的状态,下面是具体的初始化代码:
void
env_init(void)
{
int i;
for (i = NENV - 1; i >= 0; --i) {
envs[i].env_id = 0;
envs[i].env_status = ENV_FREE;
envs[i].env_link = env_free_list;
env_free_list = &envs[i];
}
// Per-CPU part of the initialization
env_init_percpu();
}
这段代码的功能是初始化进程数组 envs
。在操作系统中,这段代码的主要任务是将所有进程标记为自由(即未被使用),并将它们插入到自由进程列表 env_free_list
中。
代码首先定义了一个整数 i
,然后使用一个倒序的循环遍历 envs
数组。对于数组中的每个进程,它将进程的 env_id
设置为 0,将进程的状态 env_status
设置为 ENV_FREE
,然后将进程的 env_link
设置为当前的 env_free_list
。然后,它将 env_free_list
更新为当前进程。这样,env_free_list
就成为了一个链表,链表中的每个元素都是一个自由的进程,链表的顺序与 envs
数组中的顺序相同。
最后,代码调用 env_init_percpu
函数来完成每个 CPU 的进程初始化。这是因为在多处理器系统中,每个处理器都有自己的当前进程。
总的来说,这段代码的目的是为操作系统创建和初始化一个进程池,操作系统可以从这个进程池中分配新的进程来运行新的进程或线程。
进程相关的虚拟内存设置
在操作系统中,每个进程都有自己的虚拟地址空间,这个地址空间被分为用户空间和内核空间两部分。用户空间是每个进程独有的,用于存放进程的代码、数据和堆栈等。而内核空间则是所有进程共享的,主要用于存放内核代码和数据,以及为内核模式下的执行提供运行进程。
内核空间对所有进程是共享的,主要有以下几个原因:
-
提高效率:如果每个进程都有自己的内核空间,那么在进程切换时,除了要保存和恢复用户空间的状态,还需要保存和恢复内核空间的状态,这将大大增加进程切换的开销。而如果内核空间是共享的,那么在进程切换时,就无需切换内核空间,从而可以提高效率。
-
方便通信:内核空间是所有进程共享的,这意味着一个进程可以通过在内核空间中留下信息,来与其他进程或者内核进行通信。
-
简化设计:如果内核空间对每个进程都不同,那么内核就需要处理更多的复杂情况,比如说,当一个进程在用户模式下运行时,它可能需要访问的内核空间是一个版本,而当它在内核模式下运行时,可能需要访问的内核空间又是另一个版本。这将大大增加设计和实现的复杂性。
因此,为了提高效率、方便通信以及简化设计,操作系统通常会选择让所有进程共享内核空间。
下面的代码实现为每个进程申请对应的虚拟内存,并且设置了共享内核空间。
static int
env_setup_vm(struct Env *e)
{
int i;
struct PageInfo *p = NULL;
// Allocate a page for the page directory
if (!(p = page_alloc(ALLOC_ZERO)))
return -E_NO_MEM;
// 现在,设置 e->env_pgdir 并初始化页目录。
e->env_pgdir = (pde_t *)page2kva(p);
// 增加页目录的引用计数 p->pp_ref++;
p->pp_ref++;
// 复制内核的地址空间
memcpy(e->env_pgdir, kern_pgdir, PGSIZE);
// UVPT 将 env 的自己的页表映射为只读。
// 权限: kernel R, user R
e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_P | PTE_U;
return 0;
}
这段代码的目的是为新的进程设置和初始化其虚拟内存布局。以下是每个步骤的详细解释:
-
分配一个页面用于页目录:在 x86 架构中,页目录是用于管理虚拟内存到物理内存映射的数据结构。每个进程都需要有自己的页目录。
-
设置
e->env_pgdir
并初始化页目录:e->env_pgdir
是指向页目录的指针,这一步将其设置为新分配的页目录,并进行初始化。 -
增加页目录的引用计数:这是为了防止在还有进程使用该页目录时被错误地释放。
-
复制内核的地址空间到新进程的页目录中:这是因为在用户空间和内核空间中,内核空间是共享的,每个进程的页目录中的内核部分都是一样的。
-
设置
UVPT
映射进程自己的页表为只读:UVPT
是一个特殊的虚拟地址,在用户空间的顶部,使得进程可以读取自己的页表。这是为了让进程能够查看和修改自己的内存布局,但是为了安全,它是只读的。
总的来说,这段代码的目的是创建和初始化一个新的进程的虚拟内存布局。
如何创建进程?
接下来讲解如何创建一个进程,下面的这段代码是在操作系统内核中创建一个新的进程的函数。函数的输入参数是一个二进制文件和一个进程类型。
//
// 使用 env_alloc 分配一个新的进程,
// 使用 load_icode 将指定的 elf 二进制文件加载到其中,并设置其 env_type。
// 此函数仅在内核初始化期间调用,运行第一个用户模式进程之前。
// 新进程的父 ID 设置为 0。
//
void
env_create(uint8_t *binary, enum EnvType type)
{
struct Env *newenv;
int ret;
// 分配一个新的进程
ret = env_alloc(&newenv, 0);
if (ret < 0) {
panic("env_create: env_alloc failed");
}
// 加载 ELF 二进制文件到新进程中
load_icode(newenv, binary);
// 设置进程类型
newenv->env_type = type;
// 如果新进程的类型是用户进程,那么将其设置为可运行状态
if (newenv->env_type == ENV_TYPE_USER) {
newenv->env_status = ENV_RUNNABLE;
}
}
-
首先,函数定义了一个新的进程变量
newenv
和一个返回值ret
。 -
然后,函数调用
env_alloc
函数来分配一个新的进程。env_alloc
的输入参数是新进程的父进程的 ID,这里设置为 0,表示这个新进程没有父进程。如果env_alloc
函数返回值小于 0,表示进程分配失败,函数将会 panic 并输出错误信息。 -
如果进程分配成功,函数将调用
load_icode
函数来加载 ELF 二进制文件到新进程中。ELF 二进制文件通常是一个可执行程序,load_icode
函数将会把这个程序加载到新进程的地址空间中。 -
加载完二进制文件后,函数将设置新进程的类型。进程类型是一个枚举类型,可能的值包括用户进程、内核进程等。
-
最后,如果新进程的类型是用户进程,那么将其状态设置为可运行状态。这表示操作系统可以开始调度这个进程运行了。
这个函数是操作系统创建新进程或线程的关键步骤,它将一个程序加载到新的进程中,并设置好进程的状态,使得操作系统可以开始运行这个程序。
总结
本文介绍了进程的概念、表示和创建过程,通过具体的代码例子使读者更好地理解操作系统中进程的实现。接下来讲解如何将程序加载到虚拟内存中。
这篇文章讲解如何将程序加载到虚拟内存中?文件是以 ELF 格式组织起来的,接下来先讲解 ELF 格式。
ELF 文件格式
这段代码是关于 ELF (Executable and Linkable Format) 文件格式的定义。ELF 是一种常见的二进制文件格式,用于存储程序或库。在这个文件中,定义了一些结构体和常量,用于解析和处理 ELF 文件。
-
struct Elf
:这个结构体代表了 ELF 文件的头部信息。包括魔数、类型、机器类型、版本、入口点地址、程序头部偏移、节头部偏移、标志、头部大小等信息。 -
struct Proghdr
:这个结构体代表了 ELF 文件的程序头部信息。包括类型、偏移、虚拟地址、物理地址、文件大小、内存大小、标志、对齐等信息。 -
struct Secthdr
:这个结构体代表了 ELF 文件的节头部信息。包括名称、类型、标志、地址、偏移、大小、链接、信息、地址对齐、条目大小等信息。 -
ELF_PROG_LOAD
、ELF_PROG_FLAG_EXEC
、ELF_PROG_FLAG_WRITE
、ELF_PROG_FLAG_READ
等常量:这些常量用于解析和处理 ELF 文件的程序头部信息。 -
ELF_SHT_NULL
、ELF_SHT_PROGBITS
、ELF_SHT_SYMTAB
、ELF_SHT_STRTAB
等常量:这些常量用于解析和处理 ELF 文件的节头部信息。
这些定义是操作系统加载和运行程序的基础,通过这些定义,操作系统可以正确地解析 ELF 文件,将程序加载到内存中,并执行。
ELF 文件在内存中的表示
在内存中,ELF 文件的加载会将其各个部分放置到相应的地址空间中。以下是一个简化的 ELF 文件在内存中的文本图形化表示:
+-------------------------+
| ELF File Layout |
+-------------------------+
| |
| ELF Header |
| |
+-------------------------+ <-- 0x08048000 (ELF默认起始地址)
| Program Headers |
| |
+-------------------------+
| Section Headers |
| |
+-------------------------+
| Section 1 (.text) |
| |
+-------------------------+
| Section 2 (.data) |
| |
+-------------------------+
| Section 3 (.bss) |
| |
+-------------------------+
| Section 4 (.rodata) |
| |
+-------------------------+
| Dynamic Section |
| |
+-------------------------+
| String Table |
| |
+-------------------------+
| Symbol Table |
| |
+-------------------------+
| Relocation Table |
| |
+-------------------------+
| Stack (grows down) |
| |
+-------------------------+ <-- Bottom of Memory
解释:
-
ELF Header 包含有关整个 ELF 文件的信息,例如入口点地址、程序头和节头的偏移量等。ELF Header 在内存中的地址通常是 ELF 文件默认起始地址(0x08048000)。
-
Program Headers 包含指向各个段的信息,如代码段、数据段等。每个 Program Header 指定了在内存中的加载位置、大小等信息。
-
Section Headers 包含有关各个节的信息,如代码节、数据节等。每个 Section Header 指定了在内存中的加载位置、大小等信息。
-
各个 Section 包括代码、数据、bss(未初始化数据)、只读数据等。
-
Dynamic Section 包含动态链接信息,用于运行时动态链接和共享库加载。
-
String Table 包含节名、符号名等字符串。
-
Symbol Table 包含符号信息,如函数名、变量名等。
-
Relocation Table 包含需要在加载时进行重定位的信息,以修正代码和数据的引用地址。
-
Stack 是程序运行时使用的堆栈,通常从内存底部开始向上增长。
请注意,实际情况可能更加复杂,具体取决于 ELF 文件的结构和加载方式。
加载 ELF 到进程中
load_icode
函数是用于加载 ELF 二进制文件到新创建的进程中的函数。它接受两个参数:一个是新创建的进程的指针,另一个是指向 ELF 二进制文件的指针。
-
首先,函数会检查 ELF 文件的魔数是否正确。如果不正确,函数会触发 panic。
-
然后,函数会获取 ELF 文件的程序头部信息。程序头部信息描述了如何将 ELF 文件加载到内存中。
-
接着,函数会遍历所有的程序头部信息。对于每一个类型为
ELF_PROG_LOAD
的程序头部,函数会分配相应的内存,并将 ELF 文件的相应部分加载到内存中。如果程序头部的内存大小大于文件大小,函数会将多余的部分清零。 -
加载完所有的程序段后,函数会设置进程的入口点地址。这个地址是程序开始执行的地方。
-
最后,函数会为程序的初始堆栈分配一个页面。这个页面位于虚拟地址
USTACKTOP - PGSIZE
。
这个函数是操作系统加载和运行程序的关键步骤,它将一个程序加载到新的进程中,并设置好进程的状态,使得操作系统可以开始运行这个程序。
程序头部组成
下面是程序头部信息的文本图形化表示:
+-----------------------+
| Program Header 0 | <- ph
+-----------------------+
| p_type |
| p_offset |
| p_va |
| p_pa |
| p_filesz |
| p_memsz |
| p_flags |
| p_align |
+-----------------------+
| Program Header 1 |
+-----------------------+
| p_type |
| p_offset |
| p_va |
| p_pa |
| p_filesz |
| p_memsz |
| p_flags |
| p_align |
+-----------------------+
| ... |
+-----------------------+
| Program Header n | <- eph
+-----------------------+
| p_type |
| p_offset |
| p_va |
| p_pa |
| p_filesz |
| p_memsz |
| p_flags |
| p_align |
+-----------------------+
上述表示形式中,ph
到 eph
之间的每一项代表一个程序头部。每个程序头部包含了描述如何将 ELF 文件加载到内存中的信息。根据实际情况,n
的值可能会不同,具体取决于 ELF 文件中的程序头部数量。
加载程序段
下面这段代码是从 ELF (Executable and Linkable Format) 文件中加载程序段的部分。它遍历 ELF 文件的所有程序头部,只加载类型为 ELF_PROG_LOAD
的段。
struct Proghdr *ph, *eph;
ph = (struct Proghdr *)(binary + elf_hdr->e_phoff);
eph = ph + elf_hdr->e_phnum;
// Load each program segment
for (; ph < eph; ph++) {
if (ph->p_type != ELF_PROG_LOAD) {
continue;
}
// Allocate memory for the segment
region_alloc(e, (void *)ph->p_va, ph->p_memsz);
// Load the segment
memcpy((void *)ph->p_va, binary + ph->p_offset, ph->p_filesz);
// Zero the remaining part
if (ph->p_memsz > ph->p_filesz) {
memset((void *)(ph->p_va + ph->p_filesz), 0, ph->p_memsz - ph->p_filesz);
}
}
-
region_alloc(e, (void *)ph->p_va, ph->p_memsz);
:这行代码为程序段分配内存。ph->p_va
是程序段应该被加载到的虚拟地址,ph->p_memsz
是程序段在内存中的大小。 -
memcpy((void *)ph->p_va, binary + ph->p_offset, ph->p_filesz);
:这行代码将程序段加载到内存中。binary + ph->p_offset
是程序段在 ELF 文件中的位置,ph->p_filesz
是程序段在 ELF 文件中的大小。 -
memset((void *)(ph->p_va + ph->p_filesz), 0, ph->p_memsz - ph->p_filesz);
:如果程序段的内存大小大于文件大小,这行代码将多余的部分清零。
总的来说,这段代码将 ELF 文件的每个程序段加载到指定的虚拟地址中,并将未使用的部分清零。
为进程分配物理内存
这段代码的功能是为进程分配一定长度的物理内存,并将其映射到进程的虚拟地址空间中。这段代码并不会对映射的页面进行零初始化或其他初始化操作。页面应该可以被用户和内核写入。如果任何分配尝试失败,代码将会触发 panic。
这段代码的主要步骤如下:
-
计算需要分配的页面数量,这是通过将请求的长度(len)除以页面大小(PGSIZE)并向上取整得到的。
-
对于每个需要分配的页面,执行以下操作:
- 使用
page_alloc
函数分配一个新的页面。如果分配失败,触发 panic。 - 使用
page_insert
函数将新分配的页面映射到进程的虚拟地址空间中。映射的虚拟地址由参数va
指定,每个新页面的虚拟地址比前一个页面的虚拟地址高一个页面大小(PGSIZE)。如果映射失败,触发 panic。
- 使用
接下来实现如何为进程的虚拟地址空间分配并映射新的物理内存。这在进程需要更多内存来存储数据或代码时非常有用。例如,当进程调用 malloc
函数来请求更多堆内存时,或者当进程需要加载新的代码段时,就可能需要调用这段代码。
static void
region_alloc(struct Env *e, void *va, size_t len)
{
uintptr_t start = ROUNDDOWN((uintptr_t)va, PGSIZE);
uintptr_t end = ROUNDUP((uintptr_t)va + len, PGSIZE);
uintptr_t i;
struct PageInfo *pp;
for (i = start; i < end; i += PGSIZE) {
if (!(pp = page_alloc(0))) {
panic("region_alloc: out of memory");
}
if (page_insert(e->env_pgdir, pp, (void *)i, PTE_W|PTE_U) < 0) {
panic("region_alloc: page_insert failed");
}
}
}
启动进程
下面这段代码是在操作系统中执行上下文切换的函数,从当前环境 curenv
切换到环境 e
。如果这是对 env_run
的第一次调用,curenv
是 NULL
。
void
env_run(struct Env *e)
{
// 如果这是一个上下文切换(即正在运行一个新的环境)
if (curenv && curenv->env_status == ENV_RUNNING) {
// 将当前环境(如果有)的状态设置回ENV_RUNNABLE,如果它当前的状态是ENV_RUNNING
curenv->env_status = ENV_RUNNABLE;
}
// 将'curenv'设置为新的环境
curenv = e;
// 将新环境的状态设置为ENV_RUNNING
curenv->env_status = ENV_RUNNING;
// 更新新环境的'env_runs'计数器
curenv->env_runs++;
// 使用lcr3()切换到新环境的地址空间
lcr3(PADDR(curenv->env_pgdir));
// 使用env_pop_tf()恢复环境的寄存器并在环境中进入用户模式
env_pop_tf(&curenv->env_tf);
}
步骤 1:如果这是一个上下文切换(即正在运行一个新的环境):
- 如果当前环境存在且其状态为
ENV_RUNNING
,则将其状态设置回ENV_RUNNABLE
。 - 将
curenv
设置为新的环境。 - 将新环境的状态设置为
ENV_RUNNING
。 - 更新新环境的
env_runs
计数器。 - 使用
lcr3()
切换到新环境的地址空间。
步骤 2:使用 env_pop_tf()
恢复环境的寄存器并在环境中进入用户模式。
提示:此函数从 e->env_tf
加载新环境的状态。回顾你之前写的代码,确保你已经将 e->env_tf
的相关部分设置为合理的值。
这行代码的作用是切换到新环境的地址空间。lcr3()
是一个用于加载页目录基址寄存器(CR3)的函数,它接受一个物理地址作为参数。在 x86 架构中,CR3 寄存器存储了当前活动页目录的物理地址。当 CR3 的值改变时,处理器会清空或部分清空其转换缓冲区(TLB),这是一种硬件缓存,用于加速虚拟地址到物理地址的转换。
在这个上下文中,PADDR(curenv->env_pgdir)
计算出新环境的页目录的物理地址,然后 lcr3()
将这个地址加载到 CR3 寄存器中。这样,处理器就会开始使用新环境的页目录,从而实现了到新环境的地址空间的切换。这是在操作系统中进行进程或线程上下文切换的关键步骤之一。
总结
这篇文章主要讲解了如何将程序加载到虚拟内存中。首先介绍了 ELF 文件格式,包括其头部信息、程序头部信息和节头部信息等。然后详细解释了如何将 ELF 文件的各个部分加载到内存中,包括为进程分配物理内存、加载程序段和设置进程的入口点等步骤。最后讲解了如何在操作系统中执行上下文切换,从当前环境切换到新的环境。
在 x86 架构中,trap(陷阱)是一种中断,通常由于程序执行错误或者需要操作系统服务而触发。当 CPU 执行到某些指令或者发生某些事件时,会触发 trap,CPU 会停止当前的任务,转而去执行一个预先设定的函数,这个函数通常被称为中断处理程序(Interrupt Handler)或者陷阱处理程序(Trap Handler)。
Trap
Trap 有两种类型:硬件陷阱和软件陷阱。硬件陷阱是由 CPU 硬件触发的,例如除以零、访问非法内存地址等。软件陷阱则是由程序主动触发的,例如系统调用。
在 x86 架构中,每种陷阱都有一个对应的陷阱号(Trap Number),这个陷阱号用于在中断描述符表(Interrupt Descriptor Table,简称 IDT)中查找对应的陷阱处理程序。IDT 是一个存储在内存中的表,每个表项包含一个陷阱处理程序的地址和一些属性。
当陷阱发生时,CPU 会自动保存当前的程序状态(包括各个寄存器的值和程序计数器的值等),然后跳转到对应的陷阱处理程序去执行。陷阱处理程序执行完毕后,CPU 会恢复之前保存的程序状态,然后继续执行被中断的任务。
在操作系统中,trap 机制是非常重要的,它是操作系统实现并发、保护和抽象等功能的基础。例如,操作系统可以通过 trap 机制来实现系统调用、任务切换、内存保护等功能。
示例代码(汇编语言):
; 调用中断向量号为0x80的中断
mov eax, 1 ; 系统调用号,例如1表示退出程序
int 0x80 ; 触发trap
; 中断服务例程
section .text
global _start
_start:
; 系统调用退出程序
mov eax, 1 ; 系统调用号1表示退出程序
xor ebx, ebx ; 返回码为0
int 0x80 ; 触发trap
在上述例子中,int 0x80
触发了一个系统调用,这是通过 trap 来实现的。在真实的操作系统中,中断向量号 0x80 通常用于系统调用。
异常和中断的区别
在计算机系统中,异常和中断是两种重要的机制,它们都可以使处理器从当前的执行流程中跳转出来,去处理某些特定的事件。然而,它们之间存在一些关键的区别:
-
触发方式:中断通常是由外部事件触发的,例如硬件设备发送的信号或者定时器的到期。这些事件与处理器执行的指令流无关,可以在任何时间发生。相反,异常是由处理器执行的指令流中的事件触发的,例如除以零、访问无效内存地址或者执行无效指令。
-
同步性:由于中断是由外部事件触发的,因此它是异步的,可以在任何时间发生。然而,异常是由处理器内部的事件触发的,因此它是同步的,只能在特定的指令执行时发生。
-
处理方式:当中断发生时,处理器会立即停止当前的指令流,保存当前的上下文,然后跳转到一个预定义的中断处理程序去处理这个中断。当中断处理程序完成后,处理器会恢复之前保存的上下文,然后继续执行被中断的指令流。然而,当异常发生时,处理器可能会尝试修复引起异常的问题,例如通过重新执行引起异常的指令或者通过执行一个错误处理程序。如果异常无法被修复,处理器可能会终止引起异常的程序。
-
优先级:中断和异常通常有不同的优先级。在多数系统中,中断的优先级高于异常。这意味着,如果在处理一个异常的过程中发生了一个中断,处理器会暂停异常的处理,去处理这个中断。然而,这种行为可能会根据具体的系统和配置而变化。
总的来说,虽然异常和中断在处理器级别有很多相似之处,但它们在触发方式、同步性、处理方式和优先级等方面有很大的不同。理解这些差异对于理解计算机系统的运行方式非常重要。
受保护的控制转移
"受保护的控制转移"是指处理器在执行过程中,由于某些特定的事件(如异常或中断),会从当前的执行流程中跳转出来,去处理这些事件。这种控制流的转移是受到严格保护的,以防止用户模式的代码干扰内核或其他环境的运行。
在 x86 架构中,这种保护主要通过两种机制来实现:中断描述符表(Interrupt Descriptor Table,IDT)和任务状态段(Task State Segment,TSS)。
通过这两种机制,处理器可以确保在发生中断或异常时,能够安全、可控地从用户模式切换到内核模式,从而实现"受保护的控制转移"。
中断描述符表
在 x86 架构中,中断描述符表(Interrupt Descriptor Table,简称 IDT)是一个特殊的数据结构,用于存储中断处理程序的地址和一些相关属性。当 CPU 接收到一个中断或者陷阱(trap)信号时,会根据信号的类型(也就是中断向量)在 IDT 中查找对应的中断处理程序,然后跳转到该程序去处理中断。
x86 允许最多 256 个不同的中断或异常入口点进入内核,每个入口点都有一个不同的中断向量。向量是一个介于 0 和 255 之间的数字。中断的向量由中断的来源确定:不同的设备、错误条件和应用程序对内核的请求会生成具有不同向量的中断。CPU 使用向量作为处理器的中断描述符表(IDT)的索引,内核在内核私有内存中设置这个表,就像 GDT 一样。从这个表的适当条目中,处理器加载:
- 要加载到指令指针(EIP)寄存器的值,指向内核代码,该代码被指定为处理该类型的异常。
- 要加载到代码段(CS)寄存器的值,其中位 0-1 包含异常处理程序要运行的特权级别。在 JOS 中,所有异常都在内核模式(特权级别 0)下处理。
这种机制确保了中断和异常只能使内核在一些特定的、由内核自身确定的入口点被进入,而不是在中断或异常被接收时运行的代码。
任务状态段
任务状态段(Task State Segment,简称 TSS)是 x86 架构中的一个特殊数据结构,用于保存处理器的状态。当发生中断或异常,导致从用户模式切换到内核模式时,处理器需要一个地方来保存旧的处理器状态,例如在调用异常处理程序之前的 EIP 和 CS 的原始值,这样异常处理程序就可以恢复旧的状态,并从中断的地方继续执行代码。但是,这个用于保存旧处理器状态的区域必须受到保护,防止非特权的用户模式代码访问,否则可能会被有缺陷或恶意的用户代码破坏内核。
因此,当 x86 处理器接收到一个导致从用户模式切换到内核模式的中断或陷阱时,它也会切换到内核内存中的一个栈。任务状态段(TSS)指定了这个栈在哪里以及它的段选择子和地址。处理器在这个新栈上压入 SS、ESP、EFLAGS、CS、EIP 和一个可选的错误代码。然后它从中断描述符加载 CS 和 EIP,并设置 ESP 和 SS 指向新的栈。
虽然 TSS 很大并且可以用于各种目的,但是 JOS 只使用它来定义当从用户模式切换到内核模式时处理器应该切换到的内核栈。由于在 JOS 中,“内核模式”是 x86 上的特权级别 0,所以处理器在进入内核模式时使用 TSS 的 ESP0 和 SS0 字段来定义内核栈。JOS 不使用 TSS 的任何其他字段。
一个具体的例子
处理器在处理异常或中断时,需要保存当前的处理器状态,以便在处理完异常或中断后能够恢复并继续执行被中断的代码。这就需要一个安全的地方来保存这些状态,这个地方就是内核栈。
当处理器从用户模式切换到内核模式以处理异常或中断时,它会自动切换到内核栈,并在这个栈上保存当前的处理器状态。这样做的目的是为了保护内核的稳定性和安全性,因为内核栈位于内核空间,用户程序无法直接访问,这就避免了用户程序可能对内核栈的恶意修改。同时,通过保存和恢复处理器状态,确保了即使在发生异常或中断的情况下,处理器也能正确地执行程序。
假设处理器正在执行用户环境中的代码,并遇到一个试图除以零的除法指令。处理器切换到由 TSS 的 SS0 和 ESP0 字段定义的栈,它位于内核空间的顶部。这个栈的具体位置由 ESP0 字段指定,其值为 KSTACKTOP,这是内核栈顶的地址。SS0 字段保存的是内核数据段的段选择子 GD_K。处理器将异常参数推入内核栈,从地址 KSTACKTOP 开始:
+--------------------+ KSTACKTOP
| 0x00000 | old SS | " - 4
| old ESP | " - 8
| old EFLAGS | " - 12
| 0x00000 | old CS | " - 16
| old EIP | " - 20 <---- ESP
+--------------------+
这些参数是在处理器处理异常或中断时保存的当前处理器状态。它们的作用如下:
-
old SS
:旧的堆栈段选择器,用于在从用户模式切换到内核模式时更改堆栈段选择器,以使用内核栈。 -
old ESP
:旧的堆栈指针,用于在从用户模式切换到内核模式时更改堆栈指针,以使用内核栈。 -
old EFLAGS
:旧的状态寄存器,包含了许多重要的状态位,如中断使能位、方向位、溢出位等,用于在处理完异常或中断后恢复原来的状态。 -
old CS
:旧的代码段选择器,用于在从用户模式切换到内核模式时更改代码段选择器,以执行内核代码。 -
old EIP
:旧的指令指针,用于在处理异常或中断时更改指令指针,以执行异常或中断处理程序。
这些参数的保存和恢复是处理器处理异常或中断的重要步骤,它们确保了处理器能够在处理完异常或中断后正确地恢复到原来的状态,继续执行被中断的代码。
在处理除法错误(x86 上的中断向量 0)时,处理器会读取中断描述符表(IDT)的条目 0,并将代码段选择器(CS)和指令指针(EIP)设置为指向该条目描述的处理函数。处理函数接管控制并处理异常,例如可能会终止引起异常的用户环境。用户环境通常指的是运行在用户模式下的程序或进程。
错误码
对于某些类型的 x86 异常,除了保存的内容之外,处理器还会在栈上推入另一个包含错误代码的字。页面错误异常,编号 14,是一个重要的例子。当处理器推送错误代码时,从用户模式进入异常处理程序开始时,栈的布局如下:
+--------------------+ KSTACKTOP
| 0x00000 | old SS | " - 4
| old ESP | " - 8
| old EFLAGS | " - 12
| 0x00000 | old CS | " - 16
| old EIP | " - 20
| error code | " - 24 <---- ESP
+--------------------+
这里的error code
就是处理器推送的错误代码。在 x86 架构中,处理器会为某些类型的异常推送错误代码到栈上。这个错误代码可以帮助异常处理程序更准确地知道发生了什么异常,从而进行更准确的处理。JOS 中已经定义了一些错误代码:
// 页错误错误代码
#define FEC_PR 0x1 // 页错误由保护违规引起
#define FEC_WR 0x2 // 页错误由写操作引起
#define FEC_U 0x4 // 页错误在用户模式下发生
这些错误代码是用于页面错误异常(编号 14)的。当发生页面错误异常时,处理器会将这些错误代码推送到栈上。
嵌套异常和中断
当处理器从用户模式切换到内核模式时,x86 处理器会自动切换堆栈,然后将其旧的寄存器状态推送到堆栈上,并通过中断描述符表(IDT)调用相应的异常处理程序。然而,如果处理器已经处于内核模式,当中断或异常发生时(CS 寄存器的低 2 位已经为零,也就是说处理器当前正在运行的代码是在内核模式下执行的),CPU 只是在同一个内核堆栈上推送更多的值。
因此,当处理器已经处于内核模式时,如果发生中断或异常,处理器不需要从用户模式切换到内核模式,也就不需要切换堆栈。相反,它会在当前的内核堆栈上推送更多的值,以保存当前的执行环境。这样,内核就可以处理由内核自身代码引起的嵌套异常。这样,内核可以优雅地处理由内核自身代码引起的嵌套异常。这种能力是实现保护的重要工具,我们将在后面的系统调用部分看到。
如果处理器已经处于内核模式并且发生了嵌套异常,由于它不需要切换堆栈,因此它不会保存旧的 SS 或 ESP 寄存器。对于不推送错误代码的异常类型,内核堆栈在进入异常处理程序时看起来如下:
+--------------------+ <---- old ESP
| old EFLAGS | " - 4
| 0x00000 | old CS | " - 8
| old EIP | " - 12
+--------------------+
对于推送错误代码的异常类型,处理器在旧的 EIP 后立即推送错误代码,如前所述。
总结
本文介绍了在 x86 架构中中断描述符表(IDT)的作用和结构。Trap 是一种中断,有硬件陷阱和软件陷阱,每种陷阱对应一个陷阱号,在 IDT 中查找对应的陷阱处理程序。IDT 包含了处理程序的地址和属性,触发陷阱时 CPU 保存状态并跳转执行处理程序。文章还涵盖了异常和中断的区别,以及受保护的控制转移通过 IDT 和 TSS 实现。最后,通过具体示例和栈布局展示了异常处理的过程。
这篇文章结合一个具体的系统调用来讲解如何实现用户态切换到内核态,再从内核态切换到用户态的详细流程。
获取进程 ID 的系统调用
sys_getenvid()
是一个系统调用,它的作用是获取当前进程的进程 ID。在操作系统中每个进程都有一个唯一的进程 ID,这个 ID 是由内核分配的。通过sys_getenvid()
系统调用,进程可以知道自己的进程 ID。
在代码中,sys_getenvid()
被用于获取当前进程的进程 ID,然后将其作为参数传递给cprintf
函数,用于打印消息。这样,你可以知道是哪个进程发送了消息。下面是用户态程序调用 sys_getenvid() 的具体例子。
cprintf("i am %08x; thisenv is %p\n", sys_getenvid(), thisenv);
获取进程 ID 需要通过系统调用来实现,主要是因为进程 ID 是由操作系统内核管理和分配的。在操作系统中,每个进程都有一个唯一的 ID,这个 ID 是由内核在创建进程时分配的。进程自身无法直接获取或修改这个 ID,因为这会破坏操作系统的安全性和稳定性。
系统调用是用户空间进程与内核空间进行交互的一种机制。通过系统调用,用户空间的进程可以请求内核提供服务,比如创建进程、打开文件、获取进程 ID 等。当进程执行系统调用时,会发生上下文切换,从用户模式切换到内核模式。在内核模式下,操作系统可以访问受保护的内核数据结构,并执行可能影响整个系统的操作。
获取进程 id 的实现细节
通过sys_getenvid()
系统调用可以实现获取进程 ID,这样可以保证操作系统的安全性和稳定性,防止用户进程直接访问和修改内核数据结构。
这个系统调用在lib/syscall.c
文件中实现,具体的实现代码如下:
static inline envid_t
sys_getenvid(void)
{
return syscall(SYS_getenvid, 0, 0, 0, 0, 0, 0);
}
其中,syscall
函数是一个通用的系统调用函数,它将系统调用号和参数传递给内核。SYS_getenvid
是sys_getenvid
系统调用的系统调用号。
syscall
函数是一个系统调用的通用实现。它接受一个系统调用号和最多五个参数,然后通过中断指令int
来触发一个系统调用。
static inline int32_t
syscall(int num, int check, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
int32_t ret;
asm volatile("int %1\n" //执行int T_SYSCALL指令
: "=a" (ret)
: "i" (T_SYSCALL),
"a" (num),
"d" (a1),
"c" (a2),
"b" (a3),
"D" (a4),
"S" (a5)
: "cc", "memory");
if(check && ret > 0)
panic("syscall %d returned %d (> 0)", num, ret);
return ret;
}
函数的参数如下:
num
:系统调用号,用于指定要执行的系统调用。check
:一个标志,如果为真并且系统调用返回值大于 0,则会触发一个内核恐慌。a1
到a5
:这是系统调用的参数,最多可以有五个。
函数的主体是一个内联汇编语句,它执行int
指令来触发一个系统调用。在这个汇编语句中,系统调用号被放在AX
寄存器中,参数被放在DX
,CX
,BX
,DI
和SI
寄存器中。然后,int
指令会触发一个中断,中断号是T_SYSCALL
,这是系统调用的中断号。
这个汇编语句的输出是一个名为ret
的变量,它包含了系统调用的返回值。如果check
参数为真并且ret
大于 0,那么函数会触发一个内核 panic 。否则,函数会返回ret
。
这个函数的主要作用是提供一个通用的方式来执行系统调用。在代码中,所有的系统调用都是通过这个函数来执行的。例如,sys_cputs
,sys_cgetc
,sys_getenvid
等函数都是通过调用syscall
函数来实现的。
从 syscall 跳转到 trap 的细节
在lib/syscall.c
中的syscall
函数和kern/trap.c
中的trap
函数之间的跳转主要是通过硬件中断和操作系统的中断处理机制实现的。
当用户态程序需要请求内核提供服务时,它会执行一个特殊的指令(在 x86 架构中,这个指令是int 0x30
),这个指令会触发一个系统调用中断。这个中断的中断号是T_SYSCALL
,在kern/trap.h
中定义。
当这个中断发生时,CPU 会自动保存当前的执行环境(包括各个寄存器的值、程序计数器等),然后跳转到 IDT 中对应的中断处理函数去执行。在这个过程中,CPU 会从用户态切换到内核态。
在kern/trap.c
中的trap_init
函数中,我们可以看到系统调用中断的处理函数被设置为th_syscall
。这个函数在kern/trapentry.S
中定义,它的主要作用是保存中断前的环境,然后调用trap
函数。
trap
函数首先会检查中断的类型,如果是系统调用中断,它会调用trap_dispatch
函数。在trap_dispatch
函数中,会根据tf->tf_trapno
的值来判断中断的类型,如果是T_SYSCALL
,则会调用syscall
函数。
syscall
函数在lib/syscall.c
中定义,它会根据系统调用号(存储在eax
寄存器中)来调用相应的系统调用处理函数。
总的来说,从syscall
函数跳转到trap
函数的过程主要是通过硬件中断和操作系统的中断处理机制实现的。
如何在 IDT 中设置对应的中断处理函数?
trap_init
函数是操作系统内核中的一个重要函数,它的主要作用是初始化中断描述符表(Interrupt Descriptor Table,简称 IDT)。IDT 是用于处理中断和异常的关键数据结构,每当 CPU 接收到中断或异常时,就会根据 IDT 中的条目来调用相应的处理函数。
trap_init
函数设置了系统调用的处理函数 th_syscall
。然后,使用SETGATE
宏为每种中断或异常设置相应的处理函数。例如,SETGATE(idt[T_SYSCALL], 0, GD_KT, th_syscall, 3);
这行代码就是设置系统调用 T_SYSCALL 的处理函数为th_syscall
。
th_syscall
是一个中断处理函数的名称,它被定义在 trapentry.S
中 TRAPHANDLER_NOEC
宏中。这个宏用于生成没有错误代码的中断处理函数。
TRAPHANDLER_NOEC
宏接受两个参数:name
和num
。name
是生成的中断处理函数的名称,num
是对应的中断号。
在这个宏中,首先使用.globl
指令声明了一个全局符号name
,然后使用.type
指令将这个符号的类型设置为函数。接着,使用.align
指令将函数定义对齐到 2 字节边界。
然后,定义了函数name
,这个函数的作用是将中断号num
压入栈中,然后跳转到_alltraps
函数去执行。
在这个例子中,th_syscall
是系统调用的中断处理函数,它的中断号是T_SYSCALL
。当发生系统调用中断时,CPU 会跳转到这个函数去处理。
总的来说,th_syscall
的定义就是使用TRAPHANDLER_NOEC
宏生成一个名为th_syscall
,中断号为T_SYSCALL
的中断处理函数。
寄存器切换
所有的中断最终都会调用_alltraps
函数。
_alltraps:
pushl %ds
pushl %es
pushal
pushl $GD_KD
popl %ds
pushl $GD_KD
popl %es
pushl %esp
call trap
当发生中断时,CPU 可能正在用户模式下运行,此时%ds
和%es
寄存器中的选择子指向的是用户数据段。但是,中断处理代码通常需要运行在内核模式下,因此需要切换到内核数据段。
$GD_KD
是内核数据段的选择子,将其压入栈中,然后弹出到%ds
和%es
寄存器,就实现了将数据段切换到内核数据段的目的。
这样做的好处是,中断处理代码可以访问内核数据段中的数据,而不必担心访问到用户数据段中的数据。这对于保护内核数据的安全性和隔离用户空间和内核空间是非常重要的。
然后,将栈指针%esp
压入栈中,这样就保存了所有的寄存器和中断前的栈指针的状态。
最后,调用trap
函数进行中断处理。这个函数会根据保存在栈中的中断号来调用相应的中断处理函数。
总的来说,_alltraps
函数的目的就是在中断发生时保存 CPU 的状态,然后调用trap
函数进行中断处理。
中断处理
当 CPU 执行到 trap 函数后,会进入 trap_dispatch 函数进一步的分发,根据 tf->tf_trapno 字段来判断 T_SYSCALL 类型。这个函数的参数 tf 是一个指向 Trapframe 结构的指针,这个结构包含了发生中断或异常时 CPU 的状态。
void
trap(struct Trapframe *tf)
{
// ...
trap_dispatch(tf);
// ...
}
static void
trap_dispatch(struct Trapframe *tf)
{
switch (tf->tf_trapno) {
case T_PGFLT:
page_fault_handler(tf);
return;
case T_BRKPT:
monitor(tf);
return;
case T_SYSCALL:
tf->tf_regs.reg_eax = syscall(tf->tf_regs.reg_eax, tf->tf_regs.reg_edx, tf->tf_regs.reg_ecx,
tf->tf_regs.reg_ebx, tf->tf_regs.reg_edi, tf->tf_regs.reg_esi);
return;
default:
break;
}
// ...
}
根据参数做进一步的区分后会调用 sys_getenvid()
。
int32_t
syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
int32_t ret;
switch (syscallno) {
// ...
case SYS_getenvid:
ret = sys_getenvid();
break;
// ...
default:
return -E_INVAL;
}
return ret;
}
static envid_t
sys_getenvid(void)
{
return curenv->env_id;
}
中断处理完后
中断处理完后继续执行,若调用 env_run(curenv);
切换到用户进程继续执行,否则选取一个新的进程重新执行。
void
trap(struct Trapframe *tf)
{
// ...
trap_dispatch(tf);
// ...
if (curenv && curenv->env_status == ENV_RUNNING)
env_run(curenv);
else
sched_yield();
}
进程切换后,如何从内核态切换到用户态呢?
通过env_run
函数实现的。env_run
函数首先会调用lcr3
函数切换到新进程的页表,然后调用env_pop_tf
函数恢复新进程的寄存器状态,并使用iret
指令从内核态切换到用户态,开始执行新进程的代码。
具体的代码如下:
void
env_run(struct Env *e)
{
// ...
// 5. Use lcr3() to switch to its address space
lcr3(PADDR(curenv->env_pgdir));
unlock_kernel();
// Step 2: Use env_pop_tf() to restore the environment's registers and drop into user mode in the environment
env_pop_tf(&curenv->env_tf);
}
在env_pop_tf
函数中,使用iret
指令从内核态切换到用户态:
void
env_pop_tf(struct Trapframe *tf)
{
// ...
asm volatile(
"\tmovl %0,%%esp\n"
"\tpopal\n"
"\tpopl %%es\n"
"\tpopl %%ds\n"
"\taddl $0x8,%%esp\n" /* skip tf_trapno and tf_errcode */
"\tiret\n"
: : "g" (tf) : "memory");
panic("iret failed"); /* mostly to placate the compiler */
}
这里的iret
指令会从栈中弹出eip
、cs
和eflags
寄存器的值,然后跳转到eip
指定的地址开始执行代码,这个地址就是新进程的入口点。同时,iret
指令还会将 CPU 从内核态切换到用户态。
这里弹出的寄存器主要有以下几个:
-
%%esp
:堆栈指针寄存器,用于指向当前的栈顶。在这里,它被设置为tf
,也就是指向了保存的寄存器状态。 -
popal
:这是一个汇编指令,用于从堆栈中弹出所有的通用寄存器的值。这些寄存器包括eax
、ecx
、edx
、ebx
、esp
、ebp
、esi
和edi
。 -
%%es
和%%ds
:这两个都是段寄存器,用于在内存分段模式下存储段选择子。在这里,它们被弹出并恢复到了保存的状态。 -
addl $0x8,%%esp
:这条指令用于跳过tf_trapno
和tf_errcode
这两个字段。因为在保存寄存器状态时,这两个字段是最后压入堆栈的,所以在恢复状态时需要先跳过它们。 -
iret
:这是一个汇编指令,用于从堆栈中弹出eip
、cs
和eflags
寄存器的值,并从内核态切换到用户态。eip
寄存器存储的是下一条要执行的指令的地址,cs
寄存器是代码段寄存器,eflags
寄存器存储的是一些状态标志。
这些寄存器的恢复是为了让环境能够在被中断的地方继续执行。
这篇文章主要讲解 OS 是如何实现中断和异常的,首先讲解如何设置 IDT 。
设置中断描述符 IDT
接下来结合具体的代码讲解如何设置 IDT 来处理中断向量 0-31(处理器异常)。随后讲解如何处理系统调用中断,并添加中断 32-47(设备 IRQ)。
在 trapentry.S
中定义了 TRAPHANDLER
和 TRAPHANDLER_NOEC
两个宏,这两个宏都是用来定义处理中断和异常的处理程序的。它们的工作流程非常相似,都是先定义一个全局的函数符号,然后设置这个符号的类型为函数,接着对齐函数定义,然后在函数开始的地方推入中断或异常的编号,最后跳转到 _alltraps
函数。
这两个宏的主要差异在于处理错误代码的方式不同。TRAPHANDLER
宏用于处理那些 CPU 会自动推送错误代码的中断或异常,它直接将中断或异常的编号推入堆栈。而 TRAPHANDLER_NOEC
宏用于处理那些 CPU 不会自动推送错误代码的中断或异常,它在推入中断或异常的编号之前,先推入一个 0 作为错误代码。这样做的目的是为了保证在任何情况下,中断或异常的处理程序的堆栈帧都有相同的格式。
#define TRAPHANDLER(name, num) \
.globl name; /* 定义 'name' 的全局符号 */ \
.type name, @function; /* 符号类型是函数 */ \
.align 2; /* 对齐函数定义 */ \
name: /* 函数从这里开始 */ \
pushl $(num); \
jmp _alltraps
#define TRAPHANDLER_NOEC(name, num) \
.globl name; \
.type name, @function; \
.align 2; \
name: \
pushl $0; \
pushl $(num); \
jmp _alltraps
在处理中断或异常时,CPU 会将一些信息(如错误代码)推入堆栈。这些信息会被保存在一个叫做 trap frame
的数据结构中。trap frame
的格式对于不同的中断或异常可能会有所不同。例如,对于某些中断或异常,CPU 会自动将错误代码推入堆栈,而对于其他的中断或异常,CPU 则不会这样做。
TRAPHANDLER
和 TRAPHANDLER_NOEC
这两个宏的主要区别就在于它们如何处理这种情况。TRAPHANDLER
宏用于处理那些 CPU 会自动推送错误代码的中断或异常,它直接将中断或异常的编号推入堆栈。而 TRAPHANDLER_NOEC
宏用于处理那些 CPU 不会自动推送错误代码的中断或异常,它在推入中断或异常的编号之前,先推入一个 0 作为错误代码。
这样做的目的是为了保证在任何情况下,中断或异常的处理程序的堆栈帧都有相同的格式。也就是说,无论 CPU 是否会自动推送错误代码,处理程序都可以从相同的位置找到中断或异常的编号。这样可以简化处理程序的代码,因为它们不需要考虑不同的 trap frame
格式。
陷阱(trap)和中断请求(IRQ)对应的编号
接下来讲解陷阱(trap)和中断请求(IRQ)对应的编号,下一节为每种编号设置对应的处理函数。
// Trap numbers
// 这些是处理器定义的:
#define T_DIVIDE 0 // 除法错误
#define T_DEBUG 1 // 调试异常
#define T_NMI 2 // 不可屏蔽中断
#define T_BRKPT 3 // 断点
#define T_OFLOW 4 // 溢出
#define T_BOUND 5 // 边界检查
#define T_ILLOP 6 // 非法操作码
#define T_DEVICE 7 // 设备不可用
#define T_DBLFLT 8 // 双重错误
/* #define T_COPROC 9 */ // 保留(最近的处理器不会生成)
#define T_TSS 10 // 无效的任务切换段
#define T_SEGNP 11 // 段不存在
#define T_STACK 12 // 栈异常
#define T_GPFLT 13 // 一般保护错误
#define T_PGFLT 14 // 页错误
/* #define T_RES 15 */ // 保留
#define T_FPERR 16 // 浮点错误
#define T_ALIGN 17 // 对齐检查
#define T_MCHK 18 // 机器检查
#define T_SIMDERR 19 // SIMD浮点错误
// 这些是任意选择的,但是要注意不要与处理器定义的异常或中断向量重叠。
#define T_SYSCALL 48 // 系统调用
#define T_DEFAULT 500 // 万能捕获
#define IRQ_OFFSET 32 // IRQ 0 对应于 int IRQ_OFFSET
// 硬件IRQ编号。我们接收到的是 (IRQ_OFFSET+IRQ_WHATEVER)
#define IRQ_TIMER 0
#define IRQ_KBD 1
#define IRQ_SERIAL 4
#define IRQ_SPURIOUS 7
#define IRQ_IDE 14
#define IRQ_ERROR 19
这段代码中的宏定义主要分为三部分:
-
处理器定义的陷阱编号:这些陷阱是由 CPU 硬件定义的,例如除以零错误(T_DIVIDE)、调试异常(T_DEBUG)等。
-
自定义的陷阱编号:这些陷阱是操作系统自定义的,例如系统调用(T_SYSCALL)和默认陷阱(T_DEFAULT)。
-
硬件中断请求(IRQ)编号:这些是由硬件设备发出的中断请求,例如定时器(IRQ_TIMER)、键盘(IRQ_KBD)等。
这些宏定义在操作系统的其他部分会被用到,例如在处理陷阱和中断的代码中,会根据陷阱或中断的编号,调用相应的处理函数。
设置 trap 入口
接下来为 trap 中定义的每个陷阱(trap)设置相应的入口,下面这段代码是在为不同的陷阱(trap)生成入口点。
TRAPHANDLER_NOEC(handler0, T_DIVIDE)
TRAPHANDLER_NOEC(handler1, T_DEBUG)
TRAPHANDLER_NOEC(handler2, T_NMI)
TRAPHANDLER_NOEC(handler3, T_BRKPT)
TRAPHANDLER_NOEC(handler4, T_OFLOW)
TRAPHANDLER_NOEC(handler5, T_BOUND)
TRAPHANDLER_NOEC(handler6, T_ILLOP)
TRAPHANDLER_NOEC(handler7, T_DEVICE)
TRAPHANDLER(handler8, T_DBLFLT)
TRAPHANDLER(handler10, T_TSS)
TRAPHANDLER(handler11, T_SEGNP)
TRAPHANDLER(handler12, T_STACK)
TRAPHANDLER(handler13, T_GPFLT)
TRAPHANDLER(handler14, T_PGFLT)
TRAPHANDLER(handler16, T_FPERR)
TRAPHANDLER(handler17, T_ALIGN)
这段代码的目的是为了在发生陷阱时,能够根据陷阱号调用对应的处理函数,处理完陷阱后,再恢复程序的执行。
在这段代码中,TRAPHANDLER_NOEC(handler0, T_DIVIDE)
定义了一个名为handler0
的函数,用于处理陷阱号为T_DIVIDE
的陷阱,这是一个由除以零引起的陷阱。
当程序运行过程中发生这些特定的陷阱时,对应的handler
函数就会被调用,以处理这些陷阱。处理完陷阱后,程序会恢复执行。
alltraps
前文已经提到了 _alltraps
函数,这个函数是所有陷阱处理函数(由TRAPHANDLER
和TRAPHANDLER_NOEC
宏定义)的公共入口点。
当发生陷阱时,CPU 会跳转到相应的陷阱处理函数,然后这些处理函数会将陷阱号(和错误代码,如果有的话)压入堆栈,然后跳转到_alltraps
。
.globl _start
_alltraps:
pushl %ds
pushl %es
pushal
movw $(GD_KD), %ax
movw %ax, %ds
movw %ax, %es
pushl %esp
call trap
_alltraps
函数的主要任务是保存 CPU 的状态,然后调用trap
函数处理陷阱。具体来说,它做了以下操作:
-
pushl %ds
和pushl %es
:将数据段寄存器(DS)和附加段寄存器(ES)的值压入堆栈,以便稍后恢复。这两个寄存器是 x86 架构中的段寄存器,用于存储内存段的基地址。在处理陷阱或中断时,可能需要改变这些寄存器的值,所以需要先保存原来的值。 -
pushal
:将所有通用寄存器的值压入堆栈。pushal
是一个汇编指令,它会依次将 EAX、ECX、EDX、EBX、ESP、EBP、ESI 和 EDI 寄存器的值压入堆栈。 -
movw $(GD_KD), %ax
,movw %ax, %ds
和movw %ax, %es
:将内核数据段的选择子(GD_KD
)加载到 AX 寄存器,然后将 AX 寄存器的值复制到 DS 和 ES 寄存器。这是为了确保在处理陷阱时,数据段和附加段寄存器指向内核数据段。 -
pushl %esp
:将堆栈指针(ESP)的值压入堆栈。这是因为trap
函数需要知道陷阱帧的位置,陷阱帧是保存在堆栈上的,包含了发生陷阱时 CPU 的状态信息。 -
call trap
:调用trap
函数处理陷阱。trap
函数会根据陷阱号调用相应的处理函数。
这段代码的目的是为了在发生陷阱时,保存 CPU 的状态,然后调用trap
函数处理陷阱,处理完陷阱后,再恢复 CPU 的状态,继续执行被中断的程序。
这样做的原因是为了统一处理所有的陷阱和中断。当发生陷阱或中断时,CPU 会跳转到相应的处理函数,这些处理函数会将陷阱号(和错误代码,如果有的话)压入堆栈,然后跳转到_alltraps
。这样,无论发生何种陷阱或中断,处理流程都是一样的,都会跳转到_alltraps
进行处理。
在_alltraps
中,首先会保存当前的环境(包括寄存器的值等),然后调用trap
函数进行具体的处理。这样做的好处是,无论trap
函数如何修改寄存器的值,都不会影响到原来的环境,因为在返回到原来的代码之前,会恢复这些寄存器的值。
这种设计使得处理陷阱和中断更加灵活和方便,因为可以在trap
函数中根据陷阱号和错误代码进行不同的处理,而不需要为每种陷阱或中断都编写一个完整的处理函数。同时,这种设计也使得代码更加简洁和易于理解。
Trapframe
Trapframe
用于在发生中断或异常时保存处理器的状态。下面是对每个字段的详细解释:
struct Trapframe {
struct PushRegs tf_regs; // 保存通用寄存器的状态
uint16_t tf_es; // 保存ES寄存器的状态
uint16_t tf_padding1; // 填充,用于保持结构体的对齐
uint16_t tf_ds; // 保存DS寄存器的状态
uint16_t tf_padding2; // 填充,用于保持结构体的对齐
uint32_t tf_trapno; // 中断或异常的编号
/* below here defined by x86 hardware */
uint32_t tf_err; // 错误代码
uintptr_t tf_eip; // 保存EIP寄存器的状态,即下一条要执行的指令的地址
uint16_t tf_cs; // 保存CS寄存器的状态
uint16_t tf_padding3; // 填充,用于保持结构体的对齐
uint32_t tf_eflags; // 保存EFLAGS寄存器的状态,包含了处理器的一些状态标志
/* below here only when crossing rings, such as from user to kernel */
uintptr_t tf_esp; // 保存ESP寄存器的状态,即当前的栈顶指针
uint16_t tf_ss; // 保存SS寄存器的状态
uint16_t tf_padding4; // 填充,用于保持结构体的对齐
} __attribute__((packed)); // 表示该结构体按照紧凑模式进行对齐
这个结构体的设计是为了在发生中断或异常时,能够保存处理器的状态,然后在处理完中断或异常后,能够恢复到原来的状态,继续执行被打断的代码。
每个处理程序都应该在堆栈上构建一个struct Trapframe
并用指向 Trapframe
的指针调用 trap()
。然后trap()
处理异常/中断或分派到特定的处理函数。
初始化 IDT
下面这段代码是在初始化中断描述符表(Interrupt Descriptor Table,IDT)。当 CPU 接收到一个中断或者陷阱(trap)信号时,会根据信号的类型(也就是中断向量)在 IDT 中查找对应的中断处理程序,然后跳转到该程序去处理中断。
void
trap_init(void)
{
extern struct Segdesc gdt[];
SETGATE(idt[0], 1, GD_KT, handler0, 0);
SETGATE(idt[1], 1, GD_KT, handler1, 3);
SETGATE(idt[2], 1, GD_KT, handler2, 0);
// ...
// Per-CPU setup
trap_init_percpu();
}
在这段代码中,SETGATE
宏用于设置 IDT 中的条目。它接受五个参数:
- IDT 的条目(例如
idt[0]
,idt[1]
等)。 - 中断门的类型,这里都是 1,表示这是一个中断门。
- 段描述符,这里都是
GD_KT
,表示内核文本段。 - 中断处理程序的名称(例如
handler0
,handler1
等)。 - 特权级,0 表示内核级,3 表示用户级。
例如,SETGATE(idt[0], 1, GD_KT, handler0, 0);
这行代码设置了中断向量 0 的中断门。当 CPU 接收到中断向量为 0 的中断时,它会跳转到handler0
去处理这个中断。
最后,trap_init_percpu()
函数用于进行每个 CPU 的中断初始化。在多处理器系统中,每个处理器都有自己的中断控制器,因此需要单独进行初始化。
全局 IDT
trap_init 代码中使用了一个名为idt
的数组,这个数据是一个全局数据,定义在了函数外。数组的类型是Gatedesc
,长度为 256。Gatedesc
是一个结构体类型,用于表示中断描述符表(Interrupt Descriptor Table,IDT)中的条目。IDT 是一个数据结构,用于存储中断处理程序的地址和一些相关属性。当 CPU 接收到一个中断或者陷阱(trap)信号时,会根据信号的类型(也就是中断向量)在 IDT 中查找对应的中断处理程序,然后跳转到该程序去处理中断。
/* 中断描述符表。 (必须在运行时构建,因为
* 位移后的函数地址无法在重定位记录中表示。)
*/
struct Gatedesc idt[256] = { { 0 } };
在这段代码中,idt
数组被初始化为全 0,表示所有的中断向量初始时都没有对应的中断处理程序。在系统运行过程中,会通过SETGATE
宏来设置idt
中的条目,即为特定的中断向量指定处理程序。
IDT 必须在运行时构建,因为位移后的函数地址无法在重定位记录中表示。这是因为中断处理程序的地址是在程序运行过程中动态确定的,不能在编译时就固定下来。
SETGATE
接下来讲解 SETGATE ,即设置 IDT 的细节。下面是具体的代码:
#define SETGATE(gate, istrap, sel, off, dpl) \
{ \
(gate).gd_off_15_0 = (uint32_t) (off) & 0xffff; \
(gate).gd_sel = (sel); \
(gate).gd_args = 0; \
(gate).gd_rsv1 = 0; \
(gate).gd_type = (istrap) ? STS_TG32 : STS_IG32; \
(gate).gd_s = 0; \
(gate).gd_dpl = (dpl); \
(gate).gd_p = 1; \
(gate).gd_off_31_16 = (uint32_t) (off) >> 16; \
}
这段代码定义了一个名为SETGATE
的宏,用于设置中断门或陷阱门描述符。这个宏接受五个参数:
gate
:要设置的门描述符。istrap
:如果为 1,表示设置的是陷阱门;如果为 0,表示设置的是中断门。sel
:中断或陷阱处理程序的代码段选择器。off
:中断或陷阱处理程序在代码段中的偏移量。dpl
:描述符特权级别,表示软件使用int
指令显式调用此中断/陷阱门所需的特权级别。
这个宏的主要作用是填充gate
描述符的各个字段。例如,(gate).gd_off_15_0 = (uint32_t) (off) & 0xffff;
这行代码将off
的低 16 位赋值给gate
的gd_off_15_0
字段,表示中断或陷阱处理程序在代码段中的偏移量的低 16 位。其他字段的设置也类似。
其中,gd_type
字段的设置比较特殊,它根据istrap
的值来确定是设置为陷阱门类型(STS_TG32
)还是中断门类型(STS_IG32
)。
下面是对应 Gatedesc
的结构体,用于描述中断和陷阱门的描述符,即 gate 部分。
// 中断和陷阱门的门描述符
struct Gatedesc {
unsigned gd_off_15_0 : 16; // 段内偏移的低16位
unsigned gd_sel : 16; // 段选择器
unsigned gd_args : 5; // 参数数量,对于中断/陷阱门,此值为0
unsigned gd_rsv1 : 3; // 保留字段,我猜应该为零
unsigned gd_type : 4; // 类型(STS_{TG,IG32,TG32})
unsigned gd_s : 1; // 必须为0(系统)
unsigned gd_dpl : 2; // 描述符(新的含义)特权级别
unsigned gd_p : 1; // 存在位
unsigned gd_off_31_16 : 16; // 段内偏移的高16位
};
这段代码定义了一个名为Gatedesc
的结构体,它是中断和陷阱门的描述符。这个描述符用于在中断描述符表(IDT)中表示中断或陷阱的处理程序的信息。每个字段都包含了中断或陷阱处理程序的重要信息,如段内偏移、段选择器、参数数量、类型、特权级别等。这些信息在处理中断或陷阱时非常关键,因为它们决定了如何定位和执行相应的处理程序。
总结
文章主要介绍了操作系统如何实现中断和异常的处理,以及如何设置中断描述符表(IDT)。通过详细讲解设置 IDT 的过程,包括使用宏定义处理中断和异常、陷阱和中断请求的编号、以及为每个陷阱设置相应的入口点。此外,文章还解释了全局 IDT 的初始化、SETGATE
宏的具体实现,以及Trapframe
结构体的设计用于保存处理器状态。文章最后总结了整个过程,包括初始化 IDT、设置中断和陷阱处理程序、处理陷阱的公共入口点_alltraps
,以及Trapframe
的作用。
接下来结合具体的代码讲解 OS 是如何实现页面错误的。
什么是处理页面错误?
页面错误(Page Fault)是一种常见的异常,通常发生在虚拟内存系统中。当程序试图访问的虚拟内存页不在物理内存中,或者程序试图执行的操作违反了内存保护策略时,就会发生页面错误。
在 x86 架构中,当页面错误发生时,处理器会自动执行以下操作:
- 将引发错误的线性地址(即虚拟地址)保存在 CR2 控制寄存器中。
- 将错误代码压入内核栈。
- 将当前的程序计数器、代码段、EFLAGS 寄存器的值压入内核栈。
- 通过中断描述符表(IDT)跳转到页面错误处理函数。
页面错误处理函数通常会根据错误代码和 CR2 寄存器的值来确定错误的原因,并采取相应的处理措施。例如,如果错误是由于所需的页面不在物理内存中,处理函数可能会从磁盘中加载所需的页面;如果错误是由于违反了内存保护策略,处理函数可能会终止引发错误的程序。
C 语言中的那些行为会导致页面处理错误?
页面错误(Page Fault)通常在以下几种情况下发生:
-
非法访问:当程序试图访问一个它没有权限访问的内存地址时,会发生页面错误。例如,程序试图写入只读内存,或者试图访问用户模式下不可访问的内存。
-
非映射内存访问:当程序试图访问一个并未映射到物理内存的虚拟内存地址时,会发生页面错误。这通常发生在程序访问一个尚未加载到内存的内存页,或者访问一个已经被释放的内存页。
-
空指针解引用:当程序试图通过空指针访问内存时,也会发生页面错误。在大多数操作系统中,地址 0 是不允许访问的,因此这种操作会导致页面错误。
-
栈溢出:当程序的调用栈超过了为其分配的内存空间时,会发生页面错误。这通常发生在递归调用过深或者在栈上分配了大量数据导致的栈溢出。
以上情况都可能导致页面错误,但具体的行为和结果可能会因操作系统和硬件的不同而有所差异。
如何区分不同类型的 trap ?
下面代码是处理中断和异常的函数。当发生中断或异常时,处理器会自动保存当前的状态,并跳转到这个函数进行处理。这个函数的主要任务是根据中断或异常的类型,调用相应的处理函数。
static void
trap_dispatch(struct Trapframe *tf)
{
// Handle processor exceptions.
switch (tf->tf_trapno) {
case T_PGFLT:
page_fault_handler(tf);
return;
}
// Unexpected trap: The user process or the kernel has a bug.
print_trapframe(tf);
if (tf->tf_cs == GD_KT)
panic("unhandled trap in kernel");
else {
env_destroy(curenv);
return;
}
}
在这个函数中,首先通过一个 switch 语句检查中断或异常的类型。对于页面错误(T_PGFLT),调用page_fault_handler
函数进行处理;对于断点异常(T_BRKPT),调用monitor
函数进行处理;对于系统调用(T_SYSCALL)。
如果中断或异常的类型不在这些已知的类型中,那么就认为是一个意外的中断或异常。在这种情况下,首先打印出中断或异常的信息,然后检查中断或异常是在内核模式还是用户模式下发生的。如果是在内核模式下发生的,那么说明内核有 bug,因此调用panic
函数终止程序运行。如果是在用户模式下发生的,那么说明用户程序有 bug,因此调用env_destroy
函数销毁引发中断或异常的进程。
如何处理页面错误?
上面已经提及了会跳转到 page_fault_handler
,这个函数用来处理页面错误的函数,接下来讲解这个函数。
void
page_fault_handler(struct Trapframe *tf)
{
uint32_t fault_va;
// Read processor's CR2 register to find the faulting address
fault_va = rcr2();
// Handle kernel-mode page faults.
// LAB 3: Your code here.
if ((tf->tf_cs & 3) == 0) {
panic("unhandled page fault in kernel mode");
}
// We've already handled kernel-mode exceptions, so if we get here,
// the page fault happened in user mode.
// Destroy the environment that caused the fault.
cprintf("[%08x] user fault va %08x ip %08x\n",
curenv->env_id, fault_va, tf->tf_eip);
print_trapframe(tf);
env_destroy(curenv);
}
函数的参数 tf
是一个指向 Trapframe
结构的指针,该结构包含了处理器在异常发生时保存的状态信息。
函数首先通过 rcr2
函数读取处理器的 CR2 寄存器,该寄存器包含了引发页错误的线性地址。
接下来,函数检查异常是否在内核模式下发生。这是通过检查 tf->tf_cs
的最低两位来完成的,如果这两位为 0,那么说明异常是在内核模式下发生的。如果是在内核模式下发生的页错误,函数会调用 panic
函数并输出错误信息,因为内核不应该产生页错误。
如果异常是在用户模式下发生的,函数会打印出引发异常的进程 ID、引发页错误的地址以及异常发生时的指令指针。然后,它会调用 print_trapframe
函数打印出异常发生时的处理器状态。
最后,函数会调用 env_destroy
函数销毁引发异常的进程。这是因为在用户模式下发生的页错误通常表示程序存在错误,例如试图访问未分配的内存或违反内存保护规则,所以最简单的处理方式就是终止这个程序。
如何销毁引发异常的进程?
下面这段代码是 env_destroy
函数,它的作用是销毁一个进程。
void
env_destroy(struct Env *e)
{
env_free(e);
cprintf("Destroyed the only environment - nothing more to do!\n");
while (1)
monitor(NULL);
}
函数接收一个 Env
类型的指针 e
作为参数,这个指针指向要被销毁的进程。
首先,函数调用 env_free(e)
来释放进程 e
及其使用的所有内存。
然后,函数使用 cprintf
打印一条消息,表明已经销毁了唯一的进程,没有其他事情可以做了。
最后,函数进入一个无限循环,调用 monitor(NULL)
。这个调用会使内核进入一个简单的命令行内核监视器,用于接收和处理用户输入的命令。因为已经销毁了唯一的进程,所以内核没有其他事情可以做,只能等待用户的命令。
这个函数的主要用途是在用户模式下发生页错误时,销毁引发异常的进程。
这段代码是 env_free
函数,它的作用是释放进程 e
及其使用的所有内存。
函数首先检查是否正在释放当前进程,如果是,则在释放页目录之前切换到 kern_pgdir
,以防止页面被重用。
然后,函数遍历用户地址空间的所有映射页面,并将它们从页表中移除。这是通过遍历页目录 e->env_pgdir
中的每个条目,并检查每个页表中的每个页表项来完成的。如果页表项表示一个映射的页面(即 PTE_P
位被设置),则调用 page_remove
函数将其移除。在移除所有页面后,函数释放页表本身。
接下来,函数释放页目录 e->env_pgdir
,并将其设置为 NULL
。
最后,函数将进程 e
的状态设置为 ENV_FREE
,并将其添加到空闲进程列表 env_free_list
中。
总的来说,这个函数的作用是释放一个进程及其使用的所有内存,包括页目录、页表和映射的页面。
总结
本文介绍了操作系统中的页面错误及其处理过程。页面错误通常发生在虚拟内存系统中,当程序访问未加载到物理内存中的地址或违反内存保护策略时。处理函数根据错误类型执行相应操作,例如在用户模式下发生的页面错误会销毁引发异常的进程。销毁进程的过程包括释放内存、打印消息,并进入等待用户输入命令的循环。
接下来结合具体的代码讲解 OS 是如何实现断点异常的。
什么是断点异常?
断点异常(Breakpoint Exception)是一种特殊的中断,通常用于调试目的。当程序执行到设置了断点的位置时,处理器会触发断点异常,暂停程序的执行,并将控制权交给调试器。
在 x86 架构中,断点异常的中断号是 3(T_BRKPT)。当处理器遇到 INT 3 指令时,就会触发断点异常。INT 3 指令通常由调试替换到程序的代码中,用于标记断点的位置。
当断点异常发生时,处理器会保存当前的状态(包括程序计数器、寄存器的值等),并跳转到操作系统设置的断点异常处理函数。在你提供的代码中,trap_dispatch
函数就是处理各种中断和异常的函数,其中对断点异常的处理是调用monitor
函数。
断点异常是调试器实现单步执行、断点设置等调试功能的重要机制。
汇编代码中的断点细节
在 x86 架编中,断点异常是由中断号 3(T_BRKPT)触发的。在调试的时候如果打上断点表示在对应的汇编代码指定位置插入 INT 3 指令,当处理器遇到 INT 3 指令时,就会触发断点异常。INT 3 指令是一条特殊的软件中断指令,长度只有一个字节,因此可以替换几乎所有的机器指令。调试器通常会使用这个特性,在需要设置断点的地方将原来的指令替换为 INT 3 指令,然后在断点被触发后,再将原来的指令恢复,以此来实现断点调试的功能。
以下是一个简单的例子,展示了如何在汇编代码中使用 INT 3 指令来设置断点:
section .text
global _start
_start:
mov eax, 1
int 3 ; 设置断点
add eax, 1
; ...
在这个例子中,我们在add eax, 1
指令之前插入了一个 INT 3 指令。当这段代码被执行时,处理器会在执行到 INT 3 指令时触发一个断点异常,然后操作系统的中断处理程序会接管控制权。在 JOS 操作系统中,当发生断点异常时,会调用monitor
函数进入内核监视器,这样开发者就可以查看和修改程序的状态,或者单步执行程序,以便进行调试。
如何确定 Trap 为段点异常?
上一篇文章已经讲过页面错误处理了,在 trap_dispatch
函数中增加断点异常的处理逻辑,即下面的代码。
switch (tf->tf_trapno) {
case T_PGFLT:
page_fault_handler(tf);
return;
case T_BRKPT:
monitor(tf);
return;
}
在这段代码中,当中断向量号(tf->tf_trapno
)为 T_BRKPT
(即断点异常)时,会直接调用 monitor(tf)
函数。这是因为在 JOS 操作系统中,断点异常被用作一种原始的伪系统调用,任何用户环境都可以使用它来调用 JOS 内核监视器。
当程序执行到一个断点时,CPU 会触发一个断点异常,然后操作系统的中断处理程序会接管控制权。在这个例子中,JOS 的中断处理程序选择调用 monitor(tf)
函数,进入内核监视器。这样,开发者就可以在内核监视器中查看和修改程序的状态,或者单步执行程序,以便进行调试。
总的来说,这是一种利用断点异常进行程序调试的方法。
为什么是替换指令?不是插入指令?
在 x86 架构中,断点通常是通过替换相关的程序指令为特殊的 1 字节int 3
软件中断指令来实现的。这是因为int 3
指令的长度只有一个字节,因此它可以替换几乎所有的机器指令。当处理器执行到int 3
指令时,会触发一个断点异常,然后操作系统的中断处理程序会接管控制权,进行相应的处理。
如果我们选择插入int 3
指令,而不是替换原有的指令,那么会改变程序的控制流,可能导致程序的行为发生变化,这显然是我们不希望看到的。因此,我们选择替换原有的指令,而不是插入新的指令。
在断点被触发后,调试器会将原来的指令恢复,以此来实现断点调试的功能。这样,程序在断点处暂停执行,开发者可以查看和修改程序的状态,然后继续执行程序。这就是为什么我们选择替换原有的指令,而不是插入新的指令。
为什么插入会改变程序的控制流?
在 x86 架构中,插入int 3
指令而不是替换原有的指令会改变程序的控制流,可能导致程序的行为发生变化。这是因为插入新的指令会改变后续指令的地址,这可能会影响到程序中的跳转指令,从而改变程序的执行流程。
例如,假设我们有以下的汇编代码:
section .text
global _start
_start:
mov eax, 1
jmp label
; ...
label:
add eax, 1
; ...
在这个例子中,jmp label
指令会跳转到label
标签所在的位置执行。如果我们在mov eax, 1
指令后插入一个int 3
指令,代码会变成:
section .text
global _start
_start:
mov eax, 1
int 3
jmp label
; ...
label:
add eax, 1
; ...
在这个例子中,jmp label
指令的目标地址没有改变,但是由于我们插入了一个新的指令,label
标签的位置发生了改变。这就导致了jmp label
指令跳转到了错误的位置,从而改变了程序的执行流程。
因此,为了避免这种情况,我们通常会选择替换原有的指令,而不是插入新的指令。在断点被触发后,调试器会将原来的指令恢复,以此来实现断点调试的功能。
替换指令的细节
在断点被触发前,调试器会将原来的指令替换为int 3
指令,然后在断点被触发后,再将原来的指令恢复,以此来实现断点调试的功能。
例如,假设我们有以下的汇编代码:
mov eax, 1
add eax, 1
如果我们想在add eax, 1
这条指令处设置一个断点,我们可以将这条指令替换为int 3
指令,如下:
mov eax, 1
int 3
当这段代码被执行时,处理器会在执行到int 3
指令时触发一个断点异常,然后操作系统的中断处理程序会接管控制权。在 JOS 操作系统中,当发生断点异常时,会调用monitor
函数进入内核监视器,这样开发者就可以查看和修改程序的状态,或者单步执行程序,以便进行调试。
然后,调试器会将int 3
指令替换回原来的add eax, 1
指令,以便程序可以继续执行。这就是为什么我们选择替换原有的指令,而不是插入新的指令。
总结
本文主要讲解了操作系统如何实现断点异常。首先,它解释了什么是断点异常,即当程序执行到设置了断点的位置时,处理器会触发断点异常,暂停程序的执行,并将控制权交给调试器。然后,文章通过具体的汇编代码示例,解释了如何在代码中设置断点,以及为什么选择替换原有的指令而不是插入新的指令。最后,文章解释了如何通过中断向量号来确定断点异常,并通过具体的代码示例,展示了如何处理断点异常。
接下来结合具体的代码讲解 OS 是如何实现系统调用的。
什么是系统调用?
系统调用是操作系统提供给上层应用的接口,应用程序通过系统调用请求操作系统提供的服务。在 C 语言中,我们可以使用系统调用来执行各种操作,如读写文件、创建进程等。以下是一个使用系统调用来读取文件的例子:
#include <unistd.h>
#include <fcntl.h>
int main() {
char buffer[128];
int fileDescriptor = open("example.txt", O_RDONLY);
if (fileDescriptor < 0) {
return -1;
}
size_t bytesRead = read(fileDescriptor, buffer, sizeof(buffer) - 1);
if (bytesRead >= 0) {
buffer[bytesRead] = '\0'; // Null terminate the string
write(1, buffer, bytesRead); // Write to stdout
}
close(fileDescriptor);
return 0;
}
在这个例子中,我们首先使用 open
系统调用打开一个文件。然后,我们使用 read
系统调用从文件中读取数据,并将数据存储在 buffer
中。最后,我们使用 write
系统调用将读取的数据写入到标准输出(stdout)。
系统调用的过程
在 JOS 内核中,系统调用是通过中断机制实现的。特别的,使用 int $0x30
指令来触发系统调用中断,这个中断的中断向量号是 48(0x30),对应的常量是 T_SYSCALL
。
下面是一些具体的汇编指令:
movl $num, %eax ; 将系统调用编号放入eax寄存器
movl $a1, %edx ; 将第一个参数放入edx寄存器
movl $a2, %ecx ; 将第二个参数放入ecx寄存器
movl $a3, %ebx ; 将第三个参数放入ebx寄存器
movl $a4, %edi ; 将第四个参数放入edi寄存器
movl $a5, %esi ; 将第五个参数放入esi寄存器
int $0x30 ; 执行中断指令,触发系统调用
这里,$num
是系统调用的编号,$a1
到 $a5
是系统调用的参数。int $0x30
是触发系统调用的中断指令,0x30
是中断向量号,对应的常量是 T_SYSCALL
。
当用户程序需要进行系统调用时,它会将系统调用的编号放入 %eax
寄存器,将最多五个参数分别放入 %edx
、%ecx
、%ebx
、%edi
和 %esi
寄存器,然后执行 int $0x30
指令。这个指令会触发一个中断,导致处理器切换到内核模式并跳转到中断处理程序。
在内核中,需要设置一个中断描述符来处理这个中断。中断描述符定义了当中断发生时处理器应该跳转到的地址,以及一些其他的属性,如特权级别等。对于系统调用中断,需要设置的特权级别应该允许用户程序触发这个中断。
系统调用处理完毕后,内核会将返回值放入 %eax
寄存器,然后返回到用户程序。这样,用户程序就可以从 %eax
寄存器中获取系统调用的返回值。这是系统调用的一种常见机制,用于将结果返回给用户程序。
软件中断和硬件中断
JOS 内核使用 int $0x30
指令来触发系统调用中断。这个中断是由用户程序生成的,而不是由硬件生成的(如设备完成操作或发生错误),因此不会与硬件中断混淆。
在计算机系统中,有许多中断是由硬件生成的。这些中断通常是由某种硬件事件触发的,例如:
-
定时器中断:当系统的定时器到达预设的时间时,会触发一个中断。这种中断通常用于实现时间共享,使得操作系统可以定期从一个任务切换到另一个任务。
-
I/O 中断:当输入/输出设备完成了一个操作(例如,硬盘完成了数据的读取或写入),它会触发一个中断,通知 CPU 数据已经准备好或者已经被成功写入。
-
错误中断:当硬件发生错误时(例如,内存错误或设备故障),会触发一个中断,通知操作系统需要处理这个错误。
这些都是由硬件生成的中断的例子。与之相对,int $0x30
是由用户程序显式触发的,用于进行系统调用,因此不会与硬件中断混淆。
如何实现系统调用?
用户进程通过调用系统调用来请求内核为它们执行操作。当用户进程调用一个系统调用时,处理器进入内核模式,处理器和内核协作保存用户进程的状态,内核执行适当的代码以执行系统调用,然后恢复用户进程。
在代码中,系统调用的实现主要涉及到两个文件:kern/trap.c
和 inc/mmu.h
。
在 inc/mmu.h
文件中,定义了系统调用的中断向量号 T_SYSCALL
,这是系统调用的唯一标识符。
#define T_SYSCALL 0x30
在 kern/trap.c
文件中,系统调用的实现主要在 trap_init
函数和 trap_dispatch
函数中。
在 trap_init
函数中,设置了系统调用的门描述符。其中,SETGATE
宏用于设置门描述符,第一个参数是门描述符的地址,第二个参数表示这是一个中断门,第三个参数是段选择器,第四个参数是中断处理程序的地址,第五个参数是特权级别。
SETGATE(idt[T_SYSCALL], 0, GD_KT, th_syscall, 3);
在 trap_dispatch
函数中,当中断向量号为 T_SYSCALL
时,会调用 syscall
函数处理系统调用。
case T_SYSCALL:
tf->tf_regs.reg_eax = syscall(tf->tf_regs.reg_eax,
tf->tf_regs.reg_edx,
tf->tf_regs.reg_ecx,
tf->tf_regs.reg_ebx,
tf->tf_regs.reg_edi,
tf->tf_regs.reg_esi);
return;
总的来说,系统调用的实现主要包括设置系统调用的门描述符和处理系统调用的函数。
syscall
函数的参数是从 Trapframe
结构体中获取的,这些参数是在发生系统调用时保存的寄存器的值。这些寄存器的值包含了系统调用的编号和参数。
系统调用的编号通常保存在 eax
寄存器中,而系统调用的参数则保存在其他寄存器中。在这个例子中,系统调用的参数保存在 edx
、ecx
、ebx
、edi
和 esi
寄存器中。
syscall
函数会根据系统调用的编号,调用相应的处理函数,并将系统调用的参数传递给处理函数。处理函数执行完毕后,会返回一个结果,这个结果会被保存在 eax
寄存器中,然后返回给用户程序。
这样做的目的是为了实现用户程序与操作系统内核之间的交互。用户程序通过系统调用请求操作系统提供的服务,如读写文件、创建进程等。操作系统在完成用户程序的请求后,会将结果返回给用户程序。
syscall
接下来实现 syscall ,下面这段代码是操作系统内核中处理系统调用的部分。函数 syscall
是一个分发函数,它根据系统调用的编号(syscallno
),调用相应的处理函数。
int32_t
syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
int32_t ret;
switch (syscallno) {
case SYS_cputs:
sys_cputs((char *)a1, (size_t)a2);
ret = 0;
break;
case SYS_cgetc:
ret = sys_cgetc();
break;
case SYS_getenvid:
ret = sys_getenvid();
break;
case SYS_env_destroy:
ret = sys_env_destroy((envid_t)a1);
break;
default:
return -E_INVAL;
}
return ret;
}
函数的参数 syscallno
是系统调用的编号,a1
到 a5
是系统调用的参数。这些参数是在发生系统调用时保存的寄存器的值。
在函数体中,首先定义了一个 int32_t
类型的变量 ret
,用于保存系统调用的返回值。
然后,使用 switch
语句根据系统调用的编号调用相应的处理函数。例如,当 syscallno
为 SYS_cputs
时,调用 sys_cputs
函数处理系统调用。
sys_cputs
函数的作用是将用户程序的字符串输出到控制台。它的参数是一个指向字符串的指针和字符串的长度。这两个参数分别通过 a1
和 a2
传递给 sys_cputs
函数。
其他的 case
分支也是类似的,它们分别处理不同的系统调用。
最后,如果 syscallno
不匹配任何已知的系统调用编号,那么返回错误码 -E_INVAL
,表示无效的系统调用。
在所有的 case
分支中,都会设置 ret
的值,然后在函数的最后返回 ret
。这个返回值会被保存在 eax
寄存器中,然后返回给用户程序。
本文主要讲解了在多处理器系统中,如何通过对称多处理(SMP)和高级可编程中断控制器(APIC)来实现处理器的并行运行。
什么是 SMP?
"对称多处理"(SMP)是一种多处理器模型,其中所有的处理器(CPU)对系统资源(如内存和 I/O 总线)具有等效的访问权限。这意味着每个处理器都可以独立地访问任何内存或 I/O 设备。这种模型的优点是它可以提供良好的性能扩展性,因为增加更多的处理器可以直接增加系统的处理能力。
在 SMP 系统中,综上,第一个启动的 CPU 是 BSP,后续启动的 CPU 是 AP 。下面是两种类型处理器的详细区别:
-
引导程序处理器(Bootstrap Processor,简称 BSP):BSP 是在系统启动时负责初始化系统和引导操作系统的处理器。它首先启动,注意是第一个启动的 CPU,并执行所有的系统初始化任务,包括初始化内存控制器、I/O 子系统等。然后,它加载并启动操作系统。一旦操作系统启动并运行,BSP 就可以像系统中的其他处理器一样执行常规的计算任务。
-
应用程序处理器(Application Processor,简称 AP):AP 是在操作系统启动并运行后由 BSP 激活的处理器。一旦被激活,AP 就可以执行常规的计算任务。在多处理器系统中,可以有多个 AP。
在系统启动过程中,哪个处理器作为 BSP 是由硬件和 BIOS 决定的。在这个过程完成后,所有的处理器在功能上都是相同的,都可以执行用户级和内核级的代码。
什么是 APIC?
APIC 解决了在多处理器系统中分发和处理中断的问题。在没有 APIC 的系统中,所有的中断都会发送到一个中央处理器,这可能会导致处理器过载。通过使用 APIC,每个处理器都可以处理自己的中断,从而提高了系统的整体性能。
APIC(高级可编程中断控制器)是一种硬件设备,用于管理和控制中断。在一个对称多处理(SMP)系统中,每个 CPU 都有一个相应的本地 APIC(LAPIC)单元。本地 APIC 可以接收来自 I/O APIC 的中断,并将其传递给处理器。此外,本地 APIC 还可以发送和接收来自其他本地 APIC 的中断,这使得处理器之间可以相互通信。
参数读取细节
当 CPU 执行到 i386_init 时会执行 mp_init 将读取到的参数绑定到结构体上,随后执行 lapic_init 初始化 APIC 。
void
i386_init(void)
{
// ...
mp_init();
lapic_init();
// ...
}
参数是 Intel 多处理器规范的一部分,用于描述系统的硬件配置,包括处理器、总线、I/O APIC 等的信息。mp_init()
函数读取的数据主要来自多处理器配置表,这个配置表由 BIOS 在系统启动时生成,并存储在内存中的特定位置。
在启动应用程序处理器(APs)之前,引导服务处理器(BSP)应首先收集关于多处理器系统的信息,例如 CPU 的总数、它们的 APIC ID 以及 LAPIC 单元的 MMIO 地址。
mp_init()
函数(在kern/mpconfig.c
中)通过读取位于 BIOS 内存区域的 MP 配置表来获取这些信息。接下来讲解 mp_init()
是如何获取参数的。
在 mp_init()
中会先执行 bootcpu = &cpus[0];
,这行代码的作用是将 bootcpu
指针指向 cpus
数组的第一个元素。将 bootcpu
指向系统中的第一个 CPU,即 BSP。这样,我们就可以通过 bootcpu
指针来访问和操作 BSP 的信息。bootcpu
是一个全局变量,即 struct CpuInfo *bootcpu;
,是用来指向启动 CPU(Bootstrap Processor,简称 BSP)的信息的。
因此,bootcpu
通常被用来在初始化过程中访问和操作 BSP 的信息。cpus
是一个 CpuInfo
结构体数组,用于存储系统中所有 CPU 的信息。CpuInfo
结构体包含了关于 CPU 的信息,例如 CPU 的 ID,状态等。
接下来 mp_init
函数中遍历了多处理器配置表中的每个处理器条目,并为每个处理器设置了相应的 CpuInfo
结构。具体来说,它将每个处理器的 ID 设置为其在 cpus
数组中的索引,并将 cpu_status
设置为 CPU_STARTED
。如果处理器是引导处理器(BSP),则 bootcpu
指针会被设置为指向该处理器的 CpuInfo
结构。
APIC 是如何初始化的?
lapic_init
主要是初始化和配置本地高级可编程中断控制器(Local APIC)。首先,它会检查并映射 Local APIC 的物理地址,然后启用 Local APIC 并设置伪中断。接着,它会初始化和配置 Local APIC 的定时器,以便执行定时任务。此外,它还会根据处理器的类型和支持的功能,进行一些特定的配置,如禁用某些中断,清除错误状态寄存器,确认未处理的中断,以及在 APIC 上启用中断等。
Local APIC 如何设置虚拟内存?
首先将 LAPIC(本地高级可编程中断控制器)的物理内存映射到虚拟内存中,以便我们可以在代码中访问它。
lapic = mmio_map_region(lapicaddr, 4096);
lapicaddr
是 LAPIC 的物理地址,它是在系统启动时由 BIOS 设置的。LAPIC 是一个硬件设备,它的寄存器是映射在物理内存中的。为了在代码中访问这些寄存器,我们需要将它们映射到虚拟内存中。
mmio_map_region
是一个函数,它的作用是将物理内存映射到虚拟内存中。这个函数接受两个参数:要映射的物理地址和映射的大小。在这个例子中,我们要映射的是 LAPIC 的物理地址,大小是 4096 字节(4KB)。这是因为 LAPIC 的寄存器是在一个 4KB 的内存区域中。
这段代码的结果是,lapic
变量现在指向一个虚拟地址,这个虚拟地址映射到了 LAPIC 的物理地址。这样,我们就可以通过 lapic
变量来访问 LAPIC 的寄存器了。
这是做因为在现代操作系统中,我们通常使用虚拟内存来访问内存。虚拟内存为我们提供了一种抽象,使我们可以像操作连续的内存一样操作物理内存,即使物理内存可能是分散的。此外,虚拟内存还提供了一种保护机制,使我们可以控制哪些内存区域可以被访问,以及如何访问。因此,我们需要将 LAPIC 的物理内存映射到虚拟内存中,以便我们可以在代码中访问它。
内存映射 I/O(MMIO)
内存映射 I/O(MMIO)是一种允许 CPU 和设备进行通信的机制。在这种机制中,设备的寄存器被映射到系统的地址空间,CPU 可以通过读写这些地址来控制设备。MMIO 区域的起始地址是MMIOBASE
,结束地址是MMIOLIM
。这个区域的大小是PTSIZE
。
这个区域的权限是读写(RW)对于内核,对于用户空间是不可访问的(--)。这是因为设备通常只允许内核进行直接操作,用户程序通常需要通过系统调用来间接操作设备。
在这个区域中,设备的寄存器被映射到虚拟地址,CPU 可以通过读写这些虚拟地址来读取设备的状态或者发送命令给设备。这种方式比传统的 I/O 端口访问方式更灵活,因为它可以直接利用 CPU 的地址空间,不需要额外的 I/O 指令和 I/O 地址空间。
: . : |
: . : |
MMIOLIM ------> +------------------------------+ 0xefc00000 --+
| Memory-mapped I/O | RW/-- PTSIZE
ULIM, MMIOBASE --> +------------------------------+ 0xef800000
| Cur. Page Table (User R-) | R-/R- PTSIZE
UVPT ----> +------------------------------+ 0xef400000
下面的代码实现了如何映射,即mmio_map_region
函数将物理地址pa
到pa+size
的区域映射到这个预留的空间。这个函数返回预留区域的基址。
void *
mmio_map_region(physaddr_t pa, size_t size)
{
static uintptr_t base = MMIOBASE;
size = ROUNDUP(pa+size, PGSIZE);
pa = ROUNDDOWN(pa, PGSIZE);
size -= pa;
if (base+size >= MMIOLIM) panic("not enough memory");
boot_map_region(kern_pgdir, base, size, pa, PTE_PCD|PTE_PWT|PTE_W);
base += size;
return (void*) (base - size);
}
AP 引导过程
在系统启动时,BSP 负责初始化系统,然后启动其他的应用程序处理器(Application Processor,简称 AP)。第一个启动的 CPU 是 BSP,后续启动的 CPU 是 AP 。之前已经提及了,此处再强调一下。
在 AP 的引导过程中,BSP 会将 AP 的引导代码复制到一个特定的物理地址,这个地址是 MPENTRY_PADDR
。下面是代码复制的具体细节。
extern unsigned char mpentry_start[], mpentry_end[];
void *code;
// Write entry code to unused memory at MPENTRY_PADDR
code = KADDR(MPENTRY_PADDR);
memmove(code, mpentry_start, mpentry_end - mpentry_start);
这段代码的目的是将 AP(Application Processor)的引导代码复制到一个特定的物理地址 MPENTRY_PADDR
。
-
extern unsigned char mpentry_start[], mpentry_end[];
这行代码声明了两个外部变量mpentry_start
和mpentry_end
,它们分别表示 AP 引导代码的开始和结束位置。 -
void *code;
这行代码声明了一个指针变量code
,它将用于指向MPENTRY_PADDR
所指向的物理地址。 -
code = KADDR(MPENTRY_PADDR);
这行代码将MPENTRY_PADDR
所指向的物理地址转换为内核虚拟地址,并将结果赋值给code
。KADDR()
是一个宏,用于将物理地址转换为内核虚拟地址。 -
memmove(code, mpentry_start, mpentry_end - mpentry_start);
这行代码将mpentry_start
和mpentry_end
之间的内容(即 AP 引导代码)复制到code
所指向的地址(即MPENTRY_PADDR
所指向的物理地址)。memmove()
是一个标准的 C 函数,用于复制内存区域。
然后,BSP 通过发送启动 IPI(Inter-Processor Interrupt,处理器间中断)来启动 AP。AP 会在 MPENTRY_PADDR
指定的地址开始执行其引导代码。
// Boot each AP one at a time
for (c = cpus; c < cpus + ncpu; c++) {
if (c == cpus + cpunum()) // We've started already.
continue;
// Tell mpentry.S what stack to use
mpentry_kstack = percpu_kstacks[c - cpus] + KSTKSIZE;
// Start the CPU at mpentry_start
lapic_startap(c->cpu_id, PADDR(code));
// Wait for the CPU to finish some basic setup in mp_main()
while(c->cpu_status != CPU_STARTED)
;
}
遍历所有的处理器。对于每一个处理器,首先检查它是否已经启动。如果已经启动,则跳过这个处理器。否则,设置这个处理器的栈,然后通过 lapic_startap
函数启动这个处理器。lapic_startap
函数会发送一个 IPI 来启动目标处理器。
在处理器启动后,会执行 mpentry_start
到 mpentry_end
之间的代码。在这段代码执行完毕后,处理器的状态会被设置为 CPU_STARTED
。boot_aps
函数会等待处理器的状态变为 CPU_STARTED
,然后继续启动下一个处理器。
总的来说,这段代码通过发送 IPI 来启动非引导处理器,然后等待处理器完成初始化。这是一个典型的使用 IPI 进行处理器间同步的例子。
IPI 处理器间中断
IPI,全称为 Inter-Processor Interrupt,即处理器间中断,是一种用于多处理器系统中的通信机制。在多处理器系统中,一个处理器可以通过发送 IPI 来中断另一个处理器,以便通知它执行某些任务。
例如,当一个处理器改变了内存的某个部分,可能需要通知其他处理器刷新其缓存。这时,它就可以发送一个 IPI 来通知其他处理器。
IPI 也可以用于实现任务调度。例如,当一个处理器过载时,操作系统可以通过发送 IPI 来通知另一个处理器接管一些任务。
总的来说,IPI 是多处理器系统中处理器间同步和通信的重要机制。下面是使用 IPI 中断的具体代码。
// 启动额外的处理器并运行指定地址的入口代码
// 参见多处理器规范的附录B
void
lapic_startap(uint8_t apicid, uint32_t addr)
{
int i;
uint16_t *wrv;
// "BSP必须将CMOS关机代码初始化为0AH
// 并将热重启向量(位于40:67的DWORD)指向
// AP启动代码,然后才能执行[通用启动算法]。"
outb(IO_RTC, 0xF); // 偏移0xF是关机代码
outb(IO_RTC+1, 0x0A);
wrv = (uint16_t *)KADDR((0x40 << 4 | 0x67)); // 热重启向量
wrv[0] = 0;
wrv[1] = addr >> 4;
// "通用启动算法。"
// 发送INIT(电平触发)中断以重置其他CPU。
lapicw(ICRHI, apicid << 24);
lapicw(ICRLO, INIT | LEVEL | ASSERT);
microdelay(200);
lapicw(ICRLO, INIT | LEVEL);
microdelay(100); // 应该是10ms,但在Bochs中太慢了!
// 发送启动IPI(两次!)以进入代码。
// 正常的硬件应该只在由于INIT而处于停止状态时接受STARTUP。
// 所以第二次应该会被忽略,但这是官方Intel算法的一部分。
// Bochs对第二次发送的STARTUP有所抱怨。对Bochs来说,这是不幸的。
for (i = 0; i < 2; i++) {
lapicw(ICRHI, apicid << 24);
lapicw(ICRLO, STARTUP | (addr >> 12));
microdelay(200);
}
}
在 lapic_startap
函数中,用于启动其他处理器(AP)并运行指定地址的入口代码。这个过程涉及到了 IPI(Inter-Processor Interrupt,处理器间中断)。
首先,函数设置了 CMOS 的关机代码和热重启向量,以指向 AP 启动代码。这是在执行通用启动算法之前必须完成的步骤。
然后,函数通过发送 INIT 中断来重置其他处理器。这个中断是电平触发的,发送后会等待一段时间,然后再次发送 INIT 中断,但这次不再触发。这个过程是为了确保其他处理器已经被正确地重置。
接下来,函数发送两次启动 IPI,以使处理器进入指定地址的代码。根据 Intel 的官方算法,正常的硬件只会在由于 INIT 而处于停止状态时接受 STARTUP。因此,第二次发送的 STARTUP 应该会被忽略。但是,为了遵循官方算法,这里还是发送了两次。
总的来说,这段代码通过发送 IPI 来重置和启动其他处理器,然后等待处理器进入指定地址的代码。这是一个典型的使用 IPI 进行处理器间同步的例子。
AP 和 BSP 启动代码的差异
kern/mpentry.S
和 boot/boot.S
都是启动代码,但它们的运行环境和目标不同。boot/boot.S
是 BIOS 加载的第一段代码,它的目标是切换到保护模式,设置好分页,然后加载并跳转到内核。
而 kern/mpentry.S
是在内核中用于启动其他处理器的代码。当启动其他处理器(AP)时,BIOS 会将 AP 设置为实模式,并从预设的物理地址开始执行代码。因此,我们需要将 kern/mpentry.S
编译为可以在物理地址运行的代码,这就是 MPBOOTPHYS
宏的目的。
MPBOOTPHYS
宏将 kern/mpentry.S
中的所有地址都转换为物理地址。这是因为在 AP 启动的早期阶段,分页还没有被设置,处理器还在实模式下运行,此时处理器只能访问物理地址。
MPBOOTPHYS
宏的定义如下:
#define MPBOOTPHYS(s) ((s) - mpentry_start + MPENTRY_PADDR)
这个宏接受一个参数 s
,然后将 s
减去 mpentry_start
的地址,再加上 MPENTRY_PADDR
。这样做的目的是将链接时的虚拟地址转换为运行时的物理地址。
在 kern/mpentry.S
文件中,mpentry_start
是该文件中代码的起始地址,而 MPENTRY_PADDR
是在 inc/memlayout.h
文件中定义的物理地址,其值为 0x7000
。
如果在 kern/mpentry.S
中省略 MPBOOTPHYS
宏,那么生成的代码将无法在 AP 启动的早期阶段正确执行,因为那时的地址空间还是物理地址空间,而不是内核的虚拟地址空间。
而在 boot/boot.S
中,由于它是在没有开启分页的实模式下运行的,所以它直接使用物理地址,不需要进行地址转换,因此不需要 MPBOOTPHYS
宏。
CPU 内核
因为多个 CPU 可以同时陷入内核,所以我们需要为每个处理器提供一个单独的内核栈,以防止它们干扰彼此的执行。数组percpu_kstacks[NCPU][KSTKSIZE]
为 NCPU 的内核栈预留了空间。
此前将bootstack
引用的物理内存映射为 BSP 的内核栈,就在KSTACKTOP
下面。同样,此时需要把每个 CPU 的内核栈映射到这个区域,用保护页作为它们之间的缓冲。CPU 0 的栈仍然会从KSTACKTOP
向下增长;CPU 1 的栈将从 CPU 0 栈的底部向下KSTKGAP
字节开始,以此类推。
每个 CPU 都需要一个任务状态段(TSS)来指定每个 CPU 的内核栈在哪里。CPU i 的 TSS 存储在cpus[i].cpu_ts
中,相应的 TSS 描述符定义在 GDT 条目gdt[(GD_TSS0 >> 3) + i]
中。在kern/trap.c
中定义的全局变量ts
将不再有用。
本文介绍了在对称多处理器系统(SMP)中,每个处理器(CPU)都有独立的内核栈的重要性,避免了并发操作可能导致的数据混乱。通过在内核页目录 kern_pgdir 中为每个 CPU 映射内核栈,确保了每个 CPU 在执行内核代码时都使用自己的内核栈。
文章详细讲解了如何通过循环遍历每个 CPU,使用 boot_map_region 函数在虚拟地址区域 [KSTACKTOP-PTSIZE, KSTACKTOP)
中映射每个 CPU 的栈。进一步介绍了在多核系统中初始化任务状态段(TSS)和中断描述符表(IDT)的实现细节。重点在于为每个 CPU 设置正确的 TSS 和 IDT,以确保新启动的 CPU 能够正确处理中断和执行代码。文章最后,通过示例展示了 GDT 的定义,包括内核代码段、内核数据段、用户代码段、用户数据段以及每个 CPU 的 TSS 描述符,以实现多核环境下的正确切换和处理。
多处理器内核栈初始化细节
在对称多处理器(SMP)系统中,每个处理器(CPU)都有自己的内核栈。这是因为每个 CPU 都可能独立地运行不同的内核代码,这些代码可能会在不同的时间对其各自的内核栈进行推送和弹出操作。如果所有 CPU 共享一个内核栈,那么这些并发操作可能会导致数据混乱和不可预测的行为。
因此,为了保证每个 CPU 都有自己独立的内核栈空间,我们需要在内核页目录 kern_pgdir
中为每个 CPU 设置内核栈的映射。这样,每个 CPU 在执行内核代码时,都会使用其自己的内核栈,从而避免了上述的问题。
接下来讲解如何在内核页目录 kern_pgdir
中为每个 CPU 设置内核栈的映射。在代码中通过循环遍历每个 CPU,并使用 boot_map_region
函数在虚拟地址区域 [KSTACKTOP-PTSIZE, KSTACKTOP)
中映射每个 CPU 的栈。这个区域被划分为多个部分,每个部分对应一个 CPU 的内核栈。下面是对应区域的图形化表示:
KERNBASE, ----> +------------------------------+ 0xf0000000 --+
KSTACKTOP | CPU0's Kernel Stack | RW/-- KSTKSIZE |
| - - - - - - - - - - - - - - -| |
| Invalid Memory (*) | --/-- KSTKGAP |
+------------------------------+ |
| CPU1's Kernel Stack | RW/-- KSTKSIZE |
| - - - - - - - - - - - - - - -| PTSIZE
| Invalid Memory (*) | --/-- KSTKGAP |
+------------------------------+ |
: . : |
: . : |
MMIOLIM ------> +------------------------------+ 0xefc00000 --+
每个 CPU 的内核栈从虚拟地址 kstacktop_i = KSTACKTOP - i * (KSTKSIZE + KSTKGAP)
向下增长,并且被划分为两部分:
[kstacktop_i - KSTKSIZE, kstacktop_i)
:这部分由物理内存支持。[kstacktop_i - (KSTKSIZE + KSTKGAP), kstacktop_i - KSTKSIZE)
:这部分没有被物理内存支持;所以如果内核溢出其栈,它将触发错误,而不是覆盖另一个 CPU 的栈。这被称为 "guard page"。
这个映射的虚拟地址从 KSTACKTOP - KSTKSIZE - i * (KSTKSIZE + KSTKGAP)
开始,大小为 KSTKSIZE
,物理地址为 percpu_kstacks[i]
对应的物理地址,权限为 PTE_W
(即,可写)。
在函数的实现中,使用了一个循环来遍历每个 CPU,并使用 boot_map_region
函数来设置每个 CPU 的内核栈的映射。这个映射的虚拟地址从 KSTACKTOP - KSTKSIZE - i * (KSTKSIZE + KSTKGAP)
开始,大小为 KSTKSIZE
,物理地址为 percpu_kstacks[i]
对应的物理地址,权限为 PTE_W
(即,可写)。
AP 启动过程
在多处理器系统中,引导处理器(BSP)负责启动操作系统,然后通过发送特殊的中断请求(IPI)来启动其他的 AP。每个 AP 都是在收到引导 CPU 发送的 STARTUP IPI(中断请求)后启动的。AP 将以实模式启动,CS:IP 设置为 XY00:0000,其中 XY 是与 STARTUP 一起发送的 8 位值。因此,AP 启动时的汇编代码必须从 4096 字节边界开始。
因为这段代码将 DS(数据段寄存器)设置为零,所以它必须从物理内存的低 2^16 字节的地址运行。
在 init.c 中的 boot_aps()函数将这段代码复制到 MPENTRY_PADDR(满足上述限制)。然后,对于每个 AP,它在 mpentry_kstack 中存储预分配的每核栈的地址,发送 STARTUP IPI,并等待这段代码确认它已经启动(这在 init.c 中的 mp_main 中发生)。
这段代码与 boot/boot.S 相似,除了以下两点:
- 它不需要启用 A20
- 它使用 MPBOOTPHYS 来计算其符号的绝对地址,而不是依赖链接器来填充它们。
为什么 AP 以实模式启动?
AP(辅助处理器)以实模式启动是因为在计算机启动时,所有的 x86 处理器都会以实模式启动。实模式是 x86 处理器的初始状态,它只能访问 1MB 的内存,没有内存保护,也不支持多任务。AP 会执行位于这个地址的代码。这段代码通常会将处理器切换到保护模式或长模式,这样处理器就可以访问更多的内存,支持内存保护和多任务等特性。在你的代码中,这个过程在mpentry_start
标签开始的地方进行。
所以,AP 以实模式启动是由于 x86 处理器的硬件设计和多处理器规范的要求。
同 boot/boot.S 相比,为什么不需要启用 A20 ?
在实模式下,CPU 只能访问 1MB 的内存,但由于地址回绕的问题,实际上可以访问到 1MB+64KB 的内存。当 A20 线被禁用时,任何尝试访问超过 1MB 的内存的操作都会回绕到 64KB-1MB 的内存区域。启用 A20 线可以解决这个问题,允许 CPU 访问超过 1MB 的内存。
然而,在保护模式或长模式下,CPU 可以访问的内存远超 1MB,而且没有地址回绕的问题。因此,启用 A20 线在这些模式下并不是必需的。
在代码中,AP(辅助处理器)在接收到 STARTUP IPI 后,会以实模式启动,然后执行的代码会将处理器切换到保护模式或长模式。因此,这段代码不需要启用 A20 线。
另一方面,boot/boot.S
是引导加载器的一部分,它在实模式下运行,需要处理超过 1MB 的内存,因此需要启用 A20 线。
绝对地址计算方式
为什么使用 MPBOOTPHYS 来计算其符号的绝对地址,而不是依赖链接器来填充它们?
在代码中,使用 MPBOOTPHYS 宏来计算符号的绝对地址是因为这段代码会被复制到一个固定的物理地址(MPENTRY_PADDR)处执行,而不是在其被链接的位置执行。因此,我们不能依赖链接器来填充这些符号的地址,而需要手动计算它们在运行时的实际地址。
MPBOOTPHYS(s)宏的作用是计算出符号 s 在被复制到 MPENTRY_PADDR 后的实际物理地址。它通过将符号 s 的链接地址减去 mpentry_start 的链接地址,然后加上 MPENTRY_PADDR,得到符号 s 的实际物理地址。
在代码中,MPENTRY_PADDR
被定义为 0x7000,这是一个满足上述要求的地址。boot_aps()
函数会将mpentry.S
中的代码复制到这个地址,然后向 AP 发送 STARTUP IPI,AP 就会开始执行这段代码。
boot/boot.S
是 BSP 的启动汇编代码,和kern/mpentry.S
相比,它们的处理方式有所不同。
首先,boot/boot.S
是引导加载器的代码,它在系统启动时被执行。在编译阶段,链接器会将boot/boot.S
链接到一个可执行文件中,这个可执行文件会被写入到硬盘的引导扇区。当计算机启动时,BIOS 会加载并执行引导扇区的内容,也就是boot/boot.S
的代码。
具体的链接过程如下:
- 编译器将
boot/boot.S
编译成对象文件。 - 链接器将对象文件链接成一个可执行文件。
- 写入硬盘的引导扇区。
然后,kern/mpentry.S
是用于启动辅助处理器(AP)的代码。在系统运行时,主处理器(BSP)会将kern/mpentry.S
的代码复制到一个特定的物理地址(MPENTRY_PADDR
),然后向 AP 发送 STARTUP IPI,AP 就会开始执行这段代码。
具体的复制过程如下:
- 编译器将
kern/mpentry.S
编译成对象文件。 - 在系统运行时,
boot_aps()
函数(在kern/init.c
中)会将kern/mpentry.S
的代码复制到MPENTRY_PADDR
地址。下面是具体的复制代码。
extern unsigned char mpentry_start[], mpentry_end[];
code = KADDR(MPENTRY_PADDR);
memmove(code, mpentry_start, mpentry_end - mpentry_start);
- BSP 向 AP 发送 STARTUP IPI,AP 开始执行
MPENTRY_PADDR
地址处的代码。
这两个过程的主要区别在于,boot/boot.S
的代码是在系统启动时被执行,而kern/mpentry.S
的代码是在系统运行时被复制和执行的。
AP 启动 C 语言部分
这部分内容讲解 AP 启动后执行的 C 语言代码。这段代码是在多处理器环境中启动应用处理器(AP)的主要函数。当引导处理器(BP)启动并初始化系统。
void
mp_main(void)
{
// We are in high EIP now, safe to switch to kern_pgdir
lcr3(PADDR(kern_pgdir));
cprintf("SMP: CPU %d starting\n", cpunum());
lapic_init();
env_init_percpu();
trap_init_percpu();
xchg(&thiscpu->cpu_status, CPU_STARTED); // tell boot_aps() we're up
// ...
}
函数的主要步骤如下:
lcr3(PADDR(kern_pgdir));
这行代码的作用就是将页目录基址寄存器(CR3)设置为内核页目录的物理地址,也就是切换到内核的页表。CR3 寄存器存储的是当前活动页目录的物理地址。
cprintf("SMP: CPU %d starting\n", cpunum());
:这行代码打印一条消息,表明当前的 CPU 已经开始启动。这样做的目的是为了让开发者知道哪个 CPU 正在启动。
lapic_init();
:这行代码初始化了本地 APIC。APIC 是高级可编程中断控制器的缩写,它用于在多处理器系统中处理中断。这样做的目的是为了让新启动的 CPU 能够正确处理中断。
env_init_percpu();
:这行代码为当前 CPU 初始化环境。这样做的目的是为了让新启动的 CPU 有一个正确的运行环境。
trap_init_percpu();
:这行代码为当前 CPU 初始化陷阱处理程序。这样做的目的是为了让新启动的 CPU 能够正确处理陷阱。
xchg(&thiscpu->cpu_status, CPU_STARTED);
:这行代码将当前 CPU 的状态设置为已启动。这是一个原子操作,它同时读取和写入thiscpu->cpu_status
。这样做的目的是为了让引导处理器知道新启动的 CPU 已经完成了初始化。
总的来说,这段代码的目的是为了启动新的 CPU,并为其设置正确的运行环境。
为什么要切换页表?
在多处理器系统中,每个处理器都有自己的页表,用于管理自己的虚拟内存到物理内存的映射。当一个新的处理器启动时,它需要加载正确的页表,以便能够正确地访问内存。
lcr3(PADDR(kern_pgdir));
这行代码的作用就是将页目录基址寄存器(CR3)设置为内核页目录的物理地址,也就是切换到内核的页表。CR3 寄存器存储的是当前活动页目录的物理地址。
切换之前,新启动的处理器可能没有有效的页表,或者使用的是 BIOS 提供的页表,这个页表可能并不符合操作系统的需求。例如,它可能没有包含操作系统内核所在的地址空间,或者没有正确地设置页的权限。
切换之后,处理器就可以使用内核的页表了。这个页表包含了操作系统内核的地址空间,并且正确地设置了页的权限。这样,处理器就可以正确地访问内核代码和数据,以及用户进程的地址空间了。
在多核处理器系统中,所有的处理器都使用相同的kern_pgdir
。这是因为kern_pgdir
是内核的页目录,它包含了内核的地址空间。由于内核代码和数据是共享的,所以所有的处理器都需要访问相同的内核地址空间,因此它们都使用相同的kern_pgdir
。
当然,每个处理器都有自己的页目录基址寄存器(CR3),用于存储当前活动的页目录的物理地址。当处理器需要切换到用户模式时,它会通过改变 CR3 的值来加载对应的用户进程的页目录。但是在内核模式下,所有处理器的 CR3 都指向kern_pgdir
。
为当前 CPU 初始化环境
这段代码的目的是初始化每个处理器的段寄存器和全局描述符表(GDT)。在 x86 架构中,段寄存器和全局描述符表是用于内存管理和保护的重要组成部分。
-
lgdt(&gdt_pd);
:这行代码加载全局描述符表(GDT)。GDT 包含了内核代码段、内核数据段、用户代码段和用户数据段的描述符。这样做的目的是为了让处理器知道如何访问内存。 -
asm volatile("movw %%ax,%%gs" : : "a" (GD_UD|3));
和asm volatile("movw %%ax,%%fs" : : "a" (GD_UD|3));
:这两行代码将 GS 和 FS 寄存器的值设置为用户数据段的选择子。这样做的目的是为了让处理器在用户模式下能够正确地访问数据。 -
asm volatile("movw %%ax,%%es" : : "a" (GD_KD));
、asm volatile("movw %%ax,%%ds" : : "a" (GD_KD));
和asm volatile("movw %%ax,%%ss" : : "a" (GD_KD));
:这三行代码将 ES、DS 和 SS 寄存器的值设置为内核数据段的选择子。这样做的目的是为了让处理器在内核模式下能够正确地访问数据。 -
asm volatile("ljmp %0,$1f\n 1:\n" : : "i" (GD_KT));
:这行代码将 CS 寄存器的值设置为内核代码段的选择子,并跳转到标签 1。这样做的目的是为了让处理器在内核模式下能够正确地执行代码。 -
lldt(0);
:这行代码清除了本地描述符表(LDT)。在这个系统中,LDT 没有被使用,所以清除 LDT 是为了防止其可能存在的旧数据对系统造成影响。
总的来说,这段代码的目的是为了设置正确的内存访问环境,以便处理器能够正确地执行代码和访问数据。
单核初始化 TSS 和 IDT 的实现细节
下面的代码是只有一个 CPU 时初始化并任务状态段(Task State Segment,TSS)和中断描述符表(Interrupt Descriptor Table,IDT)的实现细节。随后要将其改为支持多核。
void
trap_init_percpu(void)
{
ts.ts_esp0 = KSTACKTOP;
ts.ts_ss0 = GD_KD;
ts.ts_iomb = sizeof(struct Taskstate);
gdt[GD_TSS0 >> 3] = SEG16(STS_T32A, (uint32_t) (&ts),
sizeof(struct Taskstate) - 1, 0);
gdt[GD_TSS0 >> 3].sd_s = 0;
ltr(GD_TSS0);
lidt(&idt_pd);
}
首先,设置 TSS 以便在陷入内核时获取正确的栈。这里的 ts.ts_esp0 = KSTACKTOP;
是当前 CPU 的内核栈顶,ts.ts_ss0 = GD_KD;
设置的是内核数据段选择子。
然后,初始化全局描述符表(Global Descriptor Table,GDT)中的 TSS 槽。gdt[GD_TSS0 >> 3] = SEG16(STS_T32A, (uint32_t) (&ts), sizeof(struct Taskstate) - 1, 0);
这行代码设置了 GDT 中的 TSS 描述符,其中 GD_TSS0 >> 3
是 TSS 描述符的索引,&(ts)
是 TSS 的地址,sizeof(struct Taskstate) - 1
是 TSS 的大小。gdt[GD_TSS0 >> 3].sd_s = 0;
这行代码将 TSS 描述符的 sd_s
字段设置为 0,表示这是一个系统段。
接下来,加载 TSS 选择子。ltr(GD_TSS0);
这行代码加载了 TSS 选择子到任务寄存器(Task Register,TR)。注意,TSS 选择子的底部三位是特殊的,我们将它们保留为 0。
最后,加载 IDT。lidt(&idt_pd);
这行代码加载了 IDT 的基地址和限制到中断描述符表寄存器(Interrupt Descriptor Table Register,IDTR)。
多核 TSS 和 IDT 的实现细节
接下来需要将此前的单核任务状态段(Task State Segment,TSS)和中断描述符表(Interrupt Descriptor Table,IDT)改为多核实现。
void
trap_init_percpu(void)
{
int cid = thiscpu->cpu_id;
thiscpu->cpu_ts.ts_esp0 = KSTACKTOP - cid * (KSTKSIZE + KSTKGAP);
thiscpu->cpu_ts.ts_ss0 = GD_KD;
gdt[(GD_TSS0 >> 3)+cid] = SEG16(STS_T32A, (uint32_t) (&(thiscpu->cpu_ts)),
sizeof(struct Taskstate), 0);
gdt[(GD_TSS0 >> 3)+cid].sd_s = 0;
ltr(GD_TSS0+8*cid);
lidt(&idt_pd);
}
首先,获取当前 CPU 的 ID,然后设置 TSS 以便在陷入内核时获取正确的栈。这里的 thiscpu->cpu_ts.ts_esp0
是当前 CPU 的内核栈顶,KSTACKTOP - cid * (KSTKSIZE + KSTKGAP)
计算的是每个 CPU 的内核栈顶地址。thiscpu->cpu_ts.ts_ss0 = GD_KD;
设置的是内核数据段选择子。当发生从用户态到内核态的中断时,CPU 会自动从 TSS 中读取 ts_esp0
和 ts_ss0
这两个字段的值,分别设置堆栈指针寄存器(ESP)和堆栈段寄存器(SS)。因此,ts.ts_ss0 = GD_KD;
这行代码实际上是在设置内核态堆栈的段选择子,以便在发生中断时能正确地切换到内核堆栈。
然后,初始化全局描述符表(Global Descriptor Table,GDT)中的 TSS 槽。gdt[(GD_TSS0 >> 3)+cid] = SEG16(STS_T32A, (uint32_t) (&(thiscpu->cpu_ts)), sizeof(struct Taskstate), 0);
这行代码设置了 GDT 中的 TSS 描述符,其中 GD_TSS0 >> 3
是 TSS 描述符的索引,cid
是当前 CPU 的 ID,&(thiscpu->cpu_ts)
是 TSS 的地址,sizeof(struct Taskstate)
是 TSS 的大小。gdt[(GD_TSS0 >> 3)+cid].sd_s = 0;
这行代码将 TSS 描述符的 sd_s
字段设置为 0,表示这是一个系统段。
接下来,加载 TSS 选择子。ltr(GD_TSS0+8*cid);
这行代码加载了 TSS 选择子到任务寄存器(Task Register,TR)。注意,TSS 选择子的底部三位是特殊的,我们将它们保留为 0。
最后,加载 IDT。lidt(&idt_pd);
这行代码加载了 IDT 的基地址和限制到中断描述符表寄存器(Interrupt Descriptor Table Register,IDTR)。
全局描述符表 GDT
在 x86 架构中,全局描述符表(GDT)是一种数据结构,用于定义各种类型的段。这些段包括内核段、用户段、任务状态段(TSS)等。每个段都有一个对应的段描述符,存储在 GDT 中。CPU 通过查询 GDT 来获取段的属性和位置。
每个段描述符定义了一个段的属性和位置。这里的段可以被用于多种目的,虽然我们不使用它们的内存映射能力,但我们需要它们来切换特权级别。
在 x86 架构中,特权级别是通过段选择子(segment selector)的特权级别(Descriptor Privilege Level,DPL)和当前特权级别(Current Privilege Level,CPL)来控制的。当 CPU 执行特权级别更高(数字更小)的代码时,需要通过加载相应的段选择子来切换特权级别。
struct Segdesc gdt[NCPU + 5] =
{
// 0x0 - unused (always faults -- for trapping NULL far pointers)
SEG_NULL,
// 0x8 - kernel code segment
[GD_KT >> 3] = SEG(STA_X | STA_R, 0x0, 0xffffffff, 0),
// 0x10 - kernel data segment
[GD_KD >> 3] = SEG(STA_W, 0x0, 0xffffffff, 0),
// 0x18 - user code segment
[GD_UT >> 3] = SEG(STA_X | STA_R, 0x0, 0xffffffff, 3),
// 0x20 - user data segment
[GD_UD >> 3] = SEG(STA_W, 0x0, 0xffffffff, 3),
// Per-CPU TSS descriptors (starting from GD_TSS0) are initialized
// in trap_init_percpu()
[GD_TSS0 >> 3] = SEG_NULL
};
在这个 GDT 中,内核模式和用户模式的段是分开的。内核和用户的段是相同的,除了 DPL(Descriptor Privilege Level,描述符特权级别)。为了加载 SS 寄存器,CPL(Current Privilege Level,当前特权级别)必须等于 DPL。因此,我们必须为用户和内核复制段。
具体来说,SEG 宏在定义 gdt 时使用的最后一个参数指定了该描述符的 DPL:0 代表内核,3 代表用户。
这个 GDT 包含以下段:
- 0x0 - 未使用(总是出错 -- 用于捕获 NULL 远指针)
- 0x8 - 内核代码段
- 0x10 - 内核数据段
- 0x18 - 用户代码段
- 0x20 - 用户数据段
- 每个 CPU 的 TSS 描述符(从 GD_TSS0 开始)在 trap_init_percpu()中初始化
上述代码中 GDT 的每个条目都是一个段描述符,定义了一个段的属性和位置。这些段包括内核代码段、内核数据段、用户代码段、用户数据段和任务状态段(Task State Segment,TSS)。这些段的 DPL 分别设置为 0(内核模式)和 3(用户模式),以此来实现特权级别的切换。
例如,当 CPU 从用户模式切换到内核模式时,会加载内核代码段和内核数据段的段选择子,从而将 CPL 切换到 0;反之,当 CPU 从内核模式切换到用户模式时,会加载用户代码段和用户数据段的段选择子,从而将 CPL 切换到 3。
在这个 GDT 中,内核模式和用户模式的段是分开的。内核和用户的段是相同的,除了 DPL(Descriptor Privilege Level,描述符特权级别)。为了加载 SS 寄存器,CPL(Current Privilege Level,当前特权级别)必须等于 DPL。
具体来说,SEG 宏在定义 gdt 时使用的最后一个参数指定了该描述符的 DPL:0 代表内核,3 代表用户。
自旋锁是一种同步机制,用于在多处理器环境中保护共享资源。当一个线程尝试获取一个已经被其他线程持有的自旋锁时,它会在一个循环中不断地检查锁是否已经被释放,这就是所谓的"自旋"。这种机制的优点是避免了线程切换的开销,但是如果锁被持有的时间过长,会导致其他等待获取锁的线程浪费 CPU 时间。
spinlock 使用示例
使用自旋锁的一般步骤如下:
- 初始化自旋锁。可以使用
__spin_initlock
函数来初始化一个自旋锁。
struct spinlock lock;
__spin_initlock(&lock, "my_lock");
- 在访问共享资源之前,使用
spin_lock
函数来获取自旋锁。如果锁已经被其他线程持有,那么spin_lock
函数会一直等待,直到锁变为可用。
spin_lock(&lock);
- 访问共享资源。在这个阶段,其他试图获取锁的线程会被阻塞,直到你释放锁。
// access shared resources
- 在访问完共享资源之后,使用
spin_unlock
函数来释放自旋锁。这会使得其他等待获取锁的线程可以继续执行。
spin_unlock(&lock);
这就是自旋锁的基本使用方法。需要注意的是,自旋锁不应该被持有的时间过长,否则会导致其他等待获取锁的线程浪费 CPU 时间。
spinlock 结构体设计
spinlock
结构体就是一个自旋锁的实现。它有一个locked
字段,用于表示锁是否被持有。当locked
的值为 0 时,表示锁是可用的;当locked
的值为 1 时,表示锁已经被某个线程持有。
这段代码定义了一个名为spinlock
的结构体,它用于实现自旋锁的功能。
// Mutual exclusion lock.
struct spinlock {
unsigned locked; // Is the lock held?
#ifdef DEBUG_SPINLOCK
// For debugging:
char *name; // Name of lock.
struct CpuInfo *cpu; // The CPU holding the lock.
uintptr_t pcs[10]; // The call stack (an array of program counters)
// that locked the lock.
#endif
};
在这个结构体中:
unsigned locked
:这是一个无符号整数,用于表示锁是否被持有。如果locked
的值为 0,那么表示锁是可用的;如果locked
的值为 1,那么表示锁已经被某个线程持有。
在DEBUG_SPINLOCK
被定义的情况下,spinlock
结构体还包含以下字段,这些字段主要用于调试:
-
char *name
:这是一个指向字符的指针,用于存储锁的名称。 -
struct CpuInfo *cpu
:这是一个指向CpuInfo
结构体的指针,用于表示当前持有锁的 CPU。 -
uintptr_t pcs[10]
:这是一个uintptr_t
类型的数组,用于存储调用栈。当锁被锁定时,会记录下当前的程序计数器(Program Counter,PC)的值,也就是锁定锁的那个函数的地址。这对于调试是非常有用的,因为它可以让我们知道是哪个函数锁定了锁。
如何获取锁?
这段代码是实现自旋锁的spin_lock
函数。即如何获取 lock 的实现细节。
// Acquire the lock.
// Loops (spins) until the lock is acquired.
// Holding a lock for a long time may cause
// other CPUs to waste time spinning to acquire it.
void
spin_lock(struct spinlock *lk)
{
#ifdef DEBUG_SPINLOCK
if (holding(lk))
panic("CPU %d cannot acquire %s: already holding", cpunum(), lk->name);
#endif
// The xchg is atomic.
// It also serializes, so that reads after acquire are not
// reordered before it.
while (xchg(&lk->locked, 1) != 0)
asm volatile ("pause");
// Record info about lock acquisition for debugging.
#ifdef DEBUG_SPINLOCK
lk->cpu = thiscpu;
get_caller_pcs(lk->pcs);
#endif
}
在这段代码中:
-
if (holding(lk)) panic("CPU %d cannot acquire %s: already holding", cpunum(), lk->name);
:这行代码检查当前 CPU 是否已经持有了这个锁。如果已经持有,那么就会触发一个 panic,因为这是一个错误的使用情况。一个 CPU 不应该尝试获取它已经持有的锁。 -
while (xchg(&lk->locked, 1) != 0) asm volatile ("pause");
:这行代码是获取锁的主要部分。xchg 指令是一种原子交换指令,它可以在多处理器环境中实现同步,它将lk->locked
的值设置为 1,并返回原来的值。如果原来的值为 0,那么表示锁是可用的,这个 CPU 就成功地获取了锁。如果原来的值为 1,那么表示锁已经被其他 CPU 持有,这个 CPU 就需要等待,直到锁变为可用。在等待的过程中,CPU 执行了一个pause
指令,这可以避免 CPU 的忙等待,减少资源的浪费。 -
lk->cpu = thiscpu; get_caller_pcs(lk->pcs);
:这两行代码记录了一些关于锁获取的调试信息,包括当前持有锁的 CPU 和获取锁时的调用栈。
总的来说,这段代码的目的是获取一个自旋锁。如果锁已经被其他 CPU 持有,那么当前 CPU 会等待,直到锁变为可用。这是一种简单但有效的同步机制,可以保护共享资源在多处理器环境中的并发访问。
如何判断当前 CPU 是否已经持有了这个锁?
在 DEBUG 模式下可以通过下面的函数判断是否持有锁。原因是 struct spinlock
中保留了当前 cpu 的 id ,可以直接同当前 cpu 比较来判断是否持有。
// Check whether this CPU is holding the lock.
static int
holding(struct spinlock *lock)
{
return lock->locked && lock->cpu == thiscpu;
}
在这段代码中:
-
struct spinlock *lock
:这是一个指向spinlock
结构体的指针,表示要检查的自旋锁。 -
return lock->locked && lock->cpu == thiscpu;
:这行代码返回一个布尔值,表示当前的 CPU 是否持有给定的自旋锁。如果lock->locked
的值为 1,并且lock->cpu
的值等于当前的 CPU,那么表示当前的 CPU 持有这个自旋锁,函数返回true
;否则,函数返回false
。
总的来说,这段代码的目的是检查当前的 CPU 是否持有给定的自旋锁。这在多处理器环境中是非常有用的,因为我们需要确保在任何时候,只有一个 CPU 可以持有一个给定的自旋锁,以保护共享资源的并发访问。
为什么 PAUSE 指令可以避免 CPU 忙等
在多处理器环境中,当一个线程尝试获取一个已经被其他线程持有的自旋锁时,它会在一个循环中不断地检查锁是否已经被释放,这就是所谓的"自旋"。这种情况下,CPU 处于忙等待状态,即它在等待锁释放的过程中,CPU 并没有做其他的有用工作,而是在消耗 CPU 时间。
pause
指令是 Intel 提供的一种优化忙等待的手段。当 CPU 执行pause
指令时,它会暂时停止执行,让出 CPU 给其他的线程或进程,从而减少资源的浪费。这是因为,如果 CPU 一直在忙等待,那么它就会占用大量的 CPU 时间,而这些 CPU 时间本可以用来执行其他的有用工作。
另外,pause
指令还可以提高多线程程序的性能。因为在多线程环境中,线程之间的同步是非常重要的。如果一个线程在等待锁释放的过程中一直占用 CPU,那么其他需要运行的线程就无法获取 CPU,这会导致程序的性能下降。而pause
指令可以让出 CPU,使得其他线程有机会运行,从而提高程序的整体性能。
总的来说,pause
指令的目的是优化忙等待,减少资源的浪费,并提高多线程程序的性能。
如何释放锁?
下面这段代码用于释放一个自旋锁。
// Release the lock.
void
spin_unlock(struct spinlock *lk)
{
#ifdef DEBUG_SPINLOCK
if (!holding(lk)) {
int i;
uint32_t pcs[10];
// Nab the acquiring EIP chain before it gets released
memmove(pcs, lk->pcs, sizeof pcs);
cprintf("CPU %d cannot release %s: held by CPU %d\nAcquired at:",
cpunum(), lk->name, lk->cpu->cpu_id);
for (i = 0; i < 10 && pcs[i]; i++) {
struct Eipdebuginfo info;
if (debuginfo_eip(pcs[i], &info) >= 0)
cprintf(" %08x %s:%d: %.*s+%x\n", pcs[i],
info.eip_file, info.eip_line,
info.eip_fn_namelen, info.eip_fn_name,
pcs[i] - info.eip_fn_addr);
else
cprintf(" %08x\n", pcs[i]);
}
panic("spin_unlock");
}
lk->pcs[0] = 0;
lk->cpu = 0;
#endif
// The xchg instruction is atomic (i.e. uses the "lock" prefix) with
// respect to any other instruction which references the same memory.
// x86 CPUs will not reorder loads/stores across locked instructions
// (vol 3, 8.2.2). Because xchg() is implemented using asm volatile,
// gcc will not reorder C statements across the xchg.
xchg(&lk->locked, 0);
}
在这段代码中 xchg(&lk->locked, 0);
:这行代码将lk->locked
的值设置为 0,表示锁已经被释放。xchg
是一个原子操作,它可以确保在多处理器环境中,锁的释放操作是原子的,不会被其他的 CPU 打断。
总的来说,这段代码的目的是释放一个自旋锁。如果当前的 CPU 不持有这个锁,那么就会触发一个 panic,表示发生了一个严重的错误。如果当前的 CPU 持有这个锁,那么就会将锁的状态设置为已释放,然后返回。
总结
自旋锁是一种同步机制,用于在多处理器环境中保护共享资源。当一个线程尝试获取一个已被其他线程持有的自旋锁时,它会在一个循环中不断检查锁是否已被释放,避免线程切换的开销。优点是避免了线程切换,但如果锁被持有时间过长,会导致其他等待线程浪费 CPU 时间。
自旋锁的基本使用包括初始化、获取、访问共享资源和释放。spinlock
结构体包含locked
字段表示锁状态,以及调试信息字段。获取锁的实现使用原子交换指令和pause
指令,避免 CPU 忙等。判断当前 CPU 是否持有锁通过比较 CPU ID。释放锁通过原子操作将锁状态置为 0 实现。
释放锁的代码还包含调试信息输出,用于追踪锁的获取和释放情况。pause
指令的作用是减少忙等待的资源浪费,提高多线程程序性能。
循环调度(Round-Robin Scheduling)是一种计算机操作系统中常用的进程或任务调度算法。在这种调度算法中,每个进程被赋予一个固定的时间片(也称为量子),在这个时间片内,进程有权利使用 CPU。如果进程在其分配的时间片内没有完成,那么系统将会剥夺其对 CPU 的使用权,并将其放回就绪队列等待下一次的调度。
循环调度算法的主要优点是公平性和简单性。每个进程都有相等的机会获得 CPU 时间,不会出现某个进程长时间得不到调度的情况。同时,这种算法的实现也相对简单。
循环调度的例子
下面是一个简单的例子来说明循环调度的工作原理:
假设我们有三个进程:P1、P2 和 P3,它们的执行时间分别为 20ms、10ms 和 30ms。我们设置时间片为 10ms。
- 首先,调度器选择 P1 运行,P1 运行 10ms 后,时间片用完,P1 被放回就绪队列,剩余执行时间为 10ms。
- 接着,调度器选择 P2 运行,P2 运行 10ms 后,任务完成,从就绪队列中移除。
- 然后,调度器选择 P3 运行,P3 运行 10ms 后,时间片用完,P3 被放回就绪队列,剩余执行时间为 20ms。
- 此时,就绪队列中有 P1 和 P3,调度器再次选择 P1 运行,P1 运行 10ms 后,任务完成,从就绪队列中移除。
- 最后,调度器选择 P3 运行,P3 运行 20ms 后,任务完成,从就绪队列中移除。
以上就是循环调度的基本工作原理。在实际的操作系统中,循环调度可能会结合优先级、I/O 等待等因素进行更复杂的调度。
循环调度的实现细节
下面的代码是一个简单的轮询调度算法的实现,用于在多个用户进程之间进行调度。
void
sched_yield(void)
{
struct Env *idle;
int start = (curenv == NULL) ? 0 : ENVX(curenv->env_id) + 1;
int i;
for (i = 0; i < NENV; i++) {
int j = (i + start) % NENV;
if (envs[j].env_status == ENV_RUNNABLE) {
env_run(&envs[j]);
}
}
if (curenv != NULL && curenv->env_status == ENV_RUNNING) {
env_run(curenv);
}
// sched_halt never returns
sched_halt();
}
首先,定义了一个 start
变量,它表示开始搜索可运行进程的位置。如果当前没有运行的进程(curenv == NULL
),那么从 envs
数组的开始位置搜索;否则,从当前进程的下一个位置开始搜索。这是为了保证所有的进程都有公平的机会被调度到,而不是总是从同一个位置开始搜索。
然后,进行一个循环,遍历 envs
数组。在每次循环中,计算出当前要检查的进程的索引 j
,这是通过 (i + start) % NENV
来实现的,保证了索引总是在 0
到 NENV-1
之间,实现了环形的搜索。如果找到一个状态为 ENV_RUNNABLE
的进程,那么就调用 env_run(&envs[j])
切换到这个进程并运行它。
如果遍历完 envs
数组都没有找到可运行的进程,但是当前进程的状态仍然是 ENV_RUNNING
,那么就继续运行当前进程。这是为了保证如果没有其他可运行的进程,当前进程可以继续运行,不会浪费 CPU 的时间。
最后,如果没有任何可运行的进程,那么就调用 sched_halt()
让 CPU 进入停机状态。这是为了在没有工作可做的时候,让 CPU 进入低功耗的状态,节省能源。
这段代码的目的是实现一个公平、简单的进程调度算法。通过轮询的方式,保证了每个进程都有公平的机会被调度到。同时,代码的实现也相对简单,易于理解和维护。
如何实现进程切换?
下面这段代码是在操作系统中进行进程切换的函数,函数名为env_run
,接收一个Env
类型的参数e
,表示要切换到的进程。
void
env_run(struct Env *e)
{
if (curenv && curenv->env_status == ENV_RUNNING) {
curenv->env_status = ENV_RUNNABLE;
}
curenv = e;
curenv->env_status = ENV_RUNNING;
curenv->env_runs++;
lcr3(PADDR(curenv->env_pgdir));
unlock_kernel();
env_pop_tf(&curenv->env_tf);
}
首先,如果当前进程(curenv
)存在并且其状态为运行(ENV_RUNNING
),那么将其状态设置为可运行(ENV_RUNNABLE
)。这是因为我们即将切换到新的进程,所以当前进程需要暂停运行,等待下一次调度。
然后,将curenv
设置为新的进程e
,并将其状态设置为运行(ENV_RUNNING
)。这是因为我们即将开始执行新进程的代码,所以需要将其状态设置为运行。
接着,更新新进程的运行次数(env_runs
)。这是为了统计进程的运行次数,可以用于调度算法,例如优先级调度。
然后,使用lcr3
函数切换到新进程的地址空间。这是因为每个进程都有自己的虚拟地址空间,所以在切换进程时,需要切换到新进程的地址空间。
最后,使用env_pop_tf
函数恢复新进程的寄存器状态,并进入用户模式开始执行新进程。env_pop_tf
函数会将进程的Trapframe
结构中保存的寄存器状态加载到对应的寄存器中,然后使用iret
指令返回到用户模式,开始执行新进程的代码。这一步是必要的,因为每个进程都有自己的寄存器状态,所以在切换进程时,必须恢复新进程的寄存器状态。
总的来说,这段代码的目的是实现进程切换,即从当前进程切换到新的进程,并开始执行新进程的代码。这是操作系统进行进程调度的关键步骤。
执行完 iret 后会跳转到哪里?
切换新进程之后会执行env_pop_tf
函数恢复新进程的寄存器状态,并进入用户模式开始执行新进程。
void
env_pop_tf(struct Trapframe *tf)
{
curenv->env_cpunum = cpunum();
asm volatile(
"\tmovl %0,%%esp\n"
"\tpopal\n"
"\tpopl %%es\n"
"\tpopl %%ds\n"
"\taddl $0x8,%%esp\n" /* skip tf_trapno and tf_errcode */
"\tiret\n"
: : "g" (tf) : "memory");
panic("iret failed"); /* mostly to placate the compiler */
}
执行iret
指令后,CPU 会从栈中弹出eip
、cs
和eflags
寄存器的值,然后跳转到eip
指定的地址开始执行代码。这个地址就是环境的入口点,也就是用户程序的入口函数。具体的函数取决于你在加载 ELF 文件并设置环境时,将哪个函数的地址设置为了环境的入口点。在代码中,这个入口点是通过e->env_tf.tf_eip = ELFHDR->e_entry;
这行代码设置的,其中ELFHDR->e_entry
是从 ELF 文件头中读取的入口地址。
如何实现 CPU 停机?
下面这段代码是 sched_halt
函数的实现,它的作用是在没有可运行的进程时,让当前的 CPU 进入停机状态。
void
sched_halt(void)
{
int i;
for (i = 0; i < NENV; i++) {
if ((envs[i].env_status == ENV_RUNNABLE ||
envs[i].env_status == ENV_RUNNING ||
envs[i].env_status == ENV_DYING))
break;
}
if (i == NENV) {
cprintf("No runnable environments in the system!\n");
while (1)
monitor(NULL);
}
curenv = NULL;
lcr3(PADDR(kern_pgdir));
xchg(&thiscpu->cpu_status, CPU_HALTED);
unlock_kernel();
asm volatile (
"movl $0, %%ebp\n"
"movl %0, %%esp\n"
"pushl $0\n"
"pushl $0\n"
"sti\n"
"hlt\n"
"1:\n"
"jmp 1b\n"
: : "a" (thiscpu->cpu_ts.ts_esp0));
}
首先,函数通过一个循环检查所有的进程,看是否有任何可运行的进程。如果所有的进程都不可运行(即它们的状态不是 ENV_RUNNABLE
、ENV_RUNNING
或 ENV_DYING
),那么就打印一条消息,并进入内核监视器。这是为了在调试和测试时,如果系统中没有可运行的进程,可以方便地进入内核监视器进行调试。
然后,函数将 curenv
设置为 NULL
,表示当前没有进程在运行。并调用 lcr3
函数将页目录切换到内核的页目录。这是因为在没有进程运行时,我们应该使用内核的页目录,而不是任何特定进程的页目录。
接着,函数将当前 CPU 的状态设置为 CPU_HALTED
,表示当前 CPU 已经进入停机状态。这样,当定时器中断发生时,我们知道应该重新获取大内核锁。然后,函数调用 unlock_kernel
释放大内核锁。这是因为在 CPU 进入停机状态时,我们应该释放大内核锁,以允许其他 CPU 进入内核。
最后,函数通过一段内联汇编代码将栈指针重置,启用中断,然后让 CPU 进入停机状态。在这段代码中,movl $0, %%ebp
和 movl %0, %%esp
将栈指针重置,pushl $0
将 0 压入栈两次,sti
启用中断,hlt
让 CPU 进入停机状态,jmp 1b
是一个无限循环,保证 CPU 一直停机,直到有中断发生。
这段代码的目的是在没有可运行的进程时,让 CPU 进入停机状态。同时,它也处理了在 CPU 停机时需要进行的一些清理工作,例如切换页目录、释放大内核锁等。
什么时候会执行进程调度?
在进程调度的过程中,进入内核态通常发生在以下几种情况:
-
系统调用:当用户进程主动发起系统调用时,会触发一个软件中断,使得 CPU 从用户态切换到内核态,开始执行内核代码。
-
异常和硬件中断:当发生异常或础件中断时,CPU 会自动从用户态切换到内核态,开始执行内核的中断处理程序。
在代码中,进程调度的过程是在sched_yield
函数中实现的。当当前进程主动放弃 CPU 使用权,或者当前进程的时间片用完,内核会调用sched_yield
函数来选择一个新的进程运行。在这个过程中,CPU 已经处于内核态。
在代码中,sched_yield
函数是在mp_main
函数中被调用的。mp_main
函数是在 AP(Application Processor,非引导处理器)启动时执行的代码。这个函数在kern/init.c
文件中定义。
void
mp_main(void)
{
lcr3(PADDR(kern_pgdir));
cprintf("SMP: CPU %d starting\n", cpunum());
lapic_init();
env_init_percpu();
trap_init_percpu();
xchg(&thiscpu->cpu_status, CPU_STARTED);
lock_kernel();
sched_yield();
}
在mp_main
函数中,首先调用lcr3
函数切换到内核的页表,然后初始化 LAPIC,初始化每个 CPU 的环境,初始化每个 CPU 的陷阱处理程序,然后将 CPU 的状态设置为已启动。这些操作都是在内核态下完成的。
然后,调用lock_kernel
函数获取内核锁。这个函数也是在内核态下执行的。获取内核锁后,就可以安全地调用sched_yield
函数进行进程调度了。
所以,调用sched_yield
函数之前,CPU 已经处于内核态。这是通过在 AP 启动时执行的mp_main
函数实现的。
Fork 是一个非常重要的系统调用,它允许创建一个与当前进程(称为父进程)完全相同的子进程。这意味着子进程拥有与父进程相同的内存空间、文件描述符、打开文件、当前工作目录等属性。
Fork 的作用和使用场景
Fork 的作用:
- 创建新进程: 最常见的是用于创建新进程。子进程可以执行不同的代码,并与父进程并行运行。
- 实现多进程: 通过 fork 和 execve 函数可以实现多进程编程,例如创建多个子进程来执行不同的任务。
- 提高程序鲁棒性: 可以通过 fork 创建子进程来执行一些危险的操作,即使子进程失败,也不会影响父进程。
- 代码复用: 可以通过 fork 创建子进程来复用代码,例如创建多个子进程来处理不同的网络连接。
使用场景:
1. 创建简单的子进程
例如,创建一个子进程来执行一个耗时的任务,父进程可以继续执行其他任务,而不用等待子进程完成。
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程代码
// 执行耗时的任务
for (int i = 0; i < 100000000; i++) {}
printf("The child process has finished.\n");
} else if (pid > 0) {
// 父进程代码
printf("The child process has been created.\n");
// 父进程可以继续执行其他任务
} else {
// fork 失败
printf("Fork failed!\n");
}
return 0;
}
输出:
The child process has been created.
The child process has finished.
2. 使用 fork 和 execve 创建新的命令行程序
例如,创建一个子进程来执行 ls
命令,列出当前目录下的所有文件。
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程代码
execve("/bin/ls", NULL, NULL);
} else if (pid > 0) {
// 父进程代码
printf("The child process has been created.\n");
} else {
// fork 失败
printf("Fork failed!\n");
}
return 0;
}
输出:
The child process has been created.
3. 使用 fork 创建守护进程
例如,创建一个守护进程来监控系统日志。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程代码
// 将子进程变为守护进程
setsid();
// 关闭所有打开的文件描述符
for (int i = 0; i < 1024; i++) {
close(i);
}
// 进入后台运行
while (1) {
// 监控系统日志
sleep(1);
}
} else if (pid > 0) {
// 父进程代码
printf("The daemon process has been created.\n");
exit(0);
} else {
// fork 失败
printf("Fork failed!\n");
}
return 0;
}
输出:
The daemon process has been created.
总结:
Fork 是一个非常强大的系统调用,它可以用于创建新进程、实现多进程、提高程序鲁棒性、代码复用等。在实际应用中,fork 可以用于各种场景,例如创建子进程来执行耗时的任务、创建新的命令行程序、创建守护进程等。
注意:
- Fork 之后,父进程和子进程会共享相同的内存空间,因此需要谨慎修改内存数据,否则可能会导致意外的结果。
- 使用 fork 创建子进程后,需要使用 wait 或 waitpid 函数来回收子进程的资源。
传统 fork 的问题和 COW fork 的解决方案
传统 fork 的问题:
-
不必要的复制: 传统 fork 方法,例如
xv6 Unix fork
和dumbfork()
,会将整个父进程地址空间复制到子进程中,即使子进程不需要它。这是低效和耗时的,特别是在子进程在 fork 后不久使用exec
替换其内存的情况下。 -
资源浪费: 复制的数据可能永远不会被子进程使用,导致内存和 CPU 资源浪费。
COW fork 的解决方案:
-
最初共享内存: COW (copy-on-write) 允许父进程和子进程最初共享相同的内存映射,而不是预先复制数据。这消除了 fork 过程中的不必要复制。
-
仅在写入时复制: 当任何一个进程尝试写入共享页面时,会发生页面错误。然后内核为修改进程创建一个私有的可写页面副本。这确保每个进程只为其实际修改的内存付费。
COW fork 的优点:
- 更快的 fork: 大大减少了
fork
操作所需的时间和资源,特别是在子进程中紧接着exec
的情况下。 - 高效的内存使用: 避免不必要的复制和内存分配。
- 灵活性: 允许各个用户模式程序定义自己的 fork 语义,如果需要,可以启用不同的内存共享策略。
在用户空间实现 COW fork:
- 简化内核,因为复杂的内存管理逻辑由用户空间库处理。
- 为程序选择首选 fork 行为提供灵活性。
总结:
COW fork 通过延迟内存复制直到实际需要,来解决传统 fork 的效率低下问题,从而导致更快的进程创建和更好的资源利用。此外,在用户空间实现它为自定义 fork 行为提供了灵活性。
COW Fork 的内存布局图解
父进程:
[Heap]
[Stack]
[Code]
[Data]
子进程:
[Heap]
[Stack]
[Code]
[Data] (**完全复制自父进程**)
问题:
- 子进程可能不需要父进程的所有数据,例如代码段和只读数据段。
- 复制整个地址空间需要时间和资源,特别是在子进程很快被
exec
替换的情况下。
改进:
使用 COW fork 可以避免不必要的复制,如下图所示:
父进程:
[Heap]
[Stack]
[Code]
[Data] (**只读**)
子进程:
[Heap]
[Stack]
[Code]
[Data] (**指向父进程的只读数据**)
COW fork 内存布局:
- 父进程和子进程共享代码段和只读数据段,无需复制。
- 子进程的堆和栈是私有的,可以根据需要进行修改。
- 当子进程尝试写入共享数据段时,会发生页面错误,内核会为子进程创建私有的可写数据页。
优点:
- 提高 fork 效率,减少资源消耗。
- 仅复制实际需要的数据,节省内存空间。
COW fork 是一种更有效、更灵活的 fork 方法,适用于大多数应用程序场景。
花了三天时间整理了一遍程序执行流程,感觉这个理解还是有点难度的。本来想一篇文章全部列完 COW Fork 的知识点,但是开头就卡住了,慢慢来吧。
这篇文章结合具体的代码讲解操作系统中 COW Fork 的页面错误实现细节。
如何实现 COW Fork ?
COW(Copy-On-Write)Fork 是一种优化的 Fork 实现方式,它在创建子进程时并不立即复制父进程的所有内存页,而是让父子进程共享同一份内存页,只有当其中一个进程试图修改某个内存页时,才会复制该内存页,这就是所谓的写时复制(Copy-On-Write)。
接下来讲解 COW Fork 的实现细节:
-
首先,设置页错误处理函数
pgfault
,这个函数会在发生页错误(例如试图写入一个只读页)时被调用。 -
调用
sys_exofork
创建一个新的进程,新进程的地址空间最初是空的。 -
如果
sys_exofork
返回 0,说明当前是子进程,设置全局变量thisenv
指向子进程的环境描述符,并返回 0。 -
如果
sys_exofork
返回的是一个正数,说明当前是父进程,开始复制页表。遍历用户空间的每一个页,如果该页是存在的并且是用户页,就调用duppage
复制该页。duppage
会检查该页是否是可写的或者是写时复制的,如果是,就将该页在父子进程中都标记为写时复制,否则,直接复制页表项。 -
为子进程的用户异常栈分配一个新的页。用户异常栈不能标记为写时复制,因为当发生页错误时,需要能够写入用户异常栈。
-
设置子进程的页错误处理函数为
_pgfault_upcall
,这个函数是一个汇编语言函数,它会保存当前的寄存器状态,并调用pgfault
。 -
最后,将子进程的状态设置为可运行(RUNNABLE),并返回子进程的环境 ID。
这样,父进程和子进程就共享了大部分内存页,只有当其中一个进程试图修改某个页时,才会复制该页,从而节省了大量的内存和 CPU 时间。
页面错误处理 trap
在 COW Fork 中,子进程的页面初始时被标记为只读,并且与父进程共享。当子进程试图写入这些只读页面时,会触发页面错误(Page Fault),操作系统会捕获这个错误,并为子进程创建一个新的、可写的页面副本。这个过程就是所谓的“写时复制”(Copy-On-Write)。在这种情况下,trap_dispatch
函数会检查Trapframe
中的中断号,如果中断号为T_PGFLT
,则调用page_fault_handler
函数处理页面错误。
static void
trap_dispatch(struct Trapframe *tf)
{
switch (tf->tf_trapno) {
case T_PGFLT:
page_fault_handler(tf);
return;
case T_BRKPT:
monitor(tf);
return;
case T_SYSCALL:
tf->tf_regs.reg_eax = syscall(tf->tf_regs.reg_eax, tf->tf_regs.reg_edx, tf->tf_regs.reg_ecx,
tf->tf_regs.reg_ebx, tf->tf_regs.reg_edi, tf->tf_regs.reg_esi);
return;
default:
// Handle other cases here
break;
}
// ...
}
接下来讲解 page_fault_handler 的实现细节。
异常栈
page_fault_handler
函数首先读取处理器的 CR2 寄存器以找到出错的地址。如果错误发生在内核模式,它会触发 panic。如果错误发生在用户模式,它会检查当前环境是否设置了页面错误处理函数。如果设置了,它会在用户异常栈上设置一个页面错误栈帧,然后跳转到页面错误处理函数。如果没有设置页面错误处理函数,或者异常栈溢出,或者异常栈没有分配,它会销毁引起错误的环境。
void
page_fault_handler(struct Trapframe *tf)
{
uint32_t fault_va;
// 读取处理器的CR2寄存器以找到出错的地址
fault_va = rcr2();
// 处理内核模式下的页面错误。
// 如果错误发生在内核模式下
if ((tf->tf_cs & 3) == 0) {
// 触发panic,因为内核模式下不应该发生页面错误
panic("page_fault_handler():page fault in kernel mode!\n");
}
// 如果当前进程有异常处理函数
if (curenv->env_pgfault_upcall) {
// 如果发生在异常栈上,那么就使用当前栈
uintptr_t stacktop = UXSTACKTOP;
// 如果当前栈指针在异常栈的范围内,处理“递归页面错误”
if (UXSTACKTOP - PGSIZE < tf->tf_esp && tf->tf_esp < UXSTACKTOP) {
// 将栈顶设置为当前栈指针
stacktop = tf->tf_esp;
}
// 为异常栈分配空间,包括一个UTrapframe结构和一个额外的字
uint32_t size = sizeof(struct UTrapframe) + sizeof(uint32_t);
// 检查异常栈是否越界
user_mem_assert(curenv, (void *)stacktop - size, size, PTE_U | PTE_W);
// 将异常栈指针指向异常栈的顶部
struct UTrapframe *utr = (struct UTrapframe *)(stacktop - size);
// 填充UTrapframe结构
utr->utf_fault_va = fault_va;
utr->utf_err = tf->tf_err;
utr->utf_regs = tf->tf_regs;
utr->utf_eip = tf->tf_eip;
utr->utf_eflags = tf->tf_eflags;
utr->utf_esp = tf->tf_esp;
// 设置eip为异常处理函数的地址,设置esp为UTrapframe结构的地址
curenv->env_tf.tf_eip = (uintptr_t)curenv->env_pgfault_upcall;
curenv->env_tf.tf_esp = (uintptr_t)utr;
// 运行异常处理函数
env_run(curenv);
}
// 如果没有异常处理函数,或者异常栈溢出,或者异常栈没有分配
cprintf("[%08x] user fault va %08x ip %08x\n",
curenv->env_id, fault_va, tf->tf_eip);
print_trapframe(tf);
env_destroy(curenv);
}
异常栈(Exception Stack)或者叫做中断栈(Interrupt Stack),是用于处理异常或中断的特殊栈。当 CPU 检测到异常或中断时,它会自动保存当前的执行状态(包括寄存器的值、指令指针等)到异常栈上,然后跳转到对应的异常处理函数或中断处理函数去处理这个异常或中断。
异常栈的主要作用有两个:
-
保存异常或中断发生时的执行状态:当异常或中断发生时,CPU 需要暂停当前的执行流程,跳转到异常处理函数或中断处理函数去处理这个异常或中断。为了能在处理完异常或中断后能恢复到原来的执行流程,CPU 需要保存当前的执行状态,这就需要用到异常栈。
-
提供处理异常或中断的运行环境:异常处理函数或中断处理函数在执行过程中可能需要使用栈来保存局部变量、传递参数等,这就需要用到异常栈。
在这段代码中,当发生页面错误(Page Fault)时,如果当前进程设置了页面错误处理函数,那么就会在用户异常栈上设置一个页面错误栈帧,然后跳转到页面错误处理函数。这个页面错误栈帧中保存了发生页面错误时的执行状态,包括发生错误的虚拟地址、错误代码、通用寄存器的值、指令指针、系统状态标志、堆栈指针等。这些信息在处理页面错误的过程中是非常重要的,它们可以帮助我们定位错误发生的原因,也可以帮助我们在处理完页面错误后恢复到错误发生时的状态。
在调用处理函数之前,会在用户异常栈上(位于UXSTACKTOP
以下)设置一个页面错误栈帧。
如果页面错误处理函数导致另一个页面错误,那么会递归地调用页面错误处理函数,并在用户异常栈的顶部推入另一个页面错误栈帧。
在处理页面错误的过程中,需要在陷阱时间栈的顶部留有一个字的临时空间,以便更容易恢复eip
和esp
。在非递归情况下,不必担心这个问题,因为常规用户栈的顶部是空闲的。但在递归情况下,需要在当前异常栈的顶部和新的栈帧之间留下一个额外的字,因为异常栈就是陷阱时间栈。
在 x86 架构中,当发生异常(如页面错误)时,CPU 会自动将当前的执行状态(包括eip
和esp
)保存到异常栈上,然后跳转到异常处理函数去处理这个异常。在处理完异常后,需要从异常栈上恢复这些保存的状态,以便程序可以在错误发生的地方继续执行。
// 为异常栈分配空间,包括一个UTrapframe结构和一个额外的字
uint32_t size = sizeof(struct UTrapframe) + sizeof(uint32_t);
这里的"一个字的临时空间",实际上是指一个 32 位的空间(在 x86 架构中,一个字等于 32 位)。这个空间是用来保存eip
和esp
的。因为在处理异常的过程中,可能会有新的数据被压入栈中,如果不预留这个空间,那么eip
和esp
可能会被覆盖,导致无法正确地恢复到错误发生时的状态。
在代码中是通过下面的逻辑来判断的:
// 如果当前栈指针在异常栈的范围内,处理“递归页面错误”
if (UXSTACKTOP - PGSIZE < tf->tf_esp && tf->tf_esp < UXSTACKTOP) {
stacktop = tf->tf_esp;
}
//...
curenv->env_tf.tf_esp = (uintptr_t)utr;
其中最后 tf_esp 记录了 utr 的指针,防止被覆盖。这个设计是为了处理递归的页面错误。在处理页面错误的过程中,可能会发生另一个页面错误,这就需要递归地调用页面错误处理函数。在递归的情况下,需要在当前的异常栈的顶部和新的栈帧之间留下一个额外的字,因为异常栈就是陷阱时间栈。
如果没有页面错误处理函数,或者进程没有为其异常栈分配一个页面,或者不能写入它,或者异常栈溢出,那么就销毁引起错误的环境。
user_mem_assert()
函数用于检查内存权限,env_run()
函数用于切换到用户态并开始执行页面错误处理函数。要改变用户环境运行的内容,需要修改curenv->env_tf
,这是当前进程的陷阱帧。
发生 Trap 后需要保存哪些信息?
UTrapframe
结构体用于保存发生异常时的 CPU 状态,具体字段的含义如下:
struct UTrapframe {
// utf_fault_va字段用于保存发生错误的虚拟地址。
// 对于页面错误(T_PGFLT),这个字段保存错误的地址,否则为0。
uint32_t utf_fault_va;
// utf_err字段用于保存错误代码。
// 错误代码是由CPU在发生异常时自动设置的,
// 它可以告诉我们错误的具体类型(例如,错误是由于缺页还是权限错误)。
uint32_t utf_err;
// utf_regs字段用于保存发生异常时的寄存器状态。
// 这些寄存器包括`eax`、`ecx`、`edx`、`ebx`、`esp`、`ebp`、`esi`和`edi`。
struct PushRegs utf_regs;
// utf_eip字段用于保存发生异常时的指令指针。
// 指令指针`eip`指向发生异常的指令。
uintptr_t utf_eip;
// utf_eflags字段用于保存发生异常时的标志寄存器。
// 标志寄存器`eflags`包含了一些重要的状态位,例如中断使能位。
uint32_t utf_eflags;
// utf_esp字段用于保存发生异常时的堆栈指针。
// 堆栈指针`esp`指向当前的堆栈顶部。
uintptr_t utf_esp;
} __attribute__((packed));
这些字段的保存是为了在处理异常的过程中,如果发生了另一个异常,可以递归地调用异常处理函数,而不会覆盖掉原来的 CPU 状态。
如何设置 env_pgfault_upcall ?
从上的代码中已经看到了 env_pgfault_upcall 的重要性,那么该如何设置呢?这其实是通过一个系统调用来实现的,在 COW Fork 的实现中第一步就是设置这个字段。下面这段代码的目的是设置页错误处理函数。
extern void _pgfault_upcall(void);
set_pgfault_handler(pgfault);
上面的代码下面声明了一个外部函数_pgfault_upcall
。这个函数在lib/pfentry.S
文件中定义,是页错误的上调入口点。当发生页错误时,内核会跳转到这个函数。
随后 set_pgfault_handler(pgfault);
这行代码调用了set_pgfault_handler
函数,将pgfault
设置为页错误处理函数。pgfault
函数在lib/fork.c
文件中定义,是用户级别的页错误处理函数。
当发生页错误时,内核会跳转到_pgfault_upcall
函数,然后_pgfault_upcall
函数会调用pgfault
函数。pgfault
函数的工作是检查错误的类型,如果是写入一个只读的页,那么就分配一个新的页,将旧页的内容复制到新页,然后将新页映射到旧页的地址,这样就实现了写时复制(Copy-On-Write)。
这样做的原因是,COW Fork 在创建子进程时,并不立即复制父进程的所有内存页,而是让父子进程共享同一份内存页,只有当其中一个进程试图修改某个内存页时,才会复制该内存页。这样可以节省大量的内存和 CPU 时间。而页错误处理函数就是实现这个逻辑的关键部分。
为什么要先跳转到汇编 _pgfault_upcall
再调用 C 语言实现的 pgfault
?
在处理页错误时,我们需要保存当前的寄存器状态,以便在处理完页错误后能够恢复到这个状态,继续执行被中断的程序。这个过程涉及到底层的硬件操作,需要直接操作寄存器,这是 C 语言无法做到的,因为 C 语言是一种高级语言,它的设计目标是让程序员能够编写与硬件无关的代码。而汇编语言是一种低级语言,它可以直接操作硬件,包括寄存器。
因此,我们需要使用汇编语言来编写_pgfault_upcall
函数。这个函数的工作是保存当前的寄存器状态,然后调用 C 语言编写的pgfault
函数。pgfault
函数的工作是检查错误的类型,如果是写入一个只读的页,那么就分配一个新的页,将旧页的内容复制到新页,然后将新页映射到旧页的地址,这样就实现了写时复制(Copy-On-Write)。这部分工作可以用 C 语言来完成,因为它不涉及到底层的硬件操作。
页面错误处理流程
总结一下,当 COW Fork 创建出来的进程试图修改父子进程共享的某个内存页时会触发一个页面错误。接下来处理页面错误,如果页面错误来自内核直接 panic ,如果来自用户态继续执行。随后在异常栈上申请一块空间,将当前寄存器的临时信息保存到这里,当然还存在递归的情形。然后切换到 _pgfault_handler 进一步处理。
_pgfault_handler 本质上是进程结构体中的一个回掉函数,通过一个系统调用来注册,这个回掉函数是自定义的。即调用sys_env_set_pgfault_upcall
函数时设置的:
sys_env_set_pgfault_upcall(envid, _pgfault_upcall);
这行代码告诉内核,当发生页错误时,应该跳转到_pgfault_upcall
函数开始执行。这行代码位于 fork 的实现中。
下面是这个系统调用的具体实现细节:
static int
sys_env_set_pgfault_upcall(envid_t envid, void *func)
{
struct Env *env;
int ret;
// 调用 envid2env 函数,将进程 ID 转换为 Env 结构体指针
// 如果转换失败(返回值小于0),则返回错误码
if ((ret = envid2env(envid, &env, 1)) < 0) {
return ret;
}
// 将 func 赋值给 env 的 env_pgfault_upcall 成员
// env_pgfault_upcall 用于存储页面错误处理函数的地址
env->env_pgfault_upcall = func;
// 如果以上操作都成功,那么返回 0
return 0;
}
简单来说上面的代码根据进程 id 获取对应进程的结构体,然后将页面错误处理函数 func “注册”到 进程的字段 env_pgfault_upcall 上,这样,当进程出现页面错误后会调用该字段上的函数来处理页面错误。
在 COW Fork 中是将 _pgfault_upcall “注册”到了 env_pgfault_upcall 字段上,当出现页面错误后会跳转到 _pgfault_upcall 上。
pgfault 实现细节
_pgfault_upcall 的主要逻辑是当发生页错误时,保存当前的状态,调用页错误处理程序 _pgfault_handler(pgfault) ,然后恢复到错误发生时的状态并重新执行导致错误的指令。
_pgfault_upcall
函数的代码在lib/pfentry.S
文件中:
.text
.globl _pgfault_upcall
_pgfault_upcall:
// 调用C语言的页错误处理程序
// 将栈指针%esp压入栈中,作为函数参数,指向UTrapframe
pushl %esp
// 将全局变量_pgfault_handler的值加载到%eax寄存器中
movl _pgfault_handler, %eax
// 调用%eax寄存器中的函数(页错误处理程序)
call *%eax
// 将栈指针%esp增加4,弹出函数参数
addl $4, %esp
这段代码首先将当前的栈指针(%esp)压入栈中,然后将_pgfault_handler
的值(也就是pgfault
函数的地址)加载到%eax 寄存器中,然后调用call *%eax
,这条指令会将当前的程序计数器压入栈中,然后跳转到%eax 寄存器中的地址(也就是pgfault
函数)开始执行。
这样,当发生页错误时,CPU 就会自动跳转到_pgfault_upcall
函数,然后_pgfault_upcall
函数再跳转到pgfault
函数,这就完成了从硬件异常到用户级别页错误处理函数的跳转。
pgfault 是一个自定义的页错误处理函数,用于处理发生在用户级别的页错误。当发生页错误时,如果错误的访问是写操作,并且访问的页面是写时复制(Copy-On-Write,COW)的,那么这个函数就会被调用。
// 自定义页错误处理函数 - 如果错误的页面是写时复制(Copy-On-Write)的,
// 则映射我们自己的私有可写副本。
static void
pgfault(struct UTrapframe *utf)
{
// 获取错误的虚拟地址和错误代码
void *addr = (void *) utf->utf_fault_va;
uint32_t err = utf->utf_err;
int r;
// 检查错误的访问是否是写操作,并且是否是对写时复制的页面的访问。如果不是,触发panic。
addr = ROUNDDOWN(addr, PGSIZE);
// 检查错误的访问是否是写操作,并且是否是对写时复制的页面的访问
if (!(err & FEC_WR) || !(uvpt[PGNUM(addr)] & PTE_COW)) {
// 不是写操作或者不是对写时复制的页面的访问,触发panic
panic("pgfault(): not COW");
}
// 分配一个新的页面,将其映射到临时位置(PFTEMP),
// 将旧页面的数据复制到新页面,然后将新页面移动到旧页面的地址。
// 提示:你应该进行三次系统调用。
// 分配一个临时页面并将其映射到PFTEMP
if ((r = sys_page_map(0, PFTEMP, 0, PFTEMP, PTE_U | PTE_P)) < 0)
panic("sys_page_map: %e", r);
// 在错误的地址处分配一个新的页面,并赋予写权限
if ((r = sys_page_alloc(0, addr, PTE_P | PTE_U | PTE_W)) < 0)
panic("sys_page_alloc: %e", r);
// 将旧页面的数据复制到新页面
memmove(addr, PFTEMP, PGSIZE);
// 取消映射临时页面
if ((r = sys_page_unmap(0, PFTEMP)) < 0)
panic("sys_page_unmap: %e", r);
}
函数的主要工作流程如下:
首先,它会检查错误的访问是否是写操作,并且是否是对写时复制的页面的访问。如果不是,那么就会触发 panic。
随后当一个进程试图写入一个标记为 COW 的页面时,操作系统不会直接让它写入,而是会复制一个新的页面,让进程写入新的页面,这样就不会影响到其他可能正在使用这个页面的进程。
- 使用
sys_page_map
函数在 PFTEMP 地址处映射一个新的页面。这个页面是临时的,用于存储旧页面的数据。如果映射失败,会触发 panic。
if ((r = sys_page_map(0, PFTEMP, 0, PFTEMP, PTE_U | PTE_P)) < 0)
panic("sys_page_map: %e", r);
具体来说,当发生写时复制的页错误时,处理流程如下:
- 分配一个新的页面,并将其映射到临时位置 PFTEMP。
- 将旧页面(即发生错误的页面)的数据复制到 PFTEMP 所映射的新页面。
- 将新页面从 PFTEMP 重新映射到旧页面的地址,这样新页面就替换了旧页面,且包含了旧页面的所有数据。
- 取消 PFTEMP 的映射。
这样,我们就实现了写时复制的功能,即在子进程试图写入共享页面时,不是直接修改共享页面,而是创建一个新的页面,将共享页面的数据复制过去,然后让子进程写入这个新页面。这样既保护了父进程的数据,又允许子进程进行写操作。
_pgfault_upcall
处理完虚拟内存之后,接下来从 pgfault 返回,随后继续执行 _pgfault_upcall 的后续内容。因为已经处理完映射了,接下来需要重新执行之前导致页面错误的指令,所以需要之前保留在异常栈上的信息复制到正常栈上,以便重新执行出错的指令。
接下来执行下面的代码,这段代码是在处理页错误后恢复中断发生时的状态,并返回到导致页错误的指令处继续执行。
// 恢复中断发生时的寄存器状态
// 将栈指针%esp增加8,跳过utf_fault_va和utf_err
// 因为在页错误处理程序中,我们并不需要这两个值。
addl $8, %esp
// 将中断发生时的栈指针的值加载到%eax寄存器中
// 以便在后面将中断发生时的指令指针的值存储到原来的栈中
movl 40(%esp), %eax
// 将中断发生时的指令指针的值加载到%ecx寄存器中
// 以便在后面将这个值存储到原来的栈中
movl 32(%esp), %ecx
// 将中断发生时的指令指针的值存储到原来的栈中
// 这是为了在返回到中断发生时的代码位置时,
// 能够正确地恢复指令指针的值
movl %ecx, -4(%eax)
// 恢复所有的通用寄存器到中断发生时的状态
popal
// 将栈指针%esp增加4,跳过eip
addl $4, %esp
// 从栈中恢复eflags,此后不能再使用任何可能修改eflags的算术操作
popfl
// 恢复栈指针%esp的值
popl %esp
// 调整栈指针的值,因为之前压入了eip的值但是没有减少esp的值
lea -4(%esp), %esp
// 返回,以重新执行导致页错误的指令
ret
总结
继续总结,当 COW Fork 创建出来的进程试图修改父子进程共享的某个内存页时会触发一个页面错误。接下来处理页面错误,如果页面错误来自内核直接 panic ,如果来自用户态继续执行。随后在异常栈上申请一块空间,将当前寄存器的临时信息保存到这里,当然还存在递归的情形。然后切换到 _pgfault_handler 进一步处理。
在 _pgfault_handler 会跳转到自定义的 pgfault 处理映射关系。随后将堆栈信息从异常栈复制出来恢复正常。然后重新执行导致页面错误的指令。因为此时已经设置了新的页面,所以不会出现页面错误。
上篇文章讲解了 COW Fork 页面错误的实现细节,接下来讲解后续的实现细节。
创建新的进程
这两段代码是在实现用户级别的 fork 操作,即创建一个新的进程。这个新的进程是当前进程的一个副本,包括代码、数据和堆栈等。
envid_t
fork(void)
{
// ...
envid_t envid = sys_exofork();
if (envid < 0) {
panic("sys_exofork: %e", envid);
}
if (envid == 0) {
// Child process
thisenv = &envs[ENVX(sys_getenvid())];
return 0;
}
// ...
}
在 COW (Copy-On-Write) fork 中,sys_exofork()
函数被用来创建一个新的进程。这个新进程是当前进程的一个副本,但是它有自己的进程 ID,可以独立于其他进程运行。
sys_exofork()
函数创建的新进程与当前进程共享所有的内存页,但是这些内存页都被标记为只读。当新进程或者当前进程试图写入这些共享的内存页时,操作系统会捕获到这个写操作,然后为写操作的进程创建这个内存页的一个私有副本,这就是所谓的 "写时复制"(Copy-On-Write)。
这样做的好处是,如果新进程并没有修改任何内存页,那么就没有必要为新进程分配新的内存页,这样可以节省大量的内存。只有当新进程真正需要修改内存页时,才会为新进程分配新的内存页。
所以,在 COW fork 中,sys_exofork()
函数的作用是创建一个新的进程,并设置好所有的内存页为只读,以便实现写时复制(Copy-On-Write)。
sys_exofork 是如何创建新进程的?
接下来讲解sys_exofork
是如何创建新进程的。
static envid_t
sys_exofork(void)
{
struct Env *e; // 用于指向新进程的指针
// 调用 env_alloc 函数创建新进程,父进程 ID 为当前进程的 ID
int ret = env_alloc(&e, curenv->env_id);
// 如果创建新进程失败(返回值小于0),则返回错误码
if (ret < 0) {
return ret;
}
// 将当前进程的寄存器状态复制到新进程
e->env_tf = curenv->env_tf;
// 设置新进程的状态为不可运行
e->env_status = ENV_NOT_RUNNABLE;
// 设置新进程的 eax 寄存器的值为 0,这样在新进程中 sys_exofork 的返回值就为 0
e->env_tf.tf_regs.reg_eax = 0;
// 返回新进程的 ID
return e->env_id;
}
首先,它调用env_alloc()
函数创建一个新的进程。env_alloc()
函数会返回一个进程结构体的指针,如果返回值小于 0,表示创建进程失败,此时会直接返回错误码。
然后,它将新进程的寄存器状态设置为当前进程的寄存器状态,这样新进程就拥有了和当前进程一样的寄存器状态。
接着,它将新进程的状态设置为ENV_NOT_RUNNABLE
,表示新进程暂时不能运行。
最后,它将新进程的eax
寄存器的值设置为 0,这样当新进程开始运行时,sys_exofork()
函数会返回 0,表示新进程创建成功。
最终,这个函数返回新进程的进程 ID。
env_alloc 实现细节
env_alloc
是在操作系统中创建一个新的进程。当创建一个新的进程时需要一个新的、独立的执行上下文,包括代码、数据和堆栈等。这个新的进程是当前进程的一个副本,但是它有自己的进程 ID,可以独立于其他进程运行。
int
env_alloc(struct Env **newenv_store, envid_t parent_id)
{
int32_t generation; // 用于生成进程ID
int r; // 用于接收函数返回值
struct Env *e; // 用于指向新进程
// 从空闲进程列表中获取一个进程
if (!(e = env_free_list))
return -E_NO_FREE_ENV; // 如果没有空闲进程,返回错误
// 为这个进程分配并设置页目录
if ((r = env_setup_vm(e)) < 0)
return r; // 如果设置失败,返回错误
// 为这个进程生成一个进程ID
generation = (e->env_id + (1 << ENVGENSHIFT)) & ~(NENV - 1);
if (generation <= 0) // 确保不会生成负的进程ID
generation = 1 << ENVGENSHIFT;
e->env_id = generation | (e - envs);
// 设置进程的基本状态变量
e->env_parent_id = parent_id; // 设置父进程ID
e->env_type = ENV_TYPE_USER; // 设置进程类型为用户进程
e->env_status = ENV_RUNNABLE; // 设置进程状态为可运行
e->env_runs = 0; // 初始化运行次数为0
// 清除所有保存的寄存器状态,防止之前进程的寄存器值泄露到新进程
memset(&e->env_tf, 0, sizeof(e->env_tf));
// 设置适当的初始值给段寄存器
e->env_tf.tf_ds = GD_UD | 3; // 设置数据段选择子
e->env_tf.tf_es = GD_UD | 3; // 设置附加段选择子
e->env_tf.tf_ss = GD_UD | 3; // 设置堆栈段选择子
e->env_tf.tf_esp = USTACKTOP; // 设置堆栈指针
e->env_tf.tf_cs = GD_UT | 3; // 设置代码段选择子
// 你将在后面设置 e->env_tf.tf_eip
// 在用户模式下启用中断
e->env_tf.tf_eflags |= FL_IF;
// 清除页错误处理程序,直到用户安装一个
e->env_pgfault_upcall = 0;
// 清除IPC接收标志
e->env_ipc_recving = 0;
// 提交分配
env_free_list = e->env_link; // 将空闲进程列表指向下一个空闲进程
*newenv_store = e; // 将新进程的地址存储到newenv_store
cprintf("[%08x] new env %08x\n", curenv ? curenv->env_id : 0, e->env_id); // 打印新进程的信息
return 0; // 返回成功
}
env_alloc
函数的目的是在操作系统中创建一个新的进程。这个函数的主要工作流程如下:
-
从空闲进程列表中获取一个进程。如果没有空闲进程,函数返回错误代码。
-
为新进程分配并设置页目录。如果设置失败,函数返回错误代码。
-
为新进程生成一个进程 ID。进程 ID 是新进程的唯一标识,可以用来在后续的操作中引用新进程。
-
设置新进程的基本状态变量。这些状态变量包括父进程 ID、进程类型、进程状态和运行次数等。
-
清除所有保存的寄存器状态,防止之前进程的寄存器值泄露到新进程。
-
设置适当的初始值给段寄存器。这些初始值包括数据段选择子、附加段选择子、堆栈段选择子、堆栈指针和代码段选择子等。
-
在用户模式下启用中断。这样,新进程在运行时可以响应中断。
-
清除页错误处理程序,直到用户安装一个。这样,新进程在运行时如果发生页错误,操作系统就会调用用户安装的页错误处理程序。
-
清除 IPC 接收标志。这样,新进程在运行时可以接收 IPC 消息。
-
提交分配。将新进程的地址存储到 newenv_store,并将空闲进程列表指向下一个空闲进程。
-
打印新进程的信息。这样,用户可以知道新进程的进程 ID 和其他信息。
-
返回成功。这表示新进程已经成功创建。
如何为新进程分配并设置页目录?
env_setup_vm 是在操作系统中为新的进程设置虚拟内存布局的函数实现。下面是对代码中每个步骤的详细解释:
static int
env_setup_vm(struct Env *e)
{
int i;
struct PageInfo *p = NULL;
// 为页目录分配一个页面
if (!(p = page_alloc(ALLOC_ZERO)))
return -E_NO_MEM; // 如果分配失败,返回内存不足的错误
// 增加页面的引用计数
p->pp_ref++;
// 将物理地址转换为内核虚拟地址,并设置为进程的页目录
e->env_pgdir = (pde_t *) page2kva(p);
// 将内核的页目录复制到新进程的页目录中
memcpy(e->env_pgdir, kern_pgdir, PGSIZE);
// 设置UVPT映射进程自己的页表为只读
// 权限:内核读,用户读
e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_P | PTE_U;
return 0; // 返回成功
}
这段代码的主要目的是为新的进程设置虚拟内存。首先,它为页目录分配一个页面。如果分配失败,函数返回内存不足的错误。然后,它增加页面的引用计数,并将物理地址转换为内核虚拟地址,设置为进程的页目录。
接着,它将内核的页目录复制到新进程的页目录中。在操作系统中,虚拟地址空间通常被分为用户空间和内核空间两部分。在 x86 架构中,UTOP(User TOP)是用户空间和内核空间的分界线。用户空间的地址范围从 0 到 UTOP,内核空间的地址范围从 UTOP 到顶部。
所有进程的虚拟地址空间在 UTOP 以上的部分都是相同的,这是因为这部分地址空间被用于内核代码、内核数据和内核堆栈等内核资源的映射。由于内核是所有进程共享的,因此所有进程都需要能够访问这些内核资源,所以所有进程的虚拟地址空间在 UTOP 以上的部分都被映射到相同的物理内存地址。
当我们创建一个新的进程时,我们需要为它创建一个新的虚拟地址空间。这就需要创建一个新的页目录和相应的页表。但是,操作系统的内核空间是被所有进程共享的,也就是说,所有进程的虚拟地址空间在 UTOP 以上的部分都是相同的。因此,我们可以直接复制内核的页目录到新进程的页目录,这样就可以快速地为新进程创建和内核相同的虚拟地址空间。
这种设计使得每个进程都可以在用户模式下运行自己的代码,同时在内核模式下访问共享的内核资源。当进程需要进行系统调用,如读写文件、创建新进程等操作时,它会切换到内核模式,此时可以访问 UTOP 以上的内核空间。系统调用完成后,再切换回用户模式,继续执行用户空间的代码。
总的来说,复制内核的页目录到新环境的页目录中,是为了保持虚拟地址空间的上半部分的一致性,以及为新环境提供正确的 UVPT 映射。
最后,它设置 UVPT 映射进程自己的页表为只读。这是因为进程不应该能够修改自己的页表,否则可能会破坏内存管理系统。这样,新的进程就有了自己的虚拟内存,可以加载和运行代码了。
复制用户栈
接下来需要将父进程的用户栈复制到子进程中,是的父子进程的用户栈是一致的。这样做可以确保正确的执行状态继承和维护进程的独立性。
envid_t
fork(void)
{
// ...
for (uint32_t addr = 0; addr < USTACKTOP; addr += PGSIZE) {
// Check for present, user-mapped pages
if ((uvpd[PDX(addr)] & PTE_P) && (uvpt[PGNUM(addr)] & (PTE_P | PTE_U))) {
duppage(envid, PGNUM(addr));
}
}
// ...
}
为什么 Fork 要保证父子进程具有相同的用户栈? 主要有以下几个原因:
-
维持代码执行上下文一致性 在 Fork 之后, 子进程需要从父进程的当前执行点继续执行。用户栈上存储了函数调用的上下文信息, 如局部变量、返回地址等。如果子进程和父进程的用户栈不同, 那么子进程将无法正确恢复执行上下文, 导致程序执行出错。
-
共享内存优化 在 COW Fork 中, 父子进程的内存页面被标记为只读, 并且共享相同的物理内存页。这种优化减少了内存占用, 提高了 Fork 效率。如果不复制用户栈, 父子进程可以共享用户栈页面, 从而节省物理内存。
-
维持进程地址空间一致性 用户栈是进程地址空间的一部分。如果父子进程的用户栈不同, 就意味着它们的地址空间布局不同。这将破坏地址空间一致性, 可能导致其他相关功能(如内存映射等)出现问题。
-
简化实现复杂度 如果不复制用户栈, 实现 COW Fork 将会更加复杂。需要特别处理用户栈区域, 而且需要考虑诸如栈增长方向、栈检测等问题。复制用户栈可以简化实现。
那段代码的作用就是遍历父进程的用户虚拟内存页面, 对于那些已经映射并且属于用户空间的页面, 调用 duppage
函数将它们复制到子进程的地址空间中。这样就确保了父子进程共享相同的用户内存映射, 包括用户栈区域。
综上所述, 在 COW Fork 中复制用户栈虽然会增加一些开销, 但可以简化实现、保证执行一致性, 并且利用了内存共享的优势, 因此是一个非常合理的设计和实现选择。
总结
总结一下,上篇文章解决了 COW Fork 中页面错误的问题。这篇文章讲解了后续如何创建一个子进程并且确保父子进程的堆栈信息一致。
时钟中断是由计算机的硬件时钟(通常是一个计数器)产生的。这个硬件时钟会在固定的时间间隔(通常是几毫秒)向 CPU 发送一个中断信号。这个时间间隔被称为时钟滴答(tick)。
当 CPU 接收到这个中断信号时,它会暂停当前正在执行的任务,保存当前任务的状态,然后跳转到一个预先定义的中断处理程序(Interrupt Service Routine,ISR)去处理这个中断。在这个中断处理程序中,操作系统可以做一些定期需要做的事情,比如更新系统时间,调度其他任务运行等。
时钟中断有什么用?
目前 JOS 内核不支持来自时钟硬件的外部硬件中断,如果用户程序是一个死循环的话,那么这个程序会永远不归还 CPU 进而使得整个系统陷入停顿。所以为了让内核能够抢占正在运行的环境,需要强制从它那里重新获得 CPU 的控制权,接下来需要扩展 JOS 内核以支持来自时钟硬件的外部硬件中断。
时钟中断是一种硬件中断,由计算机的时钟产生。它通常用于操作系统的调度器,以实现抢占式多任务。当时钟中断发生时,当前正在执行的进程会被暂停,操作系统的调度器会选择另一个进程来执行。这样,即使一个进程进入了无限循环,也不会导致整个系统停止响应,因为时钟中断会强制切换到其他进程。此外,时钟中断也常用于实现定时器功能,例如在一定时间后唤醒一个进程,或者在特定的时间点执行某个任务。
IRQ,全称为中断请求(Interrupt Request),是一种硬件设备向处理器发送中断信号的机制。当硬件设备需要处理器的注意时,它会发送一个 IRQ 信号。处理器会响应这个中断请求,暂停当前的任务,保存当前的状态,然后执行与该 IRQ 关联的中断处理程序。当中断处理程序完成后,处理器会恢复被中断的任务。IRQ 是实现硬件设备与处理器之间异步通信的重要机制。
IRQ(中断请求)和时钟中断之间的关系是,时钟中断是一种特殊类型的 IRQ。在计算机系统中,硬件设备通过发送 IRQ 信号来通知处理器需要处理某些事件。时钟中断是由计算机的时钟系统产生的 IRQ,用于告知处理器已经过去了一定的时间。
当时钟中断发生时,处理器会暂停当前正在执行的任务,保存当前的状态,然后执行与时钟中断关联的中断处理程序。这个中断处理程序通常会做一些与时间相关的处理,比如更新系统时间,或者检查是否有需要在此时执行的定时任务。
IRQ 初始化
IRQ 有 16 个,编号为 0 到 15。IRQ 号到 IDT 条目的映射不是固定的。pic_init
在 picirq.c
中将 IRQs 0-15 映射到 IDT 条目 IRQ_OFFSET 到 IRQ_OFFSET+15 。
在 inc/trap.h
文件中,IRQ_OFFSET
被定义为 32。这是因为在 x86 架构中,前 32 个中断向量(0-31)被保留用于处理器异常。因此,硬件中断(IRQs)从 32 开始编号,以避免与处理器异常冲突。
在 kern/trap.c
文件中,我们可以看到如何初始化 IDT 条目以处理 IRQs。例如,考虑以下代码行:
SETGATE(idt[T_SYSCALL], 0, GD_KT, th_syscall, 3);
这行代码设置了系统调用(T_SYSCALL
)的中断描述符表(IDT)条目。SETGATE
宏用于设置 IDT 条目的各个字段。
idt[T_SYSCALL]
是要设置的 IDT 条目。- 第二个参数
0
表示这不是一个陷阱门(trap gate)。陷阱门在处理中断后不会自动关闭中断,而中断门会。这里,我们希望在处理系统调用时自动关闭中断,所以这不是一个陷阱门。 GD_KT
是中断处理程序的代码段选择子。这告诉处理器中断处理程序在哪个代码段中。th_syscall
是中断处理程序的地址。当发生系统调用中断时,处理器会跳转到这个地址开始执行代码。- 最后一个参数
3
是描述符特权级(Descriptor Privilege Level,DPL)。这决定了哪些特权级的代码可以调用这个中断。在这里,我们设置为 3,这意味着用户模式的代码可以发起系统调用。
同样的方法也用于设置 IRQ 的 IDT 条目。例如,时钟中断(IRQ 0)的 IDT 条目是 idt[IRQ_OFFSET + 0]
或 idt[32]
。当时钟中断发生时,处理器会查找 idt[32]
条目,然后跳转到该条目指向的中断处理程序。
在 JOS 中,外部设备中断在内核模式下总是被禁用,这是一个关键的简化,这与 xv6 Unix 操作系统的行为相同。在用户空间中,外部中断是启用的。这种中断的启用和禁用是由 %eflags
寄存器的 FL_IF
标志位控制的。
当 FL_IF
标志位被设置时,外部中断就会被启用。虽然有几种方法可以修改这个标志位,但是由于我们的简化,我们只通过保存和恢复 %eflags
寄存器来在进入和离开用户模式时处理它。
例如,当我们从内核模式切换到用户模式时,我们会保存当前的 %eflags
寄存器的值,然后设置 FL_IF
标志位,以启用外部中断。当我们从用户模式切换回内核模式时,我们会恢复保存的 %eflags
寄存器的值,从而禁用外部中断。
这种处理方式简化了中断处理的逻辑,因为我们知道在内核模式下,我们不需要处理外部设备中断。这使得我们可以专注于处理内核的任务,而不需要担心被外部设备中断打断。在用户模式下,我们允许外部设备中断,因为用户程序可能需要响应外部设备的事件。
为什么硬件中断不会提供错误码?
这句话的意思是,当处理器响应础件中断时,它不会将错误代码(error code)推送到堆栈。错误代码是一种特殊的值,用于提供关于异常或中断的更多信息。例如,当发生页错误(Page Fault)时,处理器会将一个错误代码推送到堆栈,以指示导致页错误的具体原因。
然而,对于硬件中断,处理器不会这样做。这是因为硬件中断通常不是由错误引起的,而是由外部设备发出的信号,表示需要处理器的注意。例如,当硬盘完成了数据传输,或者网络卡接收到了一个数据包时,它们会发出一个硬件中断,让处理器知道现在可以处理这些数据了。
时钟中断是如何产生的?
在多处理器系统中,每个处理器都有一个本地 APIC(Advanced Programmable Interrupt Controller,高级可编程中断控制器)。lapic_init()
函数用于初始化这个本地 APIC。本地 APIC 可以接收来自系统其他部分的中断,并将其传递给相应的处理器。
在早期的单处理器系统中,PIC(Programmable Interrupt Controller,可编程中断控制器)是唯一的中断控制器。pic_init()
函数用于初始化 PIC。PIC 可以接收来自系统其他部分的中断,并将其传递给 CPU。
这两个函数的主要目的是设置时钟中断控制器以产生中断。时钟中断是由硬件时钟(通常是一个计数器)产生的。这个硬件时钟会在固定的时间间隔(通常是几毫秒)向 CPU 发送一个中断信号。这个时间间隔被称为时钟滴答(tick)。
当 CPU 接收到这个中断信号时,它会暂停当前正在执行的任务,保存当前任务的状态,然后跳转到一个预先定义的中断处理程序(Interrupt Service Routine,ISR)去处理这个中断。在这个中断处理程序中,操作系统可以做一些定期需要做的事情,比如更新系统时间,调度其他任务运行等。
时钟中断是如何处理的?
当中断号 tf->tf_trapno
等于 IRQ_OFFSET + IRQ_TIMER
时,表示发生了时钟中断。
// ...
if (tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER) {
lapic_eoi();
sched_yield();
return;
}
// ...
首先,调用 lapic_eoi()
函数来确认这个中断。在 x86 架构中,当中断处理程序完成时,需要向本地 APIC 发送一个 EOI(End of Interrupt)信号,表示中断已经被处理完毕。如果不发送 EOI,那么 APIC 将不会发送更多的中断。
然后,调用 sched_yield()
函数来进行进程调度。这是因为时钟中断通常用于实现抢占式多任务,即当一个进程运行一段时间后(由时钟中断决定),操作系统会强制该进程让出 CPU,切换到其他进程运行。sched_yield()
函数的作用就是选择一个新的进程来运行。
最后,使用 return
语句结束这个中断处理程序。
总结
通过上面的设置使得 JOS 支持时钟中断,进而使得 OS 可以进行调用切换不同的进程,随后可以在此基础上实现 IPC 机制。下篇文章讲解如何 JOS 是如何实现 IPC 的。
这篇文章结合具体的代码讲解操作系统 IPC(Inter-Process Communication) 通信机制的实现细节。
什么是 IPC ?
IPC,全称为进程间通信(Inter-Process Communication),是指在不同的进程之间传递和共享信息的机制。这种机制允许运行在同一操作系统上的不同进程之间进行数据交换。常见的 IPC 机制包括管道(Pipe)、消息队列(Message Queue)、信号量(Semaphore)、共享内存(Shared Memory)、套接字(Socket)等。这些机制都有各自的特点和适用场景,可以根据具体的需求进行选择。
为什么需要 IPC ,什么是 IPC ?
操作系统需要进程间通信(IPC)的原因主要有以下几点:
-
数据共享:多个进程可能需要访问和操作同一份数据,通过 IPC,这些进程可以共享数据,而无需复制数据。
-
速度:在某些情况下,使用 IPC 传递数据比其他方法(如通过文件系统)更快。
-
模块化:通过 IPC,可以将一个大的任务分解为多个小的、独立的进程,每个进程负责一部分任务。这样可以提高代码的模块化程度,使得代码更易于理解和维护。
-
并发:通过 IPC,多个进程可以并行执行,从而提高系统的性能。
-
资源共享:多个进程可能需要使用同一资源(如打印机、文件等),通过 IPC,这些进程可以协调对资源的使用,避免资源冲突。
-
同步和协调:多个进程在执行过程中可能需要相互协调和同步,通过 IPC,这些进程可以相互发送信号和消息,以达到同步和协调的目的。
IPC 类型有哪些
操作系统的进程间通信(IPC)主要有以下几种类型:
-
管道(Pipe)和命名管道(named pipe):这是最早的 IPC 形式,主要用于有血缘关系的进程间的通信。
-
消息队列(Message Queue):消息队列是消息的链表,存放在内核中并由消息队列标识符标识。
-
共享内存(Shared Memory):多个进程共享一段能够同时读写的内存区域。
-
信号量(Semaphore):主要作为控制多个进程对共享资源的访问。
-
套接字(Socket):更为一般的进程间通信机制,可用于不同机器之间的进程间通信。
-
信号(Signal):一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
以上就是操作系统中常见的进程间通信方式。
JOS 中的 IPC 通信方式
JOS 中的 IPC(Inter-Process Communication,进程间通信)属于消息传递类型。在 JOS 中,进程间通信主要通过sys_ipc_try_send
和sys_ipc_recv
两个系统调用来实现。sys_ipc_try_send
用于发送消息,sys_ipc_recv
用于接收消息。这种方式下,进程间的通信数据是通过内核进行中转的,发送进程将消息发送给内核,然后接收进程从内核中接收消息。
用户进程可以使用 JOS 的 IPC 机制向彼此发送的"消息"包含两个组成部分:一个 32 位的值,以及可选的一个页面映射。允许进程在消息中传递页面映射提供了一种比单个 32 位整数能容纳的数据更多的有效传输方式,也使得进程能够轻松地设置共享内存安排。
如何接收消息?
当一个进程想要接收消息时,它会调用sys_ipc_recv
系统调用。这个调用会使当前进程进入等待状态,直到它接收到一个消息为止。在这个等待期间,该进程不会被调度运行。
这里有一个重要的概念,那就是任何其他进程都可以向等待接收消息的进程发送消息。这并不限于特定的进程,也不限于与接收进程有父/子关系的进程。这意味着,IPC 的设计允许任何进程之间进行通信,而不仅仅是有特定关系的进程。
如何发送消息?
当一个进程想要发送消息时,它会调用sys_ipc_try_send
系统调用,并提供接收者的进程 id 和要发送的值。
如果指定的接收进程正在等待接收消息(即它已经调用了sys_ipc_recv
并且还没有接收到一个值),那么sys_ipc_try_send
会成功地传递消息并返回 0,表示消息发送成功。
如果指定的接收进程并没有在等待接收消息,那么sys_ipc_try_send
会返回一个错误码-E_IPC_NOT_RECV
,表示目标进程当前并不期望接收一个值。
在用户空间,有一个库函数ipc_recv
,它会调用sys_ipc_recv
,然后在当前进程的struct Env
中查找关于接收到的值的信息。这个函数的作用是帮助进程接收消息,并处理接收到的消息。
另一个库函数ipc_send
会负责反复调用sys_ipc_try_send
,直到消息发送成功。这个函数的作用是帮助进程发送消息,并处理发送消息的结果。
总的来说,这段内容描述的是进程间通信的发送和接收消息的过程,以及如何处理发送和接收消息的结果。
页面共享
当一个进程调用sys_ipc_recv
并提供一个有效的dstva
参数(低于 UTOP)时,这个进程表示它愿意接收一个页面映射。如果发送进程在调用sys_ipc_try_send
时发送了一个页面,那么这个页面将会在接收进程的地址空间中的dstva
处映射。如果接收进程在dstva
处已经映射了一个页面,那么会被覆盖。
当一个进程以一个有效的srcva
(低于 UTOP)调用sys_ipc_try_send
时,它意味着发送者想要发送当前在srcva
处映射的页面给接收者,权限为perm
。在成功的 IPC 之后,发送者保留其在地址空间中srcva
处的页面的原始映射,但接收者也在接收者的地址空间中获得了这个相同物理页面在接收者最初指定的dstva
处的映射。结果,这个页面在发送者和接收者之间共享。
简而言之就是将 srcva 对应的物理页面映射到 dstva 上。如果发送者或接收者都没有指示应该传输一个页面,那么不会传输页面。在任何 IPC 之后,内核都会将接收者的 Env 结构中的新字段env_ipc_perm
设置为接收到的页面的权限,如果没有接收到页面,则为零。这是一种保护机制,确保只有在接收者明确表示愿意接收页面,并且发送者明确表示愿意发送页面的情况下,才会进行页面传输。
这样设计的目的是为了实现进程间的内存共享。在许多情况下,进程间需要共享数据,而这些数据可能会存储在内存的页面中。通过这种设计,一个进程可以将其内存中的一个页面发送给另一个进程,而不需要复制页面的内容。这不仅可以节省内存,还可以提高数据传输的效率。
此外,这种设计还提供了一种保护机制,确保只有在接收进程愿意接收页面,并且发送进程愿意发送页面的情况下,才会进行页面传输。这可以防止恶意进程无意义地发送页面,从而干扰其他进程的正常运行。
总的来说,这种设计使得进程间的内存共享变得既高效又安全。
实现 sys_ipc_recv
sys_ipc_recv 用于接收进程间通信(IPC)的消息。当一个进程调用这个函数时,它会阻塞并等待接收一个值。这个进程通过设置env_ipc_recving
和env_ipc_dstva
字段来记录它希望接收的信息。
env_ipc_recving
字段表示这个进程正在等待接收一个值,env_ipc_dstva
字段是一个虚拟地址,表示这个进程愿意接收一个页面的数据,并且这个页面应该映射到这个虚拟地址。
static int
sys_ipc_recv(void *dstva)
{
// 如果dstva小于UTOP(用户空间的最大地址)并且dstva没有页对齐(即,它不是一个页的开始地址)
// 那么返回错误码-E_INVAL
if (dstva < (void *)UTOP && dstva != ROUNDDOWN(dstva, PGSIZE)) {
return -E_INVAL;
}
// 设置当前环境为接收状态,env_ipc_recving字段为1表示当前环境正在等待接收IPC消息
curenv->env_ipc_recving = 1;
// 将当前环境的状态设置为不可运行,这样调度器在下一次调度时不会选择这个环境运行
curenv->env_status = ENV_NOT_RUNNABLE;
// 设置当前环境期望接收的页面的虚拟地址
curenv->env_ipc_dstva = dstva;
// 调用sys_yield()让出CPU,等待其他环境发送IPC消息
sys_yield();
// 如果没有错误发生,那么返回0
return 0;
}
如果dstva
小于UTOP
,那么这个进程愿意接收一个页面的数据。dstva
是一个虚拟地址,表示接收的页面应该映射到的位置。
这个函数只有在出错时才会返回,否则它会一直阻塞,直到接收到一个值。如果成功接收到一个值,那么系统调用最终会返回 0。
如果出错,这个函数会返回一个负数。可能的错误包括:如果dstva
小于UTOP
但不是页面对齐的,那么会返回-E_INVAL
错误。
实现 sys_ipc_try_send
sys_ipc_try_send
函数是操作系统中用于进程间通信(IPC)的一部分。它尝试从当前环境(发送者)向由envid
指定的另一个环境(接收者)发送一个值,以及可选的一块内存页。
static int
sys_ipc_try_send(envid_t envid, uint32_t value, void *srcva, unsigned perm)
{
struct Env *rcvenv; // 定义一个 Env 结构体指针 rcvenv
int ret = envid2env(envid, &rcvenv, 0); // 将 envid 转换为 Env 结构体指针
if (ret) return ret; // 如果转换失败(返回值小于0),则返回错误码
if (!rcvenv->env_ipc_recving) return -E_IPC_NOT_RECV; // 如果接收进程不在接收状态,则返回错误码
if (srcva < (void*)UTOP) { // 如果虚拟地址 srcva 小于 UTOP
pte_t *pte; // 定义页表项指针 pte
struct PageInfo *pg = page_lookup(curenv->env_pgdir, srcva, &pte); // 在当前进程的页目录中查找虚拟地址 srcva 对应的物理页
if (srcva != ROUNDDOWN(srcva, PGSIZE)) return -E_INVAL; // 如果虚拟地址 srcva 不是页对齐的,则返回错误码
if ((*pte & perm) != perm) return -E_INVAL; // 如果页表项的权限位和 perm 不匹配,则返回错误码
if (!pg) return -E_INVAL; // 如果物理页不存在,则返回错误码
if ((perm & PTE_W) && !(*pte & PTE_W)) return -E_INVAL; // 如果 perm 中设置了写权限,但页表项中没有写权限,则返回错误码
if (rcvenv->env_ipc_dstva < (void*)UTOP) { // 如果接收进程的接收虚拟地址小于 UTOP
ret = page_insert(rcvenv->env_pgdir, pg, rcvenv->env_ipc_dstva, perm); // 在接收进程的页目录中插入一个新的页表项,建立虚拟地址和物理页的映射关系
if (ret) return ret; // 如果插入失败,则返回错误码
rcvenv->env_ipc_perm = perm; // 设置接收进程的接收权限
}
}
rcvenv->env_ipc_recving = 0; // 标记接收进程为非接收状态
rcvenv->env_ipc_from = curenv->env_id; // 设置接收进程的发送进程 ID
rcvenv->env_ipc_value = value; // 设置接收进程的接收值
rcvenv->env_status = ENV_RUNNABLE; // 设置接收进程的状态为可运行
rcvenv->env_tf.tf_regs.reg_eax = 0; // 设置接收进程的返回值为0
return 0; // 返回0,表示发送成功
}
函数首先将envid
转换为一个环境结构体指针,如果转换失败则返回错误。然后检查接收环境是否准备好接收 IPC,如果没有则返回错误。
如果srcva
小于UTOP
,表示发送者希望发送一块内存页。此时,函数会检查srcva
是否页对齐,权限是否合法,以及srcva
是否在发送者的地址空间中映射,如果发送者希望授予写权限,那么该页是否可写。如果任何检查失败,函数返回错误。
如果接收环境准备好接收一块内存页(即env_ipc_dstva
小于UTOP
),函数会将该页插入到接收环境的页目录中,并赋予指定的权限。如果插入失败,函数返回错误。
最后,函数更新接收环境的 IPC 字段,并将其标记为可运行。它将env_ipc_recving
设置为 0 以阻止未来的发送,将env_ipc_from
设置为发送者的envid
,将env_ipc_value
设置为value
参数,如果传输了页,则将env_ipc_perm
设置为perm
。它还将暂停的sys_ipc_recv
系统调用在接收者中的返回值设置为 0。
函数在成功时返回 0,在失败时返回负的错误代码。
ipc_recv
在用户空间,有一个库函数ipc_recv
,它会调用sys_ipc_recv
,然后在当前进程的struct Env
中查找关于接收到的值的信息。这个函数的作用是帮助进程接收消息,并处理接收到的消息。
int32_t
ipc_recv(envid_t *from_env_store, void *pg, int *perm_store)
{
// 如果 pg 为空,将 pg 设置为 (void *)-1,表示没有页面需要映射
if (pg == NULL) {
pg = (void *)-1;
}
// 调用系统调用 sys_ipc_recv,尝试接收 IPC 消息
int r = sys_ipc_recv(pg);
// 如果系统调用返回值小于0,表示系统调用失败
if (r < 0) {
// 如果 from_env_store 非空,将其值设置为0
if (from_env_store) *from_env_store = 0;
// 如果 perm_store 非空,将其值设置为0
if (perm_store) *perm_store = 0;
// 返回系统调用的错误码
return r;
}
// 如果系统调用成功,且 from_env_store 非空,将发送者的进程 ID 存储在 *from_env_store 中
if (from_env_store)
*from_env_store = thisenv->env_ipc_from;
// 如果系统调用成功,且 perm_store 非空,将发送者的页面权限存储在 *perm_store 中
if (perm_store)
*perm_store = thisenv->env_ipc_perm;
// 返回发送者发送的值
return thisenv->env_ipc_value;
}
ipc_recv
函数是用于接收进程间通信(IPC)的值,并将其返回。如果pg
非空,那么发送者发送的任何页面都将映射到该地址。如果from_env_store
非空,那么将 IPC 发送者的envid
存储在*from_env_store
中。如果perm_store
非空,那么将 IPC 发送者的页面权限存储在*perm_store
中。如果系统调用失败,那么在*fromenv
和*perm
中存储 0,并返回错误。否则,返回发送者发送的值。
如果pg
为空,那么传递给sys_ipc_recv
一个它能理解为“无页面”的值。这里选择了(void *)-1
,因为 0 是一个完全有效的映射页面的地方。
首先,函数检查pg
是否为空,如果为空,将其设置为(void *)-1
。然后,调用sys_ipc_recv
函数,将pg
作为参数。如果sys_ipc_recv
返回值小于 0,表示系统调用失败,此时,如果from_env_store
和perm_store
非空,将它们设置为 0,并返回错误码。
如果系统调用成功,那么将thisenv->env_ipc_from
的值存储在*from_env_store
中,将thisenv->env_ipc_perm
的值存储在*perm_store
中,最后返回thisenv->env_ipc_value
。
ipc_send
在用户空间,另一个库函数ipc_send
会负责反复调用sys_ipc_try_send
,直到消息发送成功。这个函数的作用是帮助进程发送消息,并处理发送消息的结果。
void
ipc_send(envid_t to_env, uint32_t val, void *pg, int perm)
{
// 如果 pg 为空,将 pg 设置为 (void *)-1,表示没有页面需要发送
if (pg == NULL) {
pg = (void *)-1;
}
int r;
// 循环尝试发送 IPC 消息,直到成功
while(1) {
// 调用系统调用 sys_ipc_try_send,尝试发送 IPC 消息
r = sys_ipc_try_send(to_env, val, pg, perm);
// 如果返回值为0,表示发送成功,函数返回
if (r == 0) {
return;
}
// 如果返回值为 -E_IPC_NOT_RECV,表示接收进程还没有准备好接收消息
// 此时,调用 sys_yield 让出 CPU,等待下一次调度
else if (r == -E_IPC_NOT_RECV) {
sys_yield();
}
// 如果返回其他错误码,表示发送过程中出现错误,调用 panic 函数打印错误信息并终止程序
else {
panic("ipc_send():%e", r);
}
}
}
ipc_send
函数的主要目的是通过进程间通信(IPC)向指定的环境发送一个值。如果pg
参数非空,那么它指向的页面将会被发送。函数首先检查pg
是否为空,如果为空,将其设置为(void *)-1
,表示没有页面需要发送。
函数进入一个无限循环,尝试调用sys_ipc_try_send
系统调用来发送 IPC 消息。如果系统调用返回 0,表示消息发送成功,函数就会返回。如果系统调用返回-E_IPC_NOT_RECV
,表示接收进程还没有准备好接收消息,此时函数会调用sys_yield
让出 CPU,等待下一次调度。如果系统调用返回其他错误码,表示发送过程中出现错误,函数就会调用panic
函数打印错误信息并终止程序。这个过程会一直重复,直到消息成功发送。
总结
支持 IPC 实现完毕。通过 IPC 在两个进程之间传递数据或者共享同一个物理页。
这篇文章通过同自旋锁对比的形式讲解睡眠锁的本质。
在多线程编程中,当一个线程试图获取一个已经被其他线程持有的锁时,它有很多选择,例如自旋等待,即不断地检查锁是否已经被释放;或者睡眠等待,即让出CPU,进入睡眠状态,等待被唤醒。
PAUSE 指令
在x86架构的CPU中,pause
指令会让当前线程暂停一段时间,然后再继续执行。这个暂停的时间通常非常短,只有几个CPU周期。这个指令通常用于自旋锁的实现,以减少CPU的使用率。
然而,pause
指令并不会导致CPU切换到其他线程。当一个线程执行pause
指令时,它仍然占用着CPU,只是在这段时间内,它不会执行任何其他操作。这就是为什么pause
指令可以减少CPU的使用率:它让CPU有机会在短暂的时间内停止执行指令,从而减少了CPU的功耗。
pause
指令的作用是暂停流水线并减少功耗,但它并不会让出CPU给其他线程。也就是说,执行 pause
指令的线程仍然处于运行状态,而不是就绪或等待状态。因此, pause
指令并不能让出CPU,它只是让CPU在等待时消耗更少的资源。
所以,当你在代码中看到pause
指令时,你应该理解为这是一种优化手段,用于减少自旋锁在忙等状态下的CPU使用率,而不是一种让出CPU的方式。因此,即使在自旋等待中插入了pause
操作,也不能改变自旋等待在等待获取锁的过程中会占用CPU的事实。
使用场景
自旋锁和睡眠锁是两种常见的锁机制,它们在不同的场景下有各自的优势。
自旋锁(Spinlock):当一个线程尝试获取自旋锁而锁已经被其他线程持有时,该线程将在一个循环中不断地检查锁是否可用。由于该线程在此期间一直处于运行状态,所以被称为自旋锁。自旋锁适用于锁持有时间非常短的情况,因为线程不会在等待锁的过程中被挂起,所以可以立即获取锁,避免了线程上下文切换的开销。
睡眠锁(Sleeping lock):当一个线程尝试获取一个已经被其他线程持有的睡眠锁时,该线程将被挂起(或者说“睡眠”),直到锁被释放。这种锁适用于锁持有时间较长的情况,因为它可以让出CPU给其他线程使用。
自旋锁的典型使用场景是低级别的系统代码,如操作系统内核。例如,在Linux内核中,自旋锁被用于保护任务队列。因为操作系统代码通常不能被挂起(因为它可能正在处理一个更高级别的中断),所以自旋锁是一个很好的选择。
睡眠锁的典型使用场景是用户级别的代码,如数据库系统。例如,一个数据库可能使用睡眠锁来保护对数据库表的访问。因为数据库操作可能需要一段时间来完成(例如,需要从磁盘读取数据),所以使用睡眠锁可以在等待期间让出CPU给其他线程使用。
睡眠锁使用示例
在C++中,std::mutex
是一种常用的睡眠锁。当一个线程试图获取一个已经被其他线程持有的std::mutex
时,它会被操作系统挂起,进入睡眠状态。在这个过程中,CPU可以被其他线程或进程使用,因为当前线程并没有执行任何操作。当锁被释放时,操作系统会唤醒等待的线程,让它再次尝试获取锁。
这个过程并不涉及到pause
操作。pause
是一种用于自旋锁的优化手段,它可以让当前线程暂时让出CPU,以减少CPU的使用率。但是,在睡眠锁中,线程在等待获取锁的过程中并不会执行任何操作,所以不需要pause
操作。
以下是一个使用std::mutex
的简单示例:
#include <mutex>
#include <thread>
std::mutex mtx;
void worker() {
mtx.lock();
// 执行一些操作...
mtx.unlock();
}
int main() {
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
return 0;
}
在这个示例中,worker
函数试图获取mtx
锁。如果mtx
已经被其他线程持有,那么当前线程会被挂起,进入睡眠状态,等待被唤醒。
总结
总结一下,自旋锁和睡眠锁的本质区别在于自旋锁在等待的过程中始终占有 CPU 不会切换上下文,而睡眠锁在等待的过程中不会占有 CPU 而是让 CPU 去执行其他任务等待条件满足后再唤醒睡眠锁。
下面是一些阅读命令行艺术的笔记,原文只是列了一个大纲告诉你需要了解哪些内容可以更顺手的使用 Linux 系统,但是还需要自己进一步的去查询。本文是原文的基础上整理归纳,将难以理解的地方展开讲解,补充了一些常见的使用场景和使用示例。
遇到不会的命令如何使用 help ?
建议直接问 GPT ,下面是传统的做法,可以用来交叉验证,毕竟 GPT 有胡说八道的可能。🤬
在 Unix 和 Linux 系统中,man
,apropos
和 help
是非常有用的命令,它们可以帮助我们查找和理解其他命令的用法。
-
man
:这个命令用于查看手册页。例如,man ls
会显示ls
命令的手册页。这个手册页包含了命令的描述,选项和用法等信息。 -
apropos
:这个命令用于搜索手册页。例如,apropos copy
会显示所有与copy
相关的手册页。这个命令非常有用,当你不确定应该使用哪个命令时,你可以使用apropos
来查找相关的命令。 -
help
:这个命令用于查看 Bash 内置命令的帮助信息。例如,help cd
会显示cd
命令的帮助信息。如果你想查看所有的 Bash 内置命令,你可以使用help -d
命令。 -
type
:这个命令用于查看命令的类型。例如,type ls
会显示ls
是一个可执行文件,type cd
会显示cd
是一个 shell 内置命令。这个命令非常有用,当你不确定一个命令是可执行文件、shell 内置命令还是别名时,你可以使用type
命令来查看。
这些命令都是在命令行中使用的,你可以在任何时候使用这些命令来查找和理解其他命令的用法。
重定向
在 Unix 和 Linux 系统中,我们可以使用 >
,<
和 |
来重定向输入和输出。这些符号允许我们控制命令行程序的输入和输出。
-
>
:这个符号用于重定向输出。它会将命令的输出写入到一个文件中,如果文件已经存在,它会被覆盖。例如,ls > file.txt
会将ls
命令的输出写入到file.txt
文件中。 -
>>
:这个符号也用于重定向输出,但是它会将输出追加到一个已经存在的文件的末尾,而不是覆盖它。例如,ls >> file.txt
会将ls
命令的输出追加到file.txt
文件的末尾。 -
<
:这个符号用于重定向输入。它会将文件的内容作为命令的输入。例如,sort < file.txt
会将file.txt
文件的内容作为sort
命令的输入。 -
|
:这个符号用于管道。它会将一个命令的输出作为另一个命令的输入。例如,ls | sort
会将ls
命令的输出作为sort
命令的输入。 -
使用
find
命令查找特定的文件,然后使用xargs
命令删除它们:find . -name "*.tmp" | xargs rm
。这个命令会在当前目录及其子目录中查找所有以 ".tmp" 结尾的文件,然后删除它们。 -
使用
sort
和uniq
命令删除重复的行:sort file.txt | uniq > output.txt
。这个命令会将file.txt
文件的内容排序,然后删除重复的行,最后将结果写入到output.txt
文件中。 -
使用
grep
命令搜索特定的文本:cat file.txt | grep "search term"
。这个命令会将file.txt
文件的内容作为cat
命令的输入,然后将cat
命令的输出作为grep
命令的输入,搜索包含 "search term" 的行。
在 Unix 和 Linux 系统中,我们有两种类型的输出:标准输出(stdout)和标准错误(stderr)。默认情况下,这两种输出都会被发送到终端。我们可以使用 >
和 >>
来重定向这两种输出,但是需要注意的是,这两个符号只能重定向标准输出。如果我们想要重定向标准错误,我们需要使用 2>
和 2>>
。例如,command 2> error.txt
会将 command
命令的标准错误输出重定向到 error.txt
文件中。
通配符
在 Unix 和 Linux 系统中,我们可以使用通配符 *
,?
和 [...]
来匹配文件和目录。这些通配符可以在很多命令中使用,例如 ls
,rm
,cp
等。
*
:这个通配符可以匹配任何数量的任何字符。例如,ls *.txt
会列出所有以 .txt
结尾的文件。
复制所有 .txt
文件到另一个目录:cp *.txt /path/to/destination/
。这个命令会将当前目录下所有以 .txt
结尾的文件复制到 /path/to/destination/
目录。
?
:这个通配符可以匹配任何单个字符。例如,ls ?.txt
会列出所有只有一个字符并且以 .txt
结尾的文件。
删除所有只有一个字符的 .txt
文件:rm ?.txt
。这个命令会删除所有只有一个字符并且以 .txt
结尾的文件。
[...]
:这个通配符可以匹配方括号中的任何一个字符。例如,ls [abc].txt
会列出名为 a.txt
,b.txt
和 c.txt
的文件。
列出所有以 a
,b
或 c
开头的文件:ls [abc]*
。这个命令会列出所有以 a
,b
或 c
开头的文件。
使用单引号 '
处理包含特殊字符的字符串:echo 'Hello, $USER'
。这个命令会输出 Hello, $USER
,而不是 Hello,
后面跟着当前用户的用户名。因为在单引号中,所有字符都会被视为普通字符,不会被解析为特殊字符。单引号会保留字符串中的所有字符的字面值,即它会禁止所有的转义序列和变量替换。
"
:双引号会保留字符串中的大部分字符的字面值,但是它会允许变量替换和某些转义序列。例如,如果你在终端中输入 echo "$HOME"
,它会输出 $HOME
这个环境变量的值,例如 /home/username
。这是因为在双引号中,$
符号表示变量替换,它会将 $HOME
替换为这个环境变量的值。
使用双引号 "
处理包含特殊字符的字符串:echo "Hello, $USER"
。这个命令会输出 Hello,
后面跟着当前用户的用户名。因为在双引号中,某些特殊字符(例如 $
)会被解析,而不是被视为普通字符。
在 Unix 和 Linux 系统中,我们可以使用单引号 '
和双引号 "
来处理包含特殊字符的字符串。这两种引号的处理方式有所不同。
这两种引号的主要区别在于是否允许变量替换和转义序列。在单引号中,所有的字符都被视为字面值,不会进行任何转义或替换。而在双引号中,某些字符,例如 $
和 \
,有特殊的含义,它们可以用于变量替换和转义序列。
这些特性使得我们可以更灵活地处理文件和目录,以及处理包含特殊字符的字符串。
Bash 中的任务管理工具
在 Unix 和 Linux 系统中,Bash 提供了一套强大的任务管理工具,可以帮助我们在后台运行任务,暂停任务,恢复任务,查看任务状态,以及结束任务。以下是一些使用示例和使用场景:
后台执行任务
&
:这个符号可以让我们在后台运行任务。例如,python script.py &
会在后台运行script.py
脚本。
在 Unix 和 Linux 系统中,&
符号通常用于以下场景:
-
长时间运行的任务:如果你有一个需要运行很长时间的任务,例如数据分析或者大规模的文件处理,你可能希望在后台运行这个任务,这样你就可以在等待任务完成的同时做其他的事情。
-
多任务处理:如果你需要同时运行多个任务,你可以使用
&
符号在后台运行这些任务。这样,你可以在一个任务运行的同时开始另一个任务。 -
服务器脚本:在服务器环境中,你可能需要运行一些持续运行的脚本或服务,例如 Web 服务器或数据库服务器。这些脚本或服务通常在后台运行,这样它们就可以在用户退出终端后继续运行。
例如,如果你有一个 Python 脚本 script.py
,这个脚本需要运行很长时间,你可以使用以下命令在后台运行这个脚本:
python script.py &
这个命令会立即返回,你可以继续在终端中输入其他的命令。你的 Python 脚本会在后台运行,直到它完成。
如何查看任务状态?
jobs
:这个命令可以让我们查看当前所有的任务和它们的状态。例如,我们可以运行 jobs
命令来查看所有的任务。
在 Unix 和 Linux 系统中,jobs
命令用于列出当前 shell 中的所有后台任务。这个命令非常有用,特别是当你在一个终端会话中启动了多个后台任务时。
例如,假设你在终端中启动了两个 Python 脚本在后台运行:
python script1.py &
python script2.py &
然后,你可以使用 jobs
命令来查看这两个任务的状态:
jobs
输出可能如下:
[1] - running python script1.py
[2] + running python script2.py
这个输出告诉你,你有两个后台任务正在运行。[1]
和 [2]
是任务的编号,你可以使用这些编号来引用这些任务,例如使用 fg
命令将任务移到前台,或者使用 kill
命令结束任务。
这个命令在你需要管理多个后台任务时非常有用,例如你可能需要检查哪些任务仍在运行,或者你可能需要将某个任务移到前台来查看它的输出或者交互式地控制它。
任务前后台如何切换?
在 Unix 和 Linux 系统中,fg
,bg
和 kill
命令是任务管理的重要工具,它们可以帮助我们在前台和后台之间切换任务,以及结束任务。以下是一些具体的使用示例和使用场景:
- 假设你正在运行一个需要很长时间的 Python 脚本
long_script.py
,你可以使用&
符号在后台运行这个脚本:
python long_script.py &
- 然后,你可以使用
jobs
命令查看当前所有的后台任务:
jobs
输出可能如下:
[1] + running python long_script.py
- 如果你想将这个后台运行的任务移到前台来查看它的输出或者交互式地控制它,你可以使用
fg
命令:
fg %1
这个命令会将任务 1 移到前台来继续运行。
- 如果你想暂停这个前台运行的任务,并将其移到后台继续运行,你可以首先按下 ctrl-z 来暂停这个任务,然后使用
bg
命令:
bg %1
这个命令会让任务 1 在后台继续运行。
- 如果你想结束这个任务,你可以使用
kill
命令:
kill %1
这个命令会结束任务 1。
以上就是在 Unix 和 Linux 系统中使用 fg
,bg
和 kill
命令的一些具体的使用示例和使用场景,希望对你有所帮助。
如何取消和暂停任务?
ctrl-z 和 ctrl-c 都是 Unix 和 Linux 系统中常用的命令行快捷键,但它们的功能是不同的:
-
ctrl-z:这个快捷键用于暂停当前正在运行的任务,并将其放入后台。这意味着任务并没有被终止,而是被暂停了,你可以随时使用
fg
命令将其恢复到前台继续运行。 -
ctrl-c:这个快捷键用于终止当前正在运行的任务。这意味着任务会被立即停止,无法恢复。如果你需要重新运行这个任务,你需要重新启动它。
所以,主要的区别在于,ctrl-z 是暂停任务,而 ctrl-c 是终止任务。
SSH
SSH (Secure Shell) 是一种网络协议,用于安全地连接到远程服务器。以下是如何使用 SSH 进行远程命令行登录的基本步骤:
- 首先,你需要在本地机器上生成 SSH 密钥对。你可以使用
ssh-keygen
命令来生成密钥对。这将生成一个公钥和一个私钥。
ssh-keygen -t rsa -b 4096 -C "your_email@example.com"
- 然后,你需要将公钥添加到远程服务器的
~/.ssh/authorized_keys
文件中。你可以使用ssh-copy-id
命令来完成这个任务。
ssh-copy-id user@remote_host
- 现在,你应该能够使用 SSH 命令来登录到远程服务器,而无需输入密码。
ssh user@remote_host
SSH Agent 是一个帮助管理私钥的程序,它可以记住你的私钥和密码,所以你不需要每次使用 SSH 时都输入密码。以下是如何使用 ssh-agent
和 ssh-add
命令来实现基础的无密码认证登录:
- 首先,你需要启动 SSH Agent。在大多数系统中,SSH Agent 会在启动时自动运行。你可以使用
ssh-agent
命令来启动它。
eval "$(ssh-agent -s)"
- 然后,你可以使用
ssh-add
命令将你的私钥添加到 SSH Agent 中。这样,当你使用 SSH 命令时,SSH Agent 就会自动提供你的私钥,而无需你手动输入。
ssh-add ~/.ssh/id_rsa
现在,你应该能够使用 SSH 命令来登录到远程服务器,而无需输入密码。
文件管理工具
文件管理工具是在命令行环境中操作和管理文件和目录的重要工具。以下是一些基本的文件管理命令的使用示例和场景:
列出目录中的文件
ls
和 ls -l
:ls
命令用于列出目录中的文件和子目录。ls -l
命令以长格式列出文件和目录的详细信息,包括文件类型、权限、链接数、所有者、组、大小、最后修改时间和文件名。例如:
例如,假设我们有一个目录,其中包含两个文件:file1.txt
和 file2.txt
。
如果我们运行 ls
命令,输出可能如下:
file1.txt file2.txt
这只显示了目录中的文件名。
然而,如果我们运行 ls -l
命令,输出可能如下:
-rw-r--r-- 1 user staff 0 Jan 1 00:00 file1.txt
-rw-r--r-- 1 user staff 0 Jan 1 00:00 file2.txt
这里,每一行都对应一个文件或目录,每一列的信息如下:
- 第一列显示文件权限。
- 第二列显示链接数。
- 第三列显示文件所有者。
- 第四列显示文件所属的组。
- 第五列显示文件大小(以字节为单位)。
- 第六、七、八列显示文件最后修改的日期和时间。
- 最后一列显示文件或目录的名称。
因此,ls -l
命令提供了比 ls
命令更详细的文件和目录信息。
查看文件内容
less
命令是一个非常有用的工具,用于在命令行环境中查看文件内容。它的主要优点是可以向前和向后浏览文件,这在查看大文件时特别有用。
例如,假设我们有一个名为 log.txt
的日志文件,我们想查看其内容。我们可以使用以下命令:
less log.txt
这将打开 log.txt
文件,并显示其内容。你可以使用以下键来导航:
空格键
或Page Down
键向下翻页。b
键或Page Up
键向上翻页。上箭头
和下箭头
键可以逐行滚动。/
键后跟一个字符串可以在文件中搜索该字符串。q
键退出less
命令。
这些是 less
命令的基本使用方法,它是 Linux 文件查看和导航的重要工具。
head
和 tail
:head
命令用于显示文件的前几行,tail
命令用于显示文件的最后几行。tail -f
命令用于实时查看文件的更新。例如:
head filename
tail filename
tail -f filename
软链接和硬链接
ln
和 ln -s
命令用于在 Unix-like 系统中创建链接。链接是文件系统中的一个重要概念,它允许一个文件名引用另一个文件的数据。
硬链接 (ln
) 是指向文件数据的指针。创建硬链接的命令格式为 ln source_file hard_link
。这将创建一个名为 hard_link
的新条目,它和 source_file
指向同一块数据。例如,如果我们有一个名为 file1.txt
的文件,我们可以创建一个硬链接 file2.txt
,如下:
ln file1.txt file2.txt
现在,file1.txt
和 file2.txt
都指向同一块数据。如果我们修改其中一个文件的内容,另一个文件的内容也会改变。
软链接(也称为符号链接或 symlink)是指向另一个链接的指针。创建软链接的命令格式为 ln -s source_file soft_link
。这将创建一个名为 soft_link
的新条目,它指向 source_file
。例如,如果我们有一个名为 file1.txt
的文件,我们可以创建一个软链接 file2.txt
,如下:
ln -s file1.txt file2.txt
现在,file2.txt
是 file1.txt
的软链接。如果我们打开 file2.txt
,我们会看到 file1.txt
的内容。但是,如果我们删除 file1.txt
,file2.txt
将变成一个指向不存在的文件的链接。
总的来说,硬链接和软链接都是链接,但它们的工作方式略有不同。硬链接是指向文件数据的指针,而软链接是指向另一个链接的指针。
文件权限
chown
和 chmod
:chown
命令用于更改文件的所有者,chmod
命令用于更改文件的权限。
例如,假设我们有一个名为 file.txt
的文件,我们想将其所有者更改为 user1
,我们可以使用以下命令:
chown user1 file.txt
现在,file.txt
的所有者是 user1
。
chmod
命令用于更改文件或目录的权限。权限分为三组:用户(u)、组(g)和其他(o)。每组权限可以有读(r)、写(w)和执行(x)权限。权限可以用数字表示,读(r)为4,写(w)为2,执行(x)为1。
例如,如果我们想给 file.txt
的用户设置读、写和执行权限,给组设置读和执行权限,给其他设置只读权限,我们可以使用以下命令:
chmod 754 file.txt
现在,file.txt
的权限被设置为 rwxr-xr--
。
文件磁盘使用情况
du
:du
命令用于查看文件和目录的磁盘使用情况。du -hs *
命令用于查看当前目录下所有文件和目录的大小。
例如,如果我们想查看 file.txt
的大小,我们可以使用以下命令:
du file.txt
这将显示 file.txt
的大小(以字节为单位)。
如果我们想查看当前目录下所有文件和目录的大小,我们可以使用以下命令:
du -hs *
这将显示当前目录下每个文件和目录的大小,以及总大小。-h
选项使得大小以人类可读的格式(如 K、M、G)显示,-s
选项使得只显示总大小,不显示每个子目录的大小。
管理文件系统
df
,mount
,fdisk
,mkfs
,lsblk
这些命令都是用于管理文件系统的重要工具。以下是这些命令的使用示例和场景:
df
:df
命令用于显示磁盘空间的使用情况。例如,如果我们想查看系统中所有文件系统的磁盘使用情况,我们可以使用以下命令:
df
这将显示每个文件系统的总空间、已用空间、可用空间和使用百分比。
mount
:mount
命令用于挂载文件系统。例如,如果我们有一个设备/dev/sda1
,我们想将其挂载到/mnt
目录,我们可以使用以下命令:
mount /dev/sda1 /mnt
现在,/dev/sda1
的内容可以在 /mnt
目录中访问。
fdisk
:fdisk
命令用于查看和管理磁盘分区。例如,如果我们想查看/dev/sda
磁盘的分区情况,我们可以使用以下命令:
fdisk /dev/sda
这将进入 fdisk
的交互模式,我们可以在这里查看、创建、删除和修改分区。
mkfs
:mkfs
命令用于格式化分区。例如,如果我们有一个分区/dev/sda1
,我们想将其格式化为ext4
文件系统,我们可以使用以下命令:
mkfs -t ext4 /dev/sda1
现在,/dev/sda1
分区已经被格式化为 ext4
文件系统。
lsblk
:lsblk
命令用于列出所有可用的块设备。例如,如果我们想查看系统中所有的块设备,我们可以使用以下命令:
lsblk
这将显示系统中所有的块设备,包括设备名、挂载点、文件系统类型等信息。
文件元数据
inode 是 Unix 和 Unix-like 系统(如 Linux)文件系统中的一个重要概念。每个文件和目录都有一个与之关联的 inode,它包含了文件的元数据,如文件大小、创建时间、所有者、文件权限等。inode 还包含了文件数据块的位置信息,这使得系统能够访问和读取文件的内容。
ls -i
命令可以显示文件的 inode 号。例如,如果我们有一个名为 file.txt
的文件,我们想查看其 inode 号,我们可以使用以下命令:
ls -i file.txt
这将显示 file.txt
文件的 inode 号。
df -i
命令用于显示 inode 的使用情况。它显示了文件系统的 inode 总数、已用数、可用数和使用百分比。例如,如果我们想查看系统中 inode 的使用情况,我们可以使用以下命令:
df -i
这将显示每个文件系统的 inode 总数、已用数、可用数和使用百分比。
inode 的概念对于理解 Unix 和 Unix-like 系统的文件系统非常重要。它是文件系统如何组织和访问文件的基础。
网络
ip
和 ifconfig
是用于管理和查看网络接口的命令,而 dig
是用于查询 DNS 的工具。以下是这些命令的使用示例和场景:
ip
或ifconfig
:这些命令用于查看和管理网络接口。例如,如果我们想查看系统中所有网络接口的信息,我们可以使用以下命令:
ip addr
或者
ifconfig
这将显示每个网络接口的信息,包括接口名、IP 地址、MAC 地址等。
如果我们想启用或禁用一个网络接口,我们可以使用以下命令:
ip link set eth0 up
ip link set eth0 down
或者
ifconfig eth0 up
ifconfig eth0 down
这将启用或禁用名为 eth0
的网络接口。
dig
:dig
命令用于查询 DNS。例如,如果我们想查询www.example.com
的 IP 地址,我们可以使用以下命令:
dig www.example.com
这将显示 www.example.com
的 DNS 查询结果,包括其 IP 地址。
如果我们想查询一个 IP 地址的反向 DNS 记录,我们可以使用以下命令:
dig -x 192.0.2.1
这将显示 192.0.2.1
的反向 DNS 查询结果。
这些命令是网络管理和故障排查的重要工具,对于理解和管理网络非常有帮助。
正则表达式
grep
和 egrep
是用于文本搜索的命令,它们支持正则表达式,可以在文件或者输入流中搜索匹配的行。以下是这些命令的使用示例和场景:
-i
:这个参数表示忽略大小写。例如,如果我们想在file.txt
中搜索hello
,不区分大小写,我们可以使用以下命令:
grep -i "hello" file.txt
-o
:这个参数表示只输出匹配的部分,而不是整行。例如,如果我们想在file.txt
中搜索hello
,并且只输出匹配的部分,我们可以使用以下命令:
grep -o "hello" file.txt
-v
:这个参数表示反转匹配,也就是输出不匹配的行。例如,如果我们想在file.txt
中搜索不包含hello
的行,我们可以使用以下命令:
grep -v "hello" file.txt
-A
:这个参数表示输出匹配行的后几行。例如,如果我们想在file.txt
中搜索hello
,并且输出匹配行的后两行,我们可以使用以下命令:
grep -A 2 "hello" file.txt
-B
:这个参数表示输出匹配行的前几行。例如,如果我们想在file.txt
中搜索hello
,并且输出匹配行的前两行,我们可以使用以下命令:
grep -B 2 "hello" file.txt
-C
:这个参数表示输出匹配行的前后几行。例如,如果我们想在file.txt
中搜索hello
,并且输出匹配行的前后两行,我们可以使用以下命令:
grep -C 2 "hello" file.txt
egrep
命令和 grep
命令类似,但是它支持扩展的正则表达式,例如 +
,?
和 |
。
这些命令是文本处理和日志分析的重要工具,对于理解和使用正则表达式非常有帮助。
总结
这篇文章主要介绍了在 Unix 和 Linux 系统中一些常见命令的使用。
MIT 6.828 JOS 2018 环境配置
环境:Ubuntu 20.04
- Windows 建议 WSL2
- MacOS 建议买个云服务器,用 Docker 的话在 M1/M2 上有些指令无法执行。
最初我在 Windows 用 WSL2 写的 Lab1 ,可以直接本地调试。后续的 Lab 是在 MacOS 上,代码使用 Clion 在本地编辑,通过其内置的 SFTP 来同步代码,即每次保存代码会自动同步到云服务器上。在终端里面通过 ssh 链接到服务器上,在里面敲命令的方式来编译运行,调试。
mkdir ~/6.828 && cd ~/6.828
git clone https://pdos.csail.mit.edu/6.828/2018/jos.git lab
sudo apt-get install -y build-essential gdb gcc-multilib
git clone https://github.com/mit-pdos/6.828-qemu.git qemu
sudo apt-get install libsdl1.2-dev libtool-bin libglib2.0-dev libz-dev libpixman-1-dev
cd qemu
./configure --disable-kvm --disable-werror --target-list="i386-softmmu x86_64-softmmu" --python=/usr/bin/python2.7
make && make install
出现如下错误:
/usr/bin/ld: qga/commands-posix.o: in function `dev_major_minor':
/home/yunwei/qemu/qga/commands-posix.c:633: undefined reference to `major'
/usr/bin/ld: /home/yunwei/qemu/qga/commands-posix.c:634: undefined reference to `minor'
collect2: error: ld returned 1 exit status
在 qga/commands-posix.c
文件中加上头文件 #include<sys/sysmacros.h>
随后重新执行 make && make install
。
进入 lab 后执行如下命令报错:
$ make
+ ld obj/kern/kernel
ld: warning: section `.bss' type changed to PROGBITS
ld: obj/kern/printfmt.o: in function `printnum':
lib/printfmt.c:41: undefined reference to `__udivdi3'
ld: lib/printfmt.c:49: undefined reference to `__umoddi3'
make: *** [kern/Makefrag:71: obj/kern/kernel] Error 1
解决方案是安装 4.8 的 gcc ,但是报错,原因是这个包没有在这个源中。
$ sudo apt-get install -y gcc-4.8-multilib
Reading package lists... Done
Building dependency tree
Reading state information... Done
E: Unable to locate package gcc-4.8-multilib
E: Couldn't find any package by glob 'gcc-4.8-multilib'
E: Couldn't find any package by regex 'gcc-4.8-multilib'
经过一番折腾,看到了这篇文章。简单来说就是这个包在 Ubuntu16.04 下可以正常下载,那么增加这个包的源即可。在 /etc/apt/sources.list
中添加如下内容:
deb http://dk.archive.ubuntu.com/ubuntu/ xenial main
deb http://dk.archive.ubuntu.com/ubuntu/ xenial universe
切记,需要更新,然后再次启动 qemu 依旧报错
$ sudo apt-get update
$ make
+ ld obj/kern/kernel
ld: warning: section `.bss' type changed to PROGBITS
ld: obj/kern/printfmt.o: in function `printnum':
lib/printfmt.c:41: undefined reference to `__udivdi3'
ld: lib/printfmt.c:49: undefined reference to `__umoddi3'
make: *** [kern/Makefrag:71: obj/kern/kernel] Error 1
经过分析,发现 gcc 版本没有修改
$ gcc --version
gcc (Ubuntu 8.4.0-3ubuntu2) 8.4.0
Copyright (C) 2018 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
于是将 gcc 版本改为 4.8 。删除原来的软连接,增加指向 4.8 版本的 软连接。查看版本更新成功。
$ sudo rm /usr/bin/gcc
$ sudo ln -s /usr/bin/gcc-4.8 /usr/bin/gcc
$ gcc --version
gcc (Ubuntu 4.8.5-4ubuntu2) 4.8.5
Copyright (C) 2015 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
再次编译,没有问题了!
$ cd lab
$ make
+ ld obj/kern/kernel
ld: warning: section `.bss' type changed to PROGBITS
+ as boot/boot.S
+ cc -Os boot/main.c
+ ld boot/boot
boot block is 380 bytes (max 510)
+ mk obj/kern/kernel.img
启动 qemu
$ sudo make qemu
至此,环境配置完成,接下来继续阅读 lab1 :https://pdos.csail.mit.edu/6.828/2018/labs/lab1/
使用 make grade
来测试。
make grade
make clean
make[1]: Entering directory '/root/6.828/lab'
rm -rf obj .gdbinit jos.in qemu.log
make[1]: Leaving directory '/root/6.828/lab'
./grade-lab1
/usr/bin/env: ‘python’: No such file or directory
make: *** [GNUmakefile:202: grade] Error 127
下面的命令可以解决上面的报错:
update-alternatives --install /usr/bin/python python /usr/bin/python3 200
计算机网络
TCP 篇
TCP 报文格式
TCP(Transmission Control Protocol,传输控制协议)的数据包由头部和数据部分组成。以下是一个文本图形化的表示方式:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| TCP Header |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| TCP Data |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
-
TCP Header:TCP头部,包含了用于控制TCP连接和数据传输的各种信息,如源端口、目标端口、序列号、确认号、窗口大小等。
-
TCP Data:TCP数据,这是TCP载荷的实际数据,也就是需要传输的信息。
TCP头部的长度通常是20字节,但如果包含了选项字段,长度可能会增加。数据部分的长度则取决于数据包的实际内容。
TCP 头部字段
下面是 TCP(Transmission Control Protocol,传输控制协议)的头部结构:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Acknowledgment Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data | |U|A|P|R|S|F| |
| Offset| Reserved |R|C|S|S|Y|I| Window |
| | |G|K|H|T|N|N| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Checksum | Urgent Pointer |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Options | Padding |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| data |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
各字段的含义如下:
- Source Port:源端口号,用于标识发送端的应用程序。
- Destination Port:目标端口号,用于标识接收端的应用程序。
- Sequence Number:序列号,用于标识数据包在数据流中的位置。
- Acknowledgment Number:确认号,用于标识期望接收的下一个数据包的序列号。
- Data Offset:数据偏移,标识TCP头部的长度。
- Reserved:保留字段,未使用。
- Flags:标志位,包括URG、ACK、PSH、RST、SYN、FIN等,用于控制TCP的各种行为。
- Window:窗口大小,用于流量控制。
- Checksum:校验和,用于错误检测。
- Urgent Pointer:紧急指针,当URG标志位被设置时使用。
- Options:选项,用于支持一些可选的TCP功能。
- Padding:填充,用于确保TCP头部的长度为32位的整数倍。
- Data:数据,TCP载荷的实际数据。
端口
当两台计算机通过TCP进行通信时,它们各自都会有一个源端口和目标端口。这两个端口号是在TCP头部中定义的,用于标识发送端和接收端的应用程序。
例如,假设你的计算机(计算机A)正在尝试通过HTTP协议(它基于TCP)访问一个网站。在这种情况下,计算机A的浏览器会选择一个源端口,例如50000(通常是随机选择的),而目标端口则是80(HTTP协议的标准端口)。
在TCP头部中,这将表示为:
Source Port: 50000
Destination Port: 80
当网站的服务器收到这个请求时,它会看到源端口是50000,目标端口是80。服务器会处理这个请求,然后发送一个响应回计算机A。在响应的TCP头部中,源端口和目标端口的值会被交换:
Source Port: 80
Destination Port: 50000
这样,当计算机A收到这个响应时,它就知道这个响应是针对源端口50000的请求的,也就是说,这个响应应该被发送到发起请求的浏览器。
这就是源端口和目标端口在TCP通信中的作用。
序列号
在TCP(传输控制协议)中,序列号(Sequence Number)是一个非常重要的概念,它用于标识数据包在数据流中的位置,以确保数据包能够按照正确的顺序被接收和重组。
假设我们有两台计算机,计算机A和计算机B,它们正在通过TCP进行通信。计算机A想要发送一段消息给计算机B,这段消息的内容是"Hello, World!",但是由于这段消息太长,不能一次性发送,所以它被分割成了两个数据包,分别是"Hello, "和"World!"。
在TCP中,每个数据包都会被赋予一个序列号。假设"Hello, "的序列号是1,"World!"的序列号是2。当计算机B收到这两个数据包时,它会根据序列号的值来确定数据包的顺序,然后将这两个数据包重组成原始的消息"Hello, World!"。
如果没有序列号,那么计算机B可能会收到"World!Hello, ",这显然是错误的。因此,序列号在TCP中起着至关重要的作用。
此外,序列号还用于实现TCP的可靠性。例如,如果计算机B没有收到序列号为2的数据包,它可以通过发送一个特殊的数据包(叫做ACK,Acknowledgment)来告诉计算机A,它需要重新发送序列号为2的数据包。这就是TCP如何确保数据的可靠传输的。
为什么序列号是随机的?
TCP(传输控制协议)连接的初始化序列号(ISN,Initial Sequence Number)在每次连接建立时都会变化,这是由于以下几个原因:
-
避免数据混淆:如果新的连接使用了与旧连接相同的序列号,那么网络中延迟的数据包可能会被错误地认为是新连接的数据包,从而导致数据混淆。
-
安全性:如果序列号是固定的或者可预测的,那么攻击者可能会利用这个特性进行攻击,例如伪造数据包。通过使每个连接的初始序列号随机,可以增加攻击的难度。
-
流量控制:序列号也用于TCP的流量控制。每个字节都有一个序列号,接收方通过确认序列号来告诉发送方哪些数据已经被接收。如果序列号不变,那么这个机制将无法正常工作。
因此,每次建立TCP连接时,都会生成一个新的、随机的初始序列号。
Acknowledgment Number
在TCP(传输控制协议)中,Acknowledgment Number(确认号)是一个非常重要的概念,它用于标识接收端期望接收的下一个数据包的序列号,以此来实现TCP的可靠性。
假设我们有两台计算机,计算机A和计算机B,它们正在通过TCP进行通信。计算机A想要发送一段消息给计算机B,这段消息的内容是"Hello, World!",但是由于这段消息太长,不能一次性发送,所以它被分割成了两个数据包,分别是"Hello, "和"World!"。
在TCP中,每个数据包都会被赋予一个序列号。假设"Hello, "的序列号是1,"World!"的序列号是2。当计算机B收到序列号为1的数据包后,它会发送一个确认号为2的ACK数据包给计算机A,表示它已经成功接收了序列号为1的数据包,期望接收序列号为2的数据包。
如果计算机A没有收到确认号为2的ACK数据包,它会认为序列号为2的数据包在传输过程中丢失,然后重新发送这个数据包。如果计算机A收到了确认号为2的ACK数据包,它就知道序列号为1的数据包已经被成功接收,然后继续发送下一个数据包。
这就是Acknowledgment Number在TCP中的作用。
Data Offset
在TCP(传输控制协议)中,Data Offset(数据偏移)是一个非常重要的字段,它标识了TCP头部的长度。这个字段的值是以32位(4字节)为单位的,所以如果Data Offset的值是5,那么实际的TCP头部长度就是5*4=20字节。
这个字段的存在是因为TCP头部的长度是可变的。TCP头部有一些可选的字段,比如Options(选项),这些字段可能会被包含在某些数据包的头部中,也可能不被包含。因此,接收端需要通过查看Data Offset的值来确定头部的实际长度,从而知道数据部分从哪里开始。
例如,假设我们有一个TCP数据包,它的Data Offset的值是5,那么接收端就知道头部的长度是20字节,数据部分就从第21字节开始。如果Data Offset的值是6,那么头部的长度就是24字节,数据部分就从第25字节开始。
这就是Data Offset在TCP中的作用。
Reserved 字段
在 TCP(传输控制协议)的头部,有一个字段被称为 "Reserved"。这个字段的长度为 6 位,用于未来的扩展。在当前的 TCP 规范中,这个字段必须被设置为 0。
这是 TCP 头部的一部分,其结构如下:
typedef struct {
unsigned short source_port; // 源端口
unsigned short dest_port; // 目标端口
unsigned int sequence_num; // 序列号
unsigned int ack_num; // 确认号
unsigned char data_offset:4; // 数据偏移
unsigned char reserved:6; // 保留字段
unsigned char flags; // 标志字段
unsigned short window_size; // 窗口大小
unsigned short checksum; // 校验和
unsigned short urgent_pointer; // 紧急指针
} tcp_header;
在这个结构中,reserved
字段被设置为 6 位。这个字段目前没有使用,但是被保留用于未来的扩展。在发送 TCP 包时,这个字段应该被设置为 0。如果接收方收到的 TCP 包中这个字段不为 0,那么这个包应该被忽略。
这是一个简单的例子,展示了如何设置和检查这个字段:
tcp_header header;
header.reserved = 0; // 设置保留字段为 0
// 检查接收到的 TCP 包的保留字段
if (received_header.reserved != 0) {
// 忽略这个包
}
这个例子中,我们首先创建了一个 tcp_header
结构的实例,并将 reserved
字段设置为 0。然后,当我们接收到一个 TCP 包时,我们检查 reserved
字段。如果这个字段不为 0,我们就忽略这个包。
标志位
TCP(传输控制协议)的头部中有一个字段被称为 "Flags"。这个字段的长度为 8 位,用于控制 TCP 连接的各种状态。每一位都代表一个特定的标志,包括 URG、ACK、PSH、RST、SYN 和 FIN。
- URG:紧急指针有效。当这个标志被设置时,表示 TCP 报文段中的紧急指针字段有效,用于告知接收端有紧急数据需要处理。
- ACK:确认序号有效。当这个标志被设置时,表示 TCP 报文段中的确认号字段有效,用于告知发送端已经接收到了哪些数据。
- PSH:接收方应该尽快将这个报文段交给应用层。当这个标志被设置时,表示 TCP 报文段中的数据应该尽快被接收端的应用层处理,而不是在缓冲区中等待。
- RST:重置连接。当这个标志被设置时,表示 TCP 连接出现错误,需要被重置。发送端收到带有 RST 标志的 TCP 报文段后,会立即关闭连接,丢弃缓冲区中的所有数据。
- SYN:同步序号,用于建立连接。当这个标志被设置时,表示 TCP 连接正在尝试建立。发送端和接收端会通过交换带有 SYN 标志的 TCP 报文段来同步序号,完成连接的建立。
- FIN:结束连接。当这个标志被设置时,表示 TCP 连接正在尝试关闭。发送端和接收端会通过交换带有 FIN 标志的 TCP 报文段来完成连接的关闭。
这是一个简单的例子,展示了如何设置和检查这个字段:
tcp_header header;
header.flags = 0x02; // 设置 SYN 标志
// 检查接收到的 TCP 包的标志字段
if (received_header.flags & 0x01) {
// 如果 FIN 标志被设置,结束连接
}
这个例子中,我们首先创建了一个 tcp_header
结构的实例,并将 flags
字段设置为 0x02,表示设置 SYN 标志。然后,当我们接收到一个 TCP 包时,我们检查 flags
字段。如果 FIN 标志被设置(即 flags & 0x01 不为 0),我们就结束连接。
窗口大小
TCP(传输控制协议)的头部中有一个字段被称为 "Window Size"。这个字段的长度为 16 位,用于控制 TCP 连接的流量控制。窗口大小字段表示的是接收方愿意接收的数据量,单位是字节。
在 TCP 连接中,发送方不能无限制地发送数据,而是需要根据接收方的窗口大小来发送。接收方通过窗口大小字段告诉发送方,它还能接收多少数据。发送方在发送数据时,需要确保未被确认的数据量不超过接收方的窗口大小。
这是 TCP 头部的一部分,其结构如下:
typedef struct {
unsigned short source_port; // 源端口
unsigned short dest_port; // 目标端口
unsigned int sequence_num; // 序列号
unsigned int ack_num; // 确认号
unsigned char data_offset:4; // 数据偏移
unsigned char reserved:6; // 保留字段
unsigned char flags; // 标志字段
unsigned short window_size; // 窗口大小
unsigned short checksum; // 校验和
unsigned short urgent_pointer; // 紧急指针
} tcp_header;
在这个结构中,window_size
字段被设置为 16 位。这个字段表示的是接收方愿意接收的数据量。
这是一个简单的例子,展示了如何设置和检查这个字段:
tcp_header header;
header.window_size = 1024; // 设置窗口大小为 1024 字节
// 检查接收到的 TCP 包的窗口大小字段
if (received_header.window_size < 1024) {
// 如果窗口大小小于 1024 字节,减小发送速率
}
这个例子中,我们首先创建了一个 tcp_header
结构的实例,并将 window_size
字段设置为 1024,表示我们愿意接收的数据量为 1024 字节。然后,当我们接收到一个 TCP 包时,我们检查 window_size
字段。如果窗口大小小于 1024 字节,我们就减小发送速率,以防止发送的数据量超过接收方的处理能力。
校验和
TCP(传输控制协议)的头部中有一个字段被称为 "Checksum"。这个字段的长度为 16 位,用于检查 TCP 报文段在传输过程中是否出现错误。校验和字段是通过对整个 TCP 报文段(包括 TCP 头部和数据)进行计算得到的,接收方在接收到 TCP 报文段后,会重新计算校验和,然后与接收到的校验和进行比较,以检查报文段是否在传输过程中出现错误。
这是 TCP 头部的一部分,其结构如下:
typedef struct {
unsigned short source_port; // 源端口
unsigned short dest_port; // 目标端口
unsigned int sequence_num; // 序列号
unsigned int ack_num; // 确认号
unsigned char data_offset:4; // 数据偏移
unsigned char reserved:6; // 保留字段
unsigned char flags; // 标志字段
unsigned short window_size; // 窗口大小
unsigned short checksum; // 校验和
unsigned short urgent_pointer; // 紧急指针
} tcp_header;
在这个结构中,checksum
字段被设置为 16 位。这个字段是通过对整个 TCP 报文段进行计算得到的。
这是一个简单的例子,展示了如何设置和检查这个字段:
tcp_header header;
header.checksum = calculate_checksum(&header); // 计算并设置校验和
// 检查接收到的 TCP 包的校验和字段
if (received_header.checksum != calculate_checksum(&received_header)) {
// 如果校验和不匹配,丢弃这个包
}
这个例子中,我们首先创建了一个 tcp_header
结构的实例,并通过 calculate_checksum
函数计算并设置 checksum
字段。然后,当我们接收到一个 TCP 包时,我们重新计算校验和,并与接收到的校验和进行比较。如果校验和不匹配,我们就丢弃这个包。
请注意,这个例子中的 calculate_checksum
函数是假设存在的,实际的校验和计算过程会涉及到对 TCP 头部和数据的处理,这个过程比较复杂,超出了这个例子的范围。
紧急指针
TCP(传输控制协议)的头部中有一个字段被称为 "Urgent Pointer"。这个字段的长度为 16 位,只有当 URG 标志位被设置时,这个字段才有效。紧急指针用于指示 TCP 报文段中的紧急数据的结束位置。
当 TCP 连接中的一方需要发送紧急数据时,可以设置 URG 标志,并通过紧急指针字段指示紧急数据的结束位置。接收方在接收到带有 URG 标志的 TCP 报文段后,会优先处理紧急数据。
这是 TCP 头部的一部分,其结构如下:
typedef struct {
unsigned short source_port; // 源端口
unsigned short dest_port; // 目标端口
unsigned int sequence_num; // 序列号
unsigned int ack_num; // 确认号
unsigned char data_offset:4; // 数据偏移
unsigned char reserved:6; // 保留字段
unsigned char flags; // 标志字段
unsigned short window_size; // 窗口大小
unsigned short checksum; // 校验和
unsigned short urgent_pointer; // 紧急指针
} tcp_header;
在这个结构中,urgent_pointer
字段被设置为 16 位。这个字段用于指示紧急数据的结束位置。
这是一个简单的例子,展示了如何设置和检查这个字段:
tcp_header header;
header.flags = 0x20; // 设置 URG 标志
header.urgent_pointer = 100; // 设置紧急指针为 100
// 检查接收到的 TCP 包的紧急指针字段
if (received_header.flags & 0x20) {
// 如果 URG 标志被设置,优先处理前 100 字节的紧急数据
}
这个例子中,我们首先创建了一个 tcp_header
结构的实例,并将 flags
字段设置为 0x20,表示设置 URG 标志。然后,我们设置 urgent_pointer
字段为 100,表示紧急数据的结束位置为第 100 字节。当我们接收到一个 TCP 包时,我们检查 flags
字段。如果 URG 标志被设置(即 flags & 0x20 不为 0),我们就优先处理前 100 字节的紧急数据。
Options
TCP(传输控制协议)的头部中有一个可选字段被称为 "Options"。这个字段的长度可以变化,用于提供 TCP 连接的额外功能。Options 字段可以包含多个选项,每个选项都有一个选项种类和选项长度。常见的 TCP 选项包括最大报文段长度(MSS)、窗口扩大因子(Window Scale)、时间戳(Timestamps)等。
-
最大报文段长度(MSS):这个选项用于指定 TCP 报文段的最大长度。发送方在建立连接时,会通过这个选项告诉接收方它能接收的最大报文段长度。接收方在接收到这个选项后,会限制发送的报文段长度不超过这个值。
-
窗口扩大因子(Window Scale):这个选项用于扩大窗口大小字段的范围。在早期的 TCP 协议中,窗口大小字段的长度为 16 位,最大值为 65535 字节。但随着网络速度的提高,这个值已经不能满足需求。通过窗口扩大因子选项,可以将窗口大小字段的最大值扩大到 1GB。
-
时间戳(Timestamps):这个选项用于提供更精确的 RTT(往返时间)测量和保护对旧的重复报文段的接收。
这是一个简单的例子,展示了如何设置和检查这个字段:
typedef struct {
unsigned char kind; // 选项种类
unsigned char length; // 选项长度
unsigned char data[]; // 选项数据
} tcp_option;
typedef struct {
unsigned short source_port; // 源端口
unsigned short dest_port; // 目标端口
unsigned int sequence_num; // 序列号
unsigned int ack_num; // 确认号
unsigned char data_offset:4; // 数据偏移
unsigned char reserved:6; // 保留字段
unsigned char flags; // 标志字段
unsigned short window_size; // 窗口大小
unsigned short checksum; // 校验和
unsigned short urgent_pointer; // 紧急指针
tcp_option options[]; // 选项字段
} tcp_header;
// 创建一个 MSS 选项
tcp_option mss_option;
mss_option.kind = 2; // MSS 选项的种类为 2
mss_option.length = 4; // MSS 选项的长度为 4
*((unsigned short*)mss_option.data) = htons(1460); // MSS 选项的数据为 1460,转换为网络字节序
// 将 MSS 选项添加到 TCP 头部
tcp_header header;
header.options[0] = mss_option;
这个例子中,我们首先创建了一个 tcp_option
结构的实例,并设置 kind
字段为 2,表示这是一个 MSS 选项。然后,我们设置 length
字段为 4,表示这个选项的长度为 4 字节。最后,我们设置 data
字段为 1460,表示我们能接收的最大报文段长度为 1460 字节。然后,我们将这个选项添加到 TCP 头部的 options
字段。
请注意,这个例子中的 htons
函数用于将主机字节序转换为网络字节序。在实际的网络编程中,我们需要确保所有的网络协议字段都使用网络字节序。
Padding
TCP(传输控制协议)的头部中有一个字段被称为 "Padding"。这个字段的长度可以变化,用于确保 TCP 头部的总长度为 32 位的整数倍。Padding 字段的内容没有实际意义,通常被设置为 0。
在 TCP 协议中,头部的长度必须是 32 位(即 4 字节)的整数倍。这是因为 TCP 协议的设计者选择了 32 位作为基本的对齐单位,以便于在硬件层面上处理 TCP 报文段。但是,由于 Options 字段的长度可以变化,所以 TCP 头部的长度可能不是 32 位的整数倍。在这种情况下,就需要使用 Padding 字段来填充 TCP 头部,使其长度达到 32 位的整数倍。
这是一个简单的例子,展示了如何设置这个字段:
typedef struct {
unsigned short source_port; // 源端口
unsigned short dest_port; // 目标端口
unsigned int sequence_num; // 序列号
unsigned int ack_num; // 确认号
unsigned char data_offset:4; // 数据偏移
unsigned char reserved:6; // 保留字段
unsigned char flags; // 标志字段
unsigned short window_size; // 窗口大小
unsigned short checksum; // 校验和
unsigned short urgent_pointer; // 紧急指针
unsigned char options_and_padding[40]; // 选项和填充字段
} tcp_header;
// 创建一个 TCP 头部
tcp_header header;
memset(&header, 0, sizeof(header)); // 将整个头部初始化为 0
// 设置选项
header.options_and_padding[0] = 0x02; // 设置 MSS 选项的种类
header.options_and_padding[1] = 0x04; // 设置 MSS 选项的长度
*((unsigned short*)&header.options_and_padding[2]) = htons(1460); // 设置 MSS 选项的数据
// 剩余的部分会作为 Padding 字段,已经被初始化为 0
这个例子中,我们首先创建了一个 tcp_header
结构的实例,并将整个头部初始化为 0。然后,我们在 options_and_padding
字段中设置 MSS 选项。剩余的部分会作为 Padding 字段,已经被初始化为 0,所以我们不需要再进行任何操作。
Data
TCP(传输控制协议)的头部后面跟随的是 "Data" 字段。这个字段的长度可以变化,用于携带 TCP 连接中传输的实际数据。Data 字段的内容由应用程序决定,TCP 协议本身并不关心其内容。
在 TCP 连接中,发送方和接收方会交换数据。发送方将要发送的数据放入 Data 字段,然后将 TCP 报文段发送给接收方。接收方在接收到 TCP 报文段后,会从 Data 字段中取出数据,然后将其交给应用程序。
这是一个简单的例子,展示了如何设置和检查这个字段:
typedef struct {
unsigned short source_port; // 源端口
unsigned short dest_port; // 目标端口
unsigned int sequence_num; // 序列号
unsigned int ack_num; // 确认号
unsigned char data_offset:4; // 数据偏移
unsigned char reserved:6; // 保留字段
unsigned char flags; // 标志字段
unsigned short window_size; // 窗口大小
unsigned short checksum; // 校验和
unsigned short urgent_pointer; // 紧急指针
unsigned char options_and_padding[40]; // 选项和填充字段
char data[]; // 数据字段
} tcp_header;
// 创建一个 TCP 头部
tcp_header* header = (tcp_header*)malloc(sizeof(tcp_header) + 1024); // 分配足够的空间来存储数据
// 设置数据
strcpy(header->data, "Hello, world!"); // 将 "Hello, world!" 复制到数据字段
这个例子中,我们首先创建了一个 tcp_header
结构的实例,并分配了足够的空间来存储数据。然后,我们将 "Hello, world!" 复制到数据字段。
请注意,这个例子中的 malloc
函数用于动态分配内存。在实际的网络编程中,我们需要根据实际的数据大小来动态分配内存。
TCP 粘包拆包问题
面试的时候被问到了,TCP 粘包和拆包问题,之前的项目中也涉及了这部分内容,写篇文章系统的总结一下。
什么是 TCP 粘包?
TCP粘包问题是由于TCP是一个面向字节流的协议,数据在传输过程中,可能会将多个数据包合并为一个数据包进行发送,这就是所谓的TCP粘包问题。这种情况通常发生在发送端的数据发送速度大于接收端的数据处理速度时。
发送端 网络 接收端
| | |
| -- 数据包A --> | |
| -- 数据包B --> | |
| | -- 数据包C (A + B) --> |
| | | -- 处理数据包C,分离出A和B
| | |
在这个示例中,发送端连续发送了两个数据包A和B。由于网络的原因,这两个数据包在到达接收端时被合并为一个数据包C。接收端在接收数据时,期望能够按照数据包的边界接收数据,即先接收数据包A,然后接收数据包B。但是由于TCP的粘包问题,接收端实际上接收到的是一个大的数据包C,这就需要接收端自己去处理如何从数据包C中分离出原来的数据包A和B。
TCP 粘包是怎么产生的?
TCP粘包问题是由于TCP协议的特性导致的。TCP是一个面向字节流的协议,这意味着TCP并不关心数据的边界,它只负责将数据作为一个连续的字节流发送出去。因此,当发送端连续发送多个数据包时,这些数据包可能会被合并为一个大的数据包进行发送,这就是所谓的TCP粘包问题。
相比之下,UDP是一个面向报文的协议,每个UDP数据包都是独立的,UDP保证了数据包的边界。在UDP中,数据包的边界是由UDP协议自身来保证的。每个UDP数据包都是独立的,包含了源端口号、目标端口号、长度和校验和等信息。当接收端接收到UDP数据包时,它可以通过这些信息来确定数据包的边界。因此,UDP不存在粘包问题。当接收端接收到UDP数据包时,它可以明确知道数据包的边界在哪里。
具体来说,UDP数据包的长度字段表示了UDP头部和数据部分的总长度,接收端可以通过这个长度字段来确定数据包的边界。因此,UDP不存在像TCP那样的粘包问题。
总的来说,TCP和UDP的主要区别在于TCP是面向连接的,提供可靠的数据传输服务,而UDP是无连接的,提供不可靠的数据传输服务。这也导致了TCP存在粘包问题,而UDP不存在粘包问题。
如何解决 TCP 粘包问题?
解决TCP粘包问题的常见方法有:
-
在数据包之间添加特殊的分隔符,使得接收端可以通过这些分隔符来识别数据包的边界。
-
在每个数据包的头部添加长度字段,表示数据包的长度,接收端通过读取长度字段,可以知道每个数据包的边界在哪里。
-
使用固定长度的数据包,这样接收端可以直接通过数据包的长度来确定数据包的边界。
如何在数据包之间添加特殊的分隔符?
在这个示例中,发送端在每个数据包的末尾添加了一个特殊的分隔符'#',接收端可以通过这个分隔符来识别数据包的边界。
发送端 网络 接收端
| | |
| -- 数据包A# --> | |
| -- 数据包B# --> | |
| | -- 数据包A#B# --> |
| | | -- 分离出数据包A和B
| | |
这种方法常见于文本协议,如HTTP和SMTP。在这些协议中,数据包之间通常使用特殊的字符(如换行符或空格)作为分隔符。例如,HTTP协议中的请求和响应头部就是通过换行符来分隔的。当接收端接收到数据时,它可以通过这些分隔符来识别数据包的边界。
以下结合HTTP协议的报文结构来讲解这种方法:
HTTP协议是一种文本协议,它的报文结构主要包括起始行、头部字段和消息体三部分。起始行和头部字段之间、头部字段和消息体之间、以及头部字段之间都是通过换行符来分隔的。
例如,一个HTTP请求报文可能如下所示:
GET /index.html HTTP/1.1\r\n
Host: www.example.com\r\n
Connection: keep-alive\r\n
\r\n
在这个例子中,GET /index.html HTTP/1.1
是起始行,Host: www.example.com
和Connection: keep-alive
是头部字段,它们之间都是通过\r\n
(换行符)来分隔的。头部字段和消息体之间的空行(即连续的两个换行符)表示头部字段的结束和消息体的开始。
当接收端接收到这个HTTP请求报文时,它可以通过这些换行符来识别数据包的边界。例如,它可以先找到第一个换行符,然后读取起始行;然后再找到下一个换行符,读取第一个头部字段;以此类推,直到读取到连续的两个换行符,表示头部字段的结束和消息体的开始。
在头部设置长度
在这个示例中,发送端在每个数据包的头部添加了一个长度字段,表示数据包的长度,接收端通过读取长度字段,可以知道每个数据包的边界在哪里。
发送端 网络 接收端
| | |
| -- 数据包(3,A) --> | |
| -- 数据包(3,B) --> | |
| | -- 数据包(3,A)(3,B) --> |
| | | -- 分离出数据包A和B
| | |
这种方法常见于二进制协议,如Protocol Buffers和Thrift。在这些协议中,每个数据包的头部通常会包含一个表示数据包长度的字段。当接收端接收到数据时,它可以通过读取这个长度字段来确定数据包的边界。例如,Protocol Buffers协议中的消息就是通过在消息头部添加一个表示消息长度的字段来解决粘包问题的。
以下是一个具体的例子,结合Protocol Buffers协议的报文结构来讲解这种方法:
Protocol Buffers(简称protobuf)是一种二进制协议,它的报文结构主要包括一个长度字段和一个数据字段。长度字段表示数据字段的长度。
例如,一个protobuf报文可能如下所示:
+----------------+------------------+
| 长度 (2 bytes) | 数据 (n bytes) |
+----------------+------------------+
在这个例子中,长度字段是2字节,表示数据字段的长度。数据字段是n字节,表示实际的数据。 当接收端接收到这个protobuf报文时,它可以先读取长度字段,然后根据长度字段的值来读取数据字段。这样,接收端就可以通过长度字段来确定数据包的边界。
这就是protobuf协议如何通过在每个数据包的头部添加长度字段来解决粘包问题的。
如何设置包长度固定
在这个示例中,发送端使用固定长度的数据包,这样接收端可以直接通过数据包的长度来确定数据包的边界。
发送端 网络 接收端
| | |
| -- 数据包A(5) --> | |
| -- 数据包B(5) --> | |
| | -- 数据包A(5)B(5) --> |
| | | -- 分离出数据包A和B
| | |
这种方法在一些特定的场景中可能会被使用,例如在一些实时通信的协议中。在这些协议中,为了简化处理过程,所有的数据包都会被设计为固定长度。当接收端接收到数据时,它可以直接通过数据包的长度来确定数据包的边界。例如,一些音频流协议就可能会使用这种方法来解决粘包问题。
以下是一个具体的例子,结合音频流协议(如RTP)的报文结构来讲解这种方法:
实时传输协议(RTP)是一种面向数据包的协议,常用于音频和视频的实时传输。在RTP协议中,所有的数据包都被设计为固定长度,以简化处理过程。
例如,一个RTP数据包的结构可能如下所示:
+----------------+------------------+------------------+
| RTP头部 (12字节) | 有效载荷 (固定长度) | RTP尾部 (可选) |
+----------------+------------------+------------------+
在这个例子中,RTP头部是12字节,有效载荷是固定长度,RTP尾部是可选的。当接收端接收到这个RTP数据包时,它可以直接通过数据包的长度来确定数据包的边界。
这就是RTP协议如何通过使用固定长度的数据包来解决粘包问题的。
什么是 TCP 拆包?
TCP 拆包是指 TCP 协议在传输数据时,将大的数据包拆分为多个小的数据包进行发送的过程。这是因为网络中的每个链路可能有不同的最大传输单元(MTU),超过 MTU 大小的数据包需要被拆分才能进行传输。
例如,假设我们有一个大小为 3000 字节的数据包需要通过 TCP 发送,而网络的 MTU 为 1500 字节。在这种情况下,TCP 协议会将这个数据包拆分为两个大小分别为 1500 字节的数据包进行发送。
发送方
+---------------------+
| 数据包 (3000字节) |
+---------------------+
|
| TCP 拆包
v
+-----------------+ +-----------------+
| 数据包1 (1500字节)| | 数据包2 (1500字节)|
+-----------------+ +-----------------+
|
| 发送
v
接收方
+-----------------+ +-----------------+
| 数据包1 (1500字节)| | 数据包2 (1500字节) |
+-----------------+ +-----------------+
|
| TCP 粘包
v
+---------------------+
| 数据包 (3000字节) |
+---------------------+
在接收端,TCP 协议会将接收到的所有数据包重新组装成原始的数据包。这个过程被称为 TCP 粘包。
什么原因导致了 TCP 拆包?
TCP 拆包主要是由于网络中的每个链路可能有不同的最大传输单元(MTU)导致的。MTU 是指网络中一次能够传输的最大数据包大小。如果一个数据包的大小超过了 MTU,那么这个数据包就需要被拆分成多个小的数据包才能进行传输。
例如,假设我们有一个大小为 3000 字节的数据包需要通过 TCP 发送,而网络的 MTU 为 1500 字节。在这种情况下,TCP 协议会将这个数据包拆分为两个大小分别为 1500 字节的数据包进行发送。
这样做的原因是,如果一个数据包的大小超过了 MTU,那么这个数据包在传输过程中可能会被丢弃,导致数据无法成功传输。通过将大的数据包拆分为多个小的数据包,可以避免这种情况,确保数据能够成功传输。
此外,TCP 拆包还可以提高网络的利用率。如果一个大的数据包在传输过程中出现了错误,那么整个数据包都需要被重新传输。而如果将大的数据包拆分为多个小的数据包,那么即使其中一个小的数据包出现了错误,也只需要重新传输这个小的数据包,而不需要重新传输整个大的数据包。这样可以减少不必要的重传,提高网络的利用率。
TCP 拆包是透明的吗?
TCP 拆包是由 TCP 协议自身处理的。当 TCP 协议在发送数据时,如果数据包的大小超过了网络的最大传输单元(MTU),TCP 协议会自动将数据包拆分为多个小的数据包进行发送。这个过程是完全透明的,对于上层协议和应用程序来说,它们并不需要知道数据包是否被拆分,也不需要进行任何额外的处理。
同样地,当 TCP 协议在接收数据时,如果接收到了多个被拆分的数据包,TCP 协议会自动将这些数据包重新组装成原始的数据包。这个过程也是完全透明的,对于上层协议和应用程序来说,它们只会看到完整的数据包,而不会看到被拆分的数据包。
因此,TCP 拆包和粘包是由 TCP 协议自身处理的,不需要上层协议进行进一步的解决。
总结
TCP粘包是由于TCP协议的字节流特性,导致在数据传输过程中,多个数据包可能被合并为一个数据包发送。这通常发生在发送端的数据发送速度大于接收端的数据处理速度时。解决TCP粘包问题的常见方法有:添加特殊的分隔符,设置长度字段,或使用固定长度的数据包。
TCP拆包则是由于网络中的每个链路可能有不同的最大传输单元(MTU),导致超过MTU大小的数据包需要被拆分为多个小的数据包进行传输。这个过程是由TCP协议自身处理的,对于上层协议和应用程序来说,是完全透明的。
TCP 和 UDP的区别?
TCP 三次握手
TCP(Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议。在 TCP 协议中,"三次握手"(Three-way Handshake)是建立连接的重要步骤,它发生在数据传输之前。
TCP 三次握手的发展历史
TCP三次握手是在1974年由Vint Cerf和Robert Kahn提出的,最初是在RFC 675中定义的。当时,互联网还处于起步阶段,网络连接经常不稳定。为了防止旧的连接请求干扰新的连接请求,TCP使用了三次握手来建立连接。
TCP 三次握手的实现细节
TCP 三次握手的实现细节可以通过以下步骤来理解:
假设客户端选择的初始序列号 X 为 100,服务器选择的初始序列号 Y 为 300。
客户端 服务器
| |
| 1. SYN, Seq=100 |
|----------------------------->|
| |
| 2. SYN-ACK, Seq=300, Ack=101|
|<-----------------------------|
| |
| 3. ACK, Seq=101, Ack=301 |
|----------------------------->|
| |
- 客户端发送 SYN 包到服务器,序列号为 100。
这一步的目的是让服务器知道客户端想要建立连接,并且告诉服务器客户端的初始序列号。
SYN包是TCP/IP协议中的一种控制标志,它是"同步序列编号"(Synchronize Sequence Numbers)的缩写。在TCP三次握手过程中,SYN包用于初始化一个连接。发送SYN包的一方将选择一个初始序列号,并将这个序列号放在TCP头部的序列号字段中,然后将SYN标志位设置为1,表示这是一个连接请求。
- 服务器收到 SYN 包后,回应 SYN-ACK 包,序列号为 300,确认号为 101。
这一步的目的是让客户端知道服务器已经准备好建立连接,并且告诉客户端服务器的初始序列号。
接收到SYN包的一方如果同意建立连接,会回应一个SYN-ACK包,同时也会选择一个初始序列号。这样,双方就可以通过这两个初始序列号来同步后续的数据传输。
- 客户端收到 SYN-ACK 包后,发送 ACK 包,序列号为 101,确认号为 301。
这一步的目的是让服务器知道客户端已经准备好建立连接。
通过这三次握手,客户端和服务器就可以确认彼此已经准备好建立连接,并且知道了对方的初始序列号,这样就可以开始进行可靠的数据传输了。
为什么需要三次?两次不可以吗?
TCP协议选择三次握手而不是两次握手的原因是为了防止已失效的连接请求报文段突然又传到了服务端,因而产生错误。具体来说,假设如果只有两次握手,那么就可能出现以下的情况:
客户端 服务端
| |
| 1. SYN, 进入SYN-SENT状态 |
|----------------------------->|
| |
| 2. 收到滞留的SYN,发送ACK |
|<-----------------------------|
| |
| 3. 收到错误的ACK,丢弃 |
| |
| |
| 4. 服务端等待数据,形成半开连接 |
| |
-
首先,客户端发送一个SYN包并进入SYN-SENT状态。然后,由于网络中滞留的这个复制的连接请求报文段以后又传到了服务端。
-
服务端收到这个滞留的连接请求报文段后,误认为是客户端再次发起的一个新的连接,于是就向客户端发送确认报文段,同意建立连接。
-
但是,此时客户端接收到这个确认报文段后,就认为是错误的报文段,因为此前网络滞留的缘故以为此前的请求报文失效了,因此这时客户端切换为了没有发送连接请求的状态。所以,就直接丢弃了这个报文段。
-
但是服务端却认为新的连接已经建立,并一直等待客户端发送数据。这样就形成了服务端一直在等待,而客户端却不知道服务端在等待的情况,也就是所谓的"半开连接",这将会浪费服务端大量的资源。
言而总之,两次握手会导致一方(此处是服务端)以为链接已经建立,但是另一方却不这样认为,二者无法达成共识,进而导致服务端盲等,即半开链接。
而三次握手可以有效防止这种情况的发生。在刚刚的例子中,如果采用三次握手,那么服务端在发送确认报文段后,需要接收到客户端的再次确认,才会认为连接已经建立。如果没有收到客户端的再次确认,服务端就会认为连接没有建立,就不会一直等待,从而避免了资源的浪费。
为什么建立连接需要三次?四次不可以吗?
TCP协议选择三次握手而不是四次握手的原因是为了提高效率和减少不必要的网络负载。在TCP协议中,三次握手已经足够确保连接的可靠性。
如果我们使用四次握手,那么第四次握手将是冗余的,因为在第三次握手后,双方已经确认了连接的建立。第四次握手将会增加网络负载,并且可能会导致连接建立的延迟。因此,TCP协议选择了三次握手而不是四次握手。
第二次握手为什么还要发送 SYN ?
在TCP三次握手过程中,第二次握手时,服务器向客户端发送的是SYN-ACK包,其中的SYN是为了告诉客户端,服务器也准备好建立连接了。
具体来说,当服务器收到客户端的SYN包(同步序列编号)后,服务器需要回应一个SYN-ACK包。这个SYN-ACK包中,ACK是对客户端SYN包的确认,而SYN则是服务器自己的连接请求。这样,客户端和服务器就可以同时确认对方的连接请求,从而建立起连接。
这个过程可以确保连接的双向性,即客户端和服务器都确认了对方的连接请求,这样才能开始数据传输。如果没有这个过程,那么连接就只能是单向的,即只有客户端确认了服务器的连接请求,但服务器并没有确认客户端的连接请求,这样就不能进行数据传输。
TCP 三次握手的状态转移
TCP 三次握手的状态转移可以通过以下的方式来表示:
客户端状态 动作/事件 服务器状态
------------------------------------------------
CLOSED --> 发送 SYN --> LISTEN
SYN-SENT <-- 返回 SYN-ACK <-- SYN-RECEIVED
ESTABLISHED --> 发送 ACK --> ESTABLISHED
在这个图中,箭头表示状态的转移,箭头的两端是转移前后的状态,箭头上的文字是触发状态转移的事件或动作。这个过程描述了 TCP 三次握手的状态转移过程。
-
CLOSED --> 发送 SYN --> LISTEN:在 TCP 三次握手开始时,客户端处于 CLOSED 状态,服务器处于 LISTEN 状态。客户端发送 SYN 包给服务器,请求建立连接。
-
SYN-SENT <-- 返回 SYN-ACK <-- SYN-RECEIVED:服务器收到 SYN 包后,返回 SYN-ACK 包给客户端,并进入 SYN-RECEIVED 状态。客户端收到 SYN-ACK 包后,进入 SYN-SENT 状态。
-
ESTABLISHED --> 发送 ACK --> ESTABLISHED:客户端发送 ACK 包给服务器,确认建立连接,然后进入 ESTABLISHED 状态。服务器收到 ACK 包后,也进入 ESTABLISHED 状态。
至此,三次握手完成,TCP 连接建立。在 ESTABLISHED 状态下,客户端和服务器可以开始数据传输。
这个过程确保了 TCP 连接的可靠性。只有当客户端和服务器都确认对方的 SYN 包和 ACK 包,才会进入 ESTABLISHED 状态,开始数据传输。这就是为什么 TCP 被称为是一种可靠的传输协议。
总结
TCP三次握手是建立TCP连接的重要步骤,它包括客户端发送SYN包,服务器回应SYN-ACK包,以及客户端发送ACK包这三个步骤。这个过程确保了TCP连接的可靠性,只有当客户端和服务器都确认对方的SYN包和ACK包,才会开始数据传输。此外,三次握手还能防止已失效的连接请求报文段突然又传到了服务端,因而产生错误。
Socket 编程中的 TCP 三次握手
TCP 超时重传
IP 篇
HTTP(超文本传输协议)是一种用于分布式、协作式和超媒体信息系统的应用层协议。它是互联网上数据通信的基础,设计用于从网页服务器传输超文本到本地浏览器的传输协议。
1. 什么是 HTTP 协议
HTTP协议,全称为超文本传输协议(HyperText Transfer Protocol),是一种用于分布式、协作式和超媒体信息系统的应用层协议。它是互联网上应用最为广泛的一种网络协议。
文本通常指的是一串有意义的字符序列,这些字符可以是字母、数字、标点符号等。文本通常用于表示人类语言,例如,一篇文章、一本书、一封电子邮件等都可以被视为文本。
超文本(HyperText)是一种组织和共享信息的方式,它通过超链接(Hyperlink)将各种不同的信息(如文本、图片、音频、视频等)连接在一起,形成一个非线性、动态的信息系统。用户可以通过点击超链接在各种信息之间自由跳转,这种信息的组织方式大大提高了信息的可获取性和易用性。
HTTP协议是基于TCP/IP通信协议来传递数据(HTML 文件, 图片文件, 查询结果等)。HTTP协议工作于客户端-服务端架构上。浏览器作为HTTP客户端通过URL向HTTP服务端即网站服务器发送所有请求。
HTTP协议在网络传输过程中是明文的,这意味着如果网络中间人截取了HTTP传输的数据,就能直接看到内容,因此HTTP协议不适合传输一些敏感信息,如密码或者银行信息等。为了解决HTTP协议的这一缺点,HTTPS协议应运而生。
2. HTTP 协议的使用场景有哪些?
HTTP协议主要用于以下几种场景:
-
网页浏览:当我们在浏览器中输入一个URL并按下回车键时,浏览器会通过HTTP协议向服务器发送请求,服务器响应请求并返回HTML文件,浏览器解析HTML文件并显示网页内容。
-
API交互:许多Web应用程序使用HTTP协议作为其API的基础,客户端(可能是另一个Web应用程序、移动应用程序或其他类型的客户端)通过发送HTTP请求来获取数据或执行操作。
-
文件传输:虽然FTP协议更常用于文件传输,但HTTP协议也可以用于文件传输,特别是在Web环境中。
-
数据推送:通过WebSockets或Server-Sent Events(SSE),HTTP协议也可以用于服务器向客户端推送数据。
-
网络爬虫:网络爬虫通过HTTP协议获取网页内容,然后解析这些内容以提取信息。
-
内容分发网络(CDN):CDN通过HTTP协议将内容(如网页、图片、视频等)分发到全球各地的服务器,以便用户可以从最近的服务器获取内容,从而提高加载速度。
-
Web服务:许多Web服务,如云存储服务、在线办公服务等,都通过HTTP协议提供服务。
总的来说,HTTP协议是Web通信的基础,几乎所有的Web应用都会使用到HTTP协议。
3。 为什么 TCP 不行?
TCP(传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。它确保了数据包的正确传输,但并不关心数据的内容和格式。TCP只负责在两个网络节点之间建立稳定的连接,并确保数据包的顺序和完整性。
而HTTP(超文本传输协议)是一种应用层协议,它基于TCP,但在其之上添加了对请求和响应的处理,以及对数据格式的规定。HTTP定义了客户端和服务器之间交换数据的格式和方式,使得数据的传输更加高效和可控。
如果直接使用TCP进行网页浏览,会遇到以下问题:
-
缺乏统一的数据格式:TCP并不关心数据的内容和格式,如果直接使用TCP,那么每个应用可能都需要定义自己的数据格式和解析方式,这将大大增加开发的复杂性。
-
缺乏状态管理:HTTP协议定义了状态码和方法(如GET、POST等),使得服务器可以更好地理解和处理客户端的请求。而TCP并不具备这样的能力。
-
缺乏缓存和其他优化机制:HTTP协议定义了许多用于提高性能的机制,如缓存、内容压缩、分块传输等。如果直接使用TCP,那么这些优化机制都需要由应用自己来实现。
-
缺乏安全机制:HTTP协议还定义了一种安全的版本HTTPS,它使用SSL/TLS协议对数据进行加密,保护数据的安全性。而TCP并不具备这样的安全机制。
因此,虽然理论上可以直接使用TCP进行网页浏览,但实际上,由于上述的种种原因,我们通常使用HTTP协议进行网页浏览。
4. HTTP的发展历史
HTTP协议的发展历史可以分为以下几个阶段:
-
HTTP/0.9:这是HTTP协议的最初版本,于1991年发布。这个版本的HTTP非常简单,只支持GET方法,且没有头部信息,服务器只能返回纯文本格式的HTML页面。
-
HTTP/1.0:这个版本的HTTP于1996年发布,相比于HTTP/0.9,它增加了POST和HEAD方法,引入了HTTP头部信息,支持多种数据格式的返回,如HTML、图片、音频、视频等。
-
HTTP/1.1:这个版本的HTTP于1997年发布,是目前使用最广泛的HTTP版本。它增加了PUT、DELETE、OPTIONS等方法,引入了持久连接、分块传输编码、内容协商、Host头部等特性,大大提高了HTTP协议的性能和可用性。
-
HTTP/2:这个版本的HTTP于2015年发布,是对HTTP/1.1的重大改进。它引入了多路复用、服务器推送、头部压缩等特性,进一步提高了HTTP协议的性能。
-
HTTP/3:这个版本的HTTP正在开发中,它将使用QUIC协议替代TCP协议,以解决TCP协议在高延迟和丢包环境下的性能问题。
以上就是HTTP协议的发展历史,每个版本的HTTP都在前一个版本的基础上,引入了新的特性和改进,以满足网络应用的发展需求。
5. 描述 HTTP 的工作过程
HTTP协议的工作过程可以通过一个简单的例子来解释,例如,当你在浏览器中输入一个URL(例如,http://www.example.com)并按下回车键时,背后发生了什么?
-
浏览器首先会解析你输入的URL,确定你要访问的是哪个网站,以及具体的页面路径。在这个例子中,你要访问的网站是www.example.com。
-
浏览器会向DNS服务器发送一个请求,要求解析www.example.com的IP地址。DNS服务器会返回对应的IP地址。
-
浏览器会向这个IP地址发送一个HTTP GET请求。这个请求包含了一些信息,例如你的浏览器类型,你接受的语言等。
-
服务器收到这个HTTP请求后,会解析这个请求,确定你要获取的是哪个页面。然后,服务器会从硬盘中找到这个页面,然后返回一个HTTP响应。这个响应包含了页面的内容,以及一些元信息,例如页面的类型,编码方式等。
-
浏览器收到HTTP响应后,会解析这个响应,然后将页面的内容显示在浏览器中。
这就是一个简单的HTTP请求和响应的过程。在实际的应用中,HTTP协议还包含了更多的功能,例如POST请求,状态码,Cookie等。
6. HTTP 状态码有什么用?
为什么 HTTP 需要状态码?如果没有状态码会出现什么问题?
简单来说就是有了一个反馈,HTTP状态码是服务器对客户端请求的响应结果的一种标识,它告诉客户端请求的处理结果是成功、失败还是需要进一步操作。状态码的存在使得客户端能够根据不同的状态码采取不同的操作,提高了通信的效率。
如果没有状态码,客户端将无法知道请求的处理结果,也就无法根据处理结果采取相应的操作。例如,如果客户端发送了一个获取资源的请求,但是服务器返回的响应中没有状态码,那么客户端无法判断资源是否获取成功,如果获取失败,又是由于什么原因失败的,这将大大降低通信的效率。
此外,没有状态码,错误处理也会变得困难。例如,如果请求的资源不存在,正常情况下服务器会返回404状态码,客户端收到404状态码后就知道资源不存在,可以给用户显示一个错误页面。但是如果没有状态码,客户端就无法知道请求失败的原因,也就无法进行正确的错误处理。
因此,HTTP状态码对于HTTP协议的正常运行是非常重要的。
HTTP状态码是由3位数字组成的,用于表示请求的处理状态。它们分为五大类:
-
1xx(信息响应):表示接收到请求,需要继续处理。这类状态码比较少见,一般用于异步操作。
- 100 Continue:客户端应当继续发送请求。这个临时响应是用来通知客户端它的部分请求已经被服务器接收,且仍未被拒绝。
-
2xx(成功):表示请求已被成功接收、理解、并接受。
- 200 OK:请求成功。请求所希望的响应头或数据体将随此响应返回。
-
3xx(重定向):需要后续操作才能完成这一请求。
- 301 Moved Permanently:被请求的资源已永久移动到新位置,并且将来任何对此资源的引用都应该使用本响应返回的若干个 URI 之一。
-
4xx(请求错误):请求含有词法错误或者无法被执行。
- 404 Not Found:请求失败,请求所希望得到的资源未被在服务器上发现。
-
5xx(服务器错误):服务器在处理某个正确请求时发生错误。
7. HTTP GET 和 POST 作用?
GET和POST是HTTP协议中两种常见的请求方法,它们的工作细节如下:
GET请求是最常见的请求方法,通常用于获取资源。GET请求的参数会附加在URL之后,通过问号(?)分隔,参数之间用&符号连接。例如,http://www.example.com/index.html?name=John&age=22
。这种方式的缺点是传输数据的大小有限制(因为浏览器对URL的长度有限制),并且不适合传输敏感信息(如密码),因为参数会直接暴露在URL中。
POST请求通常用于提交数据。POST请求将参数放在HTTP请求的主体中,而不是URL中。POST请求没有对传输数据的大小进行限制,而且可以传输任何类型的数据,包括二进制数据。因此,POST请求通常用于提交表单数据。
下面是一个GET请求和POST请求的例子:
GET请求示例:
GET /index.html?name=John&age=22 HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
POST请求示例:
POST /submit_form.php HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 13
name=John&age=22
在GET请求示例中,参数name=John&age=22
附加在URL之后。在POST请求示例中,参数name=John&age=22
放在HTTP请求的主体中。
需要注意的是,虽然POST请求在传输大量或敏感数据时更安全,但无论是GET还是POST,都不能提供真正的安全性。为了保护数据的安全,应该使用HTTPS协议,它可以对传输的数据进行加密。
8. HTTP 和 HTTPS 的区别?
HTTPS和HTTP的主要区别在于安全性和数据传输方式。
-
安全性:HTTP是明文传输,数据在传输过程中可能被窃取或篡改。而HTTPS则是在HTTP和TCP之间添加了一个安全层(SSL或TLS),用于对数据进行加密,防止数据在传输过程中被窃取或篡改。因此,HTTPS比HTTP更安全。
-
数据传输方式:HTTP使用TCP作为传输层协议,而HTTPS则使用SSL/TLS协议进行加密传输。SSL/TLS不仅提供了数据加密,还提供了数据完整性检查和身份验证,这可以防止中间人攻击。
-
端口:HTTP和HTTPS使用的端口也不同,HTTP默认使用80端口,而HTTPS默认使用443端口。
-
性能:由于HTTPS需要进行数据加密,因此在处理速度和数据传输速度上,HTTPS会比HTTP慢一些。但是,随着技术的发展,这种差距已经越来越小。
-
URL显示:在浏览器的地址栏中,HTTPS的URL会显示一个锁的图标,表示连接是安全的。而HTTP的URL则没有这个图标。
总的来说,HTTPS提供了比HTTP更高的安全性,但是需要消耗更多的资源。在处理敏感信息(如密码、信用卡号等)时,应该优先使用HTTPS。
HTTP 协议,全称为超文本传输协议,是用于从万维网(WWW)服务器传输超文本到本地浏览器的传送协议。
发展历程总揽
以下是 HTTP 协议的主要发展历程:
-
HTTP/0.9:这是 HTTP 协议的最初版本,于 1991 年发布。它是一个非常简单的协议,只支持 GET 方法,且没有头部信息,只能处理文本。
-
HTTP/1.0:这个版本于 1996 年发布,相比于 HTTP/0.9,它增加了许多新的功能,如 POST 和 HEAD 方法,以及 HTTP 头部信息。此外,HTTP/1.0 还引入了状态码,用于表示请求的处理结果。
-
HTTP/1.1:这个版本于 1997 年发布,是目前最广泛使用的 HTTP 版本。HTTP/1.1 对 HTTP/1.0 进行了许多改进,如持久连接、管道传输、分块传输编码等。此外,HTTP/1.1 还增加了 PUT、DELETE、OPTIONS、TRACE 等新的方法。
-
HTTP/2:这个版本于 2015 年发布,主要目标是提高性能。HTTP/2 引入了多路复用、服务器推送、头部压缩等新的功能,以减少延迟并提高页面加载速度。
-
HTTP/3:这个版本目前还在开发中,但已经有一些浏览器和服务器开始支持。HTTP/3 的主要改进是替换了底层的 TCP 协议,改用 QUIC 协议,以进一步提高性能。
以上就是 HTTP 协议的主要发展历程,从最初的简单文本传输,到现在的高性能、多功能的网络协议,HTTP 协议的发展反映了互联网的快速进步。
HTTP/0.9 的问题
HTTP/0.9 是 HTTP 协议的最初版本,存在以下主要问题:
-
功能有限:HTTP/0.9 只支持 GET 方法,无法处理除文本之外的其他类型的数据,这限制了其应用范围。
-
无状态:HTTP/0.9 是无状态的,服务器无法跟踪用户的请求历史,这使得无法实现像购物车这样的功能。
-
无头部信息:HTTP/0.9 没有头部信息,无法传递如内容类型、编码、语言等重要信息。
HTTP/1.0 是对 HTTP/0.9 的重大改进,解决了上述问题:
-
增加新的方法:HTTP/1.0 引入了 POST 和 HEAD 方法,使得协议能够处理更多种类的请求。
-
引入头部信息:HTTP/1.0 引入了 HTTP 头部信息,可以传递如内容类型、编码、语言等重要信息。
-
引入状态码:HTTP/1.0 引入了状态码,用于表示请求的处理结果,使得客户端可以更好地处理不同的服务器响应。
总的来说,HTTP/1.0 相比于 HTTP/0.9,功能更加丰富,可以处理更多种类的数据,提供了更好的错误处理机制,使得 HTTP 协议的应用范围大大扩展。
HTTP/1.0 的问题
HTTP/1.0 是 HTTP 协议的早期版本,存在以下主要问题:
-
无法复用连接:HTTP/1.0 默认每次请求都需要建立新的 TCP 连接,完成后立即关闭,这导致了大量的 TCP 连接建立和关闭,消耗了大量的资源和时间。
-
无法处理动态内容:HTTP/1.0 主要设计用于处理静态网页,对于动态内容的处理能力有限。
-
无法有效缓存:HTTP/1.0 的缓存控制能力有限,无法有效地控制和管理缓存内容。
HTTP/1.1 是对 HTTP/1.0 的重大改进,解决了上述问题:
-
持久连接:HTTP/1.1 默认启用了持久连接,即在一个 TCP 连接上可以发送多个 HTTP 请求和响应,大大减少了 TCP 连接的建立和关闭所消耗的时间和资源。
-
引入了新的方法:HTTP/1.1 引入了 PUT、DELETE、OPTIONS、TRACE 等新的方法,使得协议能够处理更多种类的请求,包括动态内容的处理。
-
更强大的缓存控制:HTTP/1.1 引入了一系列的缓存控制机制,如 ETag、If-Modified-Since 等,使得客户端和服务器可以更有效地控制和管理缓存内容。
总的来说,HTTP/1.1 相比于 HTTP/1.0,功能更加丰富,性能更优,可以处理更多种类的数据,提供了更好的错误处理和缓存控制机制,使得 HTTP 协议的应用范围大大扩展。
HTTP/1.1 的问题
HTTP/1.1 是 HTTP 协议的一个重要版本,但它仍然存在一些问题:
-
队头阻塞:由于 HTTP/1.1 在一个 TCP 连接上按顺序发送请求和响应,如果前面的请求处理时间较长,后面的请求就会被阻塞,这就是所谓的队头阻塞问题。
-
冗余头部信息:HTTP/1.1 的每个请求和响应都会携带完整的头部信息,这在多次请求中会产生大量的冗余数据。
-
无法并行处理请求:虽然 HTTP/1.1 支持管道化请求,但由于队头阻塞问题,实际上无法并行处理多个请求。
HTTP/2 是对 HTTP/1.1 的重大改进,解决了上述问题:
-
多路复用:HTTP/2 引入了多路复用技术,可以在一个 TCP 连接上并行发送和接收多个请求和响应,从而解决了队头阻塞问题。
-
头部压缩:HTTP/2 引入了 HPACK 压缩算法,可以有效地压缩头部信息,减少冗余数据。
-
服务器推送:HTTP/2 引入了服务器推送技术,服务器可以主动向客户端推送资源,而不需要客户端显式请求,从而进一步提高了页面加载速度。
总的来说,HTTP/2 相比于 HTTP/1.1,性能更优,可以处理更多并行的请求,提供了更好的头部压缩和服务器推送机制,使得 HTTP 协议的应用范围大大扩展。
HTTP/2 的问题
HTTP/2 是 HTTP 协议的一个重要版本,但它仍然存在一些问题:
-
TCP 阻塞:HTTP/2 依然基于 TCP 协议,当网络条件恶劣时,TCP 的丢包重传机制可能会导致整个连接被阻塞,影响性能。
-
头部压缩的复杂性:HTTP/2 使用 HPACK 算法进行头部压缩,虽然有效减少了头部大小,但这增加了实现的复杂性。
-
无法利用多路径:HTTP/2 无法利用现代设备的多网络接口,例如,无法同时使用 Wi-Fi 和蜂窝网络。
HTTP/3 是对 HTTP/2 的重大改进,解决了上述问题:
-
使用 QUIC 协议:HTTP/3 使用 QUIC 协议替代 TCP,QUIC 协议基于 UDP,具有更好的丢包恢复机制,可以避免整个连接被阻塞。
-
简化头部压缩:HTTP/3 使用 QPACK 算法进行头部压缩,相比 HPACK,QPACK 算法更简单,更易于实现。
-
支持多路径:QUIC 协议支持多路径传输,可以同时利用设备的多个网络接口,提高传输效率。
总的来说,HTTP/3 相比于 HTTP/2,性能更优,实现更简单,能更好地适应现代网络环境。
这篇文章讲解 HTTP 为什么设计为无状态,以及讲解如何在此基础上增加状态。随后引入并讲解了 Cookie 和 Session,讲解了二者区别是什么,分布式 Session 如何处理等问题。
为什么 HTTP 是无状态的?
HTTP 协议被设计为无状态的,主要是为了简化服务器的设计并提高其性能。在 HTTP 协议中,每个请求都是独立的,服务器不需要记住之前的请求。这样,服务器就可以同时处理大量的请求,而不需要为每个用户维护一个持久的连接状态。
让我们来看一个具体的使用场景:假设你正在使用一个在线购物网站。每当你查看一个商品或者添加一个商品到购物车时,你的浏览器都会向服务器发送一个 HTTP 请求。如果 HTTP 是有状态的,那么服务器就需要为每个用户维护一个连接状态,记录他们的购物车内容。这将需要大量的内存和处理器资源,尤其是在有大量用户的情况下。
然而,由于 HTTP 是无状态的,服务器不需要记住用户的购物车内容。相反,这些信息通常会被存储在用户的浏览器中(例如,使用 cookies)。当用户决定结账时,他们的购物车内容会作为 HTTP 请求的一部分发送给服务器。这样,服务器只需要处理当前的请求,而不需要记住之前的请求。
总的来说,HTTP 的无状态设计使得服务器可以更简单、更高效地处理大量的并发请求。然而,这也意味着开发者需要找到其他的方法(例如,使用 cookies 或者 session)来跟踪用户的状态。
HTTP 设计为无状态有哪些好处?
HTTP 协议设计为无状态的,主要有以下几个好处:
-
简化服务器设计:由于服务器不需要为每个用户维护一个持久的连接状态,因此服务器的设计可以更简单。
-
提高服务器性能:服务器可以同时处理大量的请求,而不需要为每个用户维护一个持久的连接状态,这可以大大提高服务器的性能。
-
提高可扩展性:由于服务器不需要为每个用户维护一个持久的连接状态,因此可以更容易地添加更多的服务器来处理更多的请求。
让我们来看一个具体的例子:假设你正在使用一个在线购物网站。每当你查看一个商品或者添加一个商品到购物车时,你的浏览器都会向服务器发送一个 HTTP 请求。如果 HTTP 是有状态的,那么服务器就需要为每个用户维护一个连接状态,记录他们的购物车内容。这将需要大量的内存和处理器资源,尤其是在有大量用户的情况下。
然而,由于 HTTP 是无状态的,服务器不需要记住用户的购物车内容。相反,这些信息通常会被存储在用户的浏览器中(例如,使用 cookies)。当用户决定结账时,他们的购物车内容会作为 HTTP 请求的一部分发送给服务器。这样,服务器只需要处理当前的请求,而不需要记住之前的请求。
总的来说,HTTP 的无状态设计使得服务器可以更简单、更高效地处理大量的并发请求。然而,这也意味着开发者需要找到其他的方法(例如,使用 cookies 或者 session)来跟踪用户的状态。
Cookie 是什么?
HTTP 协议是无状态的,这意味着服务器默认情况下不会保存用户的任何信息。然而,在实际的 Web 应用中,我们经常需要跟踪用户的状态,例如用户的登录状态、购物车内容等。这就需要使用到 Cookie。
Cookie 是服务器发送给用户浏览器并保存在浏览器中的一小段数据,它可以包含各种用户的状态信息。当浏览器再次向服务器发送请求时,它会自动将这些 Cookie 一起发送给服务器。服务器可以通过读取这些 Cookie 来恢复用户的状态。
让我们来看一个具体的使用场景:假设你正在使用一个在线购物网站。当你首次访问这个网站时,服务器会创建一个新的 Session,并将 Session ID 保存在一个 Cookie 中发送给你的浏览器。你的浏览器会保存这个 Cookie,并在之后的每个请求中都将它发送给服务器。
当你添加一个商品到购物车时,服务器会更新你的 Session,记录你的购物车内容。然后,当你决定结账时,服务器可以通过读取你的 Session ID,找到对应的 Session,从而恢复你的购物车内容。
总的来说,HTTP 通过使用 Cookie,可以在无状态的协议上实现状态的维护。这使得 Web 应用可以提供丰富的、个性化的用户体验,同时保持了 HTTP 协议的简单和高效。
Cookie 的发展历史
Cookie 的发展历史可以追溯到1994年,当时的网景通讯公司为了解决无状态的HTTP协议无法进行会话跟踪的问题,引入了这种可以在客户端存储数据的方法。以下是 Cookie 的发展历史的一些关键点:
-
1994年:网景通讯公司的工程师 Lou Montulli 提出了 Cookie 的概念,并在 Netscape Navigator 浏览器中实现了第一个版本的 Cookie。
-
1995年:Microsoft 在 Internet Explorer 浏览器中也开始支持 Cookie。
-
1997年:互联网工程任务组(IETF)发布了第一个 Cookie 的规范 RFC 2109。
-
2000年:IETF 发布了新的 Cookie 规范 RFC 2965,取代了 RFC 2109。
-
2009年:随着 Web 2.0 的兴起,Cookie 开始被广泛用于个性化网站内容、跟踪用户行为等。
-
2011年:由于隐私问题,欧盟通过了一项法律,要求网站在使用 Cookie 时必须获得用户的同意。
-
近年:随着现代浏览器开始支持各种各样的存储方式,例如 Web storage API(本地存储和会话存储)或 IndexedDB,Cookie 的使用逐渐减少。尽管如此,Cookie 仍然在某些场景下被广泛使用,例如用于存储用户的登录状态。
以上就是 Cookie 的发展历史的概述。
Session
Session 是服务器端用来保存用户状态的一种技术。当用户首次访问一个网站时,服务器会创建一个新的 Session,并生成一个唯一的 Session ID。这个 Session ID 会被存储在用户的 Cookie 中,然后发送给用户的浏览器。当用户再次访问网站时,浏览器会将这个 Session ID 发送回服务器,服务器就可以通过这个 Session ID 找到对应的 Session,从而恢复用户的状态。
让我们来看一个具体的使用场景:假设你正在使用一个在线购物网站。当你首次访问这个网站时,服务器会创建一个新的 Session,并将 Session ID 保存在一个 Cookie 中发送给你的浏览器。你的浏览器会保存这个 Cookie,并在之后的每个请求中都将它发送给服务器。
当你添加一个商品到购物车时,服务器会更新你的 Session,记录你的购物车内容。然后,当你决定结账时,服务器可以通过读取你的 Session ID,找到对应的 Session,从而恢复你的购物车内容。
总的来说,HTTP 通过使用 Session,可以在无状态的协议上实现状态的维护。这使得 Web 应用可以提供丰富的、个性化的用户体验,同时保持了 HTTP 协议的简单和高效。
Session 的发展历史
Session 的发展历史与 Web 的发展密切相关。以下是 Session 的发展历史的一些关键点:
-
1990年代初:随着 Web 的兴起,HTTP 协议被广泛使用。然而,HTTP 是无状态的,这意味着服务器无法跟踪用户的活动。这在某些情况下是一个问题,例如,当用户在网站上进行购物物车操作时,服务器需要知道这些操作是由同一用户进行的。
-
1994年:为了解决这个问题,网景通讯公司引入了 Cookie。服务器可以将一些数据(例如 Session ID)存储在 Cookie 中,然后将 Cookie 发送给用户的浏览器。当浏览器再次发送请求时,它会将 Cookie 一起发送,这样服务器就可以识别用户。
-
1995年:随着 Java 语言的发布,Java Servlet API 提供了对 Session 的支持。这使得开发者可以更容易地在 Web 应用程序中使用 Session。
-
2000年代初:随着 Web 应用程序的复杂性增加,开发者开始寻找更强大的 Session 管理工具。许多 Web 开发框架(例如 PHP、ASP.NET 和 Ruby on Rails)都提供了对 Session 的内置支持。
-
2000年代早期至今:随着云计算和分布式系统的兴起,Session 管理变得更加复杂。在这种环境下,服务器可能需要在多个服务器之间共享 Session 数据。为了解决这个问题,开发者开始使用各种 Session 存储解决方案,例如 Memcached 和 Redis。
以上就是 Session 的发展历史的概述。
Cookie 和 Session 的区别
Cookie 和 Session 都是用来跟踪用户状态的技术,但它们在使用方式和存储位置上有所不同。
-
存储位置:Cookie 是存储在客户端(即用户的浏览器)的数据,而 Session 是存储在服务器端的数据。
-
生命周期:Cookie 的生命周期由服务器和浏览器共同决定,可以是短暂的(如浏览器关闭时消失),也可以是持久的(如设置了过期时间)。而 Session 的生命周期通常是用户开始访问网站到用户关闭浏览器这段时间。
-
安全性:由于 Cookie 是存储在客户端,因此更容易被篡改或者窃取。而 Session 是存储在服务器端,相对来说更安全。
让我们来看一个具体的例子:假设你正在使用一个在线购物网站。
当你首次访问这个网站时,服务器会创建一个新的 Session,并生成一个唯一的 Session ID。这个 Session ID 会被存储在一个 Cookie 中,然后发送给你的浏览器。这个 Cookie(包含 Session ID)就保存在你的浏览器中。
当你添加一个商品到购物车时,服务器会更新你的 Session,记录你的购物车内容。然后,当你决定结账时,你的浏览器会将包含 Session ID 的 Cookie 发送回服务器,服务器就可以通过这个 Session ID 找到对应的 Session,从而恢复你的购物车内容。
在这个例子中,Cookie 和 Session 都被用来维护用户的状态,但它们的使用方式和存储位置有所不同。
Cookie 和 Session 的字段组成及其作用?
Cookie 和 Session 的字段组成如下:
Cookie:
- Name:Cookie 的名称,例如 "SessionID"。
- Value:Cookie 的值,例如一个 Session ID。
- Domain:发出 Cookie 的网站的域名。
- Path:Cookie 的作用路径,通常为 "/",表示这个 Cookie 对整个网站都有效。
- Expires/Max-Age:Cookie 的过期时间或最大生存时间,通常设置为较长的时间,以确保 Cookie 不会在用户的浏览器中过早地过期。
- Secure:如果网站通过 HTTPS 提供服务,那么这个字段通常会被设置,以确保 Cookie 只能通过安全的连接发送。
- HttpOnly:这个字段通常会被设置,以防止 JavaScript 代码访问这个 Cookie,从而提高安全性。
Session:
- Session ID:一个唯一的标识符,用于在服务器端找到对应的 Session。
- User Data:存储在 Session 中的用户数据,例如用户的登录状态、购物车内容等。
一个具体的例子:用户登陆
在用户登录的场景中,Cookie 和 Session 的交互通常如下:
-
用户首次访问网站,浏览器会发送一个 HTTP 请求到服务器。这个请求通常不包含任何 Session 信息,因为用户还没有登录。
-
服务器接收到这个请求后,会检查请求中是否包含 Session 信息。在这个场景中,因为用户还没有登录,所以请求中不包含 Session 信息。
-
服务器发现请求中没有 Session 信息后,会创建一个新的 Session。这个 Session 通常会包含一些默认的状态信息,例如用户的登录状态(未登录)。
-
服务器会为这个新创建的 Session 生成一个唯一的 Session ID。这个 Session ID 是服务器用来识别和跟踪 Session 的标识符。
-
服务器会将这个 Session ID 存储在一个 Cookie 中,然后将这个 Cookie 发送给用户的浏览器。这样,浏览器在后续的请求中就可以携带这个 Cookie,服务器就可以通过 Cookie 中的 Session ID 来找到对应的 Session。这个 Cookie 的字段可能如下:
-
Name:通常为 "SessionID" 或类似的名称。
-
Value:这就是前面提到的 Session ID,它是一个唯一的标识符,用于在服务器端找到对应的 Session。
-
Domain:通常为发出 Cookie 的网站的域名。
-
Path:通常为 "/",表示这个 Cookie 对整个网站都有效。
-
Expires/Max-Age:这个字段的值通常较长,以确保 Cookie 不会在用户的浏览器中过早地过期。
-
Secure:如果网站通过 HTTPS 提供服务,那么这个字段通常会被设置,以确保 Cookie 只能通过安全的连接发送。
-
HttpOnly:这个字段通常会被设置,以防止 JavaScript 代码访问这个 Cookie,从而提高安全性。
-
-
当用户在浏览器中输入他们的用户名和密码并点击登录按钮时,浏览器会将这个请求连同 Cookie 一起发送给服务器。
-
服务器会检查用户名和密码是否正确,如果正确,服务器会在对应的 Session 中标记用户为已登录。
-
当用户再次访问网站时,浏览器会自动将 Cookie 发送给服务器。服务器会读取 Cookie 中的 Session ID,并找到对应的 Session。如果这个 Session 标记为已登录,那么服务器就知道这个用户已经登录。
通过这种方式,服务器可以在无状态的 HTTP 协议上实现状态的维护,例如用户的登录状态。
Cookie 和 Session 什么时候被删除?
在用户登录的场景下,Cookie 和 Session 的删除通常在以下情况下发生:
-
用户注销:当用户点击注销按钮时,服务器通常会删除对应的 Session,并将一个新的、无效的 Session ID 存储在 Cookie 中,然后将这个 Cookie 发送给用户的浏览器。这样,旧的 Session ID 就被覆盖了,旧的 Session 也就无法再被访问。
-
Cookie 过期:如果 Cookie 的 Expires/Max-Age 字段被设置了一个具体的时间,那么当这个时间到达后,浏览器会自动删除这个 Cookie。服务器在接收到后续的请求时,如果没有收到包含 Session ID 的 Cookie,那么服务器通常会创建一个新的 Session。
-
Session 过期:服务器通常会为每个 Session 设置一个过期时间。如果一个 Session 长时间没有被访问(例如,用户长时间没有发送新的请求),那么服务器可能会删除这个 Session 以节省资源。当服务器接收到后续的请求时,如果服务器找不到对应的 Session,那么服务器通常会创建一个新的 Session。
-
用户清除浏览器数据:用户可以在浏览器的设置中手动清除浏览器数据,包括 Cookie。如果用户清除了 Cookie,那么浏览器在发送后续的请求时就不会再携带 Cookie,服务器在接收到这样的请求后通常会创建一个新的 Session。
以上就是在用户登录的场景下,Cookie 和 Session 何时被删除的情况。
如果浏览器禁用 Cookie 的话怎么办?
如果浏览器禁用了 Cookie,那么服务器将无法通过 Cookie 来跟踪用户的状态。这可能会导致一些问题,例如:
- 用户无法保持登录状态:因为服务器通常会将 Session ID 存储在 Cookie 中,如果浏览器禁用了 Cookie,那么服务器就无法识别用户,从而无法保持用户的登录状态。
- 网站的个性化设置可能无法保存:许多网站会使用 Cookie 来保存用户的个性化设置,例如语言选择、主题颜色等。如果浏览器禁用了 Cookie,那么这些设置可能无法保存。
在以下场景下,用户可能会禁用 Cookie:
- 隐私考虑:Cookie 可能会被用于跟踪用户的在线行为。一些用户出于对隐私的考虑,可能会选择禁用 Cookie。
- 安全考虑:虽然 Cookie 本身是安全的,但如果被恶意使用,可能会带来安全风险。例如,攻击者可能会通过 Cookie 来进行跨站脚本攻击(XSS)或跨站请求伪造攻击(CSRF)。因此,一些用户可能会选择禁用 Cookie。
如果用户禁用了 Cookie,开发者可以考虑使用以下方法来维护用户的状态:
- URL 重写:将 Session ID 直接附加到每个 URL 的末尾,这样服务器就可以从 URL 中获取 Session ID。但这种方法可能会带来安全问题,因为 Session ID 可能会被泄露。
- 隐藏表单字段:在每个表单中添加一个隐藏字段,用于存储 Session ID。当用户提交表单时,服务器就可以从表单数据中获取 Session ID。
- 使用 Local Storage:如果网站是单页应用(SPA),可以考虑使用浏览器的 Local Storage 来存储用户的状态。但需要注意的是,Local Storage 也可能被用户禁用。
Session 安全性
Session 的安全性设计主要包括以下几个方面:
-
Session ID 的生成:Session ID 应该是随机和唯一的,以防止攻击者通过猜测 Session ID 来获取用户的 Session。许多 Web 开发框架都提供了生成随机 Session ID 的功能。
-
Session ID 的传输:Session ID 通常在 Cookie 中传输,因此,应该使用 HTTPS 来保护 Cookie 的传输,防止 Session ID 在传输过程中被截获。
-
Session 的生命周期管理:Session 不应该永久有效,而应该有一个合理的超时时间。当用户登出或者超过一定时间没有活动后,应该销毁 Session。
-
Session 的存储:Session 数据通常存储在服务器端,因此,需要保护好服务器,防止攻击者直接获取 Session 数据。如果使用了分布式 Session 存储(例如 Memcached 或 Redis),那么这些存储系统也需要保护好。
-
防止 Session 劫持:可以通过一些手段来防止 Session 劫持,例如,可以绑定 Session 和 IP 地址,只有来自同一个 IP 地址的请求才能使用同一个 Session。但是,这种方法可能会导致一些问题,例如,如果用户的 IP 地址改变了(例如,用户从家里的 WiFi 切换到了移动网络),那么用户可能会失去 Session。
-
防止跨站请求伪造(CSRF):可以通过一些手段来防止 CSRF 攻击,例如,可以在每个表单中添加一个隐藏的 CSRF 令牌,服务器在处理表单提交时会检查 CSRF 令牌。
以上就是 Session 的安全性设计的一些基本原则。具体的实现可能会根据 Web 开发框架和应用的需求有所不同。
分布式 Session
分布式 Session 是一种在分布式系统中维护用户会话状态的技术。在单体应用中,用户的 Session 信息通常存储在单个服务器的内存中。然而,在分布式系统中,由于请求可能被路由到任何一个服务器,因此需要一种机制来在所有服务器之间共享 Session 信息。这就是分布式 Session 的主要作用。
让我们来看一个具体的例子:假设你正在使用一个大型的在线购物网站,这个网站使用了多个服务器来处理用户的请求。当你登录并添加一些商品到购物车时,这些信息会被保存在你当前连接的服务器的 Session 中。然后,如果你的下一个请求被路由到了另一个服务器,那么这个服务器需要能够访问到你的 Session 信息,以便恢复你的购物车内容。
分布式 Session 面临的主要问题包括:
-
数据一致性:在分布式系统中,保持所有服务器上的 Session 数据的一致性是一个挑战。例如,如果用户在一个服务器上更新了他们的 Session,那么这个更新需要被快速地同步到所有其他的服务器。
-
性能:在所有服务器之间共享 Session 数据可能会导致性能问题。例如,如果每个请求都需要从一个中心化的 Session 存储中读取数据,那么这可能会成为一个性能瓶颈。
-
可扩展性:随着系统的扩展,需要处理的 Session 数据量也会增加。设计一个可以处理大量 Session 数据的分布式 Session 系统是一个挑战。
解决这些问题的方法包括:
-
使用分布式缓存:例如,可以使用 Redis 或 Memcached 这样的分布式缓存来存储 Session 数据。这些系统提供了高性能的数据访问,并且可以在多个服务器之间共享数据。
-
使用数据库:可以使用数据库来存储 Session 数据。这可以提供持久性和一致性,但可能会牺牲一些性能。
-
使用 Session 复制:在这种方法中,每个服务器都会保存所有的 Session 数据的副本。当一个 Session 被更新时,这个更新会被复制到所有其他的服务器。这可以提供高性能和一致性,但需要更多的内存。
-
使用粘性 Session:在这种方法中,一旦一个用户的 Session 被创建在一个服务器上,那么该用户的所有后续请求都会被路由到同一个服务器。这可以避免在服务器之间共享 Session 数据,但可能会限制系统的可扩展性。例如使用 Nginx 的 ip_hash 策略,确保来自同一客户端 IP 地址的所有请求都被路由到同一台后端服务器。
JWT Token 来处理分布式 Session
JWT(JSON Web Token)的设计使其非常适合于在多台服务器之间共享。这是因为 JWT 是自包含的,它包含了所有必要的信息,无需额外的外部存储。这使得 JWT 可以在任何服务器上验证,只要这些服务器都知道用于签名 JWT 的密钥。
在分布式系统中,JWT 可以用于处理 Session,其工作原理如下:
-
用户登录:用户向服务器发送登录请求,包含其凭证(如用户名和密码)。
-
生成 JWT:服务器验证用户的凭证。如果凭证有效,服务器会生成一个 JWT,其中包含用户的标识信息(如用户 ID),并将其签名。
-
发送 JWT:服务器将生成的 JWT 发送回用户。用户将此 JWT 存储在本地,例如在 Cookie 或 Local Storage 中。
-
用户请求:当用户向服务器发送请求时,会在请求中包含此 JWT,通常是在 Authorization 头中。
-
验证 JWT:服务器收到请求后,会验证 JWT 的签名。如果签名有效,服务器就知道这是一个有效用户,并处理其请求。
-
刷新 JWT:JWT 通常有一个过期时间。当 JWT 过期时,用户需要重新登录以获取新的 JWT。或者,服务器可以提供一个刷新令牌机制,允许用户在不重新登录的情况下获取新的 JWT。
使用 JWT 处理 Session 的优点是,服务器不需要存储 Session 数据,这在分布式系统中非常有用,因为它消除了在多个服务器之间共享 Session 数据的需要。此外,JWT 也可以跨域使用,这在微服务架构中非常有用。
然而,使用 JWT 也有一些缺点。例如,一旦 JWT 被颁发,就无法从服务器端撤销,除非服务器存储已颁发的 JWT 列表,并在每个请求中检查 JWT 是否在此列表中。此外,JWT 通常比 Session ID 大,因此在每个请求中发送 JWT 可能会增加网络负载。
传统 C++
这篇文章主要讲述了汇编语言、B语言、C语言和C++语言的历史、特性、应用领域以及它们的优缺点。
汇编语言
在汇编语言出现之前,人们主要使用机器语言进行编程。机器语言是一种低级语言,直接由计算机硬件执行,它由二进制代码组成,对人类来说非常难以理解和编写。
例如,一个简单的机器语言指令可能看起来像这样:
10110000 01100001
这个指令可能代表将数字97(01100001)加载到寄存器中。但是,对于人类来说,这样的代码非常难以理解和编写。
汇编语言是一种稍微高级一点的语言,它使用了一些人类可读的符号来代替机器语言的二进制代码。例如,上面的机器语言指令在汇编语言中可能看起来像这样:
MOV AL, 61h
这个指令的意思是将十六进制数61(等于十进制的97)加载到寄存器AL中。相比于机器语言,汇编语言更容易理解和编写。
然而,尽管汇编语言比机器语言更易于理解和编写,但它仍然存在一些问题:
- 编程效率低:汇编语言的指令非常简单,因此完成复杂任务需要大量的代码。这使得编程效率非常低。
- 可读性差:尽管汇编语言使用了一些人类可读的符号,但它仍然非常难以理解。除非你非常熟悉汇编语言,否则你可能很难理解一个汇编程序的功能。
- 可移植性差:汇编语言是一种低级语言,它直接与特定的硬件架构相关。这意味着一个为特定硬件编写的汇编程序可能无法在其他硬件上运行。
- 缺乏高级语言的特性:汇编语言缺乏高级语言的许多特性,如函数、对象和异常处理等。这使得用汇编语言编程更加困难。
B 语言
1969年,Dennis M. Ritchie和Ken Thompson在AT&T的贝尔实验室开始开发一个新的操作系统,这个操作系统被命名为UNIX。这个操作系统的目标是能够供一千个用户同时使用,这在当时是一项非常雄心勃勃的目标。
在UNIX的早期版本中,整个系统都是用汇编语言编写的。汇编语言是一种非常低级的编程语言,它直接操作硬件,没有任何抽象层。这使得编写和维护代码非常困难,因为程序员需要对硬件有深入的理解,并且需要手动管理所有的内存和资源。
为了提高开发效率,UNIX系统中引入了Fortran和B语言的解释器。Fortran是一种早期的高级编程语言,主要用于科学计算。B语言是一种更通用的高级编程语言,它是C语言的直接前身。
B语言的引入使得UNIX的开发变得更加高效。使用B语言,程序员可以用几行代码完成以前需要写很多汇编代码才能完成的任务。然而,B语言也有一些限制。它不支持数据类型,所有的值都是机器字(machine word)。这意味着程序员需要手动管理内存,并且不能利用编译器进行类型检查。此外,B语言也不支持结构(structure),这是一种可以将多个相关的值组合在一起的数据结构。"机器字"是指计算机硬件一次操作的数据的位数。例如,如果你的计算机是32位的,那么它的机器字就是32位。这意味着计算机一次可以操作32位的数据。
在B语言中,所有的值都是机器字,这意味着所有的数据都是相同的大小,并且没有数据类型。例如,你不能在B语言中声明一个整数或一个浮点数,你只能声明一个机器字。这个机器字可以代表任何类型的数据,取决于你如何使用它。
例如,你可能有一个机器字,你可以将它视为一个整数,如下所示:
x = 1234;
你也可以将同一个机器字视为一个布尔值,如下所示:
x = true;
在这两个例子中,x
都是一个机器字,但是它代表的数据类型(整数或布尔值)完全取决于你如何使用它。这就是B语言中没有数据类型的含义。
C 语言
C语言的出现解决了B语言的一些限制。C语言在B语言的基础上添加了数据类型和结构,使得代码更加易于理解和维护。C语言的开发工作在1970年代进行,1988年,他们提供了最终的标准定义ANSI C。
在C语言中,我们可以声明不同的数据类型,如整数(int)、浮点数(float)、字符(char)等。这使得程序员可以更清楚地知道每个变量的类型,从而更好地管理内存和资源。例如,我们可以在C语言中声明一个整数和一个浮点数,如下所示:
int a = 10;
float b = 20.5;
在这个例子中,a
是一个整数,b
是一个浮点数。编译器会检查我们是否正确地使用了这些变量。例如,如果我们试图将一个字符串赋值给a
,编译器会报错,因为a
是一个整数,不能接受字符串。
此外,C语言还引入了结构(structure),这是一种可以将多个相关的值组合在一起的数据结构。例如,我们可以定义一个名为Person
的结构,它包含一个字符串(用于存储姓名)和一个整数(用于存储年龄):
struct Person {
char name[50];
int age;
};
然后,我们可以创建一个Person
结构的实例,并给它的字段赋值:
struct Person p;
strcpy(p.name, "Alice");
p.age = 20;
C语言的应用领域非常广泛,其中包括操作系统、嵌入式系统、电脑游戏等。C语言的强大功能和灵活性使其成为了许多重要软件项目的首选语言。C语言的出现也推动了计算机科学的发展,许多现代的编程语言,如C++、Java和Python,都受到了C语言的影响。
UNIX操作系统就是一个很好的例子。UNIX是由AT&T的贝尔实验室在20世纪70年代开发的。在最初的版本中,UNIX是用汇编语言编写的。然而,随着系统的复杂性增加,汇编语言的低级特性使得开发和维护变得越来越困难。为了解决这个问题,Dennis M. Ritchie开发了C语言,并用它重写了UNIX操作系统。这使得UNIX的代码变得更加易于理解和维护,同时也使得UNIX可以在不同的硬件平台上运行。
电脑游戏也是C语言应用的一个重要领域。在20世纪80年代,电脑游戏开始流行起来。许多经典的电脑游戏,如《星球大战》的特效,都是用C语言编写的。C语言的高效性和对硬件的直接控制使得它非常适合用于电脑游戏的开发。例如,游戏开发者可以使用C语言直接操作图形硬件,以实现复杂的图形效果。
总的来说,C语言的历史和它的应用领域紧密相连。C语言的出现极大地推动了计算机科学的发展,并且它的应用领域仍在不断扩大。
C++
C++编程语言,最初被命名为“带类的C”,是由AT&T的贝尔实验室的员工Bjarne Stroustrup设计的。C++的设计初衷是在C语言的基础上增加面向对象的特性,以便更好地支持抽象和复用。
Bjarne Stroustrup从1979年开始研究C with Classes。这个项目的目标是在C语言的基础上添加类的概念,从而支持面向对象编程。面向对象编程是一种编程范式,它通过将数据和操作数据的函数封装在一起,以创建可重用的软件组件,从而提高软件的可维护性和可复用性。
在C++的名字中,“++”是C语言的操作符,用于增加变量的值。这个名字象征着C++是C语言的一个增强版,它在C语言的基础上添加了新的特性。
C++语言的第一个商业版本发布于1985年10月。这个版本包含了许多新的特性,包括类、虚函数、运算符重载和模板等。这些特性使得C++成为一种非常强大的编程语言,它既可以进行低级的系统编程,也可以进行高级的面向对象编程。
自从1985年发布以来,C++已经经历了多次重大的更新,包括C++98、C++03、C++11、C++14、C++17和C++20等。这些更新进一步增强了C++的功能,使其成为了现代软件开发的重要工具。
C++ 的应用
C++是一种广泛使用的编程语言,全球可能有超过2000亿行的C/C++代码。C++的主要特点是性能优越,没有其他编程语言能提供C++的性能关键设施。C++为程序员提供了对性能每个方面的控制,没有留给低级语言的空间。
C++的普遍性也是其优势之一,它可以在各种环境中运行,从低功耗的嵌入式设备到大规模的超级计算机。C++支持多种编程范式,包括面向对象编程和泛型编程,这使得程序员可以编写高效的代码而不失高级抽象。
C++还允许编写低级代码,如驱动程序、内核和汇编等。C++有一个丰富的生态系统,包括许多支持工具,如调试器、内存检查器、覆盖工具、静态分析工具和性能分析工具等。
C++有40年的历史,许多软件问题已经得到解决,开发实践已经得到研究。C++被广泛用于各种应用领域,包括操作系统(如Windows、Android、OS X和Linux)、编译器(如LLVM和Swift编译器)、人工智能(如TensorFlow、Caffe和Microsoft Cognitive Toolkit)、图像编辑(如Adobe Premier、Photoshop和Illustrator)、网页浏览器(如Firefox和Chrome)、高性能计算、嵌入式系统、科学计算、数据库、视频游戏、娱乐、金融等。
例如,NASA的火星无人机的飞行代码和Webb望远镜的软件主要是用C++编写的。Google和Microsoft也使用C++进行网页索引。这些都充分证明了C++的强大和广泛应用。
C++ 设计哲学
C++哲学的一个重要方面是性能。在C++中,性能被视为至关重要的,除非作为最后的手段,否则不应牺牲性能。这就是所谓的“零开销原则”或“零成本抽象”。
零开销原则是指,如果你有一个抽象,它不应该比写等效的低级代码花费更多。换句话说,使用抽象不应该导致性能的损失。例如,如果你有一个矩阵乘法的抽象,它应该被写成这样,你不能降低到C级别的抽象并使用数组和指针等运行得更快。这意味着,使用C++的抽象结构(如类和模板)编写的代码,其性能应该与使用C语言的低级结构(如数组和指针)编写的等效代码相当。
这个原则是由C++的创造者Bjarne Stroustrup提出的,他强调,C++的设计目标是提供高级的抽象,同时保持与C语言相当的性能。这使得C++成为一种非常强大的编程语言,既可以进行低级的系统编程,也可以进行高级的面向对象编程。
C++是一种静态类型语言,这意味着类型检查在编译时进行,而不是在运行时。这是C++哲学的一个重要方面,即尽可能在编译时强制安全。C++编译器提供类型安全,并在编译时捕获许多错误,而不是运行时。这对许多商业应用来说是关键的考虑因素,因为它可以在代码运行之前发现并修复错误,从而提高代码的质量和可靠性。
类型注解使代码更易读,因为它们提供了关于变量和函数期望的输入和输出的信息。这也有助于编译器优化和提高运行时效率,因为编译器可以根据类型信息生成更有效的代码。
C++还允许用户定义自己的类型系统,这是通过类和结构体实现的。这使得程序员可以创建复杂的数据结构,以满足特定应用的需求。
C++的编程模型强调隔离,只有当它们解决实际问题时才添加特性,并允许完全控制。这意味着C++提供了一种机制,允许程序员只使用他们需要的特性,而不是强制使用所有的特性。
C++设计为可预测的运行时,没有垃圾收集器,没有动态类型系统,这使得它非常适合实时系统。这是因为垃圾收集和动态类型系统可能会引入运行时的不确定性,这在需要快速响应的系统中是不可接受的。
C++还设计为低资源消耗,包括低内存和能耗,这使得它非常适合受限的硬件平台,如嵌入式系统。
C++非常适合静态分析,这是因为它的静态类型系统和明确的语义使得工具可以在代码运行之前分析代码的行为。这对于安全关键的软件非常重要,因为它可以帮助发现和修复潜在的安全问题。
最后,C++的可移植性是其主要优势之一。现代C++标准具有高度的可移植性,这意味着用C++编写的代码可以在多种不同的硬件和操作系统上运行,只需很少或不需要修改。
C++ 适合谁?
C++是一种非常强大的编程语言,它被设计为那些想要非常好地使用硬件并通过抽象来管理这种复杂性的人。这意味着,C++适合那些需要直接控制硬件,如内存和处理器的程序员。这包括系统编程,如操作系统和数据库,以及需要高性能的应用,如游戏和实时系统。
然而,C++并不适合每个人。C++是一种复杂的语言,它提供了许多强大的特性,但是使用这些特性需要深入的理解和精确的控制。因此,C++被视为专业人士的锐利和有效的工具,基本上肯定是为那些追求某种精确度的人设计的。这包括专业的系统程序员,以及那些需要编写高性能代码的开发者。
C++ 的缺点
Bjarne Stroustrup,C++之父,用一个比喻来形容C++的困难性:“C使得你很容易射中自己的脚;C++使得这更难,但是当你这样做时,它会炸掉你的整条腿”。
Perl语言的创造者Larry Wall也指出,C++的一个问题是它要求你在做任何事情之前必须知道一切。这是因为C++有许多复杂的特性和规则,如果不完全理解这些特性和规则,就很容易犯错误。
魁北克大学的教授Daniel Lemire也分享了他的经验,即即使有20年的C++经验,但当他第一次编译一个非平凡的代码块时,如果没有任何错误或警告,他也会感到怀疑。这是因为C++的复杂性使得在没有错误或警告的情况下编译通过的代码可能仍然存在潜在的问题。
总结
汇编语言是一种低级语言,直接由计算机硬件执行,对人类来说非常难以理解和编写。B语言是一种更通用的高级编程语言,它是C语言的直接前身。C语言在B语言的基础上添加了数据类型和结构,使得代码更加易于理解和维护。C++在C语言的基础上增加了面向对象的特性,以便更好地支持抽象和复用。
C++ 编程范式有哪些?
C++是一种多范式的编程语言,主要支持以下五种编程范式:过程式编程、面向对象编程、泛型编程、元编程和函数式编程。
什么是编程范式?
-
过程式编程:这是最早的编程范式,主要关注的是程序的执行过程。在这种范式中,程序被看作是一系列的命令或者说是函数的集合。
-
面向对象编程:这种范式将程序看作是一系列互相交互的对象。每个对象都有自己的状态(数据成员)和行为(成员函数)。C++提供了类(class)和对象(object)的概念,支持封装、继承和多态等面向对象的特性。
-
泛型编程:这种范式主要关注的是算法和数据结构的抽象。C++的模板(template)就是支持泛型编程的一种机制,它允许程序员编写可以处理任意类型的代码,而不需要预先知道这些类型的具体信息。
-
元编程:这是一种在编译时执行计算的编程范式。C++的模板元编程(template metaprogramming)就是一种元编程技术,它允许程序员在编译时生成和操作代码。
-
函数式编程:这种范式将计算视为数学上的函数计算,避免了状态和可变数据。C++11开始引入了一些函数式编程的特性,如lambda表达式、
std::function
、std::bind
等。
以上就是C++支持的五种主要编程范式的概括。
不同范式的使用场景有哪些?
C++ 支持五种主要的编程范式:过程式编程、面向对象编程、泛型编程、函数式编程和元编程。下面是这五种编程范式的使用场景:
-
过程式编程:这是最基础的编程范式,主要用于编写简单的、线性的程序。例如,一个简单的文件读写程序,或者一个计算器程序,都可以使用过程式编程来实现。
-
面向对象编程:面向对象编程是一种更高级的编程范式,它允许程序员创建复杂的数据结构和操作这些数据结构的方法。这种编程范式主要用于编写大型的、复杂的软件系统。例如,一个图形用户界面(GUI)程序,或者一个数据库管理系统,都可以使用面向对象编程来实现。
-
泛型编程:泛型编程是一种允许程序员编写可以处理任何数据类型的代码的编程范式。这种编程范式主要用于编写库和框架。例如,C++ 的标准模板库(STL)就是使用泛型编程来实现的。
-
函数式编程:函数式编程是一种把计算过程看作是数学函数计算的编程范式。这种编程范式主要用于编写并行和分布式系统。例如,一个并行排序算法,或者一个分布式计算框架,都可以使用函数式编程来实现。
-
元编程:元编程是一种在编译时生成和操作代码的编程范式。这种编程范式主要用于优化代码的性能。例如,一个计算斐波那契数列的元函数,或者一个生成查找表的元程序,都可以使用元编程来实现。
以上就是 C++ 五种编程范式的使用场景。
结合具体的例子讲解不同编程范式
接下来结合具体的例子来讲解 C++ 不同编程范式的区别。
C++ 过程式编程
过程式编程是一种编程范式,它将程序看作是一系列的命令或者说是函数的集合。在这种范式中,我们关注的是程序的执行过程,而不是数据的组织方式。下面是一个简单的 C++ 过程式编程的例子:
#include <iostream>
// 定义一个函数,用于计算两个数的和
int add(int a, int b) {
return a + b;
}
// 定义主函数,程序的执行从这里开始
int main() {
int x = 5;
int y = 10;
int sum = add(x, y); // 调用 add 函数,计算 x 和 y 的和
std::cout << "The sum of " << x << " and " << y << " is " << sum << std::endl; // 输出结果
return 0;
}
在这个例子中,我们定义了一个 add
函数,用于计算两个数的和。然后在 main
函数中,我们调用了 add
函数,并将结果输出到控制台。这就是一个典型的过程式编程的例子,我们关注的是程序的执行过程,而不是数据的组织方式。
C++ 面向对象编程
面向对象编程(Object-Oriented Programming,OOP)是一种编程范式,它使用 "对象" 来设计软件。对象是类的实例,类定义了对象的数据和方法。C++ 是一种支持面向对象编程的语言,它提供了类(class)和对象(object)的概念,支持封装、继承和多态等面向对象的特性。
下面是一个简单的 C++ 面向对象编程的例子:
#include <iostream>
#include <string>
// 定义一个类
class Dog {
public:
// 构造函数
Dog(std::string name) : name_(name) {}
// 成员函数
void Bark() const {
std::cout << name_ << " says: Woof!" << std::endl;
}
private:
// 数据成员
std::string name_;
};
// 定义主函数,程序的执行从这里开始
int main() {
// 创建一个 Dog 对象
Dog myDog("Buddy");
// 调用对象的成员函数
myDog.Bark();
return 0;
}
在这个例子中,我们定义了一个 Dog
类,它有一个数据成员 name_
和一个成员函数 Bark
。然后在 main
函数中,我们创建了一个 Dog
对象 myDog
,并调用了它的 Bark
函数。这就是一个典型的面向对象编程的例子,我们通过定义类和创建对象,将数据和操作封装在一起。
C++ 泛型编程
C++ 泛型编程是一种编程方式,它允许程序员在编写代码时不指定具体的数据类型,而是在代码运行时确定数据类型。这种方式的主要优点是代码的复用性和灵活性。
下面是一个简单的例子,我们将创建一个泛型函数,该函数可以接受任何类型的参数,并返回这两个参数的最大值。
template <typename T>
T getMax(T a, T b) {
return (a > b) ? a : b;
}
在这个例子中,template <typename T>
是一个模板声明,表示我们将创建一个可以处理任何类型 T
的函数。在函数体中,我们使用 T
作为参数和返回类型。
然后,我们可以使用这个函数来获取任何类型的最大值,如下所示:
int main() {
int i = 5, j = 6, intMax;
double x = 5.5, y = 6.6, doubleMax;
intMax = getMax<int>(i, j);
doubleMax = getMax<double>(x, y);
cout << "Max of " << i << " and " << j << " is " << intMax << endl;
cout << "Max of " << x << " and " << y << " is " << doubleMax << endl;
return 0;
}
在这个例子中,我们使用了 getMax<int>(i, j)
和 getMax<double>(x, y)
来获取整数和浮点数的最大值。这就是 C++ 泛型编程的基本概念和用法。
C++ 元编程
C++ 元编程是一种编程技术,它允许程序员在编译时生成和操作代码。这种技术的主要优点是可以提高代码的性能,因为大部分计算都在编译时完成,而不是在运行时。
下面是一个简单的例子,我们将创建一个元函数,该函数可以计算斐波那契数列的第n项。
template <int N>
struct Fibonacci {
static const int value = Fibonacci<N - 1>::value + Fibonacci<N - 2>::value;
};
template <>
struct Fibonacci<0> {
static const int value = 0;
};
template <>
struct Fibonacci<1> {
static const int value = 1;
};
在这个例子中,Fibonacci<N>
是一个模板,它使用递归的方式计算斐波那契数列的第N项。我们为 N=0
和 N=1
提供了特化版本,以终止递归。
然后,我们可以使用这个元函数来在编译时计算斐波那契数列的第N项,如下所示:
int main() {
cout << "Fibonacci(5) = " << Fibonacci<5>::value << endl;
return 0;
}
在这个例子中,我们使用了 Fibonacci<5>::value
来在编译时计算斐波那契数列的第5项。这就是 C++ 元编程的基本概念和用法。
C++ 函数式编程
函数式编程是一种编程范式,它将计算视为数学上的函数计算,避免了状态和可变数据。C++11开始引入了一些函数式编程的特性,如lambda表达式、std::function
、std::bind
等。
下面是一个简单的 C++ 函数式编程的例子:
#include <iostream>
#include <vector>
#include <algorithm>
#include <functional>
int main() {
// 定义一个 vector
std::vector<int> numbers = {1, 2, 3, 4, 5};
// 定义一个 lambda 函数,用于计算数字的平方
auto square = [](int n) { return n * n; };
// 使用 std::transform 将 square 函数应用到 numbers 的每个元素
std::transform(numbers.begin(), numbers.end(), numbers.begin(), square);
// 定义一个 lambda 函数,用于打印数字
auto print = [](int n) { std::cout << n << " "; };
// 使用 std::for_each 打印 numbers 的每个元素
std::for_each(numbers.begin(), numbers.end(), print);
return 0;
}
在这个例子中,我们定义了两个 lambda 函数:square
和 print
。然后我们使用 std::transform
和 std::for_each
将这两个函数应用到 numbers
的每个元素。这就是一个典型的函数式编程的例子,我们通过定义和使用函数,来描述和组织程序的逻辑。
总结
C++支持五种主要的编程范式:过程式编程、面向对象编程、泛型编程、函数式编程和元编程。过程式编程主要用于编写简单的、线性的程序,如文件读写程序或计算器程序。面向对象编程用于创建复杂的数据结构和操作这些数据结构的方法,适用于大型的、复杂的软件系统。泛型编程允许编写可以处理任何数据类型的代码,主要用于编写库和框架。函数式编程把计算过程看作是数学函数计算,主要用于编写并行和分布式系统。元编程在编译时生成和操作代码,主要用于优化代码的性能。
注意事项
#pragma once
在 #pragma once
出现之前,C++程序员通常使用宏定义来防止头文件被重复包含。这种做法被称为 "include guards" 或 "header guards"。下面我会通过一个具体的例子来说明在有和没有 #pragma once
的情况下,这些问题是如何被处理的。
1. 没有 #pragma once
的情况
假设我们有一个名为 myheader.h
的头文件。在没有 #pragma once
的情况下,为了防止头文件内容被重复包含,我们需要使用宏定义来创建一个包含防护。这通常是这样做的:
// myheader.h
#ifndef MYHEADER_H
#define MYHEADER_H
// 头文件内容
void myFunction();
#endif // MYHEADER_H
在这个例子中:
#ifndef MYHEADER_H
检查MYHEADER_H
是否未定义。#define MYHEADER_H
定义宏,以便后续的编译过程中#ifndef
检查失败,防止重复包含。#endif
结束预处理器条件。
存在的问题
- 手动管理宏:每个头文件都需要一个唯一的宏名,这增加了开发的复杂性。
- 命名冲突:如果不小心重复使用了宏名,可能会导致错误。
- 编译器依赖:不同的编译器可能对宏的处理方式有所不同,影响代码的可移植性。
2. 有 #pragma once
的情况
#pragma once
是一个预处理器指令,用于告诉编译器当前头文件只需在单个编译中包含一次。对于同一个头文件,即使它在多个文件中被包含,也只会被处理一次。我们修改上面的例子如下:
// myheader.h
#pragma once
// 头文件内容
void myFunction();
解决的问题
- 简化处理:不需要定义和维护宏。
- 避免冲突:不再依赖于宏的唯一性,减少了命名冲突的风险。
- 提高效率:在某些情况下,
#pragma once
可以减少编译器的工作量,提高编译效率。
结论
尽管 #pragma once
在很多现代编译器中被广泛支持,并且为防止头文件重复包含提供了一种更简洁的方式,但它并不是C++标准的一部分。因此,在跨平台或使用不同编译器的项目中,使用传统的宏定义包含防护仍然是一种更安全的做法。
const 和 define 的区别?
在C++中,使用const
关键字和使用预处理指令#define
来定义常量是两种不同的方法,它们具有一些关键的区别:
使用 const
定义常量
-
类型安全:
const
定义的常量具有明确的类型,可以进行类型检查。这有助于避免类型相关的错误。 -
作用域限制:
const
定义的常量有特定的作用域,通常是在它被声明的块中。这有助于避免命名冲突,并增加了代码的可维护性。 -
调试友好:
const
定义的常量在调试过程中可以被看到,因为它们是符号名称。 -
内存分配:
const
常量通常会分配存储空间(尽管编译器可能会优化),可以取地址。
示例:
const int MAX_VALUE = 100;
使用 #define
定义常量
-
预处理器指令:
#define
是一个预处理器指令,用于在编译之前替换文本。它不进行类型检查,也没有数据类型。 -
全局替换:
#define
创建的宏在它被定义后的所有地方有效,直到被#undef
指令取消或文件结束。 -
不占用存储空间:宏通常不分配存储空间,因为它们在编译前就被替换成相应的值或表达式。
-
可能导致意外的行为:由于文本替换的方式,
#define
宏可能导致一些意外的行为,尤其是在复杂的表达式中。
示例:
#define MAX_VALUE 100
区别总结
- 类型安全:
const
比#define
提供更好的类型安全。 - 作用域控制:
const
变量有特定的作用域,而#define
没有作用域概念,它是全局替换。 - 调试:
const
常量在调试时更容易追踪。 - 内存分配:
const
可能会占用存储空间,而#define
不会。 - 编译器优化:现代编译器通常能够对
const
常量进行优化,尤其是在它们没有被取地址时。
因此,在C++中,通常推荐使用const
来定义常量,因为它提供了更好的类型安全、作用域控制和调试能力。然而,在某些特殊情况下,例如当需要定义宏函数或进行条件编译时,#define
仍然非常有用。
什么时候用 const 、什么时候用 define ?
-
使用
const
:当你需要定义一个具有特定类型的不变值,并且这个值只在某个特定区域(比如一个函数或类中)有效时。例如,你想在一个函数中定义一个不会改变的整数或浮点数:const int maxUsers = 100; const double pi = 3.14159;
const
保证了类型安全(比如你不能不小心把字符串赋给一个整数类型的const
),并且让代码更容易理解和维护。 -
使用
define
:当你需要定义一个全局常量,或者需要创建一个宏(比如一个简单的代码片段)时。这种情况下,类型不是主要关注点,而且这个值或代码片段将在整个程序中有效。#define PI 3.14159 #define MAX(a, b) ((a) > (b) ? (a) : (b))
define
是在编译之前进行文本替换,所以它不关心类型安全,也不受作用域的限制。
总结:如果你需要类型安全和作用域控制,用 const
。如果你需要全局替换或创建宏,用 define
。在现代 C++ 中,一般推荐使用 const
,因为它更安全、代码更清晰。
如何使用引用?
在 C++中,引用和指针都是用来间接引用或访问另一个对象的工具,但它们之间存在一些关键的区别。为了更好地理解这些差异,让我们通过一些具体的例子来探讨。
引用
引用在 C++中类似于对象的别名。一旦一个引用被初始化为一个对象,它就不能被改变为引用另一个对象。
#include <iostream>
int main() {
int x = 10;
int& ref = x; // ref 是 x 的引用
ref = 20; // 修改 ref 也就是修改 x
std::cout << "x: " << x << std::endl; // 输出 20
int y = 30;
// int& ref = y; // 错误:引用一旦初始化后不能改变
}
在这个例子中,ref
是变量 x
的引用,修改 ref
相当于修改 x
。一旦 ref
被初始化为 x
的引用,它就不能改变为引用 y
。
指针
指针是一个变量,其值为另一个变量的地址。指针可以被重新赋值以指向另一个对象。
#include <iostream>
int main() {
int x = 10;
int* ptr = &x; // ptr 是指向 x 的指针
*ptr = 20; // 通过 ptr 修改 x 的值
std::cout << "x: " << x << std::endl; // 输出 20
int y = 30;
ptr = &y; // ptr 现在指向 y
*ptr = 40; // 通过 ptr 修改 y 的值
std::cout << "y: " << y << std::endl; // 输出 40
}
在这个例子中,ptr
最初是指向 x
的指针,但后来被改变为指向 y
。通过解引用 ptr
(使用 *ptr
),我们可以修改它所指向的值。
引用与指针的区别
- 初始化:引用在创建时必须被初始化,而指针可以在任何时候被初始化。
- 可变性:一旦引用被初始化为对一个对象的引用,它就不能改变为引用另一个对象,而指针可以改变为指向另一个对象。
- 空值:引用必须引用某些对象,不能为
nullptr
,而指针可以是nullptr
或指向任何对象。 - 操作:引用的操作就像操作普通变量一样,而指针需要解引用。
- 内存地址:引用自身没有内存地址(或者说不可访问),而指针是存储内存地址的变量。
总的来说,引用更适合用作函数参数或返回值,使得函数操作更加直观,而指针更适合于需要动态分配内存的场景。理解和正确使用这两种不同的类型对于编写高效、可读性强的 C++代码非常重要。
使用场景
引用在 C++中有许多实用的应用场景。以下是一些具体的例子,展示了引用的常见使用方式:
1. 函数参数传递
使用引用作为函数参数可以避免复制大型对象,同时允许函数修改传入的对象。
示例:交换两个数字
#include <iostream>
void swap(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
int main() {
int x = 10, y = 20;
swap(x, y);
std::cout << "x: " << x << ", y: " << y << std::endl; // 输出:x: 20, y: 10
}
在这个例子中,swap
函数使用引用参数来交换两个整数的值。由于使用了引用,所以不需要额外的复制操作,且能够直接修改原始数据。
2. 作为函数返回值
返回引用可以避免不必要的对象复制,尤其在返回类实例或大型结构时。
示例:访问数组元素
#include <iostream>
#include <vector>
std::vector<int> vec = {1, 2, 3, 4, 5};
int& getElement(size_t index) {
return vec[index]; // 返回引用
}
int main() {
getElement(2) = 10; // 修改第三个元素
std::cout << vec[2] << std::endl; // 输出:10
}
在这个例子中,getElement
函数返回一个数组元素的引用,允许直接修改数组中的特定元素。
3. 在范围基的 for 循环中修改元素
使用引用可以在范围基的 for 循环中直接修改元素。
示例:修改向量元素
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec = {1, 2, 3, 4, 5};
for (int& num : vec) {
num *= 2; // 每个元素乘以2
}
for (const int& num : vec) {
std::cout << num << " "; // 输出:2 4 6 8 10
}
}
在这个例子中,使用引用在 for 循环中修改了向量中的每个元素。
4. 用于操作符重载
在类的操作符重载中经常使用引用,以实现链式调用或效率更高的操作。
示例:重载赋值操作符
class MyClass {
// 类成员和方法
public:
MyClass& operator=(const MyClass& other) {
// 赋值操作的实现
return *this;
}
};
在这个例子中,重载赋值操作符返回对象的引用,允许链式赋值(如 a = b = c
)。
引用在 C++中提供了一种高效且方便的方式来传递和操作数据,特别是在需要直接修改数据或避免不必要的复制时。引用的使用可以提高代码的性能和可读性。
和指针相比,引用的优势?
即使 C++中已经有了指针,引入引用仍然有其重要的原因和优势。引用和指针虽然在某些方面功能相似,但它们有各自独特的特性和适用场景:
- 易用性和可读性:引用提供了一种更直观、更容易理解的方式来传递对象。当你使用引用时,语法更加简洁,且不需要使用解引用操作(
*
)。这使得代码更容易阅读和维护。
示例:交换两个变量的值
使用引用:
void swap(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
int main() {
int x = 10, y = 20;
swap(x, y);
// x 和 y 的值被交换
}
使用指针:
void swap(int* a, int* b) {
int temp = *a;
*a = *b;
*b = temp;
}
int main() {
int x = 10, y = 20;
swap(&x, &y);
// x 和 y 的值被交换
}
引用版本的 swap
函数更简洁,使用起来也更直观。
- 安全性:引用在创建时必须被初始化,并且一旦指向一个对象,就不能再指向另一个对象。这提供了一定程度的安全性,因为引用保证总是指向一个有效的对象,而不会像指针那样可能为空(
nullptr
)或悬垂。
示例:修改数组元素
void increment(int& val) {
val++;
}
int main() {
int arr[] = {1, 2, 3};
increment(arr[0]);
// arr[0] 现在是 2
}
这里,increment
函数安全地修改了数组的第一个元素。使用引用时,不存在将引用错误地指向 nullptr
的风险。
- 函数返回值:引用允许函数返回一个对象的引用,这在需要通过函数改变传入对象的场合特别有用。通过返回对象的引用,可以避免对象的复制,提高性能。
示例:返回容器中的元素
std::vector<int>& getFirstElement(std::vector<int>& vec) {
return vec;
}
int main() {
std::vector<int> myVec = {1, 2, 3};
auto& firstElement = getFirstElement(myVec);
firstElement = 10;
// myVec[0] 现在是 10
}
- 操作符重载和复制构造函数:在 C++中,某些操作(如操作符重载和复制构造函数)必须使用引用。例如,重载赋值操作符时通常会返回对象的引用,以允许链式赋值。
示例:赋值操作符重载
class MyClass {
public:
MyClass& operator=(const MyClass& other) {
// 赋值逻辑
return *this;
}
};
- 实现某些特定语义:在某些情况下,使用引用而不是指针可以更准确地实现特定的语义。例如,引用语义强调“别名”或“代理”,而指针则强调“指向”或“地址”。
示例:引用作为别名
int main() {
int original = 10;
int& alias = original;
alias = 20;
// original 现在是 20
}
- 支持多态和继承:在处理继承和多态时,引用使得语法更为直观和简洁,特别是在处理基类和派生类对象时。
总的来说,虽然引用和指针在某些情况下可以互换使用,但引用的引入为 C++编程提供了更安全、更直观的编码方式,特别是在涉及到复杂的对象操作和类成员函数时。
示例:多态和虚函数
class Base {
public:
virtual void display() { std::cout << "Base" << std::endl; }
};
class Derived : public Base {
public:
void display() override { std::cout << "Derived" << std::endl; }
};
void print(Base& obj) {
obj.display();
}
int main() {
Base b;
Derived d;
print(b); // 输出 "Base"
print(d); // 输出 "Derived"
}
在这个例子中,print
函数通过引用接受基类对象,允许在派生类对象传入时展现多态行为。
总结
引用在 C++中提供了一种更直观、更安全的方式来处理对象和变量。
尽管引用和指针在某些情况下可以互换使用,但引用由于其易用性、安全性和特定的使用场景,成为了 C++ 中不可或缺的一部分。
C/C++ 站在汇编的视角看待引用和指针
这篇文章结合具体的汇编代码,讲解引用和指针的区别。
站在汇编的角度看待指针和引用
在C++中,我们可以使用引用或指针作为函数参数。以下是两种方法的示例:
// 使用引用作为参数
void swap_ref(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
// 使用指针作为参数
void swap_ptr(int* a, int* b) {
int temp = *a;
*a = *b;
*b = temp;
}
在这两个函数中,我们都可以交换两个整数的值。但是,使用引用作为参数时,我们可以直接操作变量,而不需要解引用。使用指针作为参数时,我们需要解引用指针才能操作变量。
对于这两个函数的汇编实现,我们可以使用gcc的-S
选项来生成汇编代码。以下是上面两个函数对应的汇编实现:
// 使用引用作为参数的汇编实现
swap_ref(int&, int&):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-24], rdi
mov QWORD PTR [rbp-32], rsi
mov rax, QWORD PTR [rbp-24]
mov eax, DWORD PTR [rax]
mov DWORD PTR [rbp-4], eax
mov rax, QWORD PTR [rbp-32]
mov edx, DWORD PTR [rax]
mov rax, QWORD PTR [rbp-24]
mov DWORD PTR [rax], edx
mov rax, QWORD PTR [rbp-32]
mov edx, DWORD PTR [rbp-4]
mov DWORD PTR [rax], edx
nop
pop rbp
ret
// 使用指针作为参数的汇编实现
swap_ptr(int*, int*):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-24], rdi
mov QWORD PTR [rbp-32], rsi
mov rax, QWORD PTR [rbp-24]
mov eax, DWORD PTR [rax]
mov DWORD PTR [rbp-4], eax
mov rax, QWORD PTR [rbp-32]
mov edx, DWORD PTR [rax]
mov rax, QWORD PTR [rbp-24]
mov DWORD PTR [rax], edx
mov rax, QWORD PTR [rbp-32]
mov edx, DWORD PTR [rbp-4]
mov DWORD PTR [rax], edx
nop
pop rbp
ret
从汇编代码中,我们可以看到两个函数对应的汇编代码是一样的。这是因为在C++中,引用实际上就是一个常量指针。所以,无论我们是使用引用还是指针,底层的实现都是通过地址来访问和修改变量的值。
常量指针
在C++中,引用被设计为对象的一个别名,它就像是对象的另一个名字。一旦一个引用被初始化为一个对象,它就不能被改变为另一个对象的引用。因此,引用总是引用同一个对象。这就是为什么说引用是一个常量指针。
在底层实现上,引用就是一个常量指针。也就是说,它是一个指针,但是你不能改变它的值(即它指向的对象)。这就是为什么你不能改变引用的引用对象,因为这实际上就是在试图改变一个常量指针的值,这是不允许的。
例如,考虑以下代码:
int x = 10;
int& ref = x;
在这里,ref
是一个引用,它被初始化为x
。在底层,ref
实际上是一个指向x
的常量指针。因此,你不能改变ref
的引用对象,就像你不能改变一个常量指针的值一样。
引用的本质
实际上可以将引用理解为在指针的基础上加了一些限制。在C++中,引用可以被看作是一种特殊的指针,它们都可以用来间接访问变量,但是引用有一些额外的限制:
- 引用必须在创建时初始化,而指针可以在任何时候初始化。
- 一旦引用被初始化为一个对象,它就不能被改变为另一个对象的引用。换句话说,引用总是引用同一个对象。而指针可以改变指向。
- 引用不能为NULL,而指针可以。
这些限制使得引用在某些情况下比指针更安全和更易于使用。
引用的实现是依赖于编译器的。在大多数C++编译器中,引用实际上是通过指针来实现的。当你创建一个引用并初始化它时,编译器在底层创建了一个指针,并将这个指针初始化为指向你指定的对象。然后,每当你使用这个引用时,编译器都会自动解引用这个指针。因此,从这个角度来看,你可以将引用看作是一个自动被解引用的常量指针。
引用不能为空的实现是通过在创建引用时必须进行初始化来实现的。在C++中,你不能创建一个没有初始化的引用。这意味着你不能创建一个引用,然后稍后再让它引用一个对象。引用必须在创建时立即引用一个对象。因此,引用总是引用一个有效的对象,不能为NULL。
引用占用空间吗?
在C++中,引用的实现方式是依赖于指针的,但这并不意味着引用就是指针。引用只是为已存在的变量提供了一个别名,它并不拥有自己的存储空间。当我们创建一个引用时,编译器并不会为引用分配新的内存,而是将引用和它所引用的变量放在同一块内存地址中。这就是为什么说引用不占用存储空间的原因。
假设我们有一个变量int a = 42;
,一个指向a
的指针int* p = &a;
,以及一个引用int& ref = a;
。
内存地址: 0x1000 0x1004 0x1008
+-------+ +-------+
| a ref | | p |
+-------+ +-------+
变量值: 42 0x1000
在这个例子中:
a
是一个已存在的变量,它占用了内存地址0x1000
的空间,其值为42
。p
是一个指针,它占用了内存地址0x1004
的空间,其值为0x1000
,也就是a
的地址。ref
是a
的引用,它并没有占用新的内存空间,而是和a
共享了同一块内存地址0x1000
。因此,ref
在内存中并不占用实际的存储空间,它只是a
在内存中的一个别名。
当我们通过p
或ref
来访问或修改值时,实际上是在访问或修改a
的值。这是因为p
是一个指向a
的指针,而ref
是a
的别名。
为什么C语言只有指针没有引用?
C语言没有引用的原因主要是因为C语言在设计时,主要目标是为了系统编程和硬件操作,而不是为了提供高级抽象。C语言提供了指针,可以直接操作内存,这对于系统级编程非常有用。
另一方面,C++引入了引用的概念,主要是为了支持操作符重载和其他高级特性。引用在某些情况下可以使代码更易读和易写,但是它也增加了语言的复杂性。在C语言中,由于没有这些高级特性的需求,因此没有引入引用的概念。
总的来说,C语言没有引用主要是因为它的设计目标和使用场景。C语言主要用于低级编程,直接操作内存,而不需要引用这样的高级抽象。
什么时候使用引用传参数什么时候用指针传递参数?
在C++中,选择使用引用还是指针传递参数主要取决于具体的使用场景和需求。以下是一些考虑因素:
-
语义清晰:引用在语义上更接近于别名,使用引用可以使代码更易读和易写。而指针则需要考虑解引用和空指针等问题。
-
安全性:引用在创建时必须被初始化,并且一旦被初始化后就不能改变引用的对象。这使得引用在某些情况下比指针更安全。
-
功能:指针提供了引用不能提供的功能,例如动态内存分配、指针算术运算、指向指针的指针等。
总的来说,如果你需要修改传入的参数,并且不需要关心参数是否存在(即参数不会是空),那么使用引用可能是更好的选择。如果你需要进行更复杂的内存操作,或者可能需要处理空参数,那么使用指针可能更合适。
C 语言指针编译前后
C 语言中的指针是一个变量,它的值是另一个变量(可能是整数、结构体或其他类型)在内存中的地址。这个地址指向的是存储块的第一个字节。这就是为什么我们说指针“指向”一个变量。
指针编译前后
当我们声明一个指针变量时,我们通常会指定它所指向的变量的类型。例如,int *p;
声明了一个指向整数的指针。这个类型信息告诉编译器,当我们通过指针访问变量时,应该如何解释内存中的数据。例如,如果我们有一个指向整数的指针,编译器就会知道它需要读取4个字节(在大多数系统上)并将其解释为一个整数。
然而,当C编译器生成机器代码时,它并不会在机器代码中包含这些类型信息。在机器代码中,所有的数据都只是字节序列。编译器知道如何正确地生成代码来读取和写入这些字节,因为它在编译时知道这些字节代表什么类型的数据。但是,一旦代码被编译,这些类型信息就不再存在了。因此,机器代码只是将每个程序对象(变量、函数等)视为一个字节块,并将整个程序视为一个字节序列。
一个具体的例子
在C语言中,我们可以声明一个指向整数的指针,并通过这个指针来读取和写入整数。例如:
int x = 10;
int *p = &x;
*p = 20; // 修改x的值为20
在这个例子中,p
是一个指向整数的指针,它的值是x
的地址。当我们通过p
来修改x
的值时,编译器知道它需要读取和写入4个字节(在大多数系统上)。
然而,当C编译器将这段代码编译为机器代码时,这些类型信息并不会被包含在生成的机器代码中。在机器代码中,所有的数据都只是字节序列。例如,上述C代码可能被编译为以下的x86汇编代码:
movl $10, -4(%ebp) ; int x = 10;
leal -4(%ebp), %eax ; int *p = &x;
movl %eax, -8(%ebp)
movl $20, %eax ; *p = 20;
movl -8(%ebp), %edx
movl %eax, (%edx)
在这个汇编代码中,我们可以看到,所有的数据都只是字节序列。编译器在生成这些指令时知道这些字节代表什么类型的数据,但是一旦代码被编译,这些类型信息就不再存在了。例如,movl $20, %eax
这条指令只是将20这个值(一个字节序列)移动到%eax
寄存器,而不关心这个值是一个整数还是其他类型的数据。
指针的类型信息
在C语言中,不同类型的指针的长度是相同的,通常为4字节(32位系统)或8字节(64位系统)。这是因为指针实际上存储的是内存地址,而不是数据本身。无论指针指向的数据类型是什么,内存地址的大小是固定的。
当C编译器将源代码转换为机器代码时,它会根据指针的类型信息生成正确的代码来读取和写入数据。例如,如果有一个指向整数的指针,编译器会生成读取或写入4个字节的代码。如果有一个指向字符的指针,编译器会生成读取或写入1个字节的代码。
然而,一旦代码被编译,这些类型信息就不再存在。在机器代码中,所有的数据都只是字节序列。机器代码并不知道这些字节代表什么类型的数据,它只知道如何按照指定的方式操作这些字节。这就是为什么我们说,机器代码只是将每个程序对象(变量、函数等)视为一个字节块,并将整个程序视为一个字节序列。
总的来说,指针的类型信息在编译时是必要的,因为它告诉编译器如何生成正确的代码来操作数据。但是在运行时,这些类型信息就不再需要了,因为机器代码只关心如何操作字节,而不关心这些字节代表什么类型的数据。
大小端
大小端是计算机存储多字节数据的一种方式,主要涉及到字节的顺序问题。这两种方式的主要区别在于多字节数据的最高有效字节(Most Significant Byte,MSB)和最低有效字节(Least Significant Byte,LSB)在内存中的存放顺序。
一个具体的例子
-
大端(Big Endian):最高有效字节在最低内存地址处,最低有效字节在最高内存地址处。比如一个32位整数0x12345678在内存中的存储顺序(从低地址到高地址)为:12 34 56 78。
-
小端(Little Endian):最低有效字节在最低内存地址处,最高有效字节在最高内存地址处。同样的32位整数0x12345678在内存中的存储顺序(从低地址到高地址)为:78 56 34 12。
举个例子,假设我们有一个32位的整数0x12345678,我们将其存储在内存地址0x100开始的位置。
如果我们的机器是大端模式,那么在内存中的存储顺序为:
0x100: 12
0x101: 34
0x102: 56
0x103: 78
如果我们的机器是小端模式,那么在内存中的存储顺序为:
0x100: 78
0x101: 56
0x102: 34
0x103: 12
为什么字节顺序没有统一?
字节顺序的不统一主要是由于历史原因和技术选择造成的。在计算机历史的早期,不同的硬件制造商选择了不同的字节顺序,这主要是由于他们对硬件设计的不同理解和优化策略。例如,一些制造商认为大端序更符合人类的阅读习惯,因为最重要的字节(类似于我们阅读数字时的最高位)在前。而另一些制造商则认为小端序在处理某些类型的运算(如增量或减量运算)时更高效。
尽管现在我们已经意识到这种差异可能会导致一些问题,但由于大量的硬件和软件已经依赖于特定的字节顺序,所以改变这一点是非常困难的。此外,由于大多数情况下,程序员并不需要关心字节顺序,因此这个问题通常只在特定的场景(如网络通信或二进制文件处理)中才会显现出来。
因此,尽管统一字节顺序可能会带来一些好处,但由于历史原因和实际的技术挑战,仍然需要处理不同的字节顺序。为了解决由此产生的问题,我们通常会在需要的地方使用特定的函数或协议来进行字节顺序的转换。
使用场景
在网络通信中,通常采用大端(Big-Endian)字节序,这也被称为网络字节序。这是因为在早期的网络协议(如TCP/IP)中,大端字节序被选为标准。因此,当我们在网络中发送和接收数据时,无论主机使用的是大端字节序还是小端字节序,都需要将数据转换为网络字节序。
在计算机硬件中,字节序的选择取决于具体的处理器设计。有些处理器(如大多数Intel和AMD的x86和x64处理器)使用小端(Little-Endian)字节序,有些处理器(如IBM的PowerPC和Oracle(Sun)的SPARC)使用大端字节序。还有一些处理器(如ARM)可以配置为使用大端或小端字节序。但是,一旦选择了操作系统,字节序就会固定下来。例如,虽然ARM处理器可以配置为大端或小端,但是最常见的两个操作系统——Android(来自Google)和iOS(来自Apple)——都只在小端模式下运行。
在查看表示整数数据的字节序列时,字节排序也很重要,这在检查机器级程序时经常发生。例如,一个反汇编器生成的机器级代码行可能包含一个十六进制的字节序列,这个序列是一条指令的字节级表示。在小端机器上,字节序列的自然写法是最低编号的字节在左边,最高编号的字节在右边,但这与我们通常写数字的方式相反,即最高有效位在左边,最低有效位在右边。
在C++中,构造函数是一个特殊的成员函数,用于在创建对象时初始化该对象。C++中的构造函数可以分为几种类型,每种类型都有其特定的用途和特点。下面是C++中常见的几种构造函数类型及其使用示例:
1. 默认构造函数
默认构造函数是在没有提供任何参数的情况下被调用的构造函数。如果你没有为类编写任何构造函数,编译器会自动提供一个默认构造函数。
例子:
class Example {
public:
Example() {
cout << "默认构造函数被调用" << endl;
}
};
// 使用示例
int main() {
Example ex; // 调用默认构造函数
return 0;
}
使用场景:
- 初始化对象时不需要任何外部数据:当你的对象在创建时不需要额外信息,或者你想为成员变量提供默认值时,使用默认构造函数。
- 创建数组:当创建对象数组时,如果没有默认构造函数,编译器将报错,因为它需要一个没有任何参数的构造函数来初始化数组中的元素。
2. 带参数的构造函数
当你需要在创建对象时初始化一些成员变量,可以使用带参数的构造函数。
例子:
class Rectangle {
int width, height;
public:
Rectangle(int w, int h) {
width = w;
height = h;
}
int area() {
return width * height;
}
};
// 使用示例
int main() {
Rectangle rect(3, 4);
cout << "面积: " << rect.area() << endl; // 输出:面积: 12
return 0;
}
使用场景:
- 需要在对象创建时初始化成员变量:如果你的对象在创建时需要一些外部值(例如配置数据或依赖项),你应该使用带参数的构造函数。
- 提供多种初始化方式:通过重载构造函数,你可以提供多种方式来初始化对象的成员,这增加了类的灵活性。
3. 拷贝构造函数
拷贝构造函数用于创建一个对象作为另一个对象的副本。当对象以值的形式传递或返回时,或者用另一个同类型的对象初始化一个新对象时,会调用拷贝构造函数。
例子:
class CopyExample {
int value;
public:
CopyExample(int v) : value(v) { }
CopyExample(const CopyExample &obj) {
value = obj.value;
cout << "拷贝构造函数被调用" << endl;
}
int getValue() { return value; }
};
// 使用示例
int main() {
CopyExample original(30);
CopyExample copy = original; // 调用拷贝构造函数
cout << "原始值: " << original.getValue() << ", 复制值: " << copy.getValue() << endl;
return 0;
}
使用场景:
- 复制对象:当你需要创建一个对象的副本时,例如在实现复制控制操作(如传递对象作为函数参数)或者在从函数返回对象时。
- 实现深拷贝:当你的类拥有动态分配的资源时,你需要在拷贝构造函数中实现深拷贝以避免资源共享问题。
4. 移动构造函数(C++11及以后)
移动构造函数允许资源的所有权从一个对象转移到另一个对象,这在处理临时对象时非常有用,可以提高效率。
例子:
#include <utility>
class MoveExample {
int *ptr;
public:
MoveExample(int val) {
ptr = new int(val);
}
// 移动构造函数
MoveExample(MoveExample &&obj) noexcept : ptr(obj.ptr) {
obj.ptr = nullptr;
cout << "移动构造函数被调用" << endl;
}
~MoveExample() {
delete ptr;
}
};
// 使用示例
MoveExample createMoveExample() {
return MoveExample(5);
}
int main() {
MoveExample obj = createMoveExample(); // 调用移动构造函数
return 0;
}
在这个例子中,移动构造函数被调用的原因是在createMoveExample
函数中创建的MoveExample
对象被返回,并且在返回过程中触发了移动语义。
这里需要理解C++中的移动语义和右值引用的概念:
-
移动语义:在C++11之后引入,允许在某些情况下进行资源所有权的转移,这通常比深拷贝更高效。移动语义允许资源(如动态分配的内存)的所有权从一个对象转移到另一个对象。
-
右值引用:通过类型
T&&
表示,主要用于实现移动语义。它可以绑定到一个将要销毁的对象(即右值),从而允许安全地从该对象“移动”资源。
在例子中,我们关注以下部分:
MoveExample createMoveExample() {
return MoveExample(5);
}
int main() {
MoveExample obj = createMoveExample(); // 调用移动构造函数
}
-
在
createMoveExample
函数中,一个临时的MoveExample
对象被创建并初始化为5
。这个临时对象是一个右值,因为它没有具体的名称,并且即将被销毁。 -
当这个临时对象被返回时,理想情况下我们希望能够转移其资源而不是创建一个新的副本。这正是移动语义的用武之地。
-
在
main
函数中,createMoveExample()
返回的临时对象是一个右值,因此它与MoveExample
类的移动构造函数(接收一个右值引用的构造函数)相匹配。因此,移动构造函数被调用。 -
在移动构造函数中,
ptr
成员从源对象(即临时对象)转移到新创建的obj
对象。通过这种方式,我们避免了不必要的资源复制(例如,重新分配内存和复制数据),而是简单地转移了指针的所有权。这通常比使用拷贝构造函数(执行深拷贝)更高效。 -
最后,临时对象的析构函数被调用,但由于其
ptr
成员已被设置为nullptr
,所以没有资源被释放。这避免了双重释放的问题,因为现在资源的所有权已经转移到了obj
对象。
简而言之,移动构造函数在这个例子中被调用是因为:
- 返回一个临时对象触发了移动语义。
- 移动构造函数使资源的转移变得有效率,避免了不必要的资源复制。
使用场景:
- 优化临时对象的处理:移动构造函数主要用于优化临时对象(右值)的资源管理。当一个临时对象在函数返回或作为参数传递时,移动构造函数可以转移其资源而非复制,提高效率。
- 管理动态分配的资源:对于管理动态内存、文件句柄、网络连接等资源的类,使用移动构造函数可以防止资源的不必要复制,优化性能。
5. 委托构造函数(C++11及以后)
委托构造函数允许一个构造函数调用同一个类的另一个构造函数,以减少代码重复。
例子:
class DelegatingConstructor {
int x, y;
public:
DelegatingConstructor() : DelegatingConstructor(0, 0) {
cout << "委托构造函数被调用" << endl;
}
DelegatingConstructor(int xx, int yy) : x(xx), y(yy) { }
};
// 使用示例
int main() {
DelegatingConstructor obj; // 首先调用无参构造函数,然后委托给有参构造函数
return 0;
}
使用场景:
- 简化多个构造函数的代码:当你的类有多个构造函数且它们之间有共同的初始化代码时,可以使用委托构造函数来避免代码重复。
- 增加构造函数的灵活性:通过委托构造函数,你可以将通用的初始化逻辑放在一个构造函数中,并从其他构造函数中调用它,使得代码更简洁、易于维护。
了解这些构造函数的使用场景有助于在C++中进行更加有效的对象初始化和资源管理。
以上示例涵盖了C++中各种类型的构造函数,展示了它们的定义方式和使用场景。每种类型的构造函数都有其特定的用途和优势,了解并合理运用这些构造函数有助于提高C++编程的效率和质量。
在C++中,如果你只定义了一个析构函数而没有定义任何构造函数,编译器会为你的类自动生成几个特殊的成员函数。具体来说,编译器将自动生成默认构造函数、拷贝构造函数和拷贝赋值运算符。从C++11开始,还会生成移动构造函数和移动赋值运算符。这些自动生成的函数是为了确保类的对象可以被正常构造、复制、移动和销毁。
让我们通过一个例子来说明这一点:
示例代码
#include <iostream>
using namespace std;
class MyClass {
public:
// 自定义析构函数
~MyClass() {
cout << "析构函数被调用" << endl;
}
};
int main() {
MyClass a; // 调用自动生成的默认构造函数
MyClass b = a; // 调用自动生成的拷贝构造函数
MyClass c(MyClass()); // 调用自动生成的移动构造函数(C++11及以后)
b = a; // 调用自动生成的拷贝赋值运算符
c = std::move(a); // 调用自动生成的移动赋值运算符(C++11及以后)
return 0;
}
解释
-
默认构造函数:当
MyClass a;
被执行时,自动生成的默认构造函数被调用,用于初始化对象a
。 -
拷贝构造函数:当
MyClass b = a;
被执行时,自动生成的拷贝构造函数被调用,用于创建对象b
作为a
的副本。 -
移动构造函数(C++11及以后):当
MyClass c(MyClass());
被执行时,由于使用了临时对象,自动生成的移动构造函数被调用(如果你使用的是C++11或更高版本)。 -
拷贝赋值运算符:当执行
b = a;
时,自动生成的拷贝赋值运算符被调用,用于将a
的内容复制到b
。 -
移动赋值运算符(C++11及以后):当执行
c = std::move(a);
时,自动生成的移动赋值运算符被调用(在C++11及以后),用于将a
的内容移动到c
。
注意
- 如果你定义了自己的拷贝构造函数、拷贝赋值运算符、移动构造函数或移动赋值运算符,编译器将不会自动生成这些函数。
- 自C++11起,如果你显式定义了析构函数,编译器仍然会自动生成移动构造函数和移动赋值运算符,但这种行为在某些情况下可能会被抑制(比如当你也定义了拷贝构造函数或拷贝赋值运算符时)。
定义一个空类呢?
在C++中,如果你声明一个空类,编译器会默认生成以下六种函数:
- 默认构造函数(Default constructor)
- 默认析构函数(Default destructor)
- 拷贝构造函数(Copy constructor)
- 拷贝赋值运算符(Copy assignment operator)
- 移动构造函数(Move constructor)(C++11及以后)
- 移动赋值运算符(Move assignment operator)(C++11及以后)
这些函数都是公有的,并且可以被继承。这就是为什么一个空类在C++中并不真正“空”,它会有一些默认的行为。
以下是一个空类的例子:
class EmptyClass {
};
编译器会为这个类生成以下的成员函数:
class EmptyClass {
public:
EmptyClass() {} // 默认构造函数
~EmptyClass() {} // 默认析构函数
EmptyClass(const EmptyClass& other) {} // 拷贝构造函数
EmptyClass& operator=(const EmptyClass& other) { return *this; } // 拷贝赋值运算符
EmptyClass(EmptyClass&& other) {} // 移动构造函数 (C++11及以后)
EmptyClass& operator=(EmptyClass&& other) { return *this; } // 移动赋值运算符 (C++11及以后)
};
请注意,这些函数的实现是编译器生成的默认实现,可能并不符合你的需求,你可以根据需要重写这些函数。
Placement new 是 C++ 中的一个特殊功能,允许开发者在已经分配的内存位置上直接构造对象。这种技术特别适用于内存管理敏感的应用,如嵌入式系统、游戏开发、实时系统等,其中控制内存分配和避免额外的内存分配开销非常关键。
例如在日志模块中,通常需要使用单例模式来实现一个全局唯一的对象用来写入日志。而通过 Placement new 作为单例模式申请内存确保了被使用的内存不被释放掉,最终实现了生命周期和程序的整个生命周期等同。
new 的两个操作
当我们在 C++ 中使用普通的 new
操作符时,它实际上执行了两个操作:
-
分配内存:
new
操作符首先在堆上分配足够的内存来存储指定类型的对象。这是通过调用内存分配函数operator new
来完成的,该函数接受一个参数,即要分配的字节数。 -
在分配的内存中构造对象:一旦内存被分配,
new
操作符会在这块内存上构造对象。这是通过调用对象的构造函数来完成的。
以下是一个简单的示例,说明了这两个步骤:
class MyClass {
public:
MyClass() {
// Constructor code
}
};
int main() {
MyClass* myObject = new MyClass(); // 使用 new 操作符分配内存并构造对象
// ...
delete myObject; // 使用 delete 操作符释放内存并析构对象
return 0;
}
在上述代码中,new MyClass()
首先调用 operator new
函数来在堆上分配足够的内存来存储一个 MyClass
对象。然后,它在这块内存上调用 MyClass
的构造函数来构造一个新的 MyClass
对象。最后,它返回一个指向新构造的对象的指针,我们可以使用这个指针来访问对象。
当我们不再需要这个对象时,我们可以使用 delete
操作符来释放对象占用的内存并调用其析构函数。这是通过首先调用对象的析构函数,然后调用内存释放函数 operator delete
来完成的。
Placement new
在 C++ 中,placement new 是一种特殊的 new 操作符,它允许我们在已经分配的内存上构造对象。这样,我们就可以更加灵活地控制对象的内存管理,例如在特定位置创建对象,或者在已经分配的内存中重新构造对象。
以下是一个简单的示例,说明了如何使用 placement new:
#include <new> // 必须包含这个头文件来使用 placement new
class MyClass {
public:
MyClass() {
// Constructor code
}
};
int main() {
char buffer[sizeof(MyClass)]; // 分配足够的内存来存储 MyClass 对象
MyClass* myObject = new (buffer) MyClass(); // 在 buffer 上构造 MyClass 对象
// 使用 myObject...
myObject->~MyClass(); // 显式调用析构函数来析构对象,因为我们不能使用 delete
return 0;
}
在上述代码中,我们首先在栈上分配了一个足够大的缓冲区来存储一个 MyClass
对象。然后,我们使用 placement new (new (buffer) MyClass()
) 在这个缓冲区上构造了一个 MyClass
对象。注意,我们需要传递一个指向已分配内存的指针给 placement new。
当我们不再需要这个对象时,我们不能使用 delete
操作符来释放内存和析构对象,因为内存并不是由 new
操作符分配的。相反,我们需要显式地调用对象的析构函数 (myObject->~MyClass()
) 来析构对象。然而,我们不需要手动释放内存,因为它是在栈上分配的,当它离开作用域时,编译器会自动释放它。
区别
综上普通的 new 说在堆上申请空间并构造对象,而 placement new 可以自己确定在堆上还是在栈上申请空间。
在 C++ 中,当我们使用普通的 new
操作符时,它会在堆上分配内存,并返回一个指向新分配内存的指针。我们通常不知道这个指针的具体值,也就是说,我们不知道对象实际存储在内存的哪个位置。
class MyClass {
public:
MyClass() {
// Constructor code
}
};
int main() {
MyClass* myObject = new MyClass(); // 使用 new 操作符分配内存并构造对象
// 我们不知道 myObject 指向的具体内存地址
// ...
delete myObject; // 使用 delete 操作符释放内存并析构对象
return 0;
}
然而,当我们使用 placement new 时,我们可以指定对象应该被构造在哪个位置。这是通过传递一个指向已分配内存的指针给 placement new 来实现的。
#include <new> // 必须包含这个头文件来使用 placement new
class MyClass {
public:
MyClass() {
// Constructor code
}
};
int main() {
char buffer[sizeof(MyClass)]; // 分配足够的内存来存储 MyClass 对象
MyClass* myObject = new (buffer) MyClass(); // 在 buffer 上构造 MyClass 对象
// 我们知道 myObject 指向的具体内存地址,就是 buffer 的地址
// ...
myObject->~MyClass(); // 显式调用析构函数来析构对象,因为我们不能使用 delete
return 0;
}
在上述代码中,我们知道 myObject
指向的具体内存地址,因为它就是我们分配的 buffer
的地址。
当我们使用普通的 new
操作符分配内存时,我们可以使用 delete
操作符来释放内存并调用对象的析构函数。然而,对于使用 placement new 的情况,我们不能使用 delete
来释放内存,因为内存并不是由 new
操作符分配的。相反,我们需要显式地调用对象的析构函数来析构对象。然而,我们不需要手动释放内存,因为它是在栈上分配的,当它离开作用域时,编译器会自动释放它。
内存释放
在 C++ 中,placement new 的语法是 new (address) Type(initializer)
,其中 address
是一个指向已分配内存的指针,Type
是要构造的对象的类型,initializer
是传递给 Type
构造函数的参数。
以下是一个简单的示例,说明了如何使用 placement new:
#include <new> // 必须包含这个头文件来使用 placement new
class MyClass {
public:
int value;
MyClass(int v) : value(v) {
// Constructor code
}
};
int main() {
char buffer[sizeof(MyClass)]; // 分配足够的内存来存储 MyClass 对象
MyClass* myObject = new (buffer) MyClass(42); // 在 buffer 上构造 MyClass 对象,传递 42 给构造函数
// 使用 myObject...
myObject->~MyClass(); // 显式调用析构函数来析构对象,因为我们不能使用 delete
return 0;
}
在上述代码中,我们首先在栈上分配了一个足够大的缓冲区来存储一个 MyClass
对象。然后,我们使用 placement new (new (buffer) MyClass(42)
) 在这个缓冲区上构造了一个 MyClass
对象,并传递了 42
给 MyClass
的构造函数。
当我们不再需要这个对象时,我们不能使用 delete
操作符来释放内存和析构对象,因为内存并不是由 new
操作符分配的。相反,我们需要显式地调用对象的析构函数 (myObject->~MyClass()
) 来析构对象。然而,此处是不需要手动释放内存,因为它是在栈上分配的,当它离开作用域时,编译器会自动释放它。
placement new 使用场景
在 C++ 中,placement new 的使用场景通常是在需要对内存管理进行精细控制的情况下。以下是一些可能会优先使用 placement new 的情况:
- 内存池:如果你正在实现一个内存池,那么你可能需要在特定的内存位置上构造对象。在这种情况下,你可以使用 placement new 在内存池中的特定位置上构造对象。
#include <new>
class MyClass {
public:
MyClass() {
// Constructor code
}
};
char memoryPool[1000]; // 内存池
int main() {
MyClass* myObject = new (memoryPool) MyClass(); // 在内存池上构造对象
// 使用 myObject...
myObject->~MyClass(); // 显式调用析构函数来析构对象
return 0;
}
- 对象重用:如果你有一个对象,你想在相同的内存位置上构造一个新的对象,那么你可以使用 placement new。这可以避免内存分配和释放的开销,提高性能。
#include <new>
class MyClass {
public:
MyClass() {
// Constructor code
}
};
int main() {
MyClass* myObject = new MyClass(); // 使用 new 构造对象
// 使用 myObject...
myObject->~MyClass(); // 显式调用析构函数来析构对象
myObject = new (myObject) MyClass(); // 在相同的内存位置上构造新的对象
// 使用新的 myObject...
delete myObject; // 使用 delete 来释放内存并析构对象
return 0;
}
在上述两个例子中,我们都使用了 placement new 来在特定的内存位置上构造对象。这可以提供更大的灵活性,允许我们更精细地控制内存管理。
placement new 的优点
在 C++ 中,placement new 操作符相比于普通的 new 操作符有以下优点:
-
预知内存地址:使用 placement new,我们可以在已经分配的内存上构造对象。这意味着我们可以预先知道对象的内存地址。
-
内存管理优化:在构建内存池、垃圾收集器或者在性能和异常安全性至关重要的情况下,placement new 非常有用。因为它允许我们在特定的内存位置上构造对象,这可以避免内存分配和释放的开销,提高性能。
-
减少分配失败的风险:由于内存已经被分配,所以使用 placement new 没有分配失败的风险。此外,由于不需要进行内存分配,所以在预分配的缓冲区上构造对象的时间通常会更少。
-
资源有限的环境:在资源有限的环境中,例如嵌入式系统,预先分配内存并使用 placement new 可以更好地控制内存使用,避免动态内存分配可能带来的问题。
new
和 delete
在 C++ 中被引入,主要是为了解决 malloc
和 free
在 C 语言中的一些限制和问题,特别是在面向对象编程方面。以下是 new/delete
相比于 malloc/free
的主要改进:
类型安全和自动大小计算
malloc/free 示例:
#include <stdlib.h>
struct MyStruct {
int data;
// ... 其他成员 ...
};
int main() {
// 使用 malloc 分配内存,需要手动计算大小
MyStruct* p = (MyStruct*)malloc(sizeof(MyStruct));
p->data = 10;
free(p);
return 0;
}
new/delete 示例:
struct MyStruct {
int data;
// ... 其他成员 ...
};
int main() {
// 使用 new 分配内存,自动处理大小和类型
MyStruct* p = new MyStruct;
p->data = 10;
delete p;
return 0;
}
改进:
new
自动计算所需内存的大小,而malloc
需要程序员手动计算。new
提供类型安全,返回正确类型的指针,避免了强制类型转换的需要。
构造函数和析构函数的调用
C++ 示例:
class MyClass {
public:
MyClass() { std::cout << "Constructor called\n"; }
~MyClass() { std::cout << "Destructor called\n"; }
};
int main() {
MyClass* obj = new MyClass; // 调用构造函数
delete obj; // 调用析构函数
return 0;
}
改进:
new
在分配内存时调用对象的构造函数,delete
在释放内存时调用析构函数。malloc
和free
只处理内存分配和释放,不调用构造函数和析构函数。
异常处理
C++ 示例:
int main() {
try {
int* p = new int[10000000000]; // 尝试分配大量内存
} catch (const std::bad_alloc& e) {
std::cerr << "Memory allocation failed: " << e.what() << '\n';
}
return 0;
}
改进:
new
在内存分配失败时抛出异常(如std::bad_alloc
),而malloc
在失败时返回NULL
。- 这使得
new
能够更好地集成到 C++ 的异常处理框架中。
配对简便性
改进:
new
和delete
是为对象配对的,而malloc
和free
需要显式计算大小。new[]
和delete[]
用于数组,简化了数组内存管理。
new
的重载
是的,C++ 允许重载 new
操作符。这意味着你可以定义自己的 new
操作符来改变对象的分配方式。重载 new
可以用于自定义内存管理,追踪内存分配,或者引入特殊的内存分配策略。
重载 new
需要提供与系统 new
相同的返回类型和参数列表。最常见的形式是重载全局 new
和 delete
:
void* operator new(std::size_t size) {
std::cout << "Custom new for size " << size << std::endl;
return std::malloc(size);
}
void operator delete(void* memory) {
std::cout << "Custom delete" << std::endl;
std::free(memory);
}
类也可以重载其自身的 new
和 delete
,这对于控制特定类的对象分配非常有用。
class MyClass {
public:
void* operator new(std::size_t size) {
std::cout << "MyClass new" << std::endl;
return std::malloc(size);
}
void operator delete(void* memory) {
std::cout << "MyClass delete" << std::endl;
std::free(memory);
}
};
关键字和操作符
new 是操作符,malloc 是函数。
关键字(Keywords)和操作符(Operators)在编程语言中是两个不同的概念:
-
关键字:这些是编程语言预定义的保留字,每个关键字有特定的含义,并在语言的语法中扮演特定的角色。例如,
if
、while
、return
等在 C++ 中都是关键字。关键字不能用作变量名或函数名。 -
操作符:操作符用于执行操作,如算术运算、逻辑运算、比较等。在 C++ 中,一些操作符可以被重载,这意味着你可以改变它们的行为以适应特定类型的操作。例如,
+
、-
、*
、/
、new
等都是操作符。
有些情况下,某些关键字也可以被视为操作符。例如,new
和 delete
在 C++ 中既是关键字也是操作符。它们作为关键字,表示特定的动作(分配和释放内存),同时它们的行为可以像操作符那样被重载。
总结
new/delete
提供了更符合 C++ 面向对象特性的内存管理方式。它们处理类型安全、对象生命周期(构造和析构)、异常安全以及简化语法。然而,这些改进也带来了一定的性能开销,这在某些性能敏感的应用中可能是一个考虑因素。在 C++ 中,new/delete
是推荐的方式,因为它们提供了更安全和便利的内存管理机制。
C++ 提供了四种类型转换运算符:static_cast
、dynamic_cast
、const_cast
和 reinterpret_cast
。下面是每种转换的具体例子:
static_cast
:这是最常用的类型转换运算符,可以在相关类型之间进行转换,例如从基类指针转换为派生类指针,或者在整数和浮点数之间进行转换。
int i = 10;
double d = static_cast<double>(i); // convert int to double
dynamic_cast
:这个运算符主要用于在类的层次结构中进行安全的向下转换。它在运行时检查转换是否有效。
class Base {
public:
virtual void foo() {}
};
class Derived : public Base {
public:
void bar() {
// Derived specific function
}
};
int main() {
Base* basePtr = new Derived();
Derived* derivedPtr = dynamic_cast<Derived*>(basePtr); // safe downcasting
if (derivedPtr) {
derivedPtr->bar();
}
delete basePtr;
return 0;
}
const_cast
:这个运算符用于修改类型的 const 或 volatile 属性。最常见的用途是在函数中删除参数的 const 属性,以便可以对其进行修改。
const int a = 10;
int* b = const_cast<int*>(&a); // remove constness
*b = 20; // now allowed
reinterpret_cast
:这是最不安全的转换运算符,它会产生一个新的值。它主要用于进行某些机器特定的转换,或者进行一些非常规的类型转换。
int i = 10;
int* p = &i;
long num = reinterpret_cast<long>(p); // convert int* to long
请注意,尽管 C++ 提供了这些类型转换运算符,但在可能的情况下,最好尽量避免使用它们。类型转换往往会隐藏潜在的错误,使得代码更难理解和维护。在许多情况下,可以通过改进代码设计来避免类型转换。
在C++中,向上转型(Upcasting)和向下转型(Downcasting)是面向对象编程中的两种常见类型转换。它们在处理类的继承关系时非常重要。我将通过一个具体的例子来解释这两种转换。
向上转型和向下转型在C++中有着不同的安全性和用途,这就解释了为什么向上转型可以使用static_cast
或dynamic_cast
,而向下转型通常推荐使用dynamic_cast
。
向上转型 (Upcasting)
向上转型是将派生类(子类)的指针或引用转换为基类(父类)的指针或引用。这种转换通常是安全的,因为每个派生类对象都是一个基类对象。在C++中,向上转型可以隐式进行,但也可以显式地使用static_cast
或dynamic_cast
。
-
使用
static_cast
:static_cast
是编译时转换,它不进行运行时类型检查。在向上转型中,由于派生类继承了基类的所有属性和行为,所以使用static_cast
来进行显式转换是安全的。 -
使用
dynamic_cast
: 尽管在向上转型中通常不需要,dynamic_cast
也可以用于这种转换。它提供了运行时类型检查的能力,但在向上转型的场景中,这种检查是不必要的,因为转换总是安全的。
#include <iostream>
class Base {
public:
virtual void print() { std::cout << "Base class\n"; }
};
class Derived : public Base {
public:
void print() override { std::cout << "Derived class\n"; }
};
void function(Base *base) {
base->print();
}
int main() {
Derived d;
function(&d); // 向上转型,将Derived*转换为Base*
return 0;
}
在这个例子中,function
接受一个指向Base
的指针。当我们传递一个指向Derived
的指针时,会发生向上转型。由于Derived
是Base
的派生类,这种转换是安全的。
向下转型 (Downcasting)
向下转型是将基类(父类)的指针或引用转换为派生类(子类)的指针或引用。这种转换可能是不安全的,因为基类的指针或引用可能实际上并不指向派生类的对象。
-
使用
dynamic_cast
: 在进行向下转型时,dynamic_cast
是首选,因为它在运行时进行类型检查。如果转换是不合法的(例如,基类指针实际上并不指向派生类对象),dynamic_cast
会返回nullptr
(对于指针)或抛出异常(对于引用)。这提供了一种安全机制来防止类型转换错误。 -
不推荐使用
static_cast
:static_cast
不进行运行时检查,因此在向下转型时使用它可能导致不安全的行为。如果基类指针或引用实际上并不指向派生类对象,使用static_cast
可能会导致未定义的行为,例如访问无效的内存。
#include <iostream>
class Base {
public:
virtual void print() { std::cout << "Base class\n"; }
};
class Derived : public Base {
public:
void print() override { std::cout << "Derived class\n"; }
void derivedFunction() { std::cout << "Derived specific function\n"; }
};
int main() {
Base *b = new Derived();
b->print();
// 向下转型,尝试将Base*转换为Derived*
Derived *d = dynamic_cast<Derived*>(b);
if (d != nullptr) {
d->derivedFunction();
} else {
std::cout << "Downcasting failed\n";
}
delete b;
return 0;
}
在这个例子中,我们首先创建了一个指向Derived
对象的Base
类型指针。然后,我们尝试使用dynamic_cast
将这个基类指针转换为派生类指针。如果转换成功(即指针不为nullptr
),我们可以安全地调用派生类特有的成员函数。
总结
- 向上转型是安全的,因此可以使用
static_cast
或dynamic_cast
。 - 向下转型可能不安全,因此推荐使用
dynamic_cast
进行运行时类型检查,以确保转换的安全性。
为什么向上转型是安全的,而向下转型可能不安全?
向上转型和向下转型的安全性差异主要源于它们各自的特性和继承层次中对象之间的关系。
向上转型 (Upcasting)
向上转型是将派生类(子类)的指针或引用转换为基类(父类)的指针或引用。这被认为是安全的,原因包括:
-
继承保证:在C++中,派生类继承了基类的所有属性和方法。因此,派生类的对象可以被安全地看作是基类的对象。这意味着,将派生类对象转换为基类对象不会丢失任何基类部分的数据。
-
类型兼容性:由于每个派生类对象在内存中都包含一个完整的基类对象,所以将派生类指针或引用转换为基类指针或引用,不会导致任何数据结构的不匹配或内存访问错误。
由于这种转换是类型安全的,因此可以使用static_cast
来进行显式转换。static_cast
在编译时执行,效率较高,适用于这种安全的场景。
向下转型 (Downcasting)
向下转型是将基类(父类)的指针或引用转换为派生类(子类)的指针或引用。这种转换可能是不安全的,原因包括:
-
类型不确定性:基类的指针或引用可能实际上并不指向派生类的对象。在这种情况下,转换结果是不确定的,可能导致运行时错误。
-
潜在的数据结构不匹配:如果基类指针或引用实际上并不指向派生类的对象,那么将其转换为派生类类型可能会导致对内存的错误访问,因为派生类可能有额外的成员变量或方法,这些在基类中不存在。
由于这些风险,向下转型需要谨慎处理。dynamic_cast
在运行时执行类型检查,确保转换的对象实际上是适当类型的派生类对象。如果转换是不合法的,dynamic_cast
会安全地失败(返回nullptr
或抛出异常),从而防止潜在的不安全操作。
总结
- 向上转型安全:因为派生类总是包含基类的部分,所以向上转型(将派生类转换为基类)是安全的。
- 向下转型风险:因为基类不一定是某个特定派生类的实例,所以向下转型(将基类转换为派生类)需要谨慎处理,最好使用
dynamic_cast
进行安全检查。
static_cast
是 C++ 中一种常用的类型转换运算符。它主要用于基础数据类型之间的转换,例如从 int
转换为 float
,或从子类指针转换为父类指针。static_cast
转换是在编译时进行检查,因此它不适用于转换有继承关系的指针或引用,除非是向上转换(从子类到父类)。
下面是一些使用 static_cast
的具体例子:
例子 1:基础数据类型转换
int i = 10;
float f = static_cast<float>(i); // 将 int 转换为 float
这里,static_cast<float>(i)
将整数 i
转换为浮点数 f
。
例子 2:类的向上转换(子类到父类)
假设有一个父类 Base
和一个继承自 Base
的子类 Derived
。
class Base {};
class Derived : public Base {};
Derived *d = new Derived();
Base *b = static_cast<Base*>(d); // 将 Derived 类型的指针转换为 Base 类型的指针
在这个例子中,static_cast<Base*>(d)
将指向 Derived
类的指针 d
转换为指向它的父类 Base
的指针 b
。
注意事项
static_cast
不能用于含有虚继承的类之间的转换。- 不能用于转换指向不相关类的指针或引用。
- 不能用于去除 const、volatile 属性,这需要用到
const_cast
。
static_cast
是一种相对安全的转换方式,因为它在编译期间就能检查转换的合法性。如果尝试进行非法的转换,比如将一个整数指针转换为一个字符指针,编译器将报错。
static_cast
不能用于含有虚继承的类之间的转换。这是因为在虚继承中,派生类中只有一个基类的实例,而这个实例的地址可能并不是派生类对象的开始地址,所以不能直接使用static_cast
进行转换。例如:
class Base {
public:
virtual void foo() {}
};
class Derived : virtual public Base {
public:
void bar() {
// Derived specific function
}
};
int main() {
Base* basePtr = new Derived();
// This is not allowed, because Derived is a virtual base of Base
// Derived* derivedPtr = static_cast<Derived*>(basePtr); // Error
delete basePtr;
return 0;
}
static_cast
不能用于转换指向不相关类的指针或引用。例如,如果你有两个完全不相关的类A
和B
,你不能使用static_cast
将A*
转换为B*
,或者将A&
转换为B&
。例如:
class A {};
class B {};
int main() {
A a;
// This is not allowed, because A and B are unrelated types
// B* b = static_cast<B*>(&a); // Error
return 0;
}
static_cast
不能用于去除const
、volatile
属性,这需要用到const_cast
。例如:
int main() {
const int a = 10;
// This is not allowed, because static_cast cannot remove constness
// int* b = static_cast<int*>(&a); // Error
// This is allowed, because const_cast can remove constness
int* c = const_cast<int*>(&a);
return 0;
}
在这个例子中,我们试图使用 static_cast
去除 a
的 const
属性,但这是不允许的。然后,我们使用 const_cast
成功地去除了 a
的 const
属性。
static cast 解决了哪些问题?
在 C++ 中,如果没有 static_cast
这样的显式类型转换运算符,我们可能会面临一些问题,尤其是在需要明确和安全地转换类型时。static_cast
提供了一种在编译时进行类型检查的方式,确保转换是明确和安全的。下面是一些具体的例子来说明没有 static_cast
时可能出现的问题。
示例 1: 基本数据类型转换
假设你想将一个整数转换为浮点数:
int i = 10;
float f;
// 假设没有 static_cast
f = i; // 隐式转换
虽然这里的隐式转换是合法的,但在更复杂的情况下,隐式转换可能会导致数据丢失或意外的行为。使用 static_cast
,你可以明确表达转换的意图:
f = static_cast<float>(i); // 明确的转换
示例 2: 类型转换的安全性
考虑以下类的层次结构:
class Base {};
class Derived : public Base {};
向上转换(安全)
Derived d;
Base *b;
// 假设没有 static_cast
b = &d; // 隐式转换,虽然安全,但不够明确
使用 static_cast
可以明确表示这种转换是有意为之的:
b = static_cast<Base*>(&d); // 明确的向上转换
向下转换(不安全)
Base b;
Derived *d;
// 假设没有 static_cast 或 dynamic_cast
d = (Derived*)&b; // 不安全的 C 风格强制转换
这种转换实际上是不安全的,因为 b
可能不是 Derived
类型的对象。没有 static_cast
(或更适合这种情况的 dynamic_cast
),程序员可能会倾向于使用 C 风格的强制转换,这可能隐藏潜在的风险。
总结
没有 static_cast
,程序员可能会过度依赖隐式转换或不安全的 C 风格转换,这可能导致以下问题:
- 代码的可读性和意图不明确:
static_cast
明确表达了程序员的意图,使代码更易于理解和维护。 - 缺乏编译时类型检查:
static_cast
在编译时检查转换的合法性,减少了运行时错误的风险。 - 潜在的安全风险:不安全的类型转换可能导致未定义行为,如内存访问错误,数据损坏等。
因此,static_cast
是 C++ 类型转换的一个重要组成部分,它提供了一种安全、明确的方式来执行类型转换。
dynamic_cast
在 C++ 中用于处理具有多态性质的对象。它主要用于安全地将指针或引用从一种类型转换为另一种类型,特别是在类层次结构中进行向下转换(从基类到派生类)。与 static_cast
不同,dynamic_cast
在运行时执行类型检查,确保转换的安全性。
让我们通过一个具体的例子来说明 dynamic_cast
的使用。
示例:多态和类层次结构
假设有一个基类 Base
和两个从 Base
派生的类 Derived1
和 Derived2
。
class Base {
virtual void print() {} // 虚函数确保多态性
};
class Derived1 : public Base {
void print() override {}
};
class Derived2 : public Base {
void print() override {}
};
在这个例子中,Base
有一个虚函数 print
,这使得 Base
、Derived1
和 Derived2
都是多态类型。
使用 dynamic_cast 进行向下转换
现在假设你有一个指向 Base
类型的指针,实际上它指向一个 Derived1
对象,你想将它安全地转换为 Derived1
类型的指针。
Base* basePtr = new Derived1(); // 实际指向 Derived1 对象
Derived1* derived1Ptr = dynamic_cast<Derived1*>(basePtr); // 向下转换
if (derived1Ptr != nullptr) {
// 转换成功
} else {
// 转换失败,basePtr 不指向 Derived1
}
在这个例子中,dynamic_cast<Derived1*>(basePtr)
尝试将 basePtr
转换为 Derived1*
类型。由于 basePtr
实际上指向一个 Derived1
对象,所以转换是成功的。如果 basePtr
指向 Derived2
或其他非 Derived1
类型的对象,则转换结果将为 nullptr
,表示转换失败。
dynamic_cast 的运行时检查
dynamic_cast
使用了运行时类型信息 (RTTI),在运行时检查转换的有效性。这意味着,如果转换不合法(例如,尝试将 Base
类型的对象转换为不兼容的派生类类型),dynamic_cast
将返回空指针(对于指针类型)或抛出异常(对于引用类型)。
示例:dynamic_cast 与异常
如果使用引用而非指针进行 dynamic_cast
,在转换失败时会抛出一个异常:
Base& baseRef = *basePtr;
try {
Derived1& derived1Ref = dynamic_cast<Derived1&>(baseRef); // 使用引用
// 转换成功
} catch (const std::bad_cast& e) {
// 转换失败
}
这里,如果 baseRef
实际上不是 Derived1
类型的引用,则 dynamic_cast
会抛出 std::bad_cast
异常。
dynamic_cast 出现之前是如何解决上述问题的?
在 C++ 中引入 dynamic_cast
之前,实现多态性质对象之间安全转换的功能较为复杂和有风险。下面是一些在没有 dynamic_cast
的情况下实现类似功能的方法,以及这些方法存在的问题:
1. 手动类型检查和转换
在 dynamic_cast
出现之前,程序员可能需要手动进行类型检查和转换。例如,通过在基类中添加标识类型的成员变量,然后基于这些信息决定是否可以安全地将基类的指针转换为派生类的指针。
示例:
class Base {
public:
enum Type { BASE, DERIVED1, DERIVED2 };
Type type;
Base(Type t) : type(t) {}
};
class Derived1 : public Base {
public:
Derived1() : Base(DERIVED1) {}
};
class Derived2 : public Base {
public:
Derived2() : Base(DERIVED2) {}
};
在需要转换时,程序员需要检查 type
字段,并进行相应的转换:
Base* basePtr = new Derived1();
if (basePtr->type == Base::DERIVED1) {
Derived1* derived1Ptr = (Derived1*)basePtr; // C 风格强制转换
}
存在的问题:
- 类型安全性问题:这种方法缺乏编译时或运行时的类型检查,容易引入错误。
- 维护难度:随着类层次结构的增加,手动维护类型信息变得越来越复杂。
- 违反封装原则:通过公开类型信息,破坏了类的封装性。
2. C 风格的强制类型转换
在 dynamic_cast
之前,C++ 程序员可能会依赖于 C 风格的强制类型转换(如 (Derived1*)basePtr
)来进行转换。
存在的问题:
- 安全性问题:这种转换不进行任何类型检查,如果转换不合法,可能导致未定义的行为。
- 代码可读性差:C 风格的转换不明确其意图,减少了代码的可读性和维护性。
3. 使用虚函数进行类型识别
一种可能的替代方法是在基类中使用虚函数来返回类型信息,然后基于这个信息进行转换。
示例:
class Base {
public:
virtual bool isDerived1() { return false; }
virtual bool isDerived2() { return false; }
};
class Derived1 : public Base {
public:
bool isDerived1() override { return true; }
};
// 类似地为 Derived2 实现
然后,根据返回的类型信息进行转换:
Base* basePtr = new Derived1();
if (basePtr->isDerived1()) {
Derived1* derived1Ptr = (Derived1*)basePtr; // C 风格强制转换
}
存在的问题:
- 代码冗余:为每个类实现这样的虚函数会增加很多重复的代码。
- 依旧不够安全:尽管这种方法通过虚函数提供了一种类型检查机制,但最终的转换仍然依赖于不安全的 C 风格强制转换。
dynamic_cast
在运行时执行的检查是基于 C++ 的运行时类型信息 (RTTI) 系统。这些检查确保了类型转换的安全性,特别是在多态类层次结构中进行向下转换(从基类到派生类)时。以下是 dynamic_cast
进行的主要检查:
1. 检查对象的实际类型
当你尝试使用 dynamic_cast
将一个基类指针或引用转换为派生类指针或引用时,dynamic_cast
首先检查对象的实际类型是否与目标类型兼容。这意味着:
- 如果基类指针实际上指向一个派生类对象,且这个派生类与目标类型相符合或是目标类型的派生类,转换将成功。
- 如果基类指针没有指向一个与目标类型兼容的对象,转换将失败。对于指针类型,转换结果为
nullptr
;对于引用类型,将抛出std::bad_cast
异常。
2. 检查多态性
dynamic_cast
要求基类具有至少一个虚函数,从而保证类具有多态性。这是因为 dynamic_cast
依赖于对象的虚函数表(vtable)来确定其实际类型。如果尝试对一个没有虚函数的类进行 dynamic_cast
,则会导致编译错误。
3. 运行时类型信息 (RTTI)
dynamic_cast
使用 RTTI 来确定对象的实际类型。RTTI 提供了对象类型信息,包括其在类层次结构中的位置。这允许 dynamic_cast
在运行时进行正确的类型检查。
总结
在 dynamic_cast
引入之前,C++ 缺乏一种安全、简洁且标准的方式来实现多态性质对象之间的转换。尽管可以通过各种手段尝试实现类似功能,但这些方法要么牺牲了安全性和封装性,要么导致代码冗余和维护困难。dynamic_cast
的引入解决了这些问题,提供了一种类型安全且易于维护的方式来处理多态性质的对象转换。
const_cast
是 C++ 中的一种类型转换运算符,它主要用于修改类型的 const 或 volatile 属性。但是,它不能改变底层类型,也不能改变变量的值,只能改变底层类型的 const 或 volatile 属性。
const 作用
const_cast
主要用于移除对象的 const
或 volatile
限定。例如,如果你有一个 const int
的对象,你可以使用 const_cast
来获取一个可以修改的 int
引用。
const int a = 10;
int& b = const_cast<int&>(a);
然而,尽管 b
现在是一个可以修改的 int
引用,但是尝试通过 b
来修改 a
的值是未定义的行为,因为 a
是一个 const
对象。
所以,const_cast
可以用来转换类型,但是它并不能修改 const
对象的值。如果你尝试修改 const
对象的值,结果是未定义的。
示例
如果你有一个 const 类型的变量,但你需要传递给一个只接受非 const 参数的函数,你可以使用 const_cast
来去除 const 属性。
下面是一个具体的例子:
#include<iostream>
void print(int* p) {
*p = 100; // 修改 p 指向的值
std::cout << *p << std::endl;
}
int main() {
const int a = 10;
print(const_cast<int*>(&a)); // 使用 const_cast 去除 const 属性
return 0;
}
在这个例子中,我们有一个 const int 类型的变量 a
,我们使用 const_cast
去除了 a
的 const 属性,然后将其传递给了 print
函数。
然而,这里的关键是,尽管 const_cast
可以移除 const
属性,但它并不能改变原始数据的 const
性质。也就是说,如果原始数据是 const
的(就像这个例子中的 a
),那么尝试修改它的值将导致未定义的行为。
在这个例子中,尽管 print
函数中的输出是 100,但实际上 a
的值并没有改变。这是因为 a
是一个 const
变量,编译器将其存储在只读内存中,所以我们无法真正改变它的值。这就是为什么我们说 const_cast
不能真正改变 const
对象的值。
所以,尽管看起来 print
函数修改了 a
的值,但实际上并没有。这是因为尝试修改 const
对象的值是未定义的行为,可能会导致程序崩溃或者其他不可预知的结果。因此,我们应该避免这种使用 const_cast
的方式。
实现细节
const_cast
的实现是由编译器完成的,它并不会生成任何实际的运行时代码。const_cast
主要是在编译时期改变类型系统中的 const
或 volatile
属性。
当编译器遇到 const_cast
时,它会检查转换的合法性。如果转换是合法的(例如,从 const int*
到 int*
),那么编译器就会接受这个转换,并在类型系统中改变相应的属性。然后,编译器会生成与原类型相同,但 const
或 volatile
属性被修改的新类型。
总的来说,const_cast
的实现主要是编译器在类型系统中改变 const
或 volatile
属性,它并不会生成任何实际的运行时代码。
使用场景
const_cast
在 C++ 中主要用于修改类型的 const 或 volatile 属性。除了上述的例子外,还有一些其他的使用场景:
- 修改函数参数的 const 属性:有些函数参数为 const 类型,但在函数内部我们可能需要修改这些参数。这时,我们可以使用
const_cast
来去除参数的 const 属性。
void func(const int& x) {
int& y = const_cast<int&>(x);
y = 10; // 修改 x 的值
}
- 修改成员函数的 const 属性:有时,我们需要在 const 成员函数中修改某些成员变量。由于 const 成员函数不能修改成员变量,我们可以使用
const_cast
来去除成员变量的 const 属性。
class MyClass {
public:
MyClass() : x(0) {}
void func() const {
MyClass* p = const_cast<MyClass*>(this);
p->x = 10; // 修改 x 的值
}
private:
int x;
};
- 实现只读和读写版本的成员函数:有时,我们需要提供一个成员函数的只读版本和读写版本。我们可以实现读写版本的函数,然后在只读版本的函数中调用读写版本的函数。
class MyClass {
public:
const char& operator[](std::size_t position) const {
return const_cast<MyClass&>(*this)[position];
}
char& operator[](std::size_t position) {
// 实现读写版本的函数
}
};
请注意,const_cast
的使用需要谨慎,因为它可能会导致未定义的行为。在使用 const_cast
时,我们需要确保原始数据不是 const,否则修改 const 数据的结果是未定义的。
const 变量内存布局
在 C++ 中,const
变量的存储位置取决于它是否有初始化值以及它的作用域。
-
如果
const
变量在全局作用域中,并且有初始化值,那么它通常存储在只读数据段(.rodata)中。这部分内存是只读的,所以尝试修改这部分内存的值会导致程序崩溃。 -
如果
const
变量在全局作用域中,但没有初始化值,那么它通常存储在 BSS 段中。BSS 段用于存储未初始化的全局变量和静态变量。 -
如果
const
变量在局部作用域中,那么它通常存储在栈上,就像其他的局部变量一样。
这些都是通常情况,具体的存储位置可能会因编译器的实现和优化策略而有所不同。
总结
const_cast
是 C++ 的类型转换运算符,用于修改类型的 const
或 volatile
属性,但不能改变底层类型或值。尽管可以用 const_cast
移除 const
属性并尝试修改值,但如果原始数据是 const
,这将导致未定义的行为。const_cast
的实现主要是编译器在类型系统中改变 const
或 volatile
属性,不会生成实际的运行时代码。const_cast
的使用场景包括修改函数参数的 const
属性,修改成员函数的 const
属性,以及实现只读和读写版本的成员函数。
reinterpret_cast
是 C++ 中的一种类型转换运算符,它可以在任何指针或引用类型之间进行转换,也可以在任何整数类型和指针类型之间进行转换。reinterpret_cast
提供了一种低级别的类型转换,它基本上依赖于位模式的重新解释。
使用
需要注意的是,reinterpret_cast
是非常危险的,因为它不进行任何类型检查或转换,只是简单地将源类型的位模式重新解释为目标类型。这可能会导致未定义的行为。
下面是一个具体的例子:
#include <iostream>
int main() {
int a = 10;
// 使用 reinterpret_cast 将 int* 转换为 char*
char* p = reinterpret_cast<char*>(&a);
// 输出 a 的第一个字节
std::cout << *p << std::endl;
return 0;
}
在这个例子中,我们有一个 int
类型的变量 a
,我们使用 reinterpret_cast
将 &a
(类型为 int*
)转换为 char*
类型,然后将其赋值给 p
。然后我们通过 p
输出 a
的第一个字节。
这个例子展示了 reinterpret_cast
的基本用法,但是需要注意的是,reinterpret_cast
是非常危险的,应该尽量避免使用。在大多数情况下,我们应该使用其他更安全的类型转换运算符,如 static_cast
或 dynamic_cast
。
使用场景
以下是一些 reinterpret_cast
的使用场景:
- 指针类型之间的转换:当你需要将一个类型的指针转换为另一个类型的指针时,可以使用
reinterpret_cast
。例如,你可能需要将void*
指针转换为具体类型的指针。
void* ptr = ...;
int* intPtr = reinterpret_cast<int*>(ptr);
- 引用类型之间的转换:
reinterpret_cast
也可以用于引用类型之间的转换。这通常在你需要将一个对象视为另一种完全不同类型的对象时使用。
int a = 10;
double& b = reinterpret_cast<double&>(a);
- 整数类型和指针类型之间的转换:
reinterpret_cast
可以用于整数类型和指针类型之间的转换。这在处理底层硬件或操作系统接口时可能会用到,例如,你可能需要将一个地址显式转换为一个指针。
uintptr_t addr = ...;
int* ptr = reinterpret_cast<int*>(addr);
- 类型别名:在某些情况下,你可能需要将一个数据类型强制转换为其类型别名。这在处理 C 语言的旧代码时可能会用到。
typedef int INT32;
long a = 10;
INT32& b = reinterpret_cast<INT32&>(a);
需要注意的是,reinterpret_cast
是非常危险的,因为它不进行任何类型检查或转换,只是简单地将源类型的位模式重新解释为目标类型。这可能会导致未定义的行为。因此,除非你确切知道你在做什么,否则应该尽量避免使用 reinterpret_cast
。
和传统转换的区别
reinterpret_cast
和传统的 C 风格转换(如 (int)ptr
或 int(ptr)
)在功能上有一些相似之处,都可以进行低级别的类型转换,但它们之间还是存在一些重要的区别:
-
类型检查:C++ 的
reinterpret_cast
在编译时不进行任何类型兼容性检查,只是简单地将源类型的位模式重新解释为目标类型。而传统的 C 风格转换在某些情况下会进行类型兼容性检查。 -
转换范围:
reinterpret_cast
可以在任何指针或引用类型之间进行转换,也可以在任何整数类型和指针类型之间进行转换。而传统的 C 风格转换不能在所有类型之间进行转换。 -
安全性:由于
reinterpret_cast
不进行类型检查,因此它比传统的 C 风格转换更危险。如果不正确地使用reinterpret_cast
,可能会导致未定义的行为。 -
可读性:
reinterpret_cast
明确地表明了程序员的意图,而传统的 C 风格转换则没有这么明显。使用reinterpret_cast
可以使代码的读者更容易理解代码的意图。
总的来说,reinterpret_cast
提供了一种低级别的类型转换机制,它比传统的 C 风格转换更强大,但也更危险。在大多数情况下,我们应该优先使用其他更安全的 C++ 类型转换运算符,如 static_cast
或 dynamic_cast
。
总结
reinterpret_cast
是 C++ 的类型转换运算符,用于进行低级别的类型转换,包括指针、引用和整数类型之间的转换。它不进行类型检查,只是重新解释源类型的位模式为目标类型,因此使用时需谨慎,可能导致未定义的行为。与传统的 C 风格转换相比,reinterpret_cast
更强大但也更危险,它明确表明了程序员的转换意图,但不进行类型检查,可能导致未定义的行为。
注意事项
如何在代码中提供更明确的语义?
注释是写给人看的,编译器不会参考注释,所以要尽可能的在代码中更清晰的表达意图,更强大的约束。这样使得编译器和其他工具也能对代码进行正确的处理和检查。下面结合了一些具体的使用场景来讲解这一点。
时间
这段代码中定义了一个Date
类,其中包含两个名为month
的成员函数。虽然两个函数都能实现相同的功能,但是第一个是推荐做法,第二个不推荐。
class Date {
public:
Month month() const; // 好
int month(); // 坏
// ...
};
第一个month
函数是一个常量成员函数,返回类型为Month
。这个函数的声明表明它不会修改Date
对象的状态。这是因为它后面带有const
关键字,这意味着这个函数不能修改类的任何成员变量(除非它们被声明为mutable
)。这是一个好的设计,因为它明确了函数的行为:这个函数只是获取月份,不会改变日期对象的状态。
第二个month
函数返回一个int
,并且它不是一个常量成员函数。这意味着它可能会修改Date
对象的状态。这是一个不好的设计,因为它的行为不清晰。从函数名month
来看,我们可能会认为这个函数应该只是获取月份,而不应该改变Date
对象的状态。但是,由于它不是一个常量成员函数,我们不能确定它是否会改变对象的状态。这可能会导致错误的使用,例如,如果一个函数只想获取月份,但不希望改变日期对象,而它错误地调用了这个版本的month
函数,那么就可能会导致意外的结果。
总的来说,当你设计类的成员函数时,应该尽可能地使函数的行为明确。如果一个函数不会修改对象的状态,那么就应该将其声明为常量成员函数。这样,使用者就能清楚地知道这个函数的行为,从而减少错误的可能性。
循环遍历
下面是两段功能一模一样的代码,都是循环便利一个数组的代码。但是第一段是不推荐的做法,第二段是建议做法。
void f(vector<string>& v)
{
string val;
cin >> val;
// ...
int index = -1; // 坏
for (int i = 0; i < v.size(); ++i) {
if (v[i] == val) {
index = i;
break;
}
}
// ...
}
这个例子中,使用了一个手动的循环来查找vector
中的一个元素。这种方法的问题在于,它需要手动管理循环变量和索引,而且如果忘记在找到元素后使用break
语句,可能会导致错误的结果。此外,这种方法的可读性较差,因为需要阅读和理解整个循环结构才能明白它的目的。
void f(vector<string>& v)
{
string val;
cin >> val;
// ...
auto p = find(begin(v), end(v), val); // 好
// ...
}
这个例子中,使用了标准库函数std::find
来查找vector
中的一个元素。这种方法的优点在于,它更简洁,更易于理解,因为std::find
的功能就是查找一个元素。此外,使用标准库函数可以减少错误的可能性,因为不需要手动管理循环变量和索引。最后,如果在未来需要更改容器类型(例如,从vector
更改为list
或set
),使用标准库函数可以使这种更改更容易,因为std::find
可以用于任何容器,而手动的循环可能需要根据容器的特性进行修改。
参数设计
change_speed(double s); // 不好:s表示什么?
// ...
change_speed(2.3);
在这个例子中,函数change_speed(double s)
的问题在于,参数s
的含义并不清晰。s
代表什么?是新的速度值?还是速度的增量?另外,s
的单位是什么?是米/秒,还是千米/小时?这些都是不清晰的。
更好的做法是使用一个明确的类型,如Speed
,来表示速度。这样,change_speed(Speed s)
函数的参数s
的含义就很清晰了:它是一个速度值。此外,Speed
类型可以包含单位信息,这样就可以避免单位不清的问题。
change_speed(Speed s); // 更好:s的含义已经指定
// ...
change_speed(2.3); // 错误:没有单位
change_speed(23_m / 10s); // 米每秒
然后,当你调用change_speed(2.3)
时,编译器会报错,因为2.3
是一个double
类型,而change_speed
函数需要一个Speed
类型的参数。这是一个好事,因为它防止了单位不清的问题。你应该使用change_speed(23_m / 10s)
来调用这个函数,其中23_m
表示23米,10s
表示10秒,所以整个表达式表示的是2.3米/秒。
如果你既想要表示绝对速度,又想要表示速度的增量,你可以定义一个Delta
类型。这样,你可以使用change_speed(Delta d)
来改变速度,其中d
是速度的增量。这样,你的代码就会更清晰,更容易理解,也更不容易出错。
一些例子
代码的预期行为通常是由开发者在编写代码时设定的。如果没有明确的说明(例如,函数或变量的名称,或者代码注释),那么其他人可能无法准确地理解代码的预期行为。
函数名
例如,考虑以下 C++ 代码片段:
int calculate(int a, int b) {
return a + b;
}
从这个函数的名称calculate
来看,我们无法确定它的具体行为。它可能是用来计算两个数的和,也可能是用来计算两个数的差,乘积,或者其他什么。只有当我们查看函数的实现,我们才能确定这个函数是用来计算两个数的和的。
但是,如果我们将函数名改为add
,那么就能明确地知道这个函数的预期行为是计算两个数的和,即使我们没有看到函数的实现。
int add(int a, int b) {
return a + b;
}
同样,如果我们在函数上添加适当的注释,那么即使函数的名称不够明确,其他人也能理解这个函数的预期行为。
// 计算两个数的和
int calculate(int a, int b) {
return a + b;
}
因此,为了使代码的预期行为更容易被理解,我们应该尽可能地使用描述性的名称,并在必要时添加适当的注释。
遍历集合
在遍历集合时,如何更好地表达代码的意图和避免潜在的错误。
首先,考虑这段代码:
gsl::index i = 0;
while (i < v.size()) {
// ... 对 v[i] 做一些事情 ...
}
这段代码使用了一个索引 i
来遍历集合 v
。但是,这种方式并没有明确表达出只是遍历 v
的元素的意图。此外,索引的实现细节被暴露出来,这可能导致误用。最后,i
在循环结束后仍然存在,这可能是或可能不是预期的。
更好的方式是使用范围 for 循环:
for (const auto& x : v) { /* 对 x 的值做一些事情 */ }
这段代码明确表示了我们只是遍历 v
的元素。此外,我们使用 const
引用 x
来访问元素,这样就无法意外地修改元素的值。如果我们想要修改元素的值,我们可以去掉 const
:
for (auto& x : v) { /* 修改 x */ }
有时候,使用命名的算法可能是更好的选择。例如,我们可以使用 for_each
算法来遍历集合:
for_each(v, [](int x) { /* 对 x 的值做一些事情 */ });
for_each(par, v, [](int x) { /* 对 x 的值做一些事情 */ });
这两个例子都使用了 for_each
算法来遍历 v
。第一个例子是单线程遍历,第二个例子是多线程遍历。在第二个例子中,我们使用了 par
参数来表示我们对 v
的元素的处理顺序不感兴趣,这意味着算法可以在多个线程中并行处理 v
的元素。
参数设计
当函数的参数列表包含多个相同类型的参数时,往往会使代码的阅读者感到困惑,因为他们可能不清楚每个参数的具体含义。
例如,考虑以下函数:
void draw_line(int, int, int, int);
这个函数接受四个 int
参数,但是从函数签名中我们无法确定这四个参数的具体含义。它们可能代表两个二维点的坐标 (x1, y1, x2, y2)
,也可能代表一个点的坐标和高度宽度 (x, y, h, w)
,或者其他的含义。为了理解这个函数的行为,我们可能需要查阅相关的文档。
相反,如果我们使用自定义的数据类型(例如,一个表示二维点的 Point
类),那么函数的签名就会变得更加清晰:
void draw_line(Point, Point);
这个函数接受两个 Point
参数,从函数签名中我们可以清楚地看出,这两个参数代表的是两个二维点。这样,代码的阅读者就可以更容易地理解这个函数的行为,而无需查阅相关的文档。
总结
注释虽然能帮助人们理解代码,但编译器并不会参考它,因此我们应该尽可能地在代码中清晰地表达意图。例如,使用const
关键字明确函数不会修改对象状态,使用标准库函数如std::find
替代手动循环提高代码可读性和减少错误,以及使用明确的类型和单位来表示函数参数,而不是依赖于不清晰的double
类型。这些做法都能使得代码更易于理解和维护,同时减少错误的可能性。
参考
- 《C++ Core Guide Line》
在编写程序时,我们应该尽可能地让编译器在编译阶段就能检测出类型错误,而不是在运行时才发现。
为什么?
这种做法的主要原因有以下几点:
提前发现错误:如果在编译阶段就能发现类型错误,那么我们就可以在程序运行之前修复这些错误,避免程序在运行时崩溃。
提高代码质量:静态类型检查可以帮助我们编写更安全、更健壮的代码。因为编译器会在编译阶段检查我们的代码,确保我们没有进行不安全的类型转换或者使用错误的类型。
提高开发效率:如果在编译阶段就能发现错误,那么我们就不需要花费大量的时间在调试程序上。这可以大大提高我们的开发效率。
提高程序性能:静态类型语言通常可以生成更优化的代码,因为编译器在编译阶段就知道了所有变量的类型。这可以提高程序的运行效率。
总的来说,静态类型安全可以帮助我们提前发现错误,提高代码质量和开发效率,以及提高程序性能。
有哪些具体措施?
联合:联合(Union)是一种可以存储不同类型数据的变量,但是一次只能存储其中一种类型的数据。在C++中,使用联合可能会导致类型安全问题。例如:
union Data {
int i;
float f;
char str[20];
} data;
data.i = 10; // 此时,data中存储的是int类型的数据
data.f = 220.5; // 现在,data中存储的是float类型的数据,之前的int类型数据被覆盖
在C++17中,我们可以使用std::variant
来替代联合,它是类型安全的:
std::variant<int, float, std::string> data;
data = 10; // 存储int类型的数据
data = 220.5f; // 存储float类型的数据,不会覆盖之前的数据
强制转换:强制转换可能会导致类型安全问题,因为它可以将任何类型的数据转换为任何其他类型的数据。在C++中,我们应该尽量避免使用强制转换,而是使用模板来实现类型安全的转换。
数组衰减:在C++中,当数组作为函数参数时,它会自动转换(衰减)为指向数组首元素的指针,这可能会导致类型安全问题。我们可以使用gsl::span
来替代原生数组,它可以保持数组的大小信息,从而避免数组衰减:
void foo(gsl::span<int> arr) {
// 使用arr,它包含数组的大小信息
}
int arr[10];
foo(arr); // 传递数组到函数,不会发生数组衰减
范围错误:当我们访问数组或其他容器时,如果索引超出了容器的范围,就会发生范围错误。我们可以使用gsl::span
来避免范围错误,因为它会在访问超出范围的元素时抛出异常。
缩小转换:缩小转换是指将一个大范围的类型转换为小范围的类型,这可能会导致数据丢失或溢出。我们应该尽量避免使用缩小转换,如果必须使用,可以使用gsl::narrow
或gsl::narrow_cast
来进行安全的缩小转换:
int i = gsl::narrow<int>(1234567890L); // 安全的缩小转换,如果数据丢失或溢出,会抛出异常
以上就是关于静态类型安全的一些具体措施,希望对你有所帮助。
有哪些只能在运行时检查的例子?
然而在编译期检查出所有错误是不可能的,有些情况只能放到运行时做检查。
例如下的例子,以数组传递为例:
extern void f(int* p);
void g(int n)
{
f(new int[n]);
}
这段代码中,函数 g
创建了一个动态数组,并将数组的首地址传递给了函数 f
。然而,这里存在一个问题,那就是 f
函数并不知道这个数组的大小。这是因为在 C++ 中,原生数组并不知道自己的大小,而且这个信息也没有被传递给 f
函数。
这种设计使得错误检测变得非常困难。静态分析可能无法确定数组的大小,因为这个信息在编译时并不可用。同时,如果 f
函数是一个应用二进制接口(ABI)的一部分,那么在运行时进行动态检查也可能非常困难,因为我们不能添加额外的信息来帮助我们跟踪这个指针。
一种可能的解决方案是将数组的大小信息嵌入到自由存储中,但这需要对系统和可能对编译器进行全局更改,这可能是不可行的。
因此,这段代码展示了一个不良的设计,它使得错误检测变得非常困难。在实际编程中,我们应该尽量避免这种设计。例如,我们可以使用标准库中的 std::vector
或 std::array
,这些容器知道自己的大小,并且可以安全地传递给其他函数。
增加参数
下面这段代码中,函数 g2
创建了一个动态数组,并将数组的首地址和大小一起传递给了函数 f2
。这种做法比仅传递数组的首地址要好,因为它使得 f2
函数能够知道数组的大小。
extern void f2(int* p, int n);
void g2(int n)
{
f2(new int[n], m);
}
然而,这里仍然存在一个问题。在调用 f2
函数时,传递的数组大小是 m
,而不是 n
。这是一个拼写错误,但是它可能会引入一个严重的错误。因为 m
可能并不等于 n
,所以 f2
函数可能会以错误的大小来处理数组,这可能会导致数组越界等问题。
此外,这段代码还暗示了 f2
函数应该负责删除它接收的数组。这是因为数组是在 g2
函数中通过 new
创建的,但是并没有在 g2
函数中被删除。如果 f2
函数并没有删除这个数组,那么这个数组就会成为一个内存泄漏。这是一个设计问题,因为它使得内存管理的责任变得不清晰。
总的来说,这段代码展示了一种将数组的大小和首地址一起传递的做法,这种做法比仅传递数组的首地址要好。然而,它也展示了一些可能的问题,包括拼写错误和内存管理的问题。在实际编程中,我们应该尽量避免这些问题。例如,我们可以使用标准库中的 std::vector
,这个容器可以自动管理内存,并且它的大小是明确的,可以安全地传递给其他函数。
使用智能指针
这段代码中,函数 g3
创建了一个动态数组,并使用 std::unique_ptr
来管理这个数组的内存。然后,它将这个 std::unique_ptr
和数组的大小一起传递给了函数 f3
。
extern void f3(unique_ptr<int[]>, int n);
void g3(int n)
{
f3(make_unique<int[]>(n), m);
}
std::unique_ptr
是一个智能指针,它可以自动管理它所指向的内存。当 std::unique_ptr
被销毁时,它会自动删除它所指向的内存。这样可以避免内存泄漏的问题。
然而,这里仍然存在一个问题。在调用 f3
函数时,传递的数组大小是 m
,而不是 n
。这是一个拼写错误,但是它可能会引入一个严重的错误。因为 m
可能并不等于 n
,所以 f3
函数可能会以错误的大小来处理数组,这可能会导致数组越界等问题。
此外,这段代码将数组的所有权和大小分别传递给了 f3
函数。这意味着 f3
函数需要同时管理这个数组的内存和大小。这可能会使得代码变得复杂,因为 f3
函数需要同时处理内存管理和数组大小的问题。
总的来说,这段代码展示了一种使用 std::unique_ptr
来管理动态数组的内存的做法,这种做法可以避免内存泄漏的问题。然而,它也展示了一些可能的问题,包括拼写错误和将数组的所有权和大小分别传递的问题。在实际编程中,我们应该尽量避免这些问题。例如,我们可以使用标准库中的 std::vector
,这个容器可以自动管理内存,并且它的大小是明确的,可以安全地传递给其他函数。
使用 vector
这段代码中,函数 g3
创建了一个 std::vector<int>
对象 v
,并将其传递给了函数 f4
。std::vector
是一个动态数组,它知道自己的大小,并且可以自动管理内存。
extern void f4(vector<int>&);
extern void f4(span<int>);
void g3(int n)
{
vector<int> v(n);
f4(v);
f4(span<int>{v});
}
在这里,f4
函数有两个重载版本。一个接受 std::vector<int>&
作为参数,另一个接受 std::span<int>
作为参数。std::span
是一个轻量级的对象,它可以视为一个数组或其他类型的连续序列的视图。它包含一个指向序列开始的指针和一个表示序列大小的值。
在 g3
函数中,首先调用了接受 std::vector<int>&
参数的 f4
函数。这个调用将 v
的引用传递给 f4
,这意味着 f4
可以访问和修改 v
,但是 v
的所有权仍然在 g3
函数中。
然后,调用了接受 std::span<int>
参数的 f4
函数。这个调用创建了一个 std::span
对象,它是 v
的视图,然后将这个视图传递给 f4
。这意味着 f4
可以访问 v
,但是不能修改 v
,并且 v
的所有权仍然在 g3
函数中。
这种设计将数组的大小和首地址作为一个整体对象进行传递,这可以避免一些错误,例如拼写错误和内存管理的问题。同时,由于 std::vector
和 std::span
都知道自己的大小,所以可以在运行时进行动态检查。
参数返回值传递方式
这段代码中,函数 f5
、f6
和 f7
都创建了一个大小为 n
的数组,并返回这个数组。这些函数的目的都是传递数组的所有权,但是它们的实现方式和效果是不同的。
vector<int> f5(int n)
{
vector<int> v(n);
// ... 初始化 v ...
return v;
}
函数 f5
创建了一个 std::vector<int>
对象 v
,并返回这个对象。std::vector
是一个动态数组,它知道自己的大小,并且可以自动管理内存。当 v
被返回时,它的所有权会被移动到调用者那里,这是通过移动语义实现的。这种做法是安全的,因为 std::vector
会自动删除它所管理的内存,所以不会出现内存泄漏的问题。同时,由于 std::vector
知道自己的大小,所以调用者可以安全地使用这个数组。
unique_ptr<int[]> f6(int n)
{
auto p = make_unique<int[]>(n);
// ... 初始化 *p ...
return p;
}
函数 f6
创建了一个 std::unique_ptr<int[]>
对象 p
,并返回这个对象。std::unique_ptr
是一个智能指针,它可以自动管理它所指向的内存。然而,这里存在一个问题,那就是 p
并不知道它所指向的数组的大小。这是因为在 C++ 中,原生数组并不知道自己的大小,而 std::unique_ptr
也没有提供一种方式来获取这个大小。因此,调用者需要以某种方式知道这个数组的大小,否则它可能会以错误的方式来使用这个数组。
owner<int*> f7(int n)
{
owner<int*> p = new int[n];
// ... 初始化 *p ...
return p;
}
函数 f7
创建了一个原生数组,并返回这个数组的首地址。这个首地址被封装在一个 owner<int*>
对象中,这个对象的目的是表明返回的指针是一个所有权指针,也就是说,调用者需要负责删除这个数组。然而,这里存在两个问题。首先,和 f6
函数一样,返回的指针并不知道它所指向的数组的大小。其次,虽然 owner<int*>
表明了返回的指针是一个所有权指针,但是它并没有提供一种自动删除数组的机制,所以调用者可能会忘记删除这个数组,这会导致内存泄漏的问题。
总的来说,这段代码展示了三种传递数组所有权的做法。其中,f5
函数的做法是最好的,因为它可以安全地传递数组的所有权,并且可以保证调用者知道数组的大小。f6
和 f7
函数的做法存在一些问题,主要是它们丢失了数组的大小信息,而 f7
函数还可能导致内存泄漏的问题。在实际编程中,我们应该尽量避免这些问题,例如,我们可以使用 std::vector
或其他知道自己大小的容器来代替原生数组。
参考
- C++ Core Guidelines.
现代 C++
C++11
enum class 作用
enum class
(也称为强类型枚举)是在 C++11 标准中引入的。C++11 带来了许多重要的语言特性和改进,enum class
是其中之一。与传统的枚举(enum
)相比,enum class
提供了更好的类型安全性和作用域控制。它避免了与整数类型的隐式转换,并确保枚举值在其定义的枚举类中是独立的。
示例:使用 enum class
假设我们正在编写一个关于交通信号灯的程序,我们可以定义一个 enum class
来表示不同的信号灯状态:
#include <iostream>
// 定义一个名为 TrafficLight 的 enum class
enum class TrafficLight {
Red,
Yellow,
Green
};
// 函数来返回交通灯的状态
std::string getTrafficLightStatus(TrafficLight light) {
switch (light) {
case TrafficLight::Red:
return "Stop";
case TrafficLight::Yellow:
return "Caution";
case TrafficLight::Green:
return "Go";
default:
return "Unknown";
}
}
int main() {
// 创建一个 TrafficLight 类型的变量
TrafficLight light = TrafficLight::Red;
// 打印交通灯的状态
std::cout << "The traffic light is " << getTrafficLightStatus(light) << std::endl;
return 0;
}
在这个例子中,enum class TrafficLight
定义了三个可能的值:Red
、Yellow
和 Green
。它们分别对应不同的交通信号灯状态。由于我们使用了 enum class
而不是传统的 enum
,这些值在 TrafficLight
枚举类中是唯一的。这意味着我们不能随意将它们与整数互换,也不能与其他枚举类中的值混淆。
在 getTrafficLightStatus
函数中,我们使用 switch
语句来根据交通灯的状态返回相应的描述。注意,在引用枚举类中的值时,我们需要使用完全限定的名称,例如 TrafficLight::Red
。
为什么使用 enum class
-
类型安全:
enum class
防止了与整数的隐式转换,减少了类型错误。 -
作用域控制:
enum class
中的枚举值有自己的作用域,并不会污染它们所在的命名空间。 -
明确性:使用
enum class
提高了代码的可读性和可维护性。
enum class
是 C++11 引入的特性,提供了一种更现代、更安全的方式来定义枚举类型。
enum class 和 enum 的区别
enum class
(强类型枚举)和传统的 enum
(非强类型枚举)是 C++ 中两种不同的枚举类型,它们之间存在几个关键区别:
在 C++ 中,enum
和 enum class
都用于定义枚举类型,但它们有着明显的不同。下面我将通过具体的例子来展示这两者之间的区别。
示例:使用传统的 enum
enum Color {
RED, // 0
GREEN, // 1
BLUE // 2
};
void printColor(int color) {
if (color == RED) {
std::cout << "RED" << std::endl;
} else if (color == GREEN) {
std::cout << "GREEN" << std::endl;
} else if (color == BLUE) {
std::cout << "BLUE" << std::endl;
}
}
int main() {
Color color = RED;
printColor(color);
return 0;
}
在这个例子中,Color
是一个传统的 enum
。enum
的一个主要问题是它的值会污染所在的作用域。比如,RED
、GREEN
和 BLUE
都是全局的,可能会与其他同名的标识符发生冲突。
示例:使用 enum class
enum class Color {
RED, // 0
GREEN, // 1
BLUE // 2
};
void printColor(Color color) {
if (color == Color::RED) {
std::cout << "RED" << std::endl;
} else if (color == Color::GREEN) {
std::cout << "GREEN" << std::endl;
} else if (color == Color::BLUE) {
std::cout << "BLUE" << std::endl;
}
}
int main() {
Color color = Color::RED;
printColor(color);
return 0;
}
在这个例子中,Color
是一个 enum class
。与传统的 enum
相比,enum class
引入了作用域。这意味着枚举值(如 RED
、GREEN
和 BLUE
)必须在其类型名(如 Color::
)之后进行访问。这提供了更好的类型安全性和名称冲突的避免。
总结对比
- 命名空间污染:传统的
enum
可能会导致命名空间污染,因为它的枚举值是在全局作用域中。而enum class
的枚举值则位于自己的作用域内,不会与外部作用域冲突。 - 类型安全:
enum class
提供了更好的类型安全性。例如,你不能直接将enum class
类型的变量赋值给int
(除非显式转换),这减少了不同枚举类型间的混用。 - 强制作用域:在
enum class
中,必须使用作用域运算符(::
)来访问枚举值,这使得代码更加清晰可读。
更多 enum class
示例
示例 1:表示星期
enum class Weekday {
Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday
};
Weekday today = Weekday::Friday;
在这个例子中,Weekday
是一个 enum class
,包含一周的七天。要引用这些值,需要使用 Weekday::
作为前缀。
示例 2:具有显式底层类型的枚举类
enum class StatusCode : unsigned int {
Ok = 200,
NotFound = 404,
ServerError = 500
};
StatusCode response = StatusCode::NotFound;
在这个例子中,StatusCode
是一个具有显式底层类型 unsigned int
的 enum class
。这对于需要特定整数值的枚举很有用。
示例 3:与 switch
语句结合使用
enum class Direction {
North, East, South, West
};
Direction heading = Direction::North;
switch (heading) {
case Direction::North:
// 北
break;
case Direction::East:
// 东
break;
case Direction::South:
// 南
break;
case Direction::West:
// 西
break;
}
在这个例子中,我们定义了一个表示方向的 enum class
,然后在 switch
语句中使用它。
总结
enum class
提供了比传统 enum
更好的类型安全和作用域控制,避免了许多常见的编程错误。它是 C++11 引入的现代 C++ 特性之一,推荐在需要枚举类型时使用。
STL
STL,全称为Standard Template Library(标准模板库),是C++标准库的一个重要部分,提供了一系列模板化的通用数据结构和算法。它的设计目的是提高软件的复用性、效率和抽象能力。STL 共有六个组成部分,分别是容器、算法、迭代器、仿函数、适配器和配置器,接下来展开讲解。
容器(Container)
容器是用来管理某一类对象的集合。STL提供了多种不同类型的容器,比如序列容器(如vector
、list
)和关联容器(如map
、set
)。
#include <vector>
#include <map>
std::vector<int> vec; // 动态数组
std::map<int, std::string> mp; // 键-值对集合
算法(Algorithm)
算法是对数据进行操作的方法,STL提供了一系列通用算法,比如查找、排序、复制、修改等。
#include <algorithm>
#include <vector>
std::vector<int> vec = {4, 2, 5, 1, 3};
std::sort(vec.begin(), vec.end()); // 排序算法
迭代器(Iterator)
迭代器是一种访问容器中元素的对象,类似于指针。它提供了对容器元素的遍历功能。
std::vector<int>::iterator it = vec.begin();
for (; it != vec.end(); ++it) {
std::cout << *it << " ";
}
仿函数(Function object,Functor)
仿函数是一种重载了operator()
的类,可以像函数一样被调用。
struct Add {
int operator()(int a, int b) {
return a + b;
}
};
Add add;
std::cout << add(3, 4); // 输出 7
适配器(Adaptor)
适配器是一种特殊的容器、迭代器或函数对象,它提供了不同的接口或行为。
#include <queue>
std::priority_queue<int> pq; // 优先队列
空间配置器(Allocator)
空间配置器是用于管理容器内存分配的对象。
在STL中,通常不需要直接与空间配置器打交道,因为STL容器已经内置了默认的空间配置器。
std::allocator<int> alloc; // 默认分配器
int* p = alloc.allocate(10); // 分配空间
alloc.deallocate(p, 10); // 释放空间
在这个例子中,std::allocator
是一个空间配置器的示例,用于分配和释放整型数值的内存。
总结
STL是C++标准库的一个重要部分,提供了丰富的数据结构和算法,使得数据处理和操作更加高效和灵活。通过结合容器、算法、迭代器、仿函数、适配器和空间配置器,STL为C++程序员提供了一个强大的工具集,以帮助解决各种复杂的编程问题。
空间配置器
在 C++ 标准模板库(STL)中,空间配置器(allocator)是用于控制容器对象如何分配和释放内存的组件。std::allocator
是最常用的配置器,它是所有标准容器的默认配置器。
空间配置器的概念
- 作用:配置器用于抽象内存分配和释放的过程,从而使容器可以独立于具体的内存分配机制。
- 自定义配置器:你可以提供自己的配置器来改变内存分配的行为(例如,为了提高效率或进行特殊的内存管理)。
std::allocator
的基本使用
- 类型:
std::allocator<T>
,其中T
是要分配内存的对象类型。 - 方法:提供了
allocate
,deallocate
,construct
,destroy
等方法来管理内存。
示例:使用 std::allocator
为 int
数组分配内存
#include <iostream>
#include <memory> // 包含 std::allocator
int main() {
std::allocator<int> allocator;
// 分配内存
int* arr = allocator.allocate(5); // 分配5个int的空间
// 构造对象
for (int i = 0; i < 5; ++i) {
allocator.construct(arr + i, i); // 在分配的内存上构造int
}
// 使用分配的内存
for (int i = 0; i < 5; ++i) {
std::cout << arr[i] << ' ';
}
std::cout << '\n';
// 析构和释放内存
for (int i = 0; i < 5; ++i) {
allocator.destroy(arr + i); // 析构对象
}
allocator.deallocate(arr, 5); // 释放内存
return 0;
}
在这个示例中,我们使用 std::allocator<int>
分配了足够存储5个 int
的内存,然后在这些空间上构造了 int
对象。使用完毕后,我们销毁了这些对象并释放了内存。
自定义配置器
虽然 std::allocator
足够一般使用,但在某些情况下,你可能想要自定义配置器。例如,你可能想要一个使用池分配策略的配置器,或者一个记录所有分配和释放操作的配置器。自定义配置器需要实现与 std::allocator
类似的接口。
和 malloc 的区别
在 C++ 中,std::allocator
和 malloc
是两种用于内存分配的机制,它们有一些关键的区别:
std::allocator
-
类型感知:
std::allocator
是一个模板类,它对其分配的对象类型是感知的。这意味着,当你使用std::allocator<T>
分配内存时,它知道每个分配的对象的类型是T
。这允许它在分配内存的同时调用对象的构造函数。 -
与 STL 容器集成:
std::allocator
与 STL 容器紧密集成。所有标准容器都使用分配器来管理其内存,std::allocator
是默认分配器。 -
构造和析构:除了内存分配和释放,
std::allocator
还提供了构造和析构对象的方法(通过construct
和destroy
方法)。 -
异常安全:
std::allocator
通常被设计为异常安全的,这意味着在内存分配失败时,它会抛出异常,而不是返回空指针。
malloc
-
类型不感知:
malloc
是 C 语言的内存分配函数,它对分配的内存类型不感知。它仅根据请求的字节数分配内存,并返回一个void*
指针。它不调用对象的构造函数或析构函数。 -
错误处理:当
malloc
无法分配内存时,它返回一个空指针,而不是抛出异常。这要求程序员显式检查返回值以确定内存分配是否成功。 -
与 C++ 容器不兼容:
malloc
不适用于 C++ STL 容器,因为它不调用构造函数和析构函数。 -
手动类型转换:使用
malloc
分配的内存需要进行手动类型转换,因为它返回的是void*
。
总结
空间配置器是 STL 中一个重要的组件,它提供了一种灵活的方式来控制内存的分配和释放。虽然大多数情况下 std::allocator
足够使用,但 STL 的设计允许你根据需要使用自定义的配置器。这是 C++ 对内存管理提供的一个高级和强大的工具,允许程序员针对特定的需求或性能目标进行优化。
std::allocator
更适合用在 C++ 中,特别是与 STL 容器一起使用,因为它提供了类型感知、对象生命周期管理(构造和析构)和异常安全性。malloc
是一种更原始的内存分配方式,来自 C 语言。它在分配原始内存时很有用,但不适用于需要对象构造和析构的场合。
STL 迭代器简介
在 C++ 的标准模板库(STL)中,迭代器是用来遍历或访问容器中元素的对象,类似于指针。它们提供了一种通用的方法来访问容器的内容,无论容器的底层实现是什么样的。
迭代器的作用
- 访问容器元素:迭代器提供了一种方法来按顺序访问容器中的元素,而不必知道容器的内部结构。
- 容器与算法的桥梁:STL 中的算法,如
sort
,find
,accumulate
等,通常通过迭代器与容器进行交互。 - 支持泛型编程:迭代器使得编写可用于不同类型容器的通用代码成为可能。
为什么需要迭代器,即使有指针
虽然指针可以用于类似的目的,但迭代器更加通用和灵活。迭代器不仅仅局限于数组或类似线性存储结构,它们也适用于如树、图这样的复杂数据结构。此外,迭代器可以为容器提供更丰富的操作集合,而指针的操作通常非常基础。
迭代器和指针都用于访问和遍历数据结构中的元素,但迭代器提供了比指针更多的功能和灵活性。让我们通过比较在不同场景下使用迭代器和指针来理解这一点:
示例 1:使用指针访问数组
#include <iostream>
int main() {
int arr[] = {1, 2, 3, 4, 5};
// 使用指针遍历数组
for (int* ptr = arr; ptr < arr + 5; ++ptr) {
std::cout << *ptr << " ";
}
return 0;
}
在这个例子中,我们使用指针来遍历数组。这是指针的典型应用场景,但它仅限于线性数据结构,如数组。
示例 2:使用迭代器访问 STL 容器
#include <iostream>
#include <vector>
#include <list>
int main() {
std::vector<int> vec = {1, 2, 3, 4, 5};
std::list<int> lst = {1, 2, 3, 4, 5};
// 使用迭代器遍历 vector
for (auto it = vec.begin(); it != vec.end(); ++it) {
std::cout << *it << " ";
}
std::cout << std::endl;
// 使用迭代器遍历 list
for (auto it = lst.begin(); it != lst.end(); ++it) {
std::cout << *it << " ";
}
std::cout << std::endl;
return 0;
}
在这个例子中,我们使用迭代器来遍历 std::vector
和 std::list
。迭代器提供了一种统一的方式来访问不同的容器,无论它们的内部实现如何。同样的迭代器接口可以用于数组、链表、树、图等多种不同的数据结构,这是指针无法做到的。
为什么选择迭代器
- 通用性:迭代器提供了一种统一的接口来访问多种不同的容器,包括那些不能使用普通指针访问的容器(如标准库中的
std::set
、std::map
)。 - 安全性:迭代器可以被设计为更安全,例如,通过检查迭代器是否失效来避免潜在的运行时错误。
- 扩展性:迭代器可以提供比指针更多的操作,如反向遍历(使用反向迭代器)等。
总之,迭代器之所以重要,是因为它们提供了一种比指针更灵活、更安全、更通用的方法来访问和操作各种数据结构。
在 C++ STL 中,迭代器失效是一个常见的问题,主要发生在对容器进行插入、删除等操作后,原有的迭代器可能会失效,继续使用这些迭代器可能会导致未定义的行为。
什么是迭代器失效?
以下是一些具体的例子:
- 对
std::vector
进行push_back
操作可能会导致所有迭代器失效。这是因为std::vector
在内存中是连续存储的,当容量不足以容纳新元素时,std::vector
会重新分配内存,这会导致原有的迭代器失效。
std::vector<int> vec = {1, 2, 3};
auto it = vec.begin();
vec.push_back(4);
// 此时 it 可能已经失效,继续使用 it 可能会导致未定义的行为
- 对
std::list
或std::vector
进行erase
操作会导致被删除元素的迭代器失效。此外,在std::vector
中,被删除元素之后的所有迭代器也会失效。
std::list<int> lst = {1, 2, 3};
auto it = lst.begin();
lst.erase(it);
// 此时 it 已经失效,继续使用 it 会导致未定义的行为
- 对
std::map
进行insert
或erase
操作不会导致其他迭代器失效。但是,被erase
的迭代器会失效。
std::map<int, int> m = {{1, 2}, {3, 4}};
auto it = m.begin();
m.erase(it);
// 此时 it 已经失效,继续使用 it 会导致未定义的行为
总的来说,对 STL 容器进行修改操作时,需要特别注意迭代器失效的问题。在使用迭代器时,应确保迭代器是有效的,避免出现未定义的行为。
顺序容器删除元素
在 C++ STL 中,对于顺序容器(如 vector
、deque
),erase
函数会删除迭代器所指向的元素,并返回下一个有效的迭代器。在删除元素后,被删除元素之后的所有迭代器都会失效。因此,不能使用 erase(it++)
的方式来删除元素,因为这样会导致未定义的行为。正确的做法是使用 erase
函数的返回值来更新迭代器,如下所示:
std::vector<int> vec = {1, 2, 3, 4, 5};
auto it = vec.begin();
while (it != vec.end()) {
if (*it % 2 == 0) { // 删除偶数元素
it = vec.erase(it); // erase 返回下一个有效的迭代器
} else {
++it;
}
}
在上述代码中,我们遍历 vector
,并删除所有的偶数元素。在删除元素时,我们使用 it = vec.erase(it);
来更新迭代器 it
,这样可以确保 it
始终指向一个有效的元素。
关联容器删除元素
在C++中,关联容器(如map、set、multimap、multiset等)的erase
方法用于删除元素。当你删除一个元素时,指向该元素的迭代器会失效,但其他迭代器不会受到影响。这是因为关联容器通常是基于树结构实现的,删除一个节点不会影响到其他节点的位置。
erase
方法的返回类型是void
,这意味着它不会返回一个新的有效迭代器。因此,如果你在遍历容器的过程中删除元素,你需要小心处理迭代器。一种常见的做法是使用it++
(先使用,后自增)。
这是一个具体的例子:
std::map<int, std::string> myMap = {{1, "one"}, {2, "two"}, {3, "three"}};
for(auto it = myMap.begin(); it != myMap.end(); ) {
if(it->first == 2) {
myMap.erase(it++);
} else {
++it;
}
}
在这个例子中,我们遍历myMap
并删除键为2的元素。我们使用it++
来先获取当前迭代器的值,然后再自增。这样,即使erase
方法使当前迭代器失效,it++
也已经返回了下一个有效的迭代器。
如何避免迭代器失效?
在C++中,迭代器失效通常是由于容器的修改操作(如插入、删除元素)导致的。以下是一些避免迭代器失效的常见策略:
-
在修改容器时不使用迭代器。例如,你可以使用容器的成员函数(如
vector::push_back
,list::insert
等)来添加或删除元素。 -
如果你需要在遍历容器的过程中修改容器,那么你应该使用一种特殊的迭代器,如
list::iterator
或forward_list::iterator
,这些迭代器在元素被删除后仍然保持有效。 -
如果你正在使用的迭代器不支持在元素被删除后仍然保持有效,那么你可以在每次修改容器后重新获取迭代器。例如,如果你正在使用
vector::iterator
,那么你可以在每次调用vector::push_back
或vector::erase
后,重新调用vector::begin
来获取新的迭代器。 -
在使用关联容器(如
map
,set
等)时,你可以使用erase(it++)
的方式来删除元素,这样可以确保it
在erase
操作后仍然指向一个有效的元素。
这是一个示例:
std::vector<int> vec = {1, 2, 3, 4, 5};
for(auto it = vec.begin(); it != vec.end(); ) {
if(*it % 2 == 0) {
it = vec.erase(it);
} else {
++it;
}
}
在这个例子中,我们遍历vec
并删除所有的偶数。我们使用it = vec.erase(it)
来删除当前元素并获取下一个元素的迭代器。这样,即使当前元素被删除,it
也仍然是一个有效的迭代器。
总结
在C++的STL中,迭代器失效主要是由于对容器进行修改操作(如插入、删除元素)导致的。这些操作可能会改变容器中元素的存储位置,使原有的迭代器不再指向有效的元素。对于不同类型的容器,迭代器失效的规则有所不同。例如,对于std::vector
,插入或删除元素可能会导致所有迭代器失效;而对于std::list
和std::forward_list
,只有指向被插入或删除元素的迭代器会失效;对于关联容器(如std::map
,std::set
等),插入操作不会使任何迭代器失效,删除操作只会使指向被删除元素的迭代器失效。因此,编程时需要特别注意迭代器的有效性,避免出现未定义的行为。
STL(Standard Template Library,标准模板库)中的容器可以从两个主要的角度进行分类:序列容器和关联容器。
-
序列容器:序列容器是元素的集合,其中每个元素都有一个固定的位置:位置取决于元素的插入顺序和元素之间的相对位置。
-
关联容器:关联容器是元素的集合,其中每个元素都有一个键和一个值,并且每个键只出现一次。元素的位置由其键决定,而不是插入的顺序。
选择哪种类型的容器取决于你的具体需求,例如,如果你需要快速随机访问,vector
或array
可能是一个好选择。如果你需要快速查找,那么各种类型的set
和map
可能更适合。
STL(Standard Template Library,标准模板库)中的序列容器是元素的集合,其中每个元素都有一个固定的位置:位置取决于元素的插入顺序和元素之间的相对位置。以下是STL中的一些主要序列容器:
-
vector
:动态数组,支持快速随机访问,但在中间插入和删除元素效率较低。vector
在内存中连续存储元素,因此可以提供非常高效的随机访问。然而,如果需要在vector
的中间插入或删除元素,可能需要移动大量的元素,因此效率较低。 -
deque
:双端队列,支持快速随机访问,且在头尾插入和删除元素效率较高。deque
在内存中的存储方式使得它可以在两端进行高效的插入和删除操作,同时还能提供良好的随机访问性能。 -
list
:双向链表,支持快速的插入和删除,但不支持随机访问。list
的元素在内存中不是连续存储的,因此不能提供高效的随机访问。但是,list
可以在任何位置进行高效的插入和删除操作。 -
forward_list
:单向链表,只支持向前迭代。forward_list
与list
类似,但只支持单向迭代,这意味着你只能从前向后遍历元素。 -
array
:固定大小的数组,支持快速随机访问,但不能添加或删除元素。array
是一个固定大小的容器,提供了与内置数组相同的性能,但具有更完善的接口和更好的类型安全性。
每种序列容器都有其特定的使用场景,选择合适的容器可以提高代码的效率和可读性。例如,如果你需要快速随机访问,vector
或array
可能是一个好选择。如果你需要在任意位置进行插入和删除操作,list
可能更适合。
STL(Standard Template Library,标准模板库)中的关联容器是元素的集合,其中每个元素都有一个键和一个值,并且每个键只出现一次。元素的位置由其键决定,而不是插入的顺序。以下是STL中的一些主要关联容器:
-
set
:集合,元素按照特定的排序准则进行排序,每个元素只能出现一次。set
内部通常实现为红黑树,因此查找、插入和删除操作的时间复杂度都是对数级别的。 -
multiset
:与set
类似,但允许元素重复。multiset
也是基于红黑树实现的,因此查找、插入和删除操作的时间复杂度同样是对数级别的。 -
map
:映射,每个元素都是一个键值对,键按照特定的排序准则进行排序,每个键只能出现一次。map
内部通常也是基于红黑树实现的,因此查找、插入和删除操作的时间复杂度是对数级别的。 -
multimap
:与map
类似,但允许键重复。multimap
的内部实现和map
相同,查找、插入和删除操作的时间复杂度也是对数级别的。
此外,STL还提供了无序关联容器,它们使用哈希表进行元素的存储,因此元素的顺序并不固定:
-
unordered_set
:无序集合,元素只能出现一次。unordered_set
内部通常实现为哈希表,因此查找、插入和删除操作的平均时间复杂度是常数级别的。 -
unordered_multiset
:与unordered_set
类似,但允许元素重复。unordered_multiset
的内部实现和unordered_set
相同,查找、插入和删除操作的平均时间复杂度也是常数级别的。 -
unordered_map
:无序映射,每个元素都是一个键值对,每个键只能出现一次。unordered_map
内部通常实现为哈希表,因此查找、插入和删除操作的平均时间复杂度是常数级别的。 -
unordered_multimap
:与unordered_map
类似,但允许键重复。unordered_multimap
的内部实现和unordered_map
相同,查找、插入和删除操作的平均时间复杂度也是常数级别的。
选择哪种类型的容器取决于你的具体需求,例如,如果你需要快速查找,那么各种类型的set
和map
可能更适合。如果你需要快速插入和删除,且不关心元素的顺序,那么无序关联容器可能是一个好选择。
map 和 unordered_map 的区别?
std::map
和std::unordered_map
都是C++标准库中的关联容器,用于存储键值对。它们的主要区别在于内部数据结构和性能特性。
实现原理
std::map
:内部实现为红黑树,是一种自平衡的二叉搜索树。这意味着std::map
中的元素是按照键的顺序排序的。插入,删除和查找操作的时间复杂度通常为O(log n)。std::map
适合于元素需要按照顺序访问,或者需要频繁进行较低成本的查找和更新操作的场景。
std::unordered_map
:内部实现为哈希表。这意味着元素的存储和检索是基于元素的哈希值,元素的顺序是不确定的。在理想情况下,插入,删除和查找操作的时间复杂度可以达到O(1),但在最坏的情况下,这些操作的时间复杂度可能会达到O(n)。std::unordered_map
适合于元素的顺序不重要,或者需要进行大量的查找操作,且哈希函数可以将元素均匀分布在哈希表中的场景。
std::map
std::map
是C++标准库中的一种关联容器,它存储的是键值对(key-value pairs),并且键是唯一的。std::map
的内部实现通常是红黑树,这是一种自平衡的二叉搜索树。因此,std::map
中的元素会按照键的顺序自动排序。
以下是std::map
的一些主要特性:
-
有序性:
std::map
中的元素按照键的顺序存储,这是由其内部的红黑树数据结构保证的。 -
唯一键:
std::map
中的每个键都是唯一的。如果尝试插入一个已经存在的键,那么新的键值对将不会被插入,或者会替换旧的键值对(取决于具体的插入操作)。 -
查找效率:由于
std::map
的内部实现是红黑树,因此查找操作的时间复杂度是O(log n),其中n是std::map
中元素的数量。 -
插入和删除效率:插入和删除操作的时间复杂度也是O(log n),这是由红黑树的性质决定的。
以下是一个std::map
的使用示例:
#include <map>
#include <string>
#include <iostream>
int main() {
std::map<std::string, int> age_map;
// 插入键值对
age_map["Alice"] = 25;
age_map["Bob"] = 30;
age_map["Charlie"] = 35;
// 查找并打印键值对
for (const auto &pair : age_map) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
// 查找特定的键
auto it = age_map.find("Bob");
if (it != age_map.end()) {
std::cout << "Found Bob, age: " << it->second << std::endl;
} else {
std::cout << "Bob not found" << std::endl;
}
// 删除键值对
age_map.erase("Alice");
return 0;
}
在这个示例中,我们首先创建了一个std::map
,然后插入了几个键值对。然后,我们遍历了std::map
并打印了所有的键值对。接着,我们查找了特定的键,并打印了对应的值。最后,我们删除了一个键值对。
std::unordered_map
std::unordered_map
是C++标准库中的一种关联容器,它存储的是键值对(key-value pairs),并且键是唯一的。std::unordered_map
的内部实现通常是哈希表,这意味着元素的存储和检索是基于元素的哈希值,元素的顺序是不确定的。
以下是std::unordered_map
的一些主要特性:
-
无序性:
std::unordered_map
中的元素不会按照键的顺序存储,而是根据哈希函数的结果存储。 -
唯一键:
std::unordered_map
中的每个键都是唯一的。如果尝试插入一个已经存在的键,那么新的键值对将不会被插入,或者会替换旧的键值对(取决于具体的插入操作)。 -
查找效率:由于
std::unordered_map
的内部实现是哈希表,因此在理想情况下,查找操作的时间复杂度是O(1),但在最坏的情况下,这些操作的时间复杂度可能会达到O(n)。 -
插入和删除效率:插入和删除操作的时间复杂度在理想情况下也是O(1),但在最坏的情况下可能会达到O(n)。
以下是一个std::unordered_map
的使用示例:
#include <unordered_map>
#include <string>
#include <iostream>
int main() {
std::unordered_map<std::string, int> age_map;
// 插入键值对
age_map["Alice"] = 25;
age_map["Bob"] = 30;
age_map["Charlie"] = 35;
// 查找并打印键值对
for (const auto &pair : age_map) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
// 查找特定的键
auto it = age_map.find("Bob");
if (it != age_map.end()) {
std::cout << "Found Bob, age: " << it->second << std::endl;
} else {
std::cout << "Bob not found" << std::endl;
}
// 删除键值对
age_map.erase("Alice");
return 0;
}
在这个示例中,我们首先创建了一个std::unordered_map
,然后插入了几个键值对。然后,我们遍历了std::unordered_map
并打印了所有的键值对。接着,我们查找了特定的键,并打印了对应的值。最后,我们删除了一个键值对。
总结
- 使用
std::map
当你需要元素按键排序时。 - 使用
std::unordered_map
当你需要更快的访问速度且不在乎元素的顺序时。
在实际应用中,选择哪种容器取决于你的特定需求,比如是否需要排序、对插入和删除操作的性能要求等。
map 和 unordered_map 的区别?
std::map
和std::unordered_map
都是C++标准库中的关联容器,用于存储键值对。它们的主要区别在于内部数据结构和性能特性。
实现原理
std::map
:内部实现为红黑树,是一种自平衡的二叉搜索树。这意味着std::map
中的元素是按照键的顺序排序的。插入,删除和查找操作的时间复杂度通常为O(log n)。std::map
适合于元素需要按照顺序访问,或者需要频繁进行较低成本的查找和更新操作的场景。
std::unordered_map
:内部实现为哈希表。这意味着元素的存储和检索是基于元素的哈希值,元素的顺序是不确定的。在理想情况下,插入,删除和查找操作的时间复杂度可以达到O(1),但在最坏的情况下,这些操作的时间复杂度可能会达到O(n)。std::unordered_map
适合于元素的顺序不重要,或者需要进行大量的查找操作,且哈希函数可以将元素均匀分布在哈希表中的场景。
std::map
std::map
是C++标准库中的一种关联容器,它存储的是键值对(key-value pairs),并且键是唯一的。std::map
的内部实现通常是红黑树,这是一种自平衡的二叉搜索树。因此,std::map
中的元素会按照键的顺序自动排序。
以下是std::map
的一些主要特性:
-
有序性:
std::map
中的元素按照键的顺序存储,这是由其内部的红黑树数据结构保证的。 -
唯一键:
std::map
中的每个键都是唯一的。如果尝试插入一个已经存在的键,那么新的键值对将不会被插入,或者会替换旧的键值对(取决于具体的插入操作)。 -
查找效率:由于
std::map
的内部实现是红黑树,因此查找操作的时间复杂度是O(log n),其中n是std::map
中元素的数量。 -
插入和删除效率:插入和删除操作的时间复杂度也是O(log n),这是由红黑树的性质决定的。
以下是一个std::map
的使用示例:
#include <map>
#include <string>
#include <iostream>
int main() {
std::map<std::string, int> age_map;
// 插入键值对
age_map["Alice"] = 25;
age_map["Bob"] = 30;
age_map["Charlie"] = 35;
// 查找并打印键值对
for (const auto &pair : age_map) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
// 查找特定的键
auto it = age_map.find("Bob");
if (it != age_map.end()) {
std::cout << "Found Bob, age: " << it->second << std::endl;
} else {
std::cout << "Bob not found" << std::endl;
}
// 删除键值对
age_map.erase("Alice");
return 0;
}
在这个示例中,我们首先创建了一个std::map
,然后插入了几个键值对。然后,我们遍历了std::map
并打印了所有的键值对。接着,我们查找了特定的键,并打印了对应的值。最后,我们删除了一个键值对。
std::unordered_map
std::unordered_map
是C++标准库中的一种关联容器,它存储的是键值对(key-value pairs),并且键是唯一的。std::unordered_map
的内部实现通常是哈希表,这意味着元素的存储和检索是基于元素的哈希值,元素的顺序是不确定的。
以下是std::unordered_map
的一些主要特性:
-
无序性:
std::unordered_map
中的元素不会按照键的顺序存储,而是根据哈希函数的结果存储。 -
唯一键:
std::unordered_map
中的每个键都是唯一的。如果尝试插入一个已经存在的键,那么新的键值对将不会被插入,或者会替换旧的键值对(取决于具体的插入操作)。 -
查找效率:由于
std::unordered_map
的内部实现是哈希表,因此在理想情况下,查找操作的时间复杂度是O(1),但在最坏的情况下,这些操作的时间复杂度可能会达到O(n)。 -
插入和删除效率:插入和删除操作的时间复杂度在理想情况下也是O(1),但在最坏的情况下可能会达到O(n)。
以下是一个std::unordered_map
的使用示例:
#include <unordered_map>
#include <string>
#include <iostream>
int main() {
std::unordered_map<std::string, int> age_map;
// 插入键值对
age_map["Alice"] = 25;
age_map["Bob"] = 30;
age_map["Charlie"] = 35;
// 查找并打印键值对
for (const auto &pair : age_map) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
// 查找特定的键
auto it = age_map.find("Bob");
if (it != age_map.end()) {
std::cout << "Found Bob, age: " << it->second << std::endl;
} else {
std::cout << "Bob not found" << std::endl;
}
// 删除键值对
age_map.erase("Alice");
return 0;
}
在这个示例中,我们首先创建了一个std::unordered_map
,然后插入了几个键值对。然后,我们遍历了std::unordered_map
并打印了所有的键值对。接着,我们查找了特定的键,并打印了对应的值。最后,我们删除了一个键值对。
总结
- 使用
std::map
当你需要元素按键排序时。 - 使用
std::unordered_map
当你需要更快的访问速度且不在乎元素的顺序时。
在实际应用中,选择哪种容器取决于你的特定需求,比如是否需要排序、对插入和删除操作的性能要求等。
如何高效的使用 STL 容器?
使用 STL 时直接存放对本身,如果应当避免直接存对象,而是存放指向对象的指针,否则前者会导致。 对象复制成本高
直接将对象、数据写入容器存在哪些问题?
在STL中,当将一个对象添加到容器中时(例如,通过insert或push_back等方法),容器并不直接存储提供的对象,而是存储该对象的一个副本。这意味着,容器中的对象与原始提供的对象在内存中是分开的,它们是两个完全独立的实例。
同样,当从容器中获取一个对象时(例如,通过front或back等方法),获取的也是该对象的一个副本,而不是容器中的原始对象。这意味着,即使修改了获取到的对象,也不会影响到容器中的原始对象。
这种“复制进,复制出”的方式是STL的基本工作原理,这样做的好处是可以保护容器中的数据不被意外修改,提高了数据的安全性。但是,这也意味着在使用STL容器时,需要注意对象复制可能带来的性能开销,特别是对于大型对象或复制操作代价较高的对象。
假设我们有一个std::vector<int>
,我们想要在其中存储一些整数。
std::vector<int> vec;
int a = 5;
vec.push_back(a);
在这个例子中,我们将整数a
添加到了向量vec
中。但是,实际上存储在vec
中的并不是a
本身,而是a
的一个副本。这意味着,即使我们稍后修改了a
的值,vec
中的值也不会改变,因为它存储的是a
的副本,而不是a
本身。
a = 10;
std::cout << vec[0]; // 输出仍然是5,而不是10
同样,当我们从vec
中获取一个元素时,我们获取的也是该元素的一个副本。
int b = vec[0];
b = 20;
std::cout << vec[0]; // 输出仍然是5,而不是20
在这个例子中,我们从vec
中获取了第一个元素,并将其赋值给了b
。然后我们修改了b
的值,但这并不会影响vec
中的元素,因为b
是vec[0]
的一个副本,而不是vec[0]
本身。
这种“复制进,复制出”的方式是STL的基本工作原理,这样做的好处是可以保护容器中的数据不被意外修改,提高了数据的安全性。但是,这也意味着在使用STL容器时,需要注意对象复制可能带来的性能开销,特别是对于大型对象或复制操作代价较高的对象。
对象是如何复制的?
在C++中,对象的复制通常通过复制构造函数和复制赋值运算符来完成。这两个函数是类的成员函数,用于创建类的新对象(复制构造函数)或将一个对象的值赋给另一个对象(复制赋值运算符)。
复制构造函数的典型声明如下:
class Widget {
public:
Widget(const Widget&); // copy constructor
...
};
这个函数接受一个同类型的对象作为参数,然后创建一个新的对象,其内容是参数对象的副本。
复制赋值运算符的典型声明如下:
class Widget {
public:
Widget& operator=(const Widget&); // copy assignment operator
...
};
这个函数接受一个同类型的对象作为参数,然后将调用对象的内容替换为参数对象的内容。
当在容器中插入或删除元素,或者使用某些算法(如排序、移除、旋转等)时,这些函数会被调用,以确保对象的正确复制。
例如,如果有一个std::vector<Widget>
,并且想在其中添加一个新的Widget
对象,那么这个对象会被复制到向量中。这个复制过程就是通过调用Widget
的复制构造函数来完成的。
std::vector<Widget> widgets;
Widget w;
widgets.push_back(w); // w is copied into the vector
同样,如果有两个Widget
对象,并且想将一个对象的值赋给另一个对象,那么这个赋值过程就是通过调用Widget
的复制赋值运算符来完成的。
Widget w1, w2;
w1 = w2; // w2 is copied into w1
如果不自己声明这些函数,编译器会为自动生成。对于内置类型(如int、指针等),复制过程更简单,只需要复制底层的位。
复制存在哪些问题?
在C++中,对象的复制可能会导致性能问题,特别是当对象的复制成本很高时。例如,如果一个对象包含大量的数据或者复杂的结构,那么复制这个对象可能会消耗大量的时间和内存。如果在容器中频繁地插入、删除或移动这种对象,那么这些操作可能会成为性能瓶颈。
例如,假设有一个Widget
类,它包含一个大型的std::vector
成员:
class Widget {
public:
Widget(const Widget& other) : data(other.data) {} // copy constructor
Widget& operator=(const Widget& other) { data = other.data; return *this; } // copy assignment operator
private:
std::vector<int> data;
};
如果在一个std::vector<Widget>
中频繁地插入或删除Widget
对象,那么每次操作都会涉及到复制Widget
对象中的data
向量,这可能会消耗大量的时间和内存。
此外,如果有一个对象,其中“复制”有一个非常规的含义,那么将这样的对象放入容器可能会导致问题。例如,如果的对象包含一个指向动态分配内存的指针,并且的复制构造函数和复制赋值运算符执行深复制,那么每次复制对象时都会分配新的内存,这可能会导致内存泄漏或其他问题。
在存在继承的情况下,复制可能会导致切片问题。切片是指当将一个派生类对象赋值给一个基类对象时,派生类特有的部分会被切掉。例如:
class Base {
public:
Base(const Base&) {} // copy constructor
Base& operator=(const Base&) { return *this; } // copy assignment operator
};
class Derived : public Base {
public:
Derived(const Derived& other) : Base(other), data(other.data) {} // copy constructor
Derived& operator=(const Derived& other) { Base::operator=(other); data = other.data; return *this; } // copy assignment operator
private:
int data;
};
std::vector<Base> bases;
Derived d;
bases.push_back(d); // d is sliced when copied into bases
在这个例子中,当Derived
对象d
被复制到bases
向量中时,data
成员将被切掉,因为Base
类并不知道它的存在。这可能会导致意外的行为,因为bases
向量中的对象并不完全等同于原始的Derived
对象。
会导致哪些意外行为?
-
数据丢失:派生类
Derived
特有的数据成员data
在复制过程中被切掉,这意味着在bases
向量中的对象并不包含data
成员。如果你期望通过bases
向量中的对象访问data
成员,那么将无法得到正确的结果,因为data
成员已经不存在了。 -
行为改变:如果派生类
Derived
重写了基类Base
的某个虚函数,那么在bases
向量中的对象将无法调用派生类Derived
的版本,而只能调用基类Base
的版本。这可能会改变程序的行为,因为你可能期望调用的是派生类的函数,而实际上调用的却是基类的函数。 -
类型信息丢失:在复制过程中,对象的动态类型信息也会丢失。也就是说,即使原始对象是派生类
Derived
的实例,但在bases
向量中的对象的类型只能被识别为基类Base
。这意味着你无法通过dynamic_cast
或typeid
等运算符获取到正确的类型信息。
如何避免复制?
如果的对象复制成本高,或者需要避免切片问题,那么使用指针的容器而不是对象的容器是一个解决方案。复制指针的成本低,且不会发生切片问题。但是,指针的容器也有其自身的问题,比如管理内存的复杂性。
例如,可以创建一个std::vector
,它包含Widget
对象的指针,而不是Widget
对象本身:
std::vector<Widget*> widgets;
Widget* w = new Widget();
widgets.push_back(w); // w is copied into the vector
在这个例子中,当将Widget
对象添加到向量中时,实际上复制的是指针,而不是Widget
对象本身。这样,无论Widget
对象有多大,复制的成本都是固定的。
然而,使用指针的容器也有其自身的问题。需要确保正确地管理内存,包括在适当的时候删除对象。如果忘记删除对象,就会导致内存泄漏。为了避免这种问题,可以使用智能指针,如std::shared_ptr
或std::unique_ptr
,它们会在不再需要对象时自动删除它。
std::vector<std::shared_ptr<Widget>> widgets;
std::shared_ptr<Widget> w = std::make_shared<Widget>();
widgets.push_back(w); // w is copied into the vector
在这个例子中,当shared_ptr
被复制时,Widget
对象不会被复制,而且当所有的shared_ptr
都消失时,Widget
对象会被自动删除。
尽管STL确实进行了很多复制操作,但它通常被设计为避免不必要的复制和对象创建。与C和C++的内置容器(如数组)相比,STL容器更加灵活和高效。它们只在需要时创建和复制对象,而且只有在明确要求时,它们才会使用默认构造函数。
总结
在使用C++标准模板库(STL)中的容器时,对象复制的常见情况和复制的实现方式是重要的考虑因素。对象的复制通常通过复制构造函数和复制赋值运算符来完成,这两个函数是类的成员函数,用于创建类的新对象或将一个对象的值赋给另一个对象。然而,对象的复制可能会导致性能问题,特别是当对象的复制成本很高时。此外,如果有一个对象,其中“复制”有一个非常规的含义,那么将这样的对象放入容器可能会导致问题。在存在继承的情况下,复制可能会导致切片问题。如果对象复制成本高,或者需要避免切片问题,那么使用指针的容器而不是对象的容器是一个解决方案。
参考
- 《Effective STL》
- 《STL 源码剖析》
STL 容器
在 C++ 标准模板库(STL)中,有几种常见的容器,每种都有其特定的用途和实现原理。
总揽
STL容器是用来存储和管理数据的数据结构,它们都提供了一些通用的成员函数,如插入元素、删除元素、查找元素等。STL容器主要分为三大类:序列容器、关联容器和无序关联容器。
-
序列容器:序列容器是元素的线性排列,元素的位置取决于插入的位置和顺序。主要包括
vector
、deque
、list
、forward_list
和array
。vector
:动态数组,支持快速随机访问。deque
:双端队列,支持快速随机访问,可以在头部和尾部插入或删除元素。list
:双向链表,只支持双向顺序访问,但在任何位置插入和删除元素都非常快速。forward_list
:单向链表,只支持单向顺序访问。array
:静态数组,支持快速随机访问,但大小固定。
-
关联容器:关联容器中的元素是按关键字来保存和访问的。主要包括
set
、multiset
、map
和multimap
。set
:集合,内部元素按照特定顺序排序,每个元素只能出现一次。multiset
:集合,内部元素按照特定顺序排序,每个元素可以出现多次。map
:映射,保存键值对,按照键排序,每个键只能出现一次。multimap
:映射,保存键值对,按照键排序,每个键可以出现多次。
-
无序关联容器:无序关联容器中的元素不按特定的顺序排序,而是根据元素的哈希值放在不同的位置,主要包括
unordered_set
、unordered_multiset
、unordered_map
和unordered_multimap
。unordered_set
:无序集合,元素在内部并不以任何特定顺序排序,而是根据元素的哈希值组织在一起,每个元素只能出现一次。unordered_multiset
:无序集合,元素在内部并不以任何特定顺序排序,而是根据元素的哈希值组织在一起,每个元素可以出现多次。unordered_map
:无序映射,保存键值对,元素在内部并不以任何特定顺序排序,而是根据键的哈希值组织在一起,每个键只能出现一次。unordered_multimap
:无序映射,保存键值对,元素在内部并不以任何特定顺序排序,而是根据键的哈希值组织在一起,每个键可以出现多次。
这些容器都有各自的优点和适用场景,选择哪种容器取决于你的具体需求,如插入和删除的效率、是否需要快速随机访问等。
选择容器时需要考虑哪些问题?
这个问题本质上是在考察数据结构。下面是一个简要的参考:
-
数据插入和删除的位置:如果你需要在容器的任意位置插入或删除数据,那么应该选择顺序容器,如
vector
、deque
、list
等。关联容器和无序关联容器通常不支持在任意位置插入或删除数据。 -
元素排序:如果你需要元素按某种特定顺序存储,那么应该选择关联容器,如
set
、map
等,它们会自动按键值对进行排序。如果你不关心元素的排序,那么无序关联容器,如unordered_set
、unordered_map
等,可能是更好的选择。 -
查找效率:如果你需要频繁地查找元素,那么应该选择关联容器或无序关联容器,因为它们提供了基于键的快速查找。而顺序容器的查找通常需要遍历整个容器,效率较低。
-
空间效率:如果内存空间有限,那么应该选择空间效率高的容器。例如,
list
和forward_list
比vector
和deque
更节省内存,因为它们不需要预分配额外的内存空间。 -
迭代器类型:不同的容器提供了不同类型的迭代器,如前向迭代器、双向迭代器、随机访问迭代器等。你需要根据你的需求选择提供了合适迭代器的容器。
-
是否需要频繁改变容器大小:如果你需要频繁地添加或删除元素,那么
vector
或string
可能不是最好的选择,因为这可能导致频繁的内存分配和释放。在这种情况下,list
、deque
或forward_list
可能是更好的选择。 -
元素类型:不同的容器对元素类型有不同的要求。例如,
array
要求所有元素的类型相同,而map
和set
则要求元素类型可以进行比较操作。你需要选择能够满足你的元素类型要求的容器。
STL 判断长度空存在哪些坑?
在C++的标准模板库(STL)中,empty()
和size()
是容器的两个常用成员函数。empty()
用于检查容器是否为空,而size()
用于获取容器中元素的数量。
选择哪种方式判断容器为空?
可以通过 if (c.size() == 0)
或 if (c.empty())
来判断容器是否为空,那么应该使用哪一个呢?
虽然两个结果是一样的,但是应该优先使用empty()
,因为 empty()
对于所有标准容器都是一个常数时间操作,但是对于一些list实现,size()
需要线性时间。
这是因为,empty()
只需要检查容器中是否有元素,这通常可以通过检查一两个内部指针或变量来实现,所以它的时间复杂度是O(1)。而size()
需要返回容器中元素的准确数量,对于一些容器(如list),可能需要遍历整个容器来计数,所以它的时间复杂度可能是O(n)。
因此,当需要检查一个容器是否为空时,应该优先使用empty()
,而不是size() == 0
。这样可以确保代码在所有情况下都有最佳的性能。
empty 是如何判空的?
empty()
函数的实现取决于具体的容器类型。在大多数情况下,empty()
函数只需要检查容器是否包含任何元素。这通常可以通过检查一两个内部指针或变量来实现。例如,对于std::vector
或std::deque
,empty()
函数可能只需要检查开始和结束迭代器是否相等。
以下是std::vector
中empty()
函数的一个可能实现:
template <class T, class Allocator = std::allocator<T>>
class vector {
public:
// ...
bool empty() const noexcept {
return begin() == end();
}
// ...
};
在这个例子中,begin()
和end()
函数返回的是指向容器开始和结束的迭代器。如果这两个迭代器相等,那么容器就是空的。
对于std::list
,empty()
函数可能需要检查头节点是否为nullptr
。以下是std::list
中empty()
函数的一个可能实现:
template <class T, class Allocator = std::allocator<T>>
class list {
public:
// ...
bool empty() const noexcept {
return head == nullptr;
}
// ...
private:
Node* head;
};
在这个例子中,head
是一个指向链表头节点的指针。如果head
为nullptr
,那么链表就是空的。
这些实现都能在常数时间内完成,因此empty()
函数的时间复杂度是O(1)。
为什么 list 需要常数时间来计算 size ?
是 splice()
函数导致的,因为 std::list
是一个双向链表,它的splice()
操作可以在常数时间内将元素从一个地方移动到另一个地方,而不需要复制任何数据。这是std::list
的一个独特功能,也是许多开发者选择使用std::list
的原因。
std::list
的splice
函数可以将一个列表中的元素移动到另一个列表中。以下是一个示例:
#include <list>
#include <iostream>
int main() {
std::list<int> list1 = {1, 2, 3, 4, 5};
std::list<int> list2 = {6, 7, 8, 9, 10};
// 打印list1和list2的初始状态
// Initial state:
// list1: 1 2 3 4 5
// list2: 6 7 8 9 10
std::cout << "Initial state:\n";
std::cout << "list1: ";
for (int n : list1) std::cout << n << ' ';
std::cout << "\nlist2: ";
for (int n : list2) std::cout << n << ' ';
std::cout << '\n';
// 使用splice将list2的元素移动到list1的末尾
list1.splice(list1.end(), list2);
// 打印list1和list2的最终状态
// Final state:
// list1: 1 2 3 4 5 6 7 8 9 10
// list2:
std::cout << "Final state:\n";
std::cout << "list1: ";
for (int n : list1) std::cout << n << ' ';
std::cout << "\nlist2: ";
for (int n : list2) std::cout << n << ' ';
std::cout << '\n';
return 0;
}
在这个示例中,我们首先创建了两个列表list1
和list2
。然后,我们使用splice
函数将list2
中的所有元素移动到list1
的末尾。最后,我们打印出list1
和list2
的最终状态,可以看到list2
现在是空的,而list1
包含了所有的元素。
std::list
的size()
操作在某些实现中可能需要线性时间。这是因为,为了实现splice()
操作的常数时间复杂度,std::list
可能需要在每次插入或删除元素时更新其元素计数器。但是,splice()
操作可能会移动任意数量的元素,而在不遍历这些元素的情况下,无法知道移动了多少元素。因此,如果splice()
需要更新元素计数器,那么它就不能在常数时间内完成。
这就导致了一个困境:size()
和splice()
不能同时在常数时间内完成。如果size()
是常数时间操作,那么splice()
就需要线性时间;如果splice()
是常数时间操作,那么size()
就需要线性时间。
不同的std::list
实现可能会以不同的方式解决这个问题,取决于它们更重视size()
的效率还是splice()
的效率。如果你正在使用的std::list
实现优先考虑了splice()
的效率,那么你应该使用empty()
来检查容器是否为空,而不是size() == 0
,因为empty()
总是常数时间操作。
总结
这篇文章主要讨论了C++标准模板库(STL)中empty()
和size()
函数的使用和性能问题。empty()
函数用于检查容器是否为空,通常可以在常数时间内完成,因为它只需要检查容器中是否有元素。而size()
函数用于获取容器中元素的数量,对于一些容器(如std::list
),可能需要遍历整个容器来计数,所以它的时间复杂度可能是O(n)。这是因为std::list
的splice()
操作需要在常数时间内完成,可能需要在每次插入或删除元素时更新其元素计数器。因此,当需要检查一个容器是否为空时,应该优先使用empty()
,而不是size() == 0
。
参考
- 《Effective STL》
- 《STL 源码剖析》
resize 和 reserve 的区别
在 C++ 的标准模板库(STL)中,特别是在如 std::vector
这样的动态数组容器中,resize
和 reserve
是两个用于调整容器大小的重要函数,但它们的用途和行为有显著的不同。
resize
resize
函数改变容器中元素的数量。如果新的大小大于当前大小,将添加新的元素。如果新的大小小于当前大小,多余的元素将被删除。
- 增加大小:新添加的元素会被初始化(如果提供了初始化值,则使用该值,否则使用默认构造)。
- 减少大小:超出新大小的元素会被销毁。
示例:使用 resize
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec = {1, 2, 3};
// 增加 vec 的大小到 5
vec.resize(5); // 新增元素初始化为 0
for (int i : vec) {
std::cout << i << " "; // 输出: 1 2 3 0 0
}
std::cout << std::endl;
// 减少 vec 的大小到 2
vec.resize(2);
for (int i : vec) {
std::cout << i << " "; // 输出: 1 2
}
std::cout << std::endl;
return 0;
}
reserve
reserve
函数用于改变容器的容量,即容器可以在重新分配之前保存多少元素。这个函数不改变容器中元素的数量,而是预分配足够的内存空间以容纳指定数量的元素。
- 避免重新分配:当你知道将要在容器中添加许多元素时,使用
reserve
可以减少多次内存分配。 - 不影响容器大小:它不会改变容器中元素的数量。
示例:使用 reserve
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec;
// 预分配空间以容纳至少 5 个元素
vec.reserve(5);
std::cout << "Capacity: " << vec.capacity() << std::endl; // 输出: 5
// 添加三个元素
vec.push_back(1);
vec.push_back(2);
vec.push_back(3);
std::cout << "Size: " << vec.size() << std::endl; // 输出: 3
std::cout << "Capacity: " << vec.capacity() << std::endl; // 输出: 5
return 0;
}
区别
现在来看resize
和reserve
的区别:
-
resize
既分配了空间,也创建了对象,而reserve
表示容器预留空间,但并不真正创建对象,需要通过insert()
或push_back()
等操作来创建对象。 -
resize
既修改了capacity
大小,也修改了size
大小;而reserve
只修改了capacity
大小,不修改size
大小。capacity
指容器能够容纳的最大元素个数,size
指容器中实际的元素个数。 -
形参个数不同:
resize
带两个参数,一个表示容器大小,一个表示初始值(默认为0);reserve
只带一个参数,表示容器预留的大小。
具体例子代码:
#include <iostream>
#include <vector>
using namespace std;
int main() {
vector<int> a;
cout << "initial capacity: " << a.capacity() << endl;
cout << "initial size: " << a.size() << endl;
// 使用resize改变capacity和size
a.resize(20);
cout << "resize capacity: " << a.capacity() << endl;
cout << "resize size: " << a.size() << endl;
vector<int> b;
// 使用reserve改变capacity,但不改变size
b.reserve(100);
cout << "reserve capacity: " << b.capacity() << endl;
cout << "reserve size: " << b.size() << endl;
return 0;
}
运行结果:
initial capacity: 0
initial size: 0
resize capacity: 20
resize size: 20
reserve capacity: 100
reserve size: 0
注意:如果resize
或reserve
的参数n
大于当前的vector的容量,会引起自动内存分配,导致已有的指针、引用和迭代器失效,并且内存的重新配置可能耗费较多时间。
使用场景
resize
和 reserve
在C++ STL中用于管理动态数组大小,它们有不同的使用场景:
-
resize 的使用场景:
-
当你想要改变数组的大小,并且需要初始化或清除元素时,可以使用
resize
。 -
例如,当你需要确保数组的大小达到某个值,并希望用默认值或特定值填充数组时,可以使用
resize
。std::vector<int> myVector; myVector.resize(10); // 将数组大小改为10,并用默认值填充
-
-
reserve 的使用场景:
-
当你预先知道数组可能达到的最大大小时,可以使用
reserve
来预留空间,以避免多次重新分配内存。 -
例如,在循环中逐步添加元素,但你知道循环结束后数组的最终大小,可以使用
reserve
避免多次动态分配。std::vector<int> myVector; myVector.reserve(100); // 预留至少能容纳100个元素的空间 for (int i = 0; i < 100; ++i) { myVector.push_back(i); }
-
总的来说,resize
用于改变数组大小并初始化/清除元素,而 reserve
用于在预先知道最大可能大小的情况下避免多次内存重新分配。选择使用哪个函数取决于你的需求和对数组操作的具体要求。
总结
resize
改变容器中元素的数量,可以增加或减少容器的大小。reserve
改变容器可以容纳元素的数量(容量),但不改变当前元素的数量。它用于优化内存分配,避免在添加新元素时重复分配内存。
vector 中 emplace_back 和 push_back 的区别?
emplace_back
和 push_back
都是 C++ 标准库容器 vector
中用来在序列末尾添加元素的成员函数,但它们在添加新元素时的行为和效率上有所不同。通过具体的例子来说明这两个函数的区别:
push_back
push_back
用于在 vector
的末尾添加一个元素。这个元素是通过拷贝构造或移动构造的方式添加的,这意味着在调用 push_back
时,传递的参数必须是已经存在的对象,vector
将会创建这个对象的一个副本(或者在支持移动语义的情况下移动这个对象)。
例子:
#include <iostream>
#include <vector>
#include <string>
int main() {
std::vector<std::string> vec;
std::string str = "Hello";
// 使用 push_back 添加元素
vec.push_back(str); // 这里会调用 std::string 的拷贝构造函数
vec.push_back(std::move(str)); // 这里会调用 std::string 的移动构造函数
for (const auto& s : vec) {
std::cout << s << std::endl;
}
return 0;
}
在这个例子中,第一次调用 push_back
时,str
被拷贝到 vector
中。第二次调用 push_back
时,使用 std::move
,str
被移动到 vector
中,避免了拷贝,但之后 str
可能变成空的。
emplace_back
emplace_back
用于在 vector
的末尾直接构造一个元素,避免了临时对象的创建和拷贝或移动操作。它直接在 vector
的存储空间中构造元素,可以接受任意数量和类型的参数,这些参数被直接传递给元素构造函数。
例子:
#include <iostream>
#include <vector>
#include <string>
int main() {
std::vector<std::string> vec;
// 使用 emplace_back 直接在 vector 中构造元素
vec.emplace_back("Hello"); // 直接调用 std::string 的构造函数
for (const auto& s : vec) {
std::cout << s << std::endl;
}
return 0;
}
在这个例子中,emplace_back
直接在 vector
的末尾调用 std::string
的构造函数,使用给定的参数 "Hello" 构造一个新的字符串对象。这避免了创建临时对象和额外的拷贝或移动操作。
总结
push_back
在添加元素前需要一个已经构造好的对象,然后它会复制或移动这个对象到vector
中。emplace_back
可以直接在vector
的存储空间中构造对象,这通常更高效,因为它省去了临时对象的创建和不必要的拷贝或移动操作。
因此,当可能的时候,推荐使用 emplace_back
,特别是当添加到 vector
的对象构造成本较高或支持移动语义时。
map 和 unordered_map 的区别?
std::map
和std::unordered_map
都是C++标准库中的关联容器,用于存储键值对。它们的主要区别在于内部数据结构和性能特性。
实现原理
std::map
:内部实现为红黑树,是一种自平衡的二叉搜索树。这意味着std::map
中的元素是按照键的顺序排序的。插入,删除和查找操作的时间复杂度通常为O(log n)。std::map
适合于元素需要按照顺序访问,或者需要频繁进行较低成本的查找和更新操作的场景。
std::unordered_map
:内部实现为哈希表。这意味着元素的存储和检索是基于元素的哈希值,元素的顺序是不确定的。在理想情况下,插入,删除和查找操作的时间复杂度可以达到O(1),但在最坏的情况下,这些操作的时间复杂度可能会达到O(n)。std::unordered_map
适合于元素的顺序不重要,或者需要进行大量的查找操作,且哈希函数可以将元素均匀分布在哈希表中的场景。
std::map
std::map
是C++标准库中的一种关联容器,它存储的是键值对(key-value pairs),并且键是唯一的。std::map
的内部实现通常是红黑树,这是一种自平衡的二叉搜索树。因此,std::map
中的元素会按照键的顺序自动排序。
以下是std::map
的一些主要特性:
-
有序性:
std::map
中的元素按照键的顺序存储,这是由其内部的红黑树数据结构保证的。 -
唯一键:
std::map
中的每个键都是唯一的。如果尝试插入一个已经存在的键,那么新的键值对将不会被插入,或者会替换旧的键值对(取决于具体的插入操作)。 -
查找效率:由于
std::map
的内部实现是红黑树,因此查找操作的时间复杂度是O(log n),其中n是std::map
中元素的数量。 -
插入和删除效率:插入和删除操作的时间复杂度也是O(log n),这是由红黑树的性质决定的。
以下是一个std::map
的使用示例:
#include <map>
#include <string>
#include <iostream>
int main() {
std::map<std::string, int> age_map;
// 插入键值对
age_map["Alice"] = 25;
age_map["Bob"] = 30;
age_map["Charlie"] = 35;
// 查找并打印键值对
for (const auto &pair : age_map) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
// 查找特定的键
auto it = age_map.find("Bob");
if (it != age_map.end()) {
std::cout << "Found Bob, age: " << it->second << std::endl;
} else {
std::cout << "Bob not found" << std::endl;
}
// 删除键值对
age_map.erase("Alice");
return 0;
}
在这个示例中,我们首先创建了一个std::map
,然后插入了几个键值对。然后,我们遍历了std::map
并打印了所有的键值对。接着,我们查找了特定的键,并打印了对应的值。最后,我们删除了一个键值对。
std::unordered_map
std::unordered_map
是C++标准库中的一种关联容器,它存储的是键值对(key-value pairs),并且键是唯一的。std::unordered_map
的内部实现通常是哈希表,这意味着元素的存储和检索是基于元素的哈希值,元素的顺序是不确定的。
以下是std::unordered_map
的一些主要特性:
-
无序性:
std::unordered_map
中的元素不会按照键的顺序存储,而是根据哈希函数的结果存储。 -
唯一键:
std::unordered_map
中的每个键都是唯一的。如果尝试插入一个已经存在的键,那么新的键值对将不会被插入,或者会替换旧的键值对(取决于具体的插入操作)。 -
查找效率:由于
std::unordered_map
的内部实现是哈希表,因此在理想情况下,查找操作的时间复杂度是O(1),但在最坏的情况下,这些操作的时间复杂度可能会达到O(n)。 -
插入和删除效率:插入和删除操作的时间复杂度在理想情况下也是O(1),但在最坏的情况下可能会达到O(n)。
以下是一个std::unordered_map
的使用示例:
#include <unordered_map>
#include <string>
#include <iostream>
int main() {
std::unordered_map<std::string, int> age_map;
// 插入键值对
age_map["Alice"] = 25;
age_map["Bob"] = 30;
age_map["Charlie"] = 35;
// 查找并打印键值对
for (const auto &pair : age_map) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
// 查找特定的键
auto it = age_map.find("Bob");
if (it != age_map.end()) {
std::cout << "Found Bob, age: " << it->second << std::endl;
} else {
std::cout << "Bob not found" << std::endl;
}
// 删除键值对
age_map.erase("Alice");
return 0;
}
在这个示例中,我们首先创建了一个std::unordered_map
,然后插入了几个键值对。然后,我们遍历了std::unordered_map
并打印了所有的键值对。接着,我们查找了特定的键,并打印了对应的值。最后,我们删除了一个键值对。
总结
- 使用
std::map
当你需要元素按键排序时。 - 使用
std::unordered_map
当你需要更快的访问速度且不在乎元素的顺序时。
在实际应用中,选择哪种容器取决于你的特定需求,比如是否需要排序、对插入和删除操作的性能要求等。
map 和 unordered_map 的区别?
std::map
和std::unordered_map
都是C++标准库中的关联容器,用于存储键值对。它们的主要区别在于内部数据结构和性能特性。
实现原理
std::map
:内部实现为红黑树,是一种自平衡的二叉搜索树。这意味着std::map
中的元素是按照键的顺序排序的。插入,删除和查找操作的时间复杂度通常为O(log n)。std::map
适合于元素需要按照顺序访问,或者需要频繁进行较低成本的查找和更新操作的场景。
std::unordered_map
:内部实现为哈希表。这意味着元素的存储和检索是基于元素的哈希值,元素的顺序是不确定的。在理想情况下,插入,删除和查找操作的时间复杂度可以达到O(1),但在最坏的情况下,这些操作的时间复杂度可能会达到O(n)。std::unordered_map
适合于元素的顺序不重要,或者需要进行大量的查找操作,且哈希函数可以将元素均匀分布在哈希表中的场景。
std::map
std::map
是C++标准库中的一种关联容器,它存储的是键值对(key-value pairs),并且键是唯一的。std::map
的内部实现通常是红黑树,这是一种自平衡的二叉搜索树。因此,std::map
中的元素会按照键的顺序自动排序。
以下是std::map
的一些主要特性:
-
有序性:
std::map
中的元素按照键的顺序存储,这是由其内部的红黑树数据结构保证的。 -
唯一键:
std::map
中的每个键都是唯一的。如果尝试插入一个已经存在的键,那么新的键值对将不会被插入,或者会替换旧的键值对(取决于具体的插入操作)。 -
查找效率:由于
std::map
的内部实现是红黑树,因此查找操作的时间复杂度是O(log n),其中n是std::map
中元素的数量。 -
插入和删除效率:插入和删除操作的时间复杂度也是O(log n),这是由红黑树的性质决定的。
以下是一个std::map
的使用示例:
#include <map>
#include <string>
#include <iostream>
int main() {
std::map<std::string, int> age_map;
// 插入键值对
age_map["Alice"] = 25;
age_map["Bob"] = 30;
age_map["Charlie"] = 35;
// 查找并打印键值对
for (const auto &pair : age_map) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
// 查找特定的键
auto it = age_map.find("Bob");
if (it != age_map.end()) {
std::cout << "Found Bob, age: " << it->second << std::endl;
} else {
std::cout << "Bob not found" << std::endl;
}
// 删除键值对
age_map.erase("Alice");
return 0;
}
在这个示例中,我们首先创建了一个std::map
,然后插入了几个键值对。然后,我们遍历了std::map
并打印了所有的键值对。接着,我们查找了特定的键,并打印了对应的值。最后,我们删除了一个键值对。
std::unordered_map
std::unordered_map
是C++标准库中的一种关联容器,它存储的是键值对(key-value pairs),并且键是唯一的。std::unordered_map
的内部实现通常是哈希表,这意味着元素的存储和检索是基于元素的哈希值,元素的顺序是不确定的。
以下是std::unordered_map
的一些主要特性:
-
无序性:
std::unordered_map
中的元素不会按照键的顺序存储,而是根据哈希函数的结果存储。 -
唯一键:
std::unordered_map
中的每个键都是唯一的。如果尝试插入一个已经存在的键,那么新的键值对将不会被插入,或者会替换旧的键值对(取决于具体的插入操作)。 -
查找效率:由于
std::unordered_map
的内部实现是哈希表,因此在理想情况下,查找操作的时间复杂度是O(1),但在最坏的情况下,这些操作的时间复杂度可能会达到O(n)。 -
插入和删除效率:插入和删除操作的时间复杂度在理想情况下也是O(1),但在最坏的情况下可能会达到O(n)。
以下是一个std::unordered_map
的使用示例:
#include <unordered_map>
#include <string>
#include <iostream>
int main() {
std::unordered_map<std::string, int> age_map;
// 插入键值对
age_map["Alice"] = 25;
age_map["Bob"] = 30;
age_map["Charlie"] = 35;
// 查找并打印键值对
for (const auto &pair : age_map) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
// 查找特定的键
auto it = age_map.find("Bob");
if (it != age_map.end()) {
std::cout << "Found Bob, age: " << it->second << std::endl;
} else {
std::cout << "Bob not found" << std::endl;
}
// 删除键值对
age_map.erase("Alice");
return 0;
}
在这个示例中,我们首先创建了一个std::unordered_map
,然后插入了几个键值对。然后,我们遍历了std::unordered_map
并打印了所有的键值对。接着,我们查找了特定的键,并打印了对应的值。最后,我们删除了一个键值对。
总结
- 使用
std::map
当你需要元素按键排序时。 - 使用
std::unordered_map
当你需要更快的访问速度且不在乎元素的顺序时。
在实际应用中,选择哪种容器取决于你的特定需求,比如是否需要排序、对插入和删除操作的性能要求等。
map 和 unordered_map 的区别?
std::map
和std::unordered_map
都是C++标准库中的关联容器,用于存储键值对。它们的主要区别在于内部数据结构和性能特性。
实现原理
std::map
:内部实现为红黑树,是一种自平衡的二叉搜索树。这意味着std::map
中的元素是按照键的顺序排序的。插入,删除和查找操作的时间复杂度通常为O(log n)。std::map
适合于元素需要按照顺序访问,或者需要频繁进行较低成本的查找和更新操作的场景。
std::unordered_map
:内部实现为哈希表。这意味着元素的存储和检索是基于元素的哈希值,元素的顺序是不确定的。在理想情况下,插入,删除和查找操作的时间复杂度可以达到O(1),但在最坏的情况下,这些操作的时间复杂度可能会达到O(n)。std::unordered_map
适合于元素的顺序不重要,或者需要进行大量的查找操作,且哈希函数可以将元素均匀分布在哈希表中的场景。
std::map
std::map
是C++标准库中的一种关联容器,它存储的是键值对(key-value pairs),并且键是唯一的。std::map
的内部实现通常是红黑树,这是一种自平衡的二叉搜索树。因此,std::map
中的元素会按照键的顺序自动排序。
以下是std::map
的一些主要特性:
-
有序性:
std::map
中的元素按照键的顺序存储,这是由其内部的红黑树数据结构保证的。 -
唯一键:
std::map
中的每个键都是唯一的。如果尝试插入一个已经存在的键,那么新的键值对将不会被插入,或者会替换旧的键值对(取决于具体的插入操作)。 -
查找效率:由于
std::map
的内部实现是红黑树,因此查找操作的时间复杂度是O(log n),其中n是std::map
中元素的数量。 -
插入和删除效率:插入和删除操作的时间复杂度也是O(log n),这是由红黑树的性质决定的。
以下是一个std::map
的使用示例:
#include <map>
#include <string>
#include <iostream>
int main() {
std::map<std::string, int> age_map;
// 插入键值对
age_map["Alice"] = 25;
age_map["Bob"] = 30;
age_map["Charlie"] = 35;
// 查找并打印键值对
for (const auto &pair : age_map) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
// 查找特定的键
auto it = age_map.find("Bob");
if (it != age_map.end()) {
std::cout << "Found Bob, age: " << it->second << std::endl;
} else {
std::cout << "Bob not found" << std::endl;
}
// 删除键值对
age_map.erase("Alice");
return 0;
}
在这个示例中,我们首先创建了一个std::map
,然后插入了几个键值对。然后,我们遍历了std::map
并打印了所有的键值对。接着,我们查找了特定的键,并打印了对应的值。最后,我们删除了一个键值对。
std::unordered_map
std::unordered_map
是C++标准库中的一种关联容器,它存储的是键值对(key-value pairs),并且键是唯一的。std::unordered_map
的内部实现通常是哈希表,这意味着元素的存储和检索是基于元素的哈希值,元素的顺序是不确定的。
以下是std::unordered_map
的一些主要特性:
-
无序性:
std::unordered_map
中的元素不会按照键的顺序存储,而是根据哈希函数的结果存储。 -
唯一键:
std::unordered_map
中的每个键都是唯一的。如果尝试插入一个已经存在的键,那么新的键值对将不会被插入,或者会替换旧的键值对(取决于具体的插入操作)。 -
查找效率:由于
std::unordered_map
的内部实现是哈希表,因此在理想情况下,查找操作的时间复杂度是O(1),但在最坏的情况下,这些操作的时间复杂度可能会达到O(n)。 -
插入和删除效率:插入和删除操作的时间复杂度在理想情况下也是O(1),但在最坏的情况下可能会达到O(n)。
以下是一个std::unordered_map
的使用示例:
#include <unordered_map>
#include <string>
#include <iostream>
int main() {
std::unordered_map<std::string, int> age_map;
// 插入键值对
age_map["Alice"] = 25;
age_map["Bob"] = 30;
age_map["Charlie"] = 35;
// 查找并打印键值对
for (const auto &pair : age_map) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
// 查找特定的键
auto it = age_map.find("Bob");
if (it != age_map.end()) {
std::cout << "Found Bob, age: " << it->second << std::endl;
} else {
std::cout << "Bob not found" << std::endl;
}
// 删除键值对
age_map.erase("Alice");
return 0;
}
在这个示例中,我们首先创建了一个std::unordered_map
,然后插入了几个键值对。然后,我们遍历了std::unordered_map
并打印了所有的键值对。接着,我们查找了特定的键,并打印了对应的值。最后,我们删除了一个键值对。
总结
- 使用
std::map
当你需要元素按键排序时。 - 使用
std::unordered_map
当你需要更快的访问速度且不在乎元素的顺序时。
在实际应用中,选择哪种容器取决于你的特定需求,比如是否需要排序、对插入和删除操作的性能要求等。
容器动态链接可能产生的问题?
在 C++ 中,使用标准模板库(STL)容器与动态链接(如使用动态链接库,DLLs 在 Windows 上或共享对象,SOs 在 Unix/Linux 上)一起时,可能会遇到一些问题。这些问题通常与对象内存管理、不同编译器设置或 ABI(应用程序二进制接口)不兼容等因素有关。以下是一些可能遇到的典型问题,以及相应的例子:
1. 内存管理问题
当 STL 对象在一个动态链接库(DLL/SO)中创建并在另一个中释放时,如果每个库有不同的堆实现,可能会导致问题。这是因为对象的分配和释放必须使用相同的内存堆。
例子
假设有两个动态链接库:
- DLL A:创建了一个
std::vector
并返回给调用者。 - DLL B:接收这个
std::vector
并销毁它。
如果 DLL A 和 DLL B 使用不同的堆实现,这可能导致内存损坏或泄露。
2. ABI 不兼容
不同的编译器或相同编译器的不同版本可能会有不同的 STL 实现,这可能导致 ABI 不兼容问题。
例子
- DLL A:使用编译器 X 编译。
- DLL B:使用编译器 Y 或编译器 X 的不同版本编译。
如果 DLL A 创建的 STL 容器对象传递给 DLL B,由于内部实现可能不同,可能会导致运行时错误。
3. STL 版本不一致
如果 STL 库在编译 DLL 时被更新,而使用这些 DLL 的应用程序仍然使用旧版本的 STL 库,这可能导致不一致性。
例子
- DLL:使用新版本的 STL 编译。
- 应用程序:使用旧版本的 STL 编译。
传递 STL 容器或对象可能会因为版本差异而导致不一致性。
解决策略
为了避免这些问题,通常采用以下策略:
-
界面封装:在 DLL 接口中避免直接使用 STL 容器。相反,可以提供 C 风格的接口或使用封装对象。
-
内存管理一致性:确保所有相关组件使用相同的堆实现进行内存分配和释放。
-
编译器和 STL 版本一致:确保所有组件使用相同的编译器和 STL 版本编译。
通过这些策略,可以最大限度地减少在使用 STL 容器与动态链接库结合时可能出现的问题。
容器是否线程安全
在C++标准模板库(STL)中,没有容器是内置线程安全的。这意味着在标准的STL容器中,没有一个是保证在多线程环境下不出现数据竞争的。如果你需要在多线程环境中使用STL容器,必须自行管理对容器的访问,通常通过互斥锁(如 std::mutex
)或其他同步机制来实现。
STL容器包括:
- 序列容器:如
std::vector
,std::list
,std::deque
- 关联容器:如
std::set
,std::map
,std::multiset
,std::multimap
- 无序关联容器:如
std::unordered_set
,std::unordered_map
,std::unordered_multiset
,std::unordered_multimap
- 容器适配器:如
std::stack
,std::queue
,std::priority_queue
所有这些容器在默认情况下都不是线程安全的。
为什么STL容器不是线程安全的?
设计STL时,考虑到性能和灵活性,决定不将线程安全作为容器的一个内置特性。这样做的原因是:
-
性能考虑:线程安全通常需要同步机制,如锁,这会降低性能。不是所有使用STL容器的程序都在多线程环境中运行,因此默认添加线程安全会使那些单线程应用程序承担不必要的性能开销。
-
灵活性:不同的应用程序可能需要不同的线程同步策略。通过不在STL容器中内置线程安全,开发者可以根据自己的需求选择最适合的同步机制。
如何安全地在多线程中使用STL容器?
要在多线程环境中安全地使用STL容器,你需要自行管理容器的线程安全。常见的方法是使用互斥锁(如 std::mutex
)来保护容器的访问。这样,每次访问或修改容器时,线程都会首先尝试获取锁,确保在该时间点没有其他线程在操作同一个容器。
下面是一个简单的例子,展示如何使用互斥锁来同步对 std::vector
的访问:
#include <iostream>
#include <vector>
#include <mutex>
#include <thread>
std::vector<int> vec;
std::mutex vec_mutex;
void add_to_vector(int value) {
std::lock_guard<std::mutex> lock(vec_mutex);
vec.push_back(value);
}
void print_vector() {
std::lock_guard<std::mutex> lock(vec_mutex);
for (int value : vec) {
std::cout << value << " ";
}
std::cout << std::endl;
}
int main() {
std::thread t1(add_to_vector, 1);
std::thread t2(add_to_vector, 2);
t1.join();
t2.join();
print_vector();
return 0;
}
在这个例子中,我们使用了 std::mutex
(互斥锁)来同步对 std::vector
的访问。每当一个线程想要修改或访问 vector
,它都会首先获取互斥锁,这可以防止其他线程同时修改或访问 vector
,从而保证了线程安全。
然而,需要注意的是,这种方式会导致性能降低,因为线程在等待锁的时候会被阻塞。在设计多线程应用程序时,应当仔细考虑数据的同步和线程间通信的最佳实践。
替代方案
如果你需要线程安全的容器,可以考虑以下替代方案:
- 使用并发库提供的线程安全容器,例如 Intel Threading Building Blocks (TBB) 提供的并发容器。
- 实现自己的线程安全容器,根据应用程序的具体需求定制同步策略。
总的来说,STL容器本身不是线程安全的,使用它们时需要考虑适当的同步策略。
LSM-Tree(Log-structured merge trees)是一种维护键值对的数据结构,它的主要特点是将所有的写操作(包括插入、更新和删除)延迟应用到存储中。这种设计使得LSM-Tree对于追加操作非常友好,因此在需要大量写入操作的场景下,如日志记录、实时数据处理等,LSM-Tree表现出了优秀的性能。
LSM-Tree 的发展历史
LSM-Tree的设计和发展脉络可以追溯到1996年,由Patrick O'Neil等人首次提出。他们的目标是设计一种能够有效处理大量写入操作的数据结构。在此之前,大多数数据库系统都是基于B-Tree或其变种的数据结构,这些数据结构对于读操作非常优化,但在处理大量写入操作时性能较差。LevelDB 是 Google 开发的基于 LSM Tree(Log-Structured Merge Tree)设计的键值存储库。
LSM-Tree 的工作原理
LSM-Tree的工作原理是,当有新的写入操作时,它首先将这些操作存储在内存中的一个结构(通常称为MemTable)中。当MemTable达到一定大小后,LSM-Tree会将其内容排序,并写入到磁盘上,形成一个SST(Sorted String Table)文件。这个过程被称为Flush。因此,LSM-Tree的写入操作实际上是先写入内存,然后再批量写入磁盘,这大大提高了写入性能。
然而,随着写入操作的进行,会产生大量的SST文件。为了管理这些文件,以及应用之前延迟的更新和删除操作,LSM-Tree会定期进行一种称为Compaction的操作。在Compaction过程中,LSM-Tree会选择一些SST文件,将它们合并成一个新的SST文件,并在这个过程中应用更新和删除操作。这样,旧的SST文件就可以被删除,从而释放磁盘空间。
LSM-Tree的这种设计,使得它在处理大量写入操作时,能够提供高效的性能。同时,通过调整Compaction的策略,可以在读性能、写性能和存储空间之间做出权衡。因此,LSM-Tree被广泛应用于各种需要高效写入性能的场景,包括分布式数据库系统,如TiDB和CockroachDB等。此外,基于LSM-Tree的存储引擎,如RocksDB和LevelDB,也提供了丰富的键值访问功能,被许多生产系统所使用。
RocksDB是Facebook开发的一种持久化键值存储,它是Google的LevelDB的一个分支,但进行了大量优化,以满足在服务器环境中运行的需求。RocksDB使用了LSM-Tree作为其核心数据结构,并提供了丰富的API,以支持各种数据操作。
TiDB是PingCAP公司开发的一种分布式SQL数据库,它使用了RocksDB作为其底层存储引擎。TiDB的目标是提供一种能够水平扩展的、支持SQL的、强一致性的分布式数据库。
CockroachDB是一种分布式SQL数据库,它的设计目标是提供一种能够在全球范围内部署、自动复制和修复的数据库。CockroachDB也使用了RocksDB作为其底层存储引擎。
总的来说,LSM-Tree由于其在处理大量写入操作时的优秀性能,已经被广泛应用于各种数据库系统和存储引擎中,成为了现代数据管理的重要组成部分。
LSM-Tree 和 B-Tree 对比
LSM-Tree(Log-structured merge trees)和B-Tree是两种常见的用于存储和检索键值对的数据结构,它们各有优势和劣势。
首先,我们来看一下B-Tree。B-Tree是一种自平衡的树,可以保持数据有序,这使得在B-Tree中进行查找、顺序访问、插入和删除等操作的时间复杂度都是O(log n)。然而,B-Tree的一个主要缺点是,每次插入和删除操作都需要对树进行调整,以保持树的平衡。这意味着每次写操作都需要即时写入磁盘,这在磁盘I/O较慢的情况下可能会成为性能瓶颈。
相比之下,LSM-Tree的设计目标是优化写操作的性能。在LSM-Tree中,写操作(包括插入、更新和删除)首先被写入内存,然后在适当的时候批量写入磁盘。这种设计可以极大地减少磁盘I/O操作,从而提高写操作的性能。此外,由于数据在磁盘上是不可变的,这使得并发控制更简单,也更容易将数据存储和服务于像S3这样的云原生存储系统。
然而,LSM-Tree也有其缺点。由于数据是批量写入磁盘的,因此在数据被写入磁盘之前,如果系统崩溃,那么这些数据可能会丢失。此外,由于LSM-Tree使用了压缩操作来合并和删除旧的数据,这可能会导致读操作的性能下降,因为读操作可能需要读取和解压缩多个数据块。
总的来说,LSM-Tree和B-Tree各有优势和劣势,适用于不同的场景。如果写操作的性能是关键考虑因素,那么LSM-Tree可能是一个好的选择。如果需要支持高效的随机读取,并且写操作不是特别频繁,那么B-Tree可能更适合。
这篇文章结合 Leveldb 的读写过程来进一步讲解 LSM-Tree 。
LSM 组成部分
一个LSM(Log-structured merge)存储引擎通常包含三个部分:
-
预写日志(Write-ahead log):预写日志是一种用于恢复临时数据的机制。在进行任何修改操作之前,系统会先将这些操作写入预写日志。如果系统在修改操作完成之前崩溃,那么在系统重启后,可以通过预写日志来恢复这些未完成的修改操作。
-
磁盘上的SST(Sorted String Table):SST是一种用于维护LSM-tree结构的文件格式。在LSM-Tree中,数据首先被写入内存中的MemTable,当MemTable达到一定大小后,会将其内容排序,并写入到磁盘上形成一个SST文件。
-
内存中的Mem-table:Mem-table是一种内存数据结构,用于批量小写入。当有新的写入操作时,LSM-Tree首先将这些操作存储在内存中的MemTable中。当MemTable达到一定大小后,LSM-Tree会将其内容排序,并写入到磁盘上,形成一个SST文件。
写入过程
LSM(Log-structured merge)的写路径包含四个步骤:
-
将键值对写入预写日志:预写日志(Write-ahead log,简称WAL)是一种用于恢复临时数据的机制。在进行任何修改操作之前,系统会先将这些操作写入预写日志。如果系统在修改操作完成之前崩溃,那么在系统重启后,可以通过预写日志来恢复这些未完成的修改操作。这种机制可以保证系统在面临突然崩溃时,不会丢失未完成的写入操作。
-
将键值对写入memtable:Memtable是一种内存中的数据结构,用于暂时存储写入操作。当有新的写入操作时,LSM-Tree首先将这些操作存储在内存中的MemTable中。完成预写日志和MemTable的写入后,我们可以通知用户写操作已完成。这是因为即使此时系统崩溃,由于预写日志的存在,我们仍然可以恢复这些写入操作。
-
当一个mem-table满了,我们将它们冻结为不可变的mem-table,并在后台将它们刷新到磁盘作为SST文件:当MemTable达到一定大小后,LSM-Tree会将其内容排序,并写入到磁盘上,形成一个SST(Sorted String Table)文件。这个过程通常在后台进行,不会阻塞新的写入操作。这种设计可以减少磁盘I/O操作,从而提高写入性能。
-
引擎将在某些级别的一些文件压缩到较低的级别,以保持LSM树的良好形状,使得读放大率低:这个过程通常被称为压缩(Compaction)。压缩是LSM-Tree中的一个后台任务,它的主要作用是合并多个SST文件,并在这个过程中应用之前延迟的更新和删除操作。通过压缩,我们可以保持LSM-Tree的良好形状,使得读放大率低。读放大率是指执行一次读操作需要读取的数据块数量。如果LSM-Tree的形状良好,那么我们可以在读取一次数据时,尽可能地减少需要读取的数据块数量,从而提高读取性能。
读取过程
在LSM(Log-structured merge)中,读取一个键的过程包含两个步骤:
-
我们首先会探测所有的mem-table,从最新的到最旧的:在LSM中,新的写入操作首先被存储在内存中的MemTable中。因此,当我们想要读取一个键时,我们首先会在MemTable中查找这个键。由于新的写入操作被存储在最新的MemTable中,所以我们会从最新的MemTable开始查找,然后依次向旧的MemTable查找。
-
如果键未找到,我们将搜索包含SST的整个LSM树以找到数据:如果在所有的MemTable中都没有找到这个键,那么我们会在磁盘上的SST文件中查找这个键。SST文件是LSM中的一种文件格式,用于存储已经从MemTable写入到磁盘上的数据。在SST文件中,数据被存储在一个树形结构中,这个树形结构被称为LSM树。我们会在LSM树中查找这个键,直到找到这个键,或者确定这个键不存在。
在LSM中,有两种类型的读取操作:查找和扫描。
-
查找:查找操作是指在LSM树中查找一个特定的键。在查找操作中,我们会按照上述的步骤,从MemTable和SST文件中查找这个键。
-
扫描:扫描操作是指在存储引擎中迭代一个范围内的所有键。在扫描操作中,我们会首先在MemTable中迭代这个范围内的所有键,然后在SST文件中迭代这个范围内的所有键。由于LSM树的特性,我们可以在扫描操作中高效地迭代一个范围内的所有键。
Memtable 是一个内存中的数据结构,它用于存储和查找键值对。在 LSM(Log-Structured Merge-tree)存储引擎中,写入操作首先会写入到 Memtable 中。当 Memtable 的大小达到一定阈值时,它会被转换为 SSTable 并刷新到磁盘。
接下来结合 Leveldb 来讲解 Memtable 具体实现细节。
Leveldb 中 Memtable 的组成部分
在 LevelDB 中,Memtable 是一个内存中的数据结构,用于存储和查找键值对。它的主要组成部分包括:
-
SkipList:SkipList 是 Memtable 的核心数据结构,用于存储键值对。SkipList 是一种可以进行快速查找的有序数据结构。在 LevelDB 中,SkipList 中的每个节点都包含一个键值对。
-
内存分配器:LevelDB 的 Memtable 使用一个简单的内存分配器来管理内存。这个分配器会预先分配一大块内存,然后逐渐使用这块内存来存储数据。
-
写缓冲区:所有的写操作(包括插入、删除和更新)首先会写入到 Memtable 的写缓冲区中。当写缓冲区满时,数据会被移动到 SkipList 中。
-
版本控制信息:LevelDB 的 Memtable 还存储了一些版本控制信息,如每个键的最新版本号。这些信息用于处理并发写入和读取。
-
删除标记(Tombstones):当一个键被删除时,Memtable 会插入一个特殊的键值对,其中键是被删除的键,值是一个特殊的标记,表示该键已被删除。这种键值对被称为 Tombstone。
以上就是 LevelDB 中 Memtable 的主要组成部分。
MemTable 的写入过程
当进行写入操作时,会将键值对添加到 MemTable 中。具体来说,MemTable 的写入过程如下:
-
首先,会创建一个内部键,这个内部键由用户键、序列号和值类型组成。序列号是一个递增的整数,用于区分同一个用户键的不同版本。值类型可以是
kTypeValue
或kTypeDeletion
,表示这是一个插入操作还是删除操作。 -
然后,会将内部键和值编码为一个字符串。编码的格式是:内部键长度(varint32)、内部键(char[])、值长度(varint32)、值(char[])。这个编码后的字符串就是要插入到 MemTable 中的键值对。
-
接着,会从 MemTable 的内存池中分配一块内存,用于存储编码后的键值对。
-
最后,会将编码后的键值对插入到 MemTable 的跳表中。跳表是一个有序的数据结构,可以在对数时间内完成查找、插入和删除操作。
以上就是 LevelDB 中 MemTable 的写入过程。
在你选中的 MemTable::Add
函数中,实现了上述的写入过程。首先创建了一个内部键,然后将内部键和值编码为一个字符串,接着从内存池中分配了一块内存,最后将编码后的键值对插入到了跳表中。
MemTable 的读取过程
当进行读取操作时,首先会在 MemTable 中查找键。如果在 MemTable 中找到了键,那么就直接返回对应的值。
具体来说,MemTable 的读取过程如下:
-
创建一个 MemTable 的迭代器,并将其定位到要查找的键的位置。这是通过调用
Table::Iterator::Seek
方法实现的。 -
检查迭代器是否有效,即是否找到了键。如果迭代器有效,那么就获取当前的键值对。
-
解析键值对的格式,获取键和值。键值对的格式是:键长度(varint32)、键(char[])、标签(uint64)、值长度(varint32)、值(char[])。
-
检查解析出的键是否与要查找的键相同。如果相同,那么就根据标签的类型返回对应的值或者表示键已被删除的状态。
-
如果在 MemTable 中没有找到键,那么就返回 false,表示键不存在。
以上就是 LevelDB 中 MemTable 的读取过程。
为什么 MemTable 没有 delete ?
在LSM(Log-structured merge-tree)中,删除操作通常是通过插入一个特殊的键值对来实现的,这个键值对被称为“tombstone”。当我们想要删除一个键时,我们会在MemTable中插入一个带有该键和一个特殊值(如null或特殊标记)的键值对。然后,在读取数据时,如果遇到这个特殊的键值对,我们就知道这个键已经被删除了。
因此,MemTable本身不需要提供一个delete
API,因为删除操作可以通过已有的put
或add
API来实现。这样做的好处是可以简化MemTable的设计,并且可以在读取数据时处理删除操作,这对于LSM的性能优化是有利的。
MemTable 的冻结过程
在 LevelDB 中,MemTable 是一个内存中的数据结构,用于存储写入操作的键值对。当 MemTable 达到一定大小(默认为 4MB)时,它会被转换为一个不可变的(即只读的)MemTable,并开始创建一个新的 MemTable 以接收新的写入操作。这个不可变的 MemTable 会被写入到磁盘,形成一个新的 SSTable 文件。
具体来说,MemTable 的冻结过程如下:
[新的写入] ---> [MemTable] ---> [不可变的 MemTable] ---> [SSTable 文件]
-
当有新的写入请求时,会检查当前的 MemTable 是否已经达到了阈值。这是通过调用
MemTable::ApproximateMemoryUsage
方法实现的,该方法会返回 MemTable 当前使用的内存大小。 -
如果当前的 MemTable 已经达到了阈值,那么就会将当前的 MemTable 标记为只读,并创建一个新的 MemTable。这是通过调用
DBImpl::MakeRoomForWrite
方法实现的。 -
将只读的 MemTable 添加到待写入磁盘的队列中,并唤醒后台线程,将只读的 MemTable 写入到磁盘,形成 SSTable。这是通过调用
DBImpl::WriteLevel0Table
方法实现的。
冻结 MemTable 的好处是可以将写入操作和磁盘 I/O 操作分离,提高写入性能。同时,由于 MemTable 是有序的,所以生成的 SSTable 也是有序的,这对于后续的合并和压缩操作非常有利。
为什么 MemTable 使用跳表?
可以使用其他数据结构作为LSM(Log-Structured Merge-tree)中的MemTable。常见的选择包括哈希表、平衡树(如红黑树)和跳表。
使用跳表作为MemTable的优点包括:
- 跳表的插入、删除和查找操作的时间复杂度都是O(log n),这使得跳表在处理大量数据时具有良好的性能。
- 跳表的结构简单,易于实现。它不需要复杂的旋转操作,这使得跳表在实现上比平衡树更简单。
- 跳表支持范围查询,这对于某些应用(如数据库)来说是非常重要的。
使用跳表作为MemTable的缺点包括:
- 跳表的空间效率较低。每个节点需要存储多个指针,这会占用更多的内存。
- 跳表的性能受到随机数生成器的影响。在跳表中,节点的级别是由随机数生成器决定的,如果随机数生成器的性能不佳,可能会影响跳表的性能。
其他的数据结构,如哈希表和平衡树,也有其各自的优点和缺点。例如,哈希表的插入和查找操作的时间复杂度是O(1),但它不支持范围查询。平衡树支持范围查询,并且空间效率较高,但它的实现比跳表复杂。
如果一个键出现在多个MemTables中应该返回哪个版本?
当我们向LSM中写入数据时,新的数据会被写入最新的MemTable中。当这个MemTable满了,它会被转换为不可变的,并且会创建一个新的MemTable来存储新的写入。这意味着,对于同一个键,它的最新版本总是在最新的MemTable中。
当我们从LSM中读取数据时,我们需要从最新的MemTable开始探测,然后按照从新到旧的顺序探测其他的MemTable。这是因为我们总是想要获取键的最新版本。如果一个键出现在多个MemTables中,我们应该返回在最新的MemTable中找到的版本,因为这是最新的数据。
因此,存储和探测MemTables的顺序对于保证数据的一致性和正确性是非常重要的。
MemTable的内存布局是否高效/是否具有良好的数据局部性?
在 MemTable
中,所有的键值对数据和跳表节点都是通过 Arena
内存分配器来分配内存的。Arena
内存分配器的工作原理是预先分配一大块内存,然后在需要分配内存时,直接从这个预先分配的内存块中切割出所需大小的内存给使用者。这种方式的优点是分配和释放内存的速度非常快,因为实际上并没有进行系统级别的内存分配和释放操作。
另外,由于 Arena
分配器是连续分配内存的,因此在 MemTable
中的数据在内存中的布局也是连续的。这种连续的内存布局可以提高 CPU 缓存的命中率,因为连续的内存区域有更大的可能性被一次性加载到 CPU 缓存中,这被称为空间局部性。因此,MemTable
的内存布局是高效的,并且具有良好的数据局部性。
然而,需要注意的是,虽然 Arena
分配器可以提供快速的内存分配和良好的数据局部性,但是它也有一些缺点。例如,一旦内存被分配出去,就不能再被回收和重新分配给其他对象。因此,Arena
分配器更适合于生命周期明确,且不需要动态增长或缩小的内存分配场景。
冻结MemTable后,是否可能有一些线程仍然持有旧的LSM状态并写入这些不可变的MemTables?
在 LevelDB 中,当 MemTable 被冻结(转变为不可变状态)后,新的写入操作将不会再被添加到这个 MemTable 中,而是会被添加到一个新的 MemTable 中。这是通过在写入操作时获取写入锁来实现的,这样可以确保在转变 MemTable 状态的过程中不会有新的写入操作。
然而,可能会有一些线程在 MemTable 被冻结之前已经开始了写入操作,并且这些写入操作可能会在 MemTable 被冻结后才完成。为了处理这种情况,LevelDB 使用了引用计数机制。每当一个线程开始写入操作时,它会增加 MemTable 的引用计数。当写入操作完成后,线程会减少 MemTable 的引用计数。只有当 MemTable 的引用计数降至零时,MemTable 才会被真正地删除。
因此,即使 MemTable 被冻结,只要还有线程持有对它的引用,它就不会被删除。这样可以确保所有已经开始的写入操作都能正确地完成。
MemTable 迭代器是用于遍历 MemTable 中的键值对的工具。在 LevelDB 中,MemTable 是内存中的键值对存储结构,使用跳表(SkipList)实现,提供了快速插入和查找键值对的功能。
MemTableIterator
在 MemTable::NewIterator()
方法中,创建了一个新的 MemTableIterator
实例,并返回其指针。这个方法提供了一种方式来创建一个新的 MemTable 迭代器,用于遍历 MemTable 中的所有元素。
合并迭代器
MergingIterator
的主要作用是合并多个迭代器的结果,这在处理多个数据源时非常有用。例如,假设你有三个数组,每个数组都已经排序,你想要遍历这三个数组中的所有元素,同时保持元素的顺序。你可以为每个数组创建一个迭代器,然后使用 MergingIterator
来合并这些迭代器。
以下是一个简化的示例:
// 假设我们有三个已排序的数组
std::vector<int> arr1 = {1, 3, 5, 7};
std::vector<int> arr2 = {2, 4, 6, 8};
std::vector<int> arr3 = {0, 9, 10, 11};
// 为每个数组创建一个迭代器
Iterator* it1 = new VectorIterator(arr1);
Iterator* it2 = new VectorIterator(arr2);
Iterator* it3 = new VectorIterator(arr3);
// 创建一个迭代器数组
Iterator* iterators[3] = {it1, it2, it3};
// 创建一个 MergingIterator 来合并这些迭代器
MergingIterator* merge_it = new MergingIterator(new IntComparator(), iterators, 3);
// 使用 MergingIterator 遍历所有元素
for (merge_it->SeekToFirst(); merge_it->Valid(); merge_it->Next()) {
std::cout << merge_it->value() << " ";
}
在这个例子中,MergingIterator
会按照正确的顺序遍历所有数组的元素,就像遍历一个单一的、有序的数组一样。首先,它会找到所有迭代器中的最小元素(在这个例子中是 0),然后移动到下一个最小元素,依此类推,直到遍历完所有元素。
上面这段代码的目的是将三个已排序的数组合并,并按照升序打印出所有的元素。MergingIterator
会按照正确的顺序遍历所有数组的元素,就像遍历一个单一的、有序的数组一样。
在这个例子中,MergingIterator
会首先找到所有迭代器中的最小元素(在这个例子中是 0),然后移动到下一个最小元素,依此类推,直到遍历完所有元素。
所以,这段代码的输出应该是:
0 1 2 3 4 5 6 7 8 9 10 11
这就是所有三个数组中的元素,按照升序排列。
请注意,这个例子假设了 VectorIterator
和 IntComparator
类的存在,这两个类在实际的 LevelDB 中并不存在,这里只是为了说明 MergingIterator
的工作原理。在实际的 LevelDB 中,MergingIterator
用于合并多个 MemTable
的迭代器,以便可以按照正确的顺序遍历所有 MemTable
的内容。
实现细节
LevelDB 的合并迭代器(MergingIterator
)是通过维护一组子迭代器来实现的,这些子迭代器分别对应于需要合并的数据源。MergingIterator
的主要工作是在这些子迭代器之间进行协调,以确保以正确的顺序返回元素。
在 MergingIterator
中,有两个主要的方法用于定位当前应该返回的元素:FindSmallest
和 FindLargest
。这两个方法分别在向前和向后遍历时使用,它们会遍历所有的子迭代器,找到具有最小或最大键的迭代器,并将其设置为当前迭代器。
当调用 Next
或 Prev
方法时,MergingIterator
会先移动当前迭代器,然后再调用 FindSmallest
或 FindLargest
来找出新的当前迭代器。这样,MergingIterator
就可以按照键的大小顺序遍历所有的元素。
在 Seek
方法中,MergingIterator
会将所有的子迭代器定位到指定的键,然后再调用 FindSmallest
来找出新的当前迭代器。这样,MergingIterator
就可以从指定的键开始遍历元素。
在 MergingIterator
的实现中,还有一些额外的逻辑用于处理迭代器的方向。如果迭代器的方向改变了(例如,先调用了 Next
,然后调用了 Prev
),MergingIterator
会确保所有的子迭代器都定位到正确的位置。
关于时间和空间复杂度:
-
时间复杂度:
MergingIterator
的操作(Next
、Prev
、Seek
等)的时间复杂度为 O(n),其中 n 是子迭代器的数量。这是因为在每个操作中,MergingIterator
都需要遍历所有的子迭代器来找出当前应该返回的元素。 -
空间复杂度:
MergingIterator
的空间复杂度为 O(n),其中 n 是子迭代器的数量。这是因为MergingIterator
需要存储所有的子迭代器。
如果一个键被移除需要将其返回给用户吗?Leveldb 在哪里处理了这个逻辑?
在 LevelDB 中,当一个键被删除时,会在 MemTable 中添加一个删除标记(也称为墓碑)。这个删除标记是一个特殊的键值对,其类型为 kTypeDeletion
,键为被删除的键,值通常为空。
当用户尝试获取一个被删除的键时,LevelDB 会首先在 MemTable 中查找这个键。如果找到了对应的删除标记,LevelDB 会返回一个 NotFound
错误,表示这个键不存在。这个逻辑在 MemTable::Get
方法中实现:
在这个方法中,LevelDB 首先在 MemTable 中查找指定的键。如果找到了对应的键值对,LevelDB 会检查其类型。如果类型为 kTypeValue
,则返回对应的值;如果类型为 kTypeDeletion
,则返回 NotFound
错误。
如果一个键有多个版本,用户会看到所有的版本吗?Leveldb 在哪里处理了这个逻辑?
在 LevelDB 中,一个键可能有多个版本,每个版本都有一个与之关联的序列号。这些版本是按照序列号的降序排列的,也就是说,序列号最大的版本在最前面。
当用户尝试获取一个键的值时,LevelDB 会返回序列号最大(也就是最新)的版本。这个逻辑在 Version::Get
方法中实现:
在这个方法中,LevelDB 首先在 MemTable 和 SSTable 中查找指定的键。如果找到了对应的键值对,LevelDB 会检查其序列号。只有当序列号小于或等于读取操作的快照序列号时,LevelDB 才会返回对应的值。这样,用户就只能看到他们在执行读取操作时已经存在的版本,而不能看到后来添加的版本。
如果一个键有多个版本,那么 Version::Get
方法只会返回序列号最大的版本。这是因为在查找键值对时,一旦找到了一个匹配的键值对,Version::Get
方法就会立即返回,而不会继续查找其他的版本。因此,用户通常只能看到一个键的最新版本。
迭代器会随着数据的更新而更新吗?
如果在 skiplist memtable 上创建一个迭代器,随后向 memtable 插入新的键,那么迭代器会看到新的键吗?
在 LevelDB 中,迭代器是基于创建它们的那一刻的状态进行操作的。也就是说,如果在创建迭代器之后有新的键插入到 MemTable,那么这个迭代器是不会看到这个新的键的。
这是因为在创建迭代器时,LevelDB 会创建一个快照(Snapshot),并且这个快照会包含创建它的那一刻 MemTable 的所有数据。然后,迭代器在进行操作时,只会操作这个快照中的数据,而不会操作后来插入到 MemTable 的数据。
这种设计可以确保迭代器的操作是一致的,也就是说,无论在迭代器操作期间 MemTable 如何变化,迭代器看到的数据都是一致的。这对于数据库的并发控制是非常重要的,因为它可以确保在读取数据时不会受到其他操作的影响。
这个逻辑在 LevelDB 的 DBImpl::Get
方法中实现,该方法在创建迭代器之前会先创建一个快照。
为什么需要确保合并迭代器按照迭代器构造顺序返回数据?
在 MergingIterator
中,我们并没有要求合并迭代器必须按照迭代器构造的顺序返回数据。实际上,MergingIterator
的设计目标是返回所有子迭代器中的最小(或最大)元素,而不是按照子迭代器的构造顺序返回元素。
在 MergingIterator
的 Next
和 Prev
方法中,我们可以看到,它会在所有子迭代器中找到键最小(或最大)的那个,然后将其设置为当前迭代器。这样,当我们调用 MergingIterator
的 key
或 value
方法时,它会返回当前迭代器的键或值,也就是所有子迭代器中键最小(或最大)的那个。
这种设计可以确保 MergingIterator
在遍历数据时,总是按照键的升序(或降序)顺序返回元素,而不是按照子迭代器的构造顺序返回元素。这对于数据库的查询操作是非常重要的,因为它可以确保查询结果的有序性,从而提高查询效率。
总结
这篇文章主要讲解了 LevelDB 中的迭代器设计,包括 MemTableIterator
和 MergingIterator
。MemTableIterator
是用于遍历内存中键值对存储结构 MemTable
的工具。MergingIterator
则是用于合并多个迭代器的结果,以便可以按照正确的顺序遍历所有的元素。此外,文章还讨论了 LevelDB 中的一些实现细节,如键的多版本处理,迭代器的一致性,以及合并迭代器的工作原理等。
在 LevelDB 中,Block
是一个重要的数据结构,它用于存储一组键值对。Block
的设计目标是将一组键值对存储在一起,以便可以快速地将它们一起读取到内存中,从而提高查询效率。
Block 概览
在 LevelDB 中,磁盘上的数据是以块(Block)为单位进行存储的。块通常为 4-KB 大小,这相当于操作系统中的页面大小和 SSD 上的页面大小。一个块存储有序的键值对,这些键值对是通过一种特殊的编码格式进行存储的,以便可以高效地进行查询和更新操作。
一个 SST(Sorted String Table)文件由多个块组成,每个块都存储了一部分键值对。当内存中的 MemTable 的数量超过系统限制时,LevelDB 会将 MemTable 刷新为一个 SST 文件。这个过程称为 Compaction,它是 LevelDB 管理磁盘空间和提高查询效率的重要机制。
块的编码和解码是 LevelDB 中的一个重要部分。在编码过程中,LevelDB 会将一组键值对按照一定的格式编码为一个字节流,然后将这个字节流写入到磁盘中。在解码过程中,LevelDB 会从磁盘中读取一个字节流,然后按照编码格式将这个字节流解码为一组键值对。
在 table/block.cc
和 table/block.h
文件中,实现了 Block
类,这个类封装了块的编码和解码操作。在 Block
类中,定义了一些方法,如 NewIterator
、ReadBlock
和 ApproximateMemoryUsage
等,这些方法用于创建迭代器、读取块的内容和估计块的内存使用量等。
在 table/format.h
文件中,定义了 BlockHandle
类和 Footer
类,这两个类用于描述块在 SST 文件中的位置和元数据信息。
在 Block
的编码格式中,包括了一系列的键值对和一个重启点数组。每个键值对包括一个共享键长度,一个非共享键长度,一个值长度,一个非共享键和一个值。重启点数组是一个固定大小的整数数组,用于帮助在 Block
中进行二分查找。
在 Block
的解码过程中,LevelDB 会首先读取 Block
的元数据,包括 Block
的大小和校验和,然后根据这些元数据读取 Block
的内容。然后,LevelDB 会按照编码格式将这个字节流解码为一组键值对。
总的来说,块的编码和解码是 LevelDB 管理磁盘空间和提高查询效率的重要部分。
Block 的编码格式
键值对部分的编码格式如下:
-
共享键长度:这是一个变长整数,表示当前键与前一个键共享的前缀长度。对于块中的第一个键,这个值总是为 0,因为它没有前一个键可以共享前缀。
-
非共享键长度:这也是一个变长整数,表示当前键与前一个键不共享的后缀长度。也就是说,这个值表示当前键的独有部分的长度。
-
值长度:这同样是一个变长整数,表示当前键对应的值的长度。
-
非共享键:这是一个字节序列,长度为非共享键长度。这个序列与前一个键的共享前缀连接在一起,就构成了当前键。
-
值:这是一个字节序列,长度为值长度。这就是当前键对应的值。
重启点数组部分的编码格式如下:
重启点数组是一个固定大小的整数数组,每个整数的大小为 4 字节。这个数组包含了一系列的重启点,每个重启点都是一个偏移量,指向 Block
中的一个键。这个数组用于帮助在 Block
中进行二分查找。
这种编码格式的设计使得 Block
可以高效地进行二分查找。当我们需要查找一个键时,可以先在重启点数组中进行二分查找,找到最接近的重启点,然后从这个重启点开始,顺序查找到我们需要的键。这样,我们就可以在 Block
中快速地查找到任何一个键。
一个具体的例子
在 LevelDB 中,键值对的编码过程主要在 BlockBuilder::Add
方法中完成。假设我们有一个键值对,键为 "key123",值为 "value123",我们将看到如何将其编码为块。
首先,我们需要计算当前键与上一个键的共享前缀长度。假设上一个键是 "key122",那么共享前缀长度就是 5,因为 "key122" 和 "key123" 的前 5 个字符是相同的。
然后,我们需要计算当前键的非共享部分的长度,也就是当前键去掉共享前缀后的长度。在这个例子中,非共享部分是 "3",所以非共享键长度就是 1。
接下来,我们需要计算值的长度。在这个例子中,值是 "value123",所以值长度就是 7。
然后,我们将共享键长度、非共享键长度和值长度编码为变长整数,添加到缓冲区中。在这个例子中,共享键长度、非共享键长度和值长度分别为 5、1 和 7,编码后的结果分别为 05、01 和 07。
接着,我们将非共享键和值添加到缓冲区中。在这个例子中,非共享键是 "3",值是 "value123"。
最后,我们更新上一个键和键值对数量。在这个例子中,上一个键变为 "key123",键值对数量加 1。
所以,"key123" 和 "value123" 编码为块的结果就是 "0501073value123"。
这只是一个简化的例子,实际的编码过程可能会涉及到更复杂的情况,比如前缀压缩、重启点的添加等。
restarts array
在 LevelDB 中,Block 的数据部分是由一系列的键值对组成,这些键值对是按照键的顺序存储的。为了提高查找效率,LevelDB 在每个 Block 的末尾添加了一个重启点数组(restarts array)。
重启点数组是一个包含多个偏移量的数组,每个偏移量都指向 Block 中的一个键。这些键是特殊的,因为它们是完整的,没有进行前缀压缩。这些键被称为重启点(restart points),因为它们是新一轮前缀压缩的开始。
当我们要在 Block 中查找一个键时,我们可以先在重启点数组中进行二分查找,找到最接近目标键的重启点,然后从这个重启点开始,顺序查找到目标键。这样,我们就可以避免扫描整个 Block,从而提高查找效率。
重启点数组的编码格式如下:
- 重启点数组是一个固定大小的整数数组,每个整数的大小为 4 字节。这个数组包含了一系列的重启点,每个重启点都是一个偏移量,指向 Block 中的一个键。
- 重启点数组的末尾是一个 4 字节的整数,表示重启点的数量。
这就是 Block 中的重启点数组的基本概念和作用。
在块中查找一个键的时间复杂度是多少?
在 LevelDB 中,查找一个键的时间复杂度主要取决于两个部分:在重启点数组中进行二分查找的时间复杂度和从找到的重启点开始顺序查找到目标键的时间复杂度。
-
在重启点数组中进行二分查找的时间复杂度是 O(log n),其中 n 是重启点的数量。因为二分查找是一种高效的查找算法,其时间复杂度是对数级别的。
-
从找到的重启点开始顺序查找到目标键的时间复杂度是 O(m),其中 m 是两个重启点之间的键的数量。因为这部分是顺序查找,所以时间复杂度是线性级别的。
因此,总的来说,查找一个键的时间复杂度是 O(log n + m)。在实际应用中,由于 LevelDB 的重启点间隔(block_restart_interval)通常设置得比较小(例如16),所以 m 的值通常不会很大,因此查找一个键的效率是相当高的。
当查找一个不存在的键时,光标会停在哪里?
在 LevelDB 中,当查找一个不存在的键时,光标(Cursor)会停在大于或等于目标键的第一个键的位置。如果所有的键都小于目标键,那么光标会移动到数据的末尾,此时调用 Valid()
方法会返回 false
,表示已经没有更多的数据。这是因为 LevelDB 的迭代器设计为半开区间,即 [start, end)
,当目标键不存在时,迭代器会定位到大于或等于目标键的第一个位置。
写入块的数字的字节顺序是什么?
在 LevelDB 中,写入块的数字使用的是小端字节序(Little-Endian)。这是由于 LevelDB 的设计者们选择了这种字节序,因为它在大多数现代处理器(包括 x86 和 ARM)上都能提供更好的性能。
在源代码中,可以看到 PutFixed32
函数,它用于将 32 位整数写入到块中:
void PutFixed32(std::string* dst, uint32_t value) {
char buf[sizeof(value)];
EncodeFixed32(buf, value);
dst->append(buf, sizeof(buf));
}
在 EncodeFixed32
函数中,你可以看到它是如何将一个 32 位整数编码为小端字节序的:
void EncodeFixed32(char* buf, uint32_t value) {
if (port::kLittleEndian) {
memcpy(buf, &value, sizeof(value));
} else {
buf[0] = value & 0xff;
buf[1] = (value >> 8) & 0xff;
buf[2] = (value >> 16) & 0xff;
buf[3] = (value >> 24) & 0xff;
}
}
这里,port::kLittleEndian
是一个编译时常量,它表示当前平台是否使用小端字节序。如果是,那么直接使用 memcpy
函数将整数的内存复制到缓冲区即可。否则,需要手动将每个字节编码为小端字节序。
Leveldb 是否容易受到恶意构建的块的影响?如果用户故意构造一个无效的块,会有无效的内存访问或 OOMs 吗?
LevelDB 是设计得相当健壮的,它有很多检查和验证机制来确保数据的完整性。然而,如果一个恶意用户故意构造一个无效的块并尝试将其插入到数据库中,LevelDB 会在尝试读取或写入这个块时遇到问题。
首先,LevelDB 在读取块时会进行一些基本的验证,例如检查块的大小是否合理,以及块的校验和是否正确。如果这些检查失败,LevelDB 将拒绝读取这个块,并返回一个错误。
其次,LevelDB 在处理块中的键值对时也有一些保护措施。例如,它会检查每个键值对的长度是否合理,以及键是否按照正确的顺序排列。如果这些检查失败,LevelDB 将停止处理这个块,并返回一个错误。
然而,尽管有这些保护措施,但是如果一个恶意用户故意构造一个非常大的块,并尝试将其插入到数据库中,那么这可能会导致 LevelDB 使用大量的内存,甚至可能导致内存耗尽(OOM)。因此,对于不受信任的输入,最好在将其插入到 LevelDB 之前进行一些额外的验证。
总的来说,虽然 LevelDB 有一些内置的保护措施来防止无效的块,但是它并不能完全防止所有的恶意攻击。因此,如果你的应用需要处理不受信任的输入,那么你应该在将数据插入到 LevelDB 之前进行一些额外的验证。
一个块可以包含重复的键吗?
在 LevelDB 中,一个块(Block)中的键是不允许重复的。这是因为 LevelDB 是一个键值存储系统,每个键在数据库中都应该是唯一的。在添加新的键值对时,如果键已经存在,那么新的值将会覆盖旧的值。
如果用户添加一个大于目标块大小的键会发生什么?
如果用户尝试添加一个大于目标块大小的键,那么这将会导致问题。首先,如果键的大小超过了块的大小,那么这个键将无法被添加到块中。其次,由于 LevelDB 使用前缀压缩来存储键,如果一个键的大小超过了块的大小,那么前缀压缩将无法工作,这将会导致存储效率降低。
在实际使用中,键的大小通常远小于块的大小,因此这种情况很少发生。如果你的应用需要处理大键,那么你可能需要调整 LevelDB 的配置,或者寻找其他更适合处理大键的数据库系统。
总结
LevelDB 是一个键值存储系统,其中数据以块(Block)的形式存储,每个块包含一组有序的键值对。块的设计使得可以快速地将一组键值对读取到内存中,提高查询效率。块的编码和解码是 LevelDB 管理磁盘空间和提高查询效率的重要部分。在编码过程中,LevelDB 会将一组键值对按照一定的格式编码为一个字节流,然后将这个字节流写入到磁盘中。在解码过程中,LevelDB 会从磁盘中读取一个字节流,然后按照编码格式将这个字节流解码为一组键值对。LevelDB 在读取块时会进行一些基本的验证,例如检查块的大小是否合理,以及块的校验和是否正确。如果这些检查失败,LevelDB 将拒绝读取这个块,并返回一个错误。在 LevelDB 中,一个块中的键是不允许重复的。如果用户尝试添加一个大于目标块大小的键,那么这将会导致问题。
SST(Sorted String Table)是 LevelDB 中的一个重要组成部分,它是 LevelDB 存储数据的主要方式。SST 文件是由多个块(Block)组成的,每个块都存储了一部分键值对。这些键值对是有序的,这使得在 SST 文件中查找一个键变得非常高效。
SST 和 MemTable 之间的关系
MemTable 是 LevelDB 中的另一个重要组成部分,它是 LevelDB 的写缓冲区。当我们向 LevelDB 写入数据时,数据首先被写入到 MemTable 中。当 MemTable 的大小超过一定的阈值(由配置选项决定)时,LevelDB 会将 MemTable 转换(Flush)成一个 SST 文件,并将这个 SST 文件写入到磁盘中。这个过程称为 Compaction。
在 Compaction 过程中,LevelDB 会将 MemTable 中的数据按照键的顺序写入到 SST 文件中,每个块存储一部分键值对。这样,当我们需要查找一个键时,我们只需要在 SST 文件中的一部分(即一个块)中查找,这大大提高了查找的效率。
总的来说,SST 和 MemTable 在 LevelDB 中起着非常重要的作用。它们一起工作,使得 LevelDB 能够高效地存储和查找数据。
如何构建 SST ?
在 LevelDB 中,SST(Sorted String Table)的构建主要通过 BlockBuilder
类来完成。以下是其主要步骤:
-
初始化 BlockBuilder:在
BlockBuilder
的构造函数中,会初始化一些成员变量,包括options_
(LevelDB 的配置选项)、restarts_
(重启点数组)、counter_
(计数器,用于记录自上一个重启点以来添加的键值对的数量)、finished_
(标记是否已经完成了一个块的构建)和last_key_
(上一个添加的键)。 -
添加键值对:通过
Add
方法添加键值对。在添加键值对时,会首先计算当前键与上一个键的共享前缀长度,然后将共享前缀长度、非共享部分长度和值的长度以变长整数的形式写入到缓冲区中,接着将非共享部分的键和值写入到缓冲区中。 -
处理重启点:如果添加的键值对的数量达到了重启点间隔(由
options_->block_restart_interval
指定),那么就会添加一个重启点。重启点是一个偏移量,指向块中的一个键。重启点数组用于帮助在块中进行二分查找。 -
完成块的构建:通过
Finish
方法完成块的构建。在完成块的构建时,会将重启点数组和重启点的数量写入到缓冲区的末尾,然后将finished_
设置为true
,表示已经完成了一个块的构建。 -
重置 BlockBuilder:通过
Reset
方法重置BlockBuilder
,以便开始构建下一个块。
以下是 BlockBuilder
的主要代码:
BlockBuilder::BlockBuilder(const Options* options)
: options_(options), restarts_(), counter_(0), finished_(false) {
assert(options->block_restart_interval >= 1);
restarts_.push_back(0); // First restart point is at offset 0
}
SST的编码格式
在LevelDB中,SST(Sorted String Table)的编码格式主要包括三个部分:数据块区域(Block Section)、元数据区域(Meta Section)和额外区域(Extra)。
-
数据块区域(Block Section):这个区域包含了所有的数据块。每个数据块都包含了一些键值对。这些键值对是按照键的顺序存储的,这使得SST可以高效地支持范围查询。
-
元数据区域(Meta Section):这个区域包含了元数据,元数据描述了数据块的信息,例如每个数据块的第一个和最后一个键,以及每个数据块的偏移量。
-
额外区域(Extra):这个区域包含了元块的偏移量,这是一个32位的无符号整数。
块缓存
在LevelDB中,SST(Sorted String Table)文件的数据块通常是懒加载的,也就是说,只有当用户请求时,它们才会被加载到内存中。这种机制被称为块缓存(Block Cache)。
块缓存的主要目的是减少对磁盘的读取次数,从而提高查询效率。当我们需要读取一个数据块时,首先会在块缓存中查找。如果找到了,就直接从缓存中读取,避免了磁盘IO操作。如果在缓存中没有找到,那么就需要从磁盘中读取数据块,并将读取的数据块添加到缓存中,以便下次使用。
块缓存通常使用LRU(Least Recently Used)策略来管理内存。当缓存满了,需要添加新的数据块时,会选择最近最少使用的数据块进行替换。
在LevelDB的实现中,块缓存是通过leveldb::Cache
类来实现的。这个类提供了Insert
、Lookup
和Erase
等方法,用于管理缓存中的数据块。
需要注意的是,块缓存只缓存数据块,不缓存索引块和过滤器块。这是因为索引块和过滤器块通常比较小,且一旦加载后会一直使用,所以没有必要缓存。
总的来说,块缓存是一种用空间换时间的策略,通过缓存常用的数据块,可以大大提高LevelDB的查询效率。
在 SST 中查找一个键的时间复杂度是多少?
在SST(Sorted String Table)中查找一个键的时间复杂度主要取决于两个部分:索引查找和数据块查找。
-
索引查找:索引块中存储了数据块的元数据,包括每个数据块的第一个和最后一个键,以及每个数据块的偏移量。索引块通常存储在内存中,因此查找索引的时间复杂度为O(log n),其中n是索引块的数量。
-
数据块查找:在找到包含给定键的数据块后,需要在数据块中查找给定的键。数据块中的键是按排序顺序存储的,因此查找键的时间复杂度为O(log m),其中m是数据块中的键的数量。
因此,总的来说,在SST中查找一个键的时间复杂度为O(log n + log m)。
在 SST 文件中进行原地更新是否可能(或必要)?
在SST(Sorted String Table)文件中进行原地更新通常是不可能的。这是因为SST文件是不可变的,一旦写入磁盘,就不能更改。这种设计可以简化系统的复杂性,并提高其可靠性,因为不需要处理在写入过程中可能发生的错误。
当需要更新SST文件中的键值对时,通常的做法是写入一个新的键值对,然后在后续的压缩操作中删除旧的键值对。这种做法被称为“写入放置”(Write-once or Write-Ahead)。
因此,原地更新SST文件中的数据既不可能,也不必要。如果需要更新存储在SST文件中的数据,应该使用LevelDB或其他键值存储系统提供的更新机制。
总结
SST(Sorted String Table)是 LevelDB 中的一个重要组成部分,它是 LevelDB 存储数据的主要方式。SST 文件是由多个块(Block)组成的,每个块都存储了一部分键值对。这些键值对是有序的,这使得在 SST 文件中查找一个键变得非常高效。SST 文件是由 MemTable 转换(Flush)而来的,当 MemTable 的大小超过一定的阈值时,LevelDB 会将 MemTable 转换成一个 SST 文件,并将这个 SST 文件写入到磁盘中。这个过程称为 Compaction。在 LevelDB 中,SST 的构建主要通过 BlockBuilder
类来完成。在添加键值对时,会首先计算当前键与上一个键的共享前缀长度,然后将共享前缀长度、非共享部分长度和值的长度以变长整数的形式写入到缓冲区中,接着将非共享部分的键和值写入到缓冲区中。在 LevelDB 中,SST 文件的数据块通常是懒加载的,也就是说,只有当用户请求时,它们才会被加载到内存中。这种机制被称为块缓存(Block Cache)。
在 LevelDB 中,布隆过滤器主要对 SSTable 文件的读取操作产生作用。
布隆过滤器的作用
当我们要读取一个键值对时,首先需要确定这个键值对是否存在于某个 SSTable 文件中。如果没有布隆过滤器,我们需要在 SSTable 文件的索引块中进行查找,这可能会涉及到磁盘 I/O 操作,因此效率较低。
布隆过滤器是一种概率型数据结构,可以用来判断一个元素是否可能存在于一个集合中。在 LevelDB 中,每个 SSTable 文件都有一个对应的布隆过滤器,用来存储该文件中所有键的信息。
当我们要读取一个键值对时,首先使用布隆过滤器判断这个键是否可能存在于 SSTable 文件中。如果布隆过滤器判断这个键可能不存在,那么我们就可以直接返回,无需进行磁盘 I/O 操作。如果布隆过滤器判断这个键可能存在,那么我们再在 SSTable 文件的索引块中进行查找。
因此,布隆过滤器可以有效地减少在 SSTable 文件中查找键值对时的磁盘 I/O 操作,从而提高 LevelDB 的读取效率。
为什么布隆过滤器不用于 memtable ?
在 LevelDB 中,布隆过滤器主要用于减少磁盘 I/O 操作。当我们要读取一个键值对时,首先使用布隆过滤器判断这个键是否可能存在于 SSTable 文件中。如果布隆过滤器判断这个键可能不存在,那么我们就可以直接返回,无需进行磁盘 I/O 操作。如果布隆过滤器判断这个键可能存在,那么我们再在 SSTable 文件的索引块中进行查找。
然而,对于 memtable,情况就不同了。memtable 是存储在内存中的,读取 memtable 中的数据不涉及到磁盘 I/O 操作,因此效率已经很高。另外,memtable 中的数据是按照键的顺序存储的,因此查找一个键值对的效率也很高。因此,对于 memtable,我们并不需要布隆过滤器来提高读取效率。
另外,布隆过滤器虽然可以提高读取效率,但是它也会占用一部分内存空间。对于 memtable,我们更希望能够尽可能地利用内存空间来存储更多的数据,因此我们通常不会为 memtable 建立布隆过滤器。
总的来说,不为 memtable 建立布隆过滤器的主要原因是因为 memtable 的读取效率已经很高,而且我们希望能够尽可能地利用内存空间来存储更多的数据。
一个具体的例子
布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否在一个集合中。它可能会产生误判(即判断一个不存在的元素存在),但不会漏判(即判断一个存在的元素不存在)。
假设我们有一个空的布隆过滤器,它有8个位,所有位都初始化为0。我们有3个哈希函数,每个函数都将输入映射到8个位中的一个。
现在,我们要添加一个元素 "hello" 到布隆过滤器中。我们将 "hello" 输入到3个哈希函数中,得到3个结果,假设是3、5和7。然后,我们将布隆过滤器中对应的位设置为1。现在,布隆过滤器看起来像这样:01010100。
接下来,我们要检查一个元素 "world" 是否在布隆过滤器中。我们将 "world" 输入到3个哈希函数中,得到3个结果,假设是2、3和7。我们检查布隆过滤器中对应的位,发现第2位是0,所以我们可以确定 "world" 不在布隆过滤器中。
然后,我们要检查一个元素 "hello" 是否在布隆过滤器中。我们将 "hello" 输入到3个哈希函数中,得到3个结果,都是3、5和7。我们检查布隆过滤器中对应的位,发现所有位都是1,所以我们认为 "hello" 可能在布隆过滤器中。
需要注意的是,布隆过滤器可能会产生误判。例如,如果我们检查一个元素 "goodbye",哈希函数返回的结果恰好是3、5和7,尽管我们从未将 "goodbye" 添加到布隆过滤器中,但布隆过滤器仍会判断 "goodbye" 存在。
这就是布隆过滤器的基本工作原理。在实际应用中,布隆过滤器的大小和哈希函数的数量需要根据具体的误判率要求和空间限制来选择。
为什么存在误判?
布隆过滤器可能会产生误判的原因主要是因为哈希函数的碰撞。在布隆过滤器中,我们使用多个哈希函数将元素映射到一个位数组中。由于位数组的大小有限,而可能的元素数量是无限的,因此不同的元素可能会被哈希到同一个位置。这就是所谓的哈希碰撞。
当我们查询一个元素是否存在于布隆过滤器中时,如果这个元素的哈希位置都为1,那么布隆过滤器会判断这个元素可能存在。但是,这些位置可能是由其他元素的哈希导致的,因此可能会产生误判。
需要注意的是,布隆过滤器只会产生假阳性误判,也就是说,它可能会错误地判断一个不存在的元素存在,但是它不会判断一个存在的元素不存在。这是因为当我们添加一个元素到布隆过滤器中时,我们会将元素的哈希位置都设置为1,因此如果一个元素真的存在于布隆过滤器中,那么它的哈希位置一定都是1。
MurmurHash 哈希
LevelDB 中使用 MurmurHash(mrmrhash)作为布隆过滤器的哈希函数,主要是因为 MurmurHash 具有以下特性:
-
性能优秀:MurmurHash 是一种非加密哈希函数,其计算速度非常快,对于大量数据的处理效率高。
-
分布均匀:MurmurHash 的哈希结果分布均匀,冲突的概率较低。这对于布隆过滤器来说非常重要,因为布隆过滤器的误判率与哈希函数的分布均匀性有直接关系。
-
简单易用:MurmurHash 的实现相对简单,易于在各种环境和语言中使用。
以上特性使得 MurmurHash 成为 LevelDB 中布隆过滤器的理想选择。
MurmurHash 实现细节
MurmurHash 是一种非加密哈希函数,它以高效率和良好的哈希结果分布性质而闻名。以下是 MurmurHash 的基本实现原理:
-
数据处理:MurmurHash 以 4 字节为单位处理输入数据。对于输入数据长度不是 4 的倍数的部分,MurmurHash 会在最后单独处理。
-
混合:对于每 4 字节的数据,MurmurHash 首先将其乘以一个魔数(例如,对于 MurmurHash3,这个魔数是 0x9e3779b1),然后对结果进行位移操作(例如,向左或向右旋转 13 位)。
-
合并:将混合后的数据与哈希的当前值进行异或操作,然后再乘以另一个魔数,得到新的哈希值。
-
最后处理:对于剩余的不足 4 字节的数据,MurmurHash 会进行特殊处理,然后与当前的哈希值进行异或操作。最后,MurmurHash 会对哈希值进行一系列的位移、异或和乘法操作,以确保哈希值的每一位都受到输入数据的影响。
以上就是 MurmurHash 的基本实现原理。需要注意的是,不同版本的 MurmurHash(例如,MurmurHash1、MurmurHash2 和 MurmurHash3)在具体的混合和合并策略上可能会有所不同,但基本原理是相同的。
布隆过滤器的发展历史
布隆过滤器(Bloom Filter)是由 Howard Bloom 在 1970 年提出的。它是一种空间效率极高的概率型数据结构,用于判断一个元素是否在一个集合中。布隆过滤器可能会产生误判(即判断一个不存在的元素存在),但不会漏判(即判断一个存在的元素不存在)。
布隆过滤器最初的设计目标是为了解决网络路由问题,特别是在处理大量数据并且需要快速查询的情况下。由于其高效的空间利用率和查询性能,布隆过滤器很快在计算机科学的许多领域得到了广泛的应用,包括数据库、网络路由、缓存系统、文件系统等。
在过去的几十年中,布隆过滤器的基本原理并没有发生太大的变化,但是人们对其进行了许多优化和改进。例如,有人提出了可扩展的布隆过滤器(Scalable Bloom Filter),可以动态地调整大小以适应元素数量的变化。还有人提出了压缩布隆过滤器(Compressed Bloom Filter),通过压缩技术进一步减少了空间需求。
总的来说,布隆过滤器是一种非常实用的数据结构,自从 1970 年被提出以来,一直在计算机科学的许多领域发挥着重要的作用。
布隆过滤器的使用场景
布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,主要用于判断一个元素是否在一个集合中。以下是一些常见的使用场景:
-
网络路由:布隆过滤器最初是为了解决网络路由问题而设计的。在网络路由中,我们需要快速判断一个 IP 地址是否在一个 IP 地址集合中。使用布隆过滤器可以有效地减少内存使用,并且提高查询效率。
-
Web 缓存代理:Web 缓存代理可以使用布隆过滤器来判断一个 Web 页面是否在缓存中。如果布隆过滤器判断这个页面不在缓存中,那么我们就可以直接从源服务器获取这个页面,无需检查缓存。
-
数据库和文件系统:在数据库和文件系统中,布隆过滤器可以用来判断一个磁盘块是否在内存中。这可以减少不必要的磁盘 I/O 操作,从而提高性能。
-
垃圾邮件和恶意网址过滤:布隆过滤器可以用来存储已知的垃圾邮件或恶意网址的特征。当我们收到一个邮件或访问一个网址时,可以使用布隆过滤器快速判断这个邮件或网址是否可能是垃圾邮件或恶意网址。
-
分布式系统:在分布式系统中,布隆过滤器可以用来减少跨机器的数据请求。例如,我们可以在每台机器上维护一个布隆过滤器,用来存储这台机器上所有数据的键。当我们要读取一个键值对时,首先使用布隆过滤器判断这个键是否可能存在于这台机器上。如果布隆过滤器判断这个键可能不存在,那么我们就可以直接跳过这台机器,无需进行网络请求。
以上就是布隆过滤器的一些常见使用场景。需要注意的是,由于布隆过滤器可能会产生误判,因此在使用布隆过滤器时,我们需要根据具体的应用场景和误判率要求来选择合适的布隆过滤器大小和哈希函数数量。
总结
布隆过滤器是一种空间效率极高的概率型数据结构,用于判断一个元素是否在一个集合中。在 LevelDB 中,布隆过滤器主要用于减少 SSTable 文件中查找键值对时的磁盘 I/O 操作,从而提高读取效率。布隆过滤器可能会产生误判,主要原因是哈希函数的碰撞。LevelDB 中使用 MurmurHash 作为布隆过滤器的哈希函数,因为它性能优秀,分布均匀,且简单易用。
在 LevelDB 中,合并(Compaction)是一种重要的操作,用于优化存储空间和提高查询性能。它的主要目的是减少读放大(Read Amplification)并提高查询效率。
什么是合并?
合并操作主要包括两种:Minor Compaction 和 Major Compaction。当内存中的 MemTable 达到一定大小时,它会被转换为 SST 文件并刷新(Flush)到磁盘。这个过程称为 Minor Compaction。
在 LevelDB 中,Major Compaction 会涉及到两个层级的 SST 文件的合并。当一个层级的 SST 文件数量或大小达到一定阈值时,LevelDB 会选择一些 SST 文件,并将这些 SST 文件与下一层级的一些 SST 文件合并,生成新的 SST 文件。这些新的 SST 文件会被存储在下一层级中。
例如,当 Level 0 的 SST 文件数量达到一定数量(默认为 4)时,LevelDB 会触发一次 Major Compaction,将 Level 0 的 SST 文件和 Level 1 的 SST 文件合并,并生成新的 SST 文件。这些新的 SST 文件被存储在 Level 1 中。
同样,当 Level 1 的 SST 文件大小达到 10MB(默认值)时,LevelDB 会触发一次 Major Compaction,将 Level 1 的 SST 文件和 Level 2 的 SST 文件合并,并生成新的 SST 文件。这些新的 SST 文件被存储在 Level 2 中。
对于 Level 3 及以上,也是同样的处理方式。每次 Major Compaction 都会涉及到两个层级的 SST 文件的合并,生成的新的 SST 文件会被存储在下一层级中。
在合并过程中,LevelDB 会选择一些 SST 文件,将这些文件中的数据项进行排序并合并,然后分割成多个新的 SST 文件。这个过程中,原来的数据会被重新写入磁盘,从而导致写放大(Write Amplification)。但是,合并操作可以减少 SST 文件的数量,从而减少读放大和空间放大。
Minor Compaction 实现细节
Minor Compaction:当内存中的 MemTable 达到一定大小时,它会被转换为 SST 文件并刷新到磁盘。这个过程称为 Minor Compaction。Minor Compaction 主要发生在 Level 0,主要涉及到最新的 SST 文件,因此产生的额外磁盘 I/O 较少。
在 LevelDB 中,Minor Compaction 主要发生在 MemTable 达到一定大小时。这个过程主要涉及到最新的 SST 文件,因此产生的额外磁盘 I/O 较少。
具体来说,当 MemTable 达到一定大小时,LevelDB 会创建一个新的 SST 文件,并将 MemTable 中的数据写入到这个 SST 文件中。在这个过程中,LevelDB 会使用 BlockBuilder
类来构建 SST 文件中的 block。
这个过程主要涉及到 Add
和 Finish
两个方法。
-
Add
方法:这个方法用于将一个键值对添加到 block 中。它首先检查是否需要添加一个 "restart point"。Restart points 是用于在 block 中进行二分查找的关键点,它们的存在可以提高查找速度。如果当前的键值对是第一个,或者与上一个键值对的共享前缀长度小于某个阈值,那么就会在restarts_
数组中添加当前 block 的偏移量,作为一个新的 restart point。然后,Add
方法会将键值对的共享前缀长度、非共享前缀长度和值长度,以及非共享的键的部分和值,依次写入到buffer_
中。 -
Finish
方法:这个方法用于完成 block 的构建。它首先将所有的 restart points 写入到buffer_
的末尾,然后将 restart points 的数量写入到buffer_
的末尾。最后,它返回一个Slice
,这个Slice
引用了buffer_
中的数据,代表了构建完成的 block。
在将 MemTable 中的数据写入到 SST 文件中时,LevelDB 会创建一个 BlockBuilder
对象,然后遍历 MemTable 中的所有键值对,使用 Add
方法将它们添加到 BlockBuilder
中。当所有的键值对都添加完毕后,调用 Finish
方法完成 block 的构建,然后将构建完成的 block 写入到 SST 文件中。
每个 block 中的数据都是按照 key 的顺序存储的,而且每个 key 都会进行前缀合并,也就是说,每个 key 只保存与前一个 key 的差异部分。但是,为了能够快速定位到任意一个 key,LevelDB 会在每个 block 中设置一些 restart points,每个 restart point 的位置都会保存一个完整的 key。
在 BlockBuilder::Finish()
方法中,LevelDB 会将所有的 restart points 的信息写入到 buffer 中,然后将 buffer 写入到 SST 文件中。这个过程就是 Minor Compaction 的一部分。
总的来说,Minor Compaction 是 LevelDB 中的一个重要操作,它可以有效地将内存中的数据刷新到磁盘,同时通过使用前缀合并和设置 restart points 来优化读取性能。
Major Compaction 实现细节
Major Compaction:Major Compaction 是指将多个 SST 文件合并为一个新的 SST 文件,同时删除重复的键和过期的数据。这个过程会产生大量的额外磁盘 I/O,从而导致写放大。Major Compaction 涉及到所有的 SST 文件,它会读取所有的数据并重新写入磁盘。
在 LevelDB 的源代码中,合并操作是在 db/db_impl.cc
文件的 DBImpl::CompactRange
方法中实现的。这个方法会创建一个 Compaction
对象,然后调用 DBImpl::DoCompactionWork
方法来执行实际的合并操作。
在 DBImpl::DoCompactionWork
方法中,它会创建一个 CompactionState
对象来保存合并操作的状态,然后通过循环调用 Compaction::Next
方法来逐步进行合并操作。在每次循环中,它会调用 Compaction::Next
方法来获取下一个要合并的键值对,然后调用 CompactionState::Add
方法将这个键值对添加到新的 SST 文件中。
在 CompactionState::Add
方法中,它会检查当前的键是否与上一个键相同,如果相同,则表示这是一个重复的键,它会被忽略。如果当前的键是一个删除标记,则表示这是一个过期的数据,它也会被忽略。只有当当前的键既不是重复的键,也不是删除标记时,它才会被添加到新的 SST 文件中。
在合并操作完成后,DBImpl::DoCompactionWork
方法会调用 CompactionState::Finish
方法来关闭新的 SST 文件,并将其添加到 SST 文件的列表中。然后,它会调用 VersionSet::LogAndApply
方法来更新元数据,并将新的 SST 文件添加到 LSM 树中。最后,它会删除旧的 SST 文件,并释放相关的资源。
以上就是 LevelDB 中合并操作的基本流程。
Leveldb 如何处理与合并并行的 L0 刷新?
LevelDB 的 L0 层刷新和合并操作主要在 db_impl.cc
文件中的 DBImpl::CompactMemTable
和 DBImpl::BackgroundCompaction
函数中进行。
首先,当内存表(MemTable)满了,或者用户手动触发了刷新操作,LevelDB 会将 MemTable 的内容写入到磁盘,形成一个新的 SST 文件,这个过程就是 L0 刷新。这个新的 SST 文件会被添加到 VersionSet 中,成为 L0 层的一部分。
然后,如果 L0 层的文件数量超过了一定的阈值(默认是 4),LevelDB 就会触发 L0 到 L1 的合并操作。这个过程是在 DBImpl::BackgroundCompaction
函数中进行的。在这个函数中,LevelDB 会选择一个合适的 L0 文件和 L1 文件,然后将这两个文件进行合并,合并的结果会生成新的 SST 文件,这些新的 SST 文件会被添加到 L1 层。
在这个过程中,LevelDB 会尽量避免获取全局的状态锁。具体来说,LevelDB 在进行 L0 刷新和合并操作时,会先获取状态锁,然后更新 VersionSet,添加新的 SST 文件,最后释放状态锁。这样,即使在进行 L0 刷新和合并操作的过程中有新的 L0 文件产生,也不会影响到正在进行的操作,因为新的 L0 文件会被添加到下一次的合并操作中。
这就是 LevelDB 如何处理和合并并行的 L0 刷新的大致过程。具体的实现细节可能会因为 LevelDB 的版本和配置的不同而有所不同。
合并后立即删除原始 SST 文件会有问题吗?
在 LevelDB 中,合并完成后立即删除原始 SST 文件通常不会在系统中引起问题。这是因为 LevelDB 使用了一种称为“延迟删除”的策略。
在这种策略中,当 LevelDB 决定删除一个 SST 文件时,它并不会立即删除这个文件。相反,它会将这个文件的文件名添加到一个待删除文件列表中。然后,在后台清理线程中,LevelDB 会定期检查这个待删除文件列表,如果列表中的文件没有被数据库的任何部分引用,那么这个文件就会被实际删除。
这种策略的好处是,即使在 LevelDB 合并完成后立即删除原始 SST 文件,也不会影响到正在使用这个文件的读操作。因为只有当文件没有被任何部分引用时,文件才会被实际删除。
这种策略在 macOS 和 Linux 上工作得很好,因为这两个操作系统都支持“删除打开文件”。也就是说,即使一个文件被删除了,只要还有一个文件句柄在引用这个文件,那么这个文件的内容就仍然可以被访问。只有当最后一个文件句柄被关闭时,文件才会被实际删除。
具体到代码层面,LevelDB 的这种延迟删除策略主要在 db_impl.cc
文件的 DBImpl::DeleteObsoleteFiles
函数中实现。这个函数会遍历待删除文件列表,对于每个文件,它会检查这个文件是否被数据库的任何部分引用,如果没有,那么这个文件就会被实际删除。
但是在 windows 上不能直接删除打开的文件,直接删除会返回一个错误。所以当删除时会线检查该文件是否正在被使用,如果不被使用时才会删除。这种策略可以确保删除操作不会影响到正在进行的读操作。
这种策略的实现主要在 env_windows.cc
文件的 Win32SequentialFile
和 Win32RandomAccessFile
类中。这两个类都有一个 Unref
方法,这个方法会在文件不再被使用时被调用。在 Unref
方法中,LevelDB 会检查待删除文件列表,如果列表中的文件没有被数据库的任何部分引用,那么这个文件就会被实际删除。
这就是 LevelDB 如何处理合并完成后的原始 SST 文件删除的问题。具体的实现细节可能会因为 LevelDB 的版本和配置的不同而有所不同。
读放大是什么?
读放大是指为了执行一个 get 操作,需要从磁盘读取的数据块的数量。如果有大量的 SST 文件,那么每个 get 操作可能需要从这些 SST 文件中读取多个数据块,这就会导致读放大。
接下来结合一个具体的例子来讲解读放大:
在 LevelDB 中,读放大(Read Amplification)是指为了读取一个键值对,需要进行的 I/O 操作数量。这主要是由于 LevelDB 的数据存储结构——LSM(Log-Structured Merge-tree)树造成的。
LSM 树由多个层级(Level)组成,每个层级包含多个 SST 文件(Sorted String Table,排序字符串表)。每个 SST 文件包含的 key-range 可能会和其他层级的 SST 文件重叠,但同一层级的 SST 文件之间的 key-range 不会重叠。
假设我们有一个 LevelDB 数据库,其中包含以下 SST 文件:
- Level 0:SST1,SST2
- Level 1:SST3,SST4
- Level 2:SST5,SST6,SST7
现在,我们要执行一个 get 操作,查找一个键值对。首先,我们需要在 Level 0 的 SST 文件中查找。如果在 SST1 中找到了,那么我们就可以直接返回结果。如果没有找到,我们需要继续在 SST2 中查找。如果在 SST2 中也没有找到,我们需要继续在 Level 1 的 SST 文件中查找,以此类推。
因此,为了查找一个键值对,我们可能需要从多个 SST 文件中读取数据。这就是读放大。在这个例子中,如果我们需要从 SST1,SST2,SST3 和 SST5 中读取数据,那么读放大就是 4。
为了减少读放大,LevelDB 会定期进行合并(Compaction)操作。合并操作会选择一些 SST 文件,将这些文件中的数据项进行排序并合并,然后分割成多个新的 SST 文件。这个过程中,原来的数据会被重新写入磁盘,从而导致写放大。但是,合并操作可以减少 SST 文件的数量,从而减少读放大。
为什么合并可以减少读放大?
当进行合并后,多个 SST 文件会被合并成一个更大的 SST 文件,这个文件的键范围是非重叠的。这样,当我们需要查找一个键时,我们只需要在一个 SST 文件中查找,这就大大减少了读放大。也就是说,为了获取一个键的值,我们只需要读取一个 SST 文件,因为每个键只会存在于一个 SST 文件中。
此外,合并过程中还会进行去重操作,只保留最新的键值对,这也有助于减少读放大。因为在没有进行合并的情况下,一个键的旧值可能会存在于多个 SST 文件中,这就需要读取多个 SST 文件才能找到这个键的最新值。但是在进行合并后,一个键的旧值会被删除,只保留最新的值,这样就只需要读取一个 SST 文件就能找到这个键的最新值。
因此,通过合并,我们可以将多个 SST 文件合并成一个更大的 SST 文件,减少了读放大,提高了查询效率。
什么是写放大?
在 LevelDB 中,合并(Compaction)是一种优化操作,它的目的是减少读放大(Read Amplification)和空间放大(Space Amplification)。然而,这个操作会导致写放大(Write Amplification),即实际写入磁盘的数据量会大于用户写入的数据量。
在 LevelDB 中,写放大(Write Amplification)是指将数据写入磁盘时,实际写入的数据量与用户写入的数据量之比。写放大是由于 LevelDB 的合并(Compaction)操作引起的。
在 LevelDB 中,新写入的数据首先被存储在内存中的 MemTable 中。当 MemTable 达到一定大小时,它会被转换为 SST 文件(Sorted String Table,排序字符串表)并刷新(Flush)到磁盘。这个过程中,每写入 1MB 的数据,就会产生 1MB 的磁盘 I/O,所以没有合并的写放大比例是 1x。
然而,LevelDB 使用了一种名为 LSM-tree(Log-Structured Merge-tree)的数据结构,它通过在后台进行合并操作来提高写入性能。合并操作是指将多个 SST 文件合并为一个新的 SST 文件,同时删除重复的键和过期的数据。这个过程会产生大量的额外磁盘 I/O,从而导致写放大。
具体来说,LevelDB 的合并操作分为两级:Minor Compaction 和 Major Compaction。Minor Compaction 主要处理 MemTable 刷新到磁盘的过程,它只涉及到最新的 SST 文件,因此产生的额外磁盘 I/O 较少,对写放大的影响较小。Major Compaction 则涉及到所有的 SST 文件,它会读取所有的数据并重新写入磁盘,因此产生的额外磁盘 I/O 较多,对写放大的影响较大。
合并对写放大的影响?
如果我们每次得到一个 SST 就做一次全合并,那么写入到磁盘的数据量将是刷新的 SST 数量的平方。这是因为每次合并都会生成一个新的 SST 文件,而每个新的 SST 文件都包含了所有之前的数据。这种情况下,每次合并都会对所有的 SST 文件进行读取和写入,这会导致大量的磁盘 I/O,从而导致写放大。
例如,如果我们将 100 个 SST 刷新到磁盘,我们将做 2 个文件,3 个文件,...,100 个文件的合并。在第一次合并时,我们将第一个和第二个 SST 文件合并成一个新的 SST 文件,这个新的 SST 文件包含了第一个和第二个 SST 文件的所有数据。在第二次合并时,我们将第一个、第二个和第三个 SST 文件合并成一个新的 SST 文件,这个新的 SST 文件包含了第一个、第二个和第三个 SST 文件的所有数据。以此类推,到第 100 次合并时,我们将所有的 SST 文件合并成一个新的 SST 文件,这个新的 SST 文件包含了所有 SST 文件的所有数据。
因此,实际写入到磁盘的总数据量大约是 5000 个 SST。在这种情况下,写入 100 个 SST 后的写放大将是 50x,即实际写入的数据量是用户写入的数据量的 50 倍。
因此,为了避免这种情况,LevelDB 在进行合并操作时,会根据 SST 文件的大小和级别进行选择,只对部分 SST 文件进行合并,从而降低写放大。
空间放大
计算空间放大最直观的方法是将 LSM 引擎实际使用的空间除以用户空间使用量(即,数据库大小,数据库中的行数等)。引擎需要存储删除墓碑,有时如果合并不够频繁,还需要存储同一键的多个版本,因此导致空间放大。
在引擎端,通常很难知道用户存储的数据的确切数量,除非我们扫描整个数据库并查看引擎中有多少死版本。因此,估计空间放大的一种方法是将完整存储文件大小除以最后一层大小。这种估计方法背后的假设是,用户填充初始数据后,工作负载的插入和删除率应该相同。我们假设用户端的数据大小不变,因此最后一层包含用户数据在某一点的快照,上层包含新的更改。当合并将所有内容合并到最后一层时,我们可以使用这种估计方法得到 1x 的空间放大因子。
请注意,合并也需要空间 -- 在合并完成之前,你不能删除正在合并的文件。如果你对数据库进行全面的合并,你将需要与当前引擎文件大小相等的空闲存储空间。
在这部分,我们将有一个合并模拟器来帮助你可视化合并过程和你的合并算法的决策。我们提供最小的测试用例来检查你的合并算法的属性,你应该密切关注统计数据和合并模拟器的输出,以了解你的合并算法的工作情况。
Leveldb 是如何估计评估读/写/空间放大的?
在 LevelDB 中,读放大、写放大和空间放大的估计主要依赖于 LevelDB 的内部统计信息。这些统计信息可以通过 LevelDB 的 DB::GetProperty
方法获取。以下是一些具体的方法:
-
读放大:LevelDB 并没有直接提供读放大的统计信息,但我们可以通过其他的统计信息来间接估计。例如,我们可以通过
leveldb.sstables
属性获取到每一层的 SST 文件数量和大小,然后根据 LSM-Tree 的特性,估计出平均每次读取操作需要访问的数据块数量。 -
写放大:LevelDB 提供了
leveldb.compaction.bytes-written
属性,这个属性表示了由于压缩操作而写入到磁盘的数据量。我们可以将这个值除以实际写入的数据量(可以通过leveldb.bytes-written
属性获取),得到写放大。 -
空间放大:LevelDB 提供了
leveldb.live-sst-files-size
属性,这个属性表示了当前所有 SST 文件的总大小。我们可以将这个值除以实际写入的数据量(可以通过leveldb.bytes-written
属性获取),得到空间放大。
具体到代码层面,我们可以通过以下的代码来获取这些统计信息:
以上就是在 LevelDB 中估计读放大、写放大和空间放大的方法。具体的实现可能会因为 LevelDB 的版本和配置的不同而有所不同。
总结
这篇文章详细介绍了LevelDB中的合并操作,包括Minor Compaction和Major Compaction,以及它们如何影响读放大、写放大和空间放大。合并操作是LevelDB优化存储空间和提高查询性能的关键,它通过减少SST文件的数量来减少读放大和空间放大,但同时会导致写放大,即实际写入磁盘的数据量大于用户写入的数据量。文章还讨论了LevelDB如何处理与合并并行的L0刷新,以及合并完成后立即删除原始SST文件的影响。最后,文章介绍了LevelDB如何估计和评估读放大、写放大和空间放大。
为了更好地理解和演示这个问题,接下来结合一个具体的例子展示如何一步步地将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。以下是示例 1 的具体过程:
初始状态
nums = [0, 1, 0, 3, 12]
第 1 次循环(i = 0)
- 因为
nums[0]
是 0,所以不进行交换。
nums = [0, 1, 0, 3, 12]
left = 0
第 2 次循环(i = 1)
nums[1]
是 1,不为 0,进行交换,但实际上是自己和自己交换。
nums = [1, 0, 0, 3, 12]
left = 1
第 3 次循环(i = 2)
nums[2]
是 0,所以不进行交换。
nums = [1, 0, 0, 3, 12]
left = 1
第 4 次循环(i = 3)
nums[3]
是 3,不为 0,与left
位置的元素交换。
nums = [1, 3, 0, 0, 12]
left = 2
第 5 次循环(i = 4)
nums[4]
是 12,不为 0,与left
位置的元素交换。
nums = [1, 3, 12, 0, 0]
left = 3
结果
循环结束后,所有的 0 被移动到了数组的末尾,同时 1、3 和 12 保持了它们原有的顺序。
输出: nums = [1, 3, 12, 0, 0]
这个方法通过使用一个额外的指针left
来标记非零元素应该移动到的位置,能够有效地在一次遍历中解决问题。
class Solution {
public:
void moveZeroes(vector<int>& nums) {
int left = 0;
int size = nums.size();
for (int i = 0; i < size; i++) {
if (nums[i] != 0) {
int t = nums[i];
nums[i] = nums[left];
nums[left] = t;
left++;
}
}
}
};
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int j = 0;
for (int i = 0; i < nums.size(); i++) {
// 当前元素不等于val时,才执行操作
if (nums[i] != val) {
// 仅当i和j不相等时才进行交换
// 这可以减少在nums[i]已经在正确位置时的不必要交换
if (i != j) {
nums[j] = nums[i];
}
j++;
}
}
return j; // 返回新的数组长度
}
};
class Solution {
public:
vector<int> sortedSquares(vector<int>& nums) {
vector<int> res(nums.size(), 0); // 创建一个和nums大小相同的结果数组,初始值为0
int size = nums.size() - 1; // 初始化一个指向结果数组末尾的指针
int i = 0, j = size; // 初始化两个指针i和j,分别指向nums的开始和末尾
while (i <= j) {
int a = nums[i] * nums[i]; // 计算nums[i]的平方
int b = nums[j] * nums[j]; // 计算nums[j]的平方
// 比较两个平方数的大小,并将较大的一个放在结果数组的末尾
if (a > b) {
res[size] = a; // 如果nums[i]的平方大,则将其放入结果数组
i++; // 移动指针i
} else {
res[size] = b; // 否则将nums[j]的平方放入结果数组
j--; // 移动指针j
}
size--; // 结果数组的下一个填充位置
}
return res; // 返回结果数组
}
};
让我们通过一个具体的例子来详细解释二分查找的过程,并通过文本图形化的方式来描述每一步的变化。假设我们有一个数组 nums = [-1, 0, 3, 5, 9, 12]
并且目标值 target = 9
。
初始状态
- 数组:
[-1, 0, 3, 5, 9, 12]
- 目标值:
9
- 左指针
left = 0
- 右指针
right = 5
(数组长度减1) - 中间位置
mid = (0 + 5) / 2 = 2
(索引为2的元素是3
)
索引: 0 1 2 3 4 5
初始数组: -1 0 3 5 9 12
↑ ↑ ↑
left mid right
第一次循环
nums[mid] = 3
小于target = 9
,因此需要调整左指针。- 更新左指针
left = mid + 1 = 3
。
索引: 0 1 2 3 4 5
第一次循环后: -1 0 3 5 9 12
↑ ↑ ↑
left mid right
第二次循环
- 更新中间位置
mid = (3 + 5) / 2 = 4
(索引为4的元素是9
) nums[mid] = 9
等于target = 9
,找到目标值,返回索引4
。
索引: 0 1 2 3 4 5
第二次循环后: -1 0 3 5 9 12
↑
mid
结果
- 目标值
9
的索引为4
。
这个文本图形化的描述显示了如何通过二分查找逐步缩小搜索范围,直到找到目标值。在每次循环中,我们都会根据中间值和目标值的比较结果来调整搜索范围,直到成功找到目标值或确定目标值不存在于数组中。
更复杂的例子
让我们考虑一个稍微复杂的情况,以进一步解释二分查找的过程。假设我们有一个数组 nums = [-10, -3, 0, 5, 9, 12, 14, 17, 19, 23, 29]
并且目标值 target = 0
。
初始状态
- 数组:
[-10, -3, 0, 5, 9, 12, 14, 17, 19, 23, 29]
- 目标值:
0
- 左指针
left = 0
- 右指针
right = 10
(数组长度减1) - 中间位置
mid = (0 + 10) / 2 = 5
(索引为5的元素是12
)
索引: 0 1 2 3 4 5 6 7 8 9 10
初始数组: -10 -3 0 5 9 12 14 17 19 23 29
↑ ↑ ↑
left mid right
第一次循环
nums[mid] = 12
大于target = 0
,需要调整右指针。- 更新右指针
right = mid - 1 = 4
。
索引: 0 1 2 3 4 5 6 7 8 9 10
第一次循环后: -10 -3 0 5 9 12 14 17 19 23 29
↑ ↑
left right
(mid 更新后将位于 left 和 right 的中间)
第二次循环
- 更新中间位置
mid = (0 + 4) / 2 = 2
(索引为2的元素是0
) nums[mid] = 0
等于target = 0
,找到目标值,返回索引2
。
索引: 0 1 2 3 4 5 6 7 8 9 10
第二次循环后: -10 -3 0 5 9 12 14 17 19 23 29
↑
mid
结果
- 目标值
0
的索引为2
。
在这个更复杂的例子中,我们看到了二分查找如何在一个更大的数组中有效地定位目标值。通过每次比较中间元素与目标值,并相应地调整搜索范围的边界,二分查找算法能够快速缩小搜索区域,直到找到目标值或确定目标值不在数组中。这种方法特别适用于大型有序数组,因为它大大减少了必须比较的元素数量,从而提高了搜索效率。
代码
下面是一个易于理解的C++代码实现:
class Solution {
public:
int search(vector<int>& nums, int target) {
// 初始化两个指针,i(左指针)和j(右指针),分别指向数组的起始和结束位置
int i = 0, j = nums.size() - 1;
// 当左指针不大于右指针时,执行循环
while (i <= j) {
// 计算中间位置的索引mid。这里使用i + (j - i) / 2来避免直接使用(i + j) / 2可能导致的整数溢出问题
int mid = i + (j - i) / 2;
// 如果中间位置的元素小于目标值,说明目标值在数组的右半部分
if (nums[mid] < target) {
// 因此,调整左指针i到中间位置的右侧,即mid + 1
i = mid + 1;
}
// 如果中间位置的元素大于目标值,说明目标值在数组的左半部分
else if (nums[mid] > target){
// 因此,调整右指针j到中间位置的左侧,即mid - 1
j = mid - 1;
}
// 如果中间位置的元素正好等于目标值
else {
// 直接返回该位置的索引
return mid;
}
}
// 如果循环结束还没有找到目标值,说明目标值不在数组中,返回-1
return -1;
}
};
这段代码首先初始化左右指针分别指向数组的开始和结束位置。然后,它进入一个循环,在循环中计算中间位置的索引 mid
并将 mid
位置上的元素与目标值 target
进行比较。如果找到目标值,则返回其索引;如果 mid
位置上的元素小于目标值,则调整左指针 left
到 mid + 1
;如果 mid
位置上的元素大于目标值,则调整右指针 right
到 mid - 1
。如果在数组中找不到目标值,则最终返回 -1
。
之前研究螺旋矩阵,网上代码绕来绕去很迷惑,下面是一个简单易懂的版本,看完就能直接写出。
题目要求生成一个 n x n
的矩阵,这个矩阵的数字从 1
开始到 n^2
结束,数字按照从外到内顺时针螺旋的方式排列。简单来说,就是让你创建一个正方形的数字阵列,数字从中心向四周螺旋展开,直到填满整个矩阵。
让我们通过一个 4x4
的矩阵来详细解释题目,同时展示变量的变化和矩阵的更新过程。
初始状态
n = 4
- 初始矩阵
matrix = [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]
left = 0
,right = 3
top = 0
,down = 3
num = 1
迭代过程与矩阵更新
第一圈填充
- 填充顶行
top
从左到右 (left
到right
)matrix[0][0] = 1
,matrix[0][1] = 2
,matrix[0][2] = 3
,matrix[0][3] = 4
top++
->top = 1
- 填充右列
right
从上到下 (top
到down
)matrix[1][3] = 5
,matrix[2][3] = 6
,matrix[3][3] = 7
right--
->right = 2
- 填充底行
down
从右到左 (right
到left
)matrix[3][2] = 8
,matrix[3][1] = 9
,matrix[3][0] = 10
down--
->down = 2
- 填充左列
left
从下到上 (down
到top
)matrix[2][0] = 11
,matrix[1][0] = 12
left++
->left = 1
矩阵现在看起来是这样的:
1 2 3 4
12 0 0 5
11 0 0 6
10 9 8 7
第二圈填充
- 填充新的顶行
top
从左到右 (left
到right
)matrix[1][1] = 13
,matrix[1][2] = 14
top++
->top = 2
- 填充新的右列
right
从上到下 (top
到down
)matrix[2][2] = 15
right--
->right = 1
- 填充新的底行
down
从右到左 (right
到left
)matrix[2][1] = 16
down--
->down = 1
矩阵现在看起来是这样的:
1 2 3 4
12 13 14 5
11 16 15 6
10 9 8 7
由于 left > right
和 top > down
,循环结束,所有的 4x4
矩阵都已经正确填充。
变量变化概览
- 初始:
left = 0
,right = 3
,top = 0
,down = 3
,num = 1
- 第一圈结束后:
left = 1
,right = 2
,top = 1
,down = 2
- 第二圈结束后:
left = 2
,right = 1
,top = 2
,down = 1
(此时循环条件不再满足,循环结束) - 最终生成的矩阵:
1 2 3 4
12 13 14 5
11 16 15 6
10 9 8 7
结论
这段代码通过控制 left
、right
、top
、down
四个边界指针,在每一圈的迭代中逐步填充矩阵,最终生成一个按螺旋顺序填充数字的 4x4
矩阵。每完成一圈后,相应地调整这四个指针的值,直到填充完成。
代码
下面的代码挺容易理解的,直接模拟即可。
class Solution {
public:
vector<vector<int>> generateMatrix(int n) {
// 创建一个n*n的矩阵,初始值为0
vector<vector<int>> matrix(n, vector<int>(n , 0));
// 定义四个方向的边界:左、右、上、下
int left = 0 , right = n - 1;
int top = 0, down = n - 1;
// 初始数字从1开始
int num = 1;
// 当左边界不超过右边界,且上边界不超过下边界时,循环继续
while (left <= right && top <= down) {
// 从左到右填充上边界的行
for (int i = left; i <= right; i++) {
matrix[top][i] = num++; // 填充并将num递增
}
top++; // 上边界下移
// 从上到下填充右边界的列
for (int i = top; i <= down; i++) {
matrix[i][right] = num++; // 填充并将num递增
}
right--; // 右边界左移
// 从右到左填充下边界的行
for (int i = right; i >= left; i--) {
matrix[down][i] = num++; // 填充并将num递增
}
down--; // 下边界上移
// 从下到上填充左边界的列
for (int i = down; i >= top; i--) {
matrix[i][left] = num++; // 填充并将num递增
}
left++; // 左边界右移
}
// 返回填充完成的矩阵
return matrix;
}
};
此代码通过不断缩小矩阵的边界(左、右、上、下)来实现螺旋填充。每完成矩阵的一圈(即一层)的填充后,相应地调整边界条件,直到所有数字都被填充到矩阵中。这种方式有效地利用了螺旋路径的模式,确保了填充顺序的正确性和高效性。
class Solution {
public:
vector<int> spiralOrder(vector<vector<int>>& matrix) {
int m = matrix.size();
int n = matrix[0].size();
vector<int> res;
// 定义四个方向的边界:左、右、上、下
int left = 0 , right = n - 1;
int top = 0, down = m - 1;
// 当左边界不超过右边界,且上边界不超过下边界时,循环继续
while (left <= right && top <= down) {
// 从左到右填充上边界的行
for (int i = left; i <= right; i++) {
res.push_back(matrix[top][i]);
}
top++; // 上边界下移
// 从上到下填充右边界的列
for (int i = top; i <= down; i++) {
res.push_back(matrix[i][right]);
}
right--; // 右边界左移
// 从右到左填充下边界的行
if (top <= down) { // 添加检查以避免重复
for (int i = right; i >= left; i--) {
res.push_back(matrix[down][i]);
}
down--; // 下边界上移
}
// 从下到上填充左边界的列
if (left <= right) { // 添加检查以避免重复
for (int i = down; i >= top; i--) {
res.push_back(matrix[i][left]);
}
left++; // 左边界右移
}
}
// 返回填充完成的矩阵
return res;
}
};
链表
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
// 使用一个哑节点(dummy node)来简化边界情况的处理
ListNode* dummy = new ListNode(-1);
dummy->next = head;
ListNode* prev = dummy; // 使用 prev 指针来遍历链表,始终指向当前节点的前一个节点
while(prev->next != nullptr) {
if (prev->next->val == val) {
ListNode* toDelete = prev->next; // 需要删除的节点
prev->next = toDelete->next; // 从链表中移除节点
delete toDelete; // 释放节点内存
} else {
prev = prev->next; // 只有当不删除节点时才移动 prev 指针
}
}
head = dummy->next; // 更新头节点(考虑头节点可能被删除的情况)
delete dummy; // 删除哑节点并释放内存
return head;
}
};
class Solution {
public:
ListNode* reverseList(ListNode* head) {
if (head == nullptr) return nullptr;
ListNode* a = nullptr;
while (head != nullptr) {
ListNode* t = head->next;
head->next = a;
a = head;
head = t;
}
return a;
}
};
class Solution {
public:
ListNode* swapPairs(ListNode* head) {
// 使用哨兵节点简化头节点的交换逻辑
ListNode* dummy = new ListNode(0);
dummy->next = head;
ListNode* current = dummy;
while (current->next != nullptr && current->next->next != nullptr) {
ListNode* first = current->next;
ListNode* second = first->next;
// 进行节点交换
first->next = second->next;
second->next = first;
current->next = second;
// 移动指针,准备下一对交换
current = first;
}
// 保存新的头节点
ListNode* newHead = dummy->next;
delete dummy; // 释放哨兵节点
return newHead;
}
};
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
if (head == nullptr) return nullptr; // 如果链表为空,直接返回nullptr
ListNode* a = new ListNode(); // 创建一个新的辅助节点a
ListNode* b = a; // 创建另一个指针b,指向a
ListNode* c = a; // 创建另一个指针c,也指向a
a->next = head; // 将a的下一个节点设置为头节点
b->next = head; // 将b的下一个节点设置为头节点
// 将指针a向前移动n个节点
while (n--) {
a = a->next;
}
// 移动指针a和b,直到a到达链表末尾
while (a->next != nullptr) {
a = a->next;
b = b->next;
}
// 删除倒数第n个节点
if (b->next != nullptr) {
ListNode* t = b->next; // 指向需要删除的节点
b->next = t->next; // 将b的下一个节点设置为t的下一个节点
delete t; // 删除t节点
}
ListNode* re = c->next; // 获取修改后链表的头节点
delete c; // 删除辅助节点c
return re; // 返回新链表的头节点
}
};
class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
ListNode* a = headA;
ListNode* b = headB;
while (headA != headB) {
headA = headA == nullptr ? b : headA->next;
headB = headB == nullptr ? a : headB->next;
}
return headA;
}
};
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
if (head == nullptr || head->next == nullptr) return nullptr;
ListNode* slow = head;
ListNode* fast = head;
// 检测环
while (true) {
if (fast == nullptr || fast->next == nullptr) return nullptr;
slow = slow->next;
fast = fast->next->next;
if (slow == fast) break; // 当快慢指针相遇,退出循环
}
// 寻找环的入口
slow = head;
while (slow != fast) {
slow = slow->next;
fast = fast->next;
}
return slow; // 返回环的入口节点
}
};
跳表存在的场景还是蛮多了,例如 Redis 和 LevelDB 中都有跳表,面试的时候被问到的频率不低。看完下面的内容就能写这道题了:力扣 1206. 设计跳表 https://leetcode.cn/problems/design-skiplist/description/
跳表是一种数据结构,可以被看作是对标准链表的一种改进,使得查找数据的速度变得更快。
接下来会逐层展示跳表的遍历过程,并在每一层都画出相应的图。下面的例子跳表有 4 层,存储的整数值如下,目标值 target = 50
。
初始跳表
跳表在链表的基础上增加了多层额外的链表,每一层都跳过一些元素,从而加快搜索速度。
Level 3: Head -> 11 -------------------------------------------> 33 -------------------------------------------> 55 -> NULL
Level 2: Head -> 11 -------------------> 22 -------------------> 33 -------------------> 44 -------------------> 55 -------------------> 66 -> NULL
Level 1: Head -> 11 -------> 15 -------> 22 -------> 27 -------> 33 -------> 37 -------> 44 -------> 48 -------> 55 -------> 59 -------> 66 -------> 72 -> NULL
Level 0: Head -> 11 -> 13 -> 15 -> 18 -> 22 -> 24 -> 27 -> 30 -> 33 -> 35 -> 37 -> 40 -> 44 -> 46 -> 48 -> 51 -> 55 -> 57 -> 59 -> 61 -> 66 -> 69 -> 72 -> 75 -> NULL
- Level 0 是原始链表,包含所有元素。
- Level 1 是第二层,它跳过了一些元素(例如,它可能只包含每两个元素中的一个)。
- Level 3 是顶层,跳过更多的元素(例如,它可能只包含每四个元素中的一个)。
当进行搜索时,你会从最顶层开始,这允许你跳过大部分元素。一旦你到达了需要下降到更低层继续搜索的点,就向下移动一层,再继续搜索。这个过程一直持续到找到目标元素或到达最底层链表。
这种结构使得跳表在查找元素时比普通链表更加高效,因为它减少了需要遍历的节点数量。同时,跳表在插入和删除操作时也保持了较高的效率,因为只需更新相对较少的链接。
遍历 Level 3
p
从Head
开始。p
移动到11
->33
。p
到达55
,停止(因为55 > 50
)。pre[3] = 33
。
Level 3: Head -> 11 -------------------------------------------> 33 (pre[3]) ----------------------------------> 55 -> NULL
Level 2: Head -> 11 -------------------> 22 -------------------> 33 -------------------> 44 -------------------> 55 -------------------> 66 -> NULL
Level 1: Head -> 11 -------> 15 -------> 22 -------> 27 -------> 33 -------> 37 -------> 44 -------> 48 -------> 55 -------> 59 -------> 66 -------> 72 -> NULL
Level 0: Head -> 11 -> 13 -> 15 -> 18 -> 22 -> 24 -> 27 -> 30 -> 33 -> 35 -> 37 -> 40 -> 44 -> 46 -> 48 -> 51 -> 55 -> 57 -> 59 -> 61 -> 66 -> 69 -> 72 -> 75 -> NULL
遍历 Level 2
p
继续从33
开始。p
移动到44
。p
到达55
,停止(因为55 > 50
)。pre[2] = 44
。
Level 3: Head -> 11 -------------------------------------------> 33 -------------------------------------------> 55 -> NULL
Level 2: Head -> 11 -------------------> 22 -------------------> 33 -------------------> 44 (pre[2]) ----------> 55 -------------------> 66 -> NULL
Level 1: Head -> 11 -------> 15 -------> 22 -------> 27 -------> 33 -------> 37 -------> 44 -------> 48 -------> 55 -------> 59 -------> 66 -------> 72 -> NULL
Level 0: Head -> 11 -> 13 -> 15 -> 18 -> 22 -> 24 -> 27 -> 30 -> 33 -> 35 -> 37 -> 40 -> 44 -> 46 -> 48 -> 51 -> 55 -> 57 -> 59 -> 61 -> 66 -> 69 -> 72 -> 75 -> NULL
遍历 Level 1
p
从44
开始。p
移动到48
。p
到达55
,停止(因为55 > 50
)。pre[1] = 48
。
Level 3: Head -> 11 -------------------------------------------> 33 ----------------------------------------------> 55 -> NULL
Level 2: Head -> 11 -------------------> 22 -------------------> 33 -------------------> 44 ----------------------> 55 ----------------> 66 -> NULL
Level 1: Head -> 11 -------> 15 -------> 22 -------> 27 -------> 33 -------> 37 -------> 44 -------> 48 (pre[1]) -> 55 ---> 59 --------> 66 -------> 72 -> NULL
Level 0: Head -> 11 -> 13 -> 15 -> 18 -> 22 -> 24 -> 27 -> 30 -> 33 -> 35 -> 37 -> 40 -> 44 -> 46 -> 48 -> 51 -> 55 -> 57 -> 59 -> 61 -> 66 -> 69 -> 72 -> 75 -> NULL
遍历 Level 0
p
从48
开始。p
到达51
,停止(因为51 > 50
)。pre[0] = 48
。
Level 3: Head -> 11 -------------------------------------------> 33 ----------------------------------------------------> 55 -> NULL
Level 2: Head -> 11 -------------------> 22 -------------------> 33 -------------------> 44 ----------------------------> 55 ----------------> 66 -> NULL
Level 1: Head -> 11 -------> 15 -------> 22 -------> 27 -------> 33 -------> 37 -------> 44 -------> 48 ----------------> 55 -------> 59 ----> 66 -------> 72 -> NULL
Level 0: Head -> 11 -> 13 -> 15 -> 18 -> 22 -> 24 -> 27 -> 30 -> 33 -> 35 -> 37 -> 40 -> 44 -> 46 -> 48 (pre[0]) -> 51 -> 55 -> 57 -> 59 -> 61 -> 66 -> 69 -> 72 -> 75 -> NULL
结果
最终 pre
数组中的节点如下,这些节点表示在每个层级中最后一个小于 50
的节点:
pre[3]
= 节点 33pre[2]
= 节点 44pre[1]
= 节点 48pre[0]
= 节点 48
在这个过程中,我们可以看到 find
函数是如何在每个层级中逐步靠近目标值的位置,从而高效地定位目标值可能存在的地方。
数据结构设计
// 跳表节点的结构定义
struct Node {
int val; // 节点存储的值
vector<Node*> next; // 存储到下一个节点的指针数组,next[i] 表示当前节点在第 i 层的下一个节点
Node(int _val) : val(_val) { // 构造函数,初始化节点值和 next 数组
next.resize(level, NULL); // 将 next 数组的大小初始化为 level,并全部指向 NULL
}
}*head; // 跳表的头节点,初始化时指向一个虚拟节点
find
结合一个具体的例子讲解 find
函数,我们假设目标值 (target
) 是 36。我们的目标是找到跳表中每个层级上小于 36 的最大节点。以下是跳表的当前状态和 find
函数的执行步骤。
跳表当前状态:
Level 3: Head -> 11 -------------------------------------------> 33 -------------------------------------------> 55 -> NULL
Level 2: Head -> 11 -------------------> 22 -------------------> 33 -------------------> 44 -------------------> 55 -------------------> 66 -> NULL
Level 1: Head -> 11 -------> 15 -------> 22 -------> 27 -------> 33 -------> 37 -------> 44 -------> 48 -------> 55 -------> 59 -------> 66 -------> 72 -> NULL
Level 0: Head -> 11 -> 13 -> 15 -> 18 -> 22 -> 24 -> 27 -> 30 -> 33 -> 35 -> 37 -> 40 -> 44 -> 46 -> 48 -> 51 -> 55 -> 57 -> 59 -> 61 -> 66 -> 69 -> 72 -> 75 -> NULL
执行 find(36, p)
的步骤:
-
开始于 Level 3:
- 从 Head 开始,寻找小于 36 的最大节点。
- 移动到 33(因为 33 < 36),然后移动到 55(因为 55 > 36),停止。
- 在 Level 3,小于 36 的最大节点是 33。
-
下降至 Level 2:
- 从 Level 3 中找到的最后一个节点(33)开始。
- 在 Level 2 中,33 已经是小于 36 的最大节点,因此无需移动。
- 在 Level 2,小于 36 的最大节点仍然是 33。
-
下降至 Level 1:
- 从 Level 2 中找到的节点(33)开始。
- 33 后面是 37,但因为 37 > 36,停止移动。
- 在 Level 1,小于 36 的最大节点是 33。
-
下降至 Level 0:
- 从 Level 1 中找到的节点(33)开始。
- 33 后面是 35(35 < 36),移动到 35。
- 35 后面是 37,但因为 37 > 36,停止移动。
- 在 Level 0,小于 36 的最大节点是 35。
find
结果:
- Level 3 中小于 36 的最大节点是 33。
- Level 2 中小于 36 的最大节点是 33。
- Level 1 中小于 36 的最大节点是 33。
- Level 0 中小于 36 的最大节点是 35。
通过这个过程,find
函数有效地确定了在每个层级中小于目标值 36 的最大节点。这个信息可以用于后续的插入、搜索或删除操作,因为它提供了每个层级中开始这些操作的正确节点。
// 辅助函数:找到小于目标值 target 的每一层的最大节点
void find(int target, vector<Node*>& p) {
Node* node = head;
for (int i = level - 1; i >= 0; i--) {
while (node->next[i] && node->next[i]->val < target) {
node = node->next[i]; // 在第 i 层向前移动,直到找到小于 target 的最大节点
}
p[i] = node; // 存储每一层找到的节点
}
}
search
为了说明如何在给定的跳表中查找值为 18 的节点。
初始跳表状态:
Level 3: Head -> 11 -------------------------------------------> 33 -------------------------------------------> 55 -> NULL
Level 2: Head -> 11 -------------------> 22 -------------------> 33 -------------------> 44 -------------------> 55 -------------------> 66 -> NULL
Level 1: Head -> 11 -------> 15 -------> 22 -------> 27 -------> 33 -------> 37 -------> 44 -------> 48 -------> 55 -------> 59 -------> 66 -------> 72 -> NULL
Level 0: Head -> 11 -> 13 -> 15 -> 18 -> 22 -> 24 -> 27 -> 30 -> 33 -> 35 -> 37 -> 40 -> 44 -> 46 -> 48 -> 51 -> 55 -> 57 -> 59 -> 61 -> 66 -> 69 -> 72 -> 75 -> NULL
执行 search(18)
的步骤:
-
开始于 Level 3:
- 从 Head 开始,寻找小于 18 的最大节点。
- 移动到 11(因为 11 < 18),然后到 33(因为 33 > 18),停止。
- 在 Level 3,小于 18 的最大节点是 11。
-
下降至 Level 2:
- 从 Level 3 中找到的最后一个节点(11)开始。
- 在 Level 2 中,11 后面是 22,但因为 22 > 18,停止。
- 在 Level 2,小于 18 的最大节点是
11。
-
下降至 Level 1:
- 从 Level 2 中找到的节点(11)开始。
- 11 后面是 15(15 < 18),移动到 15。
- 15 后面是 22,但因为 22 > 18,停止。
- 在 Level 1,小于 18 的最大节点是 15。
-
下降至 Level 0:
- 从 Level 1 中找到的节点(15)开始。
- 15 后面是 18,我们找到了目标节点。
- 在 Level 0,找到了值为 18 的节点。
search
结果:
- 在 Level 0 中找到了值为 18 的节点,所以
search(18)
返回true
。
通过这个过程,search
函数在跳表的不同层级中高效地定位了值为 18 的节点。它首先在高层级中快速移动,快速跳过那些不满足条件的节点,然后逐层下降,直到在最底层找到了目标节点。通过这种方式,跳表提供了比普通链表更快的搜索性能。
// 查找函数:判断跳表中是否存在值为 target 的节点
bool search(int target) {
vector<Node*> pre(level);
find(target, pre); // 找到每一层小于 target 的最大节点
auto p = pre[0]->next[0]; // 在最底层判断是否存在 target
return p && p->val == target; // 如果存在且值相等,返回 true
}
add
接下来结合具体的例子讲解如何在跳表中插入值为 36 的节点,我们将遵循 add
方法的步骤,并展示每个步骤如何在跳表的各层中影响链接。下面是跳表的当前状态,以及插入值为 36 的节点后的状态。
当前跳表状态(插入值为 36 的节点前):
Level 3: Head -> 11 -------------------------------------------> 33 -------------------------------------------> 55 -> NULL
Level 2: Head -> 11 -------------------> 22 -------------------> 33 -------------------> 44 -------------------> 55 -------------------> 66 -> NULL
Level 1: Head -> 11 -------> 15 -------> 22 -------> 27 -------> 33 -------> 37 -------> 44 -------> 48 -------> 55 -------> 59 -------> 66 -------> 72 -> NULL
Level 0: Head -> 11 -> 13 -> 15 -> 18 -> 22 -> 24 -> 27 -> 30 -> 33 -> 35 -> 37 -> 40 -> 44 -> 46 -> 48 -> 51 -> 55 -> 57 -> 59 -> 61 -> 66 -> 69 -> 72 -> 75 -> NULL
步骤 1: 查找前驱节点
find
函数被调用以找到跳表中每个层级上小于 36 的最大节点。- 例如,在 Level 1 中,值为 33 的节点是 36 的前驱节点。
步骤 2: 插入新节点
- 创建一个新节点
p
,值为 36。 - 在每个层级中,将新节点插入到前驱节点之后。
- 随机决定新节点出现在哪些层级中。假设随机选择它只出现在 Level 0 和 Level 1。
插入后的跳表状态:
Level 3: Head -> 11 -------------------------------------------> 33 ---------------------------------------------------> 55 -> NULL
Level 2: Head -> 11 -------------------> 22 -------------------> 33 ---------------------------> 44 -------------------> 55 --------------------> 66 -> NULL
Level 1: Head -> 11 -------> 15 -------> 22 -------> 27 -------> 33 -------> [36] -> 37 -------> 44 -------> 48 -------> 55 -------> 59 --------> 66 -------> 72 -> NULL
Level 0: Head -> 11 -> 13 -> 15 -> 18 -> 22 -> 24 -> 27 -> 30 -> 33 -> 35 -> [36] -> 37 -> 40 -> 44 -> 46 -> 48 -> 51 -> 55 -> 57 -> 59 -> 61 --> 66 -> 69 -> 72 -> 75 -> NULL
在这个图形化的表示中:
[36]
表示新插入的节点,值为 36。- 在 Level 1 中,新节点
[36]
被插入在值为 33 的节点和值为 37 的节点之间。即,33 -> [36] -> 37
。 - 在 Level 0 中,同样的插入操作发生,将
[36]
插入在值为 35 和值为 37 的节点之间。 - 在更高层级(Level 2 和 Level 3),由于
[36]
没有被包含(基于随机选择),这些层级的链接保持不变。
通过这种方式,值为 36 的节点被有效地插入到跳表中,并且跳表的结构被适当地更新以保持其快速搜索的特性。
// 插入函数:向跳表中插入一个值为 num 的新节点
void add(int num) {
vector<Node*> pre(level);
find(num, pre); // 找到每一层小于 num 的最大节点
auto p = new Node(num); // 创建新节点
for (int i = 0; i < level; i++ ) {
p->next[i] = pre[i]->next[i]; // 新节点的 next 指向前驱节点的 next
pre[i]->next[i] = p; // 前驱节点的 next 指向新节点
if (rand() % 2) break; // 有 50% 的概率停止向上层插入
}
}
erase
当我们以文本图形化的方式来讲解如何从跳表中删除一个值为 33 的节点时,我们会展示在各个层级中节点是如何被更新和移除的。以下是您提供的跳表示例,并标记了如何删除值为 33 的节点。
假设的跳表结构(删除值为 33 的节点前):
Level 3: Head -> 11 -------------------------------------------> 33 ----------------------------------------------> 55 -> NULL
Level 2: Head -> 11 -------------------> 22 -------------------> 33 -------------------> 44 ----------------------> 55 ----------------> 66 -> NULL
Level 1: Head -> 11 -------> 15 -------> 22 -------> 27 -------> 33 -------> 37 -------> 44 -------> 48 (pre[1]) -> 55 ---> 59 --------> 66 -------> 72 -> NULL
Level 0: Head -> 11 -> 13 -> 15 -> 18 -> 22 -> 24 -> 27 -> 30 -> 33 -> 35 -> 37 -> 40 -> 44 -> 46 -> 48 -> 51 -> 55 -> 57 -> 59 -> 61 -> 66 -> 69 -> 72 -> 75 -> NULL
删除步骤:
-
查找前驱节点:
- 使用
find
函数在每个层级找到值为 33 的节点的前驱节点。 - 在 Level 1 中,前驱节点是值为 27 的节点。
- 在 Level 2 中,前驱节点是值为 22 的节点。
- 在 Level 3 中,前驱节点是值为 11 的节点。
- 使用
-
更新指针绕过值为 33 的节点:
- 在每个层级,将前驱节点的
next
指针指向值为 33 的节点的下一个节点。 - 例如,在 Level 1,将值为 27 的节点的
next
指向值为 37 的节点。
- 在每个层级,将前驱节点的
跳表结构(删除值为 33 的节点后):
Level 3: Head -> 11 -------------------------------------------------------------------------------------> 55 -> NULL
Level 2: Head -> 11 -------------------> 22 -------------------------------------> 44 -------------------> 55 -------------------> 66 -> NULL
Level 1: Head -> 11 -------> 15 -------> 22 -------> 27 -------------> 37 -------> 44 -------> 48 -------> 55 -------> 59 -------> 66 -------> 72 -> NULL
Level 0: Head -> 11 -> 13 -> 15 -> 18 -> 22 -> 24 -> 27 -> 30 -> 35 -> 37 -> 40 -> 44 -> 46 -> 48 -> 51 -> 55 -> 57 -> 59 -> 61 -> 66 -> 69 -> 72 -> 75 -> NULL
在上面的示例中,你可以看到:
- 原本指向值为 33 的节点的指针现在直接指向了它在各个层级中的下一个节点。例如,原本在 Level 1 中,值为 27 的节点指向值为 33 的节点,现在直接指向值为 37 的节点。
- 这种指针的更新在所有包含值为 33 的节点的层级中发生。因此,在 Level 0 中,值为 30 的节点的
next
现在指向值为 35 的节点,绕过了值为 33 的节点。 - 通过这种方式,值为 33 的节点从跳表的所有层级中被移除,而不影响其他节点之间的链接关系。
这就完成了删除操作。在物理层面上,值为 33 的节点将被释放,以回收内存空间。在逻辑层面上,该节点就像不存在一样,因为没有任何指针再指向它,它也不再指向其他节点。
// 删除函数:从跳表中删除值为 num 的节点
bool erase(int num) {
vector<Node*> pre(level);
find(num, pre); // 找到每一层小于 num 的最大节点
auto p = pre[0]->next[0]; // 检查最底层是否存在要删除的节点
if (!p || p->val != num) return false; // 如果不存在,返回 false
// 存在则从每一层中删除
for (int i = 0; i < level && pre[i]->next[i] == p; i++) {
pre[i]->next[i] = p->next[i]; // 将前驱节点的 next 指向要删除节点的下一个节点
}
delete p; // 释放被删除节点的内存
return true;
}
code
下面是完整代码:
class Skiplist {
public:
static const int level = 8; // 定义跳表的最大层数为 8,这是一个经验值,太大会造成空间浪费
// 跳表节点的结构定义
struct Node {
int val; // 节点存储的值
vector<Node*> next; // 存储到下一个节点的指针数组,next[i] 表示当前节点在第 i 层的下一个节点
Node(int _val) : val(_val) { // 构造函数,初始化节点值和 next 数组
next.resize(level, NULL); // 将 next 数组的大小初始化为 level,并全部指向 NULL
}
}*head; // 跳表的头节点,初始化时指向一个虚拟节点
// 构造函数
Skiplist() {
head = new Node(-1); // 初始化头节点,使用一个不存在的节点值(这里用 -1)
}
// 析构函数
~Skiplist() {
Node *current = head;
while (current) {
Node *temp = current;
current = current->next[0]; // 遍历链表,释放每个节点的内存
delete temp;
}
}
// 辅助函数:找到小于目标值 target 的每一层的最大节点
void find(int target, vector<Node*>& p) {
Node* node = head;
for (int i = level - 1; i >= 0; i--) {
while (node->next[i] && node->next[i]->val < target) {
node = node->next[i]; // 在第 i 层向前移动,直到找到小于 target 的最大节点
}
p[i] = node; // 存储每一层找到的节点
}
}
// 查找函数:判断跳表中是否存在值为 target 的节点
bool search(int target) {
vector<Node*> pre(level);
find(target, pre); // 找到每一层小于 target 的最大节点
auto p = pre[0]->next[0]; // 在最底层判断是否存在 target
return p && p->val == target; // 如果存在且值相等,返回 true
}
// 插入函数:向跳表中插入一个值为 num 的新节点
void add(int num) {
vector<Node*> pre(level);
find(num, pre); // 找到每一层小于 num 的最大节点
auto p = new Node(num); // 创建新节点
for (int i = 0; i < level; i++ ) {
p->next[i] = pre[i]->next[i]; // 新节点的 next 指向前驱节点的 next
pre[i]->next[i] = p; // 前驱节点的 next 指向新节点
if (rand() % 2) break; // 有 50% 的概率停止向上层插入
}
}
// 删除函数:从跳表中删除值为 num 的节点
bool erase(int num) {
vector<Node*> pre(level);
find(num, pre); // 找到每一层小于 num 的最大节点
auto p = pre[0]->next[0]; // 检查最底层是否存在要删除的节点
if (!p || p->val != num) return false; // 如果不存在,返回 false
// 存在则从每一层中删除
for (int i = 0; i < level && pre[i]->next[i] == p; i++) {
pre[i]->next[i] = p->next[i]; // 将前驱节点的 next 指向要删除节点的下一个节点
}
delete p; // 释放被删除节点的内存
return true;
}
};
/**
* Skiplist 对象的实例化和使用:
* Skiplist* obj = new Skiplist();
* bool param_1 = obj->search(target);
* obj->add(num);
* bool param_3 = obj->erase(num);
*/
class Solution {
public:
bool isAnagram(string s, string t) {
int a[26];
for (auto ss : s) {
a[ss - 'a']++;
}
for (auto tt : t) {
a[tt - 'a']--;
}
for (int i = 0; i < 26; i++) {
if (a[i] != 0) {
return false;
}
}
return true;
}
};
class Solution {
public:
vector<string> commonChars(vector<string>& words) {
vector<int> minFreq(26, INT_MAX);
vector<int> freq(26, 0);
vector<string> result;
for (const string& word : words) {
fill(freq.begin(), freq.end(), 0);
for (char c : word) {
++freq[c - 'a'];
}
for (int i = 0; i < 26; ++i) {
minFreq[i] = min(minFreq[i], freq[i]);
}
}
for (int i = 0; i < 26; ++i) {
for (int j = 0; j < minFreq[i]; ++j) {
result.push_back(string(1, i + 'a'));
}
}
return result;
}
};
class Solution {
public:
// 计算两个数组的交集
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> resultSet; // 用于存储结果集的集合
int presenceArray[1005] = {0}; // 使用数组记录nums1中数字的出现,数组初始化为0
// 遍历nums1,标记出现过的数字
for (int number : nums1) {
presenceArray[number] = 1; // 标记数字出现过
}
// 遍历nums2,检查每个数字是否在nums1中出现过
for (int number : nums2) {
if (presenceArray[number] == 1) {
resultSet.insert(number); // 如果在nums1中出现过,加入结果集
presenceArray[number] = 0; // 避免重复添加到结果集中
}
}
// 将结果集转换为vector并返回
return vector<int>(resultSet.begin(), resultSet.end());
}
};
树
208. 实现 Trie (前缀树) 这道题挺简单的,把下面的内容看完这道题很快就能写出来。
Trie 树(又称前缀树或字典树)是一种用于快速检索字符串数据集中的键的树形数据结构。这种结构非常适合处理字符串集合,特别是实现自动补全或拼写检查等功能。下面,我将通过一个具体的例子来解释 Trie 树的结构和工作原理。
假设我们有一个 Trie 树,我们要在其中存储单词“CAT”,“CAN”,和“BAT”。Trie 树的结构将如下所示:
Root
/ \
B C
| |
A A
/ \ / \
T * T N
/ /
* *
在这个图形化表示中:
- 每个节点代表一个字符。
- 根节点(Root)不包含字符,它是所有单词的起始点。
- 每条从根节点到某个标记为“”的节点的路径代表一个单词。例如,从根节点到第一个“”的路径是“BAT”。
- 分支表示单词的不同字符。例如,“BAT”和“CAT”共享第一个字符,但第二个字符不同,因此在第二层分开。
现在,如果我们要添加另一个单词比如“CAR”,Trie 树将会更新为:
Root
/ \
B C
| |
A A
/ \ / \
T * T R
/ / |
* * *
|
*
在这个新的结构中,“CAT”和“CAR”共享前两个字符“CA”,但第三个字符不同,在“CAR”中为“R”,因此在第三层分叉。
操作说明
-
插入:当插入一个新单词时,从根节点开始,为单词的每个字符创建一个新的子节点(除非该字符已经存在)。到达单词的最后一个字符时,标记这个节点表示一个单词的结束。
-
搜索:为了查找一个单词,从根节点开始,沿着单词的字符移动。如果能够在每一步都找到相应的字符,并且最后一个字符被标记为一个单词的结尾,那么该单词存在于 Trie 树中。
-
前缀搜索:前缀搜索与全词搜索类似,但不需要最后一个字符被标记为单词的结尾。如果能够顺着前缀的字符在树中移动到最后一个字符,那么这个前缀存在于树中。
Trie 树在处理大量字符串,尤其是进行快速前缀查找和词汇自动补全时非常有效。由于它基于共享前缀来存储单词,因此相比于其他数据结构,它在空间效率上通常更优。
TrieNode 设计
TrieNode
用于表示 Trie 树中的每个节点。接下来详细解释这个代码片段,包括TrieNode
的设计以及每个成员的作用。
class TrieNode {
public:
bool isEndOfWord;
unordered_map<char, TrieNode*> children;
TrieNode() : isEndOfWord(false) {}
};
-
bool isEndOfWord;
:这是一个布尔型成员变量,用于标记当前节点是否代表一个单词的结束。如果isEndOfWord
为true
,则表示从根节点到当前节点的路径构成一个完整的单词。 -
unordered_map<char, TrieNode*> children;
:这是一个无序映射(unordered_map
),用于存储当前节点的子节点。Trie 树的一个关键特性是每个节点可以有多个子节点,每个子节点对应一个字符。这个unordered_map
允许我们将字符映射到相应的子节点。 -
TrieNode() : isEndOfWord(false) {}
:这是TrieNode
类的构造函数。构造函数用于初始化新创建的TrieNode
对象。在这里,构造函数将isEndOfWord
初始化为false
,表示节点默认不是单词的结束。
现在让我们通过一个示例来解释如何使用这个TrieNode
类来构建 Trie 树:
假设我们要在 Trie 树中插入单词"CAT",首先我们创建一个根节点。然后,我们从根节点开始,在每个字符位置上创建一个新的TrieNode
对象,将isEndOfWord
设置为false
。在这个过程中,我们将字符映射到相应的子节点,如下所示:
- 创建根节点,
isEndOfWord
为false
。 - 在根节点下创建字符'C'对应的子节点,
isEndOfWord
为false
。 - 在字符'C'对应的子节点下创建字符'A'对应的子节点,
isEndOfWord
为false
。 - 在字符'A'对应的子节点下创建字符'T'对应的子节点,
isEndOfWord
为true
,因为"CAT"的最后一个字符表示单词的结束。
这就是如何使用TrieNode
类来构建 Trie 树的一部分。这个类的设计允许我们轻松地在每个节点上附加字符和标记单词的结束。随着插入更多的单词,Trie 树的结构会不断扩展。
完整代码
这段代码实现了一个称为“Trie”(也被称为前缀树或字典树)的数据结构。以下是对代码的逐行中文注释:
// Trie节点的定义。
class TrieNode {
public:
bool isEndOfWord; // 标记这个节点是否是某个单词的结尾
unordered_map<char, TrieNode*> children; // 存储当前节点的子节点
// 构造函数,初始化时该节点不是任何单词的结尾
TrieNode() : isEndOfWord(false) {}
};
// Trie树的定义。
class Trie {
private:
TrieNode* root; // Trie树的根节点
public:
// 构造函数,初始化Trie树时创建根节点
Trie() {
root = new TrieNode();
}
// 向Trie树中插入一个单词
void insert(string word) {
TrieNode* current = root; // 从根节点开始
for (char ch : word) { // 遍历单词的每一个字符
// 如果当前字符不在子节点中,则添加它
if (current->children.find(ch) == current->children.end()) {
current->children[ch] = new TrieNode();
}
// 移动到下一个节点
current = current->children[ch];
}
// 标记单词的最后一个字符为单词的结尾
current->isEndOfWord = true;
}
// 搜索Trie树中是否存在某个单词
bool search(string word) {
TrieNode* current = root; // 从根节点开始
for (char ch : word) { // 遍历单词的每一个字符
// 如果当前字符不在子节点中,返回false
if (current->children.find(ch) == current->children.end()) {
return false;
}
// 移动到下一个节点
current = current->children[ch];
}
// 如果找到最后一个字符,并且这个字符是某个单词的结尾,则返回true
return current != nullptr && current->isEndOfWord;
}
// 检查Trie树中是否有以某个前缀开始的单词
bool startsWith(string prefix) {
TrieNode* current = root; // 从根节点开始
for (char ch : prefix) { // 遍历前缀的每一个字符
// 如果当前字符不在子节点中,返回false
if (current->children.find(ch) == current->children.end()) {
return false;
}
// 移动到下一个节点
current = current->children[ch];
}
// 如果前缀存在,则返回true
return true;
}
// 析构函数,释放Trie树所占用的所有内存
~Trie() {
clearMemory(root);
}
private:
// 递归清理内存的辅助函数
void clearMemory(TrieNode* node) {
// 递归清理所有子节点
for (auto pair : node->children) {
clearMemory(pair.second);
}
// 删除当前节点
delete node;
}
};
这个 Trie 树的实现提供了插入单词、搜索单词和搜索以某个前缀开始的单词的功能,同时在析构时清理所有占用的内存。Trie 树是一种高效的字符串检索数据结构,常用于实现字典、自动补全等功能。