驱动开发基础:字符设备驱动模型、Kernel 模块编译、与 HAL 层的交互
说实话,很多做 Android 应用开发的朋友,一听到「驱动」两个字就头大。我当年刚接触嵌入式时也一样,觉得这玩意儿离我太远了。但后来我发现,搞懂驱动开发,尤其是字符设备驱动,是理解整个 Android 系统底层的关键一步。
今天我们就来聊聊这个。我会从最基础的字符设备模型讲起,然后带你走一遍内核模块的编译流程,最后再聊聊驱动怎么跟 HAL 层打交道。嗯,都是我在项目里踩过坑的地方。
字符设备驱动模型:最基础的 I/O 抽象
Linux 设备驱动分三大类:字符设备、块设备、网络设备。咱们做嵌入式 Android,打交道最多的就是字符设备。说白了,字符设备就是按字节流读写数据的设备——你想想看,串口、GPIO、I2C、SPI,哪个不是这样?
字符设备驱动的核心结构其实很简单。我习惯把它理解成「一个文件 + 一堆操作函数」。在 Linux 里,一切皆文件。设备也是文件,用户空间通过 open()、read()、write()、ioctl() 这些系统调用来操作设备。驱动要做的,就是实现这些操作。
关键数据结构就两个:
struct file_operations:定义设备支持的操作函数指针struct cdev:字符设备的内核表示
我贴一段最精简的模板代码,你一看就明白:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#define DEVICE_NAME "my_char_dev"
#define CLASS_NAME "my_class"
static int major;
static struct class *my_class;
static struct cdev my_cdev;
static int my_open(struct inode *inode, struct file *file) {
pr_info("Device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *file) {
pr_info("Device closed\n");
return 0;
}
static ssize_t my_read(struct file *file, char __user *buf,
size_t count, loff_t *offset) {
// 这里实现从硬件读取数据
return 0;
}
static ssize_t my_write(struct file *file, const char __user *buf,
size_t count, loff_t *offset) {
// 这里实现向硬件写入数据
return count;
}
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
static int __init my_init(void) {
// 动态分配主设备号
major = register_chrdev(0, DEVICE_NAME, &fops);
if (major < 0) {
pr_err("Failed to register device\n");
return major;
}
// 创建设备类
my_class = class_create(THIS_MODULE, CLASS_NAME);
// 创建设备节点
device_create(my_class, NULL, MKDEV(major, 0), NULL, DEVICE_NAME);
pr_info("Module loaded with major number %d\n", major);
return 0;
}
static void __exit my_exit(void) {
device_destroy(my_class, MKDEV(major, 0));
class_destroy(my_class);
unregister_chrdev(major, DEVICE_NAME);
pr_info("Module unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver");
这段代码虽然简单,但五脏俱全。你注意看 register_chrdev 传了 0 进去——这是让内核自动分配主设备号。我个人建议新手都用这种方式,省得跟别人冲突。
核心要点:字符设备驱动的本质就是注册 file_operations,然后告诉内核「这个设备号归我管」。用户空间读写设备文件时,内核会通过 VFS 层找到对应的驱动函数来执行。
Kernel 模块编译:从源码到 .ko 文件
驱动写好了,怎么编译?你不能直接用 gcc,得用内核的构建系统。Android 底层开发中,我们通常有两种方式:
- 静态编译进内核:把驱动代码放到内核源码树里,配置后编译
- 动态模块编译:编译成 .ko 文件,运行时用
insmod加载
做定制 ROM 时,我更喜欢用动态模块。为什么呢?因为调试方便。改一行代码,重新编译模块,push 到设备上,rmmod 再 insmod 就完事了。不用重新烧写整个 boot.img,省时间。
编译模块需要一个 Makefile,我贴一个我常用的模板:
obj-m := my_char_dev.o
# 指定内核源码路径,AOSP 编译环境下通常是 out/ 目录下的内核
KDIR := /path/to/your/kernel/source
# 交叉编译工具链前缀
CROSS_COMPILE := aarch64-linux-android-
all:
$(MAKE) -C $(KDIR) M=$(PWD) ARCH=arm64 \
CROSS_COMPILE=$(CROSS_COMPILE) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) ARCH=arm64 clean
这里有个坑,我曾经踩过——内核版本必须匹配。你编译模块用的内核源码,跟你设备上跑的内核必须是同一个版本。否则 insmod 时会报 Invalid module format 错误。我那次折腾了一下午才发现是内核版本号差了一个小版本。
避坑指南:我曾经在 AOSP 的 out/target/product/xxx/obj/KERNEL_OBJ 目录下找内核头文件,结果发现模块编译出来加载不了。后来才意识到,必须用编译内核时生成的 Module.symvers 文件。建议直接用 make modules_prepare 生成好依赖再编译。
编译完成后,你会得到一个 .ko 文件。用 adb push 到设备上,然后:
# 加载模块
insmod /data/local/tmp/my_char_dev.ko
# 查看模块是否加载成功
lsmod | grep my_char_dev
# 查看设备节点
ls -l /dev/my_char_dev
# 测试读写
echo "hello" > /dev/my_char_dev
cat /dev/my_char_dev
# 卸载模块
rmmod my_char_dev
你想想看,整个过程是不是跟写一个普通的 Linux 驱动没什么区别?嗯,本质上确实一样。但 Android 的特殊之处在于——它上面还盖了一层 HAL。
与 HAL 层的交互:驱动和 Framework 的桥梁
驱动写好了,用户空间能通过 /dev 节点访问了。但 Android 的 App 不能直接 open 设备文件——权限不够,也不安全。所以 Google 设计了 HAL(硬件抽象层)。
HAL 层说白了就是一个用户空间的共享库(.so),它封装了对驱动的操作,向上提供统一的接口给 Framework 调用。我画了一张图,帮你理清这个关系:
从这张图你能看到,HAL 层夹在 Framework 和内核驱动之间。它做的事情其实很简单:
- 打开
/dev/xxx设备节点 - 调用
ioctl()、read()、write()与驱动通信 - 把数据封装成 Framework 能理解的格式
我举个例子。假设你有一个指纹传感器,驱动里实现了 ioctl 命令:
#define FINGERPRINT_IOCTL_INIT _IO('f', 0x01)
#define FINGERPRINT_IOCTL_SCAN _IOW('f', 0x02, struct scan_param)
#define FINGERPRINT_IOCTL_GET_IMAGE _IOR('f', 0x03, struct image_data)
HAL 层对应的代码大概长这样:
// fingerprint_hal.c
#include <hardware/hardware.h>
#include <hardware/fingerprint.h>
#include <fcntl.h>
#include <sys/ioctl.h>
static int fingerprint_open(const struct hw_module_t* module,
const char* id,
struct hw_device_t** device) {
struct fingerprint_device_t *dev;
dev = malloc(sizeof(struct fingerprint_device_t));
// 打开驱动设备节点
dev->fd = open("/dev/fingerprint", O_RDWR);
if (dev->fd < 0) {
return -1;
}
// 初始化硬件
ioctl(dev->fd, FINGERPRINT_IOCTL_INIT);
// 注册操作函数
dev->common.tag = HARDWARE_DEVICE_TAG;
dev->enroll = fingerprint_enroll;
dev->authenticate = fingerprint_authenticate;
*device = &dev->common;
return 0;
}
static int fingerprint_enroll(struct fingerprint_device_t *dev,
const char *data, int len) {
struct scan_param param;
param.data = data;
param.len = len;
return ioctl(dev->fd, FINGERPRINT_IOCTL_SCAN, ¶m);
}
个人经验:我建议你在 HAL 层多做一层错误处理。驱动返回的错误码往往很底层(比如 -EIO、-EAGAIN),HAL 层应该把这些转换成 Framework 能理解的错误码。我曾经见过一个项目,驱动返回 -EAGAIN 表示「硬件忙」,但 HAL 层直接透传,导致 Framework 那边以为是致命错误,直接弹窗让用户重启手机。
HAL 层编译后会生成一个 .so 文件,放在 /vendor/lib/hw/ 或 /system/lib/hw/ 目录下。命名规则是 <module>.<variant>.so,比如 fingerprint.default.so。
Framework 通过 hw_get_module() 来加载这个 .so:
// Framework 端 (C++ 或 Java 通过 JNI)
fingerprint_module_t *module;
hw_get_module(FINGERPRINT_HARDWARE_MODULE_ID,
(const hw_module_t**)&module);
fingerprint_device_t *device;
module->common.methods->open(&module->common, NULL,
(hw_device_t**)&device);
// 现在可以调用 HAL 接口了
device->enroll(device, data, len);
你看,整个调用链就是:App → Framework → JNI → HAL (.so) → 驱动 (.ko) → 硬件。每一层各司其职,耦合度很低。这也是 Android 架构的精妙之处——你可以换掉底层的驱动,只要 HAL 接口不变,上层完全不受影响。
总结一下:字符设备驱动是基础,内核模块编译是手段,HAL 层是桥梁。这三者合在一起,构成了 Android 硬件支持的核心链路。做定制 ROM 时,你大概率需要修改或新增驱动,然后写对应的 HAL 实现。掌握了这套流程,你就真正理解了「从 AOSP 到定制 ROM」的底层逻辑。
嗯,今天就聊到这里。驱动开发这东西,光看代码是不够的,一定要动手试。找个开发板,写个简单的 GPIO 驱动,再写个 HAL 层让 App 能控制 LED 亮灭——做完这个实验,你就入门了。