Message 内存布局

一条解析出来的消息在内存里到底长什么样?本章顺着 第 5 章 的 MiniTable,看消息实例如何只用纯数据 + 共享布局表来表示:8 字节不透明头、hasbit 位域、oneof case、扩展与未知字段的零拷贝别名,以及”看着满是分支、其实编译成直线代码”的访问器。

1. upb_Message:8 字节不透明头

可见的 upb_Message 结构刻意做得极小、不透明——只有一个机器字:

struct upb_Message {
  union {
    uintptr_t internal_opaque;  // tagged 指针,低位 == frozen
    double d;                   // 在 32/64 位都强制 8 字节大小
  };
};

message/internal/types.h:18-23

这 8 字节只是”消息头”。它含一个指向另外分配的 upb_Message_Internal 块(扩展 + 未知字段)的 tagged 指针,低位复用为”frozen / 不可变”标志types.h:29-48)。double d 成员强制 sizeof(upb_Message)==8 在 32 位和 64 位完全一致——这很重要,因为这个大小定义了保留 hasbit 区(见 §2)。

真正的字段数据不在这个结构里。一条消息被分配为一整块 m->size 字节的 arena 内存(message/internal/message.h:220-235):前 8 字节是头/hasbit 区,字段存储紧随其后,偏移烘焙进各个 upb_MiniTableField.offset。于是 msg 指针加字段 offset(经 UPB_PTR_AT(msg, ofs, type) = (type*)((char*)msg + ofs)port/def.inc:116)直接寻址该字段——没有间接、没有每字段元数据,所有布局都在共享只读的 MiniTable 里。

为什么快/省:实例是纯数据、零每字段开销;分配只是一次 upb_Arena_Malloc(且编译器被告知 size 已按 kUpb_Message_Align 对齐,message.h:230),随后一次清零 memset——这个清零在 arm64 上是手写内联汇编 _upb_Message_AlignedMemsetZeromessage.h:165-216),以避免大量微小 memset 带来的 PLT 调用和寄存器溢出。

2. hasbit:消息头里的位域

显式存在性的标量字段用一个 hasbit——消息前部某字节里的一位。字段的 presenceint16_t)被三义重载(第 5 章):>0 是 hasbit 索引、<0 是 oneof、0 是无 presence。

给定 hasbit 索引,字节偏移与掩码是纯算术:offset = index/8、mask = 1 << (index%8)field.h:140-152)。读 = (*ptr & mask)!=0、置 = |=mask、清 = &=~maskmessage/internal/accessors.h:60-82)。

保留 hasbit 妙招:布局器把前 sizeof(upb_Message)*8 = 64 个 hasbit 索引留给消息头自身(mini_descriptor/decode.c:52-56),真实字段 hasbit 从索引 64 起。因为 64/8 = 8第一个真实 hasbit 字节正好落在 8 字节头之后——hasbit 算术自动跳过那个不透明指针字,访问器里无需任何特判。

必填字段优化:必填字段被分配最低的 hasbit(紧接保留区),于是引擎用一次掩码比较就能判断”必填字段是否齐备”。_upb_Message_IsInitializedShallowmsg+1 处的 8 字节、转大端、检查 RequiredMask(m) & ~bits == 0accessors.h:138-144)——一次 load、一次 AND、一次比较验证全部必填字段。

3. oneof:case 字存”哪个成员被设置”

一个 oneof 存为 (a) 所有成员共享的一个 4 字节 “case” 槽,加 (b) 一个按最大成员表示定大小的共享值槽。oneof 成员字段 presence < 0,case 槽偏移由 ~(ptrdiff_t)presence 还原(field.h:189-193)。case 字存当前被设置成员的字段号(0 = 无):

存”字段号”而非”每成员一位”,意味着值存储可在成员间共享/重叠,且存在性与身份一次读取搞定。

4. 扩展与未知字段:一条动态增长的 tagged 指针数组

扩展和未知字段都住在那个隐藏的 upb_Message_Internal 块里:

typedef struct upb_Message_Internal {
  uint32_t size;
  uint32_t capacity;
  upb_TaggedAuxPtr aux_data[];   // 柔性数组
} upb_Message_Internal;

message/internal/message.h:150-156

每个 aux_data 槽是一个 upb_TaggedAuxPtr——一个用低两位 tag 标明所指内容的 uintptr_tmessage.h:45-68):01 = upb_Extension*00 = 非别名未知数据、10 = 别名未知数据。访问器用 & ~3ULL 取回指针(:82-90)。把扩展和未知段放进同一个有序数组,既保住跨两类的 wire 顺序,又免去两个独立容器。

扩展查找是对 aux_data 的线性扫描,匹配 ext->ext == emessage/internal/extension.c:22-38);get-or-create 预留一槽、malloc 一个 16 字节 upb_Extension {const upb_MiniTableExtension* ext; upb_MessageValue data;} 并追加一个 tagged 指针(extension.c:40-54)。槽容量按 2 的幂增长(首次 4,溢出后 RoundUpToPowerOfTwo(size+1)message/internal/message.c:56-86)。

未知字段的零拷贝 / 别名优化(最亮眼的一处):未知字节可被别名进原始输入缓冲而非拷贝。两条快路径:

  • 指针撞针式合并_upb_Message_TryAddUnknownAliasAllowMergemessage.h:256-284):若新未知字节与上一段未知段紧邻(existing->data + existing->size == data),只需 existing->size += len——零分配、零拷贝。
  • arena 原地扩展(慢路径,message/message.c:37-97):非别名数据尝试用 upb_Arena_TryExtend 原地扩大已有的 [view][data] 分配、只 memcpy 新增尾部;否则新分配一块。

删除未知区间(DeleteUnknownmessage.c:174-232)就地处理整段/前缀/后缀/中段切分,必要时把段重标为别名。

5. 访问器:”看着满是分支、其实编译成零”

核心妙招(注释在 accessors.h:215-245):只有一族MiniTableField 参数化的通用 getter/setter。它们看着分支重重,但因为生成代码传入的是编译期常量字段描述符(结构体字面量)或用 UPB_ASSUME(...) 断言其属性,编译器会把每个分支常量折叠掉,留下理想的直线代码。同一份源码同时服务生成访问器、反射和 MiniTable 访问器。

  • 基础字段读 _upb_Message_GetNonExtensionFieldaccessors.h:265-277):有 presence 且缺失则拷默认值,否则从 msg+offset 拷。拷贝是 _upb_MiniTableField_DataCopy——一个对字段表示(1/4/8 字节或 StringView)的 switchaccessors.h:165-183),编译器降级为单条 load/store。
  • 基础字段写 SetBaseFieldaccessors.h:311-319):先置 presence(hasbit 或 oneof,:156-163),再 DataCopymsg+offset
  • 表示而非 C 类型驱动存储_upb_MiniTableField_GetRep 就是 mode>>6;类型化 getter 如 GetInt64UPB_ASSUME 类型和 rep,再调通用 GetFieldaccessors.h:407-417)。

6. 默认值”免费”来自清零的 arena 内存

新消息构造时被整块清零(_upb_Message_AlignedMemsetZeromessage.h:233)。因为 proto 的隐式默认值全是零/空,缺失字段的存储已经天然是正确的”默认”,无需额外工作。getter 据此偷懒:GetNonExtensionField 只在字段缺失传入默认值非零时才替换默认值(!_upb_MiniTableField_DataIsZero(...)accessors.h:269-274)——绝大多数零默认情况直接读已清零的存储。upb_Message_Clear 就是一次 memset(msg, 0, m->size) 加重置 in->size=0accessors.h:851-860)。重复/map 字段无 presence:NULLupb_Array*/upb_Map* 语义上等同空(accessors.h:293-297),于是清零也免费给了空容器。

7. 本章小结

机制 出处
upb_Message = 单 tagged 字;double 强制 8 字节 message/internal/types.h:18-23
低位 frozen 标志 + 取/置内部指针 types.h:29-48
单次 malloc + 对齐假设 message/internal/message.h:220-235
arm64 手写清零汇编 message/internal/message.h:165-216
hasbit 掩码/偏移 field.h:140-152
hasbit 读/写/清 accessors.h:60-82
保留 64 hasbit 给消息头 mini_descriptor/decode.c:52-56
IsInitializedShallow 一次掩码比较 accessors.h:138-144
oneof case 偏移 = ~presence field.h:189-193
oneof case 存字段号、查询 accessors.h:100-133
upb_Message_Internal 柔性 tagged 数组 message/internal/message.h:150-156
TaggedAuxPtr tag 位 message/internal/message.h:45-90
扩展线性扫描 + get-or-create message/internal/extension.c:22-54
未知字段指针撞针合并 message/internal/message.h:256-284
未知字段 arena 原地扩展 message/message.c:37-97
访问器”分支折叠”设计注释 accessors.h:215-245
GetNonExtensionField 仅缺失+非零默认才替换 accessors.h:265-277
upb_Message_Clear = memset accessors.h:851-860

本站访问量

This site uses Just the Docs, a documentation theme for Jekyll.