Skip to content

内核空间基础与硬件访问 - 跨越那道墙

前言:从裸机到 Linux 的认知障碍

如果你跟我一样,是从裸机开发转到 Linux 的,那么一开始真的会有个认知障碍。在裸机时代,你想点个 LED,直接往寄存器地址写个值就行了。代码大概长这样:

c
#define GPIO_DR 0x0209C000
*(unsigned int *)GPIO_DR = 0x08;  // 点亮

简单直接,想怎么搞就怎么搞。但到了 Linux 下,你这样写代码,得到的结果要么是一串刺眼的 Unable to handle kernel paging request,要么就是系统毫无反应地死锁。

说实话,我第一次遇到这个问题的时候,真的懵了很久。为什么明明能用的代码,到 Linux 里就炸了?

后来我明白了,这里有一个根本性的认知差异:你在裸机时代拥有的是上帝视角,可以直接操纵物理地址;而在 Linux 里,你只是众多进程中的一个,甚至连内核自己都被 MMU(内存管理单元)挡在了物理世界之外。

这篇文章,我们要解决的就是这个问题:在 Linux 内核里,到底该怎么访问硬件?

两座城池:为什么要分开

在上一章我们站在城墙外看了一眼字符设备驱动,知道它是连接应用和硬件的"翻译官"。但在这之前,我们得先搞清楚一个问题:为什么需要这个翻译官?为什么不能让应用程序直接操作硬件?

这个问题的答案,藏在 Linux 最基本的设计哲学里——把世界分成两半

想象一下,如果所有程序都能直接访问硬盘、读取你浏览器保存的密码、或者修改其他进程的内存——这个世界会变成什么样?

答案是:混乱

任何程序都可以窃取其他程序的隐私数据,导致系统崩溃(比如随意修改内核数据结构),或者绕过安全限制直接读取磁盘上的任何文件。

所以 Linux 建立了这套"两座城池"的制度。用户空间是普通市民生活的地方,自由但受限。你可以写代码、上网、听歌,但不能碰硬件,也不能访问别人的内存。内核空间是政府机构所在地,特权但责任重大。这里可以访问硬件、管理内存、调度进程,但必须遵守严格的规则。

地址空间的划分:ARM32 的例子

具体到 ARM32 架构(我们用的 I.MX6ULL 就是这个),地址空间是按照 3GB/1GB 划分的。用户空间占用低 3GB(0x00000000 ~ 0xBFFFFFFF),内核空间占用高 1GB(0xC0000000 ~ 0xFFFFFFFF)。

这个划分不是随意的,而是 ARM Linux 的标准配置。用户空间的进程有自己的页表,每个进程看到的虚拟地址都不一样。内核空间的页表则是所有进程共享的——毕竟内核只有一个。

这里有个关键点需要理解:用户空间的地址和内核空间的地址是完全隔离的,即使数字相同,指向的也是不同的物理内存。比如用户空间的 0x08000000 可能是某个程序的代码段,而内核空间的 0x08000000 根本不存在(因为内核空间从 0xC0000000 才开始)。

关于 64 位系统的情况,这里简单提一句。64 位系统(比如 ARM64)使用完全不同的内存布局,地址空间大得多,用户空间和内核空间的划分方式也不一样。但我们在嵌入式开发里,32 位 ARM 还是很常见的,所以这里重点讲 32 位的情况。

系统调用:城门关卡

现在问题来了:如果用户空间的程序需要读取文件,但它无权直接访问硬盘,怎么办?

答案是:走正规程序,申请内核代劳。这个"正规程序"就是系统调用

你可以把系统调用理解成"城门关卡"。你想进城办事(访问硬件),必须先到关卡登记(系统调用),让守卫(内核)检查你的证件,然后由守卫代劳。你不能直接翻墙进城,那样会被当场抓获(触发异常,程序被杀)。

从用户态切换到内核态的唯一合法途径就是系统调用。其他方式——比如直接写汇编代码跳转到内核地址——都会被硬件拦截。这是 CPU 特权级机制在起作用。

我们平时用的 open()read()write() 这些函数,表面上看是普通的函数调用,实际上底层都触发了系统调用。C 库帮我们封装了这些细节,让代码看起来更自然。

MMU:地址翻译官

现在我们来讲讲为什么不能直接用物理地址。这得从 MMU 说起。

MMU(Memory Management Unit,内存管理单元)是现代处理器的标配,也是 Linux 内核赖以生存的基石。它的职责很简单:把程序使用的虚拟地址转换成物理内存的真实地址

停下来想一想:你在裸机时代写下的那些物理地址,在 Linux 内核启动的那一刻,已经全部失效了。CPU 看到的不再是物理地址,而是虚拟地址。每个内存访问都要经过 MMU 的翻译,才能找到真正的物理内存。

这个机制带来了很多好处。进程之间互不干扰,因为各自的虚拟地址映射到不同的物理内存。内存可以被换出到硬盘,因为程序只看到虚拟地址,不管数据真正在哪里。但同时也带来了一个麻烦:你想操作硬件寄存器,得先建立映射

ioremap:建立映射关系

假设我们现在想操作 GPIO1 的数据寄存器(物理地址 0x0209C000)。在 Linux 下,我们不能直接把 0x0209C000 当作地址用,因为这个虚拟地址可能没映射,或者映射到了完全错误的物理内存。

我们需要做的是:向内核申请,把物理地址 0x0209C000 映射到一个我们可以用的虚拟地址上。这个操作通过 ioremap() 函数完成。

c
void __iomem *ioremap(phys_addr_t phys_addr, size_t size);

这个函数的第一个参数是物理地址,第二个参数是要映射的空间大小。返回值是一个虚拟地址指针,以后我们操作这个指针,就是在操作对应的物理寄存器。

这里有个很好的类比:你可以把物理内存想象成银行保险库里的保险箱,编号是 0x0209C000。你不能直接走进金库拿着锤子去砸那个箱子(物理隔离),你需要银行柜员(MMU)给你一个临时柜台窗口(虚拟地址)。当你向柜员出示证件(调用 ioremap)说你要操作 0x0209C000 号箱子时,柜员会在大厅里给你指定一个窗口。以后你只要跟这个窗口打交道,柜员会自动把你的指令传递到金库里的那个箱子。

用完之后记得释放映射,这用 iounmap() 函数完成。这一步如果忘了做,不仅仅是内存泄漏的问题。你映射的是设备地址,如果不释放,内核可能误以为这块地址空间还在被占用,后续其他驱动想访问这段地址时可能会出问题。

readl 和 writel:正确的访问方式

现在我们已经拿到了虚拟地址指针,是不是可以直接用 C 语言的 *= 操作符来读写了呢?

虽然很多老旧的或者不规范的驱动确实这么干,甚至在某些简陋的硬件上也能跑,但 Linux 内核强烈反对这样做

因为硬件寄存器不是 RAM。有些寄存器只要一读就会清零,或者写入某个值会触发硬件动作。硬件对 32 位访问的对齐要求比内存严格。更重要的是,编译器为了优化可能会打乱指令顺序,或者把多次读写合并。但在驱动里,你必须按顺序写寄存器,比如"先设复用,再设方向,最后写数据",顺序一乱就炸。

内核提供了一套专门的函数来访问 I/O 内存:

c
u32 readl(const volatile void __iomem *addr);   // 读 32 位
void writel(u32 value, volatile void __iomem *addr);  // 写 32 位

还有 readb/writeb(8 位)和 readw/writew(16 位),但 I.MX6ULL 的寄存器基本都是 32 位的,所以我们主要用的是 readlwritel

这些函数除了保证顺序和对齐,还有一个很重要的好处:可调试性。内核可以通过拦截这些函数调用来记录所有的 I/O 操作,这在排查硬件 Bug 时是救命稻草。如果你用指针强行读写,内核对你是一无所知的。

读-改-写:永远的铁律

这里有个非常重要的操作模式,叫"读-改-写"。举个例子,假设你要把 GPIO1 的方向寄存器的 bit3 置 1(配置成输出)。正确的做法是这样的:

c
u32 val = readl(GPIO1_GDIR);  // 先读
val |= (1 << 3);               // 再改
writel(val, GPIO1_GDIR);       // 后写

你不能直接 writel(0x08, GPIO1_GDIR),因为那样会把其他 31 个引脚的配置全冲掉。在嵌入式 Linux 这种多任务环境下,其他引脚可能正被别的驱动占用着,你这么一搞,系统其他功能可能就异常了。

这个"读-改-写"的铁律一定要记住,我见过太多新手直接覆盖寄存器值,然后调试半天找不到问题。

内核编程的限制

虽然内核空间有特权,但这并不意味着你可以为所欲为。内核编程有严格的限制,这里列几个最重要的。

第一个限制是不能使用标准 C 库。你在用户空间用惯了的 printfmallocstrcpy 这些函数,在内核里统统都没有。但内核提供了自己的一套函数,功能类似但名字可能不同。比如 printk 替代 printfkmalloc 替代 malloc

第二个限制是不能做浮点运算。内核默认不保存浮点寄存器(为了提高切换效率)。如果你使用浮点数,需要显式保存/恢复浮点上下文,这很麻烦且慢。解决方法是在驱动中使用定点数或整数运算。

第三个限制是栈空间有限。用户空间的栈通常是几 MB,但内核栈很小(通常 8KB 或 16KB)。你在内核里定义大数组很容易栈溢出。解决方法是用堆分配,该 kmallockmalloc,别省。

第四个限制是在某些上下文不能睡眠。在中断处理函数里,你不能调用可能阻塞的函数,比如 msleep()。如果必须延时,用 mdelay() 这种忙等待的方式。虽然忙等待浪费 CPU,但在中断上下文里别无选择。

数据传递:越过城墙的方式

既然用户空间和内核空间是隔离的,那么两者之间如何传递数据?对于小数据量,直接通过系统调用参数传递就行。但对于大量数据(比如缓冲区),需要专门的拷贝函数。

内核提供了 copy_to_user()copy_from_user() 这两个函数来安全地在空间之间传递数据。你可能会问,为什么不能用 memcpy

memcpy 不做安全检查。如果用户传一个恶意的内核地址,memcpy 会乖乖地把内核数据拷贝过去——这是安全漏洞。copy_to_user 会检查目标地址是否在用户空间,是否可写,还会处理页错误(如果用户空间页面被换出)。

这些函数虽然名字里带 "user",但其实是我们在驱动里调用的。比如应用程序调用 read(),驱动里的 read 函数就用 copy_to_user 把数据传给用户空间。应用程序调用 write(),驱动就用 copy_from_user 接收数据。

常见的坑

这里说几个新手常犯的错误,希望能帮你少走弯路。

第一个错误是忘记 ioremap 直接用物理地址。这会触发内核 Oops(内核崩溃),因为物理地址在虚拟地址空间里没有被映射。记住,硬件寄存器的物理地址必须先映射才能用。

第二个错误是用指针而不是 readl/writel。虽然某些简陋的硬件上能跑,但不符合内核规范,可能导致编译器优化出问题,或者在某些架构上触发异常。

第三个错误是忘记 iounmap。这会导致内存泄漏,重复加载驱动时可能会失败。每次 ioremap 都要有对应的 iounmap,养成这个好习惯。

第四个错误是直接覆盖寄存器值。前面讲过"读-改-写"铁律,这里再强调一遍:永远不要直接覆盖寄存器,除非你确信自己知道在做什么。

第五个错误是直接访问用户空间指针。驱动里收到的用户空间指针绝对不能直接解引用,必须用 copy_from_user/copy_to_user。否则用户程序传个恶意地址,你就可能把内核数据泄露出去。

小结

到这里,我们已经理解了内核空间的基本概念。让我们回顾一下要点。

用户空间和内核空间是隔离的,应用程序要通过系统调用才能让内核代劳。CPU 使用虚拟地址,物理地址需要通过 MMU 映射。访问硬件寄存器要用 ioremap 建立映射,然后用 readl/writel 读写。空间之间传递数据用 copy_to_user/copy_from_user,永远不要直接访问用户空间指针。

这些概念是驱动开发的基础,不理解这些,后面的代码写起来就是照猫画虎。但理解了这些,你会发现驱动开发其实很有条理,只是多了一些规矩而已。

接下来我们要讲的是内核模块机制。知道了"怎么访问硬件"之后,下一个问题是"怎么把代码加载进内核"。模块机制是 Linux 内核的一大特色,它让我们可以在运行时动态地加载和卸载代码,不用每次修改都重启系统。

继续阅读: 03_kernel_module_mechanism.md

Built with VitePress