跳转到内容
新建笔记

字符设备驱动:设备号、读写协议与生命周期

字符设备把 open/read/write/ioctl/poll 等文件操作连接到驱动。/dev 中的节点保存设备号;内核再根据设备号找到已注册的 cdev 和 file_operations。节点存在、设备号已分配、回调已注册、硬件已就绪,是四件不同的事。

本文示例采用 Linux 6.8 系列接口,提供一个只保存 0/1 状态的完整字符设备。它不访问实际 GPIO;原笔记中的 F1C 系列寄存器信息保留在后文,真实 LED 的接入见 设备树平台驱动。

1. 设备号、节点与回调

跳转到“1. 设备号、节点与回调”

用户文件操作通过设备号和 cdev 进入驱动回调

对象或接口职责与边界
dev_t编码主设备号和次设备号;用 MAJOR、MINOR、MKDEV 读写,不能把它们当作芯片的硬件编号
alloc_chrdev_region(&number, first_minor, count, name)动态分配连续设备号,成功返回 0;number 是输出参数
register_chrdev_region(number, count, name)申请已明确保留的设备号;第一个参数是值,不是 &number
struct cdev关联设备号范围与文件操作;count 是注册的次设备号数量,不是打开文件的次数
cdev_init(&cdev, &fops) / cdev_add(&cdev, number, count)初始化已有对象,然后公开回调;cdev_add 成功后,用户可能立即进入驱动
class_create / device_create建立设备模型与 sysfs 信息,并触发设备节点管理;不替代 cdev_add
/dev/name文件系统中的特殊节点,记录类型、设备号、权限;devtmpfs/udev 通常负责创建和命名
file_operations.owner = THIS_MODULE正常打开文件期间保持模块引用;不自动保护可拔除硬件的生命周期

cdev 的 kobject 承担引用计数等对象管理,ops 指向操作表,owner 指向所属模块。结构体可能带 __randomize_layout,这是布局随机化机制,不能据此推导“可以防止所有栈溢出”。

register_chrdev(major, name, &fops) 是较旧的便捷接口:Linux 6.8 将其展开为从次设备号 0 开始注册 256 个号码,即 0~255。它不是只注册 255 个;新示例用显式设备号范围更容易表达一个设备的需求。内核声明与实现

字符、块及网络设备在原笔记中的分类示意

图中的字符/块设备节点用于说明文件接口;网络设备一般通过网络协议栈和 socket 使用,不能一概画成 /dev 节点。

这个示例的用户接口约定如下:

操作约定
写入精确接受 ASCII 0、1、0\n、1\n;不是本机字节序的 int 或 size_t
零长度写入返回 0,不改变状态
非法命令返回 -EINVAL;用户地址复制失败返回 -EFAULT
写入成功返回实际消费的 count,不是 0
读取返回 0\n 或 1\n;支持短读,读完后返回 0 表示 EOF
再次读取最新值重新打开文件;示例禁止 seek,没有把每次读取都伪装成无穷数据流

read、write 回调返回 ssize_t。copy_to_user 和 copy_from_user 返回的是尚未复制的字节数:0 才表示完整成功;用 ret < 0 判断会漏掉正常的复制错误。不能忽略用户传入的 count,也不能在部分复制后继续使用未初始化的数据。

simple_read_from_buffer 负责本例的读取长度、位置推进与 EOF。用户缓冲区出现错误时也可能短读;调用端始终按返回的字节数处理。二进制 ABI 若确有必要,应另行定义固定宽度、字节序和布局,避免 32 位用户程序与 64 位内核对 size_t 的理解不同。

保存为 vlog_char.c。所有共享状态在公开入口前初始化;最后一步才调用 cdev_add。由于设备模型先建立,初始化期间极早打开节点可能失败,调用者不能把这个瞬间当成硬件永久损坏。

vlog_char.c
// SPDX-License-Identifier: GPL-2.0-only
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/fs.h>
#include <linux/module.h>
#include <linux/mutex.h>
#include <linux/uaccess.h>
struct vlog_state {
struct mutex lock;
bool enabled;
};
static struct vlog_state state;
static struct cdev *vlog_cdev;
static dev_t number;
static struct class *vlog_class;
/* Exact byte protocol: "0", "1", "0\n" or "1\n". */
static int parse_switch(const char *text, size_t count, bool *value)
{
if (count != 1 && count != 2)
return -EINVAL;
if (count == 2 && text[1] != '\n')
return -EINVAL;
if (text[0] != '0' && text[0] != '1')
return -EINVAL;
*value = text[0] == '1';
return 0;
}
static int vlog_open(struct inode *inode, struct file *file)
{
file->private_data = &state; /* This module owns one software instance. */
return nonseekable_open(inode, file);
}
static ssize_t vlog_read(struct file *file, char __user *buf,
size_t count, loff_t *pos)
{
struct vlog_state *s = file->private_data;
char text[2];
mutex_lock(&s->lock);
text[0] = s->enabled ? '1' : '0';
mutex_unlock(&s->lock);
text[1] = '\n';
return simple_read_from_buffer(buf, count, pos, text, sizeof(text));
}
static ssize_t vlog_write(struct file *file, const char __user *buf,
size_t count, loff_t *pos)
{
struct vlog_state *s = file->private_data;
char text[2];
bool value;
int ret;
if (!count)
return 0;
if (count > sizeof(text))
return -EINVAL;
if (copy_from_user(text, buf, count))
return -EFAULT;
ret = parse_switch(text, count, &value);
if (ret)
return ret;
mutex_lock(&s->lock);
s->enabled = value;
mutex_unlock(&s->lock);
return count;
}
static const struct file_operations vlog_fops = {
.owner = THIS_MODULE,
.open = vlog_open,
.read = vlog_read,
.write = vlog_write,
.llseek = no_llseek,
};
static int __init vlog_init(void)
{
struct device *dev;
int ret;
mutex_init(&state.lock);
ret = alloc_chrdev_region(&number, 0, 1, "vlog_char");
if (ret)
return ret;
vlog_class = class_create("vlog_char"); /* Linux 6.8: one argument. */
if (IS_ERR(vlog_class)) {
ret = PTR_ERR(vlog_class);
goto release_number;
}
dev = device_create(vlog_class, NULL, number, NULL, "vlog_char");
if (IS_ERR(dev)) {
ret = PTR_ERR(dev);
goto release_class;
}
vlog_cdev = cdev_alloc();
if (!vlog_cdev) {
ret = -ENOMEM;
goto release_device;
}
vlog_cdev->ops = &vlog_fops;
vlog_cdev->owner = THIS_MODULE;
/* Publish callbacks last; an earlier open of the node can fail. */
ret = cdev_add(vlog_cdev, number, 1);
if (!ret)
return 0;
kobject_put(&vlog_cdev->kobj);
release_device:
device_destroy(vlog_class, number);
release_class:
class_destroy(vlog_class);
release_number:
unregister_chrdev_region(number, 1);
return ret;
}
static void __exit vlog_exit(void)
{
cdev_del(vlog_cdev);
device_destroy(vlog_class, number);
class_destroy(vlog_class);
unregister_chrdev_region(number, 1);
}
module_init(vlog_init);
module_exit(vlog_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Software-only character device with a one-bit text ABI");

软件状态 state 与模块一起存在,.open 直接绑定这个单实例;本例没有 platform 热解绑路径。cdev 则由 cdev_alloc 独立分配,最终清理和释放由内核负责,即使 CONFIG_DEBUG_KOBJECT_RELEASE 延迟清理,也不依赖已卸载模块的静态存储。不能把它改回模块静态对象内嵌的 cdev,再假定 cdev_del 返回就已完成清理。正常打开文件的模块引用另外保护读写回调与 state,不要使用强制卸载绕过这一条件。

cdev_del 只阻止后续通过该注册入口打开;已经打开的文件仍可能调用原来的回调。若把示例改成可拔除设备,必须在每次操作中检查离线状态,并用适当引用保留每个打开文件所需的对象;devm_kzalloc 的释放时点是设备解绑,不是最后一个文件关闭。cdev_del 的生命周期说明

原笔记的设备注册和注销步骤

原图把部分硬件初始化排在 cdev_add 之后,应改为“先准备资源和锁,再公开回调”。文件关闭回调名是 .release,不是 .close;并非每个驱动都必须自定义它。

cdev_alloc() 会分配对象并初始化用于动态释放的 kobject。采用这条路径时,直接设置 ops、owner 后添加,不要再调用 cdev_init 把它重新初始化。分配失败先撤销已创建的节点;添加失败时通过 kobject_put(&cdev->kobj) 放弃引用;成功添加后通过 cdev_del 撤销注册。不要再裸 kfree 已交给引用计数管理的 cdev。

若 cdev 嵌入另一个动态对象,必须同时设计外层对象与打开文件的生命周期。cdev_device_add 可协助组合 cdev 与 device,但并不代替驱动对业务状态和硬件离线的管理。

只编译本例时,同目录 Makefile 可写为:

obj-m += vlog_char.o

使用目标内核已配置、已生成必要头文件与符号版本的构建目录:

终端窗口
make -C "$KDIR" M="$PWD" modules

本机内核常以 /lib/modules/$(uname -r)/build 为构建目录;交叉编译时应显式设置目标内核目录、ARCH 与相应工具链,不能把主机头文件编出的模块装到另一块板上。开启符号版本时还需要与目标匹配的 Module.symvers;仅执行 modules_prepare 不会补齐一次完整内核构建的全部产物。外部模块构建文档

class_create 在 Linux 6.4 起改为单参数;旧代码常见的 class_create(THIS_MODULE, "name") 不能直接用于本例所选的 6.8 头文件。内核配置 CONFIG_LEDS_CLASS 服务于 LED 子系统,它不是所有 sysfs 或字符设备代码的通用开关。

下面的用户程序同时接受 on/off 和 1/0,转换为本例固定的一个 ASCII 字节。保存为 vlog_set.c,用目标用户空间对应的编译器构建;文件名带不带 .exe 不能决定它适用于哪个系统。

#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char **argv)
{
char value;
ssize_t written;
int fd;
if (argc != 3) {
fprintf(stderr, "Usage: %s DEVICE 0|1|off|on\n", argv[0]);
return 2;
}
if (!strcmp(argv[2], "1") || !strcmp(argv[2], "on"))
value = '1';
else if (!strcmp(argv[2], "0") || !strcmp(argv[2], "off"))
value = '0';
else {
fprintf(stderr, "Invalid state\n");
return 2;
}
fd = open(argv[1], O_WRONLY | O_CLOEXEC);
if (fd < 0) {
perror("open");
return 1;
}
do {
written = write(fd, &value, 1);
} while (written < 0 && errno == EINTR);
if (written != 1) {
if (written >= 0)
errno = EIO;
perror("write");
(void)close(fd);
return 1;
}
if (close(fd) < 0) {
perror("close");
return 1;
}
return 0;
}

在已安装此模块的实验系统上,典型使用形式为 ./vlog_set /dev/vlog_char on 和 cat /dev/vlog_char。节点权限由系统设备管理规则决定;需要手工 mknod 时,先读取这次实际分配的主、次设备号,不能照抄某个保留主设备号。

接入内核源码树的 Kconfig 与 Makefile

跳转到“接入内核源码树的 Kconfig 与 Makefile”

驱动既可内建,也可构建为模块。若把同一个 vlog_char.c 放入内核源码树中已纳入构建的目录,可在该目录的 Kconfig 添加:

config VLOG_CHAR
tristate "VitaLogos software character device"
help
Build a software-only character device with a one-bit text ABI.

并在该目录的 Makefile 添加:

obj-$(CONFIG_VLOG_CHAR) += vlog_char.o

config 后写符号 VLOG_CHAR;生成的配置和 Makefile 引用才带 CONFIG_ 前缀。tristate 允许 y/m/n:y 编入内核,m 构建模块,n 不构建;选择 m 还要求内核支持模块。若新建目录,父级 Kconfig 还须通过 source 纳入它,父级 Makefile 也须进入该目录。仅复制两个片段不会自动完成这些接入工作。Kconfig 语法;Kbuild 对象选择

对象 vlog_char.o 对应实际文件 vlog_char.c;不能混用 myled.c 与不存在的 leds-myled.o。模块也并不意味着只能手工 insmod,正确的模块别名、依赖索引和设备事件可支持自动加载。本例不是 LED class 驱动,不应为它无条件添加 depends on LEDS_CLASS。

5. 原 GPIOE 实例需要补齐的硬件条件

跳转到“5. 原 GPIOE 实例需要补齐的硬件条件”

原笔记使用 0x01c20800 作为 PIO 基址。作为寄存器布局核对资料,Allwinner F1C200s User Manual Rev.1.2 第 3.7 节给出以下 Port E 偏移;它不是本页软件模块的 MMIO 清单,实际 F1C100S 板还需对应芯片资料和 BSP 核实。

寄存器相对 PIO 偏移历史示例绝对地址与 PE3 / PE12 的关系
PE_CFG00x900x01c20890PE3 功能位为 [14:12]
PE_CFG10x940x01c20894PE12 功能位为 [18:16]
PE_DAT0xa00x01c208a0数据位分别为 3、12
PE_PULL00xac0x01c208ac每脚 2 位,PE3 为 [7:6],PE12 为 [25:24]

通用端口与外部中断的两套偏移

跳转到“通用端口与外部中断的两套偏移”

同一本手册第 3.7.2 节给出普通端口寄存器地址:B + n × 0x24 + offset。B 是 PIO 基址,n 按 A=0、B=1、C=2、D=3、E=4、F=5 编号;寄存器组内 CFG0/1/2/3 偏移为 0x00/0x04/0x08/0x0c,DAT 为 0x10,驱动能力为 0x14/0x18,上下拉为 0x1c/0x20。布局有槽位不代表每一引脚或每个保留字段都能使用。以虚构基址 B=0x1000、端口 D 的 n=3、offset=0x04 演算,结果是 0x1070;这不是板上地址。

外部中断寄存器另用 B + 0x200 + n × 0x20 + offset,这里的组编号是 D=0、E=1、F=2,不能继续用 A=0 的普通端口编号。CFG0/1/2/3 的组内偏移仍为 0x00/0x04/0x08/0x0c,控制、状态、去抖分别为 0x10/0x14/0x18。同样取虚构 B=0x1000,E 组 n=1 的 CFG1 位于 0x1224;真实 PIO 基址下对应 0x01c20a24。中断组的 D/E/F 对应关系可对照手册第 3.7.3.54、3.7.3.61、3.7.3.68 节,不能把公式理解为每个端口都提供中断组。

手册中功能 001 表示 GPIO 输出;上下拉编码 00 为禁用、01 为上拉、10 为下拉。原代码清除 PE3 的两位后设置位 7,得到的是 10,不能注释成上拉。PE12 的上下拉也不能沿用 PE3 的移位。原厂手册镜像

原来用 size_t * 访问 32 位寄存器,在 64 位环境可能变成错误宽度;MMIO 应通过 void __iomem * 与 readl/writel 等接口,并获取正确且独占的资源。即使改成读改写,若没有与其他使用者统一加锁,仍有竞争;整组写寄存器还可能破坏同一 bank 上其他引脚。

Linux 通常已有 pinctrl/GPIO 控制器驱动管理这些寄存器。LED 消费者应申请 GPIO 描述符或使用 gpio-leds,不另建一个重叠的裸 PIO 资源。低电平点亮是具体电路极性,应通过 GPIO_ACTIVE_LOW 表达;逻辑 1 表示“有效”,不必等同物理高电平。

6. 向事件与共享内存接口扩展

跳转到“6. 向事件与共享内存接口扩展”

进程代表运行中的程序实例,通常拥有地址空间和文件描述符等资源;Linux 具体调度的是任务/线程,不能把“进程分配资源”与“每次调度单位”完全等同。就绪队列组织可运行任务,等待队列组织等待某个条件的任务;不同调度策略不保证所有队列都按简单 FIFO 运行。

需要阻塞读取时,先定义“有数据/设备离线”的条件,再用等待队列循环检查条件;生产者在正确同步之后唤醒等待者,并处理信号中断和 O_NONBLOCK。中断处理见 Linux 驱动中断,不要复制依赖旧板级常量的 IRQ_EINT10、IRQT_FALLING。

mmap 可以把受控的设备或共享缓冲区映射给用户空间,但它不是把任意内核指针直接交给用户;必须验证映射长度、偏移、权限、缓存属性和对象寿命。小型开关控制通常不需要这条复杂路径。

本页完整模块已使用 Ubuntu 6.8.0-146-generic 的 x86-64 内核构建资料完成编译和 MODPOST;启用 W=1 并将编译警告视为错误。验证包含正文读写函数的主机回调测试、动态 cdev 初始化失败和撤销顺序模型,以及用户程序错误路径;这些测试使用明确的内核 API 模型,不等同于加载模块后的内核运行测试。没有在当前主机加载模块,也没有声称 F1C100S 板上 GPIO 已验证。