news 2026/9/9 18:54:45

Linux内核模块编程入门:从编写到加载第一个驱动

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux内核模块编程入门:从编写到加载第一个驱动

两年前我第一次在真实硬件上调Linux驱动,一条insmod命令把刚编译好的内核模块加载进系统,结果直接panic。从那一刻起我才真正明白:内核模块编程和用户态程序完全是两套思维方式。本文想带你走完"编写、编译、加载第一个Linux驱动"的全过程,把我踩过的坑、花了一整天才想通的原理,一次性讲清楚。它适合两类人:一类是刚接触Linux驱动、被各种术语劝退的新手;另一类是写过一些模块但只知其然、不知其所以然,想系统搞懂加载机制的人。跟着走一遍,你会理解为什么模块必须用GPL声明、为什么Makefile要那样写、为什么insmod有时候会莫名其妙失败。

1. 为什么要和"模块"打交道:Linux驱动形态的本质逻辑

1.1 模块化设计的初衷:从"重新编译内核"到"动态插拔"

Linux早期并不是现在这种形态。你要给某个硬件写驱动,最原始的做法是把驱动代码直接编进内核源码树,然后重新配置、编译整个内核,最后重启系统才能生效。如果你只是调一个字符设备驱动,每改一行代码就重新编译一次完整内核,那种痛苦几乎是劝退级的:一次完整编译动辄十几分钟到半小时,期间还不能出任何差错。

内核模块机制的出现就是冲着解决这个问题来的。驱动被编译成一个独立的.ko文件,系统运行的时候可以用insmod把它"插"进内核,不需要的时候用rmmod再"拔"出来。就好比一台打印机,你不必为了偶尔用一次扫描功能就把整个机器重装一遍,只需要插上扫描模块就能用,用完摘掉也不影响主机运行。这个思路让驱动的开发和调试效率提升了不止一个数量级。

当然,模块化不是没有代价。当你把代码编进内核时,编译器能做全程序优化,函数调用更高效;而模块机制涉及动态链接,性能上会有一点损耗。此外,模块加载时还可能面临"版本不一致"这类问题——我在第6章会展开讲。

1.2 内核态和用户态:模块运行在哪儿,凭什么危险

理解内核模块之前,你得先建立一个清晰的图景:Linux系统分成内核态和用户态两个世界。平时你运行的ls、bash、gcc这些程序都活用户态,它们通过系统调用向内核请求服务;而内核模块不同,它加载后直接运行在内核态,跟内核本身共享同一个地址空间,拥有最高的权限级别。

这意味着什么?**用户态程序段错误,顶多自己崩掉,内核不受影响;但是模块里一个野指针,可能直接让整个系统死机。**这也是我第一次加载驱动就panic的根本原因——我在模块里访问了一个未映射的内存地址。

所以做内核模块编程,最基本的心理准备是:你写的代码不再受用户态保护机制约束,任何内存访问错误都会变成系统级事故。这不是危言耸听,是每一个驱动开发者都必须接受的现实。后面讲__init__exit标记时你还会看到,内核如何想尽办法在模块生命周期结束后把资源回收干净。

1.3 一套代码两种身份:模块与内建驱动的编译差异

同样的驱动代码,既可以被编译成模块(.ko文件),也可以直接编进内核镜像(built-in)。选择哪种取决于你就是需求:模块适合开发和调试期,灵活、替换成本低;built-in适合最终产品,启动时驱动就在,不需要额外的加载流程,也不需要担心initramfs里少放了模块。

从代码角度看,这两者差了什么呢?核心是一个宏:module_init()。当一个驱动被编进内核镜像时,module_init()宏展开成的是"把初始化函数放进一个特殊的初始化段",内核启动到一定阶段会去调用它;而当这个驱动被编译成模块时,module_init()展开成另外一套逻辑,让insmod加载时能找到入口函数。你要做的只是保证初始化函数签名正确,剩下的事情Kbuild系统会自动处理。

理解这一点很重要,因为你在网上搜资料时会看到各种写法:有人写int init_module(void),有人写static int __init hello_init(void)module_init(hello_init)。这两种其实等效,但后者是现代内核推荐的方式,因为__init标记能让内核在启动完成后释放这段内存,省一点算一点。

2. 开工前夜:内核头文件版本对齐与开发工具准备

2.1 先确认你踩的到底是哪个内核

很多人写第一个内核模块,最大的误区是随便找个教程抄一遍Makefile就开编,编译通过了也不管它是针对哪个内核的,结果insmod的时候直接被拒绝。我见过太多人卡在这一步。

内核模块的本质是一段"寄生"在目标内核上的代码,它必须和当前运行的内核严格匹配。所以开工第一件事,不是写代码,而是弄清楚你当前系统跑的是什么内核版本。命令行执行:

uname -r

在我现在的Ubuntu系统上输出是:

5.15.0-91-generic

记住这个版本号。接下来所有准备工作都围绕它进行。如果你的开发机器是虚拟机,内核版本可能经常因为系统更新而变化,每次更新后都要重新确认,不要认为装过一次头文件就一劳永逸。

如果你是在开发板上做交叉编译,那就不是uname -r能解决的,你需要告诉编译系统目标平台的架构和内核源码路径。那是另一套玩法,本文先聚焦在本地编译这个最典型场景。

2.2 安装内核头文件并验证build目录可用

编译模块并不需要完整的内核源码,只需要内核头文件和一份"编译配置"。大多数发行版都提供了单独的头文件包,名字通常是linux-headers-$(uname -r)

Debian/Ubuntu系用:

sudo apt update sudo apt install linux-headers-$(uname -r)

RHEL/CentOS系用:

sudo yum install kernel-devel

装完之后,最关键的一个验证步骤是检查这个路径是否存在:

ls -l /lib/modules/$(uname -r)/build

这一步几乎可以筛掉一半的环境问题。正常情况你会看到类似输出:

lrwxrwxrwx 1 root root 44 Jun 20 10:23 /lib/modules/5.15.0-91-generic/build -> /usr/src/linux-headers-5.15.0-91-generic

build是一个软链接,指向真正存放头文件的目录。如果这个目录不存在,说明头文件没装好,或者当前内核版本对应的头文件包不在软件源里。老内核常遇到这种情况,解决办法通常是先更新系统,或者手动下载对应版本的头文件包。

2.3 编译工具链的最小集合

编译内核模块本质上是在一个已经配置好的内核工程里编译一个目标文件,所以编译器、链接器这些基础工具肯定要有。最省事的做法是安装整套编译工具:

sudo apt install build-essential sudo apt install make gcc

为了验证工具链可用,你可以先做个最简单的小测试:用gcc编译一个hello.c,确认能生成可执行文件。这能帮你区分"工具链本身坏了"和"内核模块编译环境有问题"两类故障。

顺带提一个前提:如果你是在自己的电脑上做实验,优先选一台刚装完系统、没改过内核的机器。自己从源码编译过内核的机器,头文件路径可能指向自定义内核源码,Makefile写法和发行版默认路径不一致,容易踩额外的坑。

3. 第一个模块代码逐行拆解:Hello Linux Kernel

3.1 模块的入口与出口:module_init和module_exit的约定

先看完整代码,这是本文最核心的一小段,建议手动敲一遍而不是直接复制:

#include <linux/init.h> #include <linux/module.h> #include <linux/moduleparam.h> #include <linux/kernel.h> static int hello_count = 1; module_param(hello_count, int, 0644); MODULE_PARM_DESC(hello_count, "Number of times to print hello"); static int __init hello_module_init(void) { int i; for (i = 0; i < hello_count; i++) { printk(KERN_INFO "Hello, Linux kernel module!\n"); } return 0; } static void __exit hello_module_exit(void) { printk(KERN_INFO "Goodbye, kernel module unloaded.\n"); } module_init(hello_module_init); module_exit(hello_module_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple hello world kernel module"); MODULE_VERSION("0.1");

module_initmodule_exit这两个宏是模块的"注册函"。module_init(hello_module_init)告诉内核:这个模块的初始化函数是hello_module_init,你加载模块时调用它。module_exit同理,告诉内核卸载时该调用谁。你完全可以不叫hello_module_init这个名字,叫什么都可以,只要通过宏注册进去就行。

3.2 为什么用printk而不是printf,以及日志级别

刚接触内核模块的人第一反应往往是:这代码怎么也不像C语言,连printf都没有?答案是:printf是C标准库的函数,而内核模块运行在内核态,它不链接任何用户态C库,甚至不链接传统的libc。用户态程序依赖的stdio、malloc这些基础设施,在内核态统统不存在。

内核提供的"输出函数"是printk,它把消息写到内核环形缓冲区,而不是标准输出。你在终端里看不到,得用dmesg命令才能翻出来。

再看printk(KERN_INFO "Hello, Linux kernel module!\n");这行。KERN_INFO是日志级别,它告诉内核这条消息的重要程度。常见级别从高到低有KERN_EMERGKERN_ALERTKERN_CRITKERN_ERRKERN_WARNINGKERN_NOTICEKERN_INFOKERN_DEBUG。驱动开发调试期最常用的是KERN_INFOKERN_ERR

提示:printk的日志级别不只是个摆设。系统会根据日志级别决定消息是否输出到控制台。调高控制台日志级别(比如echo 8 > /proc/sys/kernel/printk)可以使低级别消息也能直接显示在终端上,这在调试驱动时很实用。

3.3 许可证和元信息声明:这不是走形式

代码末尾的几行宏经常被初学者忽略,但它们决定了你的模块能不能被Linux内核社区"接纳",甚至影响加载行为。

MODULE_LICENSE("GPL")声明的是许可证。这个声明除了法律意义,还有一层技术含义:Linux内核内部很多符号(函数、变量)是只对GPL许可的模块可见的,如果你声明私有许可证,那些符号在你模块里被引用时,链接阶段就会报错"Unknown symbol"。所以如果你想让自己的驱动在内核社区顺畅流通,MODULE_LICENSE("GPL")基本上是必选项。

MODULE_AUTHORMODULE_DESCRIPTIONMODULE_VERSION这些是元信息,它们不影响代码逻辑,但是会被记录到模块信息里,你可以用modinfo hello_module.ko查看。养成写完整元信息的习惯,对后续问题排查很有帮助。

我在代码里还加了一个module_param,这是为了演示模块参数传递。加载模块时你可以这样:sudo insmod hello_module.ko hello_count=5,那么hello_count会被赋值为5,打印5次。这个机制在你需要给驱动传配置项时非常有用,而且它会在/sys/module/hello_module/parameters/下生成对应的sysfs节点,运行状态下改这个参数文件也能动态调整。

4. 编译环节:Makefile与Kbuild的协作逻辑

4.1 obj-m到底是什么:Kbuild如何决定谁被编译成模块

在Linux内核里,模块的编译不是靠你自己写一个gcc命令去完成,而是通过内核源码树里的Kbuild系统。Kbuild会自动处理依赖关系、编译选项、头文件路径,你只需要告诉它"我想要哪个文件被编译成模块"。

这个"告诉"的方式就是obj-m变量。它是一个特殊的内核构建变量:

  • obj-y:编译并链接进内核镜像
  • obj-m:编译成独立的可加载模块(.ko文件)

所以obj-m := hello_module.o的意思是:把hello_module.c编译成内核模块。这里的.o后缀有点误导,其实它最终会生成.ko文件。Kbuild看到obj-m指向hello_module.o,就会去对应的源码目录找hello_module.c

如果你有多个源文件组成一个模块,写法会有变化,需要用到模块名-objs这种形式。新手阶段不需要,但先了解一下没坏处:

obj-m := mydriver.o mydriver-objs := main.o helper.o

4.2 最简单的Makefile长啥样,每行都是什么意思

这是我目前最推荐新手使用的Makefile,简单、稳定、不容易出错:

obj-m := hello_module.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean

逐行拆解一下:

  • obj-m := hello_module.o:告诉Kbuild把hello_module.c编译成模块。
  • KDIR:指向当前运行内核的构建目录。前面我们已经确认了/lib/modules/$(uname -r)/build存在,这里就是用它。
  • PWD:记录你当前所在的源码目录。因为编译动作实际上发生在内核源码目录里,Kbuild需要靠M=$(PWD)知道"外部模块的源码在哪里"。
  • all目标:调用内核Makefile,传入M参数,执行modules目标。这段逻辑是Linux内核为外部模块编译预留的标准接口。
  • clean目标:调用同样的入口,执行清理。

-C $(KDIR)的意思是让make先切换到内核目录,读取那里的顶层Makefile,再去处理modules目标。M=$(PWD)则是告诉Kbuild:"我这是个外部模块,源码在M指定的目录。" 这两者的配合是整个编译机制的核心。

4.3 正常编译输出长什么样:从报错中辨认有效信息

在这个目录下执行make,你会看到类似下面的输出:

make -C /lib/modules/5.15.0-91-generic/build M=/home/user/hello_module modules make[1]: Entering directory '/usr/src/linux-headers-5.15.0-91-generic' CC [M] /home/user/hello_module/hello_module.o MODPOST /home/user/hello_module/Module.symvers CC [M] /home/user/hello_module/hello_module.mod.o LD [M] /home/user/hello_module/hello_module.ko make[1]: Leaving directory '/usr/src/linux-headers-5.15.0-91-generic'

如果IDE那种红底白字的报错铺满屏幕,不要慌,定位规则和用户态编译是一样的:先找error:开头的行,看它指向哪个文件哪一行,然后往前看几行context。最常见的模式是头文件找不到、语法错误、宏未定义这三类,八成是环境问题,不是代码本身的问题。

编译成功后会生成一个hello_module.ko文件,同时还会出现一堆中间产物:.o.mod.c.mod.oModule.symversmodules.order等。.ko文件就是最终要加载的模块文件。可以用file hello_module.ko查看它的格式,内核模块其实是ELF格式的可重定位目标文件,只是有了一些特殊的section。

5. 加载、验证与卸载:第一次和内核握手

5.1 insmod加载成功之后发生了什么

执行加载命令需要root权限:

sudo insmod hello_module.ko

如果命令没有任何输出,默默地返回了,那基本就是成功了。这跟用户态程序不同,不是"没有消息就是好消息"的普通情况,在内核模块里,没有报错就是天大的好消息。

insmod内部实现是通过init_module这个系统调用把模块文件的内容提交给内核。内核会先做一系列检查:模块文件格式、签名(如果有)、版本兼容性、依赖符号等。检查通过后,内核为模块分配内存,把代码段、数据段、.modinfo等section加载到内核地址空间,然后调用module_init注册的初始化函数。

我强烈建议每完成一次加载,立刻就检查日志:

dmesg | tail -n 20

正常你会看到类似输出:

[ 456.789012] Hello, Linux kernel module!

这个时间戳是内核启动以来的秒数,不是我们熟悉的日期格式。如果你的模块里用了hello_count参数,加载时指定了不同值,输出次数会相应变化。

5.2 lsmod、dmesg、rmmod:一组命令完成闭环

加载完成后,用lsmod检查模块列表。它会读取/proc/modules,显示当前加载了哪些模块:

lsmod | grep hello_module

输出:

hello_module 16384 0

三列含义分别是:模块名、占用内存大小(字节)、被引用次数。如果reference count不是0,说明有其他模块或内核组件正在使用它,此时rmmod会失败,报"Module is in use"。

接下来测试卸载:

sudo rmmod hello_module

再执行dmesg | tail -n 20,应该能看到卸载日志:

[ 789.012345] Goodbye, kernel module unloaded.

从这段输出你其实能看到完整生命周期:加载时init函数执行,卸载时exit函数执行。这比任何文档都能直观地说明模块机制。

5.3 模块加载失败时的通用排查顺序

第一次加载失败太正常了,不要气馁。我总结了一套排查顺序,按这个走,90%的问题能在两分钟内定位:

  1. 先看dmesg | tail -n 30的报错信息,它是最直接的内核回应。
  2. 检查模块文件是否和当前内核匹配,报错里会有version magic字样。
  3. 检查模块文件是否真的是用当前头文件编译的,必要时重新make clean && make
  4. 确认你有没有用root权限执行insmod。
  5. 如果报"Operation not permitted",考虑Secure Boot或模块签名问题(后面细说)。
  6. 如果加载成功但你的设备没反应,别急着怀疑驱动逻辑,先确认设备树或设备ID是否匹配。

这条链路我做过无数次,相信我,它救过很多个深夜。

6. 那几类反复出现的报错,和我的排查路径

6.1 version magic不匹配:头文件版本对不上

这是我见过最多的报错,几乎每一个新手都会踩。典型报错长这样:

[ 123.456789] hello_module: version magic '5.15.0-91-generic SMP mod_unload modversions' should be '5.15.0-88-generic SMP mod_unload modversions'

翻译成人话就是:当前内核是5.15.0-88,而你编译模块用的头文件是5.15.0-91的,两者不匹配。这个问题几乎都是因为你用apt upgrade更新过系统,内核升级到了新版本,但头文件包没有同步更新,或者编译时用的KDIR路径指向了旧头文件。

解决办法也很简单:重新确认uname -r,把/lib/modules/$(uname -r)/build路径指对,然后make clean && make重新编译。如果你需要使用旧内核,可以重启时在GRUB菜单选择旧内核进入,但那只能作为临时手段,长期来看还是建议跟随系统版本更新。

6.2 权限问题:insmod无法加载和Secure Boot

Permission deniedOperation not permitted这类报错,优先级排在第二位。先说一个最简单的情况:执行insmod需要root权限,如果你用普通用户执行,会直接报Operation not permitted。加上sudo即可。

但如果你已经用了sudo,仍然报Operation not permitted,这时候就要考虑Secure Boot了。一些新装机的PC默认开启了UEFI Secure Boot,它会阻止未签名的内核模块被加载。解决办法有两条路:

一种是在BIOS设置里关闭Secure Boot,这对个人开发机是最省事的方案。另一种是给模块签名。签名流程涉及mokutilopenssl生成密钥、注册Machine Owner Key等步骤,稍显繁琐,适合需要保持Secure Boot开启的正式环境。开发初期我建议直接关掉,把精力花在驱动逻辑上。

6.3 符号找不到、内核被污染:隐藏的坑

如果你在模块里引用了某些内核符号(函数或变量),而这些符号没有导出给模块使用,链接阶段就会报错:

hello_module: Unknown symbol xxx (err -2)

这时你需要确认这个符号是否在内核的Module.symvers文件中。Module.symvers记录了当前内核导出了哪些符号。如果符号确实没有导出,你有两个选择:换一个已导出的等价函数,或者修改内核的导出列表后重新编译内核。后者工程量大,新手一般不推荐。

还有一个看起来很吓人但影响不大的提示:

[ 456.789012] hello_module: module verification failed: signature and/or required key missing - tainting kernel

这行日志的意思是模块没有有效签名,内核把"内核被污染"的状态标记了下来。它不影响模块运行,只是Linus Torvalds和一些维护者认为未签名或非GPL模块可能引入不稳定因素,所以特意在日志里标出来。看到这个不用慌,开发阶段是正常现象;正式发布产品时,你需要配置好签名流程。

7. 从helloworld到能干活:下一步该写什么

7.1 从printk到操作真实硬件:字符设备的骨架

如果你以为写驱动就是printk几句话,那还差得远。真正的驱动是给用户态程序提供访问硬件或内核功能的接口。最常见的形态是字符设备,它以/dev/xxx这样的文件形式暴露给用户态,用户态程序用open、read、write、ioctl这些熟悉的系统调用来操作它。

一个最小字符设备骨架大概长这样:

#include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> static int hello_open(struct inode *inode, struct file *filp) { printk(KERN_INFO "hello device opened\n"); return 0; } static struct file_operations hello_fops = { .owner = THIS_MODULE, .open = hello_open, }; static dev_t hello_devno; static struct cdev hello_cdev; static struct class *hello_class; static int __init hello_init(void) { alloc_chrdev_region(&hello_devno, 0, 1, "hello_dev"); cdev_init(&hello_cdev, &hello_fops); cdev_add(&hello_cdev, hello_devno, 1); hello_class = class_create(THIS_MODULE, "hello_class"); device_create(hello_class, NULL, hello_devno, NULL, "hello_node"); return 0; }

这里引入了三个新概念:file_operations(定义设备支持哪些操作)、cdev(字符设备的内核对象)、classdevice(负责在/sys下创建设备节点)。这段代码的价值在于,它把"模块"和"用户态可操作的文件"连接起来,第一次让你的驱动有了实际功能。

7.2 设备和模块的关系:驱动模型的现实

很多新手会困惑:驱动到底是模块还是设备?两者其实是不同维度的概念。模块是代码的载体,设备是硬件或虚拟对象的抽象。一个模块可以支持多个设备,一个设备也可以由多个模块协作驱动。现代Linux驱动模型通过devicedriver两个核心结构,把它们组织成一条清晰的链路。

下一阶段你应该去了解platform_driverdevice_tree这两样东西。前者是Linux对"不可枚举设备"的标准抽象,很多嵌入式设备(I2C、SPI设备等)都基于它编写;后者则是描述硬件配置的标准方式。简单说,platform_driver提供了一个标准框架,把设备匹配、probe、remove这些生命周期函数规范化了,你写驱动时只需要填充这些回调函数。

等到你能独立写出一个字符设备,并且能配合设备树让它在真实硬件上工作,基本上就算入门了。这个过程可能比你想的要长,但每一步都值得。

我个人的建议是:别急着学一堆热门的驱动框架。先把helloworld这个模块的加载、卸载、传参、日志这些基本功做到闭着眼都能操作,然后再去碰字符设备和平台驱动。内核对模块的加载机制、符号解析机制、版本检查机制,都是整个驱动开发的地基,地基没打牢,后面学得越快,摔得越痛。我自己就是在直冲设备树之后又回来补模块加载原理的,走了一段弯路。你如果能把这篇里的每一条命令、每一个宏都亲手验证一遍,后续写驱动会顺畅很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 18:52:47

Telegraf 指标采集快速上手:5 分钟跑通第一条监控数据流

Telegraf 指标采集快速上手&#xff1a;5 分钟跑通第一条监控数据流 【免费下载链接】telegraf Agent for collecting, processing, aggregating, and writing metrics, logs, and other arbitrary data. 项目地址: https://gitcode.com/GitHub_Trending/te/telegraf 凌…

作者头像 李华
网站建设 2026/9/9 18:52:44

微信小程序模仿滴滴打车:地图选点与订单支付全流程实战

简介&#xff1a;一份模仿滴滴打车的小程序项目&#xff0c;适合小程序开发者或初学者学习出行类应用的完整实现思路。项目围绕乘客叫车流程展开&#xff0c;覆盖位置获取、地图展示、路线规划、订单状态管理与支付对接等关键环节&#xff0c;并包含templates、utils、libs等目…

作者头像 李华
网站建设 2026/9/9 18:50:14

32位程序内存天花板:LAA标志解锁4GB虚拟地址空间全攻略

简介&#xff1a;面向C/C与C#开发者的32位程序大内存支持工具包&#xff0c;围绕LAA&#xff08;Large Address Awareness&#xff09;技术&#xff0c;解决x86系统下32位进程默认只能访问2GB用户态内存的问题&#xff0c;使程序可尝试申请3GB乃至4GB地址空间。资源适用于大数据…

作者头像 李华
网站建设 2026/9/9 18:49:51

多路PWM模拟DAC实战:从RC滤波到STM32实现指南

简介&#xff1a;基于STM32的多路PWM模拟DAC输出电压工程包&#xff0c;聚焦PWM脉宽调制技术在高精度多通道电压输出场景的应用。工程基于STM32与Keil5环境开发&#xff0c;利用12路PWM通道通过调节占空比生成最高3.2V的可变电压&#xff0c;可用于LED调光、电机调速、模拟量输…

作者头像 李华
网站建设 2026/9/9 18:48:47

高通GPU内置AI核心:端侧AI算力融合的关键一跃

看到“高通给GPU装上专用AI核心”这条消息&#xff0c;我第一反应不是“又升级了”&#xff0c;而是“终于走到这一步了”。从早年的Hexagon DSP到后来的独立NPU&#xff0c;再到今天把AI核心直接设计进Adreno GPU内部&#xff0c;高通的算力架构终于和苹果的Neural Engine、英…

作者头像 李华
网站建设 2026/9/9 18:47:53

10 分钟上手 ShareX:免费开源截图、打码、录屏一次搞定

10 分钟上手 ShareX&#xff1a;免费开源截图、打码、录屏一次搞定 【免费下载链接】ShareX ShareX is a free and open-source application that enables users to capture or record any area of their screen with a single keystroke. It also supports uploading images, …

作者头像 李华