Linux设备驱动开发核心路径:内核模块、字符设备与platform驱动实战 📅 发布时间:2026/9/7 3:59:33 👁 浏览次数: 最近嵌入式圈子都在讨论一本新书《手把手教你学Linux设备驱动开发》正式出版的消息。很多读者问我Linux设备驱动开发现在还值得学吗门槛是不是太高了上手到底需要准备哪些东西趁着这波热度我把多年项目里积累的设备驱动开发经验整理成一篇完整的学习笔记兼实战教程从环境搭建到字符设备驱动、设备树与 platform 驱动全部走一遍文末还附上高频报错排查表和工程开发建议。这篇文章不是单纯推书而是把书籍涉及的核心知识体系拆成可操作、可复现的实践路径。新手可以按顺序跟着搭环境、写模块、加载测试有基础的开发者可以直接跳到第 4 节和第 5 节看 platform 驱动改造思路。1. 设备驱动开发是什么为什么说它是嵌入式底层的“硬核宝典”技能1.1 用大白话理解设备驱动先不急着看代码。设备驱动简单说就是“操作系统与硬件设备之间的翻译官”。Linux 内核不认识某颗传感器、某个网卡芯片、某块 LCD 屏但驱动程序认识。驱动负责把应用层的读写请求转换成硬件寄存器操作再把硬件产生的中断、数据转交给应用层可感知的事件或文件。为什么叫“硬核”因为设备驱动开发至少要求你同时具备三种知识硬件知识看得懂原理图、芯片手册、寄存器位定义。操作系统知识理解进程上下文、中断上下文、并发与锁、内存映射。Linux 内核机制内核模块、字符设备框架、platform 总线、设备树、中断子系统。一个驱动工程师的排错范围往往是“硬件电路没通、寄存器配置错、内核 API 用错、设备树节点写错”四个方向同时排查。这也是它入门门槛高、但薪资和不可替代性也高的原因。1.2 驱动开发在哪些场景中必不可少ARM 嵌入式 Linux 产品工业控制器、医疗设备、智能网关、车载终端。消费电子手机、平板、智能音箱底层的传感器、屏幕、音频驱动。物联网设备NB-IoT 模组、4G/5G 模组、蓝牙/WiFi 芯片适配。服务器周边硬件网卡、RAID 卡、FPGA 加速卡的 Linux 驱动。国产化操作系统适配随着国产 Linux 发行版在政务、金融、能源领域推广硬件厂商和集成商对驱动移植人才的需求大幅增加。1.3 为什么现在仍然是学习设备驱动的好时机很多人担心“驱动开发是不是夕阳技术”。恰恰相反Linux 是当前服务器、嵌入式、云计算基础设施的绝对主流操作系统只要 Linux 还存在设备驱动就是刚需。而随着 RISC-V、国产化芯片、边缘计算设备爆发新的硬件平台不断出现每个平台都需要有人来做 Linux 适配。另一方面Linux 内核的驱动框架已经高度标准化字符设备、块设备、网络设备、platform 驱动、设备树、Regmap、GPIO 子系统、中断子系统。学的不只是某颗芯片而是一套可迁移的“驱动设计方法论”。这也是《手把手教你学Linux设备驱动开发》这类书籍能成为“硬核宝典”的原因——它把内核中看似零散的机制串成了一条完整的学习链路。2. 环境准备从零搭建可运行的驱动开发环境驱动开发不像普通应用开发写一个 main.c 就能编译运行。驱动是内核的一部分必须借助内核源码树进行编译再通过模块加载方式放进内核。下面按步骤搭建最小可用环境。2.1 硬件与宿主机选择学习阶段不一定要真实开发板但建议有 x86 虚拟机或物理机跑 Linux同时准备一块 ARM 开发板做验证。角色推荐方案说明宿主机Ubuntu 22.04 LTS 或 Debian 12内核开发资料多包管理方便编译目标x86_64 本机内核入门最快无需交叉编译运行验证宿主机直接 insmod / rmmod新手建议在虚拟机中操作进阶平台全志、瑞芯微、NXP i.MX 开发板学习设备树、platform 驱动必用注意直接在物理机上加载自己写的驱动有一定风险建议所有模块加载测试都在虚拟机或开发板上进行避免内核崩溃影响开发机数据。2.2 安装内核头文件与编译工具以 Ubuntu 为例打开终端执行以下命令sudo apt update sudo apt install -y build-essential linux-headers-$(uname -r) git vim命令解释build-essential提供 gcc、make 等基础编译工具。linux-headers-$(uname -r)安装当前运行内核对应的头文件驱动模块编译时依赖它。uname -r动态获取当前内核版本。验证环境uname -r ls /lib/modules/$(uname -r)/build如果第二行能正常列出目录说明内核构建树可用。2.3 准备根文件系统与开发板进阶可选在开发板上学习时需要准备以下内容交叉编译工具链例如 aarch64-linux-gnu-。内核源码建议与开发板 BSP 配套。根文件系统镜像或使用 buildroot / busybox 制作。TFTP、NFS 或 SD 卡烧录方式。版本方面没有统一标准不同厂商 BSP 差异较大。建议在开始前确认内核源码版本、交叉工具链版本、设备树文件路径是否匹配。这部分如果选错很容易出现“驱动编译通过但加载报错 invalid module format”的问题。3. 内核模块与字符设备驱动核心概念拆解3.1 内核模块Linux 动态装载机制设备驱动通常以内核模块.ko 文件形式存在。模块可以在系统运行时动态加载与卸载不必每次修改都重新编译整个内核。最小内核模块示例// 文件路径hello_module.c #include linux/init.h #include linux/module.h #include linux/kernel.h static int __init hello_init(void) { printk(KERN_INFO Hello, Linux driver!\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO Goodbye, Linux driver!\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple hello module);配套 Makefile# 文件路径Makefile obj-m : hello_module.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean编译与测试命令make sudo insmod hello_module.ko dmesg | tail -5 sudo rmmod hello_module dmesg | tail -5模块机制让驱动开发有了“快速迭代”的能力不用每次修改都烧写整个内核镜像只需替换一个 .ko 文件。3.2 字符设备驱动的基本框架字符设备是 Linux 设备驱动中最基础的一类。它以字节流方式读写比如串口、GPIO、LED、按键都可以抽象成字符设备。一个完整的字符设备驱动核心要素包括设备号主设备号 次设备号。file_operations 结构体定义 open、read、write、release 等操作。cdev 结构体向内核注册字符设备。设备类与设备节点方便 udev 自动创建 /dev 节点。先看设备号分配#include linux/fs.h #define DEVICE_NAME mychardev static int major_num; static int __init chardev_init(void) { major_num register_chrdev(0, DEVICE_NAME, fops); if (major_num 0) { pr_err(Failed to register device\n); return major_num; } pr_info(Registered with major number %d\n, major_num); return 0; }register_chrdev 是旧式接口会把主设备号与次设备号 0-255 全部占用。学习阶段用它最简单但工程上更推荐 cdev_add alloc_chrdev_region 的方式。3.3 file_operations 中最常用的回调下面是一个简化版 file_operationsstatic int dev_open(struct inode *inode, struct file *filp) { pr_info(Device opened\n); return 0; } static ssize_t dev_read(struct file *filp, char __user *buf, size_t len, loff_t *off) { char kernel_buf[] driver data\n; size_t data_len strlen(kernel_buf); if (*off data_len) return 0; if (copy_to_user(buf, kernel_buf, data_len)) { pr_err(copy_to_user failed\n); return -EFAULT; } *off data_len; return data_len; } static ssize_t dev_write(struct file *filp, const char __user *buf, size_t len, loff_t *off) { char kernel_buf[128]; size_t write_len len sizeof(kernel_buf) - 1 ? sizeof(kernel_buf) - 1 : len; if (copy_from_user(kernel_buf, buf, write_len)) { pr_err(copy_from_user failed\n); return -EFAULT; } kernel_buf[write_len] \0; pr_info(Received from user: %s\n, kernel_buf); return write_len; } static int dev_release(struct inode *inode, struct file *filp) { pr_info(Device closed\n); return 0; } static struct file_operations fops { .owner THIS_MODULE, .open dev_open, .read dev_read, .write dev_write, .release dev_release, };这里有一个非常容易踩的坑在内核里不能直接使用用户空间传来的指针。必须通过 copy_to_user / copy_from_user 完成数据拷贝否则轻则数据异常重则导致系统崩溃。4. 完整实战编写一个带读写功能的字符设备驱动接下来我们把上一节的知识点组合起来完成一个真正可以直接运行的字符设备驱动。它支持 open、read、write、close并且会自动创建设备节点。4.1 创建项目结构chardev_demo/ ├── Makefile └── mychardev.c4.2 编写完整驱动代码// 文件路径chardev_demo/mychardev.c #include linux/init.h #include linux/module.h #include linux/kernel.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #include linux/slab.h #define DEVICE_NAME mychardev #define CLASS_NAME mychar static int major_num; static struct class *char_class NULL; static struct cdev char_cdev; static char *kernel_buffer; static size_t buffer_size 1024; static int dev_open(struct inode *inode, struct file *filp) { pr_info(mychardev: device opened\n); return 0; } static ssize_t dev_read(struct file *filp, char __user *buf, size_t len, loff_t *off) { size_t available strlen(kernel_buffer); size_t to_read; if (*off available) return 0; to_read min(len, available - *off); if (copy_to_user(buf, kernel_buffer *off, to_read)) { pr_err(mychardev: copy_to_user failed\n); return -EFAULT; } *off to_read; return to_read; } static ssize_t dev_write(struct file *filp, const char __user *buf, size_t len, loff_t *off) { size_t write_len min(len, buffer_size - 1); if (copy_from_user(kernel_buffer, buf, write_len)) { pr_err(mychardev: copy_from_user failed\n); return -EFAULT; } kernel_buffer[write_len] \0; *off 0; pr_info(mychardev: received %zu bytes\n, write_len); return write_len; } static int dev_release(struct inode *inode, struct file *filp) { pr_info(mychardev: device closed\n); return 0; } static struct file_operations fops { .owner THIS_MODULE, .open dev_open, .read dev_read, .write dev_write, .release dev_release, }; static int __init mychardev_init(void) { dev_t dev_num; int result; result alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (result 0) { pr_err(mychardev: failed to allocate major number\n); return result; } major_num MAJOR(dev_num); pr_info(mychardev: major number %d\n, major_num); cdev_init(char_cdev, fops); char_cdev.owner THIS_MODULE; result cdev_add(char_cdev, dev_num, 1); if (result 0) { pr_err(mychardev: cdev_add failed\n); unregister_chrdev_region(dev_num, 1); return result; } char_class class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(char_class)) { pr_err(mychardev: class_create failed\n); cdev_del(char_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(char_class); } if (!device_create(char_class, NULL, dev_num, NULL, DEVICE_NAME)) { pr_err(mychardev: device_create failed\n); class_destroy(char_class); cdev_del(char_cdev); unregister_chrdev_region(dev_num, 1); return -ENOMEM; } kernel_buffer kzalloc(buffer_size, GFP_KERNEL); if (!kernel_buffer) { pr_err(mychardev: kzalloc failed\n); device_destroy(char_class, dev_num); class_destroy(char_class); cdev_del(char_cdev); unregister_chrdev_region(dev_num, 1); return -ENOMEM; } pr_info(mychardev: driver initialized successfully\n); return 0; } static void __exit mychardev_exit(void) { dev_t dev_num MKDEV(major_num, 0); kfree(kernel_buffer); device_destroy(char_class, dev_num); class_destroy(char_class); cdev_del(char_cdev); unregister_chrdev_region(dev_num, 1); pr_info(mychardev: driver exited\n); } module_init(mychardev_init); module_exit(mychardev_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple character device driver demo); MODULE_VERSION(1.0);4.3 编写 Makefile# 文件路径chardev_demo/Makefile obj-m : mychardev.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean4.4 编译、加载与验证make sudo insmod mychardev.ko dmesg | tail -10 ls -l /dev/mychardev正常情况下/dev/mychardev 节点会自动生成。写入和读取测试使用 echo 和 catecho hello driver | sudo tee /dev/mychardev sudo cat /dev/mychardev运行预期hello driver接着卸载模块sudo rmmod mychardev dmesg | tail -5日志中会出现 mychardev: device closed 与 mychardev: driver exited。4.5 代码与运行结果说明整个流程的关键点alloc_chrdev_region 由内核动态分配主设备号避免手动指定导致冲突。cdev_add 真正把设备加入内核。class_create device_create 是为了配合 udev 自动生成 /dev 节点。kzalloc 分配内核缓冲区注意在 exit 中 kfree 释放。dev_write 中把用户数据复制进内核缓冲区dev_read 再把缓冲区内容复制回用户空间。如果去掉 class_create 和 device_create/dev/mychardev 不会自动出现必须用 mknod 手动创建这对于新手排查问题是不小的干扰。5. 从裸字符设备到 platform 驱动与设备树5.1 为什么需要设备树早期 ARM Linux 中板级文件board-xxx.c里写满了硬件寄存器地址、中断号、GPIO 信息。每次换板子就要重新编译内核维护成本极高。设备树Device Tree用文本描述硬件资源将“硬件描述”与“驱动代码”解耦。同样是 LED 驱动只要设备树节点里的 reg 和 gpios 属性不同驱动代码就不用改。5.2 platform 驱动框架platform 总线是 Linux 内核虚拟出来的一条总线用于管理那些不挂在 USB、PCI 等物理总线上的设备例如 SoC 内部的 UART、I2C 控制器、GPIO、LED。一个最小 platform 驱动包含两部分platform_driver匹配设备并实现 probe / remove。设备侧信息通常来自设备树节点。5.3 示例配合设备树的 platform 驱动假设设备树中有如下节点led_demo: led-demo { compatible demo,led; reg 0x01c20800 0x10; status okay; };对应的 platform 驱动骨架// 文件路径platform_led_demo.c #include linux/init.h #include linux/module.h #include linux/platform_device.h #include linux/of.h #include linux/of_address.h static int led_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; res platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) { dev_err(pdev-dev, no memory resource found\n); return -ENXIO; } base devm_ioremap(pdev-dev, res-start, resource_size(res)); if (!base) { dev_err(pdev-dev, ioremap failed\n); return -ENOMEM; } dev_info(pdev-dev, LED driver probed, reg base 0x%llx\n, (unsigned long long)res-start); /* 这里可以继续对 base 指向的寄存器做读写 */ return 0; } static int led_remove(struct platform_device *pdev) { dev_info(pdev-dev, LED driver removed\n); return 0; } static const struct of_device_id led_of_match[] { { .compatible demo,led }, { } }; MODULE_DEVICE_TABLE(of, led_of_match); static struct platform_driver led_driver { .probe led_probe, .remove led_remove, .driver { .name led-demo, .of_match_table led_of_match, }, }; module_platform_driver(led_driver); MODULE_LICENSE(GPL); MODULE_DESCRIPTION(Platform LED driver demo);这里模块宏从 module_init/module_exit 变成了 module_platform_driver它会自动注册 platform_driver。probe 函数在设备匹配成功后回调remove 在设备移除时回调。5.4 面向嵌入式开发板的移植要点在真实开发板编译驱动时Makefile 需要指定交叉编译工具链和内核源码目录obj-m : platform_led_demo.o KDIR : /path/to/kernel/source CROSS : aarch64-linux-gnu- all: $(MAKE) ARCHarm64 CROSS_COMPILE$(CROSS) -C $(KDIR) M$(PWD) modules clean: $(MAKE) ARCHarm64 CROSS_COMPILE$(CROSS) -C $(KDIR) M$(PWD) clean注意交叉编译工具链前缀要和你的目标架构一致ARM32 常用 arm-linux-gnueabihf-ARM64 用 aarch64-linux-gnu-。编译好的 .ko 需要通过 adb、scp、NFS 或 SD 卡拷贝到开发板再执行 insmod。6. 常见问题与排查思路6.1 高频报错对照表问题现象常见原因解决思路insmod 报 invalid module format模块编译内核版本与当前运行内核不一致确认 KDIR 是否指向 uname -r 对应头文件路径报 Unknown symbol 或 module layout changed内核配置或 ABI 不匹配换用同版本内核编译环境重新编译模块/dev 节点不出现缺少 class_create/device_create或 udev 未触发检查驱动日志 dmesg确认设备号是否注册成功copy_to_user 报 EFAULT用户空间缓冲区指针无效确认传入的 buf 地址可读避免传入 NULLprobe 不执行compatible 不匹配或设备树未使能检查设备树 status 属性与 compatible 字符串加载模块后直接死机访问非法寄存器地址或空指针使用 iounmap 规范映射增加 NULL 检查printf 不打印但 printk 打印用户态和内核态 API 不同驱动中统一使用 printk/pr_info/pr_err6.2 驱动崩溃后的通用定位流程第一步确认日志。内核崩溃信息通常在 dmesg 或 /var/log/kern.log 中。dmesg | tail -100第二步确认模块符号。用 modinfo 查看模块信息是否正常modinfo mychardev.ko第三步逐个屏蔽怀疑点。先注释 open、read、write 中部分逻辑加载测试缩小范围。这种方式比“一次改很多再测试”更高效。第四步如果是开发板硬件相关用示波器或万用表确认硬件电平再回头看寄存器配置。很多“驱动 bug”其实是硬件初始化时序问题。6.3 如何避免踩坑每个驱动模块都加 MODULE_LICENSE(GPL)避免内核无法识别。动手前先确认内核版本阅读对应版本的 Documentation 与 include/linux 头文件。不要直接修改正在运行的内核所有危险操作先放在虚拟机中验证。涉及硬件寄存器时先读芯片手册确认寄存器地址、位宽、复位值。7. 驱动开发的工程最佳实践与安全建议7.1 错误处理与资源清理驱动代码处于内核态任何资源泄漏都会伴随内核生命周期存在。必须确保每个请求的资源都能在错误路径上释放。推荐的分配/释放配对方式kmalloc / kfreekzalloc / kfreeioremap / iounmapdevm_ioremap / 自动释放推荐设备驱动使用request_irq / free_irqalloc_chrdev_region / unregister_chrdev_regiondevm_ 系列 API 是最省心的选择。它把资源生命周期绑定到设备probe 失败或设备移除时自动释放能明显减少错误路径遗漏问题。7.2 日志分级与调试开关不要直接用 printk 到处打印。内核提供分级日志宏pr_emerg / pr_alert / pr_crit致命错误。pr_err错误。pr_warn警告。pr_info正常信息。pr_debug / pr_devel调试信息。pr_debug 在开启 DEBUG 宏时才会输出。可以用动态调试机制控制某些文件的日志开关避免生产环境刷屏。7.3 并发与锁的使用原则驱动运行在中断上下文、进程上下文、多核环境中共享数据必须加锁。基本原则读多写少用 rwlock 或 seqlock。临界区短且不睡眠用 spinlock。临界区可能进入休眠状态用 mutex。中断处理函数中不能使用会导致休眠的锁。尽量避免在锁内调用复杂 API。新手写驱动最容易忽略并发问题。比如一个全局缓冲区同时被 read、write、ioctl 访问如果不加锁多核环境下很容易出现数据错乱。7.4 安全边界设备节点在 /dev 下普通用户也经常能访问。驱动作为内核与用户空间的边界必须做安全检查使用 copy_to_user/copy_from_user不要直接访问用户指针。检查用户传入的长度参数防止缓冲区溢出。ioctl 命令号建议使用 _IOR/_IOW 宏规范化定义。敏感硬件的驱动不要在模块中硬编码密钥等机密信息。7.5 代码规范与可维护性函数命名使用模块前缀例如 mychardev_read。头文件包含按字母序整理避免重复包含。用 container_of 从结构体成员反向获取外层结构体。为设备树 compatible 命名使用“厂商,功能”格式例如 “demo,led”。7.6 生产环境变更流程如果在生产环境更新驱动必须遵循最小风险原则先在测试环境加载新驱动并做压力测试。备份当前内核模块与内核版本信息。准备回滚方案保留旧 .ko、旧内核镜像。分批灰度先在一台机器验证。变更期间开启内核日志采集出现异常及时回滚。内核驱动一旦在线上环境崩溃往往不是“进程退出”而是整机 panic影响面远大于普通应用层故障。这条流程不是可选项是底线。8. 总结与后续学习路线这篇文章以《手把手教你学Linux设备驱动开发》出版为引子把设备驱动学习路径完整拆解了一遍。从内核模块的 hello world 开始到字符设备驱动的完整实现再到设备树与 platform 驱动的进阶改造最后汇总了常见报错和工程落地建议。掌握这些内容后你已经具备独立阅读内核文档、编写简单字符驱动、理解设备树匹配机制的能力。如果你准备继续深入我建议按以下顺序推进第一阶段熟练编写内核模块掌握 module_init/module_exit、printk、模块参数。第二阶段完成一个可读写的字符设备理解 file_operations 与数据拷贝。第三阶段在开发板上移植驱动掌握设备树、platform 驱动、devm_ API。第四阶段学习中断子系统、tasklet、workqueue、内核定时器。第五阶段进阶内核机制如 regmap、IIO 子系统、input 子系统、DMA 与内存屏障。第六阶段阅读真实内核源码尝试为开源开发板提交驱动补丁。设备驱动开发确实需要耐心它不像 Web 开发那样能快速看到页面效果。每跑通一个点、每定位一个内核崩溃都会让下一段路顺畅很多。如果这篇文章对你有帮助可以收藏备用后续我会继续更新中断处理、设备树语法、常见总线驱动等更深入的实战内容。