正点原子 I.MX6ULL MINI 开发板学习笔记:第一个 Linux 驱动——内核模块与 LED/蜂鸣器字符设备

系统跑起来之后,这篇开始写第一个 Linux 驱动。代码全部来自实际工程 code/2026/20260803(01_drv_template、02_drv_led、03_drv_beep),在开发板上验证通过:先写最简单的内核模块,再升级成能控制 LED 的字符设备驱动,最后照同样套路驱动蜂鸣器。
1. 完成什么实验?
编写并加载一个 LED 字符设备驱动(.ko):insmod 后生成设备号、ioremap 映射 GPIO 寄存器,应用程序通过 /dev/myled0 设备节点 write 控制灯亮灭、read 读回灯状态;蜂鸣器驱动完全复用同一框架。
2. 内核模块是什么?
内核模块(.ko)是可以动态加载到正在运行的内核中的代码。和裸机编程最大的区别:驱动代码跑在内核态,不能直接访问物理地址(要 ioremap),也不能随便打印浮点数(printk/pr_info 代替 printf)。加载用 insmod,卸载用 rmmod,打印信息用 dmesg 查看。
3. 最简单的内核模块模板
这是 01_drv_template 的完整代码,只包含加载/卸载框架,是所有驱动的起点:
#include <linux/init.h> /* __init / __exit */
#include <linux/module.h> /* module_init / module_exit / MODULE_LICENSE */
/* 模块入口:insmod 时由内核调用,返回 0 成功、负值失败 */
static int __init mydrv_init(void)
{
pr_info("驱动安装成功ok\n");
return 0;
}
/* 模块出口:rmmod 时由内核调用,释放 init 中申请的资源 */
static void __exit mydrv_exit(void)
{
pr_info("驱动卸载成功ok\n");
return;
}
module_exit(mydrv_exit); /* 注册出口函数 */
module_init(mydrv_init); /* 注册入口函数 */
MODULE_LICENSE("GPL"); /* 许可证声明,缺失会 taint 内核且无法使用 GPL 符号 */
4. 字符设备驱动框架
Linux 驱动里字符设备占绝对多数(GPIO、I2C、SPI、UART 等都是)。注册一个字符设备驱动要做四件事:
- 申请设备号:alloc_chrdev_region 动态分配主/次设备号(/proc/devices 可查);
- 准备 file_operations:把应用层的 open/read/write/close 对应到驱动里的函数指针;
- 注册 cdev:cdev_init + cdev_add,把设备号和操作集绑定,挂进内核;
- 操作硬件:ioremap 把寄存器物理地址映射成虚拟地址,然后 readl/writel 读写。
卸载时必须按相反顺序释放:iounmap → cdev_del → unregister_chrdev_region,数量参数要和申请时一致。
5. LED 字符设备驱动完整代码
02_drv_led 的 drv.c 核心代码(已验证可用):
#include <asm/io.h> /* ioremap、readl/writel */
#include <linux/cdev.h> /* struct cdev、cdev_init / cdev_add / cdev_del */
#include <linux/fs.h> /* file_operations、alloc_chrdev_region */
#include <linux/init.h>
#include <linux/module.h>
#include <linux/uaccess.h>
#define ADDR_MUX 0x20E0068 /* 引脚复用寄存器 */
#define ADDR_PAD 0x20E02F4 /* 引脚电气属性寄存器 */
#define LED_DIR 0x209c004 /* GPIO1 方向寄存器 GDIR */
#define LED_DATA 0x209c000 /* GPIO1 数据寄存器 DR */
static volatile void __iomem *pmux = NULL;
static volatile void __iomem *ppad = NULL;
static volatile void __iomem *pdir = NULL;
static volatile void __iomem *pdata = NULL;
static dev_t dev_no; /* 设备号(高 12 位主设备号 + 低 20 位次设备号) */
ssize_t my_read(struct file *f, char __user *userdata, size_t size, loff_t *off);
ssize_t my_write(struct file *f, const char __user *userdata, size_t size, loff_t *off);
int my_open(struct inode *inode, struct file *f);
int my_release(struct inode *inode, struct file *f);
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
static struct cdev mycdev = {
.owner = THIS_MODULE,
.ops = &fops,
};
/* 读 LED 状态:copy_to_user 把数据送回用户态 */
ssize_t my_read(struct file *f, char __user *userdata, size_t size, loff_t *off)
{
int sw = 0;
u32 v = 0;
v = readl(pdata);
sw = (v >> 3) & 1; /* 取 LED 所在 bit3:1=灭(高电平),0=亮(低电平) */
copy_to_user(userdata, &sw, sizeof(sw));
return sizeof(sw);
}
/* 写 1 亮灯、写 0 灭灯:copy_from_user 从用户态取数据 */
ssize_t my_write(struct file *f, const char __user *userdata, size_t size, loff_t *off)
{
int sw = 0;
u32 v = 0;
copy_from_user(&sw, userdata, size);
v = readl(pdata);
if (1 == sw)
v &= ~(1 << 3); /* 清 bit3 输出低电平:点亮 */
else if (0 == sw)
v |= (1 << 3); /* 只置 bit3,不能整体覆盖其他位 */
writel(v, pdata);
return 0;
}
int my_open(struct inode *inode, struct file *f) { return 0; }
int my_release(struct inode *inode, struct file *f) { return 0; }
static int __init mydrv_init(void)
{
u32 v = 0;
int ret = 0;
/* 动态申请 10 个连续次设备号,名称 "myled",见 /proc/devices */
ret = alloc_chrdev_region(&dev_no, 0, 10, "myled");
if (ret < 0)
return ret;
cdev_init(&mycdev, &fops);
ret = cdev_add(&mycdev, dev_no, 10);
if (ret < 0)
goto fail_region; /* 只需释放设备号 */
/* 映射 LED 相关寄存器,任一失败跳转倒序释放 */
pmux = ioremap(ADDR_MUX, 4);
if (NULL == pmux) { ret = -ENOMEM; goto fail_cdev; }
ppad = ioremap(ADDR_PAD, 4);
if (NULL == ppad) { ret = -ENOMEM; goto fail_unmap_pmux; }
pdir = ioremap(LED_DIR, 4);
if (NULL == pdir) { ret = -ENOMEM; goto fail_unmap_ppad; }
pdata = ioremap(LED_DATA, 4);
if (NULL == pdata) { ret = -ENOMEM; goto fail_unmap_pdir; }
writel(0x05, pmux); /* 复用为 GPIO1_IO03 */
writel(0x10b0, ppad); /* 电气属性 */
v = readl(pdir);
v |= (1 << 3); /* GDIR bit3 置 1:输出模式 */
writel(v, pdir);
v = readl(pdata);
v |= (1 << 3); /* DR bit3 置 1:默认熄灭 */
writel(v, pdata);
pr_info("主设备号:%d, 次设备号:%d\r\n", MAJOR(dev_no), MINOR(dev_no));
return 0;
/* 倒序释放链:跳到哪个标签就释放到哪一步,然后自然下落 */
fail_unmap_pdir:
iounmap(pdir);
fail_unmap_ppad:
iounmap(ppad);
fail_unmap_pmux:
iounmap(pmux);
fail_cdev:
cdev_del(&mycdev);
fail_region:
unregister_chrdev_region(dev_no, 10);
return ret;
}
static void __exit mydrv_exit(void)
{
iounmap(pmux);
iounmap(ppad);
iounmap(pdir);
iounmap(pdata);
cdev_del(&mycdev);
unregister_chrdev_region(dev_no, 10); /* 数量必须与申请时一致 */
}
module_exit(mydrv_exit);
module_init(mydrv_init);
MODULE_LICENSE("GPL");6. 测试程序与设备节点
这个版本的驱动不会自动创建设备节点,insmod 后要手动建:先 dmesg 或 cat /proc/devices 查到主设备号(假设是 248),再 mknod:
insmod drv_led.ko
cat /proc/devices | grep myled # 查到主设备号,例如 248
mknod /dev/myled0 c 248 0 # c=字符设备,248=主设备号,0=次设备号
./drv_led # 运行测试程序#define DEV_PATH "/dev/myled0"
int main(void)
{
int sw = 0;
int read_buf = 0;
int fd = open(DEV_PATH, O_RDWR); /* 触发 my_open */
if (fd < 0) { printf("打开设备失败error\n"); return -1; }
while (1)
{
sw = 0;
write(fd, &sw, sizeof(sw)); /* 灭灯 */
sleep(1);
read(fd, &read_buf, sizeof(read_buf));
printf("read_buf = %d\r\n", read_buf);
sw = 1;
write(fd, &sw, sizeof(sw)); /* 亮灯 */
sleep(1);
read(fd, &read_buf, sizeof(read_buf));
printf("read_buf = %d\r\n", read_buf);
}
close(fd); /* 触发 my_release */
return 0;
}7. 编译:内核模块的 Makefile
内核模块不在内核源码目录里,但要借内核源码树的 kbuild 系统编译(前提:内核源码已经完整编译过一遍)。 drv/Makefile 的关键几行:
KER_DIR := /home/linux/imx6ull/linux-imx-rel_imx_4.1.15_2.1.0_ga_alientek
CUR_DIR := $(shell pwd)
TARGET ?= drv
obj-m = $(TARGET).o # obj-m 决定编出 $(TARGET).ko
$(TARGET)-y := drv.o # 模块名与源文件名不同时,指定组成模块的 .o
all:
$(MAKE) -C $(KER_DIR) M=$(CUR_DIR) modules # -C 进内核树,M= 指定模块目录
cp $(TARGET).ko ~/nfs/rootfs # 部署到 NFS 根文件系统KER_DIR 指向的内核必须和开发板运行的内核版本一致,否则 insmod 报 vermagic 不匹配(Invalid module format)。编译原理的完整展开(树内编译进 zImage vs 树外 .ko)放到本系列总结篇《Linux 驱动架构详解》里讲。
8. 同样的套路驱动蜂鸣器
03_drv_beep 与 LED 驱动结构一模一样,只换了寄存器地址和位。蜂鸣器接 GPIO5_IO01,低电平响:
| 项目 | LED(02) | 蜂鸣器(03) |
|---|---|---|
| 复用寄存器 | 0x20E0068 | 0x229000C |
| 电气属性寄存器 | 0x20E02F4 | 0x2290050 |
| 方向/数据寄存器 | 0x209c004 / 0x209c000(GPIO1) | 0x20AC004 / 0x20AC000(GPIO5) |
| 操作位 | bit3 | bit1 |
| 默认状态 | bit3 置 1:熄灭 | bit1 置 1:不响 |
也就是说:换一个设备,只需要改 5 个常量和注释,字符设备框架一行不动。这就是框架的价值。
9. 关键点说明
- copy_to_user / copy_from_user:内核态和用户态内存不能直接 memcpy,必须用这两个函数交换数据。
- ioremap:驱动访问寄存器前必须把物理地址映射为虚拟地址,rmmod 前 iounmap。
- 语义记清:驱动里 write 1 = 亮(清 bit3 输出低电平)、write 0 = 灭;read 返回的 0 表示亮、1 表示灭。测试 app 里 灯开/灯关 的打印标签恰好与驱动语义相反,看状态以 dmesg 的 sw 值为准——这是工程源码里留的一个小瑕疵。
- 只改要改的位:写 DR 前先 readl,只置/清目标位再 writel,其他位可能被内核 GPIO 子系统使用。
- register_chrdev 已过时:2.6 以后推荐 alloc_chrdev_region + cdev 的组合。
- goto 错误处理:init 里每申请一步资源都可能失败,内核惯例是用 goto 建倒序释放链——跳到哪个标签就释放到哪一步,然后自然下落,比层层 if 清晰且不会漏释放。工程代码已加上这套处理。
10. 实验现象
开发板 NFS 启动后:insmod drv_led.ko,dmesg 打印主设备号;mknod 建好 /dev/myled0 后运行测试程序,LED 以 1 秒间隔亮灭切换,串口同步打印 read_buf 值;rmmod drv_led 后设备节点失效,再 open 会失败。蜂鸣器换成 drv_beep.ko 后现象相同,只是响的是蜂鸣器。
总结
| 问题 | 答案 |
|---|---|
| 内核模块入口/出口? | module_init / module_exit 注册的函数 |
| 字符设备注册四步? | 申请设备号 → 备 file_operations → cdev_add → ioremap 操作硬件 |
| 应用怎么调到驱动? | open/read/write → 系统调用 → VFS → cdev → file_operations |
| 内核/用户态怎么传数据? | copy_to_user / copy_from_user |
| 设备节点怎么来的? | 本篇手动 mknod;下篇用 class_create 自动生成 |
手动 mknod 挺麻烦,而且主设备号每次可能不同。下一篇用 class_create + device_create 让内核自动生成 /dev 节点,再加一个 sysfs 属性文件,用 cat/echo 就能看灯、控灯。