Knighthana
文章110
标签148
分类7

文章归档

返工C基础

返工C基础

返工C基础

记一场面试的耻辱性大败

起因

起因就是做了一段时间的应用层开发,想转去底层巩固护城河;

结果发现对底层数据的敏感性还不如毕业那会了;

早就知道人会自动忘掉不经常接触的东西,没想到看家本领也有一天会忘掉;

多维数组

别记那些复杂规则了,其实就四个字“结合顺序”;

给一个int matrix[3][4][5],它代表着,一个叫matrix的含有三个元素的数组,每个数组里面的元素是4个数组,最里面一层数组里面每个里面有5个int类型的值;

不用记什么里呀外呀左呀右呀的,没什么复杂的规则,看谁离matrix近就行;

鬼畜的*[],论表达式

*这个符号很操蛋;

在声明中的时候,每多一个*就表示多了一层引用,在表达式中的时候,每有一个*,表示解掉一层引用,这倒不是核心的“操蛋问题”;就算十年不写C,也记得这个规矩;

问题是当*[]一起出现的时候,事情就变得难顶了;我知道[]是语法糖,然后呢?

这里先记一个规则,很简单——“结合的优先级”,()大于[]大于*

初阶:当声明的时候写int* p_tiaoshi1[522],注意这个我从导师那继承来的书写习惯,写成int*的意思是在强调:这是一个指向int类型的指针,们,构成的数组;p_tiaoshi1是一个数组,里面有522个指向了int类型变量的地址的指针变量;

一阶:当声明的时候写int (*p_tiaoshi2)[312],含义就变成了,p_tiaoshi2是一个指针,p_tiaoshi2指向了“一”个int [312],也就是1个含有312int类型变量的数组;

二阶:当声明的时候写int ** p_tiaoshi3[301],首先还是用从导师那里学来的习惯,写成int** p_tiaoshi3[301],然后分析含义,根据排序,首先p_tiaoshi3[301]结合,表示这是个301个元素的数组,然后看前面,int**表示每个元素都是两次引用的指针变量,也就是说第一次解引用之后拿到的是一个int*,为了拿到int还需要再解引用一次;

三阶:当声明的时候写成int *p_tiaoshi4[212][318]的时候,含义就变成了,p_tiaoshi4首先是包含了212个对象的数组,其中每个数组里面有318个变量,这212×318个变量都是什么含义呢?就是int*;是的,按照我从导师学来的,写成int* p_tiaoshi4[212][318]的方式就容易看多了;也就是说,每个数组单元的长度是sizeof(void*)

注:

虽然在你我熟悉的现代平台上(32位/64位系统,使用“平坦”内存模型),指针大小确实都一样,但C标准本身允许两者大小不同,只要能保证void*int*之间能安全地相互转换即可。

特殊情况一:存在不同“层级”的指针

这类情况最常见于古老的 16位实模式 x86 平台。由于其内存是“分段式”的,催生了不同尺寸的“近指针”和“远指针”。

特殊情况二:为节省空间而优化

这种情况出现在一些奇特的硬件架构上。对于一些 “字寻址”机器,内存以“字”为单位,其 int 指针就是一个“字地址”。但void*char*作为“字节指针”,还需要额外信息来指示字内的字节位置,因此需要更大的尺寸。

四阶:当声明写成int (*p_tiaoshi5)[22][0x4]的时候呢?按照顺序,*p_tiaoshi5先跟*结合,代表这是一个指针变量,这个指针变量指的什么东西?是int [22][0x4],是一个含有22个元素的大数组,每个元素是[0x4]个int变量的数组,数组的单元长度是sizeof(int)

五阶:当声明写成int *(*p_tiaoshi6)[5][0x1]的时候呢?首先看括号,*p_tiaoshi6被小括号贴在一起,意味着此时分析中的p_tiaoshi6还是一个指针,然后遵循[]优先级比*高的原则,先跟[][]结合,这代表着它指向(引用)的对象是一个5×1的二维数组,然后看左边,这个玩意又套了一层*,这意味着“需要一层引用来拿到值”,int就不说了,所以,p_tiaoshi6是这样一个指针变量:它是一个指针,指向了一个5×1的,外层5个,里层1个的数组;数组里面存的是int*类型的指针,每个数组单元长度是sizeof(void*);(int*void*孰大孰小?上文引用过)

六阶:int (*(*p_gaoshiqing1)[8])[13],这是什么??? 冷静,规则不变:() [] *永恒的顺序; *p_gaoshiqing1结合,意味着它是“一个”指针,指向了“一个”什么东西;这个东西是[8],也就是说,正在分析的此时刻,它是一个指向了8个什么元素构成的数组的指针; 然后继续往外看,被小括号括进来的*,这代表着什么?代表着“修饰符”,被修饰的元素是这个层级的元素,*[8]在同一个级别,因此这8个元素每个元素都是指针,到底指向什么东西? 等着……不要着急,继续往外解析才能拿到它到底指向了什么东西的信息; 最后是外面的int[13],先看后者,后者的含义是,这是13个元素的数组,原来指向的是一个数组啊,那么数组里面是什么东西呢,是intint旁边还有没有*呢?没有了,因此数组里面的元素就是int 因此……这是一个……指针变量,作为内容存储的地址值代表的地址上有一个数组,且这个数组里面有8个元素,每个元素都是指针,这个指针各自只能指向一个包含了13个[int]元素的数组,此时此刻已经无法定义哪个是“基本单元”了,但关系离得最近的是那个8个int(*) [13],它的每个单元长度是sizeof(int*),然后才是这个int [13],它的每个单元长度是sizeof(int)

七阶:int *(*(*p_gaoshiqing1)[8])[13],你猜?嘿嘿嘿;提醒一下,可以写成int* (*(*p_gaoshiqing1)[8])[13]

答案是||p_gaoshiqing1是一个指针,指向一个包含8个元素的数组。这8个元素都是指针,每个指针指向一个包含13个int*的数组。||

这个过程其实还是在构建这个语法的“类型树”,当遇到基本类型符号char short int long double void等基本类型符号的时候,叶子结点就到了;

然后,*其实也是一个类型符号,但是它不作叶子结点;

无论是在声明还是在表达式中,()都具有最高的优先级,这个不必赘述; 而[]的优先级高于*,虽然这是因为后缀运算符[]的优先级天然高于前缀运算符*,但是可以(为了方便记忆)记成[] > *

然后从符号名字本身开始往外剥洋葱,顺序就是,以()为最高优先级,在内部,遇到[],那就加一层“这是数组”,遇到*,那就加一层“这是指针”;

永远是“里面”的在往“外面”指,里面的都是在“找‘数组里面’/‘被指向的’元素‘究竟是什么类型’”,外面则是定义了“这是个什么类型”;

提一嘴“后缀表达式”[]()

后缀表达式之间有没有优先级呢?

不需要判断优先级

原因是:

[] 只能紧跟在“数组类型”后面,不能直接跟 ()。

() 只能紧跟在“函数类型”后面,不能直接跟 []。

当你嵌套它们时,规则就是:从名字开始,向右边依次解析,谁先出现谁先结合。

比如int *arr[5](void); // 非法语法,编译不过就是一个直接无法通过编译的声明;

int (*arr[5])(void);

然而写成int (*arr[5])(void);的时候,它就合法了,而且分析起来也很简单:

arr是符号,首先往右看,跟[5]结合,表明arr是一个含有五个元素的数组;“数组里面是什么?”,继续找;

[5]右边还有无东西?没有了,已经是)了,因此往左看;

然后先找到*,意思是这数组里面的五个元素全都是指针;“指针指向什么?”,继续找;

左边空了,是(,这一层级分析结束,可以出括号往外面找了;

再往外,先往右看,跟后缀运算符(void)结合,好了现在知道了,指针指向的是某种函数,这种函数不需要传入参数;“函数返回值的类型?”,继续找;

右边空了,往左找;

找到int了,好了,叶子到了,不用找了,这个int一锤定音,“函数返回值的类型是int”;

梳理一遍:“int (*arr[5])(void);代表了一个数组,这个数组含有五个类型为指针的元素,每个指针分别指向一个参数为空,返回值为int的函数”;

int (*f(void))[5];

先找符号,f,然后看符号与谁紧贴,左边是*,右边是(void),后缀优先于前缀,因此首先f是一个参数为空的函数,返回值是某种指针;

指针指向了什么东西?外面是左边的int和右边的[5],后缀优先,先往右看,把这个[5]处理掉,把它代表的含义解释出来;

[5]代表“数组”,因此,函数的返回值是一个指向了某个含有5个元素的数组的指针;

这数组里面“元素”是什么类型?[5]右边没东西了,可以往左找了;

左边是int,分析结束了,这些元素是int!因此……

int (*f(void))[5];代表一个返回了指针的参数为空的函数,它所返回的指针指向了一个含有5个int类型变量的数组;

后缀运算符 [] 和 () 在声明中也保持后缀身份,优先级都高于前缀 *;它们之间没有优先级竞争,按从左到右、从内向外的顺序依次结合即可。

孤零零的前缀运算符*和一锤定音的叶子——基本类型符号

左边只能出现三种“东西”;

一种是“基本类型符号”,出现这个代表着“大功告成”;

char short int long float double void struct/union/enumstdint.hstddef.h里的朋友们,以及const volatile restrict,还有_Atomic

另一种是(,分析到了(的时候说明这一层已经分析完毕了,该出括号看外面了;

最后就是*,倒也简单,*的含义就是“套一层引用”,“包一层”,或者说“把目前解析出来的类型加一层‘指向了……’的意思”:

当前解析出来是个“符号”,那么就是“符号是一个指针,指向了……”,

当前解析出来的是“数组”,那么就是“数组中的每个元素均为指针,各自指向了……”,

当前解析出来的是“函数”,那么就是“函数的返回值是一个指针,指向了……”,

当前解析出来的是“指针”,那么就是“这个指针指向了另一个指针,被指向的指针指向了……”,

很单纯,很好理解的前缀运算符;

动态数组 指针 vs 柔性数组

我的回答是一个不连续另一个连续,看起来对方不太满意;

那就细分来说:

结构体里塞指针

优点:

灵活性,可以随便改变指针朝向,重新分配不同大小的数组;

缺点:

需要小心地进行两次mallocfree

内存碎片化,缓存命中率会受到影响;

柔性数组

优点:

只需一次mallocfree

内存局部性好,cache友好;

缺点:

C99,过时的编译器可能不支持;

不灵活,因为数组大小在分配时固定,无法单独替换,要改需要用realloc

柔性数组必须是结构体的最后一个成员,且结构体不能直接赋值;

“函数栈帧”的生命周期 + “函数的返回值”存在哪里,生命周期为何?

函数栈帧的生命在return的时候结束了,这是常识;

但有个问题,“函数的返回值”是个什么东西,存在哪,返回值本身的生命周期有多长?

简单来说,某个被调用的函数的返回值存储有两种情况,1. 存进寄存器,2. 调用者的栈帧;

返回值本身的生命周期就是调用函数语句的表达式里面,也就是在分号;处结束;

其实用汇编的思路想想,即便自己实现一个“通用的”函数调用,返回的过程,返回值也只能这么处理,

所以,一旦知道了答案,理解起来倒也没什么障碍;

返回值肯定是一个临时值;

算算算,怎么算结构体

很常见的一个笔试是考结构体大小的计算,遥想老夫毕业的那段时间,结构体还在按32位算,现在已经基本都在要求按64位计算了;

未设定pragma pack的前提下,结构体的计算其实就两个原则:

  1. 起始偏移量:放置成员前,首先计算该偏移量是否能被成员自身的对齐值(通常是长度,不通常的情况看后面)整除,若能,放入,若不能,填充到下个整除数,再将成员放入;
  2. 末尾填充:填充完毕后,整个结构体按照最大成员的对齐值计算是否满足整数倍关系,若不满足进行填充直到结构体自身的大小成为最大成员对齐值的某个倍数;

在满足这两条最基本原则的基础上对结构体长度进行计算;然后就结束了;

补充一下,32位系统对8字节(常见的是double)的对齐情况取决于ABI: - x86 32bit Windows/Linux:double长度为8字节,但对齐要求是4; - ARM 32bit: double长度是8字节,对齐要求是8; - MIPS / Other RISC 32bit: 基本要求8字节对齐;

结构体自身的对齐值取决于所有成员中最大的对齐值,如果double按4对齐,那么结构体就按照4进行,如果double按8对齐,那么结构体就按8对齐;

LP64一般天花板是8,但是: - 对于long double对齐值可能是16; - __int128如果被支持,那么对齐值通常就是16;

“为什么”?

众所周知,结构体成员的对齐是为了方便CPU操作,但是……

“为什么要这么对齐?”

“……如果只是为了满足成员对齐的条件,我为什么不挪动char,让它紧贴在short前面,整个数组就能对齐,还能省出一个字节?”

这是这两天的另一场面试中,一位很有耐心的面试官问我的问题,

是啊,为什么?

“如果结构体在数组中呢?”

数组要求元素之间“紧密连续排列,不允许有间隙”;

这个“紧密”和“不允许”是说,没有给填充的空间了;

此时如何保证结构体中各个元素全都能对齐?

再回去想,为什么设计中要求“每个元素从整除长度的位置开始”,为什么“结构体结尾处要填充到最长的元素长度的倍数”,

会发现精妙之处,这个设计,不光光是保证了结构体内部的对齐访问,

它还把这个“保证对齐”的责任限制在了结构体的内部,结构体自己知道自己该怎么申请内存,才能保证即便被放在数组中,也能让每个成员都对齐访问,

这个“如何对齐”的信息就蕴含在一次次的“填充”当中;

与外部完全解耦;

妙啊!

不过,这终究是一个空间换时间的行为,为了避免过度浪费空间,需要:

  1. 同类紧贴放一起;
  2. 按照对齐值顺序由长到短排列成员;

怎么回事

这件事完整叙述,其实是这样的:

  • C标准没有规定对齐值必须怎么计算,但规定了成员的顺序,数组没有间隙,还给了sizeofoffsetof,隐式要求这样一个自洽的布局;
  • ABI/CPU规定了基本类型的对齐值;
  • 编译器根据以上两个必须同时满足的规则,给出了这样一个精妙的填充方案;

在不排除重叠的情况下搬内存

要求在有可能重叠的情况下搬内存块,

其实逻辑很简单,不用想“重叠了那该怎么办”之类太复杂的问题,因为挨个字节搬运这种情况,只要有空位,就不用担心重叠;

执行规则更简单: - 如果src在des左边,那么就从后往前拷贝; - 如果des在src左边,那么就从前往后拷贝;

直接上代码,然后再展开说

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
 1 #include <stddef.h>
2 #include <stdint.h>
3
4 void memmove_self(void* dest, const void* src, size_t n){
5 if(n == 0){
6 return;
7 }
8 const uint8_t* srcptr = (uint8_t*)src;
9 uint8_t* desptr = (uint8_t*)dest;
10 if((uintptr_t)desptr == (uintptr_t)srcptr){
11 return;
12 }
13 else if((uintptr_t)desptr < (uintptr_t)srcptr){
14 for(size_t i = 0; i < n; i++){
15 desptr[i] = srcptr[i];
16 }
17 return;
18 }
19 else{
20 for(size_t i = n; i > 0; i--){
21 desptr[i-1] = srcptr[i-1];
22 }
23 return;
24 }
25 }
26

point有这么几个:

  1. 指针比较

C 标准规定:两个不指向同一数组对象(或同一分配块)的指针进行关系比较(<><=>=),行为是未定义的。

这也比较好理解;

解决方案是,把指针转成uintptr_t这个整数类型,然后对整数进行数值大小的“单纯”比较;

  1. int i的隐患

没什么好说的,注意被比较对象的类型,不要总是顺手写一个int i,尤其是被比较对象是size_t这种情况;

别把上溢不当问题;

  1. 边界条件问题desptr[i-1] = srcptr[i-1];

我这里写成了这个形式,很难看;

但是涉及到下标的边界,加上size_t是无符号类型,只能这么写;

如果写成size_t i = n -1; i>=0; i--,那么这个判断就永远为真咯~

老鸟一般这么写

1
2
3
while (n--) {
desptr[n] = srcptr[n];
}
其实也很好理解,

n--天然代表着下方的两个n在每次循环的时候是n-1

并且,等到n == 0的时候循环就结束了;

不过反正就那么一回事吧,现在这个年代,代码最重要的是可读性,

而对于不同阶段,不同状态,不同习惯的人来说,“可读性”是不一样的标准;

  1. 头文件

size_t来自于stddef.h

*_t自不必说,一般都来自于stdint.h

跑题:没被问到但是写博客的时候碰到的static

写博客的时候,被LLM提醒,“static”不是“类型符号”,这不禁让我产生了把static一次搞清楚的想法;

字面理解“静态”

“静态”是什么东西被“固定”了?

首先,微机原理的时候接触过x86程序的内存,分别是“代码段”“数据段”“堆”“栈”;

代码段(.text):机器指令,代码段通常是只读的;

数据段(.data):存放已初始化的全局变量和已初始化的静态变量

BSS(.bss):存放“未初始化的”全局变量和“未初始化的”静态变量;

栈(Stack):存放函数的局部变量,参数,返回地址,中断上下文,典型LIFO(在“生命周期”这一概念上);

堆(Heap):内存中的“堆”就是内存中的“堆”,这里的Heap就是表示一堆杂乱的东西;跟数据结构的“堆”没有半毛钱关系;

平常函数里面的那些局部变量都在栈里面,函数调用时,栈帧被建立,函数返回时,栈帧被销毁,这些局部变量的生命周期也随之LIFO地结束;

而“静态变量”跟全局变量被“搬迁”到了一个异于“栈”的地方,叫“数据段”.data/.bss或者简称DS的地方,因此,所有静态变量的生命周期跟全局变量的生命周期是等值的;

也就是说,所有静态变量的生命周期都与整个程序的生命周期等值;

全局静态变量本来就有和进程等长的生命周期,因此在生命周期方面主要是局部静态变量发生了变化;

那么作用域变了吗?

静态变量不改变局部变量的可见性,因此局部变量的作用域还是在局部变量原本的作用域里面;

所以“没变”;

上学的时候可能有劣质的教材容易让人产生静态变量“能到处发挥作用”以至于误以为静态变量的作用域改变了的误会,然而不是这样的,这个问题在后面说;

对于全局的变量与函数(函数:我本来就是全局的),静态修饰符一旦加上,就会产生这么几个变化:

这些“全局”变量和函数由“外部链接”(external linkage)变成了“内部链接”(internal linkage);

效果就是把“公开”变成了“私有”,其他文件再也不能通过external找到它;

一个美妙的“误会”

经常说C是“面向过程”,然而在这个OOP时代,理解什么是“面向过程”其实不太容易,只有到了在“局部静态变量的生命周期与作用域”这一点上,才有机会进入更深的理解;

如果像常用的方式那样,把C的函数当成“模块”来用,或者一个不太恰当的比喻,当成“对象”来用的话,就会有一种疑惑:“函数的静态局部变量总是有种‘扑朔迷离’的感觉?”

是这样的,因为每次调用一个函数的时候,是在栈上为函数创建了一个“栈帧”,函数返回的时候,栈帧被销毁;

我无从得知这种设计最早的本意是什么,我从函数这个名字上猜一下,最早的函数真的只是拿到输入然后输出的函数,

所以大概这种设计是为了让最后一个需要返回值的函数被调用完就赶快从栈里面LIFO,赶快腾出地方?

加上现在经常在函数里面弄点堆上的活,函数的作用变得越来越复杂,它不再是“返回一个值”就退出,而是“有自己生命的动态单元”;

这就造成了一个美妙的误会,潜意识中我们把函数内部当成了“对象”,误以为创建栈帧是在“实例化”,函数返回就是销毁了“函数”;

然而,C是一种非常面向对象,也非常“切入底层”的语言;

可能我们理解世界的时候,用的就是OOP思想(尽管我不常用任何OOP编程语言,但OOP本就是对认知世界的一种抽象);

这其实只是因为我们出生在了一个资源不再紧张的,OOP反过来影响C的“好年代”,不需要强行把自己这个人类的思想改造成严格的“面向过程”来匹配计算机行业;

C的函数是什么?“栈帧”被销毁了,“函数”就结束生命周期了吗?

CS里面存的是什么,回想一下汇编语言中的“调用”,CALL是怎么做的?

函数从来没有因为某次调用的栈帧被摧毁就结束生命,它一直在CS(.text)段里面好好地存在着,也是“静态地”存在着;

那个让我们以为“鲜活”的东西,其实并不是函数而是栈帧,真正的函数,是个死板(只读)的东西;

只不过栈帧太鲜活太有生命力,让我们总是忘记了汇编在CALL的时候究竟在做什么——把当前现场压栈,将返回地址压栈,然后把被CALL的那个标记所在的“起始地址”装入PC

C做了一点小小的改动:

CALL前的PUSH进行了标准化,管那个跳转(“跳转”并非JMP,因为CALL还能RET回来)的标记叫“函数”,管被标准化为了传值而PUSH的这些叫“实参”,管跳转过去之后POP的叫“形参”;

完成了一次汇编语言到高级语言的转变;

因为学习汇编期间处于“现代”因此写汇编时用的是“C”这样封装函数再调用的思想,而体会不到这种汇编到C的改变,恰如现在处在一个“OOP”的时代,写“C”时也不自觉地用“OOP”思想一样,无法直接感觉到C和“面向对象”之间的差别;

回到这个问题,这个“误会”是什么?

这个“误会”就是我们容易误以为函数运行期间的所有变量都来自于先前的PUSH和运行期间的POP,误以为这就是“生命周期”和“作用域”,

而忽略了函数在代码段里可以硬编码一个放在DS(.data/.bss)段的变量来进行操作,而非只能依赖与栈帧直接关联的那些寻址方式来对变量进行操作;

而这个DS段中的变量,就是那个神秘的,总是让人觉得为什么生命周期和作用域对不上的,“静态变量”,它真的只属于“函数”,而不是“栈帧”;

当然,真正的OOP用一个初始化函数来给自己做实例化,其机制的完备和灵活远不是C这种依靠编译出的自动指令来创建和销毁栈帧的早期高级语言能比得上的;

但它就是一个这样的美妙误会,只有从在OOP温室里面成长起来的这一代才会有的误会;

哦,我不会OOP语言;

回到那个教材的问题

一个经典的例子被到处引用来说明static是什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void foo(void)
{
static int count = 10;
printf("&count = %p, count = %d\n", (void*)&count, count);
count++;
}

int main(void)
{
foo();
foo();
foo();
return 0;
}

结果是count的输出从10,变成了11,再变成了12

劣质教材在于只通过举例子提出了一个“神迹”,却没告诉你,“为什么”,“什么时候会发生”?

然而,static不是一个“神秘的”少数人的玩具,也不是不稳定,不按常理出牌的魔法;

要理解它,只需要拆成几步:

一、把声明和定义分开

是我们理解这个问题的起步,先看一个不带初始化的版本:

1
2
3
4
5
6
void foo(void)
{
static int count;
printf("&count = %p, count = %d\n", (void*)&count, count);
count++;
}

这样,主函数中三次打印的值会变成0 1 2

这样写的好处是,先把static int count;是一个定义性声明这件事说清楚,

它同时完成了三件事,制定类型,分配存储,确定作用域;

“静态变量”的特殊之处在于,它的分配存储空间动作发生在更加早期,与程序本身有关;

编译时确定地址,链接时分配空间,程序启动时由启动代码清零;

同一个C函数内,不会每次只要定义同一个静态变量,这个静态变量就会被全新地创建出来;

而不像常见的其他变量,每次在定义语句处才拿到自己的存储空间;

然后自然可以解释,由于没有显式给予初始值,因此count初始为0

static的所有变量与其他C的全局变量一样,在进入主函数前会被启动代码全部干净地初始化成0

二、理解“不会反复初始化”

对于一个变量来说,定义的时候顺便赋个值,这个行为叫“初始化”;

平常的变量的初始化,会被每次都认真执行:在栈上分配一块新的内存空间(定义),把值写进去(初始化);

而静态变量的特殊性在于,它的定义(分配存储空间)动作并不在这个定义语句期间发生,因此无论函数被调用多少次,它永远只有固定的一个地址;

这个声明所描述的静态整型count,因为它的static属性,从一开始就在异于其他变量的,“特殊”的.data/.bss段;

运行时,对待count绝不会有像对待其他局部变量一样的“去栈上分配一块内存并初始化”的动作;

因为定义的时候没有赋值,因此连“写一次初始值”都不需要,count0是启动时就写好的;

三、带有初始值的初始化

这才到了引出这一步的时机:

1
2
3
4
5
6
void foo(void)
{
static int count = 10;
printf("&count = %p, count = %d\n", (void*)&count, count);
count++;
}

这里相比之前多了一个=10,它把“定义”变成了“定义+赋值”,也就是“初始化”;

对于静态变量,初始化在整个程序的生命周期中只执行一次;

为了确保这一点,可能的实现方式类似于: - 如果初始值是常数(比如 10),编译器直接把 10 写进可执行文件的 .data 段映像里,程序加载时 count 就已经是 10 了。 - 更一般的情况,编译器会生成一个隐藏的标志位,保证初始化代码只在第一次进入函数时运行一次。

无论是哪种方式,这个=10都不是每次调用就被执行的赋值,是因为它是伴随着“定义”只生效一次的“初始化”;

这次的赋值是“初始化”的一部分所以没有被反复执行,但这不代表所有针对static的赋值都会无效;

这才能够完备解释,为什么会打印出101112

最后一个问题,它怎么来的?

直接上DeepSeek对此渊源的介绍:

C 语言的设计哲学之一是“不为新的特性引入新的关键字,除非绝对必要”。这个原则,直接塑造了 static 关键字的前世今生。

你问到的 static 两条看似矛盾的“特性”(持久性和不可见性),其实是在C语言发展的不同历史阶段,为应对不同的实际需求,层层叠加在同一关键字上的结果:

第一阶段:持久化(为什么局部变量能“活”出函数外?)

第二阶段:模块化(为什么全局的东西要“隐藏”?)

🦕 第一阶段:持久化 —— 函数内的“记忆”

历史根源:这个概念比C语言更早,可以追溯到 ALGOL 60 (1960年) 的 own 变量类型。它的后继者 BCPL (1966年) 和 B 语言继续沿用了类似设计。当 Dennis Ritchie 在设计早期C语言时,需要一个方法来声明一个变量,它在函数退出后不会被销毁,下次调用时仍保留上次的值。

设计动机:为了满足函数内部需要维护“状态”的需求,比如统计函数被调用次数、维护一个跨调用的计数器等。这个需求可以追溯到 ALGOL 60 的 own 变量,其设计目的正是让变量的值在块重新进入时保持不变。C需要一个比全局变量更“优雅”的解决方案。

现实演化:在早期的编程语言中,默认的存储方式就是静态的。后来“自动存储(automatic storage)”(即栈)变得主流,static 关键字才应运而生,用于显式地标记出那些需要“特殊对待”的、保持持久的变量。

这解释了 static 的第一层含义:赋予局部变量“永恒的生命”。它的“生命周期”变成了整个程序运行期,而你关于其 .data/.bss 段内存布局的分析,正是其底层实现。

📦 第二阶段:模块化 —— 文件级的“封装”

随着 UNIX 系统用 C 语言重写,系统复杂性增加,项目需要被组织成多个源文件。这带来了命名冲突的问题:多个程序员在不同文件中很可能定义同名的变量或函数。

设计动机:C语言的设计者需要一个机制来“限制名字的可见性”,让一个源文件中的函数或全局变量成为“私有”,不会与其他文件中的同名实体冲突。这正如 Kernighan 和 Ritchie 在 The C Programming Language 中所述,静态声明可以“将名字的作用域限制在编译单元的其余部分”。

为何复用 static?:此时,C语言已经有了一定的用户基础。引入全新的关键字可能会破坏现有代码。设计者发现 static 在文件作用域(全局变量/函数)下尚未使用。一个 static 的全局变量本身就已拥有持久存储,再附加一个“仅本文件可见”的约束,并不会引起语义冲突。于是,static 被巧妙地“复用”,赋予了第二层含义:隐藏名字,也就是我们说的 internal linkage。

这也就解释了为什么你在笔记里,会感觉 static 像一位既会“挽留生命”又会“保守秘密”的管家。


Knighthana

2026/05/12