从零开始:IMX6ULL 最简单的 LED 驱动程序(小白也能跟着做)

张开发
2026/4/14 20:31:45 15 分钟阅读

分享文章

从零开始:IMX6ULL 最简单的 LED 驱动程序(小白也能跟着做)
目录写在前面一、硬件回顾LED 接在哪个引脚要操作这个 LED我们需要配置 3 类寄存器参考 PPT 03_IMX6ULL的LED操作方法.pptx二、字符设备驱动框架5 分钟看懂三、驱动代码逐行解析led_drv.c 重点逐行解释1. 为什么寄存器指针要加 volatile2. copy_from_user 是什么3. ioremap 的作用4. class_create / device_create5. module_init / module_exit四、测试程序ledtest.c五、Makefile 解析六、上机实验一步一步来6.1 设置交叉编译工具链6.2 修改 Makefile 中的 KERN_DIR6.3 编译6.4 把文件传到开发板6.5 加载驱动并测试七、常见问题与调试技巧❌ 加载驱动时报错version magic 4.9.88 should be 4.9.88-g12345678❌ 灯没反应但 insmod 正常✅ 调试技巧八、总结这篇文章适合完全零基础的 Linux 驱动初学者。我们将手把手实现一个最简单的 LED 驱动只要照着做你也能点亮开发板上的那颗灯。写在前面很多同学学驱动一上来就被各种platform、设备树、pinctrl绕晕了。其实最简单的 LED 驱动根本不需要这些东西 —— 我们直接操作寄存器让灯亮起来。这节课的目标✅ 理解字符设备驱动的最简框架✅ 学会在驱动里直接操作硬件寄存器✅ 编写测试程序从应用层控制 LED 亮灭✅ 完成第一次“驱动开发”的完整流程一、硬件回顾LED 接在哪个引脚我们用的开发板是100ASK_IMX6ULL ProLED 的原理图如下参考手册第 2 章LED 连接到了GPIO5_IO03。根据电路引脚输出低电平时 LED 亮输出高电平时 LED 灭。要操作这个 LED我们需要配置 3 类寄存器参考 PPT03_IMX6ULL的LED操作方法.pptx寄存器地址作用IOMUXC_SNVS_SW_MUX_CTL_PAD_SNVS_TAMPER30x02290014把引脚功能设置为 GPIOALT5 模式GPIO5_GDIR0x020AC004设置方向1 为输出0 为输入GPIO5_DR0x020AC000写 0/1 控制高低电平注意GPIO5 的时钟默认已经使能我们不需要额外操作。二、字符设备驱动框架5 分钟看懂在 Linux 中所有硬件都被抽象成文件。应用程序通过open、read、write、close这些系统调用来操作驱动。驱动程序要做的就是实现这几个函数然后把它们告诉内核。框架就是 4 步text 1. 定义一个 struct file_operations里面放你的 open/write/read 等函数 2. 实现这些函数比如 open 里初始化 GPIOwrite 里控制 LED 3. 在入口函数里调用 register_chrdev() 注册这个结构体 4. 在出口函数里调用 unregister_chrdev() 注销再加上ioremap映射物理地址就可以直接读写寄存器了。三、驱动代码逐行解析led_drv.c先看完整代码文件位置05_嵌入式Linux驱动开发基础知识/source/02_led_drv/00_led_simple/imx6ull/c #include linux/kernel.h #include linux/module.h #include linux/slab.h #include linux/init.h #include linux/fs.h #include linux/delay.h #include linux/poll.h #include linux/mutex.h #include linux/wait.h #include linux/uaccess.h #include linux/device.h #include asm/io.h static int major; static struct class *led_class; /* 寄存器指针虚拟地址 */ static volatile unsigned int *IOMUXC_SNVS_SW_MUX_CTL_PAD_SNVS_TAMPER3; static volatile unsigned int *GPIO5_GDIR; static volatile unsigned int *GPIO5_DR; /* write 函数应用层 write 时会调用 */ static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { char val; int ret; ret copy_from_user(val, buf, 1); if (val) *GPIO5_DR ~(13); // 输出低电平 → 亮 else *GPIO5_DR | (13); // 输出高电平 → 灭 return 1; } /* open 函数应用层 open 时会调用 */ static int led_open(struct inode *inode, struct file *filp) { /* 1. 设置引脚为 GPIO 模式 (ALT5) */ *IOMUXC_SNVS_SW_MUX_CTL_PAD_SNVS_TAMPER3 ~0xf; *IOMUXC_SNVS_SW_MUX_CTL_PAD_SNVS_TAMPER3 | 0x5; /* 2. 设置方向为输出 */ *GPIO5_GDIR | (13); return 0; } /* 定义 file_operations 结构体 */ static struct file_operations led_fops { .owner THIS_MODULE, .write led_write, .open led_open, }; /* 入口函数insmod 时执行 */ static int __init led_init(void) { printk(%s %s %d\n, __FILE__, __FUNCTION__, __LINE__); /* 1. 注册字符设备主设备号让内核自动分配 */ major register_chrdev(0, 100ask_led, led_fops); /* 2. 映射物理地址到虚拟地址 */ IOMUXC_SNVS_SW_MUX_CTL_PAD_SNVS_TAMPER3 ioremap(0x02290014, 4); GPIO5_GDIR ioremap(0x020AC004, 4); GPIO5_DR ioremap(0x020AC000, 4); /* 3. 自动创建设备节点 /dev/myled */ led_class class_create(THIS_MODULE, myled); device_create(led_class, NULL, MKDEV(major, 0), NULL, myled); return 0; } /* 出口函数rmmod 时执行 */ static void __exit led_exit(void) { iounmap(IOMUXC_SNVS_SW_MUX_CTL_PAD_SNVS_TAMPER3); iounmap(GPIO5_GDIR); iounmap(GPIO5_DR); device_destroy(led_class, MKDEV(major, 0)); class_destroy(led_class); unregister_chrdev(major, 100ask_led); } module_init(led_init); module_exit(led_exit); MODULE_LICENSE(GPL);1. 静态变量驱动内部使用c static int major; // 主设备号由内核分配 static struct class *led_class; // 用于自动创建设备节点的类major每个字符设备都有一个主设备号内核通过它找到对应的驱动。这里major 0表示让内核自动分配一个空闲的主设备号。led_classstruct class是 Linux 设备模型中用来对设备进行分类的后面配合device_create()可以自动在/dev下生成设备节点。2. 寄存器指针虚拟地址c static volatile unsigned int *IOMUXC_SNVS_SW_MUX_CTL_PAD_SNVS_TAMPER3; static volatile unsigned int *GPIO5_GDIR; static volatile unsigned int *GPIO5_DR;这三个指针将来会指向通过ioremap映射后的虚拟地址用来操作真实的硬件寄存器。为什么加volatile因为寄存器的值可能被硬件随时改变比如引脚电平变化如果不加volatile编译器优化时可能会把对寄存器的读写操作优化掉比如认为重复读同一个地址是多余的导致程序无法正确控制硬件。volatile告诉编译器每次读写都必须真的去那个地址操作不准偷懒优化。3.led_write函数 —— 应用层调用write时执行c static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { char val; int ret; ret copy_from_user(val, buf, 1); if (val) *GPIO5_DR ~(13); // 输出低电平 → 亮 else *GPIO5_DR | (13); // 输出高电平 → 灭 return 1; }参数含义filp文件结构体指针代表打开的设备文件一般用不到。buf用户空间传过来的数据缓冲区指针。count要写入的字节数。ppos文件偏移位置读写位置一般不用。copy_from_user因为buf指向的是用户空间的内存内核不能直接访问必须用这个安全函数把数据拷贝到内核空间的变量val里。这里只拷贝 1 个字节因为我们只传一个状态1 或 0。控制 LED如果val为 1用户传 on则把GPIO5_DR的第 3 位清零 ~(13)→ 引脚输出低电平 → LED 亮。如果val为 0用户传 off则把第 3 位置 1| (13)→ 引脚输出高电平 → LED 灭。返回值返回 1表示成功写入了 1 个字节。(13)就是二进制1000~(13)就是...11110111。对第 3 位操作是因为我们控制的是 GPIO5 的第 3 个引脚GPIO5_IO03。4.led_open函数 —— 应用层调用open时执行c static int led_open(struct inode *inode, struct file *filp) { /* 1. 设置引脚为 GPIO 模式 (ALT5) */ *IOMUXC_SNVS_SW_MUX_CTL_PAD_SNVS_TAMPER3 ~0xf; *IOMUXC_SNVS_SW_MUX_CTL_PAD_SNVS_TAMPER3 | 0x5; /* 2. 设置方向为输出 */ *GPIO5_GDIR | (13); return 0; }第一步配置引脚功能IOMUXC_SNVS_SW_MUX_CTL_PAD_SNVS_TAMPER3寄存器低 4 位决定引脚功能。 ~0xf先把低 4 位清零。| 0x5再设置成0101即 5对应 ALT5 模式 → 该引脚用作 GPIO。第二步设置方向为输出GPIO5_GDIR寄存器的第 3 位置 1表示该引脚为输出模式。返回值0 表示成功。5.file_operations结构体 —— 驱动的核心c static struct file_operations led_fops { .owner THIS_MODULE, .write led_write, .open led_open, };这是连接应用层系统调用和驱动函数的桥梁。当应用层调用open(/dev/myled, ...)时内核就会执行led_open。当应用层调用write(...)时内核就会执行led_write。.owner THIS_MODULE是一个标准写法防止模块还在使用时就被人为卸载。6. 入口函数led_init——insmod时执行c static int __init led_init(void) { printk(%s %s %d\n, __FILE__, __FUNCTION__, __LINE__); /* 1. 注册字符设备主设备号让内核自动分配 */ major register_chrdev(0, 100ask_led, led_fops); /* 2. 映射物理地址到虚拟地址 */ IOMUXC_SNVS_SW_MUX_CTL_PAD_SNVS_TAMPER3 ioremap(0x02290014, 4); GPIO5_GDIR ioremap(0x020AC004, 4); GPIO5_DR ioremap(0x020AC000, 4); /* 3. 自动创建设备节点 /dev/myled */ led_class class_create(THIS_MODULE, myled); device_create(led_class, NULL, MKDEV(major, 0), NULL, myled); return 0; }printk内核态的打印函数类似printf输出信息可以用dmesg查看。这里打印文件名、函数名、行号方便调试。register_chrdev参数0表示让内核自动分配一个空闲的主设备号100ask_led是设备名称会在/proc/devices里看到led_fops就是前面定义的操作函数集合。返回值major就是内核分配的主设备号。ioremap将物理地址芯片手册查到的映射到内核可以访问的虚拟地址。参数分别是物理地址和映射大小这里每个寄存器都是 32 位映射 4 字节足够。映射后得到的虚拟地址保存在前面的静态指针中以后就可以像访问普通内存一样操作寄存器了。class_create创建一个class结构体名为myled会在/sys/class/下创建对应目录。device_create在刚刚创建的class下创建一个设备设备号是MKDEV(major, 0)主设备号 major次设备号 0设备文件名叫myled。这条语句执行后/dev/myled就会被自动创建。不用手动 mknod非常方便。7. 出口函数led_exit——rmmod时执行c static void __exit led_exit(void) { iounmap(IOMUXC_SNVS_SW_MUX_CTL_PAD_SNVS_TAMPER3); iounmap(GPIO5_GDIR); iounmap(GPIO5_DR); device_destroy(led_class, MKDEV(major, 0)); class_destroy(led_class); unregister_chrdev(major, 100ask_led); }iounmap解除ioremap建立的映射释放资源。device_destroy删除/dev/myled设备节点。class_destroy删除之前创建的class。unregister_chrdev注销字符设备释放主设备号。8. 模块加载/卸载宏c module_init(led_init); module_exit(led_exit); MODULE_LICENSE(GPL);module_init告诉内核当insmod这个模块时执行led_init函数。module_exit告诉内核当rmmod这个模块时执行led_exit函数。MODULE_LICENSE(GPL)声明许可证否则加载时会报“模块污染内核”的警告。四、测试程序ledtest.cc #include sys/types.h #include sys/stat.h #include fcntl.h #include string.h #include unistd.h #include stdio.h int main(int argc, char **argv) { int fd; char status 0; if (argc ! 3) { printf(Usage: %s dev on|off\n, argv[0]); printf( eg: %s /dev/myled on\n, argv[0]); printf( eg: %s /dev/myled off\n, argv[0]); return -1; } fd open(argv[1], O_RDWR); if (fd 0) { printf(can not open %s\n, argv[0]); return -1; } if (strcmp(argv[2], on) 0) status 1; // 亮 write(fd, status, 1); return 0; }逻辑非常简单打开设备节点/dev/myled如果第二个参数是on写1进去否则写0默认 off驱动收到1就输出低电平亮收到0就输出高电平灭五、Makefile 解析小白友好版makefile KERN_DIR /home/book/100ask_imx6ull-sdk/Linux-4.9.88这个变量指定了开发板所用内核源码的路径。编译驱动时必须依赖内核源码中的头文件、编译规则等。一定要改成你自己电脑上的实际路径否则会找不到内核文件。makefile all: make -C $(KERN_DIR) Mpwd modules $(CROSS_COMPILE)gcc -o ledtest ledtest.call是默认目标执行make不带参数时会执行它下面的命令。第一条命令make -C $(KERN_DIR)表示先切换到内核源码目录然后执行那个目录下的顶层Makefile。Mpwd 告诉内核的构建系统要编译的模块源码在当前目录pwd会展开为当前路径。modules是内核 Makefile 的一个目标表示编译模块。最终会把你写的led_drv.c编译成led_drv.ko内核模块文件。第二条命令$(CROSS_COMPILE)gcc使用交叉编译器比如arm-buildroot-linux-gnueabihf-gcc来编译ledtest.c生成 ARM 板上能运行的测试程序ledtest。注意$(CROSS_COMPILE)这个变量需要你在执行make之前在 shell 中设置好例如export CROSS_COMPILEarm-...否则会缺前缀导致编译失败。makefile clean: make -C $(KERN_DIR) Mpwd modules clean rm -rf modules.order rm -f ledtestclean目标用于清除编译生成的文件。make -C ... modules clean会调用内核的清理规则删除led_drv.o、led_drv.ko等中间文件。rm -rf modules.ordermodules.order是内核编译过程中产生的临时文件一起删掉。rm -f ledtest删除测试程序。makefile obj-m led_drv.o这是最关键的一句它告诉内核的模块编译系统把led_drv.c编译成一个可加载模块.ko文件。obj-m表示要编译成模块module表示追加led_drv.o是目标文件内核会根据.o找同名的.c。六、上机实验一步一步来6.1 设置交叉编译工具链在 Ubuntu 中执行以 IMX6ULL 为例bash export ARCHarm export CROSS_COMPILEarm-buildroot-linux-gnueabihf- export PATH$PATH:/home/book/100ask_imx6ull-sdk/ToolChain/arm-buildroot-linux-gnueabihf_sdk-buildroot/bin6.2 修改 Makefile 中的KERN_DIR改成你实际内核源码的路径。如果你是按教程搭建的环境路径就是/home/book/100ask_imx6ull-sdk/Linux-4.9.886.3 编译bash make成功后生成led_drv.ko驱动模块ledtest测试程序6.4 把文件传到开发板使用adb或scpbash adb push led_drv.ko /root adb push ledtest /root6.5 加载驱动并测试在开发板串口终端执行bash # 先关闭系统默认的 LED 心跳灯否则会有冲突 echo none /sys/class/leds/cpu/trigger # 加载驱动 insmod led_drv.ko # 查看设备节点 ls /dev/myled # 点亮 LED ./ledtest /dev/myled on # 熄灭 LED ./ledtest /dev/myled off✅ 如果一切正常板子上的 LED 就会听话地亮灭七、常见问题与调试技巧❌ 加载驱动时报错version magic 4.9.88 should be 4.9.88-g12345678原因你编译驱动用的内核源码和开发板上运行的内核不是同一个或者内核被污染了。解决方法方法一在 Makefile 里强制忽略版本检查不推荐但能跑在make命令前加上MODULE_FLAGS-fno-pie试试不更好的办法是使用insmod -f强制加载bash insmod -f led_drv.ko方法二重新编译开发板的内核然后把新内核和设备树烧写到板子上保持两边一致推荐❌ 灯没反应但insmod正常先确认 LED 引脚是不是 GPIO5_IO03看原理图用万用表或示波器测量引脚电平变化在驱动led_write里加printk打印看 write 有没有被调用✅ 调试技巧查看内核打印dmesg | tail查看设备节点ls -l /dev/myled查看已加载模块lsmod八、总结今天我们完成了一个真正能跑的最简 LED 驱动。你学到了字符设备驱动的标准骨架如何用ioremap操作物理寄存器如何自动创建设备节点如何编写测试程序这个驱动虽然简单但麻雀虽小五脏俱全。以后你接触的任何复杂驱动I2C、SPI、网络、USB底层套路都和它一模一样。

更多文章