4、数据结构优化:紧凑型数据结构设计、位域与联合体应用、缓存行对齐策略
做光通信协议栈这些年,我踩过最大的坑,就是内存。你想想看,一个40Gbps的线速转发,每秒钟要处理几百万个报文。每个报文解析、查表、封装,中间要经过十几层数据结构。稍微浪费几个字节,乘以百万级并发,内存带宽就崩了。
所以今天这章,我重点聊聊怎么把数据结构「榨干」。说白了,就是三个方向:紧凑设计、位域联合体、缓存行对齐。咱们一个一个来。
4.1 紧凑型数据结构设计
先问个问题:一个光通路状态结构体,你打算怎么定义?
我见过不少新手这么写:
// 不推荐:浪费严重
struct odu_channel {
uint32_t channel_id; // 通道号,实际只需要0-255
uint32_t odu_level; // ODU级别,0-7就够了
uint64_t rx_packets; // 接收包数
uint64_t tx_packets; // 发送包数
uint32_t rx_errors; // 错误计数
uint32_t tx_errors; // 错误计数
uint8_t admin_state; // 管理状态
uint8_t oper_state; // 操作状态
uint16_t reserved; // 对齐填充
};
这个结构体多大?算一下:4+4+8+8+4+4+1+1+2 = 36字节。但实际用到的有效信息,远没这么多。
我建议改成这样:
// 推荐:紧凑设计
struct odu_channel_compact {
uint8_t channel_id; // 0-255,1字节足够
uint8_t odu_level; // 0-7,1字节
uint8_t admin_state; // 管理状态
uint8_t oper_state; // 操作状态
uint32_t rx_errors; // 错误计数
uint32_t tx_errors; // 错误计数
uint64_t rx_packets; // 接收包数
uint64_t tx_packets; // 发送包数
} __attribute__((packed));
这里我用了 __attribute__((packed)),强制取消编译器对齐填充。结构体大小变成:1+1+1+1+4+4+8+8 = 28字节。省了8个字节。
核心原则:能用小类型绝不用大类型。uint8_t 能搞定的,别用 uint32_t。别怕麻烦,内存就是性能。
我在项目中遇到过,一个光通路表有16K条记录。每条省8字节,总共省了128KB。对于嵌入式设备来说,这128KB可能就是缓存命中的分水岭。
4.2 位域与联合体应用
位域这东西,很多人觉得是C语言的「古董」特性。但做协议栈,它太实用了。
举个例子,OTN帧的OH开销字节。一个字节里塞了多个标志位:
// OTN帧开销字节定义
struct odu_overhead {
// 第一个字节:PSI[0]
uint8_t payload_type : 6; // PT,6位
uint8_t reserved : 1; // 保留位
uint8_t tcm_act : 1; // TCM激活标志
};
// 联合体:既可按位访问,也可按字节整体操作
union odu_overhead_byte {
struct odu_overhead bits;
uint8_t raw;
};
为什么要用联合体?因为硬件寄存器通常按字节读写。你解析的时候用位域,写寄存器的时候用raw字节。两全其美。
我的习惯:位域字段顺序一定要和硬件手册一致。不同编译器对位域的排列顺序可能不同,尤其是跨平台时。我建议在代码注释里标明硬件手册的参考章节。
再比如,光模块的DDM(数字诊断监控)数据。温度、电压、偏置电流、发射功率、接收功率,这些值通常用两个字节表示,但精度要求不同:
// DDM数据联合体
union ddm_value {
uint16_t raw; // 原始ADC值
struct {
int16_t integer : 8; // 整数部分
uint16_t frac : 8; // 小数部分
} parts;
};
这样设计,你既可以直接拿raw值做快速比较,也可以拆开做精确计算。嗯,这里要注意:位域操作是有开销的。如果性能敏感,建议用宏或内联函数封装。
4.3 缓存行对齐策略
这部分,我觉得是很多人的盲区。你以为结构体定义好了就完事了?不对。CPU缓存行(Cache Line)才是真正的性能杀手。
现代CPU的缓存行通常是64字节。什么意思?CPU从内存读数据,一次读64字节。如果你两个频繁访问的字段跨了缓存行边界,每次访问都要读两次内存。性能直接腰斩。
我建议这么做:
// 缓存行对齐示例
struct channel_stats {
// 热数据:频繁访问
uint64_t rx_packets; // 接收包数
uint64_t tx_packets; // 发送包数
uint64_t rx_bytes; // 接收字节数
uint64_t tx_bytes; // 发送字节数
// 填充到缓存行边界
uint8_t pad[32]; // 补齐到64字节
} __attribute__((aligned(64)));
struct channel_config {
// 冷数据:偶尔访问
uint32_t channel_id;
uint32_t odu_level;
uint8_t admin_state;
uint8_t oper_state;
// 填充到缓存行边界
uint8_t pad[54]; // 补齐到64字节
} __attribute__((aligned(64)));
我曾经踩过的坑:把热数据和冷数据混在一个结构体里。结果每次更新统计计数,都要把整个缓存行刷一遍,连带把配置数据也刷进了缓存。后来拆成两个结构体,性能提升了15%。
还有一个技巧:伪共享(False Sharing)。多核环境下,两个核心各自修改同一个缓存行里的不同字段,会导致缓存一致性协议频繁交互。解决办法就是让每个核心的数据独占一个缓存行。
// 每个核心的私有统计
struct per_core_stats {
uint64_t packets;
uint64_t bytes;
uint8_t pad[48]; // 填充到64字节
} __attribute__((aligned(64)));
struct per_core_stats core_stats[8]; // 8核,每个核独占一个缓存行
4.4 实战:光通路表优化案例
最后,我分享一个真实案例。之前做OTN交换设备,光通路表有32K条记录。原始设计:
| 字段 | 类型 | 大小 |
|---|---|---|
| channel_id | uint32_t | 4字节 |
| odu_level | uint32_t | 4字节 |
| rx_stats | struct | 32字节 |
| tx_stats | struct | 32字节 |
| config | struct | 16字节 |
| padding | 对齐 | 8字节 |
| 总计 | 96字节 |
优化后:
| 字段 | 类型 | 大小 |
|---|---|---|
| channel_id | uint8_t | 1字节 |
| odu_level | 位域 | 1字节 |
| flags | 位域 | 2字节 |
| rx_stats | struct | 24字节 |
| tx_stats | struct | 24字节 |
| config | struct | 12字节 |
| 总计 | 64字节 |
每条记录从96字节降到64字节,正好一个缓存行。32K条记录,内存占用从3MB降到2MB。查表时一次缓存行读取搞定所有字段。性能提升肉眼可见。
总结一下:数据结构优化不是炫技,是实打实的性能收益。紧凑设计省内存,位域联合体省空间,缓存行对齐省时间。三者结合,你的协议栈才能跑出线速。
小提示:优化前先用sizeof()打印结构体大小,用offsetof()检查字段偏移。别靠猜,靠数据说话。