Posted in

为什么你的手机Go程序总OOM?深入ARMv8内存模型,揭秘mmap分配失败的7个隐藏诱因

第一章:手机上的go语言编译器

在移动设备上直接编译和运行 Go 程序曾被视为不可能的任务,但随着 Termux、Gomobile 和 Go 的持续演进,这一场景已切实可行。现代 Android 设备(需 Android 8.0+,ARM64 或 x86_64 架构)配合合适的工具链,可完整支持 Go 源码的下载、编译、测试乃至交叉构建。

安装 Go 运行环境

在 Termux 中依次执行以下命令安装 Go 工具链:

# 更新包管理器并安装必要依赖
pkg update && pkg install clang make git -y

# 下载并解压官方 Go 二进制包(以 go1.22.5 linux-arm64 为例)
curl -L https://go.dev/dl/go1.22.5.linux-arm64.tar.gz | tar -C $HOME -xzf -
export GOROOT=$HOME/go
export GOPATH=$HOME/go-workspace
export PATH=$GOROOT/bin:$GOPATH/bin:$PATH

# 验证安装
go version  # 应输出类似 go version go1.22.5 linux/arm64

注意:Go 官方未提供原生 Android APK,但 Linux 兼容层(如 Termux)提供的 linux-arm64 构建版可在 Android 内核上稳定运行,因 Termux 使用 proot 模拟用户空间而非依赖 root 权限。

编写并运行首个移动端 Go 程序

创建 hello.go 并编译为本地可执行文件:

package main

import "fmt"

func main() {
    fmt.Println("Hello from Android! 📱")
}

保存后执行:

go build -o hello hello.go
./hello  // 输出:Hello from Android! 📱

关键能力与限制对照表

能力 是否支持 说明
go build 本地编译 支持 ARM64/x86_64 目标,生成静态二进制
go test 单元测试 依赖纯 Go 标准库的测试可正常执行
net/http 服务监听 ⚠️ 可绑定 localhost:8080,但无法对外网暴露
CGO 调用 C 代码 Termux 默认禁用 CGO(CGO_ENABLED=0),避免 ABI 冲突

Go 在手机端的价值不仅在于“能跑”,更在于快速验证算法逻辑、调试网络协议、或为 IoT 边缘设备预演交叉编译流程——它让开发者的思考闭环从桌面延伸至掌心。

第二章:ARMv8内存模型与Go运行时的耦合机制

2.1 ARMv8 TCR_EL1与Go内存布局的隐式冲突分析

ARMv8的TCR_EL1寄存器通过T0SZTG0字段定义一级页表粒度与虚拟地址空间大小,而Go运行时默认使用4KB页+39-bit虚拟地址(T0SZ=25),对应512GB用户空间。

关键参数对齐问题

  • TG0=04KB页表项,但Go在linux/arm64上可能动态启用HugePages2MB
  • T0SZ=25要求TTBR0_EL1指向512GB范围内的页表基址,而Go GC的栈映射区常位于高位地址(如0xffff_8000_0000_0000),易越界触发Translation Fault

冲突表现

// 典型错误上下文:TCR_EL1.T0SZ=24(1TB空间)时,
// Go goroutine栈分配至0xffff_9000_0000_0000 → 地址超出有效范围
mrs x0, tcr_el1        // 读取TCR_EL1
and x0, x0, #0x3f      // 提取T0SZ[5:0]
sub x1, xzr, #1        // 计算地址掩码:~(2^T0SZ - 1)
lsl x1, x1, x0

该汇编提取T0SZ后生成地址屏蔽掩码;若T0SZ配置过小,高位地址被截断,导致TLB填充失败与ESR_EL1.EC=0x24(地址转换异常)。

配置项 Go默认值 常见内核TCR_EL1 冲突风险
T0SZ 25 24–26 ⚠️ 24→溢出
TG0 0 (4KB) 1 (64KB) ❌ 页表不兼容
IPS 36 40 ✅ 安全
graph TD
    A[Go runtime allocates stack] --> B{TCR_EL1.T0SZ check}
    B -->|T0SZ ≥ 25| C[Valid VA range: 0x0000_0000_0000_0000–0x0000_7fff_ffff_ffff]
    B -->|T0SZ = 24| D[Truncated to 0x0000_0000_0000_0000–0x0000_3fff_ffff_ffff]
    D --> E[Stack at 0xffff_8... triggers Translation Fault]

2.2 TTBR0_EL1地址空间切分对mmap基址选择的实践限制

ARMv8-A中,TTBR0_EL1负责用户态地址翻译,其覆盖范围(通常为0x0–0xffff_ffff)与内核空间(TTBR1_EL1)共同构成48位虚拟地址空间的硬性切分。

mmap基址受TTBR0_EL1映射窗口约束

  • 内核vm_unmapped_area()TASK_SIZE_LOW(如0x0000_ffff_ffff_ffff)内搜索空闲区域
  • 若进程启用MAP_FIXED_NOREPLACE且指定地址超出TTBR0_EL1有效VA范围,mmap直接返回ENOMEM

典型地址空间布局(AArch64, 4KB页,48-bit VA)

组件 虚拟地址范围 映射表
用户空间(TTBR0_EL1) 0x0000_0000_0000_0000 – 0x0000_ffff_ffff_ffff 用户页表
内核空间(TTBR1_EL1) 0xffff_0000_0000_0000 – 0xffff_ffff_ffff_ffff 内核页表
// arch/arm64/mm/mmap.c 片段:基址校验逻辑
if (addr > TASK_SIZE) {
    addr = -ENOMEM; // 超出TTBR0_EL1管辖上限,拒绝映射
    goto out;
}

该检查确保addr始终落在TTBR0_EL1可寻址范围内;否则硬件TLB填充将失败,引发Translation Fault。

地址选择失效路径

graph TD
    A[mmap(addr, len, ...)] --> B{addr ≤ TASK_SIZE?}
    B -->|否| C[return -ENOMEM]
    B -->|是| D[调用__vm_area_alloc]

2.3 内存屏障指令(DSB/ISB)缺失导致的TLB一致性失效复现

数据同步机制

ARMv8中,TLB更新(如TLBI指令)后若未执行DSB ISH,后续访存可能仍命中旧页表映射;而指令流切换(如修改页表后跳转新地址)还需ISB刷新流水线。

复现场景代码

// 错误示例:缺少屏障
str x1, [x0]          // 更新页表项
tlbi vaae1, x2        // 清除对应TLB条目
ldr x3, [x4]          // ❌ 可能仍使用旧TLB映射!

逻辑分析:TLBI仅异步广播TLB失效请求,DSB ISH确保该请求在所有PE上完成;缺失则ldr可能绕过最新映射。参数ISH指定Inner Shareable域,覆盖多核场景。

关键屏障对比

指令 作用 必要性
DSB ISH 确保TLB失效操作全局可见 ✅ 强制要求
ISB 刷新预取队列,使新代码/页表生效 ✅ 跳转前必需
graph TD
    A[更新页表] --> B[TLBI指令]
    B --> C{缺少DSB ISH?}
    C -->|是| D[TLB状态不一致]
    C -->|否| E[DSB等待完成]
    E --> F[ISB刷新流水线]

2.4 页表粒度(4KB vs 16KB)与Go runtime.pageAlloc粒度错配实测

Go runtime 的 pageAlloc8KB 逻辑页为单位管理虚拟内存(heapArena 中每 bit 覆盖 8KB),而底层页表粒度由 OS 和 CPU 决定:ARM64 默认启用 16KB 大页,x86-64 通常为 4KB。

实测环境差异

  • macOS ARM64(M1/M2):vm_page_size=16384
  • Linux x86-64(默认):getconf PAGESIZE=4096

pageAlloc 与 OS 页粒度对齐问题

// src/runtime/mheap.go 中关键断言(简化)
if physPageSize != pageSize {
    println("pageAlloc assumes", pageSize, "but OS uses", physPageSize)
    // 触发非对齐警告:16KB 物理页无法被 8KB pageAlloc 单元整除
}

该检查在 mheap.init() 中执行。当 physPageSize=16384,而 pageSize=8192(Go 固定值),导致每个物理页需跨两个 pageAlloc 位图条目管理,引发位图更新竞争与统计偏差。

错配影响对比

指标 4KB OS 页 16KB OS 页
pageAlloc 位图覆盖率 100% 50%(半页悬空)
mcentral.cacheSpan 分配延迟 ±3ns +17ns(额外位图查表)

内存映射行为示意

graph TD
    A[allocSpan 申请 16KB] --> B{pageAlloc 拆分为 2×8KB 条目}
    B --> C[OS mmap 1×16KB 物理页]
    C --> D[位图第0位=1, 第1位=1]
    D --> E[但第1位可能被其他 8KB span 误复用]
  • 错配导致 pageAlloc.findRun 返回非连续物理页;
  • mspan.inHeap 判断失效,触发冗余 heapBitsForAddr 查找;
  • 在高并发分配场景下,pageAlloc.allocRange 锁争用上升约 22%。

2.5 ASID隔离不足引发的跨进程虚拟地址污染案例追踪

当ASID(Address Space Identifier)复用策略过于激进,内核未及时清空TLB中旧ASID关联条目时,进程A释放的虚拟页可能被进程B以相同VA重新映射——而TLB仍缓存A的PTE,导致B读取A的脏数据。

关键触发条件

  • ASID分配未绑定mm_struct生命周期
  • flush_tlb_mm() 调用遗漏或延迟
  • ARM64 tlbi vaae1is 指令未覆盖全部共享CPU核心

典型污染路径

// arch/arm64/mm/tlb.c: __flush_tlb_one_local()
asm volatile("tlbi vaae1is, %0" :: "r"(addr) : "cc"); 
// 注:vaae1is 清除当前EL1下所有ASID匹配的VA条目,
// 但若目标ASID尚未切换到当前CPU,则该指令不生效

此汇编仅作用于当前CPU的TLB,多核场景下需配合同步屏障与IPI广播。

环节 正常行为 污染场景
进程A退出 ASID标记为可重用,TLB异步刷新 ASID立即分配给B,TLB残留A的VA→PA映射
内存回收 page->mapping置NULL B以相同VA映射新page,TLB命中旧PTE
graph TD
    A[进程A释放VA 0xffff0000] --> B[ASID 5回收]
    B --> C[进程B申请同VA,获ASID 5]
    C --> D[TLB未刷新 → 命中A的PTE]
    D --> E[数据泄露]

第三章:Go移动编译链的特异性约束

3.1 Android NDK r21+ Clang toolchain对-gcflags=”-ldflags”的ABI兼容性陷阱

NDK r21 起全面弃用 GCC,Clang 成为唯一默认工具链,但 -gcflags="-ldflags=..." 这一 Go 构建惯用写法在交叉编译 Android 原生库时会触发静默 ABI 错配。

根本原因

Clang 不识别 Go 的 -ldflags 伪参数;实际被错误传递至 linker(lld/ld.gold),导致:

  • 忽略 -target 指定的 aarch64-linux-android ABI
  • 回退使用 host 默认 ABI(如 x86_64-unknown-linux-gnu

典型错误示例

# ❌ 错误:Clang 将 "-ldflags=-shared" 当作链接器输入而非 Go 标志
CGO_ENABLED=1 GOOS=android GOARCH=arm64 CC=$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang \
  go build -buildmode=c-shared -gcflags="-ldflags=-shared" -o libgo.so .

此命令中 -gcflags 仅用于 Go 编译器(gc),不能嵌套传递链接器标志-ldflags 应独立使用:go build -ldflags="-shared"。Clang toolchain 严格校验目标 triple,非法 ABI 会导致 .so 加载时报 dlopen: unsupported ELF machine (EM_AARCH64 vs EM_X86_64)

正确实践对比

场景 命令 是否安全
✅ 独立 -ldflags go build -ldflags="-shared -buildmode=c-shared"
❌ 嵌套在 -gcflags go build -gcflags="-ldflags=-shared" 否,Clang 忽略并污染链接上下文
graph TD
    A[go build] --> B{解析 -gcflags}
    B -->|仅送入 gc 编译器| C[Go 源码编译]
    B -->|错误透传给 linker| D[Clang 链接器]
    D --> E[ABI 检查失败]
    E --> F[dlopen: invalid machine]

3.2 iOS App Thinning机制下GOOS=ios交叉编译的符号裁剪副作用

iOS App Thinning 会基于设备能力(如架构、屏幕分辨率、本地化)动态下发精简包,但 GOOS=ios 交叉编译时,Go 工具链默认不嵌入完整的符号表与调试信息,导致 thinning 后期的 bitcode 重链接与 ld64 符号解析阶段出现非预期裁剪。

符号可见性丢失链路

# 编译命令隐含 -buildmode=pie 且 strip -S -x 默认启用
GOOS=ios GOARCH=arm64 CGO_ENABLED=1 \
  go build -ldflags="-w -s" -o app.o .

-w -s 移除 DWARF 与符号表,使 dsymutil 无法重建符号映射;App Store 处理 Bitcode 时依赖符号名进行 dead code stripping,最终引发 unresolved symbol _runtime·unlock 类运行时 panic。

关键参数影响对比

参数 是否保留符号 是否影响 Thinning 风险等级
-ldflags="-w" 高(strip 元数据) ⚠️⚠️⚠️
-ldflags="-s" 中(移除调试段) ⚠️⚠️
-ldflags="" 低(增大 IPA)

构建流程关键节点

graph TD
  A[go build GOOS=ios] --> B[linker strip -w -s]
  B --> C[IPA 打包]
  C --> D[App Store Bitcode 重编译]
  D --> E[符号解析失败 → 裁剪 runtime 函数]

3.3 移动端CGO_ENABLED=1时libclang_rt.ubsan_standalone-aarch64-android.so的内存开销实测

启用 UBSan(Undefined Behavior Sanitizer)进行 Android ARM64 调试时,libclang_rt.ubsan_standalone-aarch64-android.so 会动态注入运行时检查逻辑,显著增加常驻内存。

内存占用对比(典型 Release APK 启动后 RSS)

配置 RSS 增量(MiB) 主要来源
CGO_ENABLED=0 +0 无 Sanitizer 运行时
CGO_ENABLED=1 + -fsanitize=undefined +3.2–4.7 libclang_rt.ubsan_standalone-aarch64-android.so 映射与全局检查表

关键验证命令

# 查看进程内存映射中 sanitizer 库的驻留页
adb shell cat /proc/$(adb shell pidof com.example.app)/smaps | \
  awk '/libclang_rt\.ubsan.*aarch64-android\.so/,/^$/{sum += $2} END{print sum " KB"}'

该命令提取 smaps 中对应共享库的 Rss: 字段累加值;$2 为 KB 单位数值,反映实际物理内存占用,排除 swap 和未访问页。

优化建议

  • 仅在 debug buildType 中启用 -fsanitize=undefined
  • 使用 --exclude=third_party/.* 减少非关键路径插桩
  • 避免在 init() 或高频热路径中触发未定义行为检查(如越界访问),否则引发额外堆分配

第四章:mmap分配失败的七维根因诊断体系

4.1 /proc/sys/vm/max_map_count在Android SELinux域下的动态截断验证

Android内核在SELinux enforcing模式下,对/proc/sys/vm/max_map_count的写入会受sysctl_vm类权限约束,并可能被domain.tedontaudit规则静默截断。

SELinux策略拦截路径

# domain.te 片段(编译后生效)
allow domain sysctl_vm:sysctl write;
dontaudit domain sysctl_vm:sysctl write;  # 若无显式allow,此行将抑制avc日志但实际拒绝

此规则导致echo 262144 > /proc/sys/vm/max_map_count在受限域(如untrusted_app)中返回EPERM,且无AVC日志——因dontaudit屏蔽了审计事件,形成“静默截断”。

实测行为对比表

执行域 写入结果 AVC日志可见 是否触发截断
init 成功
untrusted_app EPERM 不可见 是(dontaudit)

动态验证流程

# 在adb shell中验证(需root)
chcon u:r:shell:s0 /data/local/tmp/test.sh
# → 触发selinux检查,验证上下文继承是否绕过限制

graph TD
A[应用调用write] –> B{SELinux检查}
B –>|allow存在| C[成功更新max_map_count]
B –>|仅dontaudit| D[EPERM + 无日志]

4.2 Go runtime.sysAlloc对MAP_FIXED_NOREPLACE的规避策略失效分析

Go 1.21+ 在 runtime.sysAlloc 中尝试用 MAP_FIXED_NOREPLACE 安全映射内存,但内核兼容性导致策略失效。

失效根源:内核版本与标志支持断层

  • Linux MAP_FIXED_NOREPLACE
  • Go 运行时降级为 MAP_FIXED,覆盖已有映射,引发 SIGBUS

典型触发路径

// runtime/mem_linux.go 中简化逻辑
flags := _MAP_ANONYMOUS | _MAP_PRIVATE
if supportsMapFixedNoReplace { // 依赖 getauxval(AT_HWCAP) + uname()
    flags |= _MAP_FIXED_NOREPLACE
} else {
    flags |= _MAP_FIXED // ⚠️ 危险降级
}

该分支在旧内核上跳过原子性校验,直接覆写地址空间,破坏 GC 元数据映射。

内核支持矩阵

内核版本 MAP_FIXED_NOREPLACE Go 行为
≥ 4.17 ✅ 原生支持 安全分配
❌ 未知标志 退化为 MAP_FIXED
graph TD
    A[sysAlloc 调用] --> B{supportsMapFixedNoReplace?}
    B -->|true| C[使用 MAP_FIXED_NOREPLACE]
    B -->|false| D[回退 MAP_FIXED → 覆盖风险]

4.3 低内存killer(LMK)触发前的anon_vma链表遍历阻塞实测

当系统内存压力陡增、LMK即将扫描匿名页时,rmap_walk_anon()anon_vma->rb_root 的遍历可能因长链表或锁竞争而显著延迟。

阻塞关键路径

  • anon_vma_lock_read() 获取读锁期间,若存在并发写操作(如 anon_vma_fork()),将引发自旋等待
  • rbtree 平衡遍历在深度 > 12 层时,单次 rb_next() 耗时跃升至 8–15 μs

实测延迟分布(内核 6.1,ARM64)

场景 平均遍历耗时 P99 延迟
空闲 anon_vma 0.3 μs 1.1 μs
10k 匿名映射进程 42 μs 186 μs
LMK 触发前临界态 217 μs 1.3 ms
// kernel/mm/rmap.c: rmap_walk_anon() 片段
list_for_each_entry(vma, &anon_vma->head, anon_vma_node) {
    if (!vma->anon_vma) continue;
    ret = rmap_one(mm, vma, addr, arg); // 此处可能因 vma 锁/TLB flush 阻塞
    if (ret != SWAP_AGAIN) break;
}

该循环未做节流或中断点检查;高密度匿名映射下,单次 anon_vma 遍历可抢占 > 3 个调度周期,直接推迟 LMK 的 OOM 判定时机。

graph TD
    A[LMK begin reclaim] --> B{scan_anon_rmap?}
    B -->|yes| C[rmap_walk_anon]
    C --> D[anon_vma_lock_read]
    D --> E[traverse vma->anon_vma_node list]
    E --> F[per-vma page walk + TLB flush]
    F --> G[可能被抢占/延迟]

4.4 mmap(MAP_ANONYMOUS|MAP_HUGETLB)在ARMv8-64K page配置下的静默降级日志解析

在ARMv8启用64KB基础页(CONFIG_ARM64_64K_PAGES=y)但未配置HUGETLB_PAGETRANSPARENT_HUGEPAGE时,mmap调用 MAP_ANONYMOUS | MAP_HUGETLB 会静默回退至常规64KB页分配,不报错亦不告警。

内核关键路径

// mm/mmap.c: do_mmap()
if (flags & MAP_HUGETLB) {
    struct hstate *hs = hstate_sizelog(hugepage_shift); // ARMv8下hugepage_shift=16 → 64KB
    if (!hs || !hugepages_supported()) // hs为NULL:hstate未注册 → 降级
        return mmap_region(file, addr, len, flags, vm_flags, NULL);
}

逻辑分析:hstate_sizelog(16) 查找 shift=16 的大页支持结构;若 CONFIG_HUGETLB_PAGE 未启用,hstate[] 全为空,hs==NULL 触发静默降级。

降级行为对比

条件 分配结果 dmesg 日志
CONFIG_HUGETLB_PAGE=y 真实2MB/1GB大页 hugetlbpage: registered 2MB page size
64K_PAGES 64KB常规页 无日志(静默)

流程示意

graph TD
    A[mmap with MAP_HUGETLB] --> B{hstate_sizelog(16) valid?}
    B -- No --> C[回退 mmap_region]
    B -- Yes --> D[分配真实hugepage]
    C --> E[返回64KB page VMA]

第五章:手机上的go语言编译器

为什么要在手机上运行Go编译器?

移动设备已具备强大算力:2024年主流旗舰手机搭载的SoC(如骁龙8 Gen3、A17 Pro)单核性能超越2015年高端笔记本CPU,内存带宽超60GB/s,存储I/O可达2GB/s。在此基础上,Go语言因其静态链接、无运行时依赖、交叉编译友好等特性,成为移动端原生编译工具链的理想候选。实测表明,在Pixel 8 Pro(Tensor G3)上,gomobile build -target=android 编译一个含3个包的CLI工具仅需2.3秒,而同等逻辑用Python+Termux编译则需依赖完整CPython解释器及pip环境,耗时超17秒。

真机编译环境搭建流程

以iPadOS 17.5 + iSH模拟器为例,部署步骤如下:

  1. 安装iSH应用(App Store官方版本,非越狱)
  2. 启动后执行 apk add go git make
  3. 下载Go源码:git clone https://go.googlesource.com/go $HOME/go-src
  4. 构建自举编译器:cd $HOME/go-src/src && ./make.bash
  5. 验证:go version 输出 go version devel go1.23-123abcde darwin/arm64

注:iSH基于Linux用户空间模拟,不支持CGO_ENABLED=1,但纯Go项目(如CLI工具、HTTP服务)可100%本地编译运行。

性能对比表格:不同设备编译相同项目耗时(单位:秒)

设备型号 OS Go版本 项目规模 编译时间 内存峰值
iPhone 15 Pro iOS 17.6 1.23rc2 12个包 4.1 1.2 GB
Samsung S24 Ultra Android 14 1.22.6 12个包 3.8 1.1 GB
Raspberry Pi 4B RPi OS 1.22.6 12个包 12.7 980 MB
MacBook Air M2 macOS 14 1.23rc2 12个包 2.9 1.4 GB

实战案例:在Android手机上构建并部署HTTP微服务

使用Termux安装必要组件后,执行以下命令链:

pkg install golang git clang
go mod init myapi && go get github.com/gorilla/mux
cat > main.go <<'EOF'
package main
import (
  "log"
  "net/http"
  "github.com/gorilla/mux"
)
func handler(w http.ResponseWriter, r *http.Request) {
  w.Write([]byte("Hello from Android!"))
}
func main() {
  r := mux.NewRouter()
  r.HandleFunc("/", handler).Methods("GET")
  log.Fatal(http.ListenAndServe(":8080", r))
}
EOF
go build -o myapi .
./myapi &

服务启动后,通过adb forward tcp:8080 tcp:8080转发端口,PC浏览器访问http://localhost:8080即可验证响应——整个过程未依赖任何远程构建服务器。

编译限制与绕行方案

flowchart TD
    A[尝试编译含cgo代码] --> B{是否启用CGO_ENABLED=1?}
    B -->|否| C[编译成功:纯Go逻辑]
    B -->|是| D[报错:/system/bin/sh: clang: not found]
    D --> E[解决方案:使用musl-cross-make预编译工具链]
    E --> F[将aarch64-linux-musl-gcc推送到/data/local/tmp]
    F --> G[设置CC_aarch64_unknown_linux_musl=/data/local/tmp/aarch64-linux-musl-gcc]

开发者工作流重构

当团队采用手机编译时,CI流程发生实质性变化:GitHub Actions中不再需要setup-go步骤;PR提交后,开发者可直接在通勤地铁上用手机拉取分支、go test ./...go vet全量扫描,并通过gofumpt -l -w .一键格式化。某开源CLI项目(github.com/termux/termux-tools)已将此模式纳入贡献指南,要求新功能必须通过Android/iOS真机编译验证。

文件系统权限适配要点

Android 11+强制执行分区存储,Go程序若需读写外部存储,必须:

  • AndroidManifest.xml中声明<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
  • 使用context.getExternalFilesDir(null)获取沙盒路径
  • 编译时添加-tags android启用条件编译分支处理路径逻辑

上述实践已在Gin框架v1.9.1+的gin-contrib/cors模块中落地,其Android兼容补丁已合并至主干。

专攻高并发场景,挑战百万连接与低延迟极限。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注