Home LKL 中的两处地址语义bug
Post
Cancel

LKL 中的两处地址语义bug

LKL 把 Linux 内核编译成一个库,运行在宿主进程(host pthread)之上。这种运行模型,使得很多原生架构上”天然成立”的地址假设悄然失效,本文介绍 PR #637 与 PR #639解决的两个不同的地址语义bug。

一、背景:LKL 的地址空间

要理解这两个 bug,必须先理解 LKL 的内存模型与原生内核的关键差异。

1.1 原生内核:一切都在”内核线性映射”里

在原生 Linux(比如 x86_64)上:

  • 内核把绝大部分物理内存通过linear mapping映射到一段固定的虚拟地址区间(page_offset_base 附近)。也就是说,对于任意一个虚拟地址 vaddr,都存在唯一的 struct page,可以通过 virt_to_page(vaddr) 得到。
  • 每个内核线程/进程都有自己的内核栈,由 task->stack 指向,大小 THREAD_SIZEtask_stack_page(current) 返回当前线程此刻正在使用的执行栈。栈顶指针保存在sp中,task_struct的指针通过gs得到。
  • 因此,一个栈上局部变量 char buf[512] 的地址 &buf,一定落在 task_stack_page(current)task_stack_page(current)+THREAD_SIZE 这个区间内。

1.2 LKL:内核跑在宿主线程的栈上

LKL 没有真正的”内核态”。它把内核代码链接成一个库,由宿主 pthread 调用。后果是:

  • 执行栈是宿主 pthread 的栈,而不是 task->stack 指向的那块内存。task->stack 在 LKL 里只保存了 thread_info(调度用的一小块结构),内核代码实际运行在 host pthread 的栈上(可通过 pthread_getattr_np + pthread_attr_getstack 取得)。
  • LKL 仍然维护 struct page 体系来管理它”认领”的内存,但宿主栈、线程局部存储等宿主进程私有的内存并不在 LKL 的线性映射里。对这些地址调用 virt_to_page() 是没有意义的——它们根本不对应任何 LKL 托管的 struct page
  • LKL 已经为宿主环境抽象了一组 host_ops(见 arch/lkl/include/uapi/asm/host_ops.h),其中 thread_stack() 回调正是用来向内核返回”当前宿主线程栈的基址与大小”:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* tools/lkl/lib/posix-host.c */
void *thread_stack(unsigned long *size)
{
	pthread_attr_t thread_attr;
	size_t stack_size;
	void *thread_stack;

	if (pthread_getattr_np(pthread_self(), &thread_attr))
		return NULL;
	if (pthread_attr_getstack(&thread_attr, &thread_stack, &stack_size))
		thread_stack = NULL;
	pthread_attr_destroy(&thread_attr);

	if (size && thread_stack)
		*size = stack_size;
	return thread_stack;
}

可以说,thread_stack() 是整个 #639 修复的”真相来源”。

1.3 一个贯穿两题的核心错误模式

两个 bug 本质上是一样的:把不属于 LKL 线性映射的地址喂给了假定线性映射的接口

  • __free_pages() 期望 struct page *
  • bio_map_kern() 内部会对传入地址调用 virt_to_page()

只要这个地址其实是宿主栈地址、或者其实是 CPU 虚拟地址却被当成 struct page *,就会触发页引用计数/伙伴系统/IO 数据的破坏。

1.4 原生内核 vs LKL 地址空间对比

graph TB
    subgraph Native["原生内核 (x86_64)"]
        direction TB
        N1["物理内存"]
        N2["内核线性映射<br/>page_offset_base 区间"]
        N3["task-&gt;stack<br/>= 真实执行栈 (THREAD_SIZE)"]
        N4["局部变量 buf[]<br/>落在 task-&gt;stack 区间内"]
        N1 -->|"virt_to_page 可逆"| N2
        N3 --- N4
    end

    subgraph LKL["LKL (内核库 + 宿主 pthread)"]
        direction TB
        L1["LKL 托管内存<br/>(struct page 体系)"]
        L2["LKL 线性映射<br/>virt_to_page 仅对此有效"]
        L3["task-&gt;stack<br/>= thread_info 小块 (kmalloc)"]
        L4["宿主 pthread 栈<br/>真实执行栈 (thread_stack())"]
        L5["局部变量 buf[]<br/>落在宿主栈, 不在线性映射"]
        L1 -->|"virt_to_page 可逆"| L2
        L3 -.->|"❌ 不是执行栈"| L4
        L4 --- L5
    end

    style N3 fill:#2e7d32,color:#fff
    style N4 fill:#2e7d32,color:#fff
    style L3 fill:#c62828,color:#fff
    style L4 fill:#c62828,color:#fff
    style L5 fill:#c62828,color:#fff

绿色 = 正确假设成立;红色 = LKL 下与原生内核假设不符之处。


二、PR #637:DMA 释放时误把 CPU 虚拟地址当 struct page *

2.1 问题代码

LKL 的 PCI DMA ops 实现在 arch/lkl/drivers/pci.c。先看分配与释放的一对函数(修复前):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* arch/lkl/drivers/pci.c (修复前) */
static void *lkl_dma_alloc(struct device *dev, size_t size,
			   dma_addr_t *dma_handle, gfp_t gfp,
			   unsigned long attrs)
{
	void *vaddr = page_to_virt(alloc_pages(gfp, get_order(size)));
	*dma_handle = (dma_addr_t)lkl_ops->pci_ops->map_page(
		to_pci_dev(dev)->sysdata, vaddr, size);
	return vaddr;
}

static void lkl_dma_free(struct device *dev, size_t size, void *cpu_addr,
			 dma_addr_t dma_addr, unsigned long attrs)
{
	lkl_ops->pci_ops->unmap_page(to_pci_dev(dev)->sysdata, dma_addr, size);
	__free_pages(cpu_addr, get_order(size));   // <-- 错误
}

2.2 原理

调用过程:

  1. lkl_dma_alloc()alloc_pages() 拿到一组页,再用 page_to_virt() 把struct page 转换成线性区虚拟地址返回。
  2. 当调用方释放时,把这个虚拟地址回传给 lkl_dma_free()cpu_addr 参数。
  3. __free_pages(struct page *page, unsigned int order) 的第一个参数必须是 struct page *,而不是虚拟地址!

换句话说,原代码把”虚拟地址”当成”页描述符指针”直接传给了页分配器。在 CONFIG_MMU=y、线性映射正常的场景里,virt_to_pagepage_to_virt 互为逆运算,本应做一次转换;这里却漏了。其后果是:

  • __free_pages()cpu_addr(一个很大的整数,被强转为指针)当成 struct page * 去解析 page->_refcountpage->flags 等字段,破坏的是某个完全不相关的内存位置的字节;
  • 伙伴系统(buddy allocator)的 freelist/order 元数据被改写,后续分配会产生诡异崩溃、double free 或内存泄漏——而且崩溃点往往远离出错点。

By the way, 内核其实提供了 free_pages(unsigned long addr, unsigned int order) 这个”接受虚拟地址”的变体,所以我觉得可能是开发者把这两个函数弄混了。

2.3 数据流对比

flowchart LR
    subgraph Buggy["修复前 (bug)"]
        direction TB
        A1["alloc_pages()"] --> A2["page_to_virt()<br/>vaddr (CPU 虚拟地址)"]
        A2 --> A3["返回给调用方"]
        A3 --> A4["调用方回传 cpu_addr"]
        A4 --> A5["__free_pages(cpu_addr)<br/>❌ 把虚拟地址当 struct page *"]
        A5 --> A6["破坏伙伴系统元数据"]
    end

    subgraph Fixed["修复后 (correct)"]
        direction TB
        B1["alloc_pages()"] --> B2["page_to_virt()<br/>vaddr (CPU 虚拟地址)"]
        B2 --> B3["返回给调用方"]
        B3 --> B4["调用方回传 cpu_addr"]
        B4 --> B5["virt_to_page(cpu_addr)<br/>✅ 还原成 struct page *"]
        B5 --> B6["__free_pages(page)<br/>正确释放"]
    end

2.4 修复

修复只有一行,把线性区虚拟地址还原成 struct page *

1
2
3
4
5
6
7
/* arch/lkl/drivers/pci.c (修复后) */
static void lkl_dma_free(struct device *dev, size_t size, void *cpu_addr,
			 dma_addr_t dma_addr, unsigned long attrs)
{
	lkl_ops->pci_ops->unmap_page(to_pci_dev(dev)->sysdata, dma_addr, size);
	__free_pages(virt_to_page(cpu_addr), get_order(size));   // <-- 修正
}

virt_to_page(cpu_addr) 借助 LKL 的线性映射把虚拟地址转换回正确的 struct page *

2.5 KUnit测试

我们新增了一个 LKL PCI KUnit 测试(arch/lkl/drivers/pci_test.c),通过 stub PCI host opsmap_page/unmap_page 替换成测试桩,从而脱离真实硬件验证分配/释放路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static unsigned long long lkl_dma_test_map_page(struct lkl_pci_dev *dev,
						void *vaddr,
						unsigned long size)
{
	lkl_dma_test_state->mapped_dev = dev;
	lkl_dma_test_state->mapped_vaddr = vaddr;
	lkl_dma_test_state->mapped_size = size;
	lkl_dma_test_state->map_calls++;
	return LKL_DMA_TEST_HANDLE;
}
...
static void lkl_dma_alloc_free_test(struct kunit *test)
{
	...
	cpu_addr = dma_alloc_attrs(&pdev.dev, PAGE_SIZE, &dma_handle,
				   GFP_KERNEL, 0);
	KUNIT_EXPECT_NOT_ERR_OR_NULL(test, cpu_addr);
	if (!cpu_addr)
		goto out_restore;

	page = virt_to_page(cpu_addr);
	KUNIT_EXPECT_EQ(test, page_count(page), 1);   // 分配后引用计数为 1
	...
	dma_free_attrs(&pdev.dev, PAGE_SIZE, cpu_addr, dma_handle, 0);

	KUNIT_EXPECT_EQ(test, page_count(page), 0);   // 释放后归零,证明页被正确回收
	KUNIT_EXPECT_EQ(test, state.unmap_calls, 1);
	...
}

这里 page_count(page) 在释放前后从 1 变为 0,是验证”页确实被正确交还伙伴系统”的关键断言。该测试通过 CONFIG_LKL_PCI_KUNIT_TEST 接入(见 arch/lkl/Kconfig),并在 CI 中启用,从机制上防止此类回归。


三、PR #639:栈上缓冲区被误判,导致 virt_to_page 作用在宿主栈地址上

3.1 问题入口:blk_rq_map_kern() 的路径选择

当内核要把一段内核缓冲区映射到块设备请求(passthrough IO)时,会走 blk_rq_map_kern()。它的核心逻辑是:如果缓冲区在栈上,就走 bio_copy_kern() 的”bounce 拷贝”路径;否则走 bio_map_kern() 的直接映射路径。

为什么栈上缓冲区不能走直接映射?因为栈是临时的、可能被复用、而且地址不稳定,直接把它的页塞进 bio 做 DMA 极其危险。所以内核设计了 object_is_on_stack() 这个判据来兜底:

1
2
3
4
5
6
7
8
9
/* include/linux/sched/task_stack.h (修复前) */
static inline int object_is_on_stack(const void *obj)
{
	void *stack = task_stack_page(current);

	obj = kasan_reset_tag(obj);

	return (obj >= stack) && (obj < (stack + THREAD_SIZE));
}

也就是说,只要 obj 落在”当前任务的 task->stack 那块 THREAD_SIZE 区间”里,就认为它在栈上。

3.2 Root cause: LKL 下这个判据彻底失效

回到 1.2 节的结论:在 LKL 中,内核代码实际运行在宿主 pthread 的栈上,而 task->stack 只保存了 thread_info(一块 kmalloc 出来的小结构,见 arch/lkl/kernel/threads.carch_alloc_thread_stack_node)。两者完全不是同一块内存:

1
2
3
4
5
6
7
/* arch/lkl/kernel/threads.c */
unsigned long *arch_alloc_thread_stack_node(struct task_struct *task, int node)
{
	struct thread_info *ti;
	ti = kmalloc(sizeof(*ti), GFP_KERNEL);  // task->stack 指向的是这块,不是执行栈
	...
}

于是,当驱动在 LKL 里写下:

1
2
char buf[512];
blk_rq_map_kern(q, rq, buf, sizeof(buf), GFP_KERNEL);

buf 真实位于宿主线程栈,但 object_is_on_stack(buf) 去和 task->stack(thread_info)的区间比较,自然得到 false。判据失灵导致:

  • 内核错误地选择了 bio_map_kern() 直接映射路径;
  • bio_map_kern() 内部对 data 调用 virt_to_page(data)(见 block/blk-map.c):
1
2
3
4
5
6
7
8
9
10
11
12
/* block/blk-map.c */
static struct bio *bio_map_kern(struct request_queue *q, void *data,
		unsigned int len, gfp_t gfp_mask)
{
	...
	for (i = 0; i < nr_pages; i++) {
		...
		if (!is_vmalloc)
			page = virt_to_page(data);   // <-- 对宿主栈地址调用,灾难
		...
	}
}
  • buf 是宿主栈地址,根本不在 LKL 的线性映射里virt_to_page() 返回的是一个无意义的 struct page *,后续 bio 的页表/IO 数据被彻底污染。

值得注意的是,这个 bug 的触发面比 #637 更广:凡是依赖 object_is_on_stack() 判断”缓冲区是否可直接映射”的子系统(USB、部分网络设备、块层等)在 LKL 下都会踩到同样的坑。

3.3 栈判定失效示意

flowchart TD
    Start["驱动: char buf[512]; blk_rq_map_kern(q, rq, buf, ...)"] --> Check{"object_is_on_stack(buf) ?"}
    Check -->|"原生内核: buf 在 task-&gt;stack 区间"| True["返回 true"]
    Check -->|"LKL: buf 在宿主栈, task-&gt;stack 是 thread_info"| False["返回 false ❌"]

    True --> Copy["走 bio_copy_kern()<br/>bounce 拷贝, 安全 ✅"]
    False --> Map["走 bio_map_kern()<br/>对 buf 调用 virt_to_page()"]
    Map --> Bad["buf 不在线性映射<br/>得到无意义 struct page *<br/>IO 数据被污染 ❌"]

    style False fill:#c62828,color:#fff
    style Bad fill:#c62828,color:#fff
    style True fill:#2e7d32,color:#fff
    style Copy fill:#2e7d32,color:#fff

3.4 修复:从”局部打补丁”到”架构层面修正”

第一版(块层特例):最初只在块层加了一个 blk_kern_needs_copy(),检查缓冲区首尾字节是否 virt_addr_valid(),非 LKL 编译为假。这能修块层,但 @tavip指出:这样每个受影响子系统都得各打各的补丁,不如让 LKL 直接提供正确的 object_is_on_stack() 语义——尤其 LKL 之前为 KASAN 已经能拿到宿主栈基址/大小(arch/lkl/mm/kasan.c 已用 lkl_ops->thread_stack())。

最终方案(架构钩子):引入一个通用机制,让架构可以覆盖栈区间判定,同时保留 KASAN 的 tag reset 逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* include/linux/sched/task_stack.h (修复后) */
static inline int object_is_on_stack(const void *obj)
{
#ifndef __HAVE_ARCH_OBJECT_IS_ON_STACK
	void *stack = task_stack_page(current);
#endif

	obj = kasan_reset_tag(obj);

#ifdef __HAVE_ARCH_OBJECT_IS_ON_STACK
	return arch_object_is_on_stack(obj);
#else
	return (obj >= stack) && (obj < (stack + THREAD_SIZE));
#endif
}

LKL 则通过 __HAVE_ARCH_OBJECT_IS_ON_STACK + arch_object_is_on_stack() 提供自己的实现,直接问宿主”我现在的栈在哪”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* arch/lkl/include/asm/thread_info.h */
#define __HAVE_ARCH_OBJECT_IS_ON_STACK
static inline int arch_object_is_on_stack(const void *obj)
{
	unsigned long addr = (unsigned long)obj;
	unsigned long stack_size;
	unsigned long stack;

	if (!lkl_ops || !lkl_ops->thread_stack)
		return 0;

	stack = (unsigned long)lkl_ops->thread_stack(&stack_size);
	if (!stack)
		return 0;

	return addr >= stack && addr - stack < stack_size;   // 与宿主栈区间比较
}

这样:

  • buf 在宿主栈上 → arch_object_is_on_stack() 返回 trueblk_rq_map_kern() 正确走 bio_copy_kern() 的 bounce 路径,不再对栈地址调用 virt_to_page()
  • 所有 object_is_on_stack() 的调用方(USB、DMA、其他驱动)都自动受益,无需逐处打补丁;
  • 非 LKL 架构不受影响(#ifdef 编译掉 LKL 分支,kasan_reset_tag 行为保持一致)。

3.5 修复后调用链

flowchart TD
    Start2["驱动: char buf[512]; blk_rq_map_kern(q, rq, buf, ...)"] --> Check2{"object_is_on_stack(buf) ?"}
    Check2 -->|"LKL: arch_object_is_on_stack()"| HostStack{"buf 在 thread_stack()<br/>返回的宿主栈区间?"}
    HostStack -->|是| Copy2["走 bio_copy_kern()<br/>bounce 拷贝 ✅"]
    HostStack -->|否| Map2["走 bio_map_kern()<br/>(buf 属线性映射, 安全) ✅"]

    style Copy2 fill:#2e7d32,color:#fff
    style Map2 fill:#2e7d32,color:#fff
    style HostStack fill:#1565c0,color:#fff

四、两个 PR 的共同脉络

维度#637(DMA 释放)#639(栈判定)
根因把 CPU 虚拟地址误当 struct page * 传给 __free_pages()object_is_on_stack()thread_info 区间去比宿主栈地址,判据失效
共性错误对”非 LKL 线性映射/类型不匹配”的地址调用了假定线性映射的接口同上:bio_map_kern() 对宿主栈地址调用 virt_to_page()
触发条件CONFIG_MMU=y 下更明显任何在栈上分配内核缓冲区并走块/USB IO 的路径
修复层次局部一行转换 + KUnit 测试从块层特例升级为架构级 object_is_on_stack 钩子
防护新增 LKL_PCI_KUNIT_TEST 接入 CI复用既有 lkl_ops->thread_stack() 基础设施

这两个PR说明了一个共同的问题:在 LKL 这种”把内核当库跑”的环境里,凡是涉及 virt_to_page / virt_addr_valid / task_stack_page / object_is_on_stack 的”地址属于内核”假设,都要重新审视。 因为 LKL 的执行栈和大量宿主内存并不在它自己维护的线性映射中。

graph LR
    Root["LKL 运行模型:<br/>内核库 + 宿主 pthread"] --> C1["执行栈 = 宿主栈<br/>(非 task-&gt;stack)"]
    Root --> C2["宿主内存<br/>不在 LKL 线性映射"]
    C1 --> B1["#639: object_is_on_stack 失效"]
    C2 --> B2["#637: 类型混淆 +<br/>地址不在线性映射"]
    B1 --> Fix1["架构钩子 arch_object_is_on_stack"]
    B2 --> Fix2["virt_to_page 转换 + KUnit"]

所以lkl中或许还有其他类似的bug,有时间或许可以拿AI扫一下:)

This post is licensed under CC BY 4.0 by the author.