嵌入式Linux驱动开发指南02------内核空间基础与硬件访问
仓库已经开源!所有教程,主线内核移植,跑新版本imx-linux/uboot都在这里!欢迎各位大佬观摩!喜欢的话点个⭐!
仓库地址:https://github.com/Awesome-Embedded-Learning-Studio/imx-forge
静态网页:https://awesome-embedded-learning-studio.github.io/imx-forge/
从裸机到Linux的认知障碍
如果直接把裸机代码搬到 Linux 里,能不能用?
这是一个极其诱人的想法。你已经熟知了 I.MX6ULL 的每一个寄存器,知道怎么把复用功能选通、怎么把时钟门打开、怎么把 GPIO 方向设为输出。在你看来,点亮一颗 LED 只需要几行赋值代码------指针一指,数据一写,灯亮了。
但在 Linux 下,这行不通。或者说,如果不理解 Linux 眼里的世界,直接去动硬件,你得到的要么是一串刺眼的 Unable to handle kernel paging request,要么就是系统毫无反应地死锁。
这里有一个根本性的认知障碍:你在裸机时代拥有的是上帝视角,可以直接操纵物理地址;而在 Linux 里,你只是众多进程中的一个,甚至连内核自己都被 MMU(内存管理单元)挡在了物理世界之外。
第一部分:两座城池的故事
为什么要分开?
在上一章,我们站在城墙外看了一眼字符设备驱动,知道它是连接应用和硬件的"翻译官"。但在这之前,我们得先搞清楚一个问题:为什么需要这个翻译官?为什么不能让应用程序直接操作硬件?
这个问题的答案,藏在 Linux 最基本的设计哲学里------把世界分成两半。
想象一下,如果所有程序都能直接访问硬盘、读取你浏览器保存的密码、或者修改其他进程的内存------这个世界会变成什么样?
答案:混乱。
任何程序都可以:
- 窃取其他程序的隐私数据
- 导致系统崩溃(比如随意修改内核数据结构)
- 绕过安全限制(比如直接读取磁盘上的任何文件)
所以 Linux 建立了这套"两座城池"的制度:
- 用户空间:普通市民生活的地方,自由但受限
- 内核空间:政府机构所在地,特权但责任重大
两套规则
根据 ARM 官方文档,ARM32 Linux 的虚拟地址空间划分如下(标准 3GB/1GB 分割配置):
用户空间(User Space)
- 地址范围 :0x00001000 ~ TASK_SIZE-1(通常 TASK_SIZE = 0xC0000000,即低 3GB)
- 权限:受限(用户态)
- 用途:进程的 text/data/heap,通过 mmap() 系统调用创建的映射
- 限制:不能直接访问硬件、不能执行特权指令、不能访问内核内存
注意:0x00000000 ~ 0x00000fff 保留用于 CPU 向量页和空指针陷阱
内核空间(Kernel Space)
内核空间包含多个区域,从 PAGE_OFFSET(通常为 0xC0000000)开始:
| 区域 | 地址范围 | 用途 |
|---|---|---|
| 直接映射 RAM | PAGE_OFFSET ~ high_memory-1 | 内核直接映射 RAM,与物理 RAM 1:1 对应 |
| vmalloc/ioremap | VMALLOC_START ~ VMALLOC_END-1 | vmalloc()/ioremap() 动态映射区域 |
| 永久映射 | PKMAP_BASE ~ PAGE_OFFSET-1 | HIGHMEM 页的永久内核映射 |
| 模块空间 | MODULES_VADDR ~ MODULES_END-1 | 通过 insmod 加载的内核模块 |
| 固定映射 | ffc80000 ~ ffefffff | fix_to_virt() 提供的固定映射 |
权限 :完全(内核态 Ring 0)
职责:管理系统资源、调度进程、处理中断、驱动硬件
关于 64 位系统
上述讨论仅适用于 32 位 ARM 系统。
64 位系统 (如 ARM64/x86_64)使用完全不同的内存布局,称为 canonical address layout:
- 虚拟地址空间远大于 4GB(ARM64/x86_64 支持 48 位或更多虚拟地址位)
- 用户空间和内核空间通常采用非对称布局
- 用户空间占用较低的虚拟地址范围
- 内核空间占用最高的虚拟地址范围
- 中间有一段不可用的"hole"区域(canonical 地址的要求)
根据 x86_64 文档,现代 64 位系统使用 4 级或 5 级页表,支持的虚拟地址空间可达 256TB(4 级)或 128PB(5 级)。
地址空间的隔离
在驱动开发中,你必须时刻记住一个重要事实:
用户空间的地址和内核空间的地址是完全隔离的,即使数字相同,指向的也是不同的物理内存。
ARM32 内存布局示意图
根据官方文档,完整的 ARM32 内存布局如下:
用户空间 (每个进程独立)
────────────────────────────────────────────────────
0x00000000 ─┐
0x00000fff ├─ CPU 向量页 / 空指针陷阱
│
0x00001000 ─┐
├─ 用户空间映射 (TASK_SIZE-1)
│ text/data/heap,通过 mmap() 创建
│
0xBFFFFFFF ─┘ (TASK_SIZE-1)
内核空间 (所有进程共享)
────────────────────────────────────────────────────
│
0xC0000000 ─┐ (PAGE_OFFSET)
├─ 内核直接映射 RAM (1:1)
│
VMALLOC_START ─┐
├─ vmalloc()/ioremap() 动态映射区域
│
VMALLOC_END ─┘ (0xff800000)
├─ 模块空间、固定映射等
│
0xFFFFFFFF ─┘
关键点
- 每个进程都有独立的用户空间(0 ~ TASK_SIZE-1)
- 所有进程共享同一个内核空间(PAGE_OFFSET ~ 0xFFFFFFFF)
- 相同的虚拟地址在不同进程中可能指向不同的物理内存
- ioremap() 返回的地址位于 vmalloc 区域
第二部分:系统调用------城门关卡
现在问题来了:如果用户空间的程序需要读取文件,但它无权直接访问硬盘,怎么办?
答案是:走正规程序,申请内核代劳。
这个"正规程序"就是系统调用(System Call)。
系统调用的工作流程
用户程序 内核
↓ ↓
open("/dev/led", O_RDWR)
│
├─→ 触发软中断 (int 0x80 / syscall)
│ (CPU 从用户态切换到内核态)
↓
内核接管执行
│
├─→ 检查权限
├─→ 查找 /dev/led 对应的驱动
├─→ 调用驱动的 open() 函数
↓
返回文件描述符给用户程序
CPU 特权级(特权环)
x86/ARM 架构提供多个特权级,Linux 只用了两个:
- Ring 0(内核态):可以执行任何指令,访问所有内存
- Ring 3(用户态):受限,不能执行特权指令
从用户态切换到内核态的唯一合法途径就是系统调用。
常见的系统调用
你可能已经用过很多系统调用,只是没意识到:
| 用户态函数 | 系统调用 | 作用 |
|---|---|---|
open() |
SYS_open |
打开文件 |
read() |
SYS_read |
读取文件 |
write() |
SYS_write |
写入文件 |
malloc() |
SYS_brk |
分配内存 |
pthread_create() |
SYS_clone |
创建线程 |
第三部分:MMU------地址翻译官
为什么不能直接用物理地址?
在裸机程序里,我们定义一个宏 #define GPIO_DR 0x0209C000,然后把 0x0209C000 当作一个地址直接赋给指针,这很自然。但在 Linux 内核里,这个 0x0209C000 是一个陌生人。
为什么?因为 MMU。
停下来想一想:你在裸机时代写下的那些物理地址,在 Linux 内核启动的那一刻,已经全部失效了。CPU 看到的不再是物理地址,而是虚拟地址。
问题来了 :如果我现在想操作 GPIO1 的数据寄存器(物理地址 0x0209C000),在 Linux 下该怎么找到它?
答案是:我需要向内核申请,让内核帮我把这个物理地址"翻译"成一个我可以用的虚拟地址。
MMU 干什么活的?
MMU(Memory Management Unit,内存管理单元)是现代处理器的标配,也是 Linux 内核赖以生存的基石。
根据官方文档,MMU 主要负责:
- 地址翻译:将程序使用的虚拟地址(Virtual Address)转换为物理内存的真实地址(Physical Address)
- 内存保护:控制谁能读写哪块内存,实现进程间的隔离
虚拟地址如何转换为物理地址?
官方文档描述了地址翻译的过程:
"每个内存访问都使用虚拟地址。当 CPU 解码一条读取(或写入)系统内存的指令时,它将指令中编码的虚拟地址转换为内存控制器可以理解的物理地址。"
物理系统内存被划分为页帧(page frames 或 pages)。页大小是架构特定的,有些架构允许在多个支持的值中选择页大小。
每个物理内存页可以映射为一个或多个虚拟页。这些映射由页表(page tables)描述,页表按层次结构组织:
- 最底层页表包含软件使用的实际页的物理地址
- 较高层页表包含属于较低层的页的物理地址
- 指向顶层页表的指针驻留在寄存器中
当 CPU 执行地址翻译时:
- 使用寄存器访问顶层页表
- 虚拟地址的高位用于索引顶层页表中的条目
- 该条目用于访问层次结构中的下一级
- 虚拟地址的下一级位作为该级页表的索引
- 虚拟地址的低位定义实际页内的偏移量
TLB:地址翻译的缓存
地址翻译需要多次内存访问,而内存访问相对于 CPU 速度来说很慢。为了避免在地址翻译上浪费宝贵的处理器周期,CPU 维护着此类翻译的缓存,称为TLB(Translation Lookaside Buffer,旁路缓冲区)。
虚拟地址 vs 物理地址
官方文档解释了虚拟内存的概念:
"虚拟内存抽象了应用程序软件的物理内存细节,允许只在物理内存中保留需要的信息(需求分页),并提供了进程间保护和受控数据共享的机制。"
在 Linux 内核启动初期,它会初始化 MMU,建立页表映射。在这之后,CPU 执行的所有指令、访问的所有数据,用的全是虚拟地址。
设备寄存器访问的问题
举例说明物理设备寄存器的访问:
- I.MX6ULL 的
GPIO1_IO03复用寄存器IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO03的物理地址 是0X020E0068 - 在没有 MMU 的裸机环境下,你直接往
0X020E0068写数据,信号直接传导到硬件外设 - 但在 Linux 下 ,MMU 已经启用。如果你直接往
0X020E0068这个虚拟地址写数据,你要么是在访问一段内核未映射的内存(触发 Page Fault),要么是在访问一段完全不相关的系统内存
结论 :我们必须通过 ioremap() 向内核申请:把「物理地址 0X020E0068」映射到「某个可用的虚拟地址 V」上。
第四部分:ioremap------建立映射关系
银行保险箱的比喻
你可以把物理内存想象成银行保险库里的保险箱 ,编号是 0X020E0068。
- 你不能直接走进金库拿着锤子去砸那个箱子(物理隔离)
- 你需要银行柜员(MMU )给你一个临时柜台窗口(虚拟地址)
- 当你向柜员出示证件(调用
ioremap)说你要操作0X020E0068号箱子时,柜员会在大厅里给你指定一个 3 号窗口 - 以后你只要跟 3 号窗口打交道,柜员会自动把你的指令传递到金库里的那个箱子
但这个比喻有个关键的细节:保险箱是静态的,而硬件寄存器是动态的。如果你对 3 号窗口的操作被柜员记在小本本上(缓存),稍后再批量提交,那硬件反应就会迟钝。
ioremap 的作用就是告诉柜员:「别缓存,我每说一句话你都要立刻跑去金库执行一遍。」
函数原型与使用
ioremap 的定义在内核的 I/O 内存接口中。从使用角度来看,它的核心参数很直观:
c
void __iomem *ioremap(phys_addr_t phys_addr, size_t size);
核心参数:
- phys_addr :你要映射的物理起始地址。比如 GPIO 寄存器的
0X020E0068 - size:你要映射多大的空间。一个寄存器通常是 4 字节(32位),但为了效率,有时候我们会映射一整块寄存器区域
- 返回值 :
void __iomem *类型的指针。这就是那个「虚拟地址 V」。之后我们操作 V,就是在操作 phys_addr
__iomem 标记告诉编译器和静态分析工具:这是个 I/O 内存地址,不能像普通内存那样随意操作。
实际使用示例
c
/* 寄存器物理地址 */
#define SW_MUX_GPIO1_IO03_BASE (0X020E0068)
/* 映射后的虚拟地址指针,__iomem 标记这是个 I/O 内存地址 */
static void __iomem *SW_MUX_GPIO1_IO03;
/* 执行映射 */
SW_MUX_GPIO1_IO03 = ioremap(SW_MUX_GPIO1_IO03_BASE, 4);
if (!SW_MUX_GPIO1_IO03) {
printk("ioremap failed\n");
return -ENOMEM;
}
这里 size 传了 4,因为我们只操作这一个 32 位的寄存器。
回到那个类比:现在 SW_MUX_GPIO1_IO03 就是那个「3 号窗口」。以后你读写 *SW_MUX_GPIO1_IO03,其实就是在读写那个远在金库里的物理寄存器。
iounmap:有借有还
当你的驱动卸载时,必须把占用的映射释放掉,把窗口还给内核大厅。这需要用到 iounmap:
c
void iounmap(volatile void __iomem *addr);
c
/* 卸载时释放映射 */
iounmap(SW_MUX_GPIO1_IO03);
这一步如果忘了做,不仅仅是内存泄漏的问题。你映射的是设备地址,如果不释放,内核可能误以为这块地址空间还在被占用,后续其他驱动想访问这段地址时可能会出问题。
虽然现在的系统没那么脆弱,但作为工程师,我们不做这种只借不还的事。
第五部分:I/O 内存访问函数
为什么不能用指针直接读写?
现在我们已经拿到了虚拟地址指针 SW_MUX_GPIO1_IO03,是不是可以直接用 C 语言的 * 和 = 操作符来读写了呢?
就像这样:
c
/* ❌ 糟糕的写法 */
unsigned int val = *SW_MUX_GPIO1_IO03;
*SW_MUX_GPIO1_IO03 = val | (1 << 0);
虽然很多老旧的或者不规范的驱动确实这么干,甚至在某些简陋的硬件上也能跑,但 Linux 内核强烈反对这样做。
因为硬件寄存器不是 RAM:
- 读写副作用:有些寄存器只要一读就会清零,或者写入某个值会触发硬件动作
- 对齐要求:硬件对 32 位访问的对齐要求比内存严格
- 顺序保证:编译器为了优化可能会打乱指令顺序,或者把多次读写合并。但在驱动里,你必须按顺序写寄存器,比如「先设复用,再设方向,最后写数据」,顺序一乱就炸
读操作函数
根据你想读的位宽(8位、16位、32位),内核提供了三个函数:
c
u8 readb(const volatile void __iomem *addr); // 读 8 位(1 字节)
u16 readw(const volatile void __iomem *addr); // 读 16 位(2 字节,w = word)
u32 readl(const volatile void __iomem *addr); // 读 32 位(4 字节,l = long)
参数 addr 就是你用 ioremap 拿到的那个虚拟地址。
回到那个类比:如果你直接去翻 addr 指向的内存(用指针读),就像是你自己翻开了银行的柜台记录本。但 readl 就像是你正式填写了一张「取款单」,银行柜员会严格按照流程去金库执行操作,并把结果封好在信封里交给你。
写操作函数
同样的,写操作也有对应的三个函数:
c
void writeb(u8 value, volatile void __iomem *addr); // 写 8 位
void writew(u16 value, volatile void __iomem *addr); // 写 16 位
void writel(u32 value, volatile void __iomem *addr); // 写 32 位
- value:你要写入的值
- addr:目标地址
为什么一定要用这些函数?
除了上面提到的顺序保证和对齐问题外,还有一个很重要的原因:可调试性。
内核可以通过拦截这些函数调用来记录所有的 I/O 操作,这在排查硬件 Bug 时是救命稻草。如果你用指针强行读写,内核对你是一无所知的。
千万别混用
如果寄存器是 32 位的,你用了
writeb,可能只改了低 8 位,或者在某些架构上直接触发异常。一定要查阅芯片手册,确认寄存器的位宽。对于 I.MX6ULL 的 GPIO 寄存器,绝大多数都是 32 位的,所以我们主要用的是
readl和writel。
第六部分:实战示例
让我们把上面的知识串起来,看一个完整的例子。
映射寄存器
c
/* 定义寄存器物理地址 */
#define GPIO1_DR_BASE (0x0209C000) // GPIO1 数据寄存器
#define GPIO1_GDIR_BASE (0x0209C004) // GPIO1 方向寄存器
/* 映射后的虚拟地址指针 */
static void __iomem *GPIO1_DR;
static void __iomem *GPIO1_GDIR;
/* 映射 */
GPIO1_DR = ioremap(GPIO1_DR_BASE, 4);
GPIO1_GDIR = ioremap(GPIO1_GDIR_BASE, 4);
if (!GPIO1_DR || !GPIO1_GDIR) {
printk("ioremap failed\n");
return -ENOMEM;
}
配置 GPIO 为输出
c
u32 val;
/* 读取当前方向寄存器的值 */
val = readl(GPIO1_GDIR);
/* 配置 GPIO1_IO03 为输出(bit3 置 1)*/
val |= (1 << 3);
/* 写回 */
writel(val, GPIO1_GDIR);
这就是经典的**「读-改-写」**铁律。你不能直接 writel(0x08, GPIO1_GDIR),因为那样会把其他 31 个引脚的配置全冲掉。在嵌入式 Linux 这种多任务环境下,其他引脚可能正被别的驱动占用着。
控制 LED
c
/* 点亮 LED(假设低电平点亮)*/
val = readl(GPIO1_DR);
val &= ~(1 << 3); // bit3 清零
writel(val, GPIO1_DR);
/* 熄灭 LED */
val = readl(GPIO1_DR);
val |= (1 << 3); // bit3 置一
writel(val, GPIO1_DR);
释放映射
c
/* 卸载时 */
iounmap(GPIO1_DR);
iounmap(GPIO1_GDIR);
第七部分:内核编程的限制与数据传递
内核空间的限制
虽然内核空间有特权,但这并不意味着你可以为所欲为。内核编程有严格的限制:
1. 不能使用标准 C 库
c
#include <stdio.h> // ❌ 不能用
#include <string.h> // ❌ 不能用
#include <stdlib.h> // ❌ 不能用
// 但可以使用内核提供的函数
#include <linux/string.h> // strcpy, strlen, memcpy...
#include <linux/slab.h> // kmalloc, kfree
#include <linux/printk.h> // printk, pr_info
内核有自己的一套函数库,功能类似但名字可能不同。
2. 不能做浮点运算
内核默认不保存浮点寄存器(为了提高切换效率)。如果你使用浮点数,需要显式保存/恢复浮点上下文,这很麻烦且慢。
解决方法:在驱动中使用定点数或整数运算。
3. 栈空间有限
用户空间的栈通常是几 MB,但内核栈很小(通常 8KB 或 16KB)。
c
// ❌ 危险!可能栈溢出
void dangerous_function(void) {
char huge_buffer[10000]; // 太大了
// ...
}
// ✅ 正确:使用堆
void safe_function(void) {
char *buffer = kmalloc(10000, GFP_KERNEL);
// ...
kfree(buffer);
}
4. 不能睡眠(某些上下文)
在中断处理函数、软中断等上下文中,代码不能"睡眠"(不能调用可能阻塞的函数)。
c
// ❌ 在中断上下文中会崩溃
irqreturn_t my_irq_handler(int irq, void *dev_id) {
msleep(1000); // 不能睡眠!
return IRQ_HANDLED;
}
// ✅ 正确:使用延迟而非睡眠
irqreturn_t my_irq_handler(int irq, void *dev_id) {
mdelay(1000); // 忙等待,不睡眠
return IRQ_HANDLED;
}
数据传递:越过城墙的方式
既然用户空间和内核空间是隔离的,那么两者之间如何传递数据?
方式 1:系统调用参数(简单数据)
对于小数据量(如整数、指针),直接通过系统调用参数传递:
c
// 用户空间
int fd = open("/dev/led", O_RDWR); // fd 通过寄存器返回
// 驱动中
static int led_open(struct inode *inode, struct file *filp) {
// filp 已经是内核空间的数据结构了
return 0;
}
方式 2:copy_to_user / copy_from_user(数据块)
对于大量数据(如缓冲区),使用专门的拷贝函数:
c
// 驱动中
static ssize_t led_read(struct file *filp, char __user *buf,
size_t count, loff_t *ppos)
{
char kernel_data[] = "LED status: ON";
// 安全地从内核复制到用户空间
if (copy_to_user(buf, kernel_data, strlen(kernel_data))) {
return -EFAULT; // 拷贝失败
}
return strlen(kernel_data);
}
为什么不能用 memcpy?
memcpy 不做安全检查。如果用户传一个恶意的内核地址,memcpy 会乖乖地把内核数据拷贝过去------这是安全漏洞。
copy_to_user 会:
- 检查目标地址是否在用户空间
- 检查地址是否可写
- 处理页错误(如果用户空间页面被换出)
常见错误与解决方案
错误 1:忘记 ioremap 直接用物理地址
c
/* ❌ 错误 */
#define GPIO1_DR 0x0209C000
u32 val = readl(GPIO1_DR); // 段错误!
后果 :触发内核 Oops(内核崩溃),因为 0x0209C000 在虚拟地址空间里没有被映射。
正确做法 :先用 ioremap 建立映射。
错误 2:用指针而不是 readl/writel
c
/* ❌ 错误 */
u32 val = *GPIO1_DR; // 危险!
*GPIO1_DR = 0x08;
后果:可能在某些硬件上能跑,但不符合内核规范。可能导致编译器优化出问题,或者在某些架构上触发异常。
正确做法 :使用 readl/writel。
错误 3:忘记 iounmap
c
/* ❌ 错误 */
static int __init led_init(void) {
GPIO1_DR = ioremap(...);
// ...
return 0;
}
static void __exit led_exit(void) {
// 忘记 iounmap
}
后果:内存泄漏,重复加载驱动时可能会失败。
正确做法 :在 exit 函数里调用 iounmap。
错误 4:直接覆盖寄存器值
c
/* ❌ 错误 */
writel(0x08, GPIO1_GDIR); // 危险!
后果:把 GPIO1 的其他 31 个引脚的配置全冲掉了,可能导致系统其他功能异常。
正确做法:读-改-写。
c
/* ✅ 正确 */
u32 val = readl(GPIO1_GDIR);
val |= (1 << 3);
writel(val, GPIO1_GDIR);
错误 5:直接访问用户空间指针
c
// ❌ 错误示例
static ssize_t bad_read(struct file *filp, char __user *buf,
size_t count, loff_t *ppos)
{
char data[] = "Hello";
memcpy(buf, data, strlen(data)); // 危险!buf 是用户空间指针
return strlen(data);
}
// ✅ 正确做法
static ssize_t good_read(struct file *filp, char __user *buf,
size_t count, loff_t *ppos)
{
char data[] = "Hello";
if (copy_to_user(buf, data, strlen(data))) {
return -EFAULT; // 正确处理拷贝失败
}
return strlen(data);
}
本章小结
核心概念
- 两座城池:用户空间(受限)和内核空间(特权)
- 城门关卡:系统调用是唯一合法的跨越方式
- 地址隔离:相同的虚拟地址在不同空间指向不同的物理内存
- MMU 映射:CPU 使用虚拟地址,物理地址需要被映射
- 硬件访问 :使用
ioremap、readl/writel访问硬件寄存器 - 安全传递 :使用
copy_to_user和copy_from_user在空间之间传递数据
编程规则
- ✅ 驱动运行在内核空间,有完全权限
- ✅ 使用内核提供的函数,不用标准 C 库
- ✅ 永远不要直接访问用户空间指针
- ✅ 限制栈空间使用,大对象用堆分配
- ✅ 注意上下文(某些地方不能睡眠)
- ✅ 使用
ioremap访问硬件,readl/writel读写寄存器
记住那个银行保险箱的比喻
- 物理地址 = 保险箱编号(0x020E0068 号箱)
- 虚拟地址 = 柜台窗口(3 号窗口)
- MMU = 银行柜员(帮你找到对应的箱子)
- ioremap = 向柜员申请窗口
- readl/writel = 正规的填单操作
- 指针直接读写 = 自己翻柜台记录本(违规操作)
参考文档:Linux 内核官方文档
- Memory Management - 内存管理子系统总览,包含虚拟内存、需求分页、内存分配等
- Virtual Memory Concepts - 虚拟内存概念详解,包含虚拟内存基础、大页、 Zones、Nodes 等
- ARM Memory Layout - ARM 处理器虚拟内存布局详解
- x86 Documentation - x86 架构文档(包含 Memory Layout 章节)
相关阅读
- 嵌入式Linux学习指南之设备树------Linux内核设备树编译机制深度解析 - 相似度 100%
- 入门 · 环境搭建 · 00 · Qt6 安装踩坑指南 - 相似度 80%
- 深入理解Linux模块------模块参数与内核调试:让模块"活"起来的魔法 - 相似度 80%