Posted in

C语言全局变量初始化顺序 × Go init()执行时序:混合构建时符号解析失败的4种LLVM/Go linker底层原因

第一章:C语言全局变量初始化顺序 × Go init()执行时序:混合构建时符号解析失败的4种LLVM/Go linker底层原因

当使用 cgo 构建 C 与 Go 混合项目,并启用 LLVM 后端(如 CC=clang CGO_CFLAGS="-O2 -fno-semantic-interposition")或链接器优化(-ldflags="-linkmode=external -extld=lld")时,全局初始化时序冲突常导致符号未定义(undefined reference to 'xxx')或运行时崩溃。根本原因在于:C 标准要求 .init_array 中函数按编译单元内声明顺序执行,而 Go 的 runtime.main 在调用 main.main 前强制执行所有 init() 函数——但 LLVM LLD 和 Go linker 对跨语言初始化段(.init_array vs _rt0_go 初始化链)的合并策略存在四类不兼容行为。

符号可见性剥离导致的跨语言引用断裂

Clang 默认启用 -fvisibility=hidden,若 C 全局变量未显式声明为 __attribute__((visibility("default"))),Go 代码通过 //exportC.xxx 访问时,LLD 在 --gc-sections 下会丢弃该符号。修复方式:

// mylib.c  
__attribute__((visibility("default"))) int c_global = 42; // 必须显式导出

初始化段重排引发的依赖倒置

LLD 将 .init_array 条目按输入文件顺序合并,而 Go linker 将 init() 函数插入 .text 并通过 _gosymtab 动态注册。若 C 静态库(libfoo.a)在链接命令中位于 Go 目标文件之后,则其 .init_array 条目晚于 Go init() 执行,造成 Go 初始化代码访问未就绪的 C 变量。

TLS 模型不匹配触发的 GOT/PLT 解析失败

GCC 默认用 global-dynamic TLS 模型,Clang + LLD 在 -shared 模式下可能降级为 local-exec。当 Go 调用含 TLS 的 C 函数时,链接器无法生成正确 GOT 条目。验证命令:

readelf -d libmixed.so | grep -E "(TLS|FLAG)"
# 若输出含 FLAGS: NOTAB, 则 TLS 支持被裁剪

Go linker 的符号弱化覆盖机制失效

Go linker 对 __attribute__((weak)) 符号采用静态解析,而 LLD 在 --allow-multiple-definition 下保留多个定义。当 C 和 Go 同名弱符号共存时,Go linker 优先绑定自身符号,导致 C 端初始化逻辑被跳过。

问题类型 触发条件 检测方法
符号可见性断裂 Clang + -fvisibility=hidden nm -C libmixed.a | grep "U c_global"
初始化段重排 gcc-ar 归档顺序错误 llvm-readobj --sections libmixed.a
TLS 模型冲突 混合 -fPIC-shared objdump -T libmixed.so \| grep tls
弱符号覆盖失效 同名 weak 符号跨语言定义 go tool link -v main.o 2>&1 \| grep "weak"

第二章:C与Go混合链接的符号生命周期模型

2.1 C全局变量初始化阶段与ELF段加载时机的实测对比

全局变量的可见性与生命周期,取决于其存储位置(.data/.bss)及加载时序,而非声明顺序。

ELF段加载时序验证

使用 readelf -S ./a.out 可见: Section Type Flags Address
.text PROGBITS AX 0x401000
.data PROGBITS WA 0x404000
.bss NOBITS WA 0x404020

初始化行为差异

int x = 42;        // → .data:加载时复制初值
int y;             // → .bss:内核在mmap后清零(非加载器动作)
static int z = 0;  // → 同x,但作用域受限

x 值由加载器从ELF文件读取并写入;y 仅在brk/mmap后由内核按页归零——二者发生在不同阶段:前者属PT_LOAD段映射,后者属__libc_start_main前的C运行时准备。

加载与初始化流程

graph TD
    A[内核mmap PT_LOAD段] --> B[.text/.data映射到内存]
    A --> C[.bss标记为NOBITS]
    B --> D[__libc_start_main]
    D --> E[memset bss pages to 0]
    E --> F[调用全局构造器/初始化列表]

2.2 Go runtime.init()调用链与_goroutine调度器介入时机的反汇编验证

Go 程序启动时,runtime.init() 并非独立函数,而是由链接器注入的初始化函数集合(initarray)经 runtime.main() 触发执行。关键在于:调度器(mstartschedule)在首个用户 init 函数返回后、main.main 调用前才真正接管

反汇编关键断点观察

TEXT runtime.main(SB) /usr/local/go/src/runtime/proc.go
  ...
  CALL runtime·init.0(SB)     // 执行第一个包级 init
  CALL runtime·init.1(SB)     // 依序调用所有 init 函数
  CALL runtime·goexit(SB)     // 注意:此时 m->curg 仍为 g0,未启用抢占

该调用序列在 go tool objdump -s "runtime\.main" 中可清晰定位;init 函数全部返回后,newproc1 才首次创建 main.main 对应的 goroutine,并触发 globrunqput 将其入全局队列。

调度器激活时序表

阶段 当前 G 调度器状态 是否可抢占
init 执行中 g0(系统栈) m->curg == g0, sched.enabled == false
init 结束后 g0main.g sched.enabled = trueschedule() 首次运行
graph TD
  A[runtime.main] --> B[逐个调用 init.i]
  B --> C[init 全部返回]
  C --> D[create goroutine for main.main]
  D --> E[schedule() 启动 M/G 调度循环]

2.3 _init_array与go:linkname符号在LLVM IR中跨语言可见性丢失的Clang AST Dump分析

当 Clang 编译含 __attribute__((section("__DATA,__mod_init_func"))) 的 C 初始化函数时,AST 中保留 _init_array 符号引用,但经 -emit-llvm 后,该符号在 .ll 中降级为匿名全局常量:

// init.c
__attribute__((section("__DATA,__mod_init_func"))) 
static void __go_init_hook(void) { }

逻辑分析:Clang AST 将 __go_init_hook 视为具名 FunctionDecl,但 LLVM IR 生成阶段未保留其外部链接语义;go:linkname 所依赖的符号名绑定在此阶段断裂。

关键差异点如下表所示:

阶段 符号可见性 跨语言可解析性
Clang AST FunctionDecl(具名) ✅(Go linker 可见)
LLVM IR @0 = internal constant ❌(无符号名)

符号生命周期断点

  • AST → IR 转换跳过 llvm.used 元数据注入
  • go:linkname 依赖的符号名未映射至 !namedmd "llvm.module.flags"
graph TD
  A[Clang Frontend] -->|AST: Named FunctionDecl| B[IR Generation]
  B -->|Omit linkage info| C[LLVM IR: anonymous @0]
  C --> D[Go linker: symbol not found]

2.4 静态库归档顺序对__libc_start_main前符号解析优先级的影响实验(ar + ld.lld -trace-symbol)

静态链接时,ld.lld 按命令行中 -l.a 文件出现从左到右的顺序扫描归档库,并仅首次定义有效——这直接影响 _startmain__libc_start_main 调用链上游符号的绑定。

实验构造

# 构建两个含同名 weak __libc_start_main 的静态库(实际劫持其调用前钩子)
echo 'void __libc_start_main() { asm("nop"); }' | clang -c -o hook.o -x c -
ar rcs libhook.a hook.o

echo 'int main(){return 0;}' | clang -c -o app.o -x c -
ar rcs libapp.a app.o

ar rcs 创建归档;ld.lld -trace-symbol=__libc_start_main 会精确报告该符号最终来自哪个 .a 中的 .o

关键观察表

链接命令 符号来源 是否触发 hook
ld.lld libhook.a libapp.a ... libhook.a(hook.o)
ld.lld libapp.a libhook.a ... libc_nonshared.a(系统默认)

符号解析流程

graph TD
    A[ld.lld 读取 libhook.a] --> B{发现 __libc_start_main 定义?}
    B -->|是| C[采纳并标记已解析]
    B -->|否| D[跳过此库]
    C --> E[后续库中同名定义被静默忽略]

2.5 C++静态构造器与Go init()在PIE二进制中vvar/vdso映射冲突的strace+perf record复现

当PIE(Position-Independent Executable)二进制同时链接C++全局对象(触发.init_array静态构造器)与Go代码(含init()函数)时,动态链接器ld-linux.so在初始化阶段可能因vvar/vdso页映射时机竞争导致mmap返回ENOMEMEFAULT

复现关键步骤

  • 编译混合目标:g++ -fPIE -pie -o mixed main.cpp go_stub.o
  • 追踪系统调用:strace -e trace=mmap,mprotect,brk ./mixed 2>&1 | grep -A2 vvar
  • 捕获内核事件:perf record -e 'syscalls:sys_enter_mmap' -g ./mixed

mmap冲突核心参数

字段 说明
addr 0x7fff00000000 冲突地址(vvar起始VA)
flags MAP_PRIVATE\|MAP_FIXED_NOREPLACE Go runtime强绑定vdso/vvar基址
// main.cpp:触发静态构造器(隐式调用__libc_start_main前)
__attribute__((constructor)) void cxx_init() {
    // 此处可能触发dl_iterate_phdr → vdso查找 → mmap冲突
}

该构造器在_dl_start_user之后、main之前执行,与Go runtime的runtime.sysMap并发争抢vvar映射区域,导致vdso重映射失败。

graph TD
    A[ld-linux.so 加载] --> B[执行.init_array]
    A --> C[Go runtime.sysInit]
    B --> D[dl_iterate_phdr → vdso probe]
    C --> E[sysMap vvar/vdso]
    D & E --> F[竞态:mmap MAP_FIXED_NOREPLACE 失败]

第三章:LLVM后端对跨语言符号重定位的语义约束

3.1 LLVM MCJIT与Go linker对STB_GLOBAL/STB_WEAK绑定策略的分歧实证

符号绑定语义差异根源

ELF规范中 STB_GLOBAL 要求强绑定(不可被覆盖),而 STB_WEAK 允许被同名全局符号覆盖。LLVM MCJIT 默认将 JIT 生成符号标记为 STB_GLOBAL,而 Go linker(基于 gold/ldd)在链接阶段对弱符号执行惰性解析,导致运行时符号决议结果不一致。

关键复现代码片段

// main.go —— Go 主程序(链接时引用 weak_sym)
func main() {
    fmt.Printf("weak_sym = %d\n", C.weak_sym) // 绑定至 Go linker 解析的符号
}
// jit_module.c —— MCJIT 动态生成模块
int __attribute__((weak)) weak_sym = 42; // MCJIT 编译后实际标记为 STB_GLOBAL

逻辑分析:MCJIT 的 RTDyldMemoryManageremitAndFinalize() 阶段未尊重 weak 属性,强制设为 STB_GLOBAL(参数 SymbolFlags::SF_Exported 默认开启),而 Go linker 依据 .symtab 中原始 STB_WEAK 标志做重定位裁决——二者符号表视图割裂。

绑定行为对比表

环境 weak_sym 类型 运行时值 是否可被覆盖
Go linker 单独链接 STB_WEAK 0(未定义则取0)
MCJIT 加载后 STB_GLOBAL 42

符号决议冲突流程

graph TD
    A[Go linker 加载 main.o] --> B{解析 weak_sym}
    B -->|查 .symtab 标记 STB_WEAK| C[默认值 0]
    D[MCJIT emitAndFinalize] --> E{注册符号 weak_sym}
    E -->|忽略 attribute weak| F[强制设为 STB_GLOBAL]
    C --> G[运行时输出 0]
    F --> H[运行时输出 42]

3.2 LLD中–allow-multiple-definition在C-Go混合目标文件中的副作用验证

当使用LLD链接器构建含//export导出符号的CGO混合目标时,--allow-multiple-definition会绕过多重定义检查,但可能引发符号覆盖静默失效。

符号冲突场景复现

// cgo_export.c
int foo() { return 1; }
// main.go
/*
#cgo LDFLAGS: -Wl,--allow-multiple-definition
#include "cgo_export.h"
*/
import "C"
func bar() int { return int(C.foo()) } // 实际调用可能被后续.o中同名foo覆盖

--allow-multiple-definition使LLD接受多个foo定义,但按输入文件顺序取首个定义(非Go生成的stub),导致Go侧调用跳转到C原始实现而非CGO包装体。

链接行为对比表

选项 多定义处理 CGO符号解析结果 安全性
默认 链接失败
--allow-multiple-definition 取首个.o中定义 可能跳过Go runtime wrapper ⚠️

关键风险路径

graph TD
    A[Go源码含//export] --> B[CGO生成foo·go]
    C[C源码含foo] --> D[编译为c.o]
    B & D --> E[LLD链接]
    E --> F{--allow-multiple-definition?}
    F -->|是| G[取c.o中foo → 绕过GC/panic handler]
    F -->|否| H[链接错误终止]

3.3 ThinLTO下跨语言内联导致的init()调用被优化掉的opt -print-after-all日志追踪

当启用ThinLTO(-flto=thin)且混合C++与Rust(通过extern "C" ABI)时,LLVM在Inline阶段可能将跨语言init()调用内联为call void @llvm.trap()后直接删除——因其被判定为“无副作用且返回值未被使用”。

关键日志特征

  • Printing analysis 'Call graph' for function 'main' 后紧接 Inlining init() into main
  • After Inlining 阶段IR中@init调用消失,仅剩ret i32 0

典型优化链路

; before Inline
define dso_local i32 @main() {
  call void @init()
  ret i32 0
}

此处@init为外部定义(如Rust导出),但ThinLTO的GlobalValueSummary未标记其NotEligibleForImport,导致InlineAdvisor误判其可内联。实际应保留调用以触发Rust侧静态构造器。

阶段 IR变化 触发条件
Before Inlining call void @init()存在 init符号可见且无noinline属性
After Inlining 调用被移除,无等效替换 init函数体为空或仅含llvm.sideeffect
graph TD
  A[ThinLTO Backend] --> B[GV Summary: init → NotPreserved]
  B --> C[InlineAdvisor: init deemed “trivial”]
  C --> D[CallSite removed in FunctionPassManager]
  D --> E[init() side effects lost]

第四章:Go linker符号解析失败的四类根本原因及修复路径

4.1 类型不匹配:C struct tag与Go struct field alignment差异引发的symbol undefined错误

当使用 cgo 调用 C 库时,若 C 头文件中定义了带 __attribute__((packed)) 的结构体,而 Go 中对应 struct 缺少 //go:pack 注释或字段对齐不一致,链接期常报 undefined symbol —— 实为符号名因 ABI 不匹配未被正确导出。

字段对齐差异示例

// C header: vec3.h
typedef struct __attribute__((packed)) {
    float x;
    int   y;  // 4-byte int, but packed → no padding
} vec3_t;
// Go code: mismatched!
type Vec3 struct {
    X float32
    Y int32 // ← Go 默认按自然对齐(int32 对齐到 4-byte boundary),但 C packed 结构无 padding
}

逻辑分析cgo 生成的包装函数依赖 _Ctype_vec3_t 符号。若 Go struct 内存布局(size=8)与 C vec3_t(size=8 only because packed)表面一致,但字段偏移不同(如 Y 在 C 中 offset=4,在 Go 中若误加填充则 offset=8),导致 cgo 无法生成正确绑定,链接器找不到匹配符号。

关键对齐参数对照表

属性 C (packed) Go (默认) Go (correct)
sizeof 8 8 8
offsetof(Y) 4 4 ✅ 必须显式对齐
//export ✅ 生效 ❌ 无效 //go:packunsafe.Offsetof 校验

修复路径

  • 在 Go struct 上添加 //go:pack 指令(Go 1.22+)
  • 或使用 unsafe.Offsetof 断言字段偏移一致性
  • 禁用 -gcflags="-d=checkptr" 仅作调试,不可用于生产

4.2 段属性冲突:C的.section “.data.rel.ro”与Go linker对read-only段的强制合并策略矛盾

背景:段语义差异

C编译器(如GCC)将 .data.rel.ro 视为“可重定位+只读数据段”,允许动态链接时进行GOT/PLT修正,但运行时禁止写入。而Go linker(cmd/link)默认将所有 readonly 属性段(含 .data.rel.ro)合并进 .rodata,并标记为 PROT_READ —— 忽略其可重定位性

冲突表现

// test.c
__attribute__((section(".data.rel.ro"))) 
static const void* ptr = &some_symbol; // 需重定位

Go linker 合并后,该符号地址无法在加载时修正,导致运行时非法访问或初始化失败。

关键参数对比

属性 GCC 处理 .data.rel.ro Go linker 行为
段权限 PROT_READ \| PROT_WRITE(重定位期)→ PROT_READ(终态) 直接设为 PROT_READ
重定位支持 ✅ 显式保留 RELA 条目 ❌ 合并中丢弃 RELA 信息
段合并策略 独立段,不混入 .rodata 强制归并至 .rodata

解决路径

  • 方案1:用 -ldflags="-s -w" 禁用符号表,但牺牲调试;
  • 方案2:改用 .rodata + __attribute__((used)),放弃运行时重定位需求;
  • 方案3:定制 Go linker patch,识别 .data.rel.ro 并跳过合并(需修改 src/cmd/link/internal/ld/lib.go)。

4.3 符号修饰差异:Clang -fvisibility=hidden与Go build -buildmode=c-shared导出符号的ABI撕裂

当 Clang 编译 C/C++ 库启用 -fvisibility=hidden 时,默认隐藏所有符号,仅 __attribute__((visibility("default"))) 显式标记的函数/变量才进入动态符号表;而 Go 的 go build -buildmode=c-shared 自动导出所有 //export 标记的符号,且使用 Go 运行时约定的 C ABI(如 _cgo_export_xxx 前缀与调用约定)。

符号可见性对比

  • Clang:-fvisibility=hidden.dynsym 中仅含显式 default 符号
  • Go://export Add → 生成 Add(无前缀)但绑定 CGO_NO_EXPORT=0 环境约束

ABI 撕裂根源

// example.h —— Clang 编译目标
void __attribute__((visibility("default"))) add(int*, int*); // 符号名: add
// lib.go —— Go 编译目标
/*
#include "example.h"
*/
import "C"
//export Add
func Add(a, b int) int { return a + b } // 符号名: Add(大小写敏感!)

🔍 关键差异:Clang 导出 add(小写),Go 导出 Add(首字母大写),链接器无法解析跨语言调用——即使签名一致,符号名不匹配即 ABI 断裂。

工具链 默认符号名风格 可控性 ABI 兼容风险
Clang (-fvisibility=hidden) 小写、无前缀 高(显式标注) 低(若命名一致)
Go (c-shared) PascalCase、无重命名机制 低(硬编码导出名) 高(大小写/命名习惯冲突)
graph TD
    A[Clang 编译] -->|生成符号 add| B[动态符号表]
    C[Go 编译] -->|生成符号 Add| B
    B --> D[链接器查找 Add]
    D -->|失败:无 Add 符号| E[undefined reference]

4.4 初始化依赖环:C全局变量引用未初始化的Go变量导致_dl_init调用时got.plt未就绪的gdb逆向调试

当C代码中定义 extern int go_var; 并在全局作用域直接引用(如 int c_dep = go_var + 1;),该初始化语句被编译进 .data 段的 __libc_subinit 前置依赖链,早于 Go 运行时 runtime.main 的符号解析时机。

动态链接关键时序

  • _dl_init 执行时,.got.plt 尚未完成重定位(DT_RELA/DT_JMPREL 未应用)
  • Go 变量符号(如 go_var)仍为 0x0,触发非法内存读或静默截断
// 示例:危险的跨语言全局依赖
extern int64_t go_counter;          // 声明来自 Go 包
int c_cache = go_counter * 2;       // ❌ 编译期放入 .data.init —— 此时 runtime 未启动!

逻辑分析:c_cache_dl_init 阶段由 elf_machine_rela() 解析,但 go_counter 的 GOT 条目尚未被 runtime·symtab 注册,导致 R_X86_64_GLOB_DAT 重定位失败,值保持为零。

gdb 调试关键断点

断点位置 触发时机 观察重点
_dl_init 开头 动态库构造器执行前 got.plt[&go_counter] == 0
runtime.main Go 初始化完成 &go_counter 地址已有效
graph TD
    A[_dl_init] --> B[解析 .rela.dyn/.rela.plt]
    B --> C{go_counter 符号已注册?}
    C -->|否| D[got.plt 条目保持 0x0]
    C -->|是| E[成功写入真实地址]

第五章:面向生产环境的C/Go混合构建最佳实践演进路线

构建隔离与交叉编译链统一管理

在某金融级嵌入式网关项目中,团队将 C 代码(OpenSSL 1.1.1w + 自研加解密模块)与 Go 主控服务(gRPC 服务端 + 硬件抽象层)共存于同一仓库。早期采用 make 驱动 gccgo build 混合执行,导致 CI 中 macOS 开发者无法生成 ARM64 Linux 目标二进制。演进后引入 cgo_enabled=0 阶段预编译 C 库为静态 .a 文件,并通过 CC_arm64_linux=~/x-tools/aarch64-linux-gnu/bin/aarch64-linux-gnu-gcc 显式指定交叉工具链,配合 Go 的 GOOS=linux GOARCH=arm64 CGO_ENABLED=1 完成最终链接。该方案使跨平台构建失败率从 37% 降至 0.2%。

符号冲突检测与 ABI 兼容性验证

C 侧升级 musl libc 后,Go 调用 getaddrinfo 时偶发 SIGSEGV。经 objdump -T libnet.a | grep getaddrinfonm -D ./main | grep getaddrinfo 对比发现:C 库导出 getaddrinfo@GLIBC_2.2.5,而 Go runtime 动态链接至 getaddrinfo@GLIBC_2.14。解决方案是强制 C 编译添加 -fvisibility=hidden 并通过 __attribute__((visibility("default"))) 显式导出接口函数,同时在 Go 侧使用 //export go_net_lookup 注释声明符号,避免隐式符号覆盖。

构建产物可重现性保障机制

组件 版本锁定方式 哈希校验字段
OpenSSL git submodule update --init --recursive submodules/openssl/.gitmodules SHA256
Go toolchain go version go1.21.13 linux/amd64 GOCACHE=off go build -ldflags="-buildid="
C headers docker run --rm -v $(pwd):/src ubuntu:22.04 sh -c "apt-get download linux-libc-dev && sha256sum *.deb" linux-libc-dev_5.15.0-107.118_amd64.deb

所有构建步骤均在 Docker 容器内完成,基础镜像哈希、源码 commit hash、编译时间戳(通过 -X main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ) 注入)共同构成唯一构建指纹。

运行时内存安全兜底策略

针对 C 代码中未初始化指针误用问题,在 Go 主程序启动时注入 LD_PRELOAD=./libguard.so,该库拦截 malloc/free 并记录调用栈(backtrace(3)),当检测到 C 模块释放后仍被 Go goroutine 访问时,触发 SIGUSR2 并由 Go 信号处理器捕获,写入 /var/log/cgo-heap-violation.log 包含完整堆栈与内存快照(minidump 格式)。上线三个月捕获 12 起越界读,其中 7 起源于第三方 C 库 libcurlCURLOPT_WRITEFUNCTION 回调中错误复用 char* 缓冲区。

# 生产环境一键诊断脚本
#!/bin/bash
set -e
echo "=== C/Go 混合运行时健康检查 ==="
go tool pprof -http=:6060 ./app &
sleep 2
curl -s http://localhost:6060/debug/pprof/goroutine?debug=2 | grep -q "C.malloc" && echo "✅ C 分配 goroutine 存活正常" || echo "⚠️  C 分配未被 goroutine 引用"
kill %1

持续交付流水线中的渐进式灰度

在电信核心网元升级中,采用三阶段灰度:第一阶段仅启用 Go 层日志透传 C 模块的 LOG_LEVEL=DEBUG 输出;第二阶段启用 CGO_CHECK=1 运行时检查(仅限测试集群);第三阶段在边缘节点部署 libgo_c_wrapper.so 替代原生 C 调用,该 wrapper 提供 panic 捕获、超时熔断(setjmp/longjmp 封装)、以及调用耗时直方图(perf_event_open 采集)。每次灰度发布前自动生成 ABI 兼容性报告,对比 readelf -Ws liboriginal.so | awk '{print $4,$8}' | sort 与新版本输出差异。

flowchart LR
    A[Git Tag v2.4.0] --> B{CI Pipeline}
    B --> C[Stage 1: C 静态分析<br/>clang --analyze]
    B --> D[Stage 2: Go cgo 检查<br/>go vet -tags cgo]
    B --> E[Stage 3: 混合单元测试<br/>cgo_test.go + test_c.c]
    C --> F[Artifact: libcore.a]
    D --> F
    E --> G[Final Binary: gateway]
    F --> G
    G --> H[Production Cluster<br/>Blue-Green Rollout]

守护数据安全,深耕加密算法与零信任架构。

发表回复

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