GoDm@'s Blog

创建一个字符设备与地址映射

版权信息

warning

本文章为博主原创文章。遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。


本篇文章主要讲解创建字符驱动设备的一个代码框架,配置一个GPIO的方式依然是最原始的配置寄存器方式(配合讲解地址映射)。

1. 地址映射介绍

1.1. 地址映射究竟在干什么?

Linux 世界里,任何外设的寄存器都不允许你“像裸机一样”随便拿地址就写。物理地址只是硬件的地盘,而用户空间、内核空间都活在自己的“虚拟地址”里。

虚拟地址这个东西,只是对 CPU 说的一套语言;真实的电路是物理地址。所以得靠 MMU(内存管理单元)做翻译,把虚拟地址 → 物理地址挂上关系。这一步就叫 地址映射(memory mapping)

简单描述一下:

虚拟地址(进程眼里看到的) → 通过页表(内核维护的一堆数据结构) → 映射到物理地址(板子上的真实寄存器)

为什么要这么麻烦?

因为 Linux 要保证:

  1. 每个进程互不干扰,不会互相写爆对方。
  2. 不让用户程序直接摸外设,保证系统稳定。
  3. 内核可以把物理地址重新分布,不影响程序。

1.2. 那如何访问外设寄存器?

访问寄存器通常需要两步:

  1. 获得物理地址(比如 datasheet 会写某个 GPIO 控制寄存器的地址)
  2. 将物理地址映射成虚拟地址 → 才能读写

在 Linux 内核中,这个动作由 ioremap() 完成。

1.3. 常用函数

1.3.1. ioremap()

把“物理地址段”映射到“内核虚拟地址空间” 你传入基地址和大小,它返回一段能访问的虚拟地址。

void __iomem *virt = ioremap(phys_addr, size);

1.3.2. iounmap()

释放映射。

1.3.3. readl()/writel()

读写 32bit 寄存器的常用函数。

value = readl(virt_addr + offset);
writel(0x1, virt_addr + offset);

比直接解引用指针更安全。

2. 创建一个字符设备

2.1. step1.定义操作集合

就是大名鼎鼎的 file_operations。让内核知道当用户空间调用open、close等文件操作函数时实际应该怎么操作。

示例:

static struct file_operations led_fops = {
    .owner = THIS_MODULE,
    .open = led_open,
    .read = led_read,// 对应用户空间的 open()
    .write = led_write,// 对应用户空间的 write()
    .release = led_release}; // 对应用户空间的 close()

需要理清楚这些操作函数与对应用户空间的函数的关系。

2.2. step2.编写实际操作函数

我们确定好要编写哪些操作函数后就可以开始编写了,函数名字要和file_operations里的一样。

我们编写实际操作函数时,要填形参,可以进入到“file_operations”的定义里面看操作函数对应的形参。

2.3. step3.四个关键结构体

我们首先定义四个关键结构体:

dev_t devno; // 用于存储设备号
struct cdev cdev; //申请一个cdev对象
static struct class *class = NULL; // 类
struct device *device //设备节点

我们将其统一包装在一个结构体,作为该设备的私有数据,例如:

struct led_dev{
    dev_t devno;      // 用于存储设备号
    struct cdev cdev; // 申请一个cdev对象
    struct class *class; // 类
    struct device *device; //设备节点
};

static struct led_dev* led_dev;

2.4. step4.确定设备号

有两种方式:静态和动态。用到step3的第一个结构体

此步只是 在内核中预留了数字,没有创建任何对象;这是字符设备的“身份证号码”。

2.4.1. alloc_chrdev_region():

动态分配一个未被占用的主设备号,并注册设备号区域。建议使用此方法,避免与现有设备冲突。

用法:

int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);

2.4.2. register_chrdev_region():

静态注册一个已知的主设备号。如果知道分配给设备的设备号,可以使用此方法.

用法:

int register_chrdev_region(dev_t from, unsigned count, const char *name)

2.5. step5.注册字符设备

此步包含 初始化cdev结构体 和 将设备添加到内核。

2.5.1. cdev_init()

用于初始化我们step3提供的第二个结构体,将设备与对应的操作集合(step1、2)绑定。

void cdev_init(struct cdev *, const struct file_operations *);

2.5.2. cdev_add()

此步正式将设备添加到内核。

int cdev_add(struct cdev *, dev_t, unsigned count);

2.6. step5.创建设备节点

向内核添加字符设备后,在用户空间使用仍然不方便,因为它没有暴露接口,也就是/dev/目录下没有它的身影。

2.6.1. class_create()

创建设备节点前要求必须有对应的类。它会在在 sysfs 中创建一个类目录。class 本质上只是一个目录 + 一些描述信息。此处用到step3提供的第三个结构体

用法:

struct class *class_create(const char *name);

同一类的设备 = 功能相似、统一管理、统一呈现、自动创建节点

tips:

想要将不同设备添加到同一类,可以在申请类的那个初始化文件里提供一个接口函数然后 extern,例如:

a.c:

struct class *my_class;

struct class *get_my_class(void)
{
    return my_class;
}

static int __init main_dev_init(void)
{
    my_class = class_create(THIS_MODULE, "myclass");
    if (IS_ERR(my_class))
        return PTR_ERR(my_class);

    return 0;
}

b.c:

extern struct class *get_my_class(void);

device_create(get_my_class(), NULL, devno, NULL, "otherdev");

2.6.2. device_create()

正式创建设备节点。用到step3提供的第四个结构体

struct device *device_create(struct class *cls, struct device *parent, dev_t devt, void *drvdata, const char *fmt, ...);

cls: 类结构体指针
parent:父设备,一般为 NULL
devt:设备号
drvdata:驱动私有数据,可为 NULL
fmt:设备名格式化字符串,即在dev目录下显示的设备名。

3. 销毁一个字符设备

举一反三,不再过多赘述
销毁需要按创建时的顺序 反顺序销毁。

//删除设备节点
void device_destroy(const struct class *cls, dev_t devt);
//删除类
void class_destroy(const struct class *cls)
//删除字符设备
void cdev_del(struct cdev *);
//释放设备号
void unregister_chrdev_region(dev_t, unsigned);

4. 完整示例程序,包含地址映射

注意的细节:goto的错误处理,私有数据。

说明一下使用私有数据后,怎么在open函数中获取这些私有数据:

container_of关键宏:已知某个结构体成员的指针,反推出整个结构体的指针。它定义在 include/linux/kernel.h 中。

在led_open()中,我们通过container_of 反推出 装有cdev成员的 struct led_dev 结构体指针(因为这里的cdev正好是struct led_dev的成员,为什么cdev不是其他结构体的成员呢?因为我们在cdev_add中指定的就是struct led_dev中的cdev的地址),也就是我们在step3中定义的结构体。即私有数据,并将这个指针指向fp的privatedata成员变量。这样read、write就可以通过fp->privatedata访问这些数据。

使用container_of在几个设备共用一个驱动时更具有通用性。

还有这个常用的函数:

unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);

表示从用户空间拷贝到内核空间,防止用户程序直接访问或修改内核数据结构和硬件资源。

相应的也有

unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);

表示从内核空间复制到用户空间。当目标地址无效时,copy_to_user会将目标内核缓冲区清零,防止信息泄漏。

除了上述两个主要函数外,Linux还提供了一系列简化函数:

get_user:获取简单变量(char/int/long等)
put_user:写入简单变量
get_user/put_user:不进行地址检查的版本
这些函数适用于简单数据类型的传输,效率更高。

—终于他妈写完了,草— 写得真是依托。。。。。不管了

led.c


#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/io.h> //io映射相关
#include <linux/uaccess.h>
#include <asm-generic/int-ll64.h>
#include <linux/string.h>

#define DEV_NAME "led"

// 定义开关状态
#define LED_OFF 0
#define LED_ON 1

// 定义寄存器物理地址
#define CCM_CCGR1_BASE (0X020C406C)
#define SW_MUX_GPIO1_IO03_BASE (0X020E006C)
#define SW_PAD_GPIO1_IO03_BASE (0X020E02F8)
#define GPIO1_DR_BASE (0X0209C000)
#define GPIO1_GDIR_BASE (0X0209C004)

// 定义映射后的虚拟地址

static void __iomem *IMX6U_CCM_CCGR1;
static void __iomem *SW_MUX_GPIO1_IO03;
static void __iomem *SW_PAD_GPIO1_IO03;
static void __iomem *GPIO1_DR;
static void __iomem *GPIO1_GDIR;

struct led_dev{
    dev_t devno;      // 用于存储设备号
    struct cdev cdev; // 申请一个cdev对象
    struct class *class; // 类
    struct device *device; //设备节点
};

static struct led_dev* led_dev;

static void led_switch(u8 sta)
{
    u32 temp = 0;
    if (sta == LED_ON)
    {
        temp = readl(GPIO1_DR);
        temp &= ~(1 << 4);
        writel(temp, GPIO1_DR);
    }
    else if (sta == LED_OFF)
    {
        temp = readl(GPIO1_DR);
        temp |= (1 << 4);
        writel(temp, GPIO1_DR);
    }
}

static int led_open(struct inode *inode, struct file *fp)
{
    struct led_dev* this_devp;
    // container_of:已知某个结构体成员的指针,反推出整个结构体的指针。
    this_devp = container_of(inode->i_cdev, struct led_dev, cdev);
    // 指定私有数据,后续read/write函数均可以直接读取
    fp->private_data = this_devp;
    printk(KERN_INFO "led dev open!");
    return 0;
}

static ssize_t led_write(struct file *fp, const char __user *buf, size_t cnt, loff_t *offt)
{
    int ret;
    char databuf[5];
    char* ledstat;
    ret = copy_from_user(databuf, buf, cnt);
    if (ret < 0)
    {
        printk(KERN_ERR "kernel write failed!\n");
        return -EFAULT;
    }
    ledstat = databuf;
    if (strcmp(ledstat, "on") == 0)
        led_switch(LED_ON);
    else if (strcmp(ledstat, "off") == 0)
        led_switch(LED_OFF);
    else
    {
        printk(KERN_ERR "E: only can write \"off\" or \"on\"\n");
        return -1;
    }
    return 0;
}


static ssize_t led_read(struct file *fp, char __user *buf, size_t nt, loff_t *offt)
{
    printk(KERN_INFO "it's unnecessary to read led!");
    return 0;
}

static int led_release(struct inode *, struct file *)
{
    printk(KERN_INFO "led dev release!");
    return 0;
}

static struct file_operations led_fops = {
    .owner = THIS_MODULE,
    .open = led_open,
    .read = led_read,
    .write = led_write,
    .release = led_release};
   
static int __init led_init(void)
{
    u32 temp = 0;
    int ret = 0;
    /* 映射 */
    IMX6U_CCM_CCGR1 = ioremap(CCM_CCGR1_BASE, 4);
    SW_MUX_GPIO1_IO03 = ioremap(SW_MUX_GPIO1_IO03_BASE, 4);
    SW_PAD_GPIO1_IO03 = ioremap(SW_PAD_GPIO1_IO03_BASE, 4);
    GPIO1_DR = ioremap(GPIO1_DR_BASE, 4);
    GPIO1_GDIR = ioremap(GPIO1_GDIR_BASE, 4);
    /* 设置寄存器值 */
    /* 1. 使能时钟 */
    // 这是通用寄存器,最好不要直接写入,要保证其他值不变,否则可能会影响其他外设,以下同理
    temp = readl(IMX6U_CCM_CCGR1);
    temp &= ~(3 << 26); // 清除设置
    temp |= (3 << 26);  // 设定26-27为1,使能时钟
    writel(temp, IMX6U_CCM_CCGR1);
    temp = 0;
    /* 2. 引脚复用为GPIO */
    writel(5, SW_MUX_GPIO1_IO03);
    /* 3. 设置电气属性 */
    writel(0x10b0, SW_PAD_GPIO1_IO03);
    /* 4. 设置为输出 */
    temp = readl(GPIO1_GDIR);
    temp &= ~(1 << 4); // 清除设定
    temp |= (1 << 4);
    writel(temp, GPIO1_GDIR);
    temp = 0;
    /* 5. 设置为输出低电平(开启LED) */
    temp = readl(GPIO1_DR);
    temp &= ~(1 << 4);
    writel(temp, GPIO1_DR);
    temp = 0;
    /* 向系统添加字符设备 */
    printk(KERN_INFO "led load!");
    /* 获取设备号 */
    ret = alloc_chrdev_region(&led_dev->devno, 1, 1, DEV_NAME); // 成功后,主设备号存储在MAJOR(devno),次设备号为1
    if (ret < 0)
    {
        printk(KERN_ERR "Failed to alloc dev number!\n");
        goto err_alloc;
    }
    /* 注册字符设备 */
    cdev_init(&led_dev->cdev, &led_fops); // 初始化cdev结构体
    led_dev->cdev.owner = THIS_MODULE;
    ret = cdev_add(&led_dev->cdev, led_dev->devno, 1); // 此步正式将设备添加到内核
    if (ret < 0)
    {
        printk(KERN_ERR "Failed to add cdev!\n");
        goto err_cdev;
    }
    /* 创建设备节点 */
    led_dev->class = class_create("led"); // 创建设备节点前要求必须有对应的类
    if (IS_ERR(led_dev->class))
    {
        printk(KERN_ERR "Failed to create class \n");
        ret = PTR_ERR(led_dev->class); // 设置返回值,否则返回0
        goto err_class;
    }

    led_dev->device = device_create(led_dev->class, NULL, led_dev->devno, NULL, DEV_NAME);
    if (IS_ERR(led_dev->device))
    {
        printk(KERN_ERR "Failed to create device\n");
        ret = PTR_ERR(led_dev->device);
        goto err_device;
    }
    return 0;
err_device:
    class_destroy(led_dev->class);
err_class:
    cdev_del(&led_dev->cdev);
err_cdev:
    unregister_chrdev_region(led_dev->devno, 1); // 释放设备号
err_alloc:
    return ret;
}

static void __exit led_exit(void)
{   /* 关灯 */
    led_switch(LED_OFF);
    /* 取消映射 */
    iounmap(IMX6U_CCM_CCGR1);
    iounmap(SW_MUX_GPIO1_IO03);
    iounmap(SW_PAD_GPIO1_IO03);
    iounmap(GPIO1_DR);
    iounmap(GPIO1_GDIR);
    /* 释放资源 */
    device_destroy(led_dev->class, led_dev->devno);
    class_destroy(led_dev->class);
    cdev_del(&led_dev->cdev);
    unregister_chrdev_region(led_dev->devno, 1); // 释放设备号
    printk(KERN_INFO "led dev closed!\n");

}

module_init(led_init);
module_exit(led_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("gdm");

测试程序chrdevbaseAPP,用于写入字符串

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int test(const char *argv[])
{
    int fd, ret;
    char readbuf[30], writebuf[30];
    const char *filename = argv[1];
    /* 打开 */
    fd = open(filename, O_RDWR); // 可读可写打开
    if (fd < 0)
    {
        printf("E: Can not open %s\n", filename);
        return -1;
    }
    /* 读取选项 */
    if (strcmp(argv[2], "r") == 0)
    {
        memset(readbuf, 0, sizeof(readbuf));
        ret = read(fd, readbuf, strlen(writebuf) + 1);
        if (ret < 0)
            printf("E: read %s filed!\n", filename);
        else
            printf("read data with %d byte: %s \n", strlen(readbuf), readbuf);
    }
    /* 写入选项 */
    else if (strcmp(argv[2], "w") == 0)
    {
        memcpy(writebuf, argv[3], strlen(argv[3]) + 1);
        ret = write(fd, writebuf, strlen(writebuf) + 1);
        if (ret < 0)
            printf("E: write %s filed!\n", filename);
        else
            printf("write data with %d byte: %s \n", strlen(writebuf), writebuf);
    }
    /* 关闭 */
    ret = close(fd);
    if (ret < 0)
    {
        printf("E: Can't close file %s\n", filename);
        return -1;
    }
    return 0;
}

int main(int argc, const char *argv[])
{
    printf("===%s:now running test APP for chrdevbase===\n", argv[0]);

    /* 参数判断 */
    if (!((argc == 3 && strcmp(argv[2], "r") == 0) || (argc == 4 && strcmp(argv[2], "w") == 0)))
    {
        printf("E: wrong usage! now argc = %d! argv[2] = %s\n usage:\n\t read:%s %s r \n\t write:%s %s w [str_data]\n",
            argc, argv[2], argv[0], argv[1], argv[0], argv[1]);
        return -1;
    }

    test(argv);
}

Makefile

# =========================================================

# Makefile for building a Linux Kernel Module

# Module Name: led

# =========================================================

# KERNEL_SOURCE 指向你的内核源码或头文件路径
# 如果你使用系统默认内核头,直接用 /lib/modules/$(shell uname -r)/build

KERNEL_SOURCE := /home/gdm/prjts/linux/driver_learning/kernel/linux-imx-lf-6.6.52-2.2.1
OUTPUT_DIR := $(PWD)/../output

# 模块源文件
obj-m := led.o  
# obj-m 用于告诉内核模块系统需要编译 chrdevbase.c 成为模块

# 默认目标
# 使用内核的 Makefile 来构建模块
# -C 指定内核源码路径
# M=$(PWD) 告诉内核在当前目录下查找源文件
all:
    arm-none-linux-gnueabihf-gcc -static chrdevbaseAPP.c -o chrdevbaseAPP
    $(MAKE) -C $(KERNEL_SOURCE) M=$(PWD) modules
    mv .*.cmd *.mod *mod.c *.o *.symvers *.order $(OUTPUT_DIR)

# 清理生成文件
clean:
# 使用内核 Makefile 清理模块相关文件
    $(MAKE) -C $(KERNEL_SOURCE) M=$(PWD) clean
    rm chrdevbaseAPP
    rm $(OUTPUT_DIR)/* $(OUTPUT_DIR)/.*.cmd

app:
    arm-none-linux-gnueabihf-gcc -static chrdevbaseAPP.c -o chrdevbaseAPP

cleanapp:
    rm chrdevbaseAPP

共计约3.7k字。于2025/11/25首次发布,最后更新于2025/11/28。

本文章为博主原创文章。遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

  1. 1. 地址映射介绍
    1. 1.1. 地址映射究竟在干什么?
    2. 1.2. 那如何访问外设寄存器?
    3. 1.3. 常用函数
  2. 2. 创建一个字符设备
    1. 2.1. step1.定义操作集合
    2. 2.2. step2.编写实际操作函数
    3. 2.3. step3.四个关键结构体
    4. 2.4. step4.确定设备号
    5. 2.5. step5.注册字符设备
    6. 2.6. step5.创建设备节点
  3. 3. 销毁一个字符设备
  4. 4. 完整示例程序,包含地址映射