正点原子 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 等都是)。注册一个字符设备驱动要做四件事:

  1. 申请设备号:alloc_chrdev_region 动态分配主/次设备号(/proc/devices 可查);
  2. 准备 file_operations:把应用层的 open/read/write/close 对应到驱动里的函数指针;
  3. 注册 cdev:cdev_init + cdev_add,把设备号和操作集绑定,挂进内核;
  4. 操作硬件: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)
复用寄存器0x20E00680x229000C
电气属性寄存器0x20E02F40x2290050
方向/数据寄存器0x209c004 / 0x209c000(GPIO1)0x20AC004 / 0x20AC000(GPIO5)
操作位bit3bit1
默认状态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 就能看灯、控灯。

继续阅读

嵌入式开发面试知识手册

嵌入式开发工程师面试知识手册 适用对象:零基础起步、目标是通过嵌入式软件/驱动/BSP 方向面试的读者 编写依据:赵哲简历全部知识点 + 智能车 AGV 控制平台完整项目代码(i.MX6ULL Linux 端 + STM32F407 固件)+ Linux 驱动练习代码包 + embedded-agent 嵌入式开发智能体仓库 + 真实面试手撕代码与追问记录 使用方法:每篇按"通俗解释 → 原理细节 → 命令/API → 代码实例 → 项目结合 → 记忆口诀 → 面试问答"组织;口诀用于快速记忆,问答用于自测。第9篇是讲项目的演练场,第10篇是手撕代码实战题库。 总目录 * 第1篇 C语言与数据结构 * 1.1 编译链接全过程 * 1.2 数据类型与内存

作者:azhezhezhe

正点原子 I.MX6ULL MINI 开发板学习笔记:Linux 驱动架构详解——编译、树内树外与运行时效果

这是 Linux 驱动小系列的总结篇。前三篇把 LED 驱动从内核模块写到 misc 设备,属于“会用”;这篇把背后的架构讲透:驱动到底怎么编译、内核树内和树外两种方式的区别、以及驱动里每个操作在系统跑起来之后到底产生了什么效果。内容整理自我的学习笔记(note.pdf)和实际工程 code/2026/20260803。 1. 驱动代码到底怎么编译的? 不管哪种方式,最终都靠内核的 kbuild 系统。核心就三个文件的关系: * Kconfig:定义 menuconfig 里的选项(如 CONFIG_MYMODULE),三态 y/m/n——y 编进内核,m 编成模块,n 不编; * .config:menuconfig 的选择结果,保存在源码顶层; * Makefile:

作者:azhezhezhe

正点原子 I.MX6ULL MINI 开发板学习笔记:misc 设备——字符设备驱动的快捷方式

上两篇分别用手动 mknod 和 class_create+device_create 注册字符设备,调用链一长串。这篇用 misc(混杂)设备 一步搞定。代码来自实际工程 code/2026/20260803/05_misc_led,已验证可用。 1. 完成什么实验? 用 misc_register 重写 LED 驱动:一步完成设备号分配、字符设备注册、/dev/myled0 节点创建,同时保留上一篇的 sysfs 属性 led_status。 2. 什么是 misc 设备? misc(混杂)设备是 Linux 给“不好归类的小字符设备”

作者:azhezhezhe