一、内存屏障 在 Linux C 语言编程 中,内存屏障(Memory Barrier) 是一种用于控制内存访问顺序的技术。它主要用于多处理器系统中,确保某些操作按预期顺序执行,避免 CPU 和编译器对内存访问进行优化,从而影响程序的正确性。内存屏障的功能在多线程和并发编程中尤为重要。 什么是内存屏障? 内存屏障的障中文意思是保护和隔离的,也有阻止的意思,阻止的是CPU对变量的继续访问,停下来更新下变量,从而保护变量的一致性。 内存屏障是针对线程所有共享变量的,而原子操作仅针对当前原子变量。 内存屏障是一种指令,它的作用是禁止 CPU 重新排序特定的内存操作。它确保在屏障之前的所有读/写操作在屏障之后的操作之前完成。内存屏障一般被用来控制多处理器环境中的内存可见性问题,尤其是在进行原子操作、锁和同步时。 在多核处理器上,每个处理器都有自己的缓存,CPU 会将内存操作缓存到自己的本地缓存中。在不同的 CPU 之间,内存的可见性并非立刻同步,这就可能导致不同线程看到不同的内存值。通过内存屏障,可以确保特定的操作顺序,以避免此类问题。 内存屏障的类型 Linux C 中,内存屏障通常有以下几种类型,主要通过内核提供的原子操作或者内存屏障函数来实现。 1.全屏障(Full Barrier 或者 LFENCE、SFENCE): 作用:以下两种相加。 用途:确保所有的内存操作都在内存屏障前完成,通常用于同步和锁定操作。 内核函数:mb() (Memory Barrier) 2.读屏障(Read Barrier 或者 LFENCE): 作用:保证屏障之前的所有读操作在屏障之后的读操作之前完成。---》翻译过来的有歧义,难以理解,那个“完成”是缓存同步主内存的意思。 本质:作用是强制将 CPU核心 中的 L1/L2 缓存 中的共享变量值写回到 主内存。 用途:在执行并行读操作时确保读顺序。 内核函数:rmb() (Read Memory Barrier) 3.写屏障(Write Barrier 或者 SFENCE): 作用:保证屏障之前的所有写操作在屏障之后的写操作之前完成。--》翻译过来有歧义,难以理解,那个“完成”是主内存同步到缓存的意思。 本质:作用是强制使数据从主内存加载,而不是直接使用可能已经过时的缓存数据。 用途:用于确保写操作顺序。 内核函数:wmb() (Write Memory Barrier) 4.无序屏障(No-op Barrier): 作用:没有实际影响,仅确保 CPU 不会重排序特定的指令。 用途:常用于确保指令的顺序性而不做其他强制性的内存同步。 读写屏障的作用域 读写屏障的作用域并不局限于当前函数或者某个函数调用的局部作用域,而是影响整个 当前线程的内存访问顺序。也就是说,只要在当前线程中,任何在屏障前后的内存操作都会受到屏障影响,而不管这些操作发生在同一个函数里还是不同的函数中。 线程之间的隔离 读写屏障是 线程级别的,因此它们只影响执行这些屏障操作的线程。也就是说,如果线程 1 执行了写屏障,它只会影响线程 1 后续的内存操作,而不会直接影响其他线程。---》翻译过来的,其实就是这个线程的读写屏障只会引发自己线程变量与主内存的同步,管不到其他线程的同步。但是写屏障触发后 会 通知其他线程,如果有现代 CPU 使用缓存一致性协议(如 MESI)的话,其他线程会把主内存中的最新值更新到自己缓存中。 读屏障不会触发其他线程去把自己的缓存同步到主内存中。 如果想让多个线程之间的共享变量同步并保持一致性,通常需要在多线程间使用某些同步机制(如锁、原子操作等),而不仅仅是依赖于单个线程的屏障。 具体来说: 写屏障(Write Barrier):会影响所有在屏障之前执行的写操作,无论这些写操作发生在当前函数内还是其他函数中。它确保屏障前的所有写操作都能同步到主内存,任何与此线程共享的缓存都能看到这些值。 读屏障(Read Barrier):会影响所有在屏障之后执行的读操作,确保这些读操作从主内存读取最新的值,而不是从 CPU 核心的缓存中读取过时的值。读屏障会影响当前线程的所有后续读取操作,无论这些读取发生在哪个函数中。 内存屏障的使用 在 Linux 中,内存屏障主要通过一组原子操作宏来提供。这些操作用于确保不同 CPU 或线程之间的内存同步。常见的内存屏障宏包括: mb():全屏障,防止 CPU 重排序所有内存操作。 rmb():读屏障,确保屏障之前的所有读操作完成。 wmb():写屏障,确保屏障之前的所有写操作完成。 示例代码 #include #include #define wmb() __asm__ __volatile__("sfence" ::: "memory") // 写屏障#define rmb() __asm__ __volatile__("lfence" ::: "memory") // 读屏障#define mb() __asm__ __volatile__("mfence" ::: "memory") // 全屏障 void example_memory_barrier() { int shared_variable = 0; // 写入数据 shared_variable = 42; // 在这里使用写屏障,确保共享变量的写操作 // 在执行屏障之后才会完成 wmb(); // 读取共享数据 printf("Shared Variable: %d\n", shared_variable); // 使用读屏障,确保屏障前的所有读取操作完成 rmb(); // 这里是确保顺序执行的一部分 printf("Shared Variable read again: %d\n", shared_variable);} int main() { example_memory_barrier(); return 0;} 为什么需要内存屏障? 避免重排序:编译器和 CPU 会对内存访问进行优化,尤其是在多处理器系统中,这可能导致指令执行顺序与预期不一致,进而导致错误的程序行为。 保证内存一致性:当一个线程或 CPU 修改共享变量时,其他线程或 CPU 可能会看到不同的内存值,内存屏障可以保证修改操作在其他线程中是可见的。 同步操作:在多线程或多处理器环境中,内存屏障确保执行顺序和同步的正确性,尤其是在没有锁或原子操作的情况下。 缓存一致性协议(例如 MESI) 为了保证多核处理器之间缓存的数据一致性,现代 CPU 会使用缓存一致性协议(如MESI 协议,即 Modified、Exclusive、Shared、Invalid)。这个协议的作用是确保一个核心的缓存修改在其他核心的缓存中得到更新,避免出现“脏数据”。 但即便如此,MESI 协议的具体实现仍然依赖于硬件,缓存之间的同步可能不会在每一次内存访问时都发生。尤其是在没有任何同步机制的情况下,一个线程修改的值可能会暂时不被另一个线程看到,直到某些缓存刷新或同步操作发生。 Linux 内核中的内存屏障 在 Linux 内核中,内存屏障主要是通过原子操作来实现的。例如,atomic_set、atomic_add 等原子操作通常会隐式地使用内存屏障来保证内存操作顺序。而直接的内存屏障通常通过 mb()、wmb() 和 rmb() 函数来实现。 总结 内存屏障在多核处理器和并发程序中非常重要,用于控制内存操作顺序,避免由于硬件优化或编译器优化引起的内存同步问题。Linux 提供了多种类型的内存屏障函数,程序员可以根据需要使用它们来确保内存操作的顺序性。 需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享 二、变量存贮与内存屏障 你提到的问题涉及 程序执行过程中变量的存储位置、内存可见性和线程切换 的多个方面。为了更清晰地解释,我们需要从操作系统的内存管理和多线程模型入手。 1. 程序执行前变量存在哪里? 在程序执行之前,变量的存储位置主要依赖于变量的类型和生命周期。变量可以存储在以下几个区域: 栈区(Stack):局部变量通常会被分配到栈中。栈是线程私有的,每个线程都有一个独立的栈空间。 堆区(Heap):动态分配的内存(通过 malloc()、free() 等)会被存储在堆中。堆是共享的,不同线程可以访问堆中的数据。 全局区/静态区(Data Segment):全局变量和静态变量通常存储在数据段中。在程序启动时,数据段会被分配并初始化。 代码区(Text Segment):存储程序的代码(即机器指令),线程不直接操作。 2. 线程对变量的修改,为什么对其他线程不可见? 当一个线程修改了它的某个变量的值,这个变量的值并不一定立即对其他线程可见,主要是因为现代处理器通常会有缓存(Cache),并且每个线程可能在自己的寄存器或局部缓存中执行操作。具体原因如下: CPU 缓存:每个 CPU 核心都有自己的缓存(例如 L1、L2 Cache),当一个线程运行时,它可能会先将某个变量加载到本地缓存中进行修改,而不是直接操作主内存。这样,修改后的值可能不会立刻反映到主内存中。 内存可见性问题:因为不同的线程可能运行在不同的 CPU 核心上,并且每个核心有自己的缓存系统,其他线程(在不同的 CPU 核心上)可能无法直接看到修改后的变量值。除非通过某种同步机制(如内存屏障、锁、原子操作等)确保所有 CPU 核心的缓存一致性,否则修改的值不会立刻对其他线程可见。 3. 线程被切换时,变量的值存储到哪里? 当一个线程被 调度器切换(例如,从运行状态切换到阻塞状态或就绪状态)时,操作系统会保存该线程的 上下文(即该线程当前的执行状态)。这个过程称为 上下文切换。 CPU 寄存器:线程的 寄存器状态(包括程序计数器、栈指针、CPU 寄存器中的数据等)会被保存在操作系统为该线程分配的 线程控制块(TCB) 中,或者在内核中由特定的机制(如进程控制块、线程栈)保存。 内存:栈中的局部变量会被存储在 栈区,这些数据在线程切换时保持不变,直到线程恢复时。 CPU 缓存:在某些情况下,线程切换后,CPU 的缓存中的数据可能会被清除或更新,以保证在切换后的线程恢复时能正确访问内存。 4. 缓存与主内存 在现代多核处理器上,每个 CPU 核心(CPU Core)通常有自己的 本地缓存(例如 L1 缓存、L2 缓存,甚至是更高层的缓存)。这些缓存的作用是加速内存访问,避免每次访问内存时都直接访问主内存(RAM)。因此,当线程对某个变量进行修改时,这个变量的值首先会被写入到该线程所在 CPU 核心的 缓存 中,而不一定立即写回到主内存。 5. 另一个线程接着运行,变量值从哪里拿? 当一个线程被切换出去后,另一个线程会接管 CPU 的执行,并且继续执行自己的代码。另一个线程获取变量的值依赖于以下几个因素: 内存一致性:如果没有任何内存屏障或同步机制,另一个线程可能会读取到一个过时的缓存值。原因是线程 1 在修改变量时,可能只是修改了本地缓存,而没有把新值写回到主内存中;而线程 2 可能仍然读取到线程 1 的旧值。 线程间同步:为了确保变量的最新值在多个线程之间可见,通常需要使用 同步原语(如互斥锁 mutex、条件变量 condvar、原子操作等)。如果使用了如 mutex 锁定共享资源,或者使用了 volatile 关键字或原子操作,线程间对变量的修改才会更可靠地同步和可见。 缓存一致性协议:现代 CPU 通常使用 缓存一致性协议(如 MESI 协议)来确保不同 CPU 核心之间缓存的一致性。当一个线程修改某个变量时,其他线程会通过协议获取这个变量的最新值,避免缓存中的脏数据。 6. CPU 缓存与线程切换 这里有一个关键点:线程切换并不意味着缓存被清除。如果线程 1 在 CPU 核心 A 上运行,修改了全局变量,并且这个修改存储在核心 A 的缓存中,线程 1 被切换出去,CPU 核心 A 的缓存不会因为线程切换而清空。即使切换到其他线程,CPU 仍然会保留 核心 A 的缓存内容。 当线程 2 在另一个 CPU 核心 B 上开始运行时,如果它访问相同的全局变量,它会根据 自己核心的缓存 来读取这个变量。如果线程 2 看到的是旧值,那么它就是从 核心 B 的缓存 中拿到的旧值,而不是从主内存 中读取的。 7. 内存一致性问题 内存一致性问题通常出现在 多核处理器 中,特别是当多个线程运行在不同的 CPU 核心上时。在这种情况下: 线程 1 可能修改了一个全局变量的值,线程 1 所在的 CPU 核心会将该变量的新值写入到 该核心的缓存(例如 L1 或 L2 缓存)中。 如果 线程 2 运行在 另一个 CPU 核心 上,它可能会直接从 自己本地的缓存 中读取这个变量,而不是从主内存读取。假如线程 2 在缓存中读取到的是 线程 1 之前的旧值(而不是修改后的新值),那么线程 2 就读取到过时的值。 8. 为什么会有内存屏障? 由于多核处理器中的 CPU 会对内存操作进行缓存优化,内存屏障(mb()、wmb()、rmb())的作用是 强制同步 内存操作,确保某些操作的顺序性和内存可见性。通过内存屏障,操作系统或程序员可以确保在特定的内存操作之前或之后的操作按顺序执行,避免缓存带来的不一致性。 9. 为什么一个线程拿到的值可能会是旧的? 这个问题的核心在于 缓存一致性 和 内存可见性: 每个 CPU 核心都有独立的缓存,这意味着它们的缓存可能保存着不同版本的内存数据。 当线程 1 修改变量时,虽然它的 本地缓存 中的数据会被更新,但主内存的更新可能并没有立刻发生,或者其他 CPU 核心的缓存并没有得到通知。 如果没有同步机制(如内存屏障、锁、原子操作等),线程 2 可能会继续读取到旧的缓存值,而不是线程 1 修改后的新值。 10. 综述:内存模型和线程切换 在现代操作系统和多核处理器中,内存模型非常复杂。通过以下几个概念可以理解线程的内存操作: 每个线程有自己的栈,但共享堆和全局变量。 缓存一致性问题:线程修改的变量不会立刻对其他线程可见。 上下文切换:线程切换时,寄存器、栈等状态会保存,并由操作系统恢复。 内存屏障和同步机制:确保变量在多个线程之间同步和可见。 示例 假设有两个线程 A 和 B,它们都操作同一个共享变量 x。假设线程 A 修改了 x 的值,但由于没有同步机制,线程 B 可能不会立刻看到修改后的值。 int x = 0; // 共享变量 void thread_a() { x = 1; // 修改共享变量 wmb(); // 写屏障,确保修改的值会被其他线程看到} void thread_b() { rmb(); // 读屏障,确保读取的是线程 A 修改后的值 printf("%d\n", x); // 打印 x 的值} 总结 线程修改变量时,该变量可能存在于不同的缓存中,其他线程可能无法看到更新的值。 上下文切换时,线程的寄存器和栈会被保存在操作系统的上下文中,恢复时会读取这些数据。 内存屏障 用于控制内存操作的顺序性,确保修改的值能在不同线程间同步。 三、编译器屏障 编译器屏障(Compiler Barrier)是一种用于控制编译器优化的机制,用来确保编译器在生成代码时不会对某些操作进行重排或优化,尤其是在多线程编程和硬件相关编程中非常重要。编译器屏障并不直接控制内存访问,而是用来防止编译器对指令的顺序进行不合适的重新排序,确保代码按照预期的顺序执行。 编译器屏障的作用 编译器屏障主要用于控制编译器如何处理代码中的指令顺序,尤其是: 防止指令重排序:编译器优化时可能会改变指令的顺序,重新排序内存访问或者其他指令。这可能导致多线程程序中某些预期的同步行为失败。编译器屏障防止这种行为。 确保指令的执行顺序:在多核处理器或并发编程中,编译器屏障确保某些关键操作(比如内存访问)在正确的顺序中执行,避免因编译器优化导致的不一致性。 编译器屏障与内存屏障的区别 编译器屏障(Compiler Barrier): 它的目的是确保编译器不会对代码进行不合理的优化或重排序,特别是在涉及并发或多核时。 编译器屏障不能强制 CPU 层面执行同步操作,仅仅是防止编译器重排代码。 内存屏障(Memory Barrier): 内存屏障则是用于确保在多核或多线程环境中,内存访问按照特定的顺序发生,它直接控制内存操作和硬件级别的缓存同步。 内存屏障不仅防止编译器重排,也会影响 CPU 对内存的读取和写入顺序。 编译器屏障的实际应用 编译器屏障通常用于那些需要确保内存或操作顺序的场景,尤其是在处理低级硬件和并发编程时。下面是一些编译器屏障的常见应用场景: 原子操作:在使用原子操作(比如 atomic_add)时,编译器屏障可用于确保原子操作的顺序性。 多线程同步:当线程间存在共享数据时,编译器屏障可以防止编译器重排序线程的操作,以保证正确的同步。 硬件访问:在直接操作硬件时,编译器屏障可以确保 I/O 操作按预期顺序执行,而不被编译器优化掉。 编译器屏障的实现 不同的编译器提供不同的方式来实现编译器屏障。常见的做法包括: volatile 关键字:在 C/C++ 中,volatile 可以告诉编译器不要优化对变量的访问,通常用于内存映射 I/O 或多线程共享变量。虽然它可以防止编译器优化,但它并不能防止 CPU 缓存重排序。 内联汇编:编译器屏障还可以通过内联汇编来实现。许多编译器(如 GCC)支持特定的内联汇编指令,用于告诉编译器不要重排某些指令。例如,GCC 提供了 asm volatile 来控制编译器优化。 编译器内置指令:某些编译器提供内置指令来实现编译器屏障。例如,GCC 中有 __asm__ __volatile__ ("": : : "memory"),这是一个编译器屏障,它不会生成任何机器指令,但会告知编译器不要重排此位置的内存操作。 示例:GCC 中的编译器屏障 在 GCC 中,可以使用 __asm__ __volatile__ ("": : : "memory") 来插入一个编译器屏障。它告诉编译器不要重新排序此点前后的内存操作。 示例代码: #include volatile int shared_var = 0; void func1() { shared_var = 1; // 修改共享变量 __asm__ __volatile__ ("" : : : "memory"); // 插入编译器屏障} void func2() { int local = shared_var; // 读取共享变量 printf("shared_var = %d\n", local);} int main() { func1(); func2(); return 0;} 在这个示例中,__asm__ __volatile__ ("" : : : "memory")强制插入一个编译器屏障,确保编译器在执行shared_var = 1和shared_var之间的读写操作时不会对它们进行优化或重排序。 编译器屏障的局限性 虽然编译器屏障可以阻止编译器对代码的优化,但它并不能保证在多核处理器上,缓存之间的同步或内存访问的正确顺序。要确保内存的一致性,尤其是跨多个 CPU 核心的同步,还需要使用 内存屏障 或 锁 等同步机制。 总结 编译器屏障 主要用于控制编译器优化,防止编译器对代码执行顺序进行重排序。 它不能控制内存访问的实际顺序,但可以防止编译器错误地优化掉重要的内存操作。 它通常与 内存屏障 一起使用,以确保在多线程或并发环境下的正确性。 四、CPU屏障 CPU 屏障,也常称为 处理器屏障,是一个硬件层面的同步机制,主要用于确保 CPU 在执行指令时按特定的顺序访问内存。它是为了处理 CPU 的 指令重排、内存缓存一致性 和 多核 CPU 系统中的缓存同步 等问题。 在多核系统中,每个 CPU 核心都有自己的缓存(L1, L2, L3 缓存),这些缓存可能存储过时的内存值,导致不同核心之间的数据不一致。CPU 屏障通过硬件指令,确保 CPU 按照特定的顺序执行内存操作,从而解决缓存一致性和内存重排序的问题。 CPU 屏障的作用 防止指令重排:现代 CPU 在执行指令时,通常会对指令进行重排序(指令乱序执行)以提高性能。CPU 屏障确保特定的指令顺序不被改变,避免并发编程中的数据不一致性。 确保内存操作顺序:CPU 屏障通过禁止指令重排和缓存同步,确保内存操作按预期的顺序发生。这对多核处理器尤其重要,避免不同核心之间的数据不一致问题。 控制缓存一致性:当一个核心修改内存中的某个值时,CPU 屏障可以确保这个修改值被写回主内存,并通知其他核心从主内存读取最新的值。 与内存屏障的区别 内存屏障(Memory Barrier) 是一种在软件层面控制 CPU 内存操作顺序的机制。它可以是一个指令,告诉 CPU 按照特定顺序访问内存,避免乱序执行或缓存不一致。 CPU 屏障 则是硬件级别的机制,它通过处理器的硬件指令实现类似的同步操作。CPU 屏障直接控制 CPU 内部的缓存管理和指令流水线,从而确保内存操作的顺序。 CPU 屏障的类型 不同的 CPU 和架构(如 x86、ARM、PowerPC)提供不同的屏障指令,以下是一些常见的屏障类型: 全屏障(Full Barrier):也叫作 全内存屏障,会确保指令完全按顺序执行,通常会阻止所有的加载(Load)和存储(Store)操作的重排序。全屏障适用于需要完全同步内存访问的场景。 加载屏障(Load Barrier):用于控制加载指令(读取内存)的顺序,保证在屏障前的加载操作完成后,才能执行屏障后的加载操作。 存储屏障(Store Barrier):用于控制存储指令(写入内存)的顺序,确保在屏障前的写操作完成后,才能执行屏障后的写操作。 轻量级屏障(Light Barrier):有些现代 CPU 提供更细粒度的屏障,能够针对特定类型的指令(如仅仅是缓存一致性)进行同步。 典型的 CPU 屏障指令 1. x86 架构: MFENCE:这是 x86 架构中的一个全屏障指令,它确保所有的加载和存储指令在屏障前后都按顺序执行。 LFENCE:加载屏障,用于确保加载操作的顺序。 SFENCE:存储屏障,用于确保存储操作的顺序。 2. ARM 架构: DMB(Data Memory Barrier):用于确保数据内存操作的顺序。DMB 会阻止内存操作的重排序。 DSB(Data Synchronization Barrier):一个更强的同步屏障,通常会确保所有的内存操作完成,才会继续执行后续操作。 ISB(Instruction Synchronization Barrier):强制 CPU 刷新指令流水线,确保指令同步。 3. PowerPC 架构: sync:PowerPC 中的同步指令,强制执行内存访问的顺序。 CPU 屏障的应用场景 多线程编程和并发控制: 在多线程程序中,多个线程可能会同时访问共享内存。使用 CPU 屏障可以确保线程之间的内存操作顺序,从而避免出现数据不一致的情况。 内存模型: 在某些硬件平台上,特别是在不同架构(如 x86 和 ARM)之间进行程序移植时,CPU 屏障能够确保程序按照预期的内存顺序执行。 硬件编程: 对于直接操作硬件的低级编程(例如内存映射 I/O、嵌入式系统开发等),CPU 屏障能够确保 I/O 操作按顺序完成,避免因为缓存一致性问题导致硬件异常。 例子:在 x86 架构中的应用 假设有一个共享变量 shared_var,多个线程可能会修改它。如果不使用 CPU 屏障,线程 1 修改 shared_var 后,可能没有立即刷新到主内存,线程 2 可能会看到过时的缓存数据。 以下是一个简单的 C 代码示例: #include #include volatile int shared_var = 0; void thread1() { shared_var = 1; // 修改共享变量 __asm__ __volatile__ ("mfence" ::: "memory"); // 使用 MFENCE 全屏障,确保写操作完成} void thread2() { while (shared_var == 0) { // 等待 thread1 更新 shared_var } printf("shared_var updated to 1\n");} int main() { // 模拟两个线程 thread1(); thread2(); return 0;} 在这个示例中,thread1 在修改 shared_var 后使用了 mfence 指令来确保修改后的值及时写入主内存,并且被其他线程看到。thread2 会等待 shared_var 更新后继续执行。 总结 CPU 屏障 是硬件层面的机制,用来控制 CPU 内部指令执行顺序和缓存同步,确保内存操作按照特定顺序发生。 它防止 CPU 对指令的重排序,从而避免多核环境中的缓存一致性问题。 不同的 CPU 架构(如 x86、ARM)提供了不同类型的 CPU 屏障指令,如 MFENCE(x86)、DMB(ARM)等。 小结图示 +-------------------------------+ | 主内存(RAM) | | | | +-------------------------+ | | | 共享变量(shared_var) | | | +-------------------------+ | +-------------------------------+ | | v v +----------------+ +----------------+ | 核心 1 | | 核心 2 | | L1 缓存 | | L1 缓存 | | 存储变量值 | | 存储变量值 | +----------------+ +----------------+ | | v v CPU 屏障(如 MFENCE) 强制同步缓存与主内存 通过 CPU 屏障的使用,确保 核心 1 对 shared_var 的修改能正确地同步到主内存,核心 2 可以看到最新的值。 五、使用内存屏障还需要使用CPU屏障吗? 对于大多数开发人员来说,内存屏障(Memory Barriers)通常是足够的,因为它们是软件级别的同步机制,而CPU 屏障(CPU Barriers)是硬件级别的机制,两者的目标都是确保内存操作按预期顺序执行。内存屏障通过插入特定的指令来控制 CPU 的缓存一致性和内存操作顺序,通常通过编译器提供的原语(如 __sync_synchronize、atomic_thread_fence、mfence 等)来实现。 1. 内存屏障 vs CPU 屏障 内存屏障: 由 程序员显式插入,通常作为一条特殊的汇编指令或者编译器指令。 它确保了程序中的某些内存操作顺序不会被优化或乱序执行。 对于 程序员来说,内存屏障是一个高层的工具,在需要同步共享数据时,它是最常用的同步机制。 CPU 屏障: 是 硬件层面的同步机制,直接控制 CPU 内部的缓存、指令流水线和内存访问顺序。 这些机制通常是针对 处理器架构(如 x86、ARM)的具体硬件指令,用于确保内存操作按顺序发生。 程序员通常 不直接操作 CPU 屏障,而是通过内存屏障指令间接地影响硬件行为。 2. 内存屏障满足开发者需求 对于程序员而言,使用 内存屏障 就足够了,因为: 内存屏障指令是跨平台的,开发人员不需要针对特定的 CPU 指令来编写代码。它们会依赖 编译器 来生成适合特定平台的机器代码。 编译器和操作系统已经为我们处理了硬件的差异。程序员插入的内存屏障会触发适当的 CPU 屏障或其他低层次同步机制,具体取决于目标平台的 CPU 和架构。 在 多核处理器 上,内存屏障确保一个线程对共享数据的修改能够及时写回主内存,并且通知其他线程访问这些数据时能看到最新的值。 3. 实际应用中的差异 3.1. 使用内存屏障 内存屏障(如__sync_synchronize、atomic_thread_fence、mfence)通过插入到代码中来显式指定内存操作顺序,从而控制并发操作时的内存一致性问题。 例如,在多线程编程中,你希望线程 A 在修改共享变量之后,线程 B 能立即看到该修改,可以在 A 中插入内存屏障: #include std::atomic<int> shared_var = 0; void thread_a() { shared_var.store(1, std::memory_order_release); // 写屏障} void thread_b() { while (shared_var.load(std::memory_order_acquire) == 0) { // 等待线程 A 更新 shared_var } // 现在可以安全地使用 shared_var} 在此例中,std::memory_order_release 和 std::memory_order_acquire 就是内存屏障的类型,它们保证了内存操作的顺序。 3.2. CPU 屏障 CPU 屏障是硬件级别的机制。程序员一般不会显式写入 CPU 屏障指令,除非进行非常低级的硬件编程或直接操作硬件。 例如,在嵌入式系统开发或驱动开发中,可能会看到 mfence 或其他处理器指令: __asm__ __volatile__("mfence" ::: "memory"); 在这种情况下,程序员直接插入了硬件指令,显式控制 CPU 执行顺序。但是在 一般应用程序开发 中,通常并不需要手动插入这些指令,而是依赖编译器和标准库中的内存屏障。 4. 总结 对于大多数应用程序开发人员,内存屏障已经足够,不需要直接使用 CPU 屏障指令。编译器会根据目标硬件生成合适的机器代码,确保内存操作的顺序性和一致性。 内存屏障:适用于大多数开发者,提供了一个高层次的同步工具,确保不同线程之间的内存操作顺序。 CPU 屏障:属于硬件层面的操作,通常程序员不需要直接处理,除非进行底层的硬件开发。 5. 应用场景 多线程同步:确保线程对共享数据的修改能够及时生效,避免缓存不一致导致的数据不一致问题。 优化:在多核处理器中,通过内存屏障确保正确的内存操作顺序,避免因指令重排和缓存不一致带来的问题。 6. 建议 a. 如果你在编写并发程序,内存屏障是足够的,尝试使用标准库中的原子操作和内存顺序。 b. 如果你进行低级别硬件编程(例如驱动开发、嵌入式系统),可能需要直接使用 CPU 屏障。 六、“原子操作/互斥锁” 与 “内存屏障” 1. 原子操作与内存屏障的对比 原子操作(Atomic Operation)是对某个特定变量进行原子级别的访问(通常是加、减、交换等操作),它确保操作对这个变量的修改是不可分割的,即操作要么完全执行,要么完全不执行。 原子操作的作用范围:原子操作是针对特定变量的,只会影响该变量的状态,它在进行操作时,也会隐含地执行内存屏障,确保对该变量的修改对其他线程可见。 内存屏障的作用范围:内存屏障则更加广泛,它会影响到线程的所有共享变量,确保整个内存访问顺序符合预期。 简而言之,原子操作是对某个特定变量的一种操作,保证它的正确性;而内存屏障控制的是线程的所有共享变量的访问顺序,确保不同线程之间的同步和一致性。 2. 互斥锁的读写屏障 互斥锁(Mutex)作为线程同步的一种机制,通常会涉及到内存屏障。因为互斥锁的使用会影响多个线程对共享数据的访问顺序,因此通常在加锁和解锁时,操作系统会插入内存屏障,确保在锁被持有时,所有操作会遵循正确的顺序。 加锁(线程持锁)时,需要确保该线程读取的共享数据是 最新的,因此需要通过 读屏障 来保证锁保护的变量被同步到主内存,确保读到的是最新的数据。 解锁(释放锁)时,需要确保该线程修改的数据被 同步到主内存,并通知其他线程,这通常通过 写屏障 来实现,以保证其他线程能看到更新后的数据。 加锁/解锁时的内存屏障作用示意: 加锁:会确保在加锁前,当前线程对共享数据的所有操作(尤其是写入)已经完成,并且数据对其他线程可见。这通常通过插入写屏障来实现。 解锁:会确保在解锁后,其他线程能看到当前线程对共享数据的最新修改。这通常通过插入读屏障来实现。 3. 总结 内存屏障: 内存屏障的作用是保护共享数据的一致性,确保线程对共享变量的操作顺序正确。它影响的是所有共享变量,而不仅仅是某个特定的变量。 它防止 CPU 对内存访问的乱序执行,确保内存操作按预期的顺序执行。 原子操作: 原子操作通常针对单个变量,确保操作是原子性的,即不可中断的,并且会隐式地执行内存屏障,保证操作对其他线程可见。 互斥锁和内存屏障: 互斥锁在加锁和解锁时会执行内存屏障。加锁时插入写屏障,确保当前线程的写操作完成;解锁时插入读屏障,确保其他线程能看到当前线程的修改。 4. 建议 a. 如果你编写多线程程序并使用互斥锁,确保你理解锁的加锁和解锁时的内存屏障作用,以避免数据不一致问题。 b. 了解并使用原子操作和内存屏障来控制线程间共享数据的顺序,特别是在性能要求较高的场合。 七、配合内存屏障来真正理解volatile volatile 关键字在 C 和 C++ 中用于修饰变量,告诉编译器该变量的值可能会在程序的任何时刻发生变化,通常是由外部因素(如硬件、操作系统或其他线程)引起的。使用 volatile 可以防止编译器对该变量进行优化(什么样的优化?),从而确保每次访问时都从内存中获取变量的最新值,而不是从寄存器或缓存中读取过时的值。 优化?变量的优化就是CPU指令对此变量的操作一直在缓存中执行,增加运行速度。 防止优化?其实是防止读优化,即每次读操作都从主内存取,但是写操作不负责优化,可能写完大概率还在缓存中,其他同时在其他CPU核心运行的线程依然拿不到最新值。 等价于每次使用此变量之前都会触发一次“只针对这个变量的读屏障”(这是个比喻,读屏障不会只针对单独一个变量) 主要作用: 1.防止编译器优化: 编译器为了提高性能,通常会将变量存储在寄存器中,并减少对内存的访问。但是,如果一个变量是由外部因素改变的(比如硬件寄存器或其他线程修改),编译器可能不会及时从内存中读取该变量的最新值。 使用 volatile 告诉编译器:“不要优化对这个变量的访问,每次都直接从内存中读取它。” 2.确保变量访问的正确性: 当一个变量的值可能被多个线程、信号处理程序或硬件设备(例如,I/O端口、硬件寄存器)所修改时,volatile 关键字确保每次读取时都会从内存中获取最新的值,而不是使用缓存或寄存器中的值。 3.不保证写操作同步到主内存:如果你修改了一个 volatile 变量,编译器会直接在内存中修改这个变量的值,但这并不意味着其他核心的 CPU 或线程会立即看到这个修改。它不会自动确保 这个修改会立即同步到其他线程或 CPU 核心的缓存中。 4.volatile的作用范围:它确保你每次访问时都能获取到变量的最新值,但并不会同步其他线程或 CPU 中的缓存。例如,在多核处理器环境下,CPU 核心 A 可能会缓存某个变量,而 CPU 核心 B 可能并不知道核心 A 修改了这个变量,volatile 不能解决这种缓存一致性问题。 适用场景: 硬件寄存器:在嵌入式系统中,直接映射到硬件的内存地址经常会用 volatile,以确保每次访问时都读取硬件的最新值,而不是使用缓存。 多线程共享变量:当多个线程共享一个变量时,某个线程对该变量的修改可能会被其他线程及时看到,volatile 可以确保每次访问该变量时,都能获取到最新的值。 信号处理函数中的变量:在多线程或多进程的环境中,如果信号处理程序修改了某个变量,程序中的其他部分在访问这个变量时,需要确保获取到的是最新的值,因此也需要用 volatile。 使用示例: #include volatile int flag = 0; // 声明为volatile,表示它的值可能被外部因素改变 void signal_handler() { flag = 1; // 外部信号处理程序改变flag的值} void wait_for_flag() { while (flag == 0) { // 这里每次访问flag时,都会从内存中读取,而不是使用寄存器或缓存中的值 } printf("Flag is set!\n");} int main() { wait_for_flag(); // 等待flag被信号处理程序修改 return 0;} 在这个例子中: flag 变量声明为 volatile,表示它可能在程序的其他地方(如信号处理函数 signal_handler)被修改。 wait_for_flag 函数会在 flag 被修改之前一直等待。当 flag 被设置为 1 时,程序才会继续执行。 volatile 确保每次检查 flag 时都从内存读取,而不是从寄存器缓存中读取过时的值。 注意事项: 1.volatile 不等同于原子性或线程同步: volatile 只确保了 每次都从内存中读取变量的值,它并不会提供线程安全或原子性。例如,如果多个线程同时修改一个 volatile 变量,它仍然会有竞态条件(race condition)问题,因此在这种情况下,还需要使用互斥锁或原子操作来保证同步。 2.volatile不会防止缓存一致性问题: 在多核 CPU 系统中,volatile 并不能解决不同核心之间的缓存一致性问题。缓存一致性通常是由硬件(如 MESI 协议)或内存屏障来处理的。 3.volatile主要是为了防止编译器优化: 在大多数情况下,volatile 只是告诉编译器不要优化对变量的访问,它并不改变该变量的实际行为或内存模型。 结论: volatile 主要用于防止编译器优化,确保程序每次访问变量时都从内存中读取它的最新值,适用于那些可能被外部因素(硬件、其他线程、信号处理程序)改变的变量。 它 不会 处理多线程之间的同步问题,如果需要同步,则应该配合 互斥锁(mutex)或其他线程同步机制。 相关建议 a. 在多线程编程中,除了 volatile,你还应该了解如何使用 原子操作、互斥锁 或 条件变量 来确保共享数据的一致性。 b. 如果涉及到硬件寄存器的访问,理解 volatile 的使用是非常重要的,同时要注意与内存屏障和同步机制配合使用。
数据压倒一切。如果选择了正确的数据结构并把一切组织的井井有条,正确的算法就不言自明。编程的核心是数据结构,而不是算法。——Rob Pike 说明 本文基于这样的认识:数据是易变的,逻辑是稳定的。本文例举的编程实现多为代码片段,但不影响描述的完整性。本文例举的编程虽然基于C语言,但其编程思想也适用于其他语言。此外,本文不涉及语言相关的运行效率讨论。 概念提出 所谓表驱动法(Table-Driven Approach)简而言之就是用查表的方法获取数据。此处的“表”通常为数组,但可视为数据库的一种体现。 根据字典中的部首检字表查找读音未知的汉字就是典型的表驱动法,即以每个字的字形为依据,计算出一个索引值,并映射到对应的页数。相比一页一页地顺序翻字典查字,部首检字法效率极高。 具体到编程方面,在数据不多时可用逻辑判断语句(if…else或switch…case)来获取值;但随着数据的增多,逻辑语句会越来越长,此时表驱动法的优势就开始显现。 例如,用36进制(A表示10,B表示11,…)表示更大的数字,逻辑判断语句如下: if(ucNum < 10) { ucNumChar = ConvertToChar(ucNum); } else if(ucNum == 10) { ucNumChar = 'A'; } else if(ucNum == 11) { ucNumChar = 'B'; } else if(ucNum == 12) { ucNumChar = 'C'; } //... ... else if(ucNum == 35) { ucNumChar = 'Z'; } 当然也可以用 switch…case 结构,但实现都很冗长。而用表驱动法(将numChar 存入数组)则非常直观和简洁。如: CHAR aNumChars[] = {'0', '1', '2', /*3~9*/'A', 'B', 'C', /*D~Y*/'Z'}; CHAR ucNumChar = aNumChars[ucNum % sizeof(aNumChars)]; 像这样直接将变量当作下数组下标来读取数值的方法就是直接查表法。 注意,如果熟悉字符串操作,则上述写法可以更简洁: CHAR ucNumChar = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"[ucNum]; 使用表驱动法时需要关注两个问题:一是如何查表,从表中读取正确的数据;二是表里存放什么,如数值或函数指针。前者参见1.1节“查表方式”内容,后者参见1.2节“实战示例”内容。 查表方式 常用的查表方式有直接查找、索引查找和分段查找等。 直接查找 即直接通过数组下标获取到数据。如果熟悉哈希表的话,可以很容易看出这种查表方式就是哈希表的直接访问法。 如获取星期名称,逻辑判断语句如下: if(0 == ucDay) { pszDayName = "Sunday"; } else if(1 == ucDay) { pszDayName = "Monday"; } //... ... else if(6 == ucDay) { pszDayName = "Saturday"; } 而实现同样的功能,可将这些数据存储到一个表里: CHAR *paNumChars[] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}; CHAR *pszDayName = paNumChars[ucDay]; 类似哈希表特性,表驱动法适用于无需有序遍历数据,且数据量大小可提前预测的情况。 对于过于复杂和庞大的判断,可将数据存为文件,需要时加载文件初始化数组,从而在不修改程序的情况下调整里面的数值。 有时,访问之前需要先进行一次键值转换。如表驱动法表示端口忙闲时,需将槽位端口号映射为全局编号。所生成的端口数目大小的数组,其下标对应全局端口编号,元素值表示相应端口的忙闲状态。 索引查找 有时通过一次键值转换,依然无法把数据(如英文单词等)转为键值。此时可将转换的对应关系写到一个索引表里,即索引访问。 如现有100件商品,4位编号,范围从0000到9999。此时只需要申请一个长度为100的数组,且对应2位键值。但将4位的编号转换为2位的键值,可能过于复杂或没有规律,最合适的方法是建立一个保存该转换关系的索引表。采用索引访问既节省内存,又方便维护。比如索引A表示通过名称访问,索引B表示通过编号访问。 分段查找 通过确定数据所处的范围确定分类(下标)。有的数据可分成若干区间,即具有阶梯性,如分数等级。此时可将每个区间的上限(或下限)存到一个表中,将对应的值存到另一表中,通过第一个表确定所处的区段,再由区段下标在第二个表里读取相应数值。注意要留意端点,可用二分法查找,另外可考虑通过索引方法来代替。 如根据分数查绩效等级: #define MAX_GRADE_LEVEL (INT8U)5 DOUBLE aRangeLimit[MAX_GRADE_LEVEL] = {50.0, 60.0, 70.0, 80.0, 100.0}; CHAR *paGrades[MAX_GRADE_LEVEL] = {"Fail", "Pass", "Credit", "Distinction", "High Distinction"}; static CHAR* EvaluateGrade(DOUBLE dScore) { INT8U ucLevel = 0; for(; ucLevel < MAX_GRADE_LEVEL; ucLevel++) { if(dScore < aRangeLimit[ucLevel]) return paGrades[ucLevel]; } return paGrades[0]; } 上述两张表(数组)也可合并为一张表(结构体数组),如下所示: typedef struct{ DOUBLE aRangeLimit; CHAR *pszGrade; }T_GRADE_MAP; T_GRADE_MAP gGradeMap[MAX_GRADE_LEVEL] = { {50.0, "Fail"}, {60.0, "Pass"}, {70.0, "Credit"}, {80.0, "Distinction"}, {100.0, "High Distinction"} }; static CHAR* EvaluateGrade(DOUBLE dScore) { INT8U ucLevel = 0; for(; ucLevel < MAX_GRADE_LEVEL; ucLevel++) { if(dScore < gGradeMap[ucLevel].aRangeLimit) return gGradeMap[ucLevel].pszGrade; } return gGradeMap[0].pszGrade; } 该表结构已具备的数据库的雏形,并可扩展支持更为复杂的数据。其查表方式通常为索引查找,偶尔也为分段查找;当索引具有规律性(如连续整数)时,退化为直接查找。 使用分段查找法时应注意边界,将每一分段范围的上界值都考虑在内。 找出所有不在最高一级范围内的值,然后把剩下的值全部归入最高一级中。有时需要人为地为最高一级范围添加一个上界。 同时应小心不要错误地用“<”来代替“<=”。要保证循环在找出属于最高一级范围内的值后恰当地结束,同时也要保证恰当处理范围边界。 实战示例 本节多数示例取自实际项目。表形式为一维数组、二维数组和结构体数组;表内容有数据、字符串和函数指针。基于表驱动的思想,表形式和表内容可衍生出丰富的组合。 字符统计 问题:统计用户输入的一串数字中每个数字出现的次数。 普通解法主体代码如下: INT32U aDigitCharNum[10] = {0}; /* 输入字符串中各数字字符出现的次数 */ INT32U dwStrLen = strlen(szDigits); INT32U dwStrIdx = 0; for(; dwStrIdx < dwStrLen; dwStrIdx++) { switch(szDigits[dwStrIdx]) { case '1': aDigitCharNum[0]++; break; case '2': aDigitCharNum[1]++; break; //... ... case '9': aDigitCharNum[8]++; break; } } 这种解法的缺点显而易见,既不美观也不灵活。其问题关键在于未将数字字符与数组aDigitCharNum下标直接关联起来。 以下示出更简洁的实现方式: for(; dwStrIdx < dwStrLen; dwStrIdx++) { aDigitCharNum[szDigits[dwStrIdx] - '0']++; } 上述实现考虑到0也为数字字符。该解法也可扩展至统计所有ASCII可见字符。 月天校验 问题:对给定年份和月份的天数进行校验(需区分平年和闰年)。 普通解法主体代码如下: switch(OnuTime.Month) { case 1: case 3: case 5: case 7: case 8: case 10: case 12: if(OnuTime.Day>31 || OnuTime.Day<1) { CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~31)!!!\n", OnuTime.Day); retcode = S_ERROR; } break; case 2: if(((OnuTime.Year%4 == 0) && (OnuTime.Year%100 != 0)) || (OnuTime.Year%400 == 0)) { if(OnuTime.Day>29 || OnuTime.Day<1) { CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~29)!!!\n", OnuTime.Day); retcode = S_ERROR; } } else { if(OnuTime.Day>28 || OnuTime.Day<1) { CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~28)!!!\n", OnuTime.Day); retcode = S_ERROR; } } break; case 4: case 6: case 9: case 11: if(OnuTime.Day>30 || OnuTime.Day<1) { CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~30)!!!\n", OnuTime.Day); retcode = S_ERROR; } break; default: CtcOamLog(FUNCTION_Pon,"Don't support this Month: %d(1~12)!!!\n", OnuTime.Month); retcode = S_ERROR; break; } 以下示出更简洁的实现方式: #define MONTH_OF_YEAR 12 /* 一年中的月份数 */ /* 闰年:能被4整除且不能被100整除,或能被400整除 */ #define IS_LEAP_YEAR(year) ((((year) % 4 == 0) && ((year) % 100 != 0)) || ((year) % 400 == 0)) /* 平年中的各月天数,下标对应月份 */ INT8U aDayOfCommonMonth[MONTH_OF_YEAR] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; INT8U ucMaxDay = 0; if((OnuTime.Month == 2) && (IS_LEAP_YEAR(OnuTime.Year))) ucMaxDay = aDayOfCommonMonth[1] + 1; else ucMaxDay = aDayOfCommonMonth[OnuTime.Month-1]; if((OnuTime.Day < 1) || (OnuTime.Day > ucMaxDay) { CtcOamLog(FUNCTION_Pon,"Month %d doesn't have this Day: %d(1~%d)!!!\n", OnuTime.Month, OnuTime.Day, ucMaxDay); retcode = S_ERROR; } 名称构造 问题:根据WAN接口承载的业务类型(Bitmap)构造业务类型名称字符串。 普通解法主体代码如下: void Sub_SetServerType(INT8U *ServerType, INT16U wan_servertype) { if ((wan_servertype & 0x0001) == 0x0001) { strcat(ServerType, "_INTERNET"); } if ((wan_servertype & 0x0002) == 0x0002) { strcat(ServerType, "_TR069"); } if ((wan_servertype & 0x0004) == 0x0004) { strcat(ServerType, "_VOIP"); } if ((wan_servertype & 0x0008) == 0x0008) { strcat(ServerType, "_OTHER"); } } 以下示出C语言中更简洁的实现方式: /* 获取var变量第bit位,编号从右至左 */ #define GET_BIT(var, bit) (((var) >> (bit)) & 0x1) const CHAR* paSvrNames[] = {"_INTERNET", "_TR069", "_VOIP", "_OTHER"}; const INT8U ucSvrNameNum = sizeof(paSvrNames) / sizeof(paSvrNames[0]); VOID SetServerType(CHAR *pszSvrType, INT16U wSvrType) { INT8U ucIdx = 0; for(; ucIdx < ucSvrNameNum; ucIdx++) { if(1 == GET_BIT(wSvrType, ucIdx)) strcat(pszSvrType, paSvrNames[ucIdx]); } } 新的实现将数据和逻辑分离,维护起来非常方便。只要逻辑(规则)不变,则唯一可能的改动就是数据(paSvrNames)。 值名解析 问题:根据枚举变量取值输出其对应的字符串,如PORT_FE(1)输出“Fe”。 //值名映射表结构体定义,用于数值解析器 typedef struct{ INT32U dwElem; //待解析数值,通常为枚举变量 CHAR* pszName; //指向数值所对应解析名字符串的指针 }T_NAME_PARSER; /****************************************************************************** * 函数名称: NameParser * 功能说明: 数值解析器,将给定数值转换为对应的具名字符串 * 输入参数: VOID *pvMap :值名映射表数组,含T_NAME_PARSER结构体类型元素 VOID指针允许用户在保持成员数目和类型不变的前提下, 定制更有意义的结构体名和/或成员名。 INT32U dwEntryNum :值名映射表数组条目数 INT32U dwElem :待解析数值,通常为枚举变量 INT8U* pszDefName :缺省具名字符串指针,可为空 * 输出参数: NA * 返回值 : INT8U *: 数值所对应的具名字符串 当无法解析给定数值时,若pszDefName为空,则返回数值对应的16进制格式 字符串;否则返回pszDefName。 ******************************************************************************/ INT8U *NameParser(VOID *pvMap, INT32U dwEntryNum, INT32U dwElem, INT8U* pszDefName) { CHECK_SINGLE_POINTER(pvMap, "NullPoniter"); INT32U dwEntryIdx = 0; for(dwEntryIdx = 0; dwEntryIdx < dwEntryNum; dwEntryIdx++) { T_NAME_PARSER *ptNameParser = (T_NAME_PARSER *)pvMap; if(dwElem == ptNameParser->dwElem) { return ptNameParser->pszName; } //ANSI标准禁止对void指针进行算法操作;GNU标准则指定void*算法操作与char*一致。 //若考虑移植性,可将pvMap类型改为INT8U*,或定义INT8U*局部变量指向pvMap。 pvMap += sizeof(T_NAME_PARSER); } if(NULL != pszDefName) { return pszDefName; } else { static INT8U szName[12] = {0}; //Max:"0xFFFFFFFF" sprintf(szName, "0x%X", dwElem); return szName; } } 以下给出NameParser的简单应用示例: //UNI端口类型值名映射表结构体定义 typedef struct{ INT32U dwPortType; INT8U* pszPortName; }T_PORT_NAME; //UNI端口类型解析器 T_PORT_NAME gUniNameMap[] = { {1, "Fe"}, {3, "Pots"}, {99, "Vuni"} }; const INT32U UNI_NAM_MAP_NUM = (INT32U)(sizeof(gUniNameMap)/sizeof(T_PORT_NAME)); VOID NameParserTest(VOID) { INT8U ucTestIndex = 1; printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("Unknown", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 0, "Unknown")) ? "ERROR" : "OK"); printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("DefName", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 0, "DefName")) ? "ERROR" : "OK"); printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("Fe", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 1, "Unknown")) ? "ERROR" : "OK"); printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("Pots", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 3, "Unknown")) ? "ERROR" : "OK"); printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("Vuni", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 99, NULL)) ? "ERROR" : "OK"); printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("Unknown", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 255, "Unknown")) ? "ERROR" : "OK"); printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("0xABCD", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 0xABCD, NULL)) ? "ERROR" : "OK"); printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("NullPoniter", NameParser(NULL, UNI_NAM_MAP_NUM, 0xABCD, NULL)) ? "ERROR" : "OK"); } gUniNameMap在实际项目中有十余个条目,若采用逻辑链实现将非常冗长。 取值映射 问题:不同模块间同一参数枚举值取值可能有所差异,需要适配。 此处不再给出普通的switch…case或if…else if…else结构,而直接示出以下表驱动实现: typedef struct{ PORTSTATE loopMEState; PORTSTATE loopMIBState; }LOOPMAPSTRUCT; static LOOPMAPSTRUCT s_CesLoop[] = { {NO_LOOP, e_ds1_looptype_noloop}, {PAYLOAD_LOOP, e_ds1_looptype_PayloadLoop}, {LINE_LOOP, e_ds1_looptype_LineLoop}, {PON_LOOP, e_ds1_looptype_OtherLoop}, {CES_LOOP, e_ds1_looptype_InwardLoop}}; PORTSTATE ConvertLoopMEStateToMIBState(PORTSTATE vPortState) { INT32U num = 0, ii; num = ARRAY_NUM(s_CesLoop); for(ii = 0; ii < num; ii++) { if(vPortState == s_CesLoop[ii].loopMEState) return s_CesLoop[ii].loopMIBState; } return e_ds1_looptype_noloop; } 相应地,从loopMIBState映射到loopMEState需要定义一个ConvertLoopMIBStateToMEState函数。更进一步,所有类似的一对一映射关系都必须如上的映射(转换)函数,相当繁琐。事实上,从抽象层面看,该映射关系非常简单。提取共性后定义带参数宏,如下所示: /********************************************************** * 功能描述:进行二维数组映射表的一对一映射,用于参数适配 * 参数说明:map -- 二维数组映射表 elemSrc -- 映射源,即待映射的元素值 elemDest -- 映射源对应的映射结果 direction -- 映射方向字节,表示从数组哪列映射至哪列。 高4位对应映射源列,低4位对应映射结果列。 defaultVal -- 映射失败时置映射结果为缺省值 * 示例:ARRAY_MAPPER(gCesLoopMap, 3, ucLoop, 0x10, NO_LOOP); 则ucLoop = 2(LINE_LOOP) **********************************************************/ #define ARRAY_MAPPER(map, elemSrc, elemDest, direction, defaultVal) do{\ INT8U ucMapIdx = 0, ucMapNum = 0; \ ucMapNum = sizeof(map)/sizeof(map[0]); \ for(ucMapIdx = 0; ucMapIdx < ucMapNum; ucMapIdx++) \ { \ if((elemSrc) == map[ucMapIdx][((direction)&0xF0)>>4]) \ { \ elemDest = map[ucMapIdx][(direction)&0x0F]; \ break; \ } \ } \ if(ucMapIdx == ucMapNum) \ { \ elemDest = (defaultVal); \ } \ }while(0) 参数取值转换时直接调用统一的映射器宏,如下: static INT8U gCesLoopMap[][2] = { {NO_LOOP, e_ds1_looptype_noloop}, {PAYLOAD_LOOP, e_ds1_looptype_PayloadLoop}, {LINE_LOOP, e_ds1_looptype_LineLoop}, {PON_LOOP, e_ds1_looptype_OtherLoop}, {CES_LOOP, e_ds1_looptype_InwardLoop}}; ARRAY_MAPPER(gCesLoopMap, tPara.dwParaVal[0], dwLoopConf, 0x01, e_ds1_looptype_noloop); 另举一例: #define CES_DEFAULT_JITTERBUF (INT32U)2000 /* 默认jitterbuf为2000us,而1帧=125us */ #define CES_JITTERBUF_STEP (INT32U)125 /* jitterbuf步长为125us,即1帧 */ #define CES_DEFAULT_QUEUESIZE (INT32U)5 #define CES_DEFAULT_MAX_QUEUESIZE (INT32U)7 #define ARRAY_NUM(array) (sizeof(array) / sizeof((array)[0])) /* 数组元素个数 */ typedef struct{ INT32U dwJitterBuffer; INT32U dwFramePerPkt; INT32U dwQueueSize; }QUEUE_SIZE_MAP; /* gCesQueueSizeMap也可以(JitterBuffer / FramePerPkt)值为索引,更加紧凑 */ static QUEUE_SIZE_MAP gCesQueueSizeMap[]= { {1,1,1}, {1,2,1}, {2,1,2}, {2,2,1}, {3,1,3}, {3,2,1}, {4,1,3}, {4,2,1}, {5,1,4}, {5,2,3}, {6,1,4}, {6,2,3}, {7,1,4}, {7,2,3}, {8,1,4}, {8,2,3}, {9,1,5}, {9,2,4}, {10,1,5}, {10,2,4}, {11,1,5}, {11,2,4}, {12,1,5}, {12,2,4}, {13,1,5}, {13,2,4}, {14,1,5}, {14,2,4}, {15,1,5}, {15,2,4}, {16,1,5}, {16,2,4}, {17,1,6}, {17,2,5}, {18,1,6}, {18,2,5}, {19,1,6}, {19,2,5}, {20,1,6}, {20,2,5}, {21,1,6}, {21,2,5}, {22,1,6}, {22,2,5}, {23,1,6}, {23,2,5}, {24,1,6}, {24,2,5}, {25,1,6}, {25,2,5}, {26,1,6}, {26,2,5}, {27,1,6}, {27,2,5}, {28,1,6}, {28,2,5}, {29,1,6}, {29,2,5}, {30,1,6}, {30,2,5}, {31,1,6}, {31,2,5}, {32,1,6}, {32,2,5}}; /********************************************************** * 函数名称:CalcQueueSize * 功能描述:根据JitterBuffer和FramePerPkt计算QueueSize * 注意事项:配置的最大缓存深度 * = 2 * JitterBuffer / FramePerPkt * = 2 * N Packet = 2 ^ QueueSize * JitterBuffer为125us帧速率的倍数, * FramePerPkt为每个分组的帧数, * QueueSize向上取整,最大为7。 **********************************************************/ INT32U CalcQueueSize(INT32U dwJitterBuffer, INT32U dwFramePerPkt) { INT8U ucIdx = 0, ucNum = 0; //本函数暂时仅考虑E1 ucNum = ARRAY_NUM(gCesQueueSizeMap); for(ucIdx = 0; ucIdx < ucNum; ucIdx++) { if((dwJitterBuffer == gCesQueueSizeMap[ucIdx].dwJitterBuffer) && (dwFramePerPkt == gCesQueueSizeMap[ucIdx].dwFramePerPkt)) { return gCesQueueSizeMap[ucIdx].dwQueueSize; } } return CES_DEFAULT_MAX_QUEUESIZE; } 版本控制 问题:控制OLT与ONU之间的版本协商。ONU本地设置三比特控制字,其中bit2(MSB)~bit0(LSB)分别对应0x21、0x30和0xAA版本号;且bitX为0表示上报对应版本号,bitX为1表示不上报对应版本号。其他版本号如0x20、0x13和0x1必须上报,即不受控制。 最初的实现采用if…else if…else结构,代码非常冗长,如下: pstSendTlv->ucLength = 0x1f; if (gOamCtrlCode == 0) { vosMemCpy(pstSendTlv->aucVersionList, ctc_oui, 3); pstSendTlv->aucVersionList[3] = 0x30; vosMemCpy(&(pstSendTlv->aucVersionList[4]), ctc_oui, 3); pstSendTlv->aucVersionList[7] = 0x21; vosMemCpy(&(pstSendTlv->aucVersionList[8]), ctc_oui, 3); pstSendTlv->aucVersionList[11] = 0x20; vosMemCpy(&(pstSendTlv->aucVersionList[12]), ctc_oui, 3); pstSendTlv->aucVersionList[15] = 0x13; vosMemCpy(&(pstSendTlv->aucVersionList[16]), ctc_oui, 3); pstSendTlv->aucVersionList[19] = 0x01; vosMemCpy(&(pstSendTlv->aucVersionList[20]), ctc_oui, 3); pstSendTlv->aucVersionList[23] = 0xaa; } else if (gOamCtrlCode == 1) { vosMemCpy(pstSendTlv->aucVersionList, ctc_oui, 3); pstSendTlv->aucVersionList[3] = 0x30; vosMemCpy(&(pstSendTlv->aucVersionList[4]), ctc_oui, 3); pstSendTlv->aucVersionList[7] = 0x21; vosMemCpy(&(pstSendTlv->aucVersionList[8]), ctc_oui, 3); pstSendTlv->aucVersionList[11] = 0x20; vosMemCpy(&(pstSendTlv->aucVersionList[12]), ctc_oui, 3); pstSendTlv->aucVersionList[15] = 0x13; vosMemCpy(&(pstSendTlv->aucVersionList[16]), ctc_oui, 3); pstSendTlv->aucVersionList[19] = 0x01; } //此处省略gOamCtrlCode == 2~6的处理代码 else if (gOamCtrlCode == 7) { vosMemCpy(&(pstSendTlv->aucVersionList), ctc_oui, 3); pstSendTlv->aucVersionList[3] = 0x20; vosMemCpy(&(pstSendTlv->aucVersionList[4]), ctc_oui, 3); pstSendTlv->aucVersionList[7] = 0x13; vosMemCpy(&(pstSendTlv->aucVersionList[8]), ctc_oui, 3); pstSendTlv->aucVersionList[11] = 0x01; } 以下示出C语言中更简洁的实现方式(基于二维数组): /********************************************************************** * 版本控制字数组定义 * gOamCtrlCode: Bitmap控制字。Bit-X为0时上报对应版本,Bit-X为1时屏蔽对应版本。 * CTRL_VERS_NUM: 可控版本个数。 * CTRL_CODE_NUM: 控制字个数。与CTRL_VERS_NUM有关。 * gOamVerCtrlMap: 版本控制字数组。行对应控制字,列对应可控版本。 元素值为0时不上报对应版本,元素值非0时上报该元素值。 * Note: 该数组旨在实现“数据与控制隔离”。后续若要新增可控版本,只需修改 -- CTRL_VERS_NUM -- gOamVerCtrlMap新增行(控制字) -- gOamVerCtrlMap新增列(可控版本) **********************************************************************/ #define CTRL_VERS_NUM 3 #define CTRL_CODE_NUM (1< u8_t gOamVerCtrlMap[CTRL_CODE_NUM][CTRL_VERS_NUM]
背景: 我们学习Linux的系统调用经常会遇到一个概念:“内核态和用户态的切换”,一般人只会告诉你说这个切换代价很大,具体是什么情况?为什么需要切换?一定需要切换吗?怎么就会触发切换?内核怎么知道要切换了?内核态是一个程序吗?是谁来切换的,CPU吗?等等具体细节很多人都不知道。我花时间整理了这些问题的答案,希望能帮到更多人。 要理解以上问题,需要有计算机的基础知识,计算机的本质是开关门三极管控制的高低电平切换,这些高低电平就是二进制的0、1。本质上也是数据的计算和保存。寄存器、缓存器、运存、内存等是数据,CPU负责计算转换这些数据。外设负责显示和传输数据,使用的“人”可以看到这些数据。 现在的操作系统其实就是把以前的“汇编版单程序”做了封装,本质还没变,一个CPU同时只能运行一个一个程序,不过是速度快了,让程序之间来回切换,让我们人感知不到任何切换感和卡顿感(电脑的卡顿不是CPU卡顿是程序的阻塞)。基于这一点:内核就是一个个程序,一上电内核肯定早于用户程序运行在CPU中。内核调度用户程序,把用户程序的上下文加载到CPU 并让出CPU,让出前定好定时器(硬件定时器--可以打断CPU运行),让用户程序时间片到了后可以切换回内核程序。 问题1:linux 系统调用 read/write这种 执行的时候 用户态和内核态的切换过程怎样,有什么缺点? 在 Linux 系统中,read 和 write 系统调用是典型的用户态和内核态之间的切换过程。下面是它们执行时的用户态和内核态切换过程以及潜在的缺点: 1. 执行过程 用户态到内核态切换: 用户程序调用 read() 或 write() 时,首先是从用户态发起系统调用。 由于 read 和 write 是系统调用,它们会触发一个软件中断或调用 syscall 指令,这将导致 CPU 从用户态切换到内核态。 内核在内核态中执行实际的 I/O 操作(例如,磁盘读写、网络操作等)。内核会访问设备驱动程序,可能会通过设备管理层与硬件进行交互。 内核态到用户态切换: 当 I/O 操作完成后,内核会将结果(例如读取的数据,写入操作的字节数)返回给用户程序。 结果返回后,系统会通过中断或返回指令将 CPU 状态从内核态切换回用户态,允许用户程序继续执行。 2. 内核态和用户态切换的缺点 内核态和用户态的切换是开销较大的操作,尤其是在频繁调用 read 和 write 系统调用时。具体的缺点包括: 1. 上下文切换的开销: 每次从用户态切换到内核态(或者反过来),都会发生上下文切换。上下文切换不仅涉及保存和恢复 CPU 寄存器的状态,还包括更新内存中的相关数据结构(如进程控制块),这些操作会带来额外的性能开销。 如果 read 或 write 调用是同步的(阻塞模式),程序会等待 I/O 完成,这时 CPU 可能空闲,浪费了大量的时间。 2. I/O 阻塞与同步: 在传统的阻塞 I/O 模式下,read 和 write 系统调用可能会使调用线程处于阻塞状态,直到 I/O 操作完成。这种情况下的上下文切换会导致 CPU 浪费时间等待 I/O 完成。 如果应用程序需要频繁进行阻塞 I/O 操作(例如,每秒钟执行大量的 read/write 系统调用),会导致频繁的用户态和内核态切换,从而增加系统开销。 3. 缓存一致性和内存访问: read 和 write 系统调用有时会涉及大量的数据拷贝。例如,read 操作可能会将数据从内核空间拷贝到用户空间,这需要额外的内存访问,尤其在大量数据传输的情况下,这种开销非常显著。 现代操作系统通常使用缓冲区或零拷贝技术(如 mmap()、sendfile() 等)来减少这种开销,但仍然需要避免频繁的内核态与用户态之间的数据拷贝。 4. 线程切换: 在多线程环境中,频繁调用 read 和 write 可能会导致频繁的线程切换(尤其是在多个线程共享同一文件描述符时),增加了额外的调度开销。 3. 优化方法 为了减少用户态和内核态切换带来的性能损耗,可以采取以下几种优化方法: 异步 I/O(AIO):通过异步 I/O,应用程序不需要在 read 或 write 调用时阻塞线程,内核会在 I/O 操作完成时通知应用程序,减少了上下文切换的次数。 零拷贝 I/O(Zero-copy I/O):通过减少内核与用户空间之间的数据拷贝来提高性能,例如使用 mmap() 或 sendfile()。 非阻塞 I/O:通过设置文件描述符为非阻塞模式,避免阻塞等待,结合 select() 或 poll() 等 I/O 多路复用技术,提高并发性能。 总结来说,read 和 write 系统调用的用户态和内核态切换过程在多次调用时可能带来显著的性能开销,尤其是上下文切换、阻塞等待和数据拷贝等问题。通过使用异步 I/O、零拷贝技术等优化方法,可以有效减少这些开销。 问题2:fread/fwrite/fopen/fclose/getchar/gets/puts/putchar是系统调用吗,异步IO怎么使用? 1. **fread/fwrite/fopen/fclose/getchar/gets/puts/putchar 是否是系统调用? 这些函数是 C 标准库提供的高级 I/O 函数,它们并不是直接的系统调用,而是通过系统调用实现的。系统调用是内核提供的接口,用于用户程序与操作系统之间的交互,而这些库函数是建立在操作系统提供的系统调用之上的,它们负责提供更高层次的功能,如缓冲、格式化等。 具体来说: fread/fwrite/fopen/fclose:这些是标准 C 库提供的文件 I/O 操作函数,通常会通过 read 和 write 系统调用来实际进行文件操作。 fopen 会调用系统调用(如 open)来打开文件。 fread 和 fwrite 在执行数据读取或写入时,最终会调用系统调用(如 read 和 write),并且会使用缓冲区进行缓存管理。 fclose 会关闭文件,并释放与文件相关的资源,通常会调用系统调用(如 close)。 getchar/puts/putchar: getchar 是标准输入函数,会调用 read 系统调用读取数据。 putchar 和 puts 是输出函数,底层通过 write 系统调用将字符输出到标准输出。 总结: 这些 C 库函数本身并不是系统调用,但它们内部通过对系统调用(如 read, write, open, close 等)的调用来实现实际的 I/O 操作。它们提供了额外的功能,如缓冲区管理、格式化输入输出等。 2. 异步 I/O (AIO) 怎么使用? 异步 I/O(AIO)允许程序在发起 I/O 操作后,不需要阻塞等待操作完成,而是可以继续执行其他任务,直到 I/O 操作完成并通知程序。这对于提高性能、避免线程阻塞非常有效,尤其在高并发的场景下。 在 Linux 上,异步 I/O 主要通过以下几种方式实现: 1. aio_read 和 aio_write: 这些是 POSIX 标准定义的异步 I/O 系统调用。与传统的 read 和 write 系统调用不同,aio_read 和 aio_write 不会阻塞进程,调用者可以在等待 I/O 操作完成时继续执行其他代码。 示例: #include #include #include #include #include #include #include int main() { struct aiocb cb; // 异步 I/O 控制块 int fd = open("example.txt", O_RDONLY); if (fd == -1) { perror("open"); return -1; } // 设置异步读取的参数 memset(&cb, 0, sizeof(struct aiocb)); cb.aio_fildes = fd; cb.aio_buf = malloc(1024); cb.aio_nbytes = 1024; cb.aio_offset = 0; // 发起异步读取 if (aio_read(&cb) == -1) { perror("aio_read"); close(fd); return -1; } // 检查 I/O 操作是否完成 while (aio_error(&cb) == EINPROGRESS) { // 可以继续执行其他操作 printf("I/O in progress...\n"); sleep(1); } // 获取操作结果 int ret = aio_return(&cb); if (ret == -1) { perror("aio_return"); close(fd); return -1; } printf("Read %d bytes: %s\n", ret, (char*)cb.aio_buf); free((void*)cb.aio_buf); close(fd); return 0;} 关键步骤: aio_read 发起异步读操作,aio_write 用于异步写操作。 aio_error 用于检查 I/O 操作是否仍在进行中。 aio_return 用于获取操作结果,如果操作已完成。 2. epoll 和 select: epoll 和 select 是 I/O 多路复用机制,允许一个线程同时监控多个文件描述符的事件,例如可读、可写等。它们本身不是完全异步 I/O,但能够以非阻塞方式进行 I/O 操作,帮助高效地管理多个并发连接。 示例(使用epoll监控文件描述符的可读事件): #include #include #include #include #include int main() { int epoll_fd = epoll_create1(0); if (epoll_fd == -1) { perror("epoll_create1"); exit(EXIT_FAILURE); } int fd = open("example.txt", O_RDONLY | O_NONBLOCK); if (fd == -1) { perror("open"); exit(EXIT_FAILURE); } struct epoll_event event; event.events = EPOLLIN; // 监控可读事件 event.data.fd = fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event) == -1) { perror("epoll_ctl"); exit(EXIT_FAILURE); } struct epoll_event events[10]; int nfds = epoll_wait(epoll_fd, events, 10, -1); if (nfds == -1) { perror("epoll_wait"); exit(EXIT_FAILURE); } for (int i = 0; i < nfds; i++) { if (events[i].events & EPOLLIN) { // 处理可读事件 char buffer[1024]; int bytes_read = read(events[i].data.fd, buffer, sizeof(buffer)); if (bytes_read > 0) { printf("Read %d bytes: %s\n", bytes_read, buffer); } else { printf("End of file or error\n"); } } } close(fd); close(epoll_fd); return 0;} 3. 使用 mmap 和 msync 进行内存映射 I/O: 内存映射 I/O 是一种通过内存操作文件的方式,这种方法可以实现高效的异步 I/O,尤其在处理大文件时可以提高性能。 总结: 异步 I/O 可以通过使用 aio_read/aio_write 等接口来实现,它们允许在 I/O 操作未完成时不阻塞当前线程。 epoll/select 提供了一种更灵活的方式来处理多个并发 I/O 操作,虽然它们并不直接提供异步 I/O,但通过非阻塞 I/O 和事件通知机制,也能达到类似的效果。 问题3:既然系统调用引发了上下文切换,那么其实就是进程间的切换,是用户进程和内核进程间的切换吗,内核进程是每次系统调用时才开始运行 还是一直在运行,对应哪个程序? 系统调用确实会导致上下文切换,但它并不是指进程间的完全切换。系统调用引发的上下文切换主要是用户进程和内核之间的切换,而不是两个不同的用户进程或两个内核进程之间的切换。 1. 用户进程与内核进程的切换 用户进程 在用户态运行,它在执行过程中可以发起 系统调用(如 read、write、open 等),此时发生 上下文切换,CPU 会从用户态切换到内核态。 内核态 由操作系统内核代码控制,内核执行的部分是操作系统的一部分,负责管理硬件资源、内存管理、I/O 操作等。内核态下执行的是内核代码,这时会执行具体的系统调用逻辑,如硬件驱动、内存管理等。 内核进程并不代表是一个普通的进程,它是由操作系统内核管理的,通常不依赖于特定的程序。内核会执行很多任务,并且可以同时管理多个用户进程。内核中有一些特定的执行上下文(如中断处理、系统调用的执行),这些都属于内核层面的“活动”。 2. 内核进程和内核态执行的区别 内核进程:通常是指内核中执行的那些特定任务(如调度、进程管理、网络处理等),这些任务由操作系统内核自己管理。 内核态执行:是指当前正在执行的内核代码,无论是否有内核进程在运行。实际上,内核并不像用户进程那样“常驻”或“独立运行”。当用户进程发起系统调用时,内核会执行对应的内核代码(如 I/O 操作),完成后再返回用户进程。 3. 系统调用时内核的执行 内核态的执行:内核并不是每次系统调用时才开始执行,它始终在内存中并处于休眠或等待状态。当用户进程触发系统调用时,CPU 切换到内核态,执行与该系统调用相关的内核代码(例如磁盘 I/O、网络操作、内存分配等)。系统调用结束后,CPU 再切换回用户态,继续执行用户进程。 在内核态,操作系统会管理多种资源,如中断、设备驱动程序、内存管理等,它通过各种机制(如中断、定时器、系统调用等)来调度内核态代码的执行。 例子:当用户程序发起read()系统调用时,内核需要通过磁盘 I/O 或网络设备获取数据,并把数据从内核空间传递到用户空间。内核执行这些操作时并不依赖于某个单独的“内核进程”,而是由内核中相应的代码块来完成。 4. 内核进程不是每次系统调用才执行 内核进程通常是指内核内部用于特定任务的程序(如内核线程)。但是,在 Linux 等操作系统中,内核是一个整体的操作系统内核,它会管理和调度所有进程(包括用户进程和内核线程)。内核进程并不是系统调用时才运行,而是内核代码在响应系统调用时会启动内核的相应部分。 例如,操作系统内核会创建和管理各种“内核线程”(如 kworker,ksoftirqd 等),这些线程用于执行后台任务,如处理硬件中断、执行调度等。这些内核线程在系统启动时就会存在,并且会一直运行,独立于用户进程。 5. 进程的执行与内核的关系 用户进程:通常会在用户态执行,除非它发起系统调用或者发生中断等需要内核介入的事件。在系统调用时,操作系统的内核代码会被调度执行。 内核进程:并不是像用户进程一样是由用户创建的。内核本身有一些“内核线程”,它们在内核模式下执行,通常由操作系统自动创建和管理。它们执行特定任务(如硬件中断处理、网络协议栈、内存管理等)。 6. 上下文切换的两种类型 用户态到内核态的切换:例如当用户进程发起系统调用时,操作系统会从用户进程切换到内核空间。 内核态到用户态的切换:当系统调用完成时,操作系统会将执行从内核空间切换回用户空间,继续执行用户进程。 上下文切换在这两种情况下都可能发生,但 内核进程并不代表是特定的独立程序,它只是内核的一部分,始终处于内核模式下,并且会响应系统调用、硬件中断等事件。 总结: 系统调用引发的上下文切换,是用户进程从用户态切换到内核态,执行内核代码。并不是进程间的切换,而是用户态和内核态之间的切换。 内核进程 并不是每次系统调用时才开始运行,内核始终在内存中,可以理解为操作系统的一部分,负责管理硬件、内存、进程调度等任务。 内核本身并不像用户进程一样是一个独立的程序,而是操作系统的一部分,内核的不同部分(如内核线程)始终在内核态下执行。 问题4:不管是内核还是用户程序 对于CPU来说就是 一段待执行的指令合集,不过是操作系统内核的指令集很大,正常处于休眠状态,等待中断来触发执行。本质上来说,用户程序是系统调用的,本质上来说也是运行的负责调度的内核程序,这个内核程序负责读取和切换待调度的每个程序的上下文。是这样吗? 1. 用户程序和内核程序的关系 用户程序:是由用户编写的应用程序,运行在 用户态。它通过系统调用与操作系统进行交互,例如使用 read 或 write 执行 I/O 操作、通过 fork 创建子进程等。当用户程序发起系统调用时,会触发用户态到内核态的上下文切换,此时操作系统内核开始执行对应的内核代码(如进程调度、文件系统操作、内存管理等)。 内核程序(内核态代码):是操作系统的核心,运行在 内核态。它负责管理硬件资源、调度进程、提供系统调用接口、处理中断等。内核代码并不是单独的“程序”,而是操作系统的一部分,它的职责是响应系统调用、管理系统资源并调度用户进程。 你说得对,内核负责调度进程并在需要时切换它们的上下文。 2. 内核程序的执行: 是操作系统内核代码的执行 内核 是一个 大指令集,包含很多功能,例如管理 CPU 调度、内存管理、I/O 操作、硬件中断处理等。它通常处于“休眠”状态,即它的代码并不会主动执行,只有在某些事件发生时(例如,系统调用、硬件中断、进程调度等),内核才会被唤醒并开始执行相应的任务。 你可以把内核想象成一个负责协调、管理和调度硬件资源的程序。当 用户程序 发起一个 系统调用 时,内核会响应这个调用,切换到内核态,执行内核代码,执行完毕后再切换回用户态。 3. 上下文切换: 上下文切换 是指 CPU 从一个执行上下文切换到另一个执行上下文。上下文包括程序计数器(PC)、寄存器值、栈指针等信息。每次从 用户程序切换到内核程序(或反之)时,都会进行上下文切换。 内核负责调度进程,并根据各种条件(如时间片、I/O 等待)决定哪个进程应该运行。内核会保存当前进程的上下文,并恢复下一个待执行进程的上下文。这个过程叫做 进程调度,它是操作系统管理多个程序并合理分配 CPU 时间的关键。 内核在执行过程中,会根据不同的事件(比如硬件中断、系统调用、时间片耗尽等)来触发上下文切换。比如,如果一个进程执行了 read 系统调用,CPU 会切换到内核态,由内核代码处理 I/O 操作,处理完毕后再切换回用户进程。 4. 用户程序的调度 用户程序 的调度并不是由用户控制的,而是由操作系统内核负责。内核通过调度算法(如时间片轮转、优先级调度等)来决定哪个进程可以占用 CPU 执行。每当进程的时间片用完,或者进程进行 I/O 等待,或者发生某些调度事件时,内核会触发上下文切换。 所以,你可以理解为 用户程序本质上是由内核调度的。虽然用户程序在用户态下运行,但它的执行是受操作系统内核控制的。 5. 内核程序的角色: 内核程序的职责不仅仅是“调度”用户程序,还包括其他很多核心任务: 硬件管理:内核通过驱动程序与硬件交互,管理 I/O 设备、网络设备等。 内存管理:管理物理内存和虚拟内存,进行地址映射、分页等操作。 进程调度:决定哪个进程应该运行,并在多个进程之间切换。 文件系统管理:管理文件系统和磁盘存储。 中断处理:响应硬件中断,处理外设请求。 6. 总结 用户程序 是由用户编写的程序,运行在 用户态,通过系统调用与操作系统进行交互。 内核程序 是操作系统的一部分,运行在 内核态,负责管理硬件资源、调度进程、提供系统调用接口等。内核并不是“单独的程序”,而是操作系统的核心部分。 操作系统内核的主要任务 包括调度用户进程、处理中断、提供系统调用等,它负责在多进程环境中协调多个程序的执行,并确保它们能有效利用 CPU 和其他资源。 内核通过 上下文切换 进行进程调度,决定哪个进程能够占用 CPU 执行,并在多个进程之间进行切换。 问题5:那用户程序时间片到了是怎么让出CPU的呢(此时内核程序并未在运行,因为CPU一次只能运行一个程序),难道内核程序执行了一个定时中断,来切换用户程序? 是的,用户程序时间片到了时,操作系统会通过 定时中断(Timer Interrupt) 来触发 内核程序 的执行,从而进行 进程调度。具体来说,CPU 在执行用户程序时,操作系统内核并没有持续运行,而是通过中断机制来控制进程的切换。 1. 时间片与定时器中断 每个进程在操作系统中都会分配一个固定的 时间片(Time Slice),这是它在 CPU 上连续执行的时间。当时间片耗尽时,操作系统需要 暂停 当前进程的执行,并将 CPU 切换到另一个进程。 关键步骤: 定时器中断:操作系统会使用硬件定时器(通常是 PIT,可编程间隔定时器)来生成定时中断。定时器中断是一个周期性事件,它在设定的时间间隔内触发一次。例如,操作系统可能每 10 毫秒或 1 毫秒触发一次定时器中断。 定时器中断触发:当定时器中断触发时,CPU 会 暂停当前执行的指令,并跳转到内核代码来处理中断。这个过程涉及 上下文切换,操作系统内核会保存当前用户程序的状态(即上下文),并恢复操作系统的中断处理程序。 进程调度:内核的调度程序会检查当前运行的进程是否已经消耗完时间片。如果时间片已用完,调度程序会选择另一个进程来运行。此时,内核会将当前进程的状态保存在其进程控制块(PCB)中,然后选择下一个进程并恢复它的上下文,切换到该进程。 恢复进程:当调度程序完成切换后,CPU 会从内核态切换回用户态,恢复到新选定进程的执行。 2. 操作系统如何使用定时中断进行调度 操作系统的调度是通过响应定时器中断来进行的。在这个过程中,操作系统内核负责: 在中断发生时 保存当前进程的状态(即上下文),包括程序计数器、寄存器等。 调度 下一个进程并恢复其上下文。 让 CPU 切换到新的进程。 这种机制确保了操作系统可以 公平地分配 CPU 时间 给多个进程,使得每个进程都有机会在 CPU 上运行,并且防止某个进程长期占用 CPU。 3. 上下文切换的具体过程 定时中断触发时,CPU 会进入 内核态,并执行操作系统的 中断处理程序。操作系统会检查是否需要切换进程。 具体的处理步骤: 保存当前进程的上下文:操作系统保存当前进程的寄存器、程序计数器等信息,以便在该进程再次运行时能够恢复它的执行状态。 更新进程调度信息:操作系统会标记当前进程为“已用完时间片”或“等待 I/O”等状态,并选择下一个进程来运行。调度器根据调度算法(如时间片轮转、优先级调度等)选择下一个候选进程。 恢复新进程的上下文:操作系统恢复新选定进程的上下文,恢复其寄存器、程序计数器等,使得新的进程能够继续执行。 返回用户态:CPU 从内核态切换回用户态,继续执行新选定进程。 4. 定时器中断是如何控制时间片的 定时器中断通常由 硬件定时器(如 PIT)生成,操作系统会设置一个时间片的长度,假设时间片为 10 毫秒,那么操作系统每 10 毫秒就会通过定时器中断检查是否需要切换进程。 在每次定时器中断发生时,操作系统内核会检查当前进程是否已经用完了它的时间片。如果用完了,内核会选择另一个进程并进行上下文切换。 5. 举个例子 假设操作系统使用的是 时间片轮转(Round-Robin)调度算法,并且每个进程的时间片为 10 毫秒: 1.进程 A 被调度执行,开始运行。 2.10 毫秒后,定时器中断触发。 内核从用户态切换到内核态,处理定时器中断。 检查进程 A 是否已用完时间片,发现时间片已用完。 保存进程 A 的上下文。 选择下一个进程(比如进程 B)。 恢复进程 B 的上下文。 3.进程 B 开始执行,运行 10 毫秒后再次触发定时器中断。 4.继续这个过程,操作系统通过定时器中断来实现进程的轮换执行。 6. 总结 时间片到时,操作系统会通过定时器中断来 切换进程。定时器中断是由硬件定时器定期触发的,操作系统内核利用定时器中断来响应并进行进程调度。 内核程序 在定时器中断触发时被执行,它负责检查当前进程是否用完时间片,并调度下一个进程运行。 通过这种机制,操作系统能够公平地分配 CPU 时间给多个进程,确保系统的 响应性 和 并发性。 问题6:top命令能看到内核的程序吗? top 命令显示的是当前系统中 用户进程 和 内核线程 的运行状态,但它并不直接显示“内核程序”本身,因为内核本身并不是一个用户进程,而是操作系统的一部分,通常运行在 内核态。 1. top 命令显示的内容 top 命令的输出显示的是系统中 所有进程 的资源使用情况(如 CPU、内存、I/O 等),并且默认情况下列出的是 用户进程。这些进程的状态和资源占用可以帮助你了解系统的负载情况。 对于 内核线程,top 命令也会显示它们的状态,尤其是在 Linux 2.6 及其之后的版本,内核线程会被列出作为进程的一部分。内核线程是内核空间中的执行单元,它们负责执行系统的内部任务,如调度、硬件管理、I/O 操作等。 2. 内核线程的显示 在 top 命令的输出中,内核线程 会以特定的名称显示,这些线程通常以 k 开头(如 kworker、ksoftirqd、kthreadd 等)。 这些内核线程的名字表明它们是由内核创建并在内核态执行的。例如: kworker 线程负责处理内核的工作队列任务。 ksoftirqd 线程用于软中断的处理。 kthreadd 线程是内核线程的创建者,负责启动和管理其他内核线程。 你可以通过 top 的 -H 选项查看线程信息(包括内核线程和用户线程): top -H 这将显示每个进程的 线程,其中包括内核线程和用户线程。 3. 如何识别内核线程 在 top 命令的输出中,内核线程 和 用户进程 是区分开的,主要通过 进程的 UID 和 进程名称 来区分: 内核线程通常会显示为 root 用户(因为大多数内核线程是由 root 权限启动的),且它们的 PID 一般较小。 内核线程的名称通常以 k 开头,例如 kworker, ksoftirqd, kthreadd 等。 4. 查看内核进程 虽然 top 显示了内核线程,但你并不能通过 top 查看完整的 内核程序。因为 内核程序 本身是操作系统的一部分,它并不像用户进程那样是一个独立的程序。内核的执行是在操作系统启动时加载的,并且与用户空间的程序是分离的。操作系统内核代码总是处于内存中,并且由 CPU 在不同的时间片和事件中执行。 5. 示例输出 下面是一个 top 命令输出的例子,包含了内核线程和用户进程的区别: top - 10:21:09 up 1 day, 1:02, 2 users, load average: 0.02, 0.01, 0.00Tasks: 146 total, 1 running, 145 sleeping, 0 stopped, 0 zombie%Cpu(s): 2.0 us, 1.0 sy, 0.0 ni, 97.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 stMiB Mem : 4000.0 total, 900.0 free, 1500.0 used, 1600.0 buff/cacheMiB Swap: 512.0 total, 400.0 free, 112.0 used. 800.0 avail Mem PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 123 root 20 0 8200 1200 1000 S 1.0 0.0 0:01.32 kworker/0:1H 124 root 20 0 8200 1200 1000 S 0.9 0.0 0:01.30 kworker/1:2 125 root 20 0 8200 1200 1000 S 0.8 0.0 0:01.40 ksoftirqd/0 1001 user1 20 0 212304 14288 1044 S 0.5 0.4 0:00.72 firefox 1002 user2 20 0 145236 8544 2052 S 0.3 0.2 0:00.60 gnome-shell 在这个输出中,进程如 kworker/0:1H 和 ksoftirqd/0 是 内核线程,它们在内核空间执行,负责系统的底层任务。 firefox 和 gnome-shell 是 用户进程,它们在用户空间执行,并显示为其他用户的程序。 6. 内核的管理 内核本身不作为单独的进程出现在 top 命令中,因为它并不是一个“用户可见”的进程。内核代码始终在内存中并执行,但它的管理不依赖于用户进程。 内核线程 负责处理系统的底层任务,如硬件管理、任务调度、内存管理、I/O 操作等。它们是由内核启动的,并且通常在 top 中显示为 root 用户,前缀为 k。 7. 总结 top 命令显示了内核线程和用户进程,它们的区别通常在于进程名称(内核线程以 k 开头)和用户 ID(通常是 root 用户)。 内核程序 本身并不作为独立的进程出现,它始终在操作系统内核中执行。top 命令能够显示 内核线程,但并不直接显示“内核程序”这一概念。 问题7:所以内核驱动加载 只是做了注册,不是直接运行,任何驱动程序都有触发条件,否则就是没用的驱动。这个触发条件可以是用户的系统调用,可以是定时中断、硬件中断等? 1. 驱动程序的加载与注册 驱动程序加载:当一个内核驱动被加载时,操作系统会将其编译为内核模块(在 Linux 中通常是 .ko 文件)。这个模块可以在系统启动时自动加载,也可以在运行时由用户手动加载(例如通过 modprobe 或 insmod 命令)。 注册过程:加载的驱动程序需要通过注册来向内核报告它将管理某些硬件或提供某些服务。例如: 字符设备驱动 会通过 register_chrdev 函数注册一个字符设备。 块设备驱动 会通过 register_blkdev 注册块设备。 网络驱动 会通过 register_netdev 注册网络设备。 注册的过程确保内核知道驱动程序的存在,并为它分配合适的资源。 2. 驱动程序的触发条件 驱动程序并不会“持续”运行,而是会根据特定的 事件或触发条件 来执行。驱动程序的执行通常是由内核和硬件事件引起的,以下是几种常见的触发条件: 2.1 用户空间的系统调用 有些驱动程序是通过用户程序发起的 系统调用 来触发的。用户程序可以通过系统调用与内核空间的驱动程序进行交互: 例如,一个字符设备驱动可能等待用户空间程序通过 open(), read(), write() 等系统调用来访问设备。用户程序的这些操作会触发内核中对应驱动的相关函数,进而执行驱动程序的操作。 2.2 硬件中断 许多硬件设备(如网卡、磁盘、USB 设备等)需要在某些事件发生时通知内核,例如数据传输完成、设备状态变化等。硬件中断是一种常见的触发条件: 硬件中断触发:硬件设备会发出中断信号,通知 CPU 该设备需要处理。内核的中断处理程序会触发相应设备驱动程序中的 中断处理函数 来处理中断事件。 例如,网卡驱动在接收到数据包时会触发一个硬件中断,内核会调用网卡驱动中的 中断处理程序,并处理网络数据。 2.3 定时中断 内核中的某些驱动程序可能依赖于 定时中断 来执行周期性任务。这类驱动程序通常是负责一些 定时操作 的,如定时刷盘(写入数据到磁盘)或周期性地检查设备状态: 例如,某些设备的驱动程序可能需要定期轮询设备状态,这种操作会通过内核定时器触发。 2.4 内核事件和工作队列 有些驱动程序会将任务放入 内核的工作队列 中,这些任务会在适当的时候由内核线程执行。工作队列可以由多种事件触发: 内核工作队列:内核使用工作队列(如 kworker 线程)来处理异步任务。例如,磁盘驱动可以将数据写入硬盘的任务放入工作队列中,等待 kworker 线程异步执行。 事件触发:内核会基于某些条件(如 I/O 完成、设备状态变化)来触发工作队列的执行。 2.5 内存或缓冲区管理 有些驱动程序依赖于 内存管理系统,比如内存的分配或释放、缓冲区的满载等。内核的内存管理子系统会触发驱动程序的某些操作: 例如,网络设备的驱动程序会等待内存缓冲区的空闲,当数据包需要发送时,内核会分配内存,并触发驱动程序的相关操作。 3. 驱动程序的生命周期 加载:驱动程序在内核启动时或通过命令行加载。驱动的注册使得内核能够识别并管理设备。 等待事件:驱动程序在注册之后会等待某些事件的发生。这些事件可以是来自用户空间的请求、硬件中断、定时器中断等。 响应事件:当触发条件满足时,驱动程序会执行相应的操作。例如,用户通过 read() 请求数据,驱动程序会读取硬件设备的数据并返回给用户空间;或者硬件中断发生,驱动程序会执行中断处理程序。 卸载:当驱动程序不再需要时(例如设备被移除,或不再使用),它会通过卸载过程从内核中注销并释放相关资源。 4. 总结 驱动程序的加载 只是一个注册过程,它将设备与驱动程序关联,并通知内核可以处理该设备的操作。 驱动程序 并不会持续运行,而是根据 触发条件(如用户程序发起的系统调用、硬件中断、定时中断等)来执行。例如,硬件中断、定时任务、用户发起的 I/O 操作等都可以触发内核中的驱动程序执行。 触发条件 是驱动程序执行的核心,通常这些条件是由外部事件(如硬件操作或用户请求)引发的。 因此,驱动程序的作用是 响应特定的事件,并在合适的时机执行相关操作,而不是持续占用 CPU 执行。 问题8:内核调度程序加载的CPU运行的代码是汇编写的吧,内核还有哪些代码是基于汇编实现的? 是的,内核调度程序以及其他一些关键部分的代码通常会使用 汇编语言 来实现。由于汇编语言能够直接与硬件进行交互,它可以提供更高效和低级的控制,尤其在操作系统内核的某些关键部分。以下是一些内核中的关键代码,它们通常是基于汇编语言实现的: 1. 内核调度程序的汇编实现 内核调度程序负责在不同进程之间进行上下文切换,这是一项至关重要的任务,涉及到保存和恢复 CPU 的上下文(如寄存器、程序计数器等)。其中,部分调度程序的实现确实使用了汇编语言来实现高效的上下文切换,特别是在保存和恢复进程状态时。 上下文切换的实现:上下文切换通常需要直接与硬件寄存器打交道,存储当前进程的寄存器值并恢复另一个进程的寄存器值。这一过程通常用汇编语言来实现,确保对 CPU 状态的精确控制。 汇编与 C 代码结合:虽然大部分调度算法和高层逻辑使用 C 语言实现,但具体的上下文切换过程会涉及到汇编指令,因为这些操作需要访问和修改底层的 CPU 寄存器。通常,汇编代码会嵌入到 C 代码中,或者由单独的汇编文件处理。 2. 中断处理和系统调用的汇编实现 中断处理:当硬件中断发生时,内核需要快速响应并切换到适当的中断处理程序。中断处理程序需要保存当前进程的上下文,处理硬件中断,然后根据需要进行调度。中断的进入和退出通常是通过汇编语言实现的,因为它涉及到修改CPU 状态(如修改程序计数器、堆栈指针等),这些操作必须直接通过汇编指令来完成。 系统调用:系统调用是用户空间与内核空间交互的桥梁,通常通过中断触发。对于 x86 架构,系统调用通过int 0x80(对于 x86)或syscall(对于 x86_64)指令来发起。系统调用的进入和退出也会涉及一些汇编代码来切换上下文(从用户态切换到内核态),并进行参数传递。 3. 内核引导(Bootstrapping) 内核引导过程是操作系统启动时非常重要的一个步骤。通常,内核的启动过程需要做很多低级操作,包括硬件初始化、内存管理的设置等。这些操作通常需要使用汇编语言来直接与硬件交互。 引导加载程序(如 GRUB、LILO 等)负责将内核加载到内存中,并启动内核的初步执行。内核的引导部分通常会使用汇编语言来初始化 CPU、设置堆栈、关闭中断等。 在现代操作系统中,启动过程通常会使用一部分汇编语言来进行硬件初始化(如设置分页、保护模式、GDT/IDT 等),然后再切换到 C 语言编写的内核代码。 4. 内核的低级硬件操作 内核中需要直接与硬件打交道的部分,如 内存管理、I/O 操作 和 硬件抽象层,通常会用汇编语言来实现一部分关键操作,特别是在一些操作必须直接与硬件交互时。 内存管理:内存管理是内核中的一个关键部分,涉及到分页、内存分配等。在某些平台上,分页表的创建和管理需要通过汇编语言进行直接控制,因为它涉及到 CPU 特性(如 CR3 寄存器的设置)和硬件地址的管理。 I/O 操作:操作系统与硬件设备的交互(如磁盘、网络设备、显示器等)通常需要使用汇编语言来执行低级 I/O 操作。例如,通过端口访问、DMA 操作等通常依赖于汇编语言实现。 5. 内核的同步机制(原子操作) 一些内核的同步机制需要通过汇编语言来实现,尤其是在多核处理器环境下,内核需要确保操作的原子性。这些操作包括原子加法、减法、比较和交换等,它们常常需要通过 CPU 提供的特殊指令来实现,譬如lock前缀指令(lock cmpxchg)来保证原子性。 例如,自旋锁 和 信号量 等机制的实现,可能需要使用汇编语言来确保在多处理器系统中对共享资源的安全访问。 6. 汇编在内核中的应用场景总结 可以总结为以下几个关键场景: 上下文切换:实现进程间的上下文切换,保存和恢复寄存器、程序计数器等。 中断处理:处理中断时切换到内核态,保存/恢复状态。 系统调用处理:用户态程序通过系统调用进入内核态的机制,涉及到上下文切换。 引导加载程序和初始化:内核引导过程中的硬件初始化和内存设置。 直接硬件交互:直接与硬件打交道时,汇编提供更精细的控制(如内存管理、I/O 操作等)。 原子操作和同步:提供多核环境下的原子操作支持,确保数据的安全性。 7. C 语言与汇编语言的结合 在现代操作系统中,内核的主要逻辑部分(如调度算法、内存管理、设备驱动等)通常使用 C 语言实现,因为 C 语言具有更好的可读性和维护性。汇编语言主要用于一些需要直接控制硬件、优化性能或实现特殊功能的低级操作。 汇编与 C 的结合:内核中的很多汇编代码通常会被嵌入到 C 语言代码中(例如,通过内联汇编或函数调用),以便 C 代码能够调用汇编实现的功能,同时也能确保代码的跨平台兼容性。 总结: 汇编语言 在操作系统内核中的作用非常重要,尤其是在 上下文切换、硬件中断处理、系统调用、内存管理、I/O 操作、同步机制 等关键部分。 内核中的 高层逻辑(如调度算法、进程管理等)通常由 C 语言 编写,而低级硬件交互、性能优化和中断处理等关键部分常常需要使用汇编语言来实现,以确保对硬件的精确控制和高效执行。
在软件开发过程中,一般来说,花在测试比花在编码的时间要多很多,通常为3:1(甚至更多)。 这个比例随着你的编程和测试水平的提高而不断下降,但不论怎样,软件测试对一般人来讲很重要。 今天以嵌入式开发为例,给大家分享一下常见软件的调试方法有哪些? 很多年前,一位开发人员为了在对嵌入式有更深层次的理解,询问了这样的一个问题:我怎么才能知道并懂得我的系统到底在干些什么呢? 面对这个问题有些吃惊,因为在当时没有人这么问过,而同时代的嵌入式开发人员问的最多的大都围绕“我怎么才能使程序跑得更快”、“什么编译器最好”等问题。 面对这个不同寻常却异乎成熟的问题,可能很多人都不知道怎么办,下面就来讲讲软件测试找bug常见方法和秘诀。 1 懂得使用工具 通常嵌入式系统对可靠性的要求比较高。嵌入式系统安全性的失效可能会导致灾难性的后果,即使是非安全性系统,由于大批量生产也会导致严重的经济损失。 这就要求对嵌入式系统,包括嵌入式软件进行严格的测试、确认和验证。随着越来越多的领域使用软件和微处理器控制各种嵌入式设备,对日益复杂的嵌入式软件进行快速有效的测试愈加显得重要。 就像修车需要工具一样,好的程序员应该能够熟练运用各种软件工具。 不同的工具,有不同的使用范围,有不同的功能。使用这些工具,你可以看到你的系统在干些什么,它又占用什么资源,它到底和哪些外界的东西打交道。 让你郁闷好几天的问题可能通过某个工具就能轻松搞定,可惜你就是不知道。 那么为什么那么多的人总是在折腾个半死之后才想到要用测试工具呢?原因很多,主要有两个: 一个是害怕; 另一个是惰性; 害怕是因为加入测试工具或测试模块到代码需要技巧同时有可能引入新的错误,所以他们总喜欢寄希望于通过不断地修改重编译代码来消除bug,结果却无济于事。 懒惰是因为他们习惯了使用printf之类的简单测试手段。 下面来介绍一些嵌入式常用的测试工具(1)、源码级调试器????[Source-levelDebugger] 这种调试器一般提供单步或多步调试、断点设置、内存检测、变量查看等功能,是嵌入式调试最根本有效的调试方法。比如VxWorksTornadoII提供的gdb就属于这一种。 (2)、简单实用的打印显示工具???? [printf] printf或其它类似的打印显示工具估计是最灵活最简单的调试工具。 打印代码执行过程中的各种变量可以让你知道代码执行的情况。但是,printf对正常的代码执行干扰比较大(一般printf占用CPU比较长的时间),需要慎重使用,最好设置打印开关来控制打印。 (3)、ICE或JTAG调试器????[In- circuitEmulator] ICE是用来仿真CPU核心的设备,它可以在不干扰运算器的正常运行情况下,实时的检测CPU的内部工作情况。 像桌面调试软件所提供的:复杂的条件断点、先进的实时跟踪、性能分析和端口分析这些功能,它也都能提供。ICE一般都有一个比较特殊的CPU,称为外合(bond-out)CPU. 这是一种被打开了封装的CPU,并且通过特殊的连接,可以访问到CPU的内部信号,而这些信号,在CPU被封装时,是没法 “看到”的。 当和工作站上强大的调试软件联合使用时,ICE就能提供你所能找到的最全面的调试功能。 但ICE同样有一些缺点:昂贵;不能全速工作;同样,并不是所有的CPU都可以作为外合CPU的,从另一个角度说,这些外合CPU也不大可能及时的被新出的CPU所更换。 JTAG(JointTestActionGroup)虽然它最初开发出来是为了监测IC和电路连接,但是这种串行接口扩展了用途,包括对调试的支持。 (4)、ROM监视器???? [ROMMonitor] ROM监控器是一小程序,驻留在嵌入系统ROM中,通过串行的或网络的连接和运行在工作站上的调试软件通信。 这是一种便宜的方式,当然也是最低端的技术。它除了要求一个通信端口和少量的内存空间外,不需要其它任何专门的硬件。 提供了如下功能:下载代码、运行控制、断点、单步步进、以及观察、修改寄存器和内存。 因为ROM监控器是操作软件的一部分,只有当你的应用程序运行时,它才会工作。 如果你想检查CPU和应用程序的状态,你就必须停下应用程序,再次进入ROM监控器。 (5)、Data监视器???? [DataMonitor] 这种监视器在不停止CPU运行的情况下不仅可以显示指定变量内容,还可以收集并以图形形式显示各个变量的变化过程。 (6)、OS监视器???? [OperatingSystemMonitor] 操作系统监视器可以显示诸如任务切换、信号量收发、中断等事件。 一方面,这些监视器能够为你呈现事件之间的关系和时间联系;另一方面,还可以提供对信号量优先级反转、死锁和中断延时等问题的诊断。 (7)、性能分析工具???? [Profiler] 可以用来测试CPU到底耗在哪里。profiler工具可以让你知道系统的瓶颈在哪里、CPU的使用率以及需要优化的地方。 (8)、内存测试工具???? [MemoryTeseter] 可以找到内存使用的问题所在,比如内存泄露、内存碎片、内存崩溃等问题。如果发现系统出现一些不可预知的或间歇性的问题,就应该使用内存测试工具测测看。 (8)、运行跟踪器???? [ExecutionTracer] 可以显示CPU执行了哪些函数、谁在调用、参数是什么、何时调用等情况。这种工具主要用于测试代码逻辑,可以在大量的事件中发现异常。 (9)、覆盖工具[CoverageTester] 主要显示CPU具体执行了哪些代码,并让你知道那些代码分支没有被执行到哪里。这样有助于提高代码质量并消除无用代码。 (10)、GUI测试工具???? [GUITester] 很多嵌入式应用带有某种形式的图形用户界面进行交互,有些系统性能测试是根据用户输入响应时间进行的。 GUI测试工具可以作为脚本工具有开发环境中运行测试用例,其功能包括对操作的记录和回放、抓取屏幕显示供以后分析和比较、设置和管理测试过程(Rational 公司的robot和Mercury的Loadrunner工具是杰出的代表)。 很多嵌入式设备没有GUI,但常常可以对嵌入式设备进行插装来运行GUI测试脚本,虽然这种方式可能要求对被测代码进行更改,但是节省了功能测试和回归测试的时间。 (11)、自制工具???? [Home-madetester] 在嵌入式应用中,有时候为了特定的目的,需要自行编写一些工具来达到某种测试目的。 本人曾经编写的视频流录显工具在测试视频会议数据流向和变化上帮了大忙,帮公司找到了几个隐藏很深的bug。 2 尽早发现内存问题 内存问题危害很大,不容易排查,主要有三种类型:内存泄露、内存碎片和内存崩溃。 对于内存问题态度必须要明确,那就是早发现早“治疗”。在软件设计中,内存泄露的“名气”最大,主要由于不断分配的内存无法及时地被释放,久而久之,系统的内存耗尽。 即使细心的编程老手有时后也会遭遇内存泄露问题。有测试过内存泄露的朋友估计都有深刻地体验,那就是内存泄露问题一般隐藏很深,很难通过代码阅读来发现。有些内存泄露甚至可能出现在库当中。 有可能这本身是库中的bug,也有可能是因为程序员没有正确理解它们的接口说明文档造成错用。 在很多时候,大多数的内存泄露问题无法探测,但可能表现为随机的故障。程序员们往往会把这种现象怪罪于硬件问题。 如果用户对系统稳定性不是很高,那么重启系统问题也不大;但,如果用户对系统稳定很高,那么这种故障就有可能使用户对产品失去信心,同时也意味着你的项目是个失败的项目。 由于内存泄露危害巨大,现在已经有许多工具来解决这个问题。 这些工具通过查找没有引用或重复使用的代码块、垃圾内存收集、库跟踪等技术来发现内存泄露的问题。 每个工具都有利有弊,不过总的来说,用要比不用好。总之,负责的开发人员应该去测试内存泄露的问题,做到防患于未然。 内存碎片比内存泄露隐藏还要深。随着内存的不断分配并释放,大块内存不断分解为小块内存,从而形成碎片,久而久之,当需要申请大块内存是,有可能就会失败。如果系统内存够大,那么坚持的时间会长一些,但最终还是逃不出分配失败的厄运。在使用动态分配的系统中,内存碎片经常发生。 目前,解决这个问题最效的方法就是使用工具通过显示系统中内存的使用情况来发现谁是导致内存碎片的罪魁祸首,然后改进相应的部分。 由于动态内存管理的种种问题,在嵌入式应用中,很多公司干脆就禁用malloc/free的以绝后患。 内存崩溃是内存使用最严重的结果,主要原因有数组访问越界、写已经释放的内存、指针计算错误、访问堆栈地址越界等等。 这种内存崩溃造成系统故障是随机的,而且很难查找,目前提供用于排查的工具也很少。 总之,如果要使用内存管理单元的话,必须要小心,并严格遵守它们的使用规则,比如谁分配谁释放。 3 深入理解代码优化 讲到系统稳定性,人们更多地会想到实时性和速度,因为代码效率对嵌入式系统来说太重要了。 知道怎么优化代码是每个嵌入式软件开发人员必须具备的技能。就像女孩子减肥一样,起码知道她哪个地方最需要减,才能去购买减肥药或器材来减掉它。 可见,代码优化的前提是找到真正需要优化的地方,然后对症下药,优化相应部分的代码。 前面提到的profile(性能分析工具,一些功能齐全IDE都提供这种内置的工具)能够记录各种情况比如各个任务的CPU占用率、各个任务的优先级是否分配妥当、某个数据被拷贝了多少次、访问磁盘多少次、是否调用了网络收发的程序、测试代码是否已经关闭等等。 但是,profile工具在分析实时系统性能方面还是有不够的地方。 一方面,人们使用profile工具往往是在系统出现问题即CPU耗尽之后,而 profile工具本身对CPU占用较大,所以profile对这种情况很可能不起作用。 根据Heisenberg效应,任何测试手段或多或少都会改变系统运行,这个对profiler同样适用! 总之,提高运行效率的前提是你必须要知道CPU到底干了些什么干的怎么样。 4 不要让自己大海捞针 大海捞针只是对调试的一种生动比喻。经常听到组里有人对自己正在调试的代码说shit! 可以理解,因为代码不是他写的,他有足够的理由去 shitbug百出的代码,只要他自己不要写出这种代码,否则有一天同组的其它人可能同样会shit他写的代码。 为何会有大海捞针呢?肯定是有人把针掉到海里咯;那针为何会掉在海里呢?肯定是有人不小心或草率呗。 所以当你在抱怨针那么难找的时候,你是否想过是你自己草率地丢掉的。 同样,当你调试个半死的时候,你是否想过你要好好反省一下当初为了寻求捷径可能没有严格地遵守好的编码设计规范、没有检测一些假设条件或算法的正确性、没有将一些可能存在问题的代码打上记号呢? 关于如何写高质量请参考林锐的《高质量c++/c编程指南》或《关于C的0x8本“经书》。 如果你确实已经把针掉在海里是,为了防止在找到之前刺到自己,你必须要做一些防范工作,比如戴上安全手套。 同样,为了尽能地暴露和捕捉问题根源,我们可以设计比较全面的错误跟踪代码。怎么来做呢? 尽可能对每个函数调用失败作出处理,尽可能检测每个参数输入输出的有效性,包括指针以及检测是否过多或过少地调用某个过程。错误跟踪能够让你知道你大概把针掉在哪个位置。 5 重现并隔离问题 如果你不是把针掉在大海了,而是掉在草堆里,那要好办些。因为至少我们可以把草堆分成很多块,一块一块的找。 对于模块独立的大型项目,使用隔离方法往往是对付那些隐藏极深bug的最后方法。 如果问题的出现是间歇性的,我们有必要设法去重现它并记录使其重现的整个过程以备在下一次可以利用这些条件去重现问题。 如果你确信可以使用记录的那些条件去重现问题,那么我们就可以着手去隔离问题。怎么隔离呢? 我们可以用#ifdef把一些可能和问题无关的代码关闭,把系统最小化到仍能够重现问题的地步。 如果还是无法定位问题所在,那么有必要打开“工具箱”了。可以试着用ICE或数据监视器去查看某个可疑变量的变化;可以使用跟踪工具获得函数调用的情况包括参数的传递;检查内存是否崩溃以及堆栈溢出的问题。 6 以退为进 猎人为了不使自己在森林里迷路,他常常会在树木上流下一些标记,以备自己将来有一天迷路时可以根据这些标记找到出路。对过去代码的修改进行跟踪记录对将来出现问题之后的调试很有帮助。 假如有一天,你最近一次修改的程序跑了很久之后忽然死掉了,那么你这时的第一反映就是我到底改动了些什么呢,因为上次修改之前是好的。 那么如何检测这次相对于上次的修改呢?没错,代码控制系统SCS或称版本控制系统 VCS可以很好地解决这个问题。 将上个版本checkin下来后和当前测试版本比较。比较的工 具可以是SCS/VCS/CVS自带的diff工具或其它功能更强的比较工具,比如BeyondCompare和 ExamDiff。通过比较,记录所有改动的代码,分析所有可能导致问题的可疑代码。 7 确定测试的完整性 你怎么知道你的测试有多全面呢?覆盖测试(coveragetesting)可以回答这个问题。覆盖测试工具可以告诉你CPU到底执行了哪些代码。 好的覆盖工具通常可以告诉你大概20%到40% 代码没有问题,而其余的可能存在bug.覆盖工具有不同的测试级别,用户可以根据自己的需要选择某个级别。 即使你很确信你的单元测试已经很全面并且没有 deadcode,覆盖工具还是可以为你指出一些潜在的问题。 看下面的代码: if(i>=0&& (almostAlwaysZero==0||(last=i))) 如果almostAlwaysZero为非0,那么last=i赋值语句就被跳过,这可能不是你所期望的。 这种问题通过覆盖工具的条件测试功能可以轻松得被发现。总之,覆盖测试对于提高代码质量很有帮助。 8 提高代码质量意味着节省时间 有研究表明软件开发的时间超过80%被用在下面几个方面:调试自己的代码(单元测试)。 调试自己和其他相关的代码(模块间测试)。调试整个系统(系统测试),更糟糕的是你可能需要花费10-200倍的时间来找一个 bug,而这个bug在开始的时候可能很容易就能找到。 一个小bug可能让你付出巨大的代价,即使这个bug对整个系统的性能没有太大的影响,但很可能会影响让那些你可以看得到的部分。 所以我们必须要养成良好的编码和测试手段以求更高的代码质量,以便缩短调试的代码。 9 发现它、分析它、解决它 这世界没有万能的膏药。profile再强大也有力不从心的时候;内存监视器再好,也有无法发现的时候;覆盖工具再好用,也有不能覆盖的地方。 一些隐藏很深的问题即使用尽所有工具也有可能无法查到其根源,这时我们能做的就是通过这些问题所表现出来的外在现象或一些数据输出来发现其中的规律或异常。 一旦发现任何异常,一定要深入地理解并回溯其根源,直到解决为止。 10 请利用初学者思维 有人这样说过:“有些事情在初学者的脑子里可能有各种各样的情况,可在专家的头脑里可能就很单一”。 有时候,有些简单的问题会被想得很复杂,有些简单的系统被设计得很复杂,就是由于你的“专家思维”。 当你被问题难住时,关掉电脑,出去走走,把你的问题和你的朋友甚至你的小狗说说,或许他们可以给你意想不到的启发。 11 总结 嵌入式调试也是一门艺术。就想其它的艺术一样,如果你想取得成功,你必须具备智慧、经验并懂得使用工具。
一、io_uring 概述 io_uring 是 Linux 内核中的一种高效异步 I/O 框架,于 Linux 5.1 版本引入,旨在提高大规模并发 I/O 操作的性能。与传统的异步 I/O 接口(如 epoll、select、poll)相比,io_uring 提供了更低的延迟和更高的吞吐量。 二、核心概念 1.提交队列(Submission Queue, SQ): 用户空间应用程序将 I/O 请求添加到提交队列中。每个请求都会被描述为一个提交队列条目(Submission Queue Entry, SQE),包含操作类型、目标文件描述符、缓冲区等信息。 2.完成队列(Completion Queue, CQ): 当 I/O 操作完成时,内核会将结果添加到完成队列中。每个结果都是一个完成队列条目(Completion Queue Entry, CQE),其中包含了操作的返回值、状态码以及用户自定义的数据。 3.异步操作: io_uring 允许用户将 I/O 操作提交给内核,内核在后台异步处理这些操作。用户不需要等待操作完成,而是可以在稍后查询完成队列以获取操作结果。 三、主要系统调用 1. io_uring_setup 功能: io_uring_setup 是用于创建和初始化一个 io_uring 实例的系统调用。它分配和配置提交队列(SQ)和完成队列(CQ),并返回一个用于标识 io_uring 实例的文件描述符。 依赖函数: io_uring_queue_init:io_uring_queue_init 是最常用的初始化函数,内部调用 io_uring_setup 来创建一个 io_uring 实例。 io_uring_queue_init_params:io_uring_queue_init_params 是一个增强的初始化函数,它允许用户传递 io_uring_params结构体以配置额外的参数。它也依赖于io_uring_setup系统调用来创建和初始化io_uring实例。 2. io_uring_enter 功能: io_uring_enter 是用于将已准备好的 I/O 操作提交给内核并处理这些操作的系统调用。它可以用于提交操作、等待操作完成,或者两者兼而有之。 依赖函数: io_uring_submit:io_uring_submit 是用户提交操作到内核的函数,它在内部调用 io_uring_enter,将所有在提交队列中的 I/O 请求提交给内核。 io_uring_submit_and_wait:io_uring_submit_and_wait 提交 I/O 操作后,还可以等待至少一个操作完成,它也是通过调用 io_uring_enter 来实现这一功能。 io_uring_wait_cqe和io_uring_wait_cqe_nr:这些函数用于等待一个或多个操作完成,它们在内部也依赖于 io_uring_enter,通过传递适当的参数来等待完成队列中的事件。 3. io_uring_register 功能: io_uring_register 是用于将文件描述符、缓冲区或其他资源预先注册到 io_uring 实例中的系统调用。这可以提高操作的效率,因为内核在处理这些操作时可以直接访问预先注册的资源,而无需每次都重新设置。 依赖函数: io_uring_register_buffers:这个函数用于注册一组内存缓冲区,使它们可以在后续的 I/O 操作中重复使用。它在内部调用 io_uring_register 系统调用。 io_uring_unregister_buffers:这个函数用于取消之前注册的缓冲区,它也依赖于 io_uring_register 系统调用来取消注册。 io_uring_register_files和io_uring_unregister_files:这些函数分别用于注册和取消注册文件描述符集合,均依赖于 io_uring_register 系统调用。 io_uring_register_eventfd和io_uring_unregister_eventfd:这些函数用于注册和取消注册一个 eventfd,用来通知完成事件,同样依赖于 io_uring_register 系统调用。 四、常用操作 io_uring_prep_* 系列函数: 用于准备 I/O 操作,如 io_uring_prep_read、io_uring_prep_write、io_uring_prep_accept、io_uring_prep_send 等。这些函数将操作的细节填写到提交队列条目(SQE)中。 io_uring_submit: 将准备好的 SQE 提交给内核,触发内核执行操作,内部依赖 io_uring_enter 系统调用。 io_uring_wait_cqe与io_uring_peek_batch_cqe: io_uring_wait_cqe:阻塞等待至少一个操作完成,并返回完成的 CQE。 io_uring_peek_batch_cqe:非阻塞地检查完成队列,获取已经完成的操作。 五、优势 减少系统调用开销:通过批量提交和批量获取结果,减少了系统调用的次数,降低了上下文切换的开销。 高效的异步操作:内核异步处理 I/O 操作,用户空间无需阻塞等待,可以在处理其他任务的同时等待操作完成。 灵活的事件模型:io_uring 支持多种 I/O 操作,并可以在不同的操作之间灵活切换,适用于网络 I/O、文件 I/O、内存映射等多种场景。 扩展性强:io_uring 支持大量并发的 I/O 操作,适合需要处理高并发连接的应用程序,如高性能服务器和数据库。 六、代码实践 完整代码: #include #include #include #include #include #define EVENT_ACCEPT 0#define EVENT_READ 1#define EVENT_WRITE 2 struct conn_info{ int fd; int event;}; int init_server(unsigned short port){ int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in serveraddr; memset(&serveraddr, 0, sizeof(struct sockaddr_in)); serveraddr.sin_family = AF_INET; serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); serveraddr.sin_port = htons(port); if (-1 == bind(sockfd, (struct sockaddr *)&serveraddr, sizeof(struct sockaddr))) { perror("bind"); return -1; } listen(sockfd, 10); return sockfd;} #define ENTRIES_LENGTH 1024#define BUFFER_LENGTH 1024 int set_event_recv(struct io_uring *ring, int sockfd, void *buf, size_t len, int flags){ struct io_uring_sqe *sqe = io_uring_get_sqe(ring); struct conn_info accept_info = { .fd = sockfd, .event = EVENT_READ, }; io_uring_prep_recv(sqe, sockfd, buf, len, flags); memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));} int set_event_send(struct io_uring *ring, int sockfd, void *buf, size_t len, int flags){ struct io_uring_sqe *sqe = io_uring_get_sqe(ring); struct conn_info accept_info = { .fd = sockfd, .event = EVENT_WRITE, }; io_uring_prep_send(sqe, sockfd, buf, len, flags); memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));} int set_event_accept(struct io_uring *ring, int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags){ struct io_uring_sqe *sqe = io_uring_get_sqe(ring); struct conn_info accept_info = { .fd = sockfd, .event = EVENT_ACCEPT, }; io_uring_prep_accept(sqe, sockfd, (struct sockaddr *)addr, addrlen, flags); memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));} int main(int argc, char *argv[]){ unsigned short port = 9999; int sockfd = init_server(port); struct io_uring_params params; memset(¶ms, 0, sizeof(params)); struct io_uring ring; io_uring_queue_init_params(ENTRIES_LENGTH, &ring, ¶ms); #if 0 struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); accept(sockfd, (struct sockaddr*)&clientaddr, &len);#else struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); set_event_accept(&ring, sockfd, (struct sockaddr *)&clientaddr, &len, 0); #endif char buffer[BUFFER_LENGTH] = {0}; while (1) { io_uring_submit(&ring); struct io_uring_cqe *cqe; io_uring_wait_cqe(&ring, &cqe); struct io_uring_cqe *cqes[128]; int nready = io_uring_peek_batch_cqe(&ring, cqes, 128); // epoll_wait int i = 0; for (i = 0; i < nready; i++) { struct io_uring_cqe *entries = cqes[i]; struct conn_info result; memcpy(&result, &entries->user_data, sizeof(struct conn_info)); if (result.event == EVENT_ACCEPT) { set_event_accept(&ring, sockfd, (struct sockaddr *)&clientaddr, &len, 0); // printf("set_event_accept\n"); // int connfd = entries->res; set_event_recv(&ring, connfd, buffer, BUFFER_LENGTH, 0); } else if (result.event == EVENT_READ) { // int ret = entries->res; // printf("set_event_recv ret: %d, %s\n", ret, buffer); // if (ret == 0) { close(result.fd); } else if (ret > 0) { set_event_send(&ring, result.fd, buffer, ret, 0); } } else if (result.event == EVENT_WRITE) { // int ret = entries->res; // printf("set_event_send ret: %d, %s\n", ret, buffer); set_event_recv(&ring, result.fd, buffer, BUFFER_LENGTH, 0); } } io_uring_cq_advance(&ring, nready); }} 1. 服务器初始化 int init_server(unsigned short port){ int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in serveraddr; memset(&serveraddr, 0, sizeof(struct sockaddr_in)); serveraddr.sin_family = AF_INET; serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); serveraddr.sin_port = htons(port); if (-1 == bind(sockfd, (struct sockaddr *)&serveraddr, sizeof(struct sockaddr))) { perror("bind"); return -1; } listen(sockfd, 10); return sockfd;} 解释: 该函数初始化了一个 TCP 服务器套接字,用于监听客户端连接请求。 socket、bind 和 listen 是常规的服务器初始化步骤,将服务器绑定到指定的端口,并使其开始监听客户端连接。 2. io_uring 环境初始化 struct io_uring_params params;memset(¶ms, 0, sizeof(params)); struct io_uring ring;io_uring_queue_init_params(ENTRIES_LENGTH, &ring, ¶ms); 解释: io_uring_queue_init_params 函数初始化了一个 io_uring 实例,这个实例将用于管理所有的异步 I/O 操作。 ENTRIES_LENGTH 定义了提交队列和完成队列的大小,表示可以同时处理的最大 I/O 操作数量。 3. 设置 accept 事件 struct sockaddr_in clientaddr;socklen_t len = sizeof(clientaddr);set_event_accept(&ring, sockfd, (struct sockaddr *)&clientaddr, &len, 0); 解释: set_event_accept 函数将一个 accept 操作添加到 io_uring 的提交队列中。这个操作用于接受客户端连接请求。 这一步是服务器启动时的初始操作,它告诉 io_uring 开始监听并处理客户端连接。 4. 主循环:提交操作和处理完成事件 while (1){ io_uring_submit(&ring); struct io_uring_cqe *cqe; io_uring_wait_cqe(&ring, &cqe); struct io_uring_cqe *cqes[128]; int nready = io_uring_peek_batch_cqe(&ring, cqes, 128); 解释: io_uring_submit:将之前添加到提交队列中的所有操作提交给内核,由内核异步执行这些操作。 io_uring_wait_cqe:等待至少一个操作完成,这是一个阻塞调用。 io_uring_peek_batch_cqe:批量获取已经完成的操作结果,nready 表示完成的操作数量。 5. 处理完成的事件 for (i = 0; i < nready; i++){ struct io_uring_cqe *entries = cqes[i]; struct conn_info result; memcpy(&result, &entries->user_data, sizeof(struct conn_info)); if (result.event == EVENT_ACCEPT) { set_event_accept(&ring, sockfd, (struct sockaddr *)&clientaddr, &len, 0); int connfd = entries->res; set_event_recv(&ring, connfd, buffer, BUFFER_LENGTH, 0); } else if (result.event == EVENT_READ) { int ret = entries->res; if (ret == 0) { close(result.fd); } else if (ret > 0) { set_event_send(&ring, result.fd, buffer, ret, 0); } } else if (result.event == EVENT_WRITE) { int ret = entries->res; set_event_recv(&ring, result.fd, buffer, BUFFER_LENGTH, 0); }} 解释: EVENT_ACCEPT:处理 accept 事件。当一个新的客户端连接到来时,io_uring 完成队列会返回 EVENT_ACCEPT 事件,表示一个新的连接已经建立。此时,服务器会: 重新设置 accept 事件,继续监听新的客户端连接。 获取新连接的文件描述符 connfd,并设置一个 recv 事件来准备接收数据。 EVENT_READ:处理 recv 事件。当从客户端接收到数据时,io_uring 返回 EVENT_READ 事件。如果接收到的数据长度大于0,则会设置一个 send 事件来将数据发送回客户端。如果 ret == 0,说明客户端关闭了连接,则关闭文件描述符。 EVENT_WRITE:处理 send 事件。当数据成功发送给客户端后,io_uring 返回 EVENT_WRITE 事件。此时,服务器会再次设置一个 recv 事件,准备接收更多数据。 6. 完成队列的推进 io_uring_cq_advance(&ring, nready); 解释: 这个函数通知 io_uring,你已经处理完了 nready 个完成队列条目(CQE)。io_uring 可以释放这些 CQE 供后续操作使用。 7. 总结 io_uring 的作用:在这个示例中,io_uring 被用来高效地处理网络 I/O 操作。通过异步提交和处理 accept、recv、send 操作,服务器能够高效处理多个并发连接,而无需阻塞等待每个I/O操作完成。 异步模型:io_uring 提供了一种低延迟、高并发的异步 I/O 处理方式。操作在提交后由内核异步执行,完成后再由应用程序查询并处理结果。这种方式大大减少了系统调用的开销,提高了程序的并发处理能力。 关键点: 提交操作:使用 io_uring_prep_* 函数准备操作,并提交给内核处理。 等待完成:使用 io_uring_wait_cqe 等方法等待操作完成,并获取结果。 处理结果:根据完成队列中的事件类型(如 EVENT_ACCEPT、EVENT_READ、EVENT_WRITE)进行相应的处理和后续操作。 七、问题与思考 1.提交队列肯定需要多线程操作,需要枷锁? 对于io_uring的提交队列来说,在多线程操作下不需要加锁。io_uring使用了无锁环形队列,通过原子操作来管理队列的头尾指针,确保多个线程可以安全且高效地并发提交I/O请求。这种设计避免了传统锁的开销,尤其适合高并发场景下的性能优化。 无锁环形队列的原理 1.环形缓冲区: 环形队列是一种常用的数据结构,其中队列的头和尾指针在到达缓冲区末尾时回绕到开始位置,从而形成一个“环”。 在io_uring 中,提交队列和完成队列实际上是一个环形缓冲区,允许多个线程高效地提交和消费I/O操作。 2.原子操作: 无锁设计的关键在于使用原子操作来管理队列指针,确保即使在多线程环境下,多个线程同时访问队列时也不会产生竞争条件。 io_uring 使用原子性操作(例如 cmpxchg 或 fetch_add)来更新队列的头尾指针,从而避免了锁的使用。 3.多生产者/多消费者支持: io_uring 的设计支持多生产者(即多个线程提交I/O请求)和多消费者(即多个线程处理I/O完成事件)。在这种模式下,通过原子操作,多个线程可以无锁地同时对提交队列或完成队列进行操作。 2.io_uring如何避免频繁的拷贝的? io_uring 的实现旨在通过减少用户空间和内核空间之间的数据复制来提高 I/O 操作的性能。虽然io_uring并不是直接依赖于mmap来避免拷贝,但它使用了多种技术和机制来减少不必要的数据复制和系统资源消耗。 io_uring 的关键机制 1.提交队列和完成队列(SQ 和 CQ): io_uring 使用了两个环形队列:提交队列(Submission Queue, SQ)和完成队列(Completion Queue, CQ)。用户空间通过 SQ 提交 I/O 请求,而内核通过 CQ 返回操作的完成状态。这两个队列都可以通过 mmap 映射到用户空间,允许用户空间直接操作这些队列,减少了系统调用的频率。 2.直接提交和批量处理: 用户空间可以将多个 I/O 请求直接写入 SQ,然后通过一个系统调用将它们提交给内核。内核可以批量处理这些请求,并将结果写入 CQ。这减少了频繁的系统调用和数据拷贝。 3.注册固定缓冲区和文件描述符: io_uring 允许用户提前注册缓冲区和文件描述符,这些缓冲区和文件描述符在后续的 I/O 操作中可以被重复使用。由于这些资源已经预先注册并映射到内核,因此在实际的 I/O 操作中不需要再次传递和复制这些资源。 4.直接 I/O 支持: io_uring 可以与直接 I/O(Direct I/O)结合使用,使得数据可以直接从用户空间传输到存储设备或网络设备,或者从设备直接读取到用户空间,绕过内核缓冲区。这进一步减少了内核空间和用户空间之间的数据拷贝。 5.零拷贝发送和接收: 在网络传输中,io_uring 支持零拷贝发送和接收,特别是在高性能网络应用中,这意味着数据可以直接从用户空间传输到网络栈,而不需要在内核缓冲区和用户缓冲区之间进行拷贝。 实现细节 内存映射(mmap):SQ 和 CQ 通常会通过 mmap 映射到用户空间,这样用户空间可以直接访问这些队列的数据结构。这不仅减少了系统调用的开销,也避免了在用户空间和内核空间之间的数据复制。 原子操作和无锁队列:在多线程环境下,io_uring 使用原子操作来管理队列的头和尾指针,从而避免了锁的使用,进一步提高了性能。 批量提交与处理:io_uring 支持批量提交 I/O 请求,允许多个请求在一次系统调用中被提交到内核。这种机制减少了系统调用的次数,并且通过批量处理可以减少内核空间与用户空间之间的数据交换。 总结 io_uring 通过设计高效的提交和完成队列、支持直接 I/O 和零拷贝技术,以及允许注册和重用缓冲区来避免频繁的数据复制。这种设计使得 io_uring 在处理大规模异步 I/O 操作时,能够提供极高的性能和低延迟。 3.当服务器通过 listen 函数开始监听端口时,程序会阻塞在哪里? 阻塞点的分析 在程序运行后,当服务器通过 listen 函数开始监听端口时,它并不会立即处理任何连接请求,而是等待客户端连接的到来。在这期间,程序的执行流会阻塞在某些地方,具体来说,它阻塞在两个可能的地方: 1.io_uring_wait_cqe(&ring, &cqe);: 这个调用是 io_uring 的一个重要部分,它用于等待完成队列(CQE)中至少有一个事件完成。在没有事件发生的情况下,比如没有客户端发起连接请求时,这个函数会阻塞,直到有新的事件到达为止。因此,如果没有客户端连接请求,程序会阻塞在这里。 2.io_uring_peek_batch_cqe(&ring, cqes, 128);: 这个函数用于检查 io_uring 完成队列中是否有已完成的事件。虽然它本身并不阻塞,但在 io_uring_wait_cqe 解除了阻塞之后,这个函数会获取已经完成的事件列表并处理它们。 重点总结 阻塞点:程序在没有客户端连接请求时,会阻塞在 io_uring_wait_cqe 函数上,等待 io_uring 完成队列中的事件。 异步处理:一旦有事件完成(例如客户端连接到来),程序会解除阻塞并处理该事件,接着继续等待下一个事件的完成。 4.io_Uring和epoll有什么相同点和区别 相同点 1.高并发I/O处理:两者都旨在高效处理大量并发I/O操作,特别是在网络服务器等需要处理众多客户端连接的场景中。 2.事件驱动模型:两者都采用事件驱动的模型,程序通过等待I/O事件发生然后进行相应处理,避免了轮询的低效性。 3.减少阻塞:epoll 和 io_uring 都通过异步或非阻塞的方式减少了I/O操作中的等待时间,提高了应用程序的响应速度和整体性能。 区别 1.设计与用途: epoll: epoll 是基于 poll 和 select 的改进版,专门用于监控多个文件描述符(socket、文件等)的事件(如读、写、异常等)。 它本身并不执行I/O操作,而是等待并通知I/O事件的发生。 epoll 适合事件驱动的网络编程,例如监视多个客户端连接的服务器。 io_uring: io_uring 是一个更广泛的异步I/O框架,它不仅仅用于事件通知,还直接执行I/O操作。 支持文件读写、网络I/O等操作,并且设计上避免了频繁的上下文切换和数据复制。 io_uring 适合需要处理大量I/O操作的高性能应用,例如高吞吐量的服务器、数据库系统等。 2.系统调用的数量与性能: epoll: 使用时需要多次系统调用。例如,你需要用 epoll_ctl 注册或修改事件,再用 epoll_wait 等待事件发生。 每次等待事件都需要从用户空间切换到内核空间,尽管 epoll 的性能比 select 和 poll 高,但频繁的系统调用仍然是一个瓶颈。 io_uring: 通过提交和完成队列(SQ和CQ)机制,大大减少了系统调用的数量。你可以批量提交多个I/O操作,然后一次性等待它们的完成。 io_uring 利用共享内存区域在用户空间和内核空间之间传递I/O请求和结果,减少了上下文切换和系统调用开销,性能优势明显。 3.I/O操作类型的支持: epoll: 主要用于监听和处理文件描述符上的事件,不直接执行I/O操作。 你可以监控 EPOLLIN、EPOLLOUT 等事件,但具体的I/O操作仍需由用户代码完成。 io_uring: 不仅可以处理事件通知,还可以直接执行I/O操作(如读写文件、网络I/O)。 支持零拷贝传输、固定缓冲区等高级功能,适合需要高效I/O处理的复杂场景。 4.阻塞与非阻塞: epoll: epoll_wait 可以设置为阻塞或非阻塞模式,通常情况下会阻塞直到有事件发生。 io_uring: io_uring 支持完全异步的操作,通过提交和完成队列的机制实现了非阻塞I/O。 可以同时处理多个I/O操作并等待它们的完成,无需像 epoll 那样分别等待每个事件的发生。 开发复杂度: epoll: 相对来说更简单,只需关注文件描述符的事件注册和处理。 io_uring: 功能更强大,支持更多操作类型,但开发复杂度较高。需要管理提交队列和完成队列,以及处理可能的错误和资源管理。
基本上,没有人会将大段的C语言代码全部塞入 main() 函数。更好的做法是按照复用率高、耦合性低的原则,尽可能的将代码拆分不同的功能模块,并封装成函数。C语言代码的组合千变万化,因此函数的功能可能会比较复杂,不同的输入,常常产生不同的输出结果。 C语言函数的返回值 C语言函数可以通过返回值表示输出结果,例如 log() 函数的返回值会根据不同的输入,返回不同的值。再比如,我们定义一个函数 myopen(),用于打开某个文件,那么,这个函数要么能够成功打开文件,要么打开文件失败,这时,可以通过返回值区分“成功”和“失败”。当然,myopen() 函数失败的原因可能很多,但不是本文关注的重点,本文更关心的是,该以何值表示“成功”,何值表示“失败”。按照C语言语法, 0 表示假,非零(常常用 1)表示真,那是否函数也用 0 返回值表示“失败”,1 返回值表示“成功”呢? 行业“潜规则” C语言函数当然可以使用返回值 0 表示“失败”,用返回值 1 表示“成功”。事实上,C语言函数用什么样的返回值表示成功或者失败,只是一种人为的约定,函数的调用者遵守这个“约定”就可以了。C语言也有“行业潜规则”,不过,对于一般的函数,大多数C语言程序员常常使用返回值 0 表示成功,非零值表示失败。因此,如果希望我们定义的函数能够被大多数人愉快的使用,最好不要反其道而行,遵守“行业潜规则”更好一点。仔细考虑下,其实C语言函数使用返回值 0 表示成功是有原因的。更一般的C语言函数返回值并不一定只有两种可能值(成功/失败),它可能还会返回对应错误原因的返回值。总之,函数成功只有一种可能,函数失败却有多种可能。实数要么是 0,要么非 0,因此可以将 0 看作一个比较特殊的“唯一”数值,使用 0 这个“唯一”的返回值,表示唯一的“成功”,多种非零的返回值,表示多种原因的失败,无疑更好一些。当然,我们也可以说“实数要么是 1,要么非 1”,不过这显然也不是“行业潜规则”。例如我们可以规定,如果 myopen() 函数因为“文件或者目录不存在”的原因失败,返回 -1,如果因为“权限不够”的原因失败,则返回 -2。-1 和 -2 都是“非零值”,而成功作为失败的对立面,也即“非零值”的对立面,myopen() 函数使用返回值 0 表示成功无可厚非。 另一种行业“潜规则” 当然,C语言程序员中还有一种“行业潜规则”。如果定义的函数是个布尔函数,也即返回值显式的使用类似于 bool 关键字定义,或者函数名类似于 is_true(),那么显然此时应该遵守C语言语法,使用“真”值表示成功,“假”值表示失败。 if( is_true() ) printf("true\n"); 请看上面这两行C语言代码,显然,遵守C语言语法的布尔函数更便于程序员写出布尔判断类的代码。 小结 本文主要讨论了C语言程序开发中关于函数返回值的问题。可见,使用什么样的返回值表示成功,什么用的返回值表示失败,其实只是一种人为约定。只不过,如果希望我们编写的代码能够被大多数同行愉快的使用,最好遵守下“行业潜规则”。当然了,若是希望我们的C语言代码应用性更广,则可以使用标准头文件里预先定义好的 EXIT_SUCCESS 和 EXIT_FAILURE 宏。
工程师们似乎认为编写垃圾回收机制是很难的,是一种只有少数智者和Hans Boehm(et al)才能理解的高深魔法。 我认为编写垃圾回收最难的地方就是内存分配,这和阅读 K&R 所写的 malloc 样例难度是相当的。 在开始之前有一些重要的事情需要说明一下: 第一,我们所写的代码是基于Linux Kernel的,注意是Linux Kernel而不是GNU/Linux。 第二,我们的代码是32bit的。 第三,请不要直接使用这些代码。我并不保证这些代码完全正确,可能其中有一些我还未发现的小的bug,但是整体思路仍然是正确的。 好了,让我们开始吧。 1 编写malloc 最开始,我们需要写一个内存分配器(memmory allocator),也可以叫做内存分配函数(malloc function)。 最简单的内存分配实现方法就是维护一个由空闲内存块组成的链表,这些空闲内存块在需要的时候被分割或分配。 当用户请求一块内存时,一块合适大小的内存块就会从链表中被移除并分配给用户。 如果链表中没有合适的空闲内存块存在,而且更大的空闲内存块已经被分割成小的内存块了或内核也正在请求更多的内存(译者注:就是链表中的空闲内存块都太小不足以分配给用户的情况)。 那么此时,会释放掉一块内存并把它添加到空闲块链表中。 在链表中的每个空闲内存块都有一个头(header)用来描述内存块的信息。我们的header包含两个部分,第一部分表示内存块的大小,第二部分指向下一个空闲内存块。 将头(header)内嵌进内存块中是唯一明智的做法,而且这样还可以享有字节自动对齐的好处,这很重要。 由于我们需要同时跟踪我们“当前使用过的内存块”和“未使用的内存块”,因此除了维护空闲内存的链表外,我们还需要一条维护当前已用内存块的链表(为了方便,这两条链表后面分别写为“空闲块链表”和“已用块链表”)。 我们从空闲块链表中移除的内存块会被添加到已用块链表中,反之亦然。 现在我们差不多已经做好准备来完成malloc实现的第一步了。但是再那之前,我们需要知道怎样向内核申请内存。 动态分配的内存会驻留在一个叫做堆(heap)的地方,堆是介于栈(stack)和BSS(未初始化的数据段-你所有的全局变量都存放在这里且具有默认值为0)之间的一块内存。 堆(heap)的内存地址起始于(低地址)BSS段的边界,结束于一个分隔地址(这个分隔地址是已建立映射的内存和未建立映射的内存的分隔线)。 为了能够从内核中获取更多的内存,我们只需提高这个分隔地址。为了提高这个分隔地址我们需要调用一个叫作 sbrk 的Unix系统的系统调用, 这个函数可以根据我们提供的参数来提高分隔地址,如果函数执行成功则会返回以前的分隔地址,如果失败将会返回-1。 利用我们现在知道的知识,我们可以创建两个函数:morecore()和add_to_free_list()。 当空闲块链表缺少内存块时,我们调用morecore()函数来申请更多的内存。由于每次向内核申请内存的代价是昂贵的,我们以页(page-size)为单位申请内存。 页的大小在这并不是很重要的知识点,不过这有一个很简单解释:页是虚拟内存映射到物理内存的最小内存单位。 接下来我们就可以使用add_to_list()将申请到的内存块加入空闲块链表。 现在我们有了两个有力的函数,接下来我们就可以直接编写malloc函数了。 我们扫描空闲块链表当遇到第一块满足要求的内存块(内存块比所需内存大即满足要求)时,停止扫描,而不是扫描整个链表来寻找大小最合适的内存块,我们所采用的这种算法思想其实就是首次适应(与最佳适应相对)。 注意:有件事情需要说明一下,内存块头部结构中size这一部分的计数单位是块(Block),而不是Byte。 注意这个函数的成功与否,取决于我们第一次使用时是否使 freep = &base 。这点我们会在初始化函数中进行设置。 尽管我们的代码完全没有考虑到内存碎片,但是它能工作。既然它可以工作,我们就可以开始下一个有趣的部分-垃圾回收! 2 标记与清扫 我们说过垃圾回收器会很简单,因此我们尽可能的使用简单的方法:标记和清除方式。这个算法分为两个部分: 首先,我们需要扫描所有可能存在指向堆中数据(heap data)的变量的内存空间并确认这些内存空间中的变量是否指向堆中的数据。 为了做到这点,对于可能内存空间中的每个字长(word-size)的数据块,我们遍历已用块链表中的内存块。 如果数据块所指向的内存是在已用链表块中的某一内存块中,我们对这个内存块进行标记。 第二部分是,当扫描完所有可能的内存空间后,我们遍历已用块链表将所有未被标记的内存块移到空闲块链表中。 现在很多人会开始认为只是靠编写类似于malloc那样的简单函数来实现C的垃圾回收是不可行的,因为在函数中我们无法获得其外面的很多信息。 例如,在C语言中没有函数可以返回分配到堆栈中的所有变量的哈希映射。但是只要我们意识到两个重要的事实,我们就可以绕过这些东西: 第一,在C中,你可以尝试访问任何你想访问的内存地址。因为不可能有一个数据块编译器可以访问但是其地址却不能被表示成一个可以赋值给指针的整数。 如果一块内存在C程序中被使用了,那么它一定可以被这个程序访问。这是一个令不熟悉C的编程者很困惑的概念,因为很多编程语言都会限制程序访问虚拟内存,但是C不会。 第二,所有的变量都存储在内存的某个地方。这意味着如果我们可以知道变量们的通常存储位置,我们可以遍历这些内存位置来寻找每个变量的所有可能值。 另外,因为内存的访问通常是字(word-size)对齐的,因此我们仅需要遍历内存区域中的每个字(word)即可。 局部变量也可以被存储在寄存器中,但是我们并不需要担心这些因为寄存器经常会用于存储局部变量,而且当函数被调用的时候他们通常会被存储在堆栈中。 现在我们有一个标记阶段的策略:遍历一系列的内存区域并查看是否有内存可能指向已用块链表。编写这样的一个函数非常的简洁明了: 为了确保我们只使用头(header)中的两个字长(two words)我们使用一种叫做标记指针(tagged pointer)的技术。 利用header中的next指针指向的地址总是字对齐(word aligned)这一特点,我们可以得出指针低位的几个有效位总会是0。 因此我们将next指针的最低位进行标记来表示当前块是否被标记。 现在,我们可以扫描内存区域了,但是我们应该扫描哪些内存区域呢?我们要扫描的有以下这些: BBS(未初始化数据段)和初始化数据段。这里包含了程序的全局变量和局部变量。因为他们有可能应用堆(heap)中的一些东西,所以我们需要扫描BSS与初始化数据段,已用的数据块。 当然,如果用户分配一个指针来指向另一个已经被分配的内存块,我们不会想去释放掉那个被指向的内存块。堆栈。因为堆栈中包含所有的局部变量,因此这可以说是最需要扫描的区域了。 我们已经了解了关于堆(heap)的一切,因此编写一个mark_from_heap函数将会非常简单: 幸运的是对于BSS段和已初始化数据段,大部分的现代unix链接器可以导出 etext 和 end 符号。etext符号的地址是初始化数据段的起点(the last address past the text segment,这个段中包含了程序的机器码),end符号是堆(heap)的起点。 因此,BSS和已初始化数据段位于 &etext 与 &end 之间。这个方法足够简单,当不是平台独立的。 堆栈这部分有一点困难。堆栈的栈顶非常容易找到,只需要使用一点内联汇编即可,因为它存储在 sp 这个寄存器中。但是我们将会使用的是 bp 这个寄存器,因为它忽略了一些局部变量。 寻找堆栈的的栈底(堆栈的起点)涉及到一些技巧。出于安全因素的考虑,内核倾向于将堆栈的起点随机化,因此我们很难得到一个地址。 老实说,我在寻找栈底方面并不是专家,但是我有一些点子可以帮你找到一个准确的地址。 一个可能的方法是,你可以扫描调用栈(call stack)来寻找 env 指针,这个指针会被作为一个参数传递给主程序。 另一种方法是从栈顶开始读取每个更大的后续地址并处理inexorible SIGSEGV。 但是我们并不打算采用这两种方法中的任何一种,我们将利用linux会将栈底放入一个字符串并存于proc目录下表示该进程的文件中这一事实。这听起来很愚蠢而且非常间接。 值得庆幸的是,我并不感觉这样做是滑稽的,因为它和Boehm GC中寻找栈底所用的方法完全相同。 现在我们可以编写一个简单的初始化函数。 在函数中,我们打开proc文件并找到栈底。栈底是文件中第28个值,因此我们忽略前27个值。Boehm GC和我们的做法不同的是他仅使用系统调用来读取文件来避免让stdlib库使用堆(heap),但是我们并不在意这些。 现在我们知道了每个我们需要扫描的内存区域的位置,所以我们终于可以编写显示调用的回收函数了: 朋友们,所有的东西都已经在这了,一个用C为C程序编写的垃圾回收器。这些代码自身并不是完整的,它还需要一些微调来使它可以正常工作,但是大部分代码是可以独立工作的。 3 总结 一开始就打算编写完整的程序是很困难的,你编程的唯一算法就是分而治之。 先编写内存分配函数,然后编写查询内存的函数,然后是清除内存的函数。最后将它们合在一起。 当你在编程方面克服这个障碍后,就再也没有困难的实践了。你可能有一个算法不太了解,但是任何人只要有足够的时间就肯定可以通过论文或书理解这个算法。 如果有一个项目看起来令人生畏,那么将它分成完全独立的几个部分。 你可能不懂如何编写一个解释器,但你绝对可以编写一个分析器,然后看一下你还有什么需要添加的,添上它。相信自己,终会成功!
一. 简介 用户层进程的虚拟地址空间是Linux的一个重要抽象,它向每个运行进程提供了同样的系统视图,这使得多个进程可以同时运行,而不会干扰到其他进程内存中的内容,此外,它容许使用各种高级的程序设计技术,如内存映射,学习虚拟内存,同样需要考察可用物理内存中的页帧与所有的进程虚拟地址空间中的页之间的关联: 逆向映射(reverse mapping)技术有助于从虚拟内存页中跟踪到对应的物理内存页,而缺页处理(page fault handling)则允许从块设备按需读取数据填充虚拟地址空间(本质上是从块设备中读取数据物理内存中,并重新建立物理内存到虚拟内存的映射关系) 相对于物理内存的组织,或者内核的虚拟地址空间的管理(内核的虚拟内存是直接线性物理内存映射的),虚拟内存的管理更加复杂 1. 每个应用程序都有自身的地址空间,与所有其他应用程序分隔开 2. 通常在巨大的线性地址空间中,只有很少的段可用于各个用户空间进程,这些段彼此之间有一定的距离,内核需要一些数据结构,来有效地管理这些"随机"分布的段 3. 地址空间只有极小一部分与物理内存页直接关联(建立页表映射),不经常使用的部分,则仅当必要时与页帧关联(基于缺页中断机制的内存换入换出机制) 4. 内核信任自身,但无法信任用户进程,因此,各个操作用户地址空间的操作都伴随有各种检查,以确保程序的权限不会超出应用的限制,进而危及系统的稳定性和安全性 5. fork-exec模型在UNIX操作系统下用于产生新进程,内核需要借助一些技巧,来尽可能高效地管理用户地址空间 1.MMU(memory management unit) 需要明白的是,我们接下来讨论的内容都似乎基于系统有一个内存管理单元MMU,该单元支持使用虚拟内存,大多数"正常"的处理器都包含这个组件 内存管理单元(memory management unit MMU),有时称作分页内存管理单元(paged memory management unit PMMU)。它是一种负责处理中央处理器(CPU)的内存访问请求的计算机硬件。它的功能包括 1. 虚拟地址到物理地址的转换(即虚拟内存管理) 2. 内存保护 3. 中央处理器高速缓存的控制 4. 在较为简单的计算机体系结构中,负责总线的仲裁以及存储体切换(bank switching,尤其是在8位的系统上) 现代的内存管理单元是以页的方式,分区虚拟地址空间(处理器使用的地址范围)的;页的大小是2的n次方,通常为几KB。地址尾部的n位(页大小的2的次方数)作为页内的偏移量保持不变。其余的地址位(address)为(虚拟)页号。内存管理单元通常借助一种叫做转译旁观缓冲器(Translation Lookaside Buffer TLB)的相联高速缓存(associative cache)来将虚拟页号转换为物理页号。当后备缓冲器中没有转换记录时,则使用一种较慢的机制,其中包括专用硬件(hardware-specific)的数据结构(Data structure)或软件辅助手段 Each process a pointer (mm_struct→pgd) to its own Page Global Directory (PGD) which is a physical page frame. This frame contains an array of type pgd_t which is an architecture specific type defined in . The page tables are loaded differently depending on the architecture. On the x86, the process page table is loaded by copying mm_struct→pgd into the cr3 register which has the side effect of flushing the TLB. In fact this is how the function __flush_tlb() is implemented in the architecture dependent code. Each active entry in the PGD table points to a page frame containing an array of Page Middle Directory (PMD) entries of type pmd_t which in turn points to page frames containing Page Table Entries (PTE) of type pte_t, which finally points to page frames containing the actual user data. In the event the page has been swapped out to backing storage, the swap entry is stored in the PTE and used by do_swap_page() during page fault to find the swap entry containing the page data. The page table layout is illustrated in Figure below 现代操作系统普遍采用多级(四级)页表的方式来组织虚拟内存和物理内存的映射关系,从虚拟地址内存到物理地址内存的翻译就是在进行多级页表的寻址过程 2.Describing a Page Table Entry 1. _PAGE_PRESENT: Page is resident in memory and not swapped out,该页是否应被高速缓冲的信息 2. _PAGE_PROTNONE: Page is resident but not accessable 3. _PAGE_RW: Set if the page may be written to 4. _PAGE_USER: Set if the page is accessible from user space,"特权位": 哪种进程可以读写该页的信息,例如用户模式(user mode)进程、还是特权模式(supervisor mode)进程 5. _PAGE_DIRTY: Set if the page is written to,"脏位"(页面重写标志位)(dirty bit): 表示该页是否被写过 6. _PAGE_ACCESSED: Set if the page is accessed,"访问位"(accessed bit): 表示该页最后使用于何时,以便于最近最少使用页面置换算法(least recently used page replacement algorithm)的实现 有时,TLB或PTE会禁止对虚拟页的访问,这可能是因为没有物理随机存取存储器(random access memory)与虚拟页相关联。如果是这种情况,MMU将向CPU发出页错误(page fault)的信号。操作系统将进行处理,也许会尝试寻找RAM的空白帧,同时创建一个新的PTE将之映射到所请求的虚拟地址。如果没有空闲的RAM,可能必须关闭一个已经存在的页面,使用一些替换算法,将之保存到磁盘中(这被称之为页面调度(paging)) 二. 进程虚拟地址空间 各个进程的虚拟地址空间范围为: [0 ~ TASK_SIZE - 1],再往"上"(栈高地址空间)是内核地址空间,在IA-32系统上地址空间的范围为: [0 ~ 2^32] = 4GB,总的地址空间通常按3:1划分 与系统完整性相关的非常重要的一个方面是,用户程序只能访问整个地址空间的下半部分,不能访问内核部分,如果没有预先达成"协议(即IPC机制)",用户进程也不能操作另一个进程的地址空间,因为不同进程的地址空间互相是不可见的 值得注意的是,无论当前哪个用户进程处于活动状态,虚拟地址空间内核部分的内容总是同样的,实现这个功能的技术取决于具体的硬件 1. 通过操作各个用户进程的页表,使得虚拟地址空间的上半部分(内核高地址段)看上去总是相同的 2. 指示处理器为内核提供一个独立的地址空间,映射在各个用户地址空间之上 虚拟地址空间由许多不同长度的段组成,用于不同的目的,必须分别处理,例如在大多数情况下,不允许修改.text段,但必须可以执行其中内容。另一方面,必须可以修改映射到地址空间中的文本文件内容,而不能允许执行其内容,文件内容只是数据,而并非机器代码 从这里我们也可以看到操作提供的安全机制本质上是一个自上而下的过程,每一级都对上游负责,并对下游提出要求 1. 进程ELF的节区中的属性标志指明了当前节区(段)具有哪些属性(R/W/RW/E),这需要操作系统在内存的PTE页表项中提供支持 2. PTE中的_PAGE_RW位表示该页具有哪些读写属性,而需要禁止某些内存页的二进制字节被CPU执行,需要底层CPU提供硬件支持 3. CPU的NX bit位提供了硬件级的禁止内存页数据执行支持,这也是DEP技术的基础 1.进程地址空间的布局 虚拟地址空间中包含了若干区域,其分布方式是特定于体系结构的,但所有方法都有下列共同成分 1. 当前运行代码的二进制代码,该代码通常称之为.text,所处的虚拟内存区域称之为.text段 2. 程序使用的动态库的代码 3. 存储全局变量和动态产生的数据的堆 4. 用于保存局部变量和实现函数/过程调用的栈 5. 环境变量和命令行参数的段 6. 将文件内容映射到虚拟地址空间中的内存映射 我们知道,系统中的各个进程都具有一个struct mm_struct的实例,关于数据结构的相关知识,后续会整理一篇文章讲解。 各个体系结构可以通过几个配置选项影响虚拟地址空间的布局 1. 如果体系结构想要在不同mmap区域布局之间作出选择,则需要设置HAVE_ARCH_PICK_MMAP_LAYOUT,并提供arch_pick_mmap_layout函数 2. 在创建新的内存映射时,除非用户指定了具体的地址,否则内核需要找到一个适当的位置,如果体系结构自身想要选择合适的位置,则必须设置预处理器符号HAVE_ARCH_UNMAPPED_AREA,并相应地定义arch_get_unmapped_area函数 3. 在寻找新的内存映射低端内存位置时,通常从较低的内存位置开始,逐渐向较高的内存地址搜索,内核提供了默认的函数arch_get_unmapped_area_topdown用于搜索,但如果某个体系结构想要提供专门的实现,则需要设置预处理符号HAVE_ARCH_UNMAPPED_AREA 4. 通常,栈自顶向下增长,具有不同处理方式的体系结构需要设置配置选项CONFIG_STACK_GROWSUP 最后,我们需要考虑进程标志PF_RANDOMIZE,如果设置了该标志,则内核不会为栈和内存映射的起点选择固定位置,而是在每次新进程启动时随机改变这些值的设置,这引入了一些复杂性,例如使得缓冲区溢出攻击更加困难,如果攻击者无法依靠固定地址找到栈,那么想要构建恶意代码,通过缓冲区溢出获得栈内存区域的访问权从而恶意操纵栈的内容,将会困难得多 (1) .text段 .text段如何映射到虚拟地址空间中由ELF标准确定,每个体系结构都指定了一个特定的起始地址 1. IA-32: 起始于0x08048000 //在text段的起始地址与最低的可用地址之间大约有128MB的间距,用于捕获NULL指针,其他体系结构也有类似的缺口 2. UltraSparc: 起始于0x100000000 3. AMD64: 0x0000000000400000 (2)堆 堆紧接着text段开始,向上增长 (3)MMAP 用于内存映射的区域起始于mm_struct->mmap_base,通常设置为TASK_UNMAPPED_BASE,每个体系结构都需要定义,在几乎所有的情况下,其值都是TASK_SIZE / 3,值得注意的是,内核的默认配置,mmap区域的起始点不是随机的,即ASLR不是默认开启的,需要特定的内核配置 (4)栈 栈起始于STACK_TOP(栈是从高地址向低地址生长的,这里的起始指的最高的地址位置),如果设置了PF_RANDOMIZE,则起始点会减少一个小的随机量,每个体系结构都必须定义STACK_TOP,大多数都设置为TASK_SIZE,即用户地址空间中最高可用地址。进程的参数列表和环境变量都是栈的初始数据(即位于栈的最高地址区域) 如果计算机提供了巨大的虚拟地址空间,那么使用上述的地址空间布局会工作地非常好,但在32位计算机上可能会出现问题。考虑IA-32的情况,虚拟地址空间从0~0xC0000000,每个用户进程有3GB可用,TASK_UMMAPPED_BASE起始于0x4000000,即1GB处( TASK_UMMAPPED_BASE = TASK_SIZE / 3),这意味着堆只有1GB空间可供使用,继续增长会进入到mmap区域,这显然不可接受 这里的关键问题在于: 内存映射区域位于虚拟地址空间的中间 因此,在内核版本2.6.7开发期间为IA-32计算机引入了一个新的虚拟地址空间布局 新的布局的思想在于使用固定值限制栈的最大长度(简单来说是将原本栈和mmap共享增长的空间,转移到了mmap和堆共享增长空间)。由于栈是有界的,因此安置内存映射的区域可以在栈末端的下方立即开始,与经典方法相反,mmap区域是自顶向下扩展,由于堆仍然位于虚拟地址空间中较低的区域并向上增长,因此mmap区域和堆可以相对扩展,直至耗尽虚拟地址空间中剩余的区域。 为了确保栈与mmap区域不发生冲突,两者之间设置了一个安全隙 2.建立布局 在do_execve()函数的准备阶段,已经从可执行文件头部读入128字节存放在bprm的缓冲区中,而且运行所需的参数和环境变量也已收集在bprm中 search_binary_handler()函数就是逐个扫描formats队列,直到找到一个匹配的可执行文件格式,运行的事就交给它 1. 如果在这个队列中没有找到相应的可执行文件格式,就要根据文件头部的信息来查找是否有为此种格式设计的可动态安装的模块 2. 如果找到对应的可执行文件格式,就把这个模块安装进内核,并挂入formats队列,然后再重新扫描 在linux_binfmt数据结构中,有三个函数指针 1. load_binary load_binary就是具体的ELF程序装载程序,不同的可执行文件其装载函数也不同 1) a.out格式的装载函数为: load_aout_binary() 2) elf的装载函数为: load_elf_binary() 3) .. 2. load_shlib 3. core_dump 对于ELF文件对应的linux_binfmt结构体来说,结构体如下 static struct linux_binfmt elf_format = { .module = THIS_MODULE, .load_binary = load_elf_binary, .load_shlib = load_elf_library, .core_dump = elf_core_dump, .min_coredump = ELF_EXEC_PAGESIZE, .hasvdso = 1}; 在使用load_elf_binary装载一个ELF二进制文件时,将创建进程的地址空间 如果进程在ELF文件中明确指出需要ASLR机制(即PF_RANDOMIZE被置位)、且全局变量randomize_va_space设置为1,则启动地址空间随机化机制。此外,用户可以通过/proc/sys/kernel/randomize_va_space停用内核对该特性的支持 static int load_elf_binary(struct linux_binprm *bprm, struct pt_regs *regs){ .. if (!(current->personality & ADDR_NO_RANDOMIZE) && randomize_va_space) current->flags |= PF_RANDOMIZE; setup_new_exec(bprm); /* Do this so that we can load the interpreter, if need be. We will change some of these later */ current->mm->free_area_cache = current->mm->mmap_base; current->mm->cached_hole_size = 0; retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP), executable_stack); if (retval < 0) { send_sig(SIGKILL, current, 0); goto out_free_dentry; } current->mm->start_stack = bprm->p; ..} 这再次说明了ASLR这种安全机制是需要操作系统内核支持,并且编译器需要显示指出需要开启指定功能的互相配合的这种模式 static int load_elf_binary(struct linux_binprm *bprm, struct pt_regs *regs){ .. /* Flush all traces of the currently running executable */ retval = flush_old_exec(bprm); if (retval) goto out_free_dentry;} int flush_old_exec(struct linux_binprm * bprm){ .. retval = exec_mmap(bprm->mm); if (retval) goto out; ..}EXPORT_SYMBOL(flush_old_exec); static int exec_mmap(struct mm_struct *mm){ .. arch_pick_mmap_layout(mm); ..} 选择布局的工作由arch_pick_mmap_layout完成,如果对应的体系结构没有提供一个具体的函数,则使用内核的默认例程,我们额外关注一下IA-32如何在经典布局和新的布局之间选择 \linux-2.6.32.63\arch\x86\mm\mmap.c /*如果设置了personality比特位,或栈的增长不受限制,则回退到标准布局*/static int mmap_is_legacy(void){ if (current->personality & ADDR_COMPAT_LAYOUT) return 1; if (current->signal->rlim[RLIMIT_STACK].rlim_cur == RLIM_INFINITY) return 1; //否则,使用新的布局方式 return sysctl_legacy_va_layout;} /* * This function, called very early during the creation of a new * process VM image, sets up which VM layout function to use: */void arch_pick_mmap_layout(struct mm_struct *mm){ if (mmap_is_legacy()) { mm->mmap_base = mmap_legacy_base(); mm->get_unmapped_area = arch_get_unmapped_area; mm->unmap_area = arch_unmap_area; } else { mm->mmap_base = mmap_base(); mm->get_unmapped_area = arch_get_unmapped_area_topdown; mm->unmap_area = arch_unmap_area_topdown; }} 在经典的配置下,mmap区域的起始点是TASK_UNMAPPED_BASE(0x4000000),而标准函数arch_get_unmapped_area用于自下而上地创建新的映射 在使用新布局时,内存映射自顶向下下增长,标准函数arch_get_unmapped_area_topdown负责该工作,我们着重关注一下如何选择内存映射的基地址 \linux-2.6.32.63\arch\x86\mm\mmap.c static unsigned long mmap_base(void){ unsigned long gap = current->signal->rlim[RLIMIT_STACK].rlim_cur; /* 在新的进程布局中,可以根据栈的最大长度,来计算栈最低的可能位置,用作mmap区域的起始点,但内核会确保栈至少可以跨越128MB的空间(即栈最小要有128MB的空间) 另外,如果指定的栈界限非常巨大,那么内核会保证至少有一小部分地址空间不被栈占据 */ if (gap < MIN_GAP) gap = MIN_GAP; else if (gap > MAX_GAP) gap = MAX_GAP; //如果要求使用地址空间随机化机制,则栈的 起始位置会减去一个随机的偏移量,最大为1MB,另外,内核会确保该区域对齐到页帧,这是体系结构的要求 return PAGE_ALIGN(TASK_SIZE - gap - mmap_rnd());} 从某种程度上来说,64位体系结构的情况会好一点,因为不需要在不同的地址空间布局中进行选择,在64位体系结构下,虚拟地址空间如此巨大,以至于堆和mmap区域的碰撞几乎不可能,所以依然可以使用老的进程布局空间,AMD64系统上对虚拟地址空间总是使用经典布局,因此无需区分各种选项,如果设置了PF_RANDOMIZE标志,则进行地址空间随机化,变动原本固定的mmap_base 我们回到最初对load_elf_binary函数的讨论上来,该函数最后需要在适当的位置创建栈 static int load_elf_binary(struct linux_binprm *bprm, struct pt_regs *regs){ .. retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP), executable_stack); if (retval < 0) { send_sig(SIGKILL, current, 0); goto out_free_dentry; } ..} 标准函数setup_arg_pages即用于建立在适当的位置创建栈,该函数需要栈顶的位置作为参数,栈顶由特定于体系结构的常数STACK_TOP给出,而后调用randomize_stack_top,确保在启用地址空间随机化的情况下,对该地址进行随机偏移 三. 内存映射的原理 由于所有用户进程总的虚拟地址空间比可用的物理内存大得多,因此只有最常用的部分才会与物理页帧关联,大部多程序只占用实际内存的一小部分。内核必须提供数据结构,以建立虚拟地址空间的区域和相关数据所在位置之间的关联,例如在映射文本文件时,映射的虚拟内存区必须关联到文件系统在硬盘上存储文件内容的区域 需要明白的是,文件数据在硬盘上的存储通常并不是连续的,而是分布到若干小的区域,内核利用address_space数据结构,提供一组方法从"后备存储器"读取数据,例如从文件系统读取,因此address_space形成了一个辅助层,将映射的数据表示为连续的线性区域,提供给内存管理子系统 按需分配和填充页称之为"按需调页法(demand paging)",它基于处理器和内核之间的交互 1. 进程试图访问用户地址空间中的一个内存地址,但使用页表无法确定物理地址(即物理内存中没有关联页) 2. 处理器接下来触发一个缺页异常,发送到内核 3. 内核会检查负责缺页区域的进程地址空间的数据结构,找到适当的后备存储器,或者确认该访问实际上是不正确的 4. 分配物理内存页,并从后备存储器读取所需数据进行填充 5. 借助于页表将物理内存页并入到用户进程的地址空间(即建立映射),回到发生缺页中断的那行代码位置,应用程序恢复执行 这一系列操作对用户进程是透明的,即进程不会注意到页是实际上在物理内存中(即建立了虚拟内存和物理内存的映射),还是需要通过按需调页加载(映射未建立) 四. 数据结构 我们知道struct mm_struct是进程虚拟内存管理中一个很重要的数据结构,该结构提供了进程在内存布局中所有必要信息,我们着重讨论下下列成员,用于管理用户进程在虚拟地址空间中的所有内存区域 struct mm_struct { .. //虚拟内存区域列表 struct vm_area_struct * mmap; struct rb_root mm_rb; //上一次find_vma的结果 struct vm_area_struct * mmap_cache; ..} 1.树和链表 进程的每个虚拟内存区域都通过一个struct vm_area_struct实例描述,进程的各区域按两种方式排序 1. 在一个单链表上(开始于mm_strcut->mmap) 用户虚拟地址空间中的每个区域由开始和结束地址描述,现存的区域按起始地址以递增次序被归入链表中,扫描链表找到与特定地址关联的区域,在有大量区域时是非常低效的操作(数据密集型的应用程序就是这样),因此vm_area_struct的各个实例还通过红黑树进行管理,可以显著加快扫描速度 2. 在一个红黑树中,根节点位于mm_rb 红黑树是一种二叉查找树,其结点标记有颜色(红或黑),它们具有普通查找树的所有性质(因此扫描特定的结点非常高效),结点的红黑标记也可以简化重新平衡树的过程 增加新区域时(载入新模块、映射新文件等),内核首先搜索红黑树,找到刚好在新区域之前的区域,因此,内核可以向树和线性链表添加新的区域,而无需扫描链表 2.虚拟内存区域的表示 进程虚拟内存的每个区域表示为struct vm_area_struct的一个实例 3.优先查找树 优先查找树(priority search tree)用于建立文件中的一个区域与该区域映射到所有虚拟地址空间之间的关联(例如一个进程文件被多个用户打开) (1)附加的数据结构 每个打开文件(和每个块设备,因为块设备也可以通过设备文件进行内存映射)都表示为struct file的一个实例,该结构包含了一个指向文件地址空间对象struct address_space的指针 该对象(struct address_space)是优先查找树(prio tree)的基础,文件区间与其映射到的地址空间之间的关联即通过优先树建立 \linux-2.6.32.63\include\linux\fs.h struct address_space{ struct inode *host; .. struct prio_tree_root i_mmap; struct list_head i_mmap_nonlinear; ..} struct file { .. struct address_space *f_mapping; ..} 此外,每个文件和块设备都表示为strcut inode的一个实例,struct file是通过open系统调用打开的文件的抽象,与此相反的是,inode则表示系统自身中的对象 \linux-2.6.32.63\include\linux\fs.h struct inode { .. struct address_space *i_mapping; ..} 需要明白的是,虽然我们一直讨论文件区间的映射,但实际上也可以映射不同的东西,例如 1. 直接映射裸(raw)块设备上的区间,而不通过文件系统迂回 2. 在打开文件时,内核将file->f_mapping设置到inode->i_mapping 3. 这使得多个进程可以访问同一个文件,而不会干扰到其他进程 //inode是一个特定于文件的数据结构,而file则是特定于给定进程的 给出struct address_space的实例,内核可以推断相关的inode,而inode可用于访问实际存储文件数据的后备存储器(通常是块设备) 1. 地址空间是优先树的基本要素,而优先树包含了所有相关的vm_area_strcut实例,描述了与inode关联的文件区间到一些虚拟地址空间的映射 2. 由于每个struct vm_area的实例都包含了一个指向所属进程的mm_struct的指针,因此建立了关联 3. vm_area_struct还可以通过i_mmap_nonlinear为表头的双链表与一个地址空间关联,这是非线性映射(nonlinear mapping) 4. 一个给定的strcut vm_area实例,可以包含在两个数据结构中 1) 一个建立进程虚拟地址空间中的区域与潜在的文件数据之间的关联 2) 一个用于查找映射了给定文件区间的所有地址空间 (2)优先树的表示 优先树用来管理表示给定文件中特定区间的所有vm_area_struct实例,这要求该数据结构不仅可以处理重叠,还要能够处理相同的文件区间 重叠区间的管理并不复杂,区间的边界提供了一个唯一索引,可用于将各个区间存储在一个唯一的树节点中,这与基数树的思想非常相似,即如果B、C、D完全包含在另一个区间A中,那么A将是B、C、D的父节点 如果多个相同区间被归入优先树,各个优先树结点表示为一个"struct vm_area_struct->struct prio_tree_node prio_tree_node"实例,该实例与一个vm_set实例在同一个联合中,这可以将一个vm_set(进而vm_area_struct)的链表与一个优先树结点关联起来 在区间插入到优先树时,内核进行如下操作 1. 在vm_area_struct实例链接到优先树中作为结点时,prio_tree_node用于建立必要的关联。为检查是否树中已经有同样的vm_area_struct,内核利用了以下事实 1) vm_set的parent成员与prio_tree_node结构的最后一个成员是相同的,这些数据结构可据此进行协调 2) 由于parent在vm_set中并不使用,内核可以使用parent != NULL来检查当前的vm_area_struct实例是否已经在树中 3) prio_tree_node的定义还确保了在share联合内核的内存布局中,vm_set的head成员与prio_tree_node不重叠,因此二者尽管在同一个联合中,也可以同时使用 4) 因此内核使用vm_set.head指向属于一个共享映射的vm_area_struct实例列表中的第一个实例 2. 如果共享映射的链表包含一个vm_area_struct,则vm_set.list用作表头,链表包含所有涉及的虚拟内存区域 五. 对区域的操作 内核提供了各种函数来操作进程的虚拟内存区域,在建立或删除映射、创建、删除区域、查找用于新区域的适当的内存位置是所需要的标准操作,内核还负责在管理这些数据结构时进行优化 1. 如果一个新区域紧接着现存区域前后直接添加(也包括在两个现存区域之间的情况),内核将涉及的数据结构合并为一个,前提是涉及的"所有"区域的访问权限是相同的,而且是从同一个后备存储器映射的连续数据 2. 如果在区域的开始或结束处进行删除,则必须据此截断现存的数据结构 3. 如果要删除两个区域之间的一个区域,那么一方面需要减小现存数据结构的长度,另一方面需要为形成的新区域创建一个新的数据结构 我们接下来讨论如何搜索与用户空间中一个特定虚拟地址相关的区域 1.将虚拟地址关联到区域 通过虚拟地址,find_vma可以查找用户地址空间中结束地址在给定地址之后的第一个区域,即满足addr < vm_area_struct->vm_end条件的第一个区域 \linux-2.6.32.63\mm\mmap.c /* Look up the first VMA which satisfies addr < vm_end, NULL if none. 1. struct mm_struct *mm: 指明扫描哪个进程的地址空间2. unsigned long addr: 虚拟地址*/struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr){ struct vm_area_struct *vma = NULL; if (mm) { /* Check the cache first. 首先检查缓存 */ /* (Cache hit rate is typically around 35%.) 缓存命中率大约是35% */ vma = mm->mmap_cache; /* 内核首先检查上次处理的区域(mm->mmap_cache)中是否"包含"嗽诩的地址,如果命中,则立即返回指针 */ if (!(vma && vma->vm_end > addr && vma->vm_start <= addr)) { struct rb_node * rb_node; rb_node = mm->mm_rb.rb_node; vma = NULL; //逐步搜索红黑树,rb_node是用于表示树中各个结点的数据结构 while (rb_node) { struct vm_area_struct * vma_tmp; //rb_entry用于从结点取出"有用数据(这里是vm_area_struct实例)" vma_tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb); //如果当前找到的区域结束地址大于目标地址、且起始地址小于目标地址,内核就找到了一个适当的结点 if (vma_tmp->vm_end > addr) { vma = vma_tmp; if (vma_tmp->vm_start <= addr) break; //如果当前区域结束地址大于目标地址,则从左子结点开始 rb_node = rb_node->rb_left; } else //如果当前区域的结束地址小于等于目标地址,则从右子节点开始 rb_node = rb_node->rb_right; } if (vma) //如果找到适当的区域,则将其指针保存在mmap_cache中,因为下一次find_vma调用搜索同一个区域中临近地址的可能性很高 mm->mmap_cache = vma; } } return vma;} EXPORT_SYMBOL(find_vma); find_vma_intersection是另一个辅助函数,用于确认边界为start_addr和end_addr的区间是否完全包含在一个现存区域内部,它基于find_vma \linux-2.6.32.63\include\linux\mm.h /* Look up the first VMA which intersects the interval start_addr..end_addr-1, NULL if none. Assume start_addr < end_addr. */static inline struct vm_area_struct * find_vma_intersection(struct mm_struct * mm, unsigned long start_addr, unsigned long end_addr){ struct vm_area_struct * vma = find_vma(mm,start_addr); if (vma && end_addr <= vma->vm_start) vma = NULL; return vma;} 2.区域合并 在新区域被加到进程的地址空间时,内核会检查它是否可以与一个或多个现存区域合并,vma_merge在可能的情况下,将一个新区域与周边区域合并,它需要很多参数 \linux-2.6.32.63\mm\mmap.c /* 1. struct mm_struct *mm: 相关进程的地址空间实例 2. struct vm_area_struct *prev: 紧接着新区域之前的区域 3. unsigned long addr: 新区域的开始地址 4. unsigned long end: 新区域的结束地址 5. unsigned long vm_flags: 新区域的标志 6. struct anon_vma *anon_vma 7. struct file *file: 如果该区域属于一个文件映射(mmap映射并不一定是文件映射),则file是一个指向表示该文件的file实例的指针 8. pgoff_t pgoff: pgoff指定了映射在文件数据内的偏移量 9. struct mempolicy *policy: 只在NUMA系统上需要 */struct vm_area_struct *vma_merge( struct mm_struct *mm, struct vm_area_struct *prev, unsigned long addr, unsigned long end, unsigned long vm_flags, struct anon_vma *anon_vma, struct file *file, pgoff_t pgoff, struct mempolicy *policy ){ pgoff_t pglen = (end - addr) >> PAGE_SHIFT; struct vm_area_struct *area, *next; /* * We later require that vma->vm_flags == vm_flags, * so this tests vma->vm_flags & VM_SPECIAL, too. */ if (vm_flags & VM_SPECIAL) return NULL; if (prev) next = prev->vm_next; else next = mm->mmap; area = next; if (next && next->vm_end == end) /* cases 6, 7, 8 */ next = next->vm_next; /* Can it merge with the predecessor? 首先检查前一个区域的结束地址是否等于新区域的起始地址(这样才能刚好对接合并起来)(否则不具备合并的条件) */ if (prev && prev->vm_end == addr && mpol_equal(vma_policy(prev), policy) && can_vma_merge_after(prev, vm_flags, anon_vma, file, pgoff)) { /* OK, it can. Can we now merge in the successor as well? 如果是,则内核接下来必须检查两个区域 1. 确认二者的标志和映射的文件相同 2. 文件映射内部的偏移量符合连续区域的要求 3. 两个区域都不包含匿名映射 4. 而且两个区域彼此兼容 */ if (next && end == next->vm_start && mpol_equal(policy, vma_policy(next)) && /* 通过之前调用的can_vma_merge_after、之后调用can_vma_merge_before检查两个区域是否可以合并 如果前一个和后一个区域都可以与当前区域合并,还必须确认前一个和后一个区域的匿名映射可以合并,然后才能创建包含这3个区域的一个单一区域 */ can_vma_merge_before(next, vm_flags, anon_vma, file, pgoff+pglen) && is_mergeable_anon_vma(prev->anon_vma, next->anon_vma)) { /* cases 1, 6 */ //调用vma_adjust执行最后的合并,它会适当地修改涉及的所有数据结构,包括优先树和vm_area_struct实例,还包括释放不再需要的结构实例 vma_adjust(prev, prev->vm_start, next->vm_end, prev->vm_pgoff, NULL); } else /* cases 2, 5, 7 */ vma_adjust(prev, prev->vm_start, end, prev->vm_pgoff, NULL); return prev; } /* * Can this new request be merged in front of next? */ if (next && end == next->vm_start && mpol_equal(policy, vma_policy(next)) && can_vma_merge_before(next, vm_flags, anon_vma, file, pgoff+pglen)) { if (prev && addr < prev->vm_end) /* case 4 */ vma_adjust(prev, prev->vm_start, addr, prev->vm_pgoff, NULL); else /* cases 3, 8 */ vma_adjust(area, addr, next->vm_end, next->vm_pgoff - pglen, NULL); return area; } return NULL;} 3.插入区域 insert_vm_struct是内核用于插入新区域的标准函数,实际工作委托给两个辅助函数 /* Insert vm structure into process list sorted by address * and into the inode's i_mmap tree. If vm_file is non-NULL * then i_mmap_lock is taken here. */int insert_vm_struct(struct mm_struct * mm, struct vm_area_struct * vma){ struct vm_area_struct * __vma, * prev; struct rb_node ** rb_link, * rb_parent; /* * The vm_pgoff of a purely anonymous vma should be irrelevant * until its first write fault, when page's anon_vma and index * are set. But now set the vm_pgoff it will almost certainly * end up with (unless mremap moves it elsewhere before that * first wfault), so /proc/pid/maps tells a consistent story. * * By setting it to reflect the virtual start address of the * vma, merges and splits can happen in a seamless way, just * using the existing file pgoff checks and manipulations. * Similarly in do_mmap_pgoff and in do_brk. */ if (!vma->vm_file) { BUG_ON(vma->anon_vma); vma->vm_pgoff = vma->vm_start >> PAGE_SHIFT; } /* 首先调用find_vma_prepare,通过新区域的起始地址和涉及的地址空间(mm_struct),获取下列信息 1. 前一个区域的vm_area_struct实例 2. 红黑树中保存新区域结点的父节点 3. 包含该区域自身的红黑树叶结点 C语言中函数只允许返回一个值,因此find_vma_prepare只返回一个指向前一个区域的指针作为结果,剩余的信息通过指针参数提供 */ __vma = find_vma_prepare(mm,vma->vm_start,&prev,&rb_link,&rb_parent); if (__vma && __vma->vm_start < vma->vm_end) return -ENOMEM; if ((vma->vm_flags & VM_ACCOUNT) && security_vm_enough_memory_mm(mm, vma_pages(vma))) return -ENOMEM; /* 查找到的信息通过vma_link将新区域合并到该进程现存的数据结构中,在经过一些准备工作之后,该函数将实际工作委托给其他函数,完成实际的工作 1. __vma_link_list: 将新区域放置到进程管理区域的线性链表上,完成该工作,需要提供使用find_vma_prepare找到的前一个和后一个区域 2. __vma_link_rb: 将新区域连接到红黑树 3. __anon_vma_link: 将vm_area_struct实例添加到匿名映射的链表上 4. __vma_link_file: 将相关的address_space和映射(如果是文件映射)关联起来,并使用vma_prio_tree_insert将该区域添加到优先树中,对多个相同区域的处理如上处理 */ vma_link(mm, vma, prev, rb_link, rb_parent); return 0;} 4.创建区域 在向数据结构插入新的内存区域之前,内核必须确认虚拟地址空间中有足够的空闲空间,可用于给定长度的区域,该工作由get_unmapped_area辅助函数完成 我们知道,根据进程虚拟地址空间的布局,会选择不同的映射函数,我们着重讨论在大多数系统上采用的标准函数arch_get_unmapped_area /* Get an address range which is currently unmapped. * For shmat() with addr=0. * * Ugly calling convention alert: * Return value with the low bits set means error value, * ie * if (ret & ~PAGE_MASK) * error = ret; * * This function "knows" that -ENOMEM has the bits set. */#ifndef HAVE_ARCH_UNMAPPED_AREAunsigned long arch_get_unmapped_area(struct file *filp, unsigned long addr, unsigned long len, unsigned long pgoff, unsigned long flags){ struct mm_struct *mm = current->mm; struct vm_area_struct *vma; unsigned long start_addr; //检查是否超过了进程虚拟地址空间的上界 if (len > TASK_SIZE) return -ENOMEM; //检查是否设置了MAP_FIXED标志,该标志表示映射将在固定地址创建,如果是,内核只会确保该地址满足对齐要求(按页对齐),而且所要求的区间完全在可用地址空间中 if (flags & MAP_FIXED) return addr; if (addr) { addr = PAGE_ALIGN(addr); vma = find_vma(mm, addr); //如果指定了ige特定的优先选用(非固定地址)地址,内核会检查该区域是否与现存区域重叠,如果不重叠,则将该地址作为目标返回 if (TASK_SIZE - len >= addr && (!vma || addr + len <= vma->vm_start)) return addr; } if (len > mm->cached_hole_size) { start_addr = addr = mm->free_area_cache; } else { start_addr = addr = TASK_UNMAPPED_BASE; mm->cached_hole_size = 0; } full_search: /* 否则,内核必须遍历进程中可用的区域,设法找到一个大小适当的空闲区域 实际的遍历,或者开始于虚拟地址空间中最后一个"空洞"的地址 或者开始于全局的起始地址TASK_UNMAPPED_BASE */ for (vma = find_vma(mm, addr); ; vma = vma->vm_next) { /* At this point: (!vma || addr < vma->vm_end). */ if (TASK_SIZE - len < addr) { /* * Start a new search - just in case we missed * some holes. */ if (start_addr != TASK_UNMAPPED_BASE) { addr = TASK_UNMAPPED_BASE; start_addr = addr; mm->cached_hole_size = 0; goto full_search; } /* 如果搜索持续到用户地址空间的末端(TASK_SIZE)。仍然没有找到适当的区域,则内核返回一个-ENOMEM错误,错误必须发送到用户空间,且由相关的应用程序来处理 错误码表示虚拟内存中可用内存不足,无法满足应用程序的请求 */ return -ENOMEM; } if (!vma || addr + len <= vma->vm_start) { /* * Remember the place where we stopped the search: */ mm->free_area_cache = addr + len; return addr; } if (addr + mm->cached_hole_size < vma->vm_start) mm->cached_hole_size = vma->vm_start - addr; addr = vma->vm_end; }}#endif void arch_unmap_area(struct mm_struct *mm, unsigned long addr){ /* * Is this a new hole at the lowest possible address? */ if (addr >= TASK_UNMAPPED_BASE && addr < mm->free_area_cache) { mm->free_area_cache = addr; mm->cached_hole_size = ~0UL; }} 六. 地址空间 文件的内存映射可以认为是两个不同的地址空间之间的映射,即用户进程的虚拟地址空间和文件系统所在的地址空间。在内核创建一个映射时,必须建立两个地址之间的关联,以支持二者以读写请求的形式通信,vm_operations_struct结构即用于完成该工作,它提供了一个操作,来读取已经映射到虚拟地址空间、但其内容尚未进入物理内存的页 struct vm_area_struct{ .. struct vm_operations_struct *vm_ops; ..} 但该操作不了解映射类型或其性质的相关信息,由于存在许多种类的文件映射(不同类型文件系统上的普通文件、设备文件等),因此需要更多的信息,即内核需要更详细地说明数据源所在的地址空间 address_space结构,即为该目的定义,包含了有关映射的附加信息,每一个文件映射都有一个先关的address_space实例,每个地址空间都有一组相关的操作,以函数指针的形式保存在如下结构中 \linux-2.6.32.63\include\linux\fs.h struct address_space_operations { //writepage将一页的内容从物理内存写回到块设备上对应的位置,以便永久保存更改的内容 int (*writepage)(struct page *page, struct writeback_control *wbc); //readpage从潜在的块设备读取一页到物理内存中 int (*readpage)(struct file *, struct page *); void (*sync_page)(struct page *); /* Write back some dirty pages from this mapping. */ int (*writepages)(struct address_space *, struct writeback_control *); /* Set a page dirty. Return true if this dirtied it set_page_dirty表示一页的内容已经改变,即与块设备上的原始内容不再匹配 */ int (*set_page_dirty)(struct page *page); int (*readpages)(struct file *filp, struct address_space *mapping, struct list_head *pages, unsigned nr_pages); int (*write_begin)(struct file *, struct address_space *mapping, loff_t pos, unsigned len, unsigned flags, struct page **pagep, void **fsdata); int (*write_end)(struct file *, struct address_space *mapping, loff_t pos, unsigned len, unsigned copied, struct page *page, void *fsdata); /* Unfortunately this kludge is needed for FIBMAP. Don't use it */ sector_t (*bmap)(struct address_space *, sector_t); void (*invalidatepage) (struct page *, unsigned long); int (*releasepage) (struct page *, gfp_t); ssize_t (*direct_IO)(int, struct kiocb *, const struct iovec *iov, loff_t offset, unsigned long nr_segs); int (*get_xip_mem)(struct address_space *, pgoff_t, int, void **, unsigned long *); /* migrate the contents of a page to the specified target */ int (*migratepage) (struct address_space *, struct page *, struct page *); int (*launder_page) (struct page *); int (*is_partially_uptodate) (struct page *, read_descriptor_t *, unsigned long); int (*error_remove_page)(struct address_space *, struct page *);}; vm_operations_struct和address_space之间的联系如何建立,这里不存在将一个结构的实例分配到另一个结构的静态连接,而是这两个结构仍然使用内核为vm_operations_struct提供的标准实现连接起来,几乎所有的文件系统都使用了这种方式 \linux-2.6.32.63\mm\filemap.c const struct vm_operations_struct generic_file_vm_ops = { .fault = filemap_fault,}; filemap_fault的实现使用了相关映射的readpage方法,因此也采用了上述的address_space 七. 内存映射 我们继续讨论在建立映射时,内核和应用程序之间的交互,我们知道C标准库提供了mmap函数建立映射,在内核一端,提供了两个系统调用mmap、mmap2。某些体系结构实现了两个版本(例如IA64、Sparc(64)),其他的只实现了第一个(AMD64)或第二个(IA-32),两个函数的参数相同 asmlinkage long sys_mmap(unsigned long, unsigned long, unsigned long, unsigned long, unsigned long, unsigned long); 这两个调用都会在用户虚拟地址空间中的pos位置,建立一个长度为len的映射,其访问权限通过prot定义,flags是一个标志集用于设置一些参数,相关的文件通过其文件描述符fs标识 mmap和mmap2之间的差别在于偏移量的予以(off) 1. mmap: 位置的单位是字节 2. mmap2: 位置的单位是页(PAGE_SIZE) //它们都表示映射在文件中开始的位置,即使文件本身比可用空间大,也可以映射文件的一部分 通常C标准库只提供一个函数,由应用程序来创建内存映射,接下来该函数调用在内部转换为适合于体系结构的系统调用 在使用munmap系统调用删除映射时,因为不需要文件偏移量,因此不需要munmap2,只需要提供映射的虚拟地址即可 1.创建映射 #include /*1. start:映射区的开始地址,设置为0时表示由系统决定映射区的起始地址 2. length:映射区的长度。//长度单位是 以字节为单位,不足一内存页按一内存页处理3. prot:期望的内存保护标志,不能与文件的打开模式冲突。是以下的某个值,可以通过or运算合理地组合在一起 1) PROT_EXEC //页内容可以被执行 2) PROT_READ //页内容可以被读取 3) PROT_WRITE //页可以被写入 4) PROT_NONE //页不可访问4. flags:指定映射对象的类型,映射选项和映射页是否可以共享。它的值可以是一个或者多个以下位的组合体 1) MAP_FIXED //使用指定的映射起始地址,如果由start和len参数指定的内存区重叠于现存的映射空间,重叠部分将会被丢弃。如果指定的起始地址不可用,操作将会失败。并且起始地址必须落在页的边界上。如果没有设置该标志,内核可以在受阻时随意改变目标地址 2) MAP_SHARED //与其它所有映射这个对象的进程共享映射空间。对共享区的写入,相当于输出到文件。直到msync()或者munmap()被调用,文件实际上不会被更新。如果一个对象(通常是文件)在几个进程之间共享时,则必须使用MAP_SHARED 3) MAP_PRIVATE //建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个。 4) MAP_DENYWRITE //这个标志被忽略 5) MAP_EXECUTABLE //同上 6) MAP_NORESERVE //不要为这个映射保留交换空间。当交换空间被保留,对映射区修改的可能会得到保证。当交换空间不被保留,同时内存不足,对映射区的修改会引起段违例信号。 7) MAP_LOCKED //锁定映射区的页面,从而防止页面被交换出内存 8) MAP_GROWSDOWN //用于堆栈,告诉内核VM系统,映射区可以向下扩展。 9) MAP_ANONYMOUS //匿名映射,映射区不与任何文件关联,fd和off参数被忽略,此类映射可用于为应用程序分配类似malloc所用的内存 10) MAP_ANON //MAP_ANONYMOUS的别称,不再被使用 11) MAP_FILE //兼容标志,被忽略 12) MAP_32BIT //将映射区放在进程地址空间的低2GB,MAP_FIXED指定时会被忽略。当前这个标志只在x86-64平台上得到支持。 13) MAP_POPULATE //为文件映射通过预读的方式准备好页表。随后对映射区的访问不会被页违例阻塞。 14) MAP_NONBLOCK //仅和MAP_POPULATE一起使用时才有意义。不执行预读,只为已存在于内存中的页面建立页表入口。5. fd:有效的文件描述词。一般是由open()函数返回,其值也可以设置为-1,此时需要指定flags参数中的MAP_ANON,表明进行的是匿名映射。6. off_toffset:被映射对象内容的起点*/void* mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset); prot指定了映射内存的访问权限,但并非所有处理器都实现了所有组合,因而区域实际授予的权限可能比指定的要多,尽管内核尽力设置指定的权限,但它只能保证实际设置的访问权限不会比指定的权限有更多的限制 sys_mmap、sys_mmap2最终会将工作委托给do_mmap_pgoff \linux-2.6.32.63\mm\mmap.c /* * The caller must hold down_write(¤t->mm->mmap_sem).*/unsigned long do_mmap_pgoff(struct file *file, unsigned long addr, unsigned long len, unsigned long prot, unsigned long flags, unsigned long pgoff){ struct mm_struct * mm = current->mm; struct inode *inode; unsigned int vm_flags; int error; unsigned long reqprot = prot; /* * Does the application expect PROT_READ to imply PROT_EXEC? * * (the exception is when the underlying filesystem is noexec * mounted, in which case we dont add PROT_EXEC.) */ if ((prot & PROT_READ) && (current->personality & READ_IMPLIES_EXEC)) if (!(file && (file->f_path.mnt->mnt_flags & MNT_NOEXEC))) prot |= PROT_EXEC; if (!len) return -EINVAL; if (!(flags & MAP_FIXED)) addr = round_hint_to_min(addr); /* Careful about overflows.. */ len = PAGE_ALIGN(len); if (!len) return -ENOMEM; /* offset overflow? */ if ((pgoff + (len >> PAGE_SHIFT)) < pgoff) return -EOVERFLOW; /* Too many mappings? */ if (mm->map_count > sysctl_max_map_count) return -ENOMEM; /* Obtain the address to map to. we verify (or select) it and ensure * that it represents a valid section of the address space. 首先调用get_unmapped_area函数,在虚拟地址空间中找到一个适当的区域用于映射 1. 应用程序可以对映射指定固定地址 2. 建议一个地址 3. 由内核选择地址 */ addr = get_unmapped_area(file, addr, len, pgoff, flags); if (addr & ~PAGE_MASK) return addr; /* Do simple checking here so the lower-level routines won't have * to. we assume access permissions have been handled by the open * of the memory object, so we don't do any here. calc_vm_prot_bits、calc_vm_flag_bits将系统调用中指定的标志和访问权限常数合并到一个共同的标志集中 在后续的操作中比较易于处理(MAP_、PROT_表示转换为VM_前缀的标志) 最有趣的是,内核在从当前运行进程的mm_struct实例获得def_flags之后,又将其包含到标志集中,def_flags的值可以以下 1. 0: 不会改变结果标志集 2. VM_LOCK: 意味着随后映射的页无法换出 为了设置def_flags的值,进程必须发出mlockall系统调用 */ vm_flags = calc_vm_prot_bits(prot) | calc_vm_flag_bits(flags) | mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC; if (flags & MAP_LOCKED) if (!can_do_mlock()) return -EPERM; /* mlock MCL_FUTURE? */ if (vm_flags & VM_LOCKED) { unsigned long locked, lock_limit; locked = len >> PAGE_SHIFT; locked += mm->locked_vm; lock_limit = current->signal->rlim[RLIMIT_MEMLOCK].rlim_cur; lock_limit >>= PAGE_SHIFT; if (locked > lock_limit && !capable(CAP_IPC_LOCK)) return -EAGAIN; } inode = file ? file->f_path.dentry->d_inode : NULL; if (file) { switch (flags & MAP_TYPE) { case MAP_SHARED: if ((prot&PROT_WRITE) && !(file->f_mode&FMODE_WRITE)) return -EACCES; /* * Make sure we don't allow writing to an append-only * file.. */ if (IS_APPEND(inode) && (file->f_mode & FMODE_WRITE)) return -EACCES; /* * Make sure there are no mandatory locks on the file. */ if (locks_verify_locked(inode)) return -EAGAIN; vm_flags |= VM_SHARED | VM_MAYSHARE; if (!(file->f_mode & FMODE_WRITE)) vm_flags &= ~(VM_MAYWRITE | VM_SHARED); /* fall through */ case MAP_PRIVATE: if (!(file->f_mode & FMODE_READ)) return -EACCES; if (file->f_path.mnt->mnt_flags & MNT_NOEXEC) { if (vm_flags & VM_EXEC) return -EPERM; vm_flags &= ~VM_MAYEXEC; } if (!file->f_op || !file->f_op->mmap) return -ENODEV; break; default: return -EINVAL; } } else { switch (flags & MAP_TYPE) { case MAP_SHARED: /* * Ignore pgoff. */ pgoff = 0; vm_flags |= VM_SHARED | VM_MAYSHARE; break; case MAP_PRIVATE: /* * Set pgoff according to addr for anon_vma. */ pgoff = addr >> PAGE_SHIFT; break; default: return -EINVAL; } } error = security_file_mmap(file, reqprot, prot, flags, addr, 0); if (error) return error; error = ima_file_mmap(file, prot); if (error) return error; //在检查过参数并设置好所有需要的标志之后,剩余的工作委托给mmap_region return mmap_region(file, addr, len, flags, vm_flags, pgoff);}EXPORT_SYMBOL(do_mmap_pgoff);\linux-2.6.32.63\mm\mmap.cunsigned long mmap_region(struct file *file, unsigned long addr, unsigned long len, unsigned long flags, unsigned int vm_flags, unsigned long pgoff){ struct mm_struct *mm = current->mm; struct vm_area_struct *vma, *prev; int correct_wcount = 0; int error; struct rb_node **rb_link, *rb_parent; unsigned long charged = 0; struct inode *inode = file ? file->f_path.dentry->d_inode : NULL; /* Clear old maps */ error = -ENOMEM;munmap_back: //调用find_vma_prepare函数,来查找前一个和后一个区域的vm_area_struct实例,以及红黑树中结点对应的数据 vma = find_vma_prepare(mm, addr, &prev, &rb_link, &rb_parent); //如果在指定的映射位置已经存在一个映射,则通过do_munmap删除它 if (vma && vma->vm_start < addr + len) { if (do_munmap(mm, addr, len)) return -ENOMEM; goto munmap_back; } /* Check against address space limit. */ if (!may_expand_vm(mm, len >> PAGE_SHIFT)) return -ENOMEM; /* * Set 'VM_NORESERVE' if we should not account for the * memory use of this mapping. 如果没有设置MAP_NORESERVE标志或内核参数sysctl_overcommit_memory设置为OVERCOMMIT_NEVER(即不允许过量使用) */ if ((flags & MAP_NORESERVE)) { /* We honor MAP_NORESERVE if allowed to overcommit */ if (sysctl_overcommit_memory != OVERCOMMIT_NEVER) vm_flags |= VM_NORESERVE; /* hugetlb applies strict overcommit unless MAP_NORESERVE */ if (file && is_file_hugepages(file)) vm_flags |= VM_NORESERVE; } ..} 在内核分配了所需的内存之后,会执行下列步骤 1. 分配并初始化一个新的vm_area_struct实例,并插入到进程的链表/树数据结构中2. 用特定于文件的函数file->f_op->mmap创建映射,大都数文件系统将generic_file_mmap用于该目的,它所作的所有工作,就是将映射的vm_ops成员设置为generic_file_vm_opsvma->vm_ops = &generic_file_vm_ops;\linux-2.6.32.63\mm\filemap.cconst struct vm_operations_struct generic_file_vm_ops = { .fault = filemap_fault,}; filemap_fault在应用程序访问映射区域但对应数据不再物理内存时调用,filemap_fault借助于潜在文件系统的底层例程取得所需数据,并读取到物理内存,这些对应用程序是透明的,即映射数据不是在建立映射时立即读入内存,只有实际需要相应数据时才进行读取 这也是虚拟内存的一个核心思想,即对应用程序看到的"platform memory"来说,大部多或者说进程刚启动的时候都只一个空的东西,底层并没有实际的基础数据支撑,只有到了正常需要的时候才会去建立这个联系,这是一种典型的"延迟绑定"思想 如果设置了VM_LOCKED,或者通过系统调用的标志参数显示传递进来,或者通过mlockall机制隐式设置,内核都会调用make_pages_present依次扫描映射中每一页,对每一页触发缺页异常以便读入其数据,这同时意味着失去了延迟读取带来的性能提高,但内核可以确保在映射建立后所涉及的页总是在物理内存中,即VM_LOCKED标志用来防止从内存换出页,因此这些页必须先读进来 2.删除映射 从虚拟地址空间删除现存映射,必须使用munmap系统调用,它需要两个参数 #include/*1. start: 解除映射区域的起始地址2. length长度*/int munmap(void *start, size_t length); /* Munmap is split into 2 main parts -- this part which finds * what needs doing, and the areas themselves, which do the * work. This now handles partial unmappings. * Jeremy Fitzhardinge */int do_munmap(struct mm_struct *mm, unsigned long start, size_t len){ unsigned long end; struct vm_area_struct *vma, *prev, *last; if ((start & ~PAGE_MASK) || start > TASK_SIZE || len > TASK_SIZE-start) return -EINVAL; if ((len = PAGE_ALIGN(len)) == 0) return -EINVAL; /* Find the first overlapping VMA 内核首先必须调用find_vma_prev,以找到解除映射区域的vm_area_struct实例 */ vma = find_vma_prev(mm, start, &prev); if (!vma) return 0; /* we have start < vma->vm_end */ /* if it doesn't overlap, we have nothing.. */ end = start + len; if (vma->vm_start >= end) return 0; /* * If we need to split any vma, do it now to save pain later. * * Note: mremap's move_vma VM_ACCOUNT handling assumes a partially * unmapped vm_area_struct will remain in use: so lower split_vma * places tmp vma above, and higher split_vma places tmp vma below. 如果解除映射区域的起始地址与find_vma_prev找到的区域起始地址不同,则只解除部分映射,而不是解除整个映射区域 */ if (start > vma->vm_start) { //内核首先将现存的映射划分为几个部分,映射的前一部分不需要解除映射 int error = split_vma(mm, vma, start, 0); if (error) return error; prev = vma; } /* Does it split the last one? */ last = find_vma(mm, end); //如果解除映射的部分区域的末端与愿区域末端不重合,那么原区域后部仍然有一部分未解除映射,因此需要再进行一次映射切割 if (last && end > last->vm_start) { int error = split_vma(mm, last, end, 1); if (error) return error; } vma = prev? prev->vm_next: mm->mmap; /* * unlock any mlock()ed ranges before detaching vmas */ if (mm->locked_vm) { struct vm_area_struct *tmp = vma; while (tmp && tmp->vm_start < end) { if (tmp->vm_flags & VM_LOCKED) { mm->locked_vm -= vma_pages(tmp); munlock_vma_pages_all(tmp); } tmp = tmp->vm_next; } } /* * Remove the vma's, and unmap the actual pages 内核调用detach_vmas_to_be_unmapped列出所有需要解除映射的区域,由于解除映射操作可能涉及地址空间中的任何区域,很可能影响连续几个区域 内核可能拆分这一系列区域中首尾两端的区域,以确保只影响到完整的区域 */ detach_vmas_to_be_unmapped(mm, vma, prev, end); //调用unmap_region从页表删除与映射相关的所有项,完成后,内核还必须确保将相关的项从TLB移除或者使之无效 unmap_region(mm, vma, prev, start, end); /* Fix up all other VM information 用remove_vma_list释放vm_area_strcut实例占用的空间,完成从内核中删除映射的工作 */ remove_vma_list(mm, vma); return 0;} 3.非线性映射 普通的映射将文件中一个连续的部分映射到虚拟内存中一个同样连续的部分,如果需要将文件的不同部分以不同顺序映射到虚拟内存的连续区域中,实现的一个简单有效的方法是使用非线性映射,内核提供了一个独立的系统调用,专门用于该目的 \linux-2.6.32.63\mm\fremap.c /** * sys_remap_file_pages - remap arbitrary pages of an existing VM_SHARED vma * @start: start of the remapped virtual memory range * @size: size of the remapped virtual memory range * @prot: new protection bits of the range (see NOTE) * @pgoff: to-be-mapped page of the backing store file * @flags: 0 or MAP_NONBLOCKED - the later will cause no IO. * * sys_remap_file_pages remaps arbitrary pages of an existing VM_SHARED vma * (shared backing store file). * * This syscall works purely via pagetables, so it's the most efficient * way to map the same (large) file into a given virtual window. Unlike * mmap()/mremap() it does not create any new vmas. The new mappings are * also safe across swapout. * * NOTE: the @prot parameter right now is ignored (but must be zero), * and the vma's default protection is used. Arbitrary protections * might be implemented in the future. */SYSCALL_DEFINE5(remap_file_pages, unsigned long, start, unsigned long, size, unsigned long, prot, unsigned long, pgoff, unsigned long, flags){ struct mm_struct *mm = current->mm; struct address_space *mapping; unsigned long end = start + size; struct vm_area_struct *vma; int err = -EINVAL; int has_write_lock = 0; if (prot) return err; /* * Sanitize the syscall parameters: */ start = start & PAGE_MASK; size = size & PAGE_MASK; /* Does the address range wrap, or is the span zero-sized? */ if (start + size <= start) return err; /* Can we represent this offset inside this architecture's pte's? */#if PTE_FILE_MAX_BITS < BITS_PER_LONG if (pgoff + (size >> PAGE_SHIFT) >= (1UL << PTE_FILE_MAX_BITS)) return err;#endif /* We need down_write() to change vma->vm_flags. */ down_read(&mm->mmap_sem); retry: vma = find_vma(mm, start); /* * Make sure the vma is shared, that it supports prefaulting, * and that the remapped range is valid and fully within * the single existing vma. vm_private_data is used as a * swapout cursor in a VM_NONLINEAR vma. */ if (!vma || !(vma->vm_flags & VM_SHARED)) goto out; if (vma->vm_private_data && !(vma->vm_flags & VM_NONLINEAR)) goto out; if (!(vma->vm_flags & VM_CAN_NONLINEAR)) goto out; if (end <= start || start < vma->vm_start || end > vma->vm_end) goto out; /* Must set VM_NONLINEAR before any pages are populated. */ if (!(vma->vm_flags & VM_NONLINEAR)) { /* Don't need a nonlinear mapping, exit success */ if (pgoff == linear_page_index(vma, start)) { err = 0; goto out; } if (!has_write_lock) { up_read(&mm->mmap_sem); down_write(&mm->mmap_sem); has_write_lock = 1; goto retry; } mapping = vma->vm_file->f_mapping; /* * page_mkclean doesn't work on nonlinear vmas, so if * dirty pages need to be accounted, emulate with linear * vmas. */ if (mapping_cap_account_dirty(mapping)) { unsigned long addr; struct file *file = vma->vm_file; flags &= MAP_NONBLOCK; get_file(file); addr = mmap_region(file, start, size, flags, vma->vm_flags, pgoff); fput(file); if (IS_ERR_VALUE(addr)) { err = addr; } else { BUG_ON(addr != start); err = 0; } goto out; } spin_lock(&mapping->i_mmap_lock); flush_dcache_mmap_lock(mapping); vma->vm_flags |= VM_NONLINEAR; vma_prio_tree_remove(vma, &mapping->i_mmap); vma_nonlinear_insert(vma, &mapping->i_mmap_nonlinear); flush_dcache_mmap_unlock(mapping); spin_unlock(&mapping->i_mmap_lock); } if (vma->vm_flags & VM_LOCKED) { /* * drop PG_Mlocked flag for over-mapped range */ unsigned int saved_flags = vma->vm_flags; munlock_vma_pages_range(vma, start, start + size); vma->vm_flags = saved_flags; } mmu_notifier_invalidate_range_start(mm, start, start + size); err = populate_range(mm, vma, start, size, pgoff); mmu_notifier_invalidate_range_end(mm, start, start + size); if (!err && !(flags & MAP_NONBLOCK)) { if (vma->vm_flags & VM_LOCKED) { /* * might be mapping previously unmapped range of file */ mlock_vma_pages_range(vma, start, start + size); } else { if (unlikely(has_write_lock)) { downgrade_write(&mm->mmap_sem); has_write_lock = 0; } make_pages_present(start, start+size); } } /* * We can't clear VM_NONLINEAR because we'd have to do * it after ->populate completes, and that would prevent * downgrading the lock. (Locks can't be upgraded). */ out: if (likely(!has_write_lock)) up_read(&mm->mmap_sem); else up_write(&mm->mmap_sem); return err;} sys_remap_file_pages系统调用允许重排映射中的页,使得内存与文件中的顺序不再等价,实现该特性无需移动内存中的数据,而是通过操作进程的页表实现 sys_remap_file_pages可以将现存映射(位置pgoff、长度size)移动到虚拟内存中的一个新位置,start标识了移动的目标映射,因而必须落入某个现存映射的地址范围中,它还指定了由pgoff和size标识的页移动的目标位置 八. 反向映射 内核通过页表,可以建立虚拟和物理地址之间的联系,以及进程的一个内存映 射区域与其虚拟内存页地址之间的关联,我们接下来讨论最后一个联系: 物理内存页和该页所属进程(准确地说去所有使用该页的进程的对应页表项)之间的联系。在换出页时需要该关联,以便更新所有涉及的进程,因为页已经换出,必 须在页表中标明,以便在这些进程访问对相应页的时候产生缺页中断的时候能够知道需要进行页换入 1. 在映射一页时,它关联到一个进程,但不一定处于使用中 2. 对页的引用次数表明页使用的活跃程度,为确定该数目,内核首先必须建立页和所有使用者之间的关联,还必须借助一些技巧来计算出页使用的活跃程度 因此第一个任务需要建立页和所有映射了该页的位置之间的关联,为此内核使用一些附加的数据结构和函数,采用一种逆向映射方法 1.数据结构 内核使用饿了简洁的数据结构,以最小化逆向映射的管理开销,struct page结构包含了一个用于实现逆向映射的成员 struct page{ .. /* 内存管理子系统中映射的页表项计数,用于表示页是否已经映射,还用于限制逆向映射搜索 */ atomic_t _mapcount; ..} _mapcount表明共享该页的位置的数目,计数器的初始值为-1,在页插入到逆向映射数据结构时,计数器赋值为0,页每次增加一个使用者时,计数器加1,这使得内核能够快速检查在所有者之外,该页有多少使用者 但是要完成逆向映射的目的: 给定page实例,找到所有映射了该物理内存页的位置,还需要两个其他的数据机构发挥作用 1. 优先查找树中嵌入了属于非匿名映射的每个区域 2. 指向内存中同一页的匿名区域的链表 struct vm_area_struct { .. union { /* links to address_space->i_mmap or i_mmap_nonlinear */ struct { struct list_head list; void *parent; struct vm_area_struct *head; } vm_set; struct prio_tree_node prio_tree_node; } shared; /* 在文件的某一页经过写时复制之后,文件的MAP_PRIVATE虚拟内存区域可能同时在i_mmap树和anon_vma链表中,MAP_SHARED虚拟内存区域只能在i_mmap树中 匿名的MAP_PRIVATE、栈或brk虚拟内存区域(file指针为NULL)只能处于anon_vma链表中 */ struct list_head anon_vma_node; /* anon_vma entry 对该成员的访问通过anon_vma->lock串行化 */ struct anon_vma *anon_vma; /* anonymous VMA object 对该成员的 ..} 内核在实现逆向映射时采用的技巧是,不直接保存页和相关的使用者之间的关联,而只保存页和页所在区域的关联,包含该页的所有其他区域(进而所有的使用者)都可以通过这以上数据机构找到。该方法又名"基于对象的逆向映射(object-based reverse mapping)",因为没有存储页和使用者之间的直接关联,相反,在两者之间插入了另一个对象(该页所在的区域) 2.建立逆向映射 在创建逆向映射时,有必要区分两个备选项: 匿名页和基于文件映射的页,因为用于管理这两种选项的数据机构不同 1. 匿名页 将匿名页插入到逆向映射数据结构中有两种方法 1) 对新的匿名页必须调用page_add_new_anon_rmap: 将映射计数器page->_mapcount设置为0 2) 已经有引用计数的页,则使用page_add_anon_rmap: 将计数器加1 2. 基于文件映射的页 所需要的只是对_mapcount变量加1(原子操作)并更新各内存域的统计量 3.使用逆向映射 page_referenced是一个重要的函数,很好地使用了逆向映射方案所涉及的数据结构,它统计了最近活跃地使用(即访问)了某个共享页的进程的数目,这不同于该页映射到的区域数目,后者大多数情况下是静态的,而如果处于使用中,前者(访问频率)会很快发生改变 该函数相当于一个多路复用器 1. 对匿名页调用page_referenced_anon 2. 对于基于文件映射的页调用page_referenced_file (1)匿名页函数 \linux-2.6.32.63\mm\rmap.c static int page_referenced_anon(struct page *page, struct mem_cgroup *mem_cont, unsigned long *vm_flags){ unsigned int mapcount; struct anon_vma *anon_vma; struct vm_area_struct *vma; int referenced = 0; //调用page_lock_anon_vma辅助函数,找到引用了某个特定page实例的区域的列表 anon_vma = page_lock_anon_vma(page); if (!anon_vma) return referenced; mapcount = page_mapcount(page); //在找到匹配的anon_vma实例之后,内核遍历链表中的所有区域,分别调用page_referenced_one,计算使用该页的次数(在系统换入/换出页时,需要一些校正) list_for_each_entry(vma, &anon_vma->head, anon_vma_node) { /* * If we are reclaiming on behalf of a cgroup, skip * counting on behalf of references from different * cgroups */ if (mem_cont && !mm_match_cgroup(vma->vm_mm, mem_cont)) continue; referenced += page_referenced_one(page, vma, &mapcount, vm_flags); if (!mapcount) break; } page_unlock_anon_vma(anon_vma); return referenced;} page_referenced_one分为两个步骤执行其任务 1. 找到指向该页的页表项 2. 检查页表项是否设置了_PAGE_ACCESSED标志位,然后清除该标志位,每次访问该页时,硬件会设置该标志(如果特定体系结构有需要,内核也会提供额外的支持),如果设置了该标志位,则引用计数器加1,否则不变,因此经常使用的页引用计数较高,而很少使用页相反,因此内核会根据引用计数,立即就能判断某一页似乎否重要 (2)基于文件映射的函数 在检查基于文件映射的页的引用次数时,采用的方法类似 static int page_referenced_file(struct page *page, struct mem_cgroup *mem_cont, unsigned long *vm_flags){ unsigned int mapcount; struct address_space *mapping = page->mapping; pgoff_t pgoff = page->index << (PAGE_CACHE_SHIFT - PAGE_SHIFT); struct vm_area_struct *vma; struct prio_tree_iter iter; int referenced = 0; /* * The caller's checks on page->mapping and !PageAnon have made * sure that this is a file page: the check for page->mapping * excludes the case just before it gets set on an anon page. */ BUG_ON(PageAnon(page)); /* * The page lock not only makes sure that page->mapping cannot * suddenly be NULLified by truncation, it makes sure that the * structure at mapping cannot be freed and reused yet, * so we can safely take mapping->i_mmap_lock. */ BUG_ON(!PageLocked(page)); spin_lock(&mapping->i_mmap_lock); /* * i_mmap_lock does not stabilize mapcount at all, but mapcount * is more likely to be accurate if we note it after spinning. */ mapcount = page_mapcount(page); vma_prio_tree_foreach(vma, &iter, &mapping->i_mmap, pgoff, pgoff) { /* * If we are reclaiming on behalf of a cgroup, skip * counting on behalf of references from different * cgroups */ if (mem_cont && !mm_match_cgroup(vma->vm_mm, mem_cont)) continue; referenced += page_referenced_one(page, vma, &mapcount, vm_flags); if (!mapcount) break; } spin_unlock(&mapping->i_mmap_lock); return referenced;} 九.堆的管理 堆是进程中用于动态分配变量和数据的内存区域,堆的管理对应用程序员来说不是直接可见的,它依赖标准库提供的各个辅助函数来分配任意长度的内存区 堆是一个连续的内存区域,在扩展时自下至上(经典布局)增长,我们之前讨论的mm_strcut结构,包含了堆在虚拟地址空间中的起始和当前结束地址 struct mm_struct { .. unsigned long start_brk, brk; ..} Glibc和内核使用mmap、brk作为堆管理的接口,而其中brk是一种经典的系统调用,负责扩展/收缩堆,最新的GNU malloc实现,使用了一种组合的方法,使用brk和匿名映射,该方法提供了更好的性能,而且在分配较大内存区时具有更好的性能 #include //brk系统调用只需要一个参数,用于指定堆在虚拟地址空间中新的结束地址(如果堆将要收缩,可以小于当前值) int brk(void *addr); brk机制不是一个独立的内核概念,而是基于匿名映射实现,以减少内部的开销 SYSCALL_DEFINE1(brk, unsigned long, brk){ unsigned long rlim, retval; unsigned long newbrk, oldbrk; struct mm_struct *mm = current->mm; unsigned long min_brk; down_write(&mm->mmap_sem); #ifdef CONFIG_COMPAT_BRK min_brk = mm->end_code;#else min_brk = mm->start_brk;#endif if (brk < min_brk) goto out; /* * Check against rlimit here. If this check is done later after the test * of oldbrk with newbrk then it can escape the test and let the data * segment grow beyond its set limit the in case where the limit is * not page aligned -Ram Gupta 检查用作brk值的新地址是否超出堆的限制 */ rlim = current->signal->rlim[RLIMIT_DATA].rlim_cur; if (rlim < RLIM_INFINITY && (brk - mm->start_brk) + (mm->end_data - mm->start_data) > rlim) goto out; //将请求的地址按页长度对齐,以确保brk的新值(原值也同样)是系统页长度的倍数,一页是brk能分配的最小内存区域 newbrk = PAGE_ALIGN(brk); oldbrk = PAGE_ALIGN(mm->brk); if (oldbrk == newbrk) goto set_brk; /* Always allow shrinking brk. */ if (brk <= mm->brk) { if (!do_munmap(mm, newbrk, oldbrk-newbrk)) goto set_brk; goto out; } /* Check against existing mmap mappings. find_vma_intersection接下来检查扩大的堆是否与进程中现存的映射重叠(因为堆生长的方向是不断靠近映射所在的虚拟内存区域) */ if (find_vma_intersection(mm, oldbrk, newbrk+PAGE_SIZE)) goto out; /* Ok, looks good - let it rip. 扩大堆的实际工作委托给do_brk,函数总是返回mm->brk的新值,无论是扩大,还是缩小 do_brk实际上在用户地址空间创建了一个匿名映射,但省去了一些安全检查和用于提高代码性能的对特殊情况的处理 */ if (do_brk(oldbrk, newbrk-oldbrk) != oldbrk) goto out;set_brk: mm->brk = brk;out: retval = mm->brk; up_write(&mm->mmap_sem); return retval;} 十. 缺页异常的处理 在实际需要某个虚拟内存区域的数据之前,虚拟和物理内存之间的关联不会建立,如果进程访问老的虚拟地址空间部分尚未与页帧关联,处理器自动自动地触发一个缺页异常,内核必须处理此异常,这是内存中最重要、最复杂的方面之一,因为还需要考虑其他的细节,包括 1. 缺页异常是由于访问用户地址空间中的有效地址而引起的,还是应用程序试图访问内核的受保护区域 2. 目标地址对应于某个现存的映射吗 3. 获取该区域的数据,需要使用何种机制 下图给出了内核在处理缺页异常时,可能使用的各种代码途径的一个大致的流程 缺页处理的实现因处理器的不同而有所不同,由于CPU采用了不同的内存管理概念,生成缺页异常的细节也不太相同,因此,缺页异常的处理例程在内核代码中处于特定于体系结构的部分,我们着重讨论IA-32体系结构上采用的方法,因为在最低限度上,大多数其他CPU的实现是类似的 缺页中断属于系统软中断的一种 \linux-2.6.32.63\arch\x86\kernel\entry_32.S中的一个汇编例程用作用作缺页异常的入口 ENTRY(page_fault) RING0_EC_FRAME pushl $do_page_fault CFI_ADJUST_CFA_OFFSET 4 ALIGNerror_code: ..END(page_fault) 立即调用了\linux-2.6.32.63\arch\x86\mm\fault.c中的C例程do_page_fault /* * This routine handles page faults. It determines the address, * and the problem, and then passes it off to one of the appropriate * routines. 1. struct pt_regs *regs: 发生异常时使用中的寄存器集合 2. unsigned long error_code: 提供异常原因的错误代码,error_code目前只使用了前5个比特位(0、1、2、3、4),语义如下 1) 0 bit: 1.1) 置位1: 缺页 1.2) 置位0: 保护异常(没有足够的访问权限) 2) 1 bit: 2.1) 置位1: 读访问 2.2) 置位0: 写访问 3) 2 bit: 3.1) 置位1: 核心态 3.2) 置位0: 用户态 4) 3 bit: 4.1) 置位1: 检测到使用了保留位 4.2) 置位0: 5) 4 bit: 5.1) 置位1: 缺页异常是在取指令时出现的 5.2) 置位0: */dotraplinkage void __kprobes do_page_fault(struct pt_regs *regs, unsigned long error_code){ struct vm_area_struct *vma; struct task_struct *tsk; unsigned long address; struct mm_struct *mm; int write; int fault; tsk = current; mm = tsk->mm; /* Get the faulting address: 内核在address中保存饿了触发异常的地址 */ address = read_cr2(); /* * Detect and handle instructions that would cause a page fault for * both a tracked kernel page and a userspace page. */ if (kmemcheck_active(regs)) kmemcheck_hide(regs); prefetchw(&mm->mmap_sem); if (unlikely(kmmio_fault(regs, address))) return; /* * We fault-in kernel-space virtual memory on-demand. The reference' page table is init_mm.pgd * 我们因为异常而进入到内核虚拟内存空间,参考页表为init_mm.pgd * * NOTE! We MUST NOT take any locks for this case. We may be in an interrupt or a critical region, * and should only copy the information from the master page table, nothing more. * 要注意!在这种情况下我们不能获取任何锁,因为我们可能是在中断或者临界区中 * 只应该从主页表中复制信息,不允许其他操作 * * This verifies that the fault happens in kernel space (error_code & 4) == 0, * and that the fault was not a protection error (error_code & 9) == 0. * 下述代码验证了异常发生于内核空间(error_code & 4) == 0, 而且异常不是保护错误(error_code & 9) == 0. */ /* * 缺页地址位于内核空间。并不代表异常发生于内核空间,有可能是用户态访问了内核空间的地址 */ if (unlikely(fault_in_kernel_space(address))) { if (!(error_code & (PF_RSVD | PF_USER | PF_PROT))) { /* * 检查发生缺页的地址是否在vmalloc区,是则进行相应的处理主要是从内核主页表向进程页表同步数据 * 该函数只是从init的页表(在IA-32系统上,这是内核的主页表)复制相关的项到当前页表, * 如果其中没有找到匹配项,则内核调用fixup_exception,作为试图从异常恢复的最后尝试 */ if (vmalloc_fault(address) >= 0) return; if (kmemcheck_fault(regs, address, error_code)) return; } /* Can handle a stale RO->RW TLB: */ /* * 检查是否是由于陈旧的TLB导致的假的pagefault(由于TLB的延迟flush导致, * 因为提前flush会有比较大的性能代价)。 */ if (spurious_fault(error_code, address)) return; /* kprobes don't want to hook the spurious faults: */ if (notify_page_fault(regs)) return; /* * Don't take the mm semaphore here. If we fixup a prefetch fault we could otherwise deadlock: 不要在这里获取mm信号量,如果修复了取指令造成的缺页异常,则会进入死锁 */ bad_area_nosemaphore(regs, error_code, address); return; } // 进入到这里,说明异常地址位于用户态 /* kprobes don't want to hook the spurious faults: */ if (unlikely(notify_page_fault(regs))) return; /* * It's safe to allow irq's after cr2 has been saved and the * vmalloc fault has been handled. * * User-mode registers count as a user access even for any * potential system fault or CPU buglet: */ /* * 开中断,这种情况下,是安全的,可以缩短因缺页异常导致的关中断时长 */ if (user_mode_vm(regs)) { local_irq_enable(); error_code |= PF_USER; } else { if (regs->flags & X86_EFLAGS_IF) local_irq_enable(); } if (unlikely(error_code & PF_RSVD)) pgtable_bad(regs, error_code, address); perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS, 1, 0, regs, address); /* * If we're in an interrupt, have no user context or are running * in an atomic region then we must not take the fault: */ /* * 当缺页异常发生于中断或其它atomic上下文中时,则产生异常,这种情况下,不应该再产生page fault * 典型的,利用kprobe这种基于int3中断模式的Hook架构,Hook代码运行在中断上下文中,如果Hook代码量过多,则有很大几率发生缺页中断 * 这种情况下,page fault无法得到正确处理,很容易使kernel处于异常状态 */ if (unlikely(in_atomic() || !mm)) { bad_area_nosemaphore(regs, error_code, address); return; } /* * When running in the kernel we expect faults to occur only to * addresses in user space. All other faults represent errors in * the kernel and should generate an OOPS. Unfortunately, in the * case of an erroneous fault occurring in a code path which already * holds mmap_sem we will deadlock attempting to validate the fault * against the address space. Luckily the kernel only validly * references user space from well defined areas of code, which are * listed in the exceptions table. * * As the vast majority of faults will be valid we will only perform * the source reference check when there is a possibility of a * deadlock. Attempt to lock the address space, if we cannot we then * validate the source. If this is invalid we can skip the address * space check, thus avoiding the deadlock: */ if (unlikely(!down_read_trylock(&mm->mmap_sem))) { /* * 缺页发生在内核上下文,这种情况发生缺页的地址只能位于用户态地址空间 * 这种情况下,也只能为exceptions table中预先定义好的异常,如果exceptions * table中没有预先定义的处理,或者缺页的地址位于内核态地址空间,则表示 * 错误,进入oops流程。 */ if ((error_code & PF_USER) == 0 && !search_exception_tables(regs->ip)) { bad_area_nosemaphore(regs, error_code, address); return; } // 如果发生在用户态或者有exception table,说明不是内核异常 down_read(&mm->mmap_sem); } else { /* * The above down_read_trylock() might have succeeded in * which case we'll have missed the might_sleep() from * down_read(): */ might_sleep(); } //如果异常并非出现在中断期间,也有相关的上下文,则内核在当前进程的地址空间中寻找发生异常的地址对应的VMA vma = find_vma(mm, address); // 如果没找到VMA,则释放mem_sem信号量后,进入__bad_area_nosemaphore处理 if (unlikely(!vma)) { bad_area(regs, error_code, address); return; } /* 搜索可能得到下面各种不同的结果 1. 没有找到结束地址在address之后的区域,即这种情况下访问是无效的 2. 找到VMA,且发生异常的虚拟地址位于vma的有效范围内,则为正常的缺页异常,则缺页异常由内核负责恢复,请求调页,分配物理内存 */ if (likely(vma->vm_start <= address)) goto good_area; /* 3. 找到了一个结束地址在异常地址之后的区域,但异常地址不在该区域内,这可能有下面两种原因 1) 该区域的VM_GROWSDOWN标志置位,这意味着区域是栈,自顶向下增长,接下来调用expand_stack适当地扩大栈,如果成功,则返回0,内核在good_area标号恢复执行,否则认为访问吴晓 2) 找到的区域不是栈,异常地址不是位于紧挨着堆栈区的那个区域,同时又没有相应VMA,则进程访问了非法地址,进入bad_area处理,访问无效 */ if (unlikely(!(vma->vm_flags & VM_GROWSDOWN))) { bad_area(regs, error_code, address); return; } if (error_code & PF_USER) { /* * Accessing the stack below %sp is always a bug. * The large cushion allows instructions like enter * and pusha to work. ("enter $65535, $31" pushes * 32 pointers and then decrements %sp by 65535.) */ /* * 压栈操作时,操作的地址最大的偏移为65536+32*sizeof(unsigned long), * 该操作由pusha命令触发(老版本中,pusha命令最大只能操作32字节,即 * 同时压栈8个寄存器)。如果访问的地址距栈顶的距离超过了,则肯定是非法 * 地址访问了。 */ if (unlikely(address + 65536 + 32 * sizeof(unsigned long) < regs->sp)) { bad_area(regs, error_code, address); return; } } /* * 运行到这里,说明设置了VM_GROWSDOWN标记,表示缺页异常地址位于堆栈区 * 需要扩展堆栈。需要明白的是,堆栈区的虚拟地址空间也是动态分配和扩展的,不是 * 一开始就分配好的。 */ if (unlikely(expand_stack(vma, address))) { bad_area(regs, error_code, address); return; } /* * Ok, we have a good vm_area for this memory access, so * we can handle it.. */ /* * 运行到这里,说明是正常的缺页异常,则进行请求调页,分配物理内存 */good_area: write = error_code & PF_WRITE; if (unlikely(access_error(error_code, write, vma))) { bad_area_access_error(regs, error_code, address); return; } /* * If for any reason at all we couldn't handle the fault, * make sure we exit gracefully rather than endlessly redo * the fault: */ /* * 分配物理内存,缺页异常的正常处理主函数 * 可能的情况有: 1. 请求调页/按需分配 2. COW 3. 缺的页位于交换分区,需要换入 */ /* handle_mm_fault是一个体系结构无关的例程,用于选择适当的异常恢复方法 1. 按需调页 2. 换入 3. .. */ fault = handle_mm_fault(mm, vma, address, write ? FAULT_FLAG_WRITE : 0); if (unlikely(fault & VM_FAULT_ERROR)) { mm_fault_error(regs, error_code, address, fault); return; } //如果页建立成功 if (fault & VM_FAULT_MAJOR) { tsk->maj_flt++; //例程返回PERF_COUNT_SW_PAGE_FAULTS_MAJ: 数据需要从块设备读取 perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS_MAJ, 1, 0, regs, address); } else { tsk->min_flt++; //例程返回PERF_COUNT_SW_PAGE_FAULTS_MIN: 数据已经在内存中 perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS_MIN, 1, 0, regs, address); } check_v8086_mode(regs, address, tsk); up_read(&mm->mmap_sem);} 十一. 用户空间缺页异常的校正 我们之前讨论了对缺页异常的特定于体系结构的代码,确认异常是在允许的地址触发,内核必须确定将所需数据读取到物理内存的释放方法,该任务委托给handle_mm_fault,它不依赖于底层体系结构,而是在内存管理的框架下、独立于系统而实现,该函数确认在各级页目录中,通向对应于异常地址的页表项的各个页目录都存在 handle_pte_fault函数分析缺页异常的原因,pte是指向相关页表项(pte_t)的指针 \linux-2.6.32.63\mm\memory.c /* * These routines also need to handle stuff like marking pages dirty * and/or accessed for architectures that don't do it in hardware (most * RISC architectures). The early dirtying is also good on the i386. * * There is also a hook called "update_mmu_cache()" that architectures * with external mmu caches can use to update those (ie the Sparc or * PowerPC hashed page tables that act as extended TLBs). * * We enter with non-exclusive mmap_sem (to exclude vma changes, * but allow concurrent faults), and pte mapped but not yet locked. * We return with mmap_sem still held, but pte unmapped and unlocked. */static inline int handle_pte_fault( struct mm_struct *mm, struct vm_area_struct *vma, unsigned long address, pte_t *pte, pmd_t *pmd, unsigned int flags ){ pte_t entry; spinlock_t *ptl; entry = *pte; /* 如果页不在物理内存中,即!pte_present(entry),则必须区分下面3种情况 1. 如果没有对应的页表项(page_none),则内核必须从头开始加载该页 1) 对匿名映射称之为按需分配(demand allocation) 2) 对基于文件的映射,称之为按需调页(demand paging) 如果vm_ops中没有注册vm_operations_struct,则不适合用以上2个方法,在这种情况下,内核必须使用do_anonymous_page返回一个匿名页 2. 如果该页标记为不存在,而页表中保存了相关的信息,在这种情况下,内核必须从系统的某个交换区换入(换入/按需调页) 3. 非线性映射已经换出的部分不能像普通页那样换入,因为必须正确地恢复非线性关联,pte_file函数可以检查页表项是否属于非线性映射,do_nonlinear_fault在这种情况下可用于处理异常 */ if (!pte_present(entry)) { if (pte_none(entry)) { if (vma->vm_ops) { if (likely(vma->vm_ops->fault)) return do_linear_fault(mm, vma, address, pte, pmd, flags, entry); } return do_anonymous_page(mm, vma, address, pte, pmd, flags); } if (pte_file(entry)) return do_nonlinear_fault(mm, vma, address, pte, pmd, flags, entry); return do_swap_page(mm, vma, address, pte, pmd, flags, entry); } ptl = pte_lockptr(mm, pmd); spin_lock(ptl); if (unlikely(!pte_same(*pte, entry))) goto unlock; /* 如果该区域对页授予了写权限,而硬件的存取机制没有授予(因此触发异常),则会发生另一种潜在的情况,但此时对应的页已经在内存中 */ if (flags & FAULT_FLAG_WRITE) { if (!pte_write(entry)) /* do_wp_page负责创建该页的副本,并插入到进程的页表中(在硬件层具备写权限),该机制称为写时复制(copy on write COW) 在进程发生分支时(fork),页并不是立即复制的,而是映射到进程的地址空间中作为"只读"副本,以免在复制信息时花费太多时间 在实际发生写访问之前,都不会为进程创建页的独立副本 */ return do_wp_page(mm, vma, address, pte, pmd, ptl, entry); entry = pte_mkdirty(entry); } entry = pte_mkyoung(entry); if (ptep_set_access_flags(vma, address, pte, entry, flags & FAULT_FLAG_WRITE)) { update_mmu_cache(vma, address, entry); } else { /* * This is needed only for protection faults but the arch code * is not yet telling us if this is a protection fault or not. * This still avoids useless tlb flushes for .text page faults * with threads. */ if (flags & FAULT_FLAG_WRITE) flush_tlb_page(vma, address); }unlock: pte_unmap_unlock(pte, ptl); return 0;} 我们接下来分别讨论handle_pte_fault中针对不同缺页校正方式的处理 1.按需分配/调页 按需分配的工位委托给了do_linear_fault \linux-2.6.32.63\mm\memory.c static inline int handle_pte_fault( struct mm_struct *mm, struct vm_area_struct *vma, unsigned long address, pte_t *pte, pmd_t *pmd, unsigned int flags ){ .. /* 首先,内核必须确保将所需的数据读入到发生异常的页,具体的处理依赖于映射到发生异常的地址空间中的文件,因此需要调用特定于文件的方法来获取数据 1. 通常该方法保存在vma->vm_ops->fault 2. 由于较早的内核版本使用的方法调用约定不同,内核必须考虑到某些代码尚未更新到新的调用约定,因此如果没有注册fault方法,则调用旧的vm->vm_ops->nopage */ if (vma->vm_ops) { if (likely(vma->vm_ops->fault)) return do_linear_fault(mm, vma, address, pte, pmd, flags, entry); } ..} static int do_linear_fault(struct mm_struct *mm, struct vm_area_struct *vma, unsigned long address, pte_t *page_table, pmd_t *pmd, unsigned int flags, pte_t orig_pte){ pgoff_t pgoff = (((address & PAGE_MASK) - vma->vm_start) >> PAGE_SHIFT) + vma->vm_pgoff; pte_unmap(page_table); return __do_fault(mm, vma, address, pmd, pgoff, flags, orig_pte);} 大多数文件都使用filemap_fault读入所需数据,该函数不仅读入所需数据,还实现了预读功能,即提前读入在未来很可能需要的页(..)。总之,我们要重点记住的是,内核使用address_space对象中的信息,从后备存储器读取到物理内存页 给定涉及区域的vm_area_strcut,内核将选择使用何种方法读取页 1. 使用vm_area_struct->vm_file找到映射的file对象 2. 在file->f_mapping中找到指向映射自身的指针 3. 每个地址空间都有特定的地址空间操作,从中选择readpage方法,使用mapping->a_ops->readpage(file, page)从文件中将数据传输到物理内存 如果需要写访问,内核必须区分共享和私有映射,对私有映射,必须准备页的一份副本 /* * __do_fault() tries to create a new page mapping. It aggressively * tries to share with existing pages, but makes a separate copy if * the FAULT_FLAG_WRITE is set in the flags parameter in order to avoid * the next page fault. * * As this is called only for pages that do not currently exist, we * do not need to flush old virtual caches or the TLB. * * We enter with non-exclusive mmap_sem (to exclude vma changes, * but allow concurrent faults), and pte neither mapped nor locked. * We return with mmap_sem still held, but pte unmapped and unlocked. */static int __do_fault(struct mm_struct *mm, struct vm_area_struct *vma, unsigned long address, pmd_t *pmd, pgoff_t pgoff, unsigned int flags, pte_t orig_pte){ pte_t *page_table; spinlock_t *ptl; struct page *page; pte_t entry; int anon = 0; int charged = 0; struct page *dirty_page = NULL; struct vm_fault vmf; int ret; int page_mkwrite = 0; vmf.virtual_address = (void __user *)(address & PAGE_MASK); vmf.pgoff = pgoff; vmf.flags = flags; vmf.page = NULL; ret = vma->vm_ops->fault(vma, &vmf); if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE))) return ret; if (unlikely(PageHWPoison(vmf.page))) { if (ret & VM_FAULT_LOCKED) unlock_page(vmf.page); return VM_FAULT_HWPOISON; } /* * For consistency in subsequent calls, make the faulted page always * locked. */ if (unlikely(!(ret & VM_FAULT_LOCKED))) lock_page(vmf.page); else VM_BUG_ON(!PageLocked(vmf.page)); /* * Should we do an early C-O-W break? 应该进行写时复制吗 */ page = vmf.page; if (flags & FAULT_FLAG_WRITE) { if (!(vma->vm_flags & VM_SHARED)) { anon = 1; /* 在用anon_vma_prepare(指向原区域的指针,在anon_vma_prepare中会重定向到新的区域)为区域建立一个新的anon_vma实例之后,必须分配一个新的页 这里会优先使用高端内存域,因为该内存域对用户空间页是没有问题的 */ if (unlikely(anon_vma_prepare(vma))) { ret = VM_FAULT_OOM; goto out; } page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, address); if (!page) { ret = VM_FAULT_OOM; goto out; } if (mem_cgroup_newpage_charge(page, mm, GFP_KERNEL)) { ret = VM_FAULT_OOM; page_cache_release(page); goto out; } charged = 1; /* * Don't let another task, with possibly unlocked vma, * keep the mlocked page. */ if (vma->vm_flags & VM_LOCKED) clear_page_mlock(vmf.page); //copy_user_highpage接下来创建数据的一份副本 copy_user_highpage(page, vmf.page, address, vma); __SetPageUptodate(page); } else { /* * If the page will be shareable, see if the backing * address space wants to know that the page is about * to become writable */ if (vma->vm_ops->page_mkwrite) { int tmp; unlock_page(page); vmf.flags = FAULT_FLAG_WRITE|FAULT_FLAG_MKWRITE; tmp = vma->vm_ops->page_mkwrite(vma, &vmf); if (unlikely(tmp & (VM_FAULT_ERROR | VM_FAULT_NOPAGE))) { ret = tmp; goto unwritable_page; } if (unlikely(!(tmp & VM_FAULT_LOCKED))) { lock_page(page); if (!page->mapping) { ret = 0; /* retry the fault */ unlock_page(page); goto unwritable_page; } } else VM_BUG_ON(!PageLocked(page)); page_mkwrite = 1; } } } page_table = pte_offset_map_lock(mm, pmd, address, &ptl); /* * This silly early PAGE_DIRTY setting removes a race * due to the bad i386 page protection. But it's valid * for other architectures too. * * Note that if FAULT_FLAG_WRITE is set, we either now have * an exclusive copy of the page, or this is a shared mapping, * so we can make it writable and dirty to avoid having to * handle that later. */ /* Only go through if we didn't race with anybody else... */ if (likely(pte_same(*page_table, orig_pte))) { /* 在知道了页的位置之后,需要将其加入进程的页表,再合并到逆向映射数据结构中 在完成这些之前,需要用flush_icache_page更新缓存,确保页的内容在用户空间可见 */ flush_icache_page(vma, page); //指向只读页的页表项通常使用mk_pte函数产生 entry = mk_pte(page, vma->vm_page_prot); if (flags & FAULT_FLAG_WRITE) //如果建立具有写权限的页,内核必须用pte_mkdirty显示设置写权限 entry = maybe_mkwrite(pte_mkdirty(entry), vma); /* 页集成到逆向映射的具体方式,取决于其类型 1. 如果在处理写访问权限时生成的页是匿名的,则用page_add_new_anon_rmap集成到逆向映射中 2. 所有其他与基于文件的映射关联的页,则调用page_add_file_rmap */ if (anon) { inc_mm_counter(mm, anon_rss); page_add_new_anon_rmap(page, vma, address); } else { inc_mm_counter(mm, file_rss); page_add_file_rmap(page); if (flags & FAULT_FLAG_WRITE) { dirty_page = page; get_page(dirty_page); } } set_pte_at(mm, address, page_table, entry); /* no need to invalidate: a not-present page won't be cached 最后,必须更新处理器的MMU缓存,因为页表已经修改 */ update_mmu_cache(vma, address, entry); } else { if (charged) mem_cgroup_uncharge_page(page); if (anon) page_cache_release(page); else anon = 1; /* no anon but release faulted_page */ } pte_unmap_unlock(page_table, ptl); out: if (dirty_page) { struct address_space *mapping = page->mapping; if (set_page_dirty(dirty_page)) page_mkwrite = 1; unlock_page(dirty_page); put_page(dirty_page); if (page_mkwrite && mapping) { /* * Some device drivers do not set page.mapping but still * dirty their pages */ balance_dirty_pages_ratelimited(mapping); } /* file_update_time outside page_lock */ if (vma->vm_file) file_update_time(vma->vm_file); } else { unlock_page(vmf.page); if (anon) page_cache_release(vmf.page); } return ret; unwritable_page: page_cache_release(page); return ret;} 2.匿名页 对于没有关联到文件作为后备存储器的页,需要调用do_anonymous_page进行映射,除了无需向页读入数据之外,该过程几乎与映射基于文件的数据没有什么区别,在highmem内存域建立一个新页,并清空其内容,接下来将页加入到进程的页表并更新高速缓存或者MMU 3.写时复制 写时复制在do_wp_page中处理 \linux-2.6.32.63\mm\memory.c /* * This routine handles present pages, when users try to write * to a shared page. It is done by copying the page to a new address * and decrementing the shared-page counter for the old page. * * Note that this routine assumes that the protection checks have been * done by the caller (the low-level page fault routine in most cases). * Thus we can safely just mark it writable once we've done any necessary * COW. * * We also mark the page dirty at this point even though the page will * change only once the write actually happens. This avoids a few races, * and potentially makes it more efficient. * * We enter with non-exclusive mmap_sem (to exclude vma changes, * but allow concurrent faults), with pte both mapped and locked. * We return with mmap_sem still held, but pte unmapped and unlocked.写时复制这个函数处理present pages, 当用户试图写共享页面时。它复制内容到新页,减少旧页面的共享计数 */static int do_wp_page(struct mm_struct *mm, struct vm_area_struct *vma, unsigned long address, pte_t *page_table, pmd_t *pmd, spinlock_t *ptl, pte_t orig_pte){ struct page *old_page, *new_page; pte_t entry; int reuse = 0, ret = 0; int page_mkwrite = 0; struct page *dirty_page = NULL; /* 内核首先调用vm_normal_page,通过页表项找到页的struct page实例,本质上这个函数基于 1. pte_pfn: 查找与页表项相关的页号 2. pfn_to_page: 确定与页号相关的page实例 这两个函数是所有体系结构都必须定义的,需要明白的是,写时复制机制只对内存中实际存在的页调用,否则首先需要通过缺页异常机制自动加载 */ old_page = vm_normal_page(vma, address, orig_pte); if (!old_page) { /* * VM_MIXEDMAP !pfn_valid() case * * We should not cow pages in a shared writeable mapping. * Just mark the pages writable as we can't do any dirty * accounting on raw pfn maps. */ if ((vma->vm_flags & (VM_WRITE|VM_SHARED)) == (VM_WRITE|VM_SHARED)) goto reuse; goto gotten; } /* * Take out anonymous pages first, anonymous shared vmas are * not dirty accountable. */ if (PageAnon(old_page) && !PageKsm(old_page)) { if (!trylock_page(old_page)) { //在page_cache_get获取页之后,接下来 page_cache_get(old_page); pte_unmap_unlock(page_table, ptl); lock_page(old_page); page_table = pte_offset_map_lock(mm, pmd, address, &ptl); if (!pte_same(*page_table, orig_pte)) { unlock_page(old_page); page_cache_release(old_page); goto unlock; } page_cache_release(old_page); } reuse = reuse_swap_page(old_page); unlock_page(old_page); } else if (unlikely((vma->vm_flags & (VM_WRITE|VM_SHARED)) == (VM_WRITE|VM_SHARED))) { /* * Only catch write-faults on shared writable pages, * read-only shared pages can get COWed by * get_user_pages(.write=1, .force=1). */ if (vma->vm_ops && vma->vm_ops->page_mkwrite) { struct vm_fault vmf; int tmp; vmf.virtual_address = (void __user *)(address & PAGE_MASK); vmf.pgoff = old_page->index; vmf.flags = FAULT_FLAG_WRITE|FAULT_FLAG_MKWRITE; vmf.page = old_page; /* * Notify the address space that the page is about to * become writable so that it can prohibit this or wait * for the page to get into an appropriate state. * * We do this without the lock held, so that it can * sleep if it needs to. */ page_cache_get(old_page); pte_unmap_unlock(page_table, ptl); tmp = vma->vm_ops->page_mkwrite(vma, &vmf); if (unlikely(tmp & (VM_FAULT_ERROR | VM_FAULT_NOPAGE))) { ret = tmp; goto unwritable_page; } if (unlikely(!(tmp & VM_FAULT_LOCKED))) { lock_page(old_page); if (!old_page->mapping) { ret = 0; /* retry the fault */ unlock_page(old_page); goto unwritable_page; } } else VM_BUG_ON(!PageLocked(old_page)); /* * Since we dropped the lock we need to revalidate * the PTE as someone else may have changed it. If * they did, we just return, as we can count on the * MMU to tell us if they didn't also make it writable. */ page_table = pte_offset_map_lock(mm, pmd, address, &ptl); if (!pte_same(*page_table, orig_pte)) { unlock_page(old_page); page_cache_release(old_page); goto unlock; } page_mkwrite = 1; } dirty_page = old_page; get_page(dirty_page); reuse = 1; } if (reuse) {reuse: flush_cache_page(vma, address, pte_pfn(orig_pte)); entry = pte_mkyoung(orig_pte); entry = maybe_mkwrite(pte_mkdirty(entry), vma); if (ptep_set_access_flags(vma, address, page_table, entry,1)) update_mmu_cache(vma, address, entry); ret |= VM_FAULT_WRITE; goto unlock; } /* * Ok, we need to copy. Oh, well.. 在用page_cache_get获取页之后,接下来anon_vma_prepare准备好逆向映射机制的数据结构,以接受一个新的匿名区域 */ page_cache_get(old_page);gotten: pte_unmap_unlock(page_table, ptl); if (unlikely(anon_vma_prepare(vma))) goto oom; if (is_zero_pfn(pte_pfn(orig_pte))) { new_page = alloc_zeroed_user_highpage_movable(vma, address); if (!new_page) goto oom; } else { //由于异常的来源是需要将一个充满有用数据的页复制到新页,因此内核调用alloc_page_vma分配一个新野 new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, address); if (!new_page) goto oom; //cow_user_page接下来将异常页的数据复制到新页,进程随后可以对新页进行写操作 cow_user_page(new_page, old_page, address, vma); } __SetPageUptodate(new_page); /* * Don't let another task, with possibly unlocked vma, * keep the mlocked page. */ if ((vma->vm_flags & VM_LOCKED) && old_page) { lock_page(old_page); /* for LRU manipulation */ clear_page_mlock(old_page); unlock_page(old_page); } if (mem_cgroup_newpage_charge(new_page, mm, GFP_KERNEL)) goto oom_free_new; /* * Re-check the pte - we dropped the lock */ page_table = pte_offset_map_lock(mm, pmd, address, &ptl); if (likely(pte_same(*page_table, orig_pte))) { if (old_page) { if (!PageAnon(old_page)) { dec_mm_counter(mm, file_rss); inc_mm_counter(mm, anon_rss); } } else inc_mm_counter(mm, anon_rss); flush_cache_page(vma, address, pte_pfn(orig_pte)); entry = mk_pte(new_page, vma->vm_page_prot); entry = maybe_mkwrite(pte_mkdirty(entry), vma); /* * Clear the pte entry and flush it first, before updating the * pte with the new entry. This will avoid a race condition * seen in the presence of one thread doing SMC and another * thread doing COW. */ ptep_clear_flush(vma, address, page_table); //通过page_add_new_anon_rmap将新页插入到逆向映射数据结构,此后。用户空间进程可以向页写入数据 page_add_new_anon_rmap(new_page, vma, address); /* * We call the notify macro here because, when using secondary * mmu page tables (such as kvm shadow page tables), we want the * new page to be mapped directly into the secondary page table. */ set_pte_at_notify(mm, address, page_table, entry); update_mmu_cache(vma, address, entry); if (old_page) { /* * Only after switching the pte to the new page may * we remove the mapcount here. Otherwise another * process may come and find the rmap count decremented * before the pte is switched to the new page, and * "reuse" the old page writing into it while our pte * here still points into it and can be read by other * threads. * * The critical issue is to order this * page_remove_rmap with the ptp_clear_flush above. * Those stores are ordered by (if nothing else,) * the barrier present in the atomic_add_negative * in page_remove_rmap. * * Then the TLB flush in ptep_clear_flush ensures that * no process can access the old page before the * decremented mapcount is visible. And the old page * cannot be reused until after the decremented * mapcount is visible. So transitively, TLBs to * old page will be flushed before it can be reused. */ //使用page_remove_rmap删除到原来的只读页的逆向映射(fork出的子进程和父进程的页映射完全分离开来),新页添加到页表,此时也必须更新CPU高速缓存 page_remove_rmap(old_page); } /* Free the old page.. */ new_page = old_page; ret |= VM_FAULT_WRITE; } else mem_cgroup_uncharge_page(new_page); if (new_page) page_cache_release(new_page); if (old_page) page_cache_release(old_page);unlock: pte_unmap_unlock(page_table, ptl); if (dirty_page) { /* * Yes, Virginia, this is actually required to prevent a race * with clear_page_dirty_for_io() from clearing the page dirty * bit after it clear all dirty ptes, but before a racing * do_wp_page installs a dirty pte. * * do_no_page is protected similarly. */ if (!page_mkwrite) { wait_on_page_locked(dirty_page); set_page_dirty_balance(dirty_page, page_mkwrite); } put_page(dirty_page); if (page_mkwrite) { struct address_space *mapping = dirty_page->mapping; set_page_dirty(dirty_page); unlock_page(dirty_page); page_cache_release(dirty_page); if (mapping) { /* * Some device drivers do not set page.mapping * but still dirty their pages */ balance_dirty_pages_ratelimited(mapping); } } /* file_update_time outside page_lock */ if (vma->vm_file) file_update_time(vma->vm_file); } return ret;oom_free_new: page_cache_release(new_page);oom: if (old_page) { if (page_mkwrite) { unlock_page(old_page); page_cache_release(old_page); } page_cache_release(old_page); } return VM_FAULT_OOM; unwritable_page: page_cache_release(old_page); return ret;} 4.获取非线性映射 与按需调页/写时复制相比,非线性映射的缺页处理要短得多 /* * Fault of a previously existing named mapping. Repopulate the pte * from the encoded file_pte if possible. This enables swappable * nonlinear vmas. * * We enter with non-exclusive mmap_sem (to exclude vma changes, * but allow concurrent faults), and pte mapped but not yet locked. * We return with mmap_sem still held, but pte unmapped and unlocked. */static int do_nonlinear_fault( struct mm_struct *mm, struct vm_area_struct *vma, unsigned long address, pte_t *page_table, pmd_t *pmd, unsigned int flags, pte_t orig_pte ){ pgoff_t pgoff; flags |= FAULT_FLAG_NONLINEAR; if (!pte_unmap_same(mm, pmd, page_table, orig_pte)) return 0; if (unlikely(!(vma->vm_flags & VM_NONLINEAR))) { /* * Page table corrupted: show pte and kill process. */ print_bad_pte(vma, address, orig_pte, NULL); return VM_FAULT_SIGBUS; } /* 由于异常地址与映射文件的内容并非线性相关,因此必须从先前用pfoff_to_pte编码的页表项中,获取所需位置的信息。现在就需要获取并使用该信息 pte_to_pgoff分析页表项并获取所需的文件中的偏移量(以页为单位) */ pgoff = pte_to_pgoff(orig_pte); //在获得了文件内部的地址之后,读取所需数据类似于普通的缺页异常,因此内核将工作移交给__do_fault,处理到此为止 return __do_fault(mm, vma, address, pmd, pgoff, flags, orig_pte);} 十二. 内核缺页异常 在访问内核地址空间时,缺页异常可能被各种条件触发,例如 1. 内核中的程序设计错误导致访问不正确的地址,这是真正的程序错误 2. 内核通过用户空间传递的系统调用参数,访问了无效地址 //前两种情况是真正的错误,内核必须对此进行额外的检查 3. 访问使用vmalloc分配的区域,触发缺页异常 //vmalloc的情况是导致缺页异常的合理原因,必须加以校正,直至对应的缺页异常发生之前,vmalloc区域中的修改都不会传递到进程的页表,必须从主页表复制适当的访问权限信息 在处理不是由于访问vmalloc区域导致的缺页异常时,异常修正(exception fixup)机制是一个最后手段,在某些时候,内核有很好的理由准备截取不正确的访问,例如从用户空间地址复制作为系统调用参数的地址数据时 复制可能由各种函数执行,例如copy_from_user,在向或从用户空间复制数据时,如果访问的地址在虚拟地址空间中不与物理内存页关联,则会发生缺页异常,对用户态发生的该情况,我们已经在上文讨论过了(在应用程序访问一个虚拟地址时,内核将使用按需调页机制,自动并透明地返回一个物理内存页)。如果访问发生在和心态,异常同样必须校正,但使用的方法稍有不同 每次发生缺页异常时,将输出异常的原因和当前执行代码的地址,这使得内核可以编译一个列表,列出所有可能执行未授权内存访问操作的危险代码,这个"异常表"在链接内核映像时创建,在二进制文件中位于__start_exception_table、__end_exception_table之间,每个表项对应于一个struct exception_table实例,该结构尽管是体系结构相关的,但通常是如下定义 \linux-2.6.32.63\include\asm-generic\uaccess.h /* * The exception table consists of pairs of addresses: the first is the * address of an instruction that is allowed to fault, and the second is * the address at which the program should continue. No registers are * modified, so it is entirely up to the continuation code to figure out * what to do. * * All the routines below use bits of fixup code that are out of line * with the main instruction path. This means when everything is well, * we don't even have to jump over them. Further, they do not intrude * on our cache or tlb entries. */ struct exception_table_entry{ //insn指定了内核预期在虚拟地址空间中发生异常的位置 unsigned long insn; //fixup指定了发生异常时执行恢复到哪个代码地址 unsigned long fixup;}; fixup_exception用于搜索异常表,在IA-32系统上如下定义 \linux-2.6.32.63\arch\x86\mm\extable.c int fixup_exception(struct pt_regs *regs){ const struct exception_table_entry *fixup; #ifdef CONFIG_PNPBIOS if (unlikely(SEGMENT_IS_PNP_CODE(regs->cs))) { extern u32 pnp_bios_fault_eip, pnp_bios_fault_esp; extern u32 pnp_bios_is_utter_crap; pnp_bios_is_utter_crap = 1; printk(KERN_CRIT "PNPBIOS fault.. attempting recovery.\n"); __asm__ volatile( "movl %0, %%esp\n\t" "jmp *%1\n\t" : : "g" (pnp_bios_fault_esp), "g" (pnp_bios_fault_eip)); panic("do_trap: can't hit this"); }#endif //search_exception_tables扫描异常表,查找适当的匹配项 fixup = search_exception_tables(regs->ip); if (fixup) { /* If fixup is less than 16, it means uaccess error */ if (fixup->fixup < 16) { current_thread_info()->uaccess_err = -EFAULT; regs->ip += fixup->fixup; return 1; } //regs->ip指向EIP寄存器,在IA-32处理器上是包含了触发异常的代码段地址 regs->ip = fixup->fixup; //在找到修正例程后,将CPU指令指针设置到对应的内存地址,在fixup_exception通过return返回后,内核将执行找到的例程 return 1; } return 0;} 如果没有修正例程,这表明出现了一个真正的内核异常,在对search_exception_tables(不成功的)调用之后,将调用do_page_fault来处理该异常,最终导致内核进入oops状态 十三. 在内核和用户空间之间复制数据 内核经常需要从用户空间向内核空间复制数据,例如,在系统调用中通过指针间接地传递了冗长的数据结构时,反过来,也有从内核空间向用户空间写数据的需要。要注意的是,有两个原因,使得不能只是传递并反引用指针 1. 用户空间程序不能访问内核地址 2. 无法保证用户空间中指针指向的虚拟内存页确实与物理内存页关联 因此,内核需要提供几个标准函数,以处理内核空间和用户空间之间的数据交换 1. unsigned long copy_to_user(void __user *to, const void *from, unsigned long n)从from(用户空间)到to(内核空间)复制一个长度为n字节的字符串 2. get_user (type *to, type* ptr);从ptr读取一个简单类型变量(char、long、...),写入to,根据指针的类型,内核自动判断需要传输的字节数 3. long strncpy_from_user ( char *dst, const char __user * src, long count);将0结尾字符串(最长为n个字符)从from(用户空间)复制到to(内核空间) 4. put_user (type *from, type* to);将一个简单值从from(内核空间)复制到to(用户空间),相应的值根据指针类型自动判断 5. unsigned long copy_to_user ( void __user * to, const void *from, unsigned long n);从from(内核空间)到to(用户空间)复制一个长度为n字节的字符串 6. unsigned long clear_user ( void __user *to, unsigned long n);用0填充to之后的n个字节 7. strlen_user ( str );获取用户空间中的一个0结尾字符串的长度(包括结束字符) 8. long strnlen_user ( const char __user * s, long n);获取一个0结尾的字符串长度,但搜索限制为不超过n个字符/*get_user、put_user函数只能正确处理指向简单数据类型的指针,如char、int等等。它们不支持复合数据类型或数组,因为需要指针运算(和实现优化方面的必要性),在用户空间和内核空间之间能够交换struct之前,复制数据后,必须通过类型转换,转为正确的类型*/ 这些函数主要使用汇编语言实现的,由于调用非常频繁,对性能要求极高,另外还必须使用GNU C用于嵌入汇编的复杂构造和代码中的链接指令 在新版本的内核中,编译过程增加了一个检查工具,该工具分析源代码,检查用户空间的指针是否能直接反引用,源自用户空间的指针必须用关键字__user标记,以便工具分辨所需检查的指针
为什么要学这个项目 传统的webserver已经烂大街了,只有一个webserver项目大概率是找不到工作的,今天给大家分享一个C++Linux进阶项目-仿写Redis之Qedis,Redis是C++ Linux开发必备的核心知识,通过学习Qedis开源项目,不仅可以深入理解redis,也更能提升自己的编程能力,比如C++11任意函数作为任务的线程池,Reactor网络模型的C++封装,时间轮定时器,Redis数据结构的实现等。 视频地址 C++Linux进阶项目分析-仿写Redis之Qedis(B站搜索程序员老廖) 1.项目介绍 Qedis网上很多编译方式是错误的。 C++后台开发偏基础服务,比如分布式存储相关的岗位,基本上都是使用c++。 校招的时候如果想从事分布式存储相关的岗位,则需要深入掌握MySQL/Redis,而且即使不是做分布式存储,C++后台开发也是必须掌握Redis,所以建议大家可以参考Qedis项目仿写Redis,这样和其他人写webserver就有区分度。 Qedis 是一个基于 C++11 实现的 Redis 服务器,支持集群功能,并使用 LevelDB 作为持久化存储。该项目旨在提供一个高性能、可扩展的内存数据库解决方案,适用于需要快速数据访问和处理的应用场景。 Qedis 可以广泛应用于需要高性能数据存储和访问的场景,例如: 实时数据分析:Qedis 的高性能和低延迟特性使其成为实时数据分析的理想选择。 缓存系统:Qedis 可以作为缓存层,加速应用程序的数据访问速度。 消息队列:结合 Redis 的发布/订阅功能,Qedis 可以用于构建高效的消息队列系统 2 项目快速启动 确保系统已安装 C++11 编译器。 2.1 安装 LevelDB 库 2.1.0 leveldb介绍 LevelDB是一种快速的键-值存储库,由Google开发,用于提供高性能的数据持久性存储。它通常被用作支持各种应用程序的底层数据库引擎,包括分布式数据库、区块链、分布式文件系统等。 2.1.1 下载Level git clone https://gitclone.com/github.com/google/leveldb 2.1.2 下载编译leveldb需要的googletest、benchmark 1、进入下载好的leveldb cd leveldb 2、进入third_party目录 cd third_party 3、下载 git clone https://gitclone.com/github.com/google/googletestgit clone https://gitclone.com/github.com/google/benchmark 2.1.3 编译安装 回到leveldb目录 cd ../mkdir build && cd build# 步骤4: 使用CMake生成构建文件,这里以Debug模式为例cmake -DCMAKE_BUILD_TYPE=Debug -DBUILD_SHARED_LIBS=1 ..makesudo make install 2.1.4 刷新环境 sudo ldconfig 2.1.5 c++测试范例 测试leveldb,文件名为hello.cc,内容如下所示: #include #include #include #include using namespace leveldb; int main(){ leveldb::DB* db; leveldb::Options options; options.create_if_missing = true; // 打开一个数据库,不存在就创建 leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb", &db); assert(status.ok()); // 插入一个键值对 status = db->Put(leveldb::WriteOptions(), "hello", "LevelDB"); assert(status.ok()); // 读取键值对 std::string value; status = db->Get(leveldb::ReadOptions(), "hello", &value); assert(status.ok()); std::cout << value << std::endl; delete db; return 0;} 编译代码: g++ hello.cc -o hello -lpthread -lleveldb 执行程序: ./hello 显示: LevelDB 2.2 下载和编译Qedis git clone https://github.com/loveyacper/Qedis.gitcd Qedis mkdir buildcd build# 以debug模式编译目的是为了方便后续debug源码cmake -DCMAKE_BUILD_TYPE=Debug ..make 编译后的执行文件在Qedis/bin目录,所以需要切换目录: lqf@ubuntu:~/long/Qedis/build$ cd ../bin/ 2.3 启动Qedis和测试 需要确保之前已经安装过redis,因为测试我们需要redis-cli,但redis-server不要启动。 2.3.1 默认方式启动Qedis lqf@ubuntu:~/long/Qedis/bin$ ./qedis_server 打印: _____ _____ _____ _ _____ / _ \ | ____| | _ \ | | / ___/ | | | | | |__ | | | | | | | |___ Qedis(1.0.0) 64 bits, another redis written in C++11 | | | | | __| | | | | | | \___ \ Port: 6379 | |_| |_ | |___ | |_| | | | ___| | Author: Bert Young \_______| |_____| |_____/ |_| /_____/ https://github.com/loveyacper/Qedis start log thread2024-11-27[14:49:20.419][USR]:Success: listen on (127.0.0.1:6379) 通过redis-cli控制台进行测试: # 启动redis-cli控制台lqf@ubuntu:~/long/Qedis/bin$ redis-cli # 设置key127.0.0.1:6379> set key qedisOK# 获取key,测试正常127.0.0.1:6379> get key"qedis"127.0.0.1:6379> 2.3.2 通过配置文件启动Qedis 如果需要使用leveldb做持久化,需要修改配置文件Qedis/qedis.conf ,将backend 设置为1: 然后重新启动qedis_server,注意你配置文件路径 ./qedis_server ~/long/Qedis/qedis.conf 打印: Load libqedismodule.dylib failed because runtime error Load libnotexist.dylib failed because runtime error / _ \ | | | _ \ | | / ___/| | | | | |__ | | | | | | | |___ Qedis(1.0.0) 64 bits, another redis written in C++11| | | | | | | | | | | | _ \ Port: 6379| || | | |_ | |_| | | | _| | Author: Bert Young___| |_| |_/ |_| /_/ https://github.com/loveyacper/Qedisstart log thread2024-11-2818:32:21.938:Success: listen on (127.0.0.1:6379)2024-11-2818:32:21.946:Open leveldb dump02024-11-2818:32:21.951:Open leveldb dump12024-11-2818:32:21.957:Open leveldb dump22024-11-2818:32:21.961:Open leveldb dump32024-11-2818:32:21.967:Open leveldb dump42024-11-2818:32:21.971:Open leveldb dump52024-11-2818:32:21.976:Open leveldb dump62024-11-2818:32:21.980:Open leveldb dump72024-11-2818:32:21.984:Open leveldb dump82024-11-2818:32:21.989:Open leveldb dump92024-11-2818:32:21.994:Open leveldb dump102024-11-2818:32:21.999:Open leveldb dump112024-11-2818:32:22.004:Open leveldb dump122024-11-2818:32:22.009:Open leveldb dump132024-11-2818:32:22.014:Open leveldb dump142024-11-2818:32:22.019:Open leveldb dump15 3 如何仿写redis 熟悉redis原理,但这个不是说一定要每行代码都看懂,推荐书籍:《Redis设计与实现》 熟悉leveldb,也是不说一定要每行代码看懂,主要是先理解下leveldb如何使用,我这里有整理的leveldb相关的资料,大家可以加laoliao6668微信获取。 熟悉Qedis源码,参考Qedis仿写redis,前期可以先照抄Qedis源码都可以,我这里主要梳理Qedis的流程。 4 Qedis框架流程 这里是服务器开源项目调试的经典流程,不只局限于qedis源码。 4.0 通过gdb启动服务程序 我们通过gdb的方式启动qedis_server gdb ./qedis_server 如果需要设置配置文件,则在gdb启动后,这样就能加载配置文件 (gdb) set args ../qedis.conf 4.1 main函数在哪个文件 4.1.1 获取main函数所在源文件位置 在main函数打断点: (gdb) b mainBreakpoint 1 at 0x5f60:file/home/lqf/long/Qedis/QedisSvr/Qedis.cc, line 447. 这样能快速定位main函数所在的文件,结合vscode,点击 自动跳转到main函数,是不是非常方便。 这里我们没有提供配置文件参数,默认是前台运行,前台运行更方便debug 4.1.2 r运行程序 我们输入r先运行到main函数再打其他断点,输入r然后回车: Breakpoint 1 at 0x5f60: file /home/lqf/long/Qedis/QedisSvr/Qedis.cc, line 447.(gdb) rStarting program: /home/lqf/long/Qedis/bin/qedis_server [Thread debugging using libthread_db enabled]Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1". Breakpoint 1, main (ac=1, av=0x7fffffffe038) at /home/lqf/long/Qedis/QedisSvr/Qedis.cc:447447 {(gdb) 接下来我们就要根据tcp 服务的套路打断点: 4.2 qedis服务框架 设置断点 所有的tcp服务器都遵循 函数: bind listen accept epoll_wait epoll_ctl 发送 send或者write,不同的项目可能用的函数不一样,先断点send 接收recv或者read,不同的项目可能用的函数不一样,先断点recv (gdb) b bindBreakpoint 2 at 0x7ffff7b71380: file ../sysdeps/unix/syscall-template.S, line 78.(gdb) b listenBreakpoint 3 at 0x7ffff7b714e0: file ../sysdeps/unix/syscall-template.S, line 78.(gdb) b acceptBreakpoint 4 at 0x7ffff798e4b0: accept. (2 locations)(gdb) b epoll_waitBreakpoint 5 at 0x7ffff7b70630: file ../sysdeps/unix/sysv/linux/epoll_wait.c, line 28. (gdb) b epoll_ctlBreakpoint 6 at 0x7ffff7b70d10: file ../sysdeps/unix/syscall-template.S, line 78.(gdb) b sendBreakpoint 7 at 0x7ffff798e770: send. (2 locations)(gdb) b recvBreakpoint 8 at 0x7ffff798e5f0: recv. (2 locations) bind和listen查看调用栈 打好断点后,我们输入c继续运行程序: Thread 1 "qedis_server" hit Breakpoint 2, bind () at ../sysdeps/unix/syscall-template.S:7878 ../sysdeps/unix/syscall-template.S: No such file or directory. 通过查看堆栈的方式: #0 bind () at ../sysdeps/unix/syscall-template.S:78#1 0x00007ffff7f11289 in Internal::ListenSocket::Bind (this=this@entry=0x5555555759a0, addr=...) at /home/lqf/long/Qedis/QBase/ListenSocket.cc:48#2 0x00007ffff7f1935a in Server::TCPBind (this=