嵌入式Linux驱动开发指南02——内核空间基础与硬件访问仓库已经开源所有教程主线内核移植跑新版本imx-linux/uboot都在这里欢迎各位大佬观摩喜欢的话点个⭐仓库地址https://github.com/Awesome-Embedded-Learning-Studio/imx-forge静态网页https://awesome-embedded-learning-studio.github.io/imx-forge/从裸机到Linux的认知障碍如果直接把裸机代码搬到 Linux 里能不能用这是一个极其诱人的想法。你已经熟知了 I.MX6ULL 的每一个寄存器知道怎么把复用功能选通、怎么把时钟门打开、怎么把 GPIO 方向设为输出。在你看来点亮一颗 LED 只需要几行赋值代码——指针一指数据一写灯亮了。但在 Linux 下这行不通。或者说如果不理解 Linux 眼里的世界直接去动硬件你得到的要么是一串刺眼的Unable to handle kernel paging request要么就是系统毫无反应地死锁。这里有一个根本性的认知障碍你在裸机时代拥有的是上帝视角可以直接操纵物理地址而在 Linux 里你只是众多进程中的一个甚至连内核自己都被 MMU内存管理单元挡在了物理世界之外。第一部分两座城池的故事为什么要分开在上一章我们站在城墙外看了一眼字符设备驱动知道它是连接应用和硬件的翻译官。但在这之前我们得先搞清楚一个问题为什么需要这个翻译官为什么不能让应用程序直接操作硬件这个问题的答案藏在 Linux 最基本的设计哲学里——把世界分成两半。想象一下如果所有程序都能直接访问硬盘、读取你浏览器保存的密码、或者修改其他进程的内存——这个世界会变成什么样答案混乱。任何程序都可以窃取其他程序的隐私数据导致系统崩溃比如随意修改内核数据结构绕过安全限制比如直接读取磁盘上的任何文件所以 Linux 建立了这套两座城池的制度用户空间普通市民生活的地方自由但受限内核空间政府机构所在地特权但责任重大两套规则根据 ARM 官方文档ARM32 Linux 的虚拟地址空间划分如下标准 3GB/1GB 分割配置用户空间User Space地址范围0x00001000 ~TASK_SIZE-1通常 TASK_SIZE 0xC0000000即低 3GB权限受限用户态用途进程的 text/data/heap通过 mmap() 系统调用创建的映射限制不能直接访问硬件、不能执行特权指令、不能访问内核内存注意0x00000000 ~ 0x00000fff 保留用于 CPU 向量页和空指针陷阱内核空间Kernel Space内核空间包含多个区域从PAGE_OFFSET通常为 0xC0000000开始区域地址范围用途直接映射 RAMPAGE_OFFSET ~ high_memory-1内核直接映射 RAM与物理 RAM 1:1 对应vmalloc/ioremapVMALLOC_START ~ VMALLOC_END-1vmalloc()/ioremap() 动态映射区域永久映射PKMAP_BASE ~ PAGE_OFFSET-1HIGHMEM 页的永久内核映射模块空间MODULES_VADDR ~ MODULES_END-1通过 insmod 加载的内核模块固定映射ffc80000 ~ ffeffffffix_to_virt() 提供的固定映射权限完全内核态 Ring 0职责管理系统资源、调度进程、处理中断、驱动硬件关于 64 位系统上述讨论仅适用于32 位 ARM 系统。64 位系统如 ARM64/x86_64使用完全不同的内存布局称为canonical address layout虚拟地址空间远大于 4GBARM64/x86_64 支持 48 位或更多虚拟地址位用户空间和内核空间通常采用非对称布局用户空间占用较低的虚拟地址范围内核空间占用最高的虚拟地址范围中间有一段不可用的hole区域canonical 地址的要求根据 x86_64 文档现代 64 位系统使用 4 级或 5 级页表支持的虚拟地址空间可达 256TB4 级或 128PB5 级。地址空间的隔离在驱动开发中你必须时刻记住一个重要事实用户空间的地址和内核空间的地址是完全隔离的即使数字相同指向的也是不同的物理内存。ARM32 内存布局示意图根据官方文档完整的 ARM32 内存布局如下用户空间 (每个进程独立) ──────────────────────────────────────────────────── 0x00000000 ─┐ 0x00000fff ├─ CPU 向量页 / 空指针陷阱 │ 0x00001000 ─┐ ├─ 用户空间映射 (TASK_SIZE-1) │ text/data/heap通过 mmap() 创建 │ 0xBFFFFFFF ─┘ (TASK_SIZE-1) 内核空间 (所有进程共享) ──────────────────────────────────────────────────── │ 0xC0000000 ─┐ (PAGE_OFFSET) ├─ 内核直接映射 RAM (1:1) │ VMALLOC_START ─┐ ├─ vmalloc()/ioremap() 动态映射区域 │ VMALLOC_END ─┘ (0xff800000) ├─ 模块空间、固定映射等 │ 0xFFFFFFFF ─┘关键点每个进程都有独立的用户空间0 ~ TASK_SIZE-1所有进程共享同一个内核空间PAGE_OFFSET ~ 0xFFFFFFFF相同的虚拟地址在不同进程中可能指向不同的物理内存ioremap() 返回的地址位于 vmalloc 区域第二部分系统调用——城门关卡现在问题来了如果用户空间的程序需要读取文件但它无权直接访问硬盘怎么办答案是走正规程序申请内核代劳。这个正规程序就是系统调用System Call。系统调用的工作流程用户程序 内核 ↓ ↓ open(/dev/led, O_RDWR) │ ├─→ 触发软中断 (int 0x80 / syscall) │ (CPU 从用户态切换到内核态) ↓ 内核接管执行 │ ├─→ 检查权限 ├─→ 查找 /dev/led 对应的驱动 ├─→ 调用驱动的 open() 函数 ↓ 返回文件描述符给用户程序CPU 特权级特权环x86/ARM 架构提供多个特权级Linux 只用了两个Ring 0内核态可以执行任何指令访问所有内存Ring 3用户态受限不能执行特权指令从用户态切换到内核态的唯一合法途径就是系统调用。常见的系统调用你可能已经用过很多系统调用只是没意识到用户态函数系统调用作用open()SYS_open打开文件read()SYS_read读取文件write()SYS_write写入文件malloc()SYS_brk分配内存pthread_create()SYS_clone创建线程第三部分MMU——地址翻译官为什么不能直接用物理地址在裸机程序里我们定义一个宏#define GPIO_DR 0x0209C000然后把0x0209C000当作一个地址直接赋给指针这很自然。但在 Linux 内核里这个0x0209C000是一个陌生人。为什么因为MMU。停下来想一想你在裸机时代写下的那些物理地址在 Linux 内核启动的那一刻已经全部失效了。CPU 看到的不再是物理地址而是虚拟地址。问题来了如果我现在想操作 GPIO1 的数据寄存器物理地址0x0209C000在 Linux 下该怎么找到它答案是我需要向内核申请让内核帮我把这个物理地址翻译成一个我可以用的虚拟地址。MMU 干什么活的MMUMemory Management Unit内存管理单元是现代处理器的标配也是 Linux 内核赖以生存的基石。根据官方文档MMU 主要负责地址翻译将程序使用的虚拟地址Virtual Address转换为物理内存的真实地址Physical Address内存保护控制谁能读写哪块内存实现进程间的隔离虚拟地址如何转换为物理地址官方文档描述了地址翻译的过程“每个内存访问都使用虚拟地址。当 CPU 解码一条读取或写入系统内存的指令时它将指令中编码的虚拟地址转换为内存控制器可以理解的物理地址。”物理系统内存被划分为页帧page frames 或 pages。页大小是架构特定的有些架构允许在多个支持的值中选择页大小。每个物理内存页可以映射为一个或多个虚拟页。这些映射由页表page tables描述页表按层次结构组织最底层页表包含软件使用的实际页的物理地址较高层页表包含属于较低层的页的物理地址指向顶层页表的指针驻留在寄存器中当 CPU 执行地址翻译时使用寄存器访问顶层页表虚拟地址的高位用于索引顶层页表中的条目该条目用于访问层次结构中的下一级虚拟地址的下一级位作为该级页表的索引虚拟地址的低位定义实际页内的偏移量TLB地址翻译的缓存地址翻译需要多次内存访问而内存访问相对于 CPU 速度来说很慢。为了避免在地址翻译上浪费宝贵的处理器周期CPU 维护着此类翻译的缓存称为TLBTranslation Lookaside Buffer旁路缓冲区。虚拟地址 vs 物理地址官方文档解释了虚拟内存的概念“虚拟内存抽象了应用程序软件的物理内存细节允许只在物理内存中保留需要的信息需求分页并提供了进程间保护和受控数据共享的机制。”在 Linux 内核启动初期它会初始化 MMU建立页表映射。在这之后CPU 执行的所有指令、访问的所有数据用的全是虚拟地址。设备寄存器访问的问题举例说明物理设备寄存器的访问I.MX6ULL的GPIO1_IO03复用寄存器IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO03的物理地址是0X020E0068在没有 MMU的裸机环境下你直接往0X020E0068写数据信号直接传导到硬件外设但在Linux 下MMU 已经启用。如果你直接往0X020E0068这个虚拟地址写数据你要么是在访问一段内核未映射的内存触发 Page Fault要么是在访问一段完全不相关的系统内存结论我们必须通过ioremap()向内核申请把「物理地址 0X020E0068」映射到「某个可用的虚拟地址 V」上。第四部分ioremap——建立映射关系银行保险箱的比喻你可以把物理内存想象成银行保险库里的保险箱编号是0X020E0068。你不能直接走进金库拿着锤子去砸那个箱子物理隔离你需要银行柜员MMU给你一个临时柜台窗口虚拟地址当你向柜员出示证件调用ioremap说你要操作0X020E0068号箱子时柜员会在大厅里给你指定一个3 号窗口以后你只要跟 3 号窗口打交道柜员会自动把你的指令传递到金库里的那个箱子但这个比喻有个关键的细节保险箱是静态的而硬件寄存器是动态的。如果你对 3 号窗口的操作被柜员记在小本本上缓存稍后再批量提交那硬件反应就会迟钝。ioremap 的作用就是告诉柜员「别缓存我每说一句话你都要立刻跑去金库执行一遍。」函数原型与使用ioremap 的定义在内核的 I/O 内存接口中。从使用角度来看它的核心参数很直观void__iomem*ioremap(phys_addr_tphys_addr,size_tsize);核心参数phys_addr你要映射的物理起始地址。比如 GPIO 寄存器的0X020E0068size你要映射多大的空间。一个寄存器通常是 4 字节32位但为了效率有时候我们会映射一整块寄存器区域返回值void __iomem *类型的指针。这就是那个「虚拟地址 V」。之后我们操作 V就是在操作 phys_addr__iomem标记告诉编译器和静态分析工具这是个 I/O 内存地址不能像普通内存那样随意操作。实际使用示例/* 寄存器物理地址 */#defineSW_MUX_GPIO1_IO03_BASE(0X020E0068)/* 映射后的虚拟地址指针__iomem 标记这是个 I/O 内存地址 */staticvoid__iomem*SW_MUX_GPIO1_IO03;/* 执行映射 */SW_MUX_GPIO1_IO03ioremap(SW_MUX_GPIO1_IO03_BASE,4);if(!SW_MUX_GPIO1_IO03){printk(ioremap failed\n);return-ENOMEM;}这里size传了 4因为我们只操作这一个 32 位的寄存器。回到那个类比现在SW_MUX_GPIO1_IO03就是那个「3 号窗口」。以后你读写*SW_MUX_GPIO1_IO03其实就是在读写那个远在金库里的物理寄存器。iounmap有借有还当你的驱动卸载时必须把占用的映射释放掉把窗口还给内核大厅。这需要用到iounmapvoidiounmap(volatilevoid__iomem*addr);/* 卸载时释放映射 */iounmap(SW_MUX_GPIO1_IO03);这一步如果忘了做不仅仅是内存泄漏的问题。你映射的是设备地址如果不释放内核可能误以为这块地址空间还在被占用后续其他驱动想访问这段地址时可能会出问题。虽然现在的系统没那么脆弱但作为工程师我们不做这种只借不还的事。第五部分I/O 内存访问函数为什么不能用指针直接读写现在我们已经拿到了虚拟地址指针SW_MUX_GPIO1_IO03是不是可以直接用 C 语言的*和操作符来读写了呢就像这样/* ❌ 糟糕的写法 */unsignedintval*SW_MUX_GPIO1_IO03;*SW_MUX_GPIO1_IO03val|(10);虽然很多老旧的或者不规范的驱动确实这么干甚至在某些简陋的硬件上也能跑但Linux 内核强烈反对这样做。因为硬件寄存器不是 RAM读写副作用有些寄存器只要一读就会清零或者写入某个值会触发硬件动作对齐要求硬件对 32 位访问的对齐要求比内存严格顺序保证编译器为了优化可能会打乱指令顺序或者把多次读写合并。但在驱动里你必须按顺序写寄存器比如「先设复用再设方向最后写数据」顺序一乱就炸读操作函数根据你想读的位宽8位、16位、32位内核提供了三个函数u8readb(constvolatilevoid__iomem*addr);// 读 8 位1 字节u16readw(constvolatilevoid__iomem*addr);// 读 16 位2 字节w wordu32readl(constvolatilevoid__iomem*addr);// 读 32 位4 字节l long参数addr就是你用ioremap拿到的那个虚拟地址。回到那个类比如果你直接去翻addr指向的内存用指针读就像是你自己翻开了银行的柜台记录本。但readl就像是你正式填写了一张「取款单」银行柜员会严格按照流程去金库执行操作并把结果封好在信封里交给你。写操作函数同样的写操作也有对应的三个函数voidwriteb(u8 value,volatilevoid__iomem*addr);// 写 8 位voidwritew(u16 value,volatilevoid__iomem*addr);// 写 16 位voidwritel(u32 value,volatilevoid__iomem*addr);// 写 32 位value你要写入的值addr目标地址为什么一定要用这些函数除了上面提到的顺序保证和对齐问题外还有一个很重要的原因可调试性。内核可以通过拦截这些函数调用来记录所有的 I/O 操作这在排查硬件 Bug 时是救命稻草。如果你用指针强行读写内核对你是一无所知的。千万别混用如果寄存器是 32 位的你用了writeb可能只改了低 8 位或者在某些架构上直接触发异常。一定要查阅芯片手册确认寄存器的位宽。对于 I.MX6ULL 的 GPIO 寄存器绝大多数都是 32 位的所以我们主要用的是readl和writel。第六部分实战示例让我们把上面的知识串起来看一个完整的例子。映射寄存器/* 定义寄存器物理地址 */#defineGPIO1_DR_BASE(0x0209C000)// GPIO1 数据寄存器#defineGPIO1_GDIR_BASE(0x0209C004)// GPIO1 方向寄存器/* 映射后的虚拟地址指针 */staticvoid__iomem*GPIO1_DR;staticvoid__iomem*GPIO1_GDIR;/* 映射 */GPIO1_DRioremap(GPIO1_DR_BASE,4);GPIO1_GDIRioremap(GPIO1_GDIR_BASE,4);if(!GPIO1_DR||!GPIO1_GDIR){printk(ioremap failed\n);return-ENOMEM;}配置 GPIO 为输出u32 val;/* 读取当前方向寄存器的值 */valreadl(GPIO1_GDIR);/* 配置 GPIO1_IO03 为输出bit3 置 1*/val|(13);/* 写回 */writel(val,GPIO1_GDIR);这就是经典的**「读-改-写」**铁律。你不能直接writel(0x08, GPIO1_GDIR)因为那样会把其他 31 个引脚的配置全冲掉。在嵌入式 Linux 这种多任务环境下其他引脚可能正被别的驱动占用着。控制 LED/* 点亮 LED假设低电平点亮*/valreadl(GPIO1_DR);val~(13);// bit3 清零writel(val,GPIO1_DR);/* 熄灭 LED */valreadl(GPIO1_DR);val|(13);// bit3 置一writel(val,GPIO1_DR);释放映射/* 卸载时 */iounmap(GPIO1_DR);iounmap(GPIO1_GDIR);第七部分内核编程的限制与数据传递内核空间的限制虽然内核空间有特权但这并不意味着你可以为所欲为。内核编程有严格的限制1. 不能使用标准 C 库#includestdio.h// ❌ 不能用#includestring.h// ❌ 不能用#includestdlib.h// ❌ 不能用// 但可以使用内核提供的函数#includelinux/string.h// strcpy, strlen, memcpy...#includelinux/slab.h// kmalloc, kfree#includelinux/printk.h// printk, pr_info内核有自己的一套函数库功能类似但名字可能不同。2. 不能做浮点运算内核默认不保存浮点寄存器为了提高切换效率。如果你使用浮点数需要显式保存/恢复浮点上下文这很麻烦且慢。解决方法在驱动中使用定点数或整数运算。3. 栈空间有限用户空间的栈通常是几 MB但内核栈很小通常 8KB 或 16KB。// ❌ 危险可能栈溢出voiddangerous_function(void){charhuge_buffer[10000];// 太大了// ...}// ✅ 正确使用堆voidsafe_function(void){char*bufferkmalloc(10000,GFP_KERNEL);// ...kfree(buffer);}4. 不能睡眠某些上下文在中断处理函数、软中断等上下文中代码不能睡眠不能调用可能阻塞的函数。// ❌ 在中断上下文中会崩溃irqreturn_tmy_irq_handler(intirq,void*dev_id){msleep(1000);// 不能睡眠returnIRQ_HANDLED;}// ✅ 正确使用延迟而非睡眠irqreturn_tmy_irq_handler(intirq,void*dev_id){mdelay(1000);// 忙等待不睡眠returnIRQ_HANDLED;}数据传递越过城墙的方式既然用户空间和内核空间是隔离的那么两者之间如何传递数据方式 1系统调用参数简单数据对于小数据量如整数、指针直接通过系统调用参数传递// 用户空间intfdopen(/dev/led,O_RDWR);// fd 通过寄存器返回// 驱动中staticintled_open(structinode*inode,structfile*filp){// filp 已经是内核空间的数据结构了return0;}方式 2copy_to_user / copy_from_user数据块对于大量数据如缓冲区使用专门的拷贝函数// 驱动中staticssize_tled_read(structfile*filp,char__user*buf,size_tcount,loff_t*ppos){charkernel_data[]LED status: ON;// 安全地从内核复制到用户空间if(copy_to_user(buf,kernel_data,strlen(kernel_data))){return-EFAULT;// 拷贝失败}returnstrlen(kernel_data);}为什么不能用 memcpymemcpy不做安全检查。如果用户传一个恶意的内核地址memcpy会乖乖地把内核数据拷贝过去——这是安全漏洞。copy_to_user会检查目标地址是否在用户空间检查地址是否可写处理页错误如果用户空间页面被换出常见错误与解决方案错误 1忘记 ioremap 直接用物理地址/* ❌ 错误 */#defineGPIO1_DR0x0209C000u32 valreadl(GPIO1_DR);// 段错误后果触发内核 Oops内核崩溃因为0x0209C000在虚拟地址空间里没有被映射。正确做法先用ioremap建立映射。错误 2用指针而不是 readl/writel/* ❌ 错误 */u32 val*GPIO1_DR;// 危险*GPIO1_DR0x08;后果可能在某些硬件上能跑但不符合内核规范。可能导致编译器优化出问题或者在某些架构上触发异常。正确做法使用readl/writel。错误 3忘记 iounmap/* ❌ 错误 */staticint__initled_init(void){GPIO1_DRioremap(...);// ...return0;}staticvoid__exitled_exit(void){// 忘记 iounmap}后果内存泄漏重复加载驱动时可能会失败。正确做法在exit函数里调用iounmap。错误 4直接覆盖寄存器值/* ❌ 错误 */writel(0x08,GPIO1_GDIR);// 危险后果把 GPIO1 的其他 31 个引脚的配置全冲掉了可能导致系统其他功能异常。正确做法读-改-写。/* ✅ 正确 */u32 valreadl(GPIO1_GDIR);val|(13);writel(val,GPIO1_GDIR);错误 5直接访问用户空间指针// ❌ 错误示例staticssize_tbad_read(structfile*filp,char__user*buf,size_tcount,loff_t*ppos){chardata[]Hello;memcpy(buf,data,strlen(data));// 危险buf 是用户空间指针returnstrlen(data);}// ✅ 正确做法staticssize_tgood_read(structfile*filp,char__user*buf,size_tcount,loff_t*ppos){chardata[]Hello;if(copy_to_user(buf,data,strlen(data))){return-EFAULT;// 正确处理拷贝失败}returnstrlen(data);}本章小结核心概念两座城池用户空间受限和内核空间特权城门关卡系统调用是唯一合法的跨越方式地址隔离相同的虚拟地址在不同空间指向不同的物理内存MMU 映射CPU 使用虚拟地址物理地址需要被映射硬件访问使用ioremap、readl/writel访问硬件寄存器安全传递使用copy_to_user和copy_from_user在空间之间传递数据编程规则✅ 驱动运行在内核空间有完全权限✅ 使用内核提供的函数不用标准 C 库✅ 永远不要直接访问用户空间指针✅ 限制栈空间使用大对象用堆分配✅ 注意上下文某些地方不能睡眠✅ 使用ioremap访问硬件readl/writel读写寄存器记住那个银行保险箱的比喻物理地址 保险箱编号0x020E0068 号箱虚拟地址 柜台窗口3 号窗口MMU 银行柜员帮你找到对应的箱子ioremap 向柜员申请窗口readl/writel 正规的填单操作指针直接读写 自己翻柜台记录本违规操作参考文档Linux 内核官方文档Memory Management - 内存管理子系统总览包含虚拟内存、需求分页、内存分配等Virtual Memory Concepts - 虚拟内存概念详解包含虚拟内存基础、大页、 Zones、Nodes 等ARM Memory Layout - ARM 处理器虚拟内存布局详解x86 Documentation - x86 架构文档包含 Memory Layout 章节相关阅读嵌入式Linux学习指南之设备树——Linux内核设备树编译机制深度解析 - 相似度 100%入门 · 环境搭建 · 00 · Qt6 安装踩坑指南 - 相似度 80%深入理解Linux模块——模块参数与内核调试让模块活起来的魔法 - 相似度 80%