影石 嵌入式软件开发二面:内核态内存管理与进程线程 13 问
- 轮次
- 二面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 你刚才讲的某某某项目,最大的技术难点是什么?
- 系统调用执行时,内核如何检查用户传入的指针是否合法?copy_from_user 和 copy_to_user 在其中起什么作用?
- 中断、异常和系统调用分别由什么事件触发?它们进入内核后的保存现场与返回流程有什么不同?
- 同一进程内的线程共享地址空间,为什么每个线程仍然需要独立的栈和寄存器上下文?
- 创建新进程时,内核哪些数据结构会被复制,哪些会被共享?execve 之后原地址空间如何处理?
- 匿名管道、命名管道和 Unix 域套接字在生命周期、通信方向、跨进程能力和使用场景上有什么区别?
- 系统里出现大量僵尸进程,通常说明代码存在什么问题?如何定位并清理?
- MMU、页表和 TLB 如何配合完成地址转换?进程切换时 TLB 为什么要刷新或做标记?
- 为什么大块内存分配常走 mmap,小块分配常走 brk?这两种方式对内存回收和碎片有什么影响?
- 写时复制只发生在 fork 时吗?还有哪些场景会触发 COW?它如何影响物理内存统计?
- 缺页异常分为哪些类型?内核如何判断是正常按需调页、写时复制,还是非法访问?
- 对影石有什么了解?
- 未来 3 年的规划?
《参考解析》
这份面经的定位。 题目密度高、全部落在操作系统内核这条线上(用户态指针校验、中断/异常/系统调用三条入口、进程与线程、IPC、僵尸进程、地址翻译、内存分配、COW、缺页),没有算法题也没有语言八股。这类面试考察的是「你写驱动、调内核参数时脑子里有没有一张真实的地址空间和调用链的图」,答的时候尽量落到具体的结构体、寄存器和系统调用名上,不要停在「大概是这样」的层面。
1. copy_from_user / copy_to_user 怎么做指针合法性检查。 分两道关:
- 范围检查——
access_ok(addr, size)判断这段用户地址是否整体落在用户地址空间内(低于TASK_SIZE,且addr + size不溢出、不越进内核空间)。这一步只做算术判断,不碰内存,所以不会触发缺页。 - 真实拷贝时的容错——
copy_from_user在拷贝过程中如果发现某页不在内存,会走缺页处理把它换进来;如果这条 vma 根本没有覆盖该地址(非法地址),拷贝会失败并返回未拷贝的字节数,内核侧据此返回-EFAULT而不是让内核崩溃。
它和 memcpy 的本质区别就在这:memcpy 直接解引用用户指针,一旦地址非法,故障发生在内核态,内核没法安全地「失败返回」,只能 oops。所以内核绝不直接解引用用户指针。现代 CPU 上还有 SMAP/SMEP 这类硬件辅助,会把内核态直接访问用户页拦下来,进一步逼着代码走 copy_*_user。
2. 中断、异常、系统调用三条入口的差异。
| 触发源 | 时机 | 同步性 | |
|---|---|---|---|
| 中断 | 外部硬件(网卡、定时器、外设) | 任意指令边界 | 异步 |
| 异常 | CPU 执行当前指令本身出错(缺页、除零、非法指令、断点、调试) | 绑定在具体指令上 | 同步 |
| 系统调用 | 用户程序主动执行 syscall / int 0x80 / svc 指令 | 由程序自己发起 | 同步、可控 |
进入内核的共同动作是:换到内核栈、保存现场(通用寄存器、返回地址、标志位,通常组成 pt_regs)、按向量跳到对应入口。差异在于:
- 异常需要额外保存出错信息——比如缺页要把出错地址从
CR2读出来,还要看错误码判断是「页不存在」还是「权限不足」,这决定了后面是补页、做 COW,还是给进程投SIGSEGV。 - 中断要考虑可屏蔽与嵌套——关中断窗口、中断优先级、下半部(softirq/tasklet/workqueue)把耗时工作挪出硬中断上下文,这是「保存现场」之外中断独有的复杂度。
- 系统调用的进入是约定好的 ABI——调用号放哪个寄存器、参数怎么排、返回值怎么回,都由体系结构 ABI 规定,所以现场保存的格式是固定的。
返回流程:中断和系统调用基本是「恢复现场 + 返回原指令的下一条」;异常则可能要在返回前做修复动作(补页成功后重执行原指令、或者投递信号),这就是「可恢复异常」和「不可恢复异常」的分界。
3. 线程为什么仍然需要独立的栈和寄存器上下文。 「共享地址空间」说的是页表映射这一层共享——代码段、数据段、堆、文件描述符表都是同一份。但栈和寄存器是执行流的状态,不是地址空间的属性:
- 栈承载的是当前这条调用链的局部变量、返回地址和参数。两条执行流如果共用一个栈,函数调用会把彼此的栈帧互相覆盖,一返回就跳到错误的地址,直接崩。
- 寄存器上下文(PC、SP、通用寄存器)是 CPU 在该时刻执行状态的全部快照。线程切换的本质就是把当前这组寄存器存进 TCB、把目标线程的寄存器装回 CPU。
所以线程切换比进程切换便宜的地方在于页表不用换(TLB 不用刷),而不是「状态不用保存」。
4. fork 复制什么、共享什么;execve 之后地址空间怎么处理。
- fork 复制的:
task_struct(新的一份,改掉 pid/ppid、挂到调度器)、新的内核栈、新的mm_struct与页表(页表项先指向同一批物理页并全部标成只读,靠 COW 延迟复制)、文件描述符表(表本身复制,表项指向的file对象共享,引用计数 +1)、信号处置设置、各种命名空间的引用计数 +1。 - fork 共享的:
file对象与文件偏移(所以父子进程读同一个 fd 会互相影响偏移)、物理页(写之前)、代码段与只读映射。 - execve 之后:旧的
mm_struct被整个抛弃——旧的 vma 链表、页表、物理页全部释放(属于别人的共享页只减引用计数),然后按新 ELF 重建:映射新的代码段/数据段、新建堆和栈、把参数和环境变量放到新栈上、跳转到 ELF 入口。 - 两个容易被追问的细节:文件描述符默认保留(想关掉要在 fd 上设
FD_CLOEXEC,或者用O_CLOEXEC打开),这条经常是泄漏 fd 的真实原因;被捕获的信号处理函数会重置成默认动作(SIG_IGN会保留),因为旧的函数地址在新地址空间里已经没有意义了。
5. 三种 IPC 的对比。
| 匿名管道 | 命名管道(FIFO) | Unix 域套接字 | |
|---|---|---|---|
| 生命周期 | 只存在于内核,最后一个 fd 关闭即消失 | 文件系统里留一个路径,open 才建立连接 | 绑定到一个路径(或抽象命名空间),随进程退出一般消失(路径要自己 unlink) |
| 方向 | 半双工(单向),双向要开两条 | 半双工,同样要两条 | 可以双向(SOCK_STREAM / SOCK_SEQPACKET),也可以 SOCK_DGRAM |
| 跨进程能力 | 只能靠 fork 继承 fd,没有亲缘关系就用不了 | 任意进程只要知道路径即可 | 任意进程,且支持传递文件描述符和对端凭证(uid/gid/pid) |
| 典型场景 | shell 里的 |、父子和解 | 两个不相关进程之间传通知 | 本机 RPC、容器与 daemon 通信(Docker、systemd 都大量使用) |
一句话概括取舍:匿名管道最轻但要求亲缘,FIFO 解决了路径问题但仍是单向字节流,Unix 域套接字最重但双向、可靠、还能传 fd 和凭证。
6. 大量僵尸进程说明什么,怎么定位和清理。 僵尸(Z / <defunct>)的本质是:子进程已经退出,但父进程没有调用 wait / waitpid 回收,内核就只能留着它的 task_struct(主要是退出码和统计信息)占着一个 PID。
出现大量僵尸说明代码层面是这几类问题之一:父进程根本没有回收逻辑(常见于只 fork 不 wait 的守护进程);信号处理不当——SIGCHLD 被设成了 SIG_IGN 之外的处理方式却没在 handler 里循环 wait,或者 handler 里用了 wait 而不是 waitpid(-1, ..., WNOHANG) 导致每次只收一个;父进程自己卡死或阻塞,收不了;以及 fork 之后异常分支忘了 wait。
定位:ps -ef | grep defunct 看有多少、它们的 PPID 是谁,ps -o ppid= 批量统计父 PID,锁定「哪个父进程在生不养」。清理:正解是修父进程的回收逻辑(注册 SIGCHLD,handler 里用 waitpid(-1, &status, WNOHANG) 循环收,注意 EINTR 和信号合并);如果是线上救火,杀掉父进程会让孤儿被 init 或 systemd 收养并自动回收,但那会连带终止父进程的业务,只能算临时手段——僵尸本身占的资源很少(一个 PID 加一点内核内存),真正的风险是 PID 耗尽。
7. MMU、页表、TLB 的配合,以及进程切换为什么要刷 TLB。 MMU 在每次访存时把虚拟地址按页表逐级翻译成物理地址,页表存在内存里、层级越来越深(x86-64 四级/五级),所以每次访存都走一遍页表代价极大。TLB 就是 MMU 旁边的翻译结果缓存(虚拟页号 → 物理页号),命中就不用查页表。
进程切换时要修改 CR3 指向新进程的页表基址,此时 TLB 里缓存的旧进程翻译对新进程完全无效——如果不清掉,新进程访存会拿到别的进程的物理页,属于严重的安全与正确性问题。两种做法:
- 整表刷新——简单,但每次上下文切换后都要经历一段 TLB 冷启动,对切换频繁的负载代价很大。
- 打标记——给每条 TLB 项带上地址空间标识(x86 的 PCID、ARM 的 ASID),切换时只换当前 ASID,属于旧进程的项留着继续有效,避免全刷。
顺带一个常被追问的点:同一个进程内的线程切换不需要换页表,所以也不用刷 TLB,这才是线程切换便宜的主要原因。
8. 大块走 mmap、小块走 brk 的原因。 brk 是把堆顶指针往上推,得到的是一段连续的堆空间;mmap 是申请一段独立的映射区。取舍在两处:
- 回收粒度——
brk只能整体回缩堆顶,如果被释放的小块夹在中间(有空洞),内核无法把内存还给系统,堆会越撑越大、碎在用户态;mmap可以munmap任意一段,直接归还内核,对碎片天然友好。 - 成本——
brk不产生新的 vma,开销小;mmap每次都要建 vma、改页表,还有一次系统调用,小块频繁走它不划算。
所以 glibc 的 malloc 用一条阈值(M_MMAP_THRESHOLD,默认 128 KB)分流:小于阈值的走堆(brk),大于的直接 mmap 并在 free 时 munmap。可以在运行时用 mallopt / M_MMAP_THRESHOLD 调这条线。
9. COW 不只发生在 fork。 凡是有「私有映射 + 多处共享同一份物理页」的组合,都可能用 COW 延迟复制:
fork(最典型);mmap的MAP_PRIVATE映射——父子或前后两次映射共享同一份文件页,谁写谁触发复制(比如动态库的私有段、只读代码页被写);- 进程加载时的只读段与私有匿名映射;
- **KSM(内核同页合并)**把内容相同的匿名页合并成一份,之后任一方写也会触发 COW 拆开;
- 某些
reflink/ 快照(btrfs、XFS reflink)在文件层面做的是同一件事。
对内存统计的影响是个真实考点:COW 共享期间,同一块物理页会被多个进程分别计入各自的 RSS,于是「所有进程 RSS 之和」会明显大于实际物理内存占用。所以看真实占用要用 PSS(把共享页按共享进程数均摊),/proc/<pid>/smaps 或 smem 里能拿到。另外「第一次写触发缺页」意味着 COW 的成本是推迟且分散的,压测时如果只测读不测写,很容易低估峰值内存。
10. 缺页异常的分类与内核判定。
- 次要缺页(minor fault)——页已经在内存里,缺的只是页表项。典型是 COW 复制、页缓存命中、进程栈自动增长、
mmap后第一次访问已缓存的文件页。只改页表,不读盘,很快。 - 主要缺页(major fault)——页不在内存,必须读磁盘(或换页进来)。这是真的慢,是性能分析里要盯的指标(
/proc/vmstat的pgmajfault)。 - 非法访问——地址不在任何 vma 里,或者 vma 存在但权限不符(写只读段)。这不算「缺页处理」,是要投递
SIGSEGV的。
内核的判定路径大致是:先按出错地址在当前进程的 vma 红黑树里查有没有覆盖这个地址的合法区域——查不到直接判非法;查到了再看 PTE:PTE 为空就是「页不在」,需要调页(此时根据 vma 是否是文件映射、有无页缓存,决定走 minor 还是 major);PTE 存在但被标成只读而本次是写操作,就是 COW,分配新页、拷贝内容、改成本进程私有可写、重执行原指令。
11. 两道开放题(了解公司、三年规划)。 「对影石有什么了解」要能说出具体的东西而不是形容词:影石做的是全景相机与运动影像硬件,产品线覆盖消费级到专业级,涉及图像/视频算法、嵌入式 BSP、无线连接、App/云等多条技术线;面试前把官网产品线、最近的发布或技术分享过一遍,再挑一条和自己岗位相关的技术点说,比如嵌入式方向可以谈固件升级、功耗与散热、传感器数据链路。「未来三年规划」要落在岗位和能力的成长路径上(先把某一块技术做深、独立负责一个模块、再往系统级或带小团队走),避免「三年后要当管理者」这类偏题答案,也避免空泛的「提升自己」。
面试节奏上的一个提示。 这份面经的作者反馈「流程推得很快、薪资有竞争力」,说明影石的面试官更看重基础链条是否打通而不是八股背诵量。这类内核连环问的应对方式是:每道题先给出结论 + 关键结构体/寄存器名,再补一两句工程含义(为什么要这么设计、错了会怎样),比堆细节更容易拿分;答不上来的部分明确说「这块我只到知道结论的程度」,比硬编更安全。