C语言系统级编程

OWPETER Lv4

以“对象”为核心的C语言概念体系

内存基本概念

  • bit (比特): 计算机中最小的数据单位,状态为0或1。
  • byte (字节):
    • C语言标准规定,1个 byteCHAR_BITbit组成。
    • <limits.h>头文件中定义了 CHAR_BIT,其值大于等于8。因此,1个字节至少包含8个比特,但不一定正好是8个。
  • 内存与地址:
    • 内存可以看作是一个由连续排列的 byte组成的巨大存储结构。
    • 每个 byte都有一个唯一的编号,这个编号就是它的地址 (Address)
    • C语言中最重要的基础存储单元就是 byte
  • 寻址能力:
    • 32位机器指的是用32个bit来表示地址编号。
    • 其寻址范围是从 0x000000000xFFFFFFFF,总共可以编号 2³² 个字节。
    • 2³² Bytes = 4 * 2³⁰ Bytes = 4 Gigabytes (GB),这就是32位系统最大支持4GB内存的根本原因。

对象 (Object)

  • 定义: 根据C语言标准,对象是“一块数据存储区域,其内容可以表示值” (region of data storage, the contents of which can represent values)。
  • 构成: 对象由一个或多个连续的字节序列组成。

对象的属性

  1. 对象类型 (Object Type)
    一个对象一般有一个对象类型,如果其对象类型是完全对象类型,那么其大小是程序员可知的,如果是不完全对象类型,其大小是程序员不可知的。但对象分配之后大小是确定的,不完全对象类型不是指的这个对象其大小可变

  2. 对象值 (Object Value) vs. 对象表示 (Object Representation)

    • 对象表示: 对象所占内存区域中所有 bit组成的二进制串。
    • 对象值: 对象表示经过其对象类型解释后所代表的实际数值。
    • 关系:
      • 相同的对象类型,如果对象表示一样,则对象值一定一样。
      • 相同的对象类型,如果对象值一样,对象表示不一定一样。这是因为对象中可能存在不影响值的填充位 (Padding Bits)
  3. 对象的size (大小)

    • 对象的大小是指它所占用的字节数 (byte)。
    • 可以通过 sizeof运算符获得。
  4. 对象的地址 (Address) 和对齐 (Alignment)

    • 地址: 对象所占用的连续字节中,编号最小的那个字节的地址。
    • 对齐要求: 一个数值(通常是2的幂),要求对象的地址必须是该数值的整数倍。例如,一个4字节对齐的对象,其地址必须是4的倍数。

类型 (Type)

分类

C语言的类型系统可以从多个视角划分,一个重要的划分是:

  • 算术类型 (Arithmetic Type)
  • 派生类型 (Derived Type)
  • 限定类型 (Qualified Type)

1. 算术类型 (Arithmetic Type)

  • 它们都是完全对象类型

  • Basic Type:上图中除枚举类型外,其他类型均为Basic Type
  • 整数类型的内部结构:
  1. 有符号整数: 其 bits1个符号位 (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

  1. 无符号整数:由若干个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

  1. 枚举类型:枚举类型的实际的实现类型(Undelying Type)由两种方式决定:

    • 在枚举类型定义的时候指定实现类型,如enum Season : int {Spring, Summer, Autumn, Winter};

    • 如果不指定实现类型,实现类型由编译器自行设置为以下5种整数类型之一,即char、标准有符号整数类型、标准无符号整数类

      型、扩展有符号整数类型、以及扩展无符号整数类型

2. 派生类型 (Derived Type)

  1. 数组类型 (Array Type)
  • 形式化定义: T[N]
    • T: 元素类型 (Element Type),必须是一个完全对象类型
    • N: 元素个数 (Number of Elements)
  • 数组类型划分
    • 没有N:T[]是一个不完全对象类型的数组类型
    • 有N且N是整数常量或整数常量表达式:普通数组类型
    • 否则:变长数组类型
  • 一般化核心思想: 数组类型 T[N] 被视作一个从类型 T 派生出的新类型,称为 “Array of T”
  • typedeftypeof:
    • typedef int AINT[5]; 可以为数组类型 int[5] 创建一个别名 AINTAINT在语法上是一个整体;单纯使用typedef必须将别名放在类型与数组中间
    • C23引入的 typeof(int[5]) 也可以在语法上将数组类型当作一个整体来处理。typedef typeof(int[5]) A,允许我们像定义普通变量一样定义类型别名,这在元编程或宏定义中非常有用。
  • 从数组类型继续派生
    元素类型是完全对象类型的数组可以继续派生数组:int[10][]中元素类型是int[],不能继续派生,但int[][10]的元素类型是int[10],可以继续派生。
  1. 指针类型 (Pointer Type)
  • 形式化定义: T*
    • T: 被引用类型 (Referenced Type),可以是任何对象类型(包括不完全类型)或函数类型。
  • 一般化核心思想: 指针类型 T* 被视作一个从类型 T 派生出的新类型,称为 “Pointer to T”
  • 指针都是完全类型: 任何指针类型自身的大小都是确定的,因此它们都是完全对象类型。
  1. 派生规则
    1. 对类型 T 派生数组: T[N];派生指针: T*
    2. 对数组类型 T[M] 派生数组: T[N][M];派生指针: T(*)[M]
    3. 对指针类型 T* 派生数组: T*[N];派生指针: T**
  2. 识别规则: () 优先级最高,同优先级先数组后指针
    例如int*(**(*)[])[4][5]:指针 <- 数组 <- 指针 <- 指针 <- 数组 <- 数组 <- 指针

3. 限定类型 (Qualified Type)

  • 四种限定符: const, volatile, restrict, _Atomic
    • const: 表示对象的值不应被程序修改。
    • volatile: 表示对象的值可能以程序未知的方式被修改(如硬件、多线程),阻止编译器对其进行优化。
    • restrict: 只能限定对象指针类型,或者由对象指针类型构成的多维数组类型
  • 限定类型形式:Q TT Q
    • TQ TT Q是两种不同的Type,但大小,表示值,对齐方式一样
    • T 是一个整体的时候,Q TT 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 TT Q 是等价的。

举例:

const int* const

  1. const int,限定类型,限定 int
  2. const int*,指针类型,引用类型是 const int
  3. const int* const,限定类型,限定 const int*

const int*[10]

  1. const int,限定类型,限定 int
  2. const int*,指针类型,引用类型是 const int
  3. const int*[10],数组类型,10个 const int*

int* const[10]

  1. int*,指针类型,引用类型为 int

  2. int* const,限定类型,限定 int*

  3. int* const[10],数组类型,10个 int* const指针

  4. _Atomic 限定类型

  • _Atomic TT _Atomic 创建一个原子限定类型。
  • _Atomic 保证了对该类型对象的操作是原子的,这在多线程编程中至关重要。
  • _Atomic TT 是两种不同的类型,它们的大小和对齐要求可能不同。
  • 注意: 本课程不深入讨论原子类型。
  1. const, volatile, 和 restrict 限定类型

绕过const限定符

1
2
3
const int a = 1;
int* p = (int*)&a; // 强制转换,移除 const 属性
*p = 10; // 编译通过,但这是未定义行为!

以上代码是未定义行为。

字符串字面量: 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* QAlias均放在最后

typedef 的作用:
使用 typedef 可以将复杂的类型(如指针)定义为语法上的整体,从而简化限定符的使用。

1
2
3
typedef int* PINT;
const PINT p; // 等价于 int* const p;
PINT const p; // 同样等价于 int* const p;

多个限定符:

  • 不同限定符: const volatile intvolatile const int 是等价的,顺序无关。
  • 相同限定符: const const int 等价于 const int

对象的属性与七元组模型

为了系统地理解对象,我们将其所有关键属性形式化为一个逻辑模型。

1. 对象的关键属性

  1. 对象类型 (Object Type): 对象最核心的属性,决定了如何解释其内存表示。
  2. 对象名称 (Object Name): 对象的标识符。没有名称的对象称为匿名对象
  3. 大小 (Size): 对象占用的字节数,通过 sizeof(T) 获得。
  4. 地址 (Address): 对象在内存中的起始位置,由系统确定。
  5. 对齐要求 (Alignment): 对象的地址需要满足的约束,通过 alignof(T) 获得。
  6. 对象值 (Object Value): 根据对象类型解释内存内容后得到的抽象值。
  7. 对象表示 (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]

1760702989494

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语言提供了多种在内存中创建对象的方式:

  1. 对象定义
  2. 字符串字面量 (String Literal)
  3. 复合字面量 (Compound Literal)
  4. 内存管理函数 (malloc, etc.)

通过对象定义分配对象

  • 基本语法形式: Storage-class specifiers T O = initializer;

    1. 分配一个 T 类型的对象。
    2. 声明一个对象标识符 O 并与该对象关联。
    3. 使用 initializer (初始化器) 来设置该对象的对象表示
    4. Storage-class specifiers (存储类说明符) 指定对象的存储周期(后续讨论)。
  • 一次定义多个对象:T必须是语法上整体形式存在的
    1. 语法上整体呈现的类型:T T O1 = initializer1, O2 = initializer2, …, On = initializern
    2. 数组类型:T O[N] = initializer, O2[N] = initializer2, …, On[N] = initializern,或:typeof(T[N]) O1 = initializer1, O2 = initializer2, ..., On = initializern;
    3. 指针类型:T* O = initializer, *O2 = initializer2, …, *On = initializern,或:typeof(T*) O1 = initializer1, O2 = initializer2, ..., On = initializern;
1
2
3
4
const int a=0, b=0, c=0; // a, b, c都是const类型
int* const a, b; // a是int* const类型,b是int类型
int* const a, *b; // a是int* const类型,b是int*类型
int* const a, * const b; // a是int* const类型,b是int* const类型
  • 初始化器 (Initializer):

    • 标量类型 (Scalar Type):
      • 包含:算术类型、数组类型、指针类型、结构体类型、联合体类型、限定类型
      • 对于一个标量类型T,以及与之相关的限定类型Q T,Initializer一共有两种形式
        1. ={}: 初始化为该类型的缺省值(整数/浮点数:0, 指针:NULL)
        2. =exp={exp}: 用表达式 exp求值结果来初始化。
    • 数组类型:
      • ={}: 将所有元素初始化为缺省值。
      • ={val1, val2, ...}: 依次初始化数组元素。未指定的部分用缺省值填充。
      • int a[] = {1, 2, 3};: 如果数组大小未指定,则根据初始化列表的长度确定。
      • 变长数组 (VLA): 如果要初始化,只能使用 {}
  • 没有初始化器的情况:

    • int a; 这样的语句是否构成对象定义,以及其初始值是否确定,与它的作用域 (scope)链接属性 (linkage) 有关(后续讨论)。
    • 在函数内部定义的 int a; 会创建一个对象,但其值是不确定的 (Indeterminate),其对象表示被称为 indeterminate representation

通过 String Literal 分配对象

  • String Literal 的定义
  • String Literal 由 编码前缀双引号引导的字符序列 构成。

encoding-prefix "s-char-sequence"

  • String Literal

    1. 所有 源字符集 (source character set) 中的字符,除了
      • 双引号 "
      • 反斜杠 \
      • 实际的回车符(换行)
    2. \ 引导的转义字符。
      • 示例: "hello\"world""hello\\world""hello\nworld"
  • String Literal与String的区别:

    • String:双引号中不显示包含\0字符的String Literal被称为String
    • hello\0world是一个合法的String Literal,因为\0也是一个\引导的转义字符,完全符合上面的定义。但作为一个String来说,只到hello就结束了。
  • String Literal 的编码前缀
    共有五种编码前缀,以 "hello" 为例:

    1. 无编码: "hello"
    2. u8: u8"hello" (UTF-8)
    3. u: u"hello" (UTF-16)
    4. U: U"hello" (UTF-32)
    5. L: L"hello" (宽字符)
  • “hello” 分配的对象

    • 过程:
      1. 在内存中分配一个大小合适的 char 类型数组对象。对于 "hello",大小为 6(包括末尾的 \0)。
      2. 分别用 ‘h’, ‘e’, ‘l’, ‘l’, ‘o’, ‘\0’ 初始化数组对象的每一个元素。
      3. 该对象是一个 匿名对象
    • 对象属性示例:
      • A (Address): 0x00404039
      • Obj_T (Object Type): char[6]
      • S (Size): 6
      • V (Value): 0x00404039 (其自身的地址)
      • V_T (Value Type): char*
      • Align (Alignment): 1
  • 对象存储周期 (Storage Duration)

    • C语言定义了4种对象存储周期:static, automatic, allocated, thread
    • 所有 String Literal 都具有静态存储周期 (static storage duration)
    • 具有 static 存储周期的对象特点:
      1. 其有效期覆盖整个程序运行期间。
      2. 在程序运行之前,其值会被初始化(如果代码中没有显式初始化)。
  • 不同编码前缀的 String Literal 分配的对象类型

    构成字符数组的元素对象类型如下表:

    编码前缀 元素字符对象类型 编码规则
    无编码
    u8 char8_t UTF-8
    u char16_t UTF-16
    U char32_t UTF-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 会在编译时合并成一个。
      • "hello" " " "world"; 等价于 "hello world";
    • 不同编码的合并规则:
      1. 允许每个字符串有自己的编码前缀。
      2. 除了无编码的字符串外,所有带编码前缀的字符串的编码必须一样。
      3. 最后合成的字符串的编码,由带编码的那个字符串决定。
    示例 相邻字符串 等价于
    1 "hello" "world" "helloworld"
    2 u8"hello" "world" u8"helloworld"
    3 "hello" U"world" U"helloworld"
    4 u8"hello" u"world" Invalid
  • 理解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 变成悬空指针。

通过内存管理函数分配对象

  • malloc: 例如 malloc(16)
    1. 创建的对象没有对象类型。
    2. 对象的对齐要求是 Fundamental Alignment (即 alignof(max_align_t))。
    3. 对象的 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) 优先级有高低之分。

1760704181772

2. 基础表达式 (Primary Expression)

基础表达式包括:

  1. 标识符 (Identifier): 包括对象标识符和函数标识符。
  2. 常量 (Constant)
  3. 字符串 (String Literal)
  4. 括号表达式 (Parenthesized Expression)
  5. 泛型选择 (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,分为两个过程:

  1. Value Computation:
    • 规则 1.1 (lvalue conversion): 如果 lvalue 定位的对象是 非数组类型,则 rvalue 就是该对象的 对象值rvalue 类型是该对象类型的非限定类型
    • 规则 1.2 (Decay/退化): 如果 lvalue 定位的对象是 数组类型,则 rvalue 就是该数组 第一个元素的首地址rvalue 类型是元素对象类型对应的指针类型
    • 简单来说就是得到Value和V_T
  2. Side Effect: 环境状态的改变

lvalue 规则示例

  • 非数组: int a = 0;
    • alvalue,定位 int 类型对象。
    • evaluate(a) 结果:rvalue0rvalue typeint
  • 数组: int b[1] = {};
    • blvalue,定位 int[1] 类型对象。
    • evaluate(b) 结果:rvalue 是数组首地址 Addressrvalue typeint*
  • 高维数组: int d[1][1] = {};
    • dlvalue,定位 int[1][1] 类型对象。
    • evaluate(d) 结果:rvalue 是首个元素(即 d[0])的地址,rvalue typeint (*)[1]

5. lvalue 做 evaluate 和不做 evaluate 的情况

1)定位非数组对象的 lvalue

给定一个定位非数组对象lvalue 表达式 exp,在以下情况 不做 evaluate

  1. 作为 sizeof 的操作数: sizeof(exp) -> <size, size_t>
  2. 作为 typeof 的操作数: typeof(exp) -> Obj_T
  3. 作为 & (取地址) 的操作数: &exp -> Addr, Obj_T*
  4. 作为 ++/-- (前置/后置) 的操作数: ++exp, exp++
  5. 作为 . (成员访问) 的左操作数: exp.member
  6. 作为赋值运算符的左操作数: exp = ...
    除以上情况外,都要做 evaluate

但作为整体,其被视为一个表达式,仍然要做evaluate

  • &a一元表达式:取<Addr, Obj_T*>;没有副作用
  • ++a<Value incremented by 1, V_T>;副作用将Value+1
  • a++<Value, V_T>;副作用将Value+1

2)定位普通数组对象的 lvalue

给定一个定位普通数组对象lvalue 表达式 exp,在以下情况 不做 evaluate

  1. 作为 sizeof 的操作数: sizeof(exp) -> <size, size_t>
  2. 作为 typeof 的操作数: typeof(exp) -> Obj_T[size]
  3. 作为 & (取地址) 的操作数: &exp -> <Addr, Obj_T(*)>
  4. lvalue 是一个 string literal,且用于初始化一个字符数组时: char str[] = "hello";此时不会退化成数组,而是作为初始化器填充str的内存
    除以上情况外,都要做 evaluate (即数组退化)。
  • 注意:Obj_T*指将Obj_T派生为其指针类型,应遵循派生规则。如int c[1][1]Obj_Tint[1][1]Obj_T*int(*)[1][1]

  • 另外,对于sizeof(c+0),由于c+0并不是能直接定位普通数组对象的lvalue,因此需要对c+0进行evaluate

    1
    2
    3
    int 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 满足以下任一条件:
    1. **T 是数组类型。**例如int b[1]中的b不能b++b=NULL
    2. Tconst 限定类型。
    3. T 是不完全对象类型。
    4. T 是结构体/联合体,且其成员含有 const 限定类型。
  • 其他 lvalue 都是 可修改左值

第三部分:常量 (Constant)

1. 常量分类

C语言包括5种常量:

  1. 整数常量 (integer-constant)
  2. 浮点数常量 (floating-constant)
  3. 枚举常量 (enumeration-constant)
  4. 字符常量 (character-constant)
  5. 预定义常量 (predefined-constant) (C23)

2. 整数常量

  • 组成: 进制前缀 + 数字序列 + 类型后缀

  • 进制前缀:

    • 0b0B: 二进制
    • 0: 八进制
    • 0x0X: 十六进制
    • 无: 十进制
  • 类型后缀: 决定整数常量的类型。

    1760705005771

    例如对于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 类型。
    • fF: float
    • 无: double
    • lL: long double
    • +5.0是一元表达式

4. 枚举常量

  • enum 定义中的成员,例如 enum Season {Spring, Summer}; 中的 SpringSummer
  • 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. 字符串的蕴含语义

  1. 一个字符串意味着 分配(或重用)一个字符数组对象
  2. 字符串本身是一个有效的 lvalue 表达式
  3. 这个 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 的蕴含语义

  1. 意味着 分配一个指定类型的对象
  2. 本身是一个有效的 lvalue 表达式
  3. 这个 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. 地址常量的概念

地址常量指的是:

  1. 空指针。
  2. 指向一个具有 static storage duration 的对象的指针。
  3. 指向函数指示符 (Function Designator) 的指针。

3. 地址常量的获得方法

rvalue 是地址常量的情况:

  1. 显式使用 & 作用于一个 static 对象。
  2. 显式将整数常量强转为指针。
  3. 隐式使用数组类型表达式获得的 rvalue (数组退化)
  4. 隐式使用函数类型表达式获得的 rvalue
  • 示例:
    1
    2
    static int z[1];
    int* p = z;
    • zevaluate 后的 rvalue 是地址常量 (方法3)。
    • pevaluate 后的 rvalue 不是地址常量,因为 p 本身是 automatic 对象,它的值在运行时才被读取。

第七部分:指针与数组

1. *exp 形式的 lvalue

  • 语义: *exp 本身是一个 lvalue。它的作用是 定位一个对象
  • 定位过程:
    1. 对表达式 exp 进行 evaluate,获得其 rvalue,形如 <Value, Value_Type>
    2. 如果 Value_Type 是一个对象指针类型,那么 *exp 就定位到一个新的对象 M。
    3. 对象 M 的 Address*expValue
    4. 对象 M 的 Object Type*expValue_Type 对应的 被引用类型 (Referenced Type)。 (例如,int** 的被引用类型是 int*)。
  • a vs *p: 在 int a=1; int* p=&a; 的上下文中,a*p 都是 lvalue,它们定位到同一个对象,且对象类型相同,因此二者在行为上完全一致。

&a形式

整个&aevaluate值为<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. 数组名与指针的异同

  • e vs e[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): 0x0082FB30
    • Obj_T (Object Type): int[2][3]
    • N (Name): g
    • S (Size): 24
    • V (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 加上偏移 1g+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
2
3
4
5
6
7
8
int n[2][3][4][5];

&n: <addr, int(*)[2][3][4][5]>; &n+1: <addr + 2*3*4*5*4, int(*)[2][3][4][5]>;
n: <addr, int(*)[3][4][5]>; n+1: <addr + 3*4*5*4, int(*)[3][4][5]>;
n[0]: <addr, int(*)[4][5]>; n[0]+1: <addr + 4*5*4, int(*)[4][5]>;
n[0][0]: <Addr, int(*)[5]>; n[0][0]+1: <Addr + 5*4, int(*)[5]>;
n[0][0][0]: <Addr, int*>; n[0][0][0]: <Addr + 4, int*>;
n[0][0][0][0]: <0, int>; n[0][0][0][0]: <1, int>;

4. 深入分析六个赋值例子

给定 int g[2][3] = {0};,目标是修改 g[1][2] 的值。

  1. g[1][2] = 1;
    • 过程: 按照标准定义,g 退化为 int(*)[3] 指针,计算 g+1 得到 g[1] 的地址。g[1] 退化为 int* 指针,计算 g[1]+2 得到 g[1][2] 的地址。最终定位到一个 int 对象,它是可修改左值,赋值成功。
  2. (*(&g))[1][2] = 2;
    • &grvalue 类型是 int(*)[2][3]
    • g不做evaluate
    • *(&g) 解引用,得到一个 lvalue,该 lvalue 定位的对象就是 g 本身 (int[2][3])。
    • 后续操作与 g[1][2] 完全相同。
  3. (&(*g))[1][2] = 3;
    • g 退化为 int(*)[3] 指针。
    • *g 定位到 g[0] 这个 int[3] 数组对象。
    • &(*g)g[0] 的地址,rvalue 类型是 int(*)[3],其值与 g 退化后的值相同。
    • 后续操作也与 g[1][2] 完全相同。
  4. (g+1-1)[1][2] = 4;
    • g 退化为 int(*)[3] 指针。
    • g+1-1 经过指针运算,结果的 rvalue 仍然和 g 退化后的 rvalue 相同。
    • 后续操作与 g[1][2] 完全相同。
  5. 1[g][2] = 5;
    • 1[g] 等价于 *(1+g),也等价于 *(g+1),即 g[1]
    • 所以 1[g][2] 等价于 g[1][2],赋值成功。
  6. 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. 直接分配连续空间 (不推荐):

    1
    2
    // 分配一个 int[2][3][4] 的空间
    int (*p)[3][4] = malloc(sizeof(int[3][4]) * 2);
    • 缺点: 数组的维度 34 需要硬编码在类型中,扩展性差。
  2. 模拟高维数组 (常见方法):

    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)
  • 原则: mallocfree 的调用次数必须匹配。
  • free(p); 函数不需要指定大小,因为内存管理器在分配时已经记录了相关信息。

第十部分:到底什么是 C 语言中的指针 (Pointer)

  • C 语言标准中没有对 “pointer” 的一个显式定义。
  • 本质: 指针就是 rvalue 的一种。
    • 任何表达式,在 evaluate 之后如果得到的 rvalue 的类型是一个 pointer type(指针类型),那么这个 rvalue(形式为 <address, pointer type>)就是我们常说的指针。
    • 这个表达式就可以被用来指向 (point to) 一个对象或函数。
  • 常见误解:
    • “指针就是地址” (不完全,指针还包含类型信息)。
    • “指针是一个变量” (不准确,int* pp 是一个对象,而对 p求值后得到的 rvalue 才是指针)。
  • 正确理解: C 语言的指针是一系列概念(对象、表达式、lvalueevaluatervalue)的体系化应用。

第十一部分:进一步理解 const 限定符

1. Qualified Type (限定类型)

  • 对象类型可以附加 const, volatile, restrict 等限定符,从 unqualified type 变为 qualified type
  • 对于非数组类型: Obj_T constconst 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: int
    • lvalue a 成为不可修改左值。
  • 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 volatileV_T: int
    • volatile int a;
  • 核心含义: 阻止编译器进行可能影响变量访问的优化。每次代码访问该变量时,都必须从其内存地址重新读取,而不是使用寄存器中可能存在的旧值。
  • 对于volatile对象的读取被视为一种Side Effect

2. volatile 的应用场景

volatile 主要用于以下几种情况:

  1. 内存映射的硬件端口 (MMIO - Memory-Mapped Input/Output): 当一个内存地址实际上对应一个硬件寄存器时(如状态寄存器、数据端口),硬件可以随时改变该地址的值。程序必须使用 volatile 来确保每次都读取到硬件的最新状态。

    • 示例: 定义一个指向特定硬件地址的 volatile 指针。
      1
      #define MY_HARDWARE_REGISTER (*(int volatile*)0x12340000)
  2. 由中断服务程序修改的全局变量: 在主程序和一个中断服务程序(ISR)之间共享的变量。当中断发生时,ISR可能会修改该变量,主程序需要能感知到这个“意料之外”的改变。

  3. 多线程应用中共享的变量: 当多个线程共享一个变量时,一个线程的修改对另一个线程来说是异步的。volatile 可以确保线程看到其他线程对变量的修改(注意:volatile 本身不保证原子性或线程间的同步,这需要互斥锁等机制)。

3. volatile 与编译器优化

  • volatile 的情况:

    1
    2
    3
    4
    int status = 10;
    while (status > 20) {
    // ... do something
    }

    编译器看到 status 初始值为10,且在循环前没有代码修改它,可能会认为 status > 20 永远为假,从而将整个 while 循环优化掉。

  • volatile 的情况:

    1
    2
    3
    4
    volatile int status = 10;
    while (status > 20) {
    // ... do something
    }

    编译器被告知 status 的值可能随时改变,因此它不会优化掉循环。在每次循环判断时,都会生成从内存中重新加载 status 值的指令。

4. volatile 指针

1
2
int volatile a = 10;
int volatile* p = &a;

此时p的Obj_Tint 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
    4
    volatile int a = 10;
    // 错误且危险的做法!
    int* p = (int*)&a;
    *p = 20; // 未定义行为

第十三部分:求值、副作用与序列点

1. 求值与副作用

  • a=ba=b的副作用,即赋值,发生在b的求值之后
  • a=b+c:b和c的求值没有先后顺序要求,但必须先于b+c
  • a=b++b++的副作用和a=b++的求值没有先后顺序

2. 未定义行为 (Undefined Behavior - UB)

C语言标准中,许多表达式的求值顺序是未指定的,如:

  1. 产生两次副作用且两次副作用没有先后顺序要求
  2. 产生的副作用和同样标量对象取值之间没有先后顺序要求
  • 经典错误示例: 这些表达式的结果完全不可预测,在不同编译器、不同优化级别下可能完全不同。在实际工程中严禁使用!

    1
    2
    3
    4
    5
    int 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语言标准规定了多个序列点:

  1. 语句结束的分号 (;): 这是最常见的序列点。
    • 示例: i=1; i++; i++; 是完全合法的,执行后 i 的值确定为3。
  2. 逻辑与 &&、逻辑或 || 运算符: &&|| 存在一个序列点。它们保证从左到右求值,并实现“短路”行为。
    • 示例: a++ && a++ (假设a初值为0)。第一个 a++ 求值为0(假),a 变为1。因为短路,第二个 a++ 不会执行。整个表达式为假,最终 a 的值为1。
  3. 逗号运算符 (,): 逗号运算符(注意,不是函数参数或声明中的逗号分隔符)引入一个序列点。
    • 示例: b = (a++, a++) (假设a初值为0)。第一个 a++ 执行,a 变为1。然后第二个 a++ 执行,a 变为2。整个逗号表达式的值是右侧表达式的值(此时a是1,a++后增),最终 b 被赋值为1,a 为2。
  4. 三目运算符 ? :: 在 ? 之后有一个序列点。
    • 示例: a++ ? a++ : a++ (假设a初值为0)。第一个 a++ 求值为0(假),a 变为1。然后执行 : 后的 a++a 最终变为2。
  5. 函数调用: 在所有函数参数求值完毕后、进入函数体执行前,有一个序列点。但是,函数参数之间的求值顺序是未指定的
    • 示例: printf("%d %d\n", a++, a++);未定义行为,因为两个 a++ 副作用的顺序不确定。两个a++之间的逗号不是逗号运算符!!
  6. scanf("%d %d", &b[0], &b[0]);:每处理完一个 %d 并写入变量后,有一个序列点。
  7. ·qsort/bsearch 的比较函数:调用比较函数前后都有序列点;比较函数返回后、对象移动前也有序列点。

第十四部分:函数、函数类型与函数指针

函数类型 (Function Type)

一个函数本身是有类型的,由它的返回值类型参数列表(参数数量和类型)共同决定。

函数类型是由其返回值类型派生而来的。返回值类型必须是非数组对象类型或void。派生规则如下:

1761483317426

  • 示例: 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仅有两种:

  1. Function identifier
  2. 如果一个表达式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
2
3
4
5
6
7
8
9
10
11
int func(int a, int b) { return a + b; }
int (*p)(int, int) = func; // p是一个函数指针

// 以下调用全部合法
int result1 = func(2, 3);
int result2 = (*func)(2, 3); // *func 求值得到 func, 再求值为函数指针
int result3 = (**func)(2, 3); // 多次解引用同样合法
int result4 = p(2, 3);
int result5 = (*p)(2, 3);
int result6 = (**p)(2, 3);
int result7 = (&func)(2, 3); // &func 求值为函数指针

非法的调用方式

  • (&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(按值传递)。
  • 传递过程:
    1. 获得实参 exp 的返回值 <V, V_T>(值和类型)。
    2. 将值 V 写入形参 Argument_Name 对应的内存。
    3. V_TArgument_Type 必须适配。

2. 非数组对象类型 Lvalue 作为函数参数 (Slide 4)

  • 示例: int a = 10; func(a);,形参 int pa
    1. 实参 a 表达式获得的是对象 a 对应内存的表示值,即 <10, int>
    2. 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.am.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。
  • 内存确定: 数组的长度 (Mrvalue) * sizeof(T) 必须在 运行时 才能确定。
  • 判断元素类型是否为VLA:
    • int a[m]:一维数组,大小为m,元素类型为int
    • int 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];都不是VLA
    • int 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),像常量一样使用。
  • 限制:
    1. 不能包含赋值、自增/自减、函数调用、逗号运算符(除非包含在不被求值的子表达式中)。
    2. 求值后 rvalue 的取值范围,应在表达 rvalue 类型的表征范围之内。

预备知识 (Slide 22, 23, 24)

  • constexpr (C23): 声明的变量是常量表达式,被称为 named constant
  • truefalsenullptr 也是 named constant
  • constexpr 修饰的 Compound Literal 被称为 Compound Literal Constant

3. 整数常量表达式 (Slide 21, 25)

  • 条件:

    1. 表达式 rvalue 类型为 整数类型
    2. 在编译时进行求值
  • 操作数 (Operand) 来源: 整数常量、字符常量、类型为整数的 named constant、类型为整数的 compound literal constantsizeof 表达式、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-nameexp 两种 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,则:

    1. sizeof(non-lvalue) 返回这个 non-lvalue 求值后 rvalue 类型的大小。
    2. 这个 non-lvalue 并不会真的做求值
  • 举例说明:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    int 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
2
3
4
5
6
void f1(int a[1][2]) {
sizeof(a[++i]); //不对++i求值, i = 1
}
void f2(int a[m][n]) {
sizeof(a[++i]); //对++i求值, i = 2
}

size、padding 和 alignment

1. size 和 padding (Slide 49-54)

  • size 内涵: sizeof(T) 是分配空间所需的字节数;sizeof(O) 是对象占用的字节数。
  • 无符号整数的 padding '_' allowed only in math moden \times \text{CHAR_BIT} bit 位分为 value bitspadding bitsunsigned char 不允许padding bits
  • 有符号整数的 padding '_' allowed only in math moden \times \text{CHAR_BIT} bit 位分为 sign bitvalue bitspadding bitssigned 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) 获得类型 TAlignment
  • 实现相关性: 对齐与编译器、硬件系统紧密相关。
  • 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
2
3
4
5
6
7
8
9
_Alignas(64) struct stru {
char a;
_Alignas(32) short b;
int c[10];
double d;
}s;

_Alignof(struct stru) // 32,结构体类型对齐要求
_Alignof(s) // 64,结构体对象对齐要求

5. 结构体对象的对齐和 size (Slide 65-73)

  • 结构体类型对齐要求 _Alignof(T)

  • 计算结构体对象 size(涉及 offsetpadding):

    1. 内部对齐(Internal Padding): 确保每个成员 的首地址偏移量 offset 满足其自身的对齐要求
    2. 拖尾对齐(Trailing Padding): 确保结构体总大小 sizeof(T) 满足结构体类型自身的对齐要求
1
2
3
4
5
6
_Alignas(64) struct stru {
char a; // offset = 0, sizeof(char) = 1
_Alignas(32) short b; // offset = 0 + sizeof(char) -> 1 align 32 = 32, sizeof(short) = 2
int c[10]; // offset = 32 + sizeof(short) -> 34 align 4 = 36, sizeof(int[10]) = 40
double d; // offset = 36 + sizeof(int[10]) -> 76 align 8 = 80, sizeof(double) = 8
} s; // offset = 80 + sizeof(double) = 88 align (alignof(struct stru)) = 80 align 32 = 96
  • #pragma pack(n) 调整:
    • 成员对齐要求
    • 结构体类型 调整后成员中的最大值。
    • pack(n) 针对结构体内部成员,结构体对象本身不受影响。
1
2
3
4
5
6
7
8
9
10
#pragma pack(1)

_Alignas(64) struct stru {
char a; // offset = 0, sizeof(a) = 1
_Alignas(32) short b; // offset = 0 + sizeof(char) -> 1 align 1 = 1, sizeof(b) = 2
int c[10]; // offset = 1 + sizeof(short) -> 3 align 1 = 3, sizeof(c) = 40
double d; // offset = 3 + sizeof(int[10]) -> 43 align 1 = 43, sizeof(d) = 8
} s; // offset = 43 + sizeof(double) = 51 align (alignof(struct stru)) = 51 align 1 = 51

// _Alignof(s.a) = 1, _Alignof(s.b)=1, _Alignof(s.c) =1, _Alignof(s.d) = 1, _Alignof(struct stru) = 1, _Alignof(s) = 64

6. 指针对齐问题 (Slide 80, 81)

  • malloc 返回的指针满足 基础对齐要求
  • 如果转换后的指针对应的对象对齐方式不正确,则指针的强制转换行为是 未定义行为

对象的存储周期 (Storage Duration)

  • 定义:任何对象都有生命周期 (lifetime),决定生命周期的就是对象的 Storage Duration
  • 生命周期内
    1. 系统确保对象的内存有效。
    2. 对象的地址是 constant address,地址不变。
    3. 对象保有最后赋的值 (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种Scopefunction, file, block, function prototype
    • Function Scope: label name (标签名) 是唯一具有函数作用域的标识符。
    • Function Prototype Scope: 出现在函数原型参数列表中的标识符,其作用域在函数声明符的末尾终止。
    • File Scope: 出现在任何块或参数列表之外的标识符声明。其作用域在翻译单元 (translation unit) 的末尾终止。
    • Block Scope: 出现在块内或函数定义的参数列表中的标识符声明。其作用域在相关块的末尾终止。

Linkage (链接)

  • 定义:一个机制,用于分辨出现在不同地方的相同标识符是否表示同一个实体。
  • 3种Linkageexternal, internal, none
  • no linkage
    1. 声明为对象或函数之外的任何东西的标识符。
    2. 声明为函数参数的标识符。
    3. 块作用域中未使用 extern 声明的对象的标识符。
    • no linkage 的标识符都指向不同的对象。
  • external linkage
    • 如果一个标识符用 extern 声明,并且之前没有可见的声明,或者之前的声明是 no linkage,则该标识符具有 external linkage
  • Internal Linkage
    • 在文件作用域 (file scope) 中声明的对象或函数的标识符,如果包含了 static 说明符,则具有 internal linkage
  • 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.
Comments