Posted in

【Go底层能力解锁】:无需cgo,纯Go获取进程内所有打开句柄(/proc/self/fd + windows PSAPI双实现)

第一章:Go语言怎么获取句柄

在 Go 语言中,“句柄”(handle)并非原生概念,它通常指操作系统层面的资源标识符,如 Windows 的 HANDLE(文件、窗口、进程等),或 Unix/Linux 中的文件描述符(file descriptor, int 类型)。Go 运行时通过 os.File 抽象统一管理底层资源,其内部封装了对应平台的句柄值。

获取底层文件描述符

对已打开的 *os.File 实例,可调用 Fd() 方法获取其整数形式的系统句柄:

f, err := os.Open("/etc/hosts")
if err != nil {
    log.Fatal(err)
}
defer f.Close()

// 获取 Unix/Linux 下的文件描述符(int 类型)
fd := f.Fd() // 返回 uintptr,在 Unix 上可安全转为 int
fmt.Printf("File descriptor: %d\n", int(fd))

⚠️ 注意:Fd() 返回类型为 uintptr,在 Windows 上对应 HANDLE(需 syscall.Handle 类型转换),在类 Unix 系统上等价于 int。该操作使文件进入“非阻塞”或“不可再被 Go 运行时管理”的状态,调用后不应再对 f 执行 Read/Write/Close 等方法,否则可能引发 panic 或未定义行为。

跨平台句柄提取示例

平台 句柄类型 推荐转换方式
Linux/macOS int(文件描述符) int(f.Fd())
Windows syscall.Handle syscall.Handle(f.Fd())

直接创建带句柄的文件对象

也可从已有句柄构造 *os.File(反向操作):

// 假设已知 fd = 3(如标准输入)
file := os.NewFile(uintptr(3), "stdin")
defer file.Close()
// 此时 file 可正常使用 Read/Write

此方式常用于继承父进程句柄、Fork 后资源传递等系统编程场景。需确保句柄有效且权限匹配,否则 os.NewFile 不会报错,但后续 I/O 操作将失败。

第二章:Linux平台句柄枚举原理与纯Go实现

2.1 /proc/self/fd 文件系统语义与权限边界分析

/proc/self/fd 是一个符号链接目录,每个条目指向当前进程打开的文件描述符所关联的实际文件或设备。其语义本质是内核态文件描述符表的用户空间投影,而非真实存储结构。

权限边界关键特性

  • 符号链接目标路径仅对进程自身有效(如 /proc/self/fd/3 → socket:[12345]);
  • 非特权进程无法通过 open("/proc/123/fd/0") 访问其他进程的 fd 目录(EACCES);
  • O_PATH 标志可绕过读写权限检查,但无法 read()write()

实时查看示例

# 查看当前 shell 的标准输入来源
ls -l /proc/self/fd/0
# 输出示例:lr-x------ 1 root root 64 Jun 10 10:22 /proc/self/fd/0 -> /dev/pts/2

该命令触发内核 proc_fd_link() 回调,动态构造符号链接目标;lr-x------ 表明仅所有者可读(链接本身),不反映目标文件权限。

操作 是否允许(同进程) 是否允许(跨进程)
readlink() ❌(需 ptrace 权限)
openat(..., O_PATH) ❌(除非 CAP_SYS_PTRACE)
dup2() ✅(fd 重定向) ❌(仅限本进程)
graph TD
    A[进程调用 readlink] --> B[内核查找 current->files->fdt->fd[0]]
    B --> C[获取 file* 结构]
    C --> D[调用 file->f_op->show_fdinfo? 或生成伪路径]
    D --> E[返回符号链接字符串]

2.2 基于os.ReadDir的无cgo句柄遍历与符号链接解析

os.ReadDir 是 Go 1.16+ 引入的零分配、无 cgo 的目录遍历接口,绕过 os.File 句柄生命周期管理,直接返回 fs.DirEntry 切片。

核心优势对比

特性 os.ReadDir filepath.WalkDir(含 cgo)
CGO 依赖 ❌ 无 ✅ 某些底层实现可能触发
内存分配 零堆分配(复用缓冲) 每次递归新建 fs.DirEntry
符号链接处理 entry.Type() 可区分 ModeSymlink 需显式 Lstat 判断

符号链接解析示例

entries, _ := os.ReadDir("/path")
for _, e := range entries {
    if e.Type()&os.ModeSymlink != 0 {
        target, _ := os.Readlink(e.Name()) // 安全解析目标路径
        fmt.Printf("→ %s → %s\n", e.Name(), target)
    }
}

e.Type() 返回文件系统原生类型位掩码;os.Readlink 不跟随链接,仅读取路径字符串,避免循环引用风险。

遍历流程(mermaid)

graph TD
    A[os.ReadDir] --> B{entry.Type()}
    B -->|ModeSymlink| C[os.Readlink]
    B -->|ModeDir| D[递归ReadDir]
    B -->|ModeRegular| E[直接处理]

2.3 文件描述符类型识别:通过stat、readlink与/proc/self/fdinfo联动判别

Linux 中文件描述符(fd)本身无类型元数据,需组合多源信息交叉验证。核心路径为:

  • /proc/self/fd/N → 符号链接指向原始路径(或特殊格式)
  • readlink /proc/self/fd/N → 解析目标路径及协议前缀(如 socket:[12345]pipe:[6789]anon_inode:[eventpoll]
  • stat -c "%F" /proc/self/fd/N → 获取“文件类型字符串”(但对 socket/pipe 常返回 symbolic link,误导性强)
  • /proc/self/fdinfo/N → 提供权威类型字段 type:(如 type: socktype: pipetype: regular

关键字段对照表

fdinfo type: 字段 readlink 目标特征 典型 stat %F 输出
sock socket:[12345] symbolic link
pipe pipe:[6789] symbolic link
regular /path/to/file regular file
eventpoll anon_inode:[eventpoll] symbolic link
# 示例:识别 fd 3 的真实类型
readlink /proc/self/fd/3  # 输出:socket:[12345]
grep "^type:" /proc/self/fdinfo/3  # 输出:type: sock

readlink 揭示内核对象标识符(如 socket:[12345]),而 /proc/self/fdinfo/Ntype: 行由内核直接写入,是唯一可信类型来源;stat 在此场景仅作辅助验证,不可单独依赖。

graph TD
    A[fd N] --> B[/proc/self/fd/N]
    B --> C{readlink}
    B --> D{/proc/self/fdinfo/N}
    C -->|解析前缀| E[socket/pipe/regular/...]
    D -->|提取 type:| F[sock/pipe/regular/eventpoll]
    E & F --> G[交叉验证类型]

2.4 句柄元数据提取:inode、设备号、访问模式与生命周期推断

文件句柄(file descriptor)背后隐含丰富的内核级元数据。通过 fstat() 可原子获取其关联 inode、主/次设备号(st_dev)、访问权限掩码(st_mode)及时间戳,进而推断生命周期阶段。

元数据关键字段语义

  • st_ino:唯一标识同一文件系统内的文件实体
  • st_dev:设备号,联合 st_ino 构成全局文件身份
  • st_mode & 0777:反映 open() 时请求的访问模式(如 O_RDONLYS_IRUSR
  • st_atime/st_mtime/st_ctime:结合进程生命周期可判断是否处于“活跃读写”或“仅缓存引用”状态

实时元数据提取示例

struct stat sb;
if (fstat(fd, &sb) == 0) {
    printf("inode: %lu, dev: %d:%d, mode: 0%o\n",
           sb.st_ino, major(sb.st_dev), minor(sb.st_dev), sb.st_mode);
}

fstat() 绕过路径解析,直接读取内核 struct file 关联的 struct inodemajor()/minor() 从32位 st_dev 解包设备编号;st_mode 的低12位即 POSIX 权限位,可反向验证 open() 调用参数。

字段 提取方式 生命周期线索
st_nlink fstat() =0 表明文件已 unlink,仅句柄存活
st_size fstat() 持续为0且 st_mtime 不变 → 内存映射临时文件
st_ctime fstat() 突增后无 write() → 被 chown()/chmod() 修改
graph TD
    A[fd] --> B[fstat]
    B --> C{st_nlink == 0?}
    C -->|Yes| D[文件已删除,仅句柄持有]
    C -->|No| E[文件仍存在于目录树]
    D --> F[close() 后 inode 释放]

2.5 并发安全的句柄快照机制与内存映射句柄过滤实践

核心挑战

多线程环境下,NtQuerySystemInformation(SystemHandleInformation) 返回的句柄列表瞬息即变,直接遍历易引发 STATUS_INVALID_HANDLE 或内存越界。

快照原子性保障

采用双缓冲+RCU风格快照:先调用 ZwDuplicateObject 复制目标进程句柄表副本,再在只读副本上执行过滤,避免锁竞争。

// 创建进程句柄快照(简化示意)
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPHANDLE, dwPid);
HANDLEENTRY entry;
entry.dwProcessId = dwPid;
// 注:实际需配合 ZwQuerySystemInformation + RtlInitializeGenericTable

逻辑说明:CreateToolhelp32Snapshot 在用户态封装了内核句柄表快照语义;dwProcessId 确保仅捕获指定进程上下文,规避跨进程污染。

内存映射句柄识别规则

属性字段 过滤条件 用途
ObjectTypeIndex == ObGetObjectTypeIndex("Section") 定位内存映射对象
HandleAttributes OBJ_INHERIT \| OBJ_PROTECT_CLOSE 判定共享/受保护映射

过滤流程

graph TD
    A[获取系统句柄表快照] --> B[遍历每个HANDLEENTRY]
    B --> C{ObjectType == Section?}
    C -->|Yes| D[检查PageProtection & MappingType]
    C -->|No| B
    D --> E[加入安全映射句柄集合]
  • 过滤后句柄集支持无锁迭代;
  • 所有内存访问均经 MmIsAddressValid 校验。

第三章:Windows平台句柄枚举的PSAPI与NtQuerySystemInformation双路径

3.1 PSAPI接口限制与HandleTable结构逆向认知

PSAPI(Process Status API)虽提供EnumProcesses等基础进程枚举能力,但存在本质限制:无法获取句柄表(Handle Table)原始布局,且GetProcessHandleCount仅返回统计值,不暴露句柄类型、权限或目标对象地址。

句柄表内存布局关键特征

  • 每个进程的_HANDLE_TABLE结构位于内核空间,由EPROCESS->ObjectTable指向;
  • 实际句柄条目以分层页表组织(三级:Directory → Tables → Entries),支持稀疏分配;
  • 句柄值低12位为索引偏移,高20位隐含层级信息(需结合HANDLE_TABLE_ENTRY_INFO解析)。

常见PSAPI局限对比

接口 可获取信息 隐蔽性缺陷
EnumProcessModules 模块基址/大小 无法识别DLL注入的伪模块
GetProcessImageFileName 映像路径 受符号链接欺骗,易返回\\??\\C:\\...而非真实路径
NtQuerySystemInformation(SystemProcessInformation) 全量EPROCESS快照 仍不包含HandleTable内容
// 从KdDebuggerDataBlock获取HandleTable结构偏移(Win10 21H2 x64)
ULONG_PTR HandleTableOffset = *(PULONG_PTR)(
    (PUCHAR)KdDebuggerDataBlock + 0x1e8 // KdDebuggerDataBlock->KiHandleTableListHead + 0x10
); // 注:实际需通过KPCR->KernelBase + KiSystemCall64符号动态定位

该偏移用于在EPROCESS中定位ObjectTable字段,但需配合MmIsAddressValid验证有效性,避免访问无效页导致BSOD。参数0x1e8KiHandleTableListHead在调试数据块中的固定偏移,不同系统版本需重定位。

graph TD
    A[EPROCESS] --> B[ObjectTable<br/>_HANDLE_TABLE*]
    B --> C[HandleTableList<br/>LIST_ENTRY]
    C --> D[Directory Page<br/>_HANDLE_TABLE_PAGE]
    D --> E[Handle Entry<br/>_HANDLE_TABLE_ENTRY]
    E --> F[Object Header<br/>_OBJECT_HEADER]

3.2 纯Go调用ntdll.dll中NtQuerySystemInformation(SystemHandleInformation)的FFI封装

核心挑战与设计取舍

Windows内核句柄信息需通过未公开导出函数 NtQuerySystemInformation 获取,SystemHandleInformation(值为16)要求调用者手动分配缓冲区并处理动态重试逻辑。

Go侧FFI关键结构

// SystemHandleInformation 结构体(WinNT.h 对应定义)
type SystemHandleInformation struct {
    Count    uint32
    Handles  [1]struct {
        ProcessID   uint32
        HandleValue uint32
        ObjectType  uint16
        Flags       uint16
        HandleCount uint32
        PoolTag     uint32
        Object      uintptr
    }
}

逻辑分析Count 表示句柄总数,但首次调用时无法预知大小;需循环调用:先以0长度试探,获取所需缓冲区字节数,再分配并重试。ObjectType 需查表映射(如 0x1e → “File”),Flags 包含 OBJ_PROTECT_CLOSE 等权限标志。

调用流程(mermaid)

graph TD
A[调用NtQuerySystemInformation] --> B{返回STATUS_INFO_LENGTH_MISMATCH?}
B -->|是| C[按OutputLength重分配缓冲区]
B -->|否| D[解析Handles数组]
C --> A
D --> E[过滤目标进程句柄]

常见错误码对照表

返回值(NTSTATUS) 含义
0x00000000 成功
0xC0000004 STATUS_INFO_LENGTH_MISMATCH
0xC0000022 STATUS_ACCESS_DENIED

3.3 句柄属性解码:Object Type Index映射与内核对象名称还原

Windows 内核中,句柄的 HandleValue 本身不携带类型信息,其语义依赖于进程句柄表中对应条目(HANDLE_TABLE_ENTRY)的 ObjectInfo 字段——其中低 4 位为 Object Type Index

Object Type Index 映射原理

该索引指向全局 ObpTypeIndexTable(内核模块 ntoskrnl.exe 中的静态数组),每个索引对应一个 OBJECT_TYPE 结构体指针。常见映射如下:

Index 对象类型 典型名称示例
0x2 Process \BaseNamedObjects\MyApp.exe
0x7 Section \BaseNamedObjects\SharedMemSec
0x1a Mutant \KernelObjects\GlobalMutex

名称还原关键路径

// ObpGetObjectName —— 从 OBJECT_HEADER 获取 NameInfo
PUNICODE_STRING ObpGetObjectName(POBJECT_HEADER Header) {
    POBJECT_HEADER_NAME_INFO NameInfo = 
        (POBJECT_HEADER_NAME_INFO)((PUCHAR)Header - sizeof(OBJECT_HEADER_NAME_INFO));
    return &NameInfo->Name; // 注意:仅当 OBJ_KERNEL_HANDLE 未置位且对象已命名时有效
}

逻辑分析OBJECT_HEADER 前置结构中,OBJECT_HEADER_NAME_INFO 存储对象全路径名;Name 字段为 UNICODE_STRING,含 LengthMaximumLengthBuffer。若对象由 NtCreateSection 命名创建,则此处可直接提取 \BaseNamedObjects\... 路径。

解码流程图

graph TD
    A[句柄值] --> B[查进程句柄表]
    B --> C[提取 Object Type Index]
    C --> D[查 ObpTypeIndexTable]
    D --> E[获取 OBJECT_TYPE]
    E --> F[定位 OBJECT_HEADER]
    F --> G[偏移取 OBJECT_HEADER_NAME_INFO]
    G --> H[读取 Name.Buffer]

第四章:跨平台句柄抽象层设计与生产级工程实践

4.1 统一句柄模型定义:HandleInfo结构体与平台无关字段语义对齐

为消除 Windows HANDLE、Linux int fd 与 macOS mach_port_t 的语义鸿沟,HandleInfo 抽象出跨平台核心元数据:

typedef struct {
    uint64_t id;           // 全局唯一标识(非OS原生句柄值)
    uint32_t type;         // 类型枚举:FILE=1, SOCKET=2, EVENT=3...
    uint16_t flags;        // 状态位:CLOEXEC=0x01, NONBLOCK=0x02
    uint8_t  lifecycle;    // 0=unknown, 1=owned, 2=borrowed, 3=transferred
} HandleInfo;

id 是运行时生成的逻辑ID,由统一资源注册中心分配;typeflags 屏蔽底层实现差异,确保上层策略(如自动关闭、超时继承)行为一致。

关键字段语义对照表

字段 Windows 映射 Linux 映射 语义作用
id InterlockedIncrement64(&g_next_id) atomic_fetch_add(&g_id_gen, 1) 资源生命周期锚点
flags DUPLICATE_SAME_ACCESSNONBLOCK O_CLOEXECCLOEXEC 控制跨fork/跨线程传播

资源归属状态流转

graph TD
    A[created] -->|acquire| B[owned]
    B -->|pass_to_thread| C[borrowed]
    B -->|transfer_ownership| D[transferred]
    C -->|release| B
    D -->|close| E[closed]

4.2 自动化平台检测与运行时动态分发策略(build tags + runtime.GOOS)

Go 构建系统通过 build tagsruntime.GOOS 协同实现跨平台能力的精准裁剪与运行时适配。

构建期平台感知:build tags 示例

//go:build linux || darwin
// +build linux darwin

package platform

func InitStorage() string {
    return "posix-compliant filesystem"
}

该文件仅在 Linux 或 macOS 构建时参与编译;//go:build 是 Go 1.17+ 推荐语法,// +build 为兼容旧版本的冗余声明。标签逻辑支持 ||(或)、&&(与)、!(非),构建时由 go build -tags="linux" 显式激活。

运行时动态路由

import "runtime"

func GetConfigPath() string {
    switch runtime.GOOS {
    case "windows":
        return `C:\App\config.yaml`
    case "linux", "darwin":
        return "/etc/myapp/config.yaml"
    default:
        return "./config.yaml"
    }
}

runtime.GOOS 在程序启动后即时返回目标操作系统标识(如 "linux"),无需重新编译即可适配路径、权限、信号处理等行为。

策略协同对比

维度 build tags runtime.GOOS
生效时机 编译期(静态) 运行时(动态)
二进制体积 减少无关平台代码,更小 全平台逻辑打包,略大
适用场景 系统调用差异大(如 syscall) 路径/配置/日志格式微调
graph TD
    A[源码含多平台文件] --> B{go build -tags=linux?}
    B -->|是| C[仅编译 linux_*.go]
    B -->|否| D[跳过 linux_*.go]
    C --> E[生成 linux 专用二进制]

4.3 资源泄漏检测场景下的句柄差异比对与Delta分析工具链

在长期运行的服务进程中,未释放的文件、socket、GDI等句柄易引发资源耗尽。Delta分析需精准捕获两次快照间的句柄集合变化。

核心比对逻辑

def diff_handles(before: dict, after: dict) -> dict:
    # before/after: {handle_id: {"type": "FILE", "path": "/tmp/a.log", "pid": 1234}}
    new = {k: v for k, v in after.items() if k not in before}
    closed = {k: v for k, v in before.items() if k not in after}
    return {"new": new, "closed": closed, "leaked": new.keys() - set(closed.keys())}

该函数以句柄ID为键执行集合差分;leaked字段标识未匹配关闭的新句柄——典型泄漏候选。

工具链关键组件

组件 职责 输出示例
HandleSniffer 实时枚举进程句柄(Win: NtQuerySystemInformation;Linux: /proc/pid/fd/ {"123": {"type":"SOCKET","state":"ESTABLISHED"}}
DeltaEngine 基于时间戳对齐快照,执行多维diff(ID + type + lifecycle context) {"leaked": [{"id":"456","type":"EVENT","age_ms":128000}]}

分析流程

graph TD
    A[定时采集句柄快照] --> B[标准化句柄元数据]
    B --> C[跨快照Delta计算]
    C --> D[泄漏置信度评分:时长+类型+调用栈深度]
    D --> E[输出可追溯的泄漏链路报告]

4.4 单元测试与集成验证:mock /proc模拟器与Windows HandleTable注入测试框架

在跨平台内核态兼容性测试中,/proc 文件系统是 Linux 侧关键数据源,而 Windows 依赖 HandleTable 实现资源映射。为解耦操作系统依赖,我们构建了双模态测试基座。

mock /proc 模拟器设计

采用内存映射式虚拟 /proc 树,支持动态注入进程状态(如 /proc/[pid]/status, /proc/[pid]/fd/):

class ProcMock:
    def __init__(self, pid=1234):
        self.pid = pid
        self._fd_map = {0: "pipe:[12345]", 3: "socket:[67890]"}  # fd → path 映射

    def read_fd_dir(self):
        return [f"{fd} -> {path}" for fd, path in self._fd_map.items()]

read_fd_dir() 返回标准 procfs 符号链接格式字符串列表,供被测模块直接解析;_fd_map 可在测试用例中灵活重置,实现边界场景(如空句柄、重复 fd)覆盖。

HandleTable 注入测试框架

通过 NtQuerySystemInformation(SystemHandleInformation) 的桩函数注入可控句柄表:

字段 类型 示例值 说明
HandleValue HANDLE 0x1234 用户态可见句柄值
ObjectType int 0x7 对应 PsProcessType 等类型索引
GrantedAccess DWORD 0x1F0FFF 模拟完整访问权限
graph TD
    A[测试用例] --> B[注入伪造HandleTable]
    B --> C[调用目标驱动API]
    C --> D[断言资源映射一致性]
    D --> E[验证跨平台行为对齐]

第五章:Go语言怎么获取句柄

在Go语言中,“句柄”并非原生概念,但开发者常需与操作系统底层资源交互,例如文件描述符(Unix/Linux)、Windows HANDLE、网络套接字、进程/线程对象等。这些资源在Go运行时被封装为抽象类型(如 *os.Filenet.Conn),而“获取句柄”实质是提取其底层平台相关整型标识符,用于调用C系统调用、与第三方库(如WinAPI、libev)集成,或进行低层调试与监控。

文件系统句柄提取

Go标准库通过 SyscallConn() 方法暴露底层连接接口。以打开的文件为例:

f, err := os.Open("/tmp/test.log")
if err != nil {
    log.Fatal(err)
}
defer f.Close()

// 获取底层syscall.Conn
conn, ok := f.(syscall.Conn)
if !ok {
    log.Fatal("not a syscall.Conn")
}

rawConn, err := conn.SyscallConn()
if err != nil {
    log.Fatal(err)
}

var fd int
err = rawConn.Control(func(fdPtr uintptr) {
    fd = int(fdPtr)
})
if err != nil {
    log.Fatal("failed to get fd:", err)
}
fmt.Printf("File descriptor: %d\n", fd) // 输出类似 3、4 等整数

该方式在Linux/macOS返回文件描述符(int),在Windows则返回HANDLE(即uintptr,需强制转换为windows.Handle)。

Windows原生HANDLE获取

在Windows平台,需结合 golang.org/x/sys/windows 包操作:

import "golang.org/x/sys/windows"

h, err := windows.CreateFile(
    &windows.UTF16FromString(`\\.\PHYSICALDRIVE0`)[0],
    windows.GENERIC_READ,
    windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
    nil,
    windows.OPEN_EXISTING,
    windows.FILE_ATTRIBUTE_NORMAL,
    0,
)
if err != nil {
    log.Fatal(err)
}
defer windows.CloseHandle(h)

fmt.Printf("Windows HANDLE: 0x%x\n", h) // 如 0x12a4

此时 h 即为原始 windows.Handle 类型,可直接传入 windows.DeviceIoControl 等API。

句柄生命周期与安全边界

资源类型 Go封装类型 底层句柄类型(Linux) 底层句柄类型(Windows) 是否可重复Close
普通文件 *os.File int(fd) windows.Handle ❌(双关引发EBADF)
TCP监听套接字 net.Listener int(fd) windows.Handle
进程对象 *os.Process 不直接暴露 windows.Handle ✅(需显式Close()

⚠️ 注意:调用 SyscallConn().Control() 后不可再对Go对象执行读写操作,否则可能触发 use of closed network connection 或数据竞争;句柄所有权移交后,应避免由Go运行时自动关闭。

实战场景:监控进程打开的句柄数

以下代码利用 /proc/[pid]/fd/ 目录(Linux)统计当前Go进程打开的文件描述符数量:

pid := os.Getpid()
fdDir := fmt.Sprintf("/proc/%d/fd", pid)
entries, _ := os.ReadDir(fdDir)
fmt.Printf("Process %d holds %d open file descriptors\n", pid, len(entries))

配合 lsof -p $(pidof myapp) 可交叉验证结果一致性。此方法无需CGO,纯Go实现,适用于容器化环境下的轻量级资源审计。

句柄泄漏常表现为 too many open files 错误,可通过定期采样 /proc/self/fd/ 并记录增长趋势定位问题模块。

分享 Go 开发中的日常技巧与实用小工具。

发表回复

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