C语言系统级编程
以“对象”为核心的C语言概念体系
内存基本概念
- bit (比特): 计算机中最小的数据单位,状态为0或1。
- byte (字节):
- C语言标准规定,1个
byte由CHAR_BIT个bit组成。 <limits.h>头文件中定义了CHAR_BIT,其值大于等于8。因此,1个字节至少包含8个比特,但不一定正好是8个。
- C语言标准规定,1个
- 内存与地址:
- 内存可以看作是一个由连续排列的
byte组成的巨大存储结构。 - 每个
byte都有一个唯一的编号,这个编号就是它的地址 (Address)。 - C语言中最重要的基础存储单元就是
byte。
- 内存可以看作是一个由连续排列的
- 寻址能力:
- 32位机器指的是用32个bit来表示地址编号。
- 其寻址范围是从
0x00000000到0xFFFFFFFF,总共可以编号 2³² 个字节。 - 2³² Bytes = 4 * 2³⁰ Bytes = 4 Gigabytes (GB),这就是32位系统最大支持4GB内存的根本原因。
对象 (Object)
- 定义: 根据C语言标准,对象是“一块数据存储区域,其内容可以表示值” (region of data storage, the contents of which can represent values)。
- 构成: 对象由一个或多个连续的字节序列组成。
对象的属性
对象类型 (Object Type)
一个对象一般有一个对象类型,如果其对象类型是完全对象类型,那么其大小是程序员可知的,如果是不完全对象类型,其大小是程序员不可知的。但对象分配之后大小是确定的,不完全对象类型不是指的这个对象其大小可变对象值 (Object Value) vs. 对象表示 (Object Representation)
- 对象表示: 对象所占内存区域中所有
bit组成的二进制串。 - 对象值: 对象表示经过其对象类型解释后所代表的实际数值。
- 关系:
- 相同的对象类型,如果对象表示一样,则对象值一定一样。
- 相同的对象类型,如果对象值一样,对象表示不一定一样。这是因为对象中可能存在不影响值的填充位 (Padding Bits)。
- 对象表示: 对象所占内存区域中所有
对象的size (大小)
- 对象的大小是指它所占用的字节数 (
byte)。 - 可以通过
sizeof运算符获得。
- 对象的大小是指它所占用的字节数 (
对象的地址 (Address) 和对齐 (Alignment)
- 地址: 对象所占用的连续字节中,编号最小的那个字节的地址。
- 对齐要求: 一个数值(通常是2的幂),要求对象的地址必须是该数值的整数倍。例如,一个4字节对齐的对象,其地址必须是4的倍数。
类型 (Type)
分类
C语言的类型系统可以从多个视角划分,一个重要的划分是:
- 算术类型 (Arithmetic Type)
- 派生类型 (Derived Type)
- 限定类型 (Qualified Type)
1. 算术类型 (Arithmetic Type)
- 它们都是完全对象类型。

- Basic Type:上图中除枚举类型外,其他类型均为Basic Type
- 整数类型的内部结构:
- 有符号整数: 其
bits由 1个符号位 (sign bit)、若干个值位 (value bits) 和 若干个填充位 (padding bits) 组成。
宽度: sign bit + value bits。决定了数的表示范围。
标准有符号整数类型 宽度宏 取值 signed char SCHAR_WIDTH CHAR_BIT short int SHRT_WIDTH 至少16 int INT_WIDTH 至少16 long int LONG_WIDTH 至少32 long long int LLONG_WIDTH 至少64 signed char类型被特殊规定,不允许有填充位,其sizeof恒为1,宽度恒为CHAR_BIT
- 无符号整数:由若干个value bits和若干个padding bits构成。
宽度:value bits的个数
标准无符号整数类型 宽度宏 取值 unsigned char UCHAR_WIDTH CHAR_BIT unsigned short int USHRT_WIDTH 至少8 unsigned int UNIT_WIDTH 至少16 unsigned long int ULONG_WIDTH 至少32 unsigned long long int ULLONG_WIDTH 至少64 bool BOOL_WIDTH 1 bool类型一定有padding bits,个数为
sizeof(bool) * CHAR_BIT - 1
枚举类型:枚举类型的实际的实现类型(Undelying Type)由两种方式决定:
在枚举类型定义的时候指定实现类型,如
enum Season : int {Spring, Summer, Autumn, Winter};如果不指定实现类型,实现类型由编译器自行设置为以下5种整数类型之一,即char、标准有符号整数类型、标准无符号整数类
型、扩展有符号整数类型、以及扩展无符号整数类型
2. 派生类型 (Derived Type)
- 数组类型 (Array Type)
- 形式化定义:
T[N]T: 元素类型 (Element Type),必须是一个完全对象类型。N: 元素个数 (Number of Elements)。
- 数组类型划分:
- 没有N:
T[]是一个不完全对象类型的数组类型 - 有N且N是整数常量或整数常量表达式:普通数组类型
- 否则:变长数组类型
- 没有N:
- 一般化核心思想: 数组类型
T[N]被视作一个从类型T派生出的新类型,称为 “Array of T”。 typedef与typeof:typedef int AINT[5];可以为数组类型int[5]创建一个别名AINT,AINT在语法上是一个整体;单纯使用typedef必须将别名放在类型与数组中间- C23引入的
typeof(int[5])也可以在语法上将数组类型当作一个整体来处理。typedef typeof(int[5]) A,允许我们像定义普通变量一样定义类型别名,这在元编程或宏定义中非常有用。
- 从数组类型继续派生
元素类型是完全对象类型的数组可以继续派生数组:int[10][]中元素类型是int[],不能继续派生,但int[][10]的元素类型是int[10],可以继续派生。
- 指针类型 (Pointer Type)
- 形式化定义:
T*T: 被引用类型 (Referenced Type),可以是任何对象类型(包括不完全类型)或函数类型。
- 一般化核心思想: 指针类型
T*被视作一个从类型T派生出的新类型,称为 “Pointer to T”。 - 指针都是完全类型: 任何指针类型自身的大小都是确定的,因此它们都是完全对象类型。
- 派生规则
- 对类型
T派生数组:T[N];派生指针:T*。 - 对数组类型
T[M]派生数组:T[N][M];派生指针:T(*)[M]。 - 对指针类型
T*派生数组:T*[N];派生指针:T**。
- 对类型
- 识别规则:
()优先级最高,同优先级先数组后指针
例如int*(**(*)[])[4][5]:指针 <- 数组 <- 指针 <- 指针 <- 数组 <- 数组 <- 指针
3. 限定类型 (Qualified Type)
- 四种限定符:
const,volatile,restrict,_Atomic。const: 表示对象的值不应被程序修改。volatile: 表示对象的值可能以程序未知的方式被修改(如硬件、多线程),阻止编译器对其进行优化。restrict: 只能限定对象指针类型,或者由对象指针类型构成的多维数组类型
- 限定类型形式:
Q T或T QT和Q T或T Q是两种不同的Type,但大小,表示值,对齐方式一样- 当
T是一个整体的时候,Q T和T Q是相同的
- 语法规则 (
Q代表限定符):Q T[N]: 限定符会“下沉”修饰元素类型,事实上等价与(Q T)[N]。例如const int a[10];是一个包含10个const int元素的数组。使用typedef定义数组别名后,依然只能限定元素类型T[N] Q:不合法,Q不能直接限定数组类型Q T*: Referenced Type是Q T,指向一个Q限定的类型。指针自身可变,但指向的内容不可通过此指针修改。例如const int* p;。T* Q: 定义一个Q限定的T*指针。指针自身是一个常量,不可变,但其指向的内容可变。例如int* const p;。- 如果类型
T是通过typedef定义的别名,则Q T和T Q是等价的。
举例:
const int* const:
const int,限定类型,限定intconst int*,指针类型,引用类型是const intconst int* const,限定类型,限定const int*
const int*[10]
const int,限定类型,限定intconst int*,指针类型,引用类型是const intconst int*[10],数组类型,10个const int*
int* const[10]
int*,指针类型,引用类型为intint* const,限定类型,限定int*int* const[10],数组类型,10个int* const指针_Atomic限定类型
_Atomic T或T _Atomic创建一个原子限定类型。_Atomic保证了对该类型对象的操作是原子的,这在多线程编程中至关重要。_Atomic T和T是两种不同的类型,它们的大小和对齐要求可能不同。- 注意: 本课程不深入讨论原子类型。
const,volatile, 和restrict限定类型
绕过const限定符
1 | const int a = 1; |
以上代码是未定义行为。
字符串字面量: char* s = “hello”; 是过时的写法。字符串字面量(如 “hello”)是不可修改的。正确的写法是 const char* s = “hello”;。试图修改字符串字面量的内容(如 s[0] = ‘a’;)也是未定义行为。
4. 补充:typedef
typedef为不同类型T设置别名的规则:
- 语法上整体呈现的类型:
typedef T Alias - 数组类型
T[N]:typedef T Alias[N] - 指针类型
T*:typedef T* Alias / T *Alias - 限定类型
Q T / T Q / T* Q:Alias均放在最后
typedef 的作用:
使用 typedef 可以将复杂的类型(如指针)定义为语法上的整体,从而简化限定符的使用。
1 | typedef int* PINT; |
多个限定符:
- 不同限定符:
const volatile int和volatile const int是等价的,顺序无关。 - 相同限定符:
const const int等价于const int。
对象的属性与七元组模型
为了系统地理解对象,我们将其所有关键属性形式化为一个逻辑模型。
1. 对象的关键属性
- 对象类型 (Object Type): 对象最核心的属性,决定了如何解释其内存表示。
- 对象名称 (Object Name): 对象的标识符。没有名称的对象称为匿名对象。
- 大小 (Size): 对象占用的字节数,通过
sizeof(T)获得。 - 地址 (Address): 对象在内存中的起始位置,由系统确定。
- 对齐要求 (Alignment): 对象的地址需要满足的约束,通过
alignof(T)获得。 - 对象值 (Object Value): 根据对象类型解释内存内容后得到的抽象值。
- 对象表示 (Object Representation): 对象在内存中实际的0/1比特串。
2. “表示值Value”和“表示值类型Value_Type”的获取规则
根据Obj_Type分以下两种情况:
- 对于非数组对象:
- 表示值 (Value) 就是其对象值 (Object Value)。
- 表示值类型 (Value Type) 是其对象类型 (Object Type) 的非限定版本。
- 对于数组对象:
- 表示值 (Value) 是数组第一个元素对象的地址。
- 表示值类型 (Value Type) 是指向其首元素的指针类型。
- 元素对象类型:指数组中元素的Object Value。例如
int *f[2][3][4]的元素对象类型为int *[3][4]。

3. 对象七元组模型
我们将一个对象的所有核心属性总结为一个七元组:
Object = { Address, Object_Type, Name, Size, Value, Value_Type, Alignment }
4. 对象表示与对象类型
const int a=1; int b[1]={1}; int c[1][1]={{1}};
他们的对象表示都是一样的(内存中存的01串是完全一样的),但对象类型和对象值(语义层面)完全不同
分配对象的方法
C语言提供了多种在内存中创建对象的方式:
- 对象定义
- 字符串字面量 (String Literal)
- 复合字面量 (Compound Literal)
- 内存管理函数 (
malloc, etc.)
通过对象定义分配对象
基本语法形式:
Storage-class specifiers T O = initializer;- 分配一个
T类型的对象。 - 声明一个对象标识符
O并与该对象关联。 - 使用
initializer(初始化器) 来设置该对象的对象表示。 Storage-class specifiers(存储类说明符) 指定对象的存储周期(后续讨论)。
- 分配一个
- 一次定义多个对象:T必须是语法上整体形式存在的
- 语法上整体呈现的类型:
T T O1 = initializer1, O2 = initializer2, …, On = initializern - 数组类型:
T O[N] = initializer, O2[N] = initializer2, …, On[N] = initializern,或:typeof(T[N]) O1 = initializer1, O2 = initializer2, ..., On = initializern; - 指针类型:
T* O = initializer, *O2 = initializer2, …, *On = initializern,或:typeof(T*) O1 = initializer1, O2 = initializer2, ..., On = initializern;
- 语法上整体呈现的类型:
1 | const int a=0, b=0, c=0; // a, b, c都是const类型 |
初始化器 (Initializer):
- 标量类型 (Scalar Type):
- 包含:算术类型、数组类型、指针类型、结构体类型、联合体类型、限定类型
- 对于一个标量类型
T,以及与之相关的限定类型Q T,Initializer一共有两种形式={}: 初始化为该类型的缺省值(整数/浮点数:0, 指针:NULL)=exp或={exp}: 用表达式exp的求值结果来初始化。
- 数组类型:
={}: 将所有元素初始化为缺省值。={val1, val2, ...}: 依次初始化数组元素。未指定的部分用缺省值填充。int a[] = {1, 2, 3};: 如果数组大小未指定,则根据初始化列表的长度确定。- 变长数组 (VLA): 如果要初始化,只能使用
{}。
- 标量类型 (Scalar Type):
没有初始化器的情况:
int a;这样的语句是否构成对象定义,以及其初始值是否确定,与它的作用域 (scope) 和 链接属性 (linkage) 有关(后续讨论)。- 在函数内部定义的
int a;会创建一个对象,但其值是不确定的 (Indeterminate),其对象表示被称为 indeterminate representation。
通过 String Literal 分配对象
- String Literal 的定义
- String Literal 由 编码前缀 和 双引号引导的字符序列 构成。
encoding-prefix "s-char-sequence"
String Literal
- 所有 源字符集 (source character set) 中的字符,除了:
- 双引号
" - 反斜杠
\ - 实际的回车符(换行)
- 双引号
\引导的转义字符。- 示例:
"hello\"world"、"hello\\world"、"hello\nworld"
- 示例:
- 所有 源字符集 (source character set) 中的字符,除了:
String Literal与String的区别:
- String:双引号中不显示包含
\0字符的String Literal被称为String hello\0world是一个合法的String Literal,因为\0也是一个\引导的转义字符,完全符合上面的定义。但作为一个String来说,只到hello就结束了。
- String:双引号中不显示包含
String Literal 的编码前缀
共有五种编码前缀,以"hello"为例:- 无编码:
"hello" - u8:
u8"hello"(UTF-8) - u:
u"hello"(UTF-16) - U:
U"hello"(UTF-32) - L:
L"hello"(宽字符)
- 无编码:
“hello” 分配的对象
- 过程:
- 在内存中分配一个大小合适的
char类型数组对象。对于"hello",大小为 6(包括末尾的\0)。 - 分别用 ‘h’, ‘e’, ‘l’, ‘l’, ‘o’, ‘\0’ 初始化数组对象的每一个元素。
- 该对象是一个 匿名对象。
- 在内存中分配一个大小合适的
- 对象属性示例:
A(Address):0x00404039Obj_T(Object Type):char[6]S(Size):6V(Value):0x00404039(其自身的地址)V_T(Value Type):char*Align(Alignment):1
- 过程:
对象存储周期 (Storage Duration)
- C语言定义了4种对象存储周期:
static,automatic,allocated,thread。 - 所有 String Literal 都具有静态存储周期 (static storage duration)。
- 具有
static存储周期的对象特点:- 其有效期覆盖整个程序运行期间。
- 在程序运行之前,其值会被初始化(如果代码中没有显式初始化)。
- C语言定义了4种对象存储周期:
不同编码前缀的 String Literal 分配的对象类型
构成字符数组的元素对象类型如下表:
编码前缀 元素字符对象类型 编码规则 无编码 — — u8 char8_tUTF-8 u char16_tUTF-16 U char32_tUTF-32 L wchar_t实现定义 多个 String Literal 的分配
- 多个同样内容的 String Literal:特别注意: 第二个
1
2"hello";
"hello";"hello"是否会分配一个新的、值一样的字符数组对象,这取决于具体实现。系统可能分配一个char[6]对象,也可能分配两个。 - 多个不同类型的 String Literal:一定会分配两个不同的对象,因为第一个对象类型是
1
2"hello";
L"hello";char数组,第二个对象类型是wchar_t数组。
- 多个同样内容的 String Literal:
相邻 String Literal 合并
- 相邻的多个 String Literal 会在编译时合并成一个。
"hello" " " "world";等价于"hello world";
- 不同编码的合并规则:
- 允许每个字符串有自己的编码前缀。
- 除了无编码的字符串外,所有带编码前缀的字符串的编码必须一样。
- 最后合成的字符串的编码,由带编码的那个字符串决定。
示例 相邻字符串 等价于 1 "hello" "world""helloworld"2 u8"hello" "world"u8"helloworld"3 "hello" U"world"U"helloworld"4 u8"hello" u"world"Invalid - 相邻的多个 String Literal 会在编译时合并成一个。
理解utf编码
utf-8,utf-16编码的字符长度不定,因此Obj_T与size都需要由计算得到。以
u8"hello"为例:Obj_T=char[char8_t[sizeof(u8“hello”)/sizeof(char8_t)]]S=sizeof(char8_t[sizeof(u8“hello”)/sizeof(char8_t)])
通过 Compound Literal 分配对象
- 定义: 提供一个匿名的 (unnamed) 对象,其值由初始化列表给定。
- 语法:
(type-name){initializer-list} - 存储周期: 具体的存储周期与其放置的地方有关。
- 示例:
(int){1}: 创建一个类型是int,值为1的匿名对象。(int[2]){1, 2}: 创建一个类型是int[2],值为{1, 2}的匿名对象。- 如果数组不指定长度,如
(int[]){1, 2},则长度由初始化列表的元素个数决定。
- 补充:
- 函数体外(File Scope):如果复合字面量出现在函数外部,它具有静态存储周期(static storage duration),也就是和全局变量一样,值在程序启动前初始化。
- 示例:
int *p = &(int){1};是合法的,因为 (int){1} 是静态的,其地址在编译时可知(地址常量)。
- 示例:
- 函数体内(Block Scope):如果出现在函数内部,它具有自动存储周期(automatic storage duration)。
- 示例:
int *p; void foo() { p = &(int){1}; }是危险的,因为函数返回后该匿名对象销毁,p 变成悬空指针。
- 示例:
- 函数体外(File Scope):如果复合字面量出现在函数外部,它具有静态存储周期(static storage duration),也就是和全局变量一样,值在程序启动前初始化。
通过内存管理函数分配对象
malloc: 例如malloc(16)- 创建的对象没有对象类型。
- 对象的对齐要求是
Fundamental Alignment(即alignof(max_align_t))。 - 对象的 storage duration 是
allocated。
- 其他函数:
calloc,realloc,aligned_alloc等。
第二部分:如何定位对象
1. 认识表达式、lvalue、rvalue
- lvalue (左值): lvalue就是能定位对象的表达式。包含:对象标识符、数组下标运算表达式、*exp,String Literal、Compound Literal、指向结构体/联合体的lvalue.member,指向结构体/联合体指针表达式->member
- 表达式 (Expression): 由一系列 operator (运算符) 和 operand (操作数) 组成的一个序列。C语言中共有17种表达式,其求值 (Evaluate) 优先级有高低之分。

2. 基础表达式 (Primary Expression)
基础表达式包括:
- 标识符 (Identifier): 包括对象标识符和函数标识符。
- 常量 (Constant)
- 字符串 (String Literal)
- 括号表达式 (Parenthesized Expression)
- 泛型选择 (Generic Selection)
3. 后缀表达式与一元表达式
给定一个表达式exp, 与后缀操作符结合构成的仍然是一个表达式
| 序号 | 后缀操作符 | 语法格式 | 示例 |
|---|---|---|---|
| 1 | [] |
exp1[exp2] |
a[3]; a[n+1]; |
| 2 | . |
exp.identifier |
struct student h; h.name |
| 3 | -> |
exp->identifier |
struct student* p; p->name |
| 4 | ++ / -- |
exp++, exp-- |
a++; |
| 5 | (type-name){} |
(type-name){Initializer-list} |
(int){2}, (int[3]){1,2,3} |
| 6 | () |
exp(argument list_opt) |
func(1, 3.5); |
给定一个表达式exp, 与一元操作符结合构成的仍然是一个表达式
| 序号 | 一元操作符 | 语法格式 | 示例 |
|---|---|---|---|
| 1 | ++ / -- |
++exp / --exp |
++a; --a; |
| 3 | & |
&exp |
&a; |
| 4 | * |
*exp |
*p; |
| 5 | + / - |
+exp, -exp |
+a; -a; |
| 6 | ~ / ! |
~exp, !exp |
~a; !a; |
| 7 | sizeof, alignof |
sizeof(int), sizeof exp, alignof(int) |
4. lvalue 表达式的 Evaluate 规则
给定一个 lvalue 表达式进行 Evaluate,分为两个过程:
- Value Computation:
- 规则 1.1 (lvalue conversion): 如果
lvalue定位的对象是 非数组类型,则rvalue就是该对象的 对象值,rvalue类型是该对象类型的非限定类型。 - 规则 1.2 (Decay/退化): 如果
lvalue定位的对象是 数组类型,则rvalue就是该数组 第一个元素的首地址,rvalue类型是元素对象类型对应的指针类型。 - 简单来说就是得到Value和V_T
- 规则 1.1 (lvalue conversion): 如果
- Side Effect: 环境状态的改变
lvalue 规则示例
- 非数组:
int a = 0;a是lvalue,定位int类型对象。evaluate(a)结果:rvalue是0,rvalue type是int。
- 数组:
int b[1] = {};b是lvalue,定位int[1]类型对象。evaluate(b)结果:rvalue是数组首地址Address,rvalue type是int*。
- 高维数组:
int d[1][1] = {};d是lvalue,定位int[1][1]类型对象。evaluate(d)结果:rvalue是首个元素(即d[0])的地址,rvalue type是int (*)[1]。
5. lvalue 做 evaluate 和不做 evaluate 的情况
1)定位非数组对象的 lvalue
给定一个定位非数组对象的 lvalue 表达式 exp,在以下情况 不做 evaluate:
- 作为
sizeof的操作数:sizeof(exp)-><size, size_t> - 作为
typeof的操作数:typeof(exp)->Obj_T - 作为
&(取地址) 的操作数:&exp->Addr, Obj_T* - 作为
++/--(前置/后置) 的操作数:++exp,exp++ - 作为
.(成员访问) 的左操作数:exp.member - 作为赋值运算符的左操作数:
exp = ...
除以上情况外,都要做evaluate。
但作为整体,其被视为一个表达式,仍然要做evaluate:
&a一元表达式:取<Addr, Obj_T*>;没有副作用++a:<Value incremented by 1, V_T>;副作用将Value+1a++:<Value, V_T>;副作用将Value+1
2)定位普通数组对象的 lvalue
给定一个定位普通数组对象的 lvalue 表达式 exp,在以下情况 不做 evaluate:
- 作为
sizeof的操作数:sizeof(exp)-><size, size_t> - 作为
typeof的操作数:typeof(exp)->Obj_T[size] - 作为
&(取地址) 的操作数:&exp-><Addr, Obj_T(*)> - 当
lvalue是一个 string literal,且用于初始化一个字符数组时:char str[] = "hello";此时不会退化成数组,而是作为初始化器填充str的内存
除以上情况外,都要做evaluate(即数组退化)。
注意:
Obj_T*指将Obj_T派生为其指针类型,应遵循派生规则。如int c[1][1]的Obj_T为int[1][1],Obj_T*为int(*)[1][1]另外,对于
sizeof(c+0),由于c+0并不是能直接定位普通数组对象的lvalue,因此需要对c+0进行evaluate1
2
3int c[2][2][2];
sizeof(c); // 2 * 2 * 2 * sizeof(int)
sizeof(c+0); // c+0退化为指针,类型为int(*)[2][2]。所有指针大小在64位系统中均为8字节
6. 可修改左值 (Modifiable lvalue)
- 一个
lvalue被称为 不可修改左值,如果它定位的对象类型T满足以下任一条件:- **
T是数组类型。**例如int b[1]中的b不能b++或b=NULL T是const限定类型。T是不完全对象类型。T是结构体/联合体,且其成员含有const限定类型。
- **
- 其他
lvalue都是 可修改左值。
第三部分:常量 (Constant)
1. 常量分类
C语言包括5种常量:
- 整数常量 (
integer-constant) - 浮点数常量 (
floating-constant) - 枚举常量 (
enumeration-constant) - 字符常量 (
character-constant) - 预定义常量 (
predefined-constant) (C23)
2. 整数常量
组成: 进制前缀 + 数字序列 + 类型后缀
进制前缀:
0b或0B: 二进制0: 八进制0x或0X: 十六进制- 无: 十进制
类型后缀: 决定整数常量的类型。

例如对于
0x8000,0000,无后缀16进制,超过了int,因此应为unsigned int类型确定规则: 从上到下查找第一个能表示该数值的类型。
示例: 假设
int是32位。2147483647: 在int范围内,类型是int。2147483648: 超出int范围,类型是long int。0x80000000: 十六进制,值为2147483648。超出int范围,但在unsigned int范围内,类型是unsigned int。
3. 浮点数常量
- 组成: 进制前缀 + 符号序列 + 后缀
- 种类: 十进制(无前缀)和十六进制(
0x前缀)。 - 后缀: 决定
rvalue类型。f或F:float- 无:
double l或L:long double+5.0是一元表达式
4. 枚举常量
enum定义中的成员,例如enum Season {Spring, Summer};中的Spring和Summer。rvalue类型是其所属的enum类型(如enum Season)。
5. 字符常量
- 组成: 编码前缀 + 单引号引导的有效字符
rvalue类型由前缀决定:
| 示例 | rvalue 类型 |
备注 |
|---|---|---|
'a' |
int |
易错点 |
u8'a' |
char8_t |
|
u'a' |
char16_t |
|
U'a' |
char32_t |
|
L'a' |
wchar_t |
- 在一对单引号里面引导的除了字符,还可以是一个以反斜杠
'\'引导的八进制,表示字符的ASCII码;也可以是以'\x'引导的十六进制整数,如'\061', '\61', '\x31'。八进制转义最长长度为3
6. 预定义常量 (C23)
false,true:rvalue类型为_Bool,值分别为 0 和 1。nullptr: 空指针常量,rvalue类型为nullptr_t。
第四部分:字符串 (String Literal) 相关的表达式 Evaluate
1. 字符串的蕴含语义
- 一个字符串意味着 分配(或重用)一个字符数组对象。
- 字符串本身是一个有效的
lvalue表达式。 - 这个
lvalue定位 刚刚分配(或重用)的那个字符数组对象。
2. 字符串 evaluate 示例
对于字符串 "hello":
- A: addr
- Obj_T:
char[6] - N: n/a
- S: 6
- V: addr
- V_T:
char* - Align: 1
其他表达式:
&hello-><addr, char(*)[6]>
3. 字符数组对象的初始化
char str[8]是一个数组对象类型,拥有和int[2]等其他数组完全一样的性质。- 初始化方式 1: 字符列表
char str[8] = {'h', 'e', 'l', 'l', 'o'};- 用
'h', 'e', 'l', 'l', 'o'初始化前 5 个字节。 - 数组剩余的 3 个字节会自动用
\0(null character) 填充。
- 初始化方式 2: String Literal
char str[8] = "hello";- 字符串
"hello"自身包含一个\0,共 6 个字符被用于初始化。 - 数组剩余的 2 个字节会自动用
\0填充。
4. 字符串对象共用问题
- 代码
if ("hello" == "hello")的行为是 实现定义的 (implementation-defined)。 - 编译器可以选择将两个相同的字符串字面量存储在同一个内存位置(共用一个对象),也可以为它们分别创建对象。
| 编译器 | 编译指令 | "hello"=="hello" 结果 |
|---|---|---|
| GCC 11.2.0 | gcc -std=c17 |
Y (true) |
| MSVC 19.35 | cl.exe /std:c17 |
N (false) |
| MSVC 19.35 | cl.exe /std:c17 /GF |
Y (true) |
| Clang 17.0.0 | clang -std=c17 |
Y (true) |
5. 思考题
char* p = "hello";"hello"需要evaluate,其rvalue(地址) 被赋值给指针p。
char str[] = "hello";"hello"不需要evaluate,它被用作初始化str数组内容的模板。
第五部分:Compound Literal 相关的表达式 Evaluate
1. Compound Literal 的蕴含语义
- 意味着 分配一个指定类型的对象。
- 本身是一个有效的
lvalue表达式。 - 这个
lvalue定位 刚刚分配的那个对象。
2. Compound Literal evaluate 示例
对于 (int){1},它是一个 lvalue,定位一个值为 1 的 int 类型对象。
evaluate规则: 与普通变量lvalue类似,++,--,&,sizeof,typeof和作为赋值左侧时,其本身不被evaluate。- 当
(int){1}被evaluate时:- 结果
rvalue是<1, int>。
- 结果
sizeof((int){1}):- 结果
rvalue是<4, size_t>(假设int为4字节)。
- 结果
typeof((int){1}):等价于int&(int){1}:- 结果
rvalue是<address, int*>。
- 结果
++(int){1}:- 有副作用,将匿名对象的值变为 2。
- 表达式的
rvalue是<2, int>。
- (const int){1}:
- 这是一个 不可修改左值,不能对其进行
++,--或赋值操作。
- 这是一个 不可修改左值,不能对其进行
3. 数组 Compound Literal 的 evaluate
对于 (int[1]){1} 这个 lvalue:
(int[1]){1}(被evaluate):rvalue为<address, int*>(数组退化)。
sizeof((int[1]){1}):rvalue为<4, size_t>。
&((int[1]){1}):rvalue为<address, int(*)[1]>。
- 思考: 在同一个表达式中多次使用
(int[1]){1},它们是否定位同一个对象?- 标准规定每次使用都会创建一个独立的对象。
第六部分:地址常量
1. sizeof 和 & 的 rvalue 特性
sizeof(lvalue):- 其
lvalue操作数 不做evaluate。 - 结果在 编译时即可推断,且在其生命周期内 不会改变。
- 其
&(lvalue):- 其
lvalue操作数 不做evaluate。 - 如果
lvalue定位的对象具有static存储期,则其地址在 编译时可知(是地址常量)。 - 否则,地址需要在 运行时才可获得。
- 其
2. 地址常量的概念
地址常量指的是:
- 空指针。
- 指向一个具有
static storage duration的对象的指针。 - 指向函数指示符 (Function Designator) 的指针。
3. 地址常量的获得方法
rvalue 是地址常量的情况:
- 显式使用
&作用于一个static对象。 - 显式将整数常量强转为指针。
- 隐式使用数组类型表达式获得的
rvalue(数组退化)。 - 隐式使用函数类型表达式获得的
rvalue。
- 示例:
1
2static int z[1];
int* p = z;z被evaluate后的rvalue是地址常量 (方法3)。p被evaluate后的rvalue不是地址常量,因为p本身是automatic对象,它的值在运行时才被读取。
第七部分:指针与数组
1. *exp 形式的 lvalue
- 语义:
*exp本身是一个lvalue。它的作用是 定位一个对象。 - 定位过程:
- 对表达式
exp进行evaluate,获得其rvalue,形如<Value, Value_Type>。 - 如果
Value_Type是一个对象指针类型,那么*exp就定位到一个新的对象 M。 - 对象 M 的
Address是*exp的Value。 - 对象 M 的
Object Type是*exp的Value_Type对应的 被引用类型 (Referenced Type)。 (例如,int**的被引用类型是int*)。
- 对表达式
avs*p: 在int a=1; int* p=&a;的上下文中,a和*p都是lvalue,它们定位到同一个对象,且对象类型相同,因此二者在行为上完全一致。
&a形式
整个&a的evaluate值为<Address, Obj_Type*>
2. 指针算术
p + n(指针 + 整数):- 结果是一个
rvalue。 - 其值等于
p的地址值+ n * sizeof(*p)。 - 其类型与
p的类型相同。
- 结果是一个
p - q(指针 - 指针):- 两个指针类型必须兼容。
- 结果是一个
rvalue,类型为ptrdiff_t。 - 其值为
(p的地址值 - q的地址值) / sizeof(*p),表示两个指针之间相隔的元素数量。
*(p+1)的问题:- 如果
p指向一个单独的对象a,那么p+1指向了紧邻a之后的一块未知内存。 - 解引用
*(p+1)来读取或写入该内存是 未定义行为 (Undefined Behavior)。
- 如果
*(exp+n) <=> exp[n]*(exp+n)和exp[n]是等价的。- 由于加法满足交换律,
exp1[exp2]也等价于exp2[exp1]。因此5[p]和p[5]是等价的。
3. 数组名与指针的异同
evse[0]:e是数组名lvalue,类型(这里指Obj_T)为int[2],是 不可修改左值,不能被整体赋值。e[0]的取值过程:是lvalue,类型为int,是 可修改左值,可以被赋值。
e=e+1报错原因:e是一个不可修改左值,不能作为赋值运算符的左操作数。
int* p=e; p++;可行的原因:p=e; p++被evaluate,退化为地址,赋值给指针p。p++:修改的是 指针对象p的值,使其指向下一个元素。这没有修改 数组对象e,是合法的。
第八部分:深入理解“一维”与“多维”数组
1. 再来观察 int g[2][3]
- 内存布局:
g[2][3]是一块连续的内存空间,总大小为2 * 3 * sizeof(int) = 24字节。它可以看作是两个int[3]类型的元素相继排列。 - 对象七元组分析:
A(Address):0x0082FB30Obj_T(Object Type):int[2][3]N(Name):gS(Size):24V(Value):0x0082FB30(数组名g退化后的地址)V_T(Value Type):int (*)[3](指向int[3]数组的指针)Align(Alignment):4
2. g[0] 和 g[1] 到底是什么?
g[0]和g[1]都是lvalue表达式,它们分别定位了数组g的第一个和第二个元素。g[0]:- 等价于
*(g+0)。 g首先被evaluate,退化成一个rvalue<0x0082FB30, int(*)[3]>。g+0的结果不变。*(g+0)解引用,定位到一个新的对象,其七元组为<0x0082FB30, int[3], N/A, 12, 0x0082FB30, int*, 4>。- 所以,
g[0]是一个类型为int[3]的数组对象。 - 再进行
evaluate,得到的是<addr, int*>
- 等价于
g[i]:- 等价于
*(g+1)。 g退化后的rvalue加上偏移1。g+1的结果为<0x0082FB30 + sizeof(*g) * i, int(*)[3]>*(g+1)解引用,定位到一个新的对象,其七元组为<0x0082FB3C, int[3], N/A, 12, 0x0082FB3C, int*, 4>。- 所以,
g[1]是一个类型为int[3]的数组对象,起始地址在0x0082FB3C。
- 等价于
g[i][j]:g[i]是一个int[3]数组对象,因此其rvalue为<addr, int*>g[i][j]等价于*(g[i] + j),结果为<addr + sizeof(*g[i]), int>
3. 关于步长
1 | int n[2][3][4][5]; |
4. 深入分析六个赋值例子
给定 int g[2][3] = {0};,目标是修改 g[1][2] 的值。
g[1][2] = 1;- 过程: 按照标准定义,
g退化为int(*)[3]指针,计算g+1得到g[1]的地址。g[1]退化为int*指针,计算g[1]+2得到g[1][2]的地址。最终定位到一个int对象,它是可修改左值,赋值成功。
- 过程: 按照标准定义,
(*(&g))[1][2] = 2;&g的rvalue类型是int(*)[2][3]。g不做evaluate*(&g)解引用,得到一个lvalue,该lvalue定位的对象就是g本身 (int[2][3])。- 后续操作与
g[1][2]完全相同。
(&(*g))[1][2] = 3;g退化为int(*)[3]指针。*g定位到g[0]这个int[3]数组对象。&(*g)取g[0]的地址,rvalue类型是int(*)[3],其值与g退化后的值相同。- 后续操作也与
g[1][2]完全相同。
(g+1-1)[1][2] = 4;g退化为int(*)[3]指针。g+1-1经过指针运算,结果的rvalue仍然和g退化后的rvalue相同。- 后续操作与
g[1][2]完全相同。
1[g][2] = 5;1[g]等价于*(1+g),也等价于*(g+1),即g[1]。- 所以
1[g][2]等价于g[1][2],赋值成功。
1[2][g] = 6;(编译错误)1[2]等价于*(1+2)。1+2的结果是rvalue<3, int>。int类型不是指针,无法进行*解引用操作,因此编译报错。
5. 总结:数组名是什么?
- 数组名是一个 Identifier (标识符),是一个 对象标识符,因此它是一个
lvalue表达式。 - 数组名 不是一个指针,也没有特殊性。其行为完全由表达式的
evaluate机制决定。 - 数组名是 不可修改左值 (
unmodifiable lvalue),因此不能放在赋值符号的左边(如g++或g = ...会出错)。其原因是它的类型是数组类型,而不是因为它是一个常量。
第九部分:深入理解 malloc
1. malloc 语法形式
void* malloc(size_t size);- 该函数分配一块指定大小 (
size) 的空间,用于存储一个 没有对象类型 的对象,其内容是未定的。
2. 访问 malloc 分配的内存
malloc分配的内存块是匿名的,无法通过标识符直接定位。- 必须通过
malloc返回的void*指针来间接访问。 - 返回的
void*指针需要 强制类型转换 为一个有意义的对象指针类型,才能通过*解引用来定位和使用这块内存。1
2
3
4
5
6
7// p 的 rvalue 是 <address, void*>
void* p = malloc(4);
// ip 的 rvalue 是 <address, int*>
int* ip = (int*)malloc(sizeof(int));
// *ip 定位到一个 int 类型的对象
*ip = 10;
3. malloc(sizeof(Obj_T) * N) 的理解
为什么malloc(sizeof(int) * 10)被强制转换为int*:
- 这是 C 语言中使用
malloc分配 一维数组 的标准范式。 - 语义:
malloc(sizeof(int) * 10)申请了 10 个连续int的存储空间。 - 类型: 这块空间可以被视为一个
int[10]类型的匿名对象。根据数组退化规则,int[10]类型的lvalue在求值后rvalue类型是int*。因此,返回值需要被转换为int*。
4. 使用 malloc 分配高维数组
直接分配连续空间 (不推荐):
1
2// 分配一个 int[2][3][4] 的空间
int (*p)[3][4] = malloc(sizeof(int[3][4]) * 2);- 缺点: 数组的维度
3和4需要硬编码在类型中,扩展性差。
- 缺点: 数组的维度
模拟高维数组 (常见方法):
1
2
3
4
5// 分配一个 int[row][col] 的空间
int** ppArray = (int**)malloc(sizeof(int*) * row); // 分配行指针数组
for(int i=0; i<row; i++) {
ppArray[i] = (int*)malloc(sizeof(int) * col); // 为每一行分配空间
}- 优点: 灵活,维度可以是变量。
- 内存布局: 数据不是一个连续的块,而是由一个指针数组分别指向各个行。并且指针也需要占内存。
5. 内存释放 free
- 通过对象声明(如
int a[10])分配的内存不需要手动释放。 - 通过
malloc分配的内存必须使用free进行释放,以避免 内存泄漏 (Memory Leak)。 - 原则:
malloc和free的调用次数必须匹配。 free(p);函数不需要指定大小,因为内存管理器在分配时已经记录了相关信息。
第十部分:到底什么是 C 语言中的指针 (Pointer)
- C 语言标准中没有对 “pointer” 的一个显式定义。
- 本质: 指针就是
rvalue的一种。- 任何表达式,在
evaluate之后如果得到的rvalue的类型是一个pointer type(指针类型),那么这个rvalue(形式为<address, pointer type>)就是我们常说的指针。 - 这个表达式就可以被用来指向 (point to) 一个对象或函数。
- 任何表达式,在
- 常见误解:
- “指针就是地址” (不完全,指针还包含类型信息)。
- “指针是一个变量” (不准确,
int* p中p是一个对象,而对p求值后得到的rvalue才是指针)。
- 正确理解: C 语言的指针是一系列概念(对象、表达式、
lvalue、evaluate、rvalue)的体系化应用。
第十一部分:进一步理解 const 限定符
1. Qualified Type (限定类型)
- 对象类型可以附加
const,volatile,restrict等限定符,从unqualified type变为qualified type。 - 对于非数组类型:
Obj_T const和const Obj_T是等价的。 - 对于数组类型:
qualifiers do not have any direct effect on the array type itself。限定符实际上是作用于数组的 元素类型。typedef int A[2]; const A g;等价于int const g[2];。
2. const 修饰不同类型
int const a:Obj_T: int const。V_T:intlvaluea成为不可修改左值。
int* const p(常量指针):const修饰的是int*这个整体,因此其类型是个限定类型Obj_T: int* const。V_T: int*p是不可修改左值,意味着p的值(即存储的地址)不能改变 (p = NULL;会报错)。但p指向的内容可以改变 (*p = 20;合法)。
int const* p(指向常量的指针):- 把
int const看做一个整体,*修饰的int const,因此其类型是个指针 Obj_T: int const*。V_T: int const*p是可修改左值,意味着p的值可以改变 (p = NULL;合法)。但p指向的内容被视为常量,不能通过*p来修改 (*p = 20;会报错)。
- 把
int const g[2][3]Obj_T: int const[2][3]V_T: int const(*)[3]- 元素类型:
V_T解引用得到int const[3],底层元素解引用得到元素类型为int const,为不可修改lvalue
int const* const p:- 第一个
const修饰int,第二个const修饰指针本身。 p的值和*p指向的内容都不能被修改。Obj_T: int const* const, V_T: int const*
- 第一个
3. const 和赋值
- 不可修改左值: 如果一个
lvalue定位的对象是const限定类型,或是数组类型,则该lvalue是不可修改左值,不能出现在赋值符号左侧。 - 类型转换:
const int a=1; int* p = &a;会产生编译警告,因为&a的类型是int const*,赋值给int*丢掉了const限定。int const a=1; int* p = (int*)&a; *p=10;- 通过强制类型转换可以绕过编译警告。
- 但通过
p修改const对象a的行为是 未定义行为。
"hello"[0] = 'a':- 这里的
"hello"对象类型是char[6],是一个数组类型,因此是不可修改左值,但是"hello"[0]的对象类型是char*,是可修改左值,这导致虽然可以对"hello[0]进行修改,但该行为是未定义行为
- 这里的
第十二部分:volatile
1. volatile 的基本概念
volatile 是一个类型限定符,它告诉编译器,被修饰的变量的值可能会以程序无法预测的方式发生改变。
- 语法:
int volatile a;:Obj_T: int volatile,V_T: intvolatile int a;
- 核心含义: 阻止编译器进行可能影响变量访问的优化。每次代码访问该变量时,都必须从其内存地址重新读取,而不是使用寄存器中可能存在的旧值。
- 对于
volatile对象的读取被视为一种Side Effect。
2. volatile 的应用场景
volatile 主要用于以下几种情况:
内存映射的硬件端口 (MMIO - Memory-Mapped Input/Output): 当一个内存地址实际上对应一个硬件寄存器时(如状态寄存器、数据端口),硬件可以随时改变该地址的值。程序必须使用
volatile来确保每次都读取到硬件的最新状态。- 示例: 定义一个指向特定硬件地址的
volatile指针。1
- 示例: 定义一个指向特定硬件地址的
由中断服务程序修改的全局变量: 在主程序和一个中断服务程序(ISR)之间共享的变量。当中断发生时,ISR可能会修改该变量,主程序需要能感知到这个“意料之外”的改变。
多线程应用中共享的变量: 当多个线程共享一个变量时,一个线程的修改对另一个线程来说是异步的。
volatile可以确保线程看到其他线程对变量的修改(注意:volatile本身不保证原子性或线程间的同步,这需要互斥锁等机制)。
3. volatile 与编译器优化
无
volatile的情况:1
2
3
4int status = 10;
while (status > 20) {
// ... do something
}编译器看到
status初始值为10,且在循环前没有代码修改它,可能会认为status > 20永远为假,从而将整个while循环优化掉。有
volatile的情况:1
2
3
4volatile int status = 10;
while (status > 20) {
// ... do something
}编译器被告知
status的值可能随时改变,因此它不会优化掉循环。在每次循环判断时,都会生成从内存中重新加载status值的指令。
4. volatile 指针
1 | int volatile a = 10; |
此时p的Obj_T是int volatile*,V_T也是int volatile*
如果int* q = (int*) &a; *q = 20,这是一个未定义行为,因为试图通过non-volatile-qualified类型访问一个volatile修饰的内存
5. volatile 的注意事项
const volatile: 这种组合表示一个值不能被程序显式修改(const的作用),但可以被外部因素(如硬件)隐式修改(volatile的作用)。常用于只读的状态寄存器。1
const volatile int hardware_status_register;
volatile与指针: 试图通过一个非volatile指针去访问一个volatile对象,其行为是未定义的 (Undefined Behavior)。1
2
3
4volatile int a = 10;
// 错误且危险的做法!
int* p = (int*)&a;
*p = 20; // 未定义行为
第十三部分:求值、副作用与序列点
1. 求值与副作用
a=b:a=b的副作用,即赋值,发生在b的求值之后a=b+c:b和c的求值没有先后顺序要求,但必须先于b+ca=b++:b++的副作用和a=b++的求值没有先后顺序
2. 未定义行为 (Undefined Behavior - UB)
C语言标准中,许多表达式的求值顺序是未指定的,如:
- 产生两次副作用且两次副作用没有先后顺序要求
- 产生的副作用和同样标量对象取值之间没有先后顺序要求
经典错误示例: 这些表达式的结果完全不可预测,在不同编译器、不同优化级别下可能完全不同。在实际工程中严禁使用!
1
2
3
4
5int i = 1;
i = ++i + 1; // i++的副作用与对i赋值的副作用
a = ++i + ++i; // UB
a = i++ + i++; // UB
a[i++] = i; // i++的副作用与i的求值思考题示例:
int a = 3; a += a -= a * a;同样是未定义行为,因为对a有多次无序的修改。
3. 序列点 (Sequence Point)
序列点是程序执行过程中的一个点,在该点之前的所有副作用都必须完成,并且在该点之后的所有副作用都尚未开始。序列点为表达式的求值顺序提供了保障。
C语言标准规定了多个序列点:
- 语句结束的分号 (
;): 这是最常见的序列点。- 示例:
i=1; i++; i++;是完全合法的,执行后i的值确定为3。
- 示例:
- 逻辑与
&&、逻辑或||运算符:&&和||存在一个序列点。它们保证从左到右求值,并实现“短路”行为。- 示例:
a++ && a++(假设a初值为0)。第一个a++求值为0(假),a变为1。因为短路,第二个a++不会执行。整个表达式为假,最终a的值为1。
- 示例:
- 逗号运算符 (
,): 逗号运算符(注意,不是函数参数或声明中的逗号分隔符)引入一个序列点。- 示例:
b = (a++, a++)(假设a初值为0)。第一个a++执行,a变为1。然后第二个a++执行,a变为2。整个逗号表达式的值是右侧表达式的值(此时a是1,a++后增),最终b被赋值为1,a为2。
- 示例:
- 三目运算符
? :: 在?之后有一个序列点。- 示例:
a++ ? a++ : a++(假设a初值为0)。第一个a++求值为0(假),a变为1。然后执行:后的a++,a最终变为2。
- 示例:
- 函数调用: 在所有函数参数求值完毕后、进入函数体执行前,有一个序列点。但是,函数参数之间的求值顺序是未指定的。
- 示例:
printf("%d %d\n", a++, a++);是未定义行为,因为两个a++副作用的顺序不确定。两个a++之间的逗号不是逗号运算符!!
- 示例:
scanf("%d %d", &b[0], &b[0]);:每处理完一个%d并写入变量后,有一个序列点。- ·
qsort/bsearch的比较函数:调用比较函数前后都有序列点;比较函数返回后、对象移动前也有序列点。
第十四部分:函数、函数类型与函数指针
函数类型 (Function Type)
一个函数本身是有类型的,由它的返回值类型和参数列表(参数数量和类型)共同决定。
函数类型是由其返回值类型派生而来的。返回值类型必须是非数组对象类型或void。派生规则如下:

- 示例:
int func(int a, char b);- 它的函数类型是
int(int, char)。 func是一个函数标识符,它“指代”一个函数。
- 它的函数类型是
函数指针 (Function Pointer)
函数指针是一个指针变量,它存储的是函数的入口地址。函数指针的类型必须与它所指向的函数的类型相匹配。
- 函数类型:
int(int, char) - 对应的函数指针类型:
int (*)(int, char)
函数类型派生指针类型的规则没有例外
函数类型七元组
- A:函数入口地址
- Func_T: 函数类型
- N:函数名
- S: 函数没有大小,sizeof无效
- V:与A相同
- V_T:Func_T对应的指针类型
- Align:n/a
Function Designator
要定位一个函数,只能通过Function Designator。Func Designator仅有两种:
- Function identifier
- 如果一个表达式exp,其rvalue类型是函数指针类型,则*exp是一个合法的Function Designator
- 对
func做evaluate -><value, value_type> - 对
&func做evaluate -><Address, Func_T*>
因此*func是一个合法的表达式,**func依然是,但&(&func)不合法,因为&func是一个rvalue,而不是lvalue,更不是一个Function Designator
函数调用
函数调用 exp(args) 的本质是:对expevaluate之后rvalue是指向func函数的指针,且args进行evaluate之后rvalue类型符合func函数的参数列表类型
基于以上规则,以下调用方式都是等价且合法的:
1 | int func(int a, int b) { return a + b; } |
非法的调用方式:
(&p)(2, 3): 非法。p是一个函数指针变量,&p是取该变量的地址,得到的是一个“指向函数指针的指针” (int (**) (int, int)),类型不匹配。&func(2, 3): 非法。函数调用运算符()的优先级高于&。表达式被解析为&(func(2, 3)),意为对函数的返回值取地址。如果返回值是一个右值(rvalue),则无法取地址。
5. 思考题解析
func = NULL;- 非法。
func是一个函数标识符,不是一个可修改的左值(lvalue)。
- 非法。
(1 ? func : NULL)(2, 3);- 合法。三目运算符的结果是一个函数指针(
func退化为指针,NULL也是一个空指针),可以被调用。
- 合法。三目运算符的结果是一个函数指针(
第七讲+第八讲
第十五部分:函数参数传递机制
1. Pass by Value 机制 (Slide 2, 3)
- 参数传递本质:
- 所有表达式(包括实参)都按照语义进行求值(evaluate)。
- 函数调用是一个合法的后缀表达式,实参是子表达式,需要按要求进行求值。
- C语言参数传递机制: Pass By Value(按值传递)。
- 传递过程:
- 获得实参
exp的返回值<V, V_T>(值和类型)。 - 将值
V写入形参Argument_Name对应的内存。 V_T和Argument_Type必须适配。
- 获得实参
2. 非数组对象类型 Lvalue 作为函数参数 (Slide 4)
- 示例:
int a = 10; func(a);,形参int pa。- 实参
a表达式获得的是对象a对应内存的表示值,即<10, int>。 - 将
10转换成 32 位0/1串,传递并存入形参对象pa对应的内存。
main函数中a: <10, int>,func函数中pa: <10, int>。
- 实参
3. 数组对象类型 Lvalue 作为函数参数 (Slide 5, 6)
- 数组/指针退化:
- 形参中
int[]int*,int[][3]int(*)[3]。 - 形参中的
int[]、int[][3]不是数组类型。 - 数组中第一维信息的丢失是 C语言表达式的取值机制 造成的。
- 形参中
- 示例: 数组
e(int e[2];) 和二维数组g(int g[2][3];)。- 实参
e的返回值是数组首元素的地址和指针类型<addr, int*>,传给形参int* pe。 - 实参
g的返回值是数组首行的地址和行指针类型<addr, int(*)[3]>,传给形参int (*pg)[3]。
- 实参
int** pg当形参问题 (Slide 8, 9):- 将二维数组
g强制转换为(int**)g传递给形参int** pg存在问题。 - 对
pg**解引用得到的是int*
- 将二维数组
4. 结构体类型作为函数参数 (Slide 11-14)
- 对象值: 结构体/联合体类型是派生类型,也是非数组类型。如
x = {1, {1}};,{1, {1}}是对象x的对象值。 - 值传递: 调用
foo(x)时,进行值传递,传递的是<Invisible Value, STR>。 - 结构体成员 Lvalue: 结构体对象
m、成员m.a、m.b都是 lvalue。 - 函数返回结构体:
MyStruct foo(),表达式foo()不是 lvalue。函数返回结构体或联合体,其成员访问表达式f().x是有效的后缀表达式,但 不是 lvalue。
第十六部分:变长数组 (Variable Length Array, VLA)
1. VLA 定义与特点 (Slide 15-18, 47)
- VLA 类型:
T[M]。 - 定义条件: 元素个数
M不是一个整数常量或整数常量表达式,或 元素类型T是一个 VLA。 - 内存确定: 数组的长度
(M的rvalue) * sizeof(T)必须在 运行时 才能确定。 - 判断元素类型是否为VLA:
int a[m]:一维数组,大小为m,元素类型为intint a[m][2]:一维数组,大小为m,元素类型为int[2]int a[2][m]:一维数组,大小为2,元素类型为int[m]int a[m][m]:一维数组,大小为m,元素类型为int[m]int a[1+2]; int b[1+'a']; int c[1+(int)5.0];都不是VLAint a[2][sizeof(int[5])]:不是VLA,因为sizeof(int[5])是整数常量表达式int a[2][sizeof(int[m])]:是VLA,因为sizeof(int[m])不是整数常量表达式
- 特性:
- 变长数组是 不完全对象类型,长度确定时完全化。
- 长度一旦确定,就 不能再更改。
- 变长数组不是长度可以一直变来变去的数组。
- VLA-Lvalue 作为
operand是否要evaluate是一个知识难点。
常量与常量表达式
1. 常量概念 (Slide 19)
- C语言包括 5 种常量:整数常量、浮点数常量、枚举常量、字符常量、预定义常量(C23)。
- 枚举的成员被称为 枚举常量(
enumeration constant)。
2. 常量表达式 (Slide 20)
- 常量表达式能在编译时进行求值(evaluate),像常量一样使用。
- 限制:
- 不能包含赋值、自增/自减、函数调用、逗号运算符(除非包含在不被求值的子表达式中)。
- 求值后
rvalue的取值范围,应在表达rvalue类型的表征范围之内。
预备知识 (Slide 22, 23, 24)
constexpr(C23): 声明的变量是常量表达式,被称为named constant。true、false、nullptr也是named constant。- 被
constexpr修饰的Compound Literal被称为Compound Literal Constant。
3. 整数常量表达式 (Slide 21, 25)
条件:
- 表达式
rvalue类型为 整数类型。 - 能 在编译时进行求值。
- 表达式
操作数 (Operand) 来源: 整数常量、字符常量、类型为整数的
named constant、类型为整数的compound literal constant、sizeof表达式、alignof表达式、cast表达式,其中cast的子表达式是浮点常量、类型为算术类型的named constant、compound literal constant,除非该cast表达式是typeof、sizeof或alignof操作符的operand举例:
const int c = 10; c不是 整数常量表达式。int m = 1; m不是整数常量表达式。(int)(+5.0),1?1:(+5.0):+5.0是一元表达式,不是浮点常量
特例:
'a'在C语言中被视为Integer Character Constant,sizeof(a)的结果是4
sizeof 与 VLA、常量表达式
1. sizeof(type-name) (Slide 27, 28)
sizeof后面可以跟type-name和exp两种operand。- Non-VLA 类型:
- 编译时得到大小,返回值是 整数常量。
type-name作为operand,整体 不做求值(evaluate)。
- VLA 类型:
- 不能在编译时得到大小,返回值 不是整数常量,需要在运行时得到大小。
type-name作为operand,子表达式需要求值。
2. sizeof(lvalue) (Slide 27, 30)
- Non-VLA 对象类型:
- 编译时得到大小,返回值是 整数常量。
lvalue作为operand,整体 不做求值,子表达式 不会做求值。
- VLA 对象类型:
- 不能在编译时得到大小,返回值 不是整数常量,需要在运行时得到大小。
lvalue作为operand,子表达式需要求值。
3. sizeof(non-lvalue) (Slide 27, 40)
如果表达式是一个
non-lvalue,则:sizeof(non-lvalue)返回这个non-lvalue求值后rvalue类型的大小。- 这个
non-lvalue并不会真的做求值。
举例说明:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20int i = 1;
int a[10][10]; // 元素类型是a[10]
int b[m][10]; // 元素类型是b[10]
int c[10][m]; // 元素类型是c[m]
sizeof(a[++i]); // i = 1
sizeof(b[++i]); // i = 1
sizeof(c[++i]); // i = 2
// --------------
int i = 1;
int a[2][sizeof(int[++m])];
sizeof(a[++i]); // a[++i]定位的对象类型是VLA,++i需要evaluate
sizeof(a[++i] + 1); // a[++i]+1是non-lvalue,不会做求值
int a[sizeof(int[++m])][2];
sizeof(a[++i]); // a[++i]定位的对象类型是int[2],++i不需要evaluate
int a[2][1+(int)(+5.0)]; // 1+(int)(+5.0)不是整数常量表达式
sizeof(a[++i]); // a[++i]定位的对象类型是VLA,++i需要evaluate
4. sizeof 用于 char 和字符常量 (Slide 42, 43)
sizeof(char)的结果为1。'a'在 C 语言中视为Integer Character Constant,其类型为int。sizeof('a')的结果是4(取决于sizeof(int))。sizeof('a')在 C++ 中返回1。
5. sizeof用于函数数组参数
- 不管函数外数组是如何定义的。只考虑函数形参是如何定义的
1 | void f1(int a[1][2]) { |
size、padding 和 alignment
1. size 和 padding (Slide 49-54)
size内涵:sizeof(T)是分配空间所需的字节数;sizeof(O)是对象占用的字节数。- 无符号整数的
padding:bit 位分为 value bits和padding bits。unsigned char不允许 有padding bits。 - 有符号整数的
padding:bit 位分为 sign bit、value bits和padding bits。signed char不允许 有padding bits。 signed/unsigned char的大小一定是 1 个byte,且没有padding。int类型: C语言规定int类型的sign bit + value bits长度必须。不能假设 sizeof(int)一定等于 4,也 不能假设 所有int没有padding bits。
2. 精确宽度整数类型 (Slide 55-57)
intN_t/uintN_t(Exact-width): 没有padding,确定宽度。C标准不要求编译器必须提供。int_leastN_t/uint_leastN_t(Minimum-width): 宽度。所有编译器必须定义。 int_fastN_t/uint_fastN_t(Fastest minimum-width): 宽度,且处理速度最快。所有编译器必须定义。
3. 对齐 (Alignment) 概念 (Slide 58-61)
- 定义: 对象首地址必须是某个字节地址的特定倍数。对齐值是
次方。 - 对象首地址 % 对象的
Alignment = 0。 - 获取对齐要求: 利用
alignof(T)/_Alignof(T)获得类型T的Alignment。 - 实现相关性: 对齐与编译器、硬件系统紧密相关。
char, signed char, and unsigned char shall have the weakest alignment requirement。
4. _Alignas 修改对齐要求 (Slide 62-64)
_Alignas可为对象设置更大(Stricter)的对齐要求。N >= alignof(T)。- 修改对象的
alignment不改变对象类型 的对齐大小,也 不改变对象的大小。 - 数组的对齐要求:
_Alignof(T) = _Alignof(E)(T是数组,E是元素类型)。 - 举例:
1 | _Alignas(64) struct stru { |
5. 结构体对象的对齐和 size (Slide 65-73)
结构体类型对齐要求
_Alignof(T):计算结构体对象
size(涉及offset和padding):- 内部对齐(Internal Padding): 确保每个成员
的首地址偏移量 offset满足其自身的对齐要求。 - 拖尾对齐(Trailing Padding): 确保结构体总大小
sizeof(T)满足结构体类型自身的对齐要求。
- 内部对齐(Internal Padding): 确保每个成员
1 | _Alignas(64) struct stru { |
#pragma pack(n)调整:- 成员对齐要求
取 。 - 结构体类型
取调整后成员中的最大值。 pack(n)针对结构体内部成员,结构体对象本身不受影响。
- 成员对齐要求
1 |
|
6. 指针对齐问题 (Slide 80, 81)
malloc返回的指针满足 基础对齐要求。- 如果转换后的指针对应的对象对齐方式不正确,则指针的强制转换行为是 未定义行为。
对象的存储周期 (Storage Duration)
- 定义:任何对象都有生命周期 (lifetime),决定生命周期的就是对象的 Storage Duration。
- 生命周期内:
- 系统确保对象的内存有效。
- 对象的地址是
constant address,地址不变。 - 对象保有最后赋的值 (last-stored value) 不变。
- 超出对象生命周期之外对对象的访问是 未定义行为。
- 4种存储周期:
static,thread,automatic,allocated。- Static存储周期:对象的生命周期是程序的整个执行期间,其存储的值仅在程序启动前初始化一次。
- Automatic存储周期:没有链接 (no linkage) 且没有
static说明符声明的对象。 - Allocated存储周期:依靠
malloc等函数分配的空间,需要调用free释放。
Storage-class Specifier (存储类说明符)
- C语言定义的说明符:
typedef,extern,static,thread_local,auto,register。 typedef:被称为存储类说明符只是为了语法上的方便,它没有定义新的对象类型,只是提供更好的书写便利性。register:告诉编译器“越快越好”,但编译器可以不理会。register修饰的对象不能取地址。
标识符的Scope (作用域)
- Identifier (标识符):是C语言中最基础的Symbol,用来识别对象、函数、标签等。
- Scope (作用域):标识符可见 (即可以使用) 的程序文本区域。
- 4种Scope:
function,file,block,function prototype。- Function Scope:
label name(标签名) 是唯一具有函数作用域的标识符。 - Function Prototype Scope: 出现在函数原型参数列表中的标识符,其作用域在函数声明符的末尾终止。
- File Scope: 出现在任何块或参数列表之外的标识符声明。其作用域在翻译单元 (translation unit) 的末尾终止。
- Block Scope: 出现在块内或函数定义的参数列表中的标识符声明。其作用域在相关块的末尾终止。
- Function Scope:
Linkage (链接)
- 定义:一个机制,用于分辨出现在不同地方的相同标识符是否表示同一个实体。
- 3种Linkage:
external,internal,none。 - no linkage:
- 声明为对象或函数之外的任何东西的标识符。
- 声明为函数参数的标识符。
- 块作用域中未使用
extern声明的对象的标识符。
no linkage的标识符都指向不同的对象。
- external linkage:
- 如果一个标识符用
extern声明,并且之前没有可见的声明,或者之前的声明是no linkage,则该标识符具有external linkage。
- 如果一个标识符用
- Internal Linkage:
- 在文件作用域 (file scope) 中声明的对象或函数的标识符,如果包含了
static说明符,则具有internal linkage。
- 在文件作用域 (file scope) 中声明的对象或函数的标识符,如果包含了
- Title: C语言系统级编程
- Author: OWPETER
- Created at : 2025-11-22 16:57:51
- Updated at : 2025-11-22 17:01:24
- Link: https://owpeter.github.io/2025/11/22/c系统/
- License: This work is licensed under CC BY-NC-SA 4.0.
