• 忘掉天地
  • 仿佛也想不起自己
bingliaolongBingliaolong  2026-08-17 15:21 Aet 隐藏边栏 |   抢沙发  1 
文章评分 1 次,平均分 5.0

结构体布局、对齐、填充与联合体

为什么结构体存在填充字节?

  1. 假设平台要求:
    1. char1 字节对齐
    2. uint16_t2 字节对齐
    3. uint32_t4 字节对齐

  1. 典型布局为:
成员 偏移 大小
version 0 1
填充 1 3
length 4 4
command 8 2
尾部填充 10 2

offsetof 观察真实布局

  1. 将大对齐成员放在前面,经常能减少填充,但不是标准保证的优化规则

标准保证和不保证什么?

  1. C 标准保证:
    1. 成员按照声明顺序排列
    2. 后声明成员的地址位于先声明成员之后
    3. 结构体开头不会在第一个成员之前插入填充
    4. 成员之间及结构体末尾可以存在填充
    5. offsetof 可以取得普通成员的偏移
  2. C 标准不保证:
    1. int 一定是 4 字节
    2. 同一个结构体在不同编译器中布局相同
    3. 不同编译选项下布局相同
    4. 枚举和位域的具体表示相同
    5. 结构体布局等于网络数据格式

不要直接发送结构体

  1. 危险代码

  1. 主要问题:
    1. 可能发送填充字节
    2. 填充字节可能包含未初始化数据
    3. lengthcommand 使用主机字节序
    4. 不同平台的结构体大小可能不同
    5. 接收端直接转换指针可能不满足对齐要求
    6. 编译器打包选项可能改变 ABI
    7. 即使两端当前都是 x86,也只是碰巧兼容,不是可靠协议设计

更可靠的方式:显式编码

  1. 定义固定的 6 字节协议头

  1. 这里协议长度永远是 6 字节,不依赖 sizeof(struct)

为什么不直接使用 #pragma pack(1)

  1. 一些编译器支持

  1. 但它们不能解决所有问题:
    1. 都不是可移植标准 C
    2. 仍然存在大小端问题
    3. 未对齐访问可能降低性能
    4. 某些 CPU 上未对齐访问可能直接异常
    5. 取得打包成员地址可能产生未对齐指针
    6. 编译器之间语法和行为可能不同

结构体不能用 == 比较

  1. C 不支持

  1. 也不应该简单使用

  1. 因为:
    1. 填充字节可能不同
    2. 某些类型可能存在多种对象表示
    3. 语义相等不等于字节表示相等
  2. 应该逐成员比较:

联合体:多个类型共享同一块存储

  1. 所有成员从同一个地址开始

  1. 联合体大小通常至少能容纳最大成员,并满足最严格成员的对齐要求

  1. 推荐用法:带标签联合体

  1. 设置整数
    1. 这种模式相当于手动实现简化版的 C++ std::variant

枚举不是固定宽度协议字段

  1. C17

  1. C23 支持固定底层类型枚举,例如:

位域

  1. 位域可以紧凑保存标志,但其许多细节由实现决定:
    1. 位的分配顺序
    2. 是否跨越存储单元
    3. 对齐方式
    4. 整体大小
    5. 普通 int 位域是否有符号
    6. 与硬件或网络位序的对应关系

  1. 所以位域适合:
    1. 当前程序内部的紧凑状态
    2. 编译器、CPUABI 都完全受控的硬件映射

作用域、存储期、链接与头文件

四种主要存储期

  1. 自动存储期
    1. 进入代码块时对象开始存在,离开代码块时生命周期结束
    2. 每次函数调用都有独立对象

  1. 静态存储期
    1. 文件作用域对象以及 static 局部对象
    2. 它们在整个程序运行期间存在,并且在程序启动前完成初始化

  1. 分配存储期
    1. 通过动态内存函数获得
    2. 从成功分配开始,到对应的 free() 或成功改变它的 realloc() 结束

  1. 线程存储期
    1. C11 提供 _Thread_local
    2. 每个线程都有独立对象,该对象在线程执行期间存在
    3. 它适合线程本地统计或错误上下文,但不等于原子变量,也不能解决共享状态同步

作用域

  1. 文件作用域
    1. 在所有函数外声明
    2. 名字从声明位置一直可见到当前源文件末尾

  1. 块作用域

  1. 名字遮蔽
    1. 局部的 value 遮蔽了文件作用域的 value
    2. 全局对象依然存在,只是当前没有 C 语法可以像 C++::value 那样直接指定它

链接

  1. 链接主要回答
    1. server.c 中的名字和 main.c 中的同名名字,是否表示同一个实体?
  2. 外部链接
    1. 文件作用域普通函数和非 static 对象默认具有外部链接
    2. 其他翻译单元可以通过声明使用它们

  1. 内部链接
    1. 文件作用域使用 static

  1. 无链接
    1. 普通局部变量通常没有链接

static 的两个主要含义

  1. 文件作用域的 static
    1. client_count 具有静态存储期
    2. 名字具有内部链接
    3. remove_client 只在当前 .c 文件中可见

  1. 块作用域的 static
    1. 作用域只在函数体内
    2. 没有外部链接
    3. 生命周期覆盖整个程序
    4. 只初始化一次
    5. 多次调用共享同一个对象

extern 通常只声明,不定义

  1. 头文件中
    1. 表示某个翻译单元中存在一个具有外部链接的 connection_count

  1. 在一个 .c 文件中提供唯一定义

  1. 注意

文件作用域的 int count

  1. 下面不是普通的 extern 声明

  1. 正确结构

完整的多文件模块

  1. 头文件包含:
    1. 类型定义
    2. 函数声明
    3. 必需头文件
    4. include guard

头文件应该放什么?

  1. 适合放在头文件:
    1. 公共类型
    2. 公共函数声明
    3. 宏和常量接口
    4. 必需的前置声明
    5. 小型 static inline 函数
  2. 通常不应放:
    1. 普通函数定义
    2. 可写全局对象定义
    3. 只供当前 .c 使用的辅助函数
    4. 不必要的内部结构
    5. 私有实现所需的头文件
  3. 头文件保护

头文件中的 static

  1. 如果在头文件中写

  1. 每个包含它的 .c 文件都会获得一份自己的内部链接函数,可能造成:
    1. 多份代码
    2. 未使用函数警告
    3. 每个翻译单元拥有不同的函数地址
    4. 调试和覆盖统计更复杂
  2. 对于很小的头文件辅助函数,通常使用

  1. static inline 表示每个翻译单元拥有自己的内部链接版本
    1. 不要把它简单等同于“编译器一定内联”;是否真正展开仍由优化器决定
  2. C 的普通 inline 链接规则和 C++ 不完全相同,现阶段优先使用
    1. 普通公共函数:头文件声明,.c 文件定义
    2. 小型头文件辅助函数:static inline

CC++const 全局变量差异

  1. C
    1. 放在文件作用域时,默认具有外部链接

  1. 如果它只供当前 .c 使用

  1. 如果作为公共对象

使用不透明类型隐藏实现

  1. 公共头文件

  1. 好处:
    1. 隐藏内部实现
    2. 减少调用者重新编译
    3. 防止直接破坏内部不变量
    4. 更容易保持 ABI 稳定

预处理器、宏、条件编译与 _Generic

概述

  1. 预处理器工作在真正编译之前,主要处理 token 和文本,不理解 C 类型、作用域、生命周期与求值规则
  2. 预处理器从 C89 就存在
    1. C99 增加可变参数宏
    2. C11 增加 _Generic
    3. C23 又加入 __VA_OPT__#elifdef#warning 等能力

查看预处理结果

  1. 只输出宏展开、头文件包含和条件编译之后的结果,不执行正式编译

对象式宏

  1. 预处理器只是替换 token

  1. 宏没有类型

  1. 如果需要 C17 整数常量表达式

  1. 常见选择
需求 推荐
条件编译开关
整数常量表达式 enum 或宏
有地址、有明确类型的对象 static const
小型计算函数 static inline
C23 编译期对象 constexpr

函数式宏必须加括号

  1. 错误定义

  1. 改进
    1. 宏参数和完整结果通常都应加括号

  1. 但是这个宏仍然不安全
    1. C17 中,两次修改之间没有确定的先后关系,产生未定义行为
    2. 括号只能解决运算优先级,不能解决参数被重复求值

优先使用 static inline

  1. inline 不保证编译器一定内联
    1. 它主要提供函数定义和链接方面的语义
    2. 优化器可以内联普通函数,也可能拒绝内联 inline 函数
  2. 对于头文件里的小工具,常用

  1. 与宏相比,它具有:
    1. 类型检查
    2. 参数只求值一次
    3. 正常作用域
    4. 更好的调试体验
    5. 不会因缺少括号改变表达式含义

多语句宏使用 do while (0)

  1. 错误写法

  1. 正确模式
    1. 宏只应在函数确实无法表达需求时使用

可变参数日志宏

  1. C99 增加 __VA_ARGS__
    1. 宏会自动记录调用位置

  1. 注意:
    1. 格式字符串与参数仍需匹配
    2. 宏本身不提供类型安全
    3. __FILE____LINE__ 是预定义宏
    4. __func__ 是函数内部的预定义标识符,可以记录函数名
  2. 生产项目中,还应考虑线程安全、日志级别和异步日志生命周期

条件编译

  1. 编译时启用

  1. 禁用

  1. 这里要区分

  1. 如果宏表示布尔开关,应写

  1. 禁用宏可能移除参数求值
    1. 因此不要把程序必须发生的副作用放进调试宏参数

跨平台条件编译

  1. 可以把 Windows/POSIX 差异集中到一个头文件

#if 不理解C类型

  1. 不能这样写
    1. 预处理器不知道 sizeofC 类型

  1. 应该使用编译期断言

  1. 条件编译适合判断预处理宏

字符串化与token拼接

  1. 字符串化 #

  1. Token 拼接 ##

C11 _Generic

  1. C 没有函数重载,C11_Generic 可以根据表达式类型在编译期选择表达式

  1. 过程是:
    1. _Generic((value), ...) 检查 value 的类型
    2. 选择对应函数
    3. 再调用选中的函数
  2. 控制表达式本身不会被求值,所以这里真正的函数参数只求值一次
  3. 可以增加默认分支

  1. _Generic 的限制:
    1. 是精确的 C 类型选择,不是 C++ 模板
    2. 不支持用户定义的隐式重载体系
    3. 未选择分支也必须能够正常解析
    4. 宏展开错误通常较难阅读
    5. 复杂接口可能降低可维护性
  2. 适合用于:
    1. 类型安全的数学包装
    2. 日志或打印接口
    3. 不同整数类型的辅助函数
    4. 模拟少量函数重载

一个相对安全的泛型最小值

  1. 相比传统宏
    1. MINIMUM 最终调用真实函数,所以每个实参只求值一次

  1. 但函数实参的求值顺序仍未指定,因此不要这样写
    1. 即使没有重复求值,两个参数之间相互依赖也可能产生未定义或未指定行为

声明:本文为原创文章,版权归所有,欢迎分享本文,转载请保留出处!

bingliaolong
Bingliaolong 关注:0    粉丝:0
Everything will be better.

发表评论

表情 格式 链接 私密 签到
扫一扫二维码分享