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_SIZE。task_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->stack<br/>= 真实执行栈 (THREAD_SIZE)"]
N4["局部变量 buf[]<br/>落在 task->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->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 原理
调用过程:
lkl_dma_alloc()用alloc_pages()拿到一组页,再用page_to_virt()把struct page 转换成线性区虚拟地址返回。- 当调用方释放时,把这个虚拟地址回传给
lkl_dma_free()的cpu_addr参数。 - 但
__free_pages(struct page *page, unsigned int order)的第一个参数必须是struct page *,而不是虚拟地址!
换句话说,原代码把”虚拟地址”当成”页描述符指针”直接传给了页分配器。在 CONFIG_MMU=y、线性映射正常的场景里,virt_to_page 与 page_to_virt 互为逆运算,本应做一次转换;这里却漏了。其后果是:
__free_pages()把cpu_addr(一个很大的整数,被强转为指针)当成struct page *去解析page->_refcount、page->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 ops 把 map_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.c 的 arch_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->stack 区间"| True["返回 true"]
Check -->|"LKL: buf 在宿主栈, task->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()返回true→blk_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->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扫一下:)。