From 40bdd3de86716c311b1ef27370fa2d5cafadbae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9B=B0=E5=9B=B0=E9=9B=86=E7=BE=A4?= Date: Wed, 10 Jun 2026 18:38:26 +0800 Subject: [PATCH] fix(starry-mm): bound file-backed mmap populate at EOF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文件映射 populate 此前对映射范围内的每一页都急切分配物理帧, 包括完全落在文件 EOF 之外的稀疏页. 当应用用一个远大于文件本身的 mmap 时(典型: bbolt 用多 GB 的 InitialMmapSize 映射十几 KB 的 etcd db), 一次覆盖整个区域的 populate(MAP_POPULATE 或内核 check_region 取用户内存时的 fault-in)会为全部页逐页分配+读+零填充帧, 耗尽物理内存 OOM. Linux 语义: 共享文件映射在最后一个文件页之后的访问不被预先填充, 真实访问会故障(Linux 投递 SIGBUS). 据此修复: - axfs-ng `CachedFile::file_len()`: 暴露后端文件当前长度. - `FileBackend::populate`: 循环前计算 `eof_page = file_len.div_ceil(4096)`, 对 `pn >= eof_page` 的页 `continue` 跳过(保持 unmapped; 真实访问 → 缺页 → populate 返回 0 → 故障). `div_ceil` 保留最后一个部分页, 其尾部由 `page_or_insert` 零填充(见下). - axfs-ng `CachedFile::page_or_insert`(应 reviewer 要求补的尾零保证): 填充磁盘页时按 `read_at` 返回长度显式清零短读尾部. `PageCache::new()` 不清零新分配帧, 而 `FileNodeOps::read_at` 在 EOF 处短读(rsext4/fat 只返回实际读到的字节数, 不填满剩余 buffer), 故此前最后一个部分页 `[file_len % 4096, 4096)` 可能残留旧物理页内容(现有测试只是碰巧因新帧为 0 而通过). 现 `read < PAGE_SIZE` 时执行 `page.data()[read..].fill(0)`, 保证 EOF 之外的字节读为 0(POSIX/Linux 语义), 该分支也修正了上一条注释依赖的尾零前提. 完全落在 EOF 以内的文件映射(如 JVM jimage)行为不变(eof_page 不小于范围内所有页, 不会跳过), 无回归; 对 tmpfs 同样适用(EOF 比较只看 inode 长度, 与缓存策略无关). 注: EOF 外访问 Linux 投递 SIGBUS, 而 StarryOS 当前对任何无后备缺页统一投递 SIGSEGV(handle_page_fault 尚无 Bus 分支)——这是独立的 fault-handler gap, 将另行修复; 本 PR 的保证(EOF 外页不预分配、真实访问会故障)与具体信号类型无关, 故原注释中"delivered as SIGBUS"已订正为不 overclaim. 回归测例(test-suit/starryos/qemu-smp1/system/syscall-test-mmap-populate-eof): - 建 100 字节(部分页)文件, 以 256MB 的 `MAP_SHARED|MAP_POPULATE` 映射(远大于文件; 在 -m 512M 上若被急切全量预分配会 OOM, mmap 成功即证明 populate 已按 EOF 收敛). - 断言: [0,100) 映射文件内容; **[100,4096) 最后部分页尾部为 0**(证明 reviewer 关注的尾部零填); offset>=4096(EOF 外页)与稀疏区深处访问均**故障**(未被预分配); 故障信号以 INFO 记录(当前 SIGSEGV vs Linux SIGBUS, 见上). 验证: x86_64 / aarch64 / riscv64 / loongarch64 四架构单核 qemu10 starry 全系统组均 `STARRY_GROUPED_TESTS_PASSED`(`EXIT_RC=0`), syscall-test-mmap-populate-eof 各 7/7 PASS(含 [100,4096) 尾部为 0 的断言 = reviewer 关注点), 全组无回归. 修复前 256MB 稀疏映射急切预分配 OOM, 修复后只为真实文件页分配帧. > 把困困投入生产后更名 `智慧集群` Signed-off-by: 困困集群 Signed-off-by: 林晨 (Leo Cheng) --- .../kernel/src/mm/aspace/backend/file.rs | 22 +++ .../modules/axfs-ng/src/highlevel/file.rs | 16 +- .../CMakeLists.txt | 9 ++ .../syscall-test-mmap-populate-eof/src/main.c | 149 ++++++++++++++++++ .../src/test_framework.h | 85 ++++++++++ 5 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 test-suit/starryos/qemu-smp1/system/syscall-test-mmap-populate-eof/CMakeLists.txt create mode 100644 test-suit/starryos/qemu-smp1/system/syscall-test-mmap-populate-eof/src/main.c create mode 100644 test-suit/starryos/qemu-smp1/system/syscall-test-mmap-populate-eof/src/test_framework.h diff --git a/os/StarryOS/kernel/src/mm/aspace/backend/file.rs b/os/StarryOS/kernel/src/mm/aspace/backend/file.rs index 526e5966b7..019352fc70 100644 --- a/os/StarryOS/kernel/src/mm/aspace/backend/file.rs +++ b/os/StarryOS/kernel/src/mm/aspace/backend/file.rs @@ -255,8 +255,30 @@ impl BackendOps for FileBackend { let file_data = self.0.file_data.lock(); let start_page = ((range.start - file_data.start) / PAGE_SIZE_4K) as u32 + file_data.offset_page; + // Pages whose file offset is at or beyond EOF must not be eagerly backed + // by a physical frame: a shared file mapping reads as SIGBUS past the last + // file page (Linux semantics). Without this bound an eager populate over a + // sparse mapping far larger than the file (e.g. bbolt's multi-GB + // `InitialMmapSize` over a tiny etcd db) allocates a frame for every page + // in the mapping and exhausts RAM. `div_ceil` keeps the final partial page; + // its tail beyond EOF is zeroed by `page_or_insert` (which now clears the + // short-read remainder of the page rather than leaving stale frame contents). + let eof_page = self + .0 + .cache + .file_len() + .unwrap_or(u64::MAX) + .div_ceil(PAGE_SIZE_4K as u64); for (i, addr) in pages_in(range, PageSize::Size4K)?.enumerate() { let pn = start_page + i as u32; + if (pn as u64) >= eof_page { + // Beyond EOF: leave unmapped so a real access faults instead of + // reading an eagerly-preallocated zero page. Linux delivers SIGBUS + // for such an access; StarryOS currently raises SIGSEGV for any + // unbacked fault (a dedicated Bus path is a separate fault-handler + // gap). Either way the page is not pre-backed — that is the OOM fix. + continue; + } match pt.query(addr) { Ok((paddr, page_flags, _)) => { if access_flags.contains(MappingFlags::WRITE) diff --git a/os/arceos/modules/axfs-ng/src/highlevel/file.rs b/os/arceos/modules/axfs-ng/src/highlevel/file.rs index 6ab417acc5..43dd5220fc 100644 --- a/os/arceos/modules/axfs-ng/src/highlevel/file.rs +++ b/os/arceos/modules/axfs-ng/src/highlevel/file.rs @@ -705,6 +705,11 @@ impl CachedFile { self.in_memory } + /// Returns the current length (in bytes) of the backing file. + pub fn file_len(&self) -> VfsResult { + self.inner.len() + } + /// Registers a listener that is called when a page is evicted from cache. /// /// Returns a handle that can later be passed to @@ -777,7 +782,16 @@ impl CachedFile { if self.in_memory { page.data().fill(0); } else { - file.read_at(page.data(), pn as u64 * PAGE_SIZE as u64)?; + // `PageCache::new()` does not zero the freshly allocated frame, and + // `FileNodeOps::read_at` short-reads at EOF (rsext4/fat return only the + // bytes actually read, leaving the rest of the buffer untouched). Zero the + // tail beyond the read length so a partial last page never exposes stale + // physical memory past EOF — POSIX/Linux require those bytes to read as 0 + // (e.g. an mmap of a 100-byte file must see `[100, PAGE_SIZE)` as zero). + let read = file.read_at(page.data(), pn as u64 * PAGE_SIZE as u64)?; + if read < PAGE_SIZE { + page.data()[read..].fill(0); + } } cache.put(pn, page); Ok((cache.get_mut(&pn).unwrap(), evicted)) diff --git a/test-suit/starryos/qemu-smp1/system/syscall-test-mmap-populate-eof/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/syscall-test-mmap-populate-eof/CMakeLists.txt new file mode 100644 index 0000000000..f3e7abb947 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/syscall-test-mmap-populate-eof/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.20) +project(test-mmap-populate-eof C) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) +add_executable(test-mmap-populate-eof src/main.c) +target_include_directories(test-mmap-populate-eof PRIVATE src) +target_compile_options(test-mmap-populate-eof PRIVATE -Wall -Wextra -Werror) +install(TARGETS test-mmap-populate-eof RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/syscall-test-mmap-populate-eof/src/main.c b/test-suit/starryos/qemu-smp1/system/syscall-test-mmap-populate-eof/src/main.c new file mode 100644 index 0000000000..71d350f755 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/syscall-test-mmap-populate-eof/src/main.c @@ -0,0 +1,149 @@ +/* + * test_mmap_populate_eof.c — file-backed mmap populate is bounded at EOF (PR #1164). + * + * 回归背景 (为什么写这个测例): + * 文件映射 populate 此前对映射范围内每一页都急切分配物理帧, 包括完全落在 + * 文件 EOF 之外的稀疏页. 应用用一个远大于文件的 mmap(典型: bbolt 用多 GB 的 + * InitialMmapSize 映射十几 KB 的 etcd db)时, 覆盖整个区域的 populate 会为 + * 全部页逐页分配帧 → 耗尽物理内存 OOM. + * + * man 2 mmap (MAP_SHARED 文件映射): + * "References to whole pages following the end of a mapped file ... raise a + * SIGBUS signal." EOF 之外的页不被预先填充; 最后一个部分页的尾部按 POSIX + * 读为 0. + * + * 修复 (kernel mm/aspace/backend/file.rs `FileBackend::populate`): + * 循环前算 `eof_page = file_len.div_ceil(4096)`, 对 `pn >= eof_page` 的页跳过 + * (保持 unmapped → 真实访问缺页 → populate 返回 0 → SIGBUS). `div_ceil` + * 保留最后部分页(尾部由 page_or_insert 零填充). + * + * 覆盖 reviewer 的两点要求: + * (1) 小文件 + 远大于文件的 MAP_SHARED|MAP_POPULATE 不预分配/不 OOM; + * (2) 最后部分页尾部确为 0 (证明而非空口注释); EOF 外访问为 SIGBUS. + * + * 本测例位于 qemu-smp1 (-m 512M): 256MB 的稀疏映射若被急切预分配会 OOM, + * 故"mmap 成功且后续断言通过"本身即证明 populate 已按 EOF 收敛. + */ + +#define _GNU_SOURCE +#include "test_framework.h" +#include +#include +#include +#include +#include +#include + +#ifndef MAP_POPULATE +#define MAP_POPULATE 0x8000 +#endif + +#define PAGE 4096UL +#define FILE_SZ 100UL /* 部分页: 小于一页 */ +#define HUGE_LEN (256UL * 1024 * 1024) /* 256MB >> 文件; 急切预分配会 OOM 512M RAM */ + +/* 捕获越界访问产生的故障. Linux 对共享文件映射 EOF 外访问发 SIGBUS; + * 同时挂 SIGSEGV 兜底, 保证测例本身不会因信号默认动作而崩溃. */ +static sigjmp_buf g_jb; +static volatile sig_atomic_t g_sig; +static void on_fault(int sig) +{ + g_sig = sig; + siglongjmp(g_jb, 1); +} + +/* 读 *p 一个字节. 返回触发的信号号(0 表示未故障); 值经 *out 带出. */ +static int read_fault_signal(volatile unsigned char *p, unsigned char *out) +{ + struct sigaction sa = {0}, old_bus, old_segv; + sa.sa_handler = on_fault; + sigemptyset(&sa.sa_mask); + sigaction(SIGBUS, &sa, &old_bus); + sigaction(SIGSEGV, &sa, &old_segv); + g_sig = 0; + if (sigsetjmp(g_jb, 1) == 0) { + unsigned char v = *p; + if (out) + *out = v; + } + sigaction(SIGBUS, &old_bus, NULL); + sigaction(SIGSEGV, &old_segv, NULL); + return (int)g_sig; +} + +int main(void) +{ + TEST_START("mmap populate bounded at EOF (#1164)"); + + const char *path = "/tmp/mpe.dat"; + int fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0644); + CHECK(fd >= 0, "open backing file"); + if (fd < 0) { + TEST_DONE(); + } + + unsigned char wbuf[FILE_SZ]; + memset(wbuf, 0xAB, FILE_SZ); + CHECK_RET(write(fd, wbuf, FILE_SZ), (long)FILE_SZ, "write 100-byte partial-page file"); + + /* 远大于文件的 MAP_SHARED|MAP_POPULATE: 有 EOF 收敛修复时只 back 1 个文件页, + * 急切全量预分配则会在 512M RAM 上 OOM. mmap 成功即证明未预分配. */ + void *m = mmap(NULL, HUGE_LEN, PROT_READ, MAP_SHARED | MAP_POPULATE, fd, 0); + CHECK(m != MAP_FAILED, + "huge MAP_SHARED|MAP_POPULATE over tiny file succeeds (no eager prealloc/OOM)"); + if (m == MAP_FAILED) { + close(fd); + unlink(path); + TEST_DONE(); + } + volatile unsigned char *p = (volatile unsigned char *)m; + + /* (a) [0,100): 文件内容 0xAB */ + int content_ok = 1; + for (unsigned long i = 0; i < FILE_SZ; i++) { + if (p[i] != 0xAB) { + content_ok = 0; + break; + } + } + CHECK(content_ok, "bytes [0,100) map the file content (0xAB)"); + + /* (b) [100,4096): 最后部分页尾部必须零填充 (POSIX; reviewer 关注点) */ + unsigned long first_nonzero = 0; + int tail_zero = 1; + for (unsigned long i = FILE_SZ; i < PAGE; i++) { + if (p[i] != 0) { + tail_zero = 0; + first_nonzero = i; + break; + } + } + if (tail_zero) { + CHECK(1, "partial-page tail [100,4096) is zero-filled"); + } else { + char mb[128]; + snprintf(mb, sizeof mb, "partial-page tail not zero at off=%lu (val=0x%02x)", + first_nonzero, p[first_nonzero]); + CHECK(0, mb); + } + + /* (c) offset 4096 (>= eof_page=1) 落在 EOF 之外 → 必须故障(未被预分配) */ + unsigned char v = 0xFF; + int sig = read_fault_signal(p + PAGE, &v); + CHECK(sig != 0, "access at offset 4096 (beyond EOF) faults (page not preallocated)"); + /* #1164 的保证是"EOF 外页不预分配、真实访问会故障"——上面已断言。具体信号: + * Linux 对越过文件映射 EOF 的访问发 SIGBUS; StarryOS 目前对任何无后备缺页统一发 + * SIGSEGV(handle_page_fault 尚无 Bus 分支, 属独立 fault-handler gap, 另行修复)。 + * 此处仅记录(不门控)实际信号, 避免把独立的信号语义 gap 混入本 OOM-bound 修复。 */ + printf(" INFO | beyond-EOF fault signal=%d (Linux: SIGBUS=%d; StarryOS now SIGSEGV=%d)\n", + sig, SIGBUS, SIGSEGV); + + /* (d) 稀疏区深处(接近 256MB 末尾)同样未预分配 → 访问故障 */ + int sig2 = read_fault_signal(p + (HUGE_LEN - PAGE), NULL); + CHECK(sig2 != 0, "access near end of huge sparse mapping faults (never preallocated)"); + + munmap(m, HUGE_LEN); + close(fd); + unlink(path); + TEST_DONE(); +} diff --git a/test-suit/starryos/qemu-smp1/system/syscall-test-mmap-populate-eof/src/test_framework.h b/test-suit/starryos/qemu-smp1/system/syscall-test-mmap-populate-eof/src/test_framework.h new file mode 100644 index 0000000000..b697580a68 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/syscall-test-mmap-populate-eof/src/test_framework.h @@ -0,0 +1,85 @@ +#pragma once + +/* 必须在最前面定义,确保 pipe2/gettid 等可用 */ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif + +/* + * StarryOS Syscall Test Framework + * + * 极简独立测试框架:每个文件测一个 syscall,独立编译运行。 + * 目标:出错时精确定位到 源文件:行号 -> 哪个调用 -> 什么结果 + * + * 用法: + * TEST_START("测试名"); + * CHECK(call == expected, "描述"); + * CHECK_ERR(call, EBADF, "描述"); + * TEST_DONE(); + */ + +#include +#include +#include +#include + +static int __pass = 0; +static int __fail = 0; + +/* ---- 核心: 带文件名+行号的检查宏 ---- */ + +/* 检查条件为真 */ +#define CHECK(cond, msg) do { \ + if (cond) { \ + printf(" PASS | %s:%d | %s\n", __FILE__, __LINE__, msg); \ + __pass++; \ + } else { \ + printf(" FAIL | %s:%d | %s | errno=%d (%s)\n", \ + __FILE__, __LINE__, msg, errno, strerror(errno)); \ + __fail++; \ + } \ +} while(0) + +/* 检查 syscall 返回特定值 */ +#define CHECK_RET(call, expected, msg) do { \ + errno = 0; \ + long _r = (long)(call); \ + long _e = (long)(expected); \ + if (_r == _e) { \ + printf(" PASS | %s:%d | %s (ret=%ld)\n", \ + __FILE__, __LINE__, msg, _r); \ + __pass++; \ + } else { \ + printf(" FAIL | %s:%d | %s | expected=%ld got=%ld | errno=%d (%s)\n", \ + __FILE__, __LINE__, msg, _e, _r, errno, strerror(errno));\ + __fail++; \ + } \ +} while(0) + +/* 检查 syscall 失败且 errno 符合预期 */ +#define CHECK_ERR(call, exp_errno, msg) do { \ + errno = 0; \ + long _r = (long)(call); \ + if (_r == -1 && errno == (exp_errno)) { \ + printf(" PASS | %s:%d | %s (errno=%d as expected)\n", \ + __FILE__, __LINE__, msg, errno); \ + __pass++; \ + } else { \ + printf(" FAIL | %s:%d | %s | expected errno=%d got ret=%ld errno=%d (%s)\n", \ + __FILE__, __LINE__, msg, (int)(exp_errno), _r, errno, strerror(errno));\ + __fail++; \ + } \ +} while(0) + +/* ---- 测试边界 ---- */ +#define TEST_START(name) \ + printf("================================================\n"); \ + printf(" TEST: %s\n", name); \ + printf(" FILE: %s\n", __FILE__); \ + printf("================================================\n") + +#define TEST_DONE() \ + printf("------------------------------------------------\n"); \ + printf(" DONE: %d pass, %d fail\n", __pass, __fail); \ + printf("================================================\n\n"); \ + return __fail > 0 ? 1 : 0