1.2 内核空间与用户空间的分界线


1.2 内核空间与用户空间的分界线

本节摘要:内核空间与用户空间的隔离机制是驱动开发一切约束的来源。本节讲清两个世界的地址划分、特权级、上下文差异,以及驱动为何能同时活在两边——理解这条线,才能理解驱动代码里那些"不能睡眠、不能信任指针"的规矩从何而来。

一个反例值得先看:应用程序里解引用一个空指针,进程崩了,系统照常运行,内核还会贴心地打印一条段错误记录;同样的空指针出现在驱动代码里,整块板子瞬间失去响应,串口终端上只剩一屏寄存器转储。同样的错误,代价天差地别——差别就在两个世界的隔离设计上。

同一颗 CPU 的两种身份

现代处理器都有特权级机制:指令分"普通"与"特权"两档,访问硬件、修改页表这类敏感操作只允许在特权态执行。操作系统借此把整个内存空间切成两半:

  • 用户空间:应用程序运行的区域,只能执行普通指令,直接访问硬件会被处理器拒绝。
  • 内核空间:内核与驱动代码运行的区域,特权态执行,可以访问所有内存与硬件端口。

以 32 位 ARM 平台为例,典型的地址划分是低 3GB 归用户、高 1GB 归内核;64 位平台划分方式不同,但"一刀两断"的思想一致。每个进程都有自己独立的用户空间,而内核空间全系统只有一份——这引出了一个重要事实:驱动代码同时服务所有进程,天然是全局共享的

/* 用户态:崩溃只是进程的葬礼 */ int *p = NULL; *p = 42; /* 本进程收到段错误,邻居进程毫无察觉 */ /* 内核态:崩溃是全系统的停电 */ int *k = NULL; *k = 42; /* 页面错误发生在内核态,系统停摆 */

两个世界的运行规矩

跨过分界线后,规则完全换了一套。驱动开发者最需要适应的有四条。

第一,执行上下文分两类。驱动代码可能在进程上下文运行——由用户系统调用触发,此时背后有一个具体的进程,允许睡眠等待;也可能在中断上下文运行——由硬件事件触发,背后没有进程,严禁睡眠。在中断上下文里调用一个可能睡眠的函数,轻则警告,重则死锁。这条规矩的机理在第 3 章中断部分展开。

第二,用户指针必须经检查后使用。上一节已经见过:跨边界拷贝要用专用函数,它们会校验页面可访问性并在失败时返回错误码,而不是让内核跟着崩溃。

第三,内核栈小得可怜。用户进程的栈通常以兆字节计,内核栈在多数配置下只有十几KB。在驱动里定义一个巨大的局部数组,栈溢出几乎是必然——这是新手驱动最常见的崩溃原因之一,大缓冲应改用动态分配。

第四,一次崩溃没有重启机会。应用可以靠看门狗进程拉起,驱动崩溃意味着内核崩溃。所以驱动代码的防御性标准要远高于应用:每个可能失败的路径都要有出口,每个资源都要有回收安排。

用户态与内核态生存条件对照

用户态与内核态生存条件对照

驱动如何跨线服务

既然隔离如此严格,用户态怎么使唤驱动?答案在上一节的旅程里:系统调用是唯一的合法关卡。用户态把请求放进约定的寄存器,执行陷入指令;内核接管后校验参数、定位驱动、执行操作,再把结果原路带回。驱动本身不直接跟用户进程见面,它只向内核的框架注册回调,由框架代表用户调用。

这带来一个容易误解的点:驱动"运行在内核态",但它的触发源有两类——用户的系统调用与硬件的中断。两类触发对应两种上下文、两套禁忌,混用工具就会出事。一个典型错误是在中断处理里调用可能睡眠的内存分配;另一个是在进程上下文里长时间关中断,把实时性拖垮。这些具体案例留到第 3 章和第 6 章。

从崩溃差异看设计哲学

本节开头那个反例,值得再咀嚼一遍。用户态崩溃可以承受,因为隔离机制保证了伤害不扩散;内核态崩溃不可承受,所以驱动代码要用工程手段把错误拦在崩溃之前:参数检查、错误码传播、资源回滚。等到第 7 章分析 oops 日志时你会发现,内核在崩溃前的最后一件事仍是把现场整理好留给你——这是一种"死也要留下遗书"的设计哲学。

三段代码对照看边界

把"同一件事在两边怎么做"摆在一起,边界会比概念描述更具体。需求很朴素:把一块缓冲区的内容换成同一个值。

/* 用户态写法:库函数直接伺候 */ char buf[4096]; memset(buf, 0xA5, sizeof(buf)); /* 一行搞定,失败不用想 */ /* 内核态进程上下文写法:分配与填充分两步想 */ u8 *kbuf = kmalloc(4096, GFP_KERNEL); /* 可能失败,必须检查 */ if (!kbuf) return -ENOMEM; memset(kbuf, 0xA5, 4096); /* 用完必须显式释放;跨函数传递要一路想着谁负责释放 */ /* 内核态中断上下文写法:连分配都不允许随意做 */ /* 正确姿势:缓冲区在 probe 里预先分配好,中断里只填数据 */ spin_lock(&buf_lock); /* 保护共享缓冲用自旋锁 */ memset(priv->kbuf, 0xA5, priv->buf_len); spin_unlock(&buf_lock);

三段代码的递进关系:用户态把"失败、释放、并发"全部藏进库与运行时;内核态进程上下文把它们还给你,但工具齐全(可以睡眠的分配、互斥锁);中断上下文进一步收走睡眠权,只剩预先备好的资源与自旋锁可用。写驱动代码前先问自己在哪个上下文,是比"用什么接口"更优先的问题——上下文决定了整个工具箱的可用范围。

边界带来的性能语义

隔离不是免费的:每次系统调用要过一次模式切换,进出各带上下文保存与缓存影响。这就是为什么高性能路径上反复出现两类设计:批量递交(一次系统调用搬运大量数据,把跨界成本摊薄)与零拷贝(让数据尽量不过 CPU,由 DMA 直接搬运,第 3.3 节展开)。理解了边界成本,再看内核与用户态各种接口的形状设计——为何有汇聚读写、为何有内存映射——就都成了顺理成章的工程答案。

常见追问

追问一:驱动拿到用户传来的指针,能不能直接解引用? 不能,哪怕它"看起来是个正常地址"。用户指针指向的是当前进程地址空间的虚拟地址,只有在进程上下文、且该地址确有映射时才可访问;换成中断上下文,背后根本没有那个进程,访问必然出错。正确做法永远是经安全拷贝函数过一道检查。历史上无数次内核安全漏洞都源于"直接用了一下用户指针"——把这条当成反射弧。

追问二:驱动那么"特权",它能访问自己进程上下文之外的用户内存吗? 有受控的通道(如进程页表的借用映射),但默认答案是"别"。跨进程访问用户内存需要显式接管目标地址空间并处理缺页,复杂度与风险都高,常规驱动设计应当避开——真要在内核与用户态之间共享大块数据,第 8 章的内存映射接口与用户态驱动方案是更体面的路。边界之上通行的是"受检的桥",不是"随意的门"。

追问三:都说用户态更安全,那把更多功能从内核挪回用户态是不是大趋势? 方向对,但动力不是"安全"而是"隔离故障面"。第 8 章会看到:文件系统跑在用户态进程里、图形栈整体搬到用户态、驱动用框架直通——这些迁移的账都算在"崩溃与迭代的代价"上,而不是权限上。内核态的权力没有变小,变小的是被迫留在内核态的代码量:留下的越少,被审计、被验证的面就越集中。这条线的终点没有定论,但方向感值得现在就建立。

本节要点回顾

  • 隔离靠特权级与地址划分:用户态碰不了硬件,内核态权力大责任也大。
  • 上下文分进程与中断两类:能否睡眠是最要紧的分界线。
  • 内核栈小、崩溃代价大:防御性编码是驱动的生存底线。
  • 系统调用是唯一关卡:驱动只被内核回调,从不直接服务用户进程。

设备文件的路径解析、驱动注册时的设备号、设备树里的节点——这些都建立在下一节的话题上:内核如何给设备分类、如何认出每一台设备。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U