[+dereference]: 解引用操作符 (*) 获取指针所指向内存地址中存储的值。

[+reference]: 引用操作符 (&) 返回变量的内存地址。

Info

这篇文章来自我学习并行计算时整理的一组探索性笔记。它更像是一篇 speculative learning note,而不是 C 语言形式语义。我把 C 指针当作入口,用简单的数学语言描述内存地址相关的直觉:函数、组合、地址空间、内存层级和分布式映射。

引言

让我们来看一个经典的C编程问题:

int AValue = 101;
int *BValue = &AValue;
// 为什么 *BValue 会返回 101?

表面上看,这个问题很简单。真正有意思的是它背后藏着什么样的映射。虽然我们以 C 指针为起点,但类似的思考方式也会出现在虚拟内存、缓存层级、分布式存储和其他类似内存的系统里。

下面的数学语言应该被理解为建模直觉。有些等式被刻意简化,是为了先看清结构,再把真实系统里的边界条件加回来。

数学内存模型

让我们将计算机内存建模为一个数学函数。定义:

M:NZM: \mathbb{N} \rightarrow \mathbb{Z}

其中:

  • M(a)M(a) 表示存储在内存地址 aa 处的值
  • 每个变量都有唯一的内存地址
  • N\mathbb{N} 表示自然数集(内存地址)
  • Z\mathbb{Z} 表示整数集(可能的值)

这个函数模型只是第一层近似,但它给了我们一个讨论指针的起点,之后可以再推广到更抽象的内存映射框架。

变量和指针定义

内存地址分配

定义:

  • aANa_A \in \mathbb{N} 为变量 AValue 的内存地址
  • aBNa_B \in \mathbb{N} 为变量 BValue 的内存地址

引用和解引用操作符

引用操作符 &\& 和解引用操作符 * 可以数学定义为:

&x=ax(返回变量 x 的地址)\&x = a_x \quad \text{(返回变量 x 的地址)}

p=M(p)(返回地址 p 处的值)*p = M(p) \quad \text{(返回地址 p 处的值)}

这些定义以数学术语捕捉了指针操作的本质。

逐步数学分析

让我们使用这个数学框架来追踪C代码示例。

步骤1:初始赋值

int AValue = 101;

这个操作修改了我们的内存函数: M(aA)=101M(a_A) = 101

步骤2:指针赋值

int *BValue = &AValue;

这将 aBa_B 处的值设置为 AValue 的地址: M(aB)=aAM(a_B) = a_A

步骤3:解引用操作

现在,当我们计算 *BValue 时: BValue=M(M(aB))*\text{BValue} = M(M(a_B))

代入步骤2的结果: BValue=M(aA)*\text{BValue} = M(a_A)

代入步骤1的结果: BValue=101*\text{BValue} = 101

函数组合分析

引用和解引用操作符提示了一种有用的数学关系。

函数定义

定义引用函数:R:VAR: V \rightarrow A,其中 R(x)=axR(x) = a_x

定义解引用函数:D:AVD: A \rightarrow V,其中 D(p)=M(p)D(p) = M(p)

映射性质

  1. 前向映射(引用): R:VAR: V \rightarrow A

    • 将变量值映射到它们的内存地址
    • R(x)=axR(x) = a_x(变量 x 的地址)
  2. 逆向映射(解引用): D:AVD: A \rightarrow V

    • 将内存地址映射到它们的存储值
    • D(p)=M(p)D(p) = M(p)(地址 p 处的值)

受限的逆函数直觉

在这个 toy model 里,如果对象是一个已经分配好的命名变量,那么“取地址之后再解引用”会回到它存储的值:

D(R(x))=M(ax)=xD(R(x)) = M(a_x) = x

反过来的方向要脆弱得多:

R(D(p))=aM(p)=pR(D(p)) = a_{M(p)} = p

第二个等式不是 C 语言中的一般规律。它只在一种过度简化的模型里成立:每个值都可以当作一个唯一可寻址的变量。真实程序里,两个位置可以存储相同的值,临时值可能没有稳定地址,无效指针还会导致 undefined behavior。这里保留这个符号,是因为它能提供第一层直觉,但不能当作完整证明。

双射关系

如果想让引用和解引用在某个模型里表现得像真正的逆函数,就需要非常强的限制条件:

  1. 单射(一对一):

    • 如果 R(x1)=R(x2)R(x_1) = R(x_2),那么 ax1=ax2a_{x_1} = a_{x_2},所以 x1=x2x_1 = x_2
    • 如果 D(p1)=D(p2)D(p_1) = D(p_2),那么 M(p1)=M(p2)M(p_1) = M(p_2),所以 p1=p2p_1 = p_2
  2. 满射(到上):

    • 对于每个地址 aAa \in A,存在变量 xVx \in V 使得 R(x)=aR(x) = a
    • 对于每个值 vVv \in V,存在地址 pAp \in A 使得 D(p)=vD(p) = v

组合恒等式

在这些限制条件下,toy model 会得到: DR=IV(值上的恒等函数)D \circ R = I_V \quad \text{(值上的恒等函数)} RD=IA(地址上的恒等函数)R \circ D = I_A \quad \text{(地址上的恒等函数)}

逆关系可以写成: R1=DD1=RR^{-1} = D \quad \text{和} \quad D^{-1} = R

因此: R1(R(x))=xD1(D(p))=pR^{-1}(R(x)) = x \quad \text{和} \quad D^{-1}(D(p)) = p

作为 sketch 的范畴论语言

一种可能的内存范畴

如果要把这套直觉进一步形式化,一个可能的起点是用范畴论+category-theory语言描述内存系统:

  • 对象:内存空间 (A,V,M)(A, V, M),其中 AA 是地址空间,VV 是值空间,M:AVM: A \rightarrow V 是内存映射
  • 态射:内存空间之间的内存保持函数
  • 组合:内存映射的函数组合

这仍然只是 sketch。真正困难的地方在于:态射到底要保持什么结构?原始地址、分配区域、权限、page identity、时间状态,还是它们的某种组合。

一种可能的内存函子 sketch

在这些范畴定义清楚之后,一个 toy construction 可以写得像一个函子 M:SetMem\mathcal{M}: \mathbf{Set} \rightarrow \mathbf{Mem}

  • Set\mathbf{Set} 是集合和函数的范畴
  • Mem\mathbf{Mem} 是某个已经选定的内存空间与结构保持态射的范畴
  • M\mathcal{M} 将集合 SS 映射到内存空间 (S,S,idS)(S, S, \text{id}_S)

这里的重点不是说这就是内存的正确语义,而是用一种紧凑方式追问:当我们从一种内存表示移动到另一种内存表示时,到底保留了什么结构?

谨慎的范畴论类比

很容易想用范畴论语言来描述 reference 和 dereference,但这里必须小心。在这篇笔记里,RRDD 只是 toy memory model 里的普通映射。它们不会自动变成函子,这篇文章也没有证明一个真正的伴随关系。

更稳妥的类比是:

  • RR 描述一个命名值如何关联到地址。
  • DD 描述一个地址如何被读回为值。
  • DRD \circ R 描述了在强限制条件下”取地址,然后从该地址读取”的模式。

只有在我们先定义清楚 memory state 的范畴,以及它们之间保持结构的态射之后,范畴论语言才真正有意义。这里我只把它当作直觉,而不是定理。

内存空间的拓扑学

作为拓扑空间的内存

我们可以用拓扑学+topology来赋予地址空间 AA 反映内存结构的拓扑:

  • 开集:表示可访问的内存区域
  • 闭集:表示受保护或保留的内存区域
  • 连续性:保持可访问性的内存操作

度量性质

在地址空间上定义度量 d:A×ARd: A \times A \rightarrow \mathbb{R}

d(a1,a2)=a1a2d(a_1, a_2) = |a_1 - a_2|

这个度量在 N\mathbb{N} 上诱导标准拓扑,其中:

  • Br(a)={xA:xa<r}B_r(a) = \{x \in A : |x - a| < r\} 表示内存邻域
  • 连续性:对于原始存储值来说,这通常不是现实假设;它更适合作为保持可访问性或布局关系的类比

紧性和内存分配

  • 紧集:可以完全分配的有限内存区域
  • 连通分量:连续的内存块
  • 分离性质:内存保护和隔离

同胚和内存重映射

内存重映射 f:AAf: A \rightarrow A'同胚,如果:

  • ff 是双射的
  • fff1f^{-1} 都是连续的
  • ff 保持内存可访问性关系

这只是地址重映射的粗略类比。真实的虚拟内存翻译是带权限、部分定义、基于 page table 的过程,通常不能严格看作拓扑意义上的同胚。

内存中的代数结构

局部 offset arithmetic,而不是真实地址代数

说真实程序里的地址空间形成阿贝尔群或环,会太强。C 指针运算受类型约束,也受已分配对象边界约束;越界之后可能变成 undefined behavior。空指针也不是普通地址加法里的单位元。

更安全的模型是局部 offset arithmetic。在一个已经分配好的 array-like 区域内,地址可以写成:

ai=a0+isizeof(T)a_i = a_0 + i \cdot \text{sizeof}(T)

这个局部模型继承了一些熟悉的算术规律,但只在有效区域内成立,而且依赖具体的元素类型。它适合帮助我们理解 layout,而不是作为所有内存地址的全局代数。

内存模型间的同态

内存同态 ϕ:M1M2\phi: M_1 \rightarrow M_2 保持代数结构:

ϕ(a+b)=ϕ(a)+ϕ(b)\phi(a + b) = \phi(a) + \phi(b) ϕ(ab)=ϕ(a)ϕ(b)\phi(a \cdot b) = \phi(a) \cdot \phi(b)

这只有在所选映射确实保持了我们关心的结构时,才能用来描述内存抽象层,例如 offset、权限或 page identity。

代数证明

给定赋值 BValue=&AValue\text{BValue} = \&\text{AValue},我们可以证明:

BValue=(&AValue)=D(R(AValue))=AValue=101*\text{BValue} = *(\&\text{AValue}) = D(R(\text{AValue})) = \text{AValue} = 101

这个 toy calculation 展示了为什么这个简单指针例子会按预期工作。

内存映射可视化

内存映射可以表示为:

aA101aBaA\begin{align*} a_A &\mapsto 101 \\ a_B &\mapsto a_A \end{align*}

因此: BValue=M(M(aB))=M(aA)=101*\text{BValue} = M(M(a_B)) = M(a_A) = 101

Info

这个双重解引用模式 M(M(aB))M(M(a_B)) 是理解指针行为的基础。第一个 MM 获取指针中存储的地址,第二个 MM 获取该地址处的值。

实际应用

指针别名

当两个指针指向相同的内存位置时,我们有: M(ap1)=M(ap2)=axM(a_{p1}) = M(a_{p2}) = a_x

这意味着: p1=p2=M(ax)*p1 = *p2 = M(a_x)

指针运算

指针运算可以建模为: p+n=ap+n×sizeof(type)p + n = a_p + n \times \text{sizeof}(type)

其中 sizeof(type)\text{sizeof}(type) 是类型的大小(以字节为单位)。

空指针

空指针可以表示为: anull=0a_{null} = 0

具有特殊性质: M(0)=未定义M(0) = \text{未定义}

常见指针模式

函数指针

函数指针扩展了我们的模型: M(af)=函数地址M(a_f) = \text{函数地址}

af=函数执行*a_f = \text{函数执行}

双重指针

双重指针(指向指针的指针)创建嵌套映射: M(app)=apM(a_{pp}) = a_p

M(ap)=axM(a_p) = a_x

pp=M(M(app))=M(ap)=ax**pp = M(M(a_{pp})) = M(a_p) = a_x

内存安全考虑

我们的数学模型有助于解释常见的指针错误:

悬空指针

M(ap)=无效地址M(a_p) = \text{无效地址}

p=M(无效地址)=未定义行为*p = M(\text{无效地址}) = \text{未定义行为}

缓冲区溢出

p+np + n 超出分配的内存边界时: M(ap+n)=未授权内存访问M(a_p + n) = \text{未授权内存访问}

通用内存映射理论

抽象内存映射框架

让我们推广到初始简单模型之外。内存映射系统是一个元组 M=(A,V,M,O)\mathcal{M} = (A, V, M, \mathcal{O}),其中:

  • AA:地址空间(可能具有结构)
  • VV:值空间(可能具有结构)
  • M:AVM: A \rightarrow V:内存映射函数
  • O\mathcal{O}:内存操作集合

内存映射分类

按单射性

  • 单射M(a1)=M(a2)a1=a2M(a_1) = M(a_2) \Rightarrow a_1 = a_2(无别名)
  • 非单射:允许多个地址映射到相同值

按满射性

  • 满射vV,aA:M(a)=v\forall v \in V, \exists a \in A : M(a) = v(完全覆盖)
  • 非满射:某些值无法存储

按组合性质

  • 幂等MM=MM \circ M = M
  • 对合MM=idM \circ M = \text{id}
  • 幂零n:Mn=0\exists n : M^n = 0

内存映射组合

给定两个内存系统 M1=(A1,V1,M1,O1)\mathcal{M}_1 = (A_1, V_1, M_1, \mathcal{O}_1)M2=(A2,V2,M2,O2)\mathcal{M}_2 = (A_2, V_2, M_2, \mathcal{O}_2),我们可以定义:

直和

M1M2=(A1A2,V1V2,M1M2,O1O2)\mathcal{M}_1 \oplus \mathcal{M}_2 = (A_1 \sqcup A_2, V_1 \sqcup V_2, M_1 \sqcup M_2, \mathcal{O}_1 \sqcup \mathcal{O}_2)

张量积

M1M2=(A1×A2,V1×V2,M1×M2,O1×O2)\mathcal{M}_1 \otimes \mathcal{M}_2 = (A_1 \times A_2, V_1 \times V_2, M_1 \times M_2, \mathcal{O}_1 \times \mathcal{O}_2)

函数组合

如果 V1=A2V_1 = A_2,那么: M2M1=(A1,V2,M2M1,O2O1)\mathcal{M}_2 \circ \mathcal{M}_1 = (A_1, V_2, M_2 \circ M_1, \mathcal{O}_2 \circ \mathcal{O}_1)

内存映射分解

内存映射 M:AVM: A \rightarrow V 可以分解为:

M=πVιAM = \pi_V \circ \iota_A

其中:

  • ιA:AA×V\iota_A: A \rightarrow A \times V 是包含映射
  • πV:A×VV\pi_V: A \times V \rightarrow V 是投影映射

这个分解更多是在提醒我们:一次内存读取可以通过一个更丰富的配对表示来分解。除非先明确周围的范畴和态射,否则我不会把它当成真正的泛性质。

可选的泛性质 sketch

在更形式化的展开里,我们可以问:是否存在由地址集合 AA 生成的某种“自由”的 memory-like construction F(A)F(A)?如果存在,它需要满足类似这样的泛性质:

M:AV,!M~:F(A)V 使得 M=M~ηA\forall M: A \rightarrow V, \exists ! \tilde{M}: F(A) \rightarrow V \text{ 使得 } M = \tilde{M} \circ \eta_A

其中 ηA:AF(A)\eta_A: A \rightarrow F(A) 把地址嵌入到生成结构里。这篇笔记没有真的构造这个范畴,只是记录这个问题的形状。

内存映射范畴

如果这样的范畴 Mem 被定义出来,它可以包含:

  • 对象:内存映射系统
  • 态射:内存保持函数
  • 组合:函数组合

只有在定义之后,我们才能继续问它是否具有:

  • :内存系统的直积
  • 余积:内存系统的不相交并
  • 指数对象:内存系统之间的函数空间

多级内存层次结构

分层内存系统

内存层次结构是一个序列 H=(M1,M2,,Mn)\mathcal{H} = (\mathcal{M}_1, \mathcal{M}_2, \ldots, \mathcal{M}_n),其中:

  • M1\mathcal{M}_1:第1层(最快,最小)
  • Mn\mathcal{M}_n:第n层(最慢,最大)
  • 传输函数 Ti,j:MiMjT_{i,j}: \mathcal{M}_i \rightarrow \mathcal{M}_j,对于 i<ji < j

性能指标

定义访问时间函数 t:HR+t: \mathcal{H} \rightarrow \mathbb{R}^+

t(Mi)=ti(第 i 层的基本访问时间)t(\mathcal{M}_i) = t_i \text{(第 i 层的基本访问时间)}

对于访问模式 PP有效访问时间

Teff(P)=i=1nhi(P)tiT_{eff}(P) = \sum_{i=1}^n h_i(P) \cdot t_i

其中 hi(P)h_i(P) 是模式 PP 在第 ii 层的命中率。

缓存内存数学

对于关联度为 kk 的缓存,缓存命中概率

Phit=i=0k1Plocality(i)P_{hit} = \sum_{i=0}^{k-1} P_{locality}(i)

其中 Plocality(i)P_{locality}(i) 是在 ii 个位置内访问的概率。

最优替换策略

最优替换问题可以表述为:

minRt=1TImiss(t,R)\min_{R} \sum_{t=1}^T \mathbb{I}_{miss}(t, R)

其中 RR 是替换策略,Imiss\mathbb{I}_{miss} 是缺失指示器。

并发内存访问模型

共享内存系统

对于并发访问,我们将模型扩展为:

M:A×TVM: A \times T \rightarrow V

其中 TT 是时间点或线程标识符的集合。

一致性模型

顺序一致性

 操作 o1,o2,o1 在某个全局顺序中出现在 o2 之前\forall \text{ 操作 } o_1, o_2, o_1 \text{ 在某个全局顺序中出现在 } o_2 \text{ 之前}

线性化

每个操作似乎在其调用和完成之间的某个时间点原子地发生。

内存栅栏和同步

内存排序定义为内存操作上的关系:

  • 顺序一致:所有操作的全序
  • 释放获取:通过释放/获取对同步操作
  • 松弛:除了程序顺序外无排序保证

竞争条件分析

数据竞争发生在:

t1t2,aA:write(t1,a)write(t2,a)\exists t_1 \neq t_2, a \in A : \text{write}(t_1, a) \parallel \text{write}(t_2, a)

其中 \parallel 表示无同步的并发执行。

跨领域应用

数据库内存管理

数据库系统使用内存映射进行:

  • 缓冲池BufferPool:DiskPagesMemoryFrames\text{BufferPool}: \text{DiskPages} \rightarrow \text{MemoryFrames}
  • 索引结构B+B^+ 树作为分层内存映射
  • 查询处理:中间结果映射

数据库缓冲池的数学模型可以表示为:

BufferPool:DiskPagesMemoryFrames×{clean,dirty}\text{BufferPool}: \text{DiskPages} \rightarrow \text{MemoryFrames} \times \{\text{clean}, \text{dirty}\}

其中每个内存帧都有一个状态,表示是否需要写回磁盘。

替换策略可以建模为:

R:MemoryFrames×AccessPatternVictimFrameR: \text{MemoryFrames} \times \text{AccessPattern} \rightarrow \text{VictimFrame}

最优替换策略最小化缺页次数:

minRt=1TIpage fault(t,R)\min_{R} \sum_{t=1}^{T} \mathbb{I}_{\text{page fault}}(t, R)

分布式计算

分布式内存系统涉及:

  • 分区Partition:GlobalAddressNodeID×LocalAddress\text{Partition}: \text{GlobalAddress} \rightarrow \text{NodeID} \times \text{LocalAddress}
  • 复制:相同数据的多个映射
  • 一致性:分布式内存一致性协议

分布式系统中的地址映射可以表示为:

GlobalMapping:GlobalAddressNodeID×LocalAddress\text{GlobalMapping}: \text{GlobalAddress} \rightarrow \text{NodeID} \times \text{LocalAddress}

一致性模型定义了多个副本之间的同步规则:

  • 强一致性:所有副本在任何时刻都相同
  • 最终一致性:副本在一段时间后收敛到相同状态
  • 因果一致性:有因果关系的操作按顺序执行

数据复制的数学模型:

Replication:Data{Node1,Node2,,Noden}\text{Replication}: \text{Data} \rightarrow \{\text{Node}_1, \text{Node}_2, \ldots, \text{Node}_n\}

其中每个节点都存储数据的副本。

结论

指针解引用 BValue*\text{BValue} 求值为 101,是因为 BValue 存储了 AValue 的地址,而解引用会沿着这个地址回到那个整数值。在 toy notation 里,真正有用的是这一部分:

D(R(AValue))=AValueD(R(\text{AValue})) = \text{AValue}

这个恒等式是一种教学简写,而不是完整的 C 内存语义。它的价值在于训练一种更通用的思考习惯:先命名地址空间,再命名值空间,然后问二者之间到底是什么映射。

这也是我还愿意保留这篇文章的原因。它不是一个完成的理论,但它指向了一个有用的桥:从低层编程里的 C 指针,到虚拟内存、缓存行为、并发内存和分布式地址空间。