• Linux C 中的内存屏障是什么?

    一、内存屏障 在 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 的使用是非常重要的,同时要注意与内存屏障和同步机制配合使用。

    01-03 17浏览
  • 8个问题搞懂linux系统

    背景: 我们学习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 语言 编写,而低级硬件交互、性能优化和中断处理等关键部分常常需要使用汇编语言来实现,以确保对硬件的精确控制和高效执行。

    01-02 68浏览
  • 一种垃圾内存回收机制(C语言设计)

    工程师们似乎认为编写垃圾回收机制是很难的,是一种只有少数智者和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 总结 一开始就打算编写完整的程序是很困难的,你编程的唯一算法就是分而治之。 先编写内存分配函数,然后编写查询内存的函数,然后是清除内存的函数。最后将它们合在一起。 当你在编程方面克服这个障碍后,就再也没有困难的实践了。你可能有一个算法不太了解,但是任何人只要有足够的时间就肯定可以通过论文或书理解这个算法。 如果有一个项目看起来令人生畏,那么将它分成完全独立的几个部分。 你可能不懂如何编写一个解释器,但你绝对可以编写一个分析器,然后看一下你还有什么需要添加的,添上它。相信自己,终会成功!

    2024-12-18 68浏览
  • 深入分析Linux内核进程的虚拟内存

    一. 简介 用户层进程的虚拟地址空间是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标记,以便工具分辨所需检查的指针

    2024-12-18 123浏览
  • ADI SIGMADSP的软件移植

    一、SIGMADSP的开发流程简介ADI 目前在DSP方面的产品主要有SIGMA、BLACKFIN、SHARC三大类型,分别应用于不同的市场。SIGMADSP是完全可编程的单芯片音频DSP,可以通过SIGMASTUDIO图形开发工...

    2024-12-06 206浏览
  • 什么是字节对齐?今天一次性说细咯

    引言 最近因为硬件的一些限制,导致没有注意到四字节对齐问题,总去位域操作,导致寄存器读写不一致,这是一篇关于字节对齐的文章,写得很全很长,建议收藏细读。 考虑下面的结构体定义: typedef struct { char c1;  short s; char c2; int i; }T_FOO; 假设这个结构体的成员在内存中是紧凑排列的,且c1的起始地址是0,则s的地址就是1,c2的地址是3,i的地址是4。 现在,我们编写一个简单的程序: int main(void) {    T_FOO a; printf("c1 -> %d, s -> %d, c2 -> %d, i -> %d\n",      (unsigned int)(void*)&a.c1 - (unsigned int)(void*)&a,     (unsigned int)(void*)&a.s  - (unsigned int)(void*)&a,      (unsigned int)(void*)&a.c2 - (unsigned int)(void*)&a,      (unsigned int)(void*)&a.i  - (unsigned int)(void*)&a); return 0; } 运行后输出: c1 -> 0, s -> 2, c2 -> 4, i -> 8 为什么会这样?这就是字节对齐导致的问题。 本文在参考诸多资料的基础上,详细介绍常见的字节对齐问题。因成文较早,资料来源大多已不可考,敬请谅解。 一、 什么是字节对齐 现代计算机中,内存空间按照字节划分,理论上可以从任何起始地址访问任意类型的变量。 但实际中在访问特定类型变量时经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序一个接一个地存放,这就是对齐。 二 、对齐的原因和作用 不同硬件平台对存储空间的处理上存在很大的不同。某些平台对特定类型的数据只能从特定地址开始存取,而不允许其在内存中任意存放。 例如Motorola 68000 处理器不允许16位的字存放在奇地址,否则会触发异常,因此在这种架构下编程必须保证字节对齐。 但最常见的情况是,如果不按照平台要求对数据存放进行对齐,会带来存取效率上的损失。比如32位的Intel处理器通过总线访问(包括读和写)内存数据。 每个总线周期从偶地址开始访问32位内存数据,内存数据以字节为单位存放。 如果一个32位的数据没有存放在4字节整除的内存地址处,那么处理器就需要2个总线周期对其进行访问,显然访问效率下降很多。 因此,通过合理的内存对齐可以提高访问效率。为使CPU能够对数据进行快速访问,数据的起始地址应具有“对齐”特性。 比如4字节数据的起始地址应位于4字节边界上,即起始地址能够被4整除。 此外,合理利用字节对齐还可以有效地节省存储空间。但要注意,在32位机中使用1字节或2字节对齐,反而会降低变量访问速度。 因此需要考虑处理器类型。还应考虑编译器的类型。在VC/C++和GNU GCC中都是默认是4字节对齐。 三 、对齐的分类和准则 主要基于Intel X86架构介绍结构体对齐和栈内存对齐,位域本质上为结构体类型。 对于Intel X86平台,每次分配内存应该是从4的整数倍地址开始分配,无论是对结构体变量还是简单类型的变量。 3.1 结构体对齐 在C语言中,结构体是种复合数据类型,其构成元素既可以是基本数据类型(如int、long、float等)的变量,也可以是一些复合数据类型(如数组、结构体、联合等)的数据单元。 编译器为结构体的每个成员按照其自然边界(alignment)分配空间。各成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构的地址相同。 字节对齐的问题主要就是针对结构体。 3.1.1 简单示例 先看个简单的例子(32位,X86处理器,GCC编译器): 【例1】设结构体如下定义: struct A { int a; char b;  short  c; }; struct B { char b; int a;  short  c; }; 已知32位机器上各数据类型的长度为:char为1字节、short为2字节、int为4字节、long为4字节、float为4字节、double为8字节。那么上面两个结构体大小如何呢? 结果是: sizeof(strcut A)值为8;sizeof(struct B)的值却是12。 结构体A中包含一个4字节的int数据,一个1字节char数据和一个2字节short数据;B也一样。按理说A和B大小应该都是7字节。之所以出现上述结果,就是因为编译器要对数据成员在空间上进行对齐。 3.1.2 对齐准则 先来看四个重要的基本概念: 数据类型自身的对齐值:char型数据自身对齐值为1字节,short型数据为2字节,int/float型为4字节,double型为8字节。 结构体或类的自身对齐值:其成员中自身对齐值最大的那个值。 指定对齐值:#pragma pack (value)时的指定对齐值value。 数据成员、结构体和类的有效对齐值:自身对齐值和指定对齐值中较小者,即有效对齐值=min{自身对齐值,当前指定的pack值}。 基于上面这些值,就可以方便地讨论具体数据结构的成员和其自身的对齐方式。 其中,有效对齐值N是最终用来决定数据存放地址方式的值。有效对齐N表示“对齐在N上”,即该数据的“存放起始地址%N=0”。而数据结构中的数据变量都是按定义的先后顺序存放。 第一个数据变量的起始地址就是数据结构的起始地址。结构体的成员变量要对齐存放,结构体本身也要根据自身的有效对齐值圆整(即结构体成员变量占用总长度为结构体有效对齐值的整数倍)。 以此分析3.1.1节中的结构体B: 假设B从地址空间0x0000开始存放,且指定对齐值默认为4(4字节对齐)。成员变量b的自身对齐值是1,比默认指定对齐值4小,所以其有效对齐值为1,其存放地址0x0000符合0x0000%1=0。 成员变量a自身对齐值为4,所以有效对齐值也为4,只能存放在起始地址为0x0004~0x0007四个连续的字节空间中,符合0x0004%4=0且紧靠第一个变量。 变量c自身对齐值为 2,所以有效对齐值也是2,可存放在0x0008~0x0009两个字节空间中,符合0x0008%2=0。所以从0x0000~0x0009存放的都是B内容。 再看数据结构B的自身对齐值为其变量中最大对齐值(这里是b)所以就是4,所以结构体的有效对齐值也是4。 根据结构体圆整的要求, 0x0000~0x0009=10字节,(10+2)%4=0。所以0x0000A~0x000B也为结构体B所占用。故B从0x0000到0x000B 共有12个字节,sizeof(struct B)=12。 之所以编译器在后面补充2个字节,是为了实现结构数组的存取效率。试想如果定义一个结构B的数组,那么第一个结构起始地址是0没有问题,但是第二个结构呢? 按照数组的定义,数组中所有元素都紧挨着。如果我们不把结构体大小补充为4的整数倍,那么下一个结构的起始地址将是0x0000A,这显然不能满足结构的地址对齐。 因此要把结构体补充成有效对齐大小的整数倍。其实对于char/short/int/float/double等已有类型的自身对齐值也是基于数组考虑的,只是因为这些类型的长度已知,所以他们的自身对齐值也就已知。 上面的概念非常便于理解,不过个人还是更喜欢下面的对齐准则。 结构体字节对齐的细节和具体编译器实现相关,但一般而言满足三个准则: 结构体变量的首地址能够被其最宽基本类型成员的大小所整除; 结构体每个成员相对结构体首地址的偏移量(offset)都是成员大小的整数倍,如有需要编译器会在成员之间加上填充字节(internal adding); 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节{trailing padding}。 对于以上规则的说明如下: 第一条:编译器在给结构体开辟空间时,首先找到结构体中最宽的基本数据类型,然后寻找内存地址能被该基本数据类型所整除的位置,作为结构体的首地址。将这个最宽的基本数据类型的大小作为上面介绍的对齐模数。 第二条:为结构体的一个成员开辟空间之前,编译器首先检查预开辟空间的首地址相对于结构体首地址的偏移是否是本成员大小的整数倍,若是,则存放本成员,反之,则在本成员和上一个成员之间填充一定的字节,以达到整数倍的要求,也就是将预开辟空间的首地址后移几个字节。 第三条:结构体总大小是包括填充字节,最后一个成员满足上面两条以外,还必须满足第三条,否则就必须在最后填充几个字节以达到本条要求。 【例2】假设4字节对齐,以下程序的输出结果是多少? /* OFFSET宏定义可取得指定结构体某成员在结构体内部的偏移 */ #define OFFSET(st, field)     (size_t)&(((st*)0)->field) typedef struct { char a;  short b; char c; int d; char e[3]; }T_Test; int main(void) { printf("Size = %d\n  a-%d, b-%d, c-%d, d-%d\n  e[0]-%d, e[1]-%d, e[2]-%d\n", sizeof(T_Test), OFFSET(T_Test, a), OFFSET(T_Test, b),   OFFSET(T_Test, c), OFFSET(T_Test, d), OFFSET(T_Test, e[0]),   OFFSET(T_Test, e[1]),OFFSET(T_Test, e[2])); return 0; } 执行后输出如下: Size = 16 a-0, b-2, c-4, d-8 e[0]-12, e[1]-13, e[2]-14 下面来具体分析: 首先char a占用1个字节,没问题。 short b本身占用2个字节,根据上面准则2,需要在b和a之间填充1个字节。 char c占用1个字节,没问题。 int d本身占用4个字节,根据准则2,需要在d和c之间填充3个字节。 char e[3];本身占用3个字节,根据原则3,需要在其后补充1个字节。 因此,sizeof(T_Test) = 1 + 1 + 2 + 1 + 3 + 4 + 3 + 1 = 16字节。 3.1.3 对齐的隐患 3.1.3.1 数据类型转换 代码中关于对齐的隐患,很多是隐式的。例如,在强制类型转换的时候: int main(void) { unsigned int i = 0x12345678; unsigned char *p = (unsigned char *)&i;  *p = 0x00; unsigned short *p1 = (unsigned short *)(p+1);  *p1 = 0x0000; return 0; } 最后两句代码,从奇数边界去访问unsigned short型变量,显然不符合对齐的规定。在X86上,类似的操作只会影响效率;但在MIPS或者SPARC上可能导致error,因为它们要求必须字节对齐。 又如对于3.1.1节的结构体struct B,定义如下函数: void Func(struct B *p) { //Code } 在函数体内如果直接访问p->a,则很可能会异常。因为MIPS认为a是int,其地址应该是4的倍数,但p->a的地址很可能不是4的倍数。 如果p的地址不在对齐边界上就可能出问题,比如p来自一个跨CPU的数据包(多种数据类型的数据被按顺序放置在一个数据包中传输),或p是经过指针移位算出来的。 因此要特别注意跨CPU数据的接口函数对接口输入数据的处理,以及指针移位再强制转换为结构指针进行访问时的安全性。 解决方式如下: 定义一个此结构的局部变量,用memmove方式将数据拷贝进来。 void Func(struct B *p) { struct B tData; memmove(&tData, p, sizeof(struct B)); //此后可安全访问tData.a,因为编译器已将tData分配在正确的起始地址上 } 注意:如果能确定p的起始地址没问题,则不需要这么处理;如果不能确定(比如跨CPU输入数据、或指针移位运算出来的数据要特别小心),则需要这样处理。 用#pragma pack (1)将STRUCT_T定义为1字节对齐方式。 3.1.3.2 处理器间数据通信 处理器间通过消息(对于C/C++而言就是结构体)进行通信时,需要注意字节对齐以及字节序的问题。 大多数编译器提供内存对其的选项供用户使用。这样用户可以根据处理器的情况选择不同的字节对齐方式。 例如C/C++编译器提供的#pragma pack(n) n=1,2,4等,让编译器在生成目标文件时,使内存数据按照指定的方式排布在1,2,4等字节整除的内存地址处。 然而在不同编译平台或处理器上,字节对齐会造成消息结构长度的变化。编译器为了使字节对齐可能会对消息结构体进行填充,不同编译平台可能填充为不同的形式,大大增加处理器间数据通信的风险。 下面以32位处理器为例,提出一种内存对齐方法以解决上述问题。 对于本地使用的数据结构,为提高内存访问效率,采用四字节对齐方式;同时为了减少内存的开销,合理安排结构体成员的位置,减少四字节对齐导致的成员之间的空隙,降低内存开销。 对于处理器之间的数据结构,需要保证消息长度不会因不同编译平台或处理器而导致消息结构体长度发生变化,使用一字节对齐方式对消息结构进行紧缩;为保证处理器之间的消息数据结构的内存访问效率,采用字节填充的方式自己对消息中成员进行四字节对齐。 数据结构的成员位置要兼顾成员之间的关系、数据访问效率和空间利用率。顺序安排原则是:四字节的放在最前面,两字节的紧接最后。 一个四字节成员,一字节紧接最后一个两字节成员,填充字节放在最后。举例如下: typedef struct tag_T_MSG { long ParaA; long ParaB;  short ParaC; char ParaD; char Pad; //填充字节 }T_MSG; 3.1.3.3 排查对齐问题 如果出现对齐或者赋值问题可查看: 编译器的字节序大小端设置; 处理器架构本身是否支持非对齐访问; 如果支持看设置对齐与否,如果没有则看访问时需要加某些特殊的修饰来标志其特殊访问操作。 3.1.4 更改对齐方式 主要是更改C编译器的缺省字节对齐方式。 在缺省情况下,C编译器为每一个变量或是数据单元按其自然对界条件分配空间。一般地,可以通过下面的方法来改变缺省的对界条件: 使用伪指令#pragma pack(n):C编译器将按照n个字节对齐; 使用伪指令#pragma pack():取消自定义字节对齐方式。 另外,还有如下的一种方式(GCC特有语法): __attribute((aligned (n))):让所作用的结构成员对齐在n字节自然边界上。如果结构体中有成员的长度大于n,则按照最大成员的长度来对齐。 __ attribute __ ((packed)):取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐。 【注】__ attribute __机制是GCC的一大特色,可以设置函数属性(Function Attribute)、变量属性(Variable Attribute)和类型属性(Type Attribute)。详细介绍请参考: http://www.unixwiz.net/techtips/gnu-c-attributes.html 下面具体针对MS VC/C++ 6.0编译器介绍下如何修改编译器默认对齐值。 VC/C++ IDE环境中,可在[Project]|[Settings],C/C++选项卡Category的Code Generation选项的Struct Member Alignment中修改,默认是8字节。 VC/C++中的编译选项有/Zp[1|2|4|8|16],/Zpn表示以n字节边界对齐。n字节边界对齐是指一个成员的地址必须安排在成员的尺寸的整数倍地址上或者是n的整数倍地址上,取它们中的最小值。亦即:min(sizeof(member), n)。 实际上,1字节边界对齐也就表示结构成员之间没有空洞。 /Zpn选项应用于整个工程,影响所有参与编译的结构体。在Struct member alignment中可选择不同的对齐值来改变编译选项。 在编码时,可用#pragma pack动态修改对齐值。具体语法说明见附录5.3节。 自定义对齐值后要用#pragma pack()来还原,否则会对后面的结构造成影响。 【例3】分析如下结构体C: #pragma pack(2) //指定按2字节对齐 struct C { char b; int a;  short c; }; #pragma pack() //取消指定对齐,恢复缺省对齐 变量b自身对齐值为1,指定对齐值为2,所以有效对齐值为1,假设C从0x0000开始,则b存放在0x0000,符合0x0000%1= 0;变量a自身对齐值为4,指定对齐值为2,所以有效对齐值为2,顺序存放在0x0002~0x0005四个连续字节中,符合0x0002%2=0。 变量c的自身对齐值为2,所以有效对齐值为2,顺序存放在0x0006~0x0007中,符合 0x0006%2=0。所以从0x0000到0x00007共八字节存放的是C的变量。C的自身对齐值为4,所以其有效对齐值为2。又8%2=0,C只占用0x0000~0x0007的八个字节。所以sizeof(struct C) = 8。 注意,结构体对齐到的字节数并非完全取决于当前指定的pack值,如下: #pragma pack(8) struct D { char b; short a; char c; }; #pragma pack() 虽然#pragma pack(8),但依然按照两字节对齐,所以sizeof(struct D)的值为6。因为:对齐到的字节数 = min{当前指定的pack值,最大成员大小}。 另外,GNU GCC编译器中按1字节对齐可写为以下形式: #define GNUC_PACKED __attribute__((packed)) struct C { char b; int a;  short c; }GNUC_PACKED; 此时sizeof(struct C)的值为7。 3.2 栈内存对齐 在VC/C++中,栈的对齐方式不受结构体成员对齐选项的影响。总是保持对齐且对齐在4字节边界上。 【例4】 #pragma pack(push, 1) //后面可改为1, 2, 4, 8 struct StrtE { char m1; long m2; }; #pragma pack(pop) int main(void) { char a;  short b; int c; double d[2]; struct StrtE s; printf("a    address:   %p\n", &a); printf("b    address:   %p\n", &b); printf("c    address:   %p\n", &c); printf("d[0] address:   %p\n", &(d[0])); printf("d[1] address:   %p\n", &(d[1])); printf("s    address:   %p\n", &s); printf("s.m2 address:   %p\n", &(s.m2)); return 0; } 结果如下: a    address: 0xbfc4cfff b    address: 0xbfc4cffc c    address: 0xbfc4cff8 d[0] address: 0xbfc4cfe8 d[1] address: 0xbfc4cff0 s    address: 0xbfc4cfe3 s.m2 address: 0xbfc4cfe4 可以看出都是对齐到4字节。并且前面的char和short并没有被凑在一起(成4字节),这和结构体内的处理是不同的。 至于为什么输出的地址值是变小的,这是因为该平台下的栈是倒着“生长”的。 3.3 位域对齐 3.3.1 位域定义 有些信息在存储时,并不需要占用一个完整的字节,而只需占几个或一个二进制位。例如在存放一个开关量时,只有0和1两种状态,用一位二进位即可。 为了节省存储空间和处理简便,C语言提供了一种数据结构,称为“位域”或“位段”。 位域是一种特殊的结构成员或联合成员(即只能用在结构或联合中),用于指定该成员在内存存储时所占用的位数,从而在机器内更紧凑地表示数据。 每个位域有一个域名,允许在程序中按域名操作对应的位。这样就可用一个字节的二进制位域来表示几个不同的对象。 位域定义与结构定义类似,其形式为: struct 位域结构名  { //位域列表 }; 其中位域列表的形式为: 类型说明符 位域名:位域长度 位域的使用和结构成员的使用相同,其一般形式为: 位域变量名.位域名 位域允许用各种格式输出。 位域在本质上就是一种结构类型,不过其成员是按二进位分配的。位域变量的说明与结构变量说明的方式相同,可先定义后说明、同时定义说明或直接说明。 位域的使用主要为下面两种情况: 当机器可用内存空间较少而使用位域可大量节省内存时。如把结构作为大数组的元素时。 当需要把一结构体或联合映射成某预定的组织结构时。如需要访问字节内的特定位时。 3.3.2 对齐准则 位域成员不能单独被取sizeof值。下面主要讨论含有位域的结构体的sizeof。 C99规定int、unsigned int和bool可以作为位域类型,但编译器几乎都对此作了扩展,允许其它类型的存在。 位域作为嵌入式系统中非常常见的一种编程工具,优点在于压缩程序的存储空间。 其对齐规则大致为: 如果相邻位域字段的类型相同,且其位宽之和小于类型的sizeof大小,则后面的字段将紧邻前一个字段存储,直到不能容纳为止; 如果相邻位域字段的类型相同,但其位宽之和大于类型的sizeof大小,则后面的字段将从新的存储单元开始,其偏移量为其类型大小的整数倍; 如果相邻的位域字段的类型不同,则各编译器的具体实现有差异,VC6采取不压缩方式,Dev-C++和GCC采取压缩方式; 如果位域字段之间穿插着非位域字段,则不进行压缩; 整个结构体的总大小为最宽基本类型成员大小的整数倍,而位域则按照其最宽类型字节数对齐。 【例5】 struct BitField { char element1  : 1; char element2  : 4; char element3  : 5; }; 位域类型为char,第1个字节仅能容纳下element1和element2,所以element1和element2被压缩到第1个字节中,而element3只能从下一个字节开始。因此sizeof(BitField)的结果为2。 【例6】 struct BitField1 { char element1   : 1;     short element2  : 5; char element3   : 7; }; 由于相邻位域类型不同,在VC6中其sizeof为6,在Dev-C++中为2。 【例7】 struct BitField2 { char element1  : 3; char element2  ; char element3  : 5; }; 非位域字段穿插在其中,不会产生压缩,在VC6和Dev-C++中得到的大小均为3。 【例8】 struct StructBitField { int element1   : 1; int element2   : 5; int element3   : 29; int element4   : 6; char element5  :2; char stelement; //在含位域的结构或联合中也可同时说明普通成员 }; 位域中最宽类型int的字节数为4,因此结构体按4字节对齐,在VC6中其sizeof为16。 3.3.3 注意事项 关于位域操作有几点需要注意: 位域的地址不能访问,因此不允许将&运算符用于位域。不能使用指向位域的指针也不能使用位域的数组(数组是种特殊指针)。 例如,scanf函数无法直接向位域中存储数据: int main(void) { struct BitField1 tBit; scanf("%d", &tBit.element2); //error: cannot take address of bit-field 'element2' return 0; } 可用scanf函数将输入读入到一个普通的整型变量中,然后再赋值给tBit.element2。 位域不能作为函数返回的结果。 位域以定义的类型为单位,且位域的长度不能够超过所定义类型的长度。例如定义int a:33是不允许的。 位域可以不指定位域名,但不能访问无名的位域。 位域可以无位域名,只用作填充或调整位置,占位大小取决于该类型。 例如,char :0表示整个位域向后推一个字节,即该无名位域后的下一个位域从下一个字节开始存放,同理short :0和int :0分别表示整个位域向后推两个和四个字节。 当空位域的长度为具体数值N时(如int :2),该变量仅用来占位N位。 【例9】 struct BitField3 { char element1  : 3; char :6; char element3  : 5; }; 结构体大小为3。因为element1占3位,后面要保留6位而char为8位,所以保留的6位只能放到第2个字节。 同样element3只能放到第3字节。 struct BitField4 { char element1  : 3; char :0; char element3  : 5; }; 长度为0的位域告诉编译器将下一个位域放在一个存储单元的起始位置。 如上,编译器会给成员element1分配3位,接着跳过余下的4位到下一个存储单元,然后给成员element3分配5位。故上面的结构体大小为2。 位域的表示范围。 位域的赋值不能超过其可以表示的范围; 位域的类型决定该编码能表示的值的结果。 对于第二点,若位域为unsigned类型,则直接转化为正数;若非unsigned类型,则先判断最高位是否为1,若为1表示补码,则对其除符号位外的所有位取反再加一得到最后的结果数据(原码)。如: unsigned int p:3 = 111; //p表示7 int p:3 = 111; //p 表示-1,对除符号位之外的所有位取反再加一 带位域的结构在内存中各个位域的存储方式取决于编译器,既可从左到右也可从右到左存储。 【例10】在VC6下执行下面的代码: int main(void) { union { int i; struct { char a : 1; char b : 1; char c : 2;         }bits;     }num; printf("Input an integer for i(0~15): "); scanf("%d", &num.i); printf("i = %d, cba = %d %d %d\n", num.i, num.bits.c, num.bits.b, num.bits.a); return 0; } 输入i值为11,则输出为i = 11, cba = -2 -1 -1。 Intel x86处理器按小字节序存储数据,所以bits中的位域在内存中放置顺序为ccba。当num.i置为11时,bits的最低有效位(即位域a)的值为1,a、b、c按低地址到高地址分别存储为10、1、1(二进制)。 但为什么最后的打印结果是a=-1而不是1? 因为位域a定义的类型signed char是有符号数,所以尽管a只有1位,仍要进行符号扩展。1做为补码存在,对应原码-1。 如果将a、b、c的类型定义为unsigned char,即可得到cba = 2 1 1。1011即为11的二进制数。 注:C语言中,不同的成员使用共同的存储区域的数据构造类型称为联合(或共用体)。联合占用空间的大小取决于类型长度最大的成员。联合在定义、说明和使用形式上与结构体相似。 位域的实现会因编译器的不同而不同,使用位域会影响程序可移植性。因此除非必要否则最好不要使用位域。 尽管使用位域可以节省内存空间,但却增加了处理时间。当访问各个位域成员时,需要把位域从它所在的字中分解出来或反过来把一值压缩存到位域所在的字位中。 四 、总结 让我们回到引言部分的问题。 缺省情况下,C/C++编译器默认将结构、栈中的成员数据进行内存对齐。因此,引言程序输出就变成"c1 -> 0, s -> 2, c2 -> 4, i -> 8"。 编译器将未对齐的成员向后移,将每一个都成员对齐到自然边界上,从而也导致整个结构的尺寸变大。尽管会牺牲一点空间(成员之间有空洞),但提高了性能。 也正是这个原因,引言例子中sizeof(T_ FOO)为12,而不是8。 总结说来,就是在结构体中,综合考虑变量本身和指定的对齐值;在栈上,不考虑变量本身的大小,统一对齐到4字节。 五 、附录 5.1 字节序与网络序 5.1.1 字节序 字节序,顾名思义就是字节的高低位存放顺序。 对于单字节,大部分处理器以相同的顺序处理比特位,因此单字节的存放和传输方式一般相同。 对于多字节数据,如整型(32位机中一般占4字节),在不同的处理器的存放方式主要有两种(以内存中0x0A0B0C0D的存放方式为例)。 大字节序(Big-Endian,又称大端序或大尾序) 在计算机中,存储介质以下面方式存储整数0x0A0B0C0D则称为大字节序: 其中,最高有效位(MSB,Most Significant Byte)0x0A存储在最低的内存地址处。下个字节0x0B存在后面的地址处。同时,最高的16bit单元0x0A0B存储在低位。 简而言之,大字节序就是高字节存入低地址,低字节存入高地址。 这里讲个词源典故:“endian”一词来源于乔纳森·斯威夫特的小说《格列佛游记》。小说中,小人国为水煮蛋该从大的一端(Big-End)剥开还是小的一端(Little-End)剥开而争论,争论的双方分别被称为Big-endians和Little-endians。 1980年,Danny Cohen在其著名的论文"On Holy Wars and a Plea for Peace"中为平息一场关于字节该以什么样的顺序传送的争论而引用了该词。 借用上面的典故,想象一下要把熟鸡蛋旋转着稳立起来,大头(高字节)肯定在下面(低地址)^_^ 小字节序(Little-Endian,又称小端序或小尾序) 在计算机中,存储介质以下面方式存储整数0x0A0B0C0D则称为小字节序: 其中,最低有效位(LSB,Least Significant Byte)0x0D存储在最低的内存地址处。后面字节依次存在后面的地址处。同时,最低的16bit单元0x0A0B存储在低位。 可见,小字节序就是“高字节存入高地址,低字节存入低地址”。 C语言中的位域结构也要遵循比特序(类似字节序)。例如: struct bitfield { unsigned char a: 2; unsigned char b: 6; } 该位域结构占1个字节,假设赋值a = 0x01和b=0x02,则大字节机器上该字节为(01)(000010),小字节机器上该字节为(000010)(01)。因此在编写可移植代码时,需要加条件编译。 注意,在包含位域的C结构中,若位域A在位域B之前定义,则位域A所占用的内存空间地址低于位域B所占用的内存空间。 对上述问题,详细的讲解可参考 http://www.linuxjournal.com/article/6788。 另见以下联合体,在小字节机器上若low=0x01,high=0x02,则hex=0x21: int main(void) { union { unsigned char hex; struct { unsigned char low  : 4; unsigned char high : 4;   };  }convert;  convert.low = 0x01;     convert.high = 0x02; printf("hex = 0x%0x\n", convert.hex); return 0; } 5.1.2 网络序 网络传输一般采用大字节序,也称为网络字节序或网络序。IP协议中定义大字节序为网络字节序。 对于可移植的代码来说,将接收的网络数据转换成主机的字节序是必须的,一般会有成对的函数用于把网络数据转换成相应的主机字节序或反之(若主机字节序与网络字节序相同,通常将函数定义为空宏)。 伯克利socket API定义了一组转换函数,用于16和32位整数在网络序和主机字节序之间的转换。Htonl、htons用于主机序转换到网络序;ntohl、ntohs用于网络序转换到本机序。 注意:在大小字节序转换时,必须考虑待转换数据的长度(如5.1.1节的数据单元)。另外对于单字符或小于单字符的几个bit数据,是不必转换的,因为在机器存储和网络发送的一个字符内的bit位存储顺序是一致的。 5.1.3 位序 用于描述串行设备的传输顺序。一般硬件传输采用小字节序(先传低位),但I2C协议采用大字节序。网络协议中只有数据链路层的底端会涉及到。 5.1.4 处理器字节序 不同处理器体系的字节序如下所示: X86、MOS Technology 6502、Z80、VAX、PDP-11等处理器为Little endian; Motorola 6800、Motorola 68000、PowerPC 970、System/370、SPARC(除V9外)等处理器为Big endian; ARM、PowerPC (除PowerPC 970外)、DEC Alpha,SPARC V9,MIPS,PA-RISC and IA64等的字节序是可配置的。 5.1.5 字节序编程 请看下面的语句: printf("%c\n", *((short*)"AB") >> 8); 在大字节序下输出为'A',小字节序下输出为'B'。 下面的代码可用来判断本地机器字节序: //字节序枚举类型 typedef enum {  ENDIAN_LITTLE = (INT8U)0X00,  ENDIAN_BIG    = (INT8U)0X01 }E_ENDIAN_TYPE; E_ENDIAN_TYPE GetEndianType(VOID) {  INT32U dwData = 0x12345678; if(0x78 == *((INT8U*)&dwData)) return ENDIAN_LITTLE; else return ENDIAN_BIG;  } //Start of GetEndianTypeTest// #include VOID GetEndianTypeTest(VOID) { #if _BYTE_ORDER == _LITTLE_ENDIAN printf("[%s]Result: %s, EndianType = %s!\n", __FUNCTION__,              (ENDIAN_LITTLE != GetEndianType()) ? "ERROR" : "OK", "Little"); #elif _BYTE_ORDER == _BIG_ENDIAN printf("[%s]Result: %s, EndianType = %s!\n", __FUNCTION__,              (ENDIAN_BIG != GetEndianType()) ? "ERROR" : "OK", "Big"); #endif } //End of GetEndianTypeTest// 在字节序不同的平台间的交换数据时,必须进行转换。比如对于int类型,大字节序写入文件: int i = 100; write(fd, &i, sizeof(int)); 小字节序读出后: int i; read(fd, &i, sizeof(int)); char buf[sizeof(int)]; memcpy(buf, &i, sizeof(int)); for(i = 0; i < sizeof(int); i++) { int v = buf[sizeof(int) - i - 1];  buf[sizeof(int) - 1] =  buf[i];  buf[i] = v; } memcpy(&i, buf, sizeof(int)); 上面仅仅是个例子。在不同平台间即使不存在字节序的问题,也尽量不要直接传递二进制数据。作为可选的方式就是使用文本来交换数据,这样至少可以避免字节序的问题。 很多的加密算法为了追求速度,都会采取字符串和数字之间的转换,在计算完毕后,必须注意字节序的问题,在某些实现中可以见到使用预编译的方式来完成,这样很不方便,如果使用前面的语句来判断,就可以自动适应。 字节序问题不仅影响异种平台间传递数据,还影响诸如读写一些特殊格式文件之类程序的可移植性。此时使用预编译的方式来完成也是一个好办法。 5.2 对齐时的填充字节 代码如下: struct A {   char  c;   int   i;   short s; }; int main(void) {    struct A a;   a.c = 1; a.i = 2; a.s = 3; printf("sizeof(A)=%d\n", sizeof(struct A)); return 0; } 执行后输出为sizeof(A)=12。 VC6.0环境中,在main函数打印语句前设置断点,执行到断点处时根据结构体a的地址查看变量存储如下: 可见填充字节为0xCC,即int3中断。 5.3 pragma pack语法说明 #pragma pack(n) #pragma pack(push, 1) #pragma pack(pop) #pragma pack(n) 该指令指定结构和联合成员的紧凑对齐。而一个完整的转换单元的结构和联合的紧凑对齐由/ Z p选项设置。紧凑对齐用pack编译指示在数据说明层设置。该编译指示在其出现后的第一个结构或者联合说明处生效。该编译指示对定义无效。 当使用#pragma pack (n) 时,n 为1、2、4、8 或1 6 。第一个结构成员后的每个结构成员都被存储在更小的成员类型或n字节界限内。如果使用无参量的#pragma pack,结构成员被紧凑为以/ Z p指定的值。该缺省/ Z p紧凑值为/ Z p 8。 2)编译器也支持以下增强型语法: #pragma pack( [ [ { push | pop } , ] [identifier, ] ] [ n] ) 若不同的组件使用pack编译指示指定不同的紧凑对齐, 这个语法允许你把程序组件组合为一个单独的转换单元。 带push参量的pack编译指示的每次出现将当前的紧凑对齐存储到一个内部编译器堆栈中。编译指示的参量表从左到右读取。如果使用push,则当前紧凑值被存储起来;如果给出一个n值,该值将成为新的紧凑值。若指定一个标识符,即选定一个名称,则该标识符将和这个新的的紧凑值联系起来。 带一个pop参量的pack编译指示的每次出现都会检索内部编译器堆栈顶的值,并使该值为新的紧凑对齐值。如果使用pop参量且内部编译器堆栈是空的,则紧凑值为命令行给定的值,并将产生一个警告信息。若使用pop且指定一个n值,该值将成为新的紧凑值。 若使用pop且指定一个标识符,所有存储在堆栈中的值将从栈中删除,直到找到一个匹配的标识符。这个与标识符相关的紧凑值也从栈中移出,并且这个仅在标识符入栈之前存在的紧凑值成为新的紧凑值。如果未找到匹配的标识符, 将使用命令行设置的紧凑值,并且将产生一个一级警告。缺省紧凑对齐为8。 pack编译指示的新的增强功能让你在编写头文件时,确保在遇到该头文件的前后的紧凑值是一样的。 5.4 Intel关于内存对齐的说明 以下内容节选自《Intel Architecture 32 Manual》。 字、双字和四字在自然边界上不需要在内存中对齐。(对于字、双字和四字来说,自然边界分别是偶数地址,可以被4整除的地址,和可以被8整除的地址。) 无论如何,为了提高程序的性能,数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;然而,对齐的内存访问仅需要一次访问。 一个字或双字操作数跨越了4字节边界,或者一个四字操作数跨越了8字节边界,被认为是未对齐的,从而需要两次总线周期来访问内存。一个字起始地址是奇数但却没有跨越字边界被认为是对齐的,能够在一个总线周期中被访问。 某些操作双四字的指令需要内存操作数在自然边界上对齐。如果操作数没有对齐,这些指令将会产生一个通用保护异常(#GP)。双四字的自然边界是能够被16 整除的地址。其他操作双四字的指令允许未对齐的访问(不会产生通用保护异常),然而,需要额外的内存总线周期来访问内存中未对齐的数据。 5.5 不同架构处理器的对齐要求 RISC指令集处理器(MIPS/ARM):这种处理器的设计以效率为先,要求所访问的多字节数据(short/int/ long)的地址必须是为此数据大小的倍数,如short数据地址应为2的倍数,long数据地址应为4的倍数,也就是说是对齐的。 CISC指令集处理器(X86):没有上述限制。 对齐处理策略 访问非对齐多字节数据时(pack数据),编译器会将指令拆成多条(因为非对齐多字节数据可能跨越地址对齐边界),保证每条指令都从正确的起始地址上获取数据,但也因此效率比较低。 访问对齐数据时则只用一条指令获取数据,因此对齐数据必须确保其起始地址是在对齐边界上。如果不是在对齐的边界,对X86 CPU是安全的,但对MIPS/ARM这种RISC CPU会出现“总线访问异常”。 为什么X86是安全的呢? X86 CPU是如何进行数据对齐的。X86 CPU的EFLAGS寄存器中包含一个特殊的位标志,称为AC(对齐检查的英文缩写)标志。按照默认设置,当CPU首次加电时,该标志被设置为0。当该标志是0时,CPU能够自动执行它应该执行的操作,以便成功地访问未对齐的数据值。 然而,如果该标志被设置为1,每当系统试图访问未对齐的数据时,CPU就会发出一个INT 17H中断。X86的Windows 2000和Windows  98版本从来不改变这个CPU标志位。因此,当应用程序在X86处理器上运行时,你根本看不到应用程序中出现数据未对齐的异常条件。 为什么MIPS/ARM不安全呢? 因为MIPS/ARM CPU不能自动处理对未对齐数据的访问。当未对齐的数据访问发生时,CPU就会将这一情况通知操作系统。这时,操作系统将会确定它是否应该引发一个数据未对齐异常条件,对vxworks是会触发这个异常的。 5.6 ARM下的对齐处理 有部分摘自ARM编译器文档对齐部分。 对齐的使用: 1) __align(num) 用于修改最高级别对象的字节边界。在汇编中使用LDRD或STRD时就要用到此命令__align(8)进行修饰限制。来保证数据对象是相应对齐。 这个修饰对象的命令最大是8个字节限制,可以让2字节的对象进行4字节对齐,但不能让4字节的对象2字节对齐。 __align是存储类修改,只修饰最高级类型对象,不能用于结构或者函数对象。 2) __packed 进行一字节对齐。需注意: 不能对packed的对象进行对齐; 所有对象的读写访问都进行非对齐访问; float及包含float的结构联合及未用__packed的对象将不能字节对齐; __packed对局部整型变量无影响。 强制由unpacked对象向packed对象转化时未定义。整型指针可以合法定义为packed,如__packed int* p(__packed int 则没有意义) 对齐或非对齐读写访问可能存在的问题: //定义如下结构,b的起始地址不对齐。在栈中访问b可能有问题,因为栈上数据对齐访问 __packed struct STRUCT_TEST { char a; int b; char c; }; //将下面的变量定义成全局静态(不在栈上) static char *p; static struct STRUCT_TEST a; void Main() {      __packed int *q; //定义成__packed来修饰当前q指向为非对齐的数据地址下面的访问则可以 p = (char*)&a;       q = (int*)(p + 1);       *q = 0x87654321; /* 得到赋值的汇编指令很清楚      ldr      r5,0x20001590 ; = #0x12345678      [0xe1a00005]   mov     r0,r5      [0xeb0000b0]   bl      __rt_uwrite4  //在此处调用一个写4字节的操作函数                [0xe5c10000]   strb    r0,[r1,#0]    //函数进行4次strb操作然后返回,正确访问数据      [0xe1a02420]   mov     r2,r0,lsr #8      [0xe5c12001]   strb    r2,[r1,#1]      [0xe1a02820]   mov     r2,r0,lsr #16      [0xe5c12002]   strb    r2,[r1,#2]      [0xe1a02c20]   mov     r2,r0,lsr #24      [0xe5c12003]   strb    r2,[r1,#3]      [0xe1a0f00e]   mov     pc,r14            若q未加__packed修饰则汇编出来指令如下(会导致奇地址处访问失败):      [0xe59f2018]   ldr      r2,0x20001594 ; = #0x87654321      [0xe5812000]   str     r2,[r1,#0]      */ //这样很清楚地看到非对齐访问如何产生错误,以及如何消除非对齐访问带来的问题 //也可看到非对齐访问和对齐访问的指令差异会导致效率问题 } 5.7 《The C Book》之位域篇 While we're on the subject of structures, we might as well look at bitfields. They can only be declared inside a structure or a union, and allow you to specify some very small objects of a given number of bits in length. Their usefulness is limited and they aren't seen in many programs, but we'll deal with them anyway. This example should help to make things clear: struct { unsigned field1 :4; //field 4 bits wide unsigned :3; //unnamed 3 bit field(allow for padding) signed field2   :1; //one-bit field(can only be 0 or -1 in two's complement) unsigned :0; //align next field on a storage unit unsigned field3 :6; }full_of_fields; Each field is accessed and manipulated as if it were an ordinary member of a structure. The keywords signed and unsigned mean what you would expect, except that it is interesting to note that a 1-bit signed field on a two's complement machine can only take the values 0 or -1. The declarations are permitted to include the const and volatile qualifiers. The main use of bitfields is either to allow tight packing of data or to be able to specify the fields within some externally produced data files. C gives no guarantee of the ordering of fields within machine words, so if you do use them for the latter reason, you program will not only be non-portable, it will be compiler-dependent too. The Standard says that fields are packed into ‘storage units’, which are typically machine words. The packing order, and whether or not a bitfield may cross a storage unit boundary, are implementation defined. To force alignment to a storage unit boundary, a zero width field is used before the one that you want to have aligned. Be careful using them. It can require a surprising amount of run-time code to manipulate these things and you can end up using more space than they save. Bit fields do not have addresses—you can't have pointers to them or arrays of them. 5.8 C语言字节相关面试题 5.8.1 Intel/微软C语言面试题 请看下面的问题: #pragma pack(8) struct s1 { short a; long b; }; struct s2 { char c;     s1   d; long long e; //VC6.0下可能要用__int64代替双long }; #pragma pack() 问:1. sizeof(s2) = ?2. s2的s1中的a后面空了几个字节接着是b? 【分析】 成员对齐有一个重要的条件,即每个成员分别按自己的方式对齐。 也就是说上面虽然指定了按8字节对齐,但并不是所有的成员都是以8字节对齐。其对齐的规则是:每个成员按其类型的对齐参数(通常是这个类型的大小)和指定对齐参数(这里是8字节)中较小的一个对齐,并且结构的长度必须为所用过的所有对齐参数的整数倍,不够就补空字节。 s1中成员a是1字节,默认按1字节对齐,而指定对齐参数为8,两值中取1,即a按1字节对齐;成员b是4个字节,默认按4字节对齐,这时就按4字节对齐,所以sizeof(s1)应该为8; s2中c和s1中a一样,按1字节对齐。而d 是个8字节结构体,其默认对齐方式就是所有成员使用的对齐参数中最大的一个,s1的就是4。所以,成员d按4字节对齐。成员e是8个字节,默认按8字节对齐,和指定的一样,所以它对到8字节的边界上。 这时,已经使用了12个字节,所以又添加4个字节的空,从第16个字节开始放置成员e。此时长度为24,并可被8(成员e按8字节对齐)整除。这样,一共使用了24个字节。 各个变量在内存中的布局为: c***aa** bbbb**** dddddddd   ——这种“矩阵写法”很方便看出结构体实际大小! 因此,sizeof(S2)结果为24,a后面空了2个字节接着是b。 这里有三点很重要: 每个成员分别按自己的方式对齐,并能最小化长度; 复杂类型(如结构)的默认对齐方式是其最长的成员的对齐方式,这样在成员是复杂类型时可以最小化长度; 对齐后的长度必须是成员中最大对齐参数的整数倍,这样在处理数组时可保证每一项都边界对齐。 还要注意,“空结构体”(不含数据成员)的大小为1,而不是0。试想如果不占空间的话,一个空结构体变量如何取地址、两个不同的空结构体变量又如何得以区分呢? 5.8.2 上海网宿科技面试题 假设硬件平台是intel x86(little endian),以下程序输出什么: //假设硬件平台是intel x86(little endian) typedef unsigned int uint32_t; void inet_ntoa(uint32_t in) { char b[18]; register char *p;     p = (char *)∈ #define UC(b) (((int)b)&0xff) //byte转换为无符号int型 sprintf(b, "%d.%d.%d.%d\n", UC(p[0]), UC(p[1]), UC(p[2]), UC(p[3])); printf(b); } int main(void) {       inet_ntoa(0x12345678);     inet_ntoa(0x87654321); return 0; } 先看如下程序: int main(void) { int a = 0x12345678; char *p = (char *)&a; char str[20]; sprintf(str,"%d.%d.%d.%d\n", p[0], p[1], p[2], p[3]); printf(str); return 0; } 按照小字节序的规则,变量a在计算机中存储方式为: 注意,p并不是指向0x12345678的开头0x12,而是指向0x78。p[0]到p[1]的操作是&p[0]+1,因此p[1]地址比p[0]地址大。输出结果为120.86.52.18。 反过来的话,令int a = 0x87654321,则输出结果为33.67.101.-121。 为什么有负值呢? 因为系统默认的char是有符号的,本来是0x87也就是135,大于127因此就减去256得到-121。 想要得到正值的话只需将char *p = (char *)&a改为unsigned char *p = (unsigned char *)&a即可。 综上不难得出,网宿面试题的答案为120.86.52.18和33.67.101.135。

    2024-12-06 101浏览
  • 如何进行linux内核调试

    内核开发比用户空间开发更难的一个因素就是内核调试艰难。内核错误往往会导致系统宕机,很难保留出错时的现场。调试内核的关键在于你的对内核的深刻理解。 一  调试前的准备 在调试一个bug之前,我们所要做的准备工作有: 有一个被确认的bug。 包含这个bug的内核版本号,需要分析出这个bug在哪一个版本被引入,这个对于解决问题有极大的帮助。可以采用二分查找法来逐步锁定bug引入版本号。 对内核代码理解越深刻越好,同时还需要一点点运气。 该bug可以复现。如果能够找到复现规律,那么离找到问题的原因就不远了。 最小化系统。把可能产生bug的因素逐一排除掉。 二  内核中的bug 内核中的bug也是多种多样的。它们的产生有无数的原因,同时表象也变化多端。从隐藏在源代码中的错误到展现在目击者面前的bug,其发作往往是一系列连锁反应的事件才可能出发的。虽然内核调试有一定的困难,但是通过你的努力和理解,说不定你会喜欢上这样的挑战。 三  内核调试配置选项 学习编写驱动程序要构建安装自己的内核(标准主线内核)。最重要的原因之一是:内核开发者已经建立了多项用于调试的功能。但是由于这些功能会造成额外的输出,并导致能下降,因此发行版厂商通常会禁止发行版内核中的调试功能。 1  内核配置 为了实现内核调试,在内核配置上增加了几项: Kernel hacking ---> [*] Magic SysRq key [*] Kernel debugging [*] Debug slab memory allocations [*] Spinlock and rw-lock debugging: basic checks [*] Spinlock debugging: sleep-inside-spinlock checking [*] Compile the kernel with debug info Device Drivers ---> Generic Driver Options ---> [*] Driver Core verbose debug messages General setup ---> [*] Configure standard kernel features (for small systems) ---> [*] Load all symbols for debugging/ksymoops 启用选项例如: slab layer debugging(slab层调试选项) high-memory debugging(高端内存调试选项) I/O mapping debugging(I/O映射调试选项) spin-lock debugging(自旋锁调试选项) stack-overflow checking(栈溢出检查选项) sleep-inside-spinlock checking(自旋锁内睡眠选项) 2  调试原子操作 从内核2.5开发,为了检查各类由原子操作引发的问题,内核提供了极佳的工具。 内核提供了一个原子操作计数器,它可以配置成,一旦在原子操作过程中,进城进入睡眠或者做了一些可能引起睡眠的操作,就打印警告信息并提供追踪线索。 所以,包括在使用锁的时候调用schedule(),正使用锁的时候以阻塞方式请求分配内存等,各种潜在的bug都能够被探测到。 下面这些选项可以最大限度地利用该特性: CONFIG_PREEMPT = y CONFIG_DEBUG_KERNEL = y CONFIG_KLLSYMS = y CONFIG_SPINLOCK_SLEEP = y 四  引发bug并打印信息 1  BUG()和BUG_ON() 一些内核调用可以用来方便标记bug,提供断言并输出信息。最常用的两个是BUG()和BUG_ON()。 定义在中: #ifndef HAVE_ARCH_BUG #define BUG() do { printk("BUG: failure at %s:%d/%s()! ", __FILE__, __LINE__, __FUNCTION__); panic("BUG!"); /* 引发更严重的错误,不但打印错误消息,而且整个系统业会挂起 */ } while (0) #endif #ifndef HAVE_ARCH_BUG_ON #define BUG_ON(condition) do { if (unlikely(condition)) BUG(); } while(0) #endif 当调用这两个宏的时候,它们会引发OOPS,导致栈的回溯和错误消息的打印。 ※ 可以把这两个调用当作断言使用,如:BUG_ON(bad_thing); 2.WARN(x) 和 WARN_ON(x) 而WARN_ON则是调用dump_stack,打印堆栈信息,不会OOPS 定义在中: #ifndef __WARN_TAINT#ifndef __ASSEMBLY__extern void warn_slowpath_fmt(const char *file, const int line, const char *fmt, ...) __attribute__((format(printf, 3, 4)));extern void warn_slowpath_fmt_taint(const char *file, const int line, unsigned taint, const char *fmt, ...) __attribute__((format(printf, 4, 5)));extern void warn_slowpath_null(const char *file, const int line);#define WANT_WARN_ON_SLOWPATH#endif#define __WARN() warn_slowpath_null(__FILE__, __LINE__)#define __WARN_printf(arg...) warn_slowpath_fmt(__FILE__, __LINE__, arg)#define __WARN_printf_taint(taint, arg...) \ warn_slowpath_fmt_taint(__FILE__, __LINE__, taint, arg)#else#define __WARN() __WARN_TAINT(TAINT_WARN)#define __WARN_printf(arg...) do { printk(arg); __WARN(); } while (0)#define __WARN_printf_taint(taint, arg...) \ do { printk(arg); __WARN_TAINT(taint); } while (0)#endif #ifndef WARN_ON#define WARN_ON(condition) ({ \ int __ret_warn_on = !!(condition); \ if (unlikely(__ret_warn_on)) \ __WARN(); \ unlikely(__ret_warn_on); \})#endif #ifndef WARN#define WARN(condition, format...) ({ \ int __ret_warn_on = !!(condition); \ if (unlikely(__ret_warn_on)) \ __WARN_printf(format); \ unlikely(__ret_warn_on); \})#endif 3 dump_stack() 有些时候,只需要在终端上打印一下栈的回溯信息来帮助你调试。这时可以使用dump_stack()。这个函数只在终端上打印寄存器上下文和函数的跟踪线索。 if (!debug_check) { printk(KERN_DEBUG “provide some information…/n”); dump_stack(); } 五  printk() 内核提供的格式化打印函数。 1  printk函数的健壮性 健壮性是printk最容易被接受的一个特质,几乎在任何地方,任何时候内核都可以调用它(中断上下文、进程上下文、持有锁时、多处理器处理时等)。 2  printk函数脆弱之处 在系统启动过程中,终端初始化之前,在某些地方是不能调用的。如果真的需要调试系统启动过程最开始的地方,有以下方法可以使用: 使用串口调试,将调试信息输出到其他终端设备。 使用early_printk(),该函数在系统启动初期就有打印能力。但它只支持部分硬件体系。 3  LOG等级 printk和printf一个主要的区别就是前者可以指定一个LOG等级。内核根据这个等级来判断是否在终端上打印消息。内核把比指定等级高的所有消息显示在终端。 可以使用下面的方式指定一个LOG级别: printk(KERN_CRIT “Hello, world!\n”); 注意,第一个参数并不一个真正的参数,因为其中没有用于分隔级别(KERN_CRIT)和格式字符的逗号(,)。KERN_CRIT本身只是一个普通的字符串(事实上,它表示的是字符串 "";表 1 列出了完整的日志级别清单)。作为预处理程序的一部分,C 会自动地使用一个名为 字符串串联 的功能将这两个字符串组合在一起。组合的结果是将日志级别和用户指定的格式字符串包含在一个字符串中。 内核使用这个指定LOG级别与当前终端LOG等级console_loglevel来决定是不是向终端打印。 下面是可使用的LOG等级: #define KERN_EMERG "" /* system is unusable */#define KERN_ALERT "" /* action must be taken immediately */ #define KERN_CRIT "" /* critical conditions */#define KERN_ERR "" /* error conditions */#define KERN_WARNING "" /* warning conditions */#define KERN_NOTICE "" /* normal but significant condition */#define KERN_INFO "" /* informational */#define KERN_DEBUG "" /* debug-level messages */#define KERN_DEFAULT "" /* Use the default kernel loglevel */ 注意,如果调用者未将日志级别提供给 printk,那么系统就会使用默认值KERN_WARNING ""(表示只有KERN_WARNING 级别以上的日志消息会被记录)。由于默认值存在变化,所以在使用时最好指定LOG级别。有LOG级别的一个好处就是我们可以选择性的输出LOG。比如平时我们只需要打印KERN_WARNING级别以上的关键性LOG,但是调试的时候,我们可以选择打印KERN_DEBUG等以上的详细LOG。而这些都不需要我们修改代码,只需要通过命令修改默认日志输出级别: mtj@ubuntu :~$ cat /proc/sys/kernel/printk4 4 1 7mtj@ubuntu :~$ cat /proc/sys/kernel/printk_delay0mtj@ubuntu :~$ cat /proc/sys/kernel/printk_ratelimit5mtj@ubuntu :~$ cat /proc/sys/kernel/printk_ratelimit_burst10 第一项定义了 printk API 当前使用的日志级别。这些日志级别表示了控制台的日志级别、默认消息日志级别、最小控制台日志级别和默认控制台日志级别。printk_delay 值表示的是 printk 消息之间的延迟毫秒数(用于提高某些场景的可读性)。注意,这里它的值为 0,而它是不可以通过 /proc 设置的。printk_ratelimit 定义了消息之间允许的最小时间间隔(当前定义为每 5 秒内的某个内核消息数)。消息数量是由 printk_ratelimit_burst 定义的(当前定义为 10)。如果您拥有一个非正式内核而又使用有带宽限制的控制台设备(如通过串口), 那么这非常有用。注意,在内核中,速度限制是由调用者控制的,而不是在printk 中实现的。如果一个 printk 用户要求进行速度限制,那么该用户就需要调用printk_ratelimit 函数。 4  记录缓冲区 内核消息都被保存在一个LOG_BUF_LEN大小的环形队列中。 关于LOG_BUF_LEN定义: #define __LOG_BUF_LEN (1 << CONFIG_LOG_BUF_SHIFT) ※ 变量CONFIG_LOG_BUF_SHIFT在内核编译时由配置文件定义,对于i386平台,其值定义如下(在linux26/arch/i386/defconfig中): CONFIG_LOG_BUF_SHIFT=18 记录缓冲区操作: ① 消息被读出到用户空间时,此消息就会从环形队列中删除。 ② 当消息缓冲区满时,如果再有printk()调用时,新消息将覆盖队列中的老消息。 ③ 在读写环形队列时,同步问题很容易得到解决。 ※ 这个纪录缓冲区之所以称为环形,是因为它的读写都是按照环形队列的方式进行操作的。 5  syslogd/klogd 在标准的Linux系统上,用户空间的守护进程klogd从纪录缓冲区中获取内核消息,再通过syslogd守护进程把这些消息保存在系统日志文件中。klogd进程既可以从/proc/kmsg文件中,也可以通过syslog()系统调用读取这些消息。默认情况下,它选择读取/proc方式实现。klogd守护进程在消息缓冲区有新的消息之前,一直处于阻塞状态。一旦有新的内核消息,klogd被唤醒,读出内核消息并进行处理。默认情况下,处理例程就是把内核消息传给syslogd守护进程。syslogd守护进程一般把接收到的消息写入/var/log/messages文件中。不过,还是可以通过/etc/syslog.conf文件来进行配置,可以选择其他的输出文件。 6  dmesg dmesg 命令也可用于打印和控制内核环缓冲区。这个命令使用 klogctl 系统调用来读取内核环缓冲区,并将它转发到标准输出(stdout)。这个命令也可以用来清除内核环缓冲区(使用 -c 选项),设置控制台日志级别(-n 选项),以及定义用于读取内核日志消息的缓冲区大小(-s 选项)。注意,如果没有指定缓冲区大小,那么 dmesg 会使用 klogctl 的SYSLOG_ACTION_SIZE_BUFFER 操作确定缓冲区大小。 7 注意 a) 虽然printk很健壮,但是看了源码你就知道,这个函数的效率很低:做字符拷贝时一次只拷贝一个字节,且去调用console输出可能还产生中断。所以如果你的驱动在功能调试完成以后做性能测试或者发布的时候千万记得尽量减少printk输出,做到仅在出错时输出少量信息。否则往console输出无用信息影响性能。 b) printk的临时缓存printk_buf只有1K,所有一次printk函数只能记录<1K的信息到log buffer,并且printk使用的“ringbuffer”. 8 内核printk和日志系统的总体结构 9  动态调试 动态调试是通过动态的开启和禁止某些内核代码来获取额外的内核信息。 首先内核选项CONFIG_DYNAMIC_DEBUG应该被设置。所有通过pr_debug()/dev_debug()打印的信息都可以动态的显示或不显示。 可以通过简单的查询语句来筛选需要显示的信息。 -源文件名 -函数名 -行号(包括指定范围的行号) -模块名 -格式化字符串 将要打印信息的格式写入/dynamic_debug/control中。 nullarbor:~ # echo 'file svcsock.c line 1603 +p' >/dynamic_debug/control 六  内存调试工具 1  MEMWATCH MEMWATCH 由 Johan Lindh 编写,是一个开放源代码 C 语言内存错误检测工具,您可以自己下载它。只要在代码中添加一个头文件并在 gcc 语句中定义了 MEMWATCH 之后,您就可以跟踪程序中的内存泄漏和错误了。MEMWATCH 支持ANSIC,它提供结果日志纪录,能检测双重释放(double-free)、错误释放(erroneous free)、没有释放的内存(unfreedmemory)、溢出和下溢等等。 内存样本(test1.c) #include #include #include "memwatch.h"int main(void){ char *ptr1; char *ptr2; ptr1 = malloc(512); ptr2 = malloc(512); ptr2 = ptr1; free(ptr2); free(ptr1);} 内存样本(test1.c)中的代码将分配两个 512 字节的内存块,然后指向第一个内存块的指针被设定为指向第二个内存块。结果,第二个内存块的地址丢失,从而产生了内存泄漏。 现在我们编译内存样本(test1.c) 的 memwatch.c。下面是一个 makefile 示例: test1 gcc -DMEMWATCH -DMW_STDIO test1.c memwatchc -o test1 当您运行 test1 程序后,它会生成一个关于泄漏的内存的报告。下面展示了示例 memwatch.log 输出文件。 test1 memwatch.log 文件 MEMWATCH 2.67 Copyright (C) 1992-1999 Johan Lindh...double-free: <4> test1.c(15), 0x80517b4 was freed from test1.c(14)...unfreed: <2> test1.c(11), 512 bytes at 0x80519e4{FE FE FE FE FE FE FE FE FE FE FE FE ..............}Memory usage statistics (global): N)umber of allocations made: 2 L)argest memory usage : 1024 T)otal of all alloc() calls: 1024 U)nfreed bytes totals : 512 MEMWATCH 为您显示真正导致问题的行。如果您释放一个已经释放过的指针,它会告诉您。对于没有释放的内存也一样。日志结尾部分显示统计信息,包括泄漏了多少内存,使用了多少内存,以及总共分配了多少内存。 2  YAMD YAMD 软件包由 Nate Eldredge 编写,可以查找 C 和 C++ 中动态的、与内存分配有关的问题。在撰写本文时,YAMD 的最新版本为 0.32。请下载 yamd-0.32.tar.gz。执行 make 命令来构建程序;然后执行 make install 命令安装程序并设置工具。 一旦您下载了 YAMD 之后,请在 test1.c 上使用它。请删除 #include memwatch.h 并对 makefile 进行如下小小的修改: 使用 YAMD 的 test1 gcc -g test1.c -o test1 展示了来自 test1 上的 YAMD 的输出,使用 YAMD 的 test1 输出 YAMD version 0.32Executable: /usr/src/test/yamd-0.32/test1...INFO: Normal allocation of this blockAddress 0x40025e00, size 512...INFO: Normal allocation of this blockAddress 0x40028e00, size 512...INFO: Normal deallocation of this blockAddress 0x40025e00, size 512...ERROR: Multiple freeing Atfree of pointer already freedAddress 0x40025e00, size 512...WARNING: Memory leakAddress 0x40028e00, size 512WARNING: Total memory leaks:1 unfreed allocations totaling 512 bytes*** Finished at Tue ... 10:07:15 2002Allocated a grand total of 1024 bytes 2 allocationsAverage of 512 bytes per allocationMax bytes allocated at one time: 102424 K alloced internally / 12 K mapped now / 8 K maxVirtual program size is 1416 KEnd. YAMD 显示我们已经释放了内存,而且存在内存泄漏。让我们在另一个样本程序上试试 YAMD。 内存代码(test2.c) #include #include int main(void){ char *ptr1; char *ptr2; char *chptr; int i = 1; ptr1 = malloc(512); ptr2 = malloc(512); chptr = (char *)malloc(512); for (i; i <= 512; i++) { chptr[i] = 'S'; } ptr2 = ptr1; free(ptr2); free(ptr1); free(chptr);} 您可以使用下面的命令来启动 YAMD: ./run-yamd /usr/src/test/test2/test2 显示了在样本程序 test2 上使用 YAMD 得到的输出。YAMD 告诉我们在 for 循环中有“越界(out-of-bounds)”的情况,使用 YAMD 的 test2 输出 Running /usr/src/test/test2/test2Temp output to /tmp/yamd-out.1243*********./run-yamd: line 101: 1248 Segmentation fault (core dumped)YAMD version 0.32Starting run: /usr/src/test/test2/test2Executable: /usr/src/test/test2/test2Virtual program size is 1380 K...INFO: Normal allocation of this blockAddress 0x40025e00, size 512...INFO: Normal allocation of this blockAddress 0x40028e00, size 512...INFO: Normal allocation of this blockAddress 0x4002be00, size 512ERROR: Crash...Tried to write address 0x4002c000Seems to be part of this block:Address 0x4002be00, size 512...Address in question is at offset 512 (out of bounds)Will dump core after checking heap.Done. MEMWATCH 和 YAMD 都是很有用的调试工具,它们的使用方法有所不同。对于 MEMWATCH,您需要添加包含文件memwatch.h 并打开两个编译时间标记。对于链接(link)语句,YAMD 只需要 -g 选项。 3  Electric Fence 多数 Linux 分发版包含一个 Electric Fence 包,不过您也可以选择下载它。Electric Fence 是一个由 Bruce Perens 编写的malloc()调试库。它就在您分配内存后分配受保护的内存。如果存在 fencepost 错误(超过数组末尾运行),程序就会产生保护错误,并立即结束。通过结合 Electric Fence 和 gdb,您可以精确地跟踪到哪一行试图访问受保护内存。ElectricFence 的另一个功能就是能够检测内存泄漏。 七  strace strace 命令是一种强大的工具,它能够显示所有由用户空间程序发出的系统调用。strace 显示这些调用的参数并返回符号形式的值。strace 从内核接收信息,而且不需要以任何特殊的方式来构建内核。将跟踪信息发送到应用程序及内核开发者都很有用。在下面代码中,分区的一种格式有错误,显示了 strace 的开头部分,内容是关于调出创建文件系统操作(mkfs )的。strace 确定哪个调用导致问题出现。 mkfs 上 strace 的开头部分 execve("/sbin/mkfs.jfs", ["mkfs.jfs", "-f", "/dev/test1"], &...open("/dev/test1", O_RDWR|O_LARGEFILE) = 4stat64("/dev/test1", {st_mode=&, st_rdev=makedev(63, 255), ...}) = 0ioctl(4, 0x40041271, 0xbfffe128) = -1 EINVAL (Invalid argument)write(2, "mkfs.jfs: warning - cannot setb" ..., 98mkfs.jfs: warning -cannot set blocksize on block device /dev/test1: Invalid argument ) = 98stat64("/dev/test1", {st_mode=&, st_rdev=makedev(63, 255), ...}) = 0open("/dev/test1", O_RDONLY|O_LARGEFILE) = 5ioctl(5, 0x80041272, 0xbfffe124) = -1 EINVAL (Invalid argument)write(2, "mkfs.jfs: can\'t determine device"..., ..._exit(1) = ? 显示 ioctl 调用导致用来格式化分区的 mkfs 程序失败。ioctl BLKGETSIZE64 失败。( BLKGET-SIZE64 在调用 ioctl的源代码中定义。) BLKGETSIZE64 ioctl 将被添加到 Linux 中所有的设备,而在这里,逻辑卷管理器还不支持它。因此,如果BLKGETSIZE64 ioctl 调用失败,mkfs 代码将改为调用较早的 ioctl 调用;这使得 mkfs 适用于逻辑卷管理器。 八  OOPS OOPS(也称 Panic)消息包含系统错误的细节,如 CPU 寄存器的内容等。是内核告知用户有不幸发生的最常用的方式。 内核只能发布OOPS,这个过程包括向终端上输出错误消息,输出寄存器保存的信息,并输出可供跟踪的回溯线索。通常,发送完OOPS之后,内核会处于一种不稳定的状态。 OOPS的产生有很多可能原因,其中包括内存访问越界或非法的指令等。 ※ 作为内核的开发者,必定将会经常处理OOPS。 ※ OOPS中包含的重要信息,对所有体系结构的机器都是完全相同的:寄存器上下文和回溯线索(回溯线索显示了导致错误发生的函数调用链)。 1  ksymoops 在 Linux 中,调试系统崩溃的传统方法是分析在发生崩溃时发送到系统控制台的 Oops 消息。一旦您掌握了细节,就可以将消息发送到 ksymoops 实用程序,它将试图将代码转换为指令并将堆栈值映射到内核符号。 ※ 如:回溯线索中的地址,会通过ksymoops转化成名称可见的函数名。 ksymoops需要几项内容:Oops 消息输出、来自正在运行的内核的 System.map 文件,还有 /proc/ksyms、vmlinux和/proc/modules。 关于如何使用 ksymoops,内核源代码 /usr/src/linux/Documentation/oops-tracing.txt 中或 ksymoops 手册页上有完整的说明可以参考。Ksymoops 反汇编代码部分,指出发生错误的指令,并显示一个跟踪部分表明代码如何被调用。 首先,将 Oops 消息保存在一个文件中以便通过 ksymoops 实用程序运行它。下面显示了由安装 JFS 文件系统的 mount命令创建的 Oops 消息。 ksymoops 处理后的 Oops 消息 ksymoops 2.4.0 on i686 2.4.17. Options used... 15:59:37 sfb1 kernel: Unable to handle kernel NULL pointer dereference atvirtual address 0000000... 15:59:37 sfb1 kernel: c01588fc... 15:59:37 sfb1 kernel: *pde = 0000000... 15:59:37 sfb1 kernel: Oops: 0000... 15:59:37 sfb1 kernel: CPU: 0... 15:59:37 sfb1 kernel: EIP: 0010:[jfs_mount+60/704]... 15:59:37 sfb1 kernel: Call Trace: [jfs_read_super+287/688] [get_sb_bdev+563/736] [do_kern_mount+189/336] [do_add_mount+35/208][do_page_fault+0/1264]... 15:59:37 sfb1 kernel: Call Trace: []...... 15:59:37 sfb1 kernel: [

    2024-11-22 83浏览
  • 内核同步缘起何处?

    内核同步缘起何处? 提到内核同步,这还要从操作系统的发展说起。操作系统在进程未出现之前,只是单任务在单处理器cpu上运行,只是系统资源利用率低,并不存在进程同步的问题。后来,随着操作系统的发展,多进程多任务的出现,系统资源利用率大幅度提升,但面临的问题就是进程之间抢夺资源,导致系统紊乱。因此,进程们需要通过进程通信一起坐下来聊一聊了进程同步的问题了,在linux系统中内核同步由此诞生。 实际上,内核同步的问题还是相对较复杂的,有人说,既然同步问题那么复杂,我们为什么还要去解决同步问题,简简单单不要并发不就好了吗?凡事都有两面性,我们要想获得更短的等待时间,就必须要去处理复杂的同步问题,而并发给我们带来的好处已经足够吸引我们去处理很复杂的同步问题。先提两个概念: 临界资源: 各进程采取互斥的方式,实现共享的资源称作临界资源。属于临界资源的硬件有打印机、磁带机等,软件有消息缓冲队列、变量、数组、缓冲区等。 诸进程间应采取互斥方式,实现对这种资源的共享。 临界区: 每个进程中访问临界资源的那段代码称为临界区。显然,若能保证诸进程互斥地进入自己的临界区,便可实现诸进程对临界资源的互斥访问。为此,每个进程在进入临界区之前,应先对欲访问的临界资源进行检查,看它是否正被访问。如果此刻该临界资源未被访问,进程便可进入临界区对该资源进行访问,并设置它正被访问的标志;如果此刻该临界资源正被某进程访问,则本进程不能进入临界区。 说到此处,内核同步实际上就是进程间通过一系列同步机制,并发执行程序,不但提高了资源利用率和系统吞吐量,而且进程之间不会随意抢占资源造成系统紊乱。 为了防止并发程序对我们的数据造成破坏,我们可以通过锁来保护数据,同时还要避免死锁。这里给出一些简单的规则来避免死锁的发生: 注意加锁的顺序 防止发生饥饿 不要重复请求同一个锁 设计锁力求简单 我们知道了可以用锁来保护我们的数据,但我们更需要知道,哪些数据容易被竞争,需要被保护,这就要求我们能够辨认出需要共享的数据和相应的临界区。实际上,我们需要在编写代码之前就设计好锁,所以我们需要知道内核中造成并发的原因,以便更好的识别出需要保护的数据和临界区。内核中造成并发的原因: 中断 内核抢占 睡眠 对称多处理 为了避免并发,防止竞争,内核提供了一些方法来实现对内核共享数据的保护。下面将介绍内核中的原子操作、自旋锁和信号量等同步措施。 1、内存屏障(memory-barrier) 内存屏障的作用是强制对内存的访问顺序进行排序,保证多线程或多核处理器下的内存访问的一致性和可见性。通过插入内存屏障,可以防止编译器对代码进行过度优化,也可以解决CPU乱序执行引起的问题,确保程序的执行顺序符合预期。 Linux内核提供了多种内存屏障,包括通用的内存屏障、数据依赖屏障、写屏障、读屏障、释放操作和获取操作等。 Linux内核中的内存屏障源码主要位于include/linux/compiler.h和arch/*/include/asm/barrier.h中。 include/linux/compiler.h中定义了一系列内存屏障相关的宏定义和函数,如: #define barrier() __asm__ __volatile__("": : :"memory")#define smp_mb() barrier()#define smp_rmb() barrier()#define smp_wmb() barrier()#define smp_read_barrier_depends() barrier()#define smp_store_mb(var, value) do { smp_wmb(); WRITE_ONCE(var, value); } while (0)#define smp_load_acquire(var) ({ typeof(var) ____p1 = ACCESS_ONCE(var); smp_rmb(); ____p1; })#define smp_cond_load_acquire(p, c) ({ typeof(*p) ____p1; ____p1 = ACCESS_ONCE(*p); smp_rmb(); unlikely(c) ? NULL : &____p1; }) 其中,barrier()是一个内存屏障宏定义,用于实现完整的内存屏障操作;smp_mb()、smp_rmb()、smp_wmb()分别是读屏障、写屏障、读写屏障的宏定义;smp_read_barrier_depends()是一个读屏障函数,用于增加依赖关系,确保之前的读取操作在之后的读取操作之前完成;smp_store_mb()和smp_load_acquire()分别是带屏障的存储和加载函数,用于确保存储和加载操作的顺序和一致性。 内存屏障原语 smp_mb():全局内存屏障,用于确保对共享变量的所有读写操作都已完成,并且按照正确顺序进行。 smp_rmb():读屏障,用于确保对共享变量的读取不会发生在该读取之前的其他内存访问完成之前。 smp_wmb():写屏障,用于确保对共享变量的写入不会发生在该写入之后的其他内存访问开始之前。 rmb():读内存屏障,类似于smp_rmb(),但更强制。它提供了一个更明确和可靠的方式来防止乱序执行。 wmb():写内存屏障,类似于smp_wmb(),但更强制。它提供了一个更明确和可靠的方式来防止乱序执行。 read_barrier_depends():依赖性读栅栏,用于指示编译器不应重排与此函数相关的代码顺序。 这些内存屏障原语主要用于指示编译器和处理器不要对其前后的指令进行优化重排,以确保内存操作按照程序员预期的顺序执行。 读内存屏障(如rmb())只针对后续的读取操作有效,它告诉编译器和处理器在读取之前先确保前面的所有写入操作已经完成。类似地,写内存屏障(如wmb())只针对前面的写入操作有效,它告诉编译器和处理器在写入之后再开始后续的其他内存访问。 smp_xxx()系列的内存屏障函数主要用于多核系统中,在竞态条件下保证数据同步、一致性和正确顺序执行。在单核系统中,这些函数没有实际效果。 而read_barrier_depends()函数是一个特殊情况,在某些架构上使用它可以避免编译器对代码进行过度重排。 在x86系统上,如果支持lfence汇编指令,则rmb()(读内存屏障)的实现可能会使用lfence指令。lfence指令是一种序列化指令,它会阻止该指令之前和之后的加载操作重排,并确保所有之前的加载操作已经完成。 具体而言,在x86架构上,rmb()可能被实现为: #define rmb() asm volatile("lfence" ::: "memory") 这个宏定义使用了GCC内联汇编语法,将lfence指令嵌入到代码中,通过volatile关键字告诉编译器不要对此进行优化,并且使用了"clobber memory"约束来通知编译器该指令可能会影响内存。 如果在x86系统上不支持lfence汇编指令,那么rmb()(读内存屏障)的实现可能会使用其他可用的序列化指令来达到相同的效果。一种常见的替代方案是使用mfence指令,它可以确保所有之前的内存加载操作已经完成。 在这种情况下,rmb()的实现可能如下: #define rmb() asm volatile("mfence" ::: "memory") 同样,这个宏定义使用了GCC内联汇编语法,将mfence指令嵌入到代码中,并通过"clobber memory"约束通知编译器该指令可能会影响内存。 内存一致性模型 内存一致性模型是指多个处理器或多个线程之间对共享内存的读写操作所满足的一组规则和保证。 在并发编程中,由于多个处理器或线程可以同时访问共享内存,存在着数据竞争和可见性问题。为了解决这些问题,需要定义一种内存一致性模型,以规定对共享内存的读写操作应该如何进行、如何保证读写操作的正确顺序和可见性。 常见的内存一致性模型包括: 顺序一致性(Sequential Consistency):在顺序一致性模型下,所有的读写操作对其他处理器或线程都是按照程序顺序可见的,即每个操作都会立即对所有处理器或线程可见,并且所有处理器或线程看到的操作顺序是一致的。这种模型简单直观,但在实践中由于需要强制同步和屏障,会导致性能开销较大。 弱一致性模型(Weak Consistency Models):弱一致性模型允许对共享变量的读写操作可能出现乱序或重排序,但是要求满足一定的一致性条件。常见的弱一致性模型有Release-Acquire模型、Total Store Order(TSO)模型和Partial Store Order(PSO)模型等。这些模型通过使用特定的内存屏障指令或同步原语,对读写操作进行排序和同步,以实现一定程度的一致性保证。 松散一致性模型(Relaxed Consistency Models):松散一致性模型放宽了对共享变量读写操作顺序的限制,允许更多的重排序和乱序访问。常见的松散一致性模型有Release Consistency模型、Entry Consistency模型和Processor Consistency模型等。这些模型通过定义不同的一致性保证级别和同步原语,允许更灵活的访问和重排序,从而提高系统的并发性能。 这些序列关系的正确性和顺序一致性是通过硬件层面的内存屏障指令、缓存一致性协议和处理器乱序执行的机制来实现的。 **顺序一致性(Sequential Consistency)**模型是指内核对于多个线程或进程之间的操作顺序保持与程序代码中指定的顺序一致。简单来说,它要求内核按照程序中编写的顺序执行操作,并且对所有线程和进程都呈现出一个统一的全局视图。 在这个模型下,每个线程或进程看到的操作执行顺序必须符合以下两个条件: 串行语义:每个线程或进程内部的操作必须按照原始程序中的指定顺序执行,不会发生重排序。 全局排序:所有线程和进程之间的操作必须按照某种确定的全局排序进行。 **弱一致性(Weak Consistency)**模型是指在多个线程或进程之间,对于操作执行顺序的保证比顺序一致性模型更为宽松。在这种模型下,程序无法依赖于全局的、确定性的操作执行顺序。 弱一致性模型允许发生以下情况: 重排序:线程或进程内部的操作可以被重新排序,只要最终结果对外表现一致即可。 缓存不一致:不同线程或进程之间的缓存数据可能不及时更新,导致读取到过期数据。 写入写入冲突:当两个线程同时写入相同地址时,写入结果的先后顺序可能会出现变化。 **松散一致性模型(Relaxed Consistency Models)**是与多处理器系统相关的概念。它涉及到多个处理器或核心共享内存时的数据一致性问题。 在传统的严格一致性模型下,对于所有处理器/核心来说,读写操作都必须按照全局总序列进行排序。这会带来很大的开销和限制,并可能导致性能下降。 而松散一致性模型则允许部分乱序执行和缓存一致性协议的使用,以提高并行度和性能。它引入了几种松散的一致性模型,包括: 总线事务内存:TM允许并发线程之间通过事务进行原子操作,并尽可能避免锁竞争。这样可以提高并行度和响应速度。 松散记忆顺序:此模型允许处理器乱序执行指令,并通过内存屏障等机制确保特定操作的有序性。 内存同步原语:Linux内核提供了多种同步原语(如原子操作、自旋锁、信号量等),用于控制共享数据的访问顺序和正确同步。 对于读取(Load)和写入(Store)指令,一共有四种组合: Load-Load(LL):两个读取指令之间的顺序关系。LL序列表示第一个读取操作必须在第二个读取操作之前完成才能保证顺序一致性。 Load-Store(LS):一个读取指令和一个写入指令之间的顺序关系。LS序列表示读取操作必须在写入操作之前完成才能保证顺序一致性。 Store-Load(SL):一个写入指令和一个读取指令之间的顺序关系。SL序列表示写入操作必须在读取操作之前完成才能保证顺序一致性。 Store-Store(SS):两个写入指令之间的顺序关系。SS序列表示第一个写入操作必须在第二个写入操作之前完成才能保证顺序一致性。 需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享 内存屏障的使用规则 常见的内存屏障使用场景和相应的规则: 在读写共享变量时,应该使用对应的读屏障和写屏障来确保读写操作的顺序和一致性。例如,在写入共享变量后,应该使用写屏障(smp_wmb())来确保写入操作已经完成,然后在读取共享变量前,应该使用读屏障(smp_rmb())来确保读取操作是在之前的写入操作之后进行的。 在多个 CPU 访问同一个共享变量时,应该使用内存屏障来保证正确性。例如,在使用自旋锁等同步机制时,应该在获取锁和释放锁的操作之前加上读写屏障(smp_mb())来确保操作的顺序和一致性。 在编写高并发性能代码时,可以使用内存屏障来优化代码的性能。例如,在使用无锁算法时,可以使用适当的内存屏障来减少 CPU 的缓存一致性花费,从而提高性能。 在使用内存屏障时,应该遵循先使用再优化的原则,不应该过度依赖内存屏障来解决并发问题。例如,在使用锁时,应该尽量减少锁的使用次数和持有时间,以避免竞争和饥饿等问题。 在选择内存屏障时,应该考虑所在的 CPU 架构和硬件平台,以选择最适合的屏障类型和实现方式。例如,在 x86 架构下,应该使用 lfence、sfence 和 mfence 指令来实现读屏障、写屏障和读写屏障。 内核中隐式的内存屏障 在 Linux 内核中,存在一些隐式的内存屏障,它们无需显式地在代码中添加,而是由编译器或硬件自动插入。这些隐式的内存屏障可以确保代码在不同的优化级别下保持正确性和一致性,同时提高代码的性能。 以下是一些常见的隐式内存屏障: 编译器层面的内存屏障:编译器会根据代码的语义和优化级别自动插入适当的内存屏障。例如,编译器会在函数调用前后插入内存屏障来确保函数的参数和返回值在内存中的正确性。 硬件层面的内存屏障:现代处理器中的指令重排和缓存一致性机制会自动插入内存屏障。例如,处理器会根据内存访问模式自动插入读写屏障,以确保读写操作的顺序和一致性。 内核层面的内存屏障:Linux 内核中的同步原语(如自旋锁、互斥锁)会在关键代码段中插入内存屏障,以确保多个 CPU 访问共享变量的顺序和一致性。 Linux内核有很多锁结构,这些锁结构都隐含一定的屏障操作,以确保内存的一致性和顺序性。 spin locks(自旋锁):在LOCK操作中会使用一个类似于原子交换的操作,确保只有一个线程能够获得锁。UNLOCK操作包括适当的内存屏障以保证对共享数据的修改可见。 R/W spin locks(读写自旋锁):用于实现读写并发控制。与普通自旋锁类似,在LOCK和UNLOCK操作中都包含适当的内存屏障。 mutexes(互斥锁):在LOCK操作中可能会使用比较和交换等原子指令,并且在UNLOCK操作中也会包含适当的屏障来保证同步和顺序性。 semaphores(信号量):在LOCK和UNLOCK操作中均包含适当的内存屏障来确保同步和顺序性。 R/W semaphores(读写信号量):与普通信号量类似,在LOCK和UNLOCK操作中都会有相应的内存屏障。 RCU(Read-Copy Update,读拷贝更新):RCU机制不直接使用传统意义上的锁,但它涉及到对共享数据进行访问、更新或者删除时需要进行同步操作,确保数据的一致性和顺序性。 优化屏障 在 Linux 中,"优化屏障"是通过barrier()宏定义来实现的。这个宏定义确保编译器不会对其前后的代码进行过度的优化,以保证特定的内存访问顺序和可见性。 在 GCC 编译器中,"优化屏障"的定义可以在linux-5.6.18\include\linux\compiler-gcc.h源码文件中找到。这个头文件通常包含一些与编译器相关的宏定义和内联汇编指令,用于处理一些特殊情况下需要控制代码生成和优化行为的需求。 具体而言,barrier()宏定义使用了GCC内置函数__asm__ volatile("": : :"memory")来作为一个空的内联汇编语句,并加上了"memory"约束来告知编译器,在此处需要添加一个内存屏障。 内存屏障指令 在ARM架构中,有三条内存屏障指令: DMB (Data Memory Barrier):用于确保在指令执行之前的所有数据访问操作都已完成,并且写回到内存中。 DSB (Data Synchronization Barrier):用于确保在指令执行之前的所有数据访问操作都已完成,并且其结果可见。 ISB (Instruction Synchronization Barrier):用于确保在指令执行之前的所有指令已被处理器执行完毕,并清空处理器流水线。 2、原子操作 原子操作,指的是一段不可打断的执行。也就是你不可分割的操作。 在 Linux 上提供了原子操作的结构,atomic_t : typedef struct { int counter;} atomic_t; 把整型原子操作定义为结构体,让原子函数只接收atomic_t类型的参数进而确保原子操作只与这种特殊类型数据一起使用,同时也保证了该类型的数据不会被传递给非原子函数。 初始化并定义一个原子变量: atomic_t v = ATOMIC_INIT(0); 基本调用 Linux 为原子操作提供了基本的操作宏函数: atomic_inc(v); // 原子变量自增1atomic_dec(v); // 原子变量自减1atomic_read(v) // 读取一个原子量atomic_add(int i, atomic_t *v) // 原子量增加 iatomic_sub(int i, atomic_t *v) // 原子量减少 i 这里定义的都是通用入口,真正的操作和处理器架构体系相关,这里分析 ARM 架构体系的实现: static inline void atomic_inc(atomic_t *v){ atomic_add_return(1, v);} 具体实现 对于 add 来说: #define atomic_inc_return(v) atomic_add_return(1, (v))static inline int atomic_add_return(int i, atomic_t *v){ unsigned long tmp; int result; smp_mb(); __asm__ __volatile__("@ atomic_add_return\n" "1: ldrex %0, [%3]\n" /*【1】独占方式加载v->counter到result*/ " add %0, %0, %4\n" /*【2】result加一*/ " strex %1, %0, [%3]\n" /*【3】独占方式将result值写回v->counter*/ " teq %1, #0\n" /*【4】判断strex更新内存是否成*/ " bne 1b" /*【5】不成功跳转到1:*/ : "=&r" (result), "=&r" (tmp), "+Qo" (v->counter) /*输出部*/ : "r" (&v->counter), "Ir" (i) /*输入部*/ : "cc"); /*损坏部*/ smp_mb(); return result;} 所以,这里我们需要着重分析两条汇编: LDREX 和 STREX LDREX和STREX指令,是将单纯的更新内存的原子操作分成了两个独立的步骤。 1)LDREX 用来读取内存中的值,并标记对该段内存的独占访问: LDREX Rx, [Ry] 上面的指令意味着,读取寄存器Ry指向的4字节内存值,将其保存到Rx寄存器中,同时标记对Ry指向内存区域的独占访问。如果执行LDREX指令的时候发现已经被标记为独占访问了,并不会对指令的执行产生影响。 2)STREX 在更新内存数值时,会检查该段内存是否已经被标记为独占访问,并以此来决定是否更新内存中的值: STREX Rx, Ry, [Rz] 如果执行这条指令的时候发现已经被标记为独占访问了,则将寄存器Ry中的值更新到寄存器Rz指向的内存,并将寄存器Rx设置成0。指令执行成功后,会将独占访问标记位清除。 而如果执行这条指令的时候发现没有设置独占标记,则不会更新内存,且将寄存器Rx的值设置成1。 一旦某条STREX指令执行成功后,以后再对同一段内存尝试使用STREX指令更新的时候,会发现独占标记已经被清空了,就不能再更新了,从而实现独占访问的机制。 在ARM系统中,内存有两种不同且对立的属性,即共享(Shareable)和非共享(Non-shareable)。共享意味着该段内存可以被系统中不同处理器访问到,这些处理器可以是同构的也可以是异构的。而非共享,则相反,意味着该段内存只能被系统中的一个处理器所访问到,对别的处理器来说不可见。 为了实现独占访问,ARM系统中还特别提供了所谓独占监视器(Exclusive Monitor)的东西,一共有两种类型的独占监视器。每一个处理器内部都有一个本地监视器(Local Monitor),且在整个系统范围内还有一个全局监视器(GlobalMonitor)。 如果要对非共享内存区中的值进行独占访问,只需要涉及本处理器内部的本地监视器就可以了;而如果要对共享内存区中的内存进行独占访问,除了要涉及到本处理器内部的本地监视器外,由于该内存区域可以被系统中所有处理器访问到,因此还必须要由全局监视器来协调。 对于本地监视器来说,它只标记了本处理器对某段内存的独占访问,在调用LDREX指令时设置独占访问标志,在调用STREX指令时清除独占访问标志。 而对于全局监视器来说,它可以标记每个处理器对某段内存的独占访问。也就是说,当一个处理器调用LDREX访问某段共享内存时,全局监视器只会设置针对该处理器的独占访问标记,不会影响到其它的处理器。当在以下两种情况下,会清除某个处理器的独占访问标记: 1)当该处理器调用LDREX指令,申请独占访问另一段内存时; 2)当别的处理器成功更新了该段独占访问内存值时。 对于第二种情况,也就是说,当独占内存访问内存的值在任何情况下,被任何一个处理器更改过之后,所有申请独占该段内存的处理器的独占标记都会被清空。 另外,更新内存的操作不一定非要是STREX指令,任何其它存储指令都可以。但如果不是STREX的话,则没法保证独占访问性。 现在的处理器基本上都是多核的,一个芯片上集成了多个处理器。而且对于一般的操作系统,系统内存基本上都被设置上了共享属性,也就是说对系统中所有处理器可见。因此,我们这里主要分析多核系统中对共享内存的独占访问的情况。 为了更加清楚的说明,我们可以举一个例子。假设系统中有两个处理器内核,而一个程序由三个线程组成,其中两个线程被分配到了第一个处理器上,另外一个线程被分配到了第二个处理器上。且他们的执行序列如下: 大致经历的步骤如下: 1)CPU2上的线程3最早执行LDREX,锁定某段共享内存区域。它会相应更新本地监视器和全局监视器。 2)然后,CPU1上的线程1执行LDREX,它也会更新本地监视器和全局监视器。这时在全局监视器上,CPU1和CPU2都对该段内存做了独占标记。 3)接着,CPU1上的线程2执行LDREX指令,它会发现本处理器的本地监视器对该段内存有了独占标记,同时全局监视器上CPU1也对该段内存做了独占标记,但这并不会影响这条指令的操作。 4)再下来,CPU1上的线程1最先执行了STREX指令,尝试更新该段内存的值。它会发现本地监视器对该段内存是有独占标记的,而全局监视器上CPU1也有该段内存的独占标记,则更新内存值成功。同时,清除本地监视器对该段内存的独占标记,还有全局监视器所有处理器对该段内存的独占标记。 5)下面,CPU2上的线程3执行STREX指令,也想更新该段内存值。它会发现本地监视器拥有对该段内存的独占标记,但是在全局监视器上CPU1没有了该段内存的独占标记(前面一步清空了),则更新不成功。 6)最后,CPU1上的线程2执行STREX指令,试着更新该段内存值。它会发现本地监视器已经没有了对该段内存的独占标记(第4步清除了),则直接更新失败,不需要再查全局监视器了。 所以,可以看出来,这套机制的精髓就是,无论有多少个处理器,有多少个地方会申请对同一个内存段进行操作,保证只有最早的更新可以成功,这之后的更新都会失败。失败了就证明对该段内存有访问冲突了。实际的使用中,可以重新用LDREX读取该段内存中保存的最新值,再处理一次,再尝试保存,直到成功为止。 还有一点需要说明,LDREX和STREX是对内存中的一个字(Word,32 bit)进行独占访问的指令。如果想独占访问的内存区域不是一个字,还有其它的指令: 1)LDREXB和STREXB:对内存中的一个字节(Byte,8 bit)进行独占访问; 2)LDREXH和STREXH:中的一个半字(Half Word,16 bit)进行独占访问; 3)LDREXD和STREXD:中的一个双字(Double Word,64 bit)进行独占访问。 它们必须配对使用,不能混用。 3、自旋锁 内核当发生访问资源冲突的时候,可以有两种锁的解决方案选择: 一个是原地等待 一个是挂起当前进程,调度其他进程执行(睡眠) Spinlock 是内核中提供的一种比较常见的锁机制,自旋锁是“原地等待”的方式解决资源冲突的,即,一个线程获取了一个自旋锁后,另外一个线程期望获取该自旋锁,获取不到,只能够原地“打转”(忙等待)。由于自旋锁的这个忙等待的特性,注定了它使用场景上的限制 ——自旋锁不应该被长时间的持有(消耗 CPU 资源)。 自旋锁的使用 在linux kernel的实现中,经常会遇到这样的场景:共享数据被中断上下文和进程上下文访问,该如何保护呢?如果只有进程上下文的访问,那么可以考虑使用semaphore或者mutex的锁机制,但是现在“中断上下文”也参和进来,那些可以导致睡眠的lock就不能使用了,这时候,可以考虑使用spin lock。 这里为什么把中断上下文加引号呢?因为在中断上下文,是不允许睡眠的,所以,这里需要的是一个不会导致睡眠的锁——spinlock。 换言之,中断上下文要用锁,首选 spinlock。 使用自旋锁,有两种方式定义一个锁: 动态的: spinlock_t lock;spin_lock_init (&lock); 静态的: DEFINE_SPINLOCK(lock); 自旋锁的死锁和解决 自旋锁不可递归,自己等待自己已经获取的锁,会导致死锁。 自旋锁可以在中断上下文中使用,但是试想一个场景:一个线程获取了一个锁,但是被中断处理程序打断,中断处理程序也获取了这个锁(但是之前已经被锁住了,无法获取到,只能自旋),中断无法退出,导致线程中后面释放锁的代码无法被执行,导致死锁。(如果确认中断中不会访问和线程中同一个锁,其实无所谓) 一、考虑下面的场景(内核抢占场景): (1)进程A在某个系统调用过程中访问了共享资源 R (2)进程B在某个系统调用过程中也访问了共享资源 R 会不会造成冲突呢?假设在A访问共享资源R的过程中发生了中断,中断唤醒了沉睡中的,优先级更高的B,在中断返回现场的时候,发生进程切换,B启动执行,并通过系统调用访问了R,如果没有锁保护,则会出现两个thread进入临界区,导致程序执行不正确。OK,我们加上spin lock看看如何:A在进入临界区之前获取了spin lock,同样的,在A访问共享资源R的过程中发生了中断,中断唤醒了沉睡中的,优先级更高的B,B在访问临界区之前仍然会试图获取spin lock,这时候由于A进程持有spin lock而导致B进程进入了永久的spin……怎么破?linux的kernel很简单,在A进程获取spin lock的时候,禁止本CPU上的抢占(上面的永久spin的场合仅仅在本CPU的进程抢占本CPU的当前进程这样的场景中发生)。如果A和B运行在不同的CPU上,那么情况会简单一些:A进程虽然持有spin lock而导致B进程进入spin状态,不过由于运行在不同的CPU上,A进程会持续执行并会很快释放spin lock,解除B进程的spin状态 二、再考虑下面的场景(中断上下文场景): (1)运行在CPU0上的进程A在某个系统调用过程中访问了共享资源 R (2)运行在CPU1上的进程B在某个系统调用过程中也访问了共享资源 R (3)外设P的中断handler中也会访问共享资源 R 在这样的场景下,使用spin lock可以保护访问共享资源R的临界区吗?我们假设CPU0上的进程A持有spin lock进入临界区,这时候,外设P发生了中断事件,并且调度到了CPU1上执行,看起来没有什么问题,执行在CPU1上的handler会稍微等待一会CPU0上的进程A,等它立刻临界区就会释放spin lock的,但是,如果外设P的中断事件被调度到了CPU0上执行会怎么样?CPU0上的进程A在持有spin lock的状态下被中断上下文抢占,而抢占它的CPU0上的handler在进入临界区之前仍然会试图获取spin lock,悲剧发生了,CPU0上的P外设的中断handler永远的进入spin状态,这时候,CPU1上的进程B也不可避免在试图持有spin lock的时候失败而导致进入spin状态。为了解决这样的问题,linux kernel采用了这样的办法:如果涉及到中断上下文的访问,spin lock需要和禁止本 CPU 上的中断联合使用。 三、再考虑下面的场景(底半部场景) linux kernel中提供了丰富的bottom half的机制,虽然同属中断上下文,不过还是稍有不同。我们可以把上面的场景简单修改一下:外设P不是中断handler中访问共享资源R,而是在的bottom half中访问。使用spin lock+禁止本地中断当然是可以达到保护共享资源的效果,但是使用牛刀来杀鸡似乎有点小题大做,这时候disable bottom half就OK了 四、中断上下文之间的竞争 同一种中断handler之间在uni core和multi core上都不会并行执行,这是linux kernel的特性。 如果不同中断handler需要使用spin lock保护共享资源,对于新的内核(不区分fast handler和slow handler),所有handler都是关闭中断的,因此使用spin lock不需要关闭中断的配合。 bottom half又分成softirq和tasklet,同一种softirq会在不同的CPU上并发执行,因此如果某个驱动中的softirq的handler中会访问某个全局变量,对该全局变量是需要使用spin lock保护的,不用配合disable CPU中断或者bottom half。 tasklet更简单,因为同一种tasklet不会多个CPU上并发。 自旋锁的实现 1、文件整理 和体系结构无关的代码如下: (1) include/linux/spinlock_types.h 这个头文件定义了通用spin lock的基本的数据结构(例如spinlock_t)和如何初始化的接口(DEFINE_SPINLOCK)。这里的“通用”是指不论SMP还是UP都通用的那些定义。 (2)include/linux/spinlock_types_up.h 这个头文件不应该直接include,在include/linux/spinlock_types.h文件会根据系统的配置(是否SMP)include相关的头文件,如果UP则会include该头文件。这个头文定义UP系统中和spin lock的基本的数据结构和如何初始化的接口。当然,对于non-debug版本而言,大部分struct都是empty的。 (3)include/linux/spinlock.h 这个头文件定义了通用spin lock的接口函数声明,例如spin_lock、spin_unlock等,使用spin lock模块接口API的驱动模块或者其他内核模块都需要include这个头文件。 (4)include/linux/spinlock_up.h 这个头文件不应该直接include,在include/linux/spinlock.h文件会根据系统的配置(是否SMP)include相关的头文件。这个头文件是debug版本的spin lock需要的。 (5)include/linux/spinlock_api_up.h 同上,只不过这个头文件是non-debug版本的spin lock需要的 (6)linux/spinlock_api_smp.h SMP上的spin lock模块的接口声明 (7)kernel/locking/spinlock.c SMP上的spin lock实现。 对UP和SMP上spin lock头文件进行整理: UP需要的头文件 SMP需要的头文件 linux/spinlock_type_up.h: linux/spinlock_types.h: linux/spinlock_up.h: linux/spinlock_api_up.h: linux/spinlock.h asm/spinlock_types.h linux/spinlock_types.h: asm/spinlock.h linux/spinlock_api_smp.h: linux/spinlock.h 2、数据结构 首先定义一个 spinlock_t 的数据类型,其本质上是一个整数值(对该数值的操作需要保证原子性),该数值表示spin lock是否可用。初始化的时候被设定为1。当thread想要持有锁的时候调用spin_lock函数,该函数将spin lock那个整数值减去1,然后进行判断,如果等于0,表示可以获取spin lock,如果是负数,则说明其他thread的持有该锁,本thread需要spin。 内核中的spinlock_t的数据类型定义如下: typedef struct spinlock { struct raw_spinlock rlock; } spinlock_t; typedef struct raw_spinlock { arch_spinlock_t raw_lock; } raw_spinlock_t; 通用(适用于各种arch)的spin lock使用spinlock_t这样的type name,各种arch定义自己的struct raw_spinlock。听起来不错的主意和命名方式,直到linux realtime tree(PREEMPT_RT)提出对spinlock的挑战。real time linux是一个试图将linux kernel增加硬实时性能的一个分支(你知道的,linux kernel mainline只是支持soft realtime),多年来,很多来自realtime branch的特性被merge到了mainline上,例如:高精度timer、中断线程化等等。realtime tree希望可以对现存的spinlock进行分类:一种是在realtime kernel中可以睡眠的spinlock,另外一种就是在任何情况下都不可以睡眠的spinlock。分类很清楚但是如何起名字?起名字绝对是个技术活,起得好了事半功倍,可维护性好,什么文档啊、注释啊都素那浮云,阅读代码就是享受,如沐春风。起得不好,注定被后人唾弃,或者拖出来吊打(这让我想起给我儿子起名字的那段不堪回首的岁月……)。最终,spin lock的命名规范定义如下: (1)spinlock,在rt linux(配置了PREEMPT_RT)的时候可能会被抢占(实际底层可能是使用支持PI(优先级翻转)的mutext)。 (2)raw_spinlock,即便是配置了PREEMPT_RT也要顽强的spin (3)arch_spinlock,spin lock是和architecture相关的,arch_spinlock是architecture相关的实现 对于UP平台,所有的arch_spinlock_t都是一样的,定义如下: typedef struct { } arch_spinlock_t; 什么都没有,一切都是空啊。当然,这也符合前面的分析,对于UP,即便是打开的preempt选项,所谓的spin lock也不过就是disable preempt而已,不需定义什么spin lock的变量。 对于SMP平台,这和arch相关,我们在下面描述。 在具体的实现面,我们不可能把每一个接口函数的代码都呈现出来,我们选择最基础的spin_lock为例子,其他的读者可以自己阅读代码来理解。 spin_lock的代码如下: static inline void spin_lock(spinlock_t *lock) { raw_spin_lock(&lock->rlock); } 当然,在linux mainline代码中,spin_lock和raw_spin_lock是一样的,在这里重点看看raw_spin_lock,代码如下: #define raw_spin_lock(lock) _raw_spin_lock(lock) UP中的实现: #define _raw_spin_lock(lock) __LOCK(lock) #define __LOCK(lock) \ do { preempt_disable(); ___LOCK(lock); } while (0) SMP的实现: void __lockfunc _raw_spin_lock(raw_spinlock_t *lock) { __raw_spin_lock(lock); } static inline void __raw_spin_lock(raw_spinlock_t *lock) { preempt_disable(); spin_acquire(&lock->dep_map, 0, 0, _RET_IP_); LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock); } UP中很简单,本质上就是一个preempt_disable而已,SMP中稍显复杂,preempt_disable当然也是必须的,spin_acquire可以略过,这是和运行时检查锁的有效性有关的,如果没有定义CONFIG_LOCKDEP其实就是空函数。如果没有定义CONFIG_LOCK_STAT(和锁的统计信息相关),LOCK_CONTENDED就是调用 do_raw_spin_lock 而已,如果没有定义CONFIG_DEBUG_SPINLOCK,它的代码如下: static inline void do_raw_spin_lock(raw_spinlock_t *lock) __acquires(lock) { __acquire(lock); arch_spin_lock(&lock->raw_lock); } __acquire和静态代码检查相关,忽略之,最终实际的获取spin lock还是要靠arch相关的代码实现。 针对 ARM 平台的 arch_spin_lock 代码位于arch/arm/include/asm/spinlock.h和spinlock_type.h,和通用代码类似,spinlock_type.h定义ARM相关的spin lock定义以及初始化相关的宏;spinlock.h中包括了各种具体的实现。 1. 回到2.6.23版本的内核中 和arm平台相关spin lock数据结构的定义如下(那时候还是使用raw_spinlock_t而不是arch_spinlock_t): typedef struct { volatile unsigned int lock; } raw_spinlock_t; 一个整数就OK了,0表示unlocked,1表示locked。配套的API包括__raw_spin_lock和__raw_spin_unlock。__raw_spin_lock会持续判断lock的值是否等于0,如果不等于0(locked)那么其他thread已经持有该锁,本thread就不断的spin,判断lock的数值,一直等到该值等于0为止,一旦探测到lock等于0,那么就设定该值为1,表示本thread持有该锁了,当然,这些操作要保证原子性,细节和exclusive版本的ldr和str(即ldrex和strexeq)相关,这里略过。立刻临界区后,持锁thread会调用__raw_spin_unlock函数是否spin lock,其实就是把0这个数值赋给lock。 这个版本的spin lock的实现当然可以实现功能,而且在没有冲突的时候表现出不错的性能,不过存在一个问题:不公平。也就是所有的thread都是在无序的争抢spin lock,谁先抢到谁先得,不管thread等了很久还是刚刚开始spin。在冲突比较少的情况下,不公平不会体现的特别明显,然而,随着硬件的发展,多核处理器的数目越来越多,多核之间的冲突越来越剧烈,无序竞争的spinlock带来的performance issue终于浮现出来,根据Nick Piggin的描述: On an 8 core (2 socket) Opteron, spinlock unfairness is extremely noticable, with a userspace test having a difference of up to 2x runtime per thread, and some threads are starved or "unfairly" granted the lock up to 1 000 000 (!) times. 多么的不公平,有些可怜的thread需要饥饿的等待1000000次。本质上无序竞争从概率论的角度看应该是均匀分布的,不过由于硬件特性导致这么严重的不公平,我们来看一看硬件block: lock本质上是保存在main memory中的,由于cache的存在,当然不需要每次都有访问main memory。在多核架构下,每个CPU都有自己的L1 cache,保存了lock的数据。假设CPU0获取了spin lock,那么执行完临界区,在释放锁的时候会调用smp_mb invalide其他忙等待的CPU的L1 cache,这样后果就是释放spin lock的那个cpu可以更快的访问L1cache,操作lock数据,从而大大增加的下一次获取该spin lock的机会。 2、回到现在:arch_spinlock_t ARM平台中的arch_spinlock_t定义如下(little endian): typedef struct { union { u32 slock; struct __raw_tickets { u16 owner; u16 next; } tickets; }; } arch_spinlock_t; 本来以为一个简单的整数类型的变量就搞定的spin lock看起来没有那么简单,要理解这个数据结构,需要了解一些ticket-based spin lock的概念。如果你有机会去九毛九去排队吃饭(声明:不是九毛九的饭托,仅仅是喜欢面食而常去吃而已)就会理解ticket-based spin lock。大概是因为便宜,每次去九毛九总是无法长驱直入,门口的笑容可掬的靓女会给一个ticket,上面写着15号,同时会告诉你,当前状态是10号已经入席,11号在等待。 回到arch_spinlock_t,这里的owner就是当前已经入席的那个号码,next记录的是下一个要分发的号码。下面的描述使用普通的计算机语言和在九毛九就餐(假设九毛九只有一张餐桌)的例子来进行描述,估计可以让吃货更有兴趣阅读下去。最开始的时候,slock被赋值为0,也就是说owner和next都是0,owner和next相等,表示unlocked。当第一个个thread调用spin_lock来申请lock(第一个人就餐)的时候,owner和next相等,表示unlocked,这时候该thread持有该spin lock(可以拥有九毛九的唯一的那个餐桌),并且执行next++,也就是将next设定为1(再来人就分配1这个号码让他等待就餐)。也许该thread执行很快(吃饭吃的快),没有其他thread来竞争就调用spin_unlock了(无人等待就餐,生意惨淡啊),这时候执行owner++,也就是将owner设定为1(表示当前持有1这个号码牌的人可以就餐)。姗姗来迟的1号获得了直接就餐的机会,next++之后等于2。1号这个家伙吃饭巨慢,这是不文明现象(thread不能持有spin lock太久),但是存在。又来一个人就餐,分配当前next值的号码2,当然也会执行next++,以便下一个人或者3的号码牌。持续来人就会分配3、4、5、6这些号码牌,next值不断的增加,但是owner岿然不动,直到欠扁的1号吃饭完毕(调用spin_unlock),释放饭桌这个唯一资源,owner++之后等于2,表示持有2那个号码牌的人可以进入就餐了。 3、ARM 结构体系 arch_spin_lock 接口实现 3.1 加锁 同样的,这里也只是选择一个典型的API来分析,其他的大家可以自行学习。我们选择的是 arch_spin_lock,其ARM32的代码如下: static inline void arch_spin_lock(arch_spinlock_t *lock) { unsigned long tmp; u32 newval; arch_spinlock_t lockval; prefetchw(&lock->slock);------------------------(0) __asm__ __volatile__( "1: ldrex %0, [%3]\n"-------------------------(1) " add %1, %0, %4\n" --------------------------(2)" strex %2, %1, [%3]\n"------------------------(3) " teq %2, #0\n"----------------------------(4) " bne 1b" : "=&r" (lockval), "=&r" (newval), "=&r" (tmp) : "r" (&lock->slock), "I" (1 << TICKET_SHIFT) : "cc"); while (lockval.tickets.next != lockval.tickets.owner) {-------(5) wfe();--------------------------------(6) lockval.tickets.owner = ACCESS_ONCE(lock->tickets.owner);----(7) } smp_mb();---------------------------------(8) } (0)和preloading cache相关的操作,主要是为了性能考虑 (1)lockval = lock->slock (如果lock->slock没有被其他处理器独占,则标记当前执行处理器对lock->slock地址的独占访问;否则不影响) (2)newval = lockval + (1 << TICKET_SHIFT) (3)strex tmp, newval, [&lock->slock] (如果当前执行处理器没有独占lock->slock地址的访问,不进行存储,返回1给temp;如果当前处理器已经独占lock->slock内存访问,则对内存进行写,返回0给temp,清除独占标记) lock->tickets.next = lock->tickets.next + 1 (4)检查是否写入成功 lockval.tickets.next (5)初始化时lock->tickets.owner、lock->tickets.next都为0,假设第一次执行arch_spin_lock,lockval = *lock,lock->tickets.next++,lockval.tickets.next 等于 lockval.tickets.owner,获取到自旋锁;自旋锁未释放,第二次执行的时候,lock->tickets.owner = 0, lock->tickets.next = 1,拷贝到lockval后,lockval.tickets.next != lockval.tickets.owner,会执行wfe等待被自旋锁释放被唤醒,自旋锁释放时会执行 lock->tickets.owner++,lockval.tickets.owner重新赋值 (6)暂时中断挂起执行。如果当前spin lock的状态是locked,那么调用wfe进入等待状态。 (7)其他的CPU唤醒了本cpu的执行,说明owner发生了变化,该新的own赋给lockval,然后继续判断spin lock的状态,也就是回到step 5。 (8)memory barrier的操作,具体的操作后面描述。 3.1 释放锁 static inline void arch_spin_unlock(arch_spinlock_t *lock){ smp_mb(); lock->tickets.owner++; ---------------------- (0) dsb_sev(); ---------------------------------- (1)} (0)lock->tickets.owner增加1,下一个被唤醒的处理器会检查该值是否与自己的lockval.tickets.next相等,lock->tickets.owner代表可以获取的自旋锁的处理器,lock->tickets.next你一个可以获取的自旋锁的owner;处理器获取自旋锁时,会先读取lock->tickets.next用于与lock->tickets.owner比较并且对lock->tickets.next加1,下一个处理器获取到的lock->tickets.next就与当前处理器不一致了,两个处理器都与lock->tickets.owner比较,肯定只有一个处理器会相等,自旋锁释放时时对lock->tickets.owner加1计算,因此,先申请自旋锁多处理器lock->tickets.next值更新,自然先获取到自旋锁 (1)执行sev指令,唤醒wfe等待的处理器 自旋锁的变体 接口API的类型 spinlock中的定义 raw_spinlock的定义 定义spin lock并初始化 DEFINE_SPINLOCK DEFINE_RAW_SPINLOCK 动态初始化spin lock spin_lock_init raw_spin_lock_init 获取指定的spin lock spin_lock raw_spin_lock 获取指定的spin lock同时disable本CPU中断 spin_lock_irq raw_spin_lock_irq 保存本CPU当前的irq状态,disable本CPU中断并获取指定的spin lock spin_lock_irqsave raw_spin_lock_irqsave 获取指定的spin lock同时disable本CPU的bottom half spin_lock_bh raw_spin_lock_bh 释放指定的spin lock spin_unlock raw_spin_unlock 释放指定的spin lock同时enable本CPU中断 spin_unlock_irq raw_spin_unock_irq 释放指定的spin lock同时恢复本CPU的中断状态 spin_unlock_irqstore raw_spin_unlock_irqstore 获取指定的spin lock同时enable本CPU的bottom half spin_unlock_bh raw_spin_unlock_bh 尝试去获取spin lock,如果失败,不会spin,而是返回非零值 spin_trylock raw_spin_trylock 判断spin lock是否是locked,如果其他的thread已经获取了该lock,那么返回非零值,否则返回0 spin_is_locked raw_spin_is_locked static inline unsigned long __raw_spin_lock_irqsave(raw_spinlock_t *lock){ unsigned long flags; local_irq_save(flags); preempt_disable(); spin_acquire(&lock->dep_map, 0, 0, _RET_IP_); /* * On lockdep we dont want the hand-coded irq-enable of * do_raw_spin_lock_flags() code, because lockdep assumes * that interrupts are not re-enabled during lock-acquire: */#ifdef CONFIG_LOCKDEP LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);#else do_raw_spin_lock_flags(lock, &flags);#endif return flags;} static inline void __raw_spin_lock_irq(raw_spinlock_t *lock){ local_irq_disable(); preempt_disable(); spin_acquire(&lock->dep_map, 0, 0, _RET_IP_); LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);} static inline void __raw_spin_lock_bh(raw_spinlock_t *lock){ local_bh_disable(); preempt_disable(); spin_acquire(&lock->dep_map, 0, 0, _RET_IP_); LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);} static inline void __raw_spin_lock(raw_spinlock_t *lock){ preempt_disable(); spin_acquire(&lock->dep_map, 0, 0, _RET_IP_); LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);} #endif /* CONFIG_PREEMPT */ static inline void __raw_spin_unlock(raw_spinlock_t *lock){ spin_release(&lock->dep_map, 1, _RET_IP_); do_raw_spin_unlock(lock); preempt_enable();} static inline void __raw_spin_unlock_irqrestore(raw_spinlock_t *lock, unsigned long flags){ spin_release(&lock->dep_map, 1, _RET_IP_); do_raw_spin_unlock(lock); local_irq_restore(flags); preempt_enable();} static inline void __raw_spin_unlock_irq(raw_spinlock_t *lock){ spin_release(&lock->dep_map, 1, _RET_IP_); do_raw_spin_unlock(lock); local_irq_enable(); preempt_enable();} static inline void __raw_spin_unlock_bh(raw_spinlock_t *lock){ spin_release(&lock->dep_map, 1, _RET_IP_); do_raw_spin_unlock(lock); preempt_enable_no_resched(); local_bh_enable_ip((unsigned long)__builtin_return_address(0));} static inline int __raw_spin_trylock_bh(raw_spinlock_t *lock){ local_bh_disable(); preempt_disable(); if (do_raw_spin_trylock(lock)) { spin_acquire(&lock->dep_map, 0, 1, _RET_IP_); return 1; } preempt_enable_no_resched(); local_bh_enable_ip((unsigned long)__builtin_return_address(0)); return 0;} 小结 spin_lock 的时候,禁止内核抢占 如果涉及到中断上下文的访问,spin lock需要和禁止本 CPU 上的中断联合使用(spin_lock_irqsave / spin_unlock_irqstore) 涉及 half bottom 使用:spin_lock_bh / spin_unlock_bh 4、读-写自旋锁(rwlock) 试想这样一种场景:一个内核链表元素,很多进程(或者线程)都会对其进行读写,但是使用 spinlock 的话,多个读之间无法并发,只能被 spin,为了提高系统的整体性能,内核定义了一种锁: 1. 允许多个处理器进程(或者线程或者中断上下文)并发的进行读操作(SMP 上),这样是安全的,并且提高了 SMP 系统的性能。 2. 在写的时候,保证临界区的完全互斥 所以,当某种内核数据结构被分为:读-写,或者生产-消费,这种类型的时候,类似这种 读-写自旋锁就起到了作用。对读者是共享的,对写者完全互斥。 读/写自旋锁是在保护SMP体系下的共享数据结构而引入的,它的引入是为了增加内核的并发能力。只要内核控制路径没有对数据结构进行修改,读/写自旋锁就允许多个内核控制路径同时读同一数据结构。如果一个内核控制路径想对这个结构进行写操作,那么它必须首先获取读/写锁的写锁,写锁授权独占访问这个资源。这样设计的目的,即允许对数据结构并发读可以提高系统性能。 加锁的逻辑: (1)假设临界区内没有任何的thread,这时候任何read thread或者write thread可以进入,但是只能是其一。 (2)假设临界区内有一个read thread,这时候新来的read thread可以任意进入,但是write thread不可以进入 (3)假设临界区内有一个write thread,这时候任何的read thread或者write thread都不可以进入 (4)假设临界区内有一个或者多个read thread,write thread当然不可以进入临界区,但是该write thread也无法阻止后续read thread的进入,他要一直等到临界区一个read thread也没有的时候,才可以进入。 解锁的逻辑: (1)在write thread离开临界区的时候,由于write thread是排他的,因此临界区有且只有一个write thread,这时候,如果write thread执行unlock操作,释放掉锁,那些处于spin的各个thread(read或者write)可以竞争上岗。 (2)在read thread离开临界区的时候,需要根据情况来决定是否让其他处于spin的write thread们参与竞争。如果临界区仍然有read thread,那么write thread还是需要spin(注意:这时候read thread可以进入临界区,听起来也是不公平的)直到所有的read thread释放锁(离开临界区),这时候write thread们可以参与到临界区的竞争中,如果获取到锁,那么该write thread可以进入。 读-写自旋锁的使用 与 spinlock 的使用方式几乎一致,读-写自旋锁初始化方式也分为两种: 动态的: rwlock_t rw_lock;rwlock_init (&rw_lock); 静态的: DEFINE_RWLOCK(rwlock); 初始化完成后就可以使用读-写自旋锁了,内核提供了一组 APIs 来操作读写自旋锁,最简单的比如: 读临界区: rwlock_t rw_lock;rwlock_init (&rw_lock); read_lock(rw_lock);------------- 读临界区 -------------read_unlock(rw_lock); 写临界区: rwlock_t rw_lock;rwlock_init (&rw_lock); write_lock(rw_lock);------------- 写临界区 -------------write_unlock(rw_lock); 注意:读锁和写锁会位于完全分开的代码中,若是: read_lock(lock);write_lock(lock); 这样会导致死锁,因为读写锁的本质还是自旋锁。写锁不断的等待读锁的释放,导致死锁。如果读-写不能清晰的分开的话,使用一般的自旋锁,就别使用读写锁了。 注意:由于读写自旋锁的这种特性(允许多个读者),使得即便是递归的获取同一个读锁也是允许的。更比如,在中断服务程序中,如果确定对数据只有读操作的话(没有写操作),那么甚至可以使用 read_lock 而不是 read_lock_irqsave,但是对于写来说,还是需要调用 write_lock_irqsave 来保证不被中断打断,否则如果在中断中去获取了锁,就会导致死锁。 读-写锁内核 APIs 与 spinlock 一样,Read/Write spinlock 有如下的 APIs: 接口API描述 Read/Write Spinlock API 定义rw spin lock并初始化 DEFINE_RWLOCK 动态初始化rw spin lock rwlock_init 获取指定的rw spin lock read_lock write_lock 获取指定的rw spin lock同时disable本CPU中断 read_lock_irq write_lock_irq 保存本CPU当前的irq状态,disable本CPU中断并获取指定的rw spin lock read_lock_irqsave write_lock_irqsave 获取指定的rw spin lock同时disable本CPU的bottom half read_lock_bh write_lock_bh 释放指定的spin lock read_unlock write_unlock 释放指定的rw spin lock同时enable本CPU中断 read_unlock_irq write_unlock_irq 释放指定的rw spin lock同时恢复本CPU的中断状态 read_unlock_irqrestore write_unlock_irqrestore 获取指定的rw spin lock同时enable本CPU的bottom half read_unlock_bh write_unlock_bh 尝试去获取rw spin lock,如果失败,不会spin,而是返回非零值 read_trylock write_trylock 读-写锁内核实现 说明:使用读写内核锁需要包含的头文件和 spinlock 一样,只需要包含:include/linux/spinlock.h 就可以了 这里仅看 和体系架构相关的部分,在 ARM 体系架构上: arch_rwlock_t 的定义: typedef struct { u32 lock; } arch_rwlock_t; 看看arch_write_lock的实现: static inline void arch_write_lock(arch_rwlock_t *rw){ unsigned long tmp; prefetchw(&rw->lock);------------------------(0) __asm__ __volatile__("1: ldrex %0, [%1]\n"--------------------------(1)" teq %0, #0\n"--------------------------------(2) WFE("ne")------------------------------------(3)" strexeq %0, %2, [%1]\n"----------------------(4)" teq %0, #0\n"--------------------------------(5)" bne 1b"--------------------------------------(6) : "=&r" (tmp) : "r" (&rw->lock), "r" (0x80000000) : "cc"); smp_mb();------------------------------------(7)} (0) : 先通知 hw 进行preloading cache (1): 标记独占,获取 rw->lock 的值并保存在 tmp 中 (2) : 判断 tmp 是否等于 0 (3) : 如果 tmp 不等于0,那么说明有read 或者write的thread持有锁,那么还是静静的等待吧。其他thread会在unlock的时候Send Event来唤醒该CPU的 (4) : 如果 tmp 等于0,将 0x80000000 这个值赋给 rw->lock (5) : 是否 str 成功,如果有其他 thread 在上面的过程插入进来就会失败 (6) : 如果不成功,那么需要重新来过跳转到标号为 1 的地方,即开始的地方,否则持有锁,进入临界区 (7) : 内存屏障,保证执行顺序 arch_write_unlock 的实现: static inline void arch_write_unlock(arch_rwlock_t *rw) { smp_mb(); ---------------------------(0) __asm__ __volatile__( "str %1, [%0]\n" -----------------(1) : : "r" (&rw->lock), "r" (0) : "cc"); dsb_sev(); --------------------------(2)} (0) : 内存屏障 (1) : rw->lock 赋值为 0 (2) :唤醒处于 WFE 的 thread arch_read_lock 的实现: static inline void arch_read_lock(arch_rwlock_t *rw){ unsigned long tmp, tmp2; prefetchw(&rw->lock); __asm__ __volatile__("1: ldrex %0, [%2]\n" ----------- (0)" adds %0, %0, #1\n" --------- (1)" strexpl %1, %0, [%2]\n" ------- (2) WFE("mi") --------------------- (3)" rsbpls %0, %1, #0\n" --------- (4)" bmi 1b" ----------------------- (5) : "=&r" (tmp), "=&r" (tmp2) : "r" (&rw->lock) : "cc"); smp_mb();} (0) : 标记独占,获取 rw->lock 的值并保存在 tmp 中 (1) : tmp = tmp + 1 (2) : 如果 tmp 结果非负值,那么就执行该指令,将 tmp 值存入rw->lock (3) : 如果 tmp 是负值,说明有 write thread,那么就进入 wait for event 状态 (4) : 判断strexpl指令是否成功执行 (5) : 如果不成功,那么需要重新来过,否则持有锁,进入临界区 arch_read_unlock 的实现: static inline void arch_read_unlock(arch_rwlock_t *rw){ unsigned long tmp, tmp2; smp_mb(); prefetchw(&rw->lock); __asm__ __volatile__("1: ldrex %0, [%2]\n" -----------(0)" sub %0, %0, #1\n" -------------(1)" strex %1, %0, [%2]\n" -------(2)" teq %1, #0\n" -----------------(3)" bne 1b" -----------------------(4) : "=&r" (tmp), "=&r" (tmp2) : "r" (&rw->lock) : "cc"); if (tmp == 0) dsb_sev(); ----------------(5)} (0) : 标记独占,获取 rw->lock 的值并保存在 tmp 中 (1) : read 退出临界区,所以,tmp = tmp + 1 (2) : 将tmp值存入 rw->lock 中 (3) :是否str成功,如果有其他thread在上面的过程插入进来就会失败 (4) : 如果不成功,那么需要重新来过,否则离开临界区 (5) : 如果read thread已经等于0,说明是最后一个离开临界区的 Reader,那么调用 sev 去唤醒 WF E的 CPU Core(配合 Writer 线程) 所以看起来,读-写锁使用了一个 32bits 的数来存储当前的状态,最高位代表着是否有写线程占用了锁,而低 31 位代表可以同时并发的读的数量,看起来现在至少是绰绰有余了。 小结 读-写锁自旋锁本质上还是属于自旋锁。只不过允许了并发的读操作,对于写和写,写和读之间,都需要互斥自旋等待。 注意读-写锁自旋锁的使用,避免死锁。 合理运用内核提供的 APIs (诸如:write_lock_irqsave 等)。 5、顺序锁(seqlock) 上面讲到的读-写自旋锁,更加偏向于读者。内核提供了更加偏向于写者的锁 —— seqlock 这种锁提供了一种简单的读写共享的机制,他的设计偏向于写者,无论是什么情况(没有多个写者竞争的情况),写者都有直接写入的权利(霸道),而读者呢?这里提供了一个序列值,当写者进入的时候,这个序列值会加 1,而读者去在读出数值的前后分别来check这个值,便知道是否在读的过程中(奇数还偶数),被写者“篡改”过数据,如果有的话,则再次 spin 的去读,一直到数据被完全的篡改完毕。 顺序锁临界区只允许一个writer thread进入(在多个写者之间是互斥的),临界区只允许一个writer thread进入,在没有writer thread的情况下,reader thread可以随意进入,也就是说reader不会阻挡reader。在临界区只有有reader thread的情况下,writer thread可以立刻执行,不会等待 Writer thread的操作: 对于writer thread,获取seqlock操作如下: (1)获取锁(例如spin lock),该锁确保临界区只有一个writer进入。 (2)sequence counter加一 释放seqlock操作如下: (1)释放锁,允许其他writer thread进入临界区。 (2)sequence counter加一(注意:不是减一哦,sequence counter是一个不断累加的counter) 由上面的操作可知,如果临界区没有任何的writer thread,那么sequence counter是偶数(sequence counter初始化为0),如果临界区有一个writer thread(当然,也只能有一个),那么sequence counter是奇数。 Reader thread的操作如下: (1)获取sequence counter的值,如果是偶数,可以进入临界区,如果是奇数,那么等待writer离开临界区(sequence counter变成偶数)。进入临界区时候的sequence counter的值我们称之old sequence counter。 (2)进入临界区,读取数据 (3)获取sequence counter的值,如果等于old sequence counter,说明一切OK,否则回到step(1) 适用场景: 一般而言,seqlock适用于: (1)read操作比较频繁 (2)write操作较少,但是性能要求高,不希望被reader thread阻挡(之所以要求write操作较少主要是考虑read side的性能) (3)数据类型比较简单,但是数据的访问又无法利用原子操作来保护。我们举一个简单的例子来描述:假设需要保护的数据是一个链表,header--->A node--->B node--->C node--->null。reader thread遍历链表的过程中,将B node的指针赋给了临时变量x,这时候,中断发生了,reader thread被preempt(注意,对于seqlock,reader并没有禁止抢占)。这样在其他cpu上执行的writer thread有充足的时间释放B node的memory(注意:reader thread中的临时变量x还指向这段内存)。当read thread恢复执行,并通过x这个指针进行内存访问(例如试图通过next找到C node),悲剧发生了…… 顺序锁的使用 定义 定义一个顺序锁有两种方式: seqlock_t seqlockseqlock_init(&seqlock) DEFINE_SEQLOCK(seqlock) 写临界区: write_seqlock(&seqlock);/* -------- 写临界区 ---------*/write_sequnlock(&seqlock); 读临界区: unsigned long seq; do { seq = read_seqbegin(&seqlock); /* ---------- 这里读临界区数据 ----------*/} while (read_seqretry(&seqlock, seq)); 例子:在 kernel 中,jiffies_64 保存了从系统启动以来的 tick 数目,对该数据的访问(以及其他jiffies相关数据)需要持有jiffies_lock 这个 seq lock: 读当前的 tick : u64 get_jiffies_64(void) { do { seq = read_seqbegin(&jiffies_lock); ret = jiffies_64; } while (read_seqretry(&jiffies_lock, seq)); } 内核更新当前的 tick : static void tick_do_update_jiffies64(ktime_t now) { write_seqlock(&jiffies_lock); /* 临界区会修改jiffies_64等相关变量 */ write_sequnlock(&jiffies_lock); } 顺序锁的实现 1. seqlock_t 结构: typedef struct { struct seqcount seqcount; spinlock_t lock;} seqlock_t; 2. write_seqlock/write_sequnlock static inline void write_seqlock(seqlock_t *sl) { spin_lock(&sl->lock); sl->sequence++; smp_wmb(); } static inline void write_sequnlock(seqlock_t *sl){ smp_wmb(); s->sequence++; spin_unlock(&sl->lock);} 可以看到 seqlock 其实也是基于 spinlock 的。smp_wmb 是写内存屏障,由于seq lock 是基于 sequence counter 的,所以必须保证这个操作。 3. read_seqbegin: static inline unsigned read_seqbegin(const seqlock_t *sl) { unsigned ret; repeat: ret = ACCESS_ONCE(sl->sequence); ---进入临界区之前,先要获取sequenc counter的快照 if (unlikely(ret & 1)) { -----如果是奇数,说明有writer thread cpu_relax(); goto repeat; ----如果有writer,那么先不要进入临界区,不断的polling sequenc counter } smp_rmb(); ---确保sequenc counter和临界区的内存访问顺序 return ret; } 如果有writer thread,read_seqbegin函数中会有一个不断polling sequenc counter,直到其变成偶数的过程,在这个过程中,如果不加以控制,那么整体系统的性能会有损失(这里的性能指的是功耗和速度)。因此,在polling过程中,有一个cpu_relax的调用,对于ARM64,其代码是: static inline void cpu_relax(void) { asm volatile("yield" ::: "memory"); } yield指令用来告知硬件系统,本cpu上执行的指令是polling操作,没有那么急迫,如果有任何的资源冲突,本cpu可以让出控制权。 4. read_seqretry static inline unsigned read_seqretry(const seqlock_t *sl, unsigned start) { smp_rmb();---确保sequenc counter和临界区的内存访问顺序 return unlikely(sl->sequence != start); } start参数就是进入临界区时候的sequenc counter的快照,比对当前退出临界区的sequenc counter,如果相等,说明没有writer进入打搅reader thread,那么可以愉快的离开临界区。 还有一个比较有意思的逻辑问题:read_seqbegin为何要进行奇偶判断?把一切都推到read_seqretry中进行判断不可以吗?也就是说,为何read_seqbegin要等到没有writer thread的情况下才进入临界区?其实有writer thread也可以进入,反正在read_seqretry中可以进行奇偶以及相等判断,从而保证逻辑的正确性。当然,这样想也是对的,不过在performance上有欠缺,reader在检测到有writer thread在临界区后,仍然放reader thread进入,可能会导致writer thread的一些额外的开销(cache miss),因此,最好的方法是在read_seqbegin中拦截。 6、信号量(semaphore) Linux Kernel 除了提供了自旋锁,还提供了睡眠锁,信号量就是一种睡眠锁。信号量的特点是,如果一个任务试图获取一个已经被占用的信号量,他会被推入等待队列,让其进入睡眠。此刻处理器重获自由,去执行其他的代码。当持有的信号量被释放,处于等待队列的任务将被唤醒,并获取到该信号量。 从信号量的睡眠特性得出一些结论: 由于竞争信号量的时候,未能拿到信号的进程会进入睡眠,所以信号量可以适用于长时间持有。 而且信号量不适合短时间的持有,因为会导致睡眠的原因,维护队列,唤醒,等各种开销,在短时间的锁定某对象,反而比忙等锁的效率低。 由于睡眠的特性,只能在进程上下文进行调用,无法再中断上下文中使用信号量。 一个进程可以在持有信号量的情况下去睡眠(可能并不需要,这里只是假如),另外的进程尝试获取该信号量时候,不会死锁。 期望去占用一个信号量的同时,不允许持有自旋锁,因为企图去获取信号量的时候,可能导致睡眠,而自旋锁不允许睡眠。 在有一些特定的场景,自旋锁和信号量没得选,比如中断上下文,只能用自旋锁,比如需要要和用户空间做同步的时候,代码需要睡眠,信号量是唯一选择。如果有的地方,既可以选择信号量,又可以选自旋锁,则需要根据持有锁的时间长短来进行选择。理想情况下是,越短的时间持有,选择自旋锁,长时间的适合信号量。与此同时,信号量不会关闭调度,他不会对调度造成影响。 信号量允许多个锁持有者,而自旋锁在一个时刻,最多允许一个任务持有。信号量同时允许的持有者数量可以在声明信号的时候指定。绝大多数情况下,信号量允许一个锁的持有者,这种类型的信号量称之为二值信号量,也就是互斥信号量。 一个任务要想访问共享资源,首先必须得到信号量,获取信号量的操作将把信号量的值减1,若当前信号量的值为负数,表明无法获得信号量,该任务必须挂起在该信号量的等待队列等待该信号量可用;若当前信号量的值为非负数,表示可以获得信号量,因而可以立刻访问被该信号量保护的共享资源。 当任务访问完被信号量保护的共享资源后,必须释放信号量,释放信号量通过把信号量的值加1实现,如果信号量的值为非正数,表明有任务等待当前信号量,因此它也唤醒所有等待该信号量的任务。 信号量的操作 信号量相关的东西放置到了: include/linux/semaphore.h 文件 初始化一个信号量有两种方式: struct semaphore sem; sema_init(&sem, val); DEFINE_SEMAPHORE(sem) 内核针对信号量提供了一组操作接口: 函数定义 功能说明 sema_init(struct semaphore *sem, int val) 初始化信号量,将信号量计数器值设置val。 down(struct semaphore *sem) 获取信号量,不建议使用此函数,因为是 UNINTERRUPTABLE 的睡眠。 down_interruptible(struct semaphore *sem) 可被中断地获取信号量,如果睡眠被信号中断,返回错误-EINTR。 down_killable (struct semaphore *sem) 可被杀死地获取信号量。如果睡眠被致命信号中断,返回错误-EINTR。 down_trylock(struct semaphore *sem) 尝试原子地获取信号量,如果成功获取,返回0,不能获取,返回1。 down_timeout(struct semaphore *sem, long jiffies) 在指定的时间jiffies内获取信号量,若超时未获取,返回错误-ETIME。 up(struct semaphore *sem) 释放信号量sem。 注意:down_interruptible 接口,在获取不到信号量的时候,该任务会进入 INTERRUPTABLE 的睡眠,但是 down() 接口会导致进入 UNINTERRUPTABLE 的睡眠,down 用的较少。 信号量的实现 1. 信号量的结构: struct semaphore { raw_spinlock_t lock; unsigned int count; struct list_head wait_list;}; 信号量用结构semaphore描述,它在自旋锁的基础上改进而成,它包括一个自旋锁、信号量计数器和一个等待队列。用户程序只能调用信号量API函数,而不能直接访问信号量结构。 2. 初始化函数sema_init #define __SEMAPHORE_INITIALIZER(name, n) \{ \ .lock = __RAW_SPIN_LOCK_UNLOCKED((name).lock), \ .count = n, \ .wait_list = LIST_HEAD_INIT((name).wait_list), \} static inline void sema_init(struct semaphore *sem, int val){ static struct lock_class_key __key; *sem = (struct semaphore) __SEMAPHORE_INITIALIZER(*sem, val); lockdep_init_map(&sem->lock.dep_map, "semaphore->lock", &__key, 0);} 初始化了信号量中的 spinlock 结构,count 计数器和初始化链表。 3. 可中断获取信号量函数down_interruptible static noinline int __sched __down_interruptible(struct semaphore *sem){ return __down_common(sem, TASK_INTERRUPTIBLE, MAX_SCHEDULE_TIMEOUT);} int down_interruptible(struct semaphore *sem){ unsigned long flags; int result = 0; raw_spin_lock_irqsave(&sem->lock, flags); if (likely(sem->count > 0)) sem->count--; else result = __down_interruptible(sem); raw_spin_unlock_irqrestore(&sem->lock, flags); return result;} down_interruptible进入后,获取信号量获取成功,进入临界区,否则进入 __down_interruptible->__down_common static inline int __sched __down_common(struct semaphore *sem, long state, long timeout){ struct semaphore_waiter waiter; list_add_tail(&waiter.list, &sem->wait_list); waiter.task = current; waiter.up = false; for (;;) { if (signal_pending_state(state, current)) goto interrupted; if (unlikely(timeout <= 0)) goto timed_out; __set_current_state(state); raw_spin_unlock_irq(&sem->lock); timeout = schedule_timeout(timeout); raw_spin_lock_irq(&sem->lock); if (waiter.up) return 0; } timed_out: list_del(&waiter.list); return -ETIME; interrupted: list_del(&waiter.list); return -EINTR;} 加入到等待队列,将状态设置成为 TASK_INTERRUPTIBLE , 并设置了调度的 Timeout : MAX_SCHEDULE_TIMEOUT 在调用了 schedule_timeout,使得进程进入了睡眠状态。 4. 释放信号量函数 up void up(struct semaphore *sem){ unsigned long flags; raw_spin_lock_irqsave(&sem->lock, flags); if (likely(list_empty(&sem->wait_list))) sem->count++; else __up(sem); raw_spin_unlock_irqrestore(&sem->lock, flags);} 如果等待队列为空,即,没有睡眠的进程期望获取这个信号量,则直接 count++,否则调用 __up: static noinline void __sched __up(struct semaphore *sem){ struct semaphore_waiter *waiter = list_first_entry(&sem->wait_list, struct semaphore_waiter, list); list_del(&waiter->list); waiter->up = true; wake_up_process(waiter->task);} 取出队列中的元素,进行唤醒操作。 7、互斥体(mutex) 互斥体是一种睡眠锁,他是一种简单的睡眠锁,其行为和 count 为 1 的信号量类似。 互斥体简洁高效,但是相比信号量,有更多的限制,因此对于互斥体的使用条件更加严格: 任何时刻,只有一个指定的任务允许持有 mutex,也就是说,mutex 的计数永远是 1; 给 mutex 上锁这,必须负责给他解锁,也就是不允许在一个上下文中上锁,在另外一个上下文中解锁。这个限制注定了 mutex 无法承担内核和用户空间同步的复杂场景。常用的方式是在一个上下文中进行上锁/解锁。 递归的调用上锁和解锁是不允许的。也就是说,不能递归的去持有同一个锁,也不能够递归的解开一个已经解开的锁。 当持有 mutex 的进程,不允许退出 mutex 不允许在中断上下文和软中断上下文中使用过,即便是mutex_trylock 也不行 mutex 只能使用内核提供的 APIs操作,不允许拷贝,手动初始化和重复初始化 信号量和互斥体 他们两者很相似,除非是 mutex 的限制妨碍到逻辑,否则这两者之间,首选 mutex 自旋锁和互斥体 多数情况,很好区分。中断中只能考虑自旋锁,任务睡眠使用互斥体。如果都可以的的情况下,低开销或者短时间的锁,选择自旋锁,长期加锁的话,使用互斥体。 互斥体的使用 函数定义 功能说明 mutex_lock(struct mutex *lock) 加锁,如果不可用,则睡眠(UNINTERRUPTIBLE) mutex_lock_interruptible(struct mutex *lock); 加锁,如果不可用,则睡眠(TASK_INTERRUPTIBLE) mutex_unlock(struct mutex *lock) 解锁 mutex_trylock(struct mutex *lock) 试图获取指定的 mutex,或得到返回1,否则返回 0 mutex_is_locked(struct mutex *lock) 如果 mutex 被占用返回1,否则返回 0 互斥体的实现 互斥体的定义在:include/linux/mutex.h 1. mutex 的结构 struct mutex { atomic_long_t owner; spinlock_t wait_lock;#ifdef CONFIG_MUTEX_SPIN_ON_OWNER struct optimistic_spin_queue osq; /* Spinner MCS lock */#endif struct list_head wait_list;#ifdef CONFIG_DEBUG_MUTEXES void *magic;#endif#ifdef CONFIG_DEBUG_LOCK_ALLOC struct lockdep_map dep_map;#endif}; 2. mutex 初始化 void__mutex_init(struct mutex *lock, const char *name, struct lock_class_key *key){ atomic_long_set(&lock->owner, 0); spin_lock_init(&lock->wait_lock); INIT_LIST_HEAD(&lock->wait_list);#ifdef CONFIG_MUTEX_SPIN_ON_OWNER osq_lock_init(&lock->osq);#endif debug_mutex_init(lock, name, key);}EXPORT_SYMBOL(__mutex_init); 3. mutex 加锁 void __sched mutex_lock(struct mutex *lock){ might_sleep(); if (!__mutex_trylock_fast(lock)) __mutex_lock_slowpath(lock);} 首先check是否能够获得锁,否则调用到 __mutex_lock_slowpath: static noinline void __sched__mutex_lock_slowpath(struct mutex *lock){ __mutex_lock(lock, TASK_UNINTERRUPTIBLE, 0, NULL, _RET_IP_);} static int __sched__mutex_lock(struct mutex *lock, long state, unsigned int subclass, struct lockdep_map *nest_lock, unsigned long ip){ return __mutex_lock_common(lock, state, subclass, nest_lock, ip, NULL, false);} 所以调用到了 __mutex_lock_common 函数: static __always_inline int __sched__mutex_lock_common(struct mutex *lock, long state, unsigned int subclass, struct lockdep_map *nest_lock, unsigned long ip, struct ww_acquire_ctx *ww_ctx, const bool use_ww_ctx){ struct mutex_waiter waiter; bool first = false; struct ww_mutex *ww; int ret; might_sleep(); ww = container_of(lock, struct ww_mutex, base); if (use_ww_ctx && ww_ctx) { if (unlikely(ww_ctx == READ_ONCE(ww->ctx))) return -EALREADY; /* * Reset the wounded flag after a kill. No other process can * race and wound us here since they can't have a valid owner * pointer if we don't have any locks held. */ if (ww_ctx->acquired == 0) ww_ctx->wounded = 0; } preempt_disable(); mutex_acquire_nest(&lock->dep_map, subclass, 0, nest_lock, ip); if (__mutex_trylock(lock) || mutex_optimistic_spin(lock, ww_ctx, use_ww_ctx, NULL)) { /* got the lock, yay! */ lock_acquired(&lock->dep_map, ip); if (use_ww_ctx && ww_ctx) ww_mutex_set_context_fastpath(ww, ww_ctx); preempt_enable(); return 0; } spin_lock(&lock->wait_lock); /* * After waiting to acquire the wait_lock, try again. */ if (__mutex_trylock(lock)) { if (use_ww_ctx && ww_ctx) __ww_mutex_check_waiters(lock, ww_ctx); goto skip_wait; } debug_mutex_lock_common(lock, &waiter); lock_contended(&lock->dep_map, ip); if (!use_ww_ctx) { /* add waiting tasks to the end of the waitqueue (FIFO): */ __mutex_add_waiter(lock, &waiter, &lock->wait_list); #ifdef CONFIG_DEBUG_MUTEXES waiter.ww_ctx = MUTEX_POISON_WW_CTX;#endif } else { /* * Add in stamp order, waking up waiters that must kill * themselves. */ ret = __ww_mutex_add_waiter(&waiter, lock, ww_ctx); if (ret) goto err_early_kill; waiter.ww_ctx = ww_ctx; } waiter.task = current; set_current_state(state); for (;;) { /* * Once we hold wait_lock, we're serialized against * mutex_unlock() handing the lock off to us, do a trylock * before testing the error conditions to make sure we pick up * the handoff. */ if (__mutex_trylock(lock)) goto acquired; /* * Check for signals and kill conditions while holding * wait_lock. This ensures the lock cancellation is ordered * against mutex_unlock() and wake-ups do not go missing. */ if (unlikely(signal_pending_state(state, current))) { ret = -EINTR; goto err; } if (use_ww_ctx && ww_ctx) { ret = __ww_mutex_check_kill(lock, &waiter, ww_ctx); if (ret) goto err; } spin_unlock(&lock->wait_lock); schedule_preempt_disabled(); /* * ww_mutex needs to always recheck its position since its waiter * list is not FIFO ordered. */ if ((use_ww_ctx && ww_ctx) || !first) { first = __mutex_waiter_is_first(lock, &waiter); if (first) __mutex_set_flag(lock, MUTEX_FLAG_HANDOFF); } set_current_state(state); /* * Here we order against unlock; we must either see it change * state back to RUNNING and fall through the next schedule(), * or we must see its unlock and acquire. */ if (__mutex_trylock(lock) || (first && mutex_optimistic_spin(lock, ww_ctx, use_ww_ctx, &waiter))) break; spin_lock(&lock->wait_lock); } spin_lock(&lock->wait_lock);acquired: __set_current_state(TASK_RUNNING); if (use_ww_ctx && ww_ctx) { /* * Wound-Wait; we stole the lock (!first_waiter), check the * waiters as anyone might want to wound us. */ if (!ww_ctx->is_wait_die && !__mutex_waiter_is_first(lock, &waiter)) __ww_mutex_check_waiters(lock, ww_ctx); } mutex_remove_waiter(lock, &waiter, current); if (likely(list_empty(&lock->wait_list))) __mutex_clear_flag(lock, MUTEX_FLAGS); debug_mutex_free_waiter(&waiter); skip_wait: /* got the lock - cleanup and rejoice! */ lock_acquired(&lock->dep_map, ip); if (use_ww_ctx && ww_ctx) ww_mutex_lock_acquired(ww, ww_ctx); spin_unlock(&lock->wait_lock); preempt_enable(); return 0; err: __set_current_state(TASK_RUNNING); mutex_remove_waiter(lock, &waiter, current);err_early_kill: spin_unlock(&lock->wait_lock); debug_mutex_free_waiter(&waiter); mutex_release(&lock->dep_map, 1, ip); preempt_enable(); return ret;} 进入等待队列。 4. mutex 解锁 void __sched mutex_unlock(struct mutex *lock){#ifndef CONFIG_DEBUG_LOCK_ALLOC if (__mutex_unlock_fast(lock)) return;#endif __mutex_unlock_slowpath(lock, _RET_IP_);}EXPORT_SYMBOL(mutex_unlock); 调用到 __mutex_unlock_slowpath : static noinline void __sched __mutex_unlock_slowpath(struct mutex *lock, unsigned long ip){ struct task_struct *next = NULL; DEFINE_WAKE_Q(wake_q); unsigned long owner; mutex_release(&lock->dep_map, 1, ip); /* * Release the lock before (potentially) taking the spinlock such that * other contenders can get on with things ASAP. * * Except when HANDOFF, in that case we must not clear the owner field, * but instead set it to the top waiter. */ owner = atomic_long_read(&lock->owner); for (;;) { unsigned long old; #ifdef CONFIG_DEBUG_MUTEXES DEBUG_LOCKS_WARN_ON(__owner_task(owner) != current); DEBUG_LOCKS_WARN_ON(owner & MUTEX_FLAG_PICKUP);#endif if (owner & MUTEX_FLAG_HANDOFF) break; old = atomic_long_cmpxchg_release(&lock->owner, owner, __owner_flags(owner)); if (old == owner) { if (owner & MUTEX_FLAG_WAITERS) break; return; } owner = old; } spin_lock(&lock->wait_lock); debug_mutex_unlock(lock); if (!list_empty(&lock->wait_list)) { /* get the first entry from the wait-list: */ struct mutex_waiter *waiter = list_first_entry(&lock->wait_list, struct mutex_waiter, list); next = waiter->task; debug_mutex_wake_waiter(lock, waiter); wake_q_add(&wake_q, next); } if (owner & MUTEX_FLAG_HANDOFF) __mutex_handoff(lock, next); spin_unlock(&lock->wait_lock); wake_up_q(&wake_q);} 做唤醒操作。 8、RCU机制 RCU 的全称是(Read-Copy-Update),意在读写-复制-更新,在Linux提供的所有内核互斥的设施当中属于一种免锁机制。在之前讨论过的读写自旋锁(rwlock)、顺序锁(seqlock)一样,RCU 的适用模型也是读写共存的系统。 读写自旋锁:读者和写者互斥,读者和读者共存,写者和写者互斥。(偏向读者) 顺序锁:写者和写者互斥,写者直接打断读者(偏向写者) 上述两种都是基于 spinlock 的一种用于特定场景的锁机制。 RCU 与他们不同,它的读取和写入操作无需考虑两者之间的互斥问题。 之前的锁分析中,可以知道,加锁、解锁都涉及内存操作,同时伴有内存屏障引入,这些都会导致锁操作的系统开销变大,在此基础之上, 内核在Kernel的 2.5 版本引入了 RCU 的免锁互斥访问机制。 什么叫免锁机制呢?对于被RCU保护的共享数据结构,读者不需要获得任何锁就可以访问它,但写者在访问它时首先拷贝一个副本,然后对副本进行修改,最后使用一个回调(callback)机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据。这个时机就是所有引用该数据的CPU都退出对共享数据的操作。 因此RCU实际上是一种改进的 rwlock,读者几乎没有什么同步开销,它不需要锁,不使用原子指令。不需要锁也使得使用更容易,因为死锁问题就不需要考虑了。写者的同步开销比较大,它需要延迟数据结构的释放,复制被修改的数据结构,它也必须使用某种锁机制同步并行的其它写者的修改操作。读者必须提供一个信号给写者以便写者能够确定数据可以被安全地释放或修改的时机。有一个专门的垃圾收集器来探测读者的信号,一旦所有的读者都已经发送信号告知它们都不在使用被RCU保护的数据结构,垃圾收集器就调用回调函数完成最后的数据释放或修改操作。 RCU与rwlock的不同之处是:它既允许多个读者同时访问被保护的数据,又允许多个读者和多个写者同时访问被保护的数据(注意:是否可以有多个写者并行访问取决于写者之间使用的同步机制),读者没有任何同步开销,而写者的同步开销则取决于使用的写者间同步机制。但RCU不能替代rwlock,因为如果写比较多时,对读者的性能提高不能弥补写者导致的损失。 读者在访问被RCU保护的共享数据期间不能被阻塞,这是RCU机制得以实现的一个基本前提,也就说当读者在引用被RCU保护的共享数据期间,读者所在的CPU不能发生上下文切换,spinlock和rwlock都需要这样的前提。写者在访问被RCU保护的共享数据时不需要和读者竞争任何锁,只有在有多于一个写者的情况下需要获得某种锁以与其他写者同步。写者修改数据前首先拷贝一个被修改元素的副本,然后在副本上进行修改,修改完毕后它向垃圾回收器注册一个回调函数以便在适当的时机执行真正的修改操作。等待适当时机的这一时期称为宽限期 grace period,而CPU发生了上下文切换称为经历一个quiescent state,grace period就是所有CPU都经历一次quiescent state所需要的等待的时间(读的时候禁止了内核抢占,也就是上下文切换,如果在某个 CPU 上发生了进程切换,那么所有对老指针的引用都会结束之后)。垃圾收集器就是在grace period之后调用写者注册的回调函数(call_rcu 函数注册回调)来完成真正的数据修改或数据释放操作的。 总的来说,RCU的行为方式: 1、随时可以拿到读锁,即对临界区的读操作随时都可以得到满足 2、某一时刻只能有一个人拿到写锁,多个写锁需要互斥,写的动作包括 拷贝--修改--宽限窗口到期后删除原值 3、临界区的原始值为m1,如会有人拿到写锁修改了临界区为m2,则在写锁修改临界区之后拿到的读锁获取的临界区的值为m2,之前获取的为m1,这通过原子操作保证 对比发现RCU读操作随时都会得到满足,但写锁之后的写操作所耗费的系统资源就相对比较多了,并且只有在宽限期之后删除原资源。 针对对象 RCU 保护的对象是指针。这一点尤其重要.因为指针赋值是一条单指令.也就是说是一个原子操作.因它更改指针指向没必要考虑它的同步.只需要考虑cache的影响。 内核中所有关于 RCU 的操作都应该使用内核提供的 RCU 的 APIs 函数完成,这些 APIs 主要集中在指针和链表的操作。 使用场景 RCU 使用在读者多而写者少的情况.RCU和读写锁相似.但RCU的读者占锁没有任何的系统开销.写者与写写者之间必须要保持同步,且写者必须要等它之前的读者全部都退出之后才能释放之前的资源 读者是可以嵌套的.也就是说rcu_read_lock()可以嵌套调用 从 RCU 的特性可知,RCU 的读取性能的提升是在增加写入者负担的前提下完成的,因此在一个读者与写者共存的系统中,按照设计者的说法,如果写入者的操作比例在 10% 以上,那么久应该考虑其他的互斥方法,反正,使用 RCU 的话,能够获取较好的性能。 使用限制 读者在访问被RCU保护的共享数据期间不能被阻塞。 在读的时候,会屏蔽掉内核抢占。 RCU 的实现原理 在RCU的实现过程中,我们主要解决以下问题: 1,在读取过程中,另外一个线程删除了一个节点。删除线程可以把这个节点从链表中移除,但它不能直接销毁这个节点,必须等到所有的读取线程读取完成以后,才进行销毁操作。RCU中把这个过程称为宽限期(Grace period)。 2,在读取过程中,另外一个线程插入了一个新节点,而读线程读到了这个节点,那么需要保证读到的这个节点是完整的。这里涉及到了发布-订阅机制(Publish-Subscribe Mechanism)。 3, 保证读取链表的完整性。新增或者删除一个节点,不至于导致遍历一个链表从中间断开。但是RCU并不保证一定能读到新增的节点或者不读到要被删除的节点。 1. 宽限期 通过例子,方便理解这个内容。以下例子修改于Paul的文章: struct foo { int a; char b; long c; }; DEFINE_SPINLOCK(foo_mutex); struct foo *gbl_foo; void foo_read (void){ foo *fp = gbl_foo; if ( fp != NULL ) dosomething(fp->a, fp->b , fp->c );} void foo_update( foo* new_fp ){ spin_lock(&foo_mutex); foo *old_fp = gbl_foo; gbl_foo = new_fp; spin_unlock(&foo_mutex); kfee(old_fp);} 如上的程序,是针对于全局变量gbl_foo的操作。假设以下场景。有两个线程同时运行foo_ read和foo_update的时候,当foo_ read执行完赋值操作后,线程发生切换;此时另一个线程开始执行foo_update并执行完成。当foo_ read运行的进程切换回来后,运行dosomething的时候,fp 已经被删除,这将对系统造成危害。为了防止此类事件的发生,RCU里增加了一个新的概念叫宽限期(Grace period)。如下图所示: 图中每行代表一个线程,最下面的一行是删除线程,当它执行完删除操作后,线程进入了宽限期。宽限期的意义是,在一个删除动作发生后,它必须等待所有在宽限期开始前已经开始的读线程结束,才可以进行销毁操作。这样做的原因是这些线程有可能读到了要删除的元素。图中的宽限期必须等待1和2结束;而读线程5在宽限期开始前已经结束,不需要考虑;而3,4,6也不需要考虑,因为在宽限期结束后开始后的线程不可能读到已删除的元素。为此RCU机制提供了相应的API来实现这个功能。 用 RCU: void foo_read(void){ rcu_read_lock(); foo *fp = gbl_foo; if ( fp != NULL ) dosomething(fp->a,fp->b,fp->c); rcu_read_unlock();} void foo_update( foo* new_fp ){ spin_lock(&foo_mutex); foo *old_fp = gbl_foo; gbl_foo = new_fp; spin_unlock(&foo_mutex); synchronize_rcu(); kfee(old_fp);} 其中 foo_ read 中增加了 rcu_read_lock 和 rcu_read_unlock,这两个函数用来标记一个RCU读过程的开始和结束。其实作用就是帮助检测宽限期是否结束。foo_update 增加了一个函数 synchronize_rcu(),调用该函数意味着一个宽限期的开始,而直到宽限期结束,该函数才会返回。我们再对比着图看一看,线程1和2,在 synchronize_rcu 之前可能得到了旧的 gbl_foo,也就是 foo_update 中的 old_fp,如果不等它们运行结束,就调用 kfee(old_fp),极有可能造成系统崩溃。而3,4,6在synchronize_rcu 之后运行,此时它们已经不可能得到 old_fp,此次的kfee将不对它们产生影响。 宽限期是RCU实现中最复杂的部分,原因是在提高读数据性能的同时,删除数据的性能也不能太差。 2. 订阅——发布机制 当前使用的编译器大多会对代码做一定程度的优化,CPU也会对执行指令做一些优化调整,目的是提高代码的执行效率,但这样的优化,有时候会带来不期望的结果。如例: void foo_update( foo* new_fp ){ spin_lock(&foo_mutex); foo *old_fp = gbl_foo; new_fp->a = 1; new_fp->b = ‘b’; new_fp->c = 100; gbl_foo = new_fp; spin_unlock(&foo_mutex); synchronize_rcu(); kfee(old_fp);} 这段代码中,我们期望的是6,7,8行的代码在第10行代码之前执行。但优化后的代码并不对执行顺序做出保证。在这种情形下,一个读线程很可能读到 new_fp,但new_fp的成员赋值还没执行完成。当读线程执行dosomething(fp->a, fp->b , fp->c ) 的 时候,就有不确定的参数传入到dosomething,极有可能造成不期望的结果,甚至程序崩溃。可以通过优化屏障来解决该问题,RCU机制对优化屏障做了包装,提供了专用的API来解决该问题。这时候,第十行不再是直接的指针赋值,而应该改为 : rcu_assign_pointer(gbl_foo,new_fp); <include/linux/rcupdate.h> 中 rcu_assign_pointer的实现比较简单,如下: #define rcu_assign_pointer(p, v) \ __rcu_assign_pointer((p), (v), __rcu) #define __rcu_assign_pointer(p, v, space) \ do { \ smp_wmb(); \ (p) = (typeof(*v) __force space *)(v); \ } while (0) 我们可以看到它的实现只是在赋值之前加了优化屏障 smp_wmb来确保代码的执行顺序。另外就是宏中用到的__rcu,只是作为编译过程的检测条件来使用的。 <include/linux/rcupdate.h> #define rcu_dereference(p) rcu_dereference_check(p, 0) #define rcu_dereference_check(p, c) \ __rcu_dereference_check((p), rcu_read_lock_held() || (c), __rcu) #define __rcu_dereference_check(p, c, space) \ ({ \ typeof(*p) *_________p1 = (typeof(*p)*__force )ACCESS_ONCE(p); \ rcu_lockdep_assert(c, "suspicious rcu_dereference_check()" \ " usage"); \ rcu_dereference_sparse(p, space); \ smp_read_barrier_depends(); \ ((typeof(*p) __force __kernel *)(_________p1)); \ }) static inline int rcu_read_lock_held(void){ if (!debug_lockdep_rcu_enabled()) return 1; if (rcu_is_cpu_idle()) return 0; if (!rcu_lockdep_current_cpu_online()) return 0; return lock_is_held(&rcu_lock_map);} 这段代码中加入了调试信息,去除调试信息,可以是以下的形式(其实这也是旧版本中的代码): #define rcu_dereference(p) ({ \ typeof(p) _________p1 = p; \ smp_read_barrier_depends(); \ (_________p1); \ }) 在赋值后加入优化屏障smp_read_barrier_depends()。 我们之前的第四行代码改为 foo *fp = rcu_dereference(gbl_foo);,就可以防止上述问题。 3. 数据读取的完整性 还是通过例子来说明这个问题: 如图我们在原list中加入一个节点new到A之前,所要做的第一步是将new的指针指向A节点,第二步才是将Head的指针指向new。这样做的目的是当插入操作完成第一步的时候,对于链表的读取并不产生影响,而执行完第二步的时候,读线程如果读到new节点,也可以继续遍历链表。如果把这个过程反过来,第一步head指向new,而这时一个线程读到new,由于new的指针指向的是Null,这样将导致读线程无法读取到A,B等后续节点。从以上过程中,可以看出RCU并不保证读线程读取到new节点。如果该节点对程序产生影响,那么就需要外部调用做相应的调整。如在文件系统中,通过RCU定位后,如果查找不到相应节点,就会进行其它形式的查找,相关内容等分析到文件系统的时候再进行叙述。 我们再看一下删除一个节点的例子: 如图我们希望删除B,这时候要做的就是将A的指针指向C,保持B的指针,然后删除程序将进入宽限期检测。由于B的内容并没有变更,读到B的线程仍然可以继续读取B的后续节点。B不能立即销毁,它必须等待宽限期结束后,才能进行相应销毁操作。由于A的节点已经指向了C,当宽限期开始之后所有的后续读操作通过A找到的是C,而B已经隐藏了,后续的读线程都不会读到它。这样就确保宽限期过后,删除B并不对系统造成影响。 如何使用 RCU 除了上一节使用 RCU 的例子,这一节在贴一个使用 RCU 的例子: // 假设 struct shared_data 是一个在读者和写者之间共享的数据结构struct shared_data { int a; int b; struct rcu_head rcu;}; Reader Code : // 读者的代码。// 读者调用 rcu_read_lock 和 rcu_read_unlock 来构建并访问临界区// 所有对指向被保护资源指针的引用都应该只在临界区出现,而且临界区代码不能睡眠static void demo_reader(struct shared_data *ptr){ struct shared_data *p = NULL; rcu_read_lock(); // call rcu_dereference to get the ptr pointer p = rcu_dereference(ptr); if (p) do_somethings(p); rcu_read_unlock();} Writer Code : // 写入侧的代码// 写入者提供的回调函数,用于释放老的数据指针static void demo_del_oldptr(struct rcu_head *rh){ struct shared_data *p = container_of(rh, struct rcu_head, rcu); kfree(p);} static void demo_writer(struct shared_data *ptr){ struct shared_data *new_ptr = kmalloc(...); .... new_ptr->a = 10; new_ptr->b = 20; // 用新指针更新老指针 rcu_assign_pointer(ptr, new_ptr); // 调用 call_rcu 让内核在确保所有对老指针 ptr 的引用都解锁后,回调到 demo_del_oldptr 释放老指针 call_rcu(ptr->rcu, demo_del_oldptr);} 在上面的例子,写者调用 rcu_assign_pointer 更新老指针后,使用 call_rcu 接口,向系统注册了一会回调函数,系统在确定没有对老指针引用之后,调用这个函数。另一个类似的函数是上一节遇到的 synchronize_rcu 调用,这个函数可能会阻塞,因为他要等待所有对老指针的引用全部结束才返回,函数返回的时候意味着系统所有对老指针的引用都消失,此时在释放老指针才是安全的。如果在中断上下文执行写入者的操作,那么就应该使用 call_rcu ,不能使用 synchronize_rcu。 对于这 call_rcu 和 synchronize_rcu 的分析如下: 在释放老指针方面,Linux内核提供两种方法供使用者使用,一个是调用call_rcu,另一个是调用synchronize_rcu。前者是一种异步 方式,call_rcu会将释放老指针的回调函数放入一个结点中,然后将该结点加入到当前正在运行call_rcu的处理器的本地链表中,在时钟中断的 softirq部分(RCU_SOFTIRQ), rcu软中断处理函数rcu_process_callbacks会检查当前处理器是否经历了一个休眠期(quiescent,此处涉及内核进程调度等方面的内容),rcu的内核代码实现在确定系统中所有的处理器都经历过了一个休眠期之后(意味着所有处理器上都发生了一次进程切换,因此老指针此时可以被安全释放掉了),将调用call_rcu提供的回调函数。 synchronize_rcu的实现则利用了等待队列,在它的实现过程中也会向call_rcu那样向当前处理器的本地链表中加入一个结点,与 call_rcu不同之处在于该结点中的回调函数是wakeme_after_rcu,然后synchronize_rcu将在一个等待队列中睡眠,直到系统中所有处理器都发生了一次进程切换,因而wakeme_after_rcu被rcu_process_callbacks所调用以唤醒睡眠的 synchronize_rcu,被唤醒之后,synchronize_rcu知道它现在可以释放老指针了。 所以我们看到,call_rcu返回后其注册的回调函数可能还没被调用,因而也就意味着老指针还未被释放,而synchronize_rcu返回后老指针肯定被释放了。所以,是调用call_rcu还是synchronize_rcu,要视特定需求与当前上下文而定,比如中断处理的上下文肯定不能使用 synchronize_rcu函数了。 基本RCU操作 APIs 对于reader,RCU的操作包括: (1)rcu_read_lock:用来标识RCU read side临界区的开始。 (2)rcu_dereference:该接口用来获取RCU protected pointer。reader要访问RCU保护的共享数据,当然要获取RCU protected pointer,然后通过该指针进行dereference的操作。 (3)rcu_read_unlock:用来标识reader离开RCU read side临界区 对于writer,RCU的操作包括: (1)rcu_assign_pointer:该接口被writer用来进行removal的操作,在witer完成新版本数据分配和更新之后,调用这个接口可以让RCU protected pointer指向RCU protected data。 (2)synchronize_rcu:writer端的操作可以是同步的,也就是说,完成更新操作之后,可以调用该接口函数等待所有在旧版本数据上的reader线程离开临界区,一旦从该函数返回,说明旧的共享数据没有任何引用了,可以直接进行reclaimation的操作。 (3)call_rcu:当然,某些情况下(例如在softirq context中),writer无法阻塞,这时候可以调用call_rcu接口函数,该函数仅仅是注册了callback就直接返回了,在适当的时机会调用callback函数,完成reclaimation的操作。这样的场景其实是分开removal和reclaimation的操作在两个不同的线程中:updater和reclaimer。 RCU 的链表操作: 在 Linux kernel 中还专门提供了一个头文件(include/linux/rculist.h),提供了利用 RCU 机制对链表进行增删查改操作的接口。 (1) list_add_rcu :该函数把链表项new插入到RCU保护的链表head的开头。使用内存栅保证了在引用这个新插入的链表项之前,新链表项的链接指针的修改对所有读者是可见的。 static inline void list_add_rcu(struct list_head *new, struct list_head *head) (2) list_add_tail_rcu:该函数类似于list_add_rcu,它将把新的链表项new添加到被RCU保护的链表的末尾。 static inline void list_add_tail_rcu(struct list_head *new, struct list_head *head) (3) list_del_rcu:该函数从RCU保护的链表中移走指定的链表项entry,并且把entry的prev指针设置为LIST_POISON2,但是并没有把entry的next指针设置为LIST_POISON1,因为该指针可能仍然在被读者用于便利该链表。 static inline void list_del_rcu(struct list_head *entry) (4) list_replace_rcu:该函数是RCU新添加的函数,并不存在非RCU版本。它使用新的链表项new取代旧的链表项old,内存栅保证在引用新的链表项之前,它的链接指针的修正对所有读者可见 static inline void list_replace_rcu(struct list_head *old, struct list_head *new) (5) list_for_each_entry_rcu:该宏用于遍历由RCU保护的链表head #define list_for_each_entry_rcu(pos, head, member) \ for (pos = list_entry_rcu((head)->next, typeof(*pos), member); \ &pos->member != (head); \ pos = list_entry_rcu(pos->member.next, typeof(*pos), member))

    2024-11-21 99浏览
  • 基于MSP430的空间定向测试仪设计要点

    0 引言 空间定向测试仪是一种应用非常广泛的电子测量仪器,尤其是伴随着微电子技术的发展,空间定向测试仪在车辆、舰船、飞行器等导航领域中的应用日趋成熟。本文所研究的空间定向测试技术主要是以MSP430单片机为基...

    2024-09-04 112浏览
  • 如何提高计算机的处理器管理效率

    所谓软件是指为方便使用计算机和提高使用效率而组织的程序以及用于开发、使用和维护的有关文档。软件系统可分为系统软件和应用软件两大类。 一、系统软件系统软件System software,由一组控制计算机系统并管理其资...

    2024-08-22 182浏览
正在努力加载更多...
EE直播间
更多
广告