限定符、对象访问与程序行为边界
本节目标
查询 C23 类型限定符、对象访问、有效类型、对象表示与程序行为分类。
读写一个 C 对象时,指针“有地址”并不等于访问一定合法。先从声明器确定限定符修饰的类型层级,再检查表达式是否可修改、对象是否仍在生存期内、访问类型是否被允许,最后才讨论具体值。本章以 ISO C23 为基线,把这条判断路径与程序行为分类放在一起;代表程序只执行定义明确、输出稳定的操作。
限定类型与限定转换
const、volatile 和 restrict 都限定类型,但它们回答的问题不同:const 限制通过相应左值进行修改,volatile 约束实现对相应访问的处理,restrict 为特定执行期间的对象访问路径建立别名契约。限定符修饰哪一层类型必须从声明器结构判断,不能只看 const 与变量名的相对位置。
int value = 1;
const int *pointer_to_const = &value;
int *const const_pointer = &value;
const int *const const_pointer_to_const = &value;
| 声明 | 被限定的层级 | 通过该名字能做什么 |
|---|---|---|
const int *pointer_to_const | 所指的 int | 指针可改指向;不能经它写目标 |
int *const const_pointer | 指针对象 | 指向固定;可以经它写目标 int |
const int *const const_pointer_to_const | 指针与所指的 int | 两层都不能经该名字修改 |
从 int * 转换到 const int * 是在所指类型上增加限定;反方向会丢弃限定,普通赋值不满足相应约束,强制转换也只改变表达式的类型,不改变底层对象本身。多级指针还要逐层判断,不能把 int ** 普遍当作 const int ** 使用,否则调用方可能借此把指向 const 对象的指针写回原接口。
const 与可修改左值
赋值、复合赋值和自增等写操作要求目标是可修改左值。概括地说,它必须指代对象、具有完整且非数组的对象类型,并且不能是 const 限定类型;结构体或联合体若含有递归意义上的 const 成员,也不能作为整体赋值目标。
const int limit = 7;
int current = 3;
const int *view = ¤t;
current = 4;
int observed = *view;
这里 current 是可修改左值;limit 和 *view 不是可修改左值,但读取它们都有定义。若一个对象实际定义时不带 const,只是通过 const 限定视图访问,恢复到合适的非 const 路径后仍可修改该对象;若对象实际定义为 const,则结论不同。通过非 const 左值修改一个实际定义为 const 的对象是未定义行为,强制转换不会使对象变得可修改。
下面片段只用于说明这个边界,不执行,也没有固定输出:
static void modify_const_object(void) {
const int value = 7;
int *writable = (int *)(const void *)&value;
*writable = 8; /* 未定义行为:实际对象定义为 const。 */
}
volatile 与可观察访问
volatile 告诉实现:对通过 volatile 左值进行的访问不能被当作与抽象机无关的普通缓存值随意消除或合并。volatile 要求相关访问按抽象机规则发生,但不提供原子性、互斥或一般线程同步。它也不会让悬空指针、越界访问或错误类型访问变得合法。
代表程序初始化一个 volatile int,随后在 printf 实参中读取一次。该例只证明这一条读取路径和确定值,不把它解释成跨线程通信。实际项目常在实现文档规定的硬件映射接口,或标准对信号处理允许的窄边界中见到 volatile;是否能用普通整数映射具体寄存器、一次 C 访问对应多少硬件事务,都需要目标实现和设备文档共同保证。
| 目标 | volatile 是否足够 |
|---|---|
| 保留语言规则要求的 volatile 访问 | 是,这是它直接处理的边界 |
| 让复合操作自动具备不可分割性 | 否 |
| 在一般线程间建立同步与顺序 | 否 |
| 修复对象生存期或类型不匹配 | 否 |
restrict 的访问契约
restrict 用于指向对象的指针访问契约。判断它时要看某次执行:若对象通过某个受限指针所基于的表达式访问,并在相关块的执行期间被修改,那么针对该对象的其他访问也必须满足 restrict 规则规定的同一路径关系。restrict 是基于执行期间访问路径的契约,不是“两个指针值永远不相等”的简单断言。
代表程序的 destination 与 source 指向两个独立数组;读取只来自 source 路径,写入只经 destination 路径,因此满足契约。函数声明中的 static count 还要求每个数组实参在调用处提供至少 count 个元素的访问范围;它不负责运行期检查。
下面反例的两个实参指向同一个对象,而函数既经 source 读取又经 destination 修改。它只用于审查,不执行,也没有固定输出:
static void restricted_increment(
int *restrict destination,
const int *restrict source
) {
*destination = *source + 1;
}
static void violate_restrict(void) {
int value = 1;
restricted_increment(&value, &value); /* restrict 违约:未定义行为。 */
}
restrict 不是运行期检测器,也不会自动报告重叠。接口若允许重叠,就不应声明不相容的 restrict 契约;若接口禁止重叠,应在调用约定中说明范围,而不只比较两个起始地址。
原子对象的基本访问规则
_Atomic int 是原子整数类型。对它执行符合原子对象规则的读取、写入和复合操作,与对普通 int 的访问具有不同语义;把原子对象强行当作普通对象访问会越过类型与并发边界。原子对象只能按原子规则访问;本章只介绍基本类型和访问差异,不展开内存序或线程库。
原子类型是条件支持的;若实现把 __STDC_NO_ATOMICS__ 定义为整数常量 1,表示它不支持 _Atomic 类型限定符和 <stdatomic.h>。本页代表程序的严格编译验证当前工具链支持这项条件特性,不代表所有 C23 实现都必须提供它。
代表程序在单线程中把 _Atomic int atomic_value 初始化为 3,再执行 atomic_value += 2,最后读取为 5。这里选择单线程是为了让输出只依赖基础对象操作,不引入调度或同步结果。原子对象可以防止其自身的原子访问形成普通数据竞争,但这不自动保护旁边的非原子对象,也不替代更高层协议。
_Atomic 访问在这里只覆盖基础对象访问边界;内存序、原子库和线程同步留到后续标准库章节。本页不使用相应库 API,也不讨论无锁性质。
对象表示、值表示与填充字节
对象在存储中占据一串字节,这些字节共同构成对象表示;其中参与表示该类型值的位构成值表示。对象表示还可能含有填充位或填充字节,同一个抽象值也不必在所有类型和实现上只有一种可比较的字节形态。
对象表示可以通过字符类型观察,但填充字节不等于成员值,不能据此构造跨实现协议。尤其不能因为两个结构体逐成员相等,就断言其全部字节必然相等;写入成员后,填充部分也不承担稳定业务含义。
| 判断对象 | 应比较什么 | 不应依赖什么 |
|---|---|---|
| 整数或枚举语义 | 转换后的值 | 当前机器字节序 |
| 结构体语义 | 各有效成员 | 填充字节恰好相等 |
| 文件或网络协议 | 明确字段宽度与编码顺序 | 直接写出本机对象表示 |
代表程序只按 int 值比较复制前后的两个普通对象,不打印 sizeof、对齐、字节序或任何具体对象字节。
有效类型、兼容类型与别名访问
左值的类型决定一次对象访问是否被允许。具有声明类型的对象以该声明类型作为判断基础;没有声明类型的已分配存储,则要结合此前通过非字符左值的写入、从其他对象进行的字节复制,以及当前访问方式判断有效类型。强制转换一个指针只改变表达式类型,不会凭空改写目标对象的有效类型。
有效类型与兼容类型规则必须按对象来源和访问左值判断,不能简化成“任何指针转换后都可解引用”。常见允许路径包括与有效类型兼容的类型、相应的限定版本、标准允许的对应有符号或无符号类型、包含这类成员的聚合或联合类型,以及字符类型访问。除此之外的类型重解释不能因为地址数值相同就变得有效。
以下严格别名反例只用于审查,不执行,也没有固定输出;即使调用方另外保证地址和大小条件,仍不能用不兼容的 float 左值读取一个实际 int 对象:
static float read_int_as_float(const int *source) {
const float *wrong_type = (const float *)(const void *)source;
return *wrong_type; /* 严格别名违规:未定义行为。 */
}
若程序要移动对象表示,应使用字符类型访问或 memcpy;若程序要转换数值,应写普通的数值转换。两者都不同于通过任意转换后的指针重新解释对象。
字符类型访问与 memcpy
字符类型左值可以观察任意对象的对象表示,常用 const unsigned char * 把对象看作一串字节。这个许可用于检查或传送表示,不等于这些字节构成跨实现稳定协议:其中仍可能包含实现相关的字节序、填充或类型表示。
memcpy(destination, source, count) 从源区域向不重叠的目标区域复制指定数量的字符。memcpy 复制对象表示,不调用语言外的构造或深复制逻辑;含指针对象时仍只复制指针值。区域可能重叠时应改用 memmove,不能依靠某次库实现碰巧采用安全顺序。
代表程序对两个普通 int 对象执行 memcpy(&copied, &original, sizeof copied)。源与目标类型相同、大小匹配、区域独立,复制后按 int 值比较得到相等。对于无声明类型的已分配存储,从另一个对象通过 memcpy 或 memmove 复制表示时,后续非修改访问还要按标准给出的来源类型规则判断;不能只看“内存里已经有字节”。
未定义、未指定与实现定义行为
程序越过边界后必须先准确分类,再决定是修复源码、查询实现文档,还是接受一组允许结果:
| 类别 | 实现承担什么 | 项目应怎样处理 |
|---|---|---|
| 定义明确的行为 | 按标准要求给出相应语义 | 可以据此写可移植断言 |
| 未定义行为 | 标准不对结果提出要求 | 在执行前消除,不观察“实际输出” |
| 未指定行为 | 从标准允许的多个选项中选择,不必记录选择 | 不依赖某一个选择,也不把一次运行当保证 |
| 实现定义行为 | 实现在允许范围内选择,并记录该选择 | 查询实现文档,在边界处验证并封装假设 |
| 与区域设置相关的行为(locale-specific behavior) | 取决于民族、文化和语言惯例,并由实现记录相应选择 | 查询实现与区域设置文档,隔离语言和文化假设 |
| 约束违反 | 符合实现必须至少给出一条诊断 | 把诊断当作源码问题,不能混称运行期结果 |
未定义行为没有实现必须提供的结果,未指定行为允许实现从多个选项中选择且不必记录选择,实现定义行为必须由实现记录选择。与区域设置相关的行为专指依赖民族、文化和语言惯例的选择;实现定义行为和与区域设置相关的行为都必须由实现记录。约束违反时,符合实现必须至少给出一条诊断,不能与运行期未定义行为混为同一个术语。诊断之后某个实现仍可能把源码作为扩展继续翻译,但这不把它变成可移植 C 程序。
以下片段只用于审查,不执行,也没有固定输出;每条注释给出主要分类,不声称任何输出:
#include <limits.h>
static int *expired_address(void) {
int local = 1;
return &local;
}
static void behavior_violations(void) {
int value = 1;
int unsequenced = value++ + value; /* 无序访问:未定义行为。 */
int maximum = INT_MAX;
int overflowed = maximum + 1; /* 有符号溢出:未定义行为。 */
int expired = *expired_address(); /* 生存期已结束后的访问:未定义行为。 */
const int fixed = 7;
fixed = 8; /* 约束违反:符合实现必须至少给出一条诊断。 */
(void)unsequenced;
(void)overflowed;
(void)expired;
}
本章中的严格别名违规和 restrict 违约也属于未定义行为。函数实参的某些求值顺序选择可属于未指定行为;普通 char 的符号范围等选择属于实现定义边界。它们都没有在代表程序中被探测或输出。
下面的完整程序只读取 const 与 volatile 对象,用不重叠数组满足 restrict 契约,以基础原子表达式访问原子整数,并把普通整数复制到同类型对象。执行路径不包含别名违规、restrict 违约、无序访问、有符号溢出、生存期错误或约束违反:
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
static void double_values(
size_t count,
int destination[restrict static count],
const int source[restrict static count]
) {
for (size_t index = 0; index < count; ++index) {
destination[index] = source[index] * 2;
}
}
int main(void) {
const int constant_value = 7;
volatile int volatile_value = 9;
const int source[] = {1, 2, 3};
int destination[3] = {0};
_Atomic int atomic_value = 3;
int original = 42;
int copied = 0;
double_values(3, destination, source);
atomic_value += 2;
memcpy(&copied, &original, sizeof copied);
printf("const-value=%d\n", constant_value);
printf("volatile-read=%d\n", volatile_value);
printf(
"restrict-result=%d,%d,%d\n",
destination[0],
destination[1],
destination[2]
);
printf("atomic-value=%d\n", atomic_value);
printf("memcpy-equal=%s\n", copied == original ? "yes" : "no");
printf("behavior-safe=%s\n", true ? "yes" : "no");
return 0;
}
输出:
const-value=7
volatile-read=9
restrict-result=2,4,6
atomic-value=5
memcpy-equal=yes
behavior-safe=yes
可移植性边界与实现假设
可移植代码不是“完全没有实现差异”,而是把差异放到明确边界中。可移植性边界要求把 CHAR_BIT、对齐、字节序等实现假设集中到少量接口;先使用标准能保证的关系,再把确实需要的假设写进接口、构建检查和测试中。
| 假设 | 查询或门禁 | 隔离方式 |
|---|---|---|
| 一个字节恰好 8 位 | <limits.h> 的 CHAR_BIT | 在编码适配层检查,不散落常量 8 |
| 类型具有某个大小或对齐 | sizeof、alignof 与实现文档 | 用静态断言守住外部接口 |
| 目标采用某种字节序 | 协议转换函数与目标文档 | 逐字段编码,不复制结构体 |
| 存在精确宽度整数类型 | <stdint.h> 是否提供对应名字 | 在公共类型层集中选择或拒绝平台 |
| 设备寄存器支持某种访问 | 编译器、ABI 与设备文档 | 封装在平台适配文件中 |
C 规定 sizeof(char) == 1,但一个 C 字节由多少位组成要查 CHAR_BIT,不能默认等于 8。结构体对齐、填充、整数表示细节和字节序也不能从某台机器的一次输出推广到其他实现。把假设集中后,核心算法只处理已经解码的值,平台差异由少量受审查接口承担。
限定符、别名与对象访问陷阱
| 症状 | 越过的边界 | 排查与修正 |
|---|---|---|
| 强制转换后写入原本的 const 对象 | 丢弃限定符没有改变对象定义 | 保留只读接口;需要修改时从非 const 对象开始 |
用 volatile 修复线程竞争 | volatile 不提供原子性或同步 | 根据共享状态协议选用原子对象或同步机制 |
| 两个 restrict 参数覆盖同一修改区域 | 调用违反执行期间的访问契约 | 规定并验证不重叠范围,或移除不真实的契约 |
| 把任意指针转换后直接解引用 | 转换不改变有效类型、对齐或生存期 | 数值转换用运算符,表示复制用 memcpy |
用 memcmp 判断结构体语义相等 | 比较了没有稳定成员语义的填充 | 逐成员比较 |
| 复制含指针结构体后释放两次目标 | memcpy 只复制指针值,不做深复制 | 明确所有权,单独复制所指资源 |
| 输出字节序、填充或地址作为固定结果 | 把实现信息误写成语言保证 | 只断言抽象值;实现探测留在适配层 |
常见陷阱不能用同一种“指针问题”修复:丢弃限定符、restrict 违约、别名违规和 volatile 误用必须分别按其语言规则处理。排查对象访问错误时,按“对象是否仍在生存期内 → 左值是否可修改 → 限定符位于哪层 → restrict 路径是否满足契约 → 原子对象是否按原子规则访问 → 左值类型是否被有效类型规则允许 → 是否误用对象表示 → 行为属于哪一类”依次检查。