ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux字符设备驱动开发全解析:框架、注册流程与避坑指南

Linux字符设备驱动开发全解析:框架、注册流程与避坑指南 简介《Linux 字符设备驱动框架详细介绍》是一份面向 Linux 驱动初学者与嵌入式开发者的 PDF 资料重点拆解字符设备驱动的核心组成与编写流程。内容围绕 struct file_operations 操作接口、struct cdev 设备对象、设备号分配与回收、模块注册与注销等关键环节展开结合键盘、串口等典型字符设备说明应用程序通过设备文件读写时内核如何借助已注册回调完成操作。资料附带一个精简驱动示例覆盖 alloc_chrdev_region、cdev_init、cdev_add 到 cdev_del、unregister_chrdev_region 的完整生命周期并讲解 copy_to_user、copy_from_user 在内核态与用户态之间搬运数据的作用。整份资源只有 1 个 PDF 文件压缩包约 91KB内容紧凑、结构清晰适合随时查阅目前已有 729 人学习。对想快速建立字符设备驱动框架、准备内核模块开发实践的开发者而言这是一份很实用的入门参考。1. 为什么嵌入式工程师都要过字符设备驱动这道坎从点亮一盏 LED 到驱动一块 SPI 屏幕再到读一个 ADC 触摸值Linux 下几乎所有的外设操作最终都会落到“字符设备驱动”这四个字上。Linux 的设备驱动框架分成三大类——字符设备、块设备、网络设备其中字符设备是门槛最低、占比最大、也是面试和日常开发里被问得最多的一类。它不要求你懂复杂的块设备调度算法也不需要处理协议栈你只需要搞明白“应用层 open/read/write 是怎么一路走到你写的硬件操作函数里的”这条链路就能把 90% 的传感器、GPIO、I2C、SPI 设备跑起来。这篇文章不绕弯子直接从框架的角度把字符设备驱动的数据结构、注册流程、操作函数集和常见的翻车点讲透让新手能照着写出第一个可用的驱动让熟手能回头查漏补缺。2. 字符设备驱动框架内核把设备抽象成了什么又是怎么把你的驱动挂进去的2.1 三个核心数据结构cdev、file_operations、dev_t字符设备驱动在内核里并不是一个神秘的黑匣子它最终体现在三个结构体上。第一个是struct cdev代表一个字符设备对象第二个是struct file_operations这是驱动真正干活的函数指针表第三个是dev_t用来保存主设备号和次设备号。三者关系可以这样理解dev_t是设备的“门牌号”cdev是设备的“登记信息”file_operations是设备提供的“服务菜单”。struct cdev { struct kobject kobj; struct module *owner; const struct file_operations *ops; // 驱动函数表 dev_t dev; unsigned int count; };内核在fs/char_dev.c里维护着一张全局的设备号映射表当你在应用层open(/dev/xxx, ...)的时候虚拟文件系统会通过设备号的 hash 表找到对应的cdev再从cdev里取出ops指向的file_operations最终调用你写的open函数。这条链路是理解整个框架的关键后面所有注册和注销操作本质上都是在围绕这张表做增删改查。file_operations这个结构体字段非常多但你必须清楚哪些是常用的owner一般设成THIS_MODULEread、write是数据传输的入口unlocked_ioctl是控制命令的入口open和release对应打开和关闭设备。需要注意的是没有实现的函数可以设为NULL内核在调用前会做检查不会崩但如果你不实现read应用层调用 read 就会得到 -EINVAL。2.2 设备号的分配方式静态指定还是动态分配设备号是驱动和设备文件之间的桥梁它分主设备号和次设备号两部分。主设备号标识设备对应的驱动程序次设备号标识同一个驱动管理下的不同设备。分配设备号有三种常见做法手动指定一个未被占用的主设备号、使用register_chrdev_region注册一个固定的设备号段、或者使用alloc_chrdev_region让内核动态分配。动态分配是更推荐的方式因为它避免了你选中的主设备号和已有内核里的其他驱动冲突。dev_t dev_num; int major, minor; // 动态分配major 由内核自动分配 ret alloc_chrdev_region(dev_num, 0, 1, my_demo_dev); if (ret 0) { pr_err(alloc_chrdev_region failed: %d\n, ret); return ret; } major MAJOR(dev_num); minor MINOR(dev_num); pr_info(got major%d minor%d\n, major, minor);这里的alloc_chrdev_region第一个参数是输出参数函数成功后会拿到完整的dev_t第二个参数表示起始次设备号这里从 0 开始第三个参数是连续设备数量比如你要管理 4 个串口就填 4最后一个字符串是设备名会在/proc/devices里显示。拿到设备号之后下一步就是创建cdev并注册到内核。2.3 cdev 的生命周期从初始化、添加到注销一个都不能少cdev的注册是整个框架的中枢环节。常见做法是分配一个struct cdev并调用cdev_init绑定操作函数集然后把设备号填进去最后通过cdev_add挂到内核的设备表里。顺序不能乱先初始化、再填设备号、最后添加。struct cdev my_cdev; cdev_init(my_cdev, my_fops); my_cdev.owner THIS_MODULE; ret cdev_add(my_cdev, dev_num, 1); if (ret 0) { pr_err(cdev_add failed: %d\n, ret); unregister_chrdev_region(dev_num, 1); return ret; }cdev_add成功之后设备就已经在内核里可见了但并不代表open一定能成功因为/dev下的设备节点还需要你手动创建这就是后面讲的 class 和 device 的作用。注销时顺序刚好相反先cdev_del再unregister_chrdev_region如果搞反了下次 load 模块时会提示设备号被占用或者 cdev 关联混乱。注册完成之后可以查看/proc/devices或者用ls -l /dev/确认设备号是否就位。这些步骤频繁出现在嵌入式 Linux 的开发中即使像 GPIO 这类驱动你最终会依赖内核自带的框架理解这个基础框架仍然很有必要因为后续的平台总线模型、设备树匹配本质上都是在这个字符设备注册流程之上做了一层封装。3. 用 file_operations 打通应用层到硬件层open、read、write 到底谁在调用谁3.1 open 和 release 的逻辑你的初始化代码放在哪应用层每次open(/dev/xxx, O_RDWR)都会在内核里走到你实现的open函数但内核打开文件的次数和你驱动里open被调用的次数并不完全等同涉及到O_NONBLOCK、O_EXCL等标志位时还会有差异。在驱动开发里open通常做几件事递增使用计数、初始化互斥锁、申请私有数据的内存、配置硬件寄存器。static int demo_open(struct inode *inode, struct file *filp) { struct demo_dev *dev; dev container_of(inode-i_cdev, struct demo_dev, cdev); filp-private_data dev; // 复位硬件状态 demo_hw_reset(dev); return 0; }这段代码最关键的是container_of宏。inode-i_cdev是驱动注册时cdev_add挂上去的cdev结构体指针而cdev往往被嵌在你的设备结构体里内核用container_of通过结构体成员地址倒推出整个结构体的首地址。这种内核惯用手段在read、write、ioctl里你还会反复见到。release函数和open对称通常做资源释放、停止硬件定时器、灭掉中断。还有一点容易被忽略open函数的filp-private_data是每次打开文件时都独立的所以你可以用它存放每次打开的状态比如本次打开只读还是可写、当前的读写偏移等。全局变量做不到这点多个进程同时操作同一个设备时就会出现状态互相覆盖的问题。3.2 read 和 write 的完整姿势copy_to_user 和 copy_from_user驱动里的read和write函数是数据搬运工。read 从内核缓冲区拷贝数据到用户空间write 从用户空间拷贝数据到内核缓冲区。这里最忌讳的做法是直接用memcpy去访问用户空间指针因为用户态的地址在内核态不一定有效强行访问会触发缺页异常甚至导致 oops。static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { struct demo_dev *dev filp-private_data; int ret; if (count dev-data_len) { count dev-data_len; } ret copy_to_user(buf, dev-data_buf, count); if (ret ! 0) { return -EFAULT; } *ppos count; return count; }copy_to_user返回值为 0 时表示全部拷贝成功非零返回值表示没能拷贝的字节数。write对应的是copy_from_user返回语义相同。这两个函数在拷贝过程中会主动处理用户空间页面换入换出程序员必须在调用它们之前保证不会出现睡眠问题一般是没问题的但如果你在内核里申请了自旋锁后再调用它们就可能在持锁期间发生睡眠这是经典的驱动 bug。ppos这个参数是当前文件读写位置驱动可以完全不理会它的自增比如你的设备是一个按键状态寄存器每次 read 都应该读最新的值那么ppos对驱动而言没有意义你甚至可以把它永远设为 0。如果驱动需要支持lseek那就要自己管理ppos的逻辑这里有个常见误解不是所有设备都必须遵循“读完后ppos自动增加”的语义字符设备驱动有绝对控制权。3.3 ioctl控制命令的集散地如何避免和内核标准命令冲突ioctl解决的是 read/write 表达不了的控制类操作比如设置波特率、使能某个引脚、读取设备版本号。在老版本的 Linux 里用ioctl现在内核用unlocked_ioctl替代两者区别在于老版本内核会为 ioctl 调用自动加锁新内核把这个责任交给了驱动开发者。#define DEMO_CMD_GET_STATUS _IOR(D, 1, struct demo_status) #define DEMO_CMD_SET_FREQ _IOW(D, 2, int) static long demo_unlocked_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct demo_dev *dev filp-private_data; struct demo_status st; switch (cmd) { case DEMO_CMD_GET_STATUS: demo_hw_read_status(st); if (copy_to_user((void __user *)arg, st, sizeof(st))) return -EFAULT; break; case DEMO_CMD_SET_FREQ: { int freq; if (copy_from_user(freq, (void __user *)arg, sizeof(freq))) return -EFAULT; demo_hw_set_freq(freq); break; } default: return -ENOTTY; } return 0; }_IOR、_IOW、_IORW这几个宏生成的是 32 位的命令码其中包含“类型、序号、数据方向、数据大小”四个信息Linux 内核提供了一套标准的命令码生成与解码机制来避免两个驱动之间命令号撞车。重要的坑是arg传进来的是用户空间的地址你必须用copy_to_user/copy_from_user去访问绝对不能直接解引用否则进程可以故意传入一个无效地址来打崩你的驱动。-ENOTTY是 ioctl 命令无法识别时的惯用返回值它字面意思是“没有这个 tty 设备”但应用层已经把ENOTTY当作“命令不支持”的约定来识别了。ioctl 还要注意一个细节整数类型的参数可以直接按值传递不需要经过指针。比如arg本身就是freq的数值而不是freq的地址这时switch后面直接(int)arg即可。但是这是有风险的习惯——32 位和 64 位系统下用户态传long和内核态收到的unsigned long位数不一致时直接传值容易截断。最稳妥的方式还是统一通过指针传参避免踩到架构兼容性的坑。4. 把设备交给 /dev 下的用户空间class、device 和 udev/mdev 的配合4.1 设备节点是自己出来的class_create 与 device_create 背后的机制前面驱动的注册只完成了内核侧的对象登记如果你用ls /dev/去查设备节点不会自动出现。传统的做法是手动mknod /dev/demo c 240 0去创建设备文件但这需要你知道主次设备号而且重启后还得重新敲一次。现在嵌入式 Linux 的做法是依赖 udevPC 端或 mdevBusyBox 环境在驱动注册的瞬间自动生成设备节点。static struct class *demo_class; demo_class class_create(demo_class); if (IS_ERR(demo_class)) { unregister_chrdev_region(dev_num, 1); return PTR_ERR(demo_class); } device_create(demo_class, NULL, dev_num, NULL, demo_dev);触发设备节点自动生成的关键动作是device_create。它会在内核的 sysfs 里创建一个 kobject同时发送 uevent 事件到用户空间udev/mdev 收到事件后查看/sys/class/demo_class/demo_dev/dev文件里的主次设备号自动执行mknod。class_create的作用是把一类设备归拢到同一个目录下嵌入式系统里的/sys/class/leds、/sys/class/gpio都是这么来的。需要注意class_create传入的字符串是设备类名不能和已存在的类重名否则会返回错误。device_create的第四个参数如果没有额外的私有数据就传NULL第五个参数是设备节点名称这个名称就是你在/dev/下看到的文件名。BusyBox 环境下记得在启动脚本里启动mdev -s否则即使驱动发出了 uevent也没人帮你创建设备节点。4.2 自动创建设备节点的引用关系sysfs、uevent、mdev 三条线索如果你在设备树或者模块加载后看到内核日志提示 “device created” 但/dev/下没有节点问题通常出现在用户空间的设备管理器没有被触发。这里梳理一下传递链device_create在 sysfs 中产生设备对象同时 kobject 子系统发出uevent事件udev/mdev 监听 netlink 收到的 uevent根据环境变量里的MAJOR、MINOR、DEVNAME执行mknod。# BusyBox 环境下手动触发一次设备节点扫描 echo /sbin/mdev /proc/sys/kernel/hotplug mdev -s这个/proc/sys/kernel/hotplug文件是用来注册热插拔事件的处理程序路径的。在 BusyBox 的手册里标准的初始化流程就是先写这个路径再执行mdev -s来扫描当前 sysfs 里已有的设备。如果用的是桌面 Ubuntu那是 udev 的 systemd 版本在自动监听不需要手动做这一步。这里有个容易踩到的坑设备节点生成成功后/dev/下的文件权限默认取决于class_create和device_create时是否指定了 devt 对应的 mode。默认只有 root 可读写普通用户open会被拒绝。想要让普通用户也能访问要么在 udev/mdev 规则里加上权限设置要么在device_create后手动chmod 666 /dev/demo_dev。产品化的时候很多工程师都把权限问题留到测试阶段才暴露白白多花时间。4.3 设备树的引入是否改变了字符设备驱动的编写方式很多初学者误以为设备树出现之后字符设备驱动框架就过时了但实际并非如此。设备树负责的只是描述硬件资源比如寄存器基地址、中断号、GPIO 编号最终传统字符设备的cdev_add、file_operations这些机制完全相同。变化的部分在于你的驱动可以通过of_系列 API 从设备树节点里拿资源值而不是把寄存器地址硬编码在代码里。static int demo_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; res platform_get_resource(pdev, IORESOURCE_MEM, 0); base devm_ioremap_resource(pdev-dev, res); if (IS_ERR(base)) { return PTR_ERR(base); } // 后续仍然需要 register_chrdev cdev_add }所以学习字符设备驱动框架真正的作用之一就是为更复杂的 platform driver 做准备。Linux 内核里大量驱动都遵循这种结构probe函数里做硬件初始化然后调用字符设备相关的注册 API 把操作接口暴露给用户态。明白了最底层的基础注册流程再看probe里的其他代码就不会晕头转向了。5. 字符设备驱动的资源和并发管理锁、休眠唤醒、资源释放的注意点5.1 并发访问怎么防自旋锁和互斥锁的选择字符设备驱动一旦被多个进程同时打开你的read、write函数里操作全局数据时就会面临并发问题。比如两个线程同时 write缓冲区后半段的写入顺序就可能乱掉或者一个线程正在 write另一个线程执行 ioctl 复位了硬件此时 write 的数据就写进了复位后的错误状态。处理并发的手段主要有自旋锁和互斥锁选型依据是临界区里能不能睡眠。struct demo_dev { struct cdev cdev; struct mutex lock; char data_buf[128]; }; static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { struct demo_dev *dev filp-private_data; mutex_lock(dev-lock); if (copy_from_user(dev-data_buf, buf, count)) { mutex_unlock(dev-lock); return -EFAULT; } mutex_unlock(dev-lock); return count; }mutex_lock在锁被占用时会休眠因此它只能用在可以睡眠的上下文里比如进程上下文中的 read/write/ioctl。如果你在中断处理函数或者软中断里需要保护共享数据就必须用spin_lock因为中断上下文不能睡眠。常见的错误是在中断里调用mutex_lock轻则死锁重则整个系统卡死。自旋锁会关闭内核抢占临界区必须非常短否则多核 CPU 上性能会变得很难看。还有一类问题多个进程同时阻塞在read上等待硬件数据时不能用锁来协调要用等待队列。wait_event_interruptible和wake_up_interruptible配合使用可以让进程在条件不满足时睡眠条件满足时被唤醒。这涉及read函数是否阻塞的问题驱动必须明确自己的行为是阻塞式还是非阻塞式filp-f_flags O_NONBLOCK就是判断标志。5.2 读写缓冲区不只是数组内存屏障和 DMA 安全的意识很多教学示例里驱动用一个简单的char buf[128]当设备缓冲区实际产品设备往往会有 DMA 传输数据由硬件直接写入内存。此时你需要注意两个问题一是 CPU 的缓存一致性DMA 写入的内存和 CPU 看到的缓存可能不一致二是内存屏障防止编译器或 CPU 乱序执行导致你读到的数据是过期的。dma_addr_t dma_handle; struct demo_dev *dev; // 分配 DMA 安全的缓冲区 dev-dma_buf dma_alloc_coherent(pdev-dev, size, dma_handle, GFP_KERNEL);dma_alloc_coherent分配的内存是一致性 DMA 缓冲区它保证了 CPU 和设备看到的地址是同步的不需要手动处理 cache 刷新。如果你用普通的kmalloc缓冲区就要在 DMA 传输前后调用dma_map_single/dma_unmap_single并在合适的时候dma_sync_single_for_cpu来刷新缓存。对于新手来说这属于进阶内容但是线上驱动的很多诡异 bug最后查出来都是这个环节的 cache 同步问题。5.3 模块卸载时资源释放顺序颠倒的后果模块卸载流程和加载流程完全相反常见做法是在exit函数里做一次完整的拆卸。错误示范是只注销 cdev 而不删设备类和设备节点导致系统里留下一个指向不存在驱动的设备节点应用层open时要么返回 ENODEV 要么直接 oops。static void __exit demo_exit(void) { device_destroy(demo_class, dev_num); class_destroy(demo_class); cdev_del(dev-cdev); unregister_chrdev_region(dev_num, 1); kfree(dev); }顺序很关键先销毁用户空间可见的设备节点再注销内核对象。因为device_destroy之后内核不会再接受新的 open 请求但是已经打开的设备文件还在被引用此时cdev_del会导致后续对已打开 fd 的 read/write 操作变成访问空指针。正确的做法往往还需要cdev_del之后等待引用计数归零这在内核里对应cdev_del的语义它本身不会强制关闭已打开的文件。因此模块卸载前最好确认没有进程持有该设备的文件描述符否则就要在release里做好容错。硬件资源的释放也容易遗漏比如ioremap得到的映射一定要iounmap申请的中断要free_irq时钟要clk_disable_unprepare。这类资源在内核里都有对应的 devm_ 版本 API比如devm_ioremap_resource、devm_request_irq、devm_clk_get它们的特征是随设备生命周期自动释放用它们可以让卸载代码大幅简化也减少了泄漏概率。6. 避坑与排查字符设备驱动开发中最常见的五个翻车现场6.1 open 设备节点时报 No such device or address现象open(/dev/demo_dev, O_RDWR)返回 -1errno是 ENODEV。ls /dev/demo_dev能看到节点但 access 访问设备时报错。原因通常有两个一是cdev_add没有执行成功或者设备号填错了应用层打开的节点对应的主次设备号和内核里注册的不一致二是device_create创建节点时用的 dev_t 和cdev_add用的不是同一个dev_t。排查方法是先看cat /proc/devices里驱动注册的设备名和主设备号再用stat /dev/demo_dev看设备节点的设备号两处一旦不匹配问题就定位了。解决保证alloc_chrdev_region拿到的dev_num同时用于cdev_add和device_create不要二次手写主设备号。6.2 写入copy_from_user时发生页面错误导致整个系统 oops现象应用层执行 write 操作时内核日志出现BUG: unable to handle page fault for address系统直接卡死或重启。原因是驱动直接解引用了用户空间指针比如执行了memcpy(dev-buf, buf, count)。内核态里用户空间地址不一定映射到当前进程的页表一旦发生缺页内核 oops 不是用户段错误直接就崩了。解决一律用copy_from_user/copy_to_user代替直接地址访问。如果你是老手还会知道copy_from_user内部会做access_ok检查不需要在每个驱动函数入口再写一遍写了也不多余只是可以省掉。6.3 模块加载提示 device or resource busy现象insmod demo.ko时返回Device or resource busy但dmesg里看不到具体代码行。原因基本锁定在cdev_add返回了 -EBUSY代表设备号冲突说明你用了register_chrdev_region静态指定设备号而这个主设备号已经被其他驱动占用。解决优先换成alloc_chrdev_region动态分配或者在register_chrdev_region之前先cat /proc/devices确认设备号是否空闲。这里还有个隐藏问题如果你卸载模块时没有调用unregister_chrdev_region再次insmod会提示同样的错误先清理干净再加载。6.4 read 函数永远返回 0应用层读取不到数据现象应用层read返回值是 0但实际上面的数据明明已经产生了。原因是驱动里你做了一次多余的if (count 0) return 0判断之外更常见的是read函数里把*ppos当成设备地址去访问了。比如有的驱动里copy_to_user拿ppos当成数据源的偏移量ppos从 0 开始但设备的数据地址不是线性内存结果 offset 累加一次次取到的是空数据。解决字符设备驱动里ppos属于文件系统语义只有在设备本身支持文件偏移时才用比如 EEPROM 驱动。对大多数传感器类设备直接用固定缓冲区忽略ppos即可。6.5 设备节点存在但open只能 root 访问现象设备正常注册成功普通用户下open报Permission denied。原因是设备节点默认权限是 660 或者 600而所属用户是 root。解决在 udev 规则里写KERNELdemo_dev, MODE0666或者用 BusyBox mdev 的配置文件/etc/mdev.conf指定权限。如果不想依赖系统配置也可以在device_create后用class_create_file暴露权限节点但这样不够优雅容易在系统重启后失效。这其实是开发流程里经常被测试部门打回来的低风险 bug但也是新手最容易忽略的环节。7. 进阶一张可复用的检查清单加上一个调试技巧7.1 驱动加载后按这张清单看一眼能省半天排查时间模块加载成功不等于驱动能正常工作。我一般会按顺序执行一遍基础检查检查项命令或操作预期结果模块是否加载lsmod | grep demo能看到模块名设备号是否注册cat /proc/devices能看到 demo 和主设备号设备节点是否存在ls -l /dev/demo_dev节点存在且主次设备号匹配节点权限是否可用sudo -u user test -r /dev/demo_dev当前用户能访问驱动是否响应echo hello /dev/demo_dev然后cat /dev/demo_dev能读到对应的输入输出这五条检查里前四条都通过、第五条失败时问题几乎必然出现在file_operations内部的逻辑或者并发处理上。这条检查法是我实际排查时最常用的路径尤其是带着新人一起调驱动时能快速把问题边界从“系统层”缩小到“驱动逻辑层”。7.2 动态调试用 trace_printk 和 dev_dbg 代替到处乱加的 printk新手调试驱动特别喜欢在每个函数里加printk(KERN_INFO ...)生产环境里这会造成大量内核日志刷屏并且影响时序敏感的硬件驱动。内核自带的dev_dbg脚本配合 dynamic_debug 功能可以做到“平时不输出需要时开启”不用重新编译驱动模块也不必修改代码重启系统。# 动态开启某个文件里所有 dev_dbg 输出 echo file drivers/demo/demo.c p /sys/kernel/debug/dynamic_debug/control动态调试除了看日志还有一个好用的技巧是trace_printk它配合 ftrace 可以在不打断内核执行的前提下记录函数的调用序列和时间戳适合排查中断上下文里的执行顺序和时延。不过 trace_printk 的使用需要在内核配置里使能CONFIG_TRACEPRINTK许多嵌入式板子的默认内核配置没有打开这个选项所以实际落地时要先确认。还有一个非常实用的习惯在file_operations的每个入口函数开头记录一个“函数名 进程名 当前毫秒时间”的日志只针对 open、release、read、write、ioctl 五个函数不加多余的中间日志。这些标记能让你在 dmesg 里快速还原一次应用层操作的完整调用链尤其是在处理多进程并发问题时日志的时间戳能直接告诉我们进程之间的竞争窗口有多大。字符设备驱动框架这条路说难不难说简单也不简单。我见过不少工程师能流畅地讲出file_operations的每个字段却在设备节点权限问题上卡了一整天也见过硬件出身的人把寄存器配置得堪称完美却因为漏了copy_to_user让应用层数据全空。写驱动的门槛不在于记住结构体而在于建立“用户空间操作路径”到“内核对象生命周期”的完整心智模型。这篇文章把我的排查习惯和避坑清单都梳理出来了如果你第一次照着写驱动建议把那五个翻车点先抄下来贴在屏幕边上。希望帮到你少走几个弯路。本文还有配套的精品资源点击获取
返回列表