
30款热门AI模型一站整合DeepSeek/GLM/Qwen 随心用限时 5 折。 点击领海量免费额度这类主题最怕一上来就讲内核架构、源码目录、编译系统新手看完还是不知道从哪里动手。我建议换个顺序先别管那些复杂概念直接动手写一个能加载、能卸载、能打印日志的最小驱动模块跑起来再说。跑通之后你自然就理解了模块是什么、怎么编译、怎么加载、怎么和内核交互。这时候再去看驱动框架、设备模型、并发控制就知道每个部分到底在解决什么问题了。下面我就按这个“先跑通再理解”的顺序带你走一遍 Linux 驱动开发最核心的落地流程。整个过程我会尽量避开那些一次用不上的理论把重点放在环境、步骤、参数和实际会遇到的坑上。1. 动手之前先搞清楚“驱动”到底要做什么很多人被“驱动”这个词吓住了以为要操作硬件寄存器、要懂芯片手册。其实对于入门来说你可以先把驱动理解成一个“内核态的程序”它负责三件事向内核注册自己告诉内核“我来了我能管理某种设备”。提供一组标准操作函数比如打开(open)、读取(read)、写入(write)、关闭(close)、控制(ioctl)等。用户程序通过系统调用最终会走到这些函数里。在合适的时机被加载和卸载通常是系统启动时加载或者手动用命令加载。你第一次写的驱动完全可以不碰真实硬件。我们就写一个“虚拟字符设备驱动”它不对应任何物理设备只是在内存里划一块空间让用户程序能像读写文件一样读写这块内存。这样做的好处是你能集中精力理解驱动框架本身不用分心去调试硬件。1.1 你需要准备什么环境别在物理机上直接折腾万一模块写崩了可能导致内核恐慌(Kernel Panic)系统就起不来了。最稳妥的方式是用虚拟机。虚拟机软件VMware Workstation 或 VirtualBox 都行。Linux 发行版推荐 Ubuntu 20.04 LTS 或 22.04 LTS。它们内核版本较新社区支持好包管理器方便。别用太老的发行版内核和工具链可能不匹配。系统配置给虚拟机分配至少 2 核 CPU、4GB 内存、30GB 硬盘空间。安装时记得勾选“安装 OpenSSH server”和“安装开发工具”这样后面装编译环境省事。内核头文件这是编译驱动必须的。驱动是内核的一部分编译时需要知道当前内核的数据结构、函数声明在哪里。在 Ubuntu 里安装命令是sudo apt update sudo apt install linux-headers-$(uname -r)命令里的$(uname -r)会自动获取你当前运行的内核版本号然后安装对应版本的头文件包。装完后头文件通常在/lib/modules/$(uname -r)/build这个链接指向的目录里。编译工具链主要是gcc和make。如果安装系统时没选开发工具就手动装sudo apt install gcc make环境准备好后先别急着写代码。打开终端运行uname -r确认内核版本再运行ls /lib/modules/$(uname -r)/build确认内核头文件目录存在。这两步没问题后面编译才不会报找不到文件的错。2. 从最小的“Hello World”模块开始驱动开发的第一步不是写驱动而是写一个能加载到内核的“模块”。模块就是一个可以动态加载和卸载的内核代码单元。我们先写一个除了打印日志什么也不干的模块目标是掌握编译、加载、卸载、查看日志的完整流程。2.1 编写模块源码创建一个工作目录比如~/driver_study然后新建文件hello.c// hello.c - 最简单的内核模块 #include linux/init.h // 包含模块初始化和清理函数的宏 #include linux/module.h // 包含模块相关的基本宏和函数 #include linux/kernel.h // 包含内核打印函数 printk 等 // 模块加载时自动调用的函数 static int __init hello_init(void) { // printk 是内核空间的打印函数类似于用户空间的 printf // KERN_INFO 是日志级别表示普通信息。消息会输出到内核日志缓冲区。 printk(KERN_INFO Hello, world! Driver module loaded.\n); return 0; // 返回 0 表示初始化成功 } // 模块卸载时自动调用的函数 static void __exit hello_exit(void) { printk(KERN_INFO Goodbye, world! Driver module unloaded.\n); } // 以下宏用于告诉内核模块的入口和出口函数 module_init(hello_init); // 指定加载时调用的函数是 hello_init module_exit(hello_exit); // 指定卸载时调用的函数是 hello_exit // 模块的元信息 MODULE_LICENSE(GPL); // 声明模块采用 GPL 许可证必须要有否则加载可能警告 MODULE_AUTHOR(Your Name); // 作者信息 MODULE_DESCRIPTION(A simple hello world kernel module); // 模块描述 MODULE_VERSION(0.1); // 模块版本关键点解释__init和__exit是给函数打的标签告诉内核这些函数只在加载/卸载时用一次用完后可以释放它们占用的内存。printk的输出默认不会显示在终端上而是写到了内核的环形日志缓冲区里。需要用dmesg命令查看。MODULE_LICENSE(“GPL”)必须写而且最好是”GPL”。很多内核符号函数、变量只对 GPL 协议的模块导出如果你的模块协议不对加载时可能会报错“模块污染内核”甚至无法使用某些内核功能。2.2 编写 Makefile内核模块不能用普通的gcc命令编译必须用内核的构建系统(kbuild)。我们需要一个Makefile来告诉make工具如何调用kbuild。在同一目录下创建Makefile注意 M 大写# 指定内核源码目录$(shell uname -r) 获取当前内核版本 KERNEL_DIR ? /lib/modules/$(shell uname -r)/build # 指定当前模块源码目录 PWD : $(shell pwd) # 默认目标编译模块 obj-m : hello.o # 编译规则 all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules # 清理规则 clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) clean关键点解释obj-m : hello.o告诉内核构建系统我们要把一个名为hello.o的目标文件构建成模块(m)。注意这里的hello.o会自动由同名的hello.c源文件编译而来。$(MAKE) -C $(KERNEL_DIR) M$(PWD) modules这是核心命令。-C $(KERNEL_DIR)先切换到内核源码目录。M$(PWD)告诉内核构建系统模块的源码在$(PWD)即当前目录。modules执行内核源码目录里Makefile中定义的modules目标也就是编译模块。这个Makefile非常简单但它隐藏了内核模块编译的所有复杂细节比如处理内核依赖、符号表等。2.3 编译、加载、卸载、查看日志现在可以开始实操了。编译模块 在~/driver_study目录下打开终端直接运行make。make如果一切正常你会看到类似下面的输出并生成几个新文件其中最重要的是hello.koko就是 kernel object内核模块文件。make -C /lib/modules/5.15.0-91-generic/build M/home/yourname/driver_study modules make[1]: Entering directory /usr/src/linux-headers-5.15.0-91-generic CC [M] /home/yourname/driver_study/hello.o MODPOST /home/yourname/driver_study/Module.symvers CC [M] /home/yourname/driver_study/hello.mod.o LD [M] /home/yourname/driver_study/hello.ko make[1]: Leaving directory /usr/src/linux-headers-5.15.0-91-generic如果报错最常见的是Makefile:xxx: *** “No rule to make target ‘modules’. Stop.”。这几乎肯定是KERNEL_DIR路径不对。请再次用ls -l /lib/modules/$(uname -r)/build确认该目录存在且是一个有效的链接。加载模块 加载模块需要 root 权限因为这是向内核插入代码。sudo insmod hello.ko命令执行后看起来什么都没发生没有输出这是正常的因为printk的消息在日志里。查看加载日志 使用dmesg命令查看内核日志。为了只看我们模块的消息可以用grep过滤或者看最后几行。sudo dmesg | tail -5 # 或者 sudo dmesg | grep “Hello”你应该能看到我们写的”Hello, world! Driver module loaded.”这条信息。查看已加载模块lsmod | grep hello这个命令会列出所有已加载的模块并用grep过滤出包含 “hello” 的行。你应该能看到hello模块以及它的大小和被谁使用目前是0。卸载模块sudo rmmod hello注意这里用的是模块名hello而不是文件名hello.ko。查看卸载日志sudo dmesg | tail -5 # 或者 sudo dmesg | grep “Goodbye”你应该能看到”Goodbye, world! Driver module unloaded.”。恭喜到这里你已经完成了一个完整的内核模块“开发-编译-加载-卸载”循环。这个流程是所有驱动开发的基石。接下来我们在这个模块里加入“设备”的概念。3. 进阶创建一个简单的字符设备驱动字符设备Character Device是指以字节流形式被顺序访问的设备比如键盘、鼠标、串口。我们创建一个虚拟的字符设备用户程序可以像读写普通文件一样读写它。3.1 驱动需要实现的核心结构Linux 内核用struct file_operations这个结构体来抽象设备能做的操作。我们的驱动就是要实现这个结构体里的函数指针然后把它注册给内核。修改或新建mydev.c文件// mydev.c - 一个简单的字符设备驱动 #include linux/module.h #include linux/kernel.h #include linux/fs.h // 包含 file_operations 结构体 #include linux/cdev.h // 包含字符设备结构体 cdev #include linux/device.h // 用于自动创建设备节点可选但推荐 #include linux/uaccess.h // 包含 copy_to_user/copy_from_user #define DEVICE_NAME “mydev” // 设备名称 #define CLASS_NAME “myclass” // 设备类名称 static int major_num 0; // 主设备号0 表示动态分配 static struct class* mydev_class NULL; static struct cdev my_cdev; // 我们用一个简单的全局数组模拟设备内存 static char device_buffer[1024]; static int buffer_index 0; // 当用户程序执行 open() 系统调用打开设备文件时这个函数被调用 static int mydev_open(struct inode *inodep, struct file *filep) { printk(KERN_INFO “mydev: Device has been opened.\n”); return 0; } // 当用户程序执行 close() 系统调用关闭设备文件时这个函数被调用 static int mydev_release(struct inode *inodep, struct file *filep) { printk(KERN_INFO “mydev: Device has been closed.\n”); return 0; } // 当用户程序执行 read() 系统调用从设备文件读取时这个函数被调用 static ssize_t mydev_read(struct file *filep, char __user *buffer, size_t len, loff_t *offset) { int bytes_to_copy; int ret; // 计算还能从设备缓冲区读取多少字节 bytes_to_copy min((size_t)(buffer_index – *offset), len); if (bytes_to_copy 0) { return 0; // 表示 EOF (End Of File) } // 将内核空间的数据拷贝到用户空间 buffer ret copy_to_user(buffer, device_buffer *offset, bytes_to_copy); if (ret) { printk(KERN_ERR “mydev: Failed to copy %d bytes to user.\n”, ret); return -EFAULT; // 返回错误码 } printk(KERN_INFO “mydev: Sent %d bytes to user.\n”, bytes_to_copy); *offset bytes_to_copy; // 更新文件偏移量 return bytes_to_copy; // 返回实际读取的字节数 } // 当用户程序执行 write() 系统调用向设备文件写入时这个函数被调用 static ssize_t mydev_write(struct file *filep, const char __user *buffer, size_t len, loff_t *offset) { int bytes_to_copy; int ret; // 计算设备缓冲区还能写入多少字节 bytes_to_copy min((size_t)(sizeof(device_buffer) – buffer_index), len); if (bytes_to_copy 0) { return -ENOMEM; // 设备缓冲区已满 } // 将用户空间 buffer 的数据拷贝到内核空间 ret copy_from_user(device_buffer buffer_index, buffer, bytes_to_copy); if (ret) { printk(KERN_ERR “mydev: Failed to copy %d bytes from user.\n”, ret); return -EFAULT; } printk(KERN_INFO “mydev: Received %d bytes from user.\n”, bytes_to_copy); buffer_index bytes_to_copy; *offset bytes_to_copy; return bytes_to_copy; // 返回实际写入的字节数 } // 定义设备支持的操作集合 static struct file_operations fops { .owner THIS_MODULE, .open mydev_open, .release mydev_release, .read mydev_read, .write mydev_write, }; // 模块初始化函数 static int __init mydev_init(void) { int ret; dev_t dev_num; printk(KERN_INFO “mydev: Initializing the device driver.\n”); // 1. 动态申请一个主设备号和此设备号 ret alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (ret 0) { printk(KERN_ERR “mydev: Failed to allocate device number.\n”); return ret; } major_num MAJOR(dev_num); // 提取主设备号 printk(KERN_INFO “mydev: Allocated major number %d.\n”, major_num); // 2. 初始化 cdev 结构体并将其与 file_operations 关联 cdev_init(my_cdev, fops); my_cdev.owner THIS_MODULE; // 3. 将 cdev 添加到内核系统 ret cdev_add(my_cdev, dev_num, 1); if (ret 0) { printk(KERN_ERR “mydev: Failed to add cdev to system.\n”); unregister_chrdev_region(dev_num, 1); return ret; } // 4. (可选但推荐) 使用 udev/class 接口自动创建设备节点 mydev_class class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(mydev_class)) { printk(KERN_ERR “mydev: Failed to create device class.\n”); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(mydev_class); } // 在 /dev 目录下创建设备文件节点 device_create(mydev_class, NULL, dev_num, NULL, DEVICE_NAME); printk(KERN_INFO “mydev: Device node created at /dev/%s.\n”, DEVICE_NAME); // 初始化设备缓冲区 memset(device_buffer, 0, sizeof(device_buffer)); buffer_index 0; printk(KERN_INFO “mydev: Driver initialization successful.\n”); return 0; } // 模块清理函数 static void __exit mydev_exit(void) { dev_t dev_num MKDEV(major_num, 0); // 根据主设备号生成设备号 printk(KERN_INFO “mydev: Cleaning up the device driver.\n”); // 销毁设备节点和类与创建顺序相反 device_destroy(mydev_class, dev_num); class_destroy(mydev_class); // 从系统中删除 cdev cdev_del(my_cdev); // 释放设备号 unregister_chrdev_region(dev_num, 1); printk(KERN_INFO “mydev: Driver cleanup successful.\n”); } module_init(mydev_init); module_exit(mydev_exit); MODULE_LICENSE(“GPL”); MODULE_AUTHOR(“Your Name”); MODULE_DESCRIPTION(“A simple character device driver”);3.2 关键代码解析与避坑点file_operations结构体这是驱动和内核的“契约”。我们实现了open,release,read,write四个最基本的操作。owner字段通常设为THIS_MODULE。用户空间与内核空间的数据拷贝这是驱动开发中最容易出错的地方之一。内核不能直接访问用户空间指针用户空间也不能直接访问内核空间指针。必须使用copy_from_user和copy_to_user这两个函数在两者之间安全地拷贝数据。忘记使用它们或使用错误是导致系统崩溃的常见原因。设备号管理设备号由主设备号标识设备类型和次设备号标识具体设备组成。alloc_chrdev_region用于动态申请一个未被使用的主设备号。cat /proc/devices可以查看系统中已注册的设备号。cdev结构体内核用struct cdev来管理一个字符设备。需要先cdev_init初始化它再cdev_add将其添加到系统。自动创建设备节点老式方法需要手动mknod命令创建设备文件。现代驱动使用class_create和device_create驱动加载时udev 规则会自动在/dev下创建对应的设备节点如/dev/mydev极大方便了测试。错误处理内核编程必须严谨处理错误。在init函数中任何一步失败都必须逆向清理之前已成功的步骤比如申请了设备号后初始化 cdev 失败就要先释放设备号再返回错误。这是内核代码健壮性的基本要求。3.3 编译和测试这个驱动修改 Makefile 将obj-m : hello.o改为obj-m : mydev.o或者直接新增一行obj-m mydev.o来同时编译多个模块。编译make生成mydev.ko。加载驱动sudo insmod mydev.ko用dmesg | tail -10查看日志应该能看到驱动初始化成功并打印出动态分配的主设备号比如247以及设备节点创建信息。检查设备节点ls -l /dev/mydev你应该能看到类似crw——- 1 root root 247, 0 …的文件。c表示字符设备247, 0就是主设备号和次设备号。编写用户态测试程序 新建test_mydev.c// test_mydev.c #include stdio.h #include stdlib.h #include fcntl.h #include unistd.h #include string.h int main() { int fd; char write_buf[] “Hello from userspace!”; char read_buf[1024] {0}; // 1. 打开设备文件 fd open(“/dev/mydev”, O_RDWR); if (fd 0) { perror(“Failed to open the device.”); return -1; } printf(“Device opened successfully.\n”); // 2. 向设备写入数据 int bytes_written write(fd, write_buf, strlen(write_buf)); printf(“Wrote %d bytes to device: %s\n”, bytes_written, write_buf); // 3. 为了从头读我们先关闭再打开或者用lseek这里简单演示 close(fd); fd open(“/dev/mydev”, O_RDWR); // 4. 从设备读取数据 int bytes_read read(fd, read_buf, sizeof(read_buf) – 1); printf(“Read %d bytes from device: %s\n”, bytes_read, read_buf); // 5. 关闭设备 close(fd); return 0; }编译并运行测试程序gcc -o test_mydev test_mydev.c sudo ./test_mydev因为设备文件默认属于 root所以测试程序也需要sudo运行。你应该能看到程序成功打开设备、写入字符串、再读出相同字符串。查看驱动日志sudo dmesg | grep “mydev:”你会看到类似这样的输出记录了驱动内部函数的调用mydev: Device has been opened. mydev: Received 22 bytes from user. mydev: Device has been closed. mydev: Device has been opened. mydev: Sent 22 bytes to user. mydev: Device has been closed.卸载驱动sudo rmmod mydev再次检查/dev/mydev文件应该消失了。至此你已经完成了一个具备基本读写功能的字符设备驱动。用户程序通过标准的open、read、write、close系统调用就能与你的驱动交互。这就是驱动最核心的价值为硬件或虚拟设备提供统一的文件操作接口。4. 从“能跑”到“能用”生产级驱动要考虑什么上面的例子为了简洁省略了很多生产环境中必须考虑的问题。一个真正的驱动至少要处理好以下几点4.1 并发与同步我们的mydev驱动有个严重问题buffer_index是全局变量如果两个进程同时调用write它们会互相覆盖数据导致混乱。内核是多任务环境驱动必须假设自己的函数会被多个执行上下文进程、中断同时调用。解决方案使用内核提供的同步机制。信号量 (semaphore)或互斥锁 (mutex)用于保护较长时间、可睡眠的临界区。在open或write开始时加锁结束时解锁。自旋锁 (spinlock)用于保护非常短促、不可睡眠的临界区比如中断处理函数。原子变量 (atomic_t)用于简单的计数器操作。例如在驱动中引入互斥锁#include linux/mutex.h static DEFINE_MUTEX(mydev_mutex); // 定义并初始化一个互斥锁 static ssize_t mydev_write(...) { mutex_lock(mydev_mutex); // 加锁 // … 临界区代码 … mutex_unlock(mydev_mutex); // 解锁 return bytes_to_copy; }4.2 阻塞与非阻塞 I/O用户程序打开设备文件时可以指定O_NONBLOCK标志要求非阻塞操作。我们的驱动目前没处理这个。在read函数中如果设备没有数据可读应该如果文件打开模式是阻塞的让进程睡眠等待直到有数据。如果文件打开模式是非阻塞的立即返回-EAGAIN错误。这通常通过wait_queue等待队列和检查filep-f_flags O_NONBLOCK来实现。4.3 完善的文件操作我们只实现了最基本的四个操作。一个完整的驱动可能还需要llseek调整文件读写位置。poll/select/epoll支持 I/O 多路复用让用户程序可以监控多个设备是否可读/可写。ioctl用于实现设备特定的控制命令比如设置波特率、读取状态等。这是驱动实现复杂功能的主要接口。mmap将设备内存映射到用户进程地址空间实现零拷贝的高性能访问。4.4 电源管理与热插拔对于真实硬件驱动可能需要处理系统休眠/唤醒事件或者设备的热插拔USB设备等。这需要实现struct dev_pm_ops中的回调函数。4.5 使用设备树Device Tree在嵌入式 Linux 中硬件信息如寄存器地址、中断号不再硬编码在驱动里而是写在设备树.dts文件中。驱动需要通过of_*系列函数Open Firmware从设备树中获取这些资源。这是现代 Linux 驱动尤其是平台设备Platform Device驱动的标准做法。5. 调试与排查驱动出问题了怎么看驱动运行在内核态崩溃会导致整个系统不稳定。调试比用户程序困难主要靠日志和分析。5.1 打印日志的艺术 (printk)printk是驱动开发者的好朋友。它有多个日志级别KERN_EMERG紧急系统可能不可用。KERN_ALERT需要立即行动。KERN_CRIT临界状态。KERN_ERR错误条件。KERN_WARNING警告条件。KERN_NOTICE正常但重要的情况。KERN_INFO提示信息我们例子中用的。KERN_DEBUG调试信息。建议错误路径if (ret 0)用KERN_ERR。关键状态变化初始化成功、打开关闭用KERN_INFO。详细的流程跟踪用KERN_DEBUG并通过内核参数控制是否输出。使用%s,%d,%p等格式符时务必小心确保类型匹配。日志不是越多越好关键点打日志即可避免刷屏。5.2 使用dmesg和journalctldmesg直接查看内核环形缓冲区日志。常用dmesg | tail -n 50看最新dmesg | grep “你的驱动名”过滤。journalctl -k在使用了 systemd 和 journal 的系统上这个命令可以查看内核日志并且支持时间过滤、优先级过滤等更强大。journalctl -f实时跟踪内核日志输出类似于tail -f。5.3 常见错误与排查顺序insmod失败提示Invalid module format最常见原因编译模块用的内核头文件版本(/lib/modules/xxx/build) 和当前运行的内核版本(uname -r) 不一致。确保虚拟机没有自动更新内核后重启而你还在用旧的头文件编译。解决方法是安装匹配的头文件并重新编译。检查命令uname -r和ls /lib/modules/$(uname -r)/buildinsmod失败提示Unknown symbol in module你的模块使用了某个内核函数或变量但内核没有导出(EXPORT_SYMBOL)这个符号。可能是函数名拼写错误或者你用的函数是某个内核配置选项下的当前内核没开启。用sudo cat /proc/kallsyms | grep function_name查看该符号是否存在。模块加载后系统不稳定或死机可能原因驱动代码有严重 bug如空指针解引用、死锁、栈溢出等。排查加载后立即用dmesg看有无Oops或kernel panic信息。Oops会打印出错的调用栈和寄存器值是宝贵的调试信息。预防先在虚拟机中测试编写时注意指针判空、资源释放使用BUG_ON()或WARN_ON()在特定条件触发时主动抛出错误便于定位。用户程序读写设备返回错误如 -1检查errnoperror会打印。常见错误ENODEV设备不存在。检查/dev/下设备节点是否存在驱动是否加载。EACCES权限不足。检查设备节点权限ls -l /dev/your_dev用户是否有读写权限。测试时可以用sudo。EFAULT非法地址。通常是copy_to/from_user失败了检查用户空间缓冲区指针是否有效。EINVAL无效参数。检查ioctl的命令号或参数是否正确。资源泄漏每次insmod后用lsmod查看模块大小。如果反复加载卸载模块大小不断增长可能发生了内存泄漏kmalloc没有kfree。确保exit函数释放了init函数申请的所有资源设备号、cdev、class、内存、中断、定时器等顺序与申请时相反。5.4 更高级的调试手段printk时间戳dmesg -T可以显示人类可读的时间方便判断事件顺序。动态调试 (Dynamic Debug)可以运行时开启/关闭特定文件、函数的pr_debug打印非常灵活。需要内核开启CONFIG_DYNAMIC_DEBUG。使用strace跟踪用户程序strace ./test_program可以看到用户程序发出的所有系统调用及其参数、返回值对于判断是驱动问题还是应用问题很有帮助。内核调试器 (KGDB)配合另一台机器进行源码级单步调试功能强大但配置复杂适合调试极其棘手的问题。仿真器 (QEMU)在 QEMU 中运行内核和驱动可以方便地使用 GDB 进行调试是嵌入式驱动开发的常用方法。驱动开发真正的门槛不是语法而是对内核机制的理解和调试排错的能力。最好的学习方式就是像我们今天这样从一个最简单的模块开始让它跑起来然后一点点增加功能每加一个功能就测试遇到问题就按上面的链路去查日志、分析代码。这个过程里积累的经验远比只看书要深刻得多。我个人更建议你把第一个能跑通的驱动代码保存好把它当作一个“脚手架”。以后学新的内核机制比如锁、等待队列、中断、DMA就在这个框架上添加、修改、测试。有了这个可运行、可修改的起点再去看那些经典的驱动开发书籍比如《Linux设备驱动程序》你会发现自己能看懂、能关联上的东西越来越多。 30款热门AI模型一站整合DeepSeek/GLM/Qwen 随心用限时 5 折。 点击领海量免费额度