操作系统

内存管理

1.介绍下Linux的内存子系统

  • 虚拟内存管理:地址空间、页表、内存布局
  • 物理内存管理:struct page、Zone区、伙伴系统、SLAB分配器
  • 页缓存、回收、换页机制:通过页缓存加速 I/O;当内存紧张时,kswapd 回收不常用页、OOM-Killer 终止大进程
  • 内存分配接口:malloc、kmalloc、vmalloc、dma_alloc_coherent、get_free_pages、ioremap

虚拟内存管理

1.Linux是几级页表

2.怎么通过虚拟地址查找物理地址

3.Linux下各个进程的虚拟内存空间的布局是什么样的?高端内存映射区是什么?它的地址是什么?

  • 首先Linux进程的虚拟内存空间分为内核空间和用户空间2部分

4.为什么要有虚拟内存

  • 管理:它为每个进程提供独立的、连续的虚拟地址空间,让编译器和程序员无需关心物理内存的碎片化布局,简化了内存管理
  • 保护:隔离了进程地址空间,并且使用页表权限控制机制,确保一个进程的错误或恶意行为不会影响其他进程和系统内核,提升了稳定性和安全性
  • 扩展:通过交换技术,将磁盘空间作为内存的延伸,使得程序可以运行比物理内存更大的应用程序,实现了‘小内存跑大程序’
  • 共享:允许将同一块物理内存(如库文件代码)映射到多个进程的地址空间,节省内存并提高效率

5.CPU访问内存的详细流程是什么

  • CPU发出虚拟地址(VA)
  • MMU通过TLB快速将VA转换为PA(若TLB未命中,则查页表)
  • 用PA访问Cache(若Cache未命中,则访问主存)
  • 最终从Cache或主存读取数据返回CPU

6.对于32位的CPU和OS,内核能够访问到所有4GB的物理内存吗,有哪些方式可以访问

  • 不能一次性访问全部的4GB物理内存,但可以做到分次访问所有
  • 虽然内核只有1GB的地址空间,这部分对应物理地址的0896MB,但是对于地址为896MB4GB的物理内存,可以通过kmap将其映射到内核的”高端内存映射区“从而在内核中访问
  • 也可以通过copy_from_user之类的内核和用户空间通信的方式访问

7.用户空间申请的内存,内核什么时候可以访问,什么时候不能访问

  • 通过系统调用进入内核时,是可以访问的,不过也要使用copy_xxx_user之类的API。因为此时是在进程上下文,current宏可以获取当前进程的task_struct,用户页表仍有效
  • 通过中断进入内核时或者运行内核线程时,是不可访问用户空间的内存的。因为此时没有用户空间的上下文及页表
  • 用户进程通过mmap将一块物理内存映射到用户空间,这块内存内核和用户态都可以访问

8.内核空间能访问用户空间的内存吗

  • 推荐使用copy_from_user的方式
  • 直接解引用用户空间的地址,理论上是没问题的,因为Linux下内核态和用户态共用一个page table,但是不推荐这种方式,存在以下隐患:
    • 如果地址无效 → Oops / Kernel panic
    • 如果页表未映射 → Page fault (内核态无法处理此异常,会导致kernel panic)
    • 如果地址映射到了用户程序恶意区域 → 安全风险
    • 如果架构启用了 SMAP / PAN(Supervisor Mode Access Prevention) → CPU 会直接拒绝内核访问用户态页,产生异常

9.什么是vm_area_struct

  • Linux管理每个用户进程的虚拟地址空间的基本单位,它是整个虚拟地址空间的一个组成成分。每一个 mmap()brk() 分配的虚拟地址区间都会对应一个VMA
  • 内核空间的虚拟地址空间不需要VMA,内核启动时就初始化了内核页表的PTE

10.如何理解mmap

定义:mmap是Linux提供的一种内存映射机制,可以把文件或设备的一部分内存映射到用户进程的虚拟地址空间,使得用户可以像访问内存一样直接操纵文件,而不必使用read/write等系统调用。底层原理实际上是在用户空间的页表创建一些PTE并建立映射,从而使用户空间的访问可以直接作用于内核管理的物理页

常见用途:

场景 示例
文件映射 加载动态库、共享内存文件
设备映射 驱动程序中的 mmap 回调(如显存映射、DMA buffer)
匿名内存分配 malloc() 底层常常通过 mmap 分配大块内存
零拷贝数据交换 用户态直接访问内核缓冲区

系统调用接口如下:

1
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
参数 说明
addr 建议的起始地址(一般传 NULL 让内核选择)
length 映射的字节数
prot 映射区的访问权限(PROT_READ, PROT_WRITE, PROT_EXEC, PROT_NONE
flags 控制映射行为(MAP_SHARED, MAP_PRIVATE, MAP_ANONYMOUS, …)
fd 被映射的文件描述符(匿名映射时填 -1
offset 文件内偏移,必须是页大小的整数倍
返回值 用户空间得到内存的起始地址

优点:

  • 减少系统调用次数,避免数据在内核空间与用户空间之间的复制
  • 多个进程可以共享映射的内存区域,实现高效通信
  • 只在真正访问到的页面才加载,提高性能

分类:mmap分为对文件的映射和匿名映射

  • 文件映射:用于文件 I/O、共享内存
  • 匿名映射:用于动态分配内存、进程通信、内存池

映射类型:mmap有一个flag形参来决定映射的类型,有以下几种:

  • 文件映射:

    • MAP_SHARED:多个进程共享同一段物理页,对映射区的修改会同步到文件中

    • MAP_PRIVATE:映射初期与文件共享同一页,但是PTE只有Read的权限,当进程修改数据时,内核执行COW机制,修改仅对当前进程可见,不会影响文件

  • 匿名映射:没有关联的文件,内核直接分配的物理内存并将内容初始化为0

    • MAP_ANONYMOUS:不映射文件,而是分配匿名内存(如堆/共享内存用途)

    • MAP_FIXED:强制使用 addr 指定的地址(一般不推荐)

底层原理(文件/匿名映射):

(1)创建一个vm_area_struct实例,加入当前进程的mm_struct

(2)根据关联的文件,设置VMA的vm_ops回调函数,比如设置成file->f_op->mmap

(3)当用户首次访问该地址时,触发Page Fault。内核在处理时,如果这段地址是有效的,则可以定位到一个vma实例,进而根据vma类型分配物理页并更新PTE建立映射关系

  • 文件映射:从文件页缓存加载相应页
  • 匿名映射:分配新的空白物理页

如果是映射设备内存(例如 framebuffer、V4L2 buffer、PCI BAR、DMA 区)时,内核在 mmap() 的时候就直接建立了虚拟地址–>物理页帧的映射,因此没有懒加载

核心原因在于file->f_op->mmap不同

11.为什么映射设备内存时,不能懒加载

  • 物理内存必须固定:DMA引擎通过物理地址访问内存,不能动态分配
  • I/O 区不在普通 RAM 中:如 PCI BAR、Framebuffer 等是设备寄存器或专用显存,无法通过页错误机制分配
  • 驱动已经分配好物理页:dma_alloc_coherent() / CMA / framebuffer 分配时已确定物理区域
  • 访问不应触发缺页异常:缺页异常处理函数通常不支持设备 I/O 区

12.VMA如何跟物理内存建立映射关系

建立映射关系本质上就是创建一些PTE,mmap在分配了物理内存之后都得创建PTE,Linux通过以下API进行用户页表的PTE的创建

  • remap_pfn_range:一次性映射多个连续的物理页
  • vm_insert_page:逐页插入不连续的物理页

注意:只有用户进程分配的内存或内核高段地址空间需要手动建立PTE,内核空间的低段虚拟地址到物理页的 PTE,早在系统启动时就准备好了,高端虚拟地址用kmap映射映射

13.V4L2申请内核缓冲区的USER_PTR方式,是如何做到内核空间访问用户空间的地址的

  • 内核通过 get_user_pages() 将这些虚拟页解析为 struct page * 数组,然后根据硬件访问方式:
    • kmap() / dma_map_page()映射这些page到内核的地址空间
    • 最后在释放时用 put_page() 解锁页

14.使用V4L2时,用户空间mmap是如何映射到摄像头的数据的

从用户态的mmap到内核态的调用链如下:

1
2
3
4
5
6
7
8
sys_mmap
└── do_mmap
└── file->f_op->mmap() //调到视频设备驱动的 .mmap 回调
└── vb2_fop_mmap()
└── vb2_mmap() //据缓冲区偏移找到对应的vb2_buffer
└── call_memop(buf, mmap, vma)//调用 memops->mmap() 完成映射
└── vb2_dma_contig_memops.mmap()
└── dma_mmap_attrs()

物理内存管理

1.Linux内核的物理内存管理方式有哪些

  • 伙伴算法:管理物理页块的分配和合并:按 2^n 页(4KB/页)分配,避免碎片化

  • Slab分配器:管理频繁分配的小块内存(如 task_struct),减少Buddy系统的内部碎片

2.超过一页的内存要怎么分配

3.内存碎片怎么处理

4.struct page是什么:Linux物理内存管理的核心数据结构,也是管理的基本单位

1
2
3
4
5
6
7
8
9
10
struct page {
...
unsigned long flags; // 页状态标志(Dirty、Locked、Referenced 等)
atomic_t _count; // 引用计数(被映射或缓存次数)
atomic_t _mapcount; // 映射到多少个页表(反映页被映射次数)
struct address_space *mapping; // 该page属于哪个地址空间
pgoff_t index; // 文件内偏移(页缓存)
struct list_head lru; // 挂在 LRU 链表上(用于页面回收)
void *virtual; // 低端内存页对应的虚拟地址(仅低端内存有效)
};
  • Linux 中的物理内存是按“页”管理的,而不是字节。 struct page 就是“物理页”的抽象。内核会在启动时为DDR中的每一个物理页(通常大小为 4KB)分配一个实例,用于追踪它的状态、所属、引用计数等
  • 内核的所有内存管理,无论是页分配、页缓存、虚拟内存映射还是 swap 回收,都以 struct page 为基本单位
  • Linux 内核在启动初始化阶段,就会根据检测到的物理内存布局,为系统中的每一个物理页创建一个对应的 struct page。这些结构体被组织成全局数组 mem_map[],用于管理所有物理页,并且内核会把这些空闲页挂在伙伴系统的空闲链表上
  • 任何内存分配(alloc_pages()kmalloc()vmalloc()、文件缓存、页表分配、DMA 内存等)都不是新建 page 结构,而是从这个数组中拿对应的 struct page 来标记状态
1
2
3
4
5
6
7
8
9
+------------------------------+
| Page 0 | Page 1 | Page 2 ...|
+------------------------------+
│ │
▼ ▼
struct page struct page
│ │
▼ ▼
mem_map[0] mem_map[1]

5.Zone区是什么

  • 内核将物理页按用途、地址范围或访问特性分为若干个Zone区,每个Zone都有自己的伙伴系统结构
1
2
3
4
5
6
7
8
9
10
struct zone {
unsigned long watermark[NR_WMARK]; // 低/中/高水位线
unsigned long managed_pages; // 可管理页数
unsigned long spanned_pages; // Zone 跨越的页数
unsigned long present_pages; // 实际存在页数

struct free_area free_area[MAX_ORDER]; // 伙伴系统
spinlock_t lock; // Zone 锁
const char *name; // "DMA" / "Normal" / "HighMem"
};
Zone 名称 典型范围 用途 备注
ZONE_DMA 0 ~ 16MB 或 0 ~ 1GB(平台相关) 供 DMA 设备访问 早期 32 位兼容
ZONE_DMA32 0 ~ 4GB 给只能寻址 32 位的设备用 仅在 64 位系统中存在
ZONE_NORMAL 低端可直接映射内存 供内核直接访问(常用区) 内核线性映射区
ZONE_HIGHMEM 超出内核直接映射范围的物理内存 32 位系统上的高端内存,用户态使用 需用 kmap() 访问
ZONE_MOVABLE 可迁移页 用于减少碎片,方便大页分配 Kernel page migration
ZONE_DEVICE NVDIMM / GPU 显存 特殊设备内存 使用 device driver

Linux的物理内存管理宏观结构如下

1
2
3
pglist_data (node)   → NUMA 节点
└── zone → 内存分区
└── struct page → 物理页

6.为什么要有Zone

  • 某些外设只能访问特定区间的地址,比如DMA就只能访问0~16MB的物理内存。Zone帮助内核用不同的才策略管理不同的物理内存
  • 每个Zone都有独立的伙伴系统,调用alloc_pages时,内核可以根据标志位从对应Zone的伙伴系统分配物理页

7.mmap/brk系统调用的物理内存是什么时候分配的

  • mmap/brk在调用时只会分配VMA并不会分配物理内存,只有在第一次访问触发page fault时才会分配物理内存(调用alloc_pages),这个机制就是lazy allocation机制

8.什么是lazy allocation机制

页缓存与交换机制

1.什么是内存交换技术

定义:物理内存紧张时,内核把某些进程的暂时不用内存页的内容写回磁盘,并释放这些内存页来获得一些可用的内存空间,并标记其PTE。当再次访问这些内存页时,再从磁盘把数据拿出来

2.介绍下Linux的页缓存(Page Cache)机制

背景:Linux的设计理念是“尽量把空闲物理内存都用起来,提高系统整体性能”,所以它引入此机制,通过struct page会把常用的文件提前缓存到内存中(通过mapping字段与文件的inode关联起来),在内存紧张时,再把不常用的数据换出

定义:在 Linux 中,所有文件 I/O 都是以页为单位进行的,并不是以块为单位的。当进程读写文件时,内核不会直接访问磁盘,而是先把文件内容缓存在页缓存strcut page中,文件的读写操作最终都是在这些页上进行的,若页缓存未命中才会触发块 I/O 读写磁盘,从而实现统一、高效的文件访问机制

1
2
3
4
5
6
文件 file.txt (大小 = 12KB)
┌────────────────────────────┐
│ Page 0 → offset 0~4KB │
│ Page 1 → offset 4KB~8KB │
│ Page 2 → offset 8KB~12KB │
└────────────────────────────┘

读取文件的流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
read()

generic_file_read_iter()

find_get_page(inode->i_mapping, index)

若页已在缓存 → 直接copy_to_user()
若页不在缓存:
alloc_page()
调用 address_space_ops->readpage()

文件系统(ext4/xfs...)调度块设备读取数据
把读到的数据放入 page cache
copy_to_user()

内存分配API

1.kmalloc最小内存分配,预期分配128字节实际多少?

  • kmalloc 最小分配单位:依赖Slab分配器的 size class,通常为 32字节或64字节(因架构而异)
  • 分配128字节时的实际开销:
    • 若Slab的 size class 包含128字节,则精确分配。
    • 若无精确匹配,会向上取整到最近 size class(如192字节)
  • 额外开销:
    • 内存对齐(通常8/16字节对齐)
    • Slab元数据(如调试信息)

2.kmalloc/vmalloc的底层原理和区别是什么

3.malloc的底层原理是什么

4.new/delete,malloc/free的不同

特性 new/delete malloc/free
语言 C++ 运算符 C 库函数
构造/析构 自动调用 不调用
类型安全
初始化 支持 不支持
失败处理 抛出异常 返回 NULL
数组处理 专用 new[]/delete[] 需要手动计算大小
内存大小 自动计算 手动指定

其他

1.零拷贝技术是什么

  • 零拷贝是避免数据在内核态和用户态之间反复拷贝的一种技术
  • 传统的I/O数据流通常存在多次的数据拷贝,零拷贝的核心是通过内存映射、DMA等方式,让用户空间/内核/设备访问同一块物理内存

2.什么是内存屏障

  • 定义:内存屏障是一种用来限制 CPU 或编译器对内存访问的重排序的的特殊指令,确保在多核/多线程环境中内存访问的可见性和顺序性。分为编译器屏障和 CPU 硬件屏障,常见形式有读屏障、写屏障和全屏障
  • 在 Linux 内核和驱动开发中,内存屏障常用于设备寄存器访问、多核同步、RCU 等场景,避免由于乱序执行导致的并发 bug

3.什么是内存抖动

  • 定义:系统因为频繁的内存分配释放,或者频繁的页面换入换出,导致 CPU 大量时间花在内存管理上而不是执行真正任务,最终性能严重下降。
  • 原因:
    • 在操作系统层面,它常见于物理内存不足引发的频繁 page fault,CPU一直忙于换页,应用跑不动
    • 在应用层面,常见于频繁 malloc/free 导致的内存碎片、Cache 命中率降低而导致性能下降
  • 解决方法一般有:增大物理内存、优化算法减少工作集、引入内存池避免频繁 malloc/free

4.缺页中断的原因是什么

  • 页面未分配:访问的虚拟地址没有对应的物理页
  • 权限违规:比如写入只读页、用户空间访问内核空间
  • 页面被置换到了磁盘中

5.什么是缺页异常,处理流程是什么

  • 看中断处理那章

6.UMA和NUMA的定义和区别

  • UMA(统一内存访问):内存被所有 CPU 均匀共享,没有“本地”或“远程”之分,所有处理器访问主存的速度是相同
1
2
3
4
5
6
7
8
9
       +---------+
| Memory |
+----+----+
|
+--------+--------+
| | |
+--v--+ +--v--+ +--v--+
|CPU0| |CPU1| |CPU2|
+-----+ +-----+ +-----+
  • NUMA(非统一内存访问):每个CPU都有本地内存,访问速度较快,他们可以访问其他的内存,但延迟更高。所有内存都是共享的逻辑地址空间,但物理分布在多个节点中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
+-----------------------------------+
| NUMA Node 0 |
| +---------+ +--------------+ |
| | CPU0/1 | <-> | Local Memory | |
| +---------+ +--------------+ |
+-----------------------------------+
||
|| (NUMA interconnect)
||
+-----------------------------------+
| NUMA Node 1 |
| +---------+ +--------------+ |
| | CPU2/3 | <-> | Local Memory | |
| +---------+ +--------------+ |
+-----------------------------------+

Linux对NUMA的支持:Linux 是一个NUMA-aware操作系统。它在调度器和内存分配器中都有 NUMA 优化:

  • Linux 调度器会尝试将线程调度到“离其内存最近”的 CPU
  • 如果线程频繁访问远程内存,会触发自动迁移页

进程管理

0.你对Linux的进程管理了解多少

  • Linux内核中所有任务(包括线程和内核线程)都用 task_struct 表示,他包含进程的一些上下文信息比如打开的文件、内存描述符、状态、寄存器的备份…
  • 进程的创建和销毁:通过fork/clone系统调用创建,通过写时复制机制共享资源,提升创建进程的速度
  • 进程的状态:运行、就绪、可中断/不可中断睡眠、停止、僵尸
  • 进程的调度:内核中实现了多种sched_class,支持多种调度策略,每个任务的调度信息由sched_entity保存
  • 进程间的通信机制

1.进程和线程的区别是什么

特性 进程 (Process) 线程 (Thread)
基本定义 资源分配和拥有的基本单位 程序执行的基本单位(CPU调度的基本单位)
资源分配 每个进程都有独立的地址空间、数据栈、代码段、文件句柄等系统资源。 共享其所属进程的全部资源(如全局变量、堆、文件描述符)。拥有独立的栈和寄存器。
通信方式 IPC(进程间通信):复杂度高,开销大。例如:管道、消息队列、共享内存、信号、套接字(Socket)。 直接读写进程的全局数据:非常简单高效。但需要同步机制(如互斥锁、信号量)来避免竞态条件。
创建与开销 开销大。创建新进程(fork())需要复制父进程的地址空间、文件描述符表等,是重量级操作。 开销小。创建新线程(pthread_create())只需分配独立的栈和寄存器,是轻量级操作。
稳定性与隔离性 隔离性高,更健壮。一个进程崩溃不会影响其他进程,因为它们的地址空间是独立的。 隔离性低,稳定性差。一个线程崩溃(如非法内存访问)会导致整个进程及其所有其他线程一起崩溃。
切换开销 上下文切换开销大。需要切换页表、刷新TLB(快表)等。 上下文切换开销小。只需切换寄存器、栈等,地址空间不变。
性能 由于资源独立,并发编程更安全,但创建、通信、切换的成本更高。 由于资源共享,通信和数据共享极其高效,极大地提升了程序的并发性能。

2.PV操作是什么

3.无锁编程的方法

4.线程的同步和互斥怎么实现的

5.linux进程间通信方式有哪些?

通信方式 特点 适用场景
管道(Pipe) 单向通信,父子进程间使用(int fd[2]; pipe(fd); 命令行管道
命名管道(FIFO) 有文件名,无关进程可通过文件路径访问(mkfifo 持久化通信
信号(Signal) 异步通知(如 SIGKILLSIGUSR1),信息量有限 进程控制(如终止进程)
共享内存 最高效的方式,进程直接读写同一块内存(shmget/mmap 高频数据交换(如视频处理)
消息队列 内核维护的链表,进程通过消息类型收发数据(msgget/msgsnd/msgrcv 结构化数据传输
信号量 同步工具,控制对共享资源的访问(semget/semop 资源竞争管理(如数据库连接池)
套接字 跨网络或本机通信,支持TCP/UDP(socket()/bind()/listen() 分布式系统或本地进程通信
文件锁 通过 fcntlflock 对文件加锁 协调文件访问(如日志写入)

6.linux内核如何获取用户态进程pid?

对于一个进程,它处于用户态还是内核态,在内核眼里都是同一个task_struct,所以获取一个进程的pid跟它在用户态还是内核态无关

1
2
#include <linux/sched.h>
pid_t pid = current->pid; // 通过该宏定义可以获取当前进程的PID

7.linux内核空间和用户空间的通讯方式有哪些?

(1)系统调用(Syscall)

  • 最基础的方式,用户程序通过软中断(如 int 0x80syscall)进入内核态
  • 示例:read(), write(), open()

(2)文件接口(/proc, /sysfs, /dev)

  • /proc文件系统:内核暴露信息给用户空间(如 /proc/cpuinfo
  • /sysfs:用于设备驱动和内核对象管理(如 /sys/class/net/eth0
  • 设备文件(/dev):用户程序通过 ioctl() 与驱动交互(如 /dev/mem

(3)Netlink Socket

  • 面向网络的内核-用户通信,支持双向数据传输(如 iproute2 工具与内核网络子系统通信)
  • 示例:NETLINK_ROUTE(网络配置)、NETLINK_KOBJECT_UEVENT(设备热插拔事件)

(4)共享内存(mmap)

  • 用户空间通过 mmap() 映射内核内存(如 DMA 缓冲区),实现零拷贝高效通信
  • 示例:显卡驱动、高速数据采集

(5)信号(Signal)

  • 内核可向用户进程发送信号(如 SIGKILLSIGSEGV),但信息量有限

(6)BPF(eBPF)

  • 现代高性能内核-用户通信方式,允许用户空间向内核注入安全代码(如网络过滤、性能分析)

8.死锁怎么产生,产生的几大条件,怎么解决

定义:死锁是指两个或多个并发进程/线程在执行过程中,因争夺资源而陷入的一种相互等待的状态,若无外力干涉,它们都将无法继续推进

条件 描述 通俗比喻
互斥 资源一次只能被一个进程占用,其他进程如需访问必须等待 独木桥一次只能过一个人
不可剥夺 资源只能由持有它的进程自愿释放,不能被系统强制剥夺 不能强行把桥上的人推下去
占有并等待 一个进程至少持有一个资源,并在等待获取其他进程持有的额外资源 桥上的人(占有着桥的资源)在等对面的人让开(等待另一个资源)
循环等待 存在一个进程等待序列 {P1, P2, …, Pn},其中 P1 等待 P2 占有的资源,P2 等待 P3 占有的资源,…,Pn 等待 P1 占有的资源 我等你,你等他,他等我,形成一个循环

9.为什么说进程比线程开销大

10.fork()的作用是什么

  • 内核创建一个与父进程完全一样的子进程
  • 内核会为子进程分配一个新的task_struct但是大多数字段会和父进程一样

11.什么是Copy on Write Fork,哪些东西copy,哪些新建

  • 创建子进程时,子进程不会直接复制父进程的内存空间,而是创建一个新的page table后,复制父进程的所有PTE,并将父、子进程的所有PTE都标记为只读,之后对这些PTE写操作时,会触发page fault,重新分配物理页并更新PTE的权限
  • 作用:减少创建新进程时的开销

12.内核线程/进程与用户线程/进程有什么区别

  • 用户线程的page table同时包含用户空间和内核空间内存的映射,而内核线程的page table只有内核空间的映射
  • 内核线程的task_struct中的内存描述符mm为NULL,active_mm指向上一个任务的mm_struct

13.Linux内核中的同步机制

(1)进程上下文之间的同步机制

机制 描述 适用场景
互斥锁 睡眠锁,竞争失败时任务睡眠 长时间持有的临界区
自旋锁 忙等待锁,中断中可用 短暂占有临界区
原子操作 硬件保证的原子指令 简单变量操作
信号量 计数器,允许有限数量的任务进入 资源池管理
读写信号量 区分读写操作,允许多个读并发 读多写少的共享数据
完成量 等待一个操作完成 线程间同步等待
序列锁 写优先的锁,读操作可重试 写少读多,数据简单
RCU(Read-Copy-Update) 无锁读机制,写者复制更新 读多写少,性能要求高

(2)进程上下文和中断上下文之间的同步机制

机制 描述 注意事项
自旋锁 忙等待锁,中断中可用 中断中必须用 spin_lock_irqsave()
原子操作 硬件保证的原子指令 简单变量操作
中断禁用 本地中断禁用 保护短临界区

(3)中断上下文之间的同步机制

机制 描述 注意事项
中断禁用 唯一可靠方法 使用 local_irq_disable()
per-CPU变量 每个CPU有独立副本 避免共享

14.互斥锁和自旋锁能套着用吗

  • 可以,但是自旋锁必须放在内层
    • 自旋锁在内:自旋锁短且不睡眠,不影响外层互斥锁的睡眠特性
    • 互斥锁在内:互斥锁可能睡眠,但自旋锁已禁用抢占,导致系统冻结

进程调度

基本概念

调度策略

Linux 支持多种调度策略,主要分为实时调度和普通调度,不同调度策略的任务由不同调度器处理:

调度策略 说明 适用场景
SCHED_DEADLINE 实时调度,是按照 deadline 进行调度的,距离当前时间点最近的 deadline 的任务会被优先调度
SCHED_FIFO 实时调度,优先级高的任务先执行,同优先级的按照先来先到的顺序运行,无时间片,直到主动让出或阻塞 硬实时任务(如工业控制)
SCHED_RR 实时调度,类似 SCHED_FIFO,但同优先级任务按时间片轮转 需要公平性的实时任务
SCHED_NOARMAL 普通调度(默认),基于 CFS 动态分配时间片 普通进程(如桌面应用、后台服务)
SCHED_BATCH 类似 SCHED_OTHER,但优化批处理任务(减少抢占) 批处理任务(如编译)
SCHED_IDLE(已废弃) 最低优先级,仅在系统空闲时运行 低优先级后台任务

调度器

Linux内核通过以下数据结构抽象出了调度器基类:

1
2
3
4
5
6
7
8
9
10
struct sched_class {
const struct sched_class *next; // 下一个调度器类(链表结构)
void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);
void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
void (*yield_task) (struct rq *rq);
void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags);
struct task_struct *(*pick_next_task)(struct rq *rq);
void (*put_prev_task)(struct rq *rq, struct task_struct *p);
// ... 其他方法
};

并实现了许多具体的调度器实例:

调度器类 管理的调度策略 核心算法
rt_sched_class SCHED_FIFO, SCHED_RR 基于优先级的抢占式调度
fair_sched_class SCHED_OTHER, SCHED_BATCH CFS(完全公平调度)
idle_sched_class SCHED_IDLE 仅在 CPU 空闲时运行
image-20250831163008430

调度实体

调度的角度看每个任务的数据结构,封装进程的调度信息(如 vruntime、权重等),供调度类使用。一般是task_struct的==成员变量==

1
2
3
4
5
6
7
8
9
10
11
12
struct sched_entity {
struct load_weight load; // 权重(优先级相关)
struct rb_node run_node; // 在CFS红黑树中的节点
u64 vruntime; // CFS虚拟运行时间
// 其他字段(如组调度、统计信息等)
};

struct task_struct {
struct sched_entity se; // 普通进程的调度实体
struct sched_rt_entity rt; // 实时进程的调度实体(若为实时任务)
// ...
};

运行队列

每个CPU核心都维护了自己的运行队列,维护这个 CPU 上所有可运行任务。作为task_struct的成员变量,调度时,首先根据调度器的优先级选择对应的调度器,然后从他的任务队列选择一个进行切换

1
2
3
4
5
struct rq {
struct cfs_rq cfs; // CFS运行队列(存储普通进程)
struct rt_rq rt; // 实时运行队列(存储实时进程)
struct task_struct *curr; // 当前正在运行的进程
};

主调度器

是整个调度流程的统称,他其实是个函数schedule(),触发时机包括:

  • 时间片耗尽(定时器中断)
  • 进程阻塞或主动让出CPU(如sys_sched_yield)
  • 高优先级任务就绪(如实时任务唤醒)

Linux调度的实现

先看一下内核中有哪些数据结构以及他们是如何分布的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// include/linux/sched.h
struct task_struct {
/* 1. 基础调度策略 */
unsigned int policy; // 调度策略(SCHED_NORMAL=0, SCHED_FIFO=1, SCHED_RR=2, ...)
int static_prio; // 静态优先级(映射到 nice 值,范围 100~139,对应 nice -20~19)
int prio; // 动态优先级(可能因优先级继承或互斥锁提升)
int normal_prio; // 基于静态优先级和调度策略计算的标准优先级
unsigned int rt_priority; // 实时优先级(1~99,仅对 SCHED_FIFO/SCHED_RR 有效)

/* 2. 调度器类关联 */
const struct sched_class *sched_class; // 指向当前调度器类(fair_sched_class, rt_sched_class 等)
struct sched_entity se; // CFS 调度实体(用于 SCHED_NORMAL/SCHED_BATCH)
struct sched_rt_entity rt; // 实时调度实体(用于 SCHED_FIFO/SCHED_RR)
struct sched_dl_entity dl; // Deadline 调度实体(用于 SCHED_DEADLINE)

/* 3. 调度统计与状态 */
unsigned int sched_flags; // 调度标志(如 SCHED_FLAG_DL_OVERRUN)
u64 vruntime; // CFS 虚拟运行时间(纳秒级,在 sched_entity 内)
u64 exec_start; // 当前调度周期开始时间
u64 sum_exec_runtime; // 累计实际运行时间
// ... 其他字段
};

Linux的具体调度流程如下,其实非常简单,就是选择下一个任务然后切换上下文:

1
2
3
4
5
6
7
// kernel/sched/core.c
void schedule(void) {
struct task_struct *prev, *next;
prev = current; // 当前任务
next = pick_next_task(rq); // 选择下一个任务
context_switch(rq, prev, next); // 切换上下文
}

比较核心的函数是选择下一个任务,在该函数中,会依次遍历所有调度器的运行队列,来选择下一个任务到底是什么

1
2
3
4
5
6
7
8
9
10
11
12
// kernel/sched/core.c
struct task_struct *pick_next_task(struct rq *rq) {
const struct sched_class *class;
struct task_struct *p;

// 遍历调度器类(从最高优先级开始)
for_each_class(class) {
p = class->pick_next_task(rq);
if (p) return p; // 找到可运行任务
}
return NULL; // 无任务可运行(运行 idle 任务)
}

相关系统调用

image-20250813110954596

面试问题

1.进程调度有哪些方法

2.中断会引起线程的调度吗

3.什么时候会引起线程的调度

在 Linux 内核中,任务调度(进程调度)主要发生在以下时机,这些时机可以归纳为主动调度被动调度两类:

  • 主动调度:进程主动放弃 CPU,触发调度器选择下一个任务:

    • 系统调用中显式调用 schedule():比如进程调用 sleep()nanosleep()sched_yield() 等函数时,会主动让出 CPU

    • 阻塞操作:当进程等待资源(如文件 I/O、网络数据、信号量、锁等)时,会进入阻塞状态,内核会主动调用 schedule() 切换其他任务

    • cond_resched() 检查:在内核代码的某些长时间循环中(如文件系统或网络栈),会调用 cond_resched() 检查是否需要调度,避免长时间占用 CPU

  • 被动调度(抢占式调度)内核强制剥夺当前任务的 CPU 使用权:

    • 时间片耗尽:在 CFS中,当在定时器中断中发现进程的时间片用完时,调度器会被触发

    • 中断返回时:硬件中断或系统调用返回用户空间前,内核会检查 TIF_NEED_RESCHED 标志位。若被标记,则调用 schedule() 切换任务

    • 更高优先级任务就绪,例如:

      • 实时进程(RT 任务)被唤醒
      • 使用 wake_up_process() 唤醒的进程优先级高于当前进程
    • 内核抢占(Kernel Preemption)配置了 CONFIG_PREEMPT时,内核态任务也可能被抢占:

      • 在中断处理返回内核态时(非关键路径)
      • 显式调用 preempt_enable() 时检查抢占标志

4.抢占式进程调度要怎么实现

5.介绍下CFS调度算法

  • 完全公平调度算法的核心思想是让每个任务的运行时间基本一致,他给每个任务维护了一个虚拟运行时间vruntime变量,当作这个任务的运行时间。当CFS调度器选择下一个运行的任务时,会根据vruntime选择任务运行,尽可能的让所有任务的vruntime一致。vruntime并不是实际运行时间,而是结合任务的优先级、nice值和实际的运行时间计算的到的,优先级越大,相同实际运行时间对应的vruntime越低

7.CFS底层怎么实现的,用的什么数据结构,红黑树怎么取的,key和value是什么

1
2
3
4
5
struct cfs_rq {
struct rb_root_cached tasks_timeline; // 红黑树
u64 min_vruntime; // 当前队列最小 vruntime
u64 load; // 队列总负载
};
  • 内部通过维护红黑树来按 vruntime 排序的所有任务,每次取红黑树左下角的节点,即可得到vruntime最小的任务
  • pick_next_task、enqueue_task、dequeue_task 都是 O(log n)

8.介绍下Linux的进程调度模块

  • Linux内核调度机制的核心目标是公平高效地分配CPU资源,同时支持实时任务。它采用多级调度架构实现进程调度,主要组件包括主调度器(Core Scheduler)、调度类(Scheduling Classes)和运行队列(Runqueue),通过策略(如CFS、实时调度)和事件驱动(如中断、系统调用)实现动态调度

9.O(1)调度器的底层原理是什么(进程调度的时间复杂度不随任务数量改变)

  • O(1)调度器是CFS出来之前(Linux 2.6.x)的默认调度器,它pick_next_task、enqueue_task、dequeue_task 都是 O(1),但不能保证公平

  • Linux为每个CPU都维护了一个二维的就绪队列和一个bitmap,通过bitmap可以知道哪个优先级的就绪链表有任务,之后再从该级链表中取任务就行了

10.实时调度器类和CFS调度器有啥区别

  • 实时:高优先级任务绝对优先
  • CFS:所有任务按权重公平分配CPU时间,以vruntime作为调度的依据

驱动开发

1.谈一下你对Linux驱动子系统的认识

  • 总线-设备-驱动的统一模型:核心类、匹配过程
  • 设备树描述板级信息
  • 驱动的分层
  • 驱动开发的2种方式:编译成单独的模块 / 编译进内核

设备树

1.设备树的作用是什么

  • 描述板级信息,把硬件的详细信息从驱动中抽离出来,增加驱动的复用性

2.设备树和驱动匹配的详细流程

  • 设备树和驱动的匹配本质上是设备和驱动在同一条总线上通过bus_typematch()回调函数找到彼此,流程分为以下几步:
    • 设备树的硬件描述:首先设备树中使用compatible等属性对设备进行标识
    • 内核解析并创建设备对象:内核启动时,会解析DTB文件,将各个节点转换成struct device_node。并且根据所在总线类型(I2C、SPI、platform),转换成对应的 device 对象,例如 i2c_clientplatform_device,并存到对应总线维护的链表
    • 驱动注册并匹配:每次有新驱动注册到内核时,都会触发总线的match函数,遍历设备链表看看有没有和该驱动匹配的,如果有的话,就会调用驱动的probe函数
    • probe阶段:完成设备的初始化,比如向内核申请GPIO、中断等资源、注册字符设备…

3.probe函数触发条件

  • probe函数其实是bus_type的一个回调函数,当bus通过match成功匹配驱动和设备时,会调用该函数

4.设备和驱动匹配的方法有哪些

  • 设备树方式:节点的compatible属性和驱动的.of_matchtable字段一致
  • ACPI方式:x86架构常用
  • id_table:驱动的.id_table字段和设备对象里的名字(比如i2c_client->name)进行匹配
  • 名字方式:设备的 name(来自设备树节点名字里@前的部分)和驱动的 driver.name 一致

5.内核解析设备树节点的详细流程(父节点、子节点、address-cells/size-cells)

  • 设备树传递:Bootloader将DTB加载到RAM,并在启动内核时把地址传给内核
  • 内核早期解包:在内核启动初期(汇编阶段),会调用early_init_dt_scan()对DTB进行初步验证,读取其Magic Number,并获取一个指向根节点的全局指针
  • 在内核中构建设备树数据结构:之后内核会调用unflatten_device_tree()在内核中构建设备树,将设备树节点转化为struct device_node,每个属性对应struct property
  • 解析设备树的chosen/memory节点:
    • 解析 chosen 节点里的 bootargs(内核启动参数)
    • 解析 memory 节点里的物理内存大小和起始地址
  • 总线扫描并注册设备:当各个 bus(platform、i2c、spi、pci)初始化时,会调用相应的 of_xxx_populate()去扫描设备树下的子节点,把device_node转成设备对象并挂到总线上
    • platform 总线:of_platform_populate()of_platform_device_create_pdata() → 创建 platform_device
    • i2c 总线:of_i2c_register_devices() → 创建 i2c_client
    • spi 总线:of_register_spi_devices() → 创建 spi_device
  • 匹配和probe:当驱动注册时(或设备先注册时),总线match函数会拿device->of_node->compatible 对比 driver->of_match_table。如果匹配成功则会调用 probe()

6.内核加载DTB文件的详细流程

  • Bootloader 阶段:选定并加载 dtb 文件到内存,启动内核时把 dtb 地址通过寄存器传给内核
  • 内核启动早期:在 setup_arch() 中获取 dtb 地址,调用 early_init_dt_verify() 校验合法性,并在必要时 relocate
  • DTB 解析阶段:early_init_dt_scan_nodes() 解析 /chosen/memory,随后 of_flat_dt_scan() 遍历整个 dtb,构建 struct device_node 树,并处理父子关系、#address-cells / #size-cells 等信息。
  • 后续使用:平台代码和总线框架(platform、I2C、SPI 等)再根据这些 device_node 实例化出具体的 device,等待驱动匹配

7.对于先有设备后有驱动以及先有驱动后有设备,内核分别是怎么创建设备节点的

  • 先有设备:device节点是由设备树解析产生 → 进入 device 链表,等 driver 上来再匹配
  • 先有驱动:driver先挂到driver链表,等device节点被创建(热插拔/DT late add/硬件枚举等)再触发匹配

8.设备树里面主要的一些属性以及作用

  • 看之前的笔记,挺多的

9.一个驱动里面需要哪些设备树信息

  • 不同的设备不一样啊,至少得有用于和驱动匹配的一些属性比如compatible

10.说一下gpio驱动怎么写

  • 比如下面这个,需要GPIO控制器,这个pin在该控制器下的索引,有效电平
1
led-gpio = <&gpio1 3 GPIO_ACTIVE_LOW>;

驱动框架

1.struct file_operations有哪些操作函数

  • open/close/read/write/unlocked_ioctl/mmap…

2.字符设备驱动的注册流程是怎样的 / 如何写个字符设备的驱动

  • 向内核申请一个主设备号(静态指定或者内核动态分配)
  • 初始化一个struct cdev,主要是填充opsowner字段
  • 使用cdev_add添加cedv到内核
  • 创建设备节点文件(class_create()device_create()

3.主/次设备号的范围和作用

  • 主设备号:驱动类别,12位
  • 次设备号:同一驱动下的不同设备实例,20位

4.应用层的open/read/write/ioctl 是如何对应到驱动层函数的 / 调用流程

字符设备驱动 — 野火嵌入式Linux驱动开发实战指南——基于i.MX6ULL系列 文档:看4.5

  • 应用层的 open/read/write/ioctl 实际上是glibc的库函数,内部会发起系统调用,执行SVC/ecall之类的特权指令,触发异常
  • 用户空间使用open()系统调用函数打开一个字符设备时(int fd = open(“dev/xxx”, O_RDWR))大致有以下过程:
    • 内核在虚拟文件系统VFS中的查找对应与字符设备路径对应inode对象,获得主设备号
    • 遍历内核中的cdev_map哈希表,根据inod节点中的主设备号找到cdev对象
    • 创建并初始化struct file对象,将 struct file对象中的 file_operations成员指向 struct cdev对象中的 file_operations成员(file->fops = cdev->fops)
    • 回调file->fops->open函数
    • 返回内核中新创建的file对象在内核中的索引即fd,后续操作(如 read)可通过 file->private_data获取设备状态

5.驱动里如何实现阻塞式 read(比如等待按键按下才返回)

  • 使用内核提供的wait_envent机制(和用户态的条件变量很像),在没数据时阻塞此线程,数据准备好了再唤醒

6.知道platform总线设备驱动的特点和核心思想吗

  • platform设备驱动框架的核心思想是实现通过提出一种虚拟总线,所有的驱动开发都可以遵循”设备-驱动-总线“这种模型,实现驱动和设备的解耦。让Linux的驱动开发统一化,增加了驱动的可移植性

7.字符设备和块设备的区别

  • 数据的访问方式:字符设备以字节为单位顺序访问,不支持随机读写;块设备以块为单位进行数据的读写,且可以根据地址随机进行访问
  • 缓存机制:字符设备没有经过内核的页缓存层,read()等系统调用直接和驱动进行交互,结构比较简单;而内核会通过页缓存机制对块设备的I/O操作做写优化,比如缓存、合并从而提高效率
1
2
字符设备:用户空间 → VFS → 驱动 → 硬件
块设备: 用户空间 → VFS → 页缓存 → 块层(I/O 调度器)→ 驱动 → 硬件
  • 驱动框架:字符设备驱动使用file_operations注册提供给VFS回调的操作集合,而块设备使用block_device_operations结构体

8.能用字符设备驱动框架来访问块设备吗

  • 理论上是可行的,但是使用字符设备驱动框架来访问块设备的话会引入一些问题:
    • read()接口只能以字节为单位顺序的对块设备进行访问,除非自己再实现seek函数,这违背了块设备能随机读写的特性
    • 使用字符设备驱动框架的话,就没法使用页缓存、I/O调度器、文件系统等机制,效率会比较低

9.什么是I/O调度器

  • I/O 调度是 Linux 块层(block layer)中一个重要子系统,它负责对来自上层的多个磁盘读写请求进行排序、合并和优化调度,以提高磁盘访问效率、减少寻道时间,并保证系统的公平性与响应性

内核模块

1.为什么有了驱动注册的代码前面要加__init__exit宏?

  • __init宏定义会把被修饰的函数放到ELF文件的.init.text节区,内核启动的时候,会通过init_call机制自动执行该段的所有函数,并在执行完毕后释放内存

2.驱动的加载流程是什么

  • 驱动可以直接编译进内核(静态加载),也可以编译成一个内核模块(动态加载),2者的加载流程是有很大区别的:
  • 静态加载
    • 驱动中通过module_init()宏将驱动的初始化函数放到一个特定的内存节区(例如.initcall
    • 内核启动时,利用initcall机制,按特定顺序依次执行所有这些初始化函数
  • 动态加载
    • 用户空间调用insmodmodprobe命令,在命令中通过sys_init_module()系统调用陷入内核
    • 内核首先检查当前用户的权限并分配内存,然后将.ko文件的内容拷贝到内核,并解析ELF文件
    • 内核调用模块通过 module_init()宏注册的初始化函数

3.用户空间是如何在/dev目录下创建设备节点的

  • /dev 下的设备节点本质上是一个特殊文件,包含主次设备号,用来让用户空间访问内核驱动
  • 早期是通过 mknod 手动创建
  • 现代系统通常由 udev/mdev 自动完成:驱动里调用 class_createdevice_create 在 sysfs 里生成设备信息,udev守护进程监听到内核uevent热插拔事件后,自动在 /dev 下生成对应的设备节点

场景题

1.V4L2会向应用层提供设备节点,比如/dev/video0,假如有多个进程要同时需要/dev/video0的数据,该怎么处理

  • 独占模式:驱动中维护一个变量表示当前设备的打开状态,当设备被一个进程打开后,其他进程open时返回错误
  • 多句柄共享模式:每个进程都在内核创建自己的buffer queue,然后通过mmap映射
  • 用户空间共享:只有一个进程负责读取数据,通过进程间的通信机制比如共享内存传递给其他进程
  • 使用 v4l2loopback 创建虚拟设备,把同一份数据复制到多个虚拟节点,让多个进程各自打开

2.对于普通的字符设备,如果有多个进程操作,如何处理

  • 独占访问

    • 驱动在 open() 里检查是否已经被占用,如果是则返回 -EBUSY。常见于硬件资源有限
  • 多进程共享读写

    • 驱动内部做并发控制,例如加锁(spinlock/mutex),保证多进程同时访问时不会破坏硬件或数据

    • 例如串口驱动,允许多个进程 read()/write(),驱动里会维护接收缓冲区和发送缓冲区

  • 一个生产者、多消费者

    • 驱动可以设计一个 环形缓冲区,把硬件数据放进去,然后多个进程 read() 时各自消费

    • 或者像 input 子系统(键盘、触摸屏),内核本身提供 多播机制,保证每个进程都能收到事件

  • 用户空间转发

    • 如果驱动本身不支持多进程共享,可以像 V4L2 一样,在用户态做转发:一个进程独占设备,再用共享内存/Socket/管道把数据分发给其他进程

中断及异常管理

1.外部中断的实现原理

2.中断的概念

  • CPU在执行当前代码的时候被打断,转而去执行另一端代码(ISR)

3.用什么函数注册中断

4.在中断中要注意什么

  • 中断上下文中,不能睡眠

5.中断和异常有什么区别

  • 中断是CPU指令以外的事件引起的
  • 异常是由CPU指令引起的

6.多个中断同时发生怎么办

  • 控制器会根据中断优先级决定哪一个先送给 CPU

  • 其他中断被标记为挂起

  • CPU 响应当前优先级最高的中断后,控制器会继续把下一个挂起中断送到 CPU

7.中断上下文里面用什么方式实现资源共享

在中断上下文访问共享资源时,不能使用会睡眠的锁(如 mutex)常用方式有:

  • spinlock(多核 CPU,可加 irqsave 禁中断)

  • local_irq_disable / irq_save(单核 CPU 临界区)

  • atomic 原子操作(简单计数器或标志)

  • RCU / seqlock(读多写少数据结构

选择合适的方式可以保证 ISR 与底半部以及进程上下文安全地共享资源

8.上半部和下半部使用场景 / 为什么Linux的中断要分为上下半部

  • 降低中断延迟

    • 硬件中断会屏蔽同级或低级中断,如果 ISR 里做的事情太多,会延迟其他中断的响应

    • 分上下半部后,上半部硬中断上下文,执行快,只做关键的最小操作

  • 允许复杂操作

    • 硬中断上下文不能休眠(例如不能调用 schedule()、不能拿可能阻塞的锁),所以像内存分配、文件操作等都必须放到下半部
  • 提高系统吞吐量

    • 上半部快速返回,系统能及时响应更多硬件中断;下半部可以批量处理数据,减少频繁的中断开销

9.page fault处理流程是什么

下面以ARMv7A为例:

  • CPU访问一个page table中没有有效映射(PTE)的虚拟地址

  • MMU触发Data Abort异常,并分别保存出错的地址和原因到FAR和FSR寄存器

  • PC跳转到异常向量表中的Data Abort或Prefetch Abort入口,并进入内核的异常处理函数中

  • 在内核的do_page_fault()中进行缺页异常的核心处理流程

    • 首先检查地址是否合法:通过 find_vma(mm, addr) 在当前进程的内存描述符( mm_struct)中看是否能找到对应的VMA

      • 找不到:访问非法地址,给当前进程发SIGSEGV

      • 找到了但权限不匹配:给当前进程发SIGSEGV

      • 如果合法且权限匹配,则进入后续的步骤,调用vma->vm_ops进行缺页处理

    • 缺页处理:区分匿名页缺页 和 文件映射缺页

      • 匿名页(堆、栈):分配新的物理页 → 建立页表映射
      • 文件映射:触发 page cache 读入
    • 更新page table,刷新TLB

  • 返回用户态,继续执行

10.什么是中断上下文

  • 当CPU响应一个硬件中断时,内核所执行的中断处理程序(ISR)的运行环境

11.为什么中断上下文中不能进行阻塞/睡眠

  • 中断上下文不是一个进程,它没有属于自己的、可以被保存和恢复的任务结构(task_sturct),因此一旦阻塞(睡眠),就再也没有办法被唤醒并继续执行了

文件系统

1.你对Linux的文件系统了解多少

  • Linux内核的文件子系统主要分为以下2个模块:

    • 虚拟文件系统层:为了使用户层的应用程序能够兼容多种不同的文件系统,Linux内核实现了个虚拟文件系统(VFS),给应用层提供统一的文件操作接口(open/read/write/stat 等)通过四个核心对象super_blockinodedentryfile 把所有文件系统统一起来,并借助 page cache、dentry cache、inode cache 提升性能
    • 各具体文件系统的实现:Linux内核为不同的文件系统如ext4、nfs、ftfs…都实现了VFS的提供的接口
1
2
3
4
5
6
7
8
9
10
11
12
13
用户空间
├── open/read/write/close 等系统调用

VFS(虚拟文件系统)
├── inode、dentry、file、super_block 等通用接口

具体文件系统实现(ext4, FAT32, NFS, tmpfs, procfs...)

块设备层(block layer)

驱动层(设备驱动)

物理存储(SSD、eMMC、SD卡、NVMe、网络磁盘等)

VFS

Linux通过VFS把所有文件系统(ext4、fat、nfs、tmpfs…)统一起来,内部用四个核心对象来抽象一切“文件”

对象 作用 代表什么 生命周期
super_block 整个文件系统实例 一个挂载点(比如 //mnt/sdcard 文件系统挂载时创建
inode 文件本体 文件的元数据(权限、大小、时间、磁盘块号) 文件被访问时创建
dentry 目录项 文件名到 inode 的映射关系 路径解析时创建
file 打开的文件 进程打开文件的实例,包含偏移量、flag open() 时创建,close() 时销毁

super_block和inode的内容来自磁盘,内核会从磁盘读取具体数据再在内存中创建对象,而dentry和file仅存在于内存

某文件系统的磁盘物理布局:

image-20240811194430032

super_block

定义:super_block表示一个已挂载的文件系统实例。它描述了一个磁盘分区或内存文件系统的全局信息。每次 mount() 时都会创建一个 super_block对象

1
2
3
4
5
6
7
8
9
struct super_block {
struct list_head s_inodes; // 挂载文件系统上的 inode 链表
struct super_operations *s_op; // 文件系统操作函数表
unsigned long s_blocksize; // 文件系统块大小
struct dentry *s_root; // 根目录 dentry
struct file_system_type *s_type; // 文件系统类型
void *s_fs_info; // 文件系统私有信息(ext4_super_block等)
...
};

核心作用:

  • 管理该文件系统所有 inode
  • 保存文件系统级别的信息(块大小、挂载选项)
  • 提供文件系统操作函数(读写 inode、同步、回写等)

inode

定义:用于存储文件的属性(包含对应文件的类型、修改时间、文件内容在磁盘的位置、链接关系、权限等信息)每个文件都有唯一的inode,可以通过stat命令查看一个文件的inode信息

1
2
3
4
5
6
7
8
9
10
11
12
13
struct inode {
umode_t i_mode; // 文件类型 + 权限
unsigned long i_ino; // inode 编号
const struct inode_operations *i_op; // inode 操作表
const struct file_operations *i_fop; // 文件操作表
struct super_block *i_sb; // 所属 super_block
struct address_space *i_mapping;// page cache的映射
loff_t i_size; // 文件大小
struct timespec64 i_atime, i_mtime, i_ctime; // 时间戳
atomic_t i_count; // 内存引用计数(有多少 open() 或缓存引用)
atomic_t i_nlink; // 硬链接计数(有多少文件名指向它)
...
};

核心作用:

  • 存储文件的属性:权限、大小、时间、类型
  • 指向文件的数据块(或 page cache)
  • 提供对文件内容和元数据的操作接口
  • 管理文件引用计数
image-20231212110910871

dentry

定义:dentry(目录项)用来形成文件系统的路径层级结构,并保存了文件名和inode的映射关系

1
2
3
4
5
6
7
8
9
struct dentry {
struct inode *d_inode; // 指向对应 inode
struct dentry *d_parent; // 父目录 dentry
struct qstr d_name; // 文件名(字符串)
struct list_head d_subdirs; // 子目录链表
struct super_block *d_sb; // 所属文件系统
struct dentry_operations *d_op; // 操作表
atomic_t d_count; // 引用计数
};

核心作用:

  • 负责路径解析
  • 加速路径缓存:Linux 为了加速路径查找,维护了一个全局 dentry cache,每个访问过的路径会被缓存为 dentry,下次查找同一路径不必访问磁盘
  • 维持目录树关系
  • 提供名字查找与比较函数

file

定义:file 表示进程打开的文件实例。每调用一次 open(),内核就创建一个新的 struct file。即使多个进程打开同一个 inode,它们也拥有不同的 file 对象

1
2
3
4
5
6
7
8
9
struct file {
const struct file_operations *f_op; // 文件操作接口
struct dentry *f_path.dentry; // 指向的 dentry
struct inode *f_inode; // 对应的 inode
loff_t f_pos; // 文件偏移量
unsigned int f_flags; // 打开模式(O_RDONLY等)
struct address_space *f_mapping; // 页缓存映射
...
};

核心作用:

  • 保存当前进程打开文件的状态
  • 记录文件偏移(读写位置)
  • 提供具体操作函数表(read, write, mmap…)
  • 和进程的 fd(文件描述符)表绑定

磁盘布局

不同文件系统的磁盘布局都不同,但基本都会有super_block和inode等数据,下面以EXT4文件系统来分析一下

Ext4将磁盘划分为多个块组(Block Groups),每个块组包含:

  • Superblock:存储文件系统的全局信息。如块大小、inode总数、空闲块数…
  • Group Descriptor Table:描述每个块组的元信息。如块位图位置、inode 表位置、inode 位图位置等
  • Inode Bitmap:标记哪些 inode 已被使用(1)或空闲(0)1 bit 对应 1 个node
  • Inode Table:存储inode的一个容器,每个 inode 对应一个文件或目录
  • Block Bitmap:标记块组中哪些数据块已被使用(1)或空闲(0)1 bit 对应 1 个块
  • Data Blocks:存储实际的文件数据或目录结构
1
2
3
4
5
6
7
8
9
10
+-------------------+-------------------+-----+-------------------+
| Block Group 0 | Block Group 1 | ... | Block Group N |
+-------------------+-------------------+-----+-------------------+
| Superblock | | | |
| Group Descriptors | | | |
| Block Bitmap | | | |
| Inode Bitmap | | | |
| Inode Table | | | |
| Data Blocks | | | |
+-------------------+-------------------+-----+-------------------+

面试问题

1.文件打开的详细流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
用户态:
open("/home/user/a.txt")

内核态:
├─ sys_openat() // 对应的系统调用
│ └─ do_sys_open() // 分配文件描述符
│ ├─ get_unused_fd_flags()
│ ├─ do_filp_open()
│ │ └─ path_openat() // 完成路径解析, 逐级查找dentry和inode
│ │ ├─ link_path_walk() → lookup dentry/inode
│ │ └─ vfs_open()
│ │ └─ f_op->open() (ext4 / driver) // 调用底层文件系统/驱动的回调
│ └─ fd_install(fd, file) //把file和进程的fd table绑定
└─ return fd

2.文件删除的流程

  • 使用rm删除一个文件时,会调用unlink这个系统调用,它的语义是从目录中移除“文件名 → inode”的映射关系(即删除目录项),但==不一定立即删除文件数据==
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
用户态: unlink("file.txt")

sys_unlinkat() // 系统调用入口

do_unlinkat()

vfs_unlink(dir_inode, dentry, mnt)

dir_inode->i_op->unlink(dir_inode, dentry) // 调用具体文件系统实现 (如 ext4_unlink)

drop_nlink(inode) // inode 链接计数 -1

mark_inode_dirty(inode) // 标记 inode 需要同步到磁盘

dentry_unlink_inode() // 解除 dentry <-> inode 关系

if (i_nlink == 0 && no open file)
evict(inode) // 释放 inode + 数据块

3.Linux为什么打开一个文件之后再删除它,不会影响当前已打开的文件,Windows却不行

  • inode有2个字段:i_nlinki_count,即使unlink()i_nlink为0了,由于还有进程打开了该文件,所以对应的i_count不为0,只有当2个字段同时为0时,OS才会删除文件

4.文件read的详细流程

  • 通过系统调用进入内核,通过 VFS 找到对应文件系统/驱动的.read()回调函数,文件系统层先查找页缓存,如果缓存中已有数据则直接拷贝给用户,若缓存未命中,则通过 submit_bio() 发起块 I/O 请求,由通用块层和 I/O 调度器处理这个请求,向块设备驱动发起读操作,由块设备驱动执行实际的磁盘读取数据并通过 DMA 写入页缓存,随后再复制到用户缓冲区
    • 用户进程层 → read(fd, buf, size)
    • 系统调用层 → sys_read()vfs_read()
    • VFS 层 → file->f_op->read
    • 文件系统层 → generic_file_read_iter() / do_generic_file_read()
    • 页缓存层 → 命中:copy_to_user();未命中:发起 I/O 请求
    • 通用块层 → submit_bio()blk_queue_bio() → I/O 调度器
    • 块设备驱动层 → request_fn() / make_request_fn() → DMA
    • 硬件层 → 磁盘控制器执行读操作
    • 回传路径 → bio_endio() → 页缓存回填 → copy_to_user()

5.文件write的详细流程

  • write()不会立即写入磁盘,只会写入page cache并标记脏页,只有通过fsync之列的写回机制后,才会将数据写入磁盘
阶段 函数 关键动作 层级
① 用户态 write() 发起系统调用 用户空间
② 内核入口 sys_write() 切换到内核 内核 syscall 层
③ VFS 层 vfs_write() 查找 file、调度文件系统 虚拟文件系统
④ 文件系统层 generic_file_write_iter() 将数据从用户空间拷贝到页缓存、标脏 文件系统通用层
⑤ 缓存管理 set_page_dirty() 延迟写回标记 页缓存层
⑥ 回写线程 wb_writeback() 异步落盘 writeback 子系统
⑦ 块层 submit_bio() 发出磁盘请求 块 I/O 层
⑧ 驱动 驱动程序 DMA → 控制器 → 盘 驱动层/硬件

6.Linux中文件的类型

  • Linux中一切皆文件,不管是控制硬件设备,还是网络,均是对文件操作。Linux中文件大致分为7类,具体属于什么类型由inode中的对应字段存储:
    • (1)普通文件:主要包括文本文件和二进制文件,是一般意义上的文件,数据都存在磁盘中,文件内容以字节为单位存储,并且可以访问

    • (2)目录文件:存一个目录结构的文件,目录中每一个元素被称为目录项。目录文件本身用struct dirent存储

    • (3)符号链接文件

    • (4)管道文件:通过pipe()系统调用创建的内存中的一个缓冲区,主要用于进程间通信

    • (5)套接字文件:用于网络通信

    • (6)字符设备文件:描述字符设备的文件,在/dev下,它是由文件系统虚拟出来的,存储于内存之中

    • (7)块设备文件:描述块设备的文件,也在虚拟文件系统中

7.路径是咋解析的

  • 从当前目录或者根目录开始,以/为分隔符,一层一层解析,每层对应一个dentry
  • 以解析/home/user/a.txt为例
    • 本质上是分别查找//home/home/user/home/user/a.txt的inode,解析每一个inode前都需要先创建一个dentry,所以会从dentry("/")创建到dentry("/home/user/a.txt")

8.硬链接和软链接的区别

对比项 硬链接 软链接
本质 多个dentry指向同一个inode 一个独立的文件,内容是目标路径字符串
inode 相同 inode 号 拥有自己的 inode
数据是否共享 完全共享(同一文件) 不共享(指向目标路径)
删除原文件影响 不影响(因为还有其他链接) 目标不存在则变“死链”
可跨文件系统 不可(inode 号只在同一文件系统内有效) 可以(路径字符串可指向任意挂载点)
对目录使用 一般禁止(防止环) 可对目录创建链接
类型识别 普通文件(- 特殊文件(l
inode 链接计数 增加(i_nlink++ 不影响目标文件
创建命令 ln <source> <target> ln -s <source> <target>

9.文件什么时候写回磁盘

  • 当用户调用 write() 时,数据通常不会立刻写入磁盘,而是先写入内存中的page cache

  • 真正写回磁盘的时机有三类:

    • 主动触发(应用调用 fsync() / sync() / msync()

    • 内核后台线程定期回写(pdflush / flush-* / wb_workqueue

      • 被修改的文件对应的page和inode都会被标记为dirty,后台线程定期把脏页回写
    • 被动触发(缓存回收、内存压力、文件关闭、文件删除等)

10.inode的生命周期是怎样的

  • inode 对象在内存中并不是一直存在的。它只有在文件系统挂载或访问文件时,才会由内核动态创建
  • 当文件第一次被访问时,VFS 调用文件系统的 lookup(),通过 iget() 从磁盘 inode 表读取对应的元数据,并在内存中创建一个 struct inode 缓存。若 inode 已在缓存中(icache 命中),则直接复用。新建文件时则调用 new_inode() 创建新的 inode 并写入磁盘
  • i_counti_nlink为 0 时,iput() 触发回收并释放该 inode

杂项

1.为什么中断中不能睡眠

  • 中断上下文无法保存:中断没有自己的task_struct,如果睡眠则会发生任务调度,无法从其他任务再切换回中断
  • 破坏实时性:中断要求快速响应,睡眠会导致不可预测的延迟

2.堆和栈的区别是什么

特性 堆(Heap) 栈(Stack)
管理方式 手动申请/释放(malloc/free 自动分配/释放(函数调用时)
连续性 动态增长,地址不连续(碎片化) 连续内存,大小固定(可能溢出)
速度 慢(需系统调用和复杂管理) 快(仅移动栈指针)
作用域 全局可见,需手动释放 局部变量,函数退出自动销毁
典型问题 内存泄漏、碎片化 栈溢出(如递归过深)

3.进程的上下文具体指什么

  • 从硬件的角度看:CPU中的各个寄存器(PC、SP、MMU基址)
  • 从软件的角度看:
    • 虚拟地址空间
    • 打开的文件
    • PCB

4.锁的类型有哪些

锁类型 特点 适用场景
自旋锁 忙等待(不睡眠),适用于短临界区 多核、中断上下文
睡眠锁 睡眠等待,避免CPU浪费 长临界区、进程上下文
读写锁 允许多读/单写,提高并发性 读多写少(如配置文件)

5.系统调用用户态到内核态会发生什么

6.用户态堆栈在系统调用时会发生什么变化吗

特性 用户态栈 用户态堆
系统调用时是否变化 不变,syscall时会切换至内核栈 一般不变,除非显式调用 brk/mmap
内核访问方式 完全隔离(内核用独立栈) 可通过安全接口(如 copy_from_user)访问

7.什么是用户态、什么是内核态

程序运行在用户空间就是用户态,运行在内核空间就是内核态。

  • 从硬件角度来看,用户态CPU处于低特权等级,只能操作部分寄存器,不能访问硬件。而内核态CPU处于高特权等级,可以操作所有寄存器并访问硬件
  • 从出错危害性来看,用户态如果一个程序崩溃了,不会影响别的程序,而内核态崩溃了,可能OS就崩溃了,只能重启系统

8.FreeRTOS和Linux的主要区别是什么

FreeRTOS Linux
本质与目标 实时操作系统(RTOS) 核心目标是确定性实时性,保证任务在严格的时间限制内完成 通用操作系统(GPOS) 核心目标是吞吐量公平性,公平地为所有应用程序分配资源
内核架构 微内核(Microkernel) 内核只提供最核心的功能(任务调度、IPC、内存管理),其他功能(如网络栈、文件系统)作为可选组件在用户任务中实现 宏内核(Monolithic Kernel) 内核庞大而完整,将文件系统、网络协议栈、设备驱动等大量功能都集成在内核空间中,性能高但复杂度也高
应用场景 深度嵌入式、实时控制领域 航空航天、工业自动化、医疗器械、汽车电子(如ECU)、家电、IoT设备等运行在资源极度受限的微控制器(MCU)上,如STM32,ESP32,Cortex-M系列 应用处理器、复杂系统 服务器、桌面电脑、智能手机、路由器、智能电视、功能复杂的嵌入式设备(如机器人、自动驾驶域控制器)运行在资源丰富的微处理器(MPU)上,如Cortex-A系列,x86
硬件资源需求 极低 ROM: 几KB ~ 几十KB RAM: 几百字节即可运行,通常几KB~几十KB CPU: 无MMU要求,8位、16位、32位MCU均可 很高 ROM: 至少几MB(精简版),通常几十MB到数GB RAM: 至少几MB(精简版),通常上百MB到数GB CPU: 通常需要带MMU(内存管理单元)的MPU
调度策略 优先级抢占式调度,支持时间片轮转。高优先级任务就绪时,会立即抢占低优先级任务响应时间是微秒(µs)级,确定性极高(硬实时) 完全公平调度(CFS)等,旨在让所有进程公平地分享CPU时间,优化整体系统吞吐量响应时间是毫秒(ms)级,虽然可以通过打PREEMPT-RT补丁提升实时性,但本质仍是软实时
内存管理 简单 通常只提供简单的堆内存分配(pvPortMalloc / vPortFree),开发者需要根据芯片RAM大小自行配置,无虚拟内存,直接访问物理地址 复杂且安全 使用虚拟内存管理,每个进程都有独立的、受保护的地址空间。这提高了系统的稳定性和安全性,一个进程崩溃不会导致整个系统崩溃