《征服C指针》

最近读了《征服C指针》,是一本好书。日本人写的计算机技术书籍像娓娓道来的技术博客那样,假设读者没有(或者有很少)相关的背景知识,围绕一个主题,除了介绍干巴巴的知识,还会谈自己的经验和理解,对其他的书和论点做点评,指出一些常见的谬误,这样读者可以更容易理解书里讲的东西。如果只有干巴巴的知识,那么去读维基百科就好了。但是维基百科(无论中文还是英文)的说明一般晦涩难懂,因为百科的首要目标是准确,消除歧义,而不是易于理解。而好的技术书里面会旁征博引,运用举例和比喻,用多的篇幅详细说明难懂的部分,加上作者的经验让读者知道哪些内容是经常使用的,哪些是晦涩又不常用的,哪些是可以辅以技巧理解的。这是我理解的优秀的技术书籍。比如,《流畅的 Python》就是这样一本好书,这本书不光介绍 Python 的编程知识,还有对 Python 语言设计的点评,和其他编程语言的对比,作者自己的理解和评价,读完之后对于整个编程概念的理解都会有提升。

这本书其实就不只是 C 语言的指针,还设计内存分配,C 的语法和编译器,CPU 等知识。从初学者到老手都可以从中有所收获。

另外书中对于有些概念可以给出确定的定义,而不是给出模棱两可的答案。如果永远模棱两可,那么永远都不会错,但是这种内容读起来也是浪费时间。一位得高望重的星际2游戏解说曾经说过:「专业解说要敢于下判断。」

以下是一些笔记。每一段引用都是书中的原文,引用如果不是连续的,就来自于不同的段落。非引用的格式是我的评论。

在C语言中,记录指针指向何种类型是只到编译器为止的,到了运行的时候就已经没有相关信息了。在运行时,指针的值就只是单纯的地址而已。​“要从这个地址里取出哪种类型的值”这一信息只残留在编译器生成的机器码中。无论是在指针的值中,还是在指针指向的变量的内存空间中,都没有类型的信息。因此,如果把指向int的指针转换成了void*,就不可能再知道它原来是指向 int 的了。

指针的类型,主要是为了告诉编译器信息。

会报错如下:

只告知了内存上的地址,却没有告知那里保存的是什么类型的数据,当然无法读取。

改成下面这样可以运行:

这正是指针运算的特征。在 C 语言中,对指针加1 后,其地址就增加该指针所指向的类型的长度。示例程序中的 hoge_p 是指向 int 的指针,而在我的环境中 int 的长度是 4,所以对地址来说,加 1 就是前进 4 字节,加 3 就是前进 12 字节。

对指针+1,地址移动的单位是「指针所指向的类型的长度」,这个也是编译器计算的,这是指针需要类型的主要原因。

由于空指针可以确保与任何非空指针进行比较都不相等,所以经常作为返回指针的函数发生异常时的返回值使用。

例如我工作的地方位于日本名古屋市某栋大楼的 5 楼,某人爬一层楼需要 10秒,那么从地面上到 5 楼需要花费多少秒?50 秒?很遗憾,正确答案是 40秒。想必大家在中学都学过等差数列,等差数列的第 n 项等于“首项 + 公差× (n –1)”​。每个都要减 1,真麻烦……此外,​“1900 年代”并不是 19 世纪,它的一大半属于 20 世纪。更加复杂的情况是,2000 年不属于 21 世纪,而属于 20 世纪。这些问题分别可通过以下方式回避。把大楼里与地面等高的那层计作第 0 层把数列的首项计作第 0 项把最初的世纪计作 0 世纪,把公历最初的年份计作 0 年这种“差 1 错误”的问题在编程中经常发生。因此,普遍认为在一般情况下如果以 0 为基准编号,那么通常(并不是所有)能回避这类问题。

延伸阅读:为什么要“包含头不包含尾”?

要点

p[i] 是 *(p + i) 的简便写法。

下标运算符 [​] 只有它原本的意义,与数组毫无关系。

要点

【比上面的要点更重要的要点】

但是,千万别写成那样。

在 get_word() 中使用下标运算符访问 buf 的内容,会让人觉得从 main() 传递过来的就是buf 数组。然而,这是个错觉,从 main() 传递过来的说到底只是指向 buf 的初始元素的指针。

函数传递只能传递指针而不能传递数组。即使数组和指针不同,但是在函数传递的时候,数组也会转换为指针,指向数组开头的元素。

在 C 语言中,参数全部都是通过值传递的。

即便是像全局变量那样在函数外部定义的变量,一旦加上 static,其作用域就只限定在当前源文件内。指定为 static 的变量(函数)对于其他源文件是不可见的(函数也是一样的)​。

另外,对于函数(非 static 限定)和全局变量,只要名称相同,即便位于不同的源文件中,也会被当作相同的对象处理。

因此,根据操作系统及CPU的不同,需要规定不同的调用方法,这就叫作调用约定(calling convention)​。本书中说明的调用方法是在x86系列处理器中被称为cdecl的调用约定。该方法中所有的参数都通过压栈的方式进行传递。

malloc() 会遍历链表,搜寻空块,若该块大小足够,就将其分割出来,做成使用中的块,并向应用程序返回紧邻管理区域的下一个地址。free() 会改写管理区域的标志,将该块置为空块,如果上下有空块,就顺便将它们合并成一个块。这是为了防止块碎片化。 这种操作称为 coalescing。当没有足够大的空块满足 malloc() 的要求时,就向操作系统请求(在 UNIX 中需要通过 brk()系统调用)扩充内存空间。

补充 

Valgrind正如前面多次提到的那样,与动态内存分配相关的Bug往往出现在距离它被发现的位置很远的地方,因此调试非常困难。在Linux上可以使用Valgrind工具追踪这类Bug。Valgrind工具用于检测对malloc()分配的内存空间越界读写、忘记free()(内存泄漏)或者对同一块内存空间多次free()这类问题。

如果运气好,标准库 glibc 也可以为我们检测出这个问题。

调用 malloc() 之后必定写上相应的free() 是一种谨慎的编程风格。程序员就应该小心翼翼地将 malloc()和 free() 对应起来。“因为调用了 exit(),所以就没必要free() 了”的想法是不负责任的偷工减料行为,是不良的编程风格。不管怎么说,程序员也是人,人就是这么一种在可能犯错的地方必定会犯错的生物。可是,​“必须 free() 派”却偏要大肆宣扬无论如何都要“谨慎地”编码,这种论调其实是于事无补的。

我认为,​“谨慎地”编码并没有什么了不起的,那些能够尽可能地回避“麻烦事”的人才是优秀的程序员。在我心中,理想的程序员是下面这样的:在能够安全地偷懒的地方尽可能地偷懒,并且尽可能地依靠工具而不是肉眼来进行检查,但在无论如何都需要人工处理麻烦的事情时,会在心中坚定地起誓“总有一天要将它自动化”​。

这是我最喜欢的一段话。Python 的初学者写的代码,会在所有的函数入口都写上 try-catch,称之为防御性编程。我觉得这么做的人肯定会有这样的疑惑:「怎么这么麻烦?」对于这个疑惑,有两类人,一类是认为「这么做一定有道理,作为程序员我们要不辞劳苦地做好工作。」另一类人认为「一定有更方便的方式」。

正如图 2-17 所示,填充有时会被放到结构体的末尾。因为在创建结构体数组时,填充是必要的。在将 sizeof 运算符应用到这样的结构体上时,返回的是包含末尾填充部分大小的长度。将结果和元素个数相乘,就可以获取数组整体的长度。

——有关结构体的对齐

小端与大端到底哪一种更好呢?这个话题经常引起人们的争论,此处就不再深入讨论了。它们各有各的优点。人类在用纸和笔做加法时也会从低位开始相加,所以对 CPU 来说,或许采用小端的方式更轻松一些,而在人类看来,大端的方式或许更容易理解。

Little Endian vs Big Endian

C 语言的声明要用英语阅读

我们可以遵循以下步骤解释 C 语言声明。

先看标识符(变量名或函数名)​。

从贴近标识符的地方开始,按照如下优先级解释派生类型(指针、数组、函数)​:

①用于整合声明的括号;

②表示数组的 [​]、表示函数的 ();

③表示指针的 *。

完成对派生类型的解释之后,通过 of、to 或returning 连接句子。添加类型修饰符(位于左侧,比如 int、double)​。如果不擅长英语,可以用中文解释。

能正确地阅读 C 指针的声明,函数参数中有关指针的声明,已经 sizeof 中的声明,是读此书最大的收获了。

像这样可以确定长度的类型,在标准中被称为对象类型(object type)​。然而,函数类型不是对象类型。C 语言中不存在函数类型的变量,因而我们无法(也没必要)确定其长度。我们说过,数组类型是由若干个派生源类型排列而成的类型。因此,数组类型的总长度为:

派生源类型的长度×数组的元素个数

但是,由于函数类型的长度无法确定,所以也就无法从函数类型派生出数组类型。也就是说,无法创造出“函数的数组”这种类型。但是,可以生成“指向函数的指针”这一类型。只是指向函数类型的指针是不能进行指针运算的,因为我们无法确定指针指向的类型的长度。

当表达式代表的是某处的存储空间时,该表达式就称为左值。与此相对,当表达式仅代表值时,该表达式称为右值。表达式中有时存在左值,有时不存在左值。例如,变量名是左值,而 5 这样的常量、1 +hoge 这样使用运算符的表达式就不是左值。

当作为 sizeof 运算符的操作数时

在以“sizeof 表达式”的形式使用 sizeof 运算符时,由于这里的操作数是表达式,所以即使是对数组使用 sizeof,数组也会被解读为指针,从而只能获取指针的长度——或许有人是这样认为的,但其实在数组作为 sizeof 运算符的操作数的情况下,将数组解读为指针这一规则是无效的,在这种情况下返回的是数组整体的长度。

总之,关于指向函数的指针的 C 语言的语法是比较混乱的。造成这种混乱的罪魁祸首就是“函数在表达式中会被解读为指向函数的指针”这一意图不明(难不成是为了与数组保持一致?​)的规则。

在表达式中,数组会被解读为指向该数组初始元素的指针,因此代码可以写成下面这样。

但是,反过来写成下面这样就不行。array = p;

数组和指针截然不同。

“不要误会我对 goto 语句持有任何教条主义的执念。我只是担忧,很多人把这件事给神化了,甚至认为仅凭某个编程技巧或某个简单的编程原则,就能解决编程语言的概念问题!”

 

2025

在新加坡的第五年整。

今年和欣好像一直在找房子。之前住在湖畔的一个 HDB,房东决定要卖房子,所以我们不续租了,从湖畔的 HDB 搬到了碧山的 Condo。住了不到一年,年底又搬了一次家。第一次住完全没有家具的房子,又花了时间去买沙发,床,桌子,组装家具,搬家,年底要处理的事情太多太多。

买了一台大电视,三星的 S95F,被惊艳到了,OLED 屏幕,还有不反光的特性,效果非常好。于是年底的假期大部分时间都和欣窝在沙发上看电视,买了 HBO,把过去看过的很多电影又看了一遍。把《权力的游戏》也从头开始看了一遍。

今年玩的游戏不多,为了准备玩《GTA 6》买了一台电脑放在客厅,但是这游戏居然又跳票了。缝合怪《潜水员戴夫》拿到了完美通关,《荒野大镖客2》玩到了 50% 左右,然后和欣一起玩《双人成行》,还没有通关。

又买了一台电子书设备和微信读书会员,看了莫言的《檀香刑》,《酒国》以及一些杂文集,一些余华和刘震云的小说,以及其他一些专业书。

学会了一个新的技能:双拼。不过感觉刚刚够得到之前全拼的速度,再用一段时间速度应该更快。

旅行。年前我和欣带爸妈来新加坡和普吉岛旅行。普吉岛去过 3 次了,但是一次博客都没有写过,拖着拖着就有一些原因不想写了。9 月份去上海「旅行」了一趟。在上海生活了这么多年,其实并没有作为游客去过一些地方,这次回去,体验了脱口秀(笑得肚子疼),逛了南京路(和当初上学的时候很不一样了,傣妹居然还活着,其他的店大部分都换了),看了一个剧。年底准备了关西的旅行,但是因为其他事情取消了。

工作方面

今年工作上的难度越来越高。我比较擅长用技术来解决问题,但是今年公司的管理风格向重视流程转移,试图通过流程上的规范来提高整体的可用性,导致审批越来越多,流程越来越复杂,一个简单的 API 调用需要花费之前数倍的成本来实现。

另一方面,在金融方面的发展带来了更多的合规要求,从而带来了巨量的运维工作。可高层在「降本增效」方面的努力没有停止的迹象,仅仅靠运维工作是没有「绩效」的,如果要拿到比较好的绩效以及晋升,就需要拿出时间去做亮点项目。这就造成了另一个矛盾点,也造成了另一部分人的离职。身边的同事越来越少。

在《凤凰项目》一书中,安全部门的主管发现项目不需要安全团队就可以通过一项审计,意识到自己的工作可有可无,在酒吧喝得酩酊大醉。他问运维部门的主管:「我们就真的对你们一点帮助也没有吗?」运维主管尽管很想安慰他,但是又不想说谎,只好说:「对不起,一点也没有。」

每当安全团队要我们把一个毫无敏感信息的 API 用最高的安全等级来要求的时候,我就会想起来这一段。

尽管如此,今年还是做了不少值得骄傲的事情:

  • 接手 Harbor 之后完成了 GC 的自动化,解决了数年之久的一个痛点1
  • 实现了镜像的 P2P 下载,彻底解决了镜像下载的瓶颈问题,之后写博客介绍一下技术细节吧;
  • 新开始了一个内部的标准化项目;

在产品方面,不再负责 Service Mesh 项目了。我从加入公司就开始维护这个项目,经历了几十个版本,也经历过它造成的(我加入之后)公司最大的故障,经历了几代开发团队变迁。开发团队的同事技术方面无可挑剔,产品在近几年没有出过重大事故,也支持了业务的需求。不足在产品的易用性上,配置过于复杂,难以理解,学习成本高。整体架构需要改变,现在是 daemonset 部署模式,难以实现资源隔离和审计。

产品运维的工作中心转到了 SDN 上面,也是我感兴趣的方面。SDN 也有很多问题:易用性和控制面的可用性太差;组件太多过于复杂;需要硬件兼容软件而不是软件兼容硬件,等等。这些都是需要解决的问题。

明年的工作会尝试一下结合 eBPF 来做架构上的可观测性,通过图数据库自动整理软件的依赖。

就写这么多吧,明年还是继续在网络的领域耕耘,学习和分享更多的知识。

  1. Harbor GC 问题 ↩︎

其他的年终总结列表:

  1. 2013年
  2. 2014年
  3. 2015年
  4. 2016年
  5. 2017年
  6. 2018年
  7. 2019年
  8. 2020年
  9. 2021年
  10. 2022年
  11. 2023年
  12. 2024年
 

我的姥姥

我的姥姥今年去世了,没有痛苦。我们之前都预料到了这一天的到来,大家在五月份都陆续去看望了她,大家也都知道这是告别。

小姨十年前告诉我,姥姥诊断出了肺癌。但是年纪太大,家里商量之后,决定不治疗,让她安享晚年,想吃什么就吃什么,想做什么就做什么。姥姥晚年喜欢抽烟,吃肉。每次去看望姥姥,我都带两条烟,十斤肉,或者红包。

这几年每一次去看姥姥,她下炕的次数越来越少,头发越来越花白,和她说话的声音需要越来越大。

姥姥的去世,我没有为她感到伤心,只为我以后再也见不到她了而伤心。至少她的晚年可以抽烟吃肉,没有把时间都花在了 ICU 里面。《长命百岁》里面提到, 现代医学专注于延长人类的寿命,但是这些寿命的质量也同样重要。人固有一死,我希望自己离开世界的时候也可以这样。

十年之后,姨们过年聚在一起,终于觉得当初的诊断不对,应该不是肺癌。但是也没有必要再去纠结了,管它是什么吧。

姥姥裹小脚,有六个女儿,一个儿子,重男轻女,我上高中的时候,过年的压岁钱姥姥就只给我和哥哥了,姐姐和妹妹没有。到了晚年,姥姥和姥爷俩人依然住在那诸城县边上的一个村子里,在那里生活了近一个世纪。姥姥和姥爷的感情是很多人都羡慕的。

姥姥的一生几乎都住在那几间房子里,没有去过太远的地方,可能没有出过省,没有吃过什么山珍海味。但也许她的一生是幸福的。这又让我思考起来那个这一年我不断思考的问题:「怎么度过这一生才是值得的呢?」

一路走好,姥姥。

 

AoC 2025 通关留念

[剧透警告]

今年又来玩了 Advant of Code1,最近看 C 语言的代码比较多,就用尽量用 C 语言做。

第 9 和和第 10 天最难,尤其是第 10 天,看着挺简单,实际是从来没有听说过的整数线性规划问题2

第 11 天一看要用 dict,放弃 C 了。用 Python 很快就过了,一个搜索加路径缓存即可。但是如何用 C 写出来完全没有思路,最后问 chatGPT,想起来还有邻接表这种巧妙的表示方法。

第 12 天最搞笑,以前是做 part1 顺利,做 part2 的时候时间爆炸。day 12 做 sample 的时候就感觉时间要爆炸了。加了几个剪枝,part1 居然过了。我想完了,part2 肯定是让我找所有的可能的情况,一定会爆炸。结果点开 part——就通关了。今年居然只有 12 天?那距离圣诞节还有 13 天呢,我做啥?

  1. https://adventofcode.com/ ↩︎
  2. 高铭骏 写的:整数线性规划 ↩︎

fuglede 的代码太优雅了:https://github.com/fuglede/adventofcode/tree/master/2025,不愧是做量化的。

 

如何把网络设备从 traceroute 中隐藏

在和朋友一起吃饭的时候,A 提了一个有意思的问题:怎样可以把一个机房内的路由设备从互联网「隐藏」呢?

「隐藏就是没有人可以知道这个设备的 IP 地址,这还不简单,只要禁用 ICMP 就可以了」, B说。

A 说,这样是可以。很多安全团队在实施起来也确实是这么做的,但是这样并不好:机房内所有的 IP 都无法 ping 通了。这样会增加 debug 的难度,得不偿失呀!

C 说,那就依然转发 ICMP 包,但是如果是 TTL=1 的包,就不要回复 ICMP Time Exceeded 了。

B 说,人家要的是「隐藏」,要是像你说的这么做,别人还是知道中间有一个设备的存在,没有完全符合要求。

一个 traceroute 的例子:第7跳直接丢弃 TTL=1 的包,不返回错误1

事实是这样的。假设一个简单的物理拓扑是 A -> B -> C,B 不回复 ICMP Time Exceeded,那么 traceroute 看起来就是 A ? C,可以猜测得到中间有一个路由器,但是已经禁止回复 ICMP Time Exceeded。看起来像下面这样。

C 说,traceroute 的原理是发送 TTL=1, 2, 3, … 的包,不断让路由器回复 ICMP Time Exceeded 信息,来得到每一跳的 IP 地址。要想完全隐藏,只需要:

  • 自己不回复 ICMP Time Exceeded
  • 让下一跳回复,仿佛下一跳就在自己的位置;

这样就可以完全隐藏了。要达到这个目的,只需要:

  • 对于 TTL=1 的包,不是丢弃,而是转发给下一跳,并且 TTL 依然保持为 1,即可。A ---[TTL=1]---> B ---[TTL=1]---> C, 对于客户端的 traceroute,看起来就像:A → C。

这样(理论上)好像确实可行了。三人对这个结论满意了。

后来我把这个讨论记录在了博客上(你现在正在阅读的一个),一位读者马上就发现了问题:可是这样 C 会出现两次吧!

确实是这样,假设在 A -> B -> C 的链路中:

  • TTL = 1 从 A 进入的时候,A 会在 ICMP 中回复自己的 IP;
  • TTL = 2 从 A 进入的时候,B 会直接转发给 C,C 会在 ICMP 中回复自己的 IP;
  • TTL = 3 从 A 进入的时候,B 会 TTL -1 转发给 C,C 会在 ICMP 中回复自己的 IP;

这样 C 就出现了 2 次!

看来,B 必须完全不减 TTL,直接转发,才能隐藏自己。不过这样就有出现环路2的风险了。

  1. 使用 mtr 检查网络问题,以及注意事项 ↩︎
  2. 网络中的环路和防环技术 ↩︎