从零构建一个基于 platform 的字符设备驱动:不只是“Hello World”
你有没有遇到过这种情况——在写一个嵌入式 Linux 驱动时,直接把硬件地址写死在代码里?比如:
#define MY_ADC_BASE 0x12000000然后用ioremap(MY_ADC_BASE, SZ_4K)映射寄存器。看似能跑,但一旦换了块板子、改了地址,就得重新编译驱动……更别提多个 SoC 平台共用同一份驱动的噩梦。
这正是现代 Linux 内核引入platform总线机制要解决的问题:让驱动不再“绑定”硬件细节。
今天我们就来手把手实现一个完整的、基于platform的字符设备驱动。这不是简单的 “Hello World”,而是一个贴近真实项目的工程实践模板,涵盖设备树匹配、资源管理、字符设备注册和安全释放等关键环节。
为什么需要 platform 驱动?
在传统的 PCI 或 USB 设备中,设备可以“自报家门”:插上就能被系统发现并分配资源。但大多数嵌入式 SoC 上的外设(如 ADC、PWM 控制器)是焊死在芯片里的,它们没有即插即用能力。
Linux 内核怎么办?搞了个“虚拟总线”——platform_bus_type,它不对应任何物理总线,而是用来承载那些“无法自我识别”的平台级设备。
于是就有了两个核心角色:
-platform_device:描述一个静态存在的设备(由设备树或板级代码创建)
-platform_driver:提供对该设备的操作逻辑
两者通过名字或.compatible字段自动匹配,实现“解耦”。
📌 关键洞察:
platform框架的本质,是把硬件资源配置权交给系统(通常是设备树),驱动只负责“使用”。这种设计极大提升了可移植性和模块化程度。
核心结构解析:device 和 driver 如何配对?
platform_device—— 硬件信息的容器
这个结构体并不需要你在驱动里手动定义。它通常来自:
- 旧方式:板级 C 文件中的静态数组(已淘汰)
- 新方式:设备树节点自动转换而来
例如,我们有一个内存映射的 ADC 控制器:
// myboard.dts myadc: adc@12000000 { compatible = "mycompany,mychardev"; reg = <0x12000000 0x1000>; // 寄存器范围:1KB interrupts = <GIC_SPI 10 IRQ_TYPE_LEVEL_HIGH>; // 使用 SPI 中断,号为 10 clocks = <&clkc 12>; // 依赖某个时钟源 };当内核启动时,of_platform_default_populate()会扫描所有未匹配的节点,并为每个带有compatible属性的节点生成一个platform_device实例。
你可以把它理解为:“这是我的设备说明书,请找对应的司机来开。”
platform_driver—— 真正干活的人
这才是我们要写的部分。它的骨架长这样:
static struct platform_driver my_platform_driver = { .probe = my_platform_probe, .remove = my_platform_remove, .driver = { .name = "mychardev", .of_match_table = of_match_ptr(my_platform_dt_ids), .owner = THIS_MODULE, }, };重点看.of_match_table,它是连接设备树的关键桥梁:
static const struct of_device_id my_platform_dt_ids[] = { { .compatible = "mycompany,mychardev" }, { } /* 必须以空项结尾 */ };只要设备树里的compatible和这里的字符串一致,内核就会调用.probe函数。
✅ 小贴士:推荐始终使用设备树匹配而非
.name匹配,因为前者更精确、更灵活,也符合主流开发规范。
字符设备怎么接入?四步走策略
现在问题来了:如何在一个platform_driver里注册一个用户态可用的/dev/mychardev?
答案是在probe()函数中完成以下四个步骤:
- 获取设备资源
- 映射寄存器空间
- 注册字符设备
- 创建设备文件节点
我们逐个拆解。
第一步:从 device 获取资源
不要硬编码地址!要用标准 API 动态获取:
static int my_platform_probe(struct platform_device *pdev) { struct resource *res; /* 获取内存资源 */ res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) { dev_err(&pdev->dev, "No memory resource\n"); return -ENXIO; } /* 获取中断资源 */ int irq = platform_get_irq(pdev, 0); if (irq < 0) return irq; dev_info(&pdev->dev, "Got mem: %pa, irq: %d\n", &res->start, irq); ... }platform_get_resource()是通用接口,支持 MEM、IRQ、DMA 等多种类型。第二个参数是索引,适用于多组同类资源的情况。
第二步:安全映射寄存器(强烈建议用 devm_*)
传统做法是ioremap()+ 手动iounmap(),但容易漏掉释放导致内存泄漏。
更好的方式是使用managed resource API(devm_系列),它们会在驱动卸载或 probe 失败时自动清理:
void __iomem *base; base = devm_ioremap_resource(&pdev->dev, res); if (IS_ERR(base)) return PTR_ERR(base);你看,连错误处理都简洁了。而且即使后面某一步出错,也不用手动回滚iounmap—— 内核已经帮你记住了。
第三步:动态注册字符设备
字符设备的核心是struct cdev和file_operations。我们先定义操作函数集:
static ssize_t my_read(struct file *file, char __user *buf, size_t len, loff_t *offset) { dev_info(file->f_inode->i_private, "read invoked\n"); return 0; } static long my_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { dev_info(file->f_inode->i_private, "ioctl cmd=%u\n", cmd); return 0; } static const struct file_operations fops = { .owner = THIS_MODULE, .read = my_read, .write = my_write, .unlocked_ioctl = my_ioctl, .open = my_open, .release = my_release, };接着动态申请设备号并注册:
static dev_t dev_num; /* 主次设备号 */ static struct class *my_class; /* 设备类 */ static struct cdev my_cdev; /* 字符设备 */ int ret = alloc_chrdev_region(&dev_num, 0, 1, "mychardev"); if (ret < 0) { dev_err(&pdev->dev, "Failed to allocate device number\n"); return ret; }为什么不推荐register_chrdev_region()?因为它要求你知道主设备号,而很多号已被占用或保留,容易冲突。动态分配才是现代驱动的标配。
第四步:自动生成 /dev 节点
以前要手动mknod,现在全靠 udev 规则配合class_create和device_create自动搞定:
/* 创建设备类 */ my_class = class_create(THIS_MODULE, "myclass"); if (IS_ERR(my_class)) { ret = PTR_ERR(my_class); goto fail_class; } /* 在 /sys/class/myclass 下创建设备,并自动生成 /dev/mychardev */ struct device *dev = device_create(my_class, NULL, dev_num, NULL, "mychardev"); if (IS_ERR(dev)) { ret = PTR_ERR(dev); goto fail_device; }这样一来,模块一加载,/dev/mychardev就出现了,完全无需用户干预。
完整驱动框架整合
下面是我们整合后的完整驱动代码(精简版):
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #include <linux/io.h> #define DEVICE_NAME "mychardev" #define CLASS_NAME "myclass" static dev_t dev_num; static struct cdev my_cdev; static struct class *my_class; static const struct of_device_id my_platform_dt_ids[] = { { .compatible = "mycompany,mychardev" }, { } /* NULL terminator */ }; MODULE_DEVICE_TABLE(of, my_platform_dt_ids); /* 文件操作函数略... */ static int my_platform_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; int ret; /* --- 获取资源 --- */ res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -ENXIO; /* --- 映射寄存器 --- */ base = devm_ioremap_resource(&pdev->dev, res); if (IS_ERR(base)) return PTR_ERR(base); /* --- 分配设备号 --- */ ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME); if (ret < 0) { dev_err(&pdev->dev, "alloc_chrdev_region failed\n"); return ret; } /* --- 注册 cdev --- */ cdev_init(&my_cdev, &fops); my_cdev.owner = THIS_MODULE; ret = cdev_add(&my_cdev, dev_num, 1); if (ret < 0) { unregister_chrdev_region(dev_num, 1); return ret; } /* --- 创建设备类与节点 --- */ my_class = class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(my_class)) { cdev_del(&my_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(my_class); } if (IS_ERR(device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME))) { class_destroy(my_class); cdev_del(&my_cdev); unregister_chrdev_region(dev_num, 1); return -ENODEV; } /* 保存私有数据(可选) */ platform_set_drvdata(pdev, base); dev_info(&pdev->dev, "Driver probed successfully, major=%d\n", MAJOR(dev_num)); return 0; } static int my_platform_remove(struct platform_device *pdev) { device_destroy(my_class, dev_num); class_destroy(my_class); cdev_del(&my_cdev); unregister_chrdev_region(dev_num, 1); dev_info(&pdev->dev, "Driver removed\n"); return 0; } static struct platform_driver my_platform_driver = { .probe = my_platform_probe, .remove = my_platform_remove, .driver = { .name = "mychardev", .of_match_table = of_match_ptr(my_platform_dt_ids), .owner = THIS_MODULE, }, }; module_platform_driver(my_platform_driver); MODULE_AUTHOR("Engineer X"); MODULE_DESCRIPTION("A real-world platform character device driver"); MODULE_LICENSE("GPL");工程最佳实践清单
写驱动不是跑通就行,还要考虑健壮性、可维护性和兼容性。以下是我在实际项目中总结的几条铁律:
| 项目 | 推荐做法 |
|---|---|
| 资源申请 | 全部使用devm_*系列(devm_kmalloc,devm_request_irq,devm_ioremap_resource) |
| 错误处理 | 使用goto统一跳转到 cleanup 标签,避免重复释放 |
| 日志输出 | 使用dev_info/dev_err替代printk,自带设备上下文 |
| 并发控制 | 多进程访问时加mutex或semaphore |
| 设备树检查 | 添加MODULE_DEVICE_TABLE(of, ...),确保 depmod 正常工作 |
| 模块加载 | 使用module_platform_driver()宏,省去 init/exit 函数 |
举个例子,如果你用了中断:
ret = devm_request_threaded_irq(&pdev->dev, irq, my_interrupt_handler, my_thread_fn, IRQF_ONESHOT, "mychardev", pdev);不仅不用手动释放,还能保证在 probe 失败时自动注销。
常见坑点与避坑秘籍
❌ 坑一:忘记 MODULE_DEVICE_TABLE
现象:驱动加载时报No such device or address,明明 compatible 对得上。
原因:没有导出设备表,modprobe 找不到匹配项。
✅ 解法:加上这一行!
MODULE_DEVICE_TABLE(of, my_platform_dt_ids);❌ 坑二:cdev_del 放错了位置
现象:模块卸载后再次加载失败,提示“Device or resource busy”。
原因:cdev_add成功但后续步骤失败(如 device_create),却没有调用cdev_del。
✅ 解法:严格按照申请顺序反向释放,且每一步都要判断是否成功再释放。
❌ 坑三:open 函数返回负数却没清零 file->private_data
后果:后续 read/write 可能访问非法指针。
✅ 解法:在 open 失败路径上务必确保不会留下脏状态。
这套模式能用在哪?
这套基于platform+ 字符设备的设计范式,广泛应用于各种专用控制器场景:
- 自定义 ADC/DAC 模块
- FPGA 扩展接口(如 PCIe endpoint 上的逻辑块)
- 特殊通信协议处理器
- 工业 I/O 板卡
- 音频采集前端
- 功率监控单元
只要你面对的是“内存映射寄存器 + 固定资源”的硬件模型,这套方案就是首选。
结语:掌握这套思维,你就掌握了内核驱动的“普通话”
很多人觉得 Linux 驱动难学,其实是没抓住主线。
platform驱动 + 设备树 + 字符设备注册,构成了现代嵌入式 Linux 驱动开发的“黄金三角”。掌握了它,你就不只是会抄 demo,而是真正理解了内核的设备模型思想。
下一步你可以尝试:
- 把这个驱动改成支持多个次设备号(minor)
- 加入 miscdevice 简化注册流程
- 引入 DMAengine 进行高效数据搬运
- 结合 input subsystem 上报事件
但无论走多远,起点都是今天这一课。
如果你正在调试自己的 platform 驱动,欢迎留言交流具体问题,我们一起踩坑、填坑。